Files
dotfiles/quickshell/overview/WorkspaceCard.qml
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

192 lines
7.1 KiB
QML

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
}
}
}