fix(slint): search refinements — artist card, history, spinner, hover
- ArtistCard rebuilt 1:1 with Tauri SearchView.artist-card: 160x220 inside a surface-card container, 12px corners, 120px circular avatar with a gradient placeholder, hover swaps to surface-elevated. The Follow chip (rectangular per ADR-008) sits below the name when the user does not follow the artist. - Search loading state uses a rotating spinner (Lucide LoaderCircle on Slint transform-rotation) matching the Tauri spinner — 1s linear infinite. The search header (title + tabs + filter radios) collapses while the initial load runs so the chrome does not show through. - Search history: live debounce + submit now use nav::push_or_replace_search, so the latest query is the single Search entry in history. Back from an opened album lands on that search. - Track row backgrounds use the OR-d row-hovered (cell / fav / dl / more), so hovering the more button no longer drops the row tint. - replace_category artists arm re-applies the top-result dedupe, so filtering does not bring the duplicate back. cc
This commit is contained in:
11 files changed
+129
-43
No files matched your search
@@ -539,7 +539,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
SEARCH_DEBOUNCE.with(|t| t.stop());
|
SEARCH_DEBOUNCE.with(|t| t.stop());
|
||||||
nav::record(nav::NavEntry::Search(q.clone()));
|
nav::push_or_replace_search(q.clone());
|
||||||
navigate_search(runtime.clone(), weak.clone(), &handle, image_cache.clone(), q);
|
navigate_search(runtime.clone(), weak.clone(), &handle, image_cache.clone(), q);
|
||||||
if let Some(w) = weak.upgrade() {
|
if let Some(w) = weak.upgrade() {
|
||||||
update_nav_flags(&w);
|
update_nav_flags(&w);
|
||||||
@@ -569,6 +569,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
slint::TimerMode::SingleShot,
|
slint::TimerMode::SingleShot,
|
||||||
std::time::Duration::from_millis(300),
|
std::time::Duration::from_millis(300),
|
||||||
move || {
|
move || {
|
||||||
|
// Record (or replace) the Search history entry so
|
||||||
|
// back/forward returns to this search instead of
|
||||||
|
// skipping past it.
|
||||||
|
nav::push_or_replace_search(q.clone());
|
||||||
navigate_search(
|
navigate_search(
|
||||||
runtime.clone(),
|
runtime.clone(),
|
||||||
weak.clone(),
|
weak.clone(),
|
||||||
@@ -576,6 +580,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
image_cache.clone(),
|
image_cache.clone(),
|
||||||
q.clone(),
|
q.clone(),
|
||||||
);
|
);
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
update_nav_flags(&w);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -33,6 +33,27 @@ thread_local! {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push a Search history entry, OR replace the cursor entry in place
|
||||||
|
/// when it is already a Search. Used by the live-search debounce so
|
||||||
|
/// quick keystrokes do not push one entry per character, while still
|
||||||
|
/// keeping the page reachable via back/forward at the final query.
|
||||||
|
pub fn push_or_replace_search(query: String) {
|
||||||
|
HISTORY.with(|h| {
|
||||||
|
let h = &mut *h.borrow_mut();
|
||||||
|
match h.entries.get(h.cursor) {
|
||||||
|
Some(NavEntry::Search(_)) => {
|
||||||
|
h.entries.truncate(h.cursor + 1);
|
||||||
|
h.entries[h.cursor] = NavEntry::Search(query);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
h.entries.truncate(h.cursor + 1);
|
||||||
|
h.entries.push(NavEntry::Search(query));
|
||||||
|
h.cursor = h.entries.len() - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Record a fresh forward navigation, dropping any forward history. A
|
/// Record a fresh forward navigation, dropping any forward history. A
|
||||||
/// no-op when the destination already is the current entry, so repeated
|
/// no-op when the destination already is the current entry, so repeated
|
||||||
/// clicks on the same page do not pile up.
|
/// clicks on the same page do not pile up.
|
||||||
|
|||||||
@@ -639,7 +639,16 @@ pub fn replace_category(window: &AppWindow, more: MoreRows) {
|
|||||||
let items: Vec<SearchTrackItem> = rows.into_iter().map(track_item).collect();
|
let items: Vec<SearchTrackItem> = rows.into_iter().map(track_item).collect();
|
||||||
state.set_tracks(ModelRc::new(VecModel::from(items)));
|
state.set_tracks(ModelRc::new(VecModel::from(items)));
|
||||||
}
|
}
|
||||||
MoreRows::Artists(rows) => {
|
MoreRows::Artists(mut rows) => {
|
||||||
|
// Re-apply the top-result dedupe — if the most-popular hero
|
||||||
|
// is an artist and is the first entry in the new list, drop
|
||||||
|
// it so the carousel does not show the duplicate.
|
||||||
|
if state.get_most_popular_kind().as_str() == "artist" {
|
||||||
|
let mp_id = state.get_most_popular_artist().id;
|
||||||
|
if rows.first().map_or(false, |r| r.id == mp_id.as_str()) {
|
||||||
|
rows.remove(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
let items: Vec<SlimItem> = rows.into_iter().map(artist_item).collect();
|
let items: Vec<SlimItem> = rows.into_iter().map(artist_item).collect();
|
||||||
state.set_artists(ModelRc::new(VecModel::from(items)));
|
state.set_artists(ModelRc::new(VecModel::from(items)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ component TrackRow inherits Rectangle {
|
|||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
// The even-row tint is clearly fainter than the hover tint so the two
|
// The even-row tint is clearly fainter than the hover tint so the two
|
||||||
// never read as the same state.
|
// never read as the same state. Use row-hovered so hovering nested
|
||||||
background: ta.has-hover
|
// controls (cell / fav / dl / more) keeps the row tint.
|
||||||
|
background: root.row-hovered
|
||||||
? #ffffff16
|
? #ffffff16
|
||||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ component TrackRow inherits Rectangle {
|
|||||||
height: 52px;
|
height: 52px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
// The even-row tint is clearly fainter than the hover tint so the two
|
// The even-row tint is clearly fainter than the hover tint so the two
|
||||||
// never read as the same state.
|
// never read as the same state. Use row-hovered so hovering the cell
|
||||||
background: ta.has-hover
|
// or the more button keeps the row tint.
|
||||||
|
background: root.row-hovered
|
||||||
? #ffffff16
|
? #ffffff16
|
||||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-6.219-8.56"/></svg>
|
||||||
|
After Width: | Height: | Size: 222 B |
@@ -1,6 +1,8 @@
|
|||||||
// Artist card — a circular portrait with the artist name and, when the
|
// Artist card — ported 1:1 from the Tauri SearchView `.artist-card`:
|
||||||
// user does not already follow the artist, a Follow chip. Style ported
|
// 160x220 tile inside a surface-card container, with a 120px circular
|
||||||
// from the Tauri Discover "For You" artist tile.
|
// avatar (gradient placeholder), centered name, and an optional Follow
|
||||||
|
// chip (rectangular per ADR-008) shown only when the user does not yet
|
||||||
|
// follow the artist.
|
||||||
|
|
||||||
import { Theme } from "../foundation/semantic-colors.slint";
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
import { Typography } from "../foundation/typography.slint";
|
import { Typography } from "../foundation/typography.slint";
|
||||||
@@ -14,13 +16,12 @@ export component ArtistCard inherits Rectangle {
|
|||||||
// kind ("artist"), artist id, action ("follow").
|
// kind ("artist"), artist id, action ("follow").
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
width: 170px;
|
width: 160px;
|
||||||
height: 248px;
|
height: 220px;
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
background: ta.has-hover ? Theme.surface-elevated : transparent;
|
background: ta.has-hover ? Theme.surface-elevated : Theme.surface-card;
|
||||||
|
animate background { duration: 150ms; }
|
||||||
|
|
||||||
// Whole-card click — declared before the content so the Follow chip
|
|
||||||
// keeps its own clicks.
|
|
||||||
ta := TouchArea {
|
ta := TouchArea {
|
||||||
mouse-cursor: pointer;
|
mouse-cursor: pointer;
|
||||||
clicked => {
|
clicked => {
|
||||||
@@ -29,23 +30,26 @@ export component ArtistCard inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
padding: 12px;
|
padding: 16px;
|
||||||
spacing: 8px;
|
spacing: 12px;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
|
||||||
// Circular portrait.
|
// 120px circular avatar with a soft gradient placeholder.
|
||||||
HorizontalLayout {
|
HorizontalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: 140px;
|
width: 120px;
|
||||||
height: 140px;
|
height: 120px;
|
||||||
border-radius: 70px;
|
border-radius: 60px;
|
||||||
clip: true;
|
clip: true;
|
||||||
background: Theme.surface-elevated;
|
background: @linear-gradient(
|
||||||
|
135deg,
|
||||||
|
Theme.surface-elevated 0%,
|
||||||
|
Theme.surface-card 100%);
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
source: @image-url("../assets/icons/user.svg");
|
source: @image-url("../assets/icons/user.svg");
|
||||||
width: 44px;
|
width: 38px;
|
||||||
height: 44px;
|
height: 38px;
|
||||||
x: (parent.width - self.width) / 2;
|
x: (parent.width - self.width) / 2;
|
||||||
y: (parent.height - self.height) / 2;
|
y: (parent.height - self.height) / 2;
|
||||||
tint: Theme.text-muted;
|
tint: Theme.text-muted;
|
||||||
@@ -61,7 +65,7 @@ export component ArtistCard inherits Rectangle {
|
|||||||
|
|
||||||
Text {
|
Text {
|
||||||
text: root.artist.title;
|
text: root.artist.title;
|
||||||
width: 146px;
|
width: 128px;
|
||||||
color: Theme.text-primary;
|
color: Theme.text-primary;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: Typography.medium;
|
font-weight: Typography.medium;
|
||||||
@@ -69,28 +73,29 @@ export component ArtistCard inherits Rectangle {
|
|||||||
overflow: elide;
|
overflow: elide;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Follow chip — only while the user does not follow the artist.
|
// Rectangular Follow chip (ADR-008 — no pills). Shown only when
|
||||||
|
// the artist is not yet followed.
|
||||||
if !root.artist.following: HorizontalLayout {
|
if !root.artist.following: HorizontalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
|
spacing: 0px;
|
||||||
Rectangle {
|
Rectangle {
|
||||||
width: chip-row.preferred-width;
|
width: chip-row.preferred-width;
|
||||||
height: 30px;
|
height: 28px;
|
||||||
// Rectangular with small rounded corners — QBZ never uses
|
|
||||||
// pill-shaped chips (ADR-008).
|
|
||||||
border-radius: Radius.sm;
|
border-radius: Radius.sm;
|
||||||
background: follow-ta.has-hover
|
background: follow-ta.has-hover
|
||||||
? Theme.surface-hover
|
? Theme.surface-hover
|
||||||
: Theme.surface-elevated;
|
: Theme.surface-elevated;
|
||||||
|
animate background { duration: 150ms; }
|
||||||
chip-row := HorizontalLayout {
|
chip-row := HorizontalLayout {
|
||||||
padding-left: 14px;
|
padding-left: 12px;
|
||||||
padding-right: 14px;
|
padding-right: 12px;
|
||||||
spacing: 6px;
|
spacing: 6px;
|
||||||
VerticalLayout {
|
VerticalLayout {
|
||||||
alignment: center;
|
alignment: center;
|
||||||
QbzIcon {
|
QbzIcon {
|
||||||
source: @image-url("../assets/icons/user-plus.svg");
|
source: @image-url("../assets/icons/user-plus.svg");
|
||||||
width: 14px;
|
width: 13px;
|
||||||
height: 14px;
|
height: 13px;
|
||||||
tint: Theme.text-primary;
|
tint: Theme.text-primary;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ export component ArtistCarousel inherits VerticalLayout {
|
|||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
callback view-all-clicked();
|
callback view-all-clicked();
|
||||||
|
|
||||||
property <length> card-width: 170px;
|
property <length> card-width: 160px;
|
||||||
property <length> card-height: 248px;
|
property <length> card-height: 220px;
|
||||||
property <length> gap: 16px;
|
property <length> gap: 16px;
|
||||||
|
|
||||||
property <int> count: root.artists.length;
|
property <int> count: root.artists.length;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// Loading spinner — a rotating 3/4 arc (Lucide LoaderCircle). Matches the
|
||||||
|
// Tauri `.spinner` (32px, 1s linear infinite). Used for heavy loads where
|
||||||
|
// `LoadingDots` reads too lightweight.
|
||||||
|
|
||||||
|
import { Theme } from "../foundation/semantic-colors.slint";
|
||||||
|
|
||||||
|
export component LoadingSpinner inherits Rectangle {
|
||||||
|
in property <length> size: 32px;
|
||||||
|
in property <color> tint: Theme.accent;
|
||||||
|
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
|
||||||
|
Image {
|
||||||
|
width: root.size;
|
||||||
|
height: root.size;
|
||||||
|
source: @image-url("../assets/icons/loader-circle.svg");
|
||||||
|
colorize: root.tint;
|
||||||
|
// Linear 1s/full-turn spin driven by animation-tick (360deg / 1000ms).
|
||||||
|
transform-rotation: ((animation-tick() / 1ms) * 0.36) * 1deg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import { PlaylistCard } from "../discover/PlaylistCard.slint";
|
|||||||
import { SearchTrackRow } from "SearchTrackRow.slint";
|
import { SearchTrackRow } from "SearchTrackRow.slint";
|
||||||
import { NavButtons } from "../shell/NavButtons.slint";
|
import { NavButtons } from "../shell/NavButtons.slint";
|
||||||
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
||||||
import { LoadingDots } from "../primitives/LoadingDots.slint";
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
||||||
|
|
||||||
// One tab in the results tab strip.
|
// One tab in the results tab strip.
|
||||||
component SearchTab inherits Rectangle {
|
component SearchTab inherits Rectangle {
|
||||||
@@ -211,8 +211,8 @@ component ArtistGrid inherits Rectangle {
|
|||||||
callback open(string);
|
callback open(string);
|
||||||
callback media-action(string, string, string);
|
callback media-action(string, string, string);
|
||||||
|
|
||||||
property <length> card-w: 170px;
|
property <length> card-w: 160px;
|
||||||
property <length> card-h: 248px;
|
property <length> card-h: 220px;
|
||||||
property <length> gap: 16px;
|
property <length> gap: 16px;
|
||||||
property <int> columns:
|
property <int> columns:
|
||||||
Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap)));
|
Math.max(1, Math.floor((self.width + root.gap) / (root.card-w + root.gap)));
|
||||||
@@ -296,7 +296,9 @@ export component SearchResultsView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// --- Fixed header: title, tabs + filter radios ------------------
|
// --- Fixed header: title, tabs + filter radios ------------------
|
||||||
VerticalLayout {
|
// Collapsed during the initial load so the empty title + tabs do
|
||||||
|
// not show through behind the loading state.
|
||||||
|
if !(SearchState.loading && !root.has-results): VerticalLayout {
|
||||||
padding-left: 32px;
|
padding-left: 32px;
|
||||||
padding-right: 32px;
|
padding-right: 32px;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
@@ -584,9 +586,24 @@ export component SearchResultsView inherits Rectangle {
|
|||||||
|
|
||||||
}
|
}
|
||||||
// ---- Loading + empty states -------------------------------
|
// ---- Loading + empty states -------------------------------
|
||||||
if SearchState.loading && !root.has-results: LoadingDots {
|
if SearchState.loading && !root.has-results: Rectangle {
|
||||||
label: @tr("Searching…");
|
height: 320px;
|
||||||
height: 140px;
|
VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
spacing: 18px;
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
LoadingSpinner {
|
||||||
|
size: 36px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: @tr("Searching…");
|
||||||
|
color: Theme.text-muted;
|
||||||
|
font-size: 13px;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if !SearchState.loading && !root.has-results: Text {
|
if !SearchState.loading && !root.has-results: Text {
|
||||||
text: @tr("No results.");
|
text: @tr("No results.");
|
||||||
|
|||||||
@@ -28,7 +28,9 @@ export component SearchTrackRow inherits Rectangle {
|
|||||||
|
|
||||||
height: 56px;
|
height: 56px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: ta.has-hover
|
// Use the OR'd row-hovered so hovering nested controls (cell / more)
|
||||||
|
// does not break the row tint.
|
||||||
|
background: root.row-hovered
|
||||||
? #ffffff16
|
? #ffffff16
|
||||||
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user