crabidy/architecture/visual-mode.md

146 lines
7.0 KiB
Markdown

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