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:
parent
435af91d9c
commit
550237f69a
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue