Files
Panama/config/dot/quickshell/modules/overview/WindowThumbnail.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();
}
}
}
}