The second of the web front-end's three pages. Lights get a toggle, five brightness steps and six colours; shutters get four presets, up/stop/down and a window that reflects the position Home Assistant reports. Its own violet hue rather than the player's teal, so the two pages read as siblings rather than one bleeding into the other - the same reason room.css has its own tokens. The backend keeps Home Assistant's URL and token and relays the calls, so this client learns only which entities exist, exactly as the browser does. A 404 from GET /api/ha means Home Assistant is not configured, which is a normal answer: the tab simply does not appear. Polled, not subscribed, and only while the page is on screen. HA's own auth-and- subscribe websocket is more machinery than a room panel needs, and a lamp's state is not worth a request every 2.5 seconds when nobody is looking at it - this device has a music player to stay out of the way of. A tap patches the entity locally and records when; a poll that was already in flight when the patch landed is dropped for that entity rather than putting the lamp back to "off" until the next one catches up. That reconciliation is the one non-obvious thing in useHomeAssistant.ts and it is carried over for the same reason. Nothing on a shutter card animates locally. A real cover reports its own movement and position, and a client-side guess at where it will end up is precisely what would fight with that - the design mockup animated it because it had no backend to ask. Two additions beyond a straight port, both because a kiosk has no pointer: CTRL+TAB cycles the pages. The web's tab rail is pointer-only, which sits badly with a front-end whose README claims every screen is reachable from the keyboard. Plain TAB was already the group cycle, and CTRL+TAB is the idiom for the outer one everywhere else. --page opens directly on a page, which a kiosk may well want and which is the only way in with neither keyboard nor pointer. Verified against the real Home Assistant: the shutter reported open and the lamp off, and both drew that way. Nothing was switched - those entities are hardware. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
916 lines
30 KiB
Rust
916 lines
30 KiB
Rust
//! 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<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 {
|
|
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<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+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<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 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
|
|
}]
|
|
);
|
|
}
|
|
}
|