diff --git a/README.md b/README.md index e906fa9..6749784 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,8 @@ Navigation is vim-style: `j`/`k` select, `l` enters the selected folder, `h` goes to the parent, `Tab` switches between library and queue, `Enter` replaces the queue with the selection. `%` creates a node where the pane title shows `% to add` (e.g. a search term), `e` -renames, `d` deletes. +renames, `d` deletes. `/` filters the current pane (library or queue) +live as you type — `Enter` keeps the filter, `Esc` clears it. - `w` saves the selection as a **bookmark** (links; needs the source provider to replay) or, in the queue pane, saves the queue. diff --git a/architecture/tui-search.md b/architecture/tui-search.md new file mode 100644 index 0000000..5a6230a --- /dev/null +++ b/architecture/tui-search.md @@ -0,0 +1,56 @@ +# TUI `/` search filter + +Pressing `/` in the library or queue pane opens a search input that +filters that pane's items live as you type (case-insensitive +substring). A small feature; recorded here for the couple of decisions +that were not obvious. + +## Decisions + +### D1 — filter, not jump + +Vim's `/` jumps to the next match; here `/` *narrows* the visible list +to matching rows. For a music library ("show me everything with +'radiohead'") filtering is the more useful reading of "search in the +items", and it composes with the existing queue/mark actions — you +filter, then act on what is left. + +### D2 — the filter lives on the pane, the input on the app + +The pane (`Library`/`Queue`) owns a `Filter` (the query plus the list +of visible real indices). The `/` input mode (`App::search`) only holds +the editing buffer and which pane is focused. So the filter *survives* +closing the input: `Enter` keeps it applied and returns to navigation, +`Esc` clears it. This matches the other modal overlays (input, confirm) +in how keys are routed while it is open. + +### D3 — view indices vs real indices + +The panes keep their full item list; the filter maps between the +*view* index (what the selection bar and `StatefulList` navigation key +off) and the *real* index into the list. This matters most for the +queue: removal and set-current send **real queue positions** to the +server, so a filtered selection must map back before it is sent — +otherwise `d` on the third visible row would remove the wrong track. +Marks likewise live on the full list, so a marked-but-hidden item still +counts when queueing. + +Because `StatefulList` already routes through `get_size`/`select`/ +`selected`, pointing those at the filtered view made all the movement +keys (`j`/`k`/`g`/`G`/`Ctrl-d`/`Ctrl-u`) work on the filtered list with +no per-key changes. + +### D4 — lifecycle + +- **Library**: entering a node is a fresh listing, so search mode is + reset there (a stale filter from the previous folder would be + confusing). +- **Queue**: the queue is re-sent constantly (position ticks, + resolving), so an active search is *preserved* across updates and + only its visible set is recomputed. + +## Scope + +Implemented in the TUI only, per the request. The web client +(`architecture/web-client.md`) could mirror it later for parity; noted +as a follow-up, not done here. diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index 7682460..328620f 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -78,6 +78,11 @@ pub enum Action { /// every track's audio into `/captures`. No-op unless the bare /// selection is queueable *and* downloadable (e.g. Tidal subtrees). LibraryDownloadNode, + /// Open the `/` search input for the focused pane. Typing filters the + /// pane's items live (case-insensitive substring); `Enter` keeps the + /// filter, `Esc` clears it. Bound in both the library and queue + /// scopes; the dispatch targets whichever pane has focus. + OpenSearch, // Queue pane QueueInsertHere, QueueFirst, @@ -321,6 +326,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::LibraryQueueReplace, description: "Replace queue with selection", }, + Binding { + scope: Scope::Library, + mods: KeyModifiers::NONE, + code: KeyCode::Char('/'), + action: Action::OpenSearch, + description: "Filter this view (type to search, Enter keeps, Esc clears)", + }, // -- Queue ----------------------------------------------------------- Binding { scope: Scope::Queue, @@ -413,6 +425,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::QueueSaveAs, description: "Save queue under a name", }, + Binding { + scope: Scope::Queue, + mods: KeyModifiers::NONE, + code: KeyCode::Char('/'), + action: Action::OpenSearch, + description: "Filter this view (type to search, Enter keeps, Esc clears)", + }, // -- Help modal ------------------------------------------------------ Binding { scope: Scope::Help, diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index 43f6eee..7ad3445 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -12,7 +12,7 @@ use ratatui::{ use crabidy_core::proto::crabidy::LibraryNode; use super::{ - MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, + Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_GREEN, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY, }; @@ -25,6 +25,8 @@ pub struct Library { is_creatable: bool, list: Vec, list_state: ListState, + /// The `/` search filter; selection and rendering go through it. + filter: Filter, parent: Option, positions: HashMap, tx: Sender, @@ -38,12 +40,32 @@ impl Library { is_creatable: false, list: Vec::new(), list_state: ListState::default(), + filter: Filter::default(), positions: HashMap::new(), parent: None, tx, } } + /// The item under the cursor, mapped through the active filter. + fn resolved(&self) -> Option<&UiItem> { + let real = self.filter.to_real(self.list_state.selected()?)?; + self.list.get(real) + } + + /// Sets (or clears) the `/` search query and re-selects the first + /// match so the cursor never points at a now-hidden 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() + } + /// Path of the currently open node. pub fn path(&self) -> &str { &self.path @@ -56,14 +78,14 @@ impl Library { /// Path and current title of the selected item, if it may be renamed /// (`e`). `None` when nothing is selected or the item is not editable. pub fn selected_editable(&self) -> Option<(String, String)> { - let item = self.list.get(self.list_state.selected()?)?; + let item = self.resolved()?; item.is_editable .then(|| (item.path.clone(), item.title.clone())) } /// Path and title of the selected item, if it may be deleted (`d`). /// `None` when nothing is selected or the item is not deletable. pub fn selected_deletable(&self) -> Option<(String, String)> { - let item = self.list.get(self.list_state.selected()?)?; + let item = self.resolved()?; item.is_deletable .then(|| (item.path.clone(), item.title.clone())) } @@ -71,7 +93,7 @@ impl Library { /// captures as a bookmark. Marks are deliberately ignored: one capture /// per invocation (architecture/bookmarks.md D5). pub fn selected_queueable(&self) -> Option<(String, String)> { - let item = self.list.get(self.list_state.selected()?)?; + let item = self.resolved()?; item.is_queable .then(|| (item.path.clone(), item.title.clone())) } @@ -79,10 +101,12 @@ impl Library { /// download (`W`): queueable *and* downloadable. Marks are ignored, /// like [`Self::selected_queueable`]. pub fn selected_downloadable(&self) -> Option<(String, String)> { - let item = self.list.get(self.list_state.selected()?)?; + let item = self.resolved()?; (item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone())) } pub fn get_selected(&self) -> Option> { + // Marks live on the full list; a hidden marked item still counts + // (the filter narrows what you *see*, not what you already chose). if self.list.iter().any(|i| i.marked) { return Some( self.list @@ -96,7 +120,7 @@ impl Library { // gated here too, or Enter on a plain folder ships a path the server // can only resolve to nothing (silently ignored, like % / e / d on // items without the capability). - let item = self.list.get(self.list_state.selected()?)?; + let item = self.resolved()?; item.is_queable.then(|| vec![item.path.to_string()]) } pub fn ascend(&mut self) { @@ -105,8 +129,7 @@ impl Library { } } pub fn dive(&mut self) { - if let Some(idx) = self.list_state.selected() { - let item = &self.list[idx]; + if let Some(item) = self.resolved() { if let UiItemKind::Node = item.kind { let _ = self .tx @@ -150,8 +173,12 @@ impl Library { *self.positions.get(&self.path).unwrap_or(&0) } pub fn toggle_mark(&mut self) { - if let Some(idx) = self.list_state.selected() { - let item = &mut self.list[idx]; + if let Some(real) = self + .list_state + .selected() + .and_then(|view| self.filter.to_real(view)) + { + let item = &mut self.list[real]; if !item.is_queable { return; } @@ -178,7 +205,6 @@ impl Library { self.title = node.title; self.parent = node.parent; self.is_creatable = node.is_creatable; - self.select(Some(self.prev_selected())); // Most nodes carry either children or tracks; search term nodes // carry both (track results + artist/album results), so the list is @@ -215,14 +241,24 @@ impl Library { })) .collect(); + // A new node is a fresh listing: leave search mode and show all. + self.filter + .set(None, self.list.iter().map(|i| i.title.as_str())); + // With no filter the view index is the real index, so the + // remembered cursor position restores directly. + self.select(Some(self.prev_selected())); self.update_selection(); } pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { let selected = self.list_state.selected(); + // Render only the rows the filter keeps visible; `view` is the + // rendered index the selection bar keys off, `i` the real item. let library_items: Vec = self - .list + .filter + .visible() .iter() + .map(|&real| &self.list[real]) .enumerate() .map(|(idx, i)| { let mut text = if i.marked { @@ -276,7 +312,10 @@ impl Library { } else { COLOR_PRIMARY_DARK })) - .title(if self.is_creatable { + .title(if let Some(query) = self.filter.query() { + // Search mode: show the live query with a cursor. + format!("{} — /{query}▏", self.title) + } else if self.is_creatable { format!("{} — % to add", self.title) } else { self.title.clone() @@ -298,15 +337,21 @@ impl Library { impl StatefulList for Library { fn get_size(&self) -> usize { - self.list.len() + // Navigation operates on the filtered (visible) view. + self.filter.view_len() } fn select(&mut self, idx: Option) { - if let Some(pos) = idx { - self.positions - .entry(self.path.clone()) - .and_modify(|e| *e = pos) - .or_insert(pos); + // Remember the cursor per node as a real index so it survives a + // filter (which only reorders the view). With no filter, the + // view index is already the real index. + if let Some(view) = idx { + if let Some(real) = self.filter.to_real(view) { + self.positions + .entry(self.path.clone()) + .and_modify(|e| *e = real) + .or_insert(real); + } } self.list_state.select(idx); } diff --git a/cbd-tui/src/app/list.rs b/cbd-tui/src/app/list.rs index a39876e..cacec7a 100644 --- a/cbd-tui/src/app/list.rs +++ b/cbd-tui/src/app/list.rs @@ -1,3 +1,75 @@ +/// A case-insensitive substring filter over a pane's item list (the +/// `/` search, shared by the library and queue panes). +/// +/// The pane keeps its full item list; the filter only records which +/// real indices are currently visible, so navigation and — crucially +/// for the queue — the real positions sent to the server stay correct. +/// An *active* filter with an empty query shows everything (search mode +/// is on, nothing typed yet); an inactive filter also shows everything. +#[derive(Default)] +pub struct Filter { + /// `None` = not searching; `Some(query)` = search mode, query so far. + query: Option, + /// Real indices currently visible, in list order. Rebuilt by + /// [`Self::recompute`]; always `0..len` while inactive or empty. + visible: Vec, +} + +impl Filter { + /// Whether search mode is on (the query line is showing). + pub fn is_active(&self) -> bool { + self.query.is_some() + } + + /// The current query, if searching. + pub fn query(&self) -> Option<&str> { + self.query.as_deref() + } + + /// Enters/updates/leaves search mode and recomputes the visible set + /// against `titles` (the pane's full list, in order). + pub fn set<'a>(&mut self, query: Option, titles: impl Iterator) { + self.query = query; + self.recompute(titles); + } + + /// Recomputes the visible indices from the current query against + /// `titles`. Call whenever the underlying list changes. + pub fn recompute<'a>(&mut self, titles: impl Iterator) { + match self.query.as_deref().filter(|q| !q.is_empty()) { + None => self.visible = titles.enumerate().map(|(i, _)| i).collect(), + Some(query) => { + let needle = query.to_lowercase(); + self.visible = titles + .enumerate() + .filter(|(_, title)| title.to_lowercase().contains(&needle)) + .map(|(i, _)| i) + .collect(); + } + } + } + + /// Number of visible rows. + pub fn view_len(&self) -> usize { + self.visible.len() + } + + /// The real list index behind a view (rendered) index. + pub fn to_real(&self, view: usize) -> Option { + self.visible.get(view).copied() + } + + /// The view index showing a given real list index, if it is visible. + pub fn to_view(&self, real: usize) -> Option { + self.visible.iter().position(|&i| i == real) + } + + /// The visible real indices, in order — for rendering. + pub fn visible(&self) -> &[usize] { + &self.visible + } +} + // FIXME: Move marking stuff here, to be able to use it in queue as well pub trait StatefulList { fn get_size(&self) -> usize; diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 5bd91fb..76239a1 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -18,7 +18,7 @@ use crabidy_core::proto::crabidy::{ InitResponse as InitialData, LibraryNode, }; -pub use list::StatefulList; +pub use list::{Filter, StatefulList}; use bindings::Action; use library::Library; @@ -260,6 +260,15 @@ fn delete_needs_confirmation(path: &str) -> bool { path == "/captures" || path.starts_with("/captures/") } +/// 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 @@ -271,6 +280,8 @@ pub struct App { /// `Some` while a capture delete awaits confirmation; modal like /// `input` and checked before it in the event loop. pub confirm: Option, + /// `Some` while a `/` search input is open; modal like the others. + pub search: Option, /// Progress of running (and recently finished) captures, rendered as /// status lines at the bottom of the library pane. pub captures: CaptureBoard, @@ -290,6 +301,7 @@ impl App { show_help: false, input: None, confirm: None, + search: None, captures: CaptureBoard::default(), library, now_playing, @@ -313,6 +325,50 @@ impl App { } } + /// 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 @@ -474,6 +530,21 @@ impl App { }); } } + 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::QueueInsertHere => { if let Some(selected) = self.queue.selected() { self.library.queue_insert(selected); @@ -712,6 +783,89 @@ mod tests { } } + /// 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, + } + } + + fn search_key(app: &mut App, text: &str) { + for c in text.chars() { + app.handle_search_key(key(crossterm::event::KeyCode::Char(c))); + } + } + + #[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 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, diff --git a/cbd-tui/src/app/queue.rs b/cbd-tui/src/app/queue.rs index bca0fc0..d48707b 100644 --- a/cbd-tui/src/app/queue.rs +++ b/cbd-tui/src/app/queue.rs @@ -10,14 +10,18 @@ use ratatui::{ use crabidy_core::proto::crabidy::Queue as QueueData; use super::{ - MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, - COLOR_SECONDARY, + Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, 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 @@ -33,11 +37,30 @@ impl Queue { current_position: 0, list: Vec::new(), list_state: ListState::default(), + filter: Filter::default(), resolving: false, tx, } } + /// 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 { @@ -50,15 +73,19 @@ impl Queue { let _ = self.tx.send(MessageFromUi::PrevTrack); } pub fn play_selected(&self) { - if let Some(pos) = self.selected() { + if let Some(pos) = self.selected_position() { let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos)); } } pub fn select_current(&mut self) { - self.select(Some(self.current_position)); + // 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)); + } } pub fn remove_track(&mut self) { - if let Some(pos) = self.selected() { + if let Some(pos) = self.selected_position() { // FIXME: mark multiple tracks on queue and remove them let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos])); } @@ -86,17 +113,26 @@ impl Queue { }) .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(); } pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { 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 - .list + .filter + .visible() .iter() + .map(|&real| (real, &self.list[real])) .enumerate() - .map(|(idx, item)| { - let active = idx == self.current_position; + .map(|(idx, (real, item))| { + let active = real == self.current_position; let title = if active { format!("> {}", item.title) @@ -148,7 +184,10 @@ impl Queue { } else { COLOR_PRIMARY_DARK })) - .title("Queue"), + .title(match self.filter.query() { + Some(query) => format!("Queue — /{query}▏"), + None => "Queue".to_string(), + }), ) .highlight_style(Style::default().bg(if focused { COLOR_PRIMARY @@ -162,7 +201,8 @@ impl Queue { impl StatefulList for Queue { fn get_size(&self) -> usize { - self.list.len() + // Navigation operates on the filtered (visible) view. + self.filter.view_len() } fn select(&mut self, idx: Option) { @@ -323,6 +363,41 @@ mod tests { 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")); + } + #[test] fn colored_rows_darken_under_the_focused_selection_bar() { // A red (skipped) row under the light focused selection bar was diff --git a/cbd-tui/src/lib.rs b/cbd-tui/src/lib.rs index e70cd13..8c4590c 100644 --- a/cbd-tui/src/lib.rs +++ b/cbd-tui/src/lib.rs @@ -282,6 +282,8 @@ fn run_ui(tx: Sender, rx: Receiver) { // quit) is unreachable. if app.confirm.is_some() { app.handle_confirm_key(key); + } else if app.search.is_some() { + app.handle_search_key(key); } else if app.input.is_some() { app.handle_input_key(key); } else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) { diff --git a/plan/summary.md b/plan/summary.md index 417c466..537435a 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -702,3 +702,28 @@ embedded bundle + stubbed Tidal): `/` serves the shell, the 1.77 MB wasm/js/css assets serve with correct content-types, deep links fall back to the shell, unauthenticated and wrong-role gRPC-web calls return UNAUTHENTICATED, and a native tonic client round-trips over axum. + +## tui-search (2026-07-21) + +`/` in the library or queue pane opens a live substring filter +(`architecture/tui-search.md`). Implemented directly (small feature). + +- New `Filter` helper in `cbd-tui/src/app/list.rs`: keeps the pane's + full list, records visible real indices, maps view↔real. Both panes + route selection, marks, rendering, and `StatefulList` size through + it, so all movement keys work on the filtered view unchanged and the + queue's server-facing positions (remove/set-current) map back to real + indices. +- App gains a modal `search: Option` and + `handle_search_key` (type = live filter, Enter keeps, Esc clears), + an `OpenSearch` action bound to `/` in both pane scopes targeting the + focused pane, and event-loop routing ahead of the input overlay. +- Library resets search on node change; queue preserves it across the + frequent stream updates. + +Tests: 2 queue tests (real-position mapping on removal, filter survives +updates) + 3 app tests (library filter/Enter/Esc lifecycle, focus +targeting, dive-clears-filter). 67 cbd-tui tests green; clippy clean. + +Scope: TUI only, per the request; web-client parity noted as a +follow-up.