aboutsummaryrefslogtreecommitdiff
path: root/working/hyprland-lua-port/hyprland.lua
blob: c00519354f93923c8b07533f07d289e63bb62ee2 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
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")