dot

vizie's NixOS desktop: Hyprland, CachyOS kernel and gaming tweaks

748 lines24249 bytesNix
1# The Hyprland desktop: compositor, lock screen, idle and wallpaper daemons, the
2# bar and notifications. Hyprland and hyprlock themselves come from the NixOS
3# modules (modules/nixos/desktop.nix); these write their configs.
4#
5# The services (hypridle, hyprpaper, waybar, dunst) run with
6# graphical-session.target, which uwsm starts; services.nix puts them in uwsm's
7# session slice.
8{
9 config,
10 lib,
11 pkgs,
12 ...
13}:
14let
15 inherit (config.lib.dot) link;
16
17 lua = lib.generators.mkLuaInline;
18 toLua = lib.generators.toLua { };
19
20 configHome = config.xdg.configHome;
21 wallpaper = ../../config/hypr/wallpaper.jpg;
22
23 # --- Hyprland -----------------------------------------------------------------
24
25 mod = "SUPER";
26 bind = keys: action: {
27 _args = [
28 "${mod} + ${keys}"
29 (lua action)
30 ];
31 };
32 exec = command: "hl.dsp.exec_cmd(${toLua command})";
33 screenshotKeys = "${configHome}/keys/scrnis";
34
35 # hl.dsp.dpms for hypridle, which runs it through hyprctl.
36 dpms = action: "hyprctl dispatch 'hl.dsp.dpms({ action = \"${action}\" })'";
37
38 # --- Waybar -------------------------------------------------------------------
39
40 toggleFlyout = "${pkgs.bash}/bin/bash ${configHome}/waybar/eww/scripts/toggle.sh";
41 trayIcons = ../../config/waybar/icons;
42in
43{
44 wayland.windowManager.hyprland = {
45 enable = true;
46 # From the NixOS module (programs.hyprland), which uwsm starts. uwsm also
47 # brings up graphical-session.target, so Home Manager's own session target
48 # isn't needed.
49 package = null;
50 portalPackage = null;
51 systemd.enable = false;
52 configType = "lua";
53
54 settings = {
55 # Screens are matched by model rather than port, so they keep their
56 # setup whichever socket the cable ends up in.
57 monitor = [
58 {
59 output = "desc:ASUSTek COMPUTER INC XG27AQWMG";
60 mode = "2560x1440@280";
61 position = "0x0";
62 scale = 1;
63 vrr = 0;
64 }
65 {
66 output = "desc:Ancor Communications Inc ROG PG278Q";
67 mode = "2560x1440@144";
68 position = "2560x-900";
69 scale = 1;
70 transform = 1;
71 vrr = 0;
72 }
73 ];
74
75 # Workspaces 1-2 on the landscape ASUS, 3-5 on the portrait ROG.
76 workspace_rule = map (workspace: {
77 workspace = toString workspace;
78 monitor =
79 if workspace <= 2 then
80 "desc:ASUSTek COMPUTER INC XG27AQWMG"
81 else
82 "desc:Ancor Communications Inc ROG PG278Q";
83 persistent = true;
84 }) (lib.range 1 5);
85
86 window_rule = [
87 {
88 name = "windowrule-1";
89 match.title = "^RuneLite.*";
90 float = true;
91 }
92 {
93 name = "windowrule-2";
94 match = {
95 class = "net-runelite-client-RuneLite";
96 title = "win.*";
97 };
98 no_focus = true;
99 }
100 {
101 name = "windowrule-3";
102 match.class = ".*";
103 immediate = true;
104 }
105 # Battle.net (Proton via Faugus). The class is shared by every umu game
106 # without its own ID, so match on the title too.
107 {
108 name = "battlenet-float";
109 match = {
110 class = "^steam_app_default$";
111 title = "^Battle\\.net.*";
112 };
113 float = true;
114 }
115 # xembedsniproxy (pkgs/xembedsniproxy) puts Wine/Proton tray icons such
116 # as Battle.net into Waybar's tray. It keeps a small helper window mapped
117 # for the icons it hosts, which Hyprland would otherwise show as a
118 # floating window.
119 {
120 name = "xembedsniproxy";
121 match.class = "^xembedsniproxy$";
122 opacity = "0.0 override 0.0 override";
123 border_size = 0;
124 no_blur = true;
125 no_shadow = true;
126 no_anim = true;
127 no_focus = true;
128 no_initial_focus = true;
129 }
130 # Dolphin's transparency comes from Kvantum's translucent_windows (see
131 # config/kvantum/RiceDark.kvconfig), not a forced window opacity: Kvantum
132 # puts alpha in the background only, so text and icons stay opaque, as
133 # in kitty. decoration.blur blurs through it. If Dolphin looks opaque
134 # after applying the Kvantum theme, this rule can come back:
135 # { name = "windowrule-4"; match.class = "^(org.kde.dolphin)$"; opacity = "0.90 0.80"; }
136
137 # Claude's desktop app creates its window opaque (transparent: false in
138 # the Electron bundle) and has no user stylesheet, so unlike kitty and
139 # Dolphin it can't put alpha in the background only. A forced window
140 # opacity is the closest it gets: decoration.blur frosts what shows
141 # through, at the cost of the text being translucent too.
142 {
143 name = "claude-frosted";
144 match.class = "^com\\.anthropic\\.Claude$";
145 opacity = "0.80 0.80";
146 }
147 ];
148
149 # Blur behind the translucent layers: Waybar, fuzzel ("launcher"), dunst
150 # and the control centre.
151 layer_rule = [
152 {
153 name = "layerrule-1";
154 match.namespace = "waybar";
155 blur = true;
156 ignore_alpha = 0;
157 blur_popups = true;
158 }
159 {
160 name = "layerrule-2";
161 match.namespace = "launcher";
162 ignore_alpha = 0;
163 blur = true;
164 }
165 {
166 name = "layerrule-3";
167 match.namespace = "notifications";
168 ignore_alpha = 0;
169 blur = true;
170 }
171 {
172 name = "layerrule-4";
173 match.namespace = "eww-control-center";
174 ignore_alpha = 0;
175 blur = true;
176 }
177 ];
178
179 bind = [
180 (bind "SHIFT + S" (exec "imsh-shot area -Cufa@${screenshotKeys} -s1"))
181 (bind "SHIFT + F" (exec "imsh-shot screen -Cua@${screenshotKeys}"))
182 (bind "SHIFT + R" (exec "imsh-cast -Cua@${screenshotKeys}"))
183 (bind "Return" (exec "${configHome}/fuzzel/launch"))
184 (bind "T" (exec (lib.getExe config.programs.kitty.package)))
185 (bind "W" (exec "firefox"))
186 (bind "Q" "hl.dsp.window.close()")
187 (bind "F" ''hl.dsp.window.float({ action = "toggle" })'')
188 (bind "G" ''hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })'')
189 (bind "mouse_down" ''hl.dsp.focus({ workspace = "m+1" })'')
190 (bind "mouse_up" ''hl.dsp.focus({ workspace = "m-1" })'')
191 (bind "mouse:272" "hl.dsp.window.drag()")
192 (bind "mouse:273" "hl.dsp.window.resize()")
193 (bind "Delete" "hl.dsp.exit()")
194 ];
195
196 env =
197 lib.mapAttrsToList
198 (name: value: {
199 _args = [
200 name
201 value
202 ];
203 })
204 {
205 LIBVA_DRIVER_NAME = "nvidia";
206 # libva-nvidia-driver's backend, for Firefox's hardware video decoding.
207 NVD_BACKEND = "direct";
208 # Lets Firefox's video decoder process reach the NVIDIA driver.
209 MOZ_DISABLE_RDD_SANDBOX = "1";
210 XDG_SESSION_TYPE = "wayland";
211 GBM_BACKEND = "nvidia-drm";
212 __GLX_VENDOR_LIBRARY_NAME = "nvidia";
213 XCURSOR_THEME = config.home.pointerCursor.name;
214 XCURSOR_SIZE = toString config.home.pointerCursor.size;
215 QT_QPA_PLATFORMTHEME = config.qt.platformTheme.name;
216 };
217
218 config = {
219 general = {
220 border_size = 1;
221 gaps_in = 5;
222 gaps_out = 17;
223 layout = "dwindle";
224 col = {
225 active_border = "rgba(00000000)";
226 inactive_border = "rgba(00000000)";
227 };
228 allow_tearing = true;
229 };
230 decoration = {
231 rounding = 10;
232 # No visible borders; the focused window is the one that isn't dimmed.
233 dim_inactive = true;
234 dim_strength = 0.15;
235 blur = {
236 enabled = true;
237 size = 9;
238 passes = 3;
239 new_optimizations = true;
240 # Blur tooltips and context menus (Kvantum's popup_blurring is on for this).
241 popups = true;
242 };
243 shadow = {
244 enabled = false;
245 render_power = 3;
246 range = 4;
247 color = "rgba(1a1a1aee)";
248 };
249 };
250 input = {
251 kb_layout = "no";
252 follow_mouse = 1;
253 sensitivity = 0;
254 accel_profile = "flat";
255 };
256 misc = {
257 disable_hyprland_logo = true;
258 disable_splash_rendering = true;
259 # The wallpaper's average colour instead of black, so the moments
260 # before hyprlock and after unlocking, before hyprpaper has drawn the
261 # wallpaper, don't flash black.
262 background_color = "rgb(121d1a)";
263 };
264 ecosystem.no_donation_nag = true;
265 binds.scroll_event_delay = 10;
266 # Software cursor, for tearing. Hyprland (0.56) refuses to tear while a
267 # hardware cursor is visible, because the kernel can't update the cursor
268 # plane during a tearing flip. So with a hardware cursor a game only
269 # tears while it hides the pointer (e.g. mouselook) and is vsynced
270 # otherwise, and switching back and forth left WoW's cursor shape stale.
271 # With a software cursor the game tears all the time; the price is that
272 # frames are composited (one extra copy) while the pointer is visible.
273 cursor.no_hardware_cursors = true;
274 # Fullscreen games (e.g. WoW) scan out straight to the monitor instead
275 # of being copied by the compositor. With the software cursor above this
276 # only applies while the game hides the pointer. 1 = always; XWayland
277 # games don't set a content type, so 2 (auto) won't trigger.
278 render.direct_scanout = 1;
279 };
280 };
281
282 # What happens at login (the apps' placement rules), in Lua: linked to the
283 # checkout like the other hand-edited configs.
284 extraLuaFiles.startup = link "hypr/startup.lua";
285 };
286
287 # --- Lock screen, idle, wallpaper -----------------------------------------------
288
289 # Also the login screen (login-lock below): the bamboo wallpaper, translucent
290 # black boxes, #eee text, rounding 10.
291 programs.hyprlock = {
292 enable = true;
293 # From the NixOS module (programs.hyprlock), which also sets up its PAM.
294 package = null;
295 settings =
296 let
297 centered = {
298 monitor = "HDMI-A-2";
299 halign = "center";
300 valign = "center";
301 };
302 in
303 {
304 "$font" = "NotoSans Nerd Font";
305 "$text" = "rgb(238, 238, 238)";
306 "$box" = "rgba(0, 0, 0, 0.2)";
307
308 general = {
309 hide_cursor = false;
310 ignore_empty_input = true;
311 };
312
313 animations = {
314 enabled = true;
315 bezier = "ease, 0.25, 0.1, 0.25, 1";
316 animation = [
317 "fadeIn, 1, 4, ease"
318 "fadeOut, 1, 4, ease"
319 "inputFieldDots, 1, 2, ease"
320 ];
321 };
322
323 # Light blur and dim so the text stays readable over the leaves, like
324 # the blurred panel behind the login box.
325 background = {
326 monitor = "";
327 path = "${wallpaper}";
328 blur_passes = 2;
329 blur_size = 5;
330 brightness = 0.75;
331 };
332
333 label = [
334 # Clock
335 (
336 centered
337 // {
338 text = ''<span font_weight="light">$TIME</span>'';
339 color = "$text";
340 font_family = "$font";
341 font_size = 110;
342 position = "0, 170";
343 }
344 )
345 # Date
346 (
347 centered
348 // {
349 text = ''cmd[update:60000] date +"%A %-d %B"'';
350 color = "rgba(238, 238, 238, 0.75)";
351 font_family = "$font";
352 font_size = 18;
353 position = "0, 70";
354 }
355 )
356 # User
357 (
358 centered
359 // {
360 text = "$USER";
361 color = "$text";
362 font_family = "$font";
363 font_size = 15;
364 position = "0, 0";
365 }
366 )
367 ];
368
369 input-field = centered // {
370 size = "320, 50";
371 rounding = 10;
372 outline_thickness = 0;
373 inner_color = "$box";
374 outer_color = "rgba(0, 0, 0, 0)";
375 check_color = "rgba(170, 255, 255, 0.5)";
376 fail_color = "rgba(255, 107, 107, 0.6)";
377 capslock_color = "rgba(244, 214, 159, 0.5)";
378 font_color = "$text";
379 font_family = "$font";
380 fade_on_empty = false;
381 placeholder_text = ''<span foreground="##eeeeeeaa">Password</span>'';
382 fail_text = ''<span foreground="##eeeeee">$FAIL ($ATTEMPTS)</span>'';
383 dots_size = 0.22;
384 dots_spacing = 0.4;
385 dots_center = true;
386 position = "0, -60";
387 };
388 };
389 };
390
391 # The login screen. tty1 logs in by itself (modules/nixos/desktop.nix), so the
392 # session starts locked: once Hyprland is up, hyprlock runs before
393 # graphical-session.target, which requires it to exit successfully (unlocked),
394 # so the bar, wallpaper, autostart apps and everything else in services.nix
395 # only start after you unlock.
396 #
397 # If hyprlock fails (it couldn't lock, or crashed), the session ends rather
398 # than carry on unlocked, and tty1 logs in again, locked. It only runs at
399 # login: started on its own (a rebuild, systemctl) it does nothing.
400 systemd.user.services.login-lock = {
401 Unit = {
402 Description = "Lock screen at login";
403 # uwsm's waitenv finishes once Hyprland has put WAYLAND_DISPLAY in the
404 # environment that this unit starts with.
405 After = [
406 "graphical-session-pre.target"
407 "wayland-session-waitenv.service"
408 ];
409 Before = [ "graphical-session.target" ];
410 OnFailure = [ "wayland-session-shutdown.target" ];
411 OnFailureJobMode = "replace-irreversibly";
412 X-RestartIfChanged = false;
413 };
414 Service = {
415 Type = "oneshot";
416 ExecCondition = "${pkgs.bash}/bin/bash -c '! ${pkgs.systemd}/bin/systemctl --user -q is-active graphical-session.target'";
417 # Lock first and draw the wallpaper once it has loaded: Hyprland takes
418 # input until the session is locked, and waiting for the wallpaper
419 # roughly quadruples that time.
420 ExecStart = "${lib.getExe pkgs.hyprlock} --immediate-render";
421 TimeoutStartSec = "infinity";
422 # With the compositor: session-graphical.slice only starts after
423 # graphical-session.target.
424 Slice = "session.slice";
425 };
426 Install.RequiredBy = [ "graphical-session.target" ];
427 };
428
429 services.hypridle = {
430 enable = true;
431 settings = {
432 general = {
433 lock_cmd = "pidof hyprlock || hyprlock";
434 before_sleep_cmd = "loginctl lock-session";
435 after_sleep_cmd = dpms "on";
436 };
437 listener = [
438 # Lock after 10 minutes idle (respects inhibitors, e.g. fullscreen video).
439 {
440 timeout = 600;
441 on-timeout = "loginctl lock-session";
442 }
443 {
444 timeout = 1;
445 on-resume = dpms "on";
446 on-timeout = "true";
447 ignore_inhibit = true;
448 }
449 {
450 timeout = 300;
451 on-timeout = dpms "off";
452 ignore_inhibit = true;
453 }
454 ];
455 };
456 };
457
458 # A new picture from the folder every 30 seconds, on every monitor.
459 services.hyprpaper = {
460 enable = true;
461 settings = {
462 wallpaper = {
463 monitor = "";
464 path = "${config.xdg.userDirs.pictures}/Wallpapers";
465 timeout = 30;
466 };
467 splash = false;
468 };
469 };
470
471 # --- Waybar ---------------------------------------------------------------------
472
473 # The flyouts it opens (control centre, notifications) are Eww windows, in
474 # config/waybar/eww; its scripts are in config/waybar/scripts. Both stay linked
475 # to the checkout (files.nix).
476 programs.waybar = {
477 enable = true;
478 systemd.enable = true;
479
480 settings.main = {
481 layer = "top";
482 position = "top";
483 height = 36;
484 margin-left = 15;
485 margin-right = 15;
486 margin-top = 15;
487
488 modules-left = [
489 "hyprland/workspaces"
490 "hyprland/window"
491 ];
492 modules-center = [
493 "custom/imsh-cast-monitor"
494 "clock"
495 ];
496 modules-right = [
497 "tray"
498 "custom/notifications"
499 "custom/notifications-count"
500 "custom/control-center"
501 ];
502
503 "hyprland/workspaces" = {
504 format = "{name}";
505 on-click = "activate";
506 sort-by = "number";
507 };
508 "hyprland/window".max-length = 60;
509
510 "custom/imsh-cast-monitor" = {
511 format = "{text}";
512 exec = "imsh-cast-monitor";
513 return-type = "json";
514 hide-empty-text = true;
515 };
516
517 clock = {
518 format = "{:%H:%M}";
519 format-alt = "{:%A, %B %d, %Y (%R)}";
520 tooltip-format = "<tt><big>{calendar}</big></tt>";
521 calendar = {
522 mode = "month";
523 iso8601 = true;
524 mode-mon-col = 3;
525 weeks-pos = "right";
526 on-scroll = 1;
527 format = {
528 months = "<span color='#ffffff'><b>{}</b></span>";
529 days = "<span color='#ffffff'>{}</span>";
530 weeks = "<span color='#8a8a8a'>W{:%V}</span>";
531 weekdays = "<span color='#b0b0b0'><b>{}</b></span>";
532 today = "<span color='#aaffff'><b><u>{}</u></b></span>";
533 };
534 };
535 actions = {
536 on-click-right = "mode";
537 on-scroll-up = "shift_up";
538 on-scroll-down = "shift_down";
539 };
540 };
541
542 # Own icons for the tray apps, by their StatusNotifierItem ID.
543 tray = {
544 icon-size = 16;
545 spacing = 10;
546 order = [ "discord_status_icon_1" ];
547 icons = {
548 Signal_status_icon_1 = "${trayIcons}/signal.svg";
549 Bitwarden_status_icon_1 = "${trayIcons}/bitwarden.svg";
550 WowUpCf_status_icon_1 = "${trayIcons}/wowup.svg";
551 Claude_status_icon_1 = "${trayIcons}/claude.svg";
552 steam_app_default = "${trayIcons}/battlenet.svg";
553 faugus-launcher = "${trayIcons}/faugus.svg";
554 steam = "${trayIcons}/steam.svg";
555 "tray-icon tray app opendeck" = "${trayIcons}/opendeck.svg";
556 };
557 };
558
559 "custom/notifications" = {
560 format = "<span size='12288'>󰂚</span>";
561 tooltip = false;
562 on-click = "${toggleFlyout} notifications";
563 };
564 "custom/notifications-count" = {
565 exec = "${pkgs.python3-desktop}/bin/python3 ${configHome}/waybar/scripts/notification-count.py";
566 return-type = "json";
567 hide-empty-text = true;
568 tooltip = false;
569 on-click = "${toggleFlyout} notifications";
570 };
571 "custom/control-center" = {
572 format = " ";
573 tooltip = false;
574 on-click = toggleFlyout;
575 };
576 };
577
578 style = ''
579 * {
580 border: none;
581 border-radius: 17px;
582 font-family: "NotoSans Nerd Font", "Noto Sans", sans-serif;
583 font-size: 12px;
584 font-weight: bolder;
585 background: transparent;
586 color: white;
587 min-height: 0;
588 }
589
590 window#waybar {
591 background: rgba(0, 0, 0, 0.1);
592 }
593
594 window#waybar #tray menu {
595 background: rgba(0, 0, 0, 0.1);
596 }
597
598 #tray menu :hover {
599 background: rgba(0, 0, 0, 0.1);
600 }
601
602 #tray menu :hover label {
603 background: rgba(0, 0, 0, 0.1);
604 }
605
606 tooltip {
607 background: rgba(0, 0, 0, 0.1);
608 }
609
610 /* Workspace buttons are pills inset 4px from the bar's edge, so their 13px radius
611 is concentric with the bar's 17px one. Everything GTK's theme draws on a button
612 (border, shadows, hover gradient) is reset here, as it otherwise shows up as a
613 square box on hover. */
614 #workspaces button {
615 margin: 4px 0 4px 4px;
616 padding: 0 6px;
617 min-width: 16px;
618 border: none;
619 border-radius: 13px;
620 outline: none;
621 box-shadow: none;
622 text-shadow: none;
623 background-color: transparent;
624 /* Active-workspace underline: 2px line that grows from the centre (see .active). */
625 background-image: linear-gradient(white, white);
626 background-repeat: no-repeat;
627 background-position: 50% 92%;
628 background-size: 0 2px;
629 transition: background-size 200ms ease-out, background-color 150ms ease-out;
630 }
631
632 #workspaces button:hover {
633 background-color: rgba(255, 255, 255, 0.12);
634 }
635
636 #tray window decoration {
637 border-style: none;
638 box-shadow: none;
639 }
640
641 #workspaces button.active {
642 background-size: 10px 2px;
643 }
644
645 /* Workspaces with no windows are dimmed, unless it's the one you're on. The colour
646 goes on the label because the `*` rule above gives every label its own white. */
647 #workspaces button label {
648 transition: color 150ms ease-out;
649 }
650
651 #workspaces button.empty:not(.active) label {
652 color: rgba(255, 255, 255, 0.4);
653 }
654
655 #clock, #tray, #custom-notifications, #custom-control-center {
656 padding: 0 7px;
657 }
658
659 /* Notification count: a small white disc overlapping the bell's top right corner.
660 The margins shrink it from the bar's full height to 12px; the negative left margin
661 pulls it over the bell. Hidden by Waybar when there are no notifications. */
662 #custom-notifications-count {
663 min-width: 6px;
664 padding: 0 3px;
665 margin: 7px 3px 17px -11px;
666 border-radius: 6px;
667 background: white;
668 color: #18181c;
669 font-size: 8px;
670 }
671
672 .imsh-cast-monitor.recording {
673 color: #f00;
674 }
675
676 .imsh-cast-monitor.error {
677 color: #ff0;
678 }
679
680 /* Control-center button: your logo as a pre-rendered 18px image.
681 GTK stretches SVG backgrounds as pixels (which makes them blurry), so logo-18.png is
682 rendered at exactly 18px, and logo-36.png is used automatically on 2x-scaled monitors. */
683 #custom-control-center {
684 min-width: 18px;
685 min-height: 18px;
686 background-image: -gtk-scaled(url("file://${../../config/waybar/logo-18.png}"),
687 url("file://${../../config/waybar/logo-36.png}"));
688 background-repeat: no-repeat;
689 background-position: center;
690 background-size: 18px 18px;
691 margin-right: 6px; /* gap between the logo and the end of the bar */
692 }
693 '';
694 };
695
696 # --- Notifications --------------------------------------------------------------
697
698 services.dunst = {
699 enable = true;
700 settings =
701 let
702 # Near-transparent, over Hyprland's blur (the "notifications" layer rule).
703 urgency = timeout: {
704 inherit timeout;
705 background = "#25252501";
706 foreground = "#ffffff";
707 frame_color = "#25252501";
708 };
709 in
710 {
711 global = {
712 monitor = 0;
713 follow = "mouse";
714 alignment = "center";
715 width = 360;
716 offset = 15;
717 origin = "top-right";
718 padding = 12;
719 horizontal_padding = 12;
720 frame_width = 0;
721 markup = "full";
722 icon_position = "off";
723 font = "DejaVu Sans Mono 10";
724 stack_duplicates = true;
725 hide_duplicate_count = true;
726 separator_height = 0;
727 fullscreen = "pushback";
728 corner_radius = 15;
729 # Left click does what the notification is for (e.g. opens the chat),
730 # then closes it. Middle click only dismisses it, right click
731 # dismisses all.
732 mouse_left_click = "do_action, close_current";
733 mouse_middle_click = "close_current";
734 mouse_right_click = "close_all";
735 progress_bar = true;
736 };
737 urgency_low = urgency 4;
738 urgency_normal = urgency 8;
739 urgency_critical = urgency 16;
740 status = {
741 category = "status";
742 set_stack_tag = "status";
743 history_ignore = true;
744 timeout = 2;
745 };
746 };
747 };
748}