Let the Dock choose an edge, choose its screens, and be dragged into order

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
This commit is contained in:
Gabriel Brown
2026-08-20 14:10:22 -04:00
parent 23141673a2
commit 3b01f1e020
9 changed files with 487 additions and 30 deletions
+87 -11
View File
@@ -22,10 +22,34 @@ PanelWindow {
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: true
right: true
left: root.position !== "right"
right: root.position !== "left"
}
color: "transparent"
@@ -42,10 +66,13 @@ PanelWindow {
// ── Geometry ────────────────────────────────────────────────────────────
// Height = room for the tooltip above the bar + the bar + the gap under it.
readonly property int revealStripHeight: 3
readonly property int bottomMargin: Theme.barGap
readonly property int edgeMargin: Theme.barGap
readonly property int tooltipSpace: 34
implicitHeight: tooltipSpace + body.implicitHeight + bottomMargin
// 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
@@ -140,22 +167,71 @@ PanelWindow {
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: root.revealed ? body.x : 0
y: root.revealed ? body.y : surface.height - root.revealStripHeight
width: root.revealed ? body.width : surface.width
height: root.revealed ? surface.height - body.y : root.revealStripHeight
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
anchors.horizontalCenter: parent.horizontalCenter
// Slides off the bottom edge when hidden.
y: root.revealed ? root.tooltipSpace : surface.height
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
+46 -16
View File
@@ -111,30 +111,51 @@ Rectangle {
inset: parent.radius
}
implicitWidth: row.implicitWidth + Theme.dockPadding * 2
implicitHeight: row.implicitHeight + Theme.dockPadding * 2
implicitWidth: strip.implicitWidth + Theme.dockPadding * 2
implicitHeight: strip.implicitHeight + Theme.dockPadding * 2
// The item the tooltip is currently describing, or null.
property Item hoveredItem: null
Row {
id: row
// Set by the Dock. A side dock runs the same strip down the screen instead
// of across it.
property bool vertical: false
// Which way a tooltip points on a side dock: away from the screen edge, so
// it never opens off-screen.
property bool leftSide: true
// Explicit rather than left to Grid's wrapping. This is always one line, so
// saying how many cells it holds is both simpler to read and immune to
// Grid's default column count quietly wrapping a long dock.
readonly property int cellCount: 2 + (root.items ? root.items.length : 0)
// A Grid rather than a Row so one declaration serves both orientations.
// Row and Column would each need their own children, and the cross-axis
// anchors that centre items in a Row (verticalCenter) are the wrong axis in
// a Column -- Grid centres through its own alignment properties instead,
// which is the same result without the anchors positioners disallow.
Grid {
id: strip
anchors.centerIn: parent
spacing: Theme.dockGap
rows: root.vertical ? root.cellCount : 1
columns: root.vertical ? 1 : root.cellCount
horizontalItemAlignment: Grid.AlignHCenter
verticalItemAlignment: Grid.AlignVCenter
ShowAppsButton {
id: showApps
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = showApps
onExited: if (root.hoveredItem === showApps)
root.hoveredItem = null
}
// Separator between the launcher and the apps, as in GNOME's dash.
// Separator between the launcher and the apps, as in GNOME's dash. It
// turns with the dock: a hairline across a column, down a row.
Rectangle {
anchors.verticalCenter: parent.verticalCenter
width: 1
height: Theme.dockIconSize * 0.7
width: root.vertical ? Theme.dockIconSize * 0.7 : 1
height: root.vertical ? 1 : Theme.dockIconSize * 0.7
border.width: 0
color: Theme.alpha(Theme.fg, 0.14)
}
@@ -146,7 +167,6 @@ Rectangle {
id: dockItem
required property var modelData
app: modelData
anchors.verticalCenter: parent.verticalCenter
onEntered: root.hoveredItem = dockItem
onExited: if (root.hoveredItem === dockItem)
root.hoveredItem = null
@@ -172,16 +192,26 @@ Rectangle {
}
}
// Centered on the hovered item, clamped inside the dock. Reads the item's
// x directly (rather than mapToItem) so the binding re-evaluates when
// the row reflows.
// Centred on the hovered item along the dock's own axis, and placed just
// outside the edge it lives on. Reads the item's position directly
// (rather than mapToItem) so the binding re-evaluates when the strip
// reflows.
x: {
if (!root.hoveredItem)
return 0;
const center = row.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, center - width / 2));
if (root.vertical)
return root.leftSide ? root.width + 8 : -width - 8;
const centre = strip.x + root.hoveredItem.x + root.hoveredItem.width / 2;
return Math.max(4, Math.min(root.width - width - 4, centre - width / 2));
}
y: {
if (!root.hoveredItem)
return -height - 8;
if (!root.vertical)
return -height - 8;
const centre = strip.y + root.hoveredItem.y + root.hoveredItem.height / 2;
return Math.max(4, Math.min(root.height - height - 4, centre - height / 2));
}
y: -height - 8
width: tipLabel.implicitWidth + Theme.popoverPadding * 2
height: tipLabel.implicitHeight + 8