// 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, SidebarPlaylistsPopupState } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; component NavTab inherits Rectangle { in property icon; in property label; in property active: false; in property show-icon: true; callback clicked(); // Fires when the pointer first enters the tab — drives hover-to-open. callback hovered(); // Fires on hover enter/leave so the host can keep the menu open while the // pointer rests on the tab (idle-close must pause then). callback hover-changed(bool); 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: 11px; 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; } } // The dropdown caret was dropped (it ate width on small screens; the // dropdown nature is learned after first use). 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; } } ta := TouchArea { mouse-cursor: pointer; clicked => { root.clicked(); } changed has-hover => { root.hover-changed(self.has-hover); if (self.has-hover) { root.hovered(); } } } } component MenuItem inherits Rectangle { in property icon; in property 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 icon; in property label; in property active: false; in property show-icon: true; in property <[NavMenuEntry]> items; // This tab's index (0..3) — written into HeaderMenuState.open-index. in property 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 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; // Header dropdown — NOT a sidebar flyout. Resetting these is what // glues the panel under the tab: a stale from-sidebar/anchor-y from a // previous sidebar flyout would otherwise fling it far away. HeaderMenuState.from-sidebar = false; HeaderMenuState.title = ""; 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(); } // Pause the idle-close while the cursor rests on the tab; only the // active tab clears the flag (avoids a leave/enter race). hover-changed(h) => { if (h) { HeaderMenuState.trigger-hovered = true; } else if (root.active) { HeaderMenuState.trigger-hovered = false; } } } } // 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 icon; in property title; in property <[NavMenuEntry]> items; in property tab-index; in property home-item: -1; width: 30px; height: 30px; border-radius: Radius.sm; property 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; } } } } // Like CompactNavBtn, but instead of a section dropdown it opens the playlists // flyout (SidebarPlaylistsPopup). Shown in the closed-sidebar header cluster so // playlists stay reachable with the sidebar fully collapsed. Header buttons are // not inside a Flickable, so absolute-position needs no correction here. component CompactPlaylistsBtn inherits Rectangle { width: 30px; height: 30px; border-radius: Radius.sm; property active: SidebarPlaylistsPopupState.open; background: root.active ? Theme.surface-elevated : cp-ta.has-hover ? Theme.surface-hover : transparent; QbzIcon { source: @image-url("../assets/icons/list-music.svg"); 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; } cp-ta := TouchArea { mouse-cursor: pointer; clicked => { SidebarPlaylistsPopupState.anchor-x = root.absolute-position.x; SidebarPlaylistsPopupState.anchor-y = root.absolute-position.y + root.height + 6px; SidebarPlaylistsPopupState.open = true; } } } // 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 icon; in property 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 icon; in property 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 show-tab-icons: root.width >= 1140px; // 80% of the prior search width (was 224px / 320px). When the section nav // is in the header (nav-in-sidebar off) it gives up 60px to make room. property search-width: (root.width < 960px ? 179px : 256px) - (ShellState.nav-in-sidebar ? 0px : 60px); // The section nav now lives inside the left-controls cluster (after the // three sacred nav buttons), so it can never overlap them. // --- Search (absolutely centered on the window) ------------------------ Rectangle { x: (root.width - self.width) / 2; y: (root.height - self.height) / 2; width: root.search-width; // Smoothly animate the width change when the nav moves in/out of the // header (and the x recenters with it). animate width { duration: 220ms; easing: ease-in-out; } 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(); } } } // Full section nav (text tabs) — header-nav mode (nav-in-sidebar OFF) // while the sidebar isn't fully closed. Sits AFTER the three sacred // buttons, so it never overlaps them. Mutually exclusive with the // compact icons below (which only show when fully closed). if !ShellState.nav-in-sidebar && ShellState.sidebar-state != 2: HorizontalLayout { spacing: 2px; Rectangle { width: 6px; } VerticalLayout { alignment: center; NavTabWithMenu { tab-index: 0; icon: @image-url("../assets/icons/compass.svg"); label: @tr("Discover"); active: true; show-icon: root.show-tab-icons; 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; 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" }, ]; } } VerticalLayout { alignment: center; 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" }, ]; } } VerticalLayout { alignment: center; 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 }, ]; } } } // 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 }, ]; } } // Thin separator, then the playlists flyout button (no sidebar list // to reach playlists from while fully closed). Rectangle { width: 1px; height: 18px; y: (parent.height - self.height) / 2; background: Theme.border-subtle; } VerticalLayout { alignment: center; CompactPlaylistsBtn { } } } } // --- 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(); } } } } } }