aboutsummaryrefslogtreecommitdiff
path: root/working/hyprland-lua-port
diff options
context:
space:
mode:
Diffstat (limited to 'working/hyprland-lua-port')
-rw-r--r--working/hyprland-lua-port/README.org131
-rw-r--r--working/hyprland-lua-port/hyprland.lua812
-rw-r--r--working/hyprland-lua-port/hyprland.lua.generated674
-rw-r--r--working/hyprland-lua-port/nomerge.lua768
-rw-r--r--working/hyprland-lua-port/ratio-local.lua43
-rw-r--r--working/hyprland-lua-port/reader-changes-for-lua.patch241
-rw-r--r--working/hyprland-lua-port/test-desktop-for-lua.patch50
-rw-r--r--working/hyprland-lua-port/velox-local.lua64
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",
+})
+