diff options
Diffstat (limited to 'docs/superpowers/specs')
| -rw-r--r-- | docs/superpowers/specs/2026-09-12-window-switcher-design.md | 228 |
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. |
