// One dock icon: the app, its running-window dots, hover scale and click // behavior. Reproduces Dash-to-Dock's item: 48px icon, up to four dots // underneath, grow-on-hover, click to focus / cycle / launch. import Quickshell import Quickshell.Hyprland import Quickshell.Widgets import QtQuick import qs.config Item { id: root // { entry: DesktopEntry|null, windows: [HyprlandToplevel], appId: string, // pinned: bool }. Built by DockBody so this file stays presentational. required property var app readonly property DesktopEntry entry: app && app.entry ? app.entry : null readonly property var windows: app && app.windows ? app.windows : [] readonly property string label: entry ? entry.name : (app && app.appId ? app.appId : "") readonly property bool running: windows.length > 0 readonly property bool hovered: mouse.containsMouse // Set by DockBody. Which way the dock runs decides which axis a drag reads. property bool vertical: false // Reordering, driven from DockBody: whether this icon may be dragged at // all, how far it is currently displaced, and whether it is the one being // dragged rather than one being pushed aside. property bool draggable: false property real dragShift: 0 property bool dragging: false // Emitted so DockBody can drive the single shared tooltip. signal entered signal exited signal contextMenuRequested // The drag, reported as travel along the dock's own axis from where the // press landed. DockBody owns what that means. // // The start also carries the pitch -- one cell plus the gap after it -- // because the cell size is this file's business: an item is taller than it // is wide (the running dots sit under the icon), so a slot down a side dock // is further than a slot across a bottom one. Measuring it here rather than // recomputing it in DockBody keeps one copy of that arithmetic. signal dragStarted(real pitch) signal dragMoved(real travel) signal dragEnded signal dragCancelled // The icon may grow past the cell on hover; the cell itself stays a fixed // size so the row doesn't reflow. implicitWidth: Theme.dockIconSize implicitHeight: Theme.dockIconSize + dots.height + 4 // Above the icons it is passing. z: root.dragging ? 2 : 0 // A transform, not an x/y binding: the Grid owns those, and assigning them // in a delegate fights the positioner rather than moving the icon. transform: Translate { x: root.vertical ? 0 : root.dragShift y: root.vertical ? root.dragShift : 0 } // Desktop entries usually carry a freedesktop icon *name*, but some ship an // absolute path. iconPath() only understands names, so branch on it. The // `true` argument makes a missing icon return "" instead of a placeholder // that renders as a black square in some themes. readonly property string iconSource: { const name = entry && entry.icon ? entry.icon : (app && app.appId ? app.appId : ""); if (!name) return ""; if (name.startsWith("/")) return "file://" + name; return Quickshell.iconPath(name, true); } Item { id: iconSlot width: Theme.dockIconSize height: Theme.dockIconSize anchors.horizontalCenter: parent.horizontalCenter y: 0 // Grow upward out of the dock, the way GNOME's dash does, so the icon // never overlaps its own running dots. transformOrigin: Item.Bottom scale: root.hovered ? 1.15 : 1.0 Behavior on scale { NumberAnimation { duration: Theme.durFast easing.type: Easing.OutBack easing.overshoot: 1.6 } } IconImage { anchors.fill: parent visible: root.iconSource !== "" source: root.iconSource asynchronous: true mipmap: true } // Last resort for an app with no resolvable icon: an initial, which is // still recognizable, unlike the theme's broken-icon placeholder. Rectangle { anchors.fill: parent visible: root.iconSource === "" radius: Theme.cardRadius border.width: 0 color: Theme.alpha(Theme.fg, Theme.activeAlpha) Text { anchors.centerIn: parent text: root.label ? root.label.charAt(0).toUpperCase() : "?" color: Theme.fg font.family: Theme.fontFamily font.pixelSize: Math.round(Theme.dockIconSize * 0.5) } } } // Running indicators. Dash-to-Dock showed one dot per window up to four. Row { id: dots anchors.horizontalCenter: parent.horizontalCenter anchors.bottom: parent.bottom spacing: 3 height: 4 Repeater { model: Math.min(root.windows.length, 4) Rectangle { width: 4 height: 4 radius: 2 border.width: 0 // QTBUG-137166: transparent rounded rects punch corners color: Theme.accent } } } MouseArea { id: mouse anchors.fill: parent hoverEnabled: true acceptedButtons: Qt.LeftButton | Qt.MiddleButton | Qt.RightButton // Where the press landed, and whether it has travelled far enough to // stop being a click. The threshold is the whole reason a drag can // share this MouseArea with the launch click: a hand that means to // click never moves eight pixels while the button is down. readonly property int dragThreshold: 8 property real pressX: 0 property real pressY: 0 property bool dragActive: false property bool dragConsumed: false // A wheel notch is 120 units, but a touchpad sends far smaller ones and // a free-spinning wheel sends larger. Accumulating and spending whole // notches is what makes both feel the same -- reacting to every event // would make a touchpad flick blur through every window an app owns. property int wheelTravel: 0 onEntered: root.entered() onExited: root.exited() onWheel: event => { if (!root.running) { mouse.wheelTravel = 0; return; } mouse.wheelTravel += event.angleDelta.y; while (mouse.wheelTravel >= 120) { mouse.wheelTravel -= 120; root.focusBy(-1); } while (mouse.wheelTravel <= -120) { mouse.wheelTravel += 120; root.focusBy(1); } } onPressed: mev => { // A press arriving while a drag is still marked active means the // last one never got its release -- a grab stolen by a surface that // opened over the dock, most often. Clearing the flag alone would // leave DockBody still holding a dragIndex, and with it the // interaction hold that keeps the dock revealed forever; the drag // has to be cancelled through the same path a stolen grab uses. if (mouse.dragActive) { mouse.dragActive = false; mouse.dragConsumed = false; root.dragCancelled(); } mouse.pressX = mev.x; mouse.pressY = mev.y; mouse.dragActive = false; mouse.dragConsumed = false; } onPositionChanged: mev => { if (!root.draggable || !mouse.pressedButtons) return; const travel = root.vertical ? mev.y - mouse.pressY : mev.x - mouse.pressX; if (!mouse.dragActive) { if (Math.abs(travel) < mouse.dragThreshold) return; mouse.dragActive = true; mouse.dragConsumed = true; root.dragStarted((root.vertical ? root.height : root.width) + Theme.dockGap); } root.dragMoved(travel); } onReleased: { if (!mouse.dragActive) return; mouse.dragActive = false; root.dragEnded(); } onCanceled: { if (!mouse.dragActive) return; mouse.dragActive = false; mouse.dragConsumed = false; root.dragCancelled(); } onClicked: mev => { // A gesture that reordered the dock is not also a launch. if (mouse.dragConsumed) { mouse.dragConsumed = false; return; } // Middle click always starts a new instance, as in GNOME. if (mev.button === Qt.MiddleButton) { root.launch(); return; } if (mev.button === Qt.RightButton) { root.contextMenuRequested(); return; } if (root.running) root.focusBy(1); else root.launch(); } } function launch(): void { if (root.entry) root.entry.execute(); } // Stepping through an app's windows: clicking a running app takes one step // forward, matching GNOME's dash, and the wheel takes one in either // direction. With nothing of this app focused, any step lands on its first // window rather than counting from a window the user is not looking at. function focusBy(delta: int): void { const wins = root.windows; if (wins.length === 0) return; let current = -1; for (let i = 0; i < wins.length; i++) { if (wins[i].activated) { current = i; break; } } const next = current < 0 ? wins[0] : wins[((current + delta) % wins.length + wins.length) % wins.length]; if (!next) return; // Quickshell reports the address without the 0x prefix Hyprland's // window selector expects; normalize so either form works. if (next.address) { const addr = next.address.startsWith("0x") ? next.address : "0x" + next.address; Hyprland.dispatch(`hl.dsp.focus({ window = "address:${addr}" })`); } else if (next.wayland) { // No address yet (the toplevel is still being reported) — the // wlr handle can still raise it. next.wayland.activate(); } } }