158 lines
7.9 KiB
Markdown
158 lines
7.9 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
|
|
the **anchor** — the view index where `v` was pressed — plus the cursor).
|
|
Behavior:
|
|
|
|
- **Enter** (`v`/`V` while normal): activate, **anchor at the current row**, and
|
|
**toggle its 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 reconcile marks to the contiguous **range `[anchor, cursor]`** —
|
|
toggle exactly the rows whose membership in that range **changed** (relative to
|
|
the anchor). Growing the range marks the rows entered; **shrinking it unmarks
|
|
the rows left**, so moving back down/up **cleanly reverses** a move and the row
|
|
you turn around on is never stranded. Jumps (`G`, `Ctrl-d`) reconcile the whole
|
|
span at once. It is a *toggle* against range membership, so sweeping over a
|
|
row that was already marked (by `s`) flips it, and sweeping back flips it back.
|
|
- **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 anchor/indices would otherwise be stale.
|
|
|
|
```d2
|
|
direction: right
|
|
shape: sequence_diagram
|
|
Normal
|
|
Visual
|
|
Normal -> Visual: "v / V (anchor here, toggle current row)"
|
|
Visual -> Visual: "j k g G C-d C-u (reconcile marks to [anchor, cursor])"
|
|
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` = anchor):
|
|
|
|
```text
|
|
v -> {0} anchor 0, toggle current
|
|
j -> {0,1} range [0,1]
|
|
j -> {0,1,2} range [0,2]
|
|
G -> {0..9} range [0,9] (jump reconciles the span)
|
|
k -> {0..8} range [0,8] -> 9 leaves, unmarked
|
|
g -> {0} range [0,0] -> 1..8 leave, unmarked
|
|
v -> exit, marks {0} kept
|
|
```
|
|
|
|
### D3 — Where the state lives and how dispatch routes it
|
|
|
|
`Library` owns the visual state as `visual: Option<usize>` — `Some(anchor_view)`
|
|
while active, so the mode and its anchor are one field, exposed as `is_visual`,
|
|
`toggle_visual`, `exit_visual`. `App::dispatch` is the single choke point (it
|
|
already maps every `Action`):
|
|
|
|
- `LibraryVisualMode` → `library.toggle_visual()` (activate: anchor at the cursor
|
|
and toggle its mark; or deactivate). Only reachable while the library is
|
|
focused (Library-scope binding).
|
|
- The six movement actions → a `library_move` helper: when visual is active, read
|
|
the cursor, run the existing move, read the cursor again, and call
|
|
`Library::paint_between(old_view, new_view)`; otherwise just move.
|
|
- `LibraryAscend`/`LibraryDive`/`CycleFocus` → covered by the catch-all below
|
|
(they exit visual, then proceed).
|
|
- `ClearSearch` (`Esc`) → if visual is active, just leave it (do not also clear
|
|
the search filter); else unchanged.
|
|
- Every other action → leave visual mode first, then proceed. Implemented as a
|
|
guard at the top of `dispatch`: capture `was_visual`, and exit unless the
|
|
action is one of the six movements, `LibraryVisualMode`, or `ClearSearch`.
|
|
|
|
`Library` gains `selected_view()` (the current view index) and
|
|
`paint_between(from_view, to_view)` — using the stored `anchor`, toggle the mark
|
|
(respecting `is_queable`) of every view index whose membership in `[anchor,
|
|
cursor]` changed between the old and new cursor, 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 `was_visual` guard + `library_move` + the
|
|
dispatch routing above.
|
|
- **`app/library.rs`** (`Library`): the `visual: Option<usize>` anchor,
|
|
`is_visual`/`toggle_visual`/`exit_visual`, `selected_view`, `paint_between`, and
|
|
the title indicator. Marks, filter, and `select` are reused unchanged.
|
|
|
|
## Risks and open questions
|
|
|
|
- **Anchor semantics chosen over per-step toggle.** An earlier half-open
|
|
per-step design stranded the turnaround row (down-then-up left the furthest row
|
|
marked). The anchored `[anchor, cursor]` range reconciliation fixes that:
|
|
moving back cleanly reverses. Sweeping over a pre-existing (`s`) mark still
|
|
*toggles* it against range membership — reversible, but worth knowing.
|
|
- **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.
|