aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/README.md
blob: 3cfb28b380122e2f72079fd2e1e477263e78a6df (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
# window-switcher

Every open window as a live preview, in one centred grid over a dimmed screen.
ALT+TAB opens it, a click or Enter picks a window, Escape drops it. It replaces
a rofi list that showed the same windows as text.

    ┌──────────────────────────────────────────────────────────────┐
    │                                                              │
    │      ┌────────────────────┐    ┌────────────────────┐        │
    │      │ [icon]         [x] │    │ [icon]         [x] │        │
    │      │                    │    │                    │        │
    │      │   live preview     │    │   live preview     │        │
    │      │   fitted in a      │    │                    │        │
    │      │   16:10 box        │    │                    │        │
    │      └────────────────────┘    └────────────────────┘        │
    │               firefox                   kitty                │
    │       a page title, elided...    ~/Programming/GIT/...       │
    │                 [1]                      [4]                 │
    │                                                              │
    └──────────────────────────────────────────────────────────────┘

The cards are ordered most recently used first, so the window you just left is
the first one, the same order ALT+TAB implies. Windows on a `special:`
workspace are the scratchpad, which has its own bind, and are left out.

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 |
| click a card, or Enter | focus that window and dismiss |
| arrows | move the selection, up and down by a row |
| Tab / Shift+Tab | move the selection by one |
| the `x` on a card | close that window, overlay stays open |
| Escape, or a click on the backdrop | dismiss, focus unchanged |

Closing leaves the overlay up on purpose. Tidying several windows would
otherwise mean reopening between each one, and the card goes when Hyprland says
the window went, not when the button is pressed.

## 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 card guards every
use of it.

## Card geometry

**A card is a fixed 16:10 box with the preview fitted inside**, not a box
shaped to its preview. The previews are not one shape: DP-1 windows are near
21:9 (2556x1034) and DP-3 reports `transform=1`, so its windows are portrait
9:16 (1076x1916). Sizing each card to its own window gives ragged rows and
breaks the alignment of the three text lines under them.

**Card width is clamped by the height as well as the width.** A centred `Grid`
has no way to scroll, so without the height term a busy desktop pushes rows off
the top and bottom where they cannot be reached. The cards shrink instead.
There is a ceiling on the width too, or a single window becomes a full-screen
mirror of itself.

**An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, not
`(0, 0)`. The 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/wal/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, waybar included, so unlike `mail-overview` it uses
`ExclusionMode.Ignore`.