# 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`.