dot

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

704 lines22162 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 moment
254 # between the login screen and hyprpaper drawing the wallpaper doesn't
255 # 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 # Styled to match the greetd login screen (config/greetd/style.css): same
284 # bamboo wallpaper, translucent 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 services.hypridle = {
386 enable = true;
387 settings = {
388 general = {
389 lock_cmd = "pidof hyprlock || hyprlock";
390 before_sleep_cmd = "loginctl lock-session";
391 after_sleep_cmd = dpms "on";
392 };
393 listener = [
394 # Lock after 10 minutes idle (respects inhibitors, e.g. fullscreen video).
395 {
396 timeout = 600;
397 on-timeout = "loginctl lock-session";
398 }
399 {
400 timeout = 1;
401 on-resume = dpms "on";
402 on-timeout = "true";
403 ignore_inhibit = true;
404 }
405 {
406 timeout = 300;
407 on-timeout = dpms "off";
408 ignore_inhibit = true;
409 }
410 ];
411 };
412 };
413
414 # A new picture from the folder every 30 seconds, on every monitor.
415 services.hyprpaper = {
416 enable = true;
417 settings = {
418 wallpaper = {
419 monitor = "";
420 path = "${config.xdg.userDirs.pictures}/Wallpapers";
421 timeout = 30;
422 };
423 splash = false;
424 };
425 };
426
427 # --- Waybar ---------------------------------------------------------------------
428
429 # The flyouts it opens (control centre, notifications) are Eww windows, in
430 # config/waybar/eww; its scripts are in config/waybar/scripts. Both stay linked
431 # to the checkout (files.nix).
432 programs.waybar = {
433 enable = true;
434 systemd.enable = true;
435
436 settings.main = {
437 layer = "top";
438 position = "top";
439 height = 36;
440 margin-left = 15;
441 margin-right = 15;
442 margin-top = 15;
443
444 modules-left = [
445 "hyprland/workspaces"
446 "hyprland/window"
447 ];
448 modules-center = [
449 "custom/imsh-cast-monitor"
450 "clock"
451 ];
452 modules-right = [
453 "tray"
454 "custom/notifications"
455 "custom/notifications-count"
456 "custom/control-center"
457 ];
458
459 "hyprland/workspaces" = {
460 format = "{name}";
461 on-click = "activate";
462 sort-by = "number";
463 };
464 "hyprland/window".max-length = 60;
465
466 "custom/imsh-cast-monitor" = {
467 format = "{text}";
468 exec = "imsh-cast-monitor";
469 return-type = "json";
470 hide-empty-text = true;
471 };
472
473 clock = {
474 format = "{:%H:%M}";
475 format-alt = "{:%A, %B %d, %Y (%R)}";
476 tooltip-format = "<tt><big>{calendar}</big></tt>";
477 calendar = {
478 mode = "month";
479 iso8601 = true;
480 mode-mon-col = 3;
481 weeks-pos = "right";
482 on-scroll = 1;
483 format = {
484 months = "<span color='#ffffff'><b>{}</b></span>";
485 days = "<span color='#ffffff'>{}</span>";
486 weeks = "<span color='#8a8a8a'>W{:%V}</span>";
487 weekdays = "<span color='#b0b0b0'><b>{}</b></span>";
488 today = "<span color='#aaffff'><b><u>{}</u></b></span>";
489 };
490 };
491 actions = {
492 on-click-right = "mode";
493 on-scroll-up = "shift_up";
494 on-scroll-down = "shift_down";
495 };
496 };
497
498 # Own icons for the tray apps, by their StatusNotifierItem ID.
499 tray = {
500 icon-size = 16;
501 spacing = 10;
502 order = [ "discord_status_icon_1" ];
503 icons = {
504 Signal_status_icon_1 = "${trayIcons}/signal.svg";
505 Bitwarden_status_icon_1 = "${trayIcons}/bitwarden.svg";
506 WowUpCf_status_icon_1 = "${trayIcons}/wowup.svg";
507 Claude_status_icon_1 = "${trayIcons}/claude.svg";
508 steam_app_default = "${trayIcons}/battlenet.svg";
509 faugus-launcher = "${trayIcons}/faugus.svg";
510 steam = "${trayIcons}/steam.svg";
511 "tray-icon tray app opendeck" = "${trayIcons}/opendeck.svg";
512 };
513 };
514
515 "custom/notifications" = {
516 format = "<span size='12288'>󰂚</span>";
517 tooltip = false;
518 on-click = "${toggleFlyout} notifications";
519 };
520 "custom/notifications-count" = {
521 exec = "${pkgs.python3-desktop}/bin/python3 ${configHome}/waybar/scripts/notification-count.py";
522 return-type = "json";
523 hide-empty-text = true;
524 tooltip = false;
525 on-click = "${toggleFlyout} notifications";
526 };
527 "custom/control-center" = {
528 format = " ";
529 tooltip = false;
530 on-click = toggleFlyout;
531 };
532 };
533
534 style = ''
535 * {
536 border: none;
537 border-radius: 17px;
538 font-family: NotoSans Nerd Font Regular;
539 font-size: 12px;
540 font-weight: bolder;
541 background: transparent;
542 color: white;
543 min-height: 0;
544 }
545
546 window#waybar {
547 background: rgba(0, 0, 0, 0.1);
548 }
549
550 window#waybar #tray menu {
551 background: rgba(0, 0, 0, 0.1);
552 }
553
554 #tray menu :hover {
555 background: rgba(0, 0, 0, 0.1);
556 }
557
558 #tray menu :hover label {
559 background: rgba(0, 0, 0, 0.1);
560 }
561
562 tooltip {
563 background: rgba(0, 0, 0, 0.1);
564 }
565
566 /* Workspace buttons are pills inset 4px from the bar's edge, so their 13px radius
567 is concentric with the bar's 17px one. Everything GTK's theme draws on a button
568 (border, shadows, hover gradient) is reset here, as it otherwise shows up as a
569 square box on hover. */
570 #workspaces button {
571 margin: 4px 0 4px 4px;
572 padding: 0 6px;
573 min-width: 16px;
574 border: none;
575 border-radius: 13px;
576 outline: none;
577 box-shadow: none;
578 text-shadow: none;
579 background-color: transparent;
580 /* Active-workspace underline: 2px line that grows from the centre (see .active). */
581 background-image: linear-gradient(white, white);
582 background-repeat: no-repeat;
583 background-position: 50% 92%;
584 background-size: 0 2px;
585 transition: background-size 200ms ease-out, background-color 150ms ease-out;
586 }
587
588 #workspaces button:hover {
589 background-color: rgba(255, 255, 255, 0.12);
590 }
591
592 #tray window decoration {
593 border-style: none;
594 box-shadow: none;
595 }
596
597 #workspaces button.active {
598 background-size: 10px 2px;
599 }
600
601 /* Workspaces with no windows are dimmed, unless it's the one you're on. The colour
602 goes on the label because the `*` rule above gives every label its own white. */
603 #workspaces button label {
604 transition: color 150ms ease-out;
605 }
606
607 #workspaces button.empty:not(.active) label {
608 color: rgba(255, 255, 255, 0.4);
609 }
610
611 #clock, #tray, #custom-notifications, #custom-control-center {
612 padding: 0 7px;
613 }
614
615 /* Notification count: a small white disc overlapping the bell's top right corner.
616 The margins shrink it from the bar's full height to 12px; the negative left margin
617 pulls it over the bell. Hidden by Waybar when there are no notifications. */
618 #custom-notifications-count {
619 min-width: 6px;
620 padding: 0 3px;
621 margin: 7px 3px 17px -11px;
622 border-radius: 6px;
623 background: white;
624 color: #18181c;
625 font-size: 8px;
626 }
627
628 .imsh-cast-monitor.recording {
629 color: #f00;
630 }
631
632 .imsh-cast-monitor.error {
633 color: #ff0;
634 }
635
636 /* Control-center button: your logo as a pre-rendered 18px image.
637 GTK stretches SVG backgrounds as pixels (which makes them blurry), so logo-18.png is
638 rendered at exactly 18px, and logo-36.png is used automatically on 2x-scaled monitors. */
639 #custom-control-center {
640 min-width: 18px;
641 min-height: 18px;
642 background-image: -gtk-scaled(url("file://${../../config/waybar/logo-18.png}"),
643 url("file://${../../config/waybar/logo-36.png}"));
644 background-repeat: no-repeat;
645 background-position: center;
646 background-size: 18px 18px;
647 margin-right: 6px; /* gap between the logo and the end of the bar */
648 }
649 '';
650 };
651
652 # --- Notifications --------------------------------------------------------------
653
654 services.dunst = {
655 enable = true;
656 settings =
657 let
658 # Near-transparent, over Hyprland's blur (the "notifications" layer rule).
659 urgency = timeout: {
660 inherit timeout;
661 background = "#25252501";
662 foreground = "#ffffff";
663 frame_color = "#25252501";
664 };
665 in
666 {
667 global = {
668 monitor = 0;
669 follow = "mouse";
670 alignment = "center";
671 width = 360;
672 offset = 15;
673 origin = "top-right";
674 padding = 12;
675 horizontal_padding = 12;
676 frame_width = 0;
677 markup = "full";
678 icon_position = "off";
679 font = "DejaVu Sans Mono 10";
680 stack_duplicates = true;
681 hide_duplicate_count = true;
682 separator_height = 0;
683 fullscreen = "pushback";
684 corner_radius = 15;
685 # Left click does what the notification is for (e.g. opens the chat),
686 # then closes it. Middle click only dismisses it, right click
687 # dismisses all.
688 mouse_left_click = "do_action, close_current";
689 mouse_middle_click = "close_current";
690 mouse_right_click = "close_all";
691 progress_bar = true;
692 };
693 urgency_low = urgency 4;
694 urgency_normal = urgency 8;
695 urgency_critical = urgency 16;
696 status = {
697 category = "status";
698 set_stack_tag = "status";
699 history_ignore = true;
700 timeout = 2;
701 };
702 };
703 };
704}