Files
qbz/crates/qbz-slint/ui/shell/HeaderBar.slint
T
vicrodh 9a964477e5 fix(slint): unblock header menu hover-switch, tighten panel + tabs
The click-outside scrim was a full-window TouchArea — it covered the
tabs and intercepted their hover events, so hover-switching between
menus still did not work (same failure mode as the old PopupWindow
grab). The scrim now starts below the header, leaving the tabs
hoverable. The panel also dropped from header-height + 6px to 45px,
removing the unjustified gap between the tab and its dropdown. Header
tab labels lost another 2px of right padding.

cc
2026-05-19 04:53:03 -06:00

488 lines
17 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 } 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 ? 11px : 13px;
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;
}
}
// 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;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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 {
x: Spacing.md;
y: (root.height - self.height) / 2;
height: 30px;
spacing: Spacing.xs;
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 },
{ label: @tr("Editor's Picks"), has-glyph: false },
{ label: @tr("For You"), has-glyph: false },
];
// 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 },
{ 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("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true },
];
}
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;
}
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();
}
}
}
}
}
}