diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 05:04:26 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | 5bf8d6dd077517270377e5d8551108ecf252374f (patch) | |
| tree | c7783fa9fc133980d3d6fff0129e8de5a1b43601 /docs | |
| parent | eefac04995ffad847a4098f16d2e82ccab16438b (diff) | |
| download | pardes-5bf8d6dd077517270377e5d8551108ecf252374f.tar.gz pardes-5bf8d6dd077517270377e5d8551108ecf252374f.zip | |
multiple cursors, regex selection, and Ctrl-c comments
The primary cursor stays exactly where it was — cur_row/cur_col plus vsel — and
sels[] holds helix's OTHER ranges. That split is why nothing moved at one
cursor: with nsel == 0 not one line of the existing motion, operator, render or
mouse code takes a different branch, which is what protects 800 differential
cases and 67 goldens.
paneRanges/setPaneRanges are the whole list; setPaneRanges IS helix's
Selection::new (min width 1, sorted, overlaps merged, primary follows its range
through a merge). An ordinary key runs the single-selection handler once per
range, visited last-first so an edit never disturbs a range still waiting, and
each finished pass is remembered as a distance from the END of the text, which
an earlier edit cannot move — helix's change mapping without a change map.
pushUndo fires once per keystroke, yanks accumulate, and a builtin acts from
the primary and stops the replay, which also closes the use-after-free window
if it frees the pane.
s and S reuse the / prompt wholesale rather than growing a second one: the
pattern is typed into the tag tail, and every keystroke re-runs the match from
the selection the prompt opened on, so the preview is live and Esc is just the
empty pattern. mvzr does runtime patterns — a bytecode VM in a fixed-size
struct with no allocator — with 64 ops and 8 char classes per pattern, no
case-insensitive flag (helix's smart case is done by folding a scratch copy),
no captures, no multi-line anchors. The last two are the two waivers.
Ctrl-c is a whole-list key and not a per-cursor replay, because helix decides
comment-vs-uncomment ONCE for the whole selection; replaying it would take that
decision n times. Comment tokens are a table in config.zig keyed on the same
extension syntax.zig picks grammars by.
Found and fixed a pre-existing single-cursor bug on the way: la<bs><esc> left
the cursor one cell before where the append began. helix's restore_cursor can
never walk past the origin; ours backed up unconditionally. hxdiff was green
before AND after — the old one-selection contract could not see it.
hxdiff 360 -> 481 cases, hxparity 440 -> 561, all goldens from real helix; the
harness contract now reports every range and its primary, omitted when there is
one, so 359 of the 360 old goldens are byte-identical. The one that moved is
o-count: helix's 2o really does leave two cursors and could not say so before.
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/helix-keys.md | 83 |
1 files changed, 74 insertions, 9 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 36002eed..839550a2 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -161,6 +161,69 @@ Phase 2 state added to `Pane`: `count` (accumulator, capped 0xffff), | `X` | snap selection to line bounds | vsel → msel over its row span; bare cursor → 1-line msel; msel: already line-wise, no-op | helix-verified (phase 5) | | `Alt-x` | shrink selection to line bounds | vsel only: partial first/last lines drop out; nothing left collapses the selection; msel: no-op | helix-verified (phase 5) | +### Multiple cursors + +helix's `Selection` is a LIST of ranges with a primary index. pardes keeps the +PRIMARY where it always was (`cur_row`/`cur_col` + `vsel`) and the other ranges +in `Pane.sels`; an ordinary key is REPLAYED once per range (`replaySels`), so +every motion and operator above works at every cursor without being rewritten. +With one cursor nothing is replayed and nothing changed. The result contract +grew `sels` + `primary`, emitted only when there is more than one range. + +| Key | Behavior | Notes | Status | +| --- | --- | --- | --- | +| `C` / `Alt-C` / `<n>C` | copy the selection to the next / previous line | helix `copy_selection_on_line`, incl. skipping lines too short to hold the column. **Byte columns, not visual ones** (a TAB counts as one) | helix-verified | +| `,` / `Alt-,` | keep only the primary / remove the primary | `Alt-,` on a lone cursor is a no-op (helix errors) | helix-verified | +| `)` / `(` / `<n>)` | rotate the primary forward / backward (wrapping) | ranges unchanged, only which one is primary | helix-verified | +| `Alt-s` | split the selection on newlines | helix `split_on_newline`; the newlines themselves drop out, primary becomes 0 (helix's own TODO) | helix-verified | +| `Alt-minus` / `Alt-_` | merge all ranges into one / merge the consecutive ones | helix `merge_selections` / `merge_consecutive_ranges` | helix-verified | +| `_` | trim whitespace off both ends of every range | empty and all-whitespace ranges drop; nothing left = collapse + keep primary (helix) | helix-verified | +| `<n>o` / `<n>O` | open n lines, one cursor per line | helix `open` with a count; this is why the `o-count` golden gained a `sels` field | helix-verified | +| every motion/operator | acts at every cursor | replayed last-range-first, so an edit never disturbs a range still waiting; each pass's result is remembered as a distance from the END of the text, which an earlier edit cannot move | helix-verified (56 msel-* cases) | +| `y` with several ranges | joins the ranges' text with newlines into the ONE register | helix keeps a register VALUE per range and pastes value[i] at range[i]. Waived (`msel-yank-paste`) | waived | +| `a` … `Esc` with several ranges | the primary's appended-over span is restored; the others collapse to bare cursors | `Pane.append_at` is a single field. Waived (`msel-append`) | waived | +| `&` | align selections into a column | **skipped**: needs visual (tab-expanded) columns, which nothing else in pardes measures | +| `Alt-(` / `Alt-)` | rotate the CONTENTS of the selections | skipped: a separate feature from rotating which range is primary | + +Anything that reaches outside the buffer — a builtin, a language query — runs +once from the primary and drops back to a single cursor rather than firing per +range (`multiOnce`). Undo restores the primary from the snapshot and leaves the +other cursors where they are (`FileSnap` holds one range). + +### Regex selection (`s` / `S`) — the interactive pair + +Both arm the tag input the same way `/` does — the pattern is typed into the +tag tail after a marker, no popup — and both re-run on EVERY keystroke, from +the selection the prompt opened on (`Pane.sel_snap`). That is what makes the +selection a live preview, what makes typing a pattern one character at a time +land where pasting it whole would, and what makes Esc a plain restore. Enter +re-runs the final pattern down the same path, so a submit can never disagree +with what is on screen. Engine: **mvzr** (`build.zig.zon`), a bytecode VM that +compiles a runtime pattern with no allocator. + +| Key | Behavior | Notes | Status | +| --- | --- | --- | --- | +| `s<pat>` Enter | select every match INSIDE each range | helix `select_on_matches`; a match sitting right off a range's end is dropped (what `\b` and empty matches produce there). No match, no pattern, or one that will not compile = the selection is left alone (helix's "nothing selected") | helix-verified | +| `S<pat>` Enter | split each range on its matches | helix `split_on_matches`; the pieces BETWEEN the matches, including the empty one a leading match produces | helix-verified | +| `s`/`S` then Esc | back to the selection the prompt opened on | the empty pattern applies nothing, so cancelling IS the restore — one path, not a second one | helix-verified | +| an all-lowercase pattern | matches case-blind | helix's smart-case. mvzr has no such flag, so the surface is lowercased instead (ASCII folding is byte-for-byte, so the offsets are identical) | helix-verified | +| `^` and `$` | assert at the SCAN position, not at a line | helix compiles with `multi_line(true)`, so its `^` is every line start. Waived (`sel-regex-caret`) | waived | +| `.` | matches a newline like any other byte | the Rust regex crate excludes `\n` by default; mvzr does not. `[^\n]` is the workaround and agrees in both. Waived (`sel-regex-dot-newline`) | waived | +| more than 64 matches | the ones past `MAX_SELS` are dropped | the ceiling the whole selection model has, not this key's | +| `K` / `Alt-K` | keep / remove ranges matching a regex | **skipped**: the same prompt, filtering instead of splitting — worth adding next | + +### `Ctrl-c` — toggle comments + +| Key | Behavior | Notes | Status | +| --- | --- | --- | --- | +| `Ctrl-c` | comment or uncomment every line the WHOLE selection touches | helix `toggle_comments`. One decision for the whole set — one uncommented non-blank line and everything gets commented — which is why it is a `wholeKey` and not a per-cursor replay | helix-verified | +| … at several cursors | each line once, in order | helix's `min_next_line`: two cursors on one line comment it once | helix-verified | +| … with mixed indents | the token goes in at the SHALLOWEST indent in the set | helix's `min`, quirks included: a deeper line is commented mid-whitespace | helix-verified | +| … uncommenting | one space after the token goes too, unless some line lacks it | helix's `margin` | helix-verified | +| … blank lines | skipped entirely, and they do not vote on commented-or-not | helix | helix-verified | +| which token | by file EXTENSION, from `config.comment_tokens` | the same notion of "language" `src/syntax.zig` picks grammars with. No extension (a terminal, an output buffer) or an unlisted one gets `config.comment_token_default` = `#`, which is helix's own `DEFAULT_COMMENT_TOKEN` and therefore what the oracle answers | helix-verified | +| block comments (`/* */`) | **skipped**: helix only reaches them when a language declares block tokens and no line tokens; every language in the table has a line comment | + ### Match mode (`m` prefix) — plain-text, no tree-sitter | Key | Behavior | Notes | Status | @@ -229,12 +292,12 @@ text. | `Q` / `q` | record / replay macro | macros — needs replayable input log | | `Ctrl-i` `Ctrl-o` `Ctrl-s` (normal) | jumplist forward/back/save | jumplist | | `Alt-u` / `Alt-U` | undo-history earlier/later | history timeline — linear snapshot u/U covers pardes | -| `s S Alt-s`, `Alt-minus Alt-_`, `& _`, `, Alt-,`, `C Alt-C`, `( ) Alt-( Alt-)`, `K Alt-K`, `Alt-:` | regex select/split, merge, align/trim, rotate, keep/remove, copy-to-line, ensure-forward | multicursor machinery — pardes has exactly one selection | -| `Alt-J` | join + select the inserted space | marginal over `J`; multicursor-flavored | +| `K Alt-K`, `Alt-:` | regex keep/remove, ensure-forward | `K`/`Alt-K` are the same prompt `s`/`S` now have, filtering instead of splitting (`s S` moved to "Regex selection" in A, the rest of the family to "Multiple cursors") | +| `&`, `Alt-(` / `Alt-)` | align selections, rotate selection CONTENTS | see the multiple-cursors table for why | +| `Alt-J` | join + select the inserted space | marginal over `J` | | `\|` `Alt-\|` `!` `Alt-!` `$` | shell pipe/insert/append/keep | shell — pardes executes via Tab / middle-click chords instead | | `:` | command mode | side-effects/file-ops — pardes builtins live in the tag, and `:` is bound to focusing it (section A) | | `gf` | goto file under selection | covered by pardes Enter-look | -| `Ctrl-c` | toggle comments | language-dependent | | `Ctrl-z` | suspend | pardes IS the terminal multiplexer | | insert `Ctrl-s` | commit undo checkpoint | undo is per-insert-session snapshots; no sub-session checkpoints | | `Shift-Tab` (insert), smart-tab semantics | insert tab / smart tab | smart-tab machinery; plain Tab-inserts-tab lands in B | @@ -256,7 +319,7 @@ mode), and the block-cursor positions of the primary selection's head Files (all in `test/hxcases/`): -- `cases.jsonl` — the corpus: 360 cases (255 file + 105 tty). Every +- `cases.jsonl` — the corpus: 481 cases (360 file + 121 tty). Every helix-equivalent row of sections A and B has at least a typical and an edge case; pure navigation/selection bindings get a `pane:"tty"` twin (same text/keys — helix on that text IS the oracle for tty navigation @@ -268,11 +331,13 @@ Files (all in `test/hxcases/`): (runs the `hx-harness` binary from the helix checkout; override with `$HX_HARNESS`). Only needed when cases change — the diff itself runs offline. -- `waivers.jsonl` — named exemptions, each with a reason. Two live ones: - `wiX-edit-drops-sel` (anchor-only: helix maps the selection through - insert-mode edits, pardes drops it on the first edit) and - `alt-c-window-op` (the pardes window op deliberately shadows helix - change-noyank). +- `waivers.jsonl` — named exemptions, each with a reason. Six live ones: + `wiX-edit-drops-sel` and `msel-append` (helix maps selections through + insert-mode edits, pardes does not), `alt-c-window-op` (the pardes + window op deliberately shadows helix change-noyank), `msel-yank-paste` + (one yank register, not one value per range), and `sel-regex-caret` / + `sel-regex-dot-newline` (mvzr is not the Rust regex crate: no + multi-line `^`/`$`, and `.` matches a newline). - `test/hxdiff.zig` builds `pardes-hxdiff`, which drives the sans-IO core headlessly at 80x24 (22 body rows, matching helix's 22 text rows). |
