aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-12-window-switcher.md
blob: 79f3be12a2c696ef52b29baa3ba418c0b7741858 (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
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
# window-switcher Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** A fifth quickshell component showing every open window as a live preview in a centred grid, replacing the rofi list bound to `ALT + TAB`.

**Architecture:** One `ShellRoot` with an `IpcHandler`, a keepalive window, and a `LazyLoader` holding the full-screen overlay, matching `mail-overview` and `vm-manager`. Window data comes from `Hyprland.toplevels`; previews are `ScreencopyView` bound to each toplevel's `.wayland`. Focus and close go out as Lua dispatches through `Hyprland.dispatch`.

**Tech Stack:** QML, Quickshell 0.3.1 (`Quickshell.Hyprland`, `Quickshell.Wayland`, `Quickshell.Widgets`), Hyprland 0.56.2.

**Spec:** `docs/superpowers/specs/2026-09-12-window-switcher-design.md`

---

## How to verify in this repo

There is no unit test runner for QML here, and AGENTS.md records how measurement goes wrong in this repo. Every task's check follows the same shape:

```bash
cd ~/Programming/GIT/quickshell
timeout 5 qs -p window-switcher >/tmp/ws.log 2>&1
grep -E "Configuration Loaded" /tmp/ws.log        # must appear
grep -iE "error|is not a|undefined|Unable to assign" /tmp/ws.log   # must be empty
```

`timeout` keeps the process owned by the harness, because a detached `qs` does not survive a tool call and a later `pgrep` then reports DEAD regardless of whether the config is sound. The process is `qs`, never `quickshell`, and `pkill -f` matches the caller's own shell, so any cleanup is `pkill -x qs` and any count is `pgrep -cx qs`.

**Anything visual is the user's call.** Screenshots of a transient overlay are a race. Where a task needs a look, it says so and stops.

## File structure

| File | Responsibility |
| --- | --- |
| `window-switcher/shell.qml` | `ShellRoot`, `IpcHandler`, keepalive window, `LazyLoader` for the overlay |
| `window-switcher/Switcher.qml` | The overlay: backdrop, key handling, selection state, grid, empty state |
| `window-switcher/WindowCard.qml` | One card: preview box, corner overlays, three text lines |
| `window-switcher/Windows.qml` | Singleton: the filtered, sorted model plus `focus()` and `closeWindow()` |
| `window-switcher/Theme.qml` | Symlink to `../shared/Theme.qml` |
| `window-switcher/README.md` | Component README, in the style of the other four |

`Windows.qml` exists so the card and the overlay never touch `Hyprland` directly: the filter, the sort and the two dispatches live in one place, and the dispatch syntax trap is contained to one file.

Every file starts with the GPLv2 header notice used across the repo:

```qml
// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
//
// This program is free software; you can redistribute it and/or modify
// it under the terms of the GNU General Public License version 2 as
// published by the Free Software Foundation.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
```

---

### Task 1: The component skeleton, and the keyboard grab

AGENTS.md records that `WlrLayershell.keyboardFocus` is necessary but not sufficient and that `Keys.onEscapePressed` on a `PanelWindow` never fires. The workaround, a focused `Item` inside the window, was tested during planning on a bare layer surface and Escape arrived. This task puts that shape in place before the grid is built on it, so a later keyboard problem is a problem in the grid rather than in the grab.

**Files:**
- Create: `window-switcher/shell.qml`
- Create: `window-switcher/Switcher.qml`
- Create: `window-switcher/Theme.qml` (symlink)

- [ ] **Step 1: Create the directory and the Theme symlink**

```bash
cd ~/Programming/GIT/quickshell
mkdir -p window-switcher
ln -s ../shared/Theme.qml window-switcher/Theme.qml
ls -l window-switcher/Theme.qml
```

Expected: `Theme.qml -> ../shared/Theme.qml`. A symlink, never a copy: AGENTS.md records that these were four drifted copies before, and editing any one of them edits all.

- [ ] **Step 2: Write `window-switcher/Switcher.qml` as a bare overlay**

After the licence header:

```qml
import Quickshell
import Quickshell.Wayland
import QtQuick

Scope {
    id: root

    property bool open: false
    readonly property var screenObj: Quickshell.screens.find(s => s.name === "DP-1") ?? null

    function toggle() { root.open = !root.open; }
    function close() { root.open = false; }

    // A config with no visible window exits, reporting nothing. This 1x1
    // click-through window is what holds the shell open while the overlay
    // is hidden, which is almost always.
    PanelWindow {
        anchors { top: true; left: true }
        implicitWidth: 1
        implicitHeight: 1
        color: "transparent"
        exclusionMode: ExclusionMode.Ignore
        mask: Region {}
        WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
    }

    LazyLoader {
        active: root.open

        PanelWindow {
            id: win
            screen: root.screenObj
            anchors { top: true; left: true; right: true; bottom: true }
            color: "transparent"
            exclusionMode: ExclusionMode.Ignore
            WlrLayershell.layer: WlrLayer.Overlay
            WlrLayershell.namespace: "window-switcher"
            WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive

            // Keys reach a focused item, not a window: Keys.onEscapePressed on
            // the PanelWindow above never fires. This item is what listens.
            Item {
                anchors.fill: parent
                focus: true

                Keys.onEscapePressed: {
                    console.log("SWITCHER escape");
                    root.close();
                }

                Rectangle {
                    anchors.fill: parent
                    color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82)

                    MouseArea {
                        anchors.fill: parent
                        onClicked: root.close()
                    }

                    Text {
                        anchors.centerIn: parent
                        text: "press Escape"
                        color: Theme.text
                        font.family: Theme.fontFamily
                        font.pixelSize: 28
                    }
                }
            }
        }
    }
}
```

- [ ] **Step 3: Write `window-switcher/shell.qml`**

After the licence header:

```qml
import Quickshell
import Quickshell.Io

ShellRoot {
    Switcher { id: switcher }

    // Bound to ALT + TAB in Hyprland:
    // `qs -p <this dir> ipc call switcher toggle`
    IpcHandler {
        target: "switcher"
        function toggle() { switcher.toggle(); }
        function show() { switcher.open = true; }
        function close() { switcher.close(); }
    }
}
```

- [ ] **Step 4: Verify it loads**

```bash
cd ~/Programming/GIT/quickshell
timeout 5 qs -p window-switcher >/tmp/ws.log 2>&1
grep -E "Configuration Loaded" /tmp/ws.log
grep -iE "error|is not a|undefined|Unable to assign" /tmp/ws.log
```

Expected: `Configuration Loaded` present, second grep empty.

- [ ] **Step 5: Prove Escape arrives**

This was verified during planning against this exact Hyprland and quickshell, on
a bare layer surface: `Keys.onEscapePressed` on a focused `Item` inside the
`PanelWindow` fires. The step re-confirms it in the component itself.

Start the shell so the harness owns it, toggle the overlay over IPC, then send Escape. Run as one command:

```bash
cd ~/Programming/GIT/quickshell
( timeout 12 qs -p window-switcher >/tmp/ws.log 2>&1 & \
  sleep 3 && qs -p window-switcher ipc call switcher toggle && \
  sleep 1 && hyprctl dispatch 'hl.dsp.send_shortcut({ mods = "", key = "escape" })' ; \
  wait )
grep -E "SWITCHER escape" /tmp/ws.log
```

Expected: `SWITCHER escape` appears.

`send_shortcut` sends a press and a release, so the line appears twice. That is
the dispatcher's real name: `sendshortcut` without the underscore does not
exist, and the error it gives reads like the key simply did not arrive.

If the line does not appear at all, the focus grab is not delivering keys. Do
not work around it silently: stop, report, and settle the approach, because
keyboard navigation and Escape both depend on it. The fallback worth trying
first is a `GlobalShortcut` from `Quickshell.Hyprland`.

- [ ] **Step 6: Ask the user to confirm the overlay looks right**

The overlay should be a dim full-screen wash on DP-1 reading "press Escape", and both Escape and a click should dismiss it. Ask; do not screenshot.

- [ ] **Step 7: Commit**

```bash
cd ~/Programming/GIT/quickshell
git add window-switcher/
git commit -m "feat(window-switcher): the overlay skeleton, and a proven Escape

A fifth component, so the same keepalive window as the other four: a
config with no visible window exits without reporting anything, and this
overlay is hidden almost always.

Escape is handled on a focused Item inside the PanelWindow rather than on
the window, because key events reach a focused item and
Keys.onEscapePressed on a PanelWindow never fires. Proven before the grid
is built on top of it, since keyboard navigation depends on the same
grab.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S"
```

---

### Task 2: The window model

**Files:**
- Create: `window-switcher/Windows.qml`

- [ ] **Step 1: Write `window-switcher/Windows.qml`**

After the licence header:

```qml
pragma Singleton

import Quickshell
import Quickshell.Hyprland
import QtQuick

// Everything that touches Hyprland lives here, so the filter, the sort and
// the dispatch syntax are in one place rather than spread across the UI.
Singleton {
    id: root

    // Hyprland.toplevels reads 0 until this is called.
    function refresh() { Hyprland.refreshToplevels(); }

    // Windows on a special: workspace are the scratchpad, which is reached by
    // its own bind and is not something to tab to. Windows on other
    // workspaces are kept whatever their visibility: jumping to another
    // desktop is the point.
    readonly property var windows: {
        const out = [];
        for (const t of Hyprland.toplevels.values) {
            const ws = t.workspace;
            if (!ws || String(ws.name).startsWith("special:")) continue;
            out.push(t);
        }
        // Lowest focusHistoryID is the most recently used.
        out.sort((a, b) => a.focusHistoryID - b.focusHistoryID);
        return out;
    }

    // Dispatch arguments are evaluated as Lua on Hyprland 0.56.2, so the
    // documented-looking `focuswindow address:0x...` is a syntax error that
    // fails silently. This form is the one that works.
    //
    // HyprlandToplevel.address has no 0x prefix, while the dispatch needs one.
    function addressOf(toplevel) {
        const a = String(toplevel.address);
        return a.startsWith("0x") ? a : "0x" + a;
    }

    function focus(toplevel) {
        Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addressOf(toplevel)}" })`);
    }

    function closeWindow(toplevel) {
        Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${addressOf(toplevel)}" })`);
    }
}
```

- [ ] **Step 2: No qmldir**

Do not add one. There is no `qmldir` anywhere in this repo and `Theme` is a
`pragma Singleton` that resolves without one, because quickshell scans the
config directory itself. `Windows` resolves the same way. Confirm rather than
assume:

```bash
find ~/Programming/GIT/quickshell -name qmldir
```

Expected: no output. The note in AGENTS.md about a singleton needing a `qmldir`
is about a singleton *outside* the config directory, which is why the palette is
parsed and `Theme.qml` is symlinked rather than imported from `shared/`.

- [ ] **Step 3: Print the model and verify against hyprctl**

Temporarily add to `Switcher.qml`, inside the keepalive `PanelWindow`:

```qml
Component.onCompleted: {
    Windows.refresh();
}
Timer {
    running: true; interval: 1500
    onTriggered: {
        console.log("WINDOWS", Windows.windows.length);
        for (const w of Windows.windows)
            console.log("  ", Windows.addressOf(w),
                        "ws=" + w.workspace.name,
                        "fh=" + w.focusHistoryID,
                        "app=" + (w.wayland ? w.wayland.appId : "?"));
    }
}
```

Run it, and compare against the compositor:

```bash
cd ~/Programming/GIT/quickshell
timeout 6 qs -p window-switcher >/tmp/ws.log 2>&1
grep -E "WINDOWS|  0x" /tmp/ws.log
echo "--- hyprctl says ---"
hyprctl -j clients | jq -r '[.[] | select(.monitor != -1) | select(.workspace.name | startswith("special:") | not)] | length'
```

Expected: the `WINDOWS` count equals the `hyprctl` count, every address carries a `0x` prefix, no `special:` workspace appears, and `fh=` ascends.

- [ ] **Step 4: Remove the temporary logging**

Delete the `Component.onCompleted` and `Timer` added in Step 3.

- [ ] **Step 5: Commit**

```bash
cd ~/Programming/GIT/quickshell
git add window-switcher/
git commit -m "feat(window-switcher): the window model, filtered and sorted

