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, 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, /// 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, } /// 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) -> 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 { 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) { 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 { 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, Vec) { 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, Vec) { 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 { 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, Vec) { 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, Vec) { let positions: Vec = (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 = self.list.iter().map(|i| i.path.clone()).collect(); let old_marked: Vec = self.list.iter().map(|i| i.marked).collect(); let new_paths: Vec = 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 = 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::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) { self.list_state.select(idx); } fn selected(&self) -> Option { 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 { self.visual } fn set_visual(&mut self, anchor: Option) { self.visual = anchor; } fn selected_view(&self) -> Option { 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 { 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::() }) .collect() } /// The row content inside the borders, trimmed. fn inner_rows(queue: &mut Queue) -> Vec { 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" ); } }