feat(slint): jump-nav scroll-to-section + hide empty tabs + UX fixes

Brings the JUMP TO bar from a visual placeholder to a working nav,
plus two interaction fixes the user flagged on the previous build.

JUMP TO bar — functional scroll-to:
  - JumpNavTab gains an `anchor-y: length` field. JumpNavBar's
    tab-clicked callback now emits (id, anchor-y); the parent
    (ArtistPageView) scrolls page-flickable to land the section
    just below the sticky bar.
  - Tab list is built in Rust (`build_jump_tabs` in artist.rs)
    from the artist's actual data and pushed to a new
    `ArtistState.jump-tabs` model. Tabs are emitted only for
    sections that exist (top tracks present, "Discography" /
    "EPs & Singles" / "Live Albums" / "Compilations" / "Others"
    only when the corresponding release_sections entry is there);
    the Playlists tab — not implemented yet — never appears.
  - anchor-y estimates use layout-derived constants per section
    type (popular-tracks header + visible row count, release grid
    headers + ceil(album_count / 5) rows). Not pixel-perfect for
    long bios or grids that wrap differently, but lands the
    viewport inside the right section.
  - Removed the hardcoded ArtistPageView property — tabs and
    visibility are now data-driven.

Sticky positioning — dynamic:
  - jump-nav.natural-top now binds to
    `body-row.absolute-position.y - page-flickable.absolute-y -
    self.height`, so the bar always sits flush in the gap above
    the body row regardless of artist-header height (long-bio
    artists stop pushing the row out from under the bar).
  - Sidebar natural-top follows the same body-row anchor, so it
    too tracks where the body actually starts.

Search input UX:
  - The outer search-icon slot is now removed from layout (via
    `if !search-open`, not `visible:`) when the input opens, so
    the expanded input runs all the way to the bar's right
    padding — right under the user's cursor where the lupa just
    was, no leftover gap on the right.
  - `changed search-open` auto-focuses the inline TextInput, so
    typing starts immediately after clicking the lupa with no
    second click in the input.

cc
This commit is contained in:
vicrodh committed 2026-05-20 14:20:47 -06:00
1 parent 3429fe4ee2
commit 06a763443a
4 files changed
+146 -38

No files matched your search

