270 lines
8.8 KiB
QML
270 lines
8.8 KiB
QML
// 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();
|
|
}
|
|
}
|
|
}
|
|
}
|