diff options
| author | Craig Jennings <c@cjennings.net> | 2026-08-24 14:27:57 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-08-24 14:27:57 -0600 |
| commit | 8b5e438d787ff73c3344b4c5ea1141cf8b23ef69 (patch) | |
| tree | 25f64dea1f918117648a6e0a383da208b2e2018e /working/hyprland-lua-port | |
| parent | 1bb5955b121d650302ba3eab08e44cc004470a91 (diff) | |
| download | archsetup-8b5e438d787ff73c3344b4c5ea1141cf8b23ef69.tar.gz archsetup-8b5e438d787ff73c3344b4c5ea1141cf8b23ef69.zip | |
Hyprland 0.57 drops the .conf format. The port is built and measured identical to the current config in a nested compositor — 38 settings, 103 binds, the host-override chain — but it is not deployed. I put it live this afternoon and took it back out the same hour, because it had not been run on real hardware and the machine has to stay usable until it has.
working/hyprland-lua-port/ carries what the switch needs, including the two reader changes as replayable patches. Those matter more than the config itself: four things read hyprland.conf as a source of truth, and dotfiles-validate globs hypr/*.conf, so with the .conf gone it finds nothing and reports a clean tree.
Two facts not worth re-deriving: with both files present 0.56.2 loads the .lua, so keeping the .conf beside it buys no rollback; and a running compositor rewrites a stub config within a second of the file vanishing, which is what fights a restow.
Also archives finished tasks and folds in several sessions of task-review edits.
Diffstat (limited to 'working/hyprland-lua-port')
| -rw-r--r-- | working/hyprland-lua-port/README.org | 131 | ||||
| -rw-r--r-- | working/hyprland-lua-port/hyprland.lua | 812 | ||||
| -rw-r--r-- | working/hyprland-lua-port/hyprland.lua.generated | 674 | ||||
| -rw-r--r-- | working/hyprland-lua-port/nomerge.lua | 768 | ||||
| -rw-r--r-- | working/hyprland-lua-port/ratio-local.lua | 43 | ||||
| -rw-r--r-- | working/hyprland-lua-port/reader-changes-for-lua.patch | 241 | ||||
| -rw-r--r-- | working/hyprland-lua-port/test-desktop-for-lua.patch | 50 | ||||
| -rw-r--r-- | working/hyprland-lua-port/velox-local.lua | 64 |
8 files changed, 2783 insertions, 0 deletions
diff --git a/working/hyprland-lua-port/README.org b/working/hyprland-lua-port/README.org new file mode 100644 index 0000000..42779e5 --- /dev/null +++ b/working/hyprland-lua-port/README.org @@ -0,0 +1,131 @@ +#+TITLE: Hyprland .conf → Lua port — staged, not deployed +#+AUTHOR: Craig Jennings + +* Status + +Built and verified in a nested compositor. *Not deployed.* I deployed it to the +dotfiles tree on 2026-08-24 and then rolled it back the same afternoon, because +the switch had not been checked on real hardware and the machine needs to stay +usable. The dotfiles repo is untouched at =8f692f5=; the live config is the +original =hyprland.conf=. + +The port goes live only after the hardware check in =todo.org= under "Manual +testing and validation" passes. + +* What is here + +| File | What it is | +|----------------------------+---------------------------------------------------------------| +| =hyprland.lua= | The deliverable, 788 lines. Shared config. | +| =velox-local.lua= | velox host overrides, for =velox/.config/hypr/conf.d/=. | +| =ratio-local.lua= | ratio host overrides, for =ratio/.config/hypr/conf.d/=. | +| =reader-changes-for-lua.patch= | dotfiles-side: the four readers, ported and mutation-tested. | +| =test-desktop-for-lua.patch= | archsetup-side: the post-install desktop checks. | +| =hyprland.lua.generated= | Raw =hyprlang2lua= output, merging mode. Derivation evidence. | +| =nomerge.lua= | Same converter with =--no-merge=. Derivation evidence. | + +The two patches are the part that is easy to lose and expensive to redo. Both +were mutation-tested — every ported assertion was confirmed to go red when the +property it guards was removed — so replay them rather than rewriting the +assertions from scratch. + +* Redeploy, when the port is ready + +1. =cp hyprland.lua ~/.dotfiles/hyprland/.config/hypr/hyprland.lua= +2. =cp velox-local.lua ~/.dotfiles/velox/.config/hypr/conf.d/local.lua= +3. =cp ratio-local.lua ~/.dotfiles/ratio/.config/hypr/conf.d/local.lua= +4. Move the three =.conf= files out of their stow packages. Do NOT merely leave + them beside the =.lua=: with both present Hyprland 0.56.2 loads the =.lua= + (proven — see below), so leaving the =.conf= in place buys no rollback and + only creates ambiguity about which file is live. +5. =cd ~/.dotfiles && git apply <path>/reader-changes-for-lua.patch= +6. =cd ~/code/archsetup && git apply <path>/test-desktop-for-lua.patch= +7. Restow. Expect two traps, both hit on 2026-08-24 and both documented in + =todo.org=: =make restow hyprland= aborts on the pre-existing + =obsbot-wb-guard.service= conflict in =common= (restow =hyprland= and the host + package individually instead), and the running Hyprland rewrites a stub + =hyprland.conf= within a second of the symlink vanishing. Silence the stub with + =hyprctl keyword misc:disable_autoreload 1=, do the stow, then set it back to 0. +8. *Push the dotfiles change before committing archsetup.* The installer clones + the dotfiles *remote* (=archsetup:1481=), so until the push lands a fresh VM + stows a tree with only =hyprland.conf= and the post-install checks fail. + +* Two things already proven, so nobody re-derives them + +*With both files present, the =.lua= wins.* Tested in a nested Hyprland 0.56.2 +with a fixture whose =.conf= set =gaps_in=11= and whose =.lua= set =77=. Result +was 77, and the log read "[cfg] Using lua config found at ...hyprland.lua". This +is why step 4 moves the =.conf= out rather than leaving it as a fallback. + +*The converter is a draft, not an answer.* =hyprlang2lua= +(github.com/EIonTusk/hyprlang2lua) reported 100% coverage and still produced +three functional defects, two of which would have broken the desktop. The two +generated files are kept as evidence: both still carry the unfixed bind defect +(="CTRL" .. mod .. " + S"=, which collapses to an unparseable =CTRLSUPER + S=), +and the merging-mode file shows the source glob emitted mid-file where it silently +reverses every per-host override. =--no-merge= fixed the ordering structurally; +the rest were hand-fixed. + +* Review findings folded in (2026-08-24) + +An isolated review of the deployed diff, before the rollback. Four were fixed in +the files here; the rest are gates on redeploying, not on the port's correctness. + +** Fixed here + +- =hl_source_glob= now surfaces all three failure modes and survives them. A + host override that fails to parse was warned about and skipped, which on velox + means coming up with no =force_zero_scaling= and no monitor scale — looking + like the whole port failed rather than one file. A runtime error inside the + chunk was unprotected and would have taken the entire config down over a single + host file. Now: parse failure says SKIPPED, runtime failure says PARTIAL and is + caught with =pcall=, and a glob matching nothing says NO MATCH. hyprlang did + none of this. +- The =col.nogroup_border*= rationale sat *below* the =col= table, reading as a + preamble to =layout=. Moved above the two keys it explains — the same defect + the file header says was fixed for autostart. +- =Generated by hyprlang2lua. Review TODOs before reloading Hyprland= removed + from all three files. No TODOs exist, and in =ratio-local.lua= it had landed + mid-paragraph, splitting the DP-4 rationale from the =hl.monitor= call it + explains. +- =ratio-local.lua='s usage examples were still hyprlang syntax + (=monitor=DP-1,...=, =bind = $mod, L, ...=), which are syntax errors in a Lua + file, and the second named =$mod= — a variable a sourced chunk cannot see. + Rewritten in Lua, with a note on the scoping. Verified rather than assumed: + =loadfile= gives the chunk globals only, and both =mod= and =at_start= are + locals, so both read =nil= inside a sourced file. + +** Refuted by measurement + +- *Duplicate chords append; they do not replace.* The config binds Super+Z twice + on purpose (=exec pypr zoom= plus =submap zoom=), and the same for Escape + inside the submap — if the Lua API replaced rather than appended, Super+Z would + enter the submap without zooming and the pairing the config's own comment + relies on would be broken. Tested in a nested Hyprland 0.56.2 with exactly that + shape: both binds register. The live =.conf= session registers the same pair, + so behaviour matches. No action needed; recorded so nobody re-derives it. + +** Gates on redeploy — do these as part of the switch + +1. *Do not put the =.conf= files in a =retired/= directory inside the repo + without also excluding them from =dotfiles-validate=.* Its find uses + =-path '*/.config/hypr/*.conf'=, which globs across slashes and would match + the retired copies — 81 of 228 checked references came from the dead config + when this was tried. The validator would then fail pointing at a file kept + precisely because nothing loads it. Add =-not -path "$root/retired/*"= to both + finds, or park the =.conf= files outside the repo entirely. +2. *Add tests for the new =dotfiles-validate= Lua branch.* The 25 new lines ship + with none. Proven vacuous: replacing both new awk regexes with =NEVERMATCHES= + still leaves =tests/dotfiles-validate/= reporting 15 tests OK. An extractor + that matches nothing prints nothing and exits 0 — the same false-pass shape + the three ported test suites got vacuity guards for. This one has no guard. +3. *Guard the override ordering.* Nothing asserts =hl_source_glob= is the last + statement in =hyprland.lua=, and it is the single invariant the whole per-host + layer rests on. An edit that moves it above the =hl.config= blocks silently + reverses every host override — converter defect 1, the one that would have + reverted the Qt scaling fix. archsetup's VM suite structurally cannot catch + it, because the VM stows no host tier. The guard belongs in the dotfiles repo. +4. *Sweep the prose comments that still name =hyprland.conf=.* About fifteen + across live scripts. =hyprland/.local/bin/waybar-reserve:12= is the one that + matters: it documents "Wired as =exec = waybar-reserve= in hyprland.conf", + which is exactly the mechanism the port replaces with =at_reload=. 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") diff --git a/working/hyprland-lua-port/hyprland.lua.generated b/working/hyprland-lua-port/hyprland.lua.generated new file mode 100644 index 0000000..04b30b9 --- /dev/null +++ b/working/hyprland-lua-port/hyprland.lua.generated @@ -0,0 +1,674 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- 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. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +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. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- 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). + +-- Core services + +-- 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. + +-- Background services +-- 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). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- 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. +-- 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. + +-- Pyprland (scratchpads, magnify, etc.) + +-- 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. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- 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.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", +}) + +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, +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.netpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.btpanel)$", + }, + float = true, + move = "100%-424 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.audiopanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.timerpanel)$", + }, + float = true, + move = "100%-444 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.settingspanel)$", + }, + float = true, + move = "100%-584 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.weatherpanel)$", + }, + float = true, + move = "100%-464 50", +}) + +hl.window_rule({ + match = { + class = "^(net\\.cjennings\\.maintpanel)$", + }, + float = true, + move = "100%-1004 50", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + float = true, + center = true, +}) + +hl.window_rule({ + match = { + title = "^(dirvish)$", + }, + float = true, + size = "1100 700", + center = true, +}) + +hl.window_rule({ + match = { + class = "^(Civ5XP)$", + }, + fullscreen = true, +}) + +local mod = "SUPER" + +hl.bind(mod .. " + T", hl.dsp.exec_cmd("foot")) +hl.bind(mod .. " + E", hl.dsp.exec_cmd("emacsclient -c -a \"\" || emacs")) +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")) + +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")) + +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")) +hl.bind(mod .. " + RETURN", hl.dsp.layout("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()) + +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" })) + +hl.bind(mod .. " + U", hl.dsp.layout("addmaster")) +hl.bind(mod .. " + D", hl.dsp.layout("removemaster")) + +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")) + +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")) + +hl.bind(mod .. " + SHIFT + D", hl.dsp.exec_cmd("dim-toggle")) +hl.bind(mod .. " + SHIFT + G", hl.dsp.exec_cmd("settings-panel")) + +hl.bind(mod .. " + I", hl.dsp.exec_cmd("caffeine-toggle")) + +hl.bind(mod .. " + SHIFT + X", hl.dsp.exec_cmd("airplane-mode")) + +hl.bind(mod .. " + B", hl.dsp.exec_cmd("waybar-toggle")) + +hl.bind(mod .. " + bracketleft", hl.dsp.exec_cmd("waybar-collapse left")) +hl.bind(mod .. " + bracketright", hl.dsp.exec_cmd("waybar-collapse right")) + +hl.bind(mod .. " + F11", hl.dsp.window.fullscreen({ mode = "fullscreen", action = "toggle" })) + +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 })) + +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 })) + +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" })) + +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")) + +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) + +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")) + +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 }) + +hl.bind("XF86AudioMicMute", hl.dsp.exec_cmd("mic-toggle"), { locked = true }) + +hl.bind(mod .. " + SHIFT + A", hl.dsp.exec_cmd("audio ptt-toggle")) + +hl.bind(mod .. " + SHIFT + B", hl.dsp.exec_cmd("bt-panel")) + +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")) + +hl.bind(mod .. " + ESCAPE", hl.dsp.exec_cmd("hyprlock")) + +hl.bind(mod .. " + M", hl.dsp.exec_cmd("audio-cycle")) + +hl.bind(mod .. " + SHIFT + Q", hl.dsp.exec_cmd("pgrep -x wlogout || wlogout-menu")) +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")) + +hl.bind(mod .. " + mouse:272", hl.dsp.window.drag()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") +hl.config({ + general = { + gaps_in = 25, + gaps_out = 30, + border_size = 2, + col = { + active_border = "rgba(daa520ff)", + inactive_border = "rgba(444444ff)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_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. + layout = "master", + resize_on_border = true, + }, + decoration = { + rounding = 10, + dim_inactive = true, + dim_strength = 0.4, + dim_special = 0.2, + blur = { + enabled = false, + }, + shadow = { + enabled = false, + }, + }, + animations = { + enabled = true, + }, + -- ============================================================================ + -- Layout (master-stack like DWM tile) + -- ============================================================================ + master = { + new_status = "master", + new_on_top = true, + mfact = 0.55, + }, + dwindle = { + preserve_split = true, + }, + -- ============================================================================ + -- Input + -- ============================================================================ + cursor = { + no_warps = true, + inactive_timeout = 2.0, + }, + 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 + -- ============================================================================ + 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) + -- ============================================================================ + debug = { + disable_logs = false, + }, + -- ============================================================================ + -- XWayland + -- ============================================================================ + xwayland = { + force_zero_scaling = true, + }, + -- ============================================================================ + -- Window Rules (Hyprland 0.53+ syntax: match:CONDITION, RULE) + -- ============================================================================ + -- Floating windows (from DWM rules) + -- 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. + -- maintenance console: the wide board (960), same right-aligned convention. + -- org-capture popup frame (quick-capture script names the frame) + -- Size is per-host in <host>/conf.d/local.conf: 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. + -- 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. + -- NOTE: center windowrules removed 2026-03-04 per pyprland maintainer suggestion + -- Testing whether pyprland handles scratchpad re-centering natively (issue #211) + -- Gaming + -- ============================================================================ + -- Key Bindings + -- ============================================================================ + -- Terminal and core apps (from DWM) + -- 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. + -- From sxhkdrc + -- Window management (from DWM) + -- Layout-aware navigation (works across master, scrolling) + -- 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). + -- Layouts: master -> monocle + -- Cycle with Shift+arrows, or jump directly with Shift+T/M + -- (scrolling layout disabled until frame-fit + wrap-around work lands) + -- Master layout adjustments + -- Stash windows (hide to special workspace) + -- O = stash focused / Alt+O = stash others / Shift+O = restore all + -- 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. + -- Auto-dim toggle (D = dim). Same action as clicking the waybar custom/dim icon. + -- 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). + -- 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. + -- Toggle bar visibility, or relaunch waybar if it crashed (no exec-once respawn). + -- Collapse / expand the left or right side of the bar to its base set + -- (same action as clicking the side's arrowhead). [ = left, ] = right. + -- Fullscreen + -- Workspaces 1-9 (from DWM TAGKEYS) + -- Move window to workspace (from DWM tag) + -- Monitor focus (from DWM focusmon) + -- ============================================================================ + -- Scratchpads (via pyprland) + -- ============================================================================ + -- Configured in ~/.config/hypr/pyprland.toml + -- Uses normal workspaces (not special), so new windows won't be captured + -- 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. + -- Calculator (not a scratchpad, just launches app) + -- Media/hardware keys + -- 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). + -- 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). + -- Bluetooth panel (blueman retired in favor of the 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 + -- Lock screen + -- Audio mute cycle (M for Mute): one key walks volume/mic through all four + -- on/off combinations. The waybar pulseaudio modules follow via PipeWire. + -- Exit/session + -- 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. + -- Mouse bindings (from DWM buttons) + -- ============================================================================ + -- Machine-local overrides + -- ============================================================================ + -- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) + -- override the defaults above. See conf.d/local.conf. +}) + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("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") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/nomerge.lua b/working/hyprland-lua-port/nomerge.lua new file mode 100644 index 0000000..c85fd24 --- /dev/null +++ b/working/hyprland-lua-port/nomerge.lua @@ -0,0 +1,768 @@ +-- Hyprland Configuration +-- Translated from DWM config.def.h and sxhkdrc +-- Craig Jennings <c@cjennings.net> + +-- ============================================================================ +-- Monitor Configuration +-- ============================================================================ +-- Generated by hyprlang2lua. Review TODOs before reloading Hyprland. + +-- 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. + local p = io.popen("ls " .. pattern .. " 2>/dev/null") + if not p then return end + for f in p:lines() do + local chunk, err = loadfile(f) + if chunk then chunk() + else io.stderr:write("hl_source_glob: " .. tostring(err) .. "\n") end + end + p:close() +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. + +-- ============================================================================ +-- Startup Applications +-- ============================================================================ +-- Portal and D-Bus setup FIRST, then waybar (needs portal for appearance query) +-- 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). + +-- Core services + +-- 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. + +-- Background services +-- 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). +hl.on("hyprland.shutdown", function() + hl.exec_cmd("pkill -x hypridle") +end) + +-- 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. +-- 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. + +-- Pyprland (scratchpads, magnify, etc.) + +-- 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. +-- QT_FONT_DPI bumps the bridge's QML UI font (qt6ct General font is ignored by Qt Quick) + +-- ============================================================================ +-- 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)", + nogroup_border_active = "rgba(daa520ff)", + nogroup_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. + 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.conf: 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.layout("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()) +hl.bind(mod .. " + mouse:273", hl.dsp.window.resize()) +hl.bind(mod .. " + SHIFT + mouse:272", hl.dsp.window.resize()) + +-- ============================================================================ +-- Machine-local overrides +-- ============================================================================ +-- Sourced last so machine-specific settings (monitor scale, gaps, keybinds) +-- override the defaults above. See conf.d/local.conf. +-- Source: $HOME/.config/hypr/conf.d/*.conf (glob; resolved at runtime). Each matched .conf must be converted to .lua. +hl_source_glob("$HOME/.config/hypr/conf.d/*.lua") + +hl.on("hyprland.start", function() + hl.exec_cmd("dbus-update-activation-environment --systemd WAYLAND_DISPLAY XDG_CURRENT_DESKTOP HYPRLAND_INSTANCE_SIGNATURE") + hl.exec_cmd("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") + hl.exec_cmd("/usr/lib/polkit-kde-authentication-agent-1") + hl.exec_cmd("/usr/bin/gnome-keyring-daemon --start --components=pkcs11,secrets,ssh") + hl.exec_cmd("dunst > ~/.local/var/log/dunst-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("awww-daemon & sleep 1 && { settings restore-wallpaper || waypaper --restore; }") + hl.exec_cmd("touchpad-auto") + hl.exec_cmd("pkill -x hypridle; hypridle-start > ~/.local/var/log/hypridle-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("/usr/lib/geoclue-2.0/demos/agent") + hl.exec_cmd("gammastep > ~/.local/var/log/gammastep-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("mpd") + hl.exec_cmd("settings restore > ~/.local/var/log/settings-restore-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("pypr > ~/.local/var/log/pypr-$(date +%Y-%m-%d-%H%M%S).log 2>&1") + hl.exec_cmd("hypr-refocus-scratchpad") + hl.exec_cmd("wait-for-tray && signal-desktop --start-in-tray --ozone-platform=wayland") + hl.exec_cmd("env QT_FONT_DPI=108 protonmail-bridge --no-window") +end) + +hl.on("config.reloaded", function() + hl.exec_cmd("for i in 1 2 3; do sleep 0.2; waybar-reserve; done") +end) + diff --git a/working/hyprland-lua-port/ratio-local.lua b/working/hyprland-lua-port/ratio-local.lua new file mode 100644 index 0000000..de190d5 --- /dev/null +++ b/working/hyprland-lua-port/ratio-local.lua @@ -0,0 +1,43 @@ +-- ratio — desktop, 1x scaling. Defaults in hyprland.lua are correct. +-- Sourced via conf.d/*.lua glob (last wins). +-- +-- Examples: +-- hl.monitor({ output = "DP-1", mode = "3440x1440@144", position = "auto", scale = 1 }) +-- hl.bind("SUPER + L", hl.dsp.exec_cmd("hyprlock")) +-- +-- Spell the modifier out. A sourced file is loaded by hl_source_glob via +-- loadfile, which gives the chunk globals only, so the shared config's `mod` +-- (and `at_start`, and the other locals) are NOT in scope here. + +-- DP-4 (Dell U3419W ultrawide) pinned to its native mode. Without an explicit +-- pin the shared catch-all monitor=,preferred,auto,auto lets an XWayland surface +-- (emacs runs X11-only on ratio) drive the mode down to 1280x720 at login. That +-- low mode also wipes DP-4's reserved area, which drops the non-exclusive waybar +-- off-screen (margin-top:-54 needs the 60px top reserve). Pinning holds both. + +hl.monitor({ + output = "DP-4", + mode = "3440x1440@60", + position = "0x0", + scale = "1", +}) + +-- org-capture popup: capped at 120 Emacs columns wide, height proportional +-- (Craig, 2026-07-14). 120 cols x 11 px/col = 1320 wide; height keeps the old +-- rule's aspect (1892:936) = 653 px (~27 lines at 24 px). The old 55% x 65% +-- scratchpad match (1892 x 936) was the "grows too large" complaint. max_size +-- holds the cap even if the frame tries to grow with capture content. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1320 653", +}) + +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1320 653", +}) + diff --git a/working/hyprland-lua-port/reader-changes-for-lua.patch b/working/hyprland-lua-port/reader-changes-for-lua.patch new file mode 100644 index 0000000..747b047 --- /dev/null +++ b/working/hyprland-lua-port/reader-changes-for-lua.patch @@ -0,0 +1,241 @@ +diff --git a/common/.local/bin/dotfiles-validate b/common/.local/bin/dotfiles-validate +index 57a7505..d5e6695 100755 +--- a/common/.local/bin/dotfiles-validate ++++ b/common/.local/bin/dotfiles-validate +@@ -3,6 +3,7 @@ + # + # Walks the tree and extracts the commands that configs promise to launch: + # - hypr conf files: exec-once = CMD / exec = CMD / bind* = ..., exec, CMD ++# - hypr lua configs: at_start/at_shutdown/at_reload("CMD"), exec_cmd("CMD") + # - waybar config: "exec(-if)", "on-click*", "on-scroll-*", + # "on-double-click" values + # - systemd user units: Exec*= lines (leading -/@ modifiers stripped) +@@ -45,6 +46,30 @@ find "$root" -path '*/.config/hypr/*.conf' -type f 2>/dev/null | while read -r f + ' "$f" + done >> "$refs_file" + ++# --- hypr lua configs: autostart collectors and exec_cmd dispatchers --- ++# The Lua config manager (Hyprland 0.55+) spells the same two things as function ++# calls rather than assignments, so the .conf walk above sees none of them. Only ++# the first word is taken, as everywhere else here. The two matches are written ++# out rather than folded into a helper: awk cannot take a regex literal as a ++# function parameter -- it collapses to a boolean match against $0, which ++# silently turns every line into a bogus reference. ++find "$root" -path '*/.config/hypr/*.lua' -type f 2>/dev/null | while read -r f; do ++ awk -v file="$f" ' ++ match($0, /at_(start|shutdown|reload)\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ match($0, /hl\.dsp\.exec_cmd\("/) { ++ rest = substr($0, RSTART + RLENGTH) ++ sub(/".*$/, "", rest) ++ n = split(rest, w, /[ \t]+/) ++ if (n > 0 && w[1] != "") print file ":" FNR ":" w[1] ++ } ++ ' "$f" ++done >> "$refs_file" ++ + # --- waybar configs: command-bearing JSON values --- + find "$root" -path '*/.config/waybar/*' -type f \( -name config -o -name '*.json' -o -name '*.jsonc' \) 2>/dev/null | while read -r f; do + awk -v file="$f" ' +diff --git a/tests/layout-cycle/test_layout_cycle.py b/tests/layout-cycle/test_layout_cycle.py +index 1454817..3740024 100644 +--- a/tests/layout-cycle/test_layout_cycle.py ++++ b/tests/layout-cycle/test_layout_cycle.py +@@ -18,6 +18,7 @@ Run from repo root: + + import json + import os ++import re + import subprocess + import tempfile + import unittest +@@ -25,7 +26,7 @@ import unittest + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + SCRIPT = os.path.join(REPO_ROOT, "hyprland/.local/bin/layout-cycle") + FAKE_HYPRCTL = os.path.join(os.path.dirname(__file__), "fake-hyprctl") +-HYPRLAND_CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPRLAND_CFG = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + FLASH = "rgba(ffd24aff)" + ACTIVE = "rgba(daa520ff)" +@@ -326,7 +327,7 @@ class TestFlashAllBorders(LayoutCycleHarness): + + + class TestScrollLayoutDisabledInConfig(unittest.TestCase): +- """Pin the hyprland.conf half of the scrolling disable. ++ """Pin the hyprland.lua half of the scrolling disable. + + The script tests above prove the ring skips scrolling; these prove no + keybinding reaches it either, and that the freed Super+Shift+S chord +@@ -335,10 +336,20 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + @classmethod + def setUpClass(cls): +- with open(HYPRLAND_CONF) as f: ++ with open(HYPRLAND_CFG) as f: + cls.conf = f.read() ++ # Every bind in the Lua config is a top-level hl.bind() call; the ++ # locked/mouse/repeat variants that hyprlang spelled bindl/bindm/binde ++ # are the same call with an options table, so one prefix covers them. + cls.binds = [l for l in cls.conf.splitlines() +- if l.strip().startswith(("bind", "bindl", "bindm"))] ++ if l.strip().startswith("hl.bind")] ++ ++ def test_the_binds_were_actually_found(self): ++ """Guard the guard: a renamed call would empty the list and make both ++ assertions below pass against nothing.""" ++ self.assertGreater(len(self.binds), 50, ++ "found almost no hl.bind lines -- the two assertions " ++ "below would pass vacuously") + + def test_no_bind_selects_scrolling_layout(self): + offenders = [l for l in self.binds if "general:layout scrolling" in l] +@@ -346,7 +357,7 @@ class TestScrollLayoutDisabledInConfig(unittest.TestCase): + + def test_super_shift_s_is_fullscreen_screenshot(self): + shift_s = [l for l in self.binds +- if "$mod SHIFT, S," in l] ++ if re.search(r'\bmod\s*\.\.\s*"\s*\+\s*SHIFT\s*\+\s*S"', l)] + self.assertEqual(len(shift_s), 1, msg=f"binds found: {shift_s}") + self.assertIn("screenshot fullscreen", shift_s[0]) + +diff --git a/tests/settings/test_session_restore.py b/tests/settings/test_session_restore.py +index a989d52..b2a2721 100644 +--- a/tests/settings/test_session_restore.py ++++ b/tests/settings/test_session_restore.py +@@ -585,12 +585,32 @@ class TestCompositorWiring(unittest.TestCase): + script and passing tests while never being placed in the bar. + """ + +- CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++ CONF = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + def _lines(self): ++ """The autostart commands, in launch order. ++ ++ The Lua config collects startup commands with at_start("CMD") and one ++ hl.on("hyprland.start") handler at the bottom replays the list in order, ++ so position in this file is still position at launch -- which is what ++ the ordering assertion below reads. Returning the command strings rather ++ than raw lines means every entry here is by construction an autostart ++ command, so the old startswith("exec-once") filter has nothing left to do. ++ """ ++ out = [] + with open(self.CONF) as f: +- return [ln.strip() for ln in f +- if ln.strip() and not ln.strip().startswith("#")] ++ for ln in f: ++ m = re.search(r'at_start\("(.*)"\)', ln.strip()) ++ if m: ++ out.append(m.group(1)) ++ return out ++ ++ def test_the_autostart_commands_were_actually_found(self): ++ """Guard the guard: a renamed collector would empty the list and make ++ every assertion below pass against nothing.""" ++ self.assertGreater(len(self._lines()), 10, ++ "found almost no at_start commands -- the assertions " ++ "below would pass vacuously") + + @staticmethod + def _is_toggle_restore(line): +@@ -607,22 +627,19 @@ class TestCompositorWiring(unittest.TestCase): + + def test_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and self._is_toggle_restore(ln) +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore` — " ++ any(self._is_toggle_restore(ln) for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore` — " + "remembered toggles would never be replayed") + + def test_wallpaper_restore_runs_at_session_start(self): + self.assertTrue( +- any(ln.startswith("exec-once") and "settings restore-wallpaper" in ln +- for ln in self._lines()), +- "hyprland.conf has no exec-once running `settings restore-wallpaper` " ++ any("settings restore-wallpaper" in ln for ln in self._lines()), ++ "hyprland.lua has no at_start running `settings restore-wallpaper` " + "— the stored wallpaper would never be put back") + + def test_the_toggle_restore_runs_only_once(self): + """Twice means the whole re-assert budget is spent twice per login.""" +- lines = [ln for ln in self._lines() +- if ln.startswith("exec-once") and self._is_toggle_restore(ln)] ++ lines = [ln for ln in self._lines() if self._is_toggle_restore(ln)] + self.assertEqual(len(lines), 1, lines) + + def test_restore_is_ordered_after_the_backings_it_corrects(self): +@@ -631,17 +648,15 @@ class TestCompositorWiring(unittest.TestCase): + # burn attempts on backings that aren't up yet. + lines = self._lines() + restore = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") +- and self._is_toggle_restore(ln)) ++ if self._is_toggle_restore(ln)) + for backing in ("hypridle", "dunst"): +- launch = next(i for i, ln in enumerate(lines) +- if ln.startswith("exec-once") and backing in ln) ++ launch = next(i for i, ln in enumerate(lines) if backing in ln) + self.assertLess(launch, restore, + f"`settings restore` is ordered before {backing}") + + + class DimEnv(TempEnv): +- """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.conf's is.""" ++ """Adds the hyprctl fake, whose dim default is 1 -- as hyprland.lua's is.""" + + def setUp(self): + super().setUp() +diff --git a/tests/waybar-reserve/test_reserve_pairing.py b/tests/waybar-reserve/test_reserve_pairing.py +index 7421692..fbeb95d 100644 +--- a/tests/waybar-reserve/test_reserve_pairing.py ++++ b/tests/waybar-reserve/test_reserve_pairing.py +@@ -26,7 +26,7 @@ import unittest + + REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + WAYBAR = os.path.join(REPO_ROOT, "hyprland/.config/waybar/config") +-HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.conf") ++HYPR = os.path.join(REPO_ROOT, "hyprland/.config/hypr/hyprland.lua") + + + def waybar_config(): +@@ -35,13 +35,19 @@ def waybar_config(): + + + def reserve_exec_wired(): +- """True when hyprland.conf runs waybar-reserve via exec (not exec-once). ++ """True when the config re-runs waybar-reserve on every config reload. + +- Matches waybar-reserve invoked anywhere in an ``exec =`` line, so the +- reload-race-safe retry-loop form (``exec = for i in 1 2 3; do sleep 0.2; +- waybar-reserve; done``) counts the same as a bare ``exec = waybar-reserve``.""" ++ Matches waybar-reserve invoked anywhere in an ``at_reload(...)`` line, so the ++ reload-race-safe retry-loop form (``at_reload("for i in 1 2 3; do sleep 0.2; ++ waybar-reserve; done")``) counts the same as a bare ``at_reload("waybar-reserve")``. ++ ++ at_reload and not at_start, because hyprlang's ``exec`` ran at startup AND on ++ every reload, and the Lua port splits those two jobs. at_reload's handler is ++ ``hl.on("config.reloaded")``, which fires on the initial load as well, so it ++ alone covers both. Matching at_start instead would pass while reservations ++ died on the next reload -- which is exactly the defect this guards.""" + with open(HYPR) as f: +- return any(re.match(r"\s*exec\s*=.*\bwaybar-reserve\b", ln) for ln in f) ++ return any(re.match(r'\s*at_reload\(".*\bwaybar-reserve\b', ln) for ln in f) + + + def reserve_target(): +@@ -61,7 +67,7 @@ class ReservePairingHarness(unittest.TestCase): + + def test_hyprland_wires_the_reserve_script(self): + self.assertTrue(reserve_exec_wired(), +- "hyprland.conf lacks `exec = waybar-reserve`: reservations " ++ "hyprland.lua lacks `at_reload(... waybar-reserve ...)`: reservations " + "die on the next config reload") + + def test_reserve_target_covers_the_bar(self): diff --git a/working/hyprland-lua-port/test-desktop-for-lua.patch b/working/hyprland-lua-port/test-desktop-for-lua.patch new file mode 100644 index 0000000..5bf7612 --- /dev/null +++ b/working/hyprland-lua-port/test-desktop-for-lua.patch @@ -0,0 +1,50 @@ +diff --git a/scripts/testing/tests/test_desktop.py b/scripts/testing/tests/test_desktop.py +index 1538d6a..ca2f266 100644 +--- a/scripts/testing/tests/test_desktop.py ++++ b/scripts/testing/tests/test_desktop.py +@@ -11,6 +11,8 @@ installs `awww` (swww successor) and `pacman -Q swww` no longer matches — so + this checks awww. That divergence from the shell sweep is a correctness fix. + """ + ++import re ++ + import pytest + + +@@ -19,8 +21,12 @@ HYPRLAND_TOOLS = [ + "awww", "grim", "slurp", "gammastep", "foot", + ] + ++# Note: hyprland.lua sits beside two .conf files on purpose. Only Hyprland's own ++# config format is deprecated (removed in 0.57); hypridle and hyprlock are ++# separate projects still on hyprlang, so their .conf names are current. Don't ++# "fix" this list for consistency -- that reverts the Lua port. + HYPRLAND_CONFIGS = [ +- ".config/hypr/hyprland.conf", ++ ".config/hypr/hyprland.lua", + ".config/hypr/hypridle.conf", + ".config/hypr/hyprlock.conf", + ".config/waybar/config", +@@ -139,8 +145,19 @@ def test_bt_panel_wired(host, hyprland_installed, home): + waybar = host.file("%s/.config/waybar/config" % home) + assert "custom/bluetooth" in waybar.content_string, \ + "waybar config lacks the custom/bluetooth module" +- hyprconf = host.file("%s/.config/hypr/hyprland.conf" % home) +- assert "bt-panel" in hyprconf.content_string, \ +- "hyprland.conf lacks the bt-panel keybind" ++ hypr = host.file("%s/.config/hypr/hyprland.lua" % home).content_string ++ # Assert the dispatcher and the chord, not the bare binary name. "bt-panel" ++ # on its own still appears inside exec_cmd() when the chord that reaches it ++ # is mangled, so the weak form passes on a config where Super+Shift+B does ++ # nothing. That is not hypothetical: converting this config to Lua produced ++ # exactly that defect elsewhere ("CTRL" .. mod .. " + S" collapsing to an ++ # unparseable CTRLSUPER + S, which Hyprland rejects at parse and leaves dead). ++ # One pattern spanning the whole bind, not two independent checks: asserting ++ # the chord and the dispatcher separately passes when they sit on different ++ # lines, and a planned reshuffle of the panel keybinding family is exactly ++ # the change that would separate them. ++ assert re.search( ++ r'hl\.bind\(\s*mod\s*\.\.\s*" \+ SHIFT \+ B",\s*hl\.dsp\.exec_cmd\("bt-panel"\)', ++ hypr), "hyprland.lua lacks the Super+Shift+B bind reaching bt-panel" + assert host.file("%s/.config/themes/dupre/panel.css" % home).exists, \ + "shared panel css missing from the stowed theme" diff --git a/working/hyprland-lua-port/velox-local.lua b/working/hyprland-lua-port/velox-local.lua new file mode 100644 index 0000000..1da7359 --- /dev/null +++ b/working/hyprland-lua-port/velox-local.lua @@ -0,0 +1,64 @@ +-- velox — Framework 13, HiDPI 2256x1504. Sourced via conf.d/*.lua glob; +-- values here override hyprland.lua (last wins). + +hl.monitor({ + output = "eDP-1", + mode = "preferred", + position = "auto", + scale = "1.566667", +}) + +-- Scaling on this panel comes from the compositor alone. +-- +-- There used to be `env = GDK_SCALE,1.5` and `env = QT_SCALE_FACTOR,1.5` here, +-- to compensate for `xwayland:force_zero_scaling = true` in hyprland.lua, which +-- makes XWayland clients render unscaled and therefore tiny. That worked for +-- XWayland and broke everything else, because env vars reach every app, not just +-- the XWayland ones. Qt 6 and GTK on Wayland already take their scale from the +-- compositor, so the factor multiplied the monitor scale above: Qt saw a 960x640 +-- logical screen instead of 1440x960 and drew half again too large. Measured +-- with QScreen.geometry on 2026-08-19; 2256/1.566667 is exactly 1440, which is +-- what Qt reports once nothing overrides it. +-- +-- Turning force_zero_scaling off for this host is the other half. XWayland then +-- scales through the compositor, so those apps come out the right size and a +-- little soft rather than sharp and tiny. That cost lands only on XWayland, +-- which is the thing I avoid anyway, and correctly-sized beats crisp for the +-- occasional Zoom window the browser spawns. +-- +-- ratio needs neither: its monitor runs at scale 1, so nothing multiplies and +-- XWayland has nothing to compensate for. +hl.config({ + xwayland = { + force_zero_scaling = false, + }, +}) + +-- calibre renders oversized at the 1.57 compositor scale. Pin its own DPI so its +-- UI is comfortable without touching the desktop or other apps (validated at 96, +-- 2026-06-27). CALIBRE_OVERRIDE_DPI is calibre-only, so a session-wide env is safe. +hl.env("CALIBRE_OVERRIDE_DPI", "96") +-- No XCURSOR_SIZE here: Hyprland scales the native cursor by the monitor scale +-- already, so the shared default (24) renders correctly on HiDPI. Pre-scaling it +-- (the old 36 = 24 x 1.5) double-applied on top of the compositor's scale. + +-- org-capture popup: match the terminal scratchpad (75% x 70% of the 1437x958 +-- logical desktop = 1078 x 671 px). Native window rules ignore percentages, so +-- it's pinned in pixels here rather than in the shared hyprland.lua. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + size = "1078 671", +}) + +-- Growth cap (Craig, 2026-07-14): the frame must never outgrow its pinned +-- size even when capture content pushes it. 1078 px is ~98 Emacs columns, +-- already under the 120-column cap ratio uses. +hl.window_rule({ + match = { + title = "^(org-capture)$", + }, + max_size = "1078 671", +}) + |
