// Copyright (C) 2026 Danilo M. // // This program is free software; you can redistribute it and/or modify // it under the terms of the GNU General Public License version 2 as // published by the Free Software Foundation. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU General Public License for more details. import Quickshell import Quickshell.Wayland import Quickshell.Widgets import QtQuick // One window in the expose: a live preview sized from outside to its rect in // the scatter. App icon, workspace marker and app name are always shown; // title only on the selected card, close only on hover, so the scatter // stays clean. Item { id: card required property var toplevel property bool selected: false readonly property bool hovered: cardHover.hovered signal activated() signal closeRequested() // A Hyprland toplevel carries its Wayland handle separately, and the two // are not created in the same instant, so this reads null for a window // that is appearing or going away. Everything below guards for it. readonly property var wl: toplevel.wayland ?? null // The bar's icon for this window's workspace, empty for a named workspace. // Resolved to a path here so the card can fall back to the number when the // active icon theme has no such icon, rather than showing a blank marker. readonly property string workspaceIconPath: { const name = Windows.workspaceIcon(toplevel.workspace); return name !== "" ? Quickshell.iconPath(name, true) : ""; } HoverHandler { id: cardHover } Glow { anchors.fill: box radius: box.radius on: card.selected } Rectangle { id: box anchors.fill: parent radius: 10 color: Theme.surface border.color: card.selected ? Theme.accent : Theme.surfaceAlt border.width: 1 // The rect already has the window's shape, so the preview fills it. // The fit is kept for the moment before a capture lands: an // uncaptured view reports sourceSize (-1, -1) rather than (0, 0), so // this tests for a positive height, falling back to the box's ratio. ScreencopyView { id: preview anchors.centerIn: parent captureSource: card.wl live: true readonly property real ar: sourceSize.height > 0 ? sourceSize.width / sourceSize.height : parent.width / Math.max(1, parent.height) width: Math.min(parent.width - 8, (parent.height - 8) * ar) height: Math.min(parent.height - 8, (parent.width - 8) / ar) } MouseArea { anchors.fill: parent onClicked: card.activated() } // Every overlay sits on an arbitrary window preview, so each carries // its own scrim: a themed icon can otherwise land on a same-coloured // region and vanish, a light icon on a white page being the obvious // case. Row { anchors { left: parent.left; top: parent.top; margins: 8 } spacing: 6 // The desktop marker, drawn with the workspace icon rather than // the number. A workspace with no icon keeps the name, so the // marker never goes blank. Rectangle { width: Math.max(34, wsName.implicitWidth + 12); height: 34; radius: 8 color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) IconImage { anchors.centerIn: parent width: 22; height: 22 source: card.workspaceIconPath } Text { id: wsName anchors.centerIn: parent visible: card.workspaceIconPath === "" text: card.toplevel.workspace ? card.toplevel.workspace.name : "?" color: Theme.accent font.family: Theme.fontFamily font.pixelSize: 13 } } } Rectangle { id: closeButton visible: cardHover.hovered anchors { right: parent.right; top: parent.top; margins: 8 } width: 34; height: 34; radius: 17 color: closeArea.pressed ? Qt.lighter(Theme.red, 1.25) : closeHover.hovered ? Theme.red : Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) IconImage { anchors.centerIn: parent width: 22; height: 22 source: Quickshell.iconPath("window-close", true) } HoverHandler { id: closeHover } MouseArea { id: closeArea anchors.fill: parent onClicked: card.closeRequested() } } Rectangle { id: appIcon anchors { left: parent.left; bottom: parent.bottom; margins: 8 } width: 40; height: 40; radius: 10 color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) IconImage { anchors.centerIn: parent width: 30; height: 30 source: Quickshell.iconPath(card.wl ? card.wl.appId : "", true) } } Rectangle { visible: card.selected anchors { left: appIcon.right; right: parent.right; bottom: parent.bottom; margins: 8 } height: 30; radius: 8 color: Qt.rgba(Theme.base.r, Theme.base.g, Theme.base.b, 0.75) Text { anchors { fill: parent; leftMargin: 10; rightMargin: 10 } verticalAlignment: Text.AlignVCenter elide: Text.ElideRight text: card.toplevel.title color: Theme.text font.family: Theme.fontFamily font.pixelSize: 13 } } } // The app's name hangs below the preview, outside the card's rect: the // scatter's gap leaves room for it. The desktop entry's name reads // better than an app id, which is the fallback. Text { anchors { top: box.bottom; topMargin: 6; horizontalCenter: box.horizontalCenter } width: Math.min(implicitWidth, box.width) elide: Text.ElideRight text: { const id = card.wl ? card.wl.appId : ""; const e = id ? DesktopEntries.heuristicLookup(id) : null; return e ? e.name : id; } color: card.selected ? Theme.text : Theme.subtext font.family: Theme.fontFamily font.pixelSize: 14 } }