+32 -27
View File
@@ -279,17 +279,6 @@ export component ArtistPageView inherits Rectangle {
// future pass will compute it from scroll position so the bar
// self-tracks the visible section.
property <string> active-jump-tab: "popular-tracks";
// Static tab list — same labels Tauri uses. The Slint tabs trigger
// scroll-to-section via tab-clicked; until the section anchors are
// wired, the click just updates the active state.
property <[JumpNavTab]> jump-tabs: [
{ id: "about", label: "About" },
{ id: "popular-tracks", label: "Popular Tracks" },
{ id: "discography", label: "Discography" },
{ id: "eps-singles", label: "EPs & Singles" },
{ id: "compilations", label: "Compilations" },
{ id: "playlists", label: "Playlists" },
];
// Width handed down to the bio paragraph. The artist header sits
// above the body row where the sidebar lives, so the bio never
// needs to shrink for the sidebar — it always uses the full
@@ -495,16 +484,22 @@ export component ArtistPageView inherits Rectangle {
// release grids) flanked by an empty 300px reservation when
// the network sidebar is open, so the actual sidebar
// overlay (rendered below) never covers the page content.
HorizontalLayout {
// jump-nav.natural-top binds to `body-row.absolute-position.y
// - 44px`, so the bar always sits flush in the gap above
// this row regardless of how tall the artist header above
// it grew (long bios push the body row down with no manual
// tuning).
body-row := HorizontalLayout {
spacing: 0px;
main-col := VerticalLayout {
horizontal-stretch: 1;
spacing: 0px;
// Reservation slot for the JUMP TO bar — the bar is an
// absolute-positioned overlay, so the body column has to
// skip its 44px (plus a small visual gap above and
// below) for content to land below it, not behind it.
// absolute-positioned overlay (sticky-positioned above
// this row), so the body column has to skip its 44px
// height plus a small visual gap for content to land
// below it rather than behind it.
Rectangle { height: 52px; }
if ArtistState.loading && ArtistState.top-tracks.length == 0: Rectangle {
@@ -689,20 +684,29 @@ export component ArtistPageView inherits Rectangle {
// y is the row just below the artist header in the page layout
// (NavButtons + spacer + artist header + small gap, ~290px).
jump-nav := JumpNavBar {
// Sits in the 52px reservation slot at the top of the body
// column (artist header + 22 spacer + 4 gap = 291). Sticks
// to viewport top once the user scrolls past the header.
property <length> natural-top: 291px;
// Dynamic anchor — sits 44px above the body row, regardless
// of how tall the artist header grew. As the user scrolls
// past the header, the bar pins to viewport top
// (sticky-top = 0).
property <length> natural-top:
body-row.absolute-position.y
- page-flickable.absolute-position.y
- self.height;
property <length> sticky-top: 0px;
x: 0;
y: max(self.natural-top + page-flickable.viewport-y, self.sticky-top);
width: root.width;
tabs: root.jump-tabs;
tabs: ArtistState.jump-tabs;
active-tab-id: root.active-jump-tab;
search-open: root.search-visible;
tab-clicked(id) => {
tab-clicked(id, anchor-y) => {
root.active-jump-tab = id;
// Scroll the page so the target section lands right
// below the sticky JUMP TO bar. anchor-y is the
// target's page-local y (content-coords); subtract
// self.height to put the section just below the bar.
page-flickable.viewport-y = -(anchor-y - self.height);
}
search-clicked => {
root.search-visible = !root.search-visible;
@@ -723,12 +727,13 @@ export component ArtistPageView inherits Rectangle {
// bottom edge is always flush with the now-playing bar
// (height = root.height - y), so no gap appears when sticky.
if NetworkSidebarState.open: sidebar-panel := Rectangle {
// 335px = jump-nav natural-top (291) + jump-nav height
// (44). Starts the sidebar flush with the bottom edge of
// the JUMP TO bar, never sliding behind it. As the user
// scrolls down, y tracks page-flickable.viewport-y until
// it hits sticky-top.
property <length> natural-top: 335px;
// Anchored to the body row's actual top, so the sidebar
// always starts flush with the bottom of the JUMP TO bar
// (which itself sits 44px above the body row). Matches
// long-bio artist pages where the header pushes
// everything further down.
property <length> natural-top:
body-row.absolute-position.y - page-flickable.absolute-position.y;
// 44px parks the sidebar just below the sticky JUMP TO
// bar (jump-nav's height) once the header scrolls away.
property <length> sticky-top: 44px;
@@ -21,6 +21,11 @@ import { QbzIcon } from "QbzIcon.slint";
export struct JumpNavTab {
id: string,
label: string,
// Page-local y the tab scrolls to when clicked. Measured from the
// top of the Flickable content (not the viewport), so the parent
// can set `viewport-y = -(anchor-y - sticky-bar-height)` to land
// the section just below the sticky bar.
anchor-y: length,
}
export component JumpNavBar inherits Rectangle {
@@ -28,7 +33,17 @@ export component JumpNavBar inherits Rectangle {
in property <string> active-tab-id;
in-out property <bool> search-open: false;
in-out property <string> search-text;
callback tab-clicked(string /* tab-id */);
// When the input opens, focus it so the caret lands inside the
// text field without an extra click. Without this the user has
// to move the cursor from the search button into the input
// before typing.
changed search-open => {
if (root.search-open) {
search-input.focus();
}
}
callback tab-clicked(string /* tab-id */, length /* anchor-y */);
callback search-clicked();
callback search-text-edited(string);
@@ -113,7 +128,7 @@ export component JumpNavBar inherits Rectangle {
mouse-cursor: pointer;
enabled: !root.search-open;
clicked => {
root.tab-clicked(tab.id);
root.tab-clicked(tab.id, tab.anchor-y);
}
}
}
@@ -223,14 +238,15 @@ export component JumpNavBar inherits Rectangle {
}
}
// Outer search toggle — only visible when the input is
// closed. Opening it expands the inline input above (which
// carries its own X close button), so the bar never shows
// two competing search affordances.
VerticalLayout {
// Outer search toggle — only present when the input is
// closed. `if` (not `visible:`) so the slot is reclaimed by
// the center region when the input opens, letting the
// expanded input run all the way to the bar's right padding
// (right where the user just clicked the lupa). The inline
// input carries its own X close button.
if !root.search-open: VerticalLayout {
alignment: center;
horizontal-stretch: 0;
visible: !root.search-open;
Rectangle {
width: 32px;
height: 32px;
@@ -250,7 +266,6 @@ export component JumpNavBar inherits Rectangle {
}
search-ta := TouchArea {
mouse-cursor: pointer;
enabled: !root.search-open;
clicked => {
root.search-clicked();
}
+8
View File
@@ -3,6 +3,9 @@
// The Rust side builds these models on a worker thread, then sets them
// on the global from the Slint event loop (see src/home.rs, src/main.rs).
import { JumpNavTab } from "primitives/JumpNavBar.slint";
export { JumpNavTab }
export struct AlbumCardItem {
id: string,
title: string,
@@ -231,6 +234,11 @@ export global ArtistState {
// Similar artists from the /artist/page response — feed the Similar
// Artists section of the network sidebar.
in property <[SimilarEntry]> similar-artists: [];
// Filtered JUMP TO tabs for this artist — only the sections
// that actually have content. Built in artist::apply_artist
// from the page response. anchor-y is the page-local y the
// ArtistPageView will scroll to when the tab is clicked.
in property <[JumpNavTab]> jump-tabs: [];
in property <bool> loading: false;
}