Add a native Slint front-end for the music player

The React UI is sluggish on the kiosk and the reasons are browser-shaped: a compositor
deciding what gets its own layer, a requestAnimationFrame loop repainting the viewport,
and a JPEG decode per album card per cold start. The last three commits chased that
through the service worker and the cover pipeline. This is the other direction - the
same backend, the same layout, the same German copy, without a browser.

Browse and play only. The typing game, the room-lights page, parent mode and the
IR-remote assignment stay web-only, and nothing here touches python-backend.

Four things carry the performance claim, in rough order of how much they should matter:

Covers are downscaled once, ever. The backend serves one size - 640px on the long edge,
no ?size= - and a grid card is 180. src/covers.rs fetches each cover once on a worker
thread, resizes it, and writes a JPEG thumbnail to ~/.cache/musicmouse-slint/covers/.
Two tiers and only two, which is what makes the cache worth keeping: a per-widget pixel
size would give each album a dozen near-identical files and a fresh decode for each. The
cache is dropped when the websocket announces a rescan, because that is the one moment
the backend rewrites the art behind an unchanged cover URL - the trap sw.js had to learn
about the hard way.

The grid is virtualized. Rust hands the UI the album list pre-chunked into rows and the
view puts those in a ListView, which instantiates only what is on screen. A flat list of
660 cards gives it no rows to skip, hence the chunking. Same purpose as
content-visibility: auto on .grid > .card.

The progress bar animates between the 2 Hz pushes rather than running a clock, so
interpolation costs a property evaluation per frame on the render side and there is no
equivalent of usePlaybackClock. It is suppressed for the frame a track changes on, so a
new track jumps instead of sliding across two unrelated positions.

Nothing on screen animates by itself. The ambient canvas is not ported, for the reason
lib/lowPower.ts already gives: a loop repainting the viewport is a floor you cannot get
under while it runs at all.

