Add a / search filter to the TUI library and queue panes

Pressing / in either pane opens a live case-insensitive substring
filter: typing narrows the visible rows, Enter keeps the filter and
returns to navigation, Esc clears it. A shared Filter helper keeps each
pane full list intact and maps view indices to real ones, so movement
keys work on the filtered view unchanged and the queue maps a filtered
selection back to the real server position before removing or setting
current. The library resets search on node change; the queue preserves
it across stream updates.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-21 22:54:39 +02:00
parent 435af91d9c
commit 550237f69a
9 changed files with 480 additions and 31 deletions

View File

@ -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 folder, `h` goes to the parent, `Tab` switches between library and
queue, `Enter` replaces the queue with the selection. `%` creates a queue, `Enter` replaces the queue with the selection. `%` creates a
node where the pane title shows `% to add` (e.g. a search term), `e` 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 - `w` saves the selection as a **bookmark** (links; needs the source
provider to replay) or, in the queue pane, saves the queue. provider to replay) or, in the queue pane, saves the queue.

View File

@ -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.

View File

@ -78,6 +78,11 @@ pub enum Action {
/// every track's audio into `/captures`. No-op unless the bare /// every track's audio into `/captures`. No-op unless the bare
/// selection is queueable *and* downloadable (e.g. Tidal subtrees). /// selection is queueable *and* downloadable (e.g. Tidal subtrees).
LibraryDownloadNode, 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 // Queue pane
QueueInsertHere, QueueInsertHere,
QueueFirst, QueueFirst,
@ -321,6 +326,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::LibraryQueueReplace, action: Action::LibraryQueueReplace,
description: "Replace queue with selection", 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 ----------------------------------------------------------- // -- Queue -----------------------------------------------------------
Binding { Binding {
scope: Scope::Queue, scope: Scope::Queue,
@ -413,6 +425,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::QueueSaveAs, action: Action::QueueSaveAs,
description: "Save queue under a name", 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 ------------------------------------------------------ // -- Help modal ------------------------------------------------------
Binding { Binding {
scope: Scope::Help, scope: Scope::Help,

View File

@ -12,7 +12,7 @@ use ratatui::{
use crabidy_core::proto::crabidy::LibraryNode; use crabidy_core::proto::crabidy::LibraryNode;
use super::{ 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, COLOR_PRIMARY_DARK, COLOR_RED, COLOR_SECONDARY,
}; };
@ -25,6 +25,8 @@ pub struct Library {
is_creatable: bool, is_creatable: bool,
list: Vec<UiItem>, list: Vec<UiItem>,
list_state: ListState, list_state: ListState,
/// The `/` search filter; selection and rendering go through it.
filter: Filter,
parent: Option<String>, parent: Option<String>,
positions: HashMap<String, usize>, positions: HashMap<String, usize>,
tx: Sender<MessageFromUi>, tx: Sender<MessageFromUi>,
@ -38,12 +40,32 @@ impl Library {
is_creatable: false, is_creatable: false,
list: Vec::new(), list: Vec::new(),
list_state: ListState::default(), list_state: ListState::default(),
filter: Filter::default(),
positions: HashMap::new(), positions: HashMap::new(),
parent: None, parent: None,
tx, 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<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()
}
/// Path of the currently open node. /// Path of the currently open node.
pub fn path(&self) -> &str { pub fn path(&self) -> &str {
&self.path &self.path
@ -56,14 +78,14 @@ impl Library {
/// Path and current title of the selected item, if it may be renamed /// 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. /// (`e`). `None` when nothing is selected or the item is not editable.
pub fn selected_editable(&self) -> Option<(String, String)> { pub fn selected_editable(&self) -> Option<(String, String)> {
let item = self.list.get(self.list_state.selected()?)?; let item = self.resolved()?;
item.is_editable item.is_editable
.then(|| (item.path.clone(), item.title.clone())) .then(|| (item.path.clone(), item.title.clone()))
} }
/// Path and title of the selected item, if it may be deleted (`d`). /// Path and title of the selected item, if it may be deleted (`d`).
/// `None` when nothing is selected or the item is not deletable. /// `None` when nothing is selected or the item is not deletable.
pub fn selected_deletable(&self) -> Option<(String, String)> { pub fn selected_deletable(&self) -> Option<(String, String)> {
let item = self.list.get(self.list_state.selected()?)?; let item = self.resolved()?;
item.is_deletable item.is_deletable
.then(|| (item.path.clone(), item.title.clone())) .then(|| (item.path.clone(), item.title.clone()))
} }
@ -71,7 +93,7 @@ impl Library {
/// captures as a bookmark. Marks are deliberately ignored: one capture /// captures as a bookmark. Marks are deliberately ignored: one capture
/// per invocation (architecture/bookmarks.md D5). /// per invocation (architecture/bookmarks.md D5).
pub fn selected_queueable(&self) -> Option<(String, String)> { pub fn selected_queueable(&self) -> Option<(String, String)> {
let item = self.list.get(self.list_state.selected()?)?; let item = self.resolved()?;
item.is_queable item.is_queable
.then(|| (item.path.clone(), item.title.clone())) .then(|| (item.path.clone(), item.title.clone()))
} }
@ -79,10 +101,12 @@ impl Library {
/// download (`W`): queueable *and* downloadable. Marks are ignored, /// download (`W`): queueable *and* downloadable. Marks are ignored,
/// like [`Self::selected_queueable`]. /// like [`Self::selected_queueable`].
pub fn selected_downloadable(&self) -> Option<(String, String)> { 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())) (item.is_queable && item.is_downloadable).then(|| (item.path.clone(), item.title.clone()))
} }
pub fn get_selected(&self) -> Option<Vec<String>> { pub fn get_selected(&self) -> Option<Vec<String>> {
// 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) { if self.list.iter().any(|i| i.marked) {
return Some( return Some(
self.list self.list
@ -96,7 +120,7 @@ impl Library {
// gated here too, or Enter on a plain folder ships a path the server // 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 // can only resolve to nothing (silently ignored, like % / e / d on
// items without the capability). // 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()]) item.is_queable.then(|| vec![item.path.to_string()])
} }
pub fn ascend(&mut self) { pub fn ascend(&mut self) {
@ -105,8 +129,7 @@ impl Library {
} }
} }
pub fn dive(&mut self) { pub fn dive(&mut self) {
if let Some(idx) = self.list_state.selected() { if let Some(item) = self.resolved() {
let item = &self.list[idx];
if let UiItemKind::Node = item.kind { if let UiItemKind::Node = item.kind {
let _ = self let _ = self
.tx .tx
@ -150,8 +173,12 @@ impl Library {
*self.positions.get(&self.path).unwrap_or(&0) *self.positions.get(&self.path).unwrap_or(&0)
} }
pub fn toggle_mark(&mut self) { pub fn toggle_mark(&mut self) {
if let Some(idx) = self.list_state.selected() { if let Some(real) = self
let item = &mut self.list[idx]; .list_state
.selected()
.and_then(|view| self.filter.to_real(view))
{
let item = &mut self.list[real];
if !item.is_queable { if !item.is_queable {
return; return;
} }
@ -178,7 +205,6 @@ impl Library {
self.title = node.title; self.title = node.title;
self.parent = node.parent; self.parent = node.parent;
self.is_creatable = node.is_creatable; self.is_creatable = node.is_creatable;
self.select(Some(self.prev_selected()));
// Most nodes carry either children or tracks; search term nodes // Most nodes carry either children or tracks; search term nodes
// carry both (track results + artist/album results), so the list is // carry both (track results + artist/album results), so the list is
@ -215,14 +241,24 @@ impl Library {
})) }))
.collect(); .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(); self.update_selection();
} }
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
let selected = self.list_state.selected(); 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<ListItem> = self let library_items: Vec<ListItem> = self
.list .filter
.visible()
.iter() .iter()
.map(|&real| &self.list[real])
.enumerate() .enumerate()
.map(|(idx, i)| { .map(|(idx, i)| {
let mut text = if i.marked { let mut text = if i.marked {
@ -276,7 +312,10 @@ impl Library {
} else { } else {
COLOR_PRIMARY_DARK 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) format!("{} — % to add", self.title)
} else { } else {
self.title.clone() self.title.clone()
@ -298,15 +337,21 @@ impl Library {
impl StatefulList for Library { impl StatefulList for Library {
fn get_size(&self) -> usize { 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<usize>) { fn select(&mut self, idx: Option<usize>) {
if let Some(pos) = idx { // Remember the cursor per node as a real index so it survives a
self.positions // filter (which only reorders the view). With no filter, the
.entry(self.path.clone()) // view index is already the real index.
.and_modify(|e| *e = pos) if let Some(view) = idx {
.or_insert(pos); 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); self.list_state.select(idx);
} }

View File

@ -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<String>,
/// Real indices currently visible, in list order. Rebuilt by
/// [`Self::recompute`]; always `0..len` while inactive or empty.
visible: Vec<usize>,
}
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<String>, titles: impl Iterator<Item = &'a str>) {
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<Item = &'a str>) {
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<usize> {
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<usize> {
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 // FIXME: Move marking stuff here, to be able to use it in queue as well
pub trait StatefulList { pub trait StatefulList {
fn get_size(&self) -> usize; fn get_size(&self) -> usize;

View File

@ -18,7 +18,7 @@ use crabidy_core::proto::crabidy::{
InitResponse as InitialData, LibraryNode, InitResponse as InitialData, LibraryNode,
}; };
pub use list::StatefulList; pub use list::{Filter, StatefulList};
use bindings::Action; use bindings::Action;
use library::Library; use library::Library;
@ -260,6 +260,15 @@ fn delete_needs_confirmation(path: &str) -> bool {
path == "/captures" || path.starts_with("/captures/") 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 struct App {
pub focus: UiFocus, pub focus: UiFocus,
/// Whether the help modal is open. While true, `bindings::lookup` only /// 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 /// `Some` while a capture delete awaits confirmation; modal like
/// `input` and checked before it in the event loop. /// `input` and checked before it in the event loop.
pub confirm: Option<ConfirmDelete>, pub confirm: Option<ConfirmDelete>,
/// `Some` while a `/` search input is open; modal like the others.
pub search: Option<SearchState>,
/// Progress of running (and recently finished) captures, rendered as /// Progress of running (and recently finished) captures, rendered as
/// status lines at the bottom of the library pane. /// status lines at the bottom of the library pane.
pub captures: CaptureBoard, pub captures: CaptureBoard,
@ -290,6 +301,7 @@ impl App {
show_help: false, show_help: false,
input: None, input: None,
confirm: None, confirm: None,
search: None,
captures: CaptureBoard::default(), captures: CaptureBoard::default(),
library, library,
now_playing, 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()`). /// Handles one key while the input overlay is open (`input.is_some()`).
/// ///
/// `Esc` cancels, `Enter` submits a non-empty trimmed buffer as the /// `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 => { Action::QueueInsertHere => {
if let Some(selected) = self.queue.selected() { if let Some(selected) = self.queue.selected() {
self.library.queue_insert(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] #[test]
fn empty_creatable_nodes_are_enterable() { fn empty_creatable_nodes_are_enterable() {
// /tidal/search starts with no terms; the update must still apply, // /tidal/search starts with no terms; the update must still apply,

View File

@ -10,14 +10,18 @@ use ratatui::{
use crabidy_core::proto::crabidy::Queue as QueueData; use crabidy_core::proto::crabidy::Queue as QueueData;
use super::{ use super::{
MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK, COLOR_RED, Filter, MessageFromUi, StatefulList, UiItem, UiItemKind, COLOR_PRIMARY, COLOR_PRIMARY_DARK,
COLOR_SECONDARY, COLOR_RED, COLOR_SECONDARY,
}; };
pub struct Queue { pub struct Queue {
current_position: usize, current_position: usize,
list: Vec<UiItem>, list: Vec<UiItem>,
list_state: ListState, 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 /// True while the server is still resolving queued paths (from
/// `Queue.resolving`); the pane then renders an animated-dots /// `Queue.resolving`); the pane then renders an animated-dots
/// pseudo-item after the last track. The pseudo-item exists only at /// pseudo-item after the last track. The pseudo-item exists only at
@ -33,11 +37,30 @@ impl Queue {
current_position: 0, current_position: 0,
list: Vec::new(), list: Vec::new(),
list_state: ListState::default(), list_state: ListState::default(),
filter: Filter::default(),
resolving: false, resolving: false,
tx, 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<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 /// The loading indicator line: one to three dots, cycling with wall
/// time (~400 ms per step). Pure so the animation is testable. /// time (~400 ms per step). Pure so the animation is testable.
fn loading_dots(elapsed_ms: u128) -> String { fn loading_dots(elapsed_ms: u128) -> String {
@ -50,15 +73,19 @@ impl Queue {
let _ = self.tx.send(MessageFromUi::PrevTrack); let _ = self.tx.send(MessageFromUi::PrevTrack);
} }
pub fn play_selected(&self) { 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)); let _ = self.tx.send(MessageFromUi::SetCurrentTrack(pos));
} }
} }
pub fn select_current(&mut self) { 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) { 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 // FIXME: mark multiple tracks on queue and remove them
let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos])); let _ = self.tx.send(MessageFromUi::RemoveTracks(vec![pos]));
} }
@ -86,17 +113,26 @@ impl Queue {
}) })
.collect(); .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(); self.update_selection();
} }
pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) { pub fn render(&mut self, f: &mut Frame, area: Rect, focused: bool) {
let selected = self.list_state.selected(); 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 let mut queue_items: Vec<ListItem> = self
.list .filter
.visible()
.iter() .iter()
.map(|&real| (real, &self.list[real]))
.enumerate() .enumerate()
.map(|(idx, item)| { .map(|(idx, (real, item))| {
let active = idx == self.current_position; let active = real == self.current_position;
let title = if active { let title = if active {
format!("> {}", item.title) format!("> {}", item.title)
@ -148,7 +184,10 @@ impl Queue {
} else { } else {
COLOR_PRIMARY_DARK 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 { .highlight_style(Style::default().bg(if focused {
COLOR_PRIMARY COLOR_PRIMARY
@ -162,7 +201,8 @@ impl Queue {
impl StatefulList for Queue { impl StatefulList for Queue {
fn get_size(&self) -> usize { 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<usize>) { fn select(&mut self, idx: Option<usize>) {
@ -323,6 +363,41 @@ mod tests {
assert_eq!(buffer[(x, y)].style().fg, Some(super::COLOR_RED)); 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] #[test]
fn colored_rows_darken_under_the_focused_selection_bar() { fn colored_rows_darken_under_the_focused_selection_bar() {
// A red (skipped) row under the light focused selection bar was // A red (skipped) row under the light focused selection bar was

View File

@ -282,6 +282,8 @@ fn run_ui(tx: Sender<MessageFromUi>, rx: Receiver<MessageToUi>) {
// quit) is unreachable. // quit) is unreachable.
if app.confirm.is_some() { if app.confirm.is_some() {
app.handle_confirm_key(key); app.handle_confirm_key(key);
} else if app.search.is_some() {
app.handle_search_key(key);
} else if app.input.is_some() { } else if app.input.is_some() {
app.handle_input_key(key); app.handle_input_key(key);
} else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) { } else if let Some(action) = bindings::lookup(app.focus, app.show_help, key) {

View File

@ -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 wasm/js/css assets serve with correct content-types, deep links fall
back to the shell, unauthenticated and wrong-role gRPC-web calls return back to the shell, unauthenticated and wrong-role gRPC-web calls return
UNAUTHENTICATED, and a native tonic client round-trips over axum. 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<SearchState>` 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.