refactor(slint): publish nav-tab menus into HeaderMenuState global

The four header nav tabs each owned a PopupWindow for their dropdown.
A PopupWindow grabs the pointer while open, so hovering a sibling tab
delivered no hover event and the menus could not be hover-switched.

Add a HeaderMenuState global (open-index, anchor-x, items, home-item)
and the NavMenuEntry struct to state.slint. Tabs now write their
identity into the global on hover/click instead of showing a popup;
the menu panel itself will be rendered by a separate in-window overlay.
Move NavMenuItem into its own file so the overlay can reuse it.

cc
This commit is contained in:
vicrodh committed 2026-05-19 04:37:26 -06:00
1 parent 3aa902292e
commit b6f5631f51
61 files changed
+324 -146

No files matched your search

+29 -146
View File
@@ -13,7 +13,7 @@ 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 } from "../state.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
@@ -144,115 +144,44 @@ component MenuItem inherits Rectangle {
}
}
// One entry of a nav-tab dropdown.
struct NavMenuEntry {
label: string,
icon: image,
has-glyph: bool,
}
// A menu row for the nav-tab dropdowns, with an optional leading glyph.
component NavMenuItem inherits Rectangle {
in property <string> label;
in property <image> icon;
in property <bool> has-glyph: false;
callback clicked();
// Fires whenever the pointer is over the row — drives the dropdown's
// idle-close timer reset.
callback hovered();
height: 32px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 22px;
spacing: 9px;
if root.has-glyph: VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
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();
}
changed has-hover => {
if (self.has-hover) {
root.hovered();
}
}
moved => {
root.hovered();
}
}
}
// A nav tab plus its dropdown menu. Clicking or hovering the tab opens a
// PopupWindow positioned just below it, styled like the hamburger menu.
// A nav tab plus its dropdown menu.
//
// Single-open is enforced by Slint itself: only one PopupWindow can be
// open per window, so calling show() on another tab's menu closes the
// previous one automatically — sweeping the pointer across tabs always
// leaves just one menu open.
// 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.
//
// Idle-close: an idle-timer (1.5s) closes the menu when the pointer has
// not interacted with it. The timer is (re)started whenever the menu is
// opened and whenever the pointer hovers/moves over a menu row.
//
// Menu rows are visual placeholders for now; the optional `home-item`
// index marks the one row that keeps the app on the working Home view.
// 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;
// True while this tab's menu is open — gates the idle-close timer.
property <bool> menu-open: false;
width: tab.width;
height: 30px;
// Opens the menu and (re)arms the idle-close countdown.
// 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() {
root.menu-open = true;
idle-timer.running = true;
idle-timer.restart();
menu.show();
}
// (Re)arms the idle-close countdown — called on pointer activity
// inside the menu. Exposed as a function so repeated menu rows can
// reach the timer, which lives in this component's scope.
function reset-idle() {
idle-timer.restart();
}
// Closes the menu and disarms the idle timer — called when a row is
// activated.
function close-menu() {
root.menu-open = false;
idle-timer.running = false;
menu.close();
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 {
@@ -263,62 +192,12 @@ component NavTabWithMenu inherits Rectangle {
clicked => {
root.open-menu();
}
// Open the dropdown on hover, no click needed.
// Open the dropdown on hover, no click needed. Hovering a
// different tab overwrites the global → instant switch.
hovered => {
root.open-menu();
}
}
// Closes the menu after 1.5s of no pointer interaction with it.
idle-timer := Timer {
interval: 1500ms;
running: false;
triggered => {
self.running = false;
if (root.menu-open) {
root.menu-open = false;
menu.close();
}
}
}
menu := PopupWindow {
x: 0;
y: parent.height + 6px;
width: 188px;
height: menu-box.preferred-height;
menu-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;
VerticalLayout {
padding-top: 6px;
padding-bottom: 6px;
for entry[idx] in root.items: NavMenuItem {
label: entry.label;
icon: entry.icon;
has-glyph: entry.has-glyph;
clicked => {
if (idx == root.home-item) {
NavState.view = ContentView.home;
}
root.close-menu();
}
// Any pointer interaction with a row resets the
// idle-close countdown.
hovered => {
root.reset-idle();
}
}
}
}
}
}
// Square icon button for the header's right-side controls. `active`
@@ -375,6 +254,7 @@ export component HeaderBar inherits Rectangle {
SidebarToggle { }
}
NavTabWithMenu {
tab-index: 0;
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: true;
@@ -389,6 +269,7 @@ export component HeaderBar inherits Rectangle {
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;
@@ -401,6 +282,7 @@ export component HeaderBar inherits Rectangle {
];
}
NavTabWithMenu {
tab-index: 2;
icon: @image-url("../assets/icons/hard-drive.svg");
label: @tr("Local Library");
show-icon: root.show-tab-icons;
@@ -412,6 +294,7 @@ export component HeaderBar inherits Rectangle {
];
}
NavTabWithMenu {
tab-index: 3;
icon: @image-url("../assets/icons/cassette-tape.svg");
label: @tr("My QBZ");
show-icon: root.show-tab-icons;
@@ -0,0 +1,56 @@
// A single row of a header nav-tab dropdown, with an optional leading
// glyph. Shared by HeaderBar (tab definitions) and HeaderMenuOverlay
// (the in-window menu panel that actually renders the rows).
import { Theme } from "../foundation/semantic-colors.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
export component NavMenuItem inherits Rectangle {
in property <string> label;
in property <image> icon;
in property <bool> has-glyph: false;
callback clicked();
// Fires whenever the pointer is over the row — drives the dropdown's
// idle-close timer reset.
callback hovered();
height: 32px;
background: ta.has-hover ? Theme.surface-hover : transparent;
HorizontalLayout {
padding-left: 14px;
padding-right: 22px;
spacing: 9px;
if root.has-glyph: VerticalLayout {
alignment: center;
QbzIcon {
source: root.icon;
width: 14px;
height: 14px;
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();
}
changed has-hover => {
if (self.has-hover) {
root.hovered();
}
}
moved => {
root.hovered();
}
}
}
+28
View File
@@ -149,6 +149,34 @@ export global SettingsState {
in-out property <int> retry-behavior-index: 0;
}
// One entry of a nav-tab dropdown.
export struct NavMenuEntry {
label: string,
icon: image,
has-glyph: bool,
}
// Header nav-tab dropdown state.
//
// The four header tabs (Discover / Library / Local Library / My QBZ) no
// longer own a PopupWindow each — a PopupWindow grabs the pointer while
// open, which blocks hover-switching between tabs. Instead a single
// in-window overlay (HeaderMenuOverlay, a late child of the AppShell
// root) draws the open tab's menu. Tabs write their identity into this
// global on hover/click; hovering a different tab overwrites it, giving
// an instant switch. `open-index` of -1 means no menu is open.
export global HeaderMenuState {
// Which tab's menu is open: -1 = none, 0..3 = the tab index.
in-out property <int> open-index: -1;
// Header x-coordinate of the open tab, so the overlay aligns the
// menu panel under it.
in-out property <length> anchor-x: 0px;
// The open tab's menu rows.
in-out property <[NavMenuEntry]> items: [];
// Index of the row wired to Home (-1 = none); only Discover uses it.
in-out property <int> home-item: -1;
}
export global NavState {
in-out property <ContentView> view: ContentView.home;
// Browser-like history flags, driven by the Rust nav module.