fix(slint): flyouts/idle, header nav layout, toasts, settings, search

Flyouts & idle (main effort):
- Header dropdowns now glue under the tab: open-menu resets from-sidebar +
  title (a stale sidebar flyout state was flinging the panel far away).
- Idle-close now pauses while the cursor rests on the TRIGGER too (full
  header tabs gained the trigger-hovered logic the others already had).
- Dropped the dropdown caret (saves width on small screens).

Header nav layout:
- Section nav moved INTO the left-controls cluster, after the three sacred
  buttons, so it never overlaps them. Full text tabs when nav-in-sidebar off
  and sidebar not closed; compact icons when closed — mutually exclusive (no
  more duplicate/cluttered header).
- Search field gives up 60px (animated) when the header hosts the nav.

Settings made functional:
- Toast component mounted in AppShell (it was never rendered) + the in-app
  toasts toggle drives ToastState.enabled.
- "Show album artwork in Local Library track list" now drives the Tracks/
  Folders rows. Both rows un-flagged as unsupported.

Collapsible search (ExpandableSearch, app-wide):
- Smooth open AND close (width animates both ways; magnifier fades).
- Reliable auto-focus on open (deferred via a one-shot timer).

cc
This commit is contained in:
vicrodh committed 2026-05-30 15:59:25 -06:00
1 parent 682719cab0
commit 13f43b802b
5 files changed
+128 -95

No files matched your search

