Files
qbz/crates/qbz-slint/ui/shell/HeaderBar.slint
T
vicrodh 1efa6fc745 feat(slint): Discover Editor's Picks + For You tabs
Makes the three-tab Discover pill (Home / Editor's Picks / For You)
functional, plus the matching Discover header-dropdown rows. Mirrors
Tauri's DiscoveryView: all tabs draw from one fetch and swap which
curated section set is shown — no extra round-trips on tab switch.

home.rs:
  - load_home now builds three section sets from a single
    get_discover_index call (cloning the editorial containers):
      * home — the existing balanced set.
      * editorPicks — editorial only (New Releases, Qobuzissimes,
        Press Accolades, Most Streamed, Ideal Discography, Albums of
        the Week), ordered per Tauri's DEFAULT_PREFS.editorPicks.
      * forYou — Release Watch (get_release_watch "artists"). The
        deeper personalized sections (Qobuz mixes, similar albums,
        spotlight) need reco-DB backend the Slint MVP lacks, so this
        is the honest first cut.
  - apply_home caches all three sets in a thread-local; select_tab
    swaps HomeState.sections to the chosen set and returns the
    Section-targeted artwork jobs to re-fire. Reuses the existing
    Section artwork target — no new targets.
  - map_full_album maps release-watch's catalog Album to a card.

Slint:
  - HomeState gains active-tab; HomeActions.select-tab wired to the
    pill (active state derived from active-tab) and to the Discover
    dropdown rows (route "discover-<tab>") via header-menu-navigate.
  - The Home-only sections (Recently Played albums/tracks, Popular)
    are gated to active-tab == "home" so Editor's Picks / For You
    render just their own set.

cc
2026-05-20 21:33:06 -06:00

497 lines
18 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";
// Sidebar collapse toggle — bare bright icon, no background. Sits at
// the very left of the header, before the Discover tab.
component SidebarToggle inherits Rectangle {
width: 30px;
height: 30px;
// 14px matches the nav-tab icons; with the 30px box the centering
// math ((30-14)/2 = 8) lands on whole pixels so the glyph stays
// crisp. Tint matches the tab icons (secondary, primary on hover).
QbzIcon {
source: @image-url("../assets/icons/panel-left.svg");
width: 14px;
height: 14px;
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 => {
ShellState.sidebar-collapsed = !ShellState.sidebar-collapsed;
}
}
}
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();
}
}
}
// 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;
VerticalLayout {
alignment: center;
SidebarToggle { }
}
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 },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true },
];
}
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;
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 7px;
VerticalLayout {
alignment: center;
QbzIcon {
source: @image-url("../assets/icons/search.svg");
width: 14px;
height: 14px;
tint: Theme.text-muted;
}
}
// Real text input, with a placeholder shown while empty —
// same pattern as the album track-search field.
Rectangle {
horizontal-stretch: 1;
search-input := TextInput {
width: 100%;
height: 100%;
color: Theme.text-primary;
font-size: 13px;
vertical-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");
// Span the input box so vertical-alignment centers the
// placeholder the same way the TextInput centers its
// own text — otherwise it rides at the top.
width: 100%;
height: 100%;
color: Theme.text-muted;
font-size: 13px;
vertical-alignment: center;
}
}
}
}
// --- Right controls: queue, lyrics, hamburger (anchored right) ---------
// Explicit width (3 * 36 + 2 * 2 spacing) so the right anchor lands
// correctly — a layout left to its own devices fills the parent.
right-controls := HorizontalLayout {
// 2px tighter to the window edge than the standard margin.
x: root.width - self.width - Spacing.md + 2px;
y: (root.height - self.height) / 2;
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");
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();
}
}
}
}
}
}