380 lines
17 KiB
QML
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()
|
|
}
|
|
}
|