One place for everything that touches Hyprland, so the UI never issues a
dispatch itself. That matters more than usual here because dispatch
arguments are Lua on 0.56.2 and the documented-looking form is a syntax
error that fails silently, and because HyprlandToplevel.address omits the
0x prefix that a dispatch needs.

Only special: workspaces are filtered, matching the rofi script this
replaces. Windows elsewhere are kept whatever their visibility, since
jumping to another desktop is the point of a switcher.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S"
```

---

### Task 3: One card

**Files:**
- Create: `window-switcher/WindowCard.qml`

- [ ] **Step 1: Write `window-switcher/WindowCard.qml`**

After the licence header:

```qml
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick

Column {
    id: card

    required property var toplevel
    property bool selected: false
    property int cardWidth: 380

    signal activated()
    signal closeRequested()

    readonly property var wl: toplevel.wayland ?? null

    width: cardWidth
    spacing: 8

    // A fixed 16:10 box with the preview fitted inside it. The previews are
    // not one shape: DP-1 windows are near 21:9 and DP-3 is rotated, so its
    // windows are portrait. Sizing each card to its own preview would give
    // ragged rows and break the alignment of the text lines below.
    Rectangle {
        id: box
        width: card.cardWidth
        height: Math.round(card.cardWidth * 10 / 16)
        radius: 10
        color: Theme.surface
        border.color: card.selected ? Theme.accent : Theme.surfaceAlt
        border.width: card.selected ? 3 : 1

        ScreencopyView {
            id: preview
            anchors.centerIn: parent
            captureSource: card.wl
            live: true

            readonly property real ar: sourceSize.height > 0
                ? sourceSize.width / sourceSize.height
                : 16 / 10
            width: Math.min(parent.width - 8, (parent.height - 8) * ar)
            height: Math.min(parent.height - 8, (parent.width - 8) / ar)
        }

        MouseArea {
            anchors.fill: parent
            onClicked: card.activated()
        }

        // Both corner overlays sit on an arbitrary window preview, so each
        // carries its own scrim: a themed icon can otherwise land on a
        // same-coloured region and vanish, a light icon on a white page being
        // the obvious case.
        Rectangle {
            anchors { left: parent.left; top: parent.top; margins: 8 }
            width: 34; height: 34; radius: 8
            color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75)

            IconImage {
                anchors.centerIn: parent
                width: 24; height: 24
                source: Quickshell.iconPath(card.wl ? card.wl.appId : "", true)
            }
        }

        Rectangle {
            id: closeButton
            anchors { right: parent.right; top: parent.top; margins: 8 }
            width: 34; height: 34; radius: 17
            color: closeHover.hovered
                ? Theme.red
                : Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75)

            IconImage {
                anchors.centerIn: parent
                width: 22; height: 22
                source: Quickshell.iconPath("window-close", true)
            }

            HoverHandler { id: closeHover }

            MouseArea {
                anchors.fill: parent
                onClicked: card.closeRequested()
            }
        }
    }

    Text {
        width: parent.width
        horizontalAlignment: Text.AlignHCenter
        elide: Text.ElideRight
        text: card.wl ? card.wl.appId : ""
        color: Theme.text
        font.family: Theme.fontFamily
        font.pixelSize: 16
        font.bold: true
    }

    Text {
        width: parent.width
        elide: Text.ElideRight
        text: card.toplevel.title
        color: Theme.subtext
        font.family: Theme.fontFamily
        font.pixelSize: 13
    }

    Text {
        width: parent.width
        horizontalAlignment: Text.AlignHCenter
        text: "[" + (card.toplevel.workspace ? card.toplevel.workspace.name : "?") + "]"
        color: Theme.accent
        font.family: Theme.fontFamily
        font.pixelSize: 13
    }
}
```

- [ ] **Step 2: Verify it parses**

The card is not referenced yet, so this only proves it compiles:

```bash
cd ~/Programming/GIT/quickshell
timeout 5 qs -p window-switcher >/tmp/ws.log 2>&1
grep -E "Configuration Loaded" /tmp/ws.log
grep -iE "error|is not a|undefined|Unable to assign" /tmp/ws.log
```

Expected: loaded, no errors.

- [ ] **Step 3: Commit**

```bash
cd ~/Programming/GIT/quickshell
git add window-switcher/WindowCard.qml
git commit -m "feat(window-switcher): one card, with the preview fitted

