Files
nvrl b69f4bcf0d 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.
2026-10-04 23:27:48 +02:00

367 lines
14 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}
}