crabidy/cbd-tui/src/app/mod.rs

2168 lines
82 KiB
Rust

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/<name>` in the library on the next visit).
SaveQueue(String),
/// Capture the queueable subtree at `path` as the bookmark `name`
/// (structure-preserving snapshot under `/crabidy/<name>`), or —
/// with `download` — as the capture `name` under `/crabidy/<name>`
/// with every track's audio downloaded next to its toml.
CaptureNode {
path: String,
name: String,
download: bool,
},
AppendTracks(Vec<String>),
QueueTracks(Vec<String>),
InsertTracks(Vec<String>, usize),
RemoveTracks(Vec<usize>),
ReplaceQueue(Vec<String>),
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<std::time::Instant>,
}
/// 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<CaptureEntry>,
}
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<InputState>,
/// `Some` while a `/` search input is open; modal like the others.
pub search: Option<SearchState>,
/// 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<MessageFromUi>,
}
impl App {
pub fn new(tx: Sender<MessageFromUi>) -> 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<String>, Vec<String>)) {
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<MessageFromUi>) {
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<MessageFromUi>) {
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]);
}
}