Files
qbz/crates/qbz-slint/ui/shell/HeaderBar.slint
T
vicrodh 682719cab0 feat(slint): library/sidebar UX overhaul + theme switching + fixes
Shell & navigation:
- Three-state sidebar (open/mini/closed); collapse cycles via header button.
- Section nav as right-flyouts (sidebar) + compact header nav (closed) +
  full header tabs (nav-in-sidebar off), via a shared HeaderMenuOverlay.
- Collapsed-folder right-flyout popup (max 4 + scrollbar), Rust-populated.
- Thin auto-hiding sidebar scrollbar pinned to the edge (Flickable, drag-safe).
- Slack-style bezel: rounded inset content panel over surface-card chrome.
- Header restructure: nav buttons, window-centered search, app menu.

Themes & components:
- Live theme switching: Dark / OLED Black / Tokyo Night / System (std-widgets).
- SegmentedTabBar (Discovery-style) with count badges for Library + Local Library.
- MP3 quality-badge tier (format-first classification) across badges.
- QbzSelect (Group/Sort) trimmed 20px.

Behavior fixes:
- Artist + Local Library Folder cover art now reach the rendered (visible/
  grouped) models, not just the source set.
- Favorites Playlists/Labels random buttons.
- Playlist track click plays from the clicked track in visible order.
- Dropdown idle-close pauses while the cursor rests on the menu.

cc
2026-05-30 15:40:35 -06:00

633 lines
24 KiB
Plaintext

