cbd-tui: add library visual (paint-select) mode on v/V; move spectrum to f

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) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-24 16:20:55 +02:00
parent 238e7c8f87
commit 0a9456e173
7 changed files with 630 additions and 19 deletions

145
architecture/visual-mode.md Normal file
View File

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

View File

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

View File

@ -29,6 +29,9 @@ pub struct Library {
filter: Filter,
parent: Option<String>,
positions: HashMap<String, usize>,
/// Visual (paint-select) mode: while true, movement toggles the mark of
/// every row swept over (architecture/visual-mode.md).
visual: bool,
tx: Sender<MessageFromUi>,
}
@ -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<String> {
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<usize> {
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 {

View File

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

View File

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

28
plan/visual-mode.md Normal file
View File

@ -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 T4T9.
- [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:*
T4T9, 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 G1G6 against the diff.

58
quality/visual-mode.md Normal file
View File

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