crabidy/architecture/visual-mode.md

7.0 KiB

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.
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):

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