Three things that were parked, and the reasons they were parked turned out to be the useful part of doing them. The Dock can sit on the left or the right as well as the bottom. Everything that assumed the bottom edge is now asked which edge it is on: the anchors, the axis that gets an implicit size, the sliver of input region that survives hiding, the direction the body slides away in, and which side a tooltip opens towards. The body was a Row and is a Grid, because one declaration then serves both orientations -- Row and Column would each need their own children, and the cross-axis anchors that centre items in a Row are the wrong axis in a Column. Bottom is unchanged in every particular, and the settings default to it, so a hot reload in the middle of this work left the running dock exactly where it was. One bug worth recording because static review would never have found it: a dock spans the edge it lives on, which means anchoring BOTH ends of that edge. The first side dock anchored top and left only, was free to collapse to its implicit height, and came out one pixel tall. It parsed, it loaded, and it rendered nothing. The contract measures the geometry rather than reading the source for that reason, and was verified by putting the single-ended anchor back. Per-screen is a list of names where empty means every screen, because a list is what goes stale when a display is unplugged and "all" should not be spelled as one. Turning off the last screen collapses to "all" rather than leaving no dock anywhere and no obvious way back. Pins can be dragged by a grip. The objection this file recorded for a long time was real -- dragging inside a Flickable inside a scrolling page fails in a way that reads as breakage -- and the answer is preventStealing on the grip, so the page cannot claim a gesture that started there. The arrow buttons stay: they are the keyboard-reachable path and a grip is not. The order is held locally during the drag and written once on release, rather than rewriting settings.json for every slot crossed. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
255 lines
11 KiB
QML
255 lines
11 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;
|
|
}
|
|
|
|
readonly property bool wantRevealed: !Settings.dockAutohide || !workspaceOccupied || pointer.hovered
|
|
|
|
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;
|
|
return root.position === "right" ? body.x : 0;
|
|
}
|
|
y: {
|
|
if (!root.revealed)
|
|
return root.vertical ? 0 : surface.height - root.revealStripHeight;
|
|
return root.vertical ? body.y : 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
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|