From 7eaa8fa9b4afa02ca5bd1667829dbec80d209f32 Mon Sep 17 00:00:00 2001 From: Test User Date: Wed, 22 Jul 2026 22:35:51 +0200 Subject: [PATCH] tui: clear-filter on Esc, captured arrow marker, spectrum toggle Three terminal-UI refinements: - Esc in navigation clears an active / filter (new ClearSearch action); Enter keeps the filter and returns to navigation, / re-opens editing. - Captured rows render a trailing down-arrow at the end of the row (outside the action brackets), visible while browsing any provider. - v toggles the frequency spectrum at runtime; the spectrum client-config value still sets the startup default. The server keeps computing and streaming the bars regardless. README and the mdbook (clients/tui.md, store.md) updated to match. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 8 ++++--- cbd-tui/src/app/bindings.rs | 38 ++++++++++++++++++++++++++++++---- cbd-tui/src/app/library.rs | 22 ++++++++++---------- cbd-tui/src/app/mod.rs | 37 ++++++++++++++++++++++++++++++++- cbd-tui/src/app/now_playing.rs | 18 ++++++++++++++++ docs/src/clients/tui.md | 12 ++++++----- docs/src/store.md | 6 +++--- 7 files changed, 114 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index fa0fd7c..e6bb694 100644 --- a/README.md +++ b/README.md @@ -216,8 +216,9 @@ live as you type — `Enter` keeps the filter, `Esc` clears it. the store rather than re-downloaded. A source that genuinely cannot be captured is recorded as *skipped* (red in the UI, skipped by playback). Download captures can take long; progress is shown in the library pane. -- Captured rows are marked with a leading `|` — visible even while - browsing another provider, so you can see what you already have. +- Captured rows are marked with a trailing `↓` (down-arrow) at the end of + the row — visible even while browsing another provider, so you can see + what you already have. - Inside `/crabidy`, `d` deletes a folder or track immediately (no confirmation): it removes only the metadata toml, never the shared store audio, which other saves may reference. @@ -227,7 +228,8 @@ Press `?` for the full binding table. A row of frequency-spectrum bars is drawn under the track progress while audio plays (the server taps its own output, runs the FFT, and streams the bars, so it works whether the server is local or remote). -Turn it off with `spectrum = false` in the client config. +Toggle it at runtime with `v`, or set the startup default with +`spectrum = false` in the client config. ## Web client diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index 5c1198b..6078455 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -42,6 +42,9 @@ pub enum Action { ToggleRepeat, NextTrack, PrevTrack, + /// Show or hide the frequency-spectrum visualizer in the now-playing + /// pane (client-side only; the server keeps streaming the bars). + ToggleSpectrum, // Library pane LibraryFirst, LibraryLast, @@ -80,9 +83,14 @@ pub enum Action { 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. + /// filter and returns to normal navigation of the filtered view; + /// `/` again re-opens the input with the current query for editing. + /// Bound in both the library and queue scopes; the dispatch targets + /// whichever pane has focus. OpenSearch, + /// Clear the focused pane's active `/` filter (bound to `Esc` in the + /// library and queue scopes). A no-op when no filter is applied. + ClearSearch, // Queue pane QueueInsertHere, QueueFirst, @@ -211,6 +219,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::PrevTrack, description: "Previous track", }, + Binding { + scope: Scope::Global, + mods: KeyModifiers::NONE, + code: KeyCode::Char('v'), + action: Action::ToggleSpectrum, + description: "Toggle the frequency spectrum", + }, // -- Library --------------------------------------------------------- Binding { scope: Scope::Library, @@ -338,6 +353,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::OpenSearch, description: "Filter this view (type to search, Enter keeps, Esc clears)", }, + Binding { + scope: Scope::Library, + mods: KeyModifiers::NONE, + code: KeyCode::Esc, + action: Action::ClearSearch, + description: "Clear the search filter", + }, // -- Queue ----------------------------------------------------------- Binding { scope: Scope::Queue, @@ -444,6 +466,13 @@ pub const BINDINGS: &[Binding] = &[ action: Action::OpenSearch, description: "Filter this view (type to search, Enter keeps, Esc clears)", }, + Binding { + scope: Scope::Queue, + mods: KeyModifiers::NONE, + code: KeyCode::Esc, + action: Action::ClearSearch, + description: "Clear the search filter", + }, // -- Help modal ------------------------------------------------------ Binding { scope: Scope::Help, @@ -791,11 +820,12 @@ mod tests { #[test] fn help_scope_never_matches_while_help_is_closed() { - // Esc is only bound inside the modal. + // With help closed, Esc drives the pane's ClearSearch, never the + // modal's CloseHelp — Help-scope bindings don't leak into navigation. for focus in [UiFocus::Library, UiFocus::Queue] { assert_eq!( lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)), - None + Some(Action::ClearSearch) ); } } diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index 5dad0cf..e673a94 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -268,17 +268,11 @@ impl Library { } else { i.title.to_string() }; - // Captured rows carry a leading `|` before every other - // prefix (selection padding, the `* ` mark): the very first - // character of the row marks the audio as present in the - // capture content store. - if i.is_captured { - text.insert(0, '|'); - } if i.is_creatable { text.push_str(" [%]"); } - // Modifiable items advertise their keys: [e], [d] or [ed]. + // Modifiable items advertise their action keys: [e], [d] or + // [ed]. if i.is_editable || i.is_deletable { text.push_str(" ["); if i.is_editable { @@ -289,6 +283,12 @@ impl Library { } text.push(']'); } + // A trailing `↓` (a status, not an action, so kept outside + // the key brackets) marks a row whose audio is in the content + // store (downloaded). + if i.is_captured { + text.push_str(" ↓"); + } let mut style = if i.marked { Style::default() .fg(COLOR_GREEN) @@ -394,7 +394,7 @@ mod tests { } #[test] - fn captured_rows_render_a_leading_pipe() { + fn captured_rows_render_a_trailing_arrow() { let (tx, _rx) = flume::unbounded(); let mut library = Library::new(tx); library.update(LibraryNode { @@ -414,8 +414,8 @@ mod tests { }); let text = render_text(&mut library); assert!( - text.contains("|album"), - "captured row carries a leading pipe: {text}" + text.contains("album ↓"), + "captured row carries a trailing down-arrow: {text}" ); } } diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 6ffc87a..16db4ee 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -57,7 +57,7 @@ struct UiItem { is_skipped: bool, /// The item's audio is present in the capture content store /// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered - /// with a leading `|` marker. + /// with a trailing `↓` marker at the end of the row. is_captured: bool, } @@ -430,6 +430,7 @@ impl App { } Action::NextTrack => self.queue.play_next(), Action::PrevTrack => self.queue.play_prev(), + Action::ToggleSpectrum => self.now_playing.toggle_spectrum(), Action::LibraryFirst => self.library.first(), Action::LibraryLast => self.library.last(), Action::LibraryNext => self.library.next(), @@ -513,6 +514,15 @@ impl App { Self::apply_search(&mut self.library, &mut self.queue, &search); self.search = Some(search); } + Action::ClearSearch => { + // `Esc` in normal navigation clears the focused pane's + // filter, restoring the full listing. A no-op when nothing + // is filtered. + match self.focus { + UiFocus::Library => self.library.set_filter(None), + UiFocus::Queue => self.queue.set_filter(None), + } + } Action::QueueInsertHere => { if let Some(selected) = self.queue.selected() { self.library.queue_insert(selected); @@ -808,6 +818,31 @@ mod tests { assert_eq!(app.library.get_size(), 3, "full listing restored"); } + #[test] + fn esc_in_navigation_clears_an_active_filter() { + use crossterm::event::KeyCode; + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + + // Filter, then Enter back to normal navigation (the filter stays). + let _ = app.dispatch(Action::OpenSearch); + search_key(&mut app, "bet"); + app.handle_search_key(key(KeyCode::Enter)); + assert!(app.search.is_none()); + assert_eq!(app.library.filter_query(), Some("bet")); + + // Esc now resolves to ClearSearch (no modal open), clearing the + // filter while staying in normal navigation. + let _ = app.dispatch(Action::ClearSearch); + assert_eq!(app.library.filter_query(), None); + assert_eq!(app.library.get_size(), 3, "full listing restored"); + + // With no filter, ClearSearch is a harmless no-op. + let _ = app.dispatch(Action::ClearSearch); + assert_eq!(app.library.filter_query(), None); + } + #[test] fn search_targets_the_focused_pane() { let (mut app, _rx) = app(); diff --git a/cbd-tui/src/app/now_playing.rs b/cbd-tui/src/app/now_playing.rs index 2ce3fea..55989a3 100644 --- a/cbd-tui/src/app/now_playing.rs +++ b/cbd-tui/src/app/now_playing.rs @@ -122,6 +122,11 @@ impl NowPlaying { pub fn set_spectrum_enabled(&mut self, enabled: bool) { self.spectrum_enabled = enabled; } + /// Shows or hides the spectrum row (the `v` keybinding). The server + /// keeps streaming the bars; this only gates rendering. + pub fn toggle_spectrum(&mut self) { + self.spectrum_enabled = !self.spectrum_enabled; + } /// Reflects the server's mute state. pub fn update_mute(&mut self, muted: bool) { self.muted = muted; @@ -375,6 +380,19 @@ mod tests { ); } + #[test] + fn toggle_spectrum_hides_then_shows_the_bars() { + let mut pane = now_playing(10_000, 60_000); + pane.update_spectrum(vec![1.0; 24]); + assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); + // `v` hides the bars… + pane.toggle_spectrum(); + assert!(!rendered_rows(&pane).iter().any(|r| r.contains('█'))); + // …and again brings them back. + pane.toggle_spectrum(); + assert!(rendered_rows(&pane).iter().any(|r| r.contains('█'))); + } + /// The position can overrun a stale or wrong duration (streams, /// hand-written track files); the gauge must clamp instead of hitting /// ratatui's `ratio should be between 0 and 1` panic. diff --git a/docs/src/clients/tui.md b/docs/src/clients/tui.md index 71823c3..ae24bda 100644 --- a/docs/src/clients/tui.md +++ b/docs/src/clients/tui.md @@ -57,8 +57,9 @@ it, and while it is open every other key is inert. a library subtree and on the queue. Downloads can take a while; progress shows in the library pane. See [The crabidy store](../store.md). -- Captured rows are marked with a leading `|`, visible even while you - browse another provider, so you can see what you already have. +- Captured rows are marked with a trailing `↓` (down-arrow) at the end of + the row, visible even while you browse another provider, so you can see + what you already have. ```admonish note Saves and captures all live under the one `/crabidy` provider. Inside @@ -98,8 +99,9 @@ them on the update stream like every other bit of live state. So the bars work whether the server is local or on another machine, and when nothing is playing they fall to the floor. -Toggle them with `spectrum` in the client config (`spectrum = true` is -the default; `false` hides them). The toggle is a display choice — the +Press `v` to show or hide them at runtime; `spectrum` in the client +config sets the startup default (`spectrum = true` is the default; +`false` starts hidden). Either way it is only a display choice — the server always computes and streams the bars while audio flows. ## Key bindings @@ -110,7 +112,6 @@ pane). | Scope | Key | Action | | ------- | ----------------- | ----------------------------------------- | -| Scope | Key | Action | | Global | `?` | Show help | | Global | `q` | Quit | | Global | `Tab` | Switch between library and queue | @@ -123,6 +124,7 @@ pane). | Global | `x` | Toggle repeat | | Global | `Ctrl-n` | Next track | | Global | `Ctrl-p` | Previous track | +| Global | `v` | Toggle the frequency spectrum | | Library | `j` / `k` | Select next / previous item | | Library | `g` / `G` | Select first / last item | | Library | `Ctrl-d` | Jump 15 items down | diff --git a/docs/src/store.md b/docs/src/store.md index 6395348..9e828b7 100644 --- a/docs/src/store.md +++ b/docs/src/store.md @@ -235,9 +235,9 @@ collector is future work. ## The captured marker -The library marks what you already hold. A captured row is prefixed with a -leading `|` — the first character of the row, before the selection padding, -e.g. `|Bohemian…`: +The library marks what you already hold. A captured row ends with a `↓` +(down-arrow) — a trailing status marker after any action-key brackets +(`[e]`/`[d]`), e.g. `Bohemian… ↓`: - A **track** is captured when its playable is store-backed, or when its `(provider, id)` is in the store index. Because the check is one index