The card box is a fixed 16:10 and the preview is fitted inside it rather
than the card taking its preview's shape. DP-1 windows are near 21:9 and
DP-3 reports transform=1, so its windows are portrait 9:16: a 4.4x span
of aspect ratio in one grid, and ragged rows would break the alignment of
the three text lines that make it scannable.

Both corner overlays carry a scrim. They are drawn over an arbitrary
window, so a themed icon can otherwise land on a same-coloured region and
disappear.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S"
```

---

### Task 4: The grid, the empty state, and the selection

**Files:**
- Modify: `window-switcher/Switcher.qml`

- [ ] **Step 1: Replace the placeholder content in `Switcher.qml`**

Replace the `Text { ... "press Escape" ... }` inside the backdrop `Rectangle` with the grid and empty state, and add selection state to the `Item` that holds focus. The `Item` becomes:

```qml
Item {
    id: keyHandler
    anchors.fill: parent
    focus: true

    property int selectedIndex: 0

    function commit() {
        const list = Windows.windows;
        if (selectedIndex >= 0 && selectedIndex < list.length) {
            Windows.focus(list[selectedIndex]);
            root.close();
        }
    }

    function move(delta) {
        const n = Windows.windows.length;
        if (n === 0) return;
        selectedIndex = (selectedIndex + delta + n) % n;
    }

    Keys.onEscapePressed: root.close()
    Keys.onReturnPressed: commit()
    Keys.onEnterPressed: commit()
    Keys.onLeftPressed: move(-1)
    Keys.onRightPressed: move(1)
    Keys.onUpPressed: move(-grid.columns)
    Keys.onDownPressed: move(grid.columns)
    Keys.onTabPressed: move(1)
    Keys.onBacktabPressed: move(-1)

    Rectangle {
        anchors.fill: parent
        color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.82)

        MouseArea {
            anchors.fill: parent
            onClicked: root.close()
        }

        // Nothing to switch to. A full-screen dim with nothing in it reads
        // as a hang, so it says so instead.
        Column {
            anchors.centerIn: parent
            spacing: 14
            visible: Windows.windows.length === 0

            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: "Nothing here but hopes and dreams..."
                color: Theme.text
                font.family: Theme.fontFamily
                font.pixelSize: 26
            }
            Text {
                anchors.horizontalCenter: parent.horizontalCenter
                text: "go create something beautiful!"
                color: Theme.overlay
                font.family: Theme.fontFamily
                font.pixelSize: 32
            }
        }

        Grid {
            id: grid
            anchors.centerIn: parent
            visible: Windows.windows.length > 0

            readonly property int count: Windows.windows.length
            readonly property int gap: 30
            readonly property int maxCardWidth: 640

            // Columns are the window count capped at six, so one card sits
            // dead centre and two straddle the middle.
            columns: Math.max(1, Math.min(count, 6))

            // Cards grow to fill their row, up to a ceiling. Without the
            // ceiling a lone preview becomes a full-screen mirror of the
            // window it stands for.
            readonly property int cardWidth: Math.min(
                maxCardWidth,
                Math.floor((parent.width * 0.92 - (columns - 1) * gap) / columns))

            spacing: gap

            Repeater {
                model: Windows.windows

                WindowCard {
                    required property int index
                    required property var modelData

                    toplevel: modelData
                    cardWidth: grid.cardWidth
                    selected: index === keyHandler.selectedIndex

                    onActivated: {
                        keyHandler.selectedIndex = index;
                        keyHandler.commit();
                    }
                    // Closing is a side errand, so the overlay stays open:
                    // a dismissal would mean reopening to close a second
                    // window. The card goes when Hyprland says it went.
                    onCloseRequested: Windows.closeWindow(modelData)
                }
            }
        }
    }
}
```

- [ ] **Step 2: Refresh the model and reset the selection when the overlay opens**

Add to the `Scope` in `Switcher.qml`, after the `toggle`/`close` functions:

```qml
onOpenChanged: if (open) Windows.refresh();
```

And inside the `LazyLoader`'s `PanelWindow`, so each opening starts on the most recently used window:

```qml
Component.onCompleted: keyHandler.selectedIndex = 0
```

- [ ] **Step 3: Verify it loads with the grid**

```bash
cd ~/Programming/GIT/quickshell
timeout 5 qs -p window-switcher >/tmp/ws.log 2>&1
grep -E "Configuration Loaded" /tmp/ws.log
grep -iE "error|is not a|undefined|Unable to assign" /tmp/ws.log
```

Expected: loaded, no errors.

- [ ] **Step 4: Ask the user to look at it**

Start it and open the overlay:

```bash
cd ~/Programming/GIT/quickshell
( timeout 30 qs -p window-switcher >/tmp/ws.log 2>&1 & \
  sleep 3 && qs -p window-switcher ipc call switcher toggle ; wait )
