cbd-tui: fix visual mode stranding the turnaround row (anchor the range)
The per-step paint toggled the row you arrived at, so going down then back up toggled off the rows re-entered but never the furthest row you turned around on — it stayed marked. Anchor the selection instead: on entering visual mode record the anchor row, and on each move reconcile marks to the contiguous range [anchor, cursor], toggling only the rows whose membership changed. Moving back now cleanly reverses; jumps reconcile the whole span. visual state becomes Option<usize> (the anchor). Adds a regression test (down then fully up leaves only the anchor); 90 cbd-tui tests green, clippy/fmt clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
0a9456e173
commit
2e94760a69
|
|
@ -42,65 +42,75 @@ 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:
|
||||
the **anchor** — the view index where `v` was pressed — plus the cursor).
|
||||
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`.
|
||||
- **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 **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.
|
||||
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 swept indices would otherwise be stale.
|
||||
(`Tab`) also exits — the anchor/indices would otherwise be stale.
|
||||
|
||||
```d2
|
||||
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)"
|
||||
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`):
|
||||
Worked example (rows `0..9`, all unmarked, cursor at `0` = anchor):
|
||||
|
||||
```text
|
||||
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
|
||||
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
|
||||
|
||||
`App` gains a `bool` "visual active" flag. `App::dispatch` is the single choke
|
||||
point (it already maps every `Action`):
|
||||
`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` → 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
|
||||
- `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 → clear the flag, then proceed.
|
||||
- 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)` — 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
|
||||
`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
|
||||
|
|
@ -127,17 +137,19 @@ mode to the queue is gated on giving the queue a mark set (the existing
|
|||
- **`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 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.
|
||||
- **`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
|
||||
|
||||
- **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.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -29,9 +29,11 @@ pub struct Library {
|
|||
filter: Filter,
|
||||
parent: Option<String>,
|
||||
positions: HashMap<String, usize>,
|
||||
/// Visual (paint-select) mode: while true, movement toggles the mark of
|
||||
/// every row swept over (architecture/visual-mode.md).
|
||||
visual: bool,
|
||||
/// Visual (paint-select) mode: `Some(anchor_view)` while active. Movement
|
||||
/// marks the contiguous range between the anchor (where `v` was pressed)
|
||||
/// and the cursor, toggling rows as they enter/leave it — so moving back
|
||||
/// cleanly reverses (architecture/visual-mode.md).
|
||||
visual: Option<usize>,
|
||||
tx: Sender<MessageFromUi>,
|
||||
}
|
||||
|
||||
|
|
@ -46,7 +48,7 @@ impl Library {
|
|||
filter: Filter::default(),
|
||||
positions: HashMap::new(),
|
||||
parent: None,
|
||||
visual: false,
|
||||
visual: None,
|
||||
tx,
|
||||
}
|
||||
}
|
||||
|
|
@ -197,7 +199,7 @@ impl Library {
|
|||
|
||||
/// Whether visual (paint-select) mode is active.
|
||||
pub fn is_visual(&self) -> bool {
|
||||
self.visual
|
||||
self.visual.is_some()
|
||||
}
|
||||
|
||||
/// Titles of the currently marked rows, in list order (inspection helper).
|
||||
|
|
@ -209,18 +211,21 @@ impl Library {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// Enter or leave visual mode. Entering also toggles the current row's
|
||||
/// mark (vim includes the row you start on, D2); leaving keeps the marks.
|
||||
/// Enter or leave visual mode. Entering anchors at the current row and
|
||||
/// toggles its mark (vim includes the row you start on, D2); leaving keeps
|
||||
/// the marks.
|
||||
pub fn toggle_visual(&mut self) {
|
||||
self.visual = !self.visual;
|
||||
if self.visual {
|
||||
if self.visual.is_some() {
|
||||
self.visual = None;
|
||||
} else {
|
||||
self.visual = Some(self.list_state.selected().unwrap_or(0));
|
||||
self.toggle_mark();
|
||||
}
|
||||
}
|
||||
|
||||
/// Leave visual mode (marks kept). Idempotent.
|
||||
pub fn exit_visual(&mut self) {
|
||||
self.visual = false;
|
||||
self.visual = None;
|
||||
}
|
||||
|
||||
/// The current cursor position as a view index.
|
||||
|
|
@ -228,20 +233,21 @@ impl Library {
|
|||
self.list_state.selected()
|
||||
}
|
||||
|
||||
/// Paint the sweep from `from_view` to `to_view`: toggle the mark of every
|
||||
/// view index in the half-open range `(from_view, to_view]` (excludes the
|
||||
/// row left, includes each row swept into, in either direction). Used after
|
||||
/// a movement while visual mode is active.
|
||||
/// Repaint after a visual-mode move from `from_view` to `to_view`. The
|
||||
/// selection is the contiguous range `[anchor, cursor]`; a move that grows
|
||||
/// or shrinks it toggles exactly the rows whose range membership changed
|
||||
/// (relative to the anchor), so moving back reverses a move cleanly and the
|
||||
/// row you turn around on is never stranded. No-op unless visual is active.
|
||||
pub fn paint_between(&mut self, from_view: usize, to_view: usize) {
|
||||
if from_view == to_view {
|
||||
let Some(anchor) = self.visual else {
|
||||
return;
|
||||
}
|
||||
if to_view > from_view {
|
||||
for view in (from_view + 1)..=to_view {
|
||||
self.toggle_mark_view(view);
|
||||
}
|
||||
} else {
|
||||
for view in to_view..from_view {
|
||||
};
|
||||
let (old_lo, old_hi) = (anchor.min(from_view), anchor.max(from_view));
|
||||
let (new_lo, new_hi) = (anchor.min(to_view), anchor.max(to_view));
|
||||
for view in old_lo.min(new_lo)..=old_hi.max(new_hi) {
|
||||
let in_old = (old_lo..=old_hi).contains(&view);
|
||||
let in_new = (new_lo..=new_hi).contains(&view);
|
||||
if in_old != in_new {
|
||||
self.toggle_mark_view(view);
|
||||
}
|
||||
}
|
||||
|
|
@ -383,7 +389,7 @@ impl Library {
|
|||
} else {
|
||||
COLOR_PRIMARY_DARK
|
||||
}))
|
||||
.title(if self.visual {
|
||||
.title(if self.visual.is_some() {
|
||||
// Visual (paint-select) mode: movement toggles marks.
|
||||
format!("{} — VISUAL", self.title)
|
||||
} else if let Some(query) = self.filter.query() {
|
||||
|
|
|
|||
|
|
@ -885,16 +885,37 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn visual_back_sweep_unpaints() {
|
||||
// Retreating shrinks the [anchor, cursor] range: the row turned around
|
||||
// on (gamma) is unmarked, not stranded.
|
||||
let (mut app, _rx) = app();
|
||||
app.library
|
||||
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||
let _ = app.dispatch(Action::LibraryVisualMode); // alpha
|
||||
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
|
||||
let _ = app.dispatch(Action::LibraryNext); // beta
|
||||
let _ = app.dispatch(Action::LibraryNext); // gamma
|
||||
let _ = app.dispatch(Action::LibraryPrev); // back onto beta -> unmark
|
||||
let _ = app.dispatch(Action::LibraryPrev); // back onto beta
|
||||
assert_eq!(
|
||||
app.library.marked_titles(),
|
||||
vec!["alpha".to_string(), "gamma".to_string()]
|
||||
vec!["alpha".to_string(), "beta".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_down_then_fully_up_leaves_only_the_anchor() {
|
||||
// Regression: going down and back up to the start must not strand the
|
||||
// furthest row marked (the reported bug).
|
||||
let (mut app, _rx) = app();
|
||||
app.library
|
||||
.update(children_listing(&["alpha", "beta", "gamma"]));
|
||||
let _ = app.dispatch(Action::LibraryVisualMode); // alpha (anchor)
|
||||
let _ = app.dispatch(Action::LibraryNext); // beta
|
||||
let _ = app.dispatch(Action::LibraryNext); // gamma
|
||||
let _ = app.dispatch(Action::LibraryPrev); // beta
|
||||
let _ = app.dispatch(Action::LibraryPrev); // alpha
|
||||
assert_eq!(
|
||||
app.library.marked_titles(),
|
||||
vec!["alpha".to_string()],
|
||||
"only the anchor stays marked after returning to it"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1188,13 +1188,13 @@ leaves it, marks kept.
|
|||
range when visual is active; `Esc` in visual leaves the mode without clearing
|
||||
the `/` filter; node/focus changes leave it.
|
||||
|
||||
Decisions/deviations: **paint semantics are toggle-based** (back-sweeping
|
||||
un-paints — the literal "toggle" requested; an anchored never-un-paint range is
|
||||
a noted future refinement); **library-only** — the queue has no marks yet (a
|
||||
standing `queue.rs` FIXME), so `v`/`V` are unbound there (D5); **spectrum key
|
||||
`f` is a
|
||||
chosen default**, trivially changed; **web-client parity deferred** (D6). Jump
|
||||
moves (`g`/`G`, `Ctrl-d`/`Ctrl-u`) paint the whole span.
|
||||
Decisions/deviations: **paint is anchored** — the selection is the contiguous
|
||||
range `[anchor, cursor]`, and a move toggles the rows whose range membership
|
||||
changed, so moving back cleanly reverses (a first per-step-toggle attempt
|
||||
stranded the turnaround row — fixed); **library-only** — the queue has no marks
|
||||
yet (a standing `queue.rs` FIXME), so `v`/`V` are unbound there (D5); **spectrum
|
||||
key `f` is a chosen default**, trivially changed; **web-client parity deferred**
|
||||
(D6). Jump moves (`g`/`G`, `Ctrl-d`/`Ctrl-u`) reconcile the whole span.
|
||||
|
||||
Verification: `cbd-tui` 89 tests green (14 new: 2 binding + 12 dispatch/paint
|
||||
covering enter/step/jump/back-sweep/exit/Esc/non-move-exit/node-focus-exit/
|
||||
|
|
|
|||
Loading…
Reference in New Issue