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

673 lines
26 KiB
Rust

use flume::Sender;
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::Span,
widgets::{Block, BorderType, Borders, List, ListItem, ListState},
Frame,
};
use crabidy_core::proto::crabidy::Queue as QueueData;
use super::{
carry_marks, Filter, MarkedPane, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN,
COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
};
pub struct Queue {
current_position: usize,
list: Vec<UiItem>,
list_state: ListState,
/// The `/` search filter; selection and rendering go through it, and
/// it maps view rows back to real queue positions before they are
/// sent to the server.
filter: Filter,
/// True while the server is still resolving queued paths (from
/// `Queue.resolving`); the pane then renders an animated-dots
/// pseudo-item after the last track. The pseudo-item exists only at
/// render time — it never enters `list`, so selection and removal
/// cannot reach it.
resolving: bool,
/// Visual (paint-select) mode: `Some(anchor_view)` while active, exactly
/// as in the library (architecture/queue-register.md D7).
visual: Option<usize>,
/// The result of the last dedup and when it arrived: shown in the pane
/// title for [`NOTICE_LINGER`], then gone
/// (architecture/queue-order.md D15).
notice: Option<(String, std::time::Instant)>,
tx: Sender<MessageFromUi>,
}
/// How long a dedup result stays in the pane title.
const NOTICE_LINGER: std::time::Duration = std::time::Duration::from_secs(4);
impl Queue {
pub fn new(tx: Sender<MessageFromUi>) -> Self {
Self {
current_position: 0,
list: Vec::new(),
list_state: ListState::default(),
filter: Filter::default(),
resolving: false,
visual: None,
notice: None,
tx,
}
}
/// Shows how many entries a dedup removed, in the pane title, for a few
/// seconds (architecture/queue-order.md D15). `0` is worth saying: it
/// means the queue held no duplicates.
pub fn show_dedup_result(&mut self, removed: u32) {
let text = match removed {
0 => "no duplicates".to_string(),
1 => "removed 1 duplicate".to_string(),
n => format!("removed {n} duplicates"),
};
self.notice = Some((text, std::time::Instant::now()));
}
/// The title note to render right now, or `None` once it has expired.
/// Separate from [`Self::show_dedup_result`] so the expiry is checked at
/// render time rather than on a timer.
fn notice(&self) -> Option<&str> {
self.notice
.as_ref()
.filter(|(_, at)| at.elapsed() < NOTICE_LINGER)
.map(|(text, _)| text.as_str())
}
/// The real queue position under the cursor, mapped through the
/// active filter — this is what the server-facing ops send.
fn selected_position(&self) -> Option<usize> {
self.filter.to_real(self.list_state.selected()?)
}
/// Sets (or clears) the `/` search query and re-selects a valid row.
pub fn set_filter(&mut self, query: Option<String>) {
self.filter
.set(query, self.list.iter().map(|i| i.title.as_str()));
self.update_selection();
}
/// The active search query, if the pane is in search mode.
pub fn filter_query(&self) -> Option<&str> {
self.filter.query()
}
/// The loading indicator line: one to three dots, cycling with wall
/// time (~400 ms per step). Pure so the animation is testable.
fn loading_dots(elapsed_ms: u128) -> String {
".".repeat(1 + (elapsed_ms / 400 % 3) as usize)
}
pub fn play_next(&self) {
let _ = self.tx.send(MessageFromUi::NextTrack);
}
pub fn play_prev(&self) {
let _ = self.tx.send(MessageFromUi::PrevTrack);
}
pub fn play_selected(&self) {
if let Some(pos) = self.selected_position() {
let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
}
}
pub fn select_current(&mut self) {
// Map the real playing position to its view row; if the filter
// hides it, leave the cursor where it is.
if let Some(view) = self.filter.to_view(self.current_position) {
self.select(Some(view));
}
}
/// The real queue positions an action applies to: every marked row, or
/// the cursor row when nothing is marked. Positions are read off the
/// **current** list, so they always match the newest snapshot
/// (architecture/queue-register.md D5).
fn action_positions(&self) -> Vec<usize> {
if self.has_marks() {
return self
.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect();
}
self.selected_position().into_iter().collect()
}
/// `d`: remove the marked rows (or the cursor row), handing them to the
/// register first so `p`/`P` can bring them back.
pub fn remove_track(&mut self) -> (Vec<String>, Vec<String>) {
let positions = self.action_positions();
if positions.is_empty() {
return (Vec::new(), Vec::new());
}
let yanked = self.entries_at(&positions);
if self.tx.send(MessageFromUi::RemoveTracks(positions)).is_ok() {
self.remove_marks();
}
yanked
}
/// `y`: put the marked rows (or the cursor row) in the register without
/// removing anything. Consumes the marks, like queueing does.
pub fn yank(&mut self) -> (Vec<String>, Vec<String>) {
let yanked = self.entries_at(&self.action_positions());
if !yanked.0.is_empty() {
self.remove_marks();
}
yanked
}
/// Real positions of the marked rows, in queue order (inspection helper,
/// mirroring the library's `marked_titles`).
pub fn marked_positions(&self) -> Vec<usize> {
self.list
.iter()
.enumerate()
.filter(|(_, item)| item.marked)
.map(|(pos, _)| pos)
.collect()
}
/// Paths and labels of the given real positions, in queue order.
fn entries_at(&self, positions: &[usize]) -> (Vec<String>, Vec<String>) {
let mut paths = Vec::with_capacity(positions.len());
let mut labels = Vec::with_capacity(positions.len());
for pos in positions {
if let Some(item) = self.list.get(*pos) {
paths.push(item.path.clone());
labels.push(item.title.clone());
}
}
(paths, labels)
}
/// Every queue entry, for `c`/`C` to hand to the register before the
/// server drops them. `keep_current` mirrors the RPC's flag.
pub fn all_entries(&self, keep_current: bool) -> (Vec<String>, Vec<String>) {
let positions: Vec<usize> = (0..self.list.len())
.filter(|pos| !(keep_current && *pos == self.current_position))
.collect();
self.entries_at(&positions)
}
/// The insert position for a paste: after the cursor for `p`, at the
/// cursor for `P` (which restores what `d` just removed). An empty queue
/// pastes at the front.
pub fn paste_position(&self, before: bool) -> usize {
match self.selected_position() {
Some(pos) if before => pos,
Some(pos) => pos + 1,
None => 0,
}
}
pub fn update_position(&mut self, pos: usize) {
self.current_position = pos;
}
pub fn update_queue(&mut self, queue: QueueData) {
self.current_position = queue.current_position as usize;
self.resolving = queue.resolving;
// The queue is server-pushed and rebuilt on every change, so marks
// are carried across by matching track paths rather than indices —
// otherwise a mark would silently retarget when playback advances
// (architecture/queue-register.md D5).
let old_paths: Vec<String> = self.list.iter().map(|i| i.path.clone()).collect();
let old_marked: Vec<bool> = self.list.iter().map(|i| i.marked).collect();
let new_paths: Vec<String> = queue.tracks.iter().map(|t| t.path.clone()).collect();
let carried = carry_marks(&old_paths, &old_marked, &new_paths);
self.list = queue
.tracks
.iter()
.enumerate()
.map(|(idx, t)| UiItem {
path: t.path.clone(),
title: format!("{} - {}", t.artist, t.title),
kind: UiItemKind::Track,
marked: carried.get(idx).copied().unwrap_or(false),
is_queable: false,
is_creatable: false,
is_editable: false,
is_deletable: false,
is_downloadable: false,
is_skipped: t.is_skipped,
is_captured: t.is_captured,
})
.collect();
// The queue is re-sent often (position ticks, resolving); keep
// any active search and just recompute which rows it matches.
self.filter
.recompute(self.list.iter().map(|i| i.title.as_str()));
self.update_selection();
}
/// Draws the pane. `register_len` is the number of entries `p`/`P` would
/// paste; a non-zero count is shown in the title so a paste is never
/// blind.
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool, register_len: usize) {
let selected = self.list_state.selected();
// Render only the visible rows; `view` is the rendered index the
// selection bar keys off, `real` the queue position (which drives
// the playing marker).
let mut queue_items: Vec<ListItem> = self
.filter
.visible()
.iter()
.map(|&real| (real, &self.list[real]))
.enumerate()
.map(|(idx, (real, item))| {
let active = real == self.current_position;
// Markers, in the library's vocabulary: `>` is the playing
// track, `*` is a mark (`s`, or painted in visual mode). A
// row can be both.
let mut title = String::new();
if active {
title.push_str("> ");
}
if item.marked {
title.push_str("* ");
}
title.push_str(&item.title);
let mut style = if active {
Style::default().fg(COLOR_RED).add_modifier(Modifier::BOLD)
} else if item.marked {
// Same green as a marked library row.
Style::default()
.fg(COLOR_GREEN)
.add_modifier(Modifier::BOLD)
} else if item.is_skipped {
// No playable audio: rendered red (not bold — the
// playing marker keeps precedence), skipped by
// playback (architecture/incremental-captures.md).
Style::default().fg(COLOR_RED)
} else {
Style::default()
};
// A colored foreground is unreadable on the light focused
// selection bar — switch it to the dark tone there (D7).
if focused && selected == Some(idx) && style.fg.is_some() {
style = style.fg(COLOR_PRIMARY_DARK);
}
ListItem::new(Span::from(title)).style(style)
})
.collect();
if self.resolving {
// Render-time pseudo-item: more tracks are on their way. It is
// not part of `self.list`, so it can never be selected or
// removed. The render loop redraws at least every 100 ms,
// which keeps the dots moving.
static RENDERED_FIRST_AT: std::sync::OnceLock<std::time::Instant> =
std::sync::OnceLock::new();
let elapsed = RENDERED_FIRST_AT
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis();
queue_items.push(
ListItem::new(Span::from(Self::loading_dots(elapsed)))
.style(Style::default().fg(COLOR_SECONDARY)),
);
}
let queue_list = List::new(queue_items)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(if focused {
COLOR_PRIMARY
} else {
COLOR_PRIMARY_DARK
}))
// One title, several claimants: a mode the user is *in*
// (visual, an active search) outranks a result they have
// already been shown, which outranks the standing register
// count (architecture/queue-order.md D15).
.title(match (self.visual.is_some(), self.filter.query()) {
(true, _) => "Queue — VISUAL".to_string(),
(false, Some(query)) => format!("Queue — /{query}"),
(false, None) => match (self.notice(), register_len) {
(Some(notice), _) => format!("Queue — {notice}"),
(None, 0) => "Queue".to_string(),
(None, n) => format!("Queue — register: {n}"),
},
}),
)
.highlight_style(Style::default().bg(if focused {
COLOR_PRIMARY
} else {
COLOR_PRIMARY_DARK
}));
f.render_stateful_widget(queue_list, area, &mut self.list_state);
}
}
impl StatefulList for Queue {
fn get_size(&self) -> usize {
// Navigation operates on the filtered (visible) view.
self.filter.view_len()
}
fn select(&mut self, idx: Option<usize>) {
self.list_state.select(idx);
}
fn selected(&self) -> Option<usize> {
self.list_state.selected()
}
}
impl MarkedPane for Queue {
fn items(&self) -> &[UiItem] {
&self.list
}
fn items_mut(&mut self) -> &mut [UiItem] {
&mut self.list
}
fn filter(&self) -> &Filter {
&self.filter
}
fn visual(&self) -> Option<usize> {
self.visual
}
fn set_visual(&mut self, anchor: Option<usize>) {
self.visual = anchor;
}
fn selected_view(&self) -> Option<usize> {
self.list_state.selected()
}
/// Every queue row is a track, so every row may be marked — the
/// library's `is_queable` gate does not apply here (queue rows carry
/// `is_queable: false`).
fn markable(&self, _item: &UiItem) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crabidy_core::proto::crabidy::Track;
use ratatui::{backend::TestBackend, Terminal};
fn queue_data(titles: &[&str], resolving: bool) -> QueueData {
QueueData {
timestamp: 0,
current_position: 0,
tracks: titles
.iter()
.map(|t| Track {
path: format!("/tidal/x/{t}"),
artist: "artist".to_string(),
title: t.to_string(),
duration: None,
album: None,
is_skipped: false,
provider_item_id: String::new(),
is_captured: false,
})
.collect(),
resolving,
}
}
fn rendered_rows(queue: &mut Queue) -> Vec<String> {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect::<String>()
})
.collect()
}
/// The row content inside the borders, trimmed.
fn inner_rows(queue: &mut Queue) -> Vec<String> {
rendered_rows(queue)
.iter()
.skip(1)
.map(|row| row.trim_matches(['│', ' ']).to_string())
.collect()
}
fn dots_row_count(rows: &[String]) -> usize {
rows.iter()
.filter(|row| !row.is_empty() && row.chars().all(|c| c == '.'))
.count()
}
#[test]
fn resolving_queue_renders_trailing_dots_item() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], true));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 1, "rows: {rows:?}");
// The dots trail the tracks: they come after the last track row.
let last_track = rows.iter().position(|r| r.contains("two")).unwrap();
let dots = rows
.iter()
.position(|r| !r.is_empty() && r.chars().all(|c| c == '.'))
.unwrap();
assert!(last_track < dots, "rows: {rows:?}");
}
#[test]
fn settled_queue_has_no_dots_item() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
}
#[test]
fn resolving_flag_clears_with_the_next_update() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one"], true));
queue.update_queue(queue_data(&["one", "two"], false));
let rows = inner_rows(&mut queue);
assert_eq!(dots_row_count(&rows), 0, "rows: {rows:?}");
}
#[test]
fn dots_item_is_outside_the_selectable_list() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], true));
// Selection, removal and navigation all key off get_size; the
// pseudo-item must not be reachable through any of them.
assert_eq!(queue.get_size(), 2);
}
#[test]
fn loading_dots_cycle_one_to_three() {
assert_eq!(Queue::loading_dots(0), ".");
assert_eq!(Queue::loading_dots(400), "..");
assert_eq!(Queue::loading_dots(800), "...");
assert_eq!(Queue::loading_dots(1200), ".");
}
/// Renders and returns the buffer plus the y of the row containing
/// `needle` and the x of its first character.
#[test]
fn marked_rows_render_a_star_like_the_library() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.select(Some(1));
queue.toggle_mark();
let rows = rendered_rows(&mut queue);
// The playing row keeps `>`; the marked row gets `*`.
assert!(
rows.iter().any(|row| row.contains("* artist - two")),
"marked row needs a star: {rows:?}"
);
// And the title says so while visual mode is on.
queue.toggle_visual();
assert!(
rendered_rows(&mut queue)
.iter()
.any(|row| row.contains("VISUAL")),
"visual mode needs a title indicator"
);
}
fn render_and_find(queue: &mut Queue, needle: &str) -> (ratatui::buffer::Buffer, u16, u16) {
let backend = TestBackend::new(40, 8);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|f| queue.render(f, f.area(), true, 0))
.expect("draw");
let buffer = terminal.backend().buffer().clone();
for y in 0..buffer.area.height {
let row: String = (0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol().to_string())
.collect();
if let Some(col) = row.find(needle) {
return (buffer, col as u16, y);
}
}
panic!("row containing {needle:?} not found");
}
#[test]
fn skipped_tracks_render_red() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
let mut data = queue_data(&["one", "two"], false);
data.tracks[1].is_skipped = true;
queue.update_queue(data);
// Selection sits on row 0; the unselected skipped row is red.
let (buffer, x, y) = render_and_find(&mut queue, "artist - two");
assert_eq!(
buffer[(x, y)].style().fg,
Some(super::COLOR_RED),
"skipped tracks must be red"
);
// The playing track keeps its red marker when not under the bar.
queue.select(Some(1));
let (buffer, x, y) = render_and_find(&mut queue, "> artist - one");
assert_eq!(buffer[(x, y)].style().fg, Some(super::COLOR_RED));
}
#[test]
fn filtering_narrows_the_view_and_maps_removal_to_the_real_position() {
let (tx, rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
// "gam" matches only the third track (real position 2).
queue.set_filter(Some("gam".to_string()));
assert_eq!(queue.get_size(), 1, "one visible row");
// The single visible row is view index 0; removing it must send
// the *real* queue position, not the view index.
queue.select(Some(0));
queue.remove_track();
match rx.try_recv() {
Ok(MessageFromUi::RemoveTracks(positions)) => assert_eq!(positions, vec![2]),
other => panic!("expected RemoveTracks([2]), got {:?}", other.is_ok()),
}
// Clearing the filter restores the full view.
queue.set_filter(None);
assert_eq!(queue.get_size(), 3);
}
#[test]
fn the_filter_survives_queue_updates() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
queue.set_filter(Some("beta".to_string()));
assert_eq!(queue.get_size(), 1);
// A stream re-send (e.g. a position tick) keeps the active search
// and just recomputes which rows match.
queue.update_queue(queue_data(&["alpha", "beta", "gamma"], false));
assert_eq!(queue.get_size(), 1, "search preserved across updates");
assert_eq!(queue.filter_query(), Some("beta"));
}
/// The dedup result is shown in the pane title, and `0` is shown too — it
/// is the answer "no duplicates" (architecture/queue-order.md D15).
#[test]
fn the_dedup_result_appears_in_the_title() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one", "two"], false));
queue.show_dedup_result(7);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains('7'), "title: {title:?}");
// Zero is an answer, not a non-event: it has to say so in words a
// user can act on, not read as a dropped keypress.
queue.show_dedup_result(0);
let title = rendered_rows(&mut queue).remove(0);
assert!(
title.contains("no duplicates"),
"a zero count is still an answer: {title:?}"
);
}
#[test]
fn the_dedup_result_expires() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["one"], false));
queue.show_dedup_result(3);
assert!(queue.notice().is_some());
// Backdate the stamp past the linger; the expiry is checked at render
// time, so nothing else has to run.
queue.notice = queue.notice.take().map(|(text, at)| {
(
text,
at - NOTICE_LINGER - std::time::Duration::from_millis(1),
)
});
assert!(queue.notice().is_none());
let title = rendered_rows(&mut queue).remove(0);
assert!(
!title.contains('3'),
"expired notice still shown: {title:?}"
);
}
/// The title has one slot and several claimants; a mode the user is *in*
/// outranks a result they have already seen.
#[test]
fn an_active_search_outranks_the_dedup_result() {
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
queue.update_queue(queue_data(&["alpha", "beta"], false));
queue.set_filter(Some("bet".to_string()));
queue.show_dedup_result(2);
let title = rendered_rows(&mut queue).remove(0);
assert!(title.contains("/bet"), "title: {title:?}");
}
#[test]
fn colored_rows_darken_under_the_focused_selection_bar() {
// A red (skipped) row under the light focused selection bar was
// unreadable; the foreground switches to the dark tone there
// (architecture/incremental-captures.md D7).
let (tx, _rx) = flume::unbounded();
let mut queue = Queue::new(tx);
let mut data = queue_data(&["one", "two"], false);
data.tracks[1].is_skipped = true;
queue.update_queue(data);
queue.select(Some(1));
let (buffer, x, y) = render_and_find(&mut queue, "two");
assert_eq!(
buffer[(x, y)].style().fg,
Some(super::COLOR_PRIMARY_DARK),
"selected colored rows must use the dark foreground"
);
}
}