Making the plain application keys focus an existing window was the wrong call. It reads well in a demo and it is what macOS does, but it made "give me another terminal" the awkward case -- and on a tiling desktop a second terminal beside the first is the normal way to work, not an edge case. Reaching for the launcher to open a second file manager is not an improvement on anything. So the plain keys do what they always did, and SUPER+ALT is the new capability rather than a tax on the old one: go to the terminal, editor, browser, files, calculator or mail you already have, wherever it is, and start one only if there is none. ALT rather than SHIFT because SUPER+SHIFT is already the window-manipulation space -- Files, Neovim and Settings would have collided with Focus session, Taller and Shorter, and breaking two keys out of the eight-key resize set to make room is the worse trade. Also fixes a real trap found while using it. The Alt-Tab overlay commits on SUPER release, which is a compositor bind running an IPC call; if that call ever fails to land, the overlay stayed up with no keyboard focus, no Escape handler and nothing clickable, so the only way out was an IPC call typed into a terminal it was covering. Clicking outside now dismisses it, clicking a row switches to that window -- which is the obvious thing to try and did nothing -- and an abandoned switch closes itself after ten seconds. Keyboard focus still stays with the compositor, because taking it mid-switch is what would break stepping.
144 lines
5.2 KiB
QML
144 lines
5.2 KiB
QML
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();
|
|
}
|
|
|
|
// Commit to a specific entry rather than the one stepping landed on.
|
|
// Clicking a row is the obvious thing to try with a list on screen.
|
|
function selectAt(position: int): void {
|
|
if (!root.open || position < 0 || position >= root.windows.length)
|
|
return;
|
|
root.index = position;
|
|
root.commit();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|