// Top header bar — navigation tabs anchored left, the user element
// anchored right, and the search field absolutely centered on the window.
//
// The search field is positioned at the true window center (not inside a
// layout column) so it stays centered regardless of how wide the tab row
// or the user element become.
//
// Responsive: below the first breakpoint the tabs drop their icons;
// below the second the search field narrows.
import { Theme } from "../foundation/semantic-colors.slint";
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, ShellState, HeaderMenuState, NavMenuEntry, SearchActions } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
component NavTab inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active: false;
in property <bool> show-icon: true;
callback clicked();
// Fires when the pointer first enters the tab — drives hover-to-open.
callback hovered();
height: 30px;
width: tab-row.preferred-width;
border-radius: Radius.sm;
background: root.active ? Theme.surface-elevated
: ta.has-hover ? Theme.surface-hover : transparent;
tab-row := HorizontalLayout {
padding-left: root.show-icon ? 9px : 11px;
padding-right: 7px;
spacing: 6px;
if root.show-icon: VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
}
// An explicit row-height box plus vertical-alignment centers the
// label optically against the glyph and caret — a bare Text box
// rides high because of the font's descender space.
Text {
text: root.label;
height: root.height;
vertical-alignment: center;
color: Theme.text-primary;
font-size: 11px;
font-weight: root.active ? Typography.semibold : Typography.regular;
}
// Caret marking the tab as a dropdown.
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/chevron-down.svg");
width: 12px;
height: 12px;
tint: root.active ? Theme.text-secondary : Theme.text-muted;
}
}
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
changed has-hover => {
if (self.has-hover) {
root.hovered();
}
}
}
}
component MenuItem inherits Rectangle {
in property <image> icon;
in property <string> label;
callback clicked();
height: 34px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 12px;
padding-right: 18px;
spacing: 10px;
VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
tint: Theme.text-secondary;
}
}
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.
//
// The tab no longer owns a PopupWindow — a PopupWindow grabs the pointer
// while open, so hovering a sibling tab delivered no hover event and the
// menus could not be hover-switched. Instead, hovering or clicking the
// tab writes this tab's identity (index, header x-position, menu rows,
// home-item) into the HeaderMenuState global. The actual menu panel is
// drawn by HeaderMenuOverlay, a late child of the AppShell root, so it
// renders on top of the content area and does not grab the pointer.
//
// Hovering a different tab simply overwrites HeaderMenuState, so the new
// menu opens and the previous one closes instantly. Single-open is
// automatic — there is one global `open-index`.
component NavTabWithMenu inherits Rectangle {
in property <image> icon;
in property <string> label;
in property <bool> active: false;
in property <bool> show-icon: true;
in property <[NavMenuEntry]> items;
// This tab's index (0..3) — written into HeaderMenuState.open-index.
in property <int> tab-index;
// Index of the row wired to Home (-1 = none). Clicking it switches
// to ContentView.home; all other rows are harmless no-ops.
in property <int> home-item: -1;
width: tab.width;
height: 30px;
// Publishes this tab's menu into the shared overlay state. The
// overlay aligns the panel under `anchor-x`; `absolute-position`
// resolves to a window-relative coordinate, which is what the
// AppShell-level overlay needs (the tab itself sits inside the
// header's tab-row layout, several offsets deep).
function open-menu() {
HeaderMenuState.open-index = root.tab-index;
HeaderMenuState.anchor-x = root.absolute-position.x;
HeaderMenuState.items = root.items;
HeaderMenuState.home-item = root.home-item;
}
tab := NavTab {
icon: root.icon;
label: root.label;
active: root.active;
show-icon: root.show-icon;
clicked => {
root.open-menu();
}
// Open the dropdown on hover, no click needed. Hovering a
// different tab overwrites the global → instant switch.
hovered => {
root.open-menu();
}
}
}
// Compact icon-only nav button shown in the header ONLY while the sidebar is
// fully closed (Cerrado), so the section nav stays reachable. Opens a downward
// dropdown headed by the section name (label + hairline + the clickable rows).
component CompactNavBtn inherits Rectangle {
in property <image> icon;
in property <string> title;
in property <[NavMenuEntry]> items;
in property <int> tab-index;
in property <int> home-item: -1;
width: 30px;
height: 30px;
border-radius: Radius.sm;
property <bool> active: HeaderMenuState.open-index == root.tab-index;
background: root.active ? Theme.surface-elevated
: cn-ta.has-hover ? Theme.surface-hover : transparent;
function open-menu() {
HeaderMenuState.open-index = root.tab-index;
HeaderMenuState.anchor-x = root.absolute-position.x;
HeaderMenuState.from-sidebar = false;
HeaderMenuState.title = root.title;
HeaderMenuState.items = root.items;
HeaderMenuState.home-item = root.home-item;
}
QbzIcon {
source: root.icon;
width: 16px;
height: 16px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.active ? Theme.text-primary : Theme.text-secondary;
}
cn-ta := TouchArea {
mouse-cursor: pointer;
clicked => { root.open-menu(); }
changed has-hover => {
if (self.has-hover) {
HeaderMenuState.trigger-hovered = true;
root.open-menu();
} else if (root.active) {
// Only the trigger owning the open menu clears the flag — this
// avoids a leave/enter race when sliding between triggers.
HeaderMenuState.trigger-hovered = false;
}
}
}
}
// Circular history / collapse button for the header's right cluster —
// same look as the Discover carousel navigators. `enabled` dims + disables.
component HeaderNavBtn inherits Rectangle {
in property <image> icon;
in property <bool> enabled: true;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
opacity: root.enabled ? 1.0 : 0.4;
background: hn-ta.has-hover && root.enabled
? Theme.surface-hover
: Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: root.enabled ? Theme.text-primary : Theme.text-muted;
}
hn-ta := TouchArea {
enabled: root.enabled;
mouse-cursor: root.enabled ? pointer : default;
clicked => {
if (root.enabled) {
root.clicked();
}
}
}
}
// 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 <image> icon;
in property <bool> active: false;
callback clicked();
width: 36px;
height: 36px;
border-radius: Radius.sm;
background: root.active ? Theme.surface-elevated
: ta.has-hover ? Theme.surface-hover : transparent;
QbzIcon {
source: root.icon;
width: 19px;
height: 19px;
// Round the centering to whole pixels — a fractional offset
// (here 8.5px) makes the glyph render blurry.
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: root.active ? Theme.accent
: ta.has-hover ? Theme.text-primary : Theme.text-secondary;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component HeaderBar inherits Rectangle {
callback logout();
callback close-app();
height: Layout.header-height;
background: Theme.surface-card;
// Responsive breakpoints.
property <bool> show-tab-icons: root.width >= 1140px;
// 80% of the prior search width (was 224px / 320px).
property <length> search-width: root.width < 960px ? 179px : 256px;
// --- Tabs (anchored left) ----------------------------------------------
tabs := HorizontalLayout {
// xs (not md): the sidebar toggle is a bare icon with ~10px of
// breathing room to the Discover tab; the window-edge margin is
// matched to that so the toggle glyph sits symmetrically.
x: Spacing.xs;
y: (root.height - self.height) / 2;
height: 30px;
spacing: 2px;
// Section nav lives in the header by default; when the user moves it
// to the sidebar (ShellState.nav-in-sidebar) the header tabs hide.
// The sidebar-collapse toggle no longer lives here — it moved next to
// the back/forward nav buttons (NavButtons), present in every view.
if !ShellState.nav-in-sidebar: HorizontalLayout {
spacing: 2px;
NavTabWithMenu {
tab-index: 0;
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: true;
show-icon: root.show-tab-icons;
// Discover rows are text-only (no glyphs).
items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
// Row 0 = Home, the one working destination.
home-item: 0;
}
NavTabWithMenu {
tab-index: 1;
icon: @image-url("../assets/icons/music-library-2.svg");
label: @tr("Library");
show-icon: root.show-tab-icons;
items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
NavTabWithMenu {
tab-index: 2;
icon: @image-url("../assets/icons/hard-drive.svg");
label: @tr("Local Library");
show-icon: root.show-tab-icons;
items: [
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
NavTabWithMenu {
tab-index: 3;
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("My QBZ");
show-icon: root.show-tab-icons;
items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
];
}
}
}
// --- Search (absolutely centered on the window) ------------------------
Rectangle {
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
width: root.search-width;
height: 32px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.border-subtle;
background: Theme.surface-elevated;
// Magnifier — absolutely positioned on the left so it does NOT take
// layout space; the text/placeholder center against the full input
// width as if the icon weren't there.
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
x: 10px;
y: (parent.height - self.height) / 2;
tint: Theme.text-muted;
}
// Full-width input — text centered against the whole box.
search-input := TextInput {
width: parent.width;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
single-line: true;
// Enter submits to the full results page; every edit feeds the
// debounced live search (Rust side).
accepted => { SearchActions.submit(self.text); }
edited => { SearchActions.live(self.text); }
}
Text {
visible: search-input.text == "";
text: @tr("Search");
width: parent.width;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
horizontal-alignment: center;
}
}
// --- Left controls: history nav + collapse (anchored left) -------------
// The three navigation buttons (back / forward / sidebar-collapse) float
// at the header's LEFT edge.
left-controls := HorizontalLayout {
x: Spacing.md;
y: (root.height - self.height) / 2;
width: self.preferred-width;
height: 36px;
spacing: 6px;
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/panel-left.svg");
clicked => { ShellState.cycle-sidebar(); }
}
}
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/chevron-left.svg");
enabled: NavState.can-back;
clicked => { NavState.request-back(); }
}
}
VerticalLayout {
alignment: center;
HeaderNavBtn {
icon: @image-url("../assets/icons/chevron-right.svg");
enabled: NavState.can-forward;
clicked => { NavState.request-forward(); }
}
}
// Compact section nav — only while the sidebar is fully closed
// (Cerrado), so Discover / Library / Local Library / My QBZ stay
// reachable. Icon-only; each opens a titled downward dropdown.
if ShellState.sidebar-state == 2: HorizontalLayout {
spacing: 2px;
// A small gap from the history buttons.
Rectangle { width: 6px; }
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 0;
title: @tr("Discover");
icon: @image-url("../assets/icons/compass.svg");
home-item: 0;
items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
}
}
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 1;
title: @tr("Library");
icon: @image-url("../assets/icons/music-library-2.svg");
items: [
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
}
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 2;
title: @tr("Local Library");
icon: @image-url("../assets/icons/hard-drive.svg");
items: [
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
}
VerticalLayout {
alignment: center;
CompactNavBtn {
tab-index: 3;
title: @tr("My QBZ");
icon: @image-url("../assets/icons/cassette-tape.svg");
items: [
{ label: @tr("Collections"), icon: @image-url("../assets/icons/library-big.svg"), has-glyph: true },
{ label: @tr("Mixtapes"), icon: @image-url("../assets/icons/cassette-tape.svg"), has-glyph: true },
];
}
}
}
}
// --- Right controls: app menu (anchored right) -------------------------
right-controls := HorizontalLayout {
x: root.width - self.width - Spacing.md + 2px;
y: (root.height - self.height) / 2;
width: 36px;
height: 36px;
VerticalLayout {
alignment: center;
HeaderIconButton {
icon: @image-url("../assets/icons/menu.svg");
clicked => {
menu.show();
}
}
}
}
menu := PopupWindow {
x: parent.width - 234px - Spacing.md;
y: Layout.header-height - 4px;
width: 234px;
height: 310px;
Rectangle {
background: Theme.surface-main;
border-radius: Radius.sm;
border-width: 1px;
border-color: Theme.border-muted;
drop-shadow-blur: 24px;
drop-shadow-color: Theme.card-shadow;
drop-shadow-offset-y: 6px;
VerticalLayout {
padding-top: 6px;
padding-bottom: 6px;
// Signed-in user — name and subscription tier.
HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
padding-top: 6px;
padding-bottom: 10px;
VerticalLayout {
spacing: 2px;
Text {
text: SessionState.user-name == ""
? @tr("Guest")
: SessionState.user-name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
}
Text {
text: SessionState.subscription == ""
? @tr("Not signed in")
: SessionState.subscription;
color: Theme.text-muted;
font-size: 12px;
}
}
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 4px; }
MenuItem {
icon: @image-url("../assets/icons/settings.svg");
label: @tr("Settings");
clicked => {
NavState.request-settings();
}
}
MenuItem {
icon: @image-url("../assets/icons/keyboard.svg");
label: @tr("Keyboard Shortcuts");
}
MenuItem {
icon: @image-url("../assets/icons/book-open.svg");
label: @tr("Documentation");
}
MenuItem {
icon: @image-url("../assets/icons/bug.svg");
label: @tr("Report an Issue");
}
MenuItem {
icon: @image-url("../assets/icons/info.svg");
label: @tr("About");
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
MenuItem {
icon: @image-url("../assets/icons/log-out.svg");
label: @tr("Log Out");
clicked => {
root.logout();
}
}
MenuItem {
icon: @image-url("../assets/icons/x.svg");
label: @tr("Close");
clicked => {
root.close-app();
}
}
}
}
}
}