{ config, pkgs, lib, ... }: let palette = import ./palette.nix; in # Per-user Hyprland + QuickShell configuration (home-manager). # This is the Hyprland counterpart to home-manager/modules/desktop-user.nix — # import this INSTEAD of desktop-user.nix when the user runs Hyprland. { imports = [ ./kitty.nix ./firefox.nix ./tools/just.nix ]; home.activation.linkOnlyOfficeFonts = lib.hm.dag.entryAfter [ "writeBoundary" ] '' FONT_DIR="/run/current-system/sw/share/X11/fonts" USER_FONTS="$HOME/.local/share/fonts" NATIVE_TARGET="$HOME/.local/share/onlyoffice/desktopeditors/data/fonts" FLATPAK_TARGET="$HOME/.var/app/org.onlyoffice.desktopeditors/data/onlyoffice/desktopeditors/data/fonts" if [ -d "$FONT_DIR" ]; then for TARGET_DIR in "$USER_FONTS" "$NATIVE_TARGET" "$FLATPAK_TARGET"; do $DRY_RUN_CMD mkdir -p "$TARGET_DIR" $DRY_RUN_CMD rm -f "$TARGET_DIR/AllFonts.js"* "$TARGET_DIR/fonts_thumbnail"* "$TARGET_DIR/font_selection.bin" "$TARGET_DIR/fonts.log" $DRY_RUN_CMD cp -Lf "$FONT_DIR"/*.ttf "$FONT_DIR"/*.otf "$FONT_DIR"/*.ttc "$TARGET_DIR/" 2>/dev/null || true done fi ''; # VS Code (same extensions set as the GNOME desktops). programs.vscode = { enable = true; package = pkgs.vscode; profiles.default = { extensions = with pkgs.vscode-extensions; [ bbenoist.nix ms-python.python dart-code.flutter ] ++ [ (pkgs.vscode-utils.extensionFromVscodeMarketplace { name = "opencode-go-for-copilot"; publisher = "DenizhanDaklr"; version = "0.1.18"; sha256 = "0igdkgv6dra3zpd80lhwymdh216dqn8m8kqwsxh258wyk9fzgfk0"; }) ]; }; }; fonts.fontconfig.enable = true; # XDG Desktop Portal backend config (per-user scope; silences the # xdg-desktop-portal >= 1.17 loader warning in this HM profile). xdg.portal.config.common.default = "hyprland"; home.packages = with pkgs; [ # Wayland user tools (screen recording) wf-recorder # QuickShell sci-fi theme fonts: Orbitron (display/clock) + Nerd Font # symbol glyphs for status icons (wifi/battery/volume). orbitron nerd-fonts.symbols-only ]; # ============================ Hyprland ============================ wayland.windowManager.hyprland = { enable = true; # Hyprland 0.55+ uses Lua configs; hyprlang (.conf) is deprecated and # will be removed in a future release. configType = "lua"; package = null; # use the NixOS-system Hyprland (programs.hyprland) systemd.enable = true; systemd.variables = [ "QT_QPA_PLATFORM" ]; # All configuration lives in extraConfig as raw Lua — this matches the # upstream Hyprland Lua API exactly and is easier to maintain than # fighting Home Manager's settings abstraction for binds / rules. settings = { }; extraConfig = '' -- Auto-detect monitor hl.monitor({ output = "", mode = "preferred", position = "auto", scale = 1 }) -- Environment variables hl.env("XDG_CURRENT_DESKTOP", "Hyprland") hl.env("XDG_SESSION_TYPE", "wayland") hl.env("XDG_SESSION_DESKTOP", "Hyprland") hl.env("QT_QPA_PLATFORM", "wayland;xcb") hl.env("QT_WAYLAND_DISABLE_WINDOWDECORATION", "1") hl.env("XCURSOR_SIZE", "24") hl.env("HYPRCURSOR_SIZE", "24") -- Autostart hl.on("hyprland.start", function() hl.exec_cmd("quickshell") hl.exec_cmd("hyprpaper") -- Re-apply the wallpaper chosen in the SUPER+CTRL+A picker. hyprpaper -- may not have its control socket up yet; qs-wall apply retries. hl.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-wall apply'") hl.exec_cmd("hypridle") -- Launch the user's chosen autostart apps (toggled from the gear -- quick-settings panel; qs-apps guards against double-starts). hl.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-apps run'") end) -- General configuration hl.config({ general = { gaps_in = 6, gaps_out = 12, border_size = 2, col = { active_border = { colors = { "${palette.hyprRgba palette.blue "ee"}", "${palette.hyprRgba palette.neon "ee"}" }, angle = 45 }, inactive_border = "${palette.hyprRgba palette.bgDark "66"}", }, layout = "dwindle", resize_on_border = true, }, decoration = { rounding = 12, blur = { enabled = true, size = 12, passes = 4, vibrancy = 0.2, noise = 0.01, popups = true, }, shadow = { enabled = true, range = 16, render_power = 3, color = "${palette.hyprRgba palette.ink "66"}", scale = 1.0, }, active_opacity = 1.0, inactive_opacity = 0.9, }, animations = { enabled = true, }, input = { kb_layout = "gb", follow_mouse = 1, sensitivity = -0.1, touchpad = { natural_scroll = true, tap_to_click = true, -- int 0..2 in 0.56 (no drag_lock_threshold key); 1 = lifting the -- finger mid-drag keeps the drag alive (laptop text-selection QoL) drag_lock = 1, }, }, misc = { -- no Hyprland logo / anime mascot on empty desktops (hyprpaper -- paints immediately, but this kills the startup flash) disable_hyprland_logo = true, force_default_wallpaper = 0, }, }) -- Scratchpad workspace: in the Lua config, workspace rules are set -- via hl.workspace_rule (the top-level `workspace = ...` keyword from -- hyprlang isn't a parser-level statement in 0.56.2). The -- on_created_empty hook spawns a terminal in the special when it -- opens empty; SUPER+` (toggle_special) reveals/hides it. hl.workspace_rule({ workspace = "special:term", on_created_empty = "kitty", }) -- Animation curves (upstream example set + our custom bezier) hl.curve("myBezier", { type = "bezier", points = { {0.05, 0.9}, {0.1, 1.05} } }) hl.curve("easeOutQuint", { type = "bezier", points = { {0.23, 1}, {0.32, 1} } }) hl.curve("almostLinear", { type = "bezier", points = { {0.5, 0.5}, {0.75, 1} } }) hl.curve("quick", { type = "bezier", points = { {0.15, 0}, {0.1, 1} } }) -- Animations hl.animation({ leaf = "windows", enabled = true, speed = 5, bezier = "myBezier" }) hl.animation({ leaf = "windowsIn", enabled = true, speed = 4.5, bezier = "easeOutQuint", style = "popin 87%" }) hl.animation({ leaf = "windowsOut", enabled = true, speed = 5, bezier = "default", style = "popin 80%" }) hl.animation({ leaf = "fadeIn", enabled = true, speed = 1.73, bezier = "almostLinear" }) hl.animation({ leaf = "fadeOut", enabled = true, speed = 1.46, bezier = "almostLinear" }) hl.animation({ leaf = "fade", enabled = true, speed = 7, bezier = "quick" }) hl.animation({ leaf = "border", enabled = true, speed = 5.39, bezier = "easeOutQuint" }) -- Gradient border: animate the 45° active_border angle on focus change -- (style=once plays per focus switch; loop would burn refresh-rate -- frames continuously and hurt battery on this laptop). hl.animation({ leaf = "borderangle", enabled = true, speed = 3, bezier = "easeOutQuint", style = "once" }) hl.animation({ leaf = "workspaces", enabled = true, speed = 6, bezier = "default", style = "slidefade" }) -- Keybindings local mod = "SUPER" hl.bind(mod .. " + Return", hl.dsp.exec_cmd("kitty")) hl.bind(mod .. " + Q", hl.dsp.window.close()) hl.bind(mod .. " + E", hl.dsp.exec_cmd("thunar")) hl.bind(mod .. " + Space", hl.dsp.exec_cmd("wofi --show drun")) -- Extended launcher modes (qs-launch → wofi): emoji picker, calculator -- (qalc), DuckDuckGo search. Full .local/bin path: ~/.local/bin is not -- on the Hyprland session PATH (same as the qs-wall/qs-apps autostart). hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch emoji'")) hl.bind(mod .. " + SHIFT + C", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch calc'")) hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-launch search'")) hl.bind(mod .. " + F", hl.dsp.window.fullscreen()) -- Float toggle + clamp into the monitor work-area + bring to front. -- The QuickShell bar is a Top layer surface, always drawn above regular -- windows, so a floating window that keeps fullscreen geometry tucks its -- title bar/menus under the bar. Clamping into the reserved work-area -- (below the bar) keeps the whole window visible. hl.bind(mod .. " + V", function() local wasFloating = (hl.get_active_window() or {}).floating == true hl.dispatch(hl.dsp.window.float({ action = "toggle" })) local w = hl.get_active_window() if w ~= nil and w.floating and not wasFloating then local m = w.monitor if m ~= nil then local wa = { x = m.x + m.reserved.left, y = m.y + m.reserved.top, w = m.width - m.reserved.left - m.reserved.right, h = m.height - m.reserved.top - m.reserved.bottom, } local x, y = w.at.x, w.at.y local ww, wh = w.size.x, w.size.y -- keep the window from sticking out below/right of the bar if y + wh > wa.y + wa.h then wh = wa.y + wa.h - y end if x + ww > wa.x + wa.w then ww = wa.x + wa.w - x end -- and never let it ride up under the bar if y < wa.y then y = wa.y if y + wh > wa.y + wa.h then wh = wa.y + wa.h - y end end if ww ~= w.size.x or wh ~= w.size.y then hl.dispatch(hl.dsp.window.resize({ x = ww, y = wh })) end if x ~= w.at.x or y ~= w.at.y then hl.dispatch(hl.dsp.window.move({ x = x, y = y })) end end end hl.dispatch(hl.dsp.window.bring_to_top()) end) hl.bind(mod .. " + P", hl.dsp.window.pseudo()) hl.bind(mod .. " + M", hl.dsp.exit()) 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 .. " + L", hl.dsp.focus({ direction = "right" })) hl.bind(mod .. " + K", hl.dsp.focus({ direction = "up" })) hl.bind(mod .. " + J", hl.dsp.focus({ direction = "down" })) for i = 1, 9 do hl.bind(mod .. " + " .. i, hl.dsp.focus({ workspace = i })) hl.bind(mod .. " + SHIFT + " .. i, hl.dsp.window.move({ workspace = i })) end hl.bind("Print", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot pick'")) hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot area'")) hl.bind(mod .. " + SHIFT + R", hl.dsp.exec_cmd("sh -c '${config.home.homeDirectory}/.local/bin/qs-shot screen'")) hl.bind(mod .. " + L", hl.dsp.exec_cmd("hyprlock")) hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("wlogout")) -- App switching + clipboard history + keybind cheatsheet + notif center + ws overview -- (QuickShell popups via IPC) -- Classic alt-tab window cycling (next) + bring to top, alt-shift-tab backwards. hl.bind("ALT + Tab", function() hl.dispatch(hl.dsp.window.cycle_next()) hl.dispatch(hl.dsp.window.bring_to_top()) end) hl.bind("ALT + SHIFT + Tab", function() hl.dispatch(hl.dsp.window.cycle_next({ direction = "prev" })) hl.dispatch(hl.dsp.window.bring_to_top()) end) hl.bind(mod .. " + Tab", hl.dsp.exec_cmd("wofi --show window")) hl.bind(mod .. " + W", hl.dsp.exec_cmd("qs ipc call ws toggle")) hl.bind(mod .. " + CTRL + V", hl.dsp.exec_cmd("qs ipc call clip toggle")) hl.bind(mod .. " + CTRL + H", hl.dsp.exec_cmd("qs ipc call help toggle")) hl.bind(mod .. " + CTRL + N", hl.dsp.exec_cmd("qs ipc call notif toggle")) hl.bind(mod .. " + CTRL + C", hl.dsp.exec_cmd("qs ipc call cal toggle")) hl.bind(mod .. " + CTRL + A", hl.dsp.exec_cmd("qs ipc call wall toggle")) -- tablet on-screen keyboard toggle (wvkbd; see hyprland-tablet). Not a -- qs IPC call because the keyboard is a Wayland surface, not Quickshell. hl.bind(mod .. " + CTRL + K", hl.dsp.exec_cmd("hyprland-tablet keyboard-toggle")) -- Scratchpad terminal (special workspace that follows you around) hl.bind(mod .. " + grave", hl.dsp.workspace.toggle_special("term")) hl.bind(mod .. " + SHIFT + grave", hl.dsp.window.move({ workspace = "special:term" })) -- Pull the focused window OUT of the scratchpad back to the main -- workspace. Open the scratchpad first (SUPER+grave) so the -- scratchpad's window has focus, then SUPER+CTRL+grave ejects it. hl.bind(mod .. " + CTRL + grave", hl.dsp.window.move({ workspace = "1" })) -- Laptop media keys. locked = also work while hyprlock is up, -- repeating = auto-repeat when held (volume ramps smoothly). hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pamixer -i 5"), { locked = true, repeating = true }) hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pamixer -d 5"), { locked = true, repeating = true }) hl.bind("XF86AudioMute", hl.dsp.exec_cmd("pamixer -t"), { locked = true }) hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("pamixer --default-source -t"), { locked = true }) hl.bind("XF86AudioPlay", hl.dsp.exec_cmd("playerctl play-pause"), { locked = true }) hl.bind("XF86AudioPause", hl.dsp.exec_cmd("playerctl 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 }) -- brightness changes are not observable from QML, so the key command -- itself pokes the QuickShell OSD via IPC after setting the level. -- NB function is named "bright" not "show" — the qs CLI treats "show" -- as its own subcommand token and never reaches the handler. hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("sh -c 'brightnessctl set 5%+ && qs ipc call osd bright'"), { locked = true, repeating = true }) hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("sh -c 'brightnessctl set 5%- && qs ipc call osd bright'"), { locked = true, repeating = true }) -- Resize submap: SUPER+R enters, arrows/HJKL resize the focused -- window, Escape (or any reset) leaves. NOTE: 0.56.2's key parser only -- splits on '+', so one key per hl.bind call (no "left, h" lists). hl.define_submap("resize", function() local function rsz(dx, dy, keys) for _, k in ipairs(keys) do hl.bind(k, hl.dsp.window.resize({ x = dx, y = dy, relative = true })) end end rsz(-20, 0, { "left", "h" }) rsz(20, 0, { "right", "l" }) rsz(0, -20, { "up", "k" }) rsz(0, 20, { "down", "j" }) hl.bind("Escape", hl.dsp.submap("reset")) end) hl.bind(mod .. " + R", hl.dsp.submap("resize")) -- Smart gaps: w[tv1] is a gapless "watch" workspace; parking a window -- there (SUPER+CTRL+F) gives borderless fullscreen-ish video without -- actually fullscreening. f[1] kills gaps under any real fullscreen. hl.workspace_rule({ workspace = "w[tv1]", gaps_out = 0, gaps_in = 0 }) hl.workspace_rule({ workspace = "f[1]", gaps_out = 0, gaps_in = 0 }) hl.window_rule({ name = "no-gaps-wtv1", match = { float = false, workspace = "w[tv1]" }, border_size = 0, rounding = 0 }) hl.window_rule({ name = "no-gaps-f1", match = { float = false, workspace = "f[1]" }, border_size = 0, rounding = 0 }) local gapless = false hl.bind(mod .. " + CTRL + F", function() if gapless then hl.dispatch(hl.dsp.window.move({ workspace = "previous" })) else hl.dispatch(hl.dsp.window.move({ workspace = "w[tv1]" })) end gapless = not gapless end) -- Flash focus: briefly override the border colour on window switch and -- revert via a oneshot timer (hyprfocus as pure Lua, no plugin). -- NB 0.56 set_prop accepts "active_border_color"/"inactive_border_color" -- (NOT "bordercol"/"border_color" — the latter is a rule-effect name -- only). Reverting to the config default colour is visually identical -- to clearing the override, since the inactive border reads a -- separate, un-overridden slot. hl.on("window.active", function(w) if w == nil then return end hl.dispatch(hl.dsp.window.set_prop({ prop = "active_border_color", value = "rgba(00e5ffff)" })) hl.timer(function() hl.dispatch(hl.dsp.window.set_prop({ prop = "active_border_color", value = "rgba(7aa2f7ee)" })) end, { timeout = 180, type = "oneshot" }) end) -- Night light (blue-light filter) toggle: hyprsunset keeps the colour -- temperature property alive while running; killing it restores normal. local nightLight = false hl.bind(mod .. " + SHIFT + N", function() if nightLight then hl.exec_cmd("pkill hyprsunset") else hl.exec_cmd("hyprsunset -t 4500") end nightLight = not nightLight end) -- Mouse binds hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) -- Window rules hl.window_rule({ match = { class = "org.pulseaudio.pavucontrol" }, float = true }) hl.window_rule({ match = { title = "Picture%-in%-Picture" }, float = true }) hl.window_rule({ match = { title = "Firefox %-%- Picture%-in%-Picture" }, float = true }) -- Layer rules (frosted-glass blur for the QuickShell bar) hl.layer_rule({ match = { namespace = "qs%-neon%-bar" }, blur = true }) hl.layer_rule({ match = { namespace = "qs%-neon%-bar" }, ignore_alpha = 0.3 }) ''; }; # ============================ QuickShell ============================ # QuickShell looks for ~/.config/quickshell/shell.qml by default. # The pinned-flake info placeholder (@FLAKEINFO@ in the QML) is resolved at # build time from flake.lock so the stats popup's system/badge chip shows # the nixpkgs rev/date without needing network access at runtime. The # @THEMECOLORS@ placeholder carries the build-time shared palette so the # bar never flashes an unthemed frame before the wallpaper palette applies. xdg.configFile."quickshell/shell.qml".text = builtins.replaceStrings [ "@FLAKEINFO@" "@THEMECOLORS@" ] [ (builtins.toJSON ( let lock = builtins.fromJSON (builtins.readFile ../../flake.lock); nixpkgs = lock.nodes.nixpkgs.locked; in { rev = nixpkgs.rev; lastModified = nixpkgs.lastModified; repo = nixpkgs.repo; owner = nixpkgs.owner; } )) (builtins.toJSON { glass = palette.hash palette.glass; glassPanel = palette.hash palette.glassPanel; surface = palette.hash palette.surface; line = palette.hash palette.line; text = palette.hash palette.text; muted = palette.hash palette.muted; ink = palette.hash palette.ink; neon = palette.hash palette.neon; violet = palette.hash palette.violet; magenta = palette.hash palette.magenta; danger = palette.hash palette.danger; }) ] (builtins.readFile ./quickshell-shell.qml); # Same-directory sibling component used by shell.qml (implicit QML import). xdg.configFile."quickshell/SciSlider.qml".text = builtins.readFile ./quickshell-slider.qml; # Keybind cheatsheet data rendered by the SUPER+CTRL+H help popup. Keep in # sync with the hl.bind() list in extraConfig above. xdg.configFile."quickshell/keybinds.json".text = builtins.toJSON [ { cat = "SESSION"; items = [ { keys = "SUPER + RETURN"; desc = "terminal (kitty)"; } { keys = "SUPER + SPACE"; desc = "app launcher (wofi)"; } { keys = "SUPER + SHIFT + E"; desc = "emoji picker"; } { keys = "SUPER + SHIFT + C"; desc = "calculator (qalc)"; } { keys = "SUPER + SHIFT + D"; desc = "DuckDuckGo search"; } { keys = "Print"; desc = "screenshot (area or full screen)"; } { keys = "SUPER + SHIFT + S"; desc = "screenshot (area)"; } { keys = "SUPER + SHIFT + R"; desc = "screenshot (full screen)"; } { keys = "SUPER + E"; desc = "file manager (thunar)"; } { keys = "ALT + TAB"; desc = "switch between windows (hold ALT, keep pressing TAB)"; } { keys = "ALT + SHIFT + TAB"; desc = "switch backwards through windows"; } { keys = "SUPER + TAB"; desc = "window switcher (list)"; } { keys = "SUPER + GRAVE"; desc = "scratchpad terminal (kitty auto-spawns); toggle"; } { keys = "SUPER + SHIFT + GRAVE"; desc = "send window to scratchpad"; } { keys = "SUPER + CTRL + GRAVE"; desc = "pull window from scratchpad to main workspace (open scratchpad first)"; } { keys = "SUPER + L"; desc = "lock screen"; } { keys = "SUPER + M"; desc = "exit hyprland"; } { keys = "SUPER + SHIFT + Q"; desc = "power / logout menu"; } ]; } { cat = "WINDOWS"; items = [ { keys = "SUPER + Q"; desc = "close window"; } { keys = "SUPER + ARROWS / H K L J"; desc = "focus neighbouring window"; } { keys = "SUPER + F"; desc = "fullscreen"; } { keys = "SUPER + V"; desc = "toggle floating"; } { keys = "SUPER + P"; desc = "pseudotile"; } { keys = "SUPER + R"; desc = "resize mode (arrows/HJKL, esc exits)"; } { keys = "SUPER + LEFT DRAG"; desc = "move window"; } { keys = "SUPER + RIGHT DRAG"; desc = "resize window"; } { keys = "SUPER + CTRL + F"; desc = "watch mode: gapless media workspace / return"; } ]; } { cat = "WORKSPACES"; items = [ { keys = "SUPER + 1..9"; desc = "switch workspace"; } { keys = "SUPER + SHIFT + 1..9"; desc = "move window to workspace"; } ]; } { cat = "MEDIA & SYSTEM"; items = [ { keys = "PRINT / SUPER + SHIFT + S"; desc = "screenshot area to clipboard"; } { keys = "SUPER + SHIFT + R"; desc = "screenshot screen to clipboard"; } { keys = "VOL UP / VOL DOWN / MUTE"; desc = "volume (neon OSD, works locked)"; } { keys = "MIC MUTE"; desc = "toggle microphone"; } { keys = "PLAY / NEXT / PREV"; desc = "media playback (MPRIS)"; } { keys = "BRIGHT UP / DOWN"; desc = "backlight (neon OSD)"; } { keys = "SUPER + SHIFT + N"; desc = "night light (blue-light filter)"; } ]; } { cat = "INTERFACE"; items = [ { keys = "SUPER + CTRL + V"; desc = "clipboard history (pick copies + pastes)"; } { keys = "SUPER + CTRL + H"; desc = "this cheatsheet"; } { keys = "SUPER + CTRL + N"; desc = "notification center (history, DND, clear)"; } { keys = "SUPER + CTRL + C"; desc = "calendar + weather (Nextcloud events, Open-Meteo)"; } { keys = "SUPER + W"; desc = "workspace overview (jump to any workspace)"; } { keys = "SUPER + CTRL + A"; desc = "wallpaper picker (arrows preview live, click to apply)"; } { keys = "SUPER + CTRL + K"; desc = "tablet on-screen keyboard toggle (wvkbd)"; } { keys = "GEAR button"; desc = "quick settings: volume, brightness, profile, keep-awake, power"; } { keys = "SPEAKER icon"; desc = "volume slider popup"; } { keys = "WI-FI button"; desc = "network menu"; } { keys = "BLUETOOTH button"; desc = "bluetooth devices (power, pair, connect, scan)"; } { keys = "BELL button"; desc = "notification center; MUTE icon means DND is on"; } { keys = "COFFEE icon"; desc = "keep-awake is on (no lock/suspend)"; } ]; } ]; # Clipboard history: a systemd user service tees every clipboard change # into cliphist; the QuickShell bar renders the picker (SUPER+CTRL+V via # `qs ipc call clip toggle`). Defaults to hyprland-session.target via # wayland.systemd.target (systemd.enable above). services.cliphist.enable = true; # ============================ Desktop bits (Wayland) ============================ # GTK theme + cursors (matching the GNOME hosts for consistency) gtk = { enable = true; gtk4.theme = config.gtk.theme; theme = { name = "catppuccin-frappe-blue-standard"; package = pkgs.catppuccin-gtk; }; iconTheme = { name = "Papirus-Dark"; package = pkgs.papirus-icon-theme; }; cursorTheme = { name = "Bibata-Modern-Classic"; package = pkgs.bibata-cursors; }; }; home.pointerCursor = { enable = true; name = "Bibata-Modern-Classic"; package = pkgs.bibata-cursors; gtk.enable = true; x11.enable = true; }; # Wallpaper for hyprpaper (Scifi.jpg = user-provided sci-fi artwork; # tokyo-night and neon-horizon kept as fallback assets). home.file."Pictures/wallpapers/tokyo-night.png".source = ../../assets/wallpapers/tokyo-night.png; home.file."Pictures/wallpapers/neon-horizon.png".source = ../../assets/wallpapers/neon-horizon.png; home.file."Pictures/wallpapers/Scifi.jpg".source = ../../assets/wallpapers/Scifi.jpg; # hyprpaper >= 0.8 uses a hyprlang special-category block per wallpaper # (`wallpaper { monitor = ... }`); the legacy `preload/wallpaper = ,img` # key-value lines are silently ignored ("monitor has no target"). `*` is the # wildcard monitor name. Note: `preload` is no longer a config keyword. xdg.configFile."hypr/hyprpaper.conf".text = '' ipc = 1 splash = 0 wallpaper { monitor = * path = ${config.home.homeDirectory}/Pictures/wallpapers/Scifi.jpg fit_mode = cover } ''; # Wallpaper picker (SUPER+CTRL+A): backend script + PATH wrapper. The state # file (not hyprpaper.conf) records the user's choice; the login autostart # above re-applies it after hyprpaper starts. xdg.configFile."quickshell/qs-wall.py".text = builtins.readFile ./quickshell-wall.py; home.file.".local/bin/qs-wall" = { executable = true; text = '' #!/bin/sh exec ${pkgs.python3}/bin/python3 \ ${config.home.homeDirectory}/.config/quickshell/qs-wall.py "$@" ''; }; # Bluetooth manager (BT status-bar button): backend script + PATH wrapper. # $BLUECTL points at the bluez binary so the wrapper works regardless of PATH; # `hardware.bluetooth` is enabled in modules/desktop/gui.nix. xdg.configFile."quickshell/qs-bt.py".text = builtins.readFile ./quickshell-bt.py; home.file.".local/bin/qs-bt" = { executable = true; text = '' #!/bin/sh export BLUECTL=${pkgs.bluez}/bin/bluetoothctl exec ${pkgs.python3}/bin/python3 \ ${config.home.homeDirectory}/.config/quickshell/qs-bt.py "$@" ''; }; # System-stats probe (stats status-bar chip + popup): backend script + PATH # wrapper. The bar polls it slowly (15s) to stay battery-cheap (~250ms idle # CPU sample); the stats popup refreshes every 3s while open. Reads /proc + # /sys (kernel hwmon zones), so no lm_sensors runtime dependency. xdg.configFile."quickshell/qs-stats.py".text = builtins.readFile ./quickshell-stats.py; home.file.".local/bin/qs-stats" = { executable = true; text = '' #!/bin/sh exec ${pkgs.python3}/bin/python3 \ ${config.home.homeDirectory}/.config/quickshell/qs-stats.py "$@" ''; }; # Screenshot capture (grimblast area/full screen) + QuickShell toast preview # with Open / Copy actions: backend script + PATH wrapper. Binary paths # injected via QS_* env vars (same pattern as qs-launch). "pick" shows a # wofi chooser (select area / full screen), area/screen capture directly. xdg.configFile."quickshell/qs-shot.py".text = builtins.readFile ./quickshell-shot.py; home.file.".local/bin/qs-shot" = { executable = true; text = '' #!/bin/sh export QS_GRIMBLAST=${pkgs.grimblast}/bin/grimblast export QS_WOFI=${pkgs.wofi}/bin/wofi export QS_NOTIFY=${pkgs.libnotify}/bin/notify-send export QS_WLCOPY=${pkgs.wl-clipboard}/bin/wl-copy export QS_XDGO=${pkgs.xdg-utils}/bin/xdg-open exec ${pkgs.python3}/bin/python3 \ ${config.home.homeDirectory}/.config/quickshell/qs-shot.py "$@" ''; }; # Extended launcher (emoji / calculator / DuckDuckGo search via wofi): # backend script + PATH wrapper. Binary paths injected via QS_* env vars so # the modes work regardless of PATH (same pattern as qs-bt's $BLUECTL). xdg.configFile."quickshell/qs-launch.py".text = builtins.readFile ./quickshell-launch.py; home.file.".local/bin/qs-launch" = { executable = true; text = '' #!/bin/sh export QS_WOFI=${pkgs.wofi}/bin/wofi export QS_QALC=${pkgs.libqalculate}/bin/qalc export QS_WLCOPY=${pkgs.wl-clipboard}/bin/wl-copy export QS_NOTIFY=${pkgs.libnotify}/bin/notify-send export QS_XDGO=${pkgs.xdg-utils}/bin/xdg-open exec ${pkgs.python3}/bin/python3 \ ${config.home.homeDirectory}/.config/quickshell/qs-launch.py "$@" ''; }; # hyprlock media probe: prints MPRIS artist/title via playerctl, or nothing # (empty label text = label hidden). playerctl's {{...}} format braces must # live in a script — hyprlock parses cmd text as math expressions and chokes # on a bare {{ }} (hyprwm/hyprlock#894). Colors from palette. home.file.".local/bin/hyprlock-media" = { executable = true; text = '' #!/bin/sh set -e title=$(${pkgs.playerctl}/bin/playerctl metadata --format '{{ title }}' 2>/dev/null) || exit 0 artist=$(${pkgs.playerctl}/bin/playerctl metadata --format '{{ artist }}' 2>/dev/null || true) printf ' %s — %s\n' \ "${palette.hash palette.violet}" "$artist" "$title" ''; }; # hyprlock battery probe: prints capacity + status, or nothing on machines # without a battery (empty label text = label hidden). home.file.".local/bin/hyprlock-battery" = { executable = true; text = '' #!/bin/sh set -e cap=$(cat /sys/class/power_supply/BAT0/capacity 2>/dev/null) || exit 0 st=$(cat /sys/class/power_supply/BAT0/status 2>/dev/null) case "$st" in Charging) col=${palette.hash palette.neon} ;; *) col=${palette.hash palette.muted} ;; esac [ "$cap" -le 15 ] && col=${palette.hash palette.danger} printf '%s%% %s\n' "$col" "$cap" "$st" ''; }; # hyprlock — matches the QuickShell sci-fi theme: blurred Scifi.jpg # background, cyan-accented password field, Orbitron clock. xdg.configFile."hypr/hyprlock.conf".text = '' general { ignore_empty_input = true } background { monitor = path = ${config.home.homeDirectory}/Pictures/wallpapers/Scifi.jpg blur_size = 6 blur_passes = 3 noise = 0.01 vibrancy = 0.2 brightness = 0.35 } input-field { monitor = size = 320, 80 outline_thickness = 2 dots_size = 0.15 dots_spacing = 0.6 dots_center = true outer_color = ${palette.lockRgba palette.neon "1.0"} inner_color = ${palette.lockRgba palette.panel "0.9"} font_color = ${palette.lockRgba palette.danger "1.0"} fade_on_empty = false placeholder_text = password // halign = center valign = center position = 0, -15% } label { monitor = text = $TIME color = ${palette.lockRgba palette.neon "1.0"} font_size = 64 font_family = Orbitron halign = center valign = center position = 0, 12% } label { monitor = text = // SYSTEM LOCKED // color = ${palette.lockRgba palette.muted "1.0"} font_size = 14 font_family = Orbitron halign = center valign = center position = 0, 4% } label { monitor = text = cmd[] ${config.home.homeDirectory}/.local/bin/hyprlock-media color = ${palette.lockRgba palette.muted "1.0"} font_size = 16 font_family = Orbitron halign = center valign = center position = 0, 2% } label { monitor = text = cmd[] ${config.home.homeDirectory}/.local/bin/hyprlock-battery color = ${palette.lockRgba palette.muted "1.0"} font_size = 14 font_family = Orbitron halign = center valign = bottom position = 0, 6% } ''; # hypridle — lock after 5 min idle, dim screen before locking xdg.configFile."hypr/hypridle.conf".text = '' general { lock_cmd = pidof hyprlock || hyprlock before_sleep_cmd = loginctl lock-session # Under the Lua config, hyprctl dispatch evals the argument as Lua, # so the legacy "dpms on" syntax errors out — use hl.dsp.dpms({on=true}) after_sleep_cmd = hyprctl dispatch "hl.dsp.dpms({on=true})" } listener { timeout = 300 on-timeout = loginctl lock-session } listener { timeout = 330 on-timeout = hyprctl dispatch "hl.dsp.dpms({off=true})" on-resume = hyprctl dispatch "hl.dsp.dpms({on=true})" } # Suspend after 15 min idle (lock at 5 min). `sudo systemctl` because # polkit does not recognise the greetd session as "active" — same reason # the networkmanager group membership is needed (see desktop/hyprland.nix). listener { timeout = 900 on-timeout = sudo systemctl suspend on-resume = hyprctl dispatch "hl.dsp.dpms({on=true})" } ''; # wofi — app launcher (sci-fi dark glass + neon, matches QuickShell theme). # No `style=` line: wofi auto-loads $XDG_CONFIG_HOME/wofi/style.css, and its # `style=` option does NOT expand `~` (loads nothing → unstyled black box). xdg.configFile."wofi/config".text = '' width=35% height=45% location=center opacity=90 hide_scroll=true allow_images=true image_size=32 key_expand=Right ''; xdg.configFile."wofi/style.css".text = '' window { background-color: ${palette.cssRgba palette.panel "0.92"}; border: 2px solid ${palette.hash palette.neon}; border-radius: 14px; } #input { background-color: ${palette.cssRgba palette.ink "0.9"}; color: ${palette.hash palette.text}; border: 1px solid ${palette.hash palette.line}; border-radius: 10px; margin: 10px; padding: 8px; } #input:focus { border: 1px solid ${palette.hash palette.neon}; } #outer-box { margin: 0; } #inner-box { margin: 0 6px; } * { font-family: "FiraCode Nerd Font", monospace; font-size: 14px; } #entry { padding: 8px 10px; border-radius: 8px; } #entry:selected { background-color: ${palette.cssRgba palette.neon "0.15"}; border: 1px solid ${palette.hash palette.neon}; border-radius: 8px; } #entry image { margin-right: 10px; min-width: 32px; min-height: 32px; } #entry label { color: ${palette.hash palette.muted}; } #entry:selected label { color: ${palette.hash palette.neon}; } #entry list { margin-top: 4px; } #entry list row { padding: 4px 8px; border-radius: 6px; } #entry list row:selected { background-color: ${palette.cssRgba palette.neon "0.25"}; } #scroll { margin: 4px; } ''; }