# The Hyprland desktop: compositor, lock screen, idle and wallpaper daemons, the # bar and notifications. Hyprland and hyprlock themselves come from the NixOS # modules (modules/nixos/desktop.nix); these write their configs. # # The services (hypridle, hyprpaper, waybar, dunst) run with # graphical-session.target, which uwsm starts; services.nix puts them in uwsm's # session slice. { config, lib, pkgs, ... }: let inherit (config.lib.dot) link; lua = lib.generators.mkLuaInline; toLua = lib.generators.toLua { }; configHome = config.xdg.configHome; wallpaper = ../../config/hypr/wallpaper.jpg; # --- Hyprland ----------------------------------------------------------------- mod = "SUPER"; bind = keys: action: { _args = [ "${mod} + ${keys}" (lua action) ]; }; exec = command: "hl.dsp.exec_cmd(${toLua command})"; screenshotKeys = "${configHome}/keys/scrnis"; # hl.dsp.dpms for hypridle, which runs it through hyprctl. dpms = action: "hyprctl dispatch 'hl.dsp.dpms({ action = \"${action}\" })'"; # --- Waybar ------------------------------------------------------------------- toggleFlyout = "${pkgs.bash}/bin/bash ${configHome}/waybar/eww/scripts/toggle.sh"; trayIcons = ../../config/waybar/icons; in { wayland.windowManager.hyprland = { enable = true; # From the NixOS module (programs.hyprland), which uwsm starts. uwsm also # brings up graphical-session.target, so Home Manager's own session target # isn't needed. package = null; portalPackage = null; systemd.enable = false; configType = "lua"; settings = { # Screens are matched by model rather than port, so they keep their # setup whichever socket the cable ends up in. monitor = [ { output = "desc:ASUSTek COMPUTER INC XG27AQWMG"; mode = "2560x1440@280"; position = "0x0"; scale = 1; vrr = 0; } { output = "desc:Ancor Communications Inc ROG PG278Q"; mode = "2560x1440@144"; position = "2560x-900"; scale = 1; transform = 1; vrr = 0; } ]; # Workspaces 1-2 on the landscape ASUS, 3-5 on the portrait ROG. workspace_rule = map (workspace: { workspace = toString workspace; monitor = if workspace <= 2 then "desc:ASUSTek COMPUTER INC XG27AQWMG" else "desc:Ancor Communications Inc ROG PG278Q"; persistent = true; }) (lib.range 1 5); window_rule = [ { name = "windowrule-1"; match.title = "^RuneLite.*"; float = true; } { name = "windowrule-2"; match = { class = "net-runelite-client-RuneLite"; title = "win.*"; }; no_focus = true; } { name = "windowrule-3"; match.class = ".*"; immediate = true; } # Battle.net (Proton via Faugus). The class is shared by every umu game # without its own ID, so match on the title too. { name = "battlenet-float"; match = { class = "^steam_app_default$"; title = "^Battle\\.net.*"; }; float = true; } # xembedsniproxy (pkgs/xembedsniproxy) puts Wine/Proton tray icons such # as Battle.net into Waybar's tray. It keeps a small helper window mapped # for the icons it hosts, which Hyprland would otherwise show as a # floating window. { name = "xembedsniproxy"; match.class = "^xembedsniproxy$"; opacity = "0.0 override 0.0 override"; border_size = 0; no_blur = true; no_shadow = true; no_anim = true; no_focus = true; no_initial_focus = true; } # Dolphin's transparency comes from Kvantum's translucent_windows (see # config/kvantum/RiceDark.kvconfig), not a forced window opacity: Kvantum # puts alpha in the background only, so text and icons stay opaque, as # in kitty. decoration.blur blurs through it. If Dolphin looks opaque # after applying the Kvantum theme, this rule can come back: # { name = "windowrule-4"; match.class = "^(org.kde.dolphin)$"; opacity = "0.90 0.80"; } # Claude's desktop app creates its window opaque (transparent: false in # the Electron bundle) and has no user stylesheet, so unlike kitty and # Dolphin it can't put alpha in the background only. A forced window # opacity is the closest it gets: decoration.blur frosts what shows # through, at the cost of the text being translucent too. { name = "claude-frosted"; match.class = "^com\\.anthropic\\.Claude$"; opacity = "0.80 0.80"; } ]; # Blur behind the translucent layers: Waybar, fuzzel ("launcher"), dunst # and the control centre. layer_rule = [ { name = "layerrule-1"; match.namespace = "waybar"; blur = true; ignore_alpha = 0; blur_popups = true; } { name = "layerrule-2"; match.namespace = "launcher"; ignore_alpha = 0; blur = true; } { name = "layerrule-3"; match.namespace = "notifications"; ignore_alpha = 0; blur = true; } { name = "layerrule-4"; match.namespace = "eww-control-center"; ignore_alpha = 0; blur = true; } ]; bind = [ (bind "SHIFT + S" (exec "imsh-shot area -Cufa@${screenshotKeys} -s1")) (bind "SHIFT + F" (exec "imsh-shot screen -Cua@${screenshotKeys}")) (bind "SHIFT + R" (exec "imsh-cast -Cua@${screenshotKeys}")) (bind "Return" (exec "${configHome}/fuzzel/launch")) (bind "T" (exec (lib.getExe config.programs.kitty.package))) (bind "W" (exec "firefox")) (bind "Q" "hl.dsp.window.close()") (bind "F" ''hl.dsp.window.float({ action = "toggle" })'') (bind "G" ''hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })'') (bind "mouse_down" ''hl.dsp.focus({ workspace = "m+1" })'') (bind "mouse_up" ''hl.dsp.focus({ workspace = "m-1" })'') (bind "mouse:272" "hl.dsp.window.drag()") (bind "mouse:273" "hl.dsp.window.resize()") (bind "Delete" "hl.dsp.exit()") ]; env = lib.mapAttrsToList (name: value: { _args = [ name value ]; }) { LIBVA_DRIVER_NAME = "nvidia"; # libva-nvidia-driver's backend, for Firefox's hardware video decoding. NVD_BACKEND = "direct"; # Lets Firefox's video decoder process reach the NVIDIA driver. MOZ_DISABLE_RDD_SANDBOX = "1"; XDG_SESSION_TYPE = "wayland"; GBM_BACKEND = "nvidia-drm"; __GLX_VENDOR_LIBRARY_NAME = "nvidia"; XCURSOR_THEME = config.home.pointerCursor.name; XCURSOR_SIZE = toString config.home.pointerCursor.size; QT_QPA_PLATFORMTHEME = config.qt.platformTheme.name; }; config = { general = { border_size = 1; gaps_in = 5; gaps_out = 17; layout = "dwindle"; col = { active_border = "rgba(00000000)"; inactive_border = "rgba(00000000)"; }; allow_tearing = true; }; decoration = { rounding = 10; # No visible borders; the focused window is the one that isn't dimmed. dim_inactive = true; dim_strength = 0.15; blur = { enabled = true; size = 9; passes = 3; new_optimizations = true; # Blur tooltips and context menus (Kvantum's popup_blurring is on for this). popups = true; }; shadow = { enabled = false; render_power = 3; range = 4; color = "rgba(1a1a1aee)"; }; }; input = { kb_layout = "no"; follow_mouse = 1; sensitivity = 0; accel_profile = "flat"; }; misc = { disable_hyprland_logo = true; disable_splash_rendering = true; # The wallpaper's average colour instead of black, so the moments # before hyprlock and after unlocking, before hyprpaper has drawn the # wallpaper, don't flash black. background_color = "rgb(121d1a)"; }; ecosystem.no_donation_nag = true; binds.scroll_event_delay = 10; # Software cursor, for tearing. Hyprland (0.56) refuses to tear while a # hardware cursor is visible, because the kernel can't update the cursor # plane during a tearing flip. So with a hardware cursor a game only # tears while it hides the pointer (e.g. mouselook) and is vsynced # otherwise, and switching back and forth left WoW's cursor shape stale. # With a software cursor the game tears all the time; the price is that # frames are composited (one extra copy) while the pointer is visible. cursor.no_hardware_cursors = true; # Fullscreen games (e.g. WoW) scan out straight to the monitor instead # of being copied by the compositor. With the software cursor above this # only applies while the game hides the pointer. 1 = always; XWayland # games don't set a content type, so 2 (auto) won't trigger. render.direct_scanout = 1; }; }; # What happens at login (the apps' placement rules), in Lua: linked to the # checkout like the other hand-edited configs. extraLuaFiles.startup = link "hypr/startup.lua"; }; # --- Lock screen, idle, wallpaper ----------------------------------------------- # Also the login screen (login-lock below): the bamboo wallpaper, translucent # black boxes, #eee text, rounding 10. programs.hyprlock = { enable = true; # From the NixOS module (programs.hyprlock), which also sets up its PAM. package = null; settings = let centered = { monitor = "HDMI-A-2"; halign = "center"; valign = "center"; }; in { "$font" = "NotoSans Nerd Font"; "$text" = "rgb(238, 238, 238)"; "$box" = "rgba(0, 0, 0, 0.2)"; general = { hide_cursor = false; ignore_empty_input = true; }; animations = { enabled = true; bezier = "ease, 0.25, 0.1, 0.25, 1"; animation = [ "fadeIn, 1, 4, ease" "fadeOut, 1, 4, ease" "inputFieldDots, 1, 2, ease" ]; }; # Light blur and dim so the text stays readable over the leaves, like # the blurred panel behind the login box. background = { monitor = ""; path = "${wallpaper}"; blur_passes = 2; blur_size = 5; brightness = 0.75; }; label = [ # Clock ( centered // { text = ''$TIME''; color = "$text"; font_family = "$font"; font_size = 110; position = "0, 170"; } ) # Date ( centered // { text = ''cmd[update:60000] date +"%A %-d %B"''; color = "rgba(238, 238, 238, 0.75)"; font_family = "$font"; font_size = 18; position = "0, 70"; } ) # User ( centered // { text = "$USER"; color = "$text"; font_family = "$font"; font_size = 15; position = "0, 0"; } ) ]; input-field = centered // { size = "320, 50"; rounding = 10; outline_thickness = 0; inner_color = "$box"; outer_color = "rgba(0, 0, 0, 0)"; check_color = "rgba(170, 255, 255, 0.5)"; fail_color = "rgba(255, 107, 107, 0.6)"; capslock_color = "rgba(244, 214, 159, 0.5)"; font_color = "$text"; font_family = "$font"; fade_on_empty = false; placeholder_text = ''Password''; fail_text = ''$FAIL ($ATTEMPTS)''; dots_size = 0.22; dots_spacing = 0.4; dots_center = true; position = "0, -60"; }; }; }; # The login screen. tty1 logs in by itself (modules/nixos/desktop.nix), so the # session starts locked: once Hyprland is up, hyprlock runs before # graphical-session.target, which requires it to exit successfully (unlocked), # so the bar, wallpaper, autostart apps and everything else in services.nix # only start after you unlock. # # If hyprlock fails (it couldn't lock, or crashed), the session ends rather # than carry on unlocked, and tty1 logs in again, locked. It only runs at # login: started on its own (a rebuild, systemctl) it does nothing. systemd.user.services.login-lock = { Unit = { Description = "Lock screen at login"; # uwsm's waitenv finishes once Hyprland has put WAYLAND_DISPLAY in the # environment that this unit starts with. After = [ "graphical-session-pre.target" "wayland-session-waitenv.service" ]; Before = [ "graphical-session.target" ]; OnFailure = [ "wayland-session-shutdown.target" ]; OnFailureJobMode = "replace-irreversibly"; X-RestartIfChanged = false; }; Service = { Type = "oneshot"; ExecCondition = "${pkgs.bash}/bin/bash -c '! ${pkgs.systemd}/bin/systemctl --user -q is-active graphical-session.target'"; # Lock first and draw the wallpaper once it has loaded: Hyprland takes # input until the session is locked, and waiting for the wallpaper # roughly quadruples that time. ExecStart = "${lib.getExe pkgs.hyprlock} --immediate-render"; TimeoutStartSec = "infinity"; # With the compositor: session-graphical.slice only starts after # graphical-session.target. Slice = "session.slice"; }; Install.RequiredBy = [ "graphical-session.target" ]; }; services.hypridle = { enable = true; settings = { general = { lock_cmd = "pidof hyprlock || hyprlock"; before_sleep_cmd = "loginctl lock-session"; after_sleep_cmd = dpms "on"; }; listener = [ # Lock after 10 minutes idle (respects inhibitors, e.g. fullscreen video). { timeout = 600; on-timeout = "loginctl lock-session"; } { timeout = 1; on-resume = dpms "on"; on-timeout = "true"; ignore_inhibit = true; } { timeout = 300; on-timeout = dpms "off"; ignore_inhibit = true; } ]; }; }; # A new picture from the folder every 30 seconds, on every monitor. services.hyprpaper = { enable = true; settings = { wallpaper = { monitor = ""; path = "${config.xdg.userDirs.pictures}/Wallpapers"; timeout = 30; }; splash = false; }; }; # --- Waybar --------------------------------------------------------------------- # The flyouts it opens (control centre, notifications) are Eww windows, in # config/waybar/eww; its scripts are in config/waybar/scripts. Both stay linked # to the checkout (files.nix). programs.waybar = { enable = true; systemd.enable = true; settings.main = { layer = "top"; position = "top"; height = 36; margin-left = 15; margin-right = 15; margin-top = 15; modules-left = [ "hyprland/workspaces" "hyprland/window" ]; modules-center = [ "custom/imsh-cast-monitor" "clock" ]; modules-right = [ "tray" "custom/notifications" "custom/notifications-count" "custom/control-center" ]; "hyprland/workspaces" = { format = "{name}"; on-click = "activate"; sort-by = "number"; }; "hyprland/window".max-length = 60; "custom/imsh-cast-monitor" = { format = "{text}"; exec = "imsh-cast-monitor"; return-type = "json"; hide-empty-text = true; }; clock = { format = "{:%H:%M}"; format-alt = "{:%A, %B %d, %Y (%R)}"; tooltip-format = "{calendar}"; calendar = { mode = "month"; iso8601 = true; mode-mon-col = 3; weeks-pos = "right"; on-scroll = 1; format = { months = "{}"; days = "{}"; weeks = "W{:%V}"; weekdays = "{}"; today = "{}"; }; }; actions = { on-click-right = "mode"; on-scroll-up = "shift_up"; on-scroll-down = "shift_down"; }; }; # Own icons for the tray apps, by their StatusNotifierItem ID. tray = { icon-size = 16; spacing = 10; order = [ "discord_status_icon_1" ]; icons = { Signal_status_icon_1 = "${trayIcons}/signal.svg"; Bitwarden_status_icon_1 = "${trayIcons}/bitwarden.svg"; WowUpCf_status_icon_1 = "${trayIcons}/wowup.svg"; Claude_status_icon_1 = "${trayIcons}/claude.svg"; steam_app_default = "${trayIcons}/battlenet.svg"; faugus-launcher = "${trayIcons}/faugus.svg"; steam = "${trayIcons}/steam.svg"; "tray-icon tray app opendeck" = "${trayIcons}/opendeck.svg"; }; }; "custom/notifications" = { format = "󰂚"; tooltip = false; on-click = "${toggleFlyout} notifications"; }; "custom/notifications-count" = { exec = "${pkgs.python3-desktop}/bin/python3 ${configHome}/waybar/scripts/notification-count.py"; return-type = "json"; hide-empty-text = true; tooltip = false; on-click = "${toggleFlyout} notifications"; }; "custom/control-center" = { format = " "; tooltip = false; on-click = toggleFlyout; }; }; style = '' * { border: none; border-radius: 17px; font-family: "NotoSans Nerd Font", "Noto Sans", sans-serif; font-size: 12px; font-weight: bolder; background: transparent; color: white; min-height: 0; } window#waybar { background: rgba(0, 0, 0, 0.1); } window#waybar #tray menu { background: rgba(0, 0, 0, 0.1); } #tray menu :hover { background: rgba(0, 0, 0, 0.1); } #tray menu :hover label { background: rgba(0, 0, 0, 0.1); } tooltip { background: rgba(0, 0, 0, 0.1); } /* Workspace buttons are pills inset 4px from the bar's edge, so their 13px radius is concentric with the bar's 17px one. Everything GTK's theme draws on a button (border, shadows, hover gradient) is reset here, as it otherwise shows up as a square box on hover. */ #workspaces button { margin: 4px 0 4px 4px; padding: 0 6px; min-width: 16px; border: none; border-radius: 13px; outline: none; box-shadow: none; text-shadow: none; background-color: transparent; /* Active-workspace underline: 2px line that grows from the centre (see .active). */ background-image: linear-gradient(white, white); background-repeat: no-repeat; background-position: 50% 92%; background-size: 0 2px; transition: background-size 200ms ease-out, background-color 150ms ease-out; } #workspaces button:hover { background-color: rgba(255, 255, 255, 0.12); } #tray window decoration { border-style: none; box-shadow: none; } #workspaces button.active { background-size: 10px 2px; } /* Workspaces with no windows are dimmed, unless it's the one you're on. The colour goes on the label because the `*` rule above gives every label its own white. */ #workspaces button label { transition: color 150ms ease-out; } #workspaces button.empty:not(.active) label { color: rgba(255, 255, 255, 0.4); } #clock, #tray, #custom-notifications, #custom-control-center { padding: 0 7px; } /* Notification count: a small white disc overlapping the bell's top right corner. The margins shrink it from the bar's full height to 12px; the negative left margin pulls it over the bell. Hidden by Waybar when there are no notifications. */ #custom-notifications-count { min-width: 6px; padding: 0 3px; margin: 7px 3px 17px -11px; border-radius: 6px; background: white; color: #18181c; font-size: 8px; } .imsh-cast-monitor.recording { color: #f00; } .imsh-cast-monitor.error { color: #ff0; } /* Control-center button: your logo as a pre-rendered 18px image. GTK stretches SVG backgrounds as pixels (which makes them blurry), so logo-18.png is rendered at exactly 18px, and logo-36.png is used automatically on 2x-scaled monitors. */ #custom-control-center { min-width: 18px; min-height: 18px; background-image: -gtk-scaled(url("file://${../../config/waybar/logo-18.png}"), url("file://${../../config/waybar/logo-36.png}")); background-repeat: no-repeat; background-position: center; background-size: 18px 18px; margin-right: 6px; /* gap between the logo and the end of the bar */ } ''; }; # --- Notifications -------------------------------------------------------------- services.dunst = { enable = true; settings = let # Near-transparent, over Hyprland's blur (the "notifications" layer rule). urgency = timeout: { inherit timeout; background = "#25252501"; foreground = "#ffffff"; frame_color = "#25252501"; }; in { global = { monitor = 0; follow = "mouse"; alignment = "center"; width = 360; offset = 15; origin = "top-right"; padding = 12; horizontal_padding = 12; frame_width = 0; markup = "full"; icon_position = "off"; font = "DejaVu Sans Mono 10"; stack_duplicates = true; hide_duplicate_count = true; separator_height = 0; fullscreen = "pushback"; corner_radius = 15; # Left click does what the notification is for (e.g. opens the chat), # then closes it. Middle click only dismisses it, right click # dismisses all. mouse_left_click = "do_action, close_current"; mouse_middle_click = "close_current"; mouse_right_click = "close_all"; progress_bar = true; }; urgency_low = urgency 4; urgency_normal = urgency 8; urgency_critical = urgency 16; status = { category = "status"; set_stack_tag = "status"; history_ignore = true; timeout = 2; }; }; }; }