crabidy/architecture/visual-mode.md

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

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

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