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_queablegating is preserved.toggle_markonly marks queueable rows; paint-toggle does the same, so sweeping over a non-queueable row leaves it unmarked (consistent withs).- 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/Vwhile normal): activate, anchor at the current row, and toggle its mark (vim includes the row you start on). A lonev … vthus behaves like a singles. - Move (any of
j/k,g/G,Ctrl-d/Ctrl-uwhile 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 (bys) flips it, and sweeping back flips it back. - Exit:
v/Vagain, orEsc, deactivates (marks persist). Any other action key (a,Enter,w, …) also exits first, then runs normally, sov j j aselects 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):
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_movehelper: when visual is active, read the cursor, run the existing move, read the cursor again, and callLibrary::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: capturewas_visual, and exit unless the action is one of the six movements,LibraryVisualMode, orClearSearch.
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 theToggleSpectrumchord movesv→f. All dispatch/help/label logic is already derived from the table.app/mod.rs(App): thewas_visualguard +library_move+ the dispatch routing above.app/library.rs(Library): thevisual: Option<usize>anchor,is_visual/toggle_visual/exit_visual,selected_view,paint_between, and the title indicator. Marks, filter, andselectare 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 howsandget_selectedalready 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.