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.
367 lines
14 KiB
QML
367 lines
14 KiB
QML
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
|
||
}
|
||
}
|
||
}
|