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:
1 parent
e9735d1129
commit
b69f4bcf0d
7 files changed
+637
-2
No files matched your search
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user