pub mod bindings; mod help; mod library; mod list; mod now_playing; mod queue; mod register; use flume::Sender; use ratatui::{ layout::{Constraint, Direction, Layout, Rect}, style::{Color, Style}, widgets::{Clear, Paragraph}, Frame, }; use crabidy_core::proto::crabidy::{ get_update_stream_response::Update as StreamUpdate, CaptureProgress, InitResponse as InitialData, LibraryNode, }; pub(crate) use list::{carry_marks, MarkedPane}; pub use list::{Filter, StatefulList}; use bindings::Action; use library::Library; use now_playing::NowPlaying; pub use now_playing::SpectrumStyle; use queue::Queue; pub use register::Register; #[derive(Clone, Copy)] pub enum UiFocus { Library, Queue, } #[derive(Clone, Copy)] enum UiItemKind { Node, Track, } pub(crate) struct UiItem { path: String, title: String, kind: UiItemKind, marked: bool, is_queable: bool, /// Children may be created under this item — rendered with a `%` marker. is_creatable: bool, /// This item may be renamed (`e`) — part of the `[ed]` marker. is_editable: bool, /// This item may be deleted (`d`) — part of the `[ed]` marker. is_deletable: bool, /// This item allows download captures (`W`). Tracks inherit their /// containing node's flag; child nodes carry their own. is_downloadable: bool, /// The track has no playable audio (`Track.is_skipped`) — rendered /// red; playback skips it. Always false for nodes. is_skipped: bool, /// The item's audio is present in the capture content store /// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered /// with a trailing `↓` marker at the end of the row. is_captured: bool, } pub const COLOR_PRIMARY: Color = Color::Rgb(129, 161, 193); /// [`COLOR_PRIMARY`] as a config-file string — the default /// `spectrum_peak_color`. The pair is checked by /// `the_default_peak_color_is_the_primary_blue`. pub const COLOR_PRIMARY_HEX: &str = "#81a1c1"; // const COLOR_PRIMARY_DARK: Color = Color::Rgb(94, 129, 172); pub const COLOR_PRIMARY_DARK: Color = Color::Rgb(59, 66, 82); pub const COLOR_SECONDARY: Color = Color::Rgb(180, 142, 173); pub const COLOR_RED: Color = Color::Rgb(191, 97, 106); /// [`COLOR_RED`] as a config-file string — the default `spectrum_color`, /// so the bars match the playing queue row out of the box. The pair is /// checked by `the_default_spectrum_color_is_the_queue_red`. pub const COLOR_RED_HEX: &str = "#bf616a"; pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140); // const COLOR_ORANGE: Color = Color::Rgb(208, 135, 112); // const COLOR_BRIGHT: Color = Color::Rgb(216, 222, 233); /// Library path of the continuously persisted live queue — what the /// queue pane's `W` download-captures (server: `queue_store`'s /// `CURRENT_QUEUE_NAME`, now mirrored under the single `/crabidy` /// provider). const CURRENT_QUEUE_PATH: &str = "/crabidy/current"; /// How far one press of a seek key moves the playing position, in /// milliseconds — the unit the wire speaks, so any step is expressible /// (architecture/seek.md D3). /// /// The step lives here, in the client: the server is handed an offset and adds /// it to the live position, which is what makes repeated presses compose. The /// help text and the docs spell the number out, so change those too. pub(crate) const SEEK_STEP_MILLIS: i64 = 15_000; // FIXME: Rename this pub enum MessageToUi { Init(InitialData), ReplaceLibraryNode(LibraryNode), Update(StreamUpdate), } // FIXME: Rename this pub enum MessageFromUi { GetLibraryNode(String), /// Create a child node (e.g. a search term) under a creatable parent; /// the orchestrator navigates the library into the created node. CreateNode { parent_path: String, title: String, }, /// Rename an editable node; the orchestrator navigates the library into /// the renamed node (its path changes with the title). RenameNode { path: String, new_title: String, }, /// Delete a deletable node; the orchestrator shows the refreshed parent /// listing the server returns. DeleteNode { path: String, }, /// Save the current queue under a name (server-side snapshot; appears /// as `/crabidy/` in the library on the next visit). SaveQueue(String), /// Capture the queueable subtree at `path` as the bookmark `name` /// (structure-preserving snapshot under `/crabidy/`), or — /// with `download` — as the capture `name` under `/crabidy/` /// with every track's audio downloaded next to its toml. CaptureNode { path: String, name: String, download: bool, }, AppendTracks(Vec), QueueTracks(Vec), InsertTracks(Vec, usize), RemoveTracks(Vec), ReplaceQueue(Vec), ClearQueue(bool), NextTrack, PrevTrack, RestartTrack, /// Move the position inside the playing track by a signed millisecond /// offset. Relative: the server adds it to the live position, so holding /// the key composes instead of aiming at the same spot every time /// (architecture/seek.md D1). Seek(i64), SetCurrentTrack(usize), TogglePlay, ChangeVolume(f32), ToggleMute, ToggleShuffle, ToggleRepeat, } /// What the event loop should do after dispatching an action. /// /// Quitting stays a loop-level concern: `App::dispatch` never tears down the /// terminal itself, it only reports that the loop should end. #[must_use] #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum DispatchResult { Continue, Quit, } /// Why the input overlay is open — decides the submit message and the /// overlay label. pub enum InputPurpose { /// `%`: create a child under the creatable node at `parent_path`. Create { parent_path: String }, /// `e`: rename the node at `path`. The buffer starts prefilled with the /// current title, so "rename" degrades to "retype" (append-only editing, /// no cursor movement). Rename { path: String }, /// `w`: save the current queue under the entered name. SaveQueue, /// `w` in the library: capture the queueable subtree at `path` as a /// bookmark — or, for `W` (`download`), as a download capture (the /// selection must also be downloadable). The buffer starts prefilled /// with the selection's title. Capture { path: String, download: bool }, } /// How long a finished capture's line lingers before it disappears. const CAPTURE_DONE_LINGER: std::time::Duration = std::time::Duration::from_secs(5); /// Failures linger longer — the user should get to read them. const CAPTURE_ERROR_LINGER: std::time::Duration = std::time::Duration::from_secs(10); /// One capture's latest progress plus, once finished, when it finished — /// the render loop expires finished entries after their linger time. struct CaptureEntry { progress: CaptureProgress, finished_at: Option, } /// Live capture progress lines (architecture/incremental-captures.md D5): /// fed from `CaptureProgress` stream updates, keyed by capture name, /// expired at render time via [`Self::lines`]. #[derive(Default)] pub struct CaptureBoard { entries: Vec, } impl CaptureBoard { /// Applies one stream update: replaces the entry of the same name (a /// re-run supersedes the lingering result of the previous run) and /// stamps the finish time on terminal events. pub fn apply(&mut self, progress: CaptureProgress) { let finished_at = progress.finished.then(std::time::Instant::now); let entry = CaptureEntry { progress, finished_at, }; match self .entries .iter_mut() .find(|e| e.progress.name == entry.progress.name) { Some(existing) => *existing = entry, None => self.entries.push(entry), } } /// The lines to render right now, oldest first, paired with an /// is-error flag. Drops finished entries past their linger time. fn lines(&mut self) -> Vec<(String, bool)> { self.entries.retain(|e| match e.finished_at { None => true, Some(at) if e.progress.error.is_empty() => at.elapsed() < CAPTURE_DONE_LINGER, Some(at) => at.elapsed() < CAPTURE_ERROR_LINGER, }); self.entries .iter() .map(|e| (Self::line(&e.progress), !e.progress.error.is_empty())) .collect() } /// One entry's display line. Pure so it is testable without clocks. fn line(p: &CaptureProgress) -> String { let verb = if p.download { ("capturing", "captured", "capture") } else { ("bookmarking", "bookmarked", "bookmark") }; let skipped = if p.tracks_skipped > 0 { format!(" ({} skipped)", p.tracks_skipped) } else { String::new() }; if !p.finished { let total = if p.tracks_total > 0 { p.tracks_total.to_string() } else { "?".to_string() }; format!("{} {} {}/{total}{skipped}", verb.0, p.name, p.tracks_done) } else if p.error.is_empty() { format!("{} {}: {} tracks{skipped}", verb.1, p.name, p.tracks_done) } else { format!("{} {} failed: {}", verb.2, p.name, p.error) } } } /// State of the one-line text input overlay (node creation and rename). /// /// While this is `Some` on [`App`], key events bypass `bindings::lookup` /// entirely and go to [`App::handle_input_key`]. pub struct InputState { pub purpose: InputPurpose, /// Text typed so far. Append-only editing: chars push, Backspace pops. pub buffer: String, } /// An open `/` search input, filtering one pane live as the user types. /// The filter itself lives on the pane (it survives closing this input); /// this only holds the editing buffer and which pane is being filtered. /// Modal like the other overlays: while `Some`, keys edit the query. pub struct SearchState { pub focus: UiFocus, pub buffer: String, } pub struct App { pub focus: UiFocus, /// Whether the help modal is open. While true, `bindings::lookup` only /// admits `Scope::Help` actions and `render` draws the overlay last. pub show_help: bool, /// `Some` while the input overlay is open; takes precedence over the /// bindings table (checked first in the event loop). pub input: Option, /// `Some` while a `/` search input is open; modal like the others. pub search: Option, /// Progress of running (and recently finished) captures, rendered as /// status lines at the bottom of the library pane. pub captures: CaptureBoard, pub library: Library, pub now_playing: NowPlaying, pub queue: Queue, /// What `y`/`d`/`c`/`C` set aside and `p`/`P` paste back /// (architecture/queue-register.md). Per client, one slot, in memory. pub register: Register, tx: Sender, } impl App { pub fn new(tx: Sender) -> App { let library = Library::new(tx.clone()); let queue = Queue::new(tx.clone()); let now_playing = NowPlaying::default(); App { focus: UiFocus::Library, show_help: false, input: None, search: None, captures: CaptureBoard::default(), library, now_playing, queue, register: Register::default(), tx, } } /// Handles one key while a `/` search input is open /// (`search.is_some()`). Typing filters the focused pane live; /// `Enter` keeps the filter and returns to navigation; `Esc` clears /// it. Mirrors the modal input overlay, but edits a pane filter /// rather than sending a message. pub fn handle_search_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::KeyCode; let Some(search) = self.search.as_mut() else { return; }; match key.code { KeyCode::Esc => { // Cancel: leave search mode and restore the full listing. match search.focus { UiFocus::Library => self.library.set_filter(None), UiFocus::Queue => self.queue.set_filter(None), } self.search = None; } KeyCode::Enter => { // Keep the filter applied, close the input. self.search = None; } KeyCode::Backspace => { search.buffer.pop(); Self::apply_search(&mut self.library, &mut self.queue, search); } KeyCode::Char(c) => { search.buffer.push(c); Self::apply_search(&mut self.library, &mut self.queue, search); } _ => {} } } /// Pushes the current search buffer into the focused pane's filter. fn apply_search(library: &mut Library, queue: &mut Queue, search: &SearchState) { let query = Some(search.buffer.clone()); match search.focus { UiFocus::Library => library.set_filter(query), UiFocus::Queue => queue.set_filter(query), } } /// Handles one key while the input overlay is open (`input.is_some()`). /// /// `Esc` cancels, `Enter` submits a non-empty trimmed buffer as the /// message matching [`InputPurpose`] (empty submits just close), /// `Backspace` pops, printable chars append. Everything else is ignored — /// the bindings table is never consulted while the overlay is open. pub fn handle_input_key(&mut self, key: crossterm::event::KeyEvent) { use crossterm::event::KeyCode; let Some(input) = self.input.as_mut() else { return; }; match key.code { KeyCode::Esc => { self.input = None; } KeyCode::Enter => { let title = input.buffer.trim().to_string(); if !title.is_empty() { match &input.purpose { InputPurpose::Create { parent_path } => { let _ = self.tx.send(MessageFromUi::CreateNode { parent_path: parent_path.clone(), title, }); } InputPurpose::Rename { path } => { let _ = self.tx.send(MessageFromUi::RenameNode { path: path.clone(), new_title: title, }); } InputPurpose::SaveQueue => { let _ = self.tx.send(MessageFromUi::SaveQueue(title)); } InputPurpose::Capture { path, download } => { let _ = self.tx.send(MessageFromUi::CaptureNode { path: path.clone(), name: title, download: *download, }); } } } self.input = None; } KeyCode::Backspace => { input.buffer.pop(); } KeyCode::Char(c) => { input.buffer.push(c); } _ => {} } } /// Execute one [`Action`] against the app: mutate UI state (focus, help /// modal, list selections) or send the matching [`MessageFromUi`] via /// `tx`. Send failures are ignored like everywhere else in this module — /// the orchestrator side owns error reporting. /// Run a library cursor move, painting the swept `(old, new]` range when /// visual mode is active (architecture/visual-mode.md D3). fn library_move(&mut self, mv: impl FnOnce(&mut Library)) { if self.library.is_visual() { let old = self.library.selected_view(); mv(&mut self.library); let new = self.library.selected_view(); if let (Some(old), Some(new)) = (old, new) { self.library.paint_between(old, new); } } else { mv(&mut self.library); } } /// Run a queue cursor move, painting the swept `(old, new]` range when /// visual mode is active — the queue mirror of [`Self::library_move`]. fn queue_move(&mut self, mv: impl FnOnce(&mut Queue)) { if self.queue.is_visual() { let old = self.queue.selected_view(); mv(&mut self.queue); let new = self.queue.selected_view(); if let (Some(old), Some(new)) = (old, new) { self.queue.paint_between(old, new); } } else { mv(&mut self.queue); } } /// `p`/`P`: insert the register at the cursor. Nothing to paste is a /// no-op, not an empty round trip; the register survives so the same /// yank can be pasted again (architecture/queue-register.md D3). fn paste(&mut self, before: bool) { if self.register.is_empty() { return; } let pos = self.queue.paste_position(before); let _ = self.tx.send(MessageFromUi::InsertTracks( self.register.paths().to_vec(), pos, )); } /// Put `entries` in the register, unless a command produced nothing. fn set_register(&mut self, entries: (Vec, Vec)) { if !entries.0.is_empty() { self.register.set(entries.0, entries.1); } } pub fn dispatch(&mut self, action: Action) -> DispatchResult { // Visual mode (library paint-select): the six movements paint (handled // in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other // action leaves visual mode before running (architecture/visual-mode.md). // `Esc` leaves visual mode instead of clearing the `/` filter, so the // arms below need to know a pane was painting before the guards run. let was_visual = self.library.is_visual() || self.queue.is_visual(); if self.library.is_visual() && !matches!( action, Action::LibraryFirst | Action::LibraryLast | Action::LibraryNext | Action::LibraryPrev | Action::LibraryJumpDown | Action::LibraryJumpUp | Action::LibraryVisualMode | Action::ClearSearch ) { self.library.exit_visual(); } if self.queue.is_visual() && !matches!( action, Action::QueueFirst | Action::QueueLast | Action::QueueNext | Action::QueuePrev | Action::QueueJumpDown | Action::QueueJumpUp | Action::QueueSelectCurrent | Action::QueueVisualMode | Action::ClearSearch ) { self.queue.exit_visual(); } match action { Action::Quit => return DispatchResult::Quit, Action::OpenHelp => self.show_help = true, Action::CloseHelp => self.show_help = false, Action::CycleFocus => self.cycle_active(), Action::TogglePlay => { let _ = self.tx.send(MessageFromUi::TogglePlay); } Action::RestartTrack => { let _ = self.tx.send(MessageFromUi::RestartTrack); } Action::VolumeUp => { let _ = self.tx.send(MessageFromUi::ChangeVolume(0.1)); } Action::VolumeDown => { let _ = self.tx.send(MessageFromUi::ChangeVolume(-0.1)); } Action::ToggleMute => { let _ = self.tx.send(MessageFromUi::ToggleMute); } Action::ToggleShuffle => { let _ = self.tx.send(MessageFromUi::ToggleShuffle); } Action::ToggleRepeat => { let _ = self.tx.send(MessageFromUi::ToggleRepeat); } Action::NextTrack => self.queue.play_next(), Action::PrevTrack => self.queue.play_prev(), Action::SeekBackward => { let _ = self.tx.send(MessageFromUi::Seek(-SEEK_STEP_MILLIS)); } Action::SeekForward => { let _ = self.tx.send(MessageFromUi::Seek(SEEK_STEP_MILLIS)); } Action::ToggleSpectrum => self.now_playing.toggle_spectrum(), Action::LibraryFirst => self.library_move(|l| l.first()), Action::LibraryLast => self.library_move(|l| l.last()), Action::LibraryNext => self.library_move(|l| l.next()), Action::LibraryPrev => self.library_move(|l| l.prev()), Action::LibraryJumpDown => self.library_move(|l| l.down()), Action::LibraryJumpUp => self.library_move(|l| l.up()), Action::LibraryAscend => self.library.ascend(), Action::LibraryDive => self.library.dive(), Action::LibraryQueueNext => self.library.queue_queue(), Action::LibraryQueueAppend => self.library.queue_append(), Action::LibraryQueueReplace => self.library.queue_replace(), Action::LibraryToggleMark => self.library.toggle_mark(), Action::LibraryVisualMode => self.library.toggle_visual(), Action::LibraryCreateNode => { // Opens the input overlay when the open library node is // creatable; silently ignored otherwise. if self.library.is_creatable() { self.input = Some(InputState { purpose: InputPurpose::Create { parent_path: self.library.path().to_string(), }, buffer: String::new(), }); } } Action::LibraryEditNode => { // Opens the overlay prefilled with the current title when the // selected item is editable; silently ignored otherwise. if let Some((path, title)) = self.library.selected_editable() { self.input = Some(InputState { purpose: InputPurpose::Rename { path }, buffer: title, }); } } Action::LibraryDeleteNode => { // Deletes go through directly on every provider: audio lives // in a shared content store that track-deletion never // touches, so capture deletes are no longer destructive. if let Some((path, _title)) = self.library.selected_deletable() { let _ = self.tx.send(MessageFromUi::DeleteNode { path }); } } Action::LibraryCaptureNode => { // Prefilled with the selection's title; silently ignored on // non-queueable selections like the other gated openers. if let Some((path, title)) = self.library.selected_queueable() { self.input = Some(InputState { purpose: InputPurpose::Capture { path, download: false, }, buffer: title, }); } } Action::LibraryDownloadNode => { // `W`: like `w`, but the selection must also allow download // captures (architecture/captures.md D6). if let Some((path, title)) = self.library.selected_downloadable() { self.input = Some(InputState { purpose: InputPurpose::Capture { path, download: true, }, buffer: title, }); } } Action::OpenSearch => { // Search the focused pane; reopening edits the current // query. Applying it now activates search mode (an empty // query shows everything). let focus = self.focus; let buffer = match focus { UiFocus::Library => self.library.filter_query(), UiFocus::Queue => self.queue.filter_query(), } .unwrap_or_default() .to_string(); let search = SearchState { focus, buffer }; Self::apply_search(&mut self.library, &mut self.queue, &search); self.search = Some(search); } Action::ClearSearch => { if was_visual { // In visual mode `Esc` leaves the mode (marks kept) and // does not also clear the search filter. self.library.exit_visual(); self.queue.exit_visual(); } else { // `Esc` in normal navigation clears the focused pane's // filter, restoring the full listing. A no-op when nothing // is filtered. match self.focus { UiFocus::Library => self.library.set_filter(None), UiFocus::Queue => self.queue.set_filter(None), } } } Action::QueueFirst => self.queue_move(|q| q.first()), Action::QueueLast => self.queue_move(|q| q.last()), Action::QueueNext => self.queue_move(|q| q.next()), Action::QueuePrev => self.queue_move(|q| q.prev()), Action::QueueJumpDown => self.queue_move(|q| q.down()), Action::QueueJumpUp => self.queue_move(|q| q.up()), Action::QueueSelectCurrent => self.queue_move(|q| q.select_current()), Action::QueuePlaySelected => self.queue.play_selected(), Action::QueueRemoveTrack => { let removed = self.queue.remove_track(); self.set_register(removed); } Action::QueueToggleMark => self.queue.toggle_mark(), Action::QueueVisualMode => self.queue.toggle_visual(), Action::QueueYank => { let yanked = self.queue.yank(); self.set_register(yanked); } Action::QueuePaste => self.paste(false), Action::QueuePasteBefore => self.paste(true), Action::LibraryYank => { if let Some((paths, labels)) = self.library.selection() { self.register.set(paths, labels); self.library.remove_marks(); } } Action::QueueClearKeepCurrent => { let dropped = self.queue.all_entries(true); self.set_register(dropped); let _ = self.tx.send(MessageFromUi::ClearQueue(true)); } Action::QueueClearAll => { let dropped = self.queue.all_entries(false); self.set_register(dropped); let _ = self.tx.send(MessageFromUi::ClearQueue(false)); } Action::QueueSaveAs => { // Nothing to save from an empty queue; silently ignored // like the other capability-gated openers. if !self.queue.is_empty() { self.input = Some(InputState { purpose: InputPurpose::SaveQueue, buffer: String::new(), }); } } Action::QueueDownloadCapture => { // Capture the live queue directly (its continuously // persisted `/crabidy/current` snapshot), so the user need // not save-then-capture. Empty queue: nothing to capture. if !self.queue.is_empty() { self.input = Some(InputState { purpose: InputPurpose::Capture { path: CURRENT_QUEUE_PATH.to_string(), download: true, }, buffer: String::new(), }); } } } DispatchResult::Continue } pub fn cycle_active(&mut self) { self.focus = match (self.focus, self.queue.is_empty()) { (UiFocus::Library, false) => UiFocus::Queue, (UiFocus::Library, true) => UiFocus::Library, (UiFocus::Queue, _) => UiFocus::Library, }; } pub fn render(&mut self, f: &mut Frame) { let _full_screen = f.area(); let library_focused = matches!(self.focus, UiFocus::Library); let queue_focused = matches!(self.focus, UiFocus::Queue); let main = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref()) .split(f.area()); self.library.render(f, main[0], library_focused); // `Min(10)`, not `Max(10)`: a capped last segment leaves the // remaining rows unfilled (a gap under the now-playing pane), // whereas `Min` lets it grow to the bottom — so the spectrum // fills all the space left below the queue. let right_side = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(70), Constraint::Min(10)].as_ref()) .split(main[1]); self.queue .render(f, right_side[0], queue_focused, self.register.len()); self.now_playing.render(f, right_side[1]); // The node-creation/rename input: one line inside the bottom of the // library pane, drawn over the list while open. if let Some(input) = &self.input { let area = main[0]; if area.height >= 3 && area.width >= 4 { let label = match &input.purpose { InputPurpose::Create { .. } => "new node", InputPurpose::Rename { .. } => "rename", InputPurpose::SaveQueue => "save queue", InputPurpose::Capture { download: false, .. } => "bookmark", // Downloading a whole subtree can take a long time; // re-capturing the same name resumes it (D5 warning — // kept short so the line fits narrow panes). InputPurpose::Capture { download: true, .. } => "capture (slow, resumable)", }; let line = Rect::new(area.x + 1, area.y + area.height - 2, area.width - 2, 1); f.render_widget(Clear, line); f.render_widget( Paragraph::new(format!("{label}: {}▏", input.buffer)) .style(Style::default().fg(COLOR_SECONDARY)), line, ); } } // Capture progress lines: stacked up from the bottom of the library // pane, above the input overlay when that is open. Failures render // red. At most three — more concurrent captures than that keep // running fine, only their lines wait for a free row. let capture_lines = self.captures.lines(); if !capture_lines.is_empty() { let area = main[0]; let bottom_offset = if self.input.is_some() { 3 } else { 2 }; for (i, (text, is_error)) in capture_lines.iter().take(3).enumerate() { let offset = bottom_offset + i as u16; if area.height <= offset + 1 || area.width < 4 { break; } let line = Rect::new( area.x + 1, area.y + area.height - 1 - offset, area.width - 2, 1, ); let color = if *is_error { COLOR_RED } else { COLOR_SECONDARY }; f.render_widget(Clear, line); f.render_widget( Paragraph::new(text.as_str()).style(Style::default().fg(color)), line, ); } } // The help modal renders last so it overlays every pane. if self.show_help { help::render(f); } } } #[cfg(test)] mod tests { use super::*; use flume::Receiver; fn app() -> (App, Receiver) { let (tx, rx) = flume::unbounded(); (App::new(tx), rx) } #[test] fn quit_is_reported_to_the_loop_not_executed() { let (mut app, _rx) = app(); assert_eq!(app.dispatch(Action::Quit), DispatchResult::Quit); } #[test] fn open_and_close_help_toggle_the_flag() { let (mut app, _rx) = app(); assert!(!app.show_help); assert_eq!(app.dispatch(Action::OpenHelp), DispatchResult::Continue); assert!(app.show_help); assert_eq!(app.dispatch(Action::CloseHelp), DispatchResult::Continue); assert!(!app.show_help); } #[test] fn cycle_focus_respects_empty_queue() { let (mut app, _rx) = app(); // The queue starts empty, so focus must stay on the library. assert_eq!(app.dispatch(Action::CycleFocus), DispatchResult::Continue); assert!(matches!(app.focus, UiFocus::Library)); } #[test] fn playback_actions_send_the_matching_message() { let (mut app, rx) = app(); let _ = app.dispatch(Action::TogglePlay); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::TogglePlay))); let _ = app.dispatch(Action::RestartTrack); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RestartTrack))); // Seek sends the *step*, signed — never a target position: the server // adds it to the live position (architecture/seek.md D1). let _ = app.dispatch(Action::SeekForward); assert!( matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == SEEK_STEP_MILLIS), "forward seek must send +one step" ); let _ = app.dispatch(Action::SeekBackward); assert!( matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == -SEEK_STEP_MILLIS), "backward seek must send -one step" ); let _ = app.dispatch(Action::ToggleMute); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleMute))); let _ = app.dispatch(Action::ToggleShuffle); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleShuffle))); let _ = app.dispatch(Action::ToggleRepeat); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleRepeat))); } #[test] fn volume_actions_send_signed_deltas() { let (mut app, rx) = app(); let _ = app.dispatch(Action::VolumeUp); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d > 0.0)); let _ = app.dispatch(Action::VolumeDown); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ChangeVolume(d)) if d < 0.0)); } fn creatable_node(path: &str) -> LibraryNode { LibraryNode { path: path.to_string(), title: "search".to_string(), children: Vec::new(), parent: Some("/tidal".to_string()), tracks: Vec::new(), is_queable: false, is_creatable: true, is_downloadable: false, tracks_deletable: false, is_captured: false, } } fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent { crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) } fn type_str(app: &mut App, text: &str) { for c in text.chars() { app.handle_input_key(key(crossterm::event::KeyCode::Char(c))); } } /// A plain queueable listing with the given child titles. fn children_listing(titles: &[&str]) -> LibraryNode { use crabidy_core::proto::crabidy::LibraryNodeChild; LibraryNode { path: "/fs/music".to_string(), title: "music".to_string(), children: titles .iter() .map(|t| LibraryNodeChild::new(format!("/fs/music/{t}"), t.to_string(), true)) .collect(), parent: Some("/fs".to_string()), tracks: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, } } fn search_key(app: &mut App, text: &str) { for c in text.chars() { app.handle_search_key(key(crossterm::event::KeyCode::Char(c))); } } /// A listing whose child queueability follows `queueable` per title. fn mixed_listing(items: &[(&str, bool)]) -> LibraryNode { use crabidy_core::proto::crabidy::LibraryNodeChild; LibraryNode { path: "/fs/music".to_string(), title: "music".to_string(), parent: Some(crabidy_core::ROOT_PATH.to_string()), children: items .iter() .map(|(t, q)| LibraryNodeChild::new(format!("/fs/music/{t}"), t.to_string(), *q)) .collect(), tracks: Vec::new(), is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: false, is_captured: false, } } #[test] fn visual_enter_toggles_current_mark() { let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); assert!(app.library.is_visual(), "visual mode active"); assert_eq!(app.library.marked_titles(), vec!["alpha".to_string()]); } #[test] fn visual_move_paints_swept_range() { // Step moves paint one row each. let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); // marks alpha (anchor) let _ = app.dispatch(Action::LibraryNext); // marks beta let _ = app.dispatch(Action::LibraryNext); // marks gamma assert_eq!( app.library.marked_titles(), vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()] ); } #[test] fn visual_jump_paints_whole_span() { // A jump paints every row in the span, not just the endpoint. let (mut app, _rx) = app(); app.library .update(children_listing(&["a", "b", "c", "d", "e"])); let _ = app.dispatch(Action::LibraryVisualMode); // marks a let _ = app.dispatch(Action::LibraryLast); // paints b,c,d,e assert_eq!(app.library.marked_titles().len(), 5); } #[test] fn visual_back_sweep_unpaints() { // Retreating shrinks the [anchor, cursor] range: the row turned around // on (gamma) is unmarked, not stranded. let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor) let _ = app.dispatch(Action::LibraryNext); // beta let _ = app.dispatch(Action::LibraryNext); // gamma let _ = app.dispatch(Action::LibraryPrev); // back onto beta assert_eq!( app.library.marked_titles(), vec!["alpha".to_string(), "beta".to_string()] ); } #[test] fn visual_down_then_fully_up_leaves_only_the_anchor() { // Regression: going down and back up to the start must not strand the // furthest row marked (the reported bug). let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor) let _ = app.dispatch(Action::LibraryNext); // beta let _ = app.dispatch(Action::LibraryNext); // gamma let _ = app.dispatch(Action::LibraryPrev); // beta let _ = app.dispatch(Action::LibraryPrev); // alpha assert_eq!( app.library.marked_titles(), vec!["alpha".to_string()], "only the anchor stays marked after returning to it" ); } #[test] fn visual_exit_keeps_marks() { // Second v exits; marks persist. let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); let _ = app.dispatch(Action::LibraryNext); let before = app.library.marked_titles(); let _ = app.dispatch(Action::LibraryVisualMode); assert!(!app.library.is_visual()); assert_eq!(app.library.marked_titles(), before); } #[test] fn visual_esc_exits_keeping_marks() { // Esc exits visual and keeps the marks. let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); let _ = app.dispatch(Action::LibraryNext); let _ = app.dispatch(Action::ClearSearch); assert!(!app.library.is_visual()); assert_eq!( app.library.marked_titles(), vec!["alpha".to_string(), "beta".to_string()] ); } #[test] fn visual_non_move_action_exits_then_runs() { let (mut app, rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); // alpha let _ = app.dispatch(Action::LibraryNext); // beta let _ = app.dispatch(Action::LibraryQueueAppend); // exits + appends marks assert!(!app.library.is_visual(), "append leaves visual mode"); assert!( app.library.marked_titles().is_empty(), "append consumes the marks" ); match rx.try_recv() { Ok(MessageFromUi::AppendTracks(paths)) => assert_eq!(paths.len(), 2), _ => panic!("expected AppendTracks(2)"), } } #[test] fn visual_exits_on_node_or_focus_change() { for action in [ Action::LibraryDive, Action::LibraryAscend, Action::CycleFocus, ] { let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); let _ = app.dispatch(Action::LibraryVisualMode); assert!(app.library.is_visual()); let _ = app.dispatch(action); assert!(!app.library.is_visual(), "{action:?} leaves visual mode"); } } #[test] fn visual_paint_respects_is_queable() { // Sweeping over a non-queueable row leaves it unmarked, like `s`. let (mut app, _rx) = app(); app.library .update(mixed_listing(&[("a", true), ("b", false), ("c", true)])); let _ = app.dispatch(Action::LibraryVisualMode); // marks a let _ = app.dispatch(Action::LibraryNext); // sweeps b (not queueable) let _ = app.dispatch(Action::LibraryNext); // marks c assert_eq!( app.library.marked_titles(), vec!["a".to_string(), "c".to_string()] ); } #[test] fn esc_in_visual_only_exits_visual_keeping_the_filter() { use crossterm::event::KeyCode; let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); // Apply a filter and return to navigation. let _ = app.dispatch(Action::OpenSearch); search_key(&mut app, "a"); app.handle_search_key(key(KeyCode::Enter)); assert_eq!(app.library.filter_query(), Some("a")); // Enter visual, then Esc: leaves visual but keeps the filter. let _ = app.dispatch(Action::LibraryVisualMode); let _ = app.dispatch(Action::ClearSearch); assert!(!app.library.is_visual()); assert_eq!(app.library.filter_query(), Some("a"), "filter untouched"); // A second Esc (not visual) now clears the filter. let _ = app.dispatch(Action::ClearSearch); assert_eq!(app.library.filter_query(), None); } #[test] fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() { use crossterm::event::KeyCode; let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); // `/` opens search on the focused (library) pane. let _ = app.dispatch(Action::OpenSearch); assert!(app.search.is_some(), "search input open"); search_key(&mut app, "bet"); assert_eq!(app.library.filter_query(), Some("bet")); assert_eq!(app.library.get_size(), 1, "only 'beta' matches"); // Enter keeps the filter and closes the input. app.handle_search_key(key(KeyCode::Enter)); assert!(app.search.is_none()); assert_eq!(app.library.filter_query(), Some("bet")); // Reopening prefills the current query; Esc clears the filter. let _ = app.dispatch(Action::OpenSearch); assert_eq!( app.search.as_ref().map(|s| s.buffer.as_str()), Some("bet"), "reopen edits the existing query" ); app.handle_search_key(key(KeyCode::Esc)); assert!(app.search.is_none()); assert_eq!(app.library.filter_query(), None); assert_eq!(app.library.get_size(), 3, "full listing restored"); } #[test] fn esc_in_navigation_clears_an_active_filter() { use crossterm::event::KeyCode; let (mut app, _rx) = app(); app.library .update(children_listing(&["alpha", "beta", "gamma"])); // Filter, then Enter back to normal navigation (the filter stays). let _ = app.dispatch(Action::OpenSearch); search_key(&mut app, "bet"); app.handle_search_key(key(KeyCode::Enter)); assert!(app.search.is_none()); assert_eq!(app.library.filter_query(), Some("bet")); // Esc now resolves to ClearSearch (no modal open), clearing the // filter while staying in normal navigation. let _ = app.dispatch(Action::ClearSearch); assert_eq!(app.library.filter_query(), None); assert_eq!(app.library.get_size(), 3, "full listing restored"); // With no filter, ClearSearch is a harmless no-op. let _ = app.dispatch(Action::ClearSearch); assert_eq!(app.library.filter_query(), None); } #[test] fn search_targets_the_focused_pane() { let (mut app, _rx) = app(); app.library.update(children_listing(&["alpha", "beta"])); // Focus the queue; `/` must filter the queue, not the library. app.focus = UiFocus::Queue; let _ = app.dispatch(Action::OpenSearch); search_key(&mut app, "x"); assert_eq!(app.queue.filter_query(), Some("x")); assert_eq!(app.library.filter_query(), None, "library untouched"); } #[test] fn diving_into_a_node_clears_an_active_library_filter() { let (mut app, _rx) = app(); app.library.update(children_listing(&["alpha", "beta"])); let _ = app.dispatch(Action::OpenSearch); search_key(&mut app, "beta"); assert_eq!(app.library.filter_query(), Some("beta")); // A fresh listing (a new node) resets search mode. app.library .update(children_listing(&["one", "two", "three"])); assert_eq!(app.library.filter_query(), None); assert_eq!(app.library.get_size(), 3); } #[test] fn empty_creatable_nodes_are_enterable() { // /tidal/search starts with no terms; the update must still apply, // otherwise the user can never get inside to press '%'. let (mut app, _rx) = app(); app.library.update(creatable_node("/tidal/search")); assert_eq!(app.library.path(), "/tidal/search"); assert!(app.library.is_creatable()); } #[test] fn create_node_only_opens_input_on_creatable_nodes() { let (mut app, _rx) = app(); // The initial root is not creatable: '%' must be a no-op. assert_eq!( app.dispatch(Action::LibraryCreateNode), DispatchResult::Continue ); assert!(app.input.is_none()); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); let input = app.input.as_ref().expect("input overlay open"); assert!( matches!(&input.purpose, InputPurpose::Create { parent_path } if parent_path == "/tidal/search") ); assert_eq!(input.buffer, ""); } #[test] fn input_appends_and_backspace_pops() { let (mut app, _rx) = app(); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); type_str(&mut app, "abba"); assert_eq!(app.input.as_ref().unwrap().buffer, "abba"); app.handle_input_key(key(crossterm::event::KeyCode::Backspace)); assert_eq!(app.input.as_ref().unwrap().buffer, "abb"); // Backspace on an empty buffer must not panic or close the overlay. for _ in 0..5 { app.handle_input_key(key(crossterm::event::KeyCode::Backspace)); } assert_eq!(app.input.as_ref().unwrap().buffer, ""); } #[test] fn esc_cancels_without_sending() { let (mut app, rx) = app(); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); type_str(&mut app, "abba"); app.handle_input_key(key(crossterm::event::KeyCode::Esc)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "cancel must not create anything"); } #[test] fn enter_submits_trimmed_title_and_closes() { let (mut app, rx) = app(); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); type_str(&mut app, " abba "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); match rx.try_recv() { Ok(MessageFromUi::CreateNode { parent_path, title }) => { assert_eq!(parent_path, "/tidal/search"); assert_eq!(title, "abba"); } other => panic!("expected CreateNode, got {:?}", other.is_ok()), } } #[test] fn input_overlay_shows_the_buffer() { let (mut app, _rx) = app(); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); type_str(&mut app, "abba"); let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); terminal.draw(|f| app.render(f)).expect("draw app"); let buf = terminal.backend().buffer(); let mut text = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { text.push_str(buf[(x, y)].symbol()); } text.push('\n'); } assert!(text.contains("new node: abba"), "overlay with buffer"); assert!(text.contains("% to add"), "creatable pane-title hint"); } #[test] fn enter_on_empty_input_closes_without_sending() { let (mut app, rx) = app(); app.library.update(creatable_node("/tidal/search")); let _ = app.dispatch(Action::LibraryCreateNode); type_str(&mut app, " "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "whitespace-only must not create"); } /// A /tidal/search listing with one term child, modifiable or not. fn search_listing(modifiable: bool) -> LibraryNode { use crabidy_core::proto::crabidy::LibraryNodeChild; LibraryNode { children: vec![LibraryNodeChild { is_editable: modifiable, is_deletable: modifiable, ..LibraryNodeChild::new("/tidal/search/abba".to_string(), "abba".to_string(), false) }], ..creatable_node("/tidal/search") } } #[test] fn queue_ops_ignore_non_queueable_selections() { use crabidy_core::proto::crabidy::LibraryNodeChild; let (mut app, rx) = app(); // The selected child of the search listing is not queueable: Enter // (and every other queue op) must send nothing — previously this // shipped the path anyway and the zero-track replace blanked the // queue while playback continued. app.library.update(search_listing(false)); for action in [ Action::LibraryQueueReplace, Action::LibraryQueueAppend, Action::LibraryQueueNext, ] { let _ = app.dispatch(action); assert!( rx.try_recv().is_err(), "non-queueable selection sent a message for {action:?}" ); } // A queueable selection still goes through. app.library.update(LibraryNode { children: vec![LibraryNodeChild::new( "/tidal/artists/1".to_string(), "artist".to_string(), true, )], ..creatable_node("/tidal/artists") }); let _ = app.dispatch(Action::LibraryQueueReplace); match rx.try_recv() { Ok(MessageFromUi::ReplaceQueue(paths)) => { assert_eq!(paths, vec!["/tidal/artists/1".to_string()]); } other => panic!("expected ReplaceQueue, got {:?}", other.is_ok()), } } #[test] fn edit_opens_the_overlay_prefilled_only_on_editable_selections() { let (mut app, _rx) = app(); // Nothing loaded: 'e' must be a no-op. assert_eq!( app.dispatch(Action::LibraryEditNode), DispatchResult::Continue ); assert!(app.input.is_none()); app.library.update(search_listing(false)); let _ = app.dispatch(Action::LibraryEditNode); assert!(app.input.is_none(), "non-editable selection: no overlay"); app.library.update(search_listing(true)); let _ = app.dispatch(Action::LibraryEditNode); let input = app.input.as_ref().expect("rename overlay open"); assert!( matches!(&input.purpose, InputPurpose::Rename { path } if path == "/tidal/search/abba") ); assert_eq!(input.buffer, "abba", "prefilled with the current title"); } #[test] fn rename_submit_sends_the_trimmed_new_title() { let (mut app, rx) = app(); app.library.update(search_listing(true)); let _ = app.dispatch(Action::LibraryEditNode); type_str(&mut app, " tribute "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); match rx.try_recv() { Ok(MessageFromUi::RenameNode { path, new_title }) => { assert_eq!(path, "/tidal/search/abba"); assert_eq!(new_title, "abba tribute"); } other => panic!("expected RenameNode, got {:?}", other.is_ok()), } } #[test] fn rename_cancels_and_emptied_buffers_send_nothing() { let (mut app, rx) = app(); app.library.update(search_listing(true)); let _ = app.dispatch(Action::LibraryEditNode); app.handle_input_key(key(crossterm::event::KeyCode::Esc)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "Esc must not rename"); let _ = app.dispatch(Action::LibraryEditNode); for _ in 0.."abba".len() { app.handle_input_key(key(crossterm::event::KeyCode::Backspace)); } app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "emptied buffer must not rename"); } #[test] fn delete_sends_only_for_deletable_selections() { let (mut app, rx) = app(); let _ = app.dispatch(Action::LibraryDeleteNode); assert!(rx.try_recv().is_err(), "no selection: no delete"); app.library.update(search_listing(false)); let _ = app.dispatch(Action::LibraryDeleteNode); assert!(rx.try_recv().is_err(), "non-deletable selection: no delete"); app.library.update(search_listing(true)); let _ = app.dispatch(Action::LibraryDeleteNode); match rx.try_recv() { Ok(MessageFromUi::DeleteNode { path }) => { assert_eq!(path, "/tidal/search/abba"); } other => panic!("expected DeleteNode, got {:?}", other.is_ok()), } } /// A /crabidy/mix listing: one deletable track (tracks come first in /// the pane) and one deletable album folder. fn captures_listing() -> LibraryNode { use crabidy_core::proto::crabidy::{LibraryNodeChild, Track}; LibraryNode { path: "/crabidy/mix".to_string(), title: "mix".to_string(), children: vec![LibraryNodeChild { is_deletable: true, ..LibraryNodeChild::new("/crabidy/mix/album".to_string(), "album".to_string(), true) }], parent: Some("/crabidy".to_string()), tracks: vec![Track { path: "/crabidy/mix/0001%20song.cbd-track.toml".to_string(), artist: "artist".to_string(), title: "song".to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }], is_queable: true, is_creatable: false, is_downloadable: false, tracks_deletable: true, is_captured: false, } } #[test] fn capture_deletes_send_immediately_without_confirmation() { let (mut app, rx) = app(); app.library.update(captures_listing()); // The first item is the capture's track, deletable via the node's // tracks_deletable flag. Deletes under `/crabidy` now go through // directly: audio lives in a shared content store that // track-deletion never touches, so the delete is not destructive. let _ = app.dispatch(Action::LibraryDeleteNode); match rx.try_recv() { Ok(MessageFromUi::DeleteNode { path }) => { assert_eq!(path, "/crabidy/mix/0001%20song.cbd-track.toml"); } other => panic!("expected DeleteNode, got {:?}", other.is_ok()), } // Folders below the top level delete immediately too. app.library.update(captures_listing()); app.library.last(); let _ = app.dispatch(Action::LibraryDeleteNode); match rx.try_recv() { Ok(MessageFromUi::DeleteNode { path }) => { assert_eq!(path, "/crabidy/mix/album"); } other => panic!("expected DeleteNode, got {:?}", other.is_ok()), } } #[test] fn modifiable_items_are_marked_and_the_rename_overlay_is_labelled() { let (mut app, _rx) = app(); app.library.update(search_listing(true)); let draw = |app: &mut App| { let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); terminal.draw(|f| app.render(f)).expect("draw app"); let buf = terminal.backend().buffer(); let mut text = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { text.push_str(buf[(x, y)].symbol()); } text.push('\n'); } text }; let text = draw(&mut app); assert!(text.contains("abba [ed]"), "modifiable marker: {text}"); let _ = app.dispatch(Action::LibraryEditNode); let text = draw(&mut app); assert!(text.contains("rename: abba"), "rename overlay label"); } /// An artists listing with one queueable child. fn queueable_listing() -> LibraryNode { use crabidy_core::proto::crabidy::LibraryNodeChild; LibraryNode { children: vec![LibraryNodeChild::new( "/tidal/artists/1".to_string(), "artist".to_string(), true, )], ..creatable_node("/tidal/artists") } } #[test] fn capture_opens_prefilled_only_on_queueable_selections() { let (mut app, _rx) = app(); // Nothing loaded: 'w' must be a no-op. assert_eq!( app.dispatch(Action::LibraryCaptureNode), DispatchResult::Continue ); assert!(app.input.is_none()); // A non-queueable selection must not open the overlay. app.library.update(search_listing(false)); let _ = app.dispatch(Action::LibraryCaptureNode); assert!(app.input.is_none()); app.library.update(queueable_listing()); let _ = app.dispatch(Action::LibraryCaptureNode); let input = app.input.as_ref().expect("capture overlay open"); assert!(matches!( &input.purpose, InputPurpose::Capture { path, download: false } if path == "/tidal/artists/1" )); assert_eq!(input.buffer, "artist", "prefilled with the title"); } #[test] fn capture_submits_the_trimmed_name_and_esc_cancels() { let (mut app, rx) = app(); app.library.update(queueable_listing()); let _ = app.dispatch(Action::LibraryCaptureNode); type_str(&mut app, " favs "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); match rx.try_recv() { Ok(MessageFromUi::CaptureNode { path, name, download, }) => { assert_eq!(path, "/tidal/artists/1"); assert_eq!(name, "artist favs"); assert!(!download, "w captures a bookmark, not a download"); } other => panic!("expected CaptureNode, got {:?}", other.is_ok()), } let _ = app.dispatch(Action::LibraryCaptureNode); app.handle_input_key(key(crossterm::event::KeyCode::Esc)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "Esc must not capture"); } /// Like [`queueable_listing`], but the child also allows download /// captures (a Tidal subtree). fn downloadable_listing() -> LibraryNode { use crabidy_core::proto::crabidy::LibraryNodeChild; LibraryNode { children: vec![LibraryNodeChild { is_downloadable: true, ..LibraryNodeChild::new("/tidal/artists/1".to_string(), "artist".to_string(), true) }], ..creatable_node("/tidal/artists") } } #[test] fn download_capture_requires_the_downloadable_flag() { let (mut app, _rx) = app(); // Queueable but not downloadable (e.g. an /fs folder): `W` must // stay closed even though `w` would open. app.library.update(queueable_listing()); let _ = app.dispatch(Action::LibraryDownloadNode); assert!(app.input.is_none()); app.library.update(downloadable_listing()); let _ = app.dispatch(Action::LibraryDownloadNode); let input = app.input.as_ref().expect("download overlay open"); assert!(matches!( &input.purpose, InputPurpose::Capture { path, download: true } if path == "/tidal/artists/1" )); assert_eq!(input.buffer, "artist", "prefilled with the title"); } #[test] fn tracks_inherit_their_nodes_download_blessing() { use crabidy_core::proto::crabidy::Track; let track = Track { path: "/tidal/artists/1/2/3".to_string(), artist: "a".to_string(), title: "one".to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }; let listing = |downloadable| LibraryNode { tracks: vec![track.clone()], is_downloadable: downloadable, ..creatable_node("/tidal/artists/1/2") }; let (mut app, _rx) = app(); app.library.update(listing(false)); let _ = app.dispatch(Action::LibraryDownloadNode); assert!(app.input.is_none(), "unblessed node, unblessed tracks"); app.library.update(listing(true)); let _ = app.dispatch(Action::LibraryDownloadNode); let input = app.input.as_ref().expect("download overlay open"); assert!(matches!( &input.purpose, InputPurpose::Capture { path, download: true } if path == "/tidal/artists/1/2/3" )); } #[test] fn download_capture_submits_with_the_download_flag() { let (mut app, rx) = app(); app.library.update(downloadable_listing()); let _ = app.dispatch(Action::LibraryDownloadNode); type_str(&mut app, " local "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); match rx.try_recv() { Ok(MessageFromUi::CaptureNode { path, name, download, }) => { assert_eq!(path, "/tidal/artists/1"); assert_eq!(name, "artist local"); assert!(download, "W captures a download"); } other => panic!("expected CaptureNode, got {:?}", other.is_ok()), } } /// A one-track queue update, as the server would broadcast it. fn one_track_queue() -> crabidy_core::proto::crabidy::Queue { use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track}; ProtoQueue { timestamp: 0, current_position: 0, tracks: vec![Track { path: "/tidal/playlists/p/1".to_string(), artist: "artist".to_string(), title: "track".to_string(), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }], resolving: false, } } #[test] fn save_queue_opens_the_overlay_only_with_a_non_empty_queue() { let (mut app, _rx) = app(); // Nothing to save from an empty queue: 'w' must be a no-op. assert_eq!(app.dispatch(Action::QueueSaveAs), DispatchResult::Continue); assert!(app.input.is_none()); app.queue.update_queue(one_track_queue()); let _ = app.dispatch(Action::QueueSaveAs); let input = app.input.as_ref().expect("save overlay open"); assert!(matches!(input.purpose, InputPurpose::SaveQueue)); assert_eq!(input.buffer, ""); } #[test] fn queue_download_capture_targets_the_current_queue() { let (mut app, rx) = app(); // Empty queue: nothing to capture. assert_eq!( app.dispatch(Action::QueueDownloadCapture), DispatchResult::Continue ); assert!(app.input.is_none()); app.queue.update_queue(one_track_queue()); let _ = app.dispatch(Action::QueueDownloadCapture); let input = app.input.as_ref().expect("capture overlay open"); assert!( matches!( &input.purpose, InputPurpose::Capture { path, download: true } if path == "/crabidy/current" ), "queue W download-captures /crabidy/current" ); // Submitting sends a download capture of the live queue. type_str(&mut app, "party"); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); match rx.try_recv() { Ok(MessageFromUi::CaptureNode { path, name, download, }) => { assert_eq!(path, "/crabidy/current"); assert_eq!(name, "party"); assert!(download); } other => panic!("expected CaptureNode, got {:?}", other.is_ok()), } } #[test] fn save_queue_submits_the_trimmed_name_and_esc_cancels() { let (mut app, rx) = app(); app.queue.update_queue(one_track_queue()); let _ = app.dispatch(Action::QueueSaveAs); type_str(&mut app, " road trip "); app.handle_input_key(key(crossterm::event::KeyCode::Enter)); assert!(app.input.is_none()); match rx.try_recv() { Ok(MessageFromUi::SaveQueue(name)) => assert_eq!(name, "road trip"), other => panic!("expected SaveQueue, got {:?}", other.is_ok()), } let _ = app.dispatch(Action::QueueSaveAs); type_str(&mut app, "discard"); app.handle_input_key(key(crossterm::event::KeyCode::Esc)); assert!(app.input.is_none()); assert!(rx.try_recv().is_err(), "Esc must not save"); } fn render_text(app: &mut App) -> String { let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); terminal.draw(|f| app.render(f)).expect("draw app"); let buf = terminal.backend().buffer(); let mut text = String::new(); for y in 0..buf.area.height { for x in 0..buf.area.width { text.push_str(buf[(x, y)].symbol()); } text.push('\n'); } text } #[test] fn capture_board_lines_cover_all_states() { let mut p = CaptureProgress { name: "faves".to_string(), download: true, tracks_done: 3, tracks_total: 0, tracks_skipped: 0, finished: false, error: String::new(), }; // Before the enumeration finishes the total is unknown. assert_eq!(CaptureBoard::line(&p), "capturing faves 3/?"); p.tracks_total = 12; p.tracks_skipped = 2; assert_eq!(CaptureBoard::line(&p), "capturing faves 3/12 (2 skipped)"); p.finished = true; p.tracks_done = 12; assert_eq!( CaptureBoard::line(&p), "captured faves: 12 tracks (2 skipped)" ); p.error = "boom".to_string(); assert_eq!(CaptureBoard::line(&p), "capture faves failed: boom"); // Bookmarks use their own verbs. let b = CaptureProgress { name: "b".to_string(), tracks_done: 1, tracks_total: 2, ..Default::default() }; assert_eq!(CaptureBoard::line(&b), "bookmarking b 1/2"); } #[test] fn capture_progress_renders_and_reruns_replace_finished_lines() { let (mut app, _rx) = app(); app.captures.apply(CaptureProgress { name: "faves".to_string(), download: true, tracks_done: 3, tracks_total: 12, tracks_skipped: 1, finished: false, error: String::new(), }); let text = render_text(&mut app); assert!( text.contains("capturing faves 3/12 (1 skipped)"), "progress line: {text}" ); // A later event for the same name replaces the line. app.captures.apply(CaptureProgress { name: "faves".to_string(), download: true, tracks_done: 12, tracks_total: 12, tracks_skipped: 1, finished: true, error: String::new(), }); let text = render_text(&mut app); assert!(!text.contains("capturing faves"), "{text}"); assert!( text.contains("captured faves: 12 tracks (1 skipped)"), "{text}" ); } #[test] fn download_capture_overlay_warns_about_long_captures() { let (mut app, _rx) = app(); app.library.update(downloadable_listing()); let _ = app.dispatch(Action::LibraryDownloadNode); let text = render_text(&mut app); assert!( text.contains("capture (slow, resumable): artist"), "warning label: {text}" ); } #[test] fn focused_selection_darkens_colored_library_items() { // The editable [ed] item is rendered in the secondary color; under // the focused selection bar that was unreadable — the foreground // must switch to the dark tone (D7). let (mut app, _rx) = app(); app.library.update(search_listing(true)); let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).expect("test terminal"); terminal.draw(|f| app.render(f)).expect("draw app"); let buf = terminal.backend().buffer().clone(); let mut found = None; for y in 0..buf.area.height { let row: String = (0..buf.area.width) .map(|x| buf[(x, y)].symbol().to_string()) .collect(); if let Some(col) = row.find("abba [ed]") { found = Some((col as u16, y)); break; } } let (x, y) = found.expect("editable row rendered"); assert_eq!( buf[(x, y)].style().fg, Some(COLOR_PRIMARY_DARK), "focused selected editable item must be readable" ); } #[test] fn queue_clear_actions_carry_the_keep_current_flag() { let (mut app, rx) = app(); let _ = app.dispatch(Action::QueueClearKeepCurrent); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ClearQueue(true)))); let _ = app.dispatch(Action::QueueClearAll); assert!(matches!( rx.try_recv(), Ok(MessageFromUi::ClearQueue(false)) )); } /// A three-track queue for the register/mark tests. fn queue_of(paths: &[&str]) -> crabidy_core::proto::crabidy::Queue { use crabidy_core::proto::crabidy::{Queue as ProtoQueue, Track}; ProtoQueue { timestamp: 0, current_position: 0, tracks: paths .iter() .enumerate() .map(|(i, path)| Track { path: path.to_string(), artist: "artist".to_string(), title: format!("t{i}"), duration: None, album: None, is_skipped: false, provider_item_id: String::new(), is_captured: false, }) .collect(), resolving: false, } } fn filled_queue_app() -> (App, Receiver) { let (mut app, rx) = app(); app.queue.update_queue(queue_of(&["/a", "/b", "/c"])); app.focus = UiFocus::Queue; app.queue.select(Some(0)); (app, rx) } /// `d` removes every marked row in one call, with positions read off the /// current list (quality/queue-register.md G1). #[test] fn deleting_marked_rows_sends_their_current_positions() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueToggleMark); // row 0 let _ = app.dispatch(Action::QueueNext); let _ = app.dispatch(Action::QueueNext); let _ = app.dispatch(Action::QueueToggleMark); // row 2 let _ = app.dispatch(Action::QueueRemoveTrack); match rx.try_recv() { Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![0, 2]), _ => panic!("expected RemoveTracks"), } // The rows it acted on are handed to the register, and the marks are // consumed. assert_eq!(app.register.paths(), ["/a", "/c"]); assert!(!app.queue.has_marks()); } /// With nothing marked, `d` still removes just the cursor row (G15). #[test] fn deleting_without_marks_removes_the_cursor_row() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueNext); let _ = app.dispatch(Action::QueueRemoveTrack); match rx.try_recv() { Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![1]), _ => panic!("expected RemoveTracks"), } assert_eq!(app.register.paths(), ["/b"]); } /// A marked row hidden by the `/` filter still counts (G13). #[test] fn a_filtered_out_marked_row_is_still_deleted() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueToggleMark); // mark "t0" app.queue.set_filter(Some("t2".to_string())); let _ = app.dispatch(Action::QueueRemoveTrack); match rx.try_recv() { Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![0]), _ => panic!("expected RemoveTracks"), } } /// `y` fills the register without removing anything (G5, G14). #[test] fn yanking_fills_the_register_and_sends_nothing() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueToggleMark); let _ = app.dispatch(Action::QueueYank); assert_eq!(app.register.paths(), ["/a"]); assert_eq!(app.register.labels(), ["artist - t0"]); assert!(!app.queue.has_marks()); assert!(rx.try_recv().is_err(), "yank must not talk to the server"); } /// `c`/`C` hand what they drop to the register first (G4). #[test] fn clear_fills_the_register_with_what_it_dropped() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueClearAll); assert_eq!(app.register.paths(), ["/a", "/b", "/c"]); assert!(matches!( rx.try_recv(), Ok(MessageFromUi::ClearQueue(false)) )); // `c` keeps the current track, so it is not in the register either. let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueClearKeepCurrent); assert_eq!(app.register.paths(), ["/b", "/c"]); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ClearQueue(true)))); } /// Nothing but `y`/`d`/`c`/`C` writes the register (G5). #[test] fn marking_and_queueing_do_not_touch_the_register() { let (mut app, _rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueYank); let before = app.register.clone(); for action in [ Action::QueueToggleMark, Action::QueueVisualMode, Action::QueueNext, Action::QueuePrev, Action::TogglePlay, Action::QueueSelectCurrent, ] { let _ = app.dispatch(action); } assert_eq!(app.register, before); } /// `p` inserts after the cursor, `P` before it (G8). #[test] fn paste_after_and_before_use_the_right_position() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueNext); // cursor on position 1 let _ = app.dispatch(Action::QueueYank); let _ = app.dispatch(Action::QueuePaste); match rx.try_recv() { Ok(MessageFromUi::InsertTracks(paths, pos)) => { assert_eq!(paths, vec!["/b".to_string()]); assert_eq!(pos, 2, "p pastes after the cursor"); } _ => panic!("expected InsertTracks"), } let _ = app.dispatch(Action::QueuePasteBefore); match rx.try_recv() { Ok(MessageFromUi::InsertTracks(_, pos)) => { assert_eq!(pos, 1, "P pastes before the cursor"); } _ => panic!("expected InsertTracks"), } } /// The register survives a paste, so the same yank pastes twice (G7). #[test] fn pasting_twice_inserts_twice() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueYank); let _ = app.dispatch(Action::QueuePaste); let _ = app.dispatch(Action::QueuePaste); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::InsertTracks(..)))); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::InsertTracks(..)))); assert!(!app.register.is_empty()); } /// Deleting then pasting before restores the rows where they were (G8). #[test] fn delete_then_paste_before_restores_the_positions() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueNext); // cursor on position 1 let _ = app.dispatch(Action::QueueRemoveTrack); assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RemoveTracks(_)))); // The server echoes the shortened queue; the cursor stays on 1, which // is where the deleted row belongs. app.queue.update_queue(queue_of(&["/a", "/c"])); let _ = app.dispatch(Action::QueuePasteBefore); match rx.try_recv() { Ok(MessageFromUi::InsertTracks(paths, pos)) => { assert_eq!(paths, vec!["/b".to_string()]); assert_eq!(pos, 1); } _ => panic!("expected InsertTracks"), } } /// An empty register pastes nothing at all (G9). #[test] fn pasting_an_empty_register_sends_nothing() { let (mut app, rx) = filled_queue_app(); let _ = app.dispatch(Action::QueuePaste); let _ = app.dispatch(Action::QueuePasteBefore); assert!(rx.try_recv().is_err()); } /// `y` in the library fills the register under the queueing gate (G5). #[test] fn library_yank_fills_the_register() { let (mut app, rx) = app(); app.library.update(children_listing(&["alpha", "beta"])); app.library.select(Some(0)); let _ = app.dispatch(Action::LibraryYank); assert!(!app.register.is_empty()); assert!(rx.try_recv().is_err(), "yank must not talk to the server"); } /// Queue visual mode paints the swept range, and `Esc` leaves it keeping /// the marks (G11). #[test] fn queue_visual_mode_paints_and_esc_leaves_it() { let (mut app, _rx) = filled_queue_app(); let _ = app.dispatch(Action::QueueVisualMode); assert!(app.queue.is_visual()); let _ = app.dispatch(Action::QueueNext); let _ = app.dispatch(Action::QueueNext); assert_eq!(app.queue.marked_positions(), vec![0, 1, 2]); // Sweeping back reverses. let _ = app.dispatch(Action::QueuePrev); assert_eq!(app.queue.marked_positions(), vec![0, 1]); let _ = app.dispatch(Action::ClearSearch); assert!(!app.queue.is_visual()); assert_eq!(app.queue.marked_positions(), vec![0, 1]); } }