aboutsummaryrefslogtreecommitdiffstats
path: root/window-switcher/README.md
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-12 19:19:41 +0200
committerDanilo M. <danix@danix.xyz>2026-09-12 19:19:41 +0200
commitf84176cf1c866da4d263d278774932acb81b4b8d (patch)
treece6cb066d08901db7b5957db6fc38dc06e4e4f45 /window-switcher/README.md
parent94b99069f00413e5ad426c93a8ffbd52386d5b4e (diff)
downloadquickshell-f84176cf1c866da4d263d278774932acb81b4b8d.tar.gz
quickshell-f84176cf1c866da4d263d278774932acb81b4b8d.zip
docs(window-switcher): write down what the build cost to learn
The component is finished and on the user's ALT+TAB, so what is left is the part that would otherwise have to be rediscovered. Most of the time this build took went to three things that report success and then do nothing: Hyprland 0.56.2 evaluating dispatch arguments as Lua, so the documented-looking `focuswindow address:0x...` is a silent syntax error; a focus dispatched while the overlay still holds an exclusive keyboard grab being accepted and ignored; and a sort on a `focusHistoryID` property that does not exist, comparing NaN and leaving an arbitrary order that looks plausible. None of them logs anything. Those six, plus the missing `0x` prefix, the empty `toplevels` before `refreshToplevels()` and `ScreencopyView` reporting (-1, -1) rather than (0, 0), generalise past this component, so they go in AGENTS.md next to the other notes that exist because a commit message explained itself once. The card geometry and the blank icon square are specific to the grid and stay in the component README. Also corrects the counts that four components made true and five do not: the component lists, "the other three use ExclusionMode.Ignore", and the Theme symlink note. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S
Diffstat (limited to 'window-switcher/README.md')
-rw-r--r--window-switcher/README.md151
1 files changed, 151 insertions, 0 deletions
diff --git a/window-switcher/README.md b/window-switcher/README.md
new file mode 100644
index 0000000..3cfb28b
--- /dev/null
+++ b/window-switcher/README.md
@@ -0,0 +1,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`.