From 33df7bce76c358f57d9a8dfe7428d71e25e099db Mon Sep 17 00:00:00 2001
From: vicrodh <103399283+vicrodh@users.noreply.github.com>
Date: Mon, 18 May 2026 21:54:31 -0600
Subject: [PATCH] feat(slint): collapsible sidebar, Queue/Lyrics panels, nav
menus
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Shell chrome (steps 1-3):
- Left sidebar is collapsible — a toggle in the nav cluster (left of the
[<] [>] pair) and a draggable right edge that resizes 200-420px and
collapses below 160px. ShellState global drives it.
- Queue and Lyrics right sidebars: 320px panels rendered as layout
siblings, so opening one pushes the content area narrower rather than
overlaying it. The header captions/queue buttons toggle them
(mutually exclusive) and light up when active.
- The four nav tabs (Discover/Library/Local Library/My QBZ) open
dropdown menus listing their sub-sections; Discover -> Home is wired.
Detail views (steps 4-5):
- Track rows: the even-row tint is now clearly fainter than the hover
tint so the two states no longer look identical.
- The favorite and offline-cache glyphs in album track rows are real
buttons (hover state, emit media-action).
- Album header artist link uses the metadata text color, bold, instead
of the accent blue.
- Album description and artist biography span the full content width
below the header block.
cc
---
crates/qbz-slint/ui/album/AlbumPageView.slint | 133 +++++++++++-------
.../qbz-slint/ui/artist/ArtistPageView.slint | 23 +--
.../qbz-slint/ui/assets/icons/panel-left.svg | 4 +
crates/qbz-slint/ui/shell/AppShell.slint | 9 +-
crates/qbz-slint/ui/shell/HeaderBar.slint | 132 +++++++++++++++--
crates/qbz-slint/ui/shell/LyricsSidebar.slint | 85 +++++++++++
crates/qbz-slint/ui/shell/NavButtons.slint | 15 +-
crates/qbz-slint/ui/shell/QueueSidebar.slint | 85 +++++++++++
crates/qbz-slint/ui/shell/Sidebar.slint | 45 +++++-
crates/qbz-slint/ui/state.slint | 10 ++
10 files changed, 461 insertions(+), 80 deletions(-)
create mode 100644 crates/qbz-slint/ui/assets/icons/panel-left.svg
create mode 100644 crates/qbz-slint/ui/shell/LyricsSidebar.slint
create mode 100644 crates/qbz-slint/ui/shell/QueueSidebar.slint
diff --git a/crates/qbz-slint/ui/album/AlbumPageView.slint b/crates/qbz-slint/ui/album/AlbumPageView.slint
index ea78365ed..58c96f8fb 100644
--- a/crates/qbz-slint/ui/album/AlbumPageView.slint
+++ b/crates/qbz-slint/ui/album/AlbumPageView.slint
@@ -56,9 +56,11 @@ component TrackRow inherits Rectangle {
height: 56px;
border-radius: 8px;
+ // The even-row tint is clearly fainter than the hover tint so the two
+ // never read as the same state.
background: ta.has-hover
- ? Theme.surface-hover
- : (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent);
+ ? #ffffff16
+ : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Row hover/background detector — declared before the content so the
// more-button keeps its own clicks.
@@ -144,28 +146,52 @@ component TrackRow inherits Rectangle {
vertical-alignment: center;
}
- // Favorite column (visual).
- Rectangle {
- width: 28px;
- QbzIcon {
- source: @image-url("../assets/icons/heart.svg");
- width: 16px;
- height: 16px;
- x: (parent.width - self.width) / 2;
- y: (parent.height - self.height) / 2;
- tint: Theme.text-muted;
+ // Favorite button.
+ VerticalLayout {
+ alignment: center;
+ Rectangle {
+ width: 28px;
+ height: 28px;
+ border-radius: Radius.sm;
+ background: fav-ta.has-hover ? Theme.surface-elevated : transparent;
+ QbzIcon {
+ source: @image-url("../assets/icons/heart.svg");
+ width: 16px;
+ height: 16px;
+ x: (parent.width - self.width) / 2;
+ y: (parent.height - self.height) / 2;
+ tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted;
+ }
+ fav-ta := TouchArea {
+ mouse-cursor: pointer;
+ clicked => {
+ root.media-action("track", root.track.id, "favorite");
+ }
+ }
}
}
- // Offline-cache column (visual).
- Rectangle {
- width: 28px;
- QbzIcon {
- source: @image-url("../assets/icons/cloud-download.svg");
- width: 16px;
- height: 16px;
- x: (parent.width - self.width) / 2;
- y: (parent.height - self.height) / 2;
- tint: Theme.text-muted;
+ // Offline-cache button.
+ VerticalLayout {
+ alignment: center;
+ Rectangle {
+ width: 28px;
+ height: 28px;
+ border-radius: Radius.sm;
+ background: dl-ta.has-hover ? Theme.surface-elevated : transparent;
+ QbzIcon {
+ source: @image-url("../assets/icons/cloud-download.svg");
+ width: 16px;
+ height: 16px;
+ x: (parent.width - self.width) / 2;
+ y: (parent.height - self.height) / 2;
+ tint: dl-ta.has-hover ? Theme.text-primary : Theme.text-muted;
+ }
+ dl-ta := TouchArea {
+ mouse-cursor: pointer;
+ clicked => {
+ root.media-action("track", root.track.id, "download");
+ }
+ }
}
}
@@ -377,11 +403,13 @@ export component AlbumPageView inherits Rectangle {
}
artist-text := Text {
text: AlbumState.artist;
+ // Same color as the surrounding metadata,
+ // bold to read as a link.
color: artist-area.has-hover
- ? Theme.accent-hover
- : Theme.accent;
+ ? Theme.text-primary
+ : Theme.text-secondary;
font-size: Typography.heading;
- font-weight: Typography.medium;
+ font-weight: Typography.bold;
}
}
}
@@ -393,34 +421,6 @@ export component AlbumPageView inherits Rectangle {
overflow: elide;
}
- // Editorial description — fills the metadata column's
- // width (the dead space right of the artwork), truncated,
- // with a Read more link to the full modal.
- if AlbumState.description != "": Rectangle { height: 12px; }
- if AlbumState.description != "": Text {
- text: AlbumState.description-short;
- color: Theme.text-secondary;
- font-size: Typography.legal;
- wrap: word-wrap;
- }
- if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
- if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
- width: read-more-row.preferred-width;
- height: 20px;
- mouse-cursor: pointer;
- clicked => {
- root.show-description = true;
- }
- read-more-row := HorizontalLayout {
- Text {
- text: @tr("Read more");
- color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
- font-size: Typography.legal;
- vertical-alignment: center;
- }
- }
- }
-
Rectangle { height: 20px; }
HorizontalLayout {
alignment: start;
@@ -474,6 +474,33 @@ export component AlbumPageView inherits Rectangle {
}
}
+ // Editorial description — full content width (the artwork no
+ // longer caps it), truncated, with a Read more link.
+ if AlbumState.description != "": Rectangle { height: 18px; }
+ if AlbumState.description != "": Text {
+ text: AlbumState.description-short;
+ color: Theme.text-secondary;
+ font-size: Typography.body;
+ wrap: word-wrap;
+ }
+ if AlbumState.description != AlbumState.description-short: Rectangle { height: 4px; }
+ if AlbumState.description != AlbumState.description-short: read-more := TouchArea {
+ width: read-more-row.preferred-width;
+ height: 20px;
+ mouse-cursor: pointer;
+ clicked => {
+ root.show-description = true;
+ }
+ read-more-row := HorizontalLayout {
+ Text {
+ text: @tr("Read more");
+ color: read-more.has-hover ? Theme.accent-hover : Theme.accent;
+ font-size: Typography.legal;
+ vertical-alignment: center;
+ }
+ }
+ }
+
Rectangle { height: 20px; }
Rectangle {
height: 1px;
diff --git a/crates/qbz-slint/ui/artist/ArtistPageView.slint b/crates/qbz-slint/ui/artist/ArtistPageView.slint
index c332b12ea..a7bdc946b 100644
--- a/crates/qbz-slint/ui/artist/ArtistPageView.slint
+++ b/crates/qbz-slint/ui/artist/ArtistPageView.slint
@@ -54,9 +54,11 @@ component TrackRow inherits Rectangle {
height: 52px;
border-radius: 8px;
+ // The even-row tint is clearly fainter than the hover tint so the two
+ // never read as the same state.
background: ta.has-hover
- ? Theme.surface-hover
- : (Math.mod(root.index, 2) == 1 ? Theme.surface-card : transparent);
+ ? #ffffff16
+ : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
ta := TouchArea {
mouse-cursor: default;
@@ -253,14 +255,6 @@ export component ArtistPageView inherits Rectangle {
font-weight: Typography.bold;
overflow: elide;
}
- Rectangle { height: 12px; }
- Text {
- text: ArtistState.bio;
- visible: ArtistState.bio != "";
- color: Theme.text-secondary;
- font-size: Typography.body;
- wrap: word-wrap;
- }
Rectangle { height: 18px; }
HorizontalLayout {
alignment: start;
@@ -290,6 +284,15 @@ export component ArtistPageView inherits Rectangle {
}
}
+ // Biography — full content width, below the header block.
+ if ArtistState.bio != "": Rectangle { height: 18px; }
+ if ArtistState.bio != "": Text {
+ text: ArtistState.bio;
+ color: Theme.text-secondary;
+ font-size: Typography.body;
+ wrap: word-wrap;
+ }
+
Rectangle { height: 36px; }
if ArtistState.loading && ArtistState.top-tracks.length == 0: Text {
diff --git a/crates/qbz-slint/ui/assets/icons/panel-left.svg b/crates/qbz-slint/ui/assets/icons/panel-left.svg
new file mode 100644
index 000000000..bd423196c
--- /dev/null
+++ b/crates/qbz-slint/ui/assets/icons/panel-left.svg
@@ -0,0 +1,4 @@
+
diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint
index 10e1d1019..a439e5878 100644
--- a/crates/qbz-slint/ui/shell/AppShell.slint
+++ b/crates/qbz-slint/ui/shell/AppShell.slint
@@ -6,10 +6,12 @@
// content well); no hairline borders.
import { Theme } from "../foundation/semantic-colors.slint";
-import { NavState, ContentView } from "../state.slint";
+import { NavState, ContentView, ShellState } from "../state.slint";
import { Sidebar } from "Sidebar.slint";
import { HeaderBar } from "HeaderBar.slint";
import { PlayerBar } from "PlayerBar.slint";
+import { QueueSidebar } from "QueueSidebar.slint";
+import { LyricsSidebar } from "LyricsSidebar.slint";
import { HomeView } from "../discover/HomeView.slint";
import { AlbumPageView } from "../album/AlbumPageView.slint";
import { ArtistPageView } from "../artist/ArtistPageView.slint";
@@ -105,6 +107,11 @@ export component AppShell inherits Rectangle {
}
}
}
+
+ // Right-side panel — a layout sibling of the content area,
+ // so it shrinks the content rather than overlaying it.
+ if ShellState.right-panel == 1: QueueSidebar { }
+ if ShellState.right-panel == 2: LyricsSidebar { }
}
PlayerBar { }
diff --git a/crates/qbz-slint/ui/shell/HeaderBar.slint b/crates/qbz-slint/ui/shell/HeaderBar.slint
index b34dc5ace..47eead81d 100644
--- a/crates/qbz-slint/ui/shell/HeaderBar.slint
+++ b/crates/qbz-slint/ui/shell/HeaderBar.slint
@@ -13,7 +13,7 @@ import { Spacing } from "../foundation/spacing.slint";
import { Typography } from "../foundation/typography.slint";
import { Layout } from "../foundation/layout.slint";
import { Radius } from "../foundation/radius.slint";
-import { SessionState, NavState, ContentView } from "../state.slint";
+import { SessionState, NavState, ContentView, ShellState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavTab inherits Rectangle {
@@ -97,15 +97,104 @@ component MenuItem inherits Rectangle {
}
}
-// Square icon button for the header's right-side controls.
+// Text-only menu row for the nav-tab dropdowns (no leading icon).
+component NavMenuItem inherits Rectangle {
+ in property label;
+ callback clicked();
+
+ height: 32px;
+ background: ta.has-hover ? Theme.surface-hover : transparent;
+
+ HorizontalLayout {
+ padding-left: 14px;
+ padding-right: 22px;
+ Text {
+ text: root.label;
+ color: Theme.text-secondary;
+ font-size: 13px;
+ vertical-alignment: center;
+ horizontal-stretch: 1;
+ }
+ }
+
+ ta := TouchArea {
+ mouse-cursor: pointer;
+ clicked => {
+ root.clicked();
+ }
+ }
+}
+
+// A nav tab plus its dropdown menu. Clicking the tab opens a
+// PopupWindow positioned just below it, styled like the hamburger menu.
+// Menu rows are visual placeholders for now; the optional `home-item`
+// index marks the one row that keeps the app on the working Home view.
+component NavTabWithMenu inherits Rectangle {
+ in property icon;
+ in property label;
+ in property active: false;
+ in property show-icon: true;
+ in property <[string]> items;
+ // Index of the row wired to Home (-1 = none). Clicking it switches
+ // to ContentView.home; all other rows are harmless no-ops.
+ in property home-item: -1;
+
+ width: tab.width;
+ height: 30px;
+
+ tab := NavTab {
+ icon: root.icon;
+ label: root.label;
+ active: root.active;
+ show-icon: root.show-icon;
+ clicked => {
+ menu.show();
+ }
+ }
+
+ menu := PopupWindow {
+ x: 0;
+ y: parent.height + 6px;
+ width: 188px;
+ height: menu-box.preferred-height;
+
+ menu-box := Rectangle {
+ background: Theme.surface-elevated;
+ border-radius: Radius.md;
+ border-width: 1px;
+ border-color: Theme.border-subtle;
+ drop-shadow-blur: 24px;
+ drop-shadow-color: Theme.card-shadow;
+ drop-shadow-offset-y: 6px;
+
+ VerticalLayout {
+ padding-top: 6px;
+ padding-bottom: 6px;
+ for entry[idx] in root.items: NavMenuItem {
+ label: entry;
+ clicked => {
+ if (idx == root.home-item) {
+ NavState.view = ContentView.home;
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+// Square icon button for the header's right-side controls. `active`
+// gives it a highlighted look when its associated panel is open.
component HeaderIconButton inherits Rectangle {
in property icon;
+ in property active: false;
callback clicked();
width: 36px;
height: 36px;
border-radius: Radius.sm;
- background: ta.has-hover ? Theme.surface-hover : transparent;
+ background: root.active ? Theme.surface-elevated
+ : ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
@@ -113,7 +202,8 @@ component HeaderIconButton inherits Rectangle {
height: 19px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
- tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
+ tint: root.active ? Theme.accent
+ : ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
ta := TouchArea {
@@ -141,26 +231,34 @@ export component HeaderBar inherits Rectangle {
y: (root.height - self.height) / 2;
height: 30px;
spacing: Spacing.xs;
- NavTab {
+ NavTabWithMenu {
icon: @image-url("../assets/icons/compass.svg");
- label: "Discover";
+ label: @tr("Discover");
active: true;
show-icon: root.show-tab-icons;
+ items: [@tr("Home"), @tr("Editor's Picks"), @tr("For You")];
+ // Row 0 = Home, the one working destination.
+ home-item: 0;
}
- NavTab {
+ NavTabWithMenu {
icon: @image-url("../assets/icons/library-big.svg");
- label: "Library";
+ label: @tr("Library");
show-icon: root.show-tab-icons;
+ items: [@tr("Tracks"), @tr("Albums"), @tr("Artists"),
+ @tr("Playlists"), @tr("Labels")];
}
- NavTab {
+ NavTabWithMenu {
icon: @image-url("../assets/icons/hard-drive.svg");
- label: "Local Library";
+ label: @tr("Local Library");
show-icon: root.show-tab-icons;
+ items: [@tr("Albums"), @tr("Artists"), @tr("Folders"),
+ @tr("Tracks")];
}
- NavTab {
+ NavTabWithMenu {
icon: @image-url("../assets/icons/cassette-tape.svg");
- label: "My QBZ";
+ label: @tr("My QBZ");
show-icon: root.show-tab-icons;
+ items: [@tr("Collections"), @tr("Mixtapes")];
}
}
@@ -206,11 +304,21 @@ export component HeaderBar inherits Rectangle {
width: 112px;
height: 36px;
spacing: 2px;
+ // Lyrics panel toggle.
HeaderIconButton {
icon: @image-url("../assets/icons/captions.svg");
+ active: ShellState.right-panel == 2;
+ clicked => {
+ ShellState.right-panel = ShellState.right-panel == 2 ? 0 : 2;
+ }
}
+ // Queue panel toggle.
HeaderIconButton {
icon: @image-url("../assets/icons/list-ordered.svg");
+ active: ShellState.right-panel == 1;
+ clicked => {
+ ShellState.right-panel = ShellState.right-panel == 1 ? 0 : 1;
+ }
}
HeaderIconButton {
icon: @image-url("../assets/icons/menu.svg");
diff --git a/crates/qbz-slint/ui/shell/LyricsSidebar.slint b/crates/qbz-slint/ui/shell/LyricsSidebar.slint
new file mode 100644
index 000000000..c8145287e
--- /dev/null
+++ b/crates/qbz-slint/ui/shell/LyricsSidebar.slint
@@ -0,0 +1,85 @@
+// Right-side Lyrics panel.
+//
+// Rendered as a layout sibling of the content area (see AppShell), so it
+// shrinks the content rather than overlaying it. Closing sets
+// ShellState.right-panel = 0. The body is a structural placeholder;
+// lyric contents are wired in a later milestone.
+
+import { Theme } from "../foundation/semantic-colors.slint";
+import { Spacing } from "../foundation/spacing.slint";
+import { Typography } from "../foundation/typography.slint";
+import { Radius } from "../foundation/radius.slint";
+import { QbzIcon } from "../primitives/QbzIcon.slint";
+import { ShellState } from "../state.slint";
+
+component CloseButton inherits Rectangle {
+ callback clicked();
+ width: 28px;
+ height: 28px;
+ border-radius: Radius.sm;
+ background: ta.has-hover ? Theme.surface-hover : transparent;
+
+ QbzIcon {
+ source: @image-url("../assets/icons/x.svg");
+ width: 15px;
+ height: 15px;
+ x: (parent.width - self.width) / 2;
+ y: (parent.height - self.height) / 2;
+ tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
+ }
+
+ ta := TouchArea {
+ mouse-cursor: pointer;
+ clicked => {
+ root.clicked();
+ }
+ }
+}
+
+export component LyricsSidebar inherits Rectangle {
+ width: 320px;
+ background: Theme.surface-card;
+
+ VerticalLayout {
+ spacing: 0px;
+
+ // Header row — title + close button.
+ HorizontalLayout {
+ padding-left: Spacing.md;
+ padding-right: Spacing.sm;
+ padding-top: Spacing.sm;
+ padding-bottom: Spacing.sm;
+ height: 44px;
+ Text {
+ text: @tr("Lyrics");
+ color: Theme.text-primary;
+ font-size: Typography.heading;
+ font-weight: Typography.semibold;
+ vertical-alignment: center;
+ horizontal-stretch: 1;
+ }
+ VerticalLayout {
+ alignment: center;
+ CloseButton {
+ clicked => {
+ ShellState.right-panel = 0;
+ }
+ }
+ }
+ }
+ Rectangle {
+ height: 1px;
+ background: Theme.border-subtle;
+ }
+
+ // Body — placeholder until lyric contents are wired.
+ Rectangle {
+ vertical-stretch: 1;
+ Text {
+ text: @tr("No lyrics");
+ color: Theme.text-muted;
+ font-size: Typography.body;
+ }
+ }
+ }
+}
diff --git a/crates/qbz-slint/ui/shell/NavButtons.slint b/crates/qbz-slint/ui/shell/NavButtons.slint
index 38af2eecf..85487b5eb 100644
--- a/crates/qbz-slint/ui/shell/NavButtons.slint
+++ b/crates/qbz-slint/ui/shell/NavButtons.slint
@@ -6,7 +6,7 @@
// Rust nav history records every move.
import { Theme } from "../foundation/semantic-colors.slint";
-import { NavState } from "../state.slint";
+import { NavState, ShellState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavBtn inherits Rectangle {
@@ -48,6 +48,19 @@ export component NavButtons inherits Rectangle {
btn-row := HorizontalLayout {
padding: 3px;
spacing: 4px;
+ // Leading button: collapses / expands the left sidebar. Lives
+ // here so it appears just left of the [<] [>] pair in every view.
+ NavBtn {
+ icon: @image-url("../assets/icons/panel-left.svg");
+ clicked => {
+ ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed;
+ }
+ }
+ // 1px separator between the collapse button and the history pair.
+ Rectangle {
+ width: 1px;
+ background: Theme.border-subtle;
+ }
NavBtn {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: NavState.can-back;
diff --git a/crates/qbz-slint/ui/shell/QueueSidebar.slint b/crates/qbz-slint/ui/shell/QueueSidebar.slint
new file mode 100644
index 000000000..beb0995a9
--- /dev/null
+++ b/crates/qbz-slint/ui/shell/QueueSidebar.slint
@@ -0,0 +1,85 @@
+// Right-side Queue panel.
+//
+// Rendered as a layout sibling of the content area (see AppShell), so it
+// shrinks the content rather than overlaying it. Closing sets
+// ShellState.right-panel = 0. The body is a structural placeholder;
+// queue contents are wired in a later milestone.
+
+import { Theme } from "../foundation/semantic-colors.slint";
+import { Spacing } from "../foundation/spacing.slint";
+import { Typography } from "../foundation/typography.slint";
+import { Radius } from "../foundation/radius.slint";
+import { QbzIcon } from "../primitives/QbzIcon.slint";
+import { ShellState } from "../state.slint";
+
+component CloseButton inherits Rectangle {
+ callback clicked();
+ width: 28px;
+ height: 28px;
+ border-radius: Radius.sm;
+ background: ta.has-hover ? Theme.surface-hover : transparent;
+
+ QbzIcon {
+ source: @image-url("../assets/icons/x.svg");
+ width: 15px;
+ height: 15px;
+ x: (parent.width - self.width) / 2;
+ y: (parent.height - self.height) / 2;
+ tint: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
+ }
+
+ ta := TouchArea {
+ mouse-cursor: pointer;
+ clicked => {
+ root.clicked();
+ }
+ }
+}
+
+export component QueueSidebar inherits Rectangle {
+ width: 320px;
+ background: Theme.surface-card;
+
+ VerticalLayout {
+ spacing: 0px;
+
+ // Header row — title + close button.
+ HorizontalLayout {
+ padding-left: Spacing.md;
+ padding-right: Spacing.sm;
+ padding-top: Spacing.sm;
+ padding-bottom: Spacing.sm;
+ height: 44px;
+ Text {
+ text: @tr("Queue");
+ color: Theme.text-primary;
+ font-size: Typography.heading;
+ font-weight: Typography.semibold;
+ vertical-alignment: center;
+ horizontal-stretch: 1;
+ }
+ VerticalLayout {
+ alignment: center;
+ CloseButton {
+ clicked => {
+ ShellState.right-panel = 0;
+ }
+ }
+ }
+ }
+ Rectangle {
+ height: 1px;
+ background: Theme.border-subtle;
+ }
+
+ // Body — placeholder until queue contents are wired.
+ Rectangle {
+ vertical-stretch: 1;
+ Text {
+ text: @tr("Nothing queued");
+ color: Theme.text-muted;
+ font-size: Typography.body;
+ }
+ }
+ }
+}
diff --git a/crates/qbz-slint/ui/shell/Sidebar.slint b/crates/qbz-slint/ui/shell/Sidebar.slint
index 936afcbc7..8757afb72 100644
--- a/crates/qbz-slint/ui/shell/Sidebar.slint
+++ b/crates/qbz-slint/ui/shell/Sidebar.slint
@@ -3,18 +3,31 @@
// The playlist list is empty until real playlist data is wired (the
// sidebar is a later milestone). The user element now lives in the
// header, not at the bottom of the sidebar.
+//
+// The sidebar is collapsible: its rendered width is driven by
+// ShellState (0px when collapsed, otherwise the user-set width). A thin
+// drag handle on the right edge resizes it, clamped to [200px, 420px];
+// dragging below ~160px collapses it entirely.
import { Theme } from "../foundation/semantic-colors.slint";
import { Spacing } from "../foundation/spacing.slint";
-import { Layout } from "../foundation/layout.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
+import { ShellState } from "../state.slint";
import { ScrollView } from "std-widgets.slint";
export component Sidebar inherits Rectangle {
- width: Layout.sidebar-width;
+ // Rendered width — 0 when collapsed, otherwise the user-set width.
+ width: ShellState.sidebar-collapsed ? 0px : ShellState.sidebar-width;
// The sidebar sits one surface above the content area, matching the
// QBZ dark theme (--bg-secondary sidebar over --bg-primary content).
background: Theme.surface-card;
+ // Clip so content doesn't spill while the width animates to 0.
+ clip: true;
+ animate width { duration: 160ms; easing: ease-in-out; }
+
+ // Captured sidebar width at pointer-down, so drag deltas are
+ // measured from a stable origin.
+ property drag-start-width: ShellState.sidebar-width;
VerticalLayout {
padding: Spacing.md;
@@ -22,7 +35,7 @@ export component Sidebar inherits Rectangle {
HorizontalLayout {
Text {
- text: "PLAYLISTS";
+ text: @tr("PLAYLISTS");
color: Theme.text-muted;
font-size: 11px;
letter-spacing: 1.5px;
@@ -50,4 +63,30 @@ export component Sidebar inherits Rectangle {
}
}
}
+
+ // Drag handle — a thin strip pinned to the right edge. Resizes the
+ // sidebar; below ~160px it collapses instead of resizing.
+ drag-handle := TouchArea {
+ x: parent.width - self.width;
+ width: 6px;
+ height: parent.height;
+ mouse-cursor: ew-resize;
+
+ pointer-event(e) => {
+ if (e.kind == PointerEventKind.down) {
+ root.drag-start-width = ShellState.sidebar-width;
+ }
+ }
+ moved => {
+ if (self.pressed) {
+ if (root.drag-start-width + (self.mouse-x - self.pressed-x) < 160px) {
+ ShellState.sidebar-collapsed = true;
+ } else {
+ ShellState.sidebar-width = Math.min(Math.max(
+ root.drag-start-width + (self.mouse-x - self.pressed-x),
+ 200px), 420px);
+ }
+ }
+ }
+ }
}
diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint
index 804e28afc..9008ec944 100644
--- a/crates/qbz-slint/ui/state.slint
+++ b/crates/qbz-slint/ui/state.slint
@@ -168,6 +168,16 @@ export global ArtistState {
in property loading: false;
}
+// Shell chrome state — collapsible left sidebar and right-side panels.
+// Globals are imported directly by the shell components, so toggles
+// need no callback plumbing.
+export global ShellState {
+ in-out property sidebar-collapsed: false;
+ in-out property sidebar-width: 280px;
+ // 0 = none, 1 = queue, 2 = lyrics
+ in-out property right-panel: 0;
+}
+
// Authenticated user, for the header user element.
export global SessionState {
in property user-name: "";