Files

380 lines
17 KiB
QML

// 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()
}
}