# 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 ]; 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; "col.active_border" = rgb c.base0D; "col.inactive_border" = rgb c.base03; }; decoration = { # tracks Theme.radius via settings/ui.nix rounding = ui.radius; blur = { enabled = true; size = 1; passes = 3; }; shadow.color = rgba c.base00 "99"; }; group = { "col.border_active" = rgb c.base0D; "col.border_inactive" = rgb c.base03; "col.border_locked_active" = rgb c.base0C; groupbar = { text_color = rgb c.base05; "col.active" = rgb c.base0D; "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; # keep compositor ticking, don't idle between frames 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 }) -- 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") -- 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", title = "^$", float = true }, workspace = "special silent", }) -- 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 (a non-Steam shortcut, so class steam_app_0) spams -- window-activation events that clear quickshell's focus grab and -- instantly close the launcher / power menu. Drop those events. hl.window_rule({ match = { class = "steam_app_0" }, 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")) -- 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 = "horizontal", 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")) -- 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 }) ''; }; # 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"; before_sleep_cmd = "loginctl lock-session"; after_sleep_cmd = "hyprctl dispatch dpms on"; }; listener = [ { timeout = 300; # 5 min — lock on-timeout = unlessPlaying "loginctl lock-session"; } { timeout = 420; # 7 min — display off on-timeout = unlessPlaying "hyprctl dispatch dpms off"; on-resume = "hyprctl dispatch dpms 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"; }; }; }