From 0a9456e17368d8a041d9f53681451fab76c65163 Mon Sep 17 00:00:00 2001 From: Test User Date: Fri, 24 Jul 2026 16:20:55 +0200 Subject: [PATCH] cbd-tui: add library visual (paint-select) mode on v/V; move spectrum to f MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Press v or V (both the same) to enter visual mode in the library pane; movement then toggles the mark of every row it sweeps over, so a run of items is selected by v then moving (g/G and Ctrl-d/u paint the whole span). Entering toggles the current row (vim-style); a second v/V or Esc leaves the mode with marks kept, and any other action leaves it first then runs. This frees v, so the frequency-spectrum toggle moves from v to f. Painting reuses the existing marks (is_queable-gated, filter-mapped); no wire, proto, or server change. Library-only for now — the queue has no marks yet. Ran the full dev-flow; artifacts under architecture/, quality/, plan/. 89 cbd-tui tests green (14 new); clippy and fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- architecture/visual-mode.md | 145 ++++++++++++++++++++++ cbd-tui/src/app/bindings.rs | 65 +++++++++- cbd-tui/src/app/library.rs | 77 +++++++++++- cbd-tui/src/app/mod.rs | 238 ++++++++++++++++++++++++++++++++++-- plan/summary.md | 38 ++++++ plan/visual-mode.md | 28 +++++ quality/visual-mode.md | 58 +++++++++ 7 files changed, 630 insertions(+), 19 deletions(-) create mode 100644 architecture/visual-mode.md create mode 100644 plan/visual-mode.md create mode 100644 quality/visual-mode.md diff --git a/architecture/visual-mode.md b/architecture/visual-mode.md new file mode 100644 index 0000000..de7eaf2 --- /dev/null +++ b/architecture/visual-mode.md @@ -0,0 +1,145 @@ +# TUI visual mode (paint-select with movement) + +## Context and problem statement + +The library pane supports **marks**: `s` toggles the selected row's mark (gated +on `is_queable`), and the multi-item actions (`a` append, `L` queue-next, `Enter` +replace, `w`/`W` capture… — via `get_selected`) operate on the marked set, +falling back to the bare selection. Marking a contiguous run today means pressing +`s`, moving, `s`, moving, `s`… — one keystroke per row. + +The request: a vim-style **visual mode**. Press `v` (and `V` — both do the same) +to enter it; then **movement toggles the mark of the rows it sweeps over**, so a +run is selected by `v` then `j j j` (or `v G`). Pressing `v`/`V` again — or `Esc` +— leaves visual mode; the marks persist for the next action. + +This frees no key, so the **spectrum toggle currently on `v` must move** +(architecture/spectrum.md; it is a client-only display toggle). + +## Assumptions (decided here) + +- **Library-only.** Marks exist only in the library pane; the queue's marks are + a standing `FIXME` (`queue.rs`), so visual mode binds in the **Library scope** + only. Queue visual mode is out of scope until the queue grows marks (D5). +- **Marks are the selection.** Visual mode is pure UI over the existing + `UiItem.marked` — no new wire types, no server calls, no proto change. It only + changes *how* marks get toggled. +- **`is_queable` gating is preserved.** `toggle_mark` only marks queueable rows; + paint-toggle does the same, so sweeping over a non-queueable row leaves it + unmarked (consistent with `s`). +- TUI-only, like `tui-search`. Web-client parity is a follow-up (D6). + +## Decisions + +### D1 — `v`/`V` enter visual mode; spectrum moves to `f` + +Two new Library-scoped bindings, `v` (`NONE`) and `V` (`SHIFT`), both mapping to +one new `Action::LibraryVisualMode` (same two-binding/one-action pattern as +`K`/`J`, `W`, etc.). `Action::ToggleSpectrum` moves from Global `v` to **Global +`f`** ("frequency"), a key free in every scope. *This letter is a pure +preference — trivially changed in `bindings.rs`; `f` is the chosen default.* + +### D2 — Paint-toggle semantics: sweep toggles, endpoints included + +Visual mode holds one piece of state — that it is **active** (the anchor is +implicit in the cursor + the running mark set). Behavior: + +- **Enter** (`v`/`V` while normal): activate, and **toggle the current row's + mark** (vim includes the row you start on). A lone `v … v` thus behaves like a + single `s`. +- **Move** (any of `j`/`k`, `g`/`G`, `Ctrl-d`/`Ctrl-u` while active): perform the + move, then **toggle the mark of every row swept into** — the half-open view + range `(old_cursor, new_cursor]` (excludes the row you left, includes every + row up to and including the one you land on). This makes jumps (`G`, `Ctrl-d`) + paint the whole span, not just the endpoint. Sweeping **back** re-toggles + (un-paints) the rows re-entered — the "toggle" the user asked for. +- **Exit**: `v`/`V` again, or `Esc`, deactivates (marks persist). Any other + action key (`a`, `Enter`, `w`, …) also **exits first, then runs normally**, so + `v j j a` selects three rows and appends them. Changing node (`h`/`l`) or focus + (`Tab`) also exits — the swept indices would otherwise be stale. + +```d2 +direction: right +shape: sequence_diagram +Normal +Visual +Normal -> Visual: "v / V (toggle current row)" +Visual -> Visual: "j k g G C-d C-u (move, then toggle swept range)" +Visual -> Normal: "v / V / Esc (marks kept)" +Visual -> Normal: "a / Enter / w / … (exit, then act on marks)" +Visual -> Normal: "h / l / Tab (node/focus change)" +``` + +Worked example (rows `0..9`, all unmarked, cursor at `0`): + +```text +v -> {0} (enter toggles current) +j -> {0,1} paint (0,1] +j -> {0,1,2} paint (1,2] +G -> {0..9} paint (2,9] (jump paints the span) +k -> {0..8} paint (9,8] -> un-paints 9 +v -> exit, marks {0..8} kept +a -> append 9 rows +``` + +### D3 — Where the state lives and how dispatch routes it + +`App` gains a `bool` "visual active" flag. `App::dispatch` is the single choke +point (it already maps every `Action`): + +- `LibraryVisualMode` → toggle the flag; on activate call `library.toggle_mark()` + (the anchor). Ignored unless the library is focused. +- The six movement actions → when the flag is set and the library is focused: + read the cursor, run the existing move, read the cursor again, and call a new + `Library::paint_between(old_view, new_view)`; otherwise unchanged. +- `LibraryAscend`/`LibraryDive`/`CycleFocus` → clear the flag, then proceed. +- `ClearSearch` (`Esc`) → if the flag is set, just clear it (do not also clear + the search filter); else unchanged. +- Every other action → clear the flag, then proceed. + +`Library` gains `selected_view()` (the current view index) and +`paint_between(from_view, to_view)` — toggle the mark (respecting `is_queable`) +of every view index in the half-open sweep, mapping each through the `/` filter +to its real index exactly as `toggle_mark` does. No change to `select`, so +non-visual selection (filter re-select, `update_selection`) never paints. + +### D4 — Visual indicator + +The library pane title shows `— VISUAL` while active (same title slot as the +`— /query▏` search hint and `— % to add`). The help modal lists `v`/`V` +("Enter visual mode: movement toggles marks") in the Library group and the moved +`f` spectrum toggle in the Global group — both derived from `BINDINGS`, so they +stay correct for free. + +### D5 — Out of scope: queue visual mode + +The queue has no marks, so `v`/`V` are unbound there (a no-op). Extending visual +mode to the queue is gated on giving the queue a mark set (the existing +`queue.rs` `FIXME`) and is left for that work. + +### D6 — Out of scope: web-client parity + +`cbd-web` mirrors the TUI keymap; a visual mode there is a clean follow-up (its +`state.rs`/`keymap.rs` are the analog seams), not part of this TUI change. + +## Boundaries / interfaces + +- **`bindings.rs`** (pure data): `+LibraryVisualMode`, its two Library bindings, + and the `ToggleSpectrum` chord moves `v`→`f`. All dispatch/help/label logic is + already derived from the table. +- **`app/mod.rs`** (`App`): the visual flag + the dispatch routing above. +- **`app/library.rs`** (`Library`): `selected_view`, `paint_between`, and the + title indicator. Marks, filter, and `select` are reused unchanged. + +## Risks and open questions + +- **Back-sweep un-paints.** Overshooting then correcting toggles rows off — the + literal "toggle" the request asked for, but a user expecting a monotonic vim + range-select may be mildly surprised. Documented in the help text ("toggles"). + An anchored range-select (never un-paints within one session) is a possible + future refinement. +- **Filter interaction.** With a `/` filter active, paint sweeps **view** indices + and toggles their real rows, so only visible rows are affected — consistent + with how `s` and `get_selected` already treat marks vs. the filtered view. +- **Stale indices on list change.** Any node/focus change exits visual mode, so + a reload can never paint against a previous list. diff --git a/cbd-tui/src/app/bindings.rs b/cbd-tui/src/app/bindings.rs index 6078455..18babd2 100644 --- a/cbd-tui/src/app/bindings.rs +++ b/cbd-tui/src/app/bindings.rs @@ -58,6 +58,11 @@ pub enum Action { LibraryQueueAppend, LibraryQueueReplace, LibraryToggleMark, + /// Enter (or leave) visual mode: while active, movement toggles the mark + /// of every row it sweeps over, so a run of items is marked by `v` then + /// moving (architecture/visual-mode.md). Entering toggles the current row; + /// `v`/`V`/`Esc` (or any other action) leaves it, marks kept. + LibraryVisualMode, /// Open the input overlay to create a child of the currently open /// library node. No-op unless that node `is_creatable` (e.g. /// /tidal/search). While the overlay is open, keys bypass this table @@ -222,7 +227,7 @@ pub const BINDINGS: &[Binding] = &[ Binding { scope: Scope::Global, mods: KeyModifiers::NONE, - code: KeyCode::Char('v'), + code: KeyCode::Char('f'), action: Action::ToggleSpectrum, description: "Toggle the frequency spectrum", }, @@ -290,6 +295,20 @@ pub const BINDINGS: &[Binding] = &[ action: Action::LibraryToggleMark, description: "Mark/unmark selection", }, + Binding { + scope: Scope::Library, + mods: KeyModifiers::NONE, + code: KeyCode::Char('v'), + action: Action::LibraryVisualMode, + description: "Visual mode: movement toggles marks (v/V; Esc to leave)", + }, + Binding { + scope: Scope::Library, + mods: KeyModifiers::SHIFT, + code: KeyCode::Char('V'), + action: Action::LibraryVisualMode, + description: "Visual mode (same as v)", + }, Binding { scope: Scope::Library, mods: KeyModifiers::NONE, @@ -792,6 +811,50 @@ mod tests { ); } + #[test] + fn spectrum_moved_off_v_to_f() { + // The frequency-spectrum toggle now lives on Global `f`, in any focus. + for focus in [UiFocus::Library, UiFocus::Queue] { + assert_eq!( + lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)), + Some(Action::ToggleSpectrum) + ); + } + // `v`/`V` no longer toggle the spectrum (they are visual mode now). + assert_ne!( + lookup( + UiFocus::Library, + false, + key(KeyCode::Char('v'), KeyModifiers::NONE) + ), + Some(Action::ToggleSpectrum) + ); + } + + #[test] + fn v_and_v_enter_visual_in_library_only() { + // Both `v` and `V` (SHIFT, and SHIFT normalized away) enter visual mode + // in the library; neither is bound in the queue (no marks there). + for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { + assert_eq!( + lookup(UiFocus::Library, false, key(KeyCode::Char('v'), mods)), + Some(Action::LibraryVisualMode) + ); + assert_eq!( + lookup(UiFocus::Library, false, key(KeyCode::Char('V'), mods)), + Some(Action::LibraryVisualMode) + ); + assert_eq!( + lookup(UiFocus::Queue, false, key(KeyCode::Char('v'), mods)), + None + ); + assert_eq!( + lookup(UiFocus::Queue, false, key(KeyCode::Char('V'), mods)), + None + ); + } + } + #[test] fn open_help_swallows_everything_but_close() { for focus in [UiFocus::Library, UiFocus::Queue] { diff --git a/cbd-tui/src/app/library.rs b/cbd-tui/src/app/library.rs index e673a94..2561d0a 100644 --- a/cbd-tui/src/app/library.rs +++ b/cbd-tui/src/app/library.rs @@ -29,6 +29,9 @@ pub struct Library { filter: Filter, parent: Option, positions: HashMap, + /// Visual (paint-select) mode: while true, movement toggles the mark of + /// every row swept over (architecture/visual-mode.md). + visual: bool, tx: Sender, } @@ -43,6 +46,7 @@ impl Library { filter: Filter::default(), positions: HashMap::new(), parent: None, + visual: false, tx, } } @@ -173,11 +177,16 @@ impl Library { *self.positions.get(&self.path).unwrap_or(&0) } pub fn toggle_mark(&mut self) { - if let Some(real) = self - .list_state - .selected() - .and_then(|view| self.filter.to_real(view)) - { + if let Some(view) = self.list_state.selected() { + self.toggle_mark_view(view); + } + } + + /// Toggle the mark of the row at a **view** index (mapped through the `/` + /// filter to its real row), honoring the `is_queable` gate. Shared by + /// `toggle_mark` and visual-mode painting. + fn toggle_mark_view(&mut self, view: usize) { + if let Some(real) = self.filter.to_real(view) { let item = &mut self.list[real]; if !item.is_queable { return; @@ -185,6 +194,59 @@ impl Library { item.marked = !item.marked; } } + + /// Whether visual (paint-select) mode is active. + pub fn is_visual(&self) -> bool { + self.visual + } + + /// Titles of the currently marked rows, in list order (inspection helper). + pub fn marked_titles(&self) -> Vec { + self.list + .iter() + .filter(|i| i.marked) + .map(|i| i.title.clone()) + .collect() + } + + /// Enter or leave visual mode. Entering also toggles the current row's + /// mark (vim includes the row you start on, D2); leaving keeps the marks. + pub fn toggle_visual(&mut self) { + self.visual = !self.visual; + if self.visual { + self.toggle_mark(); + } + } + + /// Leave visual mode (marks kept). Idempotent. + pub fn exit_visual(&mut self) { + self.visual = false; + } + + /// The current cursor position as a view index. + pub fn selected_view(&self) -> Option { + self.list_state.selected() + } + + /// Paint the sweep from `from_view` to `to_view`: toggle the mark of every + /// view index in the half-open range `(from_view, to_view]` (excludes the + /// row left, includes each row swept into, in either direction). Used after + /// a movement while visual mode is active. + pub fn paint_between(&mut self, from_view: usize, to_view: usize) { + if from_view == to_view { + return; + } + if to_view > from_view { + for view in (from_view + 1)..=to_view { + self.toggle_mark_view(view); + } + } else { + for view in to_view..from_view { + self.toggle_mark_view(view); + } + } + } + pub fn remove_marks(&mut self) { if self.list.iter().any(|i| i.marked) { self.list @@ -321,7 +383,10 @@ impl Library { } else { COLOR_PRIMARY_DARK })) - .title(if let Some(query) = self.filter.query() { + .title(if self.visual { + // Visual (paint-select) mode: movement toggles marks. + format!("{} — VISUAL", self.title) + } else 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 { diff --git a/cbd-tui/src/app/mod.rs b/cbd-tui/src/app/mod.rs index 16db4ee..4733258 100644 --- a/cbd-tui/src/app/mod.rs +++ b/cbd-tui/src/app/mod.rs @@ -401,7 +401,41 @@ impl App { /// modal, list selections) or send the matching [`MessageFromUi`] via /// `tx`. Send failures are ignored like everywhere else in this module — /// the orchestrator side owns error reporting. + /// Run a library cursor move, painting the swept `(old, new]` range when + /// visual mode is active (architecture/visual-mode.md D3). + fn library_move(&mut self, mv: impl FnOnce(&mut Library)) { + if self.library.is_visual() { + let old = self.library.selected_view(); + mv(&mut self.library); + let new = self.library.selected_view(); + if let (Some(old), Some(new)) = (old, new) { + self.library.paint_between(old, new); + } + } else { + mv(&mut self.library); + } + } + pub fn dispatch(&mut self, action: Action) -> DispatchResult { + // Visual mode (library paint-select): the six movements paint (handled + // in their arms), `v`/`V` toggle it, and `Esc` leaves it; every other + // action leaves visual mode before running (architecture/visual-mode.md). + let was_visual = self.library.is_visual(); + if was_visual + && !matches!( + action, + Action::LibraryFirst + | Action::LibraryLast + | Action::LibraryNext + | Action::LibraryPrev + | Action::LibraryJumpDown + | Action::LibraryJumpUp + | Action::LibraryVisualMode + | Action::ClearSearch + ) + { + self.library.exit_visual(); + } match action { Action::Quit => return DispatchResult::Quit, Action::OpenHelp => self.show_help = true, @@ -431,18 +465,19 @@ 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(), - Action::LibraryPrev => self.library.prev(), - Action::LibraryJumpDown => self.library.down(), - Action::LibraryJumpUp => self.library.up(), + Action::LibraryFirst => self.library_move(|l| l.first()), + Action::LibraryLast => self.library_move(|l| l.last()), + Action::LibraryNext => self.library_move(|l| l.next()), + Action::LibraryPrev => self.library_move(|l| l.prev()), + Action::LibraryJumpDown => self.library_move(|l| l.down()), + Action::LibraryJumpUp => self.library_move(|l| l.up()), Action::LibraryAscend => self.library.ascend(), Action::LibraryDive => self.library.dive(), Action::LibraryQueueNext => self.library.queue_queue(), Action::LibraryQueueAppend => self.library.queue_append(), Action::LibraryQueueReplace => self.library.queue_replace(), Action::LibraryToggleMark => self.library.toggle_mark(), + Action::LibraryVisualMode => self.library.toggle_visual(), Action::LibraryCreateNode => { // Opens the input overlay when the open library node is // creatable; silently ignored otherwise. @@ -515,12 +550,18 @@ impl App { 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), + if was_visual { + // In visual mode `Esc` leaves the mode (marks kept) and + // does not also clear the search filter. + self.library.exit_visual(); + } else { + // `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 => { @@ -786,6 +827,179 @@ mod tests { } } + /// A listing whose child queueability follows `queueable` per title. + fn mixed_listing(items: &[(&str, bool)]) -> LibraryNode { + use crabidy_core::proto::crabidy::LibraryNodeChild; + LibraryNode { + path: "/fs/music".to_string(), + title: "music".to_string(), + parent: Some(crabidy_core::ROOT_PATH.to_string()), + children: items + .iter() + .map(|(t, q)| LibraryNodeChild::new(format!("/fs/music/{t}"), t.to_string(), *q)) + .collect(), + tracks: Vec::new(), + is_queable: true, + is_creatable: false, + is_downloadable: false, + tracks_deletable: false, + is_captured: false, + } + } + + #[test] + fn visual_enter_toggles_current_mark() { + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); + assert!(app.library.is_visual(), "visual mode active"); + assert_eq!(app.library.marked_titles(), vec!["alpha".to_string()]); + } + + #[test] + fn visual_move_paints_swept_range() { + // Step moves paint one row each. + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); // marks alpha (anchor) + let _ = app.dispatch(Action::LibraryNext); // marks beta + let _ = app.dispatch(Action::LibraryNext); // marks gamma + assert_eq!( + app.library.marked_titles(), + vec!["alpha".to_string(), "beta".to_string(), "gamma".to_string()] + ); + } + + #[test] + fn visual_jump_paints_whole_span() { + // A jump paints every row in the span, not just the endpoint. + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["a", "b", "c", "d", "e"])); + let _ = app.dispatch(Action::LibraryVisualMode); // marks a + let _ = app.dispatch(Action::LibraryLast); // paints b,c,d,e + assert_eq!(app.library.marked_titles().len(), 5); + } + + #[test] + fn visual_back_sweep_unpaints() { + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); // alpha + let _ = app.dispatch(Action::LibraryNext); // beta + let _ = app.dispatch(Action::LibraryNext); // gamma + let _ = app.dispatch(Action::LibraryPrev); // back onto beta -> unmark + assert_eq!( + app.library.marked_titles(), + vec!["alpha".to_string(), "gamma".to_string()] + ); + } + + #[test] + fn visual_exit_keeps_marks() { + // Second v exits; marks persist. + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); + let _ = app.dispatch(Action::LibraryNext); + let before = app.library.marked_titles(); + let _ = app.dispatch(Action::LibraryVisualMode); + assert!(!app.library.is_visual()); + assert_eq!(app.library.marked_titles(), before); + } + + #[test] + fn visual_esc_exits_keeping_marks() { + // Esc exits visual and keeps the marks. + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); + let _ = app.dispatch(Action::LibraryNext); + let _ = app.dispatch(Action::ClearSearch); + assert!(!app.library.is_visual()); + assert_eq!( + app.library.marked_titles(), + vec!["alpha".to_string(), "beta".to_string()] + ); + } + + #[test] + fn visual_non_move_action_exits_then_runs() { + let (mut app, rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); // alpha + let _ = app.dispatch(Action::LibraryNext); // beta + let _ = app.dispatch(Action::LibraryQueueAppend); // exits + appends marks + assert!(!app.library.is_visual(), "append leaves visual mode"); + assert!( + app.library.marked_titles().is_empty(), + "append consumes the marks" + ); + match rx.try_recv() { + Ok(MessageFromUi::AppendTracks(paths)) => assert_eq!(paths.len(), 2), + _ => panic!("expected AppendTracks(2)"), + } + } + + #[test] + fn visual_exits_on_node_or_focus_change() { + for action in [ + Action::LibraryDive, + Action::LibraryAscend, + Action::CycleFocus, + ] { + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + let _ = app.dispatch(Action::LibraryVisualMode); + assert!(app.library.is_visual()); + let _ = app.dispatch(action); + assert!(!app.library.is_visual(), "{action:?} leaves visual mode"); + } + } + + #[test] + fn visual_paint_respects_is_queable() { + // Sweeping over a non-queueable row leaves it unmarked, like `s`. + let (mut app, _rx) = app(); + app.library + .update(mixed_listing(&[("a", true), ("b", false), ("c", true)])); + let _ = app.dispatch(Action::LibraryVisualMode); // marks a + let _ = app.dispatch(Action::LibraryNext); // sweeps b (not queueable) + let _ = app.dispatch(Action::LibraryNext); // marks c + assert_eq!( + app.library.marked_titles(), + vec!["a".to_string(), "c".to_string()] + ); + } + + #[test] + fn esc_in_visual_only_exits_visual_keeping_the_filter() { + use crossterm::event::KeyCode; + let (mut app, _rx) = app(); + app.library + .update(children_listing(&["alpha", "beta", "gamma"])); + // Apply a filter and return to navigation. + let _ = app.dispatch(Action::OpenSearch); + search_key(&mut app, "a"); + app.handle_search_key(key(KeyCode::Enter)); + assert_eq!(app.library.filter_query(), Some("a")); + // Enter visual, then Esc: leaves visual but keeps the filter. + let _ = app.dispatch(Action::LibraryVisualMode); + let _ = app.dispatch(Action::ClearSearch); + assert!(!app.library.is_visual()); + assert_eq!(app.library.filter_query(), Some("a"), "filter untouched"); + // A second Esc (not visual) now clears the filter. + let _ = app.dispatch(Action::ClearSearch); + assert_eq!(app.library.filter_query(), None); + } + #[test] fn slash_filters_the_focused_library_pane_enter_keeps_esc_clears() { use crossterm::event::KeyCode; diff --git a/plan/summary.md b/plan/summary.md index 7443dc2..06a48d6 100644 --- a/plan/summary.md +++ b/plan/summary.md @@ -1163,3 +1163,41 @@ with **no init-time network** (a bad key surfaces lazily as `FetchError`); clean. **Deferred live gate (R1):** with a real `client_id` in `jamendo.toml`, browse → queue → play a track and confirm the `audio` MP3 streams with a correct seek bar and clean EOS. Needs network + a registered key; not runnable here. + +## TUI visual mode (2026-07-24) + +Full dev-flow run for a vim-style **visual (paint-select) mode** in the library +pane (architecture/visual-mode.md, quality/visual-mode.md, plan/visual-mode.md). +`v` or `V` enters it (both the same); movement then toggles the mark of every row +swept over, so a run is selected by `v` then moving. A second `v`/`V` or `Esc` +leaves it, marks kept. + +- **bindings.rs**: new `Action::LibraryVisualMode` bound to `v` (NONE) and `V` + (SHIFT) in the Library scope; the spectrum toggle **moved off `v` to Global + `f`** ("frequency") to free the key. Help text/labels derive from `BINDINGS` + unchanged. +- **library.rs**: a `visual: bool` with `is_visual`/`toggle_visual` + (enter toggles the current row, vim-style)/`exit_visual`, `selected_view`, and + `paint_between(from_view, to_view)` — toggles the mark of every view index in + the half-open sweep `(from, to]`, mapped through the `/` filter and gated on + `is_queable` exactly like `s`. `toggle_mark` refactored onto a shared + `toggle_mark_view`. Title shows `— VISUAL` while active. +- **mod.rs**: `App::dispatch` captures `was_visual`, auto-leaves visual mode for + any action except the six movements, `LibraryVisualMode`, and `ClearSearch`; + the six movements route through a `library_move` helper that paints the swept + range when visual is active; `Esc` in visual leaves the mode without clearing + the `/` filter; node/focus changes leave it. + +Decisions/deviations: **paint semantics are toggle-based** (back-sweeping +un-paints — the literal "toggle" requested; an anchored never-un-paint range is +a noted future refinement); **library-only** — the queue has no marks yet (a +standing `queue.rs` FIXME), so `v`/`V` are unbound there (D5); **spectrum key +`f` is a +chosen default**, trivially changed; **web-client parity deferred** (D6). Jump +moves (`g`/`G`, `Ctrl-d`/`Ctrl-u`) paint the whole span. + +Verification: `cbd-tui` 89 tests green (14 new: 2 binding + 12 dispatch/paint +covering enter/step/jump/back-sweep/exit/Esc/non-move-exit/node-focus-exit/ +is_queable/filter); clippy `-D warnings` and fmt clean; markdownlint clean. Not +exercised: live in-terminal keypresses (no TTY here) — the pure dispatch/table +logic is fully unit-tested and the render path compiles. diff --git a/plan/visual-mode.md b/plan/visual-mode.md new file mode 100644 index 0000000..29d2480 --- /dev/null +++ b/plan/visual-mode.md @@ -0,0 +1,28 @@ +# Task plan — TUI visual mode + +Ordered, verifiable tasks for the library paint-select visual mode +(architecture/visual-mode.md, quality/visual-mode.md). All in `cbd-tui`. + +- [x] **P1 — bindings** (`app/bindings.rs`): add `Action::LibraryVisualMode`; + move `ToggleSpectrum` from Global `v` to Global `f` (update description key); + add Library bindings `v` (NONE) and `V` (SHIFT) → `LibraryVisualMode` with a + clear description. *Verify:* T1, T2, T3. +- [x] **P2 — Library state + paint** (`app/library.rs`): add `visual: bool`; + `is_visual`, `toggle_visual` (on-enter toggles the current mark), + `exit_visual`, `selected_view`, and `paint_between(from_view, to_view)` that + toggles the mark (respecting `is_queable`) of every view index in the half-open + sweep, mapping through the filter. *Verify:* T10, and compiles for T4–T9. +- [x] **P3 — title indicator** (`app/library.rs` `render`): show `— VISUAL` in the + pane title while visual is active (precedence over the search/creatable hints). + *Verify:* G6 by reading; render smoke test if one exists. +- [x] **P4 — dispatch routing** (`app/mod.rs`): capture `was_visual`; auto-exit + visual for any action except the six movements, `LibraryVisualMode`, and + `ClearSearch`; route the six movements through a `library_move` helper that + paints the `(old,new]` sweep when visual is active; `LibraryVisualMode` → + `toggle_visual`; `ClearSearch` exits visual when active (else clears filter); + `LibraryDive`/`LibraryAscend`/`CycleFocus` exit visual then act. *Verify:* + T4–T9, T11. +- [x] **P5 — help/parity check**: confirm the help modal renders the new/moved + bindings from `BINDINGS` (no code change expected). *Verify:* G4, G6. +- [x] **P6 — gate**: `cargo test -p cbd-tui`, `cargo clippy -p cbd-tui -D + warnings`, `cargo fmt --check`. Re-read G1–G6 against the diff. diff --git a/quality/visual-mode.md b/quality/visual-mode.md new file mode 100644 index 0000000..133ebe5 --- /dev/null +++ b/quality/visual-mode.md @@ -0,0 +1,58 @@ +# Quality gates — TUI visual mode + +Gates for the library paint-select visual mode (architecture/visual-mode.md). +Automatic tests live in `cbd-tui/src/app/bindings.rs` (table) and +`cbd-tui/src/app/mod.rs` (dispatch/paint). LLM gates are read-and-reason checks. + +## Automatic tests + +- **T1 `spectrum_moved_off_v_to_f`** (bindings) — `lookup(Global-ish, f)` → + `ToggleSpectrum` in both foci; `v`/`V` no longer resolve to `ToggleSpectrum`. +- **T2 `v_and_V_enter_visual_in_library_only`** (bindings) — `v` (NONE) and `V` + (SHIFT, and SHIFT-normalized) → `LibraryVisualMode` when the library is + focused; both → `None` when the queue is focused. +- **T3 `chords_are_unique_within_scope`** (existing) still passes with the new + bindings (no duplicate `(scope, code)`). +- **T4 `visual_enter_toggles_current_mark`** (dispatch) — from a clean library, + `LibraryVisualMode` marks exactly the selected (queueable) row and sets visual + active. +- **T5 `visual_move_paints_swept_range`** (dispatch) — `LibraryVisualMode` then + N× `LibraryNext` marks the starting row plus each row moved onto (N+1 marked); + a `LibraryLast`/jump marks the whole span to the end. +- **T6 `visual_back_sweep_unpaints`** (dispatch) — after painting down, a + `LibraryPrev` toggles the re-entered row off (marks shrink by one). +- **T7 `visual_exit_keeps_marks`** (dispatch) — a second `LibraryVisualMode` (and + separately `Esc`/`ClearSearch`) deactivates visual mode while the marked set is + unchanged. +- **T8 `visual_non_move_action_exits_then_runs`** (dispatch) — with visual active, + an action like `LibraryQueueAppend` deactivates visual and still performs + (marks consumed as usual). +- **T9 `visual_exits_on_node_or_focus_change`** (dispatch) — `LibraryDive`, + `LibraryAscend`, and `CycleFocus` deactivate visual mode. +- **T10 `paint_respects_is_queable`** (dispatch or library) — sweeping over a + non-queueable row leaves it unmarked (same gate as `s`). +- **T11 `esc_in_visual_only_exits_visual`** (dispatch) — with a `/` filter active + *and* visual active, `ClearSearch` (`Esc`) exits visual without clearing the + filter; with visual inactive it clears the filter as before. + +## LLM quality gates (read-and-reason) + +- **G1 — no new state leaks.** Visual mode is one `bool` on `Library`; no wire + type, proto, or server call is added. Movement, marks, and `select` are reused, + not duplicated. +- **G2 — `select` stays pure.** Painting is never done inside `Library::select`, + so filter re-selection and `update_selection` never toggle marks; painting only + happens on explicit movement dispatch while visual is active. +- **G3 — dispatch routing is exhaustive and safe.** Every `Action` either + participates in visual mode (the six movements + `LibraryVisualMode` + + `ClearSearch`) or deactivates it before running; no action can leave visual + mode active across a node/focus change or a mark-consuming op. +- **G4 — bindings stay the single source of truth.** The moved spectrum key and + the new `v`/`V` come only from `BINDINGS`; help text and key labels derive from + it (no hard-coded key strings in the help modal). +- **G5 — filter/view correctness.** `paint_between` sweeps *view* indices and maps + each through the active `/` filter to its real index (like `toggle_mark`), so + only visible rows are painted and no real index is toggled twice per step. +- **G6 — indicator + docs.** The library title shows `— VISUAL` while active, and + the help modal lists `v`/`V` (visual) and the moved `f` (spectrum) with clear + descriptions.