673 lines
26 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|