summaryrefslogtreecommitdiff
path: root/docs/helix-keys.md
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 05:04:26 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit5bf8d6dd077517270377e5d8551108ecf252374f (patch)
treec7783fa9fc133980d3d6fff0129e8de5a1b43601 /docs/helix-keys.md
parenteefac04995ffad847a4098f16d2e82ccab16438b (diff)
downloadpardes-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/helix-keys.md')
-rw-r--r--docs/helix-keys.md83
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).