The device must not use FemtoVG. On Mesa V3D it draws every runtime-loaded Image as
solid black (slint-ui/slint#11785, open), which here means every album cover; Skia and
the software renderer are unaffected. So `kiosk` is linuxkms + Skia and FemtoVG stays
the default only for desktop development. Both profiles are verified to build; the kiosk
binary links libinput/libgbm/libdrm and no X11 or Wayland at all.

lib/search.ts, lib/keyboard.ts and lib/format.ts were already pure functions with their
own tests, so they port across as pure Rust with theirs: 43 tests, no window required.
The .slint files are layout only - nothing in them formats a number or picks a word.

Verified against the real 660-album library rather than a fixture. tools/headless-shots.sh
renders the UI inside a nested headless compositor and grabs a frame per screen, which
is how that was checked on a machine whose session was locked; a Slint window needs a
real compositor, and under bare Xvfb nothing maps at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-20 22:26:09 +02:00
parent 83d16e0058
commit 6ccb3e458f
21 changed files with 11270 additions and 0 deletions

View File

@@ -0,0 +1,833 @@
//! What you are looking at, and what a keypress does to it.
//!
//! Ported from `web/src/lib/keyboard.ts`, which is already a pure function from a
//! keypress to a list of actions - the single most portable thing in that codebase, and
//! tested there without a DOM for the same reason it is tested here without a window.
//!
//! The split this rests on: **what is playing** comes from the backend, **what you are
//! looking at** lives here. A real player has other front-ends - the buttons on the
//! mouse, a figurine on the reader, Home Assistant - so this UI follows playback rather
//! than owning it, and owns only the browse state.
//!
//! Not ported: the typing game, the room-lights page, the in-app help overlay and the
//! IR-remote assign mode, none of which this front-end hosts.
use crate::library::{Group, Results, GROUPS};
pub const VOLUME_STEP: i32 = 10;
pub const SEEK_STEP: f64 = 15.0;
/// How far Ctrl+d/Ctrl+u jump - vim's half-page scroll, applied to rows of cards.
pub const PAGE_ROWS: i32 = 3;
#[derive(Debug, Clone, PartialEq)]
pub struct UiState {
pub search: String,
pub tracks_mode: bool,
/// `None` is the bare root screen (three shelves); otherwise which one is open.
pub group: Option<Group>,
pub category: Option<String>,
pub sel_index: usize,
/// Which of the three root shelves is focused. Only meaningful at the bare root,
/// where `sel_index` doubles as "which tile in that row".
pub shelf_row: usize,
pub play_view: bool,
pub open_album: Option<String>,
/// Which track is highlighted in the open album's list.
pub sheet_index: usize,
/// Grid columns, recomputed from the window width - `move_selection` needs it to
/// know what one row down means.
pub cols: usize,
}
impl Default for UiState {
fn default() -> Self {
Self {
search: String::new(),
tracks_mode: false,
group: None,
category: None,
sel_index: 0,
shelf_row: 0,
play_view: false,
open_album: None,
sheet_index: 0,
cols: 4,
}
}
}
impl UiState {
/// The true root: nothing chosen yet, drawn as three shelves rather than a list.
pub fn is_root_shelf(&self) -> bool {
self.group.is_none()
&& self.search.is_empty()
&& !self.tracks_mode
&& self.category.is_none()
}
pub fn browsing(&self) -> bool {
!self.play_view && self.open_album.is_none()
}
/// One step of "back": search, then track-search mode, then group and category
/// together - the order ESC and the corner button both use.
///
/// `group` and `category` clear as one step because a root shelf tile sets both at
/// once, jumping straight to one category's albums; undoing that jump should be one
/// step too, however the category was reached.
pub fn browse_back(&mut self) {
self.sel_index = 0;
if !self.search.is_empty() {
self.search.clear();
} else if self.tracks_mode {
self.tracks_mode = false;
} else {
self.group = None;
self.category = None;
}
}
/// Whether anything is left to back out of - what the corner button's presence is.
pub fn can_go_back(&self) -> bool {
self.play_view
|| self.open_album.is_some()
|| !self.search.is_empty()
|| self.tracks_mode
|| self.group.is_some()
|| self.category.is_some()
}
}
/// What a keypress asks the *player* to do. Everything else a key can do is a change to
/// [`UiState`], applied in place.
#[derive(Debug, Clone, PartialEq)]
pub enum Action {
Play {
album_id: String,
track_index: usize,
},
Toggle,
Next,
Previous,
/// Percentage points, positive or negative.
Volume(i32),
/// Seconds, positive or negative.
Seek(f64),
Mute,
}
/// Where the flat selection index is pointing. Songs, then categories, then albums.
enum Selection {
Song {
album_id: String,
track_index: usize,
},
Category(String),
Album(String),
None,
}
fn selection_at(results: &Results, sel_index: usize) -> Selection {
let total = results.total();
if total == 0 {
return Selection::None;
}
let index = sel_index.min(total - 1);
if let Some(hit) = results.songs.get(index) {
return Selection::Song {
album_id: hit.album.id.clone(),
track_index: hit.index,
};
}
let after_songs = index - results.songs.len();
if let Some(category) = results.categories.get(after_songs) {
return Selection::Category(category.key.clone());
}
match results.albums.get(after_songs - results.categories.len()) {
Some(album) => Selection::Album(album.id.clone()),
None => Selection::None,
}
}
/// The album behind the selection, when "show me the tracks" makes sense for it: an
/// album tile directly, or - so you can reach the rest of it - the album behind a song
/// hit. `None` for a category tile, and for a podcast episode, which is a single track
/// with nothing else to show.
pub fn selection_album(results: &Results, sel_index: usize) -> Option<String> {
let total = results.total();
if total == 0 {
return None;
}
let index = sel_index.min(total - 1);
let album = if let Some(hit) = results.songs.get(index) {
Some(&hit.album)
} else {
let after_songs = index - results.songs.len();
if after_songs < results.categories.len() {
return None;
}
results.albums.get(after_songs - results.categories.len())
}?;
(Group::of(album) != Group::Podcasts).then(|| album.id.clone())
}
/// The bare root is a genuine two-axis layout: three rows, each a differently-long row
/// of tiles. `shelf_lengths` is how long each row actually is, so a vertical move can
/// clamp the column to the row it lands on rather than guessing.
fn move_root_shelf(state: &mut UiState, dx: i32, dy: i32, shelf_lengths: &[usize; 3]) {
if dy != 0 {
state.shelf_row = (state.shelf_row as i32 + dy).clamp(0, GROUPS.len() as i32 - 1) as usize;
state.sel_index = state
.sel_index
.min(shelf_lengths[state.shelf_row].saturating_sub(1));
}
if dx != 0 {
let last = shelf_lengths[state.shelf_row].saturating_sub(1) as i32;
state.sel_index = (state.sel_index as i32 + dx).clamp(0, last.max(0)) as usize;
}
}
fn move_selection(state: &mut UiState, results: &Results, dx: i32, dy: i32) {
let total = results.total();
if total == 0 {
return;
}
let cols = state.cols.max(1) as i32;
let mut index = state.sel_index.min(total - 1) as i32;
index += dx;
if dy != 0 {
// Song hits are a single-column list; everything below them is a grid.
index += if (index as usize) < results.songs.len() {
dy
} else {
dy * cols
};
}
state.sel_index = index.clamp(0, total as i32 - 1) as usize;
}
/// The keys that mean the same thing from anywhere, matching the muscle memory of every
/// other vim-ish media app. Keyed on the exact shifted letter, so this intercepts only
/// these five - every other Shift+letter still reaches the search box. Search is
/// case-insensitive, so nothing searchable is lost.
fn shift_media(key: &str) -> Option<Action> {
match key.to_ascii_uppercase().as_str() {
"H" => Some(Action::Previous),
"L" => Some(Action::Next),
"K" => Some(Action::Volume(VOLUME_STEP)),
"J" => Some(Action::Volume(-VOLUME_STEP)),
"M" => Some(Action::Mute),
_ => None,
}
}
/// A plain character key's default: type it into the search box. Letters and digits
/// only - `char::is_alphanumeric` rather than `[a-z0-9]`, so the umlauts a German title
/// needs are searchable too.
fn type_into_search(state: &mut UiState, key: &str) -> bool {
let mut chars = key.chars();
match (chars.next(), chars.next()) {
(Some(c), None) if c.is_alphanumeric() => {
state.search.push(c);
state.play_view = false;
state.sel_index = 0;
true
}
_ => false,
}
}
/// Translate one keypress: mutate `state`, and return whatever the player should be
/// asked to do.
///
/// `key` is already named by the UI layer - `"escape"`, `"left"`, `"enter"`, … - or is
/// the literal character typed. `shelf_lengths` and `sheet_tracks` are the two counts
/// that `results` does not carry but the clamping needs.
pub fn handle_key(
key: &str,
ctrl: bool,
shift: bool,
state: &mut UiState,
results: &Results,
shelf_lengths: &[usize; 3],
sheet_tracks: usize,
) -> Vec<Action> {
// Reachable from anywhere, including from the play view and with an album sheet open.
if shift && !ctrl {
if let Some(action) = shift_media(key) {
return vec![action];
}
}
if key == "space" {
// Except mid-search, where a space is punctuation the query needs, as in
// "geolino azte".
if state.browsing() && !state.search.is_empty() {
state.search.push(' ');
state.sel_index = 0;
return vec![];
}
return vec![Action::Toggle];
}
// Ctrl+hjkl moves the highlight in whichever list is on screen, the same job the
// arrows do but without leaving the home row. Ctrl+d/u are vim's half-page jump.
if ctrl {
if state.open_album.is_some() {
match key.to_ascii_lowercase().as_str() {
"j" => {
state.sheet_index = (state.sheet_index + 1).min(sheet_tracks.saturating_sub(1))
}
"k" => state.sheet_index = state.sheet_index.saturating_sub(1),
_ => {}
}
return vec![];
}
if state.play_view {
return vec![];
}
let lower = key.to_ascii_lowercase();
if state.is_root_shelf() {
match lower.as_str() {
"h" => move_root_shelf(state, -1, 0, shelf_lengths),
"l" => move_root_shelf(state, 1, 0, shelf_lengths),
"j" | "d" => move_root_shelf(state, 0, 1, shelf_lengths),
"k" | "u" => move_root_shelf(state, 0, -1, shelf_lengths),
_ => {}
}
return vec![];
}
match lower.as_str() {
"h" => move_selection(state, results, -1, 0),
"l" => move_selection(state, results, 1, 0),
"j" => move_selection(state, results, 0, 1),
"k" => move_selection(state, results, 0, -1),
"d" => move_selection(state, results, 0, PAGE_ROWS),
"u" => move_selection(state, results, 0, -PAGE_ROWS),
_ => {}
}
return vec![];
}
let browsing = state.browsing();
match key {
"tab" => {
let current = state.group.map_or(-1, |g| g.index() as i32);
state.group = Some(Group::from_index(
((current + 1) % GROUPS.len() as i32) as usize,
));
state.category = None;
state.sel_index = 0;
state.play_view = false;
vec![]
}
"/" => {
state.play_view = false;
vec![]
}
// Arrows browse while browsing and drive the player while playing - the one
// pair of keys that changes meaning with the screen, because on the play view
// there is no selection for them to move.
"right" => {
if shift {
return vec![Action::Seek(SEEK_STEP)];
}
if !browsing {
return vec![Action::Next];
}
if state.is_root_shelf() {
move_root_shelf(state, 1, 0, shelf_lengths);
} else {
move_selection(state, results, 1, 0);
}
vec![]
}
"left" => {
if shift {
return vec![Action::Seek(-SEEK_STEP)];
}
if !browsing {
return vec![Action::Previous];
}
if state.is_root_shelf() {
move_root_shelf(state, -1, 0, shelf_lengths);
} else {
move_selection(state, results, -1, 0);
}
vec![]
}
"down" => {
if !browsing {
return vec![Action::Volume(-VOLUME_STEP)];
}
if state.is_root_shelf() {
move_root_shelf(state, 0, 1, shelf_lengths);
} else {
move_selection(state, results, 0, 1);
}
vec![]
}
"up" => {
if !browsing {
return vec![Action::Volume(VOLUME_STEP)];
}
if state.is_root_shelf() {
move_root_shelf(state, 0, -1, shelf_lengths);
} else {
move_selection(state, results, 0, -1);
}
vec![]
}
"?" => {
state.tracks_mode = true;
state.search.clear();
state.sel_index = 0;
state.play_view = false;
vec![]
}
"escape" => {
// Peel one layer at a time rather than dumping you back at the top.
if state.open_album.is_some() {
state.open_album = None;
} else if state.play_view {
state.play_view = false;
} else {
state.browse_back();
}
vec![]
}
"backspace" => {
if !state.search.is_empty() {
state.search.pop();
state.sel_index = 0;
}
vec![]
}
"enter" => {
if let Some(album_id) = state.open_album.clone() {
return vec![Action::Play {
album_id,
track_index: state.sheet_index,
}];
}
if browsing && state.is_root_shelf() {
let row = state.shelf_row.min(GROUPS.len() - 1);
state.group = Some(Group::from_index(row));
// A shelf row with no tiles still opens its group; there is simply no
// category to pre-select.
state.category = None;
state.sel_index = 0;
return vec![];
}
if shift {
if let Some(album_id) = selection_album(results, state.sel_index) {
state.open_album = Some(album_id);
state.sheet_index = 0;
return vec![];
}
}
match selection_at(results, state.sel_index) {
Selection::Song {
album_id,
track_index,
} => {
vec![Action::Play {
album_id,
track_index,
}]
}
Selection::Album(album_id) => vec![Action::Play {
album_id,
track_index: 0,
}],
Selection::Category(key) => {
state.category = Some(key);
state.sel_index = 0;
vec![]
}
Selection::None => vec![],
}
}
"f1" => vec![],
_ => {
type_into_search(state, key);
vec![]
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::library::{Category, Library, SongHit};
fn empty() -> Results {
Results::default()
}
fn press(key: &str, state: &mut UiState) -> Vec<Action> {
handle_key(key, false, false, state, &empty(), &[0, 0, 0], 0)
}
#[test]
fn typing_a_letter_searches_and_leaves_the_play_view() {
let mut state = UiState {
play_view: true,
..Default::default()
};
press("k", &mut state);
press("i", &mut state);
assert_eq!(state.search, "ki");
assert!(!state.play_view);
}
#[test]
fn umlauts_are_searchable_like_any_other_letter() {
let mut state = UiState::default();
press("ö", &mut state);
assert_eq!(state.search, "ö");
}
#[test]
fn space_toggles_but_types_mid_search() {
let mut state = UiState::default();
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
state.search = "geolino".into();
assert!(press("space", &mut state).is_empty());
assert_eq!(state.search, "geolino ");
}
#[test]
fn space_toggles_from_the_play_view_even_with_a_query_behind_it() {
let mut state = UiState {
search: "conni".into(),
play_view: true,
..Default::default()
};
assert_eq!(press("space", &mut state), vec![Action::Toggle]);
}
#[test]
fn shift_media_works_from_every_screen() {
for play_view in [false, true] {
let mut state = UiState {
play_view,
..Default::default()
};
let got = handle_key("H", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Previous]);
let got = handle_key("K", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Volume(VOLUME_STEP)]);
}
}
#[test]
fn other_shifted_letters_still_reach_the_search_box() {
let mut state = UiState::default();
handle_key("C", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(state.search, "C");
}
#[test]
fn arrows_browse_while_browsing_and_drive_the_player_while_playing() {
let mut state = UiState {
group: Some(Group::Music),
..Default::default()
};
assert!(press("right", &mut state).is_empty());
state.play_view = true;
assert_eq!(press("right", &mut state), vec![Action::Next]);
assert_eq!(press("up", &mut state), vec![Action::Volume(VOLUME_STEP)]);
assert_eq!(
press("down", &mut state),
vec![Action::Volume(-VOLUME_STEP)]
);
}
#[test]
fn shift_arrows_seek_from_either_screen() {
let mut state = UiState::default();
let got = handle_key("right", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Seek(SEEK_STEP)]);
let got = handle_key("left", false, true, &mut state, &empty(), &[0, 0, 0], 0);
assert_eq!(got, vec![Action::Seek(-SEEK_STEP)]);
}
#[test]
fn the_root_shelf_is_two_axis_and_clamps_to_the_row_it_lands_on() {
let mut state = UiState::default();
let lengths = [5, 2, 0];
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
handle_key("right", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (0, 3));
// Row 1 only has two tiles, so the column has to come back with it.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (1, 1));
// Row 2 has none at all.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!((state.shelf_row, state.sel_index), (2, 0));
// And it does not walk off the bottom.
handle_key("down", false, false, &mut state, &empty(), &lengths, 0);
assert_eq!(state.shelf_row, 2);
}
#[test]
fn enter_at_the_root_opens_the_focused_shelfs_group() {
let mut state = UiState {
shelf_row: 1,
..Default::default()
};
assert!(press("enter", &mut state).is_empty());
assert_eq!(state.group, Some(Group::Audiobooks));
assert_eq!(state.sel_index, 0);
}
#[test]
fn escape_peels_one_layer_at_a_time() {
let mut state = UiState {
group: Some(Group::Music),
category: Some("Rolf".into()),
search: "abc".into(),
tracks_mode: true,
play_view: true,
open_album: Some("x".into()),
..Default::default()
};
press("escape", &mut state);
assert!(state.open_album.is_none(), "the sheet closes first");
press("escape", &mut state);
assert!(!state.play_view, "then the play view");
press("escape", &mut state);
assert_eq!(state.search, "", "then the query");
press("escape", &mut state);
assert!(!state.tracks_mode, "then track-search mode");
press("escape", &mut state);
assert_eq!(
(state.group, state.category.clone()),
(None, None),
"then both at once"
);
assert!(!state.can_go_back());
}
#[test]
fn tab_cycles_the_three_groups_and_wraps() {
let mut state = UiState::default();
for expected in [
Group::Music,
Group::Audiobooks,
Group::Podcasts,
Group::Music,
] {
press("tab", &mut state);
assert_eq!(state.group, Some(expected));
}
}
#[test]
fn backspace_only_bites_when_there_is_a_query() {
let mut state = UiState {
search: "ab".into(),
..Default::default()
};
press("backspace", &mut state);
assert_eq!(state.search, "a");
press("backspace", &mut state);
press("backspace", &mut state);
assert_eq!(state.search, "");
}
#[test]
fn question_mark_switches_to_track_search_and_clears_the_query() {
let mut state = UiState {
search: "conni".into(),
..Default::default()
};
press("?", &mut state);
assert!(state.tracks_mode);
assert_eq!(state.search, "");
}
// ---- selection over a real results set ----
fn results_with(songs: usize, categories: usize, albums: usize) -> Results {
let library = Library::build(
(0..albums.max(1))
.map(|i| {
serde_json::from_value(serde_json::json!({
"id": format!("al{i}"),
"section": "Musik",
"kind": "music",
"title": format!("Album {i}"),
"artist": "A",
"category": "A",
"tracks": [{ "title": "T", "duration": 10.0 }],
}))
.unwrap()
})
.collect(),
);
Results {
songs: (0..songs)
.map(|i| SongHit {
album: std::rc::Rc::clone(&library.albums[0]),
index: i,
title: format!("S{i}"),
duration: 10.0,
})
.collect(),
categories: (0..categories)
.map(|i| Category {
key: format!("C{i}"),
albums: vec![],
})
.collect(),
albums: library
.albums
.iter()
.take(albums)
.map(std::rc::Rc::clone)
.collect(),
}
}
#[test]
fn the_selection_runs_flat_across_songs_then_categories_then_albums() {
let results = results_with(2, 2, 3);
assert_eq!(results.total(), 7);
let mut state = UiState {
group: Some(Group::Music),
cols: 3,
..Default::default()
};
// Inside the song list a vertical move is one row, not one grid row.
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 1);
// Past the songs it jumps a whole grid row.
state.sel_index = 2;
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 5);
// And it never runs off the end.
handle_key("down", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(state.sel_index, 6);
}
#[test]
fn enter_plays_a_song_hit_at_its_own_track_index() {
let results = results_with(2, 0, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(
got,
vec![Action::Play {
album_id: "al0".into(),
track_index: 1
}]
);
}
#[test]
fn enter_on_a_category_drills_in_rather_than_playing() {
let results = results_with(0, 2, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
assert!(handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0).is_empty());
assert_eq!(state.category.as_deref(), Some("C1"));
}
#[test]
fn enter_on_an_album_plays_it_from_the_top() {
let results = results_with(0, 0, 3);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 2,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &results, &[0, 0, 0], 0);
assert_eq!(
got,
vec![Action::Play {
album_id: "al2".into(),
track_index: 0
}]
);
}
#[test]
fn shift_enter_opens_the_track_list_instead() {
let results = results_with(0, 0, 2);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 1,
..Default::default()
};
assert!(handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0).is_empty());
assert_eq!(state.open_album.as_deref(), Some("al1"));
}
#[test]
fn shift_enter_on_a_category_falls_through_to_drilling_in() {
let results = results_with(0, 1, 0);
let mut state = UiState {
group: Some(Group::Music),
sel_index: 0,
..Default::default()
};
handle_key("enter", false, true, &mut state, &results, &[0, 0, 0], 0);
assert!(state.open_album.is_none());
assert_eq!(state.category.as_deref(), Some("C0"));
}
#[test]
fn ctrl_jk_walks_the_open_track_list_and_clamps() {
let mut state = UiState {
open_album: Some("al0".into()),
..Default::default()
};
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
handle_key("j", true, false, &mut state, &empty(), &[0, 0, 0], 3);
assert_eq!(state.sheet_index, 2);
handle_key("k", true, false, &mut state, &empty(), &[0, 0, 0], 3);
assert_eq!(state.sheet_index, 1);
}
#[test]
fn enter_with_the_sheet_open_plays_the_highlighted_track() {
let mut state = UiState {
open_album: Some("al0".into()),
sheet_index: 2,
..Default::default()
};
let got = handle_key("enter", false, false, &mut state, &empty(), &[0, 0, 0], 4);
assert_eq!(
got,
vec![Action::Play {
album_id: "al0".into(),
track_index: 2
}]
);
}
}