feat(slint): A-Z jump strip for favorites tracks + albums grouping

Add an AlphaStrip overlaid on the right edge when grouping
alphabetically: tracks "Group: Name" (jumps the virtualized ListView to
the first row of a letter — accurate, uniform 50px rows) and albums
"Group: A-Z" (jumps the grid Flickable proportionally by section ordinal
— approximate, always lands the section in view, per the agreed
ArtistView-style behavior). derive_tracks/derive_albums build the
per-letter jump model (tracks-alpha / albums-alpha).

cc
This commit is contained in:
vicrodh committed 2026-05-27 10:38:34 -06:00
1 parent 4465a36716
commit dbd6d2e4b5
3 files changed
+97 -1

No files matched your search

+31 -1
View File
@@ -21,7 +21,7 @@ use slint::{ComponentHandle, Model, ModelRc, VecModel};
use crate::album_map::{self, map_album, to_item, AlbumCard};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{
AlbumCardItem, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
AlbumCardItem, AlphaJump, AppWindow, DiscoverSection, FavoriteArtistItem, FavoriteLabelItem,
FavoritePlaylistItem, FavoritesState, TrackItem,
};
@@ -512,6 +512,7 @@ pub fn derive_tracks(window: &AppWindow) {
let group = state.get_tracks_group_mode().to_string();
let genre_names = crate::genre_filter::selected_names("favorites");
let all = state.get_tracks();
state.set_tracks_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
// Fast path: no search + no grouping + no genre filter -> share model.
if query.is_empty() && group == "off" && genre_names.is_empty() {
state.set_tracks_visible(all);
@@ -544,6 +545,22 @@ pub fn derive_tracks(window: &AppWindow) {
"name" => filtered.sort_by(|a, b| lc(&a.title).cmp(&lc(&b.title))),
_ => {}
}
// A-Z jump strip for name grouping: first row index per distinct initial.
if group == "name" {
let mut jumps: Vec<AlphaJump> = Vec::new();
let mut last = String::new();
for (i, t) in filtered.iter().enumerate() {
let key = album_alpha_key(t.title.as_str());
if key != last {
jumps.push(AlphaJump {
letter: key.clone().into(),
index: i as i32,
});
last = key;
}
}
state.set_tracks_alpha(ModelRc::new(VecModel::from(jumps)));
}
state.set_tracks_visible(ModelRc::new(VecModel::from(filtered)));
}
@@ -583,6 +600,7 @@ pub fn derive_albums(window: &AppWindow) {
let group = state.get_albums_group_mode().to_string();
let genre_names = crate::genre_filter::selected_names("favorites");
let all = state.get_albums();
state.set_albums_alpha(ModelRc::new(VecModel::from(Vec::<AlphaJump>::new())));
let empty_sections = || ModelRc::new(VecModel::from(Vec::<DiscoverSection>::new()));
// Fast path: no filter, default order, no grouping, no genre -> share.
@@ -638,6 +656,18 @@ pub fn derive_albums(window: &AppWindow) {
(false, true) => std::cmp::Ordering::Greater,
_ => a.to_lowercase().cmp(&b.to_lowercase()),
});
// A-Z jump strip for alpha grouping: the section letters in order.
if group == "alpha" {
let alpha: Vec<AlphaJump> = map
.iter()
.enumerate()
.map(|(i, (k, _))| AlphaJump {
letter: k.clone().into(),
index: i as i32,
})
.collect();
state.set_albums_alpha(ModelRc::new(VecModel::from(alpha)));
}
let sections: Vec<DiscoverSection> = map
.into_iter()
.map(|(key, items)| DiscoverSection {
@@ -19,6 +19,7 @@ import {
DragState,
GenreFilterState,
GenreFilterActions,
AlphaJump,
} from "../state.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint";
@@ -366,6 +367,37 @@ component ViewToggle inherits Rectangle {
}
}
// Vertical A-Z jump strip overlaid on the right edge of a grouped list /
// grid. Emits jump(ordinal, index); the host scrolls its list/flickable.
component AlphaStrip inherits Rectangle {
in property <[AlphaJump]> jumps;
callback jump(int /* ordinal */, int /* index */);
width: 18px;
VerticalLayout {
alignment: center;
spacing: 0px;
for j[k] in root.jumps: Rectangle {
height: 15px;
width: 18px;
Text {
text: j.letter;
color: la.has-hover ? Theme.accent : Theme.text-muted;
font-size: 9px;
font-weight: Typography.semibold;
horizontal-alignment: center;
vertical-alignment: center;
}
la := TouchArea {
mouse-cursor: pointer;
clicked => {
root.jump(k, j.index);
}
}
}
}
}
export component FavoritesView inherits Rectangle {
callback open-album(string);
callback open-artist(string);
@@ -740,6 +772,18 @@ export component FavoritesView inherits Rectangle {
visible-height: track-list.visible-height;
viewport-y <=> track-list.viewport-y;
}
// A-Z jump strip (name grouping) — accurate (uniform rows).
if FavoritesState.tracks-group-mode == "name"
&& FavoritesState.tracks-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 20px;
height: 100%;
jumps: FavoritesState.tracks-alpha;
jump(ord, idx) => {
track-list.viewport-y = -Math.min(
idx * 50px / 1px,
Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px;
}
}
}
}
@@ -866,6 +910,18 @@ export component FavoritesView inherits Rectangle {
visible-height: other-flick.height;
viewport-y <=> other-flick.viewport-y;
}
// A-Z jump strip (albums alpha grouping) — proportional jump.
if FavoritesState.active-tab == "albums"
&& FavoritesState.albums-group-mode == "alpha"
&& FavoritesState.albums-alpha.length > 0: AlphaStrip {
x: parent.width - self.width - 20px;
height: 100%;
jumps: FavoritesState.albums-alpha;
jump(ord, idx) => {
other-flick.viewport-y = -(ord / Math.max(1, FavoritesState.albums-alpha.length - 1))
* Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px;
}
}
}
}
}
+10
View File
@@ -29,6 +29,14 @@ export struct AlbumCardItem {
plain-year: string, // "1973" — bare year for the list-row YEAR column
}
// One entry in an A-Z jump strip (favorites tracks name-group / albums
// A-Z group). `index` is the first row index (tracks); albums jump
// proportionally by the entry's ordinal so it needs no exact offset.
export struct AlphaJump {
letter: string,
index: int,
}
// One button in the shared multi-select bulk bar (MultiSelectBar).
export struct BulkAction {
id: string, // emitted verbatim via MultiSelectBar.action(id)
@@ -1463,6 +1471,7 @@ export global FavoritesState {
in property <[TrackItem]> tracks-visible: [];
in-out property <string> tracks-search: "";
in-out property <string> tracks-group-mode: "off"; // off|album|artist|name
in property <[AlphaJump]> tracks-alpha: []; // A-Z jumps (name grouping)
in-out property <bool> tracks-multi-select: false;
in property <int> tracks-selected-count: 0;
in property <[AlbumCardItem]> albums: [];
@@ -1472,6 +1481,7 @@ export global FavoritesState {
in-out property <string> albums-search: "";
in-out property <string> albums-sort-by: "default"; // default|title-asc|title-desc|artist-asc
in-out property <string> albums-group-mode: "off"; // off|alpha|artist
in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha grouping)
in property <int> albums-shown: 0; // count after filter (grouped or flat)
in property <[FavoriteArtistItem]> artists: [];
in property <[FavoritePlaylistItem]> playlists: [];