// What a dock icon has open, shown after a dwell on hover. // // Its own surface, not something drawn inside the dock. The dock's input mask // is a thin strip when hidden and the bar's own rectangle when revealed; // widening it to cover a preview strip would hand the dock every click in the // empty space above it, which is most of the screen. // // Capture is one-shot -- `live: false` plus an explicit captureFrame() -- for // the same reason the overview's thumbnails are: streaming four windows for as // long as a pointer rests on an icon repaints continuously for nothing. The // deferral around that first capture is copied from // modules/overview/WindowThumbnail.qml, where the reasoning is written out. import Quickshell import Quickshell.Hyprland import Quickshell.Wayland import Quickshell.Widgets import QtQuick import qs.config PopupWindow { id: root // The DockItem being hovered, and the app object behind it. Both are // written by the Dock from DockBody's dwell timer. property Item anchorItem: null property var app: null // Which edge the dock lives on, so the strip appears on the side of the // icon that faces the screen rather than off the edge. property string position: "bottom" readonly property bool vertical: root.position === "left" || root.position === "right" // Four is the cap. A strip of previews wider than the screen is not a // preview of anything, and the point of this surface is to answer "which // window do I want" at a glance -- past four, the answer is the overview. readonly property int previewCap: 4 readonly property var allWindows: root.app && root.app.windows ? root.app.windows : [] readonly property var windows: root.allWindows.length > root.previewCap ? root.allWindows.slice(0, root.previewCap) : root.allWindows readonly property int overflow: root.allWindows.length - root.windows.length // Read by the Dock, which feeds it back to DockBody's grace timer. Crossing // from the icon to a preview leaves the dock entirely -- these are separate // surfaces -- so without this the act of reaching for a preview closes it. readonly property bool hovered: pointer.hovered signal dismissed // Bumped each time the strip opens; each bump re-captures, so a window that // has changed since the last look is not shown as it was. property int refreshToken: 0 anchor.item: root.anchorItem // Bottom dock: above the icon. Side dock: alongside it, away from the edge. anchor.edges: root.vertical ? (root.position === "left" ? Edges.Right : Edges.Left) : Edges.Top anchor.gravity: root.vertical ? (root.position === "left" ? Edges.Right : Edges.Left) : Edges.Top anchor.margins.bottom: root.vertical ? 0 : 10 anchor.margins.left: root.position === "left" ? 10 : 0 anchor.margins.right: root.position === "right" ? 10 : 0 implicitWidth: strip.implicitWidth + 12 implicitHeight: strip.implicitHeight + 12 color: "transparent" visible: root.anchorItem !== null && root.windows.length > 0 // Deliberately NOT grabFocus. A grab would close the strip on the first // click anywhere and take the pointer with it, which is exactly the // gesture that is supposed to focus a window. grabFocus: false onVisibleChanged: { if (root.visible) root.refreshToken++; } function addressOf(toplevel: var): string { const raw = String(toplevel?.address ?? ""); if (!raw) return ""; return raw.startsWith("0x") ? raw : "0x" + raw; } function focusToplevel(toplevel: var): void { if (!toplevel) return; if (toplevel.workspace) toplevel.workspace.activate(); const address = root.addressOf(toplevel); if (address) Hyprland.dispatch(`hl.dsp.focus({ window = "address:${address}" })`); else if (toplevel.wayland) toplevel.wayland.activate(); root.dismissed(); } HoverHandler { id: pointer } Rectangle { anchors.fill: parent radius: Theme.popoverRadius color: Theme.alpha(Theme.bgPopover, Theme.popoverAlpha) border.width: 1 border.color: Theme.alpha(Theme.fg, 0.08) Row { id: strip anchors.centerIn: parent padding: 10 spacing: 8 Repeater { model: root.windows Rectangle { id: card required property var modelData readonly property var source: card.modelData ? card.modelData.wayland : null width: 176 height: 132 radius: Theme.cardRadius border.width: 0 // QTBUG-137166 color: cardHover.hovered ? Theme.alpha(Theme.fg, Theme.hoverAlpha) : Theme.alpha(Theme.bg, 0.5) clip: true Item { id: frame anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top anchors.bottom: caption.top anchors.margins: 6 anchors.bottomMargin: 2 // Shown until a frame arrives, and forever on a // compositor without screencopy -- an icon rather than // an empty box or an error. IconImage { anchors.centerIn: parent visible: root.app && root.app.entry && root.app.entry.icon source: root.app && root.app.entry && root.app.entry.icon ? (String(root.app.entry.icon).startsWith("/") ? "file://" + root.app.entry.icon : Quickshell.iconPath(root.app.entry.icon, true)) : "" implicitSize: 32 asynchronous: true mipmap: true opacity: shotLoader.hasFrame ? 0 : 1 } Loader { id: shotLoader anchors.fill: parent readonly property bool hasFrame: item ? item.hasContent : false // Gated on refreshToken for the reason spelled out // in WindowThumbnail: a ScreencopyView created // before its surface has mapped has no recording // context, and its first capture fails silently. active: !!card.source && root.refreshToken > 0 sourceComponent: shotComponent } Component { id: shotComponent ScreencopyView { id: shot captureSource: card.source live: false paintCursor: false constraintSize: Qt.size(frame.width, frame.height) 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 property bool recordingReady: false function tryCapture(): void { if (shot.hasContent) return; if (!shot.recordingReady) { frameReady.restart(); return; } shot.captureFrame(); } // One frame of the popup's own rendering is // what makes the capture context exist. FrameAnimation { id: frameReady running: false onTriggered: { running = false; if (shot.hasContent) return; shot.recordingReady = true; shot.tryCapture(); } } Component.onCompleted: tryCapture() } } } Text { id: caption anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right anchors.margins: 7 text: String(card.modelData?.title ?? "").trim() || (root.app ? root.app.appId : "") elide: Text.ElideRight horizontalAlignment: Text.AlignHCenter color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } HoverHandler { id: cardHover } TapHandler { onTapped: root.focusToplevel(card.modelData) } } } // Only when there are more windows than fit. Says so rather than // silently showing four of nine. Text { anchors.verticalCenter: parent.verticalCenter visible: root.overflow > 0 width: visible ? implicitWidth : 0 text: "+" + root.overflow color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } } }