aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-12-window-switcher-design.md
blob: ce95f3ea5d418f91ff74bfe22cd22bacc8b2d3fc (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
# 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 odd-looking
`hl.dsp.focus({ window = "address:..." })` dispatch is confirmed working, so
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.

Cards are a fixed size and the grid wraps, centered. A preview is 21:9, so a
card readable at arm's length is around 380px wide and DP-1 at 2560px fits six
per row. Six or fewer windows therefore render as the single row this started
as; more wrap rather than shrinking, so a large window count degrades by
growing downwards instead of becoming unreadable.

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.

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

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.

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

## 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. Whether plain
  `focuswindow` still needs it is unknown; it goes back in only if a fullscreen
  window is actually observed staying on top.
- **Multi-monitor overlays.** DP-1 only. DP-3's windows appear as cards, the
  overlay just does not paint there.
- **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.