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

View File

@ -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)
);
}
}

View File

@ -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}"
);
}
}

View File

@ -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();

View File

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

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;
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 |

View File

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