From 5972e8052b9331b7485d3b7966856f530eb00e59 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 19 May 2026 04:41:11 -0600 Subject: [PATCH] feat(slint): in-window overlay for nav-tab dropdowns with hover-switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add HeaderMenuOverlay, a plain in-window Rectangle rendered as the last child of the AppShell root so it draws on top of the header and content region. Unlike a PopupWindow it does not grab the pointer, so the header tabs stay hoverable while a menu is open — hovering from one tab to another overwrites HeaderMenuState and switches the menu instantly. Behaviour preserved from the old PopupWindow menus: - hover-to-open and click-to-open - 1.5s idle auto-close, restarted on pointer activity over the panel or its rows and on every tab (re)publish via a changed handler - click-outside-close via a full-window transparent scrim - Discover home-item row still switches to ContentView.home - glyphs on all menus except Discover Menu panel visuals are unchanged: surface-main bg, 1px border-muted, Radius.sm, 24px drop shadow, 188px wide. cc --- crates/qbz-slint/ui/shell/AppShell.slint | 7 + .../ui/shell/HeaderMenuOverlay.slint | 121 ++++++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index bd37dd54e..6b009635d 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -9,6 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint"; import { NavState, ContentView, ShellState } from "../state.slint"; import { Sidebar } from "Sidebar.slint"; import { HeaderBar } from "HeaderBar.slint"; +import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint"; import { PlayerBar } from "PlayerBar.slint"; import { QueueSidebar } from "QueueSidebar.slint"; import { LyricsSidebar } from "LyricsSidebar.slint"; @@ -124,4 +125,10 @@ export component AppShell inherits Rectangle { } } } + + // Header nav-tab dropdown menus. Declared LAST so it draws on top of + // the header and the content region. A plain in-window overlay (not + // a PopupWindow) — it does not grab the pointer, so the header tabs + // stay hoverable and menus can be hover-switched instantly. + HeaderMenuOverlay { } } diff --git a/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint b/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint new file mode 100644 index 000000000..a9c38e2e0 --- /dev/null +++ b/crates/qbz-slint/ui/shell/HeaderMenuOverlay.slint @@ -0,0 +1,121 @@ +// 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 } 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; + + // (Re)arms the idle countdown — called on any pointer activity over + // the panel or its rows. + function reset-idle() { + idle-timer.restart(); + } + + // Closes the menu and disarms the timer. + function close-menu() { + HeaderMenuState.open-index = -1; + idle-timer.running = false; + } + + // Restart the idle countdown every time a tab (re)publishes the + // open menu — covers both the initial open and hover-switching + // between tabs. + changed open-index => { + if (HeaderMenuState.open-index >= 0) { + idle-timer.running = true; + idle-timer.restart(); + } else { + idle-timer.running = false; + } + } + property open-index: HeaderMenuState.open-index; + + // Closes the menu after 1.5s without pointer interaction. + idle-timer := Timer { + interval: 1500ms; + running: false; + triggered => { + self.running = false; + HeaderMenuState.open-index = -1; + } + } + + // Full-window transparent scrim — a click anywhere outside the panel + // closes the menu. Declared before the panel so the panel sits on + // top and its own TouchArea wins over this one. + if HeaderMenuState.open-index >= 0: TouchArea { + 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; + y: Layout.header-height + 6px; + 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 resets the idle countdown on + // any movement over the panel body. + panel-ta := TouchArea { + moved => { + root.reset-idle(); + } + } + + VerticalLayout { + padding-top: 6px; + padding-bottom: 6px; + for entry[idx] in HeaderMenuState.items: NavMenuItem { + label: entry.label; + icon: entry.icon; + has-glyph: entry.has-glyph; + clicked => { + if (idx == HeaderMenuState.home-item) { + NavState.view = ContentView.home; + } + root.close-menu(); + } + // Any pointer interaction with a row resets the + // idle-close countdown. + hovered => { + root.reset-idle(); + } + } + } + } + } +}