feat(quickshell): Alt+Tab overview of every monitor and workspace

Each monitor is a row of workspace cards at its own aspect ratio, with
live captures of the windows where they sit, including windows on
hidden workspaces. Alt+Tab opens it with the previous window picked,
further Tabs walk the focus history, and releasing Alt switches.
Super+W opens it without the hold. Hover, click, Enter and the
workspace digits work too.

Group cycling moves from Alt+Tab to Super+Tab and Super+Shift+Tab.
This commit is contained in:
nvrl committed 2026-10-04 23:27:48 +02:00
1 parent e9735d1129
commit b69f4bcf0d
7 files changed
+637 -2

No files matched your search

+366
View File
@@ -0,0 +1,366 @@
import QtQuick
import Quickshell
import Quickshell.Hyprland
import Quickshell.Wayland
import "root:/config"
import "root:/components"
import "root:/services"
// Every monitor, every workspace, every window, at once. Alt+Tab opens it on
// the focused screen with the previous window picked; more Tabs walk back
// through focus history, and letting go of Alt goes there. Hover and click
// work too, as do Enter, Escape and the workspace digits.
//
// It is a scene, like the power menu: no panel, the desktop dims, and each
// monitor is laid out as a row of small portholes — its workspaces, at its
// own aspect ratio, with live captures of the windows where they sit. The
// picked window is ringed in the light and named underneath.
PanelWindow {
id: root
required property var modelData
screen: modelData
readonly property bool open: Actions.overviewOpen
&& Actions.overviewScreen === modelData.name
visible: root.open || fade.running
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.namespace: "takemi-overview"
WlrLayershell.keyboardFocus: root.open
? WlrKeyboardFocus.Exclusive
: WlrKeyboardFocus.None
exclusionMode: ExclusionMode.Ignore
anchors { top: true; bottom: true; left: true; right: true }
color: "transparent"
// ----------------------------------------------------------------- model
// Rebuilt from Hyprland's IPC snapshots on open and whenever windows move
// while open. Plain arrays rather than live models: everything here is a
// picture of one moment.
property var monitors: [] // [{ mon, ipc, workspaces: [{ ws, windows: [...] }] }]
property var mru: [] // window addresses, most recent first
property string picked: "" // address of the picked window
readonly property var pickedWindow: {
for (const m of root.monitors)
for (const w of m.workspaces)
for (const win of w.windows)
if (win.address === root.picked) return win;
return null;
}
function rebuild() {
const mons = Hyprland.monitors.values.slice()
.filter(m => m.lastIpcObject && m.lastIpcObject.width)
.sort((a, b) => a.lastIpcObject.x - b.lastIpcObject.x);
const all = [];
const out = [];
for (const mon of mons) {
const mi = mon.lastIpcObject;
const wss = Hyprland.workspaces.values
.filter(ws => ws.monitor === mon)
.filter(ws => ws.id > 0 || ws.toplevels.values.length > 0)
.sort((a, b) => (a.id < 0) - (b.id < 0) || a.id - b.id);
const rows = [];
for (const ws of wss) {
const wins = [];
for (const tl of ws.toplevels.values) {
const ipc = tl.lastIpcObject;
if (!ipc || !ipc.at || !ipc.mapped) continue;
const win = {
toplevel: tl,
address: "0x" + String(tl.address).replace(/^0x/, ""),
x: ipc.at[0] - mi.x, y: ipc.at[1] - mi.y,
w: ipc.size[0], h: ipc.size[1],
title: ipc.title || "",
appClass: ipc.class || "",
hidden: ipc.hidden === true,
floating: ipc.floating === true,
group: (ipc.grouped || []).map(a => "0x" + String(a).replace(/^0x/, "")),
history: ipc.focusHistoryID ?? 999,
};
wins.push(win);
all.push(win);
}
// Tiled first, floating on top, as Hyprland draws them.
wins.sort((a, b) => a.floating - b.floating);
rows.push({ ws: ws, windows: wins });
}
out.push({ mon: mon, ipc: mi, workspaces: rows });
}
root.monitors = out;
root.mru = all.sort((a, b) => a.history - b.history).map(w => w.address);
if (!root.touched || root.mru.indexOf(root.picked) < 0)
root.picked = root.mru.length > 1 ? root.mru[1] : (root.mru[0] ?? "");
}
function step(delta: int) {
root.touched = true;
const n = root.mru.length;
if (n === 0) return;
const at = Math.max(0, root.mru.indexOf(root.picked));
root.picked = root.mru[(at + delta + n) % n];
}
// Hyprland will not move focus while a layer holds the keyboard
// exclusively, so let go of it first and dispatch a moment later.
property string pendingDispatch: ""
function commit() {
root.pendingDispatch = root.picked !== ""
? "hl.dsp.focus({ window = \"address:" + root.picked + "\" })"
: "";
Actions.closeOverview();
if (root.pendingDispatch !== "") release.restart();
}
function goToWorkspace(id: int) {
root.pendingDispatch = "hl.dsp.focus({ workspace = " + id + " })";
Actions.closeOverview();
release.restart();
}
Timer {
id: release
interval: 40
onTriggered: Hyprland.dispatch(root.pendingDispatch)
}
// Whether the pointer has moved since the overview opened. A pointer that
// happens to rest over a card would otherwise pick that window the moment
// the cards appear under it, overriding Alt+Tab's choice.
property bool pointerLive: false
property point pointerStart: Qt.point(-1, -1)
// Whether the pick came from a person rather than from the first snapshot.
property bool touched: false
onOpenChanged: {
if (root.open) {
PopupCoordinator.dismissAll();
Hyprland.refreshMonitors();
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
root.picked = "";
root.touched = false;
root.pointerLive = false;
root.pointerStart = Qt.point(-1, -1);
root.rebuild();
// The IPC snapshots land a moment after the refresh request.
settle.restart();
keys.forceActiveFocus();
}
}
Timer {
id: settle
interval: 60
onTriggered: root.rebuild()
}
Connections {
target: Actions
function onOverviewStepped(delta) { if (root.open) root.step(delta); }
function onOverviewCommitted() { if (root.open) root.commit(); }
}
Connections {
target: Hyprland
enabled: root.open
function onRawEvent(event) {
const n = event.name;
if (n === "openwindow" || n === "closewindow" || n === "movewindowv2"
|| n === "changefloatingmode" || n === "fullscreen") {
Hyprland.refreshToplevels();
settle.restart();
}
}
}
// ------------------------------------------------------------- backdrop
Rectangle {
anchors.fill: parent
color: Theme.ink
opacity: root.open ? 0.92 : 0
Behavior on opacity {
NumberAnimation { id: fade; duration: Theme.durBase; easing.type: Easing.OutCubic }
}
TapHandler { onTapped: Actions.closeOverview() }
}
// ---------------------------------------------------------------- scene
Item {
id: keys
anchors.fill: parent
focus: true
HoverHandler {
onPointChanged: {
const p = point.position;
if (root.pointerStart.x < 0) { root.pointerStart = p; return; }
if (Math.abs(p.x - root.pointerStart.x) + Math.abs(p.y - root.pointerStart.y) > 6)
root.pointerLive = true;
}
}
Keys.onPressed: event => {
const k = event.key;
if (k === Qt.Key_Escape) Actions.closeOverview();
else if (k === Qt.Key_Return || k === Qt.Key_Enter || k === Qt.Key_Space) root.commit();
else if (k === Qt.Key_Tab || k === Qt.Key_Right || k === Qt.Key_Down
|| k === Qt.Key_L || k === Qt.Key_J) root.step(1);
else if (k === Qt.Key_Backtab || k === Qt.Key_Left || k === Qt.Key_Up
|| k === Qt.Key_H || k === Qt.Key_K) root.step(-1);
else if (k >= Qt.Key_1 && k <= Qt.Key_9) root.goToWorkspace(k - Qt.Key_0);
else return;
event.accepted = true;
}
// Alt+Tab is a hold: letting go of Alt is the choice.
Keys.onReleased: event => {
if (event.key === Qt.Key_Alt && Actions.overviewHold) {
event.accepted = true;
root.commit();
}
}
}
Column {
id: scene
anchors.centerIn: parent
spacing: Theme.padXL
opacity: root.open ? 1 : 0
transform: Translate {
y: root.open ? 0 : 10
Behavior on y {
NumberAnimation { duration: Theme.durSlow; easing.type: Easing.OutCubic }
}
}
Behavior on opacity {
NumberAnimation { duration: Theme.durBase; easing.type: Easing.OutCubic }
}
// The widest a row of cards may be, and how tall all rows together.
readonly property real maxRowWidth: root.width * 0.86
readonly property real maxRowsHeight: root.height * 0.62
// One scale for every card on every monitor, so a window is the same
// size wherever it sits: the largest that fits both the widest row and
// the summed heights.
readonly property real k: {
let k = 0.25;
let totalH = 0;
for (const m of root.monitors) {
const w = m.ipc.width / m.ipc.scale;
const h = m.ipc.height / m.ipc.scale;
const n = Math.max(1, m.workspaces.length);
k = Math.min(k, (scene.maxRowWidth - (n - 1) * Theme.padL) / (n * w));
totalH += h;
}
if (totalH > 0) k = Math.min(k, scene.maxRowsHeight / totalH);
return Math.max(0.05, k);
}
Repeater {
model: root.monitors
Column {
id: monRow
required property var modelData
readonly property real logicalW: modelData.ipc.width / modelData.ipc.scale
readonly property real logicalH: modelData.ipc.height / modelData.ipc.scale
anchors.horizontalCenter: parent.horizontalCenter
spacing: Theme.padS
// The monitor's name only matters once there is more than one.
Text {
visible: root.monitors.length > 1
text: (monRow.modelData.mon.name + " · "
+ Math.round(monRow.logicalW) + "×" + Math.round(monRow.logicalH)).toUpperCase()
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
Row {
spacing: Theme.padL
Repeater {
model: monRow.modelData.workspaces
WorkspaceCard {
required property var modelData
ws: modelData.ws
windows: modelData.windows
k: scene.k
logicalW: monRow.logicalW
logicalH: monRow.logicalH
current: monRow.modelData.mon.activeWorkspace === modelData.ws
|| (modelData.ws.id < 0 && monRow.modelData.ipc.specialWorkspace?.id === modelData.ws.id)
picked: root.picked
onPick: address => {
if (!root.pointerLive) return;
root.touched = true;
root.picked = address;
}
onActivate: address => { root.picked = address; root.commit(); }
onActivateWorkspace: id => root.goToWorkspace(id)
}
}
}
}
}
// ------------------------------------------------------------ caption
Column {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 4
width: scene.maxRowWidth * 0.7
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
text: root.pickedWindow ? root.pickedWindow.title : "No windows"
color: Theme.accent
elide: Text.ElideRight
font.family: Theme.fontDisplay
font.pixelSize: Theme.fsTitle
font.weight: Font.Light
renderType: Text.NativeRendering
}
Text {
width: parent.width
horizontalAlignment: Text.AlignHCenter
visible: root.pickedWindow !== null
text: root.pickedWindow
? (root.pickedWindow.appClass + " · workspace "
+ (root.pickedWindow.toplevel.workspace?.name ?? "?")).toUpperCase()
: ""
color: Theme.muted
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
text: "TAB NEXT ENTER GO 1–9 WORKSPACE ESC CANCEL"
color: Theme.overlay
font.family: Theme.fontMono
font.pixelSize: Theme.fsMicro
font.letterSpacing: 2
renderType: Text.NativeRendering
}
}
}