From f84176cf1c866da4d263d278774932acb81b4b8d Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sat, 12 Sep 2026 19:19:41 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_01SYg4wYHq5XNbiVmMeKRb1S --- AGENTS.md | 41 +++++++++++-- README.md | 9 +-- window-switcher/README.md | 151 ++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 10 deletions(-) create mode 100644 window-switcher/README.md diff --git a/AGENTS.md b/AGENTS.md index 3a2e504..6a58d76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,10 +8,11 @@ Quickshell components for a Hyprland desktop, one per directory, each a complete shell in its own right. They are not modules of a single bar: any of them runs alone, and running one does not require the others. - volume-osd/ volume for output and input, plus what is playing - vm-manager/ libvirt drawer: state, live stats, snapshots - appearance/ wallpaper picker and colour scheme switcher - mail-overview/ notmuch unread counts per account, waybar icon and drawer + volume-osd/ volume for output and input, plus what is playing + vm-manager/ libvirt drawer: state, live stats, snapshots + appearance/ wallpaper picker and colour scheme switcher + mail-overview/ notmuch unread counts per account, waybar icon and drawer + window-switcher/ open windows as live previews in a grid, on ALT+TAB They are started from `~/.config/hypr/sections/autostart.lua` and keep running for the whole session. @@ -102,6 +103,31 @@ changing that component. The ones that generalise: `block.allocation` is qcow2 growth on the host, not usage inside the guest. The real numbers come from qemu-guest-agent, and the panel shows a dash rather than substituting the host-side ones. +- **Hyprland 0.56.2 evaluates dispatch arguments as Lua.** `dispatch + focuswindow address:0x...` is a syntax error rather than a command, and it + fails silently unless stderr is read. The working form is `dispatch + hl.dsp.focus({ window = "address:0x..." })`. This is why + `~/bin/hypr-windows.sh` is written the way it is, and its syntax was misread + as legacy oddity worth modernising. +- **A focus dispatched while an overlay holds the keyboard is accepted and then + ignored.** A layer surface with `keyboardFocus: Exclusive` grabs the + keyboard, and the compositor will not move window focus out from under that + grab. The dispatch reports `ok`, nothing moves, and no log line says + otherwise. Closing in the same turn does not help either, because a `close()` + that clears a property leaves the surface alive until the frame after. Focus + after the overlay is really gone, which `window-switcher` does with a 60ms + timer. +- **`HyprlandToplevel` has no `focusHistoryID` property.** It reads + `undefined`, so a sort on it compares `NaN` and silently does nothing, + leaving a plausible looking list in arbitrary order. The value is on + `lastIpcObject`. +- **`HyprlandToplevel.address` omits the `0x`** that `hyprctl clients` prints + and that every dispatch requires. +- **`Hyprland.toplevels` reads 0 until `refreshToplevels()` is called.** +- **An uncaptured `ScreencopyView` reports `sourceSize` of `QSize(-1, -1)`**, + not `(0, 0)`. An aspect ratio guard has to test for a positive height: the + obvious rewrites, `!== 0` or a truthiness check, all pass on `-1` and produce + a negative ratio. ## Theme @@ -128,7 +154,7 @@ they never had. A symlink rather than a shared import path because a singleton outside the config directory needs a `qmldir`, which is the same friction that keeps the palette parsed rather than imported; quickshell follows the link and resolves the singleton with no qmldir and no consumer change. Editing any -component's `Theme.qml` edits all four. Do not replace a link with a copy. +component's `Theme.qml` edits all five. Do not replace a link with a copy. ## Blur @@ -144,9 +170,12 @@ A panel that should sit below waybar rather than over it wants exclusive zone without the component knowing the bar's height. Measured with `hyprctl layers`: waybar at `y=-540 h=42`, a `Normal` overlay on the same screen at `y=-498 h=1038`, starting exactly where the bar ends, so the -backdrop never dims it. `mail-overview` does this; the other three use +backdrop never dims it. `mail-overview` does this; the other four use `ExclusionMode.Ignore` and cover the whole screen. +`window-switcher` is the fifth, with namespace `quickshell-window-switcher` and +a `blur-window-switcher` rule of its own. + ## Reloading | | How | diff --git a/README.md b/README.md index 1dffab2..a74de8a 100644 --- a/README.md +++ b/README.md @@ -13,10 +13,11 @@ repos stay independent, this one has no build-time dependency on that one. ## Implementations - volume-osd/ on-screen display for output and input volume - vm-manager/ libvirt VM drawer: state, live stats, snapshots - appearance/ wallpaper picker and colour scheme switcher - mail-overview/ unread mail per account, as a waybar count and a drawer + volume-osd/ on-screen display for output and input volume + vm-manager/ libvirt VM drawer: state, live stats, snapshots + appearance/ wallpaper picker and colour scheme switcher + mail-overview/ unread mail per account, as a waybar count and a drawer + window-switcher/ open windows as live previews in a grid, on ALT+TAB Each directory has its own README covering what it does and how to run it. 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`. -- cgit v1.2.3