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_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 implicit in the cursor + the running mark set). Behavior:
- Enter (
v/Vwhile normal): activate, and toggle the current row's 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 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/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 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 calllibrary.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 theToggleSpectrumchord movesv→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, andselectare 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 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.