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

+8
View File
@@ -10,6 +10,14 @@ shell("lock", { key = mod .. " + CTRL + L", description = "lock screen" })
shell("power", { key = mod .. " + P", description = "power menu" })
shell("ask", { key = mod .. " + A", description = "ask claude" })
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("toggleStayAwake", { key = mod .. " + SHIFT + I", description = "keep the screen awake" })
shell("clearNotifications", { key = mod .. " + CTRL + SHIFT + N", description = "clear notifications" })
+4 -2
View File
@@ -18,8 +18,10 @@ end
-- ------------------------------------------------------------------- groups
hl.bind(mod .. " + n", hl.dsp.group.toggle())
hl.bind("ALT + Tab", hl.dsp.group.next())
-- hl.bind("ALT + Tab", hl.dsp.group.prev())
-- Alt+Tab belongs to the overview (lua/binds/shell.lua), so tabs in a group
-- 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
hl.bind(mod .. " + mouse:272", hl.dsp.window.drag(), { mouse = true })
+25
View File
@@ -66,6 +66,7 @@ bar/ the two surfaces and their modules
popouts/ hover panels hanging off rail and bar modules
overlays/ power menu, notification toasts, volume/brightness OSD
lock/ session lock
overview/ every monitor, workspace and window at once
backdrop/ the wallpaper, drawn by the shell
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+P` — power menu
- `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+Ctrl+Shift+N` — clear notifications
- `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
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
`backdrop/Backdrop.qml` draws it on the background layer; there is no
+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
}
}
}
+191
View File
@@ -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
}
}
}
+35
View File
@@ -19,6 +19,9 @@ Singleton {
property bool launcherOpen: false
property bool askOpen: 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
// launcher a query it did not have to type — `qs ipc call shell prompt
@@ -40,6 +43,9 @@ Singleton {
// …and for the dashboard drawer.
property string dashboardScreen: ""
// …and for the overview.
property string overviewScreen: ""
// 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
// lands on the clock, which would toggle it straight back open. A toggle
@@ -51,6 +57,8 @@ Singleton {
property string askSeed: ""
signal osdRequested(string kind)
signal overviewStepped(int delta)
signal overviewCommitted()
function togglePowerMenu() {
if (!root.powerMenuOpen)
@@ -88,6 +96,29 @@ Singleton {
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() {
if (!root.askOpen) {
root.askSeed = "";
@@ -164,6 +195,10 @@ Singleton {
function toggleDnd(): void { Notifs.toggleDnd(); }
function clearNotifications(): void { Notifs.clearAll(); }
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); }
}
}
+8
View File
@@ -7,6 +7,7 @@ import "root:/bar"
import "root:/launcher"
import "root:/dashboard"
import "root:/ask"
import "root:/overview"
import "root:/overlays"
import "root:/lock"
@@ -18,6 +19,7 @@ import "root:/lock"
// popouts/ hover panels hanging off bar modules
// launcher/ the application launcher
// 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
// overlays/ power menu, notification toasts, OSD
// lock/ session lock
@@ -84,6 +86,12 @@ ShellRoot {
AskPanel {}
}
Variants {
model: Quickshell.screens
Overview {}
}
Variants {
model: Quickshell.screens