import QtQuick import Quickshell import Quickshell.Wayland import qs.Commons import qs.Ui import "Switchboard.js" as Switchboard // One window in the overview: a live capture, a frame, and a chip naming it. // // The capture keeps the window's true aspect ratio — that is what makes the // overview read as your actual desktop rather than a wall of identical // rectangles — so the layout hands this item explicit image dimensions and the // chip is added below them at a fixed height. Uniform chip height is what keeps // every tile in a justified row the same total height. Item { id: root required property var entry // window record from Switchboard.buildModel property var state: null // match state from Switchboard.applyQuery property bool hasCursor: false property bool live: false // only the cursor tile stays live property real dimOpacity: 0.25 property real imageWidth: 160 property real imageHeight: 100 property var appLibrary: null // shell.appLibrary, when the shell injected one signal activated(rect frameRect) signal closeRequested() signal cursorRequested() signal dragStarted() signal dragMoved(real globalX, real globalY) signal dragEnded(real globalX, real globalY) // Reported whenever this tile becomes the cursor, so a keyboard jump can fly // from the same place a mouse jump would. signal rectReported(rect frameRect) property bool dragging: false readonly property var matchState: state || Switchboard.neutralState() readonly property bool matched: matchState.matched !== false readonly property int badge: matchState.badge || 0 readonly property string appName: entry ? String(entry.app || "") : "" readonly property string windowTitle: entry ? Switchboard.cleanTitle(entry.title) : "" readonly property int chipHeight: Style.space(30) readonly property int matWidth: Math.max(1, Style.space(3)) implicitWidth: root.imageWidth implicitHeight: root.imageHeight + root.chipHeight // The cursor tile lifts slightly. Everything else sits flat, so exactly one // tile is ever raised and the eye has a single place to land. scale: root.hasCursor ? 1.03 : 1.0 transformOrigin: Item.Center z: root.hasCursor ? 2 : 1 opacity: root.dragging ? 0.35 : (root.matched ? 1.0 : root.dimOpacity) Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutCubic } } // Reported one tick late so the row has been positioned first. A Timer owned // by this tile is used rather than Qt.callLater: delegates are destroyed and // rebuilt whenever the window list changes, and a pending callLater on a // destroyed delegate throws in an invalid context. Timer { id: reportTimer interval: 0 onTriggered: root.rectReported(root.tileFrameRect()) } onHasCursorChanged: if (root.hasCursor) reportTimer.restart() Component.onCompleted: if (root.hasCursor) reportTimer.restart() Behavior on opacity { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } // ------------------------------------------------------------------- frame BorderSurface { id: frame width: root.imageWidth height: root.imageHeight radius: Style.cornerRadius padding: root.matWidth // A mat rather than a rounded mask: Style.cornerRadius mirrors Hyprland's // own decoration:rounding, so a square desktop gets square tiles, and a // rounded one gets tiles whose captures already carry the compositor's // rounding. Masking each capture would cost a render target per tile. color: root.hasCursor ? Util.alpha(Color.accent, 0.16) : Util.alpha(Color.popups.text, 0.06) borderSpec: root.hasCursor ? Border.flat(Color.accent, Math.max(1, Style.space(2))) : (root.entry && root.entry.urgent ? Border.flat(Color.urgent, Math.max(1, Style.space(2))) : Border.flat(Util.alpha(Color.popups.text, 0.14), 1)) Behavior on color { ColorAnimation { duration: 120 } } // ---------------------------------------------------------- the capture Item { id: canvas anchors.fill: parent anchors.margins: frame.padding clip: true ScreencopyView { id: capture anchors.fill: parent captureSource: root.entry ? root.entry.toplevel.wayland : null // Every tile paints a first frame; only the cursor tile keeps // streaming. Holding N live captures open is the one genuinely // expensive thing this overlay can do. live: root.live paintCursor: false visible: hasContent } // Shown until the first frame lands, and for any window the compositor // will not export. It has to look deliberate, because on a slow first // open it is the first thing the user sees. Item { anchors.fill: parent visible: !capture.hasContent Rectangle { anchors.fill: parent color: Util.alpha(Color.accent, 0.07) } Image { id: appIcon anchors.centerIn: parent width: Math.min(Style.space(56), Math.round(Math.min(parent.width, parent.height) * 0.42)) height: width source: root.iconSource() sourceSize.width: width sourceSize.height: width fillMode: Image.PreserveAspectFit smooth: true asynchronous: true visible: status === Image.Ready } // Last resort when the app has no resolvable icon: its own initial, // which is still recognisable and never a broken-image box. Text { anchors.centerIn: parent visible: appIcon.status !== Image.Ready textFormat: Text.PlainText text: root.appName.length > 0 ? root.appName.charAt(0).toUpperCase() : "?" color: Util.alpha(Color.popups.text, 0.45) font.family: Style.font.family font.pixelSize: Math.round(Math.min(parent.width, parent.height) * 0.34) font.bold: true renderType: Text.NativeRendering } } // Dimming a filtered-out tile by veiling it toward the panel background // reads as desaturation without costing a shader pass per tile. Rectangle { anchors.fill: parent color: Color.popups.background opacity: root.matched ? 0 : 0.55 Behavior on opacity { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } } } } // ------------------------------------------------------------ jump badge Rectangle { id: badgePill visible: root.badge > 0 anchors.top: parent.top anchors.left: parent.left anchors.margins: Style.space(6) width: Math.max(Style.space(20), badgeLabel.implicitWidth + Style.space(10)) height: Style.space(20) radius: Style.cornerRadius > 0 ? height / 2 : 0 color: root.hasCursor ? Color.accent : Util.alpha(Color.popups.background, 0.92) border.width: 1 border.color: root.hasCursor ? Color.accent : Util.alpha(Color.popups.text, 0.22) Behavior on color { ColorAnimation { duration: 120 } } Text { id: badgeLabel anchors.centerIn: parent textFormat: Text.PlainText text: String(root.badge) color: root.hasCursor ? Color.popups.background : Color.popups.text font.family: Style.font.family font.pixelSize: Style.font.caption font.bold: true renderType: Text.NativeRendering } } } // -------------------------------------------------------------------- chip Item { id: chip anchors.top: frame.bottom anchors.left: frame.left anchors.right: frame.right height: root.chipHeight Row { anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter anchors.leftMargin: Style.space(2) anchors.rightMargin: Style.space(2) spacing: Style.space(7) Image { id: chipIcon width: Style.space(15) height: width anchors.verticalCenter: parent.verticalCenter source: root.iconSource() sourceSize.width: width sourceSize.height: width fillMode: Image.PreserveAspectFit smooth: true asynchronous: true visible: status === Image.Ready } Text { id: appLabel anchors.verticalCenter: parent.verticalCenter // StyledText so the characters that earned the match can be tinted. // Switchboard.highlightHtml escapes every segment: a window title is content // the window itself controls. textFormat: Text.StyledText text: Switchboard.highlightHtml( root.appName, root.matchState.field === "app" ? root.matchState.ranges : [], String(Color.accent)) color: Color.popups.text font.family: Style.font.family font.pixelSize: Style.font.bodySmall font.bold: true elide: Text.ElideRight width: Math.min(implicitWidth, chip.width * 0.5) renderType: Text.NativeRendering } Text { id: titleLabel anchors.verticalCenter: parent.verticalCenter textFormat: Text.StyledText text: Switchboard.highlightHtml( root.windowTitle, root.matchState.field === "title" ? root.matchState.ranges : [], String(Color.accent)) color: Qt.darker(Color.popups.text, 1.5) font.family: Style.font.family font.pixelSize: Style.font.caption elide: Text.ElideRight width: Math.max(0, chip.width - chipIcon.width - appLabel.width - Style.space(20)) visible: root.windowTitle.length > 0 && width > Style.space(24) renderType: Text.NativeRendering } } } // ------------------------------------------------------------------ input MouseArea { id: pointer anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.MiddleButton cursorShape: root.dragging ? Qt.ClosedHandCursor : (root.matched ? Qt.PointingHandCursor : Qt.ArrowCursor) // Distance the pointer must travel before a press becomes a drag. Below it, // a click is still a click — otherwise a jittery mouse would turn every // jump into an accidental move. readonly property int dragThreshold: Style.space(8) property point pressPoint: Qt.point(0, 0) property bool armed: false // Hover raises the shared cursor rather than painting this tile directly. // CursorSurface.qml is explicit that components must not read containsMouse // for colour, and that convention is what guarantees exactly one highlight // on screen whether the user is on the keyboard or the mouse. onEntered: if (root.matched) root.cursorRequested() onPressed: function (mouse) { if (mouse.button !== Qt.LeftButton) return pointer.pressPoint = Qt.point(mouse.x, mouse.y) pointer.armed = root.matched } onPositionChanged: function (mouse) { if (!pointer.armed) return if (!root.dragging) { var dx = mouse.x - pointer.pressPoint.x var dy = mouse.y - pointer.pressPoint.y if (Math.abs(dx) < pointer.dragThreshold && Math.abs(dy) < pointer.dragThreshold) return root.dragging = true root.dragStarted() } var global = root.mapToItem(null, mouse.x, mouse.y) root.dragMoved(global.x, global.y) } onReleased: function (mouse) { if (root.dragging) { var global = root.mapToItem(null, mouse.x, mouse.y) root.dragging = false root.dragEnded(global.x, global.y) } else if (pointer.armed && mouse.button === Qt.LeftButton) { root.activated(root.tileFrameRect()) } pointer.armed = false } onCanceled: { // A grab lost mid-drag must not leave the tile stuck at drag opacity. if (root.dragging) { root.dragging = false root.dragEnded(-1, -1) } pointer.armed = false } onClicked: function (mouse) { if (mouse.button === Qt.MiddleButton && root.matched) root.closeRequested() } } // Where this tile's image sits in window coordinates. The overlay flies a // ghost from here to the window's real position on the desktop, so the jump // reads as the window coming to meet you rather than a cut. function tileFrameRect() { var origin = frame.mapToItem(null, 0, 0) return Qt.rect(origin.x, origin.y, frame.width, frame.height) } // ---------------------------------------------------------------- helpers function iconSource() { var cls = root.entry ? Switchboard.windowClass(root.entry.toplevel) : "" if (cls === "") return "" // The shell's AppLibrary keeps a desktop-entry index that resolves icons // Qt's themed lookup misses; fall back to the plain lookup in the dev // harness, where no shell is injected. if (root.appLibrary && typeof root.appLibrary.iconSource === "function") { return root.appLibrary.iconSource(cls) } return Quickshell.iconPath(cls, true) } }