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:
833
slint-frontend/src/keymap.rs
Normal file
833
slint-frontend/src/keymap.rs
Normal 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
|
||||
}]
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user