From 697bb731fdf9584650b2e075a63da6eba5566eab Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 12 Sep 2026 18:40:24 +0200 Subject: docs(window-switcher): the implementation plan Six tasks, each ending in a commit: the skeleton and the keyboard grab, the window model, one card, the grid and empty state, the Hyprland wiring, and the docs. Two things were checked against the running system while writing it rather than left for the implementation to discover. The keyboard grab works: Keys.onEscapePressed on a focused Item inside the layer surface fires, so the AGENTS.md workaround holds and keyboard navigation is safe to build on. And the dispatcher for sending a key is send_shortcut with the underscore; sendshortcut does not exist, and a test written against that name would have failed in a way that looks exactly like the key never arriving. No qmldir is added. There is none anywhere in this repo and Theme resolves without one, since quickshell scans the config directory itself; the AGENTS.md note about needing one is about singletons outside it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S --- .../plans/2026-09-12-window-switcher.md | 849 +++++++++++++++++++++ 1 file changed, 849 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-12-window-switcher.md (limited to 'docs/superpowers/plans') diff --git a/docs/superpowers/plans/2026-09-12-window-switcher.md b/docs/superpowers/plans/2026-09-12-window-switcher.md new file mode 100644 index 0000000..79f3be1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-12-window-switcher.md @@ -0,0 +1,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. +// +// 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 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 +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 +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 +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 +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 +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. -- cgit v1.2.3