From dc158bd9b691717d5aec1feed88cefb21424577a Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:09:47 -0600 Subject: [PATCH] feat(shell): resizable playlist sidebar and queue/lyrics column (#771) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/qbz-qt/build.rs | 1 + .../qbz-qt/qml/controls/PanelResizeHandle.qml | 107 ++++++++++ crates/qbz-qt/qml/shell/AppShell.qml | 69 +++++- crates/qbz-qt/qml/shell/Sidebar.qml | 12 +- crates/qbz-qt/src/main.rs | 1 + crates/qbz-qt/src/panel_resize.rs | 196 ++++++++++++++++++ crates/qbz-qt/src/settings_qt.rs | 29 +++ crates/qbz-qt/src/shell_bridge.rs | 123 +++++++++++ 8 files changed, 536 insertions(+), 2 deletions(-) create mode 100644 crates/qbz-qt/qml/controls/PanelResizeHandle.qml create mode 100644 crates/qbz-qt/src/panel_resize.rs diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index c2b5cdb4b..f163f1966 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -600,6 +600,7 @@ fn main() { "qml/controls/QbzToggle.qml", "qml/controls/QbzToolButton.qml", "qml/controls/QbzTooltip.qml", + "qml/controls/PanelResizeHandle.qml", "qml/controls/QbzProgressRing.qml", // Applied-filters tooltip: the trigger a filter control mounts // beside itself (it writes the shell channel QbzTooltip reads). diff --git a/crates/qbz-qt/qml/controls/PanelResizeHandle.qml b/crates/qbz-qt/qml/controls/PanelResizeHandle.qml new file mode 100644 index 000000000..1cfaaf1b6 --- /dev/null +++ b/crates/qbz-qt/qml/controls/PanelResizeHandle.qml @@ -0,0 +1,107 @@ +// Edge drag handle for a resizable shell panel (#771) — the playlist sidebar's +// right edge and the queue/lyrics column's left edge. +// +// It owns NOTHING but the pointer: every drag position is handed to the host +// through `dragged(px)` and the host (AppShell) forwards it to the bridge, +// where `panel_resize.rs` decides clamp / snap / close. `released()` is where +// the host persists. `active` mirrors the press so the host can suspend the +// panel's width animation while the pointer is driving it (a 160 ms Behavior +// fighting a mouse reads as lag). +// +// Where it sits: on the border the user SEES. The panel's own edge is +// invisible — the content frame's 8px bezel is the same colour as the panel +// (AppShell.qml `contentFrame`) — so the visible line is the content PANE's +// edge, 8px in. The host places the strip there (owner, 2026-09-11, with the +// pointer screenshot: "ahí debería estar el grabber"). +// +// Visual: one 2x28 notch in border-muted, vertically centred on the border +// (the Local Library tree rail draws a full-height line that lights up in +// accent; this is the "much more subtle" version the owner asked for). It +// brightens a little on hover/press, and `pulse()` — called by the host when +// a drag reaches a snap point (min, max, mini) — runs ONE short accent fade +// behind it. Nothing here ticks: the fade is a one-shot that ends. + +import QtQuick +import "../theme" + +Item { + id: root + width: 5 + + /// The px the host cares about, in the host's coordinate space: for the + /// sidebar the pointer's x (a width from the window's left edge); for + /// the right column the host converts to a width itself. + signal dragged(real px) + signal released() + readonly property bool active: area.pressed + readonly property bool lit: area.containsMouse || area.pressed + + /// One accent pulse behind the notch — the host calls it when the drag + /// reaches a snap point. Restarting mid-fade is fine (it just re-peaks). + function pulse() { + glowFade.restart() + } + + /// "There is more past here": a steady, slightly larger glow that leans + /// toward `beyondSide` (-1 = left, +1 = right) while the host says the + /// drag is parked at a range end that can push through to something + /// else (the queue column at its maximum -> Listen List). Static while + /// on; nothing ticks. + property bool beyond: false + property int beyondSide: -1 + + Rectangle { + visible: root.beyond + anchors.verticalCenter: parent.verticalCenter + x: root.beyondSide < 0 ? (root.width / 2) - width + 2 : (root.width / 2) - 2 + width: 12 + height: 44 + radius: 6 + color: theme.accent + opacity: 0.28 + } + + Rectangle { + id: glow + anchors.centerIn: parent + width: 4 + height: 34 + radius: 2 + color: theme.accent + opacity: 0.0 + SequentialAnimation { + id: glowFade + NumberAnimation { target: glow; property: "opacity"; to: 0.55; duration: 60 } + NumberAnimation { target: glow; property: "opacity"; to: 0.0; duration: 260; easing.type: Easing.OutQuad } + } + } + + Rectangle { + id: notch + anchors.centerIn: parent + width: 2 + height: 28 + radius: 1 + color: theme.borderMuted + opacity: root.lit ? 0.95 : 0.55 + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.SplitHCursor + acceptedButtons: Qt.LeftButton + // The strip is 5px wide: the pointer leaves it on the first move, so + // the press must keep delivering moves to us, not to what is under. + preventStealing: true + onPositionChanged: function (mouse) { + if (!pressed) + return + const p = area.mapToItem(root.parent, mouse.x, mouse.y) + root.dragged(p.x) + } + onReleased: root.released() + onCanceled: root.released() + } +} diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 9589fc82d..467a6ee50 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -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 diff --git a/crates/qbz-qt/qml/shell/Sidebar.qml b/crates/qbz-qt/qml/shell/Sidebar.qml index d7f0627d9..0c25981af 100644 --- a/crates/qbz-qt/qml/shell/Sidebar.qml +++ b/crates/qbz-qt/qml/shell/Sidebar.qml @@ -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 } } diff --git a/crates/qbz-qt/src/main.rs b/crates/qbz-qt/src/main.rs index a616a3bd8..3070771f4 100644 --- a/crates/qbz-qt/src/main.rs +++ b/crates/qbz-qt/src/main.rs @@ -39,6 +39,7 @@ mod home_bridge; mod player_bridge; mod queue_bridge; mod session_bridge; +mod panel_resize; mod shell_bridge; mod viz_bridge; // Immersive mode (2026-08-02 immersive-port contract, block B1): the diff --git a/crates/qbz-qt/src/panel_resize.rs b/crates/qbz-qt/src/panel_resize.rs new file mode 100644 index 000000000..1fa336970 --- /dev/null +++ b/crates/qbz-qt/src/panel_resize.rs @@ -0,0 +1,196 @@ +//! Resizable shell panels (#771): the pure drag rules for the playlist +//! sidebar and the queue/lyrics column. QML owns the pointer and paints the +//! handle; every decision about what a dragged x means lives here so it can +//! be unit-tested without Qt. +//! +//! Ranges (owner spec 2026-09-11): the minimum is the width each panel had +//! before, the maximum exactly double. Dragging the sidebar narrower than its +//! minimum snaps it to the mini rail, and narrower still closes it through the +//! same state the header button uses. Dragging the column narrower than its +//! minimum closes it, as its X would. + +/// The three-state sidebar's open width, before #771. +pub const SIDEBAR_MIN_WIDTH: i32 = 240; +pub const SIDEBAR_MAX_WIDTH: i32 = SIDEBAR_MIN_WIDTH * 2; +/// The mini rail (theme `sidebarMiniWidth`). +pub const SIDEBAR_MINI_WIDTH: i32 = 64; +/// The queue/lyrics column, before #771. +pub const QUEUE_MIN_WIDTH: i32 = 300; +pub const QUEUE_MAX_WIDTH: i32 = QUEUE_MIN_WIDTH * 2; +/// How far past a boundary the pointer must go before a state snaps. Wide +/// enough that a sloppy drag to "exactly 240" does not collapse the panel. +pub const SNAP_THRESHOLD: i32 = 40; +/// Hysteresis between collapsing and reopening the sidebar: the open panel +/// collapses below `MIN - SNAP_THRESHOLD` (200), the mini rail reopens only +/// at or above `MIN - REOPEN_MARGIN` (220). The 20px band in between is +/// stable in BOTH states. Without it (the first cut reopened at 104) every +/// pointer move between 104 and 199 flipped the state — the open/collapsed +/// flapping in the owner's 2026-09-11 screencast. +pub const REOPEN_MARGIN: i32 = 20; +/// How far past the column's maximum the pointer must push to open the +/// Listen List (the full queue view) — only while the queue is in the column. +pub const PUSH_THROUGH: i32 = 60; + +/// Sidebar states as `QbzShell.sidebarState` publishes them. +pub const SIDEBAR_OPEN: i32 = 0; +pub const SIDEBAR_MINI: i32 = 1; +pub const SIDEBAR_CLOSED: i32 = 2; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SidebarDrag { + /// Stay open at this width (already clamped to the range). + Open(i32), + /// Snap to the mini rail. + Mini, + /// Close, exactly as the header button's third state. + Closed, +} + +/// What a pointer at `x` (window-left-relative px) means for the sidebar in +/// `state`. The closed state has no edge to grab, so it never drags. +pub fn sidebar_drag_target(state: i32, x: i32) -> SidebarDrag { + match state { + SIDEBAR_OPEN => { + if x >= SIDEBAR_MIN_WIDTH - SNAP_THRESHOLD { + SidebarDrag::Open(x.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH)) + } else if x >= SIDEBAR_MINI_WIDTH - SNAP_THRESHOLD { + SidebarDrag::Mini + } else { + SidebarDrag::Closed + } + } + SIDEBAR_MINI => { + if x >= SIDEBAR_MIN_WIDTH - REOPEN_MARGIN { + // Re-expanding lands on the minimum; the persisted wider width + // is restored by the caller (it never dropped below the min). + SidebarDrag::Open(x.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH)) + } else if x >= SIDEBAR_MINI_WIDTH - SNAP_THRESHOLD { + SidebarDrag::Mini + } else { + SidebarDrag::Closed + } + } + _ => SidebarDrag::Closed, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColumnDrag { + Open(i32), + Closed, + /// Pushed past the maximum with the queue in the column: hand the queue + /// to the Listen List view (queue-view). Lyrics, if open, stay in the + /// column. A lyrics-only column never pushes through. + PushThrough, +} + +/// What a column width of `width` px (window-right edge minus pointer x) +/// means for the queue/lyrics column. `queue_in_column` is whether the +/// queue panel is currently part of the column. +pub fn queue_drag_target(width: i32, queue_in_column: bool) -> ColumnDrag { + if queue_in_column && width >= QUEUE_MAX_WIDTH + PUSH_THROUGH { + ColumnDrag::PushThrough + } else if width >= QUEUE_MIN_WIDTH - SNAP_THRESHOLD { + ColumnDrag::Open(width.clamp(QUEUE_MIN_WIDTH, QUEUE_MAX_WIDTH)) + } else { + ColumnDrag::Closed + } +} + +/// Persisted widths are clamped on the way in so a hand-edited or stale +/// pref can never paint a panel outside its range. +pub fn clamp_sidebar_width(width: i32) -> i32 { + width.clamp(SIDEBAR_MIN_WIDTH, SIDEBAR_MAX_WIDTH) +} + +pub fn clamp_queue_width(width: i32) -> i32 { + width.clamp(QUEUE_MIN_WIDTH, QUEUE_MAX_WIDTH) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn open_sidebar_clamps_between_min_and_double() { + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 240), SidebarDrag::Open(240)); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 333), SidebarDrag::Open(333)); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 480), SidebarDrag::Open(480)); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 900), SidebarDrag::Open(480)); + } + + #[test] + fn a_sloppy_drag_just_under_the_minimum_stays_open_at_the_minimum() { + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 239), SidebarDrag::Open(240)); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 200), SidebarDrag::Open(240)); + } + + #[test] + fn past_the_threshold_the_sidebar_snaps_to_mini_then_closed() { + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 199), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 64), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 24), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 23), SidebarDrag::Closed); + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, 0), SidebarDrag::Closed); + } + + #[test] + fn mini_rail_reopens_past_its_threshold_and_closes_below_it() { + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 64), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 199), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 219), SidebarDrag::Mini); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 220), SidebarDrag::Open(240)); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 300), SidebarDrag::Open(300)); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, 23), SidebarDrag::Closed); + } + + /// The screencast bug: no pointer x may make the open panel collapse AND + /// the mini rail reopen — that pair flips the state on every move. + #[test] + fn open_and_mini_never_disagree_into_a_flip() { + for x in 0..=SIDEBAR_MAX_WIDTH + 50 { + let from_open = sidebar_drag_target(SIDEBAR_OPEN, x); + let from_mini = sidebar_drag_target(SIDEBAR_MINI, x); + let flips = from_open == SidebarDrag::Mini + && matches!(from_mini, SidebarDrag::Open(_)); + assert!(!flips, "x={x} flips open<->mini"); + } + // And the band between the two thresholds is stable both ways. + for x in (SIDEBAR_MIN_WIDTH - SNAP_THRESHOLD)..(SIDEBAR_MIN_WIDTH - REOPEN_MARGIN) { + assert_eq!(sidebar_drag_target(SIDEBAR_OPEN, x), SidebarDrag::Open(240)); + assert_eq!(sidebar_drag_target(SIDEBAR_MINI, x), SidebarDrag::Mini); + } + } + + #[test] + fn closed_sidebar_never_drags() { + assert_eq!(sidebar_drag_target(SIDEBAR_CLOSED, 300), SidebarDrag::Closed); + } + + #[test] + fn queue_column_clamps_and_closes_past_the_threshold() { + assert_eq!(queue_drag_target(300, true), ColumnDrag::Open(300)); + assert_eq!(queue_drag_target(450, true), ColumnDrag::Open(450)); + assert_eq!(queue_drag_target(659, true), ColumnDrag::Open(600)); + assert_eq!(queue_drag_target(261, true), ColumnDrag::Open(300)); + assert_eq!(queue_drag_target(260, true), ColumnDrag::Open(300)); + assert_eq!(queue_drag_target(259, true), ColumnDrag::Closed); + } + + #[test] + fn pushing_past_the_maximum_opens_the_listen_list_only_with_the_queue_in_the_column() { + assert_eq!(queue_drag_target(660, true), ColumnDrag::PushThrough); + assert_eq!(queue_drag_target(900, true), ColumnDrag::PushThrough); + // Lyrics-only column: clamps at the maximum, never pushes through. + assert_eq!(queue_drag_target(660, false), ColumnDrag::Open(600)); + assert_eq!(queue_drag_target(900, false), ColumnDrag::Open(600)); + } + + #[test] + fn persisted_widths_are_clamped_on_read() { + assert_eq!(clamp_sidebar_width(0), 240); + assert_eq!(clamp_sidebar_width(10_000), 480); + assert_eq!(clamp_queue_width(-5), 300); + assert_eq!(clamp_queue_width(601), 600); + } +} diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 15a8db5f2..ec6b3fb03 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -894,6 +894,35 @@ pub fn set_sidebar_state(state: i32) -> i32 { state } +/// Persisted open-sidebar width (#771), clamped to the panel's range; +/// absent = the pre-#771 240. +pub fn sidebar_width() -> i32 { + crate::panel_resize::clamp_sidebar_width(pref_i32( + "sidebar_width", + crate::panel_resize::SIDEBAR_MIN_WIDTH, + )) +} + +pub fn set_sidebar_width(width: i32) -> i32 { + let width = crate::panel_resize::clamp_sidebar_width(width); + save_pref("sidebar_width", serde_json::Value::Number(width.into())); + width +} + +/// Persisted queue/lyrics column width (#771), clamped; absent = 300. +pub fn queue_panel_width() -> i32 { + crate::panel_resize::clamp_queue_width(pref_i32( + "queue_panel_width", + crate::panel_resize::QUEUE_MIN_WIDTH, + )) +} + +pub fn set_queue_panel_width(width: i32) -> i32 { + let width = crate::panel_resize::clamp_queue_width(width); + save_pref("queue_panel_width", serde_json::Value::Number(width.into())); + width +} + /// Section-nav placement defaults — the Slint `ShellState` literals /// (state.slint:4124 / :4129). Sidebar ON, compact-header OFF. pub const NAV_IN_SIDEBAR_DEFAULT: bool = true; diff --git a/crates/qbz-qt/src/shell_bridge.rs b/crates/qbz-qt/src/shell_bridge.rs index cea94790b..da00fb2bc 100644 --- a/crates/qbz-qt/src/shell_bridge.rs +++ b/crates/qbz-qt/src/shell_bridge.rs @@ -29,6 +29,17 @@ pub mod qbz_shell { // Seeded from ui_prefs `sidebar_state` and rewritten by cycle_sidebar // (1:1 Slint: persist-sidebar-state). #[qproperty(i32, sidebar_state)] + // #771: the open sidebar's width (240..480) and the queue/lyrics + // column's width (300..600), both persisted in ui_prefs and clamped + // in Rust (`panel_resize`). QML binds them; the drag handles call the + // `*_drag` invokables below and never write these directly. + #[qproperty(i32, sidebar_width)] + #[qproperty(i32, queue_panel_width)] + // True from the moment a DRAG snaps the sidebar's state until the next + // pointer move that merely follows the pointer. Set BEFORE the state + // so QML's width Behavior (disabled while dragging) sees it and + // animates the 240<->64 jump instead of teleporting. + #[qproperty(bool, sidebar_snapping)] #[qproperty(bool, queue_open)] // --- Section-nav placement (Slint ShellState) --------------------- // ON = the Discover / Library / Local Library / My QBZ rows live in @@ -503,6 +514,22 @@ pub mod qbz_shell { /// NPB queue button / queue panel close. #[qinvokable] fn toggle_queue(self: Pin<&mut QbzShell>); + /// #771 sidebar edge drag: `x` is the pointer's window-left-relative + /// px. Applies the snap rules (`panel_resize::sidebar_drag_target`) + /// LIVE: open widths follow the pointer, past the thresholds the + /// sidebar snaps to mini / closed exactly as the header button would. + #[qinvokable] + fn sidebar_drag(self: Pin<&mut QbzShell>, x: f64); + /// Pointer released: persist the width the drag ended on. + #[qinvokable] + fn sidebar_drag_end(self: Pin<&mut QbzShell>); + /// #771 column edge drag: `width` is window-right minus pointer x. + /// Past the threshold the whole column closes (queue and lyrics), + /// as their X buttons would. + #[qinvokable] + fn queue_panel_drag(self: Pin<&mut QbzShell>, width: f64); + #[qinvokable] + fn queue_panel_drag_end(self: Pin<&mut QbzShell>); /// Header history buttons. #[qinvokable] fn navigate_back(self: Pin<&mut QbzShell>); @@ -811,6 +838,9 @@ use qbz_shell::QbzShell; /// Rust side of the shell bridge (plain storage, phase-1 pattern). pub struct QbzShellRust { sidebar_state: i32, + sidebar_width: i32, + queue_panel_width: i32, + sidebar_snapping: bool, nav_in_sidebar: bool, nav_header_compact: bool, sidebar_playlist_collage: bool, @@ -907,6 +937,9 @@ impl Default for QbzShellRust { qbz_audio::set_seek_waveform_enabled(seekbar_waveform); Self { sidebar_state: crate::settings_qt::sidebar_state(), + sidebar_width: crate::settings_qt::sidebar_width(), + queue_panel_width: crate::settings_qt::queue_panel_width(), + sidebar_snapping: false, nav_in_sidebar: crate::settings_qt::nav_in_sidebar(), nav_header_compact: crate::settings_qt::nav_header_compact(), sidebar_playlist_collage: crate::settings_qt::sidebar_playlist_collage(), @@ -1306,6 +1339,96 @@ impl qbz_shell::QbzShell { self.as_mut().set_queue_open(next); } + /// Snap the sidebar to `state` from a drag: the same persistence and the + /// same Large-NPB fallback as `cycle_sidebar`, without the cycle. + fn snap_sidebar_state(mut self: Pin<&mut Self>, state: i32) { + if *self.sidebar_state() == state { + return; + } + self.as_mut().set_sidebar_state(state); + crate::settings_qt::set_sidebar_state(state); + if state != 0 && *self.npb_mode() == 3 { + self.as_mut().set_npb_mode(0); + } + } + + pub fn sidebar_drag(mut self: Pin<&mut Self>, x: f64) { + use crate::panel_resize::{sidebar_drag_target, SidebarDrag}; + let x = x.round() as i32; + match sidebar_drag_target(*self.sidebar_state(), x) { + SidebarDrag::Open(width) => { + if *self.sidebar_state() != crate::panel_resize::SIDEBAR_OPEN { + // A state snap: animate it (flag first, then state). + self.as_mut().set_sidebar_snapping(true); + self.as_mut().snap_sidebar_state(crate::panel_resize::SIDEBAR_OPEN); + } else if *self.sidebar_snapping() { + // Back to pointer-following: the width tracks the mouse. + self.as_mut().set_sidebar_snapping(false); + } + if *self.sidebar_width() != width { + self.as_mut().set_sidebar_width(width); + } + } + SidebarDrag::Mini => { + if *self.sidebar_state() != crate::panel_resize::SIDEBAR_MINI { + self.as_mut().set_sidebar_snapping(true); + self.as_mut().snap_sidebar_state(crate::panel_resize::SIDEBAR_MINI); + } + } + SidebarDrag::Closed => { + if *self.sidebar_state() != crate::panel_resize::SIDEBAR_CLOSED { + self.as_mut().set_sidebar_snapping(true); + self.as_mut().snap_sidebar_state(crate::panel_resize::SIDEBAR_CLOSED); + } + } + } + } + + pub fn sidebar_drag_end(mut self: Pin<&mut Self>) { + // The width never drops below the minimum (Mini/Closed are STATES), + // so reopening later restores the last open width. + crate::settings_qt::set_sidebar_width(*self.sidebar_width()); + if *self.sidebar_snapping() { + self.as_mut().set_sidebar_snapping(false); + } + } + + pub fn queue_panel_drag(mut self: Pin<&mut Self>, width: f64) { + use crate::panel_resize::{queue_drag_target, ColumnDrag}; + let queue_in_column = + *self.queue_open() && self.current_view().to_string() != "queue-view"; + match queue_drag_target(width.round() as i32, queue_in_column) { + ColumnDrag::Open(width) => { + if *self.queue_panel_width() != width { + self.as_mut().set_queue_panel_width(width); + } + } + ColumnDrag::Closed => { + if *self.queue_open() { + self.as_mut().toggle_queue(); + } + if *self.lyrics_open() { + self.as_mut().toggle_lyrics(); + } + } + // Owner spec 2026-09-11: pushed past the maximum with the queue in + // the column, the queue moves to the Listen List view; lyrics (if + // open) keep the column. The logical queue toggle is untouched, so + // leaving the view restores the drawer, exactly as the panel's own + // expand button does (`QbzShell.navigateTo("queue-view")`). + ColumnDrag::PushThrough => { + if *self.queue_panel_width() != crate::panel_resize::QUEUE_MAX_WIDTH { + self.as_mut().set_queue_panel_width(crate::panel_resize::QUEUE_MAX_WIDTH); + } + crate::navigate_to("queue-view"); + } + } + } + + pub fn queue_panel_drag_end(self: Pin<&mut Self>) { + crate::settings_qt::set_queue_panel_width(*self.queue_panel_width()); + } + pub fn navigate_back(self: Pin<&mut Self>) { crate::nav_qt::back(); }