```

Ask the user to confirm: previews are live and fitted, both orientations look right, the grid is centred, the icon and close button are legible over the previews, the three text lines read correctly, arrows and TAB move the selection border, Enter focuses, clicking a card focuses, the close button closes a window without dismissing the overlay, and Escape leaves. Do not screenshot.

- [ ] **Step 5: Commit**

```bash
cd ~/Programming/GIT/quickshell
git add window-switcher/Switcher.qml
git commit -m "feat(window-switcher): the grid, the selection, and an empty state

Columns are the window count capped at six and the grid centres, so one
card sits dead centre and two straddle the middle. Cards grow to fill
their row up to 640px: without a ceiling a lone preview becomes a
full-screen mirror of the window it stands for.

The close button leaves the overlay open, because closing is a side
errand and a dismissal would mean reopening to close a second window.
That makes an empty overlay reachable by closing the last window, so
there is an empty state rather than a full-screen dim with nothing in it,
which reads as a hang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S"
```

---

### Task 5: Wire it to ALT + TAB

The Hyprland config is not in this repo, and AGENTS.md records that absolute paths belong only in the live config. These edits happen outside the repository.

**Files:**
- Modify: `~/.config/hypr/sections/keybindings.lua:50`
- Modify: `~/.config/hypr/sections/autostart.lua`
- Modify: `~/.config/hypr/sections/decorations.lua`

- [ ] **Step 1: Add the component to autostart**

Read how the other four are started, and follow it exactly:

```bash
grep -n "quickshell" ~/.config/hypr/sections/autostart.lua
```

Add a line for `window-switcher` in the same form as the existing entries.

- [ ] **Step 2: Repoint the ALT + TAB bind**

In `~/.config/hypr/sections/keybindings.lua`, line 50 currently reads:

```lua
hl.bind("ALT + TAB",                   hl.dsp.exec_cmd("hypr-windows.sh"))
```

Replace it with, following the `SUPER + v` line just below it as the model, and writing the path out in full as that line does. It is shown with a `~` here because this plan is committed and a home path in a repo file is blocked by a hook; the live config is not in this repo and takes the absolute path:

```lua
hl.bind("ALT + TAB",                   hl.dsp.exec_cmd("qs -p ~/Programming/GIT/quickshell/window-switcher ipc call switcher toggle"))
```

Leave `~/bin/hypr-windows.sh` in place, unbound, exactly as `rofi-qemu.sh` was left when `vm-manager` took over.

- [ ] **Step 3: Add the blur rule**

The overlay sets `WlrLayershell.namespace: "window-switcher"`, and Hyprland blurs a layer surface only when a rule names it. In `~/.config/hypr/sections/decorations.lua`, follow the existing `hl.layer_rule` entries and add one matching `window-switcher`.

```bash
grep -n "layer_rule" ~/.config/hypr/sections/decorations.lua
```

- [ ] **Step 4: Reload Hyprland and test the real bind**

```bash
hyprctl reload
```

Then ask the user to press `ALT + TAB` and confirm the overlay opens, since this is the first time the actual keybind is in play. Binds, layer rules and autostart need `hyprctl reload`; the component's QML hot-reloads on save and needs nothing.

- [ ] **Step 5: Commit**

Nothing here is in the repo, so there is nothing to commit. Say so rather than committing an empty change.

---

### Task 6: Documentation

**Files:**
- Create: `window-switcher/README.md`
- Modify: `README.md`
- Modify: `AGENTS.md`

- [ ] **Step 1: Write `window-switcher/README.md`**

Follow `mail-overview/README.md` in shape: what it is, an ASCII sketch of the layout, how to run it, how it is reached over IPC, and a notes section. The notes that belong to this component:

- Dispatch arguments are Lua on Hyprland 0.56.2, so `dispatch focuswindow address:0x...` is a syntax error that fails silently, and `hl.dsp.focus({ window = "address:0x..." })` is the form that works.
- `HyprlandToplevel.address` has no `0x` prefix; dispatches need one.
- `Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.
- A card is a fixed 16:10 box with the preview fitted, because DP-3 is rotated and its windows are portrait while DP-1's are near 21:9.
- Focus alone raises a stacked window on a `monocle` workspace, tested on workspace 1, so no `alterzorder` and no batch sequence are needed.

