Files
qbz/crates/qbz-slint/ui/shell/HeaderMenuOverlay.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

164 lines
7.2 KiB
Plaintext

// In-window overlay that draws the open header nav-tab dropdown.
//
// Why this exists: the four header tabs used to each own a PopupWindow.
// A PopupWindow grabs the pointer while open, so hovering a sibling tab
// delivered no hover event — the menus could not be hover-switched, the
// new menu only appeared after the old one's idle timer fired.
//
// This overlay is a plain Rectangle, so it does NOT grab the pointer:
// the header tabs stay hoverable while a menu is open. It must be the
// LAST child of the AppShell root so it draws on top of the content
// area (the header itself is only 48px tall — a menu hosted inside it
// would be clipped by the content region below).
//
// Tabs publish their identity into HeaderMenuState (open-index, anchor-x,
// items, home-item). Hovering a different tab overwrites the global, so
// the menu switches instantly. open-index == -1 means nothing is open.
import { Theme } from "../foundation/semantic-colors.slint";
import { Radius } from "../foundation/radius.slint";
import { Layout } from "../foundation/layout.slint";
import { HeaderMenuState, NavState, ContentView, ShellState } from "../state.slint";
import { NavMenuItem } from "NavMenuItem.slint";
export component HeaderMenuOverlay inherits Rectangle {
// Fills the whole shell; only visible (and only consumes events)
// while a menu is open.
visible: HeaderMenuState.open-index >= 0;
// Bubbles a row's destination route (e.g. "favorites-tracks") up
// to the shell so Rust can run the navigation + data load.
callback navigate(string);
// True while the pointer rests on the panel body (set by panel-ta, which
// lives inside the conditional panel and so can't be referenced directly).
property <bool> panel-hovered: false;
// Which row is hovered (-1 = none). Rows don't overlap, so one at a time.
property <int> hovered-row: -1;
// True while the pointer rests anywhere on the menu (panel body OR a row).
// The idle timer is paused while this holds, so resting the cursor on the
// menu — even without moving it — never lets the menu close.
property <bool> menu-hovered:
root.panel-hovered || root.hovered-row >= 0 || HeaderMenuState.trigger-hovered;
// Closes the menu.
function close-menu() {
HeaderMenuState.open-index = -1;
}
// Clear the panel/row hover trackers whenever the open menu changes, so a
// stale state from the previous menu can't wrongly pause the timer.
// NOTE: trigger-hovered is deliberately NOT reset here — the trigger owns
// it (resetting it on open would clear the flag the instant the hovered
// trigger opens the menu, defeating the whole purpose).
changed open-index => {
root.hovered-row = -1;
root.panel-hovered = false;
}
property <int> open-index: HeaderMenuState.open-index;
// Closes the menu after 1s idle (project rule). The timer runs only while a
// menu is open AND the pointer is NOT over it — so hovering the menu pauses
// the countdown, and leaving it restarts a fresh 1s.
idle-timer := Timer {
interval: 1000ms;
running: HeaderMenuState.open-index >= 0 && !root.menu-hovered;
triggered => {
HeaderMenuState.open-index = -1;
}
}
// Transparent scrim — a click outside the panel closes the menu.
// It starts BELOW the header so the tabs stay hoverable while a menu
// is open: a full-window scrim would intercept the tabs' hover
// events and block hover-switching, exactly like the old
// PopupWindow's pointer grab did.
if HeaderMenuState.open-index >= 0: TouchArea {
// Header dropdowns: scrim starts below the header so the tabs stay
// hoverable. Sidebar flyouts: scrim starts right of the sidebar so the
// section rows stay clickable.
x: HeaderMenuState.from-sidebar ? ShellState.sidebar-rendered-width : 0px;
y: HeaderMenuState.from-sidebar ? 0px : Layout.header-height;
width: parent.width - self.x;
height: parent.height - self.y;
clicked => {
root.close-menu();
}
}
// The menu panel — same styling as the former PopupWindow content:
// surface-main background, 1px border-muted, Radius.sm, drop shadow.
if HeaderMenuState.open-index >= 0: panel := Rectangle {
x: HeaderMenuState.anchor-x;
// Header dropdown: snug under the tabs (~45px). Sidebar flyout: aligned
// to the row's window y, opening to the right.
y: HeaderMenuState.from-sidebar ? HeaderMenuState.anchor-y : 45px;
width: 188px;
height: panel-box.preferred-height;
panel-box := 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;
// Keeps the pointer "inside" the menu so the scrim's click does
// not pass through, and reports panel-body hover so the idle timer
// pauses while the cursor rests on the menu.
panel-ta := TouchArea {
changed has-hover => {
root.panel-hovered = self.has-hover;
}
}
VerticalLayout {
padding-top: 6px;
padding-bottom: 6px;
// Optional section-name header + hairline (used by the compact
// header nav shown when the sidebar is fully closed).
if HeaderMenuState.title != "": HorizontalLayout {
padding-left: 14px;
padding-right: 14px;
Text {
text: HeaderMenuState.title;
color: Theme.text-muted;
font-size: 11px;
font-weight: 600;
height: 26px;
vertical-alignment: center;
}
}
if HeaderMenuState.title != "": Rectangle {
height: 1px;
background: Theme.border-subtle;
}
for entry[idx] in HeaderMenuState.items: NavMenuItem {
label: entry.label;
icon: entry.icon;
has-glyph: entry.has-glyph;
clicked => {
if (entry.route != "") {
root.navigate(entry.route);
} else if (idx == HeaderMenuState.home-item) {
NavState.view = ContentView.home;
}
root.close-menu();
}
// Track per-row hover so the idle timer stays paused while
// the cursor rests on a row (not just while it moves).
hover-changed(h) => {
if (h) {
root.hovered-row = idx;
} else if (root.hovered-row == idx) {
root.hovered-row = -1;
}
}
}
}
}
}
}