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
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
|
# 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.
## 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.
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.
|