nixos/settings/hyprland.nix
rope b5372d46f3 ink ladder: base03/base04 derive from base05, shared with hyprland
base03/base04/base05 were three independent slots off the wallpaper, not
one ink at three strengths. On wallhaven-k8dqr6 the raw ladder is red,
blue and cream; on 5y39p9 base03 is a saturated gold against pale cream.
That is why muted labels read as a second accent and why the shell frame
never quite matched the window border beside it.

base04 and base03 are now base05 mixed toward base00. The two factors and
the mix live in settings/ui.nix and are used by all three consumers:
Theme.qml, theming.nix's hypr.lua (applied at runtime) and hyprland.nix's
build-time fallback. Same trick base01/base02 already used for surfaces.

Verified on 5y39p9: hypr.lua now emits aca888 / 727761 from base05 f1e2b6
against base00 0a1f1c, matching what inkStep computes in the shell.
2026-08-31 13:03:45 +01:00

657 lines
31 KiB
Nix

# 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)
# HOME too: system units don't set it for User=, and qs resolves
# the config (and thus the instance to talk to) via ~/.config —
# without it: "Could not find "default" config directory".
export HOME=/home/fred
# --any-display: no WAYLAND_DISPLAY here, and qs skips instances
# on other displays without it.
${pkgs.quickshell}/bin/qs ipc --any-display 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})";
# 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;
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]<flags>/..." 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 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;
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 is NOT started here. It asks the Secret Service for its
-- credentials the moment it launches, and at this point the
-- lockscreen has not been dismissed yet, so the login keyring is
-- still locked and it lands on gcr's unlock prompt instead of
-- loading. Lock.qml starts it after pam accepts the password
-- (pam_gnome_keyring unlocks the keyring in its auth phase). The
-- window rule below still swallows it onto special:music, and the
-- respawn-on-close rule above is unaffected.
-- 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,
})
-- 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 = "monitor_w*0.8 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" }))
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).
-- --clipboard-only: nothing hits the disk, so there is no folder to
-- fill and no file for the notification to thumbnail. The bar draws
-- a camera glyph instead of holding a decoded screen-sized texture.
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region --clipboard-only; qs ipc call screenshot unpin'"))
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only"))
-- 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";
};
};
}