@@ -18,7 +18,7 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, LocalArtistItem, AlphaJump, LibAlbumFilterState } from "../state.slint";
import { LocalLibraryState, LocalLibraryActions, FolderNode, LocalArtistItem, AlphaJump, LibAlbumFilterState, AppearanceState } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
@@ -993,7 +993,7 @@ export component LocalLibraryView inherits Rectangle {
TrackRow {
track: track;
index: i;
show-artwork: false;
show-artwork: AppearanceState.local-library-track-artwork;
show-album: LocalLibraryState.tracks-group-mode != "album";
show-favorite: false;
show-download: false;
@@ -1309,7 +1309,7 @@ export component LocalLibraryView inherits Rectangle {
for track[ti] in LocalLibraryState.folder-detail-tracks: TrackRow {
track: track;
index: ti;
show-artwork: false;
show-artwork: AppearanceState.local-library-track-artwork;
show-album: true;
show-favorite: false;
show-download: false;
@@ -19,9 +19,21 @@ export component ExpandableSearch inherits Rectangle {
in property <bool> sm: false;
callback edited(string);
// Defer the focus: focusing the input synchronously inside `changed open`
// races the open animation/layout (and can panic with "Recursion
// detected"). A one-shot timer grabs focus a tick later, reliably.
focus-timer := Timer {
interval: 30ms;
running: false;
triggered => {
input.focus();
self.running = false;
}
}
changed open => {
if (root.open) {
input.focus();
focus-timer.running = true;
focus-timer.restart();
}
}
@@ -29,9 +41,11 @@ export component ExpandableSearch inherits Rectangle {
width: root.sm ? 30px : 34px;
height: root.sm ? 30px : 34px;
// Closed: the magnifier toggle.
// Closed: the magnifier toggle. Fades (not toggled) so open/close is
// smooth in both directions.
Rectangle {
visible: !root.open;
opacity: root.open ? 0.0 : 1.0;
animate opacity { duration: 120ms; easing: ease-in-out; }
width: root.sm ? 30px : 34px;
height: root.sm ? 30px : 34px;
border-radius: 6px;
@@ -45,6 +59,7 @@ export component ExpandableSearch inherits Rectangle {
tint: open-ta.has-hover ? Theme.text-primary : Theme.text-muted;
}
open-ta := TouchArea {
enabled: !root.open;
mouse-cursor: pointer;
clicked => {
root.open = true;
@@ -52,15 +67,15 @@ export component ExpandableSearch inherits Rectangle {
}
}
// Open: right-anchored overlay that grows left.
// Open: right-anchored overlay that grows left. Always rendered; its width
// animates to 0 when closed (clip hides it), so the shrink is also smooth.
overlay := Rectangle {
visible: root.open;
x: parent.width - self.width;
width: root.open ? root.max-open-width : 0px;
height: root.sm ? 30px : 34px;
background: Theme.surface-card;
border-radius: 6px;
border-width: 1px;
border-width: root.open ? 1px : 0px;
border-color: input.has-focus ? Theme.accent : Theme.border-subtle;
clip: true;
animate width { duration: 200ms; easing: ease-in-out; }
@@ -14,7 +14,7 @@ import { Theme } from "../foundation/semantic-colors.slint";
import { ThemeState } from "../foundation/theme-state.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { AppearanceState, SidebarState, ShellState } from "../state.slint";
import { AppearanceState, SidebarState, ShellState, ToastState } from "../state.slint";
import { QbzToggle } from "../primitives/QbzToggle.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
@@ -312,7 +312,6 @@ export component AppearanceSettings inherits VerticalLayout {
}
}
SettingRow {
unsupported: true;
label: @tr("Show album artwork in Local Library track list");
description: @tr("Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost.");
QbzToggle {
@@ -334,12 +333,12 @@ export component AppearanceSettings inherits VerticalLayout {
GroupHeader { text: @tr("NOTIFICATIONS"); }
SettingRow {
unsupported: true;
label: @tr("In-app toasts notifications");
QbzToggle {
checked: AppearanceState.in-app-toasts;
toggled(v) => {
AppearanceState.in-app-toasts = v;
ToastState.enabled = v;
AppearanceState.appearance-bool("in-app-toasts", v);
}
}
+4
View File
@@ -20,6 +20,7 @@ import { FolderEditModal } from "../primitives/FolderEditModal.slint";
import { LibFolderEditModal } from "../settings/LibFolderEditModal.slint";
import { TagEditorModal } from "../primitives/TagEditorModal.slint";
import { DragGhost } from "../primitives/DragGhost.slint";
import { Toast } from "../primitives/Toast.slint";
import { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
@@ -322,4 +323,7 @@ export component AppShell inherits Rectangle {
// Floating track-drag ghost (follows the cursor, on top of all).
DragGhost { }
// In-app toast notifications — bottom-center, above everything.
Toast { }
}
+98 -83
View File
@@ -24,6 +24,9 @@ component NavTab inherits Rectangle {
callback clicked();
// Fires when the pointer first enters the tab — drives hover-to-open.
callback hovered();
// Fires on hover enter/leave so the host can keep the menu open while the
// pointer rests on the tab (idle-close must pause then).
callback hover-changed(bool);
height: 30px;
width: tab-row.preferred-width;
@@ -33,7 +36,7 @@ component NavTab inherits Rectangle {
tab-row := HorizontalLayout {
padding-left: root.show-icon ? 9px : 11px;
padding-right: 7px;
padding-right: 11px;
spacing: 6px;
if root.show-icon: VerticalLayout {
alignment: center;
@@ -44,9 +47,8 @@ component NavTab inherits Rectangle {
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.
// The dropdown caret was dropped (it ate width on small screens; the
// dropdown nature is learned after first use).
Text {
text: root.label;
height: root.height;
@@ -55,16 +57,6 @@ component NavTab inherits Rectangle {
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 {
@@ -73,6 +65,7 @@ component NavTab inherits Rectangle {
root.clicked();
}
changed has-hover => {
root.hover-changed(self.has-hover);
if (self.has-hover) {
root.hovered();
}
@@ -154,6 +147,11 @@ component NavTabWithMenu inherits Rectangle {
function open-menu() {
HeaderMenuState.open-index = root.tab-index;
HeaderMenuState.anchor-x = root.absolute-position.x;
// Header dropdown — NOT a sidebar flyout. Resetting these is what
// glues the panel under the tab: a stale from-sidebar/anchor-y from a
// previous sidebar flyout would otherwise fling it far away.
HeaderMenuState.from-sidebar = false;
HeaderMenuState.title = "";
HeaderMenuState.items = root.items;
HeaderMenuState.home-item = root.home-item;
}
@@ -171,6 +169,15 @@ component NavTabWithMenu inherits Rectangle {
hovered => {
root.open-menu();
}
// Pause the idle-close while the cursor rests on the tab; only the
// active tab clears the flag (avoids a leave/enter race).
hover-changed(h) => {
if (h) {
HeaderMenuState.trigger-hovered = true;
} else if (root.active) {
HeaderMenuState.trigger-hovered = false;
}
}
}
}
@@ -301,82 +308,22 @@ export component HeaderBar inherits Rectangle {
// 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;
// 80% of the prior search width (was 224px / 320px). When the section nav
// is in the header (nav-in-sidebar off) it gives up 60px to make room.
property <length> search-width:
(root.width < 960px ? 179px : 256px) - (ShellState.nav-in-sidebar ? 0px : 60px);
// --- Tabs (anchored left) ----------------------------------------------
tabs := HorizontalLayout {
// xs (not md): the sidebar toggle is a bare icon with ~10px of
// breathing room to the Discover tab; the window-edge margin is
// matched to that so the toggle glyph sits symmetrically.
x: Spacing.xs;
y: (root.height - self.height) / 2;
height: 30px;
spacing: 2px;
// Section nav lives in the header by default; when the user moves it
// to the sidebar (ShellState.nav-in-sidebar) the header tabs hide.
// The sidebar-collapse toggle no longer lives here — it moved next to
// the back/forward nav buttons (NavButtons), present in every view.
if !ShellState.nav-in-sidebar: HorizontalLayout {
spacing: 2px;
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, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
// 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, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
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, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
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 },
];
}
}
}
// The section nav now lives inside the left-controls cluster (after the
// three sacred nav buttons), so it can never overlap them.
// --- Search (absolutely centered on the window) ------------------------
Rectangle {
x: (root.width - self.width) / 2;
y: (root.height - self.height) / 2;
width: root.search-width;
// Smoothly animate the width change when the nav moves in/out of the
// header (and the x recenters with it).
animate width { duration: 220ms; easing: ease-in-out; }
height: 32px;
border-radius: 6px;
border-width: 1px;
@@ -453,6 +400,74 @@ export component HeaderBar inherits Rectangle {
clicked => { NavState.request-forward(); }
}
}
// Full section nav (text tabs) — header-nav mode (nav-in-sidebar OFF)
// while the sidebar isn't fully closed. Sits AFTER the three sacred
// buttons, so it never overlaps them. Mutually exclusive with the
// compact icons below (which only show when fully closed).
if !ShellState.nav-in-sidebar && ShellState.sidebar-state != 2: HorizontalLayout {
spacing: 2px;
Rectangle { width: 6px; }
VerticalLayout {
alignment: center;
NavTabWithMenu {
tab-index: 0;
icon: @image-url("../assets/icons/compass.svg");
label: @tr("Discover");
active: true;
show-icon: root.show-tab-icons;
home-item: 0;
items: [
{ label: @tr("Home"), has-glyph: false, route: "discover-home" },
{ label: @tr("Editor's Picks"), has-glyph: false, route: "discover-editorPicks" },
{ label: @tr("For You"), has-glyph: false, route: "discover-forYou" },
];
}
}
VerticalLayout {
alignment: center;
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, route: "favorites-tracks" },
{ label: @tr("Albums"), icon: @image-url("../assets/icons/disc.svg"), has-glyph: true, route: "favorites-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "favorites-artists" },
{ label: @tr("Playlists"), icon: @image-url("../assets/icons/list-music.svg"), has-glyph: true, route: "favorites-playlists" },
{ label: @tr("Labels"), icon: @image-url("../assets/icons/disc-3.svg"), has-glyph: true, route: "favorites-labels" },
];
}
}
VerticalLayout {
alignment: center;
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, route: "local-albums" },
{ label: @tr("Artists"), icon: @image-url("../assets/icons/user.svg"), has-glyph: true, route: "local-artists" },
{ label: @tr("Folders"), icon: @image-url("../assets/icons/folder.svg"), has-glyph: true, route: "local-folders" },
{ label: @tr("Tracks"), icon: @image-url("../assets/icons/music.svg"), has-glyph: true, route: "local-tracks" },
];
}
}
VerticalLayout {
alignment: center;
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 },
];
}
}
}
// Compact section nav — only while the sidebar is fully closed
// (Cerrado), so Discover / Library / Local Library / My QBZ stay
// reachable. Icon-only; each opens a titled downward dropdown.