# 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; }; # Let Hyprland show the session menu for a short power-key press instead # of letting logind immediately shut the machine down. At the greeter or # on a TTY, the key simply does nothing. services.logind.settings.Login.HandlePowerKey = "ignore"; 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 ]; home-manager.users.fred = { config, lib, pkgs, inputs, ... }: let c = config.lib.stylix.colors; rgb = hex: "rgb(${hex})"; rgba = hex: a: "rgba(${hex}${a})"; # Build-time fallback for the border colours, until theme-apply first # runs and hyprctl-evals the theme's own hypr.lua. Same two mixes as # settings/theming.nix and quickshell's Theme.qml — see ui.nix. Not # c.base04/c.base03: those are independent slots off the wallpaper and # land in unrelated hues from the ink the shell frame is drawn in. ink4 = ui.mixHex c.base05 c.base00 ui.inkMuted; ink3 = ui.mixHex c.base05 c.base00 ui.inkFaint; screenshotWithLoupe = pkgs.writeShellScript "screenshot-with-loupe" '' set -eu dir="''${XDG_RUNTIME_DIR:?}/loupe-screenshots" ${pkgs.coreutils}/bin/mkdir -m 700 -p "$dir" name="$(${pkgs.coreutils}/bin/date +%Y%m%d-%H%M%S-%N).png" # Keep Hyprshot's own notification: Quickshell already handled it when # screenshots were clipboard-only, and its icon carries this file path # for the Loupe click handler below. ${pkgs.hyprshot}/bin/hyprshot -m "$1" -o "$dir" -f "$name" [ -s "$dir/$name" ] || exit 0 # Quickshell retains at most 30 notifications. Keep the same number of # screenshots so notification clicks work without filling runtime tmpfs. ${pkgs.findutils}/bin/find "$dir" -maxdepth 1 -type f -name '*.png' -printf '%f\n' \ | ${pkgs.coreutils}/bin/sort \ | ${pkgs.coreutils}/bin/head -n -30 \ | while IFS= read -r old; do ${pkgs.coreutils}/bin/rm -- "$dir/$old"; done ''; 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. allow_token_by_default pre-ticks the "allow a # restore token" box so Vesktop/Discord isn't re-prompted. The custom # picker skips the dialog when a game is open: XDPH hands it the window # list as id[HC>]class[HT>]title[HE>]address[HA>]… and reads back # "[SELECTION]/window:" (flag r = token allowed). Hearthstone # wins, else any other Proton window (steam_app_*) that isn't a launcher # or HDT. Gaming's patched Vesktop writes the time to xdph-pick-manually # right before asking the portal ("Pick Another Window", shift-click) # to get the normal picker instead. # # After answering, it grim-shots the selection to xdph-preview.jpg for # Vesktop's dialog (~70ms vs Electron's ~1s PipeWire thumbnail). grim # reads the screen, so a window that isn't visible is skipped and Vesktop # falls back to the thumbnail. xdg.configFile."hypr/xdph.conf".text = '' screencopy { allow_token_by_default = true custom_picker_binary = ${lib.getExe (pkgs.writeShellApplication { name = "share-picker-game-first"; runtimeInputs = with pkgs; [ coreutils gawk jq grim hyprland xdg-desktop-portal-hyprland ]; excludeShellChecks = [ "SC2016" ]; # $ in awk/jq programs is meant literally text = '' run=''${XDG_RUNTIME_DIR:-/tmp} flag=$run/xdph-pick-manually preview=$run/xdph-preview.jpg list=''${XDPH_WINDOW_SHARING_LIST-} rm -f "$preview" printf -v now '%(%s)T' -1 { forced=$(< "$flag"); } 2>/dev/null || forced=0 # no trailing newline, so not `read` echo 0 > "$flag" # run awk program $2 over the window list as id/class/title/address # columns, with want=$1 entries() { printf '%s' "$list" | awk ' BEGIN { RS = "\\[HA>\\]"; FS = "\\[H[CTE]>\\]" } { print $1 "\t" $2 "\t" $3 "\t" $4 }' | awk -F '\t' -v want="$1" "$2" } game=$(entries "" ' $2 ~ /^steam_app_/ && $3 == "Hearthstone" { hs = $1 } $2 ~ /^steam_app_/ && $3 != "" && !other \ && $3 !~ /^(Battle\.net|Hearthstone Deck Tracker|HearthstoneOverlay)/ { other = $1 } END { print hs != "" ? hs : other }') if [ -n "$game" ] && (( now - forced > 10 )); then selection=r/window:$game else out=$(hyprland-share-picker "$@") || true selection=''${out#*"[SELECTION]"} if [ "$selection" = "$out" ]; then # closed / failed: pass through printf '%s\n' "$out" exit 0 fi fi echo "[SELECTION]$selection" shoot() { local target=''${1#*/} where=() case $target in window:*) local id=''${target#window:} addr geom if (( id < 0 )); then id=$(( id + 4294967296 )); fi # the Qt picker prints it signed addr=$(entries "$id" '$1 == want { print $4 }') [ -n "$addr" ] || return 0 geom=$(hyprctl -j clients | jq -r --arg a "$(printf '0x%x' "$addr")" \ --argjson mons "$(hyprctl -j monitors)" ' [$mons[] | .activeWorkspace.id, .specialWorkspace.id] as $shown | .[] | select(.address == $a and (.hidden | not)) | select(.workspace.id as $w | any($shown[]; . == $w)) | "\(.at[0]),\(.at[1]) \(.size[0])x\(.size[1])"') [ -n "$geom" ] || return 0 where=(-g "$geom") ;; screen:*) where=(-o "''${target#screen:}") ;; region:*) local spec=''${target#region:} x y w h geom IFS=, read -r x y w h <<< "''${spec#*@}" geom=$(hyprctl -j monitors | jq -r --arg o "''${spec%@*}" \ --argjson x "$x" --argjson y "$y" --arg wh "''${w}x$h" ' .[] | select(.name == $o) | "\(.x + $x),\(.y + $y) \($wh)"') [ -n "$geom" ] || return 0 where=(-g "$geom") ;; *) return 0 ;; esac grim "''${where[@]}" -s 0.5 -t jpeg -q 85 "$preview.tmp" && mv -f "$preview.tmp" "$preview" } shoot "$selection" > /dev/null 2>&1 < /dev/null & ''; })} } ''; 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 ink4; "col.inactive_border" = rgb ink3; }; 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 ink4; "col.border_inactive" = rgb ink3; "col.border_locked_active" = rgb c.base05; groupbar = { text_color = rgb c.base05; "col.active" = rgb ink4; "col.inactive" = rgb ink3; }; }; render = { direct_scanout = false; # Fullscreen video advertising HDR made Hyprland flip the monitor # into HDR mode mid-playback (flicker + "wide color gamut ... not # in 10-bit mode" toast, since DP-3 is 8-bit). Keep HDR off. cm_auto_hdr = 0; }; 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; # Black, not base00: login hands over greeter fade → black → # Quickshell wallpaper fade, and only shows before that maps. background_color = "rgb(000000)"; # 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") -- Complete the keyring's PAM login initialization before Spotify -- requests credentials. greetd has already authenticated Fred. hl.exec_cmd("sh -c '${pkgs.gnome-keyring}/bin/gnome-keyring-daemon --start --components=secrets && exec spotify'") -- 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") -- Scratchpad terminal: 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 -- No hypridle here: services.hypridle already runs it as a user -- unit off hyprland-session.target. Launching it a second time -- left two daemons racing, and the shell's caffeine toggle -- (systemctl --user stop hypridle) only silenced one of them. 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, }) -- Screenshot annotations are a short-lived task over the current -- workspace, so keep Loupe centred and floating on both desktops. hl.window_rule({ name = "loupe-floating", match = { class = "^org[.]gnome[.]Loupe$" }, float = true, center = 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", }) -- Hearthstone Deck Tracker's overlay: a transparent WPF window HDT -- itself keeps moved and sized over Hearthstone (title from its -- OverlayWindow XAML). The Wine plugin in apps/hdt-wine-overlay -- keeps it override-redirect; this is the safety net if Wine ever -- hands it over as a managed window, which the tile rule above -- would tile. no_focus matters either way: Hyprland hit-tests the -- window box, not the X11 input shape, so focus-follows-mouse -- handed focus to the overlay whenever the cursor was over the game. hl.window_rule({ name = "hdt-overlay", match = { class = "^steam_app_(0|default)$", title = "^HearthstoneOverlay$" }, float = true, no_focus = true, no_initial_focus = true, decorate = false, border_size = 0, no_shadow = true, no_blur = true, no_dim = true, no_anim = true, }) -- 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 = "monitor_w*0.8 monitor_h*0.8", center = true, }) -- Hearthstone Deck Tracker's main window: its own special workspace -- (SUPER+H), same float-to-inset as Spotify. Matched on title since -- it shares steam_app_default with the game and Battle.net; the -- overlay ("HearthstoneOverlay", no space) doesn't match. hl.window_rule({ name = "hdt-scratch", match = { class = "^steam_app_(0|default)$", title = "^Hearthstone Deck Tracker" }, workspace = "special:hdt silent", float = true, size = "monitor_w*0.6 monitor_h*0.8", 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 = "monitor_w*0.5 monitor_h*0.5", 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", }) -- Dungeon Runners does the same. Faugus/umu gives non-Steam games -- the same steam_app_default class, so the generic tile rule above -- already catches it; this only stops the fullscreen requests. hl.window_rule({ match = { class = "steam_app_default", title = "^Dungeon Runners$" }, 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")) -- Yazi in a terminal, not nemo: it re-themes with ghostty's -- palette instead of needing a GTK restart. Nemo stays one key -- away on Super+N — it is the only one of the two that can drag -- a file into a browser. -- -- +new-window, not `ghostty -e yazi`: bare -e force-sets -- gtk-single-instance=false, so every press built a whole second -- Ghostty — GTK, OpenGL, font discovery — measured at 1.36s. -- +new-window asks the running daemon over D-Bus instead: 39ms. -- -- The exact spelling matters on 1.3.1: -- * -e, never --command=. The latter is corrupted in transit — -- the daemon logs `started subcommand path=/bin/sh` and gets -- handed an empty or garbage string, so the window dies at once. -- * --working-directory is mandatory and must come BEFORE -e. -- Without it the auto-detected cwd is appended to the command -- and corrupts it (ghostty#11356); everything after -e is -- swallowed into the command itself. hl.bind(mod .. " + E", hl.dsp.exec_cmd("ghostty +new-window --working-directory=${config.home.homeDirectory} -e yazi")) hl.bind(mod .. " + N", 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" })) ${if isGaming then '' -- SUPER+H is HDT's scratch layer here; SUPER+left still focuses left. -- toggle_scratchpad focuses by class, which HDT shares with the game. hl.bind(mod .. " + H", function() hl.dispatch(hl.dsp.workspace.toggle_special("hdt")) if hl.get_active_special_workspace() then for _, w in ipairs(hl.get_workspace_windows("special:hdt")) do hl.dispatch(hl.dsp.focus({ window = w })) break end end end) '' else '' 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")) hl.bind("XF86PowerOff", 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). -- Save temporary PNGs so the notification can reopen -- the exact screenshot in Loupe, while hyprshot still copies it. hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; ${screenshotWithLoupe} region; qs ipc call screenshot unpin'")) hl.bind("Print", hl.dsp.exec_cmd("${screenshotWithLoupe} 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; # The independent Quickshell lock is configured in settings/auth.nix, # triggered through the confirmed-lock helper. 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 = "fred-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"; }; }; }