// 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 // A ScreencopyView cannot tell us that its recording context is // ready before a capture. The enclosing overview gets that // context only after its first rendered frame, so defer the first // one-shot capture to that event. Retrying before then only emits // "no recording context is ready" warnings; if a capture later // cannot produce content, the app icon remains the fallback. property bool recordingReady: false function tryCapture(): void { if (shot.hasContent) return; if (!shot.recordingReady) { frameReady.restart(); return; } shot.captureFrame(); } FrameAnimation { id: frameReady running: false onTriggered: { running = false; if (shot.hasContent) return; shot.recordingReady = true; shot.tryCapture(); } } 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(); } } } }