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
277 lines
11 KiB
Plaintext
277 lines
11 KiB
Plaintext
// JUMP TO bar — reusable sticky tab bar with an inline search input.
|
|
//
|
|
// Tauri shows this above each section-rich page (artist, library,
|
|
// future label / musician views). The bar is normally tucked between
|
|
// the page header and the body and pins to the top of its scroll
|
|
// container once the user scrolls past the header. Per-tab click
|
|
// callbacks let the parent decide what each tab scrolls to; the
|
|
// search icon expands an inline search input that sits in the tab
|
|
// row itself (Tauri's `.page-search.open` behaviour), animated with
|
|
// a width transition so the tabs visually slide behind it.
|
|
//
|
|
// The component is presentation-only — sticky positioning is the
|
|
// parent's responsibility. Reuse: drop the bar inside any layout,
|
|
// pass `tabs` + the active id, wire `tab-clicked`, `search-clicked`
|
|
// and `search-text-edited` to the page-side handlers.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
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 {
|
|
in property <[JumpNavTab]> tabs;
|
|
in property <string> active-tab-id;
|
|
in-out property <bool> search-open: false;
|
|
in-out property <string> search-text;
|
|
|
|
// 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);
|
|
|
|
height: 44px;
|
|
background: Theme.surface-main;
|
|
border-width: 0px;
|
|
|
|
// Bottom border so the bar reads as a separate strip when
|
|
// sticky over the body content.
|
|
Rectangle {
|
|
x: 0;
|
|
y: parent.height - 1px;
|
|
width: parent.width;
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
HorizontalLayout {
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
spacing: 18px;
|
|
alignment: stretch;
|
|
|
|
// JUMP TO label
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 0;
|
|
Text {
|
|
text: @tr("JUMP TO");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 0.5px;
|
|
}
|
|
}
|
|
|
|
// Center region — tabs AND the animated search input share
|
|
// this slot. The search input grows from 0 width on the
|
|
// right edge, covering the tabs as it expands.
|
|
center-region := Rectangle {
|
|
horizontal-stretch: 1;
|
|
clip: true;
|
|
|
|
// Tabs — laid out as soon as the parent has size; the
|
|
// search input draws on top of them when open.
|
|
HorizontalLayout {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: parent.height;
|
|
spacing: 14px;
|
|
alignment: start;
|
|
for tab in root.tabs: VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
height: 32px;
|
|
width: tab-text.preferred-width + 4px;
|
|
tab-text := Text {
|
|
x: 2px;
|
|
text: tab.label;
|
|
color: tab.id == root.active-tab-id
|
|
? Theme.text-primary
|
|
: (tab-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-secondary);
|
|
font-size: Typography.body;
|
|
font-weight: tab.id == root.active-tab-id
|
|
? Typography.semibold
|
|
: Typography.regular;
|
|
vertical-alignment: center;
|
|
height: 100%;
|
|
}
|
|
Rectangle {
|
|
x: 0;
|
|
y: parent.height - 2px;
|
|
width: parent.width;
|
|
height: 2px;
|
|
background: Theme.accent;
|
|
visible: tab.id == root.active-tab-id;
|
|
}
|
|
tab-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
enabled: !root.search-open;
|
|
clicked => {
|
|
root.tab-clicked(tab.id, tab.anchor-y);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Animated search input — slides in from the right when
|
|
// search-open flips to true and shrinks back to 0 width
|
|
// when it goes false. surface-main background fully
|
|
// hides the tabs while expanded.
|
|
search-overlay := Rectangle {
|
|
// 216px (= 60% of the previous 360 cap) keeps the
|
|
// open input as a compact box pinned to the right
|
|
// side of the bar — wide enough to read a few words
|
|
// of query, narrow enough not to swallow the tabs
|
|
// visually. min() guards narrow viewports.
|
|
property <length> max-open-width: 216px;
|
|
x: parent.width - self.width;
|
|
y: 4px;
|
|
width: root.search-open
|
|
? min(parent.width, self.max-open-width)
|
|
: 0px;
|
|
height: parent.height - 8px;
|
|
background: Theme.surface-card;
|
|
border-radius: 6px;
|
|
border-width: 1px;
|
|
border-color: search-input.has-focus
|
|
? Theme.border-subtle
|
|
: Theme.surface-card;
|
|
clip: true;
|
|
animate width { duration: 200ms; easing: ease-in-out; }
|
|
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 6px;
|
|
spacing: 8px;
|
|
// Leading magnifier glyph.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 0;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
// Input + placeholder share a stretched cell.
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
search-input := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
text <=> root.search-text;
|
|
edited => {
|
|
root.search-text-edited(self.text);
|
|
}
|
|
}
|
|
Text {
|
|
visible: search-input.text == "";
|
|
width: 100%;
|
|
height: 100%;
|
|
text: @tr("Search this page…");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
// Trailing X — clears the query and closes the
|
|
// input. Lives INSIDE the input so the close
|
|
// affordance reads as part of the search widget
|
|
// (not a stray button hanging off the side of
|
|
// the bar).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 0;
|
|
Rectangle {
|
|
width: 24px;
|
|
height: 24px;
|
|
border-radius: 4px;
|
|
background: clear-ta.has-hover
|
|
? Theme.surface-elevated
|
|
: transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/x.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: clear-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-muted;
|
|
}
|
|
clear-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.search-clicked();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: search-ta.has-hover
|
|
? Theme.surface-elevated
|
|
: transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: search-ta.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-muted;
|
|
}
|
|
search-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.search-clicked();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|