dot

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

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