# settings/hyprland.nix { config, pkgs, lib, inputs, ... }: let isMacbook = config.networking.hostName == "FredOS-Macbook"; isGaming = !isMacbook; ui = import ./ui.nix; # shared corner-rounding knob in { config = lib.mkIf (lib.elem config.networking.hostName [ "FredOS-Gaming" "FredOS-Macbook" ]) { programs.hyprland = { enable = true; xwayland.enable = true; }; xdg.portal = { enable = true; # xdg-desktop-portal-hyprland is registered automatically by # programs.hyprland.portalPackage; listing it here too produced a # duplicate user-unit symlink during nixos-rebuild. extraPortals = with pkgs; [ xdg-desktop-portal-gtk ]; config.hyprland.default = [ "hyprland" "gtk" ]; }; security.polkit.enable = true; # graphical-desktop.nix mkDefault-enables this; unused (no screen reader, # no Discord TTS). services.speechd.enable = false; # Polkit GUI agent for GUI sudo prompts under Hyprland systemd.user.services.polkit-gnome-authentication-agent-1 = { description = "polkit-gnome-authentication-agent-1"; wantedBy = [ "graphical-session.target" ]; partOf = [ "graphical-session.target" ]; after = [ "graphical-session.target" ]; serviceConfig = { Type = "simple"; ExecStart = "${pkgs.polkit_gnome}/libexec/polkit-gnome-authentication-agent-1"; Restart = "on-failure"; }; }; environment.systemPackages = with pkgs; [ ghostty libnotify grim slurp wl-clipboard cliphist brightnessctl playerctl hypridle hyprshot hyprpicker networkmanagerapplet pavucontrol polkit_gnome zenity libcanberra-gtk3 ]; # Lock *before* the machine actually suspends. hypridle's # before_sleep_cmd cannot do this: it spawns the command and releases # its sleep inhibitor in the same breath (journal: "Executing …" then # "Releasing the sleep inhibitor!" on the same second), so the qs # process gets frozen mid-lock and only finishes on resume — the lid # opens showing the pre-suspend desktop for a beat before the lock # paints. A unit ordered Before=sleep.target genuinely blocks the # suspend until it exits. The sleep covers the gap between quickshell # accepting the IPC and the lock surface committing a frame. systemd.services.lock-before-sleep = lib.mkIf isMacbook { description = "Lock the session before suspend"; before = [ "sleep.target" ]; wantedBy = [ "sleep.target" ]; serviceConfig = { Type = "oneshot"; User = "fred"; # XDG_RUNTIME_DIR must be derived at runtime: the %U specifier is the # UID of the *manager* running the unit (0 for system units), not the # one in User=, so it pointed qs at /run/user/0. ExecStart = pkgs.writeShellScript "lock-before-sleep" '' export XDG_RUNTIME_DIR=/run/user/$(${pkgs.coreutils}/bin/id -u) ${pkgs.quickshell}/bin/qs ipc call lock lock ${pkgs.coreutils}/bin/sleep 1 ''; TimeoutStartSec = "5s"; }; }; home-manager.users.fred = { config, lib, pkgs, inputs, ... }: let c = config.lib.stylix.colors; rgb = hex: "rgb(${hex})"; rgba = hex: a: "rgba(${hex}${a})"; in { # Stylix's Hyprland target injects settings.{general,decoration,group,misc} # as top-level keys, which render as hl.general()/hl.decoration()/… in Lua # mode — functions that don't exist. Disable it and absorb the colours # into settings.config below. stylix.targets.hyprland.enable = false; # Wallpaper is drawn by quickshell (settings/quickshell.nix); no # separate hyprpaper daemon. stylix.targets.hyprpaper.enable = false; # Screenshare picker: auto-allow restore tokens (the "allow a restore # token" checkbox) so Vesktop/Discord gets a token on the first pick # instead of re-prompting. XDPH parses "[SELECTION]/..." from the # picker's stdout; flag 'r' = token allowed, so inject it always. xdg.configFile."hypr/xdph.conf".text = '' screencopy { custom_picker_binary = ${pkgs.writeShellScript "share-picker-auto-restore" '' ${pkgs.xdg-desktop-portal-hyprland}/bin/hyprland-share-picker "$@" \ | ${pkgs.gnused}/bin/sed 's/^\[SELECTION\]/[SELECTION]r/' ''} } ''; wayland.windowManager.hyprland = { enable = true; configType = "lua"; systemd.variables = [ "--all" ]; settings = { # hl.config({...}) — all static named-section configuration. # monitor is set per-host in hosts/FredOS-{Gaming,Macbook}.nix. config = { general = { gaps_in = 6; # 6px of the outer gap is occupied by the quickshell screen # frame (Theme.frameWidth) — 18 on framed sides keeps the # visible frame↔window gap equal to the inter-window gap (12). # Left stays 12: the bar lives there and reserves its own space. gaps_out = { top = 18; right = 18; bottom = 18; left = 12; }; border_size = 2; layout = "dwindle"; resize_on_border = true; # Active = base04, inactive = base03: adjacent slots on stylix's # dark→light ramp, so active reads as a subtle brightness lift in # any palette. NOT base0D — a wallpaper-derived scheme can hand # base0D and base03 the same value (this one does: both 3fa8ab), # which left active and inactive windows indistinguishable. "col.active_border" = rgb c.base04; "col.inactive_border" = rgb c.base03; }; decoration = { # tracks Theme.radius via settings/ui.nix rounding = ui.radius; blur = { enabled = true; size = 1; # 3 passes is 3 full-screen shader round-trips per frame. Free # on Navi 22, not on the Macbook's gen7.5 iGPU driving a 2x # scaled panel. passes = if isMacbook then 1 else 3; }; shadow.color = rgba c.base00 "99"; }; group = { # Same base04/base03 ramp as general.col.* above. "col.border_active" = rgb c.base04; "col.border_inactive" = rgb c.base03; "col.border_locked_active" = rgb c.base05; groupbar = { text_color = rgb c.base05; "col.active" = rgb c.base04; "col.inactive" = rgb c.base03; }; }; render = { direct_scanout = false; }; animations = { enabled = true; }; input = { kb_layout = "gb,no"; kb_options = "grp:win_space_toggle" + lib.optionalString isGaming ",altwin:swap_lalt_lwin"; follow_mouse = 1; accel_profile = "flat"; sensitivity = 0; } // lib.optionalAttrs isMacbook { touchpad = { tap_to_click = true; tap_button_map = "lrm"; natural_scroll = true; }; }; binds = { # default 300ms swallows rapid super+scroll workspace switches scroll_event_delay = 50; }; cursor = { no_warps = true; # Force real HW cursor plane (default "auto" can fall back to a # software cursor on i915 → cursor waits a full repaint = laggy). no_hardware_cursors = false; }; dwindle = { preserve_split = true; }; misc = { disable_hyprland_logo = true; disable_splash_rendering = true; # Apps demanding attention don't get to yank focus — they'll # show as urgent in the bar instead. focus_on_activate = false; vrr = 2; background_color = rgb c.base00; # If quickshell dies while holding the session lock, let its # restarted instance re-attach instead of a dead red screen. allow_session_lock_restore = true; }; # vfr moved from misc: to debug: in 0.55.0 debug = { # vfr=false renders every refresh forever, idle or not. That was # the pre-patch workaround for cursor lag; the aquamarine # legacy-cursor patch now moves the cursor by ioctl, so the # Macbook can idle its GPU between frames again. Gaming box keeps # the constant tick (games/present timing). vfr = isMacbook; disable_logs = false; }; }; }; extraConfig = let wpctl = "${pkgs.wireplumber}/bin/wpctl"; qs = "${pkgs.quickshell}/bin/qs"; in '' -- Environment hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") hl.env("XCURSOR_SIZE", "24") hl.env("HYPRCURSOR_THEME", "Bibata-Modern-Ice") hl.env("HYPRCURSOR_SIZE", "24") hl.env("ELECTRON_OZONE_PLATFORM_HINT", "wayland") hl.env("MOZ_ENABLE_WAYLAND", "1") hl.env("QT_QPA_PLATFORM", "wayland;xcb") hl.env("SDL_VIDEODRIVER", "wayland") hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") ${lib.optionalString isGaming '' -- GPU pinning — Navi 22 is card1 on the dual-GPU gaming box. hl.env("AQ_DRM_DEVICES", "/dev/dri/card1") hl.env("DRI_PRIME", "pci-0000_03_00_0") ''} -- Layer rules: blur behind bar and toasts hl.layer_rule({ match = { namespace = "quickshell-bar" }, blur = true, ignore_alpha = 0.3 }) hl.layer_rule({ match = { namespace = "quickshell-osd" }, blur = true, ignore_alpha = 0.3 }) -- The scratchpad terminal gets its own --class, which does two -- things ghostty's --title cannot. First, gtk-single-instance -- means a second `ghostty` hands its request to the running instance -- and exits; forwarded requests keep -e but DROP --title, so the -- window ends up named by the shell and no title rule matches it. -- A distinct class forces a separate instance. Second, the class is -- fixed at surface creation, so the shell's own title escapes can't -- desync the window rules or the respawn below. -- -- Respawn: these are meant to exist for the whole session, so SUPER+Q -- on them reads as "reset this thing", not "close it". local scratchpads = { ["dev.fred.scratchpad"] = "ghostty --class=dev.fred.scratchpad --title=scratchpad", -- Spotify sets its own class, so no --class trick here; the -- lookup below lowercases before falling back, which covers both -- the Wayland app id and the XWayland "Spotify". ["spotify"] = "spotify", } local shutting_down = false hl.on("hyprland.shutdown", function() shutting_down = true end) hl.on("window.close", function(win) local cmd = scratchpads[win.class] or scratchpads[(win.class or ""):lower()] if cmd and not shutting_down then hl.exec_cmd(cmd) end end) -- Toggling a special workspace leaves keyboard focus wherever the -- cursor already was, so a scratchpad opened over another window -- came up unfocused. Focus its window explicitly on the way in. -- Deliberately not cursor:warp_on_toggle_special — that yanks the -- pointer to the window on open and back again on close. local function toggle_scratchpad(name, class) return function() hl.dispatch(hl.dsp.workspace.toggle_special(name)) -- Only on the opening half of the toggle. if hl.get_active_special_workspace() then hl.dispatch(hl.dsp.focus({ window = "class<" .. class .. ">" })) end end end -- Startup hl.on("hyprland.start", function() -- Ensure hyprland-session.target starts even if HM's -- dbus-update-activation-environment chain fails upstream. hl.exec_cmd("systemctl --user start hyprland-session.target") -- greetd's pam leaves gnome-keyring-daemon in half-initialized -- --login mode (autologin = no password, and nothing in a -- non-GNOME session runs the second stage). --start completes -- init so the lockscreen's pam unlock actually reaches it. hl.exec_cmd("${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets") -- mako removed; notifications handled by quickshell hl.exec_cmd("wl-paste --type text --watch cliphist store") hl.exec_cmd("wl-paste --type image --watch cliphist store") hl.exec_cmd("hyprctl setcursor Bibata-Modern-Ice 24") -- Spotify starts hidden: the window rule below swallows it onto -- special:music silently, so it never steals focus and SUPER+M is -- always an instant toggle. hl.exec_cmd(scratchpads["spotify"]) -- Scratchpad terminal, same trick: parked on special:scratch at -- login so SUPER+Return is always an instant toggle and the shell -- keeps its history/cwd between peeks. hl.exec_cmd(scratchpads["dev.fred.scratchpad"]) -- swayosd removed; volume/brightness OSD handled by quickshell ${lib.optionalString isMacbook ''hl.exec_cmd("hypridle")''} end) -- Animation curve and definitions hl.curve("snap", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.0} } }) hl.animation({ leaf = "windows", enabled = true, speed = 1, bezier = "snap" }) hl.animation({ leaf = "windowsOut", enabled = true, speed = 1, bezier = "snap", style = "popin 80%" }) hl.animation({ leaf = "layers", enabled = true, speed = 1, bezier = "snap" }) hl.animation({ leaf = "border", enabled = true, speed = 2, bezier = "default" }) hl.animation({ leaf = "fade", enabled = true, speed = 1, bezier = "default" }) -- slidevert: workspaces slide up/down to match the vertical -- workspace column in the bar (default "slide" is horizontal). hl.animation({ leaf = "workspaces", enabled = true, speed = 1, bezier = "snap", style = "slidevert" }) -- Window rules -- Don't lock/idle while any window is fullscreen (video, games). hl.window_rule({ match = { class = ".*" }, idle_inhibit = "fullscreen", }) -- Steam games often open floating (fixed XWayland size hints); -- force tile generically. Rule order matters: the Battle.net -- tray-icon rule below is more specific and runs after, so it -- still wins and keeps the tray icon floated on its hidden -- workspace. hl.window_rule({ match = { class = "^steam_app_.*" }, tile = true, }) -- Battle.net tray icon leaks as a tiny floating XWayland window. hl.window_rule({ match = { class = "^steam_app_(0|default)$", title = "^$", float = true }, workspace = "special silent", }) -- Spotify lives on a special workspace for its whole session; -- SUPER+M slides it in and out. Matched on class, not title: the -- title carries the current track. hl.window_rule({ match = { class = "^[Ss]potify$" }, workspace = "special:music silent", }) -- Inset it rather than filling the screen, so the workspace -- underneath still frames it. Floating is what makes a percentage -- size stick: a tiled window is sized by the layout, which would -- just expand it back to the full area. Matched on class for the -- same reason as above, and because matching on the special -- workspace would depend on the assignment rule running first. hl.window_rule({ name = "spotify-inset", match = { class = "^[Ss]potify$" }, float = true, size = "80% 80%", center = true, }) -- Scratchpad terminal — same class-match + float-to-inset pattern as -- Spotify above. hl.window_rule({ match = { class = "^dev.fred.scratchpad$" }, workspace = "special:scratch silent", }) hl.window_rule({ name = "scratchpad-inset", match = { class = "^dev.fred.scratchpad$" }, float = true, size = "60% 60%", center = true, }) -- Games stall on other workspaces: no frame callbacks = no render = -- game loop blocks. Keep them ticking in the background at -- misc:render_unfocused_fps (15). hl.window_rule({ match = { class = "^steam_app_.*" }, render_unfocused = true, }) -- WoW keeps requesting fullscreen/maximize; ignore so it stays tiled. hl.window_rule({ match = { class = "steam_app_default", title = "^World of Warcraft$" }, suppress_event = "fullscreen maximize", }) -- Battle.net requests activation on its own while running, which -- steals focus. Drop those events. -- -- Non-Steam shortcuts have been BOTH steam_app_0 and -- steam_app_default across Steam versions, and this rule matched only -- steam_app_0, so it silently stopped applying when Battle.net turned -- up as steam_app_default. Match either. -- -- Does NOT fix the launcher dying while Battle.net runs. That is -- Hyprland destroying quickshell's seat grab from inside -- mouseMoveUnified() whenever it refocuses with the cursor over a -- surface the grab doesn't own — no activation request involved, so -- there is nothing here to suppress. Unsolved; see the note in -- settings/quickshell.nix. -- -- WoW shares steam_app_default and so loses self-activation too. -- Harmless: a game window is focused when it maps, which is not an -- activation request, and alt-tab is compositor-side. hl.window_rule({ match = { class = "^steam_app_(0|default)$" }, suppress_event = "activate activatefocus", }) -- Binds local mod = "SUPER" -- Apps hl.bind(mod .. " + T", hl.dsp.exec_cmd("ghostty")) hl.bind(mod .. " + E", hl.dsp.exec_cmd("nemo")) hl.bind(mod .. " + R", hl.dsp.exec_cmd("${pkgs.quickshell}/bin/qs ipc call launcher toggle")) hl.bind(mod .. " + Q", hl.dsp.window.close()) hl.bind(mod .. " + SHIFT + E", hl.dsp.exit()) -- Floating / layout hl.bind(mod .. " + V", hl.dsp.window.float({ action = "toggle" })) hl.bind(mod .. " + F", hl.dsp.window.fullscreen()) hl.bind(mod .. " + P", hl.dsp.window.pseudo()) hl.bind(mod .. " + S", hl.dsp.layout("togglesplit")) hl.bind(mod .. " + M", toggle_scratchpad("music", "^[Ss]potify$")) hl.bind(mod .. " + Return", toggle_scratchpad("scratch", "dev.fred.scratchpad")) -- Focus hl.bind(mod .. " + left", hl.dsp.focus({ direction = "left" })) hl.bind(mod .. " + right", hl.dsp.focus({ direction = "right" })) hl.bind(mod .. " + up", hl.dsp.focus({ direction = "up" })) hl.bind(mod .. " + down", hl.dsp.focus({ direction = "down" })) hl.bind(mod .. " + H", hl.dsp.focus({ direction = "left" })) hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" })) hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" })) -- Power menu (quickshell launcher in power mode) hl.bind(mod .. " + L", hl.dsp.exec_cmd("${pkgs.quickshell}/bin/qs ipc call launcher powermenu")) -- Move windows hl.bind(mod .. " + SHIFT + left", hl.dsp.window.move({ direction = "left" })) hl.bind(mod .. " + SHIFT + right", hl.dsp.window.move({ direction = "right" })) hl.bind(mod .. " + SHIFT + up", hl.dsp.window.move({ direction = "up" })) hl.bind(mod .. " + SHIFT + down", hl.dsp.window.move({ direction = "down" })) -- Workspaces for i = 0, 9 do local workspace_id = tostring((i == 0) and 10 or i) hl.bind(mod .. " + " .. i, hl.dsp.focus({ workspace = workspace_id })) hl.bind(mod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = workspace_id, follow = false })) end -- Scroll through workspaces hl.bind(mod .. " + mouse_down", hl.dsp.focus({ workspace = "e+1" })) hl.bind(mod .. " + mouse_up", hl.dsp.focus({ workspace = "e-1" })) ${lib.optionalString isMacbook '' -- Three-finger touchpad swipe between workspaces hl.gesture({ fingers = 3, direction = "vertical", action = "workspace" }) ''} -- Screenshots — Shift+Super+S matches GNOME binding -- Pin/unpin quickshell's focus grab around the region select so an -- open menu survives slurp's input grab (no-ops if qs isn't up). hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region; qs ipc call screenshot unpin'")) hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output")) -- Settings shortcut — Super+I matches GNOME binding hl.bind(mod .. " + I", hl.dsp.exec_cmd("pavucontrol")) -- Custom shortcuts hl.bind(mod .. " + Z", hl.dsp.exec_cmd("zen-beta")) -- Color picker — copies hex to clipboard hl.bind(mod .. " + A", hl.dsp.exec_cmd("hyprpicker -a")) -- Keybind cheat sheet (quickshell overlay) hl.bind(mod .. " + C", hl.dsp.exec_cmd("${qs} ipc call cheatsheet toggle")) -- Mouse window manipulation hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) -- Volume / brightness (repeating). The OSD overlay is quickshell's -- (see settings/quickshell.nix) — swayosd and wob are both gone. -- Volume needs no IPC: quickshell watches Pipewire, so the OSD -- also shows for changes made in pavucontrol or a mixer. hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("${wpctl} set-volume @DEFAULT_AUDIO_SINK@ 5%+ -l 1.0"), { repeating = true }) hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("${wpctl} set-volume @DEFAULT_AUDIO_SINK@ 5%-"), { repeating = true }) hl.bind("XF86AudioMute", hl.dsp.exec_cmd("${wpctl} set-mute @DEFAULT_AUDIO_SINK@ toggle")) hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("${qs} ipc call osd brightnessUp"), { repeating = true }) hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("${qs} ipc call osd brightnessDown"), { repeating = true }) ${lib.optionalString isMacbook '' hl.bind("XF86KbdBrightnessUp", hl.dsp.exec_cmd("${qs} ipc call osd kbdUp"), { repeating = true }) hl.bind("XF86KbdBrightnessDown", hl.dsp.exec_cmd("${qs} ipc call osd kbdDown"), { repeating = true }) ''} -- Media keys (locked — work through lockscreen) hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) hl.bind("XF86AudioNext", hl.dsp.exec_cmd("playerctl next"), { locked = true }) hl.bind("XF86AudioPrev", hl.dsp.exec_cmd("playerctl previous"), { locked = true }) -- The live theme, last so it beats the build-time colours in -- settings.config above. A rebuild rewrites this file and hyprland -- reloads it, which re-runs the whole config — that is why the -- borders used to snap back to the build-time palette on every -- `update` and stay there until the next wallpaper switch. -- theme-apply's `hyprctl eval` only touches the running instance; -- this is the half that survives a reload. It also means a fresh -- session starts on the last-used theme rather than stylix's. -- -- pcall, not a bare call: before the first theme-apply on a new -- account the state symlink does not exist, and hyprland opens -- only selected Lua libraries (luaL_openselectedlibs), so `dofile` -- itself may be absent. Either way this degrades to a no-op -- instead of aborting the rest of the config. pcall(dofile, "${config.xdg.stateHome}/quickshell-theme/hypr.lua") ''; }; # Night light — hyprsunset applies the gamma; the schedule and # temperature are driven by quickshell's quick-settings menu # (settings/quickshell.nix) via `hyprctl hyprsunset`. services.hyprsunset.enable = true; # Lock screen is quickshell's WlSessionLock (settings/quickshell.nix), # triggered via `qs ipc call lock lock`. services.hypridle = lib.mkIf isMacbook ( let # Skip the action if any MPRIS player is playing — covers windowed # video (Jellyfin in a browser, mpv, …) that the fullscreen # idle_inhibit rule misses. Browsers expose MPRIS via playerctl. unlessPlaying = cmd: "playerctl -a status 2>/dev/null | grep -q Playing || ${cmd}"; in { enable = true; settings = { general = { lock_cmd = "qs ipc call lock lock"; # No before_sleep_cmd: hypridle does not wait for it before # dropping its sleep inhibitor, so the lock lands after resume. # systemd.services.lock-before-sleep handles suspend instead. after_sleep_cmd = "hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'"; }; listener = [ { timeout = 300; # 5 min — lock on-timeout = unlessPlaying "loginctl lock-session"; } { timeout = 420; # 7 min — display off # Lua config manager: `hyprctl dispatch X` evaluates # `hl.dispatch(X)`, and a non-table arg parses as "toggle", # so the action has to be spelled out in a table. on-timeout = unlessPlaying "hyprctl dispatch 'hl.dsp.dpms({ action = \"off\" })'"; on-resume = "hyprctl dispatch 'hl.dsp.dpms({ action = \"on\" })'"; } { timeout = 600; # 10 min — suspend on-timeout = unlessPlaying "systemctl suspend"; } ]; }; } ); # Scope all HM Wayland services (hyprpaper, etc.) to the # Hyprland session so they don't crash-loop in a GNOME session. wayland.systemd.target = "hyprland-session.target"; }; }; }