Add a visible window switcher
Super+Tab already cycled windows, but nothing was drawn, so you chose
blind and could only confirm the choice by arriving. A visible switcher
is muscle memory for anyone arriving from macOS or GNOME, and it was the
last item of roadmap phase 03 that did not need coordination.
Ordered most-recently-used, not by creation, because that is what makes
the gesture useful: one Tab returns to the window you just came from.
Hyprland does not report an MRU order, so it is tracked from focus
changes and keyed by address, which is the only property stable for a
window's lifetime.
The gesture needs three binds rather than two. Tab steps the selection,
and the switch is committed on Super RELEASE -- the only way the
compositor can say the gesture is over. That bind is on the bare
modifier, so it fires on every Super release in the session; commit()
returns immediately when nothing is open, which is what makes it
affordable.
A list of names rather than thumbnails: at a glance you are looking for
"the other terminal", and a row of live previews is slower to read and
far more expensive to draw than this gesture deserves.
The interesting part is the bug. The overlay was built, mapped nothing,
and logged absolutely nothing -- because it declared `required property
var screen` while Variants supplies `modelData`. shell.qml has carried a
comment warning about exactly this since the Bar hit it, and I read that
comment earlier in the same session and still walked into it. A comment
that does not stop the person who read it is an argument for a test, so
per-screen-surface-contract now checks every per-screen delegate takes
its screen from modelData. Verified it catches the exact mistake.
Also fixes a regression from 8be3fc2: settings-pages-contract still
required vitalsIntervalMs on Home, where it no longer is. That contract
was pinning the split-across-two-pages arrangement the same commit
fixed, and I pushed without running it.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
pragma Singleton
|
||||
|
||||
// Alt-Tab, with something on screen while you do it.
|
||||
//
|
||||
// Named WindowSwitcherState rather than WindowSwitcher: the overlay component
|
||||
// in modules/switcher already owns that name, and a singleton sharing it is
|
||||
// silently shadowed wherever both are imported -- the same failure that made an
|
||||
// earlier Locale singleton resolve to QML's built-in type instead.
|
||||
//
|
||||
// Super+Tab already cycled windows; nothing was drawn, so you were choosing
|
||||
// blind and could only confirm by arriving. This holds the selection while a
|
||||
// switch is in progress and lets the overlay render it.
|
||||
//
|
||||
// MOST-RECENTLY-USED ORDER
|
||||
//
|
||||
// The list is ordered by when each window last had focus, not by when it was
|
||||
// opened, because that is what makes the gesture useful: one Tab returns to the
|
||||
// window you just came from, which is the overwhelmingly common case. Creation
|
||||
// order would send you to whichever window happens to be first in Hyprland's
|
||||
// list, which is arbitrary from the user's point of view.
|
||||
//
|
||||
// Hyprland does not report an MRU order, so it is tracked here: every time a
|
||||
// toplevel becomes active it moves to the front. Addresses are used as the key
|
||||
// because they are stable for a window's lifetime, where titles and app ids are
|
||||
// not.
|
||||
//
|
||||
// HOW A SWITCH ENDS
|
||||
//
|
||||
// The compositor fires a bind on Super RELEASE, which commits. That is the only
|
||||
// way to know the gesture is over -- there is no "modifier released" signal
|
||||
// otherwise. It means close() runs on every Super release in the session, so it
|
||||
// must be cheap and a no-op when nothing is open.
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool open: false
|
||||
property int index: 0
|
||||
|
||||
// Window addresses, most recently focused first.
|
||||
property var recent: []
|
||||
|
||||
// The switch candidates, resolved fresh each time the gesture starts.
|
||||
property var windows: []
|
||||
|
||||
readonly property var selected: (root.index >= 0 && root.index < root.windows.length)
|
||||
? root.windows[root.index] : null
|
||||
|
||||
// Ordered by the MRU list, with anything unseen appended in Hyprland's own
|
||||
// order so a brand new window is still reachable.
|
||||
function orderedWindows(): var {
|
||||
const all = (Hyprland.toplevels?.values ?? []).filter(t => t && t.wayland && t.wayland.appId);
|
||||
const byAddress = {};
|
||||
for (const toplevel of all)
|
||||
byAddress[String(toplevel.address)] = toplevel;
|
||||
|
||||
const ordered = [];
|
||||
for (const address of root.recent) {
|
||||
const match = byAddress[address];
|
||||
if (match) {
|
||||
ordered.push(match);
|
||||
delete byAddress[address];
|
||||
}
|
||||
}
|
||||
for (const toplevel of all)
|
||||
if (byAddress[String(toplevel.address)])
|
||||
ordered.push(toplevel);
|
||||
return ordered;
|
||||
}
|
||||
|
||||
// Starts the gesture if it is not already running, then steps. The first
|
||||
// Tab lands on the PREVIOUS window rather than the current one, which is
|
||||
// what every other implementation of this gesture does.
|
||||
function step(forward: bool): void {
|
||||
if (!root.open) {
|
||||
root.windows = root.orderedWindows();
|
||||
if (root.windows.length < 2)
|
||||
return;
|
||||
root.open = true;
|
||||
root.index = forward ? 1 : root.windows.length - 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if (root.windows.length === 0)
|
||||
return;
|
||||
const count = root.windows.length;
|
||||
root.index = forward
|
||||
? (root.index + 1) % count
|
||||
: (root.index - 1 + count) % count;
|
||||
}
|
||||
|
||||
// Runs on every Super release in the session, so it does as little as
|
||||
// possible when no switch is in progress.
|
||||
function commit(): void {
|
||||
if (!root.open)
|
||||
return;
|
||||
const target = root.selected;
|
||||
root.open = false;
|
||||
root.windows = [];
|
||||
root.index = 0;
|
||||
if (target && target.wayland)
|
||||
target.wayland.activate();
|
||||
}
|
||||
|
||||
function cancel(): void {
|
||||
root.open = false;
|
||||
root.windows = [];
|
||||
root.index = 0;
|
||||
}
|
||||
|
||||
// Focus changes maintain the MRU order. This runs whether or not a switch
|
||||
// is in progress, because ordinary clicking between windows is most of how
|
||||
// the order is established.
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onActiveToplevelChanged(): void {
|
||||
const active = Hyprland.activeToplevel;
|
||||
if (!active || !active.address)
|
||||
return;
|
||||
const address = String(active.address);
|
||||
const next = [address];
|
||||
for (const existing of root.recent)
|
||||
if (existing !== address)
|
||||
next.push(existing);
|
||||
// Bounded: a session can accumulate a lot of closed addresses, and
|
||||
// this list is only ever used to order what is currently open.
|
||||
root.recent = next.slice(0, 64);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user