1{
2 config,
3 lib,
4 pkgs,
5 ...
6}:
7let
8
9 cfg = config.services.autorandr;
10 hookType = lib.types.lines;
11
12 matrixOf =
13 n: m: elemType:
14 lib.mkOptionType rec {
15 name = "matrixOf";
16 description = "${toString n}×${toString m} matrix of ${elemType.description}s";
17 check =
18 xss:
19 let
20 listOfSize = l: xs: lib.isList xs && lib.length xs == l;
21 in
22 listOfSize n xss && lib.all (xs: listOfSize m xs && lib.all elemType.check xs) xss;
23 merge = lib.mergeOneOption;
24 getSubOptions =
25 prefix:
26 elemType.getSubOptions (
27 prefix
28 ++ [
29 "*"
30 "*"
31 ]
32 );
33 getSubModules = elemType.getSubModules;
34 substSubModules = mod: matrixOf n m (elemType.substSubModules mod);
35 functor = (lib.defaultFunctor name) // {
36 wrapped = elemType;
37 };
38 };
39
40 profileModule = lib.types.submodule {
41 options = {
42 fingerprint = lib.mkOption {
43 type = lib.types.attrsOf lib.types.str;
44 description = ''
45 Output name to EDID mapping.
46 Use `autorandr --fingerprint` to get current setup values.
47 '';
48 default = { };
49 };
50
51 config = lib.mkOption {
52 type = lib.types.attrsOf configModule;
53 description = "Per output profile configuration.";
54 default = { };
55 };
56
57 hooks = lib.mkOption {
58 type = hooksModule;
59 description = "Profile hook scripts.";
60 default = { };
61 };
62 };
63 };
64
65 configModule = lib.types.submodule {
66 options = {
67 enable = lib.mkOption {
68 type = lib.types.bool;
69 description = "Whether to enable the output.";
70 default = true;
71 };
72
73 crtc = lib.mkOption {
74 type = lib.types.nullOr lib.types.ints.unsigned;
75 description = "Output video display controller.";
76 default = null;
77 example = 0;
78 };
79
80 primary = lib.mkOption {
81 type = lib.types.bool;
82 description = "Whether output should be marked as primary";
83 default = false;
84 };
85
86 position = lib.mkOption {
87 type = lib.types.str;
88 description = "Output position";
89 default = "";
90 example = "5760x0";
91 };
92
93 mode = lib.mkOption {
94 type = lib.types.str;
95 description = "Output resolution.";
96 default = "";
97 example = "3840x2160";
98 };
99
100 rate = lib.mkOption {
101 type = lib.types.str;
102 description = "Output framerate.";
103 default = "";
104 example = "60.00";
105 };
106
107 gamma = lib.mkOption {
108 type = lib.types.str;
109 description = "Output gamma configuration.";
110 default = "";
111 example = "1.0:0.909:0.833";
112 };
113
114 rotate = lib.mkOption {
115 type = lib.types.nullOr (
116 lib.types.enum [
117 "normal"
118 "left"
119 "right"
120 "inverted"
121 ]
122 );
123 description = "Output rotate configuration.";
124 default = null;
125 example = "left";
126 };
127
128 transform = lib.mkOption {
129 type = lib.types.nullOr (matrixOf 3 3 lib.types.float);
130 default = null;
131 example = lib.literalExpression ''
132 [
133 [ 0.6 0.0 0.0 ]
134 [ 0.0 0.6 0.0 ]
135 [ 0.0 0.0 1.0 ]
136 ]
137 '';
138 description = ''
139 Refer to
140 {manpage}`xrandr(1)`
141 for the documentation of the transform matrix.
142 '';
143 };
144
145 dpi = lib.mkOption {
146 type = lib.types.nullOr lib.types.ints.positive;
147 description = "Output DPI configuration.";
148 default = null;
149 example = 96;
150 };
151
152 scale = lib.mkOption {
153 type = lib.types.nullOr (
154 lib.types.submodule {
155 options = {
156 method = lib.mkOption {
157 type = lib.types.enum [
158 "factor"
159 "pixel"
160 ];
161 description = "Output scaling method.";
162 default = "factor";
163 example = "pixel";
164 };
165
166 x = lib.mkOption {
167 type = lib.types.either lib.types.float lib.types.ints.positive;
168 description = "Horizontal scaling factor/pixels.";
169 };
170
171 y = lib.mkOption {
172 type = lib.types.either lib.types.float lib.types.ints.positive;
173 description = "Vertical scaling factor/pixels.";
174 };
175 };
176 }
177 );
178 description = ''
179 Output scale configuration.
180
181 Either configure by pixels or a scaling factor. When using pixel method the
182 {manpage}`xrandr(1)`
183 option
184 `--scale-from`
185 will be used; when using factor method the option
186 `--scale`
187 will be used.
188
189 This option is a shortcut version of the transform option and they are mutually
190 exclusive.
191 '';
192 default = null;
193 example = lib.literalExpression ''
194 {
195 x = 1.25;
196 y = 1.25;
197 }
198 '';
199 };
200 };
201 };
202
203 hooksModule = lib.types.submodule {
204 options = {
205 postswitch = lib.mkOption {
206 type = lib.types.attrsOf hookType;
207 description = "Postswitch hook executed after mode switch.";
208 default = { };
209 };
210
211 preswitch = lib.mkOption {
212 type = lib.types.attrsOf hookType;
213 description = "Preswitch hook executed before mode switch.";
214 default = { };
215 };
216
217 predetect = lib.mkOption {
218 type = lib.types.attrsOf hookType;
219 description = ''
220 Predetect hook executed before autorandr attempts to run xrandr.
221 '';
222 default = { };
223 };
224 };
225 };
226
227 hookToFile =
228 folder: name: hook:
229 lib.nameValuePair "xdg/autorandr/${folder}/${name}" {
230 source = "${pkgs.writeShellScriptBin "hook" hook}/bin/hook";
231 };
232 profileToFiles =
233 name: profile:
234 with profile;
235 lib.mkMerge ([
236 {
237 "xdg/autorandr/${name}/setup".text = lib.concatStringsSep "\n" (
238 lib.mapAttrsToList fingerprintToString fingerprint
239 );
240 "xdg/autorandr/${name}/config".text = lib.concatStringsSep "\n" (
241 lib.mapAttrsToList configToString profile.config
242 );
243 }
244 (lib.mapAttrs' (hookToFile "${name}/postswitch.d") hooks.postswitch)
245 (lib.mapAttrs' (hookToFile "${name}/preswitch.d") hooks.preswitch)
246 (lib.mapAttrs' (hookToFile "${name}/predetect.d") hooks.predetect)
247 ]);
248 fingerprintToString = name: edid: "${name} ${edid}";
249 configToString =
250 name: config:
251 if config.enable then
252 lib.concatStringsSep "\n" (
253 [ "output ${name}" ]
254 ++ lib.optional (config.position != "") "pos ${config.position}"
255 ++ lib.optional (config.crtc != null) "crtc ${toString config.crtc}"
256 ++ lib.optional config.primary "primary"
257 ++ lib.optional (config.dpi != null) "dpi ${toString config.dpi}"
258 ++ lib.optional (config.gamma != "") "gamma ${config.gamma}"
259 ++ lib.optional (config.mode != "") "mode ${config.mode}"
260 ++ lib.optional (config.rate != "") "rate ${config.rate}"
261 ++ lib.optional (config.rotate != null) "rotate ${config.rotate}"
262 ++ lib.optional (config.transform != null) (
263 "transform " + lib.concatMapStringsSep "," toString (lib.flatten config.transform)
264 )
265 ++ lib.optional (config.scale != null) (
266 (if config.scale.method == "factor" then "scale" else "scale-from")
267 + " ${toString config.scale.x}x${toString config.scale.y}"
268 )
269 )
270 else
271 ''
272 output ${name}
273 off
274 '';
275
276in
277{
278
279 options = {
280
281 services.autorandr = {
282 enable = lib.mkEnableOption "handling of hotplug and sleep events by autorandr";
283
284 defaultTarget = lib.mkOption {
285 default = "default";
286 type = lib.types.str;
287 description = ''
288 Fallback if no monitor layout can be detected. See the docs
289 (https://github.com/phillipberndt/autorandr/blob/v1.0/README.md#how-to-use)
290 for further reference.
291 '';
292 };
293
294 ignoreLid = lib.mkOption {
295 default = false;
296 type = lib.types.bool;
297 description = "Treat outputs as connected even if their lids are closed";
298 };
299
300 matchEdid = lib.mkOption {
301 default = false;
302 type = lib.types.bool;
303 description = "Match displays based on edid instead of name";
304 };
305
306 hooks = lib.mkOption {
307 type = hooksModule;
308 description = "Global hook scripts";
309 default = { };
310 example = lib.literalExpression ''
311 {
312 postswitch = {
313 "notify-i3" = "''${pkgs.i3}/bin/i3-msg restart";
314 "change-background" = readFile ./change-background.sh;
315 "change-dpi" = '''
316 case "$AUTORANDR_CURRENT_PROFILE" in
317 default)
318 DPI=120
319 ;;
320 home)
321 DPI=192
322 ;;
323 work)
324 DPI=144
325 ;;
326 *)
327 echo "Unknown profle: $AUTORANDR_CURRENT_PROFILE"
328 exit 1
329 esac
330 echo "Xft.dpi: $DPI" | ''${pkgs.xorg.xrdb}/bin/xrdb -merge
331 ''';
332 };
333 }
334 '';
335 };
336 profiles = lib.mkOption {
337 type = lib.types.attrsOf profileModule;
338 description = "Autorandr profiles specification.";
339 default = { };
340 example = lib.literalExpression ''
341 {
342 "work" = {
343 fingerprint = {
344 eDP1 = "<EDID>";
345 DP1 = "<EDID>";
346 };
347 config = {
348 eDP1.enable = false;
349 DP1 = {
350 enable = true;
351 crtc = 0;
352 primary = true;
353 position = "0x0";
354 mode = "3840x2160";
355 gamma = "1.0:0.909:0.833";
356 rate = "60.00";
357 rotate = "left";
358 };
359 };
360 hooks.postswitch = readFile ./work-postswitch.sh;
361 };
362 }
363 '';
364 };
365
366 };
367
368 };
369
370 config = lib.mkIf cfg.enable {
371
372 services.udev.packages = [ pkgs.autorandr ];
373
374 environment = {
375 systemPackages = [ pkgs.autorandr ];
376 etc = lib.mkMerge ([
377 (lib.mapAttrs' (hookToFile "postswitch.d") cfg.hooks.postswitch)
378 (lib.mapAttrs' (hookToFile "preswitch.d") cfg.hooks.preswitch)
379 (lib.mapAttrs' (hookToFile "predetect.d") cfg.hooks.predetect)
380 (lib.mkMerge (lib.mapAttrsToList profileToFiles cfg.profiles))
381 ]);
382 };
383
384 systemd.services.autorandr = {
385 wantedBy = [ "sleep.target" ];
386 description = "Autorandr execution hook";
387 after = [ "sleep.target" ];
388
389 startLimitIntervalSec = 5;
390 startLimitBurst = 1;
391 serviceConfig = {
392 ExecStart = ''
393 ${pkgs.autorandr}/bin/autorandr \
394 --batch \
395 --change \
396 --default ${cfg.defaultTarget} \
397 ${lib.optionalString cfg.ignoreLid "--ignore-lid"} \
398 ${lib.optionalString cfg.matchEdid "--match-edid"}
399 '';
400 Type = "oneshot";
401 RemainAfterExit = false;
402 KillMode = "process";
403 };
404 };
405
406 };
407
408 meta.maintainers = with lib.maintainers; [ alexnortung ];
409}