// The dock — a replacement for Dash-to-Dock configured as: bottom edge, 48px // icons, intellihide against all windows, dot running-indicators, show-apps // button at the leading edge. // // Intellihide means the dock reserves no space at all (exclusiveZone 0) and // floats over windows, appearing when the pointer reaches the bottom edge or // when nothing is in the way. import Quickshell import Quickshell.Wayland import Quickshell.Hyprland import QtQuick import qs.config import qs.services PanelWindow { id: root // Set by Variants when instantiated per-screen. Plain rather than // `required` so the dock can also be created standalone while testing; // Variants injects into the existing property either way. property var modelData: null screen: root.modelData // Which edge this dock lives on, and everything that follows from it. The // bottom case is unchanged in every particular: same anchors, same // geometry, same slide -- so a machine that never touches the setting sees // exactly the dock it had. readonly property string position: Settings.dockPosition readonly property bool vertical: root.position === "left" || root.position === "right" // Only on the screens asked for. An empty list means all of them, which is // what a single-monitor machine wants and what an unplugged display should // not be able to change. readonly property bool onThisScreen: { const wanted = Settings.dockScreens; if (!wanted || wanted.length === 0) return true; return wanted.indexOf(String(root.screen?.name ?? "")) >= 0; } visible: root.onThisScreen // A dock spans the edge it lives on, which means anchoring BOTH ends of // that edge: bottom+left+right across the screen, or top+bottom plus one // side down it. Anchoring only one end leaves the surface free to collapse // to its implicit size on that axis -- a side dock came out one pixel tall. anchors { top: root.vertical bottom: true left: root.position !== "right" right: root.position !== "left" } color: "transparent" // A dock that reserved space would not be intellihiding. exclusiveZone: 0 exclusionMode: ExclusionMode.Ignore // Matched by the `qs-dock` layer rule in hypr/rules.lua — do not rename. WlrLayershell.namespace: "qs-dock" WlrLayershell.layer: WlrLayer.Top WlrLayershell.keyboardFocus: WlrKeyboardFocus.None // ── Geometry ──────────────────────────────────────────────────────────── // Height = room for the tooltip above the bar + the bar + the gap under it. readonly property int revealStripHeight: 3 readonly property int edgeMargin: Theme.barGap readonly property int tooltipSpace: 34 // Only the axis the dock is thin on gets an implicit size; the other is // spanned by the anchors above. Setting both would fight them. implicitHeight: root.vertical ? 0 : tooltipSpace + body.implicitHeight + edgeMargin implicitWidth: root.vertical ? tooltipSpace + body.implicitWidth + edgeMargin : 0 // ── Intellihide ───────────────────────────────────────────────────────── // This instance's own monitor, the same lookup Workspaces.qml uses to // scope a per-screen bar to its own screen. Falls back to null when this // Dock is created standalone (no `screen` set) rather than via Variants. readonly property HyprlandMonitor monitor: root.screen ? Hyprland.monitorFor(root.screen) : null // Hyprland does not expose live toplevel geometry, so exact overlap cannot // be computed. "Is anything on this workspace at all" is the robust proxy, // and it is what Dash-to-Dock's all-windows intellihide felt like in // practice: an empty workspace keeps the dock out. // // Deliberately this instance's own monitor's active workspace, not the // globally-focused one -- with one Dock per screen, keying off the global // focus would make focusing an empty workspace on monitor A hide the dock // on monitor B even though B's own workspace is still busy. readonly property bool workspaceOccupied: { const ws = root.monitor ? root.monitor.activeWorkspace : Hyprland.focusedWorkspace; return !!ws && ws.toplevels.values.length > 0; } // Anything anchored to the dock that the dock would drag off-screen with // it. The pointer leaves the dock the moment it enters an open menu -- the // menu is its own surface -- so without this the dock slides away under // the menu it opened, and the row the hand was reaching for goes with it. // The same is true mid-drag, and while a preview is up. readonly property bool interactionHeld: dockContextMenu.visible || body.dragging || dockPreviews.visible readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered || root.interactionHeld property bool revealed: true onWantRevealedChanged: { if (wantRevealed) { hideTimer.stop(); revealTimer.restart(); } else { revealTimer.stop(); hideTimer.restart(); } } Timer { id: revealTimer interval: Settings.dockRevealDelayMs onTriggered: root.revealed = true } Timer { id: hideTimer interval: Settings.dockHideDelayMs onTriggered: root.revealed = false } // ShellState.dockRevealed is one shared flag and there is one Dock per // monitor, so exactly one instance may write it -- otherwise whichever // instance last changed reveal state stomps the others and a reader gets an // arbitrary monitor's answer. Scoped this way the flag means "is the dock // revealed on the monitor the user is on", which is the only question a // reader outside the dock can sensibly ask of it. (A genuinely per-monitor // answer would need the property itself keyed by screen.) // // Two things disqualify an instance. One is not being on the focused // monitor, which is the whole point. The other is not being on screen at // all: a Dock whose screen is not in `dockScreens` is invisible, and an // invisible dock reporting itself as revealed is a lie a reader acts on. An // instance whose monitor could not be resolved -- created standalone, or // asked before Hyprland has reported the screen -- knows nothing about // which monitor the user is on, so it writes only when there is no focused // monitor to be wrong about rather than stomping the instance that knows. // // Nothing reads the flag today: the bar and the capture overlay it was // written for both stopped. It is written correctly rather than left // half-wrong, and when ShellState is next opened the property, this // arbitration and _syncShellState should go together. readonly property bool isFocusedMonitorInstance: { if (!root.onThisScreen) return false; if (root.monitor !== null) return root.monitor === Hyprland.focusedMonitor; return Hyprland.focusedMonitor === null; } onRevealedChanged: root._syncShellState() onIsFocusedMonitorInstanceChanged: root._syncShellState() function _syncShellState(): void { if (root.isFocusedMonitorInstance) ShellState.dockRevealed = root.revealed; } // wantRevealed's first evaluation emits no change signal when it lands on // false (the default), so the initial state has to be taken explicitly — // otherwise a shell started on a busy workspace would leave the dock up. Component.onCompleted: { revealed = wantRevealed; root._syncShellState(); } // ── Input region ──────────────────────────────────────────────────────── // Revealed: the bar plus everything below it, so crossing the gap under the // dock does not count as leaving. Hidden: a sliver along the screen edge, // which is the only thing that can still trigger the dock — every other // click passes straight through to the window underneath. mask: Region { item: maskItem } Item { id: surface anchors.fill: parent // Reports the pointer anywhere inside the input region. A HoverHandler // rather than a MouseArea because it keeps reporting while the icons' // own MouseAreas are hovered. HoverHandler { id: pointer } // Revealed: the dock plus everything between it and the edge, so // crossing the gap does not count as leaving. Hidden: a sliver along // the edge the dock lives on, which is the only thing that can bring // it back -- every other click passes through to the window beneath. Item { id: maskItem // Where the body comes to rest once the slide finishes, mirroring // DockBody's own x/y bindings in their revealed case. // // The revealed region is built from these rather than from the // body's live position. The body slides in over a couple of hundred // milliseconds, and a region that follows it in is a region that is // a few pixels tall on the frame the dock is summoned -- the exact // frame the pointer that summoned it needs to be inside, and the // exact frame it drops the hover and sends the dock back. Following // the animation also means an input-region commit to the compositor // on every one of those frames, for a rectangle that is only right // on the last of them. // // Clamped rather than assigned outright so the mask still tracks // the body if anything else ever moves it: the reveal only ever // approaches these values from outside the screen edge. readonly property real settledX: { if (!root.vertical) return body.x; return root.position === "left" ? Math.max(body.x, root.tooltipSpace) : Math.min(body.x, surface.width - body.width - root.tooltipSpace); } readonly property real settledY: root.vertical ? body.y : Math.min(body.y, root.tooltipSpace) x: { if (!root.revealed) return root.position === "right" ? surface.width - root.revealStripHeight : 0; // A horizontal dock is centred on its edge, so the region has // to start where the body starts -- starting at 0 puts it over // the left of the screen while the pointer that summoned the // dock is in the middle, and the hover drops on the very frame // the dock arrives. A vertical dock reaches from the body out // to its own edge, which is x 0 on the left and the body on // the right. if (!root.vertical) return maskItem.settledX; return root.position === "right" ? maskItem.settledX : 0; } y: { if (!root.revealed) return root.vertical ? 0 : surface.height - root.revealStripHeight; return maskItem.settledY; } width: { if (!root.revealed) return root.vertical ? root.revealStripHeight : surface.width; return root.vertical ? (root.position === "right" ? surface.width - maskItem.settledX : maskItem.settledX + body.width) : body.width; } height: { if (!root.revealed) return root.vertical ? surface.height : root.revealStripHeight; return root.vertical ? body.height : surface.height - maskItem.settledY; } } DockBody { id: body onContextMenuRequested: (anchorItem, app) => { // The whole app object, not its desktop entry: the menu lists // the app's own windows and offers to pin or unpin it, and // neither fact survives being narrowed to an entry. body.dismissPreview(); dockContextMenu.anchorItem = anchorItem; dockContextMenu.app = app; dockContextMenu.requested = true; } vertical: root.vertical leftSide: root.position === "left" // Both axes are computed rather than anchored. Anchoring the centre // on one axis and binding a position on the other looks tidier and // is a conflict: an anchored centre owns that coordinate, so the // binding beside it is fighting for the same value. // // Centred on the long axis; on the short one it sits a tooltip's // width in from the edge when revealed, and off-screen when not. x: { if (!root.vertical) return (surface.width - width) / 2; if (root.position === "left") return root.revealed ? root.tooltipSpace : -width; return root.revealed ? surface.width - width - root.tooltipSpace : surface.width; } y: { if (root.vertical) return (surface.height - height) / 2; return root.revealed ? root.tooltipSpace : surface.height; } opacity: root.revealed ? 1 : 0 Behavior on x { NumberAnimation { duration: root.revealed ? Theme.durDockReveal : Theme.durNormal easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic } } // Asymmetric on purpose. Revealing is a response to something the // user just did, so it has to feel immediate — any delay there // reads as lag. Hiding is not a response to anything, so it can // take its time and stay calm in peripheral vision. Behavior on y { NumberAnimation { duration: root.revealed ? Theme.durDockReveal : Theme.durNormal easing.type: root.revealed ? Easing.OutQuint : Easing.InCubic } } Behavior on opacity { NumberAnimation { duration: root.revealed ? Theme.durDockReveal : Theme.durNormal easing.type: Easing.OutCubic } } } } DockContextMenu { id: dockContextMenu } // The safety net for a latched interaction flag. // // Every term of `interactionHeld` is cleared by an event that can go // missing: a drag by its release, which a stolen grab eats; a preview by // the pointer leaving, which a destroyed anchor never reports; a menu by a // row being chosen, which a menu whose anchor died is never offered. Any // one of them left set holds the dock revealed with the pointer nowhere // near it, and there is no gesture that gets it back -- the flag is stuck, // so the dock is out until the shell restarts. // // Eight seconds of held-but-untouched is not a gesture. The pointer being // on the previews or on the menu counts as touched, because both are their // own surfaces and leaving the dock is how you reach them. Timer { id: interactionWatchdog interval: 8000 running: root.interactionHeld && !pointer.hovered && !dockPreviews.hovered && !dockContextMenu.hovered onTriggered: { body.cancelDrag(); body.dismissPreview(); dockContextMenu.requested = false; } } // Its own surface rather than something drawn inside the dock: the dock's // input mask is a thin strip when hidden and the bar's own rectangle when // shown, and widening it to cover a preview would hand the dock every // click in the empty space above it. DockPreviews { id: dockPreviews anchorItem: body.previewAnchor app: body.previewApp position: root.position // The pointer crossing from the icon to the previews leaves the dock // entirely -- these are separate surfaces -- so the previews report // their own hover back, and DockBody's grace timer uses it to tell // "reaching for a preview" from "moved away". onHoveredChanged: body.previewHovered = dockPreviews.hovered onDismissed: body.dismissPreview() } }