// 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 } // Other modules (the bar, the capture overlay) read this. It is one // shared flag but there is one Dock per monitor, so only the instance on // the currently-focused monitor is allowed to write it -- otherwise // whichever instance last changed reveal state would stomp the others, // and a reader would see an arbitrary monitor's value. This scopes the // flag to mean "is the dock revealed on the monitor the user is on", // which is what a capture overlay or the bar actually care about. // (A true per-monitor flag would need ShellState.dockRevealed itself to // become keyed by screen, which is out of scope here -- see the report.) readonly property bool isFocusedMonitorInstance: root.monitor === null || root.monitor === Hyprland.focusedMonitor 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 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 body.x; return root.position === "right" ? body.x : 0; } y: { if (!root.revealed) return root.vertical ? 0 : surface.height - root.revealStripHeight; return body.y; } width: { if (!root.revealed) return root.vertical ? root.revealStripHeight : surface.width; return root.vertical ? (root.position === "right" ? surface.width - body.x : body.x + body.width) : body.width; } height: { if (!root.revealed) return root.vertical ? surface.height : root.revealStripHeight; return root.vertical ? body.height : surface.height - body.y; } } 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.visible = 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 } // 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() } }