//! 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; /// Which top-level page is showing. Orthogonal to everything below it, so switching /// pages and coming back leaves the music side exactly as it was. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Page { Music, Room, Typing, } impl Page { pub fn icon(self) -> &'static str { match self { // A plain Unicode symbol rather than the emoji: that glyph carries its own // muted colour on some platforms and ignores `color`, which makes it // illegible on the rail's own background. Self::Music => "♪", Self::Room => "💡", Self::Typing => "⌨️", } } pub fn label(self) -> &'static str { match self { Self::Music => "Musik", Self::Room => "Mein Zimmer", Self::Typing => "Tippen", } } } #[derive(Debug, Clone, PartialEq)] pub struct UiState { pub page: Page, pub search: String, pub tracks_mode: bool, /// `None` is the bare root screen (three shelves); otherwise which one is open. pub group: Option, pub category: Option, 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, /// 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 { page: Page::Music, 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.page != Page::Music || 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 { 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 { 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 { // 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+Tab cycles the pages. This is an addition to the web front-end, where the // tab rail is reachable only with a pointer - which sits badly with a device whose // stated design is that every screen can be reached from the keyboard. Plain Tab // is already taken by the group cycle, and Ctrl+Tab is the idiom for the outer // one everywhere else. if ctrl && key == "tab" { state.page = match state.page { Page::Music => Page::Room, Page::Room => Page::Typing, Page::Typing => Page::Music, }; return vec![]; } // 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. The // page is the outermost layer: ESC on the room or typing page comes back // to the player, and only then starts unwinding the browse hierarchy. if state.page != Page::Music { state.page = Page::Music; } else 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 { 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 ctrl_tab_cycles_the_pages_and_plain_tab_still_cycles_groups() { let mut state = UiState::default(); for expected in [Page::Room, Page::Typing, Page::Music] { handle_key("tab", true, false, &mut state, &empty(), &[0, 0, 0], 0); assert_eq!(state.page, expected); } // And the outer cycle has not eaten the inner one. press("tab", &mut state); assert_eq!(state.group, Some(Group::Music)); assert_eq!(state.page, Page::Music); } #[test] fn escape_comes_back_from_another_page_before_it_touches_the_browse_state() { let mut state = UiState { page: Page::Room, group: Some(Group::Music), ..Default::default() }; press("escape", &mut state); assert_eq!(state.page, Page::Music); assert_eq!( state.group, Some(Group::Music), "the player was left as it was" ); press("escape", &mut state); assert_eq!(state.group, None); } #[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 }] ); } }