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:
parent
238e7c8f87
commit
0a9456e173
|
|
@ -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.
|
||||
|
|
@ -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] {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,6 +550,11 @@ impl App {
|
|||
self.search = Some(search);
|
||||
}
|
||||
Action::ClearSearch => {
|
||||
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.
|
||||
|
|
@ -523,6 +563,7 @@ impl App {
|
|||
UiFocus::Queue => self.queue.set_filter(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
Action::QueueInsertHere => {
|
||||
if let Some(selected) = self.queue.selected() {
|
||||
self.library.queue_insert(selected);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
Loading…
Reference in New Issue