aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-12-window-switcher-design.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/specs/2026-09-12-window-switcher-design.md')
-rw-r--r--docs/superpowers/specs/2026-09-12-window-switcher-design.md228
1 files changed, 228 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-12-window-switcher-design.md b/docs/superpowers/specs/2026-09-12-window-switcher-design.md
new file mode 100644
index 0000000..487de1d
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-12-window-switcher-design.md
@@ -0,0 +1,228 @@
+# window-switcher
+
+A full-screen grid of live window previews, replacing the rofi list that
+`ALT + TAB` runs today. The fifth quickshell component, and like the other four
+a complete shell in its own right.
+
+## Why
+
+`~/bin/hypr-windows.sh` works, but it is a textual rofi list: class, workspace,
+title and address in four columns. It says which windows exist, not what is in
+them. A preview grid answers "which window did I mean" by showing it, which is
+the whole reason a switcher exists.
+
+The replacement is therefore not a bug fix. The script's
+`hl.dsp.focus({ window = "address:..." })` dispatch is not the legacy oddity it
+resembles, it is the only form this Hyprland accepts, and it works. Nothing is
+being repaired here, only improved.
+
+## What it looks like
+
+A full-screen translucent overlay on DP-1, windows as a wrapping grid of cards:
+
+ ┌─────────────────────────┐ ┌─────────────────────────┐
+ │ [icon] [x] │ │ [icon] [x] │
+ │ live preview │ │ live preview │
+ └─────────────────────────┘ └─────────────────────────┘
+ Firefox kitty
+ noland.dnx services — … ◐ HANDOFF.md
+ [workspace 1] [workspace 3]
+
+Per card, top to bottom:
+
+- The live preview, with two corner overlays: the app icon top-left and a
+ close button top-right.
+- The application name, centered.
+- The window title, left aligned, elided at the card width.
+- The workspace, centered, in brackets and in the accent colour.
+
+## Card size and the grid
+
+Previews are not one shape. DP-1 is landscape and its windows are 2556x1034,
+near 21:9. DP-3 has `transform=1`, so it is rotated and its windows are
+1076x1916, portrait 9:16. A fullscreen app is portrait there too. That is a
+4.4x span of aspect ratio inside one grid, so a card cannot take its shape from
+its preview.
+
+Instead the card is a fixed 16:10 box and the preview is fitted inside it,
+preserving aspect. A landscape preview letterboxes slightly, a portrait one
+pillarboxes to roughly a third of the box width, and the empty space reads as
+framing given the box has a background. 16:10 is the compromise: less extreme
+than either input, so neither orientation is punished. The alternative, sizing
+each card to its own preview, was rejected because ragged rows would break the
+alignment of the three text lines under each card, which is what makes a grid
+scannable.
+
+The grid centres, and its column count is the window count capped at six. One
+window sits dead centre, two straddle the middle, three centre on the middle
+one. Seven wraps to a centred 4 and 3.
+
+Cards grow to fill the row they are in, up to a ceiling of 640px wide. So a
+lone window gets a 640px card, where a DP-1 preview renders 640x259 and a DP-3
+one 226x400: bigger, but still a preview rather than something pretending to be
+the window. Six windows give cards around 380px. The ceiling is what stops a
+single preview from becoming a full-screen mirror.
+
+The name is redundant with the icon and usually with the title too. It is kept
+deliberately: it is short and stable, so the eye has something to land on when
+scanning a grid, and it still reads when a title is empty or cryptic.
+
+Both corner overlays need a scrim or shadow behind them. A themed icon drawn
+over an arbitrary window preview can land on a same-coloured region and
+disappear, a light icon on a white page being the obvious case.
+
+## With nothing to show
+
+When the filtered list is empty, the overlay still opens, and shows two centred
+lines in place of the grid:
+
+ Nothing here but hopes and dreams...
+ go create something beautiful!
+
+The second is larger and dimmer than the first.
+
+The state is reachable two ways: a fresh session before anything is launched,
+and closing the last window from the switcher itself, which stays open by
+design. Opening on nothing was considered and rejected in favour of this,
+because a full-screen dim with nothing in it reads as a hang.
+
+Since there is no card to click in this state, the backdrop closes the overlay
+on click as well as on Escape. That holds generally, not only when empty.
+
+## Data
+
+One source: `Hyprland.toplevels`, refreshed with `Hyprland.refreshToplevels()`
+when the overlay opens. Each `HyprlandToplevel` carries everything the card
+needs:
+
+| Field | Use |
+| --- | --- |
+| `address` | focus and close dispatches |
+| `workspace.name` | the bracketed label, and the filter |
+| `title` | the title line |
+| `.wayland` | the `ScreencopyView` capture source, and `appId` |
+
+No `hyprctl` subprocess and no `jq`, unlike the script being replaced.
+
+Windows on a workspace whose name starts with `special:` are dropped, matching
+the script's `grep -v special`. Windows on other workspaces are kept whatever
+their visibility: jumping to a window on another desktop is the point, and the
+workspace label says where the jump goes.
+
+Order is by `focusHistoryID`, so the last-used window comes first.
+
+## Interaction
+
+Toggle-style, not hold-and-release. `ALT + TAB` opens the overlay and it stays
+open; there is no attempt to detect the ALT release.
+
+| Input | Result |
+| --- | --- |
+| click a card | focus that window, close the overlay |
+| close button | close that window, overlay stays open |
+| arrows / TAB | move the selection |
+| Enter | focus the selection, close the overlay |
+| Escape | close the overlay, focus nothing |
+| click the backdrop | close the overlay, focus nothing |
+
+The close button leaves the overlay open because closing is a side errand: a
+dismissal would mean reopening to close a second window. The card disappears on
+the Hyprland event, so nothing needs refreshing by hand.
+
+## The traps this has to clear
+
+Three are already recorded in AGENTS.md and all three apply here.
+
+**A config with no visible window exits.** The overlay is hidden most of the
+time, so the component needs the same 1x1 transparent `PanelWindow` with
+`mask: Region {}` that the other four hold themselves open with.
+
+**Key events reach a focused item, not a window.** `WlrLayershell.keyboardFocus:
+Exclusive` is necessary but not sufficient, and `Keys.onEscapePressed` on a
+`PanelWindow` never fires. Keys are handled on an `Item` inside the overlay with
+`focus: true`. This is the part to prove first, on a bare overlay, before any
+grid is built on top of it.
+
+**`qs` is the process name, and `pkill -f` matches the caller.** Verification
+uses `pkill -x qs` and `pgrep -cx qs`, and a detached `qs` does not survive a
+tool call, so it is started such that the harness owns the process and confirmed
+from the log rather than a later `pgrep`.
+
+A fourth is specific to this component: `HyprlandToplevel.address` reads
+`1140f190`, without the `0x` that `hyprctl clients` reports, while dispatches
+need the prefix.
+
+A fifth cost half an hour of this design and would have cost more during
+implementation. **Hyprland 0.56.2 evaluates dispatch arguments as Lua**, so the
+documented-looking form is a syntax error:
+
+ dispatch focuswindow address:0x11971b20
+ → error: ')' expected near 'address'
+
+ dispatch hl.dsp.focus({ window = "address:0x11971b20" })
+ → ok
+
+The first fails silently unless stderr is checked: the window simply does not
+focus and nothing is reported. This is why `hypr-windows.sh` is written the way
+it is, and its syntax was wrongly read as legacy oddity earlier in this design.
+Both focus and close go through the `hl.dsp.*` form.
+
+## What was verified before writing this
+
+Against Hyprland 0.56.2 and quickshell 0.3.1, in a scratch config:
+
+- `ScreencopyView` is compiled in, with both wlroots and hyprland toplevel
+ export. A capture rendered at 2556x1034.
+- All four open windows captured, the scratchpad on `special:special`
+ included. Hidden windows therefore do not need an icon-only fallback.
+- `Hyprland.toplevels` reads 0 until `refreshToplevels()` is called, then
+ reports all four with workspace and `.wayland` links intact.
+- `Quickshell.iconPath` resolves through `Material-Black-Plum-Suru`, for
+ `window-close` and for app icons.
+- `DesktopEntries.byId` returns null for bare app ids such as `firefox`. The
+ app id itself resolves as an icon name, so lookup falls back to it.
+- DP-3 reports `transform=1`, and the window on it measures 1076x1916 against
+ 2556x1034 on DP-1. Both orientations are live at once, which is what the
+ fitted card box exists for.
+- Layouts vary per workspace, from `workspaces.lua`: 1 and 8 are `monocle`,
+ 4 and 5 are `scrolling`, the rest are the default `master`. Focusing a
+ stacked window on a `monocle` workspace raises it without further help.
+- Dispatch arguments are Lua on this version, see the trap above.
+
+## Theme
+
+No palette of its own. `Theme.qml` is a symlink to `shared/Theme.qml` like the
+other three, and the accent used for the workspace label comes from
+`udt-palette.qml`, so it follows the wallpaper.
+
+A distinct `WlrLayershell.namespace` of `window-switcher`, so a Hyprland
+`layer_rule` can blur it later. Without the rule it renders flat translucent,
+which is fine.
+
+## Deliberately left out
+
+- **The fullscreen dance.** The script has a `--batch` sequence for focusing a
+ window when another is fullscreen on the same workspace. It is not carried
+ over. Workspaces 1 and 8 are `monocle` and 4 and 5 are `scrolling`, so
+ stacking is the normal state here rather than an edge case, and the concern
+ was that focus would land without the window becoming visible. Tested with
+ two stacked windows on workspace 1: `hl.dsp.focus` alone raised the buried
+ one, confirmed on screen in both directions. No `alterzorder` and no batch
+ sequence. It goes back in only if a fullscreen window is actually observed
+ staying on top.
+- **Multi-monitor overlays.** The overlay paints on DP-1 only. DP-3's windows
+ still appear as cards, and being portrait they are the reason the card box is
+ a fitted 16:10 rather than the shape of a preview.
+- **Hold-and-release ALT + TAB.** Considered and rejected: it depends on the
+ switcher seeing the ALT release, and a missed release leaves a stuck
+ full-screen overlay.
+- **Icon-only fallback for uncapturable windows.** Every window captured, so
+ the case has not been observed.
+
+## Done means
+
+The overlay opens on `ALT + TAB` over IPC, shows every non-special window as a
+live preview with icon, name, title and workspace, focuses on click and on
+Enter, closes a window from the corner button without dismissing, and leaves on
+Escape. The user judges the visual result; screenshots of a transient overlay
+are a race.