feat(shell): resizable playlist sidebar and queue/lyrics column (#771)

Both panels were fixed-width (240 / 300); playlist names elided on wide
monitors. Each panel's visible edge (the content pane's edge, 8px past the
panel — the bezel between them shares the panel colour) now carries a 5px
grab strip with a 2x28 notch, split cursor, and a one-shot accent pulse when
a drag lands on a snap point.

Rules live in `panel_resize.rs` (pure, unit-tested), the bridge applies them
live and persists on release (`ui_prefs` `sidebar_width` / `queue_panel_width`,
clamped on read):
- sidebar: 240..480 while open; below 200 it snaps to the mini rail, below 24
  it closes through the same state the header button uses (with the Large-NPB
  fallback); the mini rail reopens at 220 — a 20px hysteresis band so no
  pointer x can flip open<->mini on every move (the flapping seen in the
  2026-09-11 screencast). The stored width never drops below 240, so
  reopening restores the last open width. A state snap during a drag still
  animates: the bridge raises `sidebarSnapping` before the state change and
  Sidebar's width Behavior honours it while the drag otherwise disables it.
- column: 300..600; below 260 it closes queue and lyrics as their X would.
  With the queue in the column and the width parked at 600, the notch shows a
  steady larger glow toward the content; pushing 60px further opens the
  Listen List (queue-view) and the queue leaves the column, lyrics staying
  if open. A lyrics-only column never pushes through.

The Large-NPB dock is untouched: it is mounted with absolute x/width in
AppShell and reads nothing from the sidebar width.

Verified: panel_resize 9/9, qmllint + the six qt-run.sh audits, release
builds v1-v4, four owner smoke rounds.

cc
This commit is contained in:
vicrodh committed 2026-09-11 13:09:47 -06:00
1 parent f9795e3c18
commit dc158bd9b6
8 files changed
+536 -2

No files matched your search

+68 -1
View File
@@ -264,6 +264,7 @@ Rectangle {
// AppShell level rather than inside Sidebar.slint.
tooltip: tooltipOverlay
// Animated 3-state width lives inside the component.
resizing: sidebarHandle.active
}
// QueueView temporarily takes the queue drawer's visual slot without
@@ -282,12 +283,14 @@ Rectangle {
anchors.right: parent.right
anchors.top: header.bottom
anchors.bottom: npb.top
// #771: the open width is the user's (300..600, persisted).
width: (root.queueSidebarVisible || QbzShell.lyricsOpen)
? theme.queuePanelWidth : 0
? QbzShell.queuePanelWidth : 0
clip: true
color: root.ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
Behavior on width {
enabled: !queueHandle.active
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}
@@ -325,6 +328,70 @@ Rectangle {
}
}
// --- #771 resize handles ------------------------------------------------
// Siblings of the panels, not children: Sidebar clips and the column
// animates, and a handle inside either would be clipped or dragged along.
// Each sits on the border the user SEES — the content PANE's edge, 8px
// past the panel (the bezel between them is panel-coloured, so the
// panel's own edge is invisible) — nudged 1-2px toward its panel, above
// the content frame in z. The bridge (`panel_resize.rs`) decides
// clamp/snap/close; these only report the pointer, and pulse their notch
// when a drag lands on a snap point.
PanelResizeHandle {
id: sidebarHandle
// No edge to grab once the sidebar is closed; the header button
// reopens it, as before.
visible: QbzShell.sidebarState !== 2
// Pane edge at sidebar.width + 8; strip 245..249 at 240, centre 247.
x: sidebar.width + 5
anchors.top: header.bottom
anchors.bottom: npb.top
z: 5
onDragged: function (px) { QbzShell.sidebarDrag(px) }
onReleased: QbzShell.sidebarDragEnd()
}
PanelResizeHandle {
id: queueHandle
visible: queueColumn.width > 0
// Pane edge at queueColumn.x - 8; strip x-9..x-4, centre x-6.5.
x: queueColumn.x - 9
anchors.top: header.bottom
anchors.bottom: npb.top
z: 5
// The column hangs off the window's RIGHT edge: a pointer at x means
// a width of (window width - x).
onDragged: function (px) { QbzShell.queuePanelDrag(root.width - px) }
onReleased: QbzShell.queuePanelDragEnd()
// Parked at the maximum with the QUEUE in the column: there is more
// past here (push through -> Listen List). Lyrics-only: nothing.
beyond: active && root.queueSidebarVisible
&& QbzShell.queuePanelWidth === theme.queuePanelWidth * 2
beyondSide: -1
}
// Snap feedback: the bridge clamps and snaps; a width landing EXACTLY on
// a range end, or a state snap, while the pointer is down is the moment
// the notch pulses. A drag inside the range changes the width on every
// move and never hits these equalities twice in a row.
Connections {
target: QbzShell
function onSidebarWidthChanged() {
if (sidebarHandle.active
&& (QbzShell.sidebarWidth === theme.sidebarOpenWidth
|| QbzShell.sidebarWidth === theme.sidebarOpenWidth * 2))
sidebarHandle.pulse()
}
function onSidebarStateChanged() {
if (sidebarHandle.active)
sidebarHandle.pulse()
}
function onQueuePanelWidthChanged() {
if (queueHandle.active
&& (QbzShell.queuePanelWidth === theme.queuePanelWidth
|| QbzShell.queuePanelWidth === theme.queuePanelWidth * 2))
queueHandle.pulse()
}
}
// Content FRAME — the chrome slab the content pane is inset INTO
// (AppShell.slint:357-362). It is the same tier as the sidebar, the header
// and the player bar, and under the dynamic background it takes the same
+11 -1
View File
@@ -486,9 +486,14 @@ Rectangle {
}
}
// #771: the open width is the user's (240..480, persisted); the other
// two states keep their fixed rails.
width: QbzShell.sidebarState === 2 ? 0
: QbzShell.sidebarState === 1 ? theme.sidebarMiniWidth
: theme.sidebarOpenWidth
: QbzShell.sidebarWidth
/// Set by AppShell while its resize handle is pressed: the width follows
/// the pointer directly, the state snaps still animate.
property bool resizing: false
// surface-card @ 0.5 while the ambient background is active (phase 14).
color: ambientOn ? theme.surfaceCardA50 : theme.surfaceCard
readonly property bool ambientOn: theme.ambientOn
@@ -497,7 +502,12 @@ Rectangle {
// animates (same as the Slint root).
clip: true
// While the pointer drives the width the Behavior is off (it would lag
// the mouse); a STATE snap during that drag (open <-> mini <-> closed)
// still animates, because the bridge raises `sidebarSnapping` right
// before it changes the state and lowers it on the next pointer-follow.
Behavior on width {
enabled: !root.resizing || QbzShell.sidebarSnapping
NumberAnimation { duration: 160; easing.type: Easing.InOutQuad }
}