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
|
# window-switcher
Every open window as a live preview, scattered over a dimmed DP-1 the way
macOS Mission Control does it. ALT+TAB or the top-right corner of DP-1 opens
it, a click picks a window, Escape drops it. It replaced a uniform grid, which
replaced a rofi list.
┌──────────────────────────────────────────────────────────────┐
│ ┌───────────────┐ ◜ │
│ │ [icon][ws] │ ┌────┐ ┌───────────────┐ │
│ │ │ │ │ │ │ │
│ └───────────────┘ │ │ │ (hovered) │ │
│ ┌───────────────┐ ┌──────────┐ │ │ │ title... [x]│ │
│ │ │ │ most │ │ │ └───────────────┘ │
│ │ │ │ recent │ └────┘ │
│ └───────────────┘ └──────────┘ ┌───────────────┐ │
│ │ │ │
│ └───────────────┘ │
└──────────────────────────────────────────────────────────────┘
Every preview keeps its window's real shape and its size relative to the
others: one scale for all, so a portrait DP-3 window is visibly narrower and
taller than a DP-1 one. The most recently used window sits at the centre and
older ones spiral outwards. Where a window lives on its desktop carries no
meaning here, by choice. Windows on a `special:` workspace are the scratchpad,
which has its own bind, and are left out.
Each preview's desktop marker is an icon for that workspace rather than a bare
number. The eight icons come from `Workspaces.qml`, a singleton in `shared/`
that this component links, as it does `Theme.qml`, and that the island's flick
uses too, so the two agree. The names are resolved through the active icon
theme with `Quickshell.iconPath`. Hot reload does not follow the symlink, so
after editing `Workspaces.qml` reload both. A named workspace, or one past eight, has no
icon and falls back to `[name]`.
With nothing to switch to it says so, because a full-screen dim with nothing in
it reads as a hang.
## Running it
qs -p .
It is started from `autostart.lua` and reached over IPC, so the shell has to be
running for the keybind to do anything:
qs -p ~/Programming/GIT/quickshell/window-switcher ipc call switcher toggle
Write that path out in full in the real config: Hyprland's Lua strings have no
shell to expand `~`. The bind lives in `keybindings.lua` on ALT+TAB. `show` and
`close` exist on the same IPC target for anything that wants one direction only.
## Interaction
| | |
| --- | --- |
| ALT+TAB | open, or close if already open |
| top-right corner of DP-1 | open, after the glow (see below) |
| click a preview | focus that window and dismiss |
| the `x` on a hovered preview | close that window, overlay stays open |
| Escape, or a click on the backdrop | dismiss, focus unchanged |
The title and the `x` show only on hover, which also draws the accent border.
There is no keyboard selection: this is a pointer feature, and the keyboard
keeps only the way in and Escape.
Closing leaves the overlay up on purpose. Tidying several windows would
otherwise mean reopening between each one, and the preview goes when Hyprland
says the window went, not when the button is pressed. The rest then move to
fill the space.
## Hot corner
`HotCorner.qml` is a 48x48 layer window in the top-right corner of DP-1, which
is a true corner: DP-1 is the right-hand monitor, so the pointer stops there.
Its input region is only the 2x2 pixels in the very corner. The rest is drawn
but click-through, because a larger input area would swallow clicks meant for
the windows underneath. That is also why it cannot light up while the pointer
is merely approaching.
Touching the corner grows a quarter-circle accent glow out of it over 250ms,
and the expose opens when it is full. Leaving first shrinks it back and
nothing opens. The dwell is what keeps a pointer passing through on its way to
something else from triggering it.
After it fires it re-arms only once the pointer leaves the corner while the
expose is closed. Opening the expose covers the corner, which reads as
leaving, so without that rule an Escape with the pointer still in the corner
would reopen it 250ms later.
It is hidden while `status.gaming` is set. It reads that file from
`$XDG_RUNTIME_DIR` itself rather than linking `shared/Status.qml`, which is not
passive: a second instance would run its own game poll and react to
presentation mode a second time, pausing breaktimer twice.
## Hyprland dispatches from QML
**Dispatch arguments are evaluated as Lua on Hyprland 0.56.2.** The form that
reads like the documented one, `dispatch focuswindow address:0x...`, is a
syntax error rather than a command, and it fails silently unless stderr is
read. What works:
hl.dsp.focus({ window = "address:0x..." })
hl.dsp.window.close({ window = "address:0x..." })
`~/bin/hypr-windows.sh`, the script behind the rofi list this replaces, is
written that way for this reason. While planning, its syntax was read as
legacy oddity worth modernising, which would have broken it.
**A focus dispatched while the overlay is up is accepted and then ignored.**
The layer surface holds keyboard focus exclusively and the compositor will not
move window focus out from under that grab. The dispatch reports `ok`, nothing
happens, and no log line says otherwise. Closing in the same turn does not help
either: `close()` only clears a property, and the surface survives until the
frame after. So the target is remembered, the overlay closes, and a 60ms timer
does the focus once the grab is really gone. The symptom was picking a window
on the other monitor and seeing nothing happen.
**Focus alone raises a stacked window.** Workspaces 1 and 8 here are `monocle`
and 4 and 5 are scrolling, so stacking is normal rather than an edge case, and
it was tested with two stacked windows on workspace 1 in both directions. No
`alterzorder` and no batch sequence are needed, which is why the fullscreen
batch in `hypr-windows.sh` was not carried over.
## Reading the toplevel list
**`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.** The
model is refreshed on every opening, which also picks up windows opened since
the last one.
**`HyprlandToplevel` has no `focusHistoryID` property.** It reads `undefined`,
so a sort on it compares `NaN` and does nothing at all, leaving a plausible
looking list in arbitrary order. The value is on `lastIpcObject` instead, which
is what `focusOrder()` reads, and a missing one sorts last rather than as 0.
**`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints and
that a dispatch requires, so `addressOf()` puts it back.
**`toplevel.wayland` can be null.** The Hyprland toplevel and its wlr handle
are separate objects created at different moments, so a window that is
appearing or being destroyed has one and not the other. The preview guards every
use of it.
## The scatter
`Expose.js` holds the layout, pure JavaScript tested under node:
node window-switcher/test_expose.js
Windows are seeded on a golden-angle spiral, stretched to the screen's aspect,
then overlapping pairs are pushed apart, each by half the overlap with a 60px
real gap, until nothing overlaps. Thirty gravity rounds follow, each pulling
every window 15% closer to the centre and pushing them apart again: without
them the spiral's gaps stayed and the scatter read as loose, filling 24 to 30%
of the screen; with them 35 to 55%. Twenty windows lay out in about 4ms. A single scale then fits the result into 92%
of the screen, capped at 0.5 so a lone window does not become a full-screen
mirror of itself.
**Seeding from real positions does not work on this desk.** It was the first
design: every window here fills its monitor, so they all started at the same
spot, and pushing a pile apart made a line that still overlapped after 300
passes and filled 8 to 15% of the screen. The spiral converged in 1 to 55
passes.
**The push axis is chosen against the screen's shape, not in raw pixels.** In
pixels a wide window overlaps less vertically, so wide windows always
separated up and down and stacked into a column on a 21:9 screen.
**Real sizes come from `lastIpcObject.size`.** A window without one yet stands
in at 1280x800 until it arrives; the layout is a binding and repaints.
**An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, not
`(0, 0)`. The preview's aspect ratio guard therefore tests for a positive height. The
obvious defensive rewrites, `!== 0` or a plain truthiness check, all pass on
`-1` and produce a negative ratio.
**A `Repeater` delegate with required properties needs both `required property
int index` and `required property var modelData`.** Declaring `modelData`
required makes the implicit `index` unavailable.
**An app with no themed icon renders a blank scrim square.**
`Quickshell.iconPath("", true)` returns `""`, and an `IconImage` with an empty
source draws nothing. There is no fallback glyph. Both corner overlays carry
their own scrim regardless, because a themed icon on an arbitrary window
preview can otherwise land on a same-coloured region and vanish.
## Theme and blur
`Theme.qml` is a symlink to `shared/Theme.qml`: the palette comes from
`~/.cache/udt/udt-palette.qml` and is watched.
Frosting is Hyprland's, matched on this window's namespace:
hl.layer_rule({
name = "blur-window-switcher",
match = { namespace = "^(quickshell-window-switcher)$" },
blur = true,
xray = false,
ignore_alpha = 0.1,
})
Without the rule it still works, rendering flat translucent. The overlay covers
the whole screen, so unlike `mail-overview` it uses
`ExclusionMode.Ignore`.
|