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
@@ -10,6 +10,14 @@ shell("lock", { key = mod .. " + CTRL + L", description = "lock screen" })
|
|||||||
shell("power", { key = mod .. " + P", description = "power menu" })
|
shell("power", { key = mod .. " + P", description = "power menu" })
|
||||||
shell("ask", { key = mod .. " + A", description = "ask claude" })
|
shell("ask", { key = mod .. " + A", description = "ask claude" })
|
||||||
shell("dashboard", { key = mod .. " + D", description = "dashboard" })
|
shell("dashboard", { key = mod .. " + D", description = "dashboard" })
|
||||||
|
shell("overview", { key = mod .. " + W", description = "overview of every workspace" })
|
||||||
|
|
||||||
|
-- Alt+Tab: hold Alt, tap Tab through focus history, let go to switch. The
|
||||||
|
-- overview sees the release itself; the release bind is a backstop for the
|
||||||
|
-- case where Alt comes up before the overview has the keyboard.
|
||||||
|
shell("overviewNext", { key = "ALT + Tab", description = "switch windows" })
|
||||||
|
shell("overviewPrev", { key = "ALT + SHIFT + Tab", description = "switch windows, backwards" })
|
||||||
|
hl.bind("ALT + ALT_L", hl.dsp.exec_cmd("qs ipc call shell overviewCommit"), { release = true })
|
||||||
shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" })
|
shell("toggleDnd", { key = mod .. " + SHIFT + N", description = "toggle do not disturb" })
|
||||||
shell("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" })
|
shell("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" })
|
||||||
shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" })
|
shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" })
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ end
|
|||||||
|
|
||||||
-- ------------------------------------------------------------------- groups
|
-- ------------------------------------------------------------------- groups
|
||||||
hl.bind(mod .. " + n", hl.dsp.group.toggle())
|
hl.bind(mod .. " + n", hl.dsp.group.toggle())
|
||||||
hl.bind("ALT + Tab", hl.dsp.group.next())
|
-- Alt+Tab belongs to the overview (lua/binds/shell.lua), so tabs in a group
|
||||||
-- hl.bind("ALT + Tab", hl.dsp.group.prev())
|
-- cycle on Super+Tab.
|
||||||
|
hl.bind(mod .. " + Tab", hl.dsp.group.next(), { description = "next window in group" })
|
||||||
|
hl.bind(mod .. " + SHIFT + Tab", hl.dsp.group.prev(), { description = "previous window in group" })
|
||||||
|
|
||||||
-- -------------------------------------------------------------------- mouse
|
-- -------------------------------------------------------------------- mouse
|
||||||
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ bar/ the two surfaces and their modules
|
|||||||
popouts/ hover panels hanging off rail and bar modules
|
popouts/ hover panels hanging off rail and bar modules
|
||||||
overlays/ power menu, notification toasts, volume/brightness OSD
|
overlays/ power menu, notification toasts, volume/brightness OSD
|
||||||
lock/ session lock
|
lock/ session lock
|
||||||
|
overview/ every monitor, workspace and window at once
|
||||||
backdrop/ the wallpaper, drawn by the shell
|
backdrop/ the wallpaper, drawn by the shell
|
||||||
assets/ halftone and stripe tiles, generated with ImageMagick
|
assets/ halftone and stripe tiles, generated with ImageMagick
|
||||||
```
|
```
|
||||||
@@ -144,6 +145,9 @@ Set in `~/.config/hypr/lua/binds/shell.lua`:
|
|||||||
- `Super+Ctrl+L` — lock
|
- `Super+Ctrl+L` — lock
|
||||||
- `Super+P` — power menu
|
- `Super+P` — power menu
|
||||||
- `Super+Shift+N` — toggle do not disturb
|
- `Super+Shift+N` — toggle do not disturb
|
||||||
|
- `Alt+Tab` / `Alt+Shift+Tab` — overview, walking focus history; let go of Alt to switch
|
||||||
|
- `Super+W` — overview, without the hold
|
||||||
|
- `Super+Tab` / `Super+Shift+Tab` — next / previous window in a group
|
||||||
- `Super+Shift+I` — stay awake (no dim, lock or screen-off until toggled back)
|
- `Super+Shift+I` — stay awake (no dim, lock or screen-off until toggled back)
|
||||||
- `Super+Ctrl+Shift+N` — clear notifications
|
- `Super+Ctrl+Shift+N` — clear notifications
|
||||||
- `Super+A` — ask panel
|
- `Super+A` — ask panel
|
||||||
@@ -215,6 +219,27 @@ NetworkManager for Wi-Fi, UPower for battery, MPRIS for media, and Quickshell's
|
|||||||
own notification server. These are event-driven, so the bar and OSD react the
|
own notification server. These are event-driven, so the bar and OSD react the
|
||||||
instant a volume key is pressed instead of on the next poll.
|
instant a volume key is pressed instead of on the next poll.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
`overview/Overview.qml`. Each monitor is a row of small portholes, one per
|
||||||
|
workspace at that monitor's aspect ratio, with live captures of the windows
|
||||||
|
where they sit (`ScreencopyView`, so windows on hidden workspaces show too).
|
||||||
|
The monitor's name heads its row once there is more than one. A group shows
|
||||||
|
the member in front with a `1/3` tag; picking a hidden member puts it in
|
||||||
|
front.
|
||||||
|
|
||||||
|
`Alt+Tab` opens it with the previous window picked, more Tabs walk back
|
||||||
|
through focus history, and letting go of Alt switches. Hyprland keeps the
|
||||||
|
Alt+Tab bind while the overview has the keyboard, so those presses arrive
|
||||||
|
over IPC (`overviewNext` / `overviewPrev`); the Alt release comes to the
|
||||||
|
overview itself, with a release bind on `ALT_L` as a backstop. Hover picks
|
||||||
|
only once the pointer moves, so a resting pointer cannot override the
|
||||||
|
keyboard. Enter, a click, or `1`–`9` for a workspace also go; Escape or a
|
||||||
|
click on the dark cancels.
|
||||||
|
|
||||||
|
Focus is dispatched a moment after the overview lets go of the keyboard:
|
||||||
|
Hyprland ignores focus changes while a layer holds it exclusively.
|
||||||
|
|
||||||
## Wallpaper
|
## Wallpaper
|
||||||
|
|
||||||
`backdrop/Backdrop.qml` draws it on the background layer; there is no
|
`backdrop/Backdrop.qml` draws it on the background layer; there is no
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import QtQuick
|
||||||
|
import Quickshell
|
||||||
|
import Quickshell.Hyprland
|
||||||
|
import Quickshell.Wayland
|
||||||
|
import "root:/config"
|
||||||
|
import "root:/services"
|
||||||
|
|
||||||
|
// One workspace in the overview: a small porthole at its monitor's aspect
|
||||||
|
// ratio, the wallpaper faint behind it, and its windows as live captures where
|
||||||
|
// they sit. Under it, the workspace's number in the bar's pill when it is the
|
||||||
|
// one on screen.
|
||||||
|
Item {
|
||||||
|
id: card
|
||||||
|
|
||||||
|
required property HyprlandWorkspace ws
|
||||||
|
required property var windows
|
||||||
|
required property real k // logical px -> card px
|
||||||
|
required property real logicalW
|
||||||
|
required property real logicalH
|
||||||
|
property bool current: false
|
||||||
|
property string picked: ""
|
||||||
|
|
||||||
|
signal pick(string address)
|
||||||
|
signal activate(string address)
|
||||||
|
signal activateWorkspace(int id)
|
||||||
|
|
||||||
|
readonly property bool special: card.ws.id < 0
|
||||||
|
// The picked window, if it is one of the hidden members of a group here:
|
||||||
|
// the group's slot shows it instead of the member that is in front.
|
||||||
|
readonly property var pickedGroup: {
|
||||||
|
for (const w of card.windows)
|
||||||
|
if (w.address === card.picked && w.group.length > 1) return w.group;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitWidth: Math.round(card.logicalW * card.k)
|
||||||
|
implicitHeight: porthole.height + label.anchors.topMargin + label.height
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: porthole
|
||||||
|
width: card.implicitWidth
|
||||||
|
height: Math.round(card.logicalH * card.k)
|
||||||
|
radius: Theme.radiusM
|
||||||
|
color: Theme.mantle
|
||||||
|
border.width: 1
|
||||||
|
border.color: card.current ? Theme.outline : Theme.rim
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
Image {
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 1
|
||||||
|
source: Wallpaper.source
|
||||||
|
sourceSize: Qt.size(width, height)
|
||||||
|
fillMode: Image.PreserveAspectCrop
|
||||||
|
asynchronous: true
|
||||||
|
opacity: 0.35
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on bare wallpaper: go to the workspace itself.
|
||||||
|
TapHandler {
|
||||||
|
onTapped: if (!card.special) card.activateWorkspace(card.ws.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
Repeater {
|
||||||
|
model: card.windows
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
id: tile
|
||||||
|
required property var modelData
|
||||||
|
readonly property bool isPicked: tile.modelData.address === card.picked
|
||||||
|
// A group draws one member: the picked one if it is in this
|
||||||
|
// group, otherwise whichever Hyprland has in front.
|
||||||
|
visible: card.pickedGroup.length > 0 && card.pickedGroup.indexOf(tile.modelData.address) >= 0
|
||||||
|
? tile.isPicked
|
||||||
|
: !tile.modelData.hidden
|
||||||
|
|
||||||
|
x: Math.round(tile.modelData.x * card.k)
|
||||||
|
y: Math.round(tile.modelData.y * card.k)
|
||||||
|
width: Math.max(8, Math.round(tile.modelData.w * card.k))
|
||||||
|
height: Math.max(8, Math.round(tile.modelData.h * card.k))
|
||||||
|
z: tile.isPicked ? 2 : (tile.modelData.floating ? 1 : 0)
|
||||||
|
radius: Theme.radiusS
|
||||||
|
color: Theme.surface
|
||||||
|
clip: true
|
||||||
|
|
||||||
|
// The app's icon, under the capture, for the moment before the
|
||||||
|
// first frame lands or for a window that cannot be captured.
|
||||||
|
Image {
|
||||||
|
anchors.centerIn: parent
|
||||||
|
width: Math.min(32, parent.width * 0.4)
|
||||||
|
height: width
|
||||||
|
sourceSize: Qt.size(width, height)
|
||||||
|
source: {
|
||||||
|
const e = DesktopEntries.heuristicLookup(tile.modelData.appClass);
|
||||||
|
return e && e.icon ? Quickshell.iconPath(e.icon, true) : "";
|
||||||
|
}
|
||||||
|
visible: !capture.hasContent
|
||||||
|
}
|
||||||
|
|
||||||
|
ScreencopyView {
|
||||||
|
id: capture
|
||||||
|
anchors.fill: parent
|
||||||
|
captureSource: tile.modelData.toplevel.wayland
|
||||||
|
live: true
|
||||||
|
constraintSize: Qt.size(tile.width, tile.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: "transparent"
|
||||||
|
border.width: tile.isPicked ? 2 : 1
|
||||||
|
border.color: tile.isPicked ? Theme.accent : Theme.alpha(Theme.ink, 0.6)
|
||||||
|
|
||||||
|
Behavior on border.color {
|
||||||
|
ColorAnimation { duration: Theme.durFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else sinks back a little.
|
||||||
|
Rectangle {
|
||||||
|
anchors.fill: parent
|
||||||
|
radius: parent.radius
|
||||||
|
color: Theme.ink
|
||||||
|
opacity: tile.isPicked ? 0 : 0.28
|
||||||
|
|
||||||
|
Behavior on opacity {
|
||||||
|
NumberAnimation { duration: Theme.durFast }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many windows share this slot.
|
||||||
|
Rectangle {
|
||||||
|
visible: tile.modelData.group.length > 1
|
||||||
|
anchors.right: parent.right
|
||||||
|
anchors.top: parent.top
|
||||||
|
anchors.margins: 4
|
||||||
|
width: groupText.implicitWidth + 8
|
||||||
|
height: groupText.implicitHeight + 2
|
||||||
|
radius: height / 2
|
||||||
|
color: Theme.alpha(Theme.ink, 0.75)
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: groupText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: (tile.modelData.group.indexOf(tile.modelData.address) + 1)
|
||||||
|
+ "/" + tile.modelData.group.length
|
||||||
|
color: Theme.subtext
|
||||||
|
font.family: Theme.fontMono
|
||||||
|
font.pixelSize: Theme.fsMicro
|
||||||
|
renderType: Text.NativeRendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HoverHandler {
|
||||||
|
onHoveredChanged: if (hovered) card.pick(tile.modelData.address)
|
||||||
|
// Hovered from the start, then the pointer moves: still a pick.
|
||||||
|
onPointChanged: if (hovered) card.pick(tile.modelData.address)
|
||||||
|
}
|
||||||
|
|
||||||
|
TapHandler {
|
||||||
|
onTapped: card.activate(tile.modelData.address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The workspace's name: the focused one wears it in the bar's light pill,
|
||||||
|
// the rest in grey.
|
||||||
|
Rectangle {
|
||||||
|
id: label
|
||||||
|
anchors.top: porthole.bottom
|
||||||
|
anchors.topMargin: Theme.padS
|
||||||
|
anchors.horizontalCenter: porthole.horizontalCenter
|
||||||
|
width: Math.max(height * 1.6, labelText.implicitWidth + 14)
|
||||||
|
height: labelText.implicitHeight + 4
|
||||||
|
radius: height / 2
|
||||||
|
color: card.current ? Theme.accent : "transparent"
|
||||||
|
|
||||||
|
Text {
|
||||||
|
id: labelText
|
||||||
|
anchors.centerIn: parent
|
||||||
|
text: card.special ? card.ws.name.replace(/^special:/, "") : String(card.ws.id)
|
||||||
|
color: card.current ? Theme.ink : Theme.muted
|
||||||
|
font.family: card.special ? Theme.fontMono : Theme.fontDisplay
|
||||||
|
font.pixelSize: Theme.fsSmall
|
||||||
|
font.weight: Theme.weightDisplay
|
||||||
|
renderType: Text.NativeRendering
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ Singleton {
|
|||||||
property bool launcherOpen: false
|
property bool launcherOpen: false
|
||||||
property bool askOpen: false
|
property bool askOpen: false
|
||||||
property bool dashboardOpen: false
|
property bool dashboardOpen: false
|
||||||
|
property bool overviewOpen: false
|
||||||
|
// Opened by Alt+Tab, so letting go of Alt picks the window.
|
||||||
|
property bool overviewHold: false
|
||||||
|
|
||||||
// Seed text for the next launcher open. Lets a keybind or a script hand the
|
// Seed text for the next launcher open. Lets a keybind or a script hand the
|
||||||
// launcher a query it did not have to type — `qs ipc call shell prompt
|
// launcher a query it did not have to type — `qs ipc call shell prompt
|
||||||
@@ -40,6 +43,9 @@ Singleton {
|
|||||||
// …and for the dashboard drawer.
|
// …and for the dashboard drawer.
|
||||||
property string dashboardScreen: ""
|
property string dashboardScreen: ""
|
||||||
|
|
||||||
|
// …and for the overview.
|
||||||
|
property string overviewScreen: ""
|
||||||
|
|
||||||
// When the dashboard was last closed from outside. A click on the bar's
|
// When the dashboard was last closed from outside. A click on the bar's
|
||||||
// clock first clears the drawer's focus grab, which closes it, and then
|
// clock first clears the drawer's focus grab, which closes it, and then
|
||||||
// lands on the clock, which would toggle it straight back open. A toggle
|
// lands on the clock, which would toggle it straight back open. A toggle
|
||||||
@@ -51,6 +57,8 @@ Singleton {
|
|||||||
property string askSeed: ""
|
property string askSeed: ""
|
||||||
|
|
||||||
signal osdRequested(string kind)
|
signal osdRequested(string kind)
|
||||||
|
signal overviewStepped(int delta)
|
||||||
|
signal overviewCommitted()
|
||||||
|
|
||||||
function togglePowerMenu() {
|
function togglePowerMenu() {
|
||||||
if (!root.powerMenuOpen)
|
if (!root.powerMenuOpen)
|
||||||
@@ -88,6 +96,29 @@ Singleton {
|
|||||||
root.dashboardClosedAt = Date.now();
|
root.dashboardClosedAt = Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleOverview() {
|
||||||
|
if (!root.overviewOpen) {
|
||||||
|
root.overviewHold = false;
|
||||||
|
root.overviewScreen = root.focusedScreenName();
|
||||||
|
}
|
||||||
|
root.overviewOpen = !root.overviewOpen;
|
||||||
|
}
|
||||||
|
function closeOverview() { root.overviewOpen = false; }
|
||||||
|
|
||||||
|
// Alt+Tab and Alt+Shift+Tab. The first press opens the overview with the
|
||||||
|
// previous window picked; Hyprland keeps the bind while it is open, so
|
||||||
|
// later presses arrive here too and walk the focus history.
|
||||||
|
function overviewStep(delta: int) {
|
||||||
|
if (!root.overviewOpen) {
|
||||||
|
root.overviewHold = true;
|
||||||
|
root.overviewScreen = root.focusedScreenName();
|
||||||
|
root.overviewOpen = true;
|
||||||
|
if (delta < 0) root.overviewStepped(-2);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.overviewStepped(delta);
|
||||||
|
}
|
||||||
|
|
||||||
function toggleAsk() {
|
function toggleAsk() {
|
||||||
if (!root.askOpen) {
|
if (!root.askOpen) {
|
||||||
root.askSeed = "";
|
root.askSeed = "";
|
||||||
@@ -164,6 +195,10 @@ Singleton {
|
|||||||
function toggleDnd(): void { Notifs.toggleDnd(); }
|
function toggleDnd(): void { Notifs.toggleDnd(); }
|
||||||
function clearNotifications(): void { Notifs.clearAll(); }
|
function clearNotifications(): void { Notifs.clearAll(); }
|
||||||
function toggleStayAwake(): void { Idle.toggleStayAwake(); }
|
function toggleStayAwake(): void { Idle.toggleStayAwake(); }
|
||||||
|
function overview(): void { root.toggleOverview(); }
|
||||||
|
function overviewNext(): void { root.overviewStep(1); }
|
||||||
|
function overviewPrev(): void { root.overviewStep(-1); }
|
||||||
|
function overviewCommit(): void { if (root.overviewHold) root.overviewCommitted(); }
|
||||||
function wallpaper(path: string): void { Wallpaper.set(path); }
|
function wallpaper(path: string): void { Wallpaper.set(path); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,7 @@ import "root:/bar"
|
|||||||
import "root:/launcher"
|
import "root:/launcher"
|
||||||
import "root:/dashboard"
|
import "root:/dashboard"
|
||||||
import "root:/ask"
|
import "root:/ask"
|
||||||
|
import "root:/overview"
|
||||||
import "root:/overlays"
|
import "root:/overlays"
|
||||||
import "root:/lock"
|
import "root:/lock"
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ import "root:/lock"
|
|||||||
// popouts/ hover panels hanging off bar modules
|
// popouts/ hover panels hanging off bar modules
|
||||||
// launcher/ the application launcher
|
// launcher/ the application launcher
|
||||||
// dashboard/ the drawer that drops from the bar
|
// dashboard/ the drawer that drops from the bar
|
||||||
|
// overview/ every monitor, workspace and window at once (Alt+Tab)
|
||||||
// ask/ the question panel, dropped from the top edge
|
// ask/ the question panel, dropped from the top edge
|
||||||
// overlays/ power menu, notification toasts, OSD
|
// overlays/ power menu, notification toasts, OSD
|
||||||
// lock/ session lock
|
// lock/ session lock
|
||||||
@@ -84,6 +86,12 @@ ShellRoot {
|
|||||||
AskPanel {}
|
AskPanel {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Variants {
|
||||||
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
Overview {}
|
||||||
|
}
|
||||||
|
|
||||||
Variants {
|
Variants {
|
||||||
model: Quickshell.screens
|
model: Quickshell.screens
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user