# 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.