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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-22 22:35:51 +02:00
parent be2676080d
commit 7eaa8fa9b4
7 changed files with 114 additions and 27 deletions

View File

@ -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 the store rather than re-downloaded. A source that genuinely cannot be
captured is recorded as *skipped* (red in the UI, skipped by playback). captured is recorded as *skipped* (red in the UI, skipped by playback).
Download captures can take long; progress is shown in the library pane. Download captures can take long; progress is shown in the library pane.
- Captured rows are marked with a leading `|` — visible even while - Captured rows are marked with a trailing `↓` (down-arrow) at the end of
browsing another provider, so you can see what you already have. 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 - Inside `/crabidy`, `d` deletes a folder or track immediately (no
confirmation): it removes only the metadata toml, never the shared store confirmation): it removes only the metadata toml, never the shared store
audio, which other saves may reference. 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 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 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). 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 ## Web client

View File

@ -42,6 +42,9 @@ pub enum Action {
ToggleRepeat, ToggleRepeat,
NextTrack, NextTrack,
PrevTrack, 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 // Library pane
LibraryFirst, LibraryFirst,
LibraryLast, LibraryLast,
@ -80,9 +83,14 @@ pub enum Action {
LibraryDownloadNode, LibraryDownloadNode,
/// Open the `/` search input for the focused pane. Typing filters the /// Open the `/` search input for the focused pane. Typing filters the
/// pane's items live (case-insensitive substring); `Enter` keeps the /// pane's items live (case-insensitive substring); `Enter` keeps the
/// filter, `Esc` clears it. Bound in both the library and queue /// filter and returns to normal navigation of the filtered view;
/// scopes; the dispatch targets whichever pane has focus. /// `/` 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, 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 // Queue pane
QueueInsertHere, QueueInsertHere,
QueueFirst, QueueFirst,
@ -211,6 +219,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::PrevTrack, action: Action::PrevTrack,
description: "Previous track", description: "Previous track",
}, },
Binding {
scope: Scope::Global,
mods: KeyModifiers::NONE,
code: KeyCode::Char('v'),
action: Action::ToggleSpectrum,
description: "Toggle the frequency spectrum",
},
// -- Library --------------------------------------------------------- // -- Library ---------------------------------------------------------
Binding { Binding {
scope: Scope::Library, scope: Scope::Library,
@ -338,6 +353,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::OpenSearch, action: Action::OpenSearch,
description: "Filter this view (type to search, Enter keeps, Esc clears)", 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 ----------------------------------------------------------- // -- Queue -----------------------------------------------------------
Binding { Binding {
scope: Scope::Queue, scope: Scope::Queue,
@ -444,6 +466,13 @@ pub const BINDINGS: &[Binding] = &[
action: Action::OpenSearch, action: Action::OpenSearch,
description: "Filter this view (type to search, Enter keeps, Esc clears)", 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 ------------------------------------------------------ // -- Help modal ------------------------------------------------------
Binding { Binding {
scope: Scope::Help, scope: Scope::Help,
@ -791,11 +820,12 @@ mod tests {
#[test] #[test]
fn help_scope_never_matches_while_help_is_closed() { 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] { for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!( assert_eq!(
lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)), lookup(focus, false, key(KeyCode::Esc, KeyModifiers::NONE)),
None Some(Action::ClearSearch)
); );
} }
} }

View File

@ -268,17 +268,11 @@ impl Library {
} else { } else {
i.title.to_string() 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 { if i.is_creatable {
text.push_str(" [%]"); 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 { if i.is_editable || i.is_deletable {
text.push_str(" ["); text.push_str(" [");
if i.is_editable { if i.is_editable {
@ -289,6 +283,12 @@ impl Library {
} }
text.push(']'); 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 { let mut style = if i.marked {
Style::default() Style::default()
.fg(COLOR_GREEN) .fg(COLOR_GREEN)
@ -394,7 +394,7 @@ mod tests {
} }
#[test] #[test]
fn captured_rows_render_a_leading_pipe() { fn captured_rows_render_a_trailing_arrow() {
let (tx, _rx) = flume::unbounded(); let (tx, _rx) = flume::unbounded();
let mut library = Library::new(tx); let mut library = Library::new(tx);
library.update(LibraryNode { library.update(LibraryNode {
@ -414,8 +414,8 @@ mod tests {
}); });
let text = render_text(&mut library); let text = render_text(&mut library);
assert!( assert!(
text.contains("|album"), text.contains("album"),
"captured row carries a leading pipe: {text}" "captured row carries a trailing down-arrow: {text}"
); );
} }
} }

View File

@ -57,7 +57,7 @@ struct UiItem {
is_skipped: bool, is_skipped: bool,
/// The item's audio is present in the capture content store /// The item's audio is present in the capture content store
/// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered /// (`Track.is_captured` / `LibraryNodeChild.is_captured`) — rendered
/// with a leading `|` marker. /// with a trailing `↓` marker at the end of the row.
is_captured: bool, is_captured: bool,
} }
@ -430,6 +430,7 @@ impl App {
} }
Action::NextTrack => self.queue.play_next(), Action::NextTrack => self.queue.play_next(),
Action::PrevTrack => self.queue.play_prev(), Action::PrevTrack => self.queue.play_prev(),
Action::ToggleSpectrum => self.now_playing.toggle_spectrum(),
Action::LibraryFirst => self.library.first(), Action::LibraryFirst => self.library.first(),
Action::LibraryLast => self.library.last(), Action::LibraryLast => self.library.last(),
Action::LibraryNext => self.library.next(), Action::LibraryNext => self.library.next(),
@ -513,6 +514,15 @@ impl App {
Self::apply_search(&mut self.library, &mut self.queue, &search); Self::apply_search(&mut self.library, &mut self.queue, &search);
self.search = Some(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 => { 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);
@ -808,6 +818,31 @@ mod tests {
assert_eq!(app.library.get_size(), 3, "full listing restored"); 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] #[test]
fn search_targets_the_focused_pane() { fn search_targets_the_focused_pane() {
let (mut app, _rx) = app(); let (mut app, _rx) = app();

View File

@ -122,6 +122,11 @@ impl NowPlaying {
pub fn set_spectrum_enabled(&mut self, enabled: bool) { pub fn set_spectrum_enabled(&mut self, enabled: bool) {
self.spectrum_enabled = enabled; 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. /// Reflects the server's mute state.
pub fn update_mute(&mut self, muted: bool) { pub fn update_mute(&mut self, muted: bool) {
self.muted = muted; 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, /// The position can overrun a stale or wrong duration (streams,
/// hand-written track files); the gauge must clamp instead of hitting /// hand-written track files); the gauge must clamp instead of hitting
/// ratatui's `ratio should be between 0 and 1` panic. /// ratatui's `ratio should be between 0 and 1` panic.

View File

@ -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; a library subtree and on the queue. Downloads can take a while;
progress shows in the library pane. See [The crabidy progress shows in the library pane. See [The crabidy
store](../store.md). store](../store.md).
- Captured rows are marked with a leading `|`, visible even while you - Captured rows are marked with a trailing `↓` (down-arrow) at the end of
browse another provider, so you can see what you already have. the row, visible even while you browse another provider, so you can see
what you already have.
```admonish note ```admonish note
Saves and captures all live under the one `/crabidy` provider. Inside 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 bars work whether the server is local or on another machine, and when
nothing is playing they fall to the floor. nothing is playing they fall to the floor.
Toggle them with `spectrum` in the client config (`spectrum = true` is Press `v` to show or hide them at runtime; `spectrum` in the client
the default; `false` hides them). The toggle is a display choice — the 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. server always computes and streams the bars while audio flows.
## Key bindings ## Key bindings
@ -110,7 +112,6 @@ pane).
| Scope | Key | Action | | Scope | Key | Action |
| ------- | ----------------- | ----------------------------------------- | | ------- | ----------------- | ----------------------------------------- |
| Scope | Key | Action |
| Global | `?` | Show help | | Global | `?` | Show help |
| Global | `q` | Quit | | Global | `q` | Quit |
| Global | `Tab` | Switch between library and queue | | Global | `Tab` | Switch between library and queue |
@ -123,6 +124,7 @@ pane).
| Global | `x` | Toggle repeat | | Global | `x` | Toggle repeat |
| Global | `Ctrl-n` | Next track | | Global | `Ctrl-n` | Next track |
| Global | `Ctrl-p` | Previous track | | Global | `Ctrl-p` | Previous track |
| Global | `v` | Toggle the frequency spectrum |
| Library | `j` / `k` | Select next / previous item | | Library | `j` / `k` | Select next / previous item |
| Library | `g` / `G` | Select first / last item | | Library | `g` / `G` | Select first / last item |
| Library | `Ctrl-d` | Jump 15 items down | | Library | `Ctrl-d` | Jump 15 items down |

View File

@ -235,9 +235,9 @@ collector is future work.
## The captured marker ## The captured marker
The library marks what you already hold. A captured row is prefixed with a The library marks what you already hold. A captured row ends with a `↓`
leading `|` — the first character of the row, before the selection padding, (down-arrow) — a trailing status marker after any action-key brackets
e.g. `|Bohemian…`: (`[e]`/`[d]`), e.g. `Bohemian…`:
- A **track** is captured when its playable is store-backed, or when its - 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 `(provider, id)` is in the store index. Because the check is one index