Build the Panama Hyprland desktop

This commit is contained in:
Gabriel Brown
2026-08-17 10:32:55 -04:00
parent 67033f2a31
commit 5248883e4b
190 changed files with 18554 additions and 34 deletions
@@ -0,0 +1,113 @@
// The overview — a replacement for GNOME's Activities. Opened with Super+grave
// (the lead wires the IPC; this file only reacts to ShellState.overviewOpen).
//
// The compositor blurs and dims what is behind this surface via the
// `qs-overlay` layer rule; the scrim below adds the tint on top of that.
import Quickshell
import Quickshell.Wayland
import Quickshell.Hyprland
import QtQuick
import qs.config
import qs.services
PanelWindow {
id: root
anchors {
top: true
bottom: true
left: true
right: true
}
color: "transparent"
exclusiveZone: 0
exclusionMode: ExclusionMode.Ignore
// Matched by the `qs-overlay` layer rule in hypr/rules.lua — do not rename.
WlrLayershell.namespace: "qs-overview"
WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.keyboardFocus: root.open ? WlrKeyboardFocus.Exclusive : WlrKeyboardFocus.None
readonly property bool open: ShellState.overviewOpen
// The window has to stay mapped for the length of the close animation,
// otherwise it vanishes instantly and only the open transition is ever
// seen. `mapped` tracks overviewOpen, delayed on the way down.
property bool mapped: false
visible: mapped
// Each open bumps the token, which makes every thumbnail re-capture once.
// Nothing captures while the overview is closed or idle.
property int refreshToken: 0
onOpenChanged: {
if (open) {
unmapTimer.stop();
// The IPC/toplevel caches can lag a fast workspace switch.
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
mapped = true;
refreshToken++;
Qt.callLater(() => body.prepare());
} else {
unmapTimer.restart();
}
}
Timer {
id: unmapTimer
interval: Theme.durNormal
onTriggered: root.mapped = false
}
Item {
id: surface
anchors.fill: parent
focus: true
Keys.onEscapePressed: ShellState.close()
opacity: root.open ? 1 : 0
Behavior on opacity {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
transform: Scale {
origin.x: surface.width / 2
origin.y: surface.height / 2
xScale: root.open ? 1 : 0.96
yScale: xScale
Behavior on xScale {
NumberAnimation {
duration: Theme.durNormal
easing.type: Easing.OutCubic
}
}
}
Rectangle {
anchors.fill: parent
color: Theme.alpha(Theme.bgDark, Theme.overlayAlpha)
// Clicking anywhere that is not a card dismisses, as in GNOME.
MouseArea {
anchors.fill: parent
onClicked: ShellState.close()
}
}
OverviewBody {
id: body
anchors.fill: parent
refreshToken: root.refreshToken
open: root.open
}
}
}
@@ -0,0 +1,496 @@
// Continuum Mission Control: a GNOME-like workspace filmstrip above a large
// selected desktop, with cross-workspace window search when the user types.
import Quickshell
import Quickshell.Hyprland
import Quickshell.Io
import QtQuick
import qs.config
import qs.services
Item {
id: root
property int refreshToken: 0
property bool open: false
property int selectedWorkspaceId: 0
property string query: ""
property int actionRefreshToken: 0
property int selectedResultIndex: 0
readonly property int sideMargin: 52
readonly property int filmCardWidth: 210
readonly property int filmCardHeight: 140
readonly property var workspaceList: {
const out = [];
const all = Hyprland.workspaces?.values ?? [];
for (const workspace of all) {
if (workspace && workspace.id > 0)
out.push(workspace);
}
out.sort((a, b) => a.id - b.id);
return out;
}
readonly property var scratchWorkspace: {
const all = Hyprland.workspaces?.values ?? [];
return all.find(workspace => workspace?.name === "special:scratch") ?? null;
}
readonly property int nextWorkspaceId: {
let max = 0;
for (const workspace of root.workspaceList)
max = Math.max(max, workspace.id);
return max + 1;
}
readonly property var selectedWorkspace: {
for (const workspace of root.workspaceList) {
if (workspace.id === root.selectedWorkspaceId)
return workspace;
}
return Hyprland.focusedWorkspace?.id > 0 ? Hyprland.focusedWorkspace : (root.workspaceList[0] ?? null);
}
readonly property real screenAspect: {
const monitor = Hyprland.focusedMonitor;
return monitor && monitor.height > 0 ? monitor.width / monitor.height : 3 / 2;
}
readonly property var windowEntries: {
const out = [];
for (const workspace of root.workspaceList) {
const windows = workspace.toplevels?.values ?? [];
for (const toplevel of windows)
out.push({ workspace, toplevel });
}
return out;
}
readonly property var filteredWindows: {
const needle = root.query.trim().toLowerCase();
if (!needle)
return [];
return root.windowEntries.filter(entry => {
const title = entry.toplevel?.title ?? "";
const appId = entry.toplevel?.wayland?.appId ?? "";
return `${title} ${appId}`.toLowerCase().includes(needle);
});
}
readonly property int resultColumns: Math.min(3, Math.max(1, root.filteredWindows.length))
readonly property var selectedResult: root.filteredWindows.length > 0
? root.filteredWindows[Math.min(root.selectedResultIndex, root.filteredWindows.length - 1)]
: null
onQueryChanged: root.selectedResultIndex = 0
onFilteredWindowsChanged: {
if (root.selectedResultIndex >= root.filteredWindows.length)
root.selectedResultIndex = Math.max(0, root.filteredWindows.length - 1);
}
function prepare(): void {
root.query = ShellState.overviewQuery;
const requested = ShellState.overviewWorkspaceId;
const focused = Hyprland.focusedWorkspace?.id ?? 0;
root.selectedWorkspaceId = root.hasWorkspace(requested) ? requested : focused;
Qt.callLater(() => searchInput.forceActiveFocus());
}
function hasWorkspace(id: int): bool {
if (id <= 0)
return false;
return root.workspaceList.some(workspace => workspace.id === id);
}
function selectWorkspace(workspace: var): void {
if (!workspace)
return;
root.selectedWorkspaceId = workspace.id;
workspace.activate();
}
function cycleWorkspace(delta: int): void {
if (root.workspaceList.length === 0)
return;
let index = root.workspaceList.findIndex(workspace => workspace.id === root.selectedWorkspaceId);
if (index < 0)
index = 0;
const next = Math.max(0, Math.min(root.workspaceList.length - 1, index + delta));
root.selectWorkspace(root.workspaceList[next]);
}
function focusToplevel(toplevel: var): void {
if (!toplevel)
return;
const workspace = toplevel.workspace;
if (workspace)
workspace.activate();
if (toplevel.address) {
const address = toplevel.address.startsWith("0x") ? toplevel.address : "0x" + toplevel.address;
Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`);
} else if (toplevel.wayland) {
toplevel.wayland.activate();
}
ShellState.close();
}
function addressOf(toplevel: var): string {
const raw = String(toplevel?.address ?? "");
if (!raw)
return "";
return raw.startsWith("0x") ? raw : "0x" + raw;
}
function closeToplevel(toplevel: var): void {
const address = root.addressOf(toplevel);
if (!address)
return;
Hyprland.dispatch(`hl.dsp.window.close({ window = "address:${address}" })`);
root.refreshAfterAction();
}
function moveToplevel(toplevel: var, workspaceName: string): void {
const address = root.addressOf(toplevel);
if (!address || !/^(?:[1-9]\d*|special:scratch)$/.test(workspaceName))
return;
Hyprland.dispatch(`hl.dsp.window.move({ workspace = "${workspaceName}", follow = false, window = "address:${address}" })`);
root.refreshAfterAction();
}
function restoreToplevel(toplevel: var): void {
const address = root.addressOf(toplevel);
const target = root.selectedWorkspaceId > 0 ? root.selectedWorkspaceId : (Hyprland.focusedWorkspace?.id ?? 1);
if (!address || target <= 0)
return;
Hyprland.dispatch(`hl.dsp.window.move({ workspace = ${target}, follow = true, window = "address:${address}" })`);
ShellState.close();
}
function refreshAfterAction(): void {
actionRefresh.restart();
}
Timer {
id: actionRefresh
interval: 140
onTriggered: {
Hyprland.refreshWorkspaces();
Hyprland.refreshToplevels();
root.actionRefreshToken++;
}
}
// Address-only actions make the exact same boundary testable end-to-end
// without synthesizing pointer input. They are also useful to launcher
// commands later; validation still limits destinations to Panama's model.
IpcHandler {
target: "overview-actions"
function move(address: string, workspaceName: string): void {
root.moveToplevel({ address }, workspaceName);
}
function close(address: string): void {
root.closeToplevel({ address });
}
function restore(address: string): void {
root.restoreToplevel({ address });
}
}
// ── Header and search ──────────────────────────────────────────────────
Item {
id: header
anchors.top: parent.top
anchors.topMargin: 42
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root.sideMargin
anchors.rightMargin: root.sideMargin
height: 42
Text {
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
text: "Activities"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeTitle
font.weight: Font.DemiBold
}
Rectangle {
id: searchBox
anchors.centerIn: parent
width: 390
height: 38
radius: Theme.pillRadius
border.width: searchInput.activeFocus ? 1 : 0
border.color: Theme.alpha(Theme.accent, 0.72)
color: Theme.alpha(Theme.bgPopover, 0.84)
Text {
anchors.left: parent.left
anchors.leftMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: "\u{F0349}" // magnify
color: searchInput.activeFocus ? Theme.accent : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 16
}
TextInput {
id: searchInput
anchors.left: parent.left
anchors.leftMargin: 40
anchors.right: parent.right
anchors.rightMargin: 14
anchors.verticalCenter: parent.verticalCenter
text: root.query
color: Theme.fg
selectionColor: Theme.accent
selectedTextColor: Theme.bgDark
clip: true
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
onTextEdited: root.query = text
Keys.onReturnPressed: {
if (root.query.trim() === "") {
if (root.selectedWorkspace)
root.selectWorkspace(root.selectedWorkspace);
ShellState.close();
} else if (root.selectedResult) {
root.focusToplevel(root.selectedResult.toplevel);
}
}
Keys.onLeftPressed: event => {
if (root.query.trim() !== "")
return;
root.cycleWorkspace(-1);
event.accepted = true;
}
Keys.onRightPressed: event => {
if (root.query.trim() !== "")
return;
root.cycleWorkspace(1);
event.accepted = true;
}
Keys.onUpPressed: event => {
if (!root.selectedResult)
return;
root.selectedResultIndex = Math.max(0, root.selectedResultIndex - root.resultColumns);
event.accepted = true;
}
Keys.onDownPressed: event => {
if (!root.selectedResult)
return;
root.selectedResultIndex = Math.min(root.filteredWindows.length - 1, root.selectedResultIndex + root.resultColumns);
event.accepted = true;
}
Keys.onDeletePressed: event => {
if (!root.selectedResult)
return;
root.closeToplevel(root.selectedResult.toplevel);
event.accepted = true;
}
}
Text {
anchors.left: searchInput.left
anchors.verticalCenter: parent.verticalCenter
visible: searchInput.text === ""
text: "Find a window"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
text: root.workspaceList.length + (root.workspaceList.length === 1 ? " workspace" : " workspaces")
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
}
}
ScratchpadShelf {
id: scratchpadShelf
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root.sideMargin
anchors.rightMargin: root.sideMargin
anchors.bottom: parent.bottom
anchors.bottomMargin: 20
workspace: root.scratchWorkspace
refreshToken: root.refreshToken + root.actionRefreshToken
onWindowDropped: toplevel => root.moveToplevel(toplevel, "special:scratch")
onRestoreRequested: toplevel => root.restoreToplevel(toplevel)
onCloseRequested: toplevel => root.closeToplevel(toplevel)
}
// ── Workspace filmstrip ────────────────────────────────────────────────
Flickable {
id: filmstrip
anchors.top: header.bottom
anchors.topMargin: 18
anchors.left: parent.left
anchors.right: parent.right
anchors.leftMargin: root.sideMargin
anchors.rightMargin: root.sideMargin
height: root.filmCardHeight
clip: true
contentWidth: Math.max(width, filmRow.implicitWidth)
contentHeight: height
boundsBehavior: Flickable.StopAtBounds
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.NoButton
onWheel: event => root.cycleWorkspace(event.angleDelta.y > 0 ? -1 : 1)
}
Row {
id: filmRow
x: filmstrip.contentWidth > filmstrip.width ? 0 : (filmstrip.width - implicitWidth) / 2
spacing: Theme.itemSpacing
Repeater {
model: root.workspaceList
WorkspaceCard {
required property var modelData
workspace: modelData
refreshToken: root.refreshToken + root.actionRefreshToken
emphasized: modelData.id === root.selectedWorkspaceId
focusBound: FocusSession.active && modelData.id === FocusSession.workspaceId
width: root.filmCardWidth
height: root.filmCardHeight
onActivated: root.selectWorkspace(modelData)
onWindowActivated: toplevel => root.focusToplevel(toplevel)
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
}
}
WorkspaceCard {
workspace: null
newWorkspaceId: root.nextWorkspaceId
emphasized: false
width: root.filmCardWidth
height: root.filmCardHeight
onActivated: {
Hyprland.dispatch(`hl.dsp.focus({ workspace = ${root.nextWorkspaceId} })`);
root.selectedWorkspaceId = root.nextWorkspaceId;
}
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
}
}
}
// ── Large selected desktop ─────────────────────────────────────────────
Item {
id: desktopStage
anchors.top: filmstrip.bottom
anchors.topMargin: 26
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: hint.top
anchors.bottomMargin: 18
visible: root.query.trim() === ""
WorkspaceCard {
id: largeWorkspace
anchors.top: parent.top
anchors.topMargin: 18
anchors.horizontalCenter: parent.horizontalCenter
workspace: root.selectedWorkspace
refreshToken: root.refreshToken + root.actionRefreshToken
emphasized: true
focusBound: FocusSession.active && root.selectedWorkspace?.id === FocusSession.workspaceId
width: Math.min(parent.width - root.sideMargin * 2, 1760)
height: Math.min(parent.height, Math.round(width / root.screenAspect) + labelHeight)
onActivated: {
if (workspace)
workspace.activate();
ShellState.close();
}
onWindowActivated: toplevel => root.focusToplevel(toplevel)
onWindowCloseRequested: toplevel => root.closeToplevel(toplevel)
onWindowDropped: (toplevel, workspaceName) => root.moveToplevel(toplevel, workspaceName)
}
}
// ── Cross-workspace search results ─────────────────────────────────────
Flickable {
id: searchResults
anchors.top: filmstrip.bottom
anchors.topMargin: 28
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: hint.top
anchors.leftMargin: root.sideMargin
anchors.rightMargin: root.sideMargin
anchors.bottomMargin: 18
visible: root.query.trim() !== ""
clip: true
contentWidth: width
contentHeight: resultFlow.implicitHeight
boundsBehavior: Flickable.StopAtBounds
Flow {
id: resultFlow
readonly property real cardWidth: (searchResults.width - Theme.sectionSpacing * 2) / 3
x: Math.max(0, (searchResults.width - width) / 2)
width: root.resultColumns * cardWidth + (root.resultColumns - 1) * Theme.sectionSpacing
spacing: Theme.sectionSpacing
Repeater {
model: root.filteredWindows
WindowThumbnail {
required property var modelData
required property int index
toplevel: modelData.toplevel
refreshToken: root.refreshToken + root.actionRefreshToken
selected: index === root.selectedResultIndex
width: resultFlow.cardWidth
height: Math.round(width / root.screenAspect) + 18
onActivated: root.focusToplevel(modelData.toplevel)
onCloseRequested: root.closeToplevel(modelData.toplevel)
}
}
}
Text {
anchors.centerIn: parent
visible: root.filteredWindows.length === 0
text: "No windows match “" + root.query + "”"
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeLarge
}
}
Text {
id: hint
anchors.bottom: scratchpadShelf.top
anchors.bottomMargin: 8
anchors.horizontalCenter: parent.horizontalCenter
text: root.query.trim() === "" ? "Drag windows between workspaces or into Scratchpad · Esc to return" : "Type to search every workspace · Enter opens the first match"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
@@ -0,0 +1,129 @@
// One global place to set windows aside. Empty, it is a quiet drop target;
// populated, it expands into a compact row of restorable window cards.
import QtQuick
import qs.config
Item {
id: root
required property var workspace
property int refreshToken: 0
signal windowDropped(var toplevel)
signal restoreRequested(var toplevel)
signal closeRequested(var toplevel)
readonly property var windows: root.workspace?.toplevels?.values ?? []
readonly property bool populated: root.windows.length > 0
implicitHeight: root.populated ? 112 : 42
Rectangle {
id: surface
anchors.horizontalCenter: parent.horizontalCenter
anchors.bottom: parent.bottom
width: root.populated
? Math.min(parent.width, Math.max(360, root.windows.length * 198 + 28))
: 292
height: root.populated ? 112 : 42
radius: root.populated ? Theme.popoverRadius : Theme.pillRadius
border.width: 1
border.color: dropArea.containsDrag ? Theme.ok : Theme.alpha(Theme.fg, root.populated ? 0.1 : 0.08)
color: Theme.alpha(Theme.bgPopover, root.populated ? 0.76 : 0.42)
Behavior on width {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
Behavior on height {
NumberAnimation { duration: Theme.durNormal; easing.type: Easing.OutCubic }
}
Behavior on border.color {
ColorAnimation { duration: Theme.durFast }
}
Row {
anchors.centerIn: parent
visible: !root.populated
spacing: 8
Text {
anchors.verticalCenter: parent.verticalCenter
text: dropArea.containsDrag ? "\u{F04E7}" : "\u{F02D4}"
color: dropArea.containsDrag ? Theme.ok : Theme.fgDim
font.family: Theme.fontMono
font.pixelSize: 15
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: dropArea.containsDrag ? "Release to set aside" : "Drop to Scratchpad"
color: dropArea.containsDrag ? Theme.fg : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.weight: Font.Medium
}
}
Text {
id: heading
anchors.left: parent.left
anchors.leftMargin: 14
anchors.top: parent.top
anchors.topMargin: 9
visible: root.populated
text: "Scratchpad · " + root.windows.length
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.DemiBold
}
Flickable {
id: windowStrip
anchors.left: parent.left
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.leftMargin: 12
anchors.rightMargin: 12
anchors.bottomMargin: 8
height: 76
visible: root.populated
clip: true
contentWidth: Math.max(width, cards.implicitWidth)
contentHeight: height
boundsBehavior: Flickable.StopAtBounds
Row {
id: cards
spacing: Theme.itemSpacing
Repeater {
model: root.windows
WindowThumbnail {
required property var modelData
toplevel: modelData
refreshToken: root.refreshToken
width: 190
height: 76
onActivated: root.restoreRequested(modelData)
onCloseRequested: root.closeRequested(modelData)
}
}
}
}
DropArea {
id: dropArea
anchors.fill: parent
z: 5
onDropped: drop => {
if (!drop.source?.toplevel)
return;
root.windowDropped(drop.source.toplevel);
drop.acceptProposedAction();
}
}
}
}
@@ -0,0 +1,269 @@
// A single live window preview inside a workspace card, with the app icon and
// title underneath — GNOME's overview window chrome.
//
// Capture is deliberately one-shot (`live: false` + an explicit captureFrame()
// on open). Streaming every window continuously would repaint the whole
// overview forever, which is exactly the idle-repaint cost this shell avoids.
import Quickshell
import Quickshell.Wayland
import Quickshell.Widgets
import QtQuick
import qs.config
Item {
id: root
required property var toplevel // HyprlandToplevel
// Bumped by Overview each time it opens; each bump re-captures.
property int refreshToken: 0
property bool selected: false
signal activated
signal closeRequested
signal dragStarted
signal dragFinished
property real dragStartX: 0
property real dragStartY: 0
z: dragHandler.active ? 100 : 0
opacity: dragHandler.active ? 0.88 : 1
Drag.active: dragHandler.active
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
Drag.supportedActions: Qt.MoveAction
// Null until Hyprland reports the address — and always null on compositors
// without wlr-screencopy, which is why every use of it is guarded.
readonly property var source: toplevel ? toplevel.wayland : null
readonly property string title: toplevel && toplevel.title ? toplevel.title : ""
readonly property string appId: source && source.appId ? source.appId : ""
// The `applications` read is deliberate: the entry scan finishes
// asynchronously, and heuristicLookup() alone creates no binding dependency.
readonly property var entry: appId && DesktopEntries.applications.values.length > 0 ? DesktopEntries.heuristicLookup(appId) : null
// "" when nothing resolves — a missing icon is left blank rather than shown
// as the theme's broken-icon placeholder.
readonly property string iconSource: {
const name = entry && entry.icon ? entry.icon : appId;
if (!name)
return "";
if (name.startsWith("/"))
return "file://" + name;
return Quickshell.iconPath(name, true);
}
onRefreshTokenChanged: root.recapture()
function recapture(): void {
if (shotLoader.item)
shotLoader.item.tryCapture();
}
Rectangle {
id: preview
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: caption.top
anchors.bottomMargin: 4
radius: Theme.cardRadius
border.width: 0 // QTBUG-137166
color: Theme.alpha(Theme.bg, 0.6)
clip: true
scale: dragHandler.active ? 1.04 : (hover.hovered ? 1.025 : 1)
Behavior on scale {
NumberAnimation {
duration: Theme.durFast
easing.type: Easing.OutCubic
}
}
// Shown until (or unless) a frame arrives. On a compositor without
// screencopy this is all the user ever sees, rather than an error.
IconImage {
anchors.centerIn: parent
visible: root.iconSource !== ""
source: root.iconSource
implicitSize: Math.max(24, Math.min(64, Math.min(preview.width, preview.height) * 0.4))
asynchronous: true
mipmap: true
opacity: shotLoader.hasFrame ? 0 : 1
Behavior on opacity {
NumberAnimation {
duration: Theme.durFast
}
}
}
Loader {
id: shotLoader
anchors.fill: parent
readonly property bool hasFrame: item ? item.hasContent : false
// Only instantiate a capture when there is something to capture AND
// the overview has been opened at least once.
//
// refreshToken is incremented by Overview.qml at the moment it maps,
// so gating on it guarantees the ScreencopyView is created while the
// window is actually mapped. Creating it earlier makes the initial
// captureFrame() fail with "no recording context is ready", because
// an unmapped layer surface has no capture context yet.
active: !!root.source && root.refreshToken > 0
sourceComponent: shotComponent
}
Rectangle {
anchors.fill: parent
visible: root.selected
radius: parent.radius
color: "transparent"
border.width: 2
border.color: Theme.accent
z: 3
}
Rectangle {
id: closeButton
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 7
visible: hover.hovered && !dragHandler.active
width: 26
height: 26
radius: 13
z: 4
border.width: 1
border.color: Theme.alpha(Theme.fg, 0.1)
color: closeHover.hovered ? Theme.alpha(Theme.danger, 0.28) : Theme.alpha(Theme.bgDark, 0.82)
Text {
anchors.centerIn: parent
text: "\u{F0156}"
color: closeHover.hovered ? Theme.danger : Theme.fg
font.family: Theme.fontMono
font.pixelSize: 13
}
HoverHandler { id: closeHover }
TapHandler {
onTapped: root.closeRequested()
}
}
}
Component {
id: shotComponent
ScreencopyView {
id: shot
captureSource: root.source
live: false
paintCursor: false
constraintSize: Qt.size(preview.width, preview.height)
// Fit inside the preview without distorting the window's shape.
readonly property real aspect: sourceSize.height > 0 ? sourceSize.width / sourceSize.height : 16 / 9
anchors.centerIn: parent
width: Math.min(parent.width, parent.height * aspect)
height: aspect > 0 ? width / aspect : parent.height
opacity: hasContent ? 1 : 0
// The capture context isn't ready the instant the layer surface is
// told to become visible — it needs the surface to actually map,
// which takes a frame or two. Calling captureFrame() before then
// logs "no recording context is ready" and yields nothing, so
// retry a few times and then give up quietly (the caption and app
// icon are still shown, so a missing thumbnail is cosmetic).
property int captureAttempts: 0
function tryCapture(): void {
captureAttempts = 0;
captureRetry.restart();
}
// `shot`, not `parent`: Timer is a QtObject, so `parent` does not
// resolve to the enclosing ScreencopyView.
Timer {
id: captureRetry
interval: 80
repeat: true
running: false
onTriggered: {
if (shot.hasContent || shot.captureAttempts >= 6) {
stop();
return;
}
shot.captureAttempts++;
shot.captureFrame();
}
}
Component.onCompleted: tryCapture()
}
}
Row {
id: caption
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
spacing: 6
height: 18
IconImage {
anchors.verticalCenter: parent.verticalCenter
visible: root.iconSource !== ""
width: visible ? 16 : 0
height: 16
source: root.iconSource
asynchronous: true
mipmap: true
}
Text {
anchors.verticalCenter: parent.verticalCenter
// Clamped against the thumbnail rather than the Row: sizing against
// the Row would make its width depend on its own implicitWidth.
width: Math.min(implicitWidth, Math.max(0, root.width - 22))
text: root.title
elide: Text.ElideRight
color: Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
}
}
HoverHandler {
id: hover
}
TapHandler {
onTapped: root.activated()
}
DragHandler {
id: dragHandler
target: root
cursorShape: Qt.DragMoveCursor
onActiveChanged: {
if (active) {
root.dragStartX = root.x;
root.dragStartY = root.y;
root.dragStarted();
} else {
root.Drag.drop();
root.x = root.dragStartX;
root.y = root.dragStartY;
root.dragFinished();
}
}
}
}
@@ -0,0 +1,182 @@
// One workspace in the overview: a proportional card holding live thumbnails
// of its windows, labelled underneath. The focused workspace is outlined in the
// accent colour, as GNOME outlines the current workspace.
//
// A card with a null `workspace` is the trailing "new workspace" affordance
// that dynamic-workspace setups (GNOME's, and Hyprland's) always show.
import Quickshell
import QtQuick
import qs.config
Item {
id: root
// HyprlandWorkspace, or null for the trailing "new workspace" card.
required property var workspace
// Shown on the empty trailing card.
property int newWorkspaceId: 0
property int refreshToken: 0
// `emphasized` is the overview's current spatial selection. It normally
// follows compositor focus, but can point at a requested focus workspace
// before the user commits to switching there.
property bool emphasized: focused
property bool focusBound: false
signal activated
signal windowActivated(var toplevel)
signal windowCloseRequested(var toplevel)
signal windowDropped(var toplevel, string workspaceName)
readonly property bool isNew: !workspace
readonly property var windows: workspace ? workspace.toplevels.values : []
readonly property bool focused: workspace ? workspace.focused : false
readonly property string dropWorkspaceName: workspace ? String(workspace.id)
: (newWorkspaceId > 0 ? String(newWorkspaceId) : "")
readonly property string label: {
if (isNew)
return qsTr("New");
// Named workspaces keep their name; numbered ones read better as
// "Workspace N" only when there is room, so just show the name.
return workspace.name ? workspace.name : String(workspace.id);
}
// Card body — the label sits under it, so reserve that space.
readonly property int labelHeight: 22
Rectangle {
id: card
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.bottomMargin: root.labelHeight
radius: Theme.cardRadius
color: Theme.alpha(Theme.bgDark, 0.55)
border.width: 2
border.color: {
if (dropArea.containsDrag)
return Theme.ok;
if (root.emphasized)
return Theme.accent;
if (root.focusBound)
return Theme.alpha(Theme.accentSecondary, 0.72);
return Theme.alpha(Theme.fg, 0.08);
}
Behavior on border.color {
ColorAnimation {
duration: Theme.durFast
}
}
// Windows are laid out on a near-square grid. Hyprland does not expose
// live per-window geometry, so an honest grid beats a wrong guess at
// the real tiling.
readonly property int columns: Math.max(1, Math.ceil(Math.sqrt(root.windows.length)))
readonly property int rows: Math.max(1, Math.ceil(root.windows.length / columns))
readonly property int cellW: Math.floor((width - Theme.itemSpacing * (columns + 1)) / columns)
readonly property int cellH: Math.floor((height - Theme.itemSpacing * (rows + 1)) / rows)
Grid {
anchors.centerIn: parent
columns: card.columns
spacing: Theme.itemSpacing
visible: root.windows.length > 0
Repeater {
model: root.windows
WindowThumbnail {
required property var modelData
toplevel: modelData
refreshToken: root.refreshToken
width: card.cellW
height: card.cellH
onActivated: root.windowActivated(modelData)
onCloseRequested: root.windowCloseRequested(modelData)
}
}
}
DropArea {
id: dropArea
anchors.fill: parent
z: 3
enabled: root.dropWorkspaceName !== ""
onDropped: drop => {
if (!drop.source?.toplevel)
return;
root.windowDropped(drop.source.toplevel, root.dropWorkspaceName);
drop.acceptProposedAction();
}
}
// Empty workspace: a big soft "+" so the card still reads as clickable.
Text {
anchors.centerIn: parent
visible: root.windows.length === 0
text: "+"
color: Theme.alpha(Theme.fg, 0.25)
font.family: Theme.fontFamily
font.pixelSize: Math.max(Theme.fontSizeTitle, Math.round(card.height * 0.28))
}
Rectangle {
anchors.top: parent.top
anchors.right: parent.right
anchors.margins: 8
visible: root.focusBound
width: focusBadge.implicitWidth + 16
height: 24
radius: Theme.pillRadius
border.width: 0
color: Theme.alpha(Theme.bgDark, 0.82)
Row {
id: focusBadge
anchors.centerIn: parent
spacing: 5
Text {
anchors.verticalCenter: parent.verticalCenter
text: "\u{F051F}"
color: Theme.accent
font.family: Theme.fontMono
font.pixelSize: 12
}
Text {
anchors.verticalCenter: parent.verticalCenter
text: "Focus"
color: Theme.fg
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSizeSmall
font.weight: Font.Medium
}
}
}
// Sits below the thumbnails so clicking a window still focuses it.
MouseArea {
anchors.fill: parent
z: -1
onClicked: root.activated()
}
}
Text {
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
height: root.labelHeight
verticalAlignment: Text.AlignVCenter
text: root.label
color: root.emphasized ? Theme.accent : Theme.fgDim
font.family: Theme.fontFamily
font.pixelSize: Theme.fontSize
font.bold: root.emphasized
}
}