{
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;
}
'';
}