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:
parent
be2676080d
commit
7eaa8fa9b4
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue