diff options
Diffstat (limited to 'working/hyprland-lua-port/hyprland.lua')
| -rw-r--r-- | working/hyprland-lua-port/hyprland.lua | 812 |
1 files changed, 812 insertions, 0 deletions
diff --git a/working/hyprland-lua-port/hyprland.lua b/working/hyprland-lua-port/hyprland.lua new file mode 100644 index 0000000..c005193 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua @@ -0,0 +1,812 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ + +-- hyprlang2lua polyfills — runtime helpers reproducing +-- hyprlang behaviour the typed Lua API doesn't expose directly. + +local function hl_source_glob(pattern) + -- 'source = path/*.conf' had hyprlang glob and inline-expand the + -- matches. require() can't glob, so we shell out to ls (matching + -- the user's brace-expansion behaviour) and dofile each result. + -- Paths with spaces or shell metacharacters in the directory + -- portion will misparse; typical ~/.config/hypr/ layouts don't + -- hit this. Swap to lfs.dir() or find -name if you need fancier. + -- + -- Both failure paths are surfaced loudly and neither is fatal. hyprlang did + -- neither, and the asymmetry matters in opposite directions: a syntax error + -- that only warns means velox comes up with no force_zero_scaling and no + -- monitor scale, which looks like the port failed rather than like one file + -- failed; and an uncaught runtime error inside the chunk would take the + -- entire config down over a single host override. So: report both, continue + -- past both. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + local matched = 0 + for f in p:lines() do + matched = matched + 1 + local chunk, err = loadfile(f) + if not chunk then + io.stderr:write("hl_source_glob: SKIPPED " .. f .. + " -- it did not parse, so nothing in it applied: " .. + tostring(err) .. "\n") + else + local ok, rerr = pcall(chunk) + if not ok then + io.stderr:write("hl_source_glob: PARTIAL " .. f .. + " -- it errored partway, so some of it applied " .. + "and the rest did not: " .. tostring(rerr) .. "\n") + end + end + end + p:close() + if matched == 0 then + io.stderr:write("hl_source_glob: NO MATCH for " .. pattern .. + " -- every host override is missing. On a machine that " .. + "has a conf.d file this means the glob is wrong.\n") + end +end + +-- Autostart collectors. Each command stays under the comment that explains it, +-- in the order hyprlang ran them; the hl.on() handlers at the bottom replay the +-- lists. Written this way because the generated form hoisted every command into +-- one block at the end of the file and left the comments stranded where the +-- commands had been -- in this config that means paragraphs of rationale with +-- no code under them, and one cross-reference ("the two exec-once lines above") +-- that had become false. +local autostart, atshutdown, atreload = {}, {}, {} +local function at_start(cmd) autostart[#autostart + 1] = cmd end +local function at_shutdown(cmd) atshutdown[#atshutdown + 1] = cmd end +local function at_reload(cmd) atreload[#atreload + 1] = cmd end + +hl.monitor({ + output = "", + mode = "preferred", + position = "auto", + scale = "auto", +}) + +-- Waybar's strip (6px top margin + 54px bar) is reserved statically by +-- waybar-reserve, and waybar runs with "exclusive": false. The bar's own +-- exclusive zone would vanish and reappear on every SIGUSR2 reload (the +-- collapse mechanism) and on hide/crash/relaunch, snapping every tiled window +-- up and back down. The static reservation holds the clients in place; only +-- the bar itself changes. `exec` (not exec-once) reruns it on every config +-- reload, which is exactly when Hyprland resets dynamic reservations. The +-- script is idempotent, and a catch-all `monitor=,addreserved,...` rule can't +-- replace it (empty-name addreserved silently no-ops). +-- +-- Run three times over ~0.6s, not once: on reload Hyprland clears the +-- reservation AND re-fires this exec, and the two race. A single run that +-- fires before the clear no-ops (reserved still looks correct), the clear then +-- wins, and the non-exclusive bar drops off-screen. Re-applying past the clear +-- window makes the restore reliable; the script is idempotent so extra runs are +-- free. Applying a monitor rule (e.g. the DP-4 pin) also clears the reservation, +-- so this covers a reload that re-asserts monitors too. +at_reload("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +at_start("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") +-- Start hyprland-session.target FIRST: it pulls up graphical-session.target, +-- which xdg-desktop-portal 1.22+ hard-requires (Requisite=). A bare-exec Hyprland +-- session has no session manager to raise that target, so without this the portal +-- fails its dependency at every login (screen-share + file pickers dead). +-- 'systemctl start' blocks until active, so the ';' sequence guarantees the target +-- is up before the portal restart runs. +-- +-- Portal restart (not start) reconnects stale portals on Hyprland restart. +-- Backend portals (GTK, Hyprland) restart BEFORE the main portal to avoid a 50s +-- GTK settings proxy timeout; the sequence keeps that ordering. Separated by ';' +-- not '&&' so a failing portal restart can't stop waybar from launching — waybar +-- degrades gracefully without the portal (only the appearance query is missed), +-- and gating the bar behind the portal left the desktop bar-less whenever +-- xdg-desktop-portal failed its dependency at login. Waybar stays gated on its +-- own config generation (waybar-active-config && waybar). +at_start("systemctl --user start hyprland-session.target; systemctl --user restart xdg-desktop-portal-hyprland xdg-desktop-portal-gtk; systemctl --user restart xdg-desktop-portal; waybar-active-config && waybar -c \"$XDG_RUNTIME_DIR/waybar/config\" -s ~/.config/waybar/style.css 2>&1 | grep -v \"LIBDBUSMENU-GLIB-WARNING\" > ~/.local/var/log/waybar-$(date +%Y-%m-%d-%H%M%S).log") + +-- Core services +at_start("/usr/lib/polkit-kde-authentication-agent-1") +at_start("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") +at_start("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Desktop appearance +-- `settings restore` replays the remembered toggles and reapplies the stored +-- wallpaper. It replaced `waypaper --restore` on 2026-08-14: waypaper keeps +-- its own config.ini and the settings store keeps another, neither knew about +-- the other, and the login replay always won — so a wallpaper chosen in the +-- panel came back as whatever the shell had last set. The store is the only +-- one of the two that can hold a sun pair, a video or a projected face, so it +-- owns the restore. set-wallpaper records into it for choices made outside +-- the panel. +-- +-- waypaper --restore stays as the fallback, not the owner. If the stored +-- wallpaper cannot be applied (an image deleted, a drive not mounted yet), +-- `settings restore` exits 3 and waypaper's independent copy still puts +-- something on the screen. Dropping it outright would trade this bug for a +-- bare desktop. +-- +-- The wallpaper half only. The toggle half runs from its own exec-once further +-- down, after hypridle and dunst — caffeine *is* "hypridle isn't running" and +-- DND *is* dunst's pause level, so replaying them here would spend the whole +-- re-assert budget correcting backings that have not launched yet, and would +-- replay them a second time besides. This slot exists for awww's timing, not +-- theirs. +at_start("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + +-- Background services +at_start("touchpad-auto") +-- hypridle is reaped on both exit paths, because it outlives its compositor +-- otherwise. An orphaned daemon keeps firing idle actions at whatever session +-- is live next, and it holds its old logind session scope open (the scope can't +-- close while a process sits in it), so orphans accumulate one per abnormal +-- session death. On 2026-07-22 velox reached five concurrent hypridle daemons; +-- two of them racing to lock produced "Cannot re-lock" and a session wedged +-- locked with no client able to draw a password prompt — recoverable only from +-- another console. exec-shutdown covers a clean compositor exit; the pkill in +-- exec-once covers the paths where it never runs (crash, SIGKILL, TTY logout). +at_shutdown("pkill -x hypridle") +-- hypridle.conf is rendered here rather than tracked, because its contents +-- are this machine's stage times and hibernate setting. Tracking the render +-- meant every panel change dirtied the repo, and whichever machine +-- committed last imposed its policy on the others: a desktop ended up +-- carrying a laptop's suspend-then-hibernate line that it cannot run. +-- Rendering at session start makes the store the only source of truth and +-- the file a build artifact. +-- +-- hypridle-start owns the render, the fallback, and the ordering between +-- them, because that ordering is subtle enough to get wrong in a config +-- line nothing can test: a render can fail on purpose (a damaged store, to +-- avoid overwriting a real policy with defaults), and a fallback that +-- fired there would perform exactly the overwrite the render refused. +at_start("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("/usr/lib/geoclue-2.0/demos/agent") +at_start("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("mpd") +-- Replay the toggles that have no durable state of their own. Caffeine *is* +-- "hypridle isn't running" and DND *is* dunst's pause level, so the two +-- exec-once lines above (and dunst's) recreate both at a fixed default every +-- start — a deliberately-set caffeine was silently discarded on every login. +-- Ordered after those launches so it corrects a backing that exists; it also +-- re-asserts for a few seconds, which covers a backing that comes up late. +-- Logged like its neighbours: a silent exec-once failure here would look +-- exactly like the bug it fixes, and gammastep is the standing proof that a +-- launch dying quietly at session start can go unnoticed for a long time. +at_start("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + +-- Pyprland (scratchpads, magnify, etc.) +at_start("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") +at_start("hypr-refocus-scratchpad") + +-- Tray apps. wait-for-tray blocks until waybar's systray host is up (a fixed +-- sleep can't cover a slow cold-start waybar), so these register their icons +-- instead of opening as windows. Caps at ~30s, then launches anyway. +at_start("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) +at_start("env QT_FONT_DPI=108 protonmail-bridge --no-window") + +-- ============================================================================ +-- Environment Variables +-- ============================================================================ +hl.env("XCURSOR_SIZE", "24") +hl.env("XCURSOR_THEME", "Bibata-Modern-Ice") +hl.env("XDG_CURRENT_DESKTOP", "Hyprland") +hl.env("XDG_SESSION_TYPE", "wayland") +hl.env("XDG_SESSION_DESKTOP", "Hyprland") +hl.env("_JAVA_AWT_WM_NONREPARENTING", "1") + +-- ============================================================================ +-- Appearance (matching DWM colors) +-- ============================================================================ +-- DWM colors: gray1=#222222, gray2=#444444, gray3=#bbbbbb, gray4=#eeeeee, cyan=#daa520 + +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + -- Pyprland 3.4+ applies `group deny` to scratchpads, which routes + -- their border through col.nogroup_border* instead of col.*_border. + -- Without these overrides Hyprland's defaults paint scratchpads + -- bright magenta. + nogroup_border_active = "rgba(daa520ff)", + nogroup_border = "rgba(444444ff)", + }, + layout = "master", + resize_on_border = true, + }, +}) + +hl.config({ + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, +}) + +hl.config({ + animations = { + enabled = true, + }, +}) + +hl.curve("myBezier", { type = "bezier", points = { { 0.05, 0.9 }, { 0.1, 1.05 } } }) +hl.animation({ + leaf = "windows", + enabled = true, + speed = 2, + bezier = "myBezier", +}) +hl.animation({ + leaf = "windowsOut", + enabled = true, + speed = 2, + bezier = "default", + style = "popin 80%", +}) +hl.animation({ + leaf = "fade", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "workspaces", + enabled = true, + speed = 2, + bezier = "default", +}) +hl.animation({ + leaf = "specialWorkspace", + enabled = true, + speed = 2, + bezier = "default", + style = "slidevert", +}) + +-- ============================================================================ +-- Layout (master-stack like DWM tile) +-- ============================================================================ + +hl.config({ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, +}) + +hl.config({ + dwindle = { + preserve_split = true, + }, +}) + +-- ============================================================================ +-- Input +-- ============================================================================ + +hl.config({ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, +}) + +hl.config({ + input = { + kb_layout = "us", + kb_options = "ctrl:nocaps", + numlock_by_default = true, + follow_mouse = 0, + -- 0, not the default 1: with follow_mouse off we never want focus to follow + -- the cursor. At 1, focus still jumps to the window under the pointer when it + -- crosses a floating<->tiled boundary, so launching a floating scratchpad (or + -- the org-capture popup) re-enabled focus-follows-mouse onto tiled windows. + float_switch_override_focus = 0, + mouse_refocus = false, + natural_scroll = true, + touchpad = { + natural_scroll = false, + }, + }, +}) + +-- ============================================================================ +-- Misc +-- ============================================================================ + +hl.config({ + misc = { + force_default_wallpaper = 0, + disable_hyprland_logo = true, + -- false so apps can't pull focus via activation requests. New windows still + -- focus on open (separate path); this stops e.g. a browser yanking focus + -- back off a freshly opened emacs frame. + focus_on_activate = false, + -- Let a fresh lock client adopt a session whose previous one died. The + -- default (off) is the strict reading of ext-session-lock: a dead lock + -- client leaves the session locked forever and refuses every replacement + -- ("Cannot re-lock"), so the screen stays up with nothing able to draw a + -- password prompt and the only way back in is another console. That is a + -- hard lockout, and it cost a session on velox 2026-07-22. On means a + -- replacement hyprlock re-attaches and prompts normally. The screen stays + -- locked either way — this decides whether the lock is recoverable, never + -- whether it holds. + allow_session_lock_restore = true, + }, +}) + +-- ============================================================================ +-- Debug (temporary - disable when stable) +-- ============================================================================ + +hl.config({ + debug = { + disable_logs = false, + }, +}) + +-- ============================================================================ +-- XWayland +-- ============================================================================ + +hl.config({ + xwayland = { + force_zero_scaling = true, + }, +}) + +-- ============================================================================ +-- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) +-- ============================================================================ +-- Floating windows (from DWM rules) +hl.window_rule({ + match = { + class = "^(xdg-desktop-portal-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(Gimp)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(caffeine)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(qalculate-gtk)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(Event Tester)$", + }, + float = true, +}) + +-- net / bluetooth instrument-console panels. Normal floating windows (formerly +-- gtk4-layer-shell overlays) so they drag to move and corner-drag to resize. +-- Opened top-right to match their old anchored spot: the panel is right-aligned +-- with a 44px gap, so x = 100% - (window width + 44). net is 420 wide, bt 380. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + move = "100%-464 50", +}) + +-- maintenance console: the wide board (960), same right-aligned convention. +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + move = "100%-1004 50", +}) + +-- org-capture popup frame (quick-capture script names the frame) +-- Size is per-host in <host>/conf.d/local.lua: native window rules ignore +-- percentages (only pyprland honors them), so the popup is sized in absolute +-- pixels matching that host's terminal scratchpad. No size rule here means a +-- host without an override falls back to the script's char-cell geometry. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + center = true, +}) + +-- dirvish popup frame (dirvish-popup script names the frame). No stay_focused — +-- it's a file manager that launches files into other apps, so focus must be free +-- to follow; q (cj/dirvish-popup-quit) closes the frame. +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + size = "1100 700", +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + center = true, +}) + +-- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion +-- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + +-- Gaming +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +-- ============================================================================ +-- Key Bindings +-- ============================================================================ +local mod = "SUPER" + +-- Terminal and core apps (from DWM) +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +-- Standalone emacs: its own process, not a frame on the daemon, so killing it +-- takes no other frame with it. init.el guards server-start on server-running-p, +-- so while the daemon holds the socket this process leaves it alone. With no +-- daemon up it finds no server and becomes one -- the guard lives in init.el, and +-- a keybind can't override it, since --eval runs after init. +hl.bind(mod .. " + SHIFT + E", hl.dsp.exec_cmd("emacs")) +hl.bind(mod .. " + N", hl.dsp.exec_cmd("quick-capture")) +hl.bind(mod .. " + W", hl.dsp.exec_cmd("$BROWSER")) +hl.bind(mod .. " + F", hl.dsp.exec_cmd("dirvish-popup")) +hl.bind(mod .. " + SHIFT + F", hl.dsp.exec_cmd("layout-cycle float-toggle")) + +-- From sxhkdrc +hl.bind(mod .. " + SPACE", hl.dsp.exec_cmd("fuzzel-toggle")) +hl.bind(mod .. " + SHIFT + W", hl.dsp.exec_cmd("$ALTBROWSER")) +hl.bind(mod .. " + P", hl.dsp.exec_cmd("media-toggle-all")) +hl.bind(mod .. " + SHIFT + L", hl.dsp.exec_cmd("calibre")) +hl.bind(mod .. " + SHIFT + P", hl.dsp.exec_cmd("toggle-touchpad")) + +-- Window management (from DWM) +-- Layout-aware navigation (works across master, scrolling) +hl.bind(mod .. " + J", hl.dsp.exec_cmd("layout-navigate next")) +hl.bind(mod .. " + K", hl.dsp.exec_cmd("layout-navigate prev")) +hl.bind(mod .. " + SHIFT + J", hl.dsp.exec_cmd("layout-navigate next move")) +hl.bind(mod .. " + SHIFT + K", hl.dsp.exec_cmd("layout-navigate prev move")) +hl.bind(mod .. " + H", hl.dsp.exec_cmd("layout-resize shrink")) +hl.bind(mod .. " + L", hl.dsp.exec_cmd("layout-resize grow")) +-- Swap focused window with master, then force focus onto the master slot. +-- swapwithmaster's own `master` focus param doesn't stick when invoked from +-- the master, so focusmaster master pins focus afterward. The 50ms sleep is +-- load-bearing: swapwithmaster fires an async focus event ~1-2ms after it +-- returns; without the delay that event lands AFTER focusmaster and flips +-- focus back to the detail. The sleep lets the swap's focus settle so +-- focusmaster runs last and wins. Proven via instrumented capture (19/19). +hl.bind(mod .. " + RETURN", hl.dsp.exec_cmd("hyprctl dispatch layoutmsg swapwithmaster && sleep 0.05 && hyprctl dispatch layoutmsg focusmaster master")) +hl.bind(mod .. " + G", hl.dsp.window.center()) +hl.bind(mod .. " + TAB", hl.dsp.focus({ workspace = "previous" })) +hl.bind(mod .. " + SHIFT + C", hl.dsp.window.close()) + +-- Layouts: master -> monocle +-- Cycle with Shift+arrows, or jump directly with Shift+T/M +-- (scrolling layout disabled until frame-fit + wrap-around work lands) +hl.bind(mod .. " + SHIFT + RIGHT", hl.dsp.exec_cmd("layout-cycle next")) +hl.bind(mod .. " + SHIFT + LEFT", hl.dsp.exec_cmd("layout-cycle prev")) +hl.bind(mod .. " + SHIFT + T", hl.dsp.exec_cmd("hyprctl keyword general:layout master && hyprctl keyword master:orientation left")) +hl.bind(mod .. " + SHIFT + M", hl.dsp.exec_cmd("hyprctl keyword general:layout monocle")) +hl.bind(mod .. " + SHIFT + SPACE", hl.dsp.window.float({ action = "toggle" })) + +-- Master layout adjustments +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +-- Stash windows (hide to special workspace) +-- O = stash focused / Alt+O = stash others / Shift+O = restore all +hl.bind(mod .. " + O", hl.dsp.exec_cmd("stash-window")) +hl.bind(mod .. " + ALT + O", hl.dsp.exec_cmd("stash-others")) +hl.bind(mod .. " + SHIFT + O", hl.dsp.exec_cmd("stash-restore")) + +-- Gaps between windows only; window-gaps leaves the monitor-edge gap +-- (general:gaps_out) fixed, so widening/narrowing moves the space between +-- windows, not the screen-edge margin. +hl.bind(mod .. " + MINUS", hl.dsp.exec_cmd("window-gaps narrow")) +hl.bind(mod .. " + EQUAL", hl.dsp.exec_cmd("window-gaps widen")) +hl.bind(mod .. " + SHIFT + EQUAL", hl.dsp.exec_cmd("window-gaps reset")) +hl.bind(mod .. " + SHIFT + MINUS", hl.dsp.exec_cmd("window-gaps zero")) + +-- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +-- Caffeine (keep-awake) toggle. Same action as clicking the waybar +-- custom/caffeine icon — flips the hypridle daemon so the screen will / won't +-- lock. Stays on $mod+I ($mod+C is taken by hyprpicker; no free caffeine key). +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +-- Airplane mode (low-power: wifi off + CPU/brightness/services). A deliberate +-- keybind, not a bar click — engaging it disconnects you, so it shouldn't be a +-- misclick away. The custom/net module shows the state; this toggles it. +-- On Super+Shift+X ("X" = everything off); Super+Shift+A toggles push-to-talk. +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +-- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +-- Collapse / expand the left or right side of the bar to its base set +-- (same action as clicking the side's arrowhead). [ = left, ] = right. +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +-- Fullscreen +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +-- Workspaces 1-9 (from DWM TAGKEYS) +hl.bind(mod .. " + 1", hl.dsp.focus({ workspace = 1 })) +hl.bind(mod .. " + 2", hl.dsp.focus({ workspace = 2 })) +hl.bind(mod .. " + 3", hl.dsp.focus({ workspace = 3 })) +hl.bind(mod .. " + 4", hl.dsp.focus({ workspace = 4 })) +hl.bind(mod .. " + 5", hl.dsp.focus({ workspace = 5 })) +hl.bind(mod .. " + 6", hl.dsp.focus({ workspace = 6 })) +hl.bind(mod .. " + 7", hl.dsp.focus({ workspace = 7 })) +hl.bind(mod .. " + 8", hl.dsp.focus({ workspace = 8 })) +hl.bind(mod .. " + 9", hl.dsp.focus({ workspace = 9 })) + +-- Move window to workspace (from DWM tag) +hl.bind(mod .. " + SHIFT + 1", hl.dsp.window.move({ workspace = 1, follow = false })) +hl.bind(mod .. " + SHIFT + 2", hl.dsp.window.move({ workspace = 2, follow = false })) +hl.bind(mod .. " + SHIFT + 3", hl.dsp.window.move({ workspace = 3, follow = false })) +hl.bind(mod .. " + SHIFT + 4", hl.dsp.window.move({ workspace = 4, follow = false })) +hl.bind(mod .. " + SHIFT + 5", hl.dsp.window.move({ workspace = 5, follow = false })) +hl.bind(mod .. " + SHIFT + 6", hl.dsp.window.move({ workspace = 6, follow = false })) +hl.bind(mod .. " + SHIFT + 7", hl.dsp.window.move({ workspace = 7, follow = false })) +hl.bind(mod .. " + SHIFT + 8", hl.dsp.window.move({ workspace = 8, follow = false })) +hl.bind(mod .. " + SHIFT + 9", hl.dsp.window.move({ workspace = 9, follow = false })) + +-- Monitor focus (from DWM focusmon) +hl.bind(mod .. " + COMMA", hl.dsp.focus({ monitor = "-1" })) +hl.bind(mod .. " + PERIOD", hl.dsp.focus({ monitor = "+1" })) +hl.bind(mod .. " + SHIFT + COMMA", hl.dsp.window.move({ monitor = "-1" })) +hl.bind(mod .. " + SHIFT + PERIOD", hl.dsp.window.move({ monitor = "+1" })) + +-- ============================================================================ +-- Scratchpads (via pyprland) +-- ============================================================================ +-- Configured in ~/.config/hypr/pyprland.toml +-- Uses normal workspaces (not special), so new windows won't be captured +hl.bind(mod .. " + SHIFT + RETURN", hl.dsp.exec_cmd("pypr toggle term")) +hl.bind(mod .. " + A", hl.dsp.exec_cmd("audio-panel")) +hl.bind(mod .. " + R", hl.dsp.exec_cmd("pypr toggle monitor")) +hl.bind(mod .. " + SHIFT + N", hl.dsp.exec_cmd("net panel")) +hl.bind(mod .. " + SLASH", hl.dsp.exec_cmd("pypr toggle music")) + +-- Magnify (zoom) +-- mod+Z zooms and enters the "zoom" submap; inside it, Escape or mod+Z +-- unzooms and returns to the normal keymap. Exit forces `pypr zoom 1` +-- (factor 1) so submap state and zoom state can't desync. Note: while +-- zoomed, other Hyprland binds pause until you exit the submap. +hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom")) +hl.bind(mod .. " + Z", hl.dsp.submap("zoom")) + +hl.define_submap("zoom", function() + hl.bind("ESCAPE", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind(mod .. " + Z", hl.dsp.exec_cmd("pypr zoom 1")) + hl.bind(mod .. " + Z", hl.dsp.submap("reset")) +end) + +-- Calculator (not a scratchpad, just launches app) +hl.bind(mod .. " + X", hl.dsp.exec_cmd("calc-toggle")) +hl.bind(mod .. " + C", hl.dsp.exec_cmd("hyprpicker -a")) +hl.bind(mod .. " + CONTROL + C", hl.dsp.exec_cmd("clock-panel toggle")) + +-- Media/hardware keys +hl.bind("XF86AudioRaiseVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ +5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioLowerVolume", hl.dsp.exec_cmd("pactl set-sink-volume @DEFAULT_SINK@ -5%"), { locked = true, repeating = true }) +hl.bind("XF86AudioMute", hl.dsp.exec_cmd("audio quick-mute"), { locked = true }) +hl.bind("XF86MonBrightnessUp", hl.dsp.exec_cmd("brightnessctl s +10%"), { locked = true, repeating = true }) +hl.bind("XF86MonBrightnessDown", hl.dsp.exec_cmd("brightnessctl s 10%-"), { locked = true, repeating = true }) + +-- Microphone mute toggle (waybar pulseaudio#mic indicator follows via PipeWire events). +-- On the hardware mic-mute key. Super+Shift+A used to duplicate this; it now +-- toggles push-to-talk mode instead (mic-toggle stays reachable on the hw key). +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +-- Push-to-talk toggle: enter PTT (mic muted, hold key armed) / exit (restore). +-- The hold key is configurable (audio config ptt_key = Control_R or mouse:NNN). +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +-- Bluetooth panel (blueman retired in favor of the bt panel) +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +-- Screenshots (grim + slurp + fuzzel menu) +-- Shift+S captures the whole desktop with no pointer interaction, so it +-- works on scratchpads and popups that region-select would dismiss +hl.bind(mod .. " + S", hl.dsp.exec_cmd("screenshot region")) +hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("screenshot fullscreen")) +hl.bind("CTRL + " .. mod .. " + S", hl.dsp.exec_cmd("screenshot fullscreen")) + +-- Lock screen +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +-- Audio mute cycle (M for Mute): one key walks volume/mic through all four +-- on/off combinations. The waybar pulseaudio modules follow via PipeWire. +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +-- Exit/session +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +-- mod+Shift+Backspace no longer exits outright — it enters the "exitconfirm" +-- submap, where a second Backspace confirms and anything else backs out. +-- The bare bind killed two live sessions on 2026-07-22: it sits one key from +-- the mod+Shift cluster (Q wlogout, C killactive, Return terminal, G settings) +-- and took the whole session down with no prompt. wlogout owns the normal +-- exit path; this stays as the keyboard escape hatch for when wlogout won't +-- come up, so it's gated rather than removed. +hl.bind(mod .. " + SHIFT + BACKSPACE", hl.dsp.submap("exitconfirm")) + +hl.define_submap("exitconfirm", function() + hl.bind("BACKSPACE", hl.dsp.exit()) + hl.bind("ESCAPE", hl.dsp.submap("reset")) + hl.bind("catchall", hl.dsp.submap("reset")) +end) + +hl.bind(mod .. " + SHIFT + ESCAPE", hl.dsp.exec_cmd("hyprctl reload")) +hl.bind("CTRL + ALT + " .. mod .. " + K", hl.dsp.exec_cmd("hyprctl kill")) + +-- Mouse bindings (from DWM buttons) +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true }) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize(), { mouse = true }) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize(), { mouse = true }) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.lua. + +-- Replay the collected autostart commands. +hl.on("hyprland.start", function() + for _, cmd in ipairs(autostart) do hl.exec_cmd(cmd) end +end) + +hl.on("hyprland.shutdown", function() + for _, cmd in ipairs(atshutdown) do hl.exec_cmd(cmd) end +end) + +-- hyprlang's `exec` ran at startup AND on every reload. config.reloaded fires on +-- the initial load too (verified in a nested Hyprland 0.56.2 on 2026-08-24: a +-- marker written from this handler appears at session start), so this single +-- handler covers both cases, which is what waybar-reserve needs. +hl.on("config.reloaded", function() + for _, cmd in ipairs(atreload) do hl.exec_cmd(cmd) end +end) + +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") |
