2168 lines
82 KiB
Rust
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]);
|
|
}
|
|
}
|