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