- [ ] **Step 2: Add the component to the repo README**

`README.md` lists the components. Add `window-switcher/` in the same style, with its one-line description.

- [ ] **Step 3: Add the generalising traps to AGENTS.md**

AGENTS.md carries the per-component notes that generalise. Add, in the style of the existing bullets:

- **Hyprland dispatch arguments are Lua.** `dispatch focuswindow address:0x...` is a syntax error, not a command, and it fails silently unless stderr is read. The working form is `dispatch hl.dsp.focus({ window = "address:0x..." })`. This is why `hypr-windows.sh` looks the way it does.
- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` reports and that dispatches require.
- **`Hyprland.toplevels` is empty until `refreshToplevels()`.**

Also update the component list at the top of AGENTS.md to five, and note in the Blur section that `window-switcher` has its own namespace and layer rule.

- [ ] **Step 4: Verify the symlink survived, and that nothing else drifted**

```bash
cd ~/Programming/GIT/quickshell
ls -l */Theme.qml
md5sum shared/Theme.qml */Theme.qml | sort | uniq -c -w32
```

Expected: five symlinks, all pointing at `../shared/Theme.qml`, and one checksum group.

- [ ] **Step 5: Commit**

```bash
cd ~/Programming/GIT/quickshell
git add README.md AGENTS.md window-switcher/README.md
git commit -m "docs(window-switcher): the component README, and the traps it found

Three of these generalise beyond this component and go in AGENTS.md.
Dispatch arguments are Lua on 0.56.2, so the documented-looking form is a
silent syntax error; HyprlandToplevel.address omits the 0x that a
dispatch needs; and Hyprland.toplevels is empty until refreshToplevels()
is called.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S"
```

---

## Done means

`ALT + TAB` opens a centred grid of live previews on DP-1, one card per non-special window, each with its icon, name, title and workspace. Clicking or Enter focuses and dismisses; the corner button closes a window and leaves the overlay up; Escape or a backdrop click leaves. With no windows it says so. The user has looked at it and is happy, which is the only check that counts for anything visual here.