feat(slint): in-window overlay for nav-tab dropdowns with hover-switch
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
This commit is contained in:
2 files changed
+128
No files matched your search
@@ -9,6 +9,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
|
|||||||
import { NavState, ContentView, ShellState } from "../state.slint";
|
import { NavState, ContentView, ShellState } from "../state.slint";
|
||||||
import { Sidebar } from "Sidebar.slint";
|
import { Sidebar } from "Sidebar.slint";
|
||||||
import { HeaderBar } from "HeaderBar.slint";
|
import { HeaderBar } from "HeaderBar.slint";
|
||||||
|
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
|
||||||
import { PlayerBar } from "PlayerBar.slint";
|
import { PlayerBar } from "PlayerBar.slint";
|
||||||
import { QueueSidebar } from "QueueSidebar.slint";
|
import { QueueSidebar } from "QueueSidebar.slint";
|
||||||
import { LyricsSidebar } from "LyricsSidebar.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 { }
|
||||||
}
|
}
|
||||||
@@ -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 <int> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user