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 | |
| 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.
| -rw-r--r-- | build.zig | 8 | ||||
| -rw-r--r-- | build.zig.zon | 4 | ||||
| -rw-r--r-- | docs/helix-keys.md | 83 | ||||
| -rw-r--r-- | next-steps.txt | 2 | ||||
| -rw-r--r-- | src/config.zig | 62 | ||||
| -rw-r--r-- | src/pardes.zig | 1067 | ||||
| -rw-r--r-- | test/hxcases/cases.jsonl | 121 | ||||
| -rw-r--r-- | test/hxcases/goldens.jsonl | 123 | ||||
| -rw-r--r-- | test/hxcases/parity-waivers.jsonl | 3 | ||||
| -rw-r--r-- | test/hxcases/waivers.jsonl | 4 | ||||
| -rw-r--r-- | test/hxdiff.zig | 99 | ||||
| -rw-r--r-- | test/snapshots/lspdebug.golden | 14 | ||||
| -rw-r--r-- | test/snapshots/multicursor.golden | 217 | ||||
| -rw-r--r-- | test/snapshots/multicursor.snap | 46 | ||||
| -rw-r--r-- | test/snapshots/selregex.golden | 275 | ||||
| -rw-r--r-- | test/snapshots/selregex.snap | 56 |
16 files changed, 2098 insertions, 86 deletions
@@ -329,6 +329,14 @@ pub fn build(b: *std.Build) void { root_mod.addImport("zstbi", zstbi_dep.module("root")); hx_core_mod.addImport("zstbi", zstbi_dep.module("root")); + // mvzr: the regex engine behind `s` / `S` (helix select/split on a regex). + // A bytecode VM that compiles a RUNTIME pattern into a fixed-size struct + // with no allocator at all, which is exactly the shape an interactive + // prompt needs — see applySelRegex in src/pardes.zig. + const mvzr_mod = b.dependency("mvzr", .{ .target = target, .optimize = optimize }).module("mvzr"); + root_mod.addImport("mvzr", mvzr_mod); + hx_core_mod.addImport("mvzr", mvzr_mod); + // ghostty-vt and vaxis both depend on uucode, but Zig forbids one source // file in two modules — build a single uucode (our config, unpacked tables) // and hand it to both. Same trick as the prototype; see uucode_config.zig. diff --git a/build.zig.zon b/build.zig.zon index b1c3cafe..488dae7b 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -156,6 +156,10 @@ .url = "git+https://github.com/airbus-cert/tree-sitter-powershell#d398441825243b00e317e87e1829b9d6a3e54ce0", .hash = "N-V-__8AAKzlUACji5nMCtCC_dgDtF1rEg1qoduDm3MT5B5H", }, + .mvzr = .{ + .url = "git+https://github.com/mnemnion/mvzr#3efcfe339bd32e122c36b8f1db25b237f93a7ec2", + .hash = "mvzr-0.3.9-ZSOky6t2AQCA7efmm5EYbPb5sOdbXa4EFsUgYbQ6hZv9", + }, }, .minimum_zig_version = "0.16.0", .paths = .{""}, 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). diff --git a/next-steps.txt b/next-steps.txt index b421f7a4..7599ebdd 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -1,7 +1,7 @@ + All syntatic choices must be centralized in single .zig file: keybindings, syntax for Look, etc; the motivation for this is that those can easilly be changed and tweaked if they're centralized like this and in the process we may cut down some repetition of logic. + Carve out and isolate the Output pane as its own semantic entity, right now we are doing specific stuff on the output pane based on the command that opened it, let's make this part of the data structure that represents it, the heuristics for special handling of the output panes will be done by reading the Command that originated it field from itself. Make this centralized on the code so its clear to see and tweak those heuristics. + File panes need to split the latest column which a file pane was being used. -- Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch. ++ Add multiple cursor support, and Ctrl-c auto comments. Use the same methodology of running helix in a harness as the oracle for differential testing. Also add regex interactive selection like on helix and the more specific selection semantics, use https://github.com/mnemnion/mvzr for regexes, add it using zig fetch. + Add a new builtin that will list all builtins and their respective shortcuts if they have any (note that shortcuts may be spc+... or just keyboard or mouse chords/movements) + Scrolling on a touchpad moves horizontally accidentally way too much, add some heuristics to prioritize vertical scrolling and make horizontal scrolling work only when the user is a bit more explicit about it - for example by measuring if there was recent high-enough vertical movement, etc. + Add a new builtin to list the themes and create an output window to select those themes; each theme now must live on its own .zig file, when moving with n/N on out ThemeSel output window it will select that theme so it's a interactive way to select the themes. We're being clever now with how these output windows will work, this feature depends on the new semantics for output windows but basically now the ThemeSel output panes will set the Command flag that will basically tell that when doing n/N movement on the output pane instead of Look-ing it will Execute the line, so the ThemeSel output pane will be like: (Theme acme, Theme a, Theme b) seperated by lines so the n/N will automatically consider selecting the Theme + argument (this is not output pane specific, it should be generalized to work on every pane kind), and will Exec it. Add new themes that will be automatically exported from helix and zed, a new binary will execute that will generate .zig files, this binary will be added as a execute step on the build.zig so when building it will regenarate those .zig that will be then included on the runtime as themes. diff --git a/src/config.zig b/src/config.zig index 7f83155a..8f2175f6 100644 --- a/src/config.zig +++ b/src/config.zig @@ -443,6 +443,30 @@ pub const url_schemes = [_][]const u8{ "http://", "https://" }; /// a path ending in one of these opens an image pane instead of a file pane pub const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" }; +/// What `Ctrl-c` (comment_toggle) puts at the front of a line, by file +/// EXTENSION — which is how src/syntax.zig already tells one language from +/// another, so this is that same notion and not a second one. A pane whose +/// path matches nothing here (and every terminal, which has no path at all) +/// gets `comment_token_default`. +/// +/// `#` as the default is not a guess: it is helix's own DEFAULT_COMMENT_TOKEN, +/// which is what a helix buffer with no language configured comments with — +/// and therefore what the differential oracle answers, since the harness runs +/// with zero language configs. +/// Languages with no LINE comment at all (css, html, json, ocaml) are absent +/// on purpose: helix leaves those buffers on its default too, and inventing a +/// token for them would be a divergence nothing asked for. +pub const comment_token_default = "#"; +pub const comment_tokens: []const struct { exts: []const []const u8, token: []const u8 } = &.{ + .{ .token = "//", .exts = &.{ ".zig", ".zon", ".c", ".h", ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx", ".rs", ".go", ".java", ".scala", ".sc", ".kt", ".kts", ".cs", ".csx", ".php", ".pas", ".pp", ".p", ".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".swift", ".dart" } }, + .{ .token = "#", .exts = &.{ ".py", ".pyw", ".sh", ".bash", ".zsh", ".rb", ".rake", ".ex", ".exs", ".ps1", ".psm1", ".psd1", ".pl", ".pm", ".r", ".jl", ".nix", ".toml", ".yaml", ".yml", ".cmake", ".mk", ".tf" } }, + .{ .token = "--", .exts = &.{ ".lua", ".hs", ".lhs", ".elm", ".sql", ".adb", ".ads", ".ada" } }, + .{ .token = ";", .exts = &.{ ".clj", ".cljs", ".cljc", ".edn", ".el", ".lisp", ".scm", ".asm", ".s" } }, + .{ .token = "%", .exts = &.{ ".erl", ".hrl", ".tex", ".cls", ".sty" } }, + .{ .token = "!", .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" } }, + .{ .token = "\"", .exts = &.{ ".vim", ".vimrc" } }, +}; + /// What an armed search writes into the tag tail — and the ONLY record of /// which search it is: Enter reads the marker back (submitSearch) instead of /// pardes carrying a second piece of pane state per command. The pattern is @@ -454,6 +478,12 @@ pub const find_marker = " Find /"; pub const grep_marker = " Grep /"; pub const rename_marker = " Rename /"; pub const symbol_marker = " WsSymbols /"; +/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there +/// is no Select/Split command to run from a tag, they name the key that armed +/// the input so the tag still reads as what it is about to do. They also mark +/// the one input that previews as you type (pardes.zig, previewSelRegex). +pub const select_marker = " Select /"; +pub const split_marker = " Split /"; /// Output-buffer names (acme's +Errors). Cosmetic now, and deliberately so: a /// buffer is DERIVED from the command that opened it (output_pane.traits), and @@ -605,6 +635,33 @@ pub const collapse_selection: []const Chord = &.{.{ .cp = ';' }}; pub const flip_selection: []const Chord = &.{.{ .cp = ';', .alt = true }}; pub const select_all: []const Chord = &.{.{ .cp = '%' }}; +// ---- multiple cursors ---- +// +// helix's Selection is a LIST of ranges with a primary index, and these ten +// keys are the ones that act on the list rather than on the text: every other +// key is replayed once per range instead (pardes.zig, replaySels). Alt-C is +// Alt-SHIFT-c and so does not collide with pane_to_new_column's Alt-c — `hit` +// compares the codepoint, and `C` is `C`. +pub const copy_sel_below: []const Chord = &.{.{ .cp = 'C' }}; +pub const copy_sel_above: []const Chord = &.{.{ .cp = 'C', .alt = true }}; +pub const keep_primary_sel: []const Chord = &.{.{ .cp = ',' }}; +pub const remove_primary_sel: []const Chord = &.{.{ .cp = ',', .alt = true }}; +pub const rotate_sel_fwd: []const Chord = &.{.{ .cp = ')' }}; +pub const rotate_sel_back: []const Chord = &.{.{ .cp = '(' }}; +pub const split_sel_newline: []const Chord = &.{.{ .cp = 's', .alt = true }}; +pub const merge_sels: []const Chord = &.{.{ .cp = '-', .alt = true }}; +pub const merge_consecutive_sels: []const Chord = &.{.{ .cp = '_', .alt = true }}; +pub const trim_sels: []const Chord = &.{.{ .cp = '_' }}; + +// The other two list-making keys: a REGEX turns each range into many. Both +// arm the tag input above (select_marker / split_marker) instead of doing +// anything immediately, so `s` and `S` are the only normal-mode keys whose +// effect lands a keystroke later, on Enter — or live, as you type. +// `s` is free here despite `gs` (goto_first_nonws) also being `s`: a pending +// prefix is matched by the stored codepoint, never by these chords. +pub const select_regex: []const Chord = &.{.{ .cp = 's' }}; +pub const split_regex: []const Chord = &.{.{ .cp = 'S' }}; + // ---- edits ---- pub const delete: []const Chord = &.{.{ .cp = 'd' }}; @@ -628,6 +685,11 @@ pub const increment: []const Chord = &.{.{ .cp = 'a', .ctrl = true }}; pub const decrement: []const Chord = &.{.{ .cp = 'x', .ctrl = true }}; pub const undo: []const Chord = &.{.{ .cp = 'u' }}; pub const redo: []const Chord = &.{.{ .cp = 'U' }}; +/// helix `toggle_comments`: comment or uncomment every line the selection +/// touches, with `comment_tokens` above choosing the token. Ctrl-c reaches a +/// terminal pane only in NORMAL mode — raw tty forwards it to the program, +/// where it is still SIGINT. +pub const comment_toggle: []const Chord = &.{.{ .cp = 'c', .ctrl = true }}; /// Exit select mode / drop pending prefixes and the count; leave insert mode; /// abandon a tag, an armed search or the topbar. On an OUTPUT buffer there is diff --git a/src/pardes.zig b/src/pardes.zig index c1f8df44..947c1c96 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -21,6 +21,7 @@ const std = @import("std"); const ghostty_vt = @import("ghostty-vt"); const uucode = @import("uucode"); +const mvzr = @import("mvzr"); const modal = @import("modal.zig"); const look = @import("look.zig"); const syntax = @import("syntax.zig"); @@ -266,6 +267,12 @@ const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 }; // modal line/char selection: warm tan, reads as keyboard-driven const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e }; const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 }; +// the OTHER cursors of a multi-selection: the same tan turned down, so the +// primary is the one that reads as "here" at a glance (helix does the same +// with its two selection styles). Each secondary's own cursor cell is then +// painted msel_fg-on-msel_bg — there is only one hardware cursor. +const msel2_bg = [3]u8{ 0x2e, 0x26, 0x16 }; +const msel2_fg = [3]u8{ 0xa8, 0x9c, 0x84 }; const UNDO_MAX = 256; const EDIT_UNDO_MAX = 64; // terminal snapshots copy the edit buffer; cap tighter @@ -662,6 +669,28 @@ pub const CharSel = struct { explicit: bool = false, }; +/// ponytail: at most this many cursors at once. helix's `Selection.ranges` is +/// an unbounded Vec; a fixed array keeps a Pane trivially copyable (the undo +/// snapshots memcpy it) and costs nothing at one cursor. The ceiling only +/// bites on `C`/`Alt-s` over a very long selection, where the extra ranges are +/// simply not created — raise the number, or swap in an ArrayList, if that +/// ever matters. +pub const MAX_SELS = 64; + +/// One selection range in PANE coordinates: the block-cursor cell and the +/// anchor cell. Deliberately the same pair `cur_row`/`cur_col` + `vsel` +/// already are, so a range moves in and out of the primary slot without a +/// conversion. +pub const SelRange = struct { + row: i32, + col: i32, + arow: i32, + acol: i32, + /// this range's own j/k goal column (helix Range::old_visual_position); + /// the PRIMARY's copy is Pane.sticky_col + sticky: i32 = -1, +}; + /// Undo snapshot of a terminal pane's edit buffer (whole-state, dumb on /// purpose). `ovl` is null for a pane nothing has been typed into yet, and /// owns its text exactly like FileSnap does. @@ -674,6 +703,12 @@ pub const EditSnap = struct { /// Undo snapshot of a file pane: content + the selection at commit time — /// helix undo restores the selection along with the text. +/// ponytail: the PRIMARY selection only. With multiple cursors an undo puts +/// the primary back and leaves the others where they are — which is right +/// whenever the undone edit is the one they made (the common case, and what +/// the msel-undo differential case pins), and merely misplaced otherwise. +/// Storing the whole list would put MAX_SELS ranges in every one of the 256 +/// snapshots a pane keeps, for a case nobody has complained about. pub const FileSnap = struct { content: []u8, cur_row: i32, @@ -740,6 +775,17 @@ pub const Pane = struct { image: ?Image = null, msel: LineSel = .{}, vsel: CharSel = .{}, + /// MULTIPLE CURSORS. helix's Selection is a list of ranges plus a primary + /// index; pardes keeps the PRIMARY exactly where it has always been — + /// cur_row/cur_col + vsel — and the other ranges here, document-ordered + /// and disjoint (helix's Selection::normalize). That split is the whole + /// design: every motion, operator, renderer and mouse path in this file + /// still reads one selection, so with `nsel == 0` not a byte of behaviour + /// moves, and the 800 differential cases and 66 snapshots keep proving it. + /// The extra ranges are driven by replaying the single-selection key + /// handler once per range (see replaySels). + sels: [MAX_SELS - 1]SelRange = undefined, + nsel: u8 = 0, /// helix select/extend mode (`v`): motions extend the selection from its /// fixed anchor instead of replacing it. Reported as mode "select"; /// pane.mode stays .normal (insert/tty transitions drop it). @@ -776,6 +822,21 @@ pub const Pane = struct { search_at: u16 = 0, search_pane: ?usize = null, search_row: ?usize = null, + /// The selection an `s`/`S` input was armed on, as gap offsets over the + /// motion surface. Every keystroke re-derives the preview FROM here rather + /// than from the previous preview — which is what helix's regex_prompt + /// does (it reverts to its snapshot before each update), what makes typing + /// a pattern one character at a time land on the same answer as pasting it + /// whole, and what makes Esc a plain restore with nothing else to undo. + /// `nsel_snap == 0` means no such input is armed; exitTagEdit, the one + /// place search_edit is ever cleared, clears it too. + sel_snap: [MAX_SELS]modal.HxRange = undefined, + nsel_snap: u8 = 0, + sel_snap_pri: u8 = 0, + /// ...including whether it was a user-intent selection: a preview IS one + /// (you picked those matches), but restoring must not silently promote + /// motion residue into something the acme chords will act on + sel_snap_expl: bool = false, /// the editable tag tail: seeded with the default builtins on first touch; /// edited in place by a modal one-line editor sharing the pane's mode tag_tail: std.ArrayList(u8) = .empty, @@ -1015,6 +1076,16 @@ pub const Pardes = struct { /// is proof the gesture ended, and still needs no clock. wheel_guard: u8 = 0, ctrl_w_pending: bool = false, + /// A key is being REPLAYED over several selections (replaySels). The one + /// thing the replay cannot do is let a per-pass action that is really a + /// per-KEYSTROKE action fire once per range: the undo snapshot must be + /// taken once, the yank register accumulates instead of being overwritten, + /// and anything that opens, closes or focuses a pane (runBuiltin) or asks + /// the language backend (lspRequest) happens on the first pass and stops + /// the replay dead — see multiOnce. + multi_on: bool = false, + multi_first: bool = false, + multi_stop: bool = false, /// SPC leader in flight, holding the key path typed so far (empty = just /// SPC). Global like ctrl_w_pending — there is one leader and it acts on /// the active pane, whose tag shows the pending path while it waits. @@ -1319,11 +1390,38 @@ pub const Pardes = struct { } fn setYank(p: *Pardes, text: []const u8) void { + // Inside a multi-selection replay the register collects EVERY range's + // text, in document order — the passes run last-range-first, so each + // new piece goes in front of what is already there. + // ponytail: helix keeps one register VALUE per range and pastes + // value[i] back at range[i]; pardes has a single register, so N + // cursors yank one newline-joined blob and a paste puts that whole + // blob at every cursor. Written down as a differential waiver rather + // than faked — a per-range register is its own feature. + if (p.multi_on and !p.multi_first) { + const old = p.yank orelse ""; + const sep: []const u8 = if (text.len > 0 and text[text.len - 1] == '\n') "" else "\n"; + const joined = std.fmt.allocPrint(p.gpa, "{s}{s}{s}", .{ text, sep, old }) catch return; + if (p.yank) |y| p.gpa.free(y); + p.yank = joined; + return; + } if (p.yank) |y| p.gpa.free(y); p.yank = p.gpa.dupe(u8, text) catch null; p.emit(.{ .set_clipboard = {} }); } + /// A per-KEYSTROKE action reached from inside a per-SELECTION replay — + /// one that opens, closes or focuses a pane, or asks the language backend. + /// It must happen once rather than once per cursor, and once it has, the + /// remaining passes are meaningless (the pane they would edit may be gone), + /// so the replay stops. True = this pass may go ahead. + fn multiOnce(p: *Pardes) bool { + if (!p.multi_on) return true; + p.multi_stop = true; + return p.multi_first; + } + // ---- tag + selection text (chord sources) ---- /// the live tag prefix: mode + cwd/path (an image has only its path — the @@ -1389,6 +1487,7 @@ pub const Pardes = struct { pane.tag_edit = false; pane.tag_sel = false; pane.search_edit = false; // an abandoned search input stays as tag text + pane.nsel_snap = 0; // ...and its s/S preview keeps whatever it previewed } fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } { @@ -1834,7 +1933,13 @@ pub const Pardes = struct { // a search input in flight (`/` or Find): Enter searches, Esc abandons; // both restore the tag tail and hand focus back to the body. if (pane.search_edit and (hit(key, config.search_submit) or hit(key, config.escape))) { - if (hit(key, config.search_submit)) p.submitSearch(p.active); + if (hit(key, config.search_submit)) + p.submitSearch(p.active) + else if (selRegexArmed(pane)) |_| + // Esc on an `s`/`S` preview: back to the selection it opened + // on. The empty pattern applies nothing, so this IS the + // snapshot — one path, not a second restore. + p.applySelRegex(pane, "", false); pane.tag_tail.shrinkRetainingCapacity(@min(pane.search_at, pane.tag_tail.items.len)); exitTagEdit(pane); pane.mode = .normal; @@ -1846,6 +1951,11 @@ pub const Pardes = struct { // tag owns Enter/Tab too (that IS the `:` command line). if (pane.tag_edit) { if (pane.mode == .normal) p.tagNormalKey(pane, key) else p.tagInsertKey(pane, key); + // an armed `s`/`S` re-runs its pattern after EVERY keystroke: that + // live preview is what makes it interactive. Through the slot, not + // `pane` — a tag chord above can run a builtin that closed it. + const pn = p.panes[p.active] orelse return; + if (selRegexArmed(pn)) |a| p.applySelRegex(pn, a.pat, a.split); return; } // the acme chords in the body — look at / execute (config.look_key and @@ -1877,7 +1987,11 @@ pub const Pardes = struct { // Skipped while a prefix waits for its char argument (f/r/m and // friends take `p` literally). if (pane.mode == .normal and pane.pending == 0 and (hit(key, config.paste_after) or hit(key, config.paste_before))) { - p.normalPaste(pane, hit(key, config.paste_before)); + const before = hit(key, config.paste_before); + if (pane.nsel == 0) + p.normalPaste(pane, before) + else + p.replaySels(pane, if (before) .paste_before else .paste_after, key); return; } switch (pane.mode) { @@ -1895,14 +2009,36 @@ pub const Pardes = struct { const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; const gap = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }); - const back = modal.prevGrapheme(text, gap); + const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); + // helix restore_cursor is Range::new(from, prev(to)) on + // the GAP range, so it can never walk back past the + // append origin — a session whose edits ate everything + // typed collapses ONTO it. Without the clamp the cursor + // lands one cell before where the append began (`la`, + // Backspace, Esc). + const back = @max(a_off, modal.prevGrapheme(text, gap)); const bc = modal.hxPos(text, back); pane.cur_row = @intCast(bc.row); pane.cur_col = @intCast(bc.col); - const a_off = modal.hxOff(text, .{ .row = @intCast(@max(0, aa.row)), .col = @intCast(@max(0, aa.col)) }); pane.vsel = .{ .active = a_off != back, .row = aa.row, .col = aa.col, .explicit = false }; pane.cur_pinned = true; pane.ensureCursorVisible(); + // The other cursors move the same way the primary did — + // every one of them was handed the same keys, so every + // session shrank by the same grapheme — but they + // COLLAPSE rather than span: only the primary remembers + // where its append began (append_at is one field). + // ponytail: helix restores every range's appended-over + // span; store an origin per SelRange if that matters. + for (pane.sels[0..pane.nsel]) |*s| { + const sgap = modal.hxOff(text, .{ .row = @intCast(@max(0, s.row)), .col = @intCast(@max(0, s.col)) }); + const b2 = if (back == gap) sgap else modal.prevGrapheme(text, sgap); + const bp = modal.hxPos(text, b2); + s.row = @intCast(bp.row); + s.col = @intCast(bp.col); + s.arow = s.row; + s.acol = s.col; + } } return; } @@ -1972,6 +2108,7 @@ pub const Pardes = struct { pane.mode = .tty; pane.msel.active = false; pane.vsel.active = false; + pane.nsel = 0; // raw tty: the program owns the pane, and its cursor is the shell's pane.select = false; pane.append_at = null; pane.sticky_col = -1; @@ -2209,49 +2346,617 @@ pub const Pardes = struct { const e = if (r1 + 1 >= modal.hxLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; } - if (pane.vsel.active) { - const a = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - row0)), .col = @intCast(@max(0, pane.vsel.col)) }); - if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; - return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; - } + if (pane.vsel.active) return cellRange(text, pane.vsel.row - row0, pane.vsel.col, pane.cur_row - row0, pane.cur_col); return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; } + /// a gap range from its two block-cursor CELLS — the arithmetic paneRange + /// does for cur/vsel, shared with the extra selections, which are stored + /// in exactly the same shape + fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.HxRange { + const a = modal.hxOff(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); + const c = modal.hxOff(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); + return cellOffRange(text, a, c); + } + + /// the same, from the two cells' gap offsets + fn cellOffRange(text: []const u8, a: usize, c: usize) modal.HxRange { + if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; + return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; + } + + /// the inverse: a gap range's cursor and anchor CELLS (equal for a bare + /// 1-wide cursor). setPaneRange's own conversion, factored out so the + /// extra selections write back through the same three lines. + fn rangeCells(text: []const u8, r: modal.HxRange) struct { cur: usize, anc: usize } { + if (r.head > r.anchor) return .{ .cur = modal.prevGrapheme(text, r.head), .anc = r.anchor }; + if (r.head < r.anchor) return .{ .cur = r.head, .anc = modal.prevGrapheme(text, r.anchor) }; + return .{ .cur = r.head, .anc = r.head }; + } + /// write a helix range back into pane state. `explicit` marks user-intent /// selections (v/x/X, terminal n/N, file-search n/N) — the acme chords /// act only on those; motion residue stays implicit. fn setPaneRange(pane: *Pane, pl: PaneLines, text: []const u8, r0: modal.HxRange, explicit: bool) void { var r = r0; if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 - var cur_off: usize = undefined; - var anc_off: usize = undefined; - if (r.head > r.anchor) { - cur_off = modal.prevGrapheme(text, r.head); - anc_off = r.anchor; - } else if (r.head < r.anchor) { - cur_off = r.head; - anc_off = modal.prevGrapheme(text, r.anchor); - } else { - cur_off = r.head; - anc_off = r.head; - } - const cc = modal.hxPos(text, cur_off); - const ac = modal.hxPos(text, anc_off); + const off = rangeCells(text, r); + const cc = modal.hxPos(text, off.cur); + const ac = modal.hxPos(text, off.anc); pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0; pane.cur_col = @intCast(cc.col); pane.vsel = .{ - .active = anc_off != cur_off or pane.select, + .active = off.anc != off.cur or pane.select, .row = @as(i32, @intCast(ac.row)) + pl.row0, .col = @intCast(ac.col), .explicit = explicit or pane.select, }; pane.msel.active = false; + pane.nsel = 0; // writing ONE range means the selection IS that range pane.cur_pinned = true; pane.sticky_col = -1; pane.pending = 0; pane.ensureCursorVisible(); } + // ---- the OTHER selections (helix Selection.ranges / primary_index) ---- + // Two functions read and write the whole list; everything else in this + // file still speaks the single primary range, and replaySels below is what + // makes an ordinary key act at every cursor. + + /// The whole selection as helix gap ranges over `text`, DOCUMENT ORDER + /// (pane.sels is kept that way, so this only has to slot the primary in). + /// Returns how many were written and which index is the primary. + fn paneRanges(pane: *Pane, text: []const u8, row0: i32, out: *[MAX_SELS]modal.HxRange) struct { n: usize, pri: usize } { + const pr = paneRange(pane, text, row0); + var n: usize = 0; + var pri: usize = 0; + var placed = false; + for (pane.sels[0..pane.nsel]) |s| { + const r = cellRange(text, s.arow - row0, s.acol, s.row - row0, s.col); + if (!placed and @min(pr.anchor, pr.head) <= @min(r.anchor, r.head)) { + pri = n; + out[n] = pr; + n += 1; + placed = true; + } + out[n] = r; + n += 1; + } + if (!placed) { + pri = n; + out[n] = pr; + n += 1; + } + return .{ .n = n, .pri = pri }; + } + + /// Write a whole selection back — helix's `Selection::new`: min-width-1, + /// sorted by start, overlapping ranges merged (the primary following its + /// range through a merge). `ranges[pri]` lands in the primary slot through + /// setPaneRange, so nothing downstream can tell it apart from a lone + /// cursor; the rest become pane.sels. `sticky` carries each range's own + /// j/k goal column, -1 for the ones that have none. + fn setPaneRanges(pane: *Pane, pl: PaneLines, text: []const u8, in: []const modal.HxRange, sticky: []const i32, pri0: usize, explicit: bool) void { + if (in.len == 0) return; // helix asserts non-empty; here it just means "no change" + var r: [MAX_SELS]modal.HxRange = undefined; + var st: [MAX_SELS]i32 = undefined; + var n: usize = @min(in.len, MAX_SELS); + var pri: usize = @min(pri0, n - 1); + for (in[0..n], 0..) |x, i| { + r[i] = x; + if (r[i].anchor == r[i].head) r[i].head = modal.nextGrapheme(text, r[i].head); + st[i] = if (i < sticky.len) sticky[i] else -1; + } + // insertion sort by start — n is tiny and usually already ordered + var i: usize = 1; + while (i < n) : (i += 1) { + var j = i; + while (j > 0 and @min(r[j].anchor, r[j].head) < @min(r[j - 1].anchor, r[j - 1].head)) : (j -= 1) { + std.mem.swap(modal.HxRange, &r[j], &r[j - 1]); + std.mem.swap(i32, &st[j], &st[j - 1]); + if (pri == j) pri = j - 1 else if (pri == j - 1) pri = j; + } + } + // merge overlaps (helix Range::overlaps + Range::merge, kept forward: + // a merged range takes the union and loses its direction only when the + // two disagree, which is what helix's else-branch does) + var k: usize = 0; + i = 1; + while (i < n) : (i += 1) { + const a = r[k]; + const b = r[i]; + const af = @min(a.anchor, a.head); + const at = @max(a.anchor, a.head); + const bf = @min(b.anchor, b.head); + const bt = @max(b.anchor, b.head); + if (af == bf or (at > bf and bt > af)) { + r[k] = if (a.anchor > a.head and b.anchor > b.head) + .{ .anchor = @max(a.anchor, b.anchor), .head = @min(a.head, b.head) } + else + .{ .anchor = @min(af, bf), .head = @max(at, bt) }; + if (pri == i) pri = k; + if (st[k] < 0) st[k] = st[i]; + continue; + } + k += 1; + r[k] = b; + st[k] = st[i]; + if (pri == i) pri = k; + } + n = k + 1; + setPaneRange(pane, pl, text, r[pri], explicit); + pane.sticky_col = st[pri]; + var w: usize = 0; + for (r[0..n], 0..) |x, idx| { + if (idx == pri) continue; + const c = rangeCells(text, x); + const cc = modal.hxPos(text, c.cur); + const ac = modal.hxPos(text, c.anc); + pane.sels[w] = .{ + .row = @as(i32, @intCast(cc.row)) + pl.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + pl.row0, + .acol = @intCast(ac.col), + .sticky = st[idx], + }; + w += 1; + } + pane.nsel = @intCast(w); + } + + /// The helix keys that act on the selection LIST rather than on the text. + /// Each reads the whole list and writes a whole list back; none is a + /// motion, which is why they are exempt from the per-range replay + /// (wholeKey). The goal columns are dropped on the way through — every + /// one of these is a fresh intent about WHERE the cursors are, the same + /// reason setPaneRange resets sticky_col. + fn multiSelKey(pane: *Pane, pl: PaneLines, text: []const u8, key: Key, cnt: usize) void { + var rs: [MAX_SELS]modal.HxRange = undefined; + const got = paneRanges(pane, text, pl.row0, &rs); + const n = got.n; + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + if (hit(key, config.remove_primary_sel)) { + if (n < 2) return; // helix: "no selections remaining" + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (i == got.pri) continue; + out[m] = r; + m += 1; + } + // helix Selection::remove: the NEXT range takes over, or the + // previous one when the primary was last + return setPaneRanges(pane, pl, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); + } + if (hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back)) { + const step = cnt % n; + const pri = if (hit(key, config.rotate_sel_fwd)) (got.pri + step) % n else (got.pri + (n - step)) % n; + return setPaneRanges(pane, pl, text, rs[0..n], &.{}, pri, expl); + } + if (hit(key, config.merge_sels)) { + // helix merge_selections: first.merge(last) — the ranges are + // sorted, so that is simply the whole span + const lo = @min(rs[0].anchor, rs[0].head); + const hi = @max(rs[n - 1].anchor, rs[n - 1].head); + const rev = rs[0].anchor > rs[0].head and rs[n - 1].anchor > rs[n - 1].head; + const one: modal.HxRange = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; + return setPaneRanges(pane, pl, text, &.{one}, &.{}, 0, expl); + } + if (hit(key, config.merge_consecutive_sels)) { + // ranges that TOUCH become one; setPaneRanges already merges the + // ones that overlap, so widening each by a grapheme says exactly + // "consecutive counts as overlapping" and nothing else + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + var pri: usize = 0; + for (rs[0..n], 0..) |r, i| { + if (m > 0 and @min(r.anchor, r.head) == @max(out[m - 1].anchor, out[m - 1].head)) { + const lo = @min(@min(out[m - 1].anchor, out[m - 1].head), @min(r.anchor, r.head)); + const hi = @max(@max(out[m - 1].anchor, out[m - 1].head), @max(r.anchor, r.head)); + out[m - 1] = .{ .anchor = lo, .head = hi }; + if (i == got.pri) pri = m - 1; + continue; + } + if (i == got.pri) pri = m; + out[m] = r; + m += 1; + } + return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); + } + if (hit(key, config.split_sel_newline)) { + // helix selection::split_on_newline — one range per line the + // selection covers, the newlines themselves left out + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + if (from == to) { + if (m < MAX_SELS) { + out[m] = r; + m += 1; + } + continue; + } + var start = from; + while (start < to and m < MAX_SELS) { + const eol = modal.hxLineEndIdx(text, modal.hxLineOf(text, start)); + if (eol >= to) { + out[m] = .{ .anchor = start, .head = to }; + m += 1; + break; + } + out[m] = .{ .anchor = start, .head = eol }; + m += 1; + start = eol + 1; + } + } + if (m == 0) return; + return setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 + } + if (hit(key, config.trim_sels)) { + // helix trim_selections: whitespace off both ends; ranges that are + // empty or all whitespace are dropped entirely + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + for (rs[0..n]) |r| { + var from = @min(r.anchor, r.head); + var to = @max(r.anchor, r.head); + while (from < to and std.ascii.isWhitespace(text[from])) from += 1; + while (to > from and std.ascii.isWhitespace(text[to - 1])) to -= 1; + if (from >= to) continue; + out[m] = if (r.anchor > r.head) .{ .anchor = to, .head = from } else .{ .anchor = from, .head = to }; + m += 1; + } + if (m == 0) { // helix: collapse_selection + keep_primary_selection + const c = modal.hxCursor(text, rs[got.pri]); + return setPaneRange(pane, pl, text, .{ .anchor = c, .head = c }, false); + } + // helix: the first survivor that OVERLAPS the old primary, else the last + const pf = @min(rs[got.pri].anchor, rs[got.pri].head); + const pt = @max(rs[got.pri].anchor, rs[got.pri].head); + var pri = m - 1; + for (out[0..m], 0..) |r, i| { + const f = @min(r.anchor, r.head); + const t = @max(r.anchor, r.head); + if (f == pf or (t > pf and pt > f)) { + pri = i; + break; + } + } + return setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); + } + // C / Alt-C — helix copy_selection_on_line, a copy of each range on the + // next/previous line that is long enough to hold its columns. + // ponytail: BYTE columns, not helix's visual ones, so a TAB counts as + // one column here. Everything else in this file measures the same way + // (hscroll, the mouse, the renderer's gutter), and fixing it means + // teaching all of them tab stops at once. + const below = hit(key, config.copy_sel_below); + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + var pri: usize = 0; + const nlines = modal.hxLineCount(text); + for (rs[0..n], 0..) |r, ri| { + const is_pri = ri == got.pri; + // head-exclusive: back the leading end off onto its own cell + const hp = modal.hxPos(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); + const ap = modal.hxPos(text, if (r.anchor < r.head) r.anchor else modal.prevGrapheme(text, r.anchor)); + const height = @max(hp.row, ap.row) - @min(hp.row, ap.row) + 1; + if (m >= MAX_SELS) break; + if (is_pri) pri = m; + out[m] = r; + m += 1; + var made: usize = 0; + var k: usize = 0; + while (made < cnt and m < MAX_SELS) : (k += 1) { + const d = (k + 1) * height; + const arow = if (below) ap.row + d else ap.row -| d; + const hrow = if (below) hp.row + d else hp.row -| d; + if (arow >= nlines or hrow >= nlines) break; + const a2 = modal.hxOff(text, .{ .row = arow, .col = ap.col }); + const h2 = modal.hxOff(text, .{ .row = hrow, .col = hp.col }); + // a line too short to reach the column is skipped, not clamped + if (modal.hxPos(text, a2).col == ap.col and modal.hxPos(text, h2).col == hp.col) { + if (is_pri) pri = m; + out[m] = modal.hxPutCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); + m += 1; + made += 1; + } + if (arow == 0 and hrow == 0) break; + } + } + setPaneRanges(pane, pl, text, out[0..m], &.{}, pri, expl); + } + + // ---- `s` / `S`: the selection LIST from a regex ---- + // The other two list-making keys, and the only ones that need a pattern + // typed first. They reuse the `/` input wholesale (startSearch — the tag + // tail IS the prompt) and differ from it in one thing: the pattern is + // re-applied on every keystroke, so the selection is the preview. + + /// helix `s` / `S`: arm the tag input for a regex, remembering the + /// selection it is about to rewrite. Nothing moves yet — every keystroke + /// below re-derives the preview from this snapshot, and Enter simply stops + /// while Esc puts the snapshot back. + fn startSelRegex(p: *Pardes, pane: *Pane, split: bool) void { + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + const got = paneRanges(pane, text, pl.row0, &pane.sel_snap); + pane.nsel_snap = @intCast(got.n); + pane.sel_snap_pri = @intCast(got.pri); + pane.sel_snap_expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + p.startSearch(pane, if (split) config.split_marker else config.select_marker); + } + + /// The pattern an armed `s`/`S` input holds right now, and which of the two + /// it is — read back off the MARKER, exactly the way submitSearch decides + /// which search is running. Null for `/`, Find, Grep and Rename. + fn selRegexArmed(pane: *Pane) ?struct { pat: []const u8, split: bool } { + if (!pane.search_edit or pane.nsel_snap == 0) return null; + const tail = pane.tag_tail.items; + const armed = tail[@min(pane.search_at, tail.len)..]; + const split = std.mem.startsWith(u8, armed, config.split_marker); + if (!split and !std.mem.startsWith(u8, armed, config.select_marker)) return null; + const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return null; + return .{ .pat = armed[slash + 1 ..], .split = split }; + } + + /// Put the selection back the way `s`/`S` found it and then, if `pat` + /// compiles and hits, rewrite it: helix's select_on_matches (every match + /// INSIDE each range becomes a range) and split_on_matches (each range + /// becomes the pieces BETWEEN its matches). + /// + /// Anything that yields nothing — an empty pattern, one that will not + /// compile, one that does not match — leaves the snapshot standing, which + /// is helix's "nothing selected" and also what makes typing a pattern one + /// character at a time bearable: every prefix of it is one of those. + /// + /// ponytail: MAX_SELS ranges, and matches past that are dropped rather + /// than growing the list — the ceiling the whole selection model has. + /// ponytail: mvzr searches from each match's end, so `^` and `$` assert + /// against THAT position rather than against a line the way helix's + /// multi_line regex does, and `.` matches a newline like any other byte. + /// Both are waived cases; `[^\n]` is the workaround for the second. + fn applySelRegex(p: *Pardes, pane: *Pane, pat: []const u8, split: bool) void { + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + const snap = pane.sel_snap[0..pane.nsel_snap]; + var out: [MAX_SELS]modal.HxRange = undefined; + var m: usize = 0; + if (pat.len > 0) if (mvzr.compile(pat)) |re| { + // helix smart-case: a pattern with no uppercase in it matches + // case-blind. mvzr has no such flag — "lowercase your string" is + // its own advice — and ASCII folding is byte for byte, so a + // lowercased copy of the surface has exactly the same offsets. + var hay_all = text; + if (for (pat) |c| { + if (std.ascii.isUpper(c)) break false; + } else true) { + const low = p.scratch.allocator().dupe(u8, text) catch return; + for (low) |*c| c.* = std.ascii.toLower(c.*); + hay_all = low; + } + for (snap) |r| { + const from = @min(r.anchor, r.head); + const to = @min(@max(r.anchor, r.head), text.len); + if (from >= to) continue; + const hay = hay_all[from..to]; + var at: usize = 0; + var piece = from; // split: where the next piece begins + while (at < hay.len and m < MAX_SELS) { + const hit_at = re.matchPos(at, hay) orelse break; + if (split) { + out[m] = .{ .anchor = piece, .head = from + hit_at.start }; + m += 1; + piece = from + hit_at.end; + } else if (from + hit_at.start != to) { + // a match sitting right off the END of the range is + // dropped (helix: what `\b` and empty matches produce + // there), everything else becomes a range + out[m] = .{ .anchor = from + hit_at.start, .head = from + hit_at.end }; + m += 1; + } + // an empty match would otherwise never advance + at = if (hit_at.end > hit_at.start) hit_at.end else hit_at.end + 1; + } + if (split and piece < to and m < MAX_SELS) { + out[m] = .{ .anchor = piece, .head = to }; + m += 1; + } + } + }; + if (m == 0) { + // the text CAN move under an armed prompt (a tag chord runs a + // builtin), and these are raw offsets into the surface as it was + for (snap) |*r| { + r.anchor = @min(r.anchor, text.len); + r.head = @min(r.head, text.len); + } + return setPaneRanges(pane, pl, text, snap, &.{}, pane.sel_snap_pri, pane.sel_snap_expl); + } + setPaneRanges(pane, pl, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 (its own TODO) + } + + /// Everything one keystroke may CONSUME on the way through the modal + /// handler. A key means the same thing at every cursor, so the replay puts + /// all of it back before each pass and keeps whatever the PRIMARY's pass + /// left. (sticky_col is deliberately absent: it is per-range, and rides + /// along in SelRange.sticky instead.) + const KeyState = struct { + mode: Mode, + select: bool, + count: u32, + pending: u21, + pending2: u21, + pending_ch: u21, + find_op: u8, + find_ch: u21, + append_at: @FieldType(Pane, "append_at"), + + fn of(pane: *Pane) KeyState { + return .{ + .mode = pane.mode, + .select = pane.select, + .count = pane.count, + .pending = pane.pending, + .pending2 = pane.pending2, + .pending_ch = pane.pending_ch, + .find_op = pane.find_op, + .find_ch = pane.find_ch, + .append_at = pane.append_at, + }; + } + + fn into(s: KeyState, pane: *Pane) void { + pane.mode = s.mode; + pane.select = s.select; + pane.count = s.count; + pane.pending = s.pending; + pane.pending2 = s.pending2; + pane.pending_ch = s.pending_ch; + pane.find_op = s.find_op; + pane.find_ch = s.find_ch; + pane.append_at = s.append_at; + } + }; + + /// what a replayed key does at each cursor + const Replay = enum { normal, insert, paste_after, paste_before }; + + /// Run one keystroke at EVERY cursor, by replaying the single-selection + /// handler once per range. This IS the multiple-cursor mechanism, and it + /// is why one cursor costs nothing: with `nsel == 0` nobody calls it, and + /// the handler underneath is the same code the 800 differential cases pin. + /// + /// Two rules make the replay legal without helix's change-mapping: + /// * ranges are visited LAST FIRST, so an edit never disturbs the + /// row/col of a range still waiting its turn — everything it touches + /// is below. + /// * a finished pass's result is recorded as a distance from the END of + /// the surface, which an edit strictly before it cannot change (the + /// text and the position shift by exactly the same amount). + fn replaySels(p: *Pardes, pane: *Pane, what: Replay, key: Key) void { + const id = p.active; + const serial = pane.serial; + // the whole selection in pane coordinates, document order. pane.sels + // is already ordered, so this only slots the primary into place. + var list: [MAX_SELS]SelRange = undefined; + var n: usize = 0; + var pri: usize = 0; + const prim: SelRange = .{ + .row = pane.cur_row, + .col = pane.cur_col, + .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, + .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, + .sticky = pane.sticky_col, + }; + const pr = selStart(prim); + var placed = false; + for (pane.sels[0..pane.nsel]) |s| { + const sr = selStart(s); + if (!placed and (pr.row < sr.row or (pr.row == sr.row and pr.col <= sr.col))) { + pri = n; + list[n] = prim; + n += 1; + placed = true; + } + list[n] = s; + n += 1; + } + if (!placed) { + pri = n; + list[n] = prim; + n += 1; + } + + const saved = KeyState.of(pane); + const explicit = pane.vsel.explicit; + var after = saved; + var after_expl = explicit; + // each pass's result: cursor and anchor cells as distances from the + // end of the surface text, plus the range's own j/k goal column + var res: [MAX_SELS]struct { cur: usize, anc: usize, sticky: i32 } = undefined; + p.multi_on = true; + p.multi_stop = false; + var passes: usize = 0; + var i: usize = n; + while (i > 0) { + i -= 1; + p.multi_first = passes == 0; + passes += 1; + saved.into(pane); + pane.nsel = 0; // the handler underneath sees ONE selection + pane.cur_row = list[i].row; + pane.cur_col = list[i].col; + pane.vsel = .{ + .active = list[i].arow != list[i].row or list[i].acol != list[i].col or pane.select, + .row = list[i].arow, + .col = list[i].acol, + .explicit = explicit, + }; + pane.msel.active = false; + pane.sticky_col = list[i].sticky; + switch (what) { + .normal => p.normalKey(pane, key), + .insert => p.insertKey(pane, key), + .paste_after => p.normalPaste(pane, false), + .paste_before => p.normalPaste(pane, true), + } + // the stop check comes FIRST: the pass that set it may have freed + // this very pane (a builtin closing it), so nothing below may read + // through the pointer + if (p.multi_stop) break; + if (i == pri) { + after = KeyState.of(pane); + after_expl = pane.vsel.explicit; + } + const pl = p.paneCursorLines(pane) catch { + p.multi_stop = true; // out of memory mid-replay: collapse, don't guess + break; + }; + const text = p.flatSurface(pane, pl) catch { + p.multi_stop = true; + break; + }; + const co = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - pl.row0)), .col = @intCast(@max(0, pane.cur_col)) }); + const ao = if (pane.vsel.active) + modal.hxOff(text, .{ .row = @intCast(@max(0, pane.vsel.row - pl.row0)), .col = @intCast(@max(0, pane.vsel.col)) }) + else + co; + res[i] = .{ .cur = text.len - @min(co, text.len), .anc = text.len - @min(ao, text.len), .sticky = pane.sticky_col }; + } + p.multi_on = false; + p.multi_first = false; + if (p.multi_stop) { + // the pass reached outside the buffer (a builtin, a language + // query) and may have closed or reused the pane it ran on: drop + // back to one cursor rather than replaying it n more times + p.multi_stop = false; + const pn = p.panes[id] orelse return; + if (pn.serial == serial) pn.nsel = 0; + return; + } + const pl = p.paneCursorLines(pane) catch return; + const text = p.flatSurface(pane, pl) catch return; + var rs: [MAX_SELS]modal.HxRange = undefined; + var st: [MAX_SELS]i32 = undefined; + for (res[0..n], 0..) |r, k| { + rs[k] = cellOffRange(text, text.len - @min(r.anc, text.len), text.len - @min(r.cur, text.len)); + st[k] = r.sticky; + } + setPaneRanges(pane, pl, text, rs[0..n], st[0..n], pri, after_expl); + after.into(pane); + pane.ensureCursorVisible(); // the view follows the PRIMARY, not the last pass + } + + /// a range's start CELL (document order key) — the smaller of its two ends + fn selStart(s: SelRange) struct { row: i32, col: i32 } { + if (s.arow < s.row or (s.arow == s.row and s.acol < s.col)) return .{ .row = s.arow, .col = s.acol }; + return .{ .row = s.row, .col = s.col }; + } + /// point-target motion: collapse there (extend in select mode) fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void { setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false); @@ -2389,7 +3094,40 @@ pub const Pardes = struct { setPaneRange(pane, pl, text, r, expl); } + /// Keys that are about the selection LIST, or about the session rather + /// than the text: they act ONCE however many cursors there are. The + /// multi-cursor family rewrites the list wholesale, and the rest would + /// either fight the replay (Esc, undo) or fire n times (`:`, `/`, n/N, SPC). + /// + /// A key that turns one range into MANY belongs here and writes its result + /// with setPaneRanges — `Alt-s` is the worked example and `s`/`S` are the + /// same shape a regex later. A key that merely EDITS wants the opposite: + /// leave it out and the replay runs it at every cursor for free. Ctrl-c is + /// the exception that proves it: it edits, but comment-or-uncomment is ONE + /// decision over every line every cursor touches, and a replay would take + /// that decision n times. + fn wholeKey(pane: *Pane, key: Key) bool { + if (hit(key, config.escape)) return true; // Esc means the same whatever is pending + if (pane.pending != 0) return false; // a prefix's char argument is literal + return hit(key, config.copy_sel_below) or hit(key, config.copy_sel_above) or + hit(key, config.keep_primary_sel) or hit(key, config.remove_primary_sel) or + hit(key, config.rotate_sel_fwd) or hit(key, config.rotate_sel_back) or + hit(key, config.split_sel_newline) or hit(key, config.merge_sels) or + hit(key, config.merge_consecutive_sels) or hit(key, config.trim_sels) or + hit(key, config.select_regex) or hit(key, config.split_regex) or + hit(key, config.comment_toggle) or + hit(key, config.undo) or hit(key, config.redo) or + hit(key, config.command_line) or hit(key, config.search) or + hit(key, config.search_next) or hit(key, config.search_prev) or + hit(key, config.leader); + } + fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void { + if (pane.nsel == 0 or wholeKey(pane, key)) return p.normalKey(pane, key); + p.replaySels(pane, .normal, key); + } + + fn normalKey(p: *Pardes, pane: *Pane, key: Key) void { pane.pinCursor(); const pl = p.paneCursorLines(pane) catch return; const text = p.flatSurface(pane, pl) catch return; @@ -2698,6 +3436,20 @@ pub const Pardes = struct { return setPaneRange(pane, pl, text, .{ .anchor = range.head, .head = range.anchor }, expl); } if (hit(key, config.select_all)) return setPaneRange(pane, pl, text, .{ .anchor = 0, .head = text.len }, false); + // multiple cursors: `,` is the one that needs nothing but the list + if (hit(key, config.keep_primary_sel)) { + pane.nsel = 0; // helix Selection::single(primary) + return; + } + if (hit(key, config.remove_primary_sel) or hit(key, config.rotate_sel_fwd) or + hit(key, config.rotate_sel_back) or hit(key, config.copy_sel_below) or + hit(key, config.copy_sel_above) or hit(key, config.split_sel_newline) or + hit(key, config.merge_sels) or hit(key, config.merge_consecutive_sels) or + hit(key, config.trim_sels)) return multiSelKey(pane, pl, text, key, cnt); + // the two that need a REGEX first: arm the tag input and let the + // pattern's own keystrokes drive the preview (startSelRegex) + if (hit(key, config.select_regex)) return p.startSelRegex(pane, false); + if (hit(key, config.split_regex)) return p.startSelRegex(pane, true); // edits if (hit(key, config.delete)) return p.normalDelete(pane, true); if (hit(key, config.delete_noyank)) return p.normalDelete(pane, false); @@ -2715,6 +3467,7 @@ pub const Pardes = struct { if (hit(key, config.format)) return p.lspRequest(p.active, .format, ""); if (hit(key, config.increment)) return p.normalAdjustNumber(pane, @intCast(cnt)); if (hit(key, config.decrement)) return p.normalAdjustNumber(pane, -@as(i64, @intCast(cnt))); + if (hit(key, config.comment_toggle)) return p.normalToggleComment(pane); if (hit(key, config.undo)) return p.doUndo(pane); if (hit(key, config.redo)) return p.doRedo(pane); // SPC — the leader: a key path from here runs a BUILTIN with no @@ -2781,6 +3534,10 @@ pub const Pardes = struct { /// ate the marker: nothing to run. fn submitSearch(p: *Pardes, id: usize) void { const pane = p.panes[id] orelse return; + // `s`/`S` have already applied themselves keystroke by keystroke; + // Enter re-runs the final pattern so a submit is one code path with + // the preview and cannot disagree with what is on screen. + if (selRegexArmed(pane)) |a| return p.applySelRegex(pane, a.pat, a.split); const tail = pane.tag_tail.items; const armed = tail[@min(pane.search_at, tail.len)..]; const slash = std.mem.indexOfScalar(u8, armed, '/') orelse return; @@ -2919,6 +3676,7 @@ pub const Pardes = struct { // select the result row in the results pane and keep it in view rp.msel = .{ .active = true, .r0 = r, .r1 = r }; rp.vsel.active = false; + rp.nsel = 0; rp.cur_row = r; rp.cur_col = 0; rp.cur_pinned = true; @@ -2950,6 +3708,7 @@ pub const Pardes = struct { /// it. Unsupported kinds never get here (the keymap drops them), so a /// backend that answers nothing simply never opens a buffer. pub fn lspRequest(p: *Pardes, id: usize, kind: lsp.Kind, arg: []const u8) void { + if (!p.multiOnce()) return; // one question per keystroke, from the primary if (!lsp.supports.contains(kind)) return; const pane = p.panes[id] orelse return; // `status` is about the BACKEND, not about a document, so it answers @@ -3070,6 +3829,7 @@ pub const Pardes = struct { // EXPLICIT: Enter's look chord acts on it pane.vsel = .{ .active = true, .row = r, .col = @intCast(hit_t1 - 1), .explicit = true }; pane.msel.active = false; + pane.nsel = 0; pane.cur_row = r; pane.cur_col = @intCast(hit_t0); pane.cur_pinned = true; @@ -3244,6 +4004,24 @@ pub const Pardes = struct { pane.cur_row = @as(i32, @intCast(if (below) row + 1 else row)) + eb.row0; pane.cur_col = @intCast(ind.len); pane.cur_pinned = true; + // helix `open` with a COUNT leaves one cursor per line it + // opened — `3o` then typing fills all three. The lines are + // consecutive and start at the cursor, so this is just cnt + // points a row apart, the first one primary. + // (Inside a replay pass the extra ranges cannot be created: + // a pass produces exactly one range. `3o` at several cursors + // therefore opens the lines but keeps the cursors it had.) + if (cnt > 1 and !p.multi_on) opened: { + const pl2 = p.paneCursorLines(pane) catch break :opened; + const t2 = p.flatSurface(pane, pl2) catch break :opened; + var rs: [MAX_SELS]modal.HxRange = undefined; + const m = @min(cnt, MAX_SELS); + for (0..m) |k| { + const o = modal.hxOff(t2, .{ .row = @intCast(@max(0, pane.cur_row - pl2.row0) + @as(i32, @intCast(k))), .col = ind.len }); + rs[k] = .{ .anchor = o, .head = o }; + } + setPaneRanges(pane, pl2, t2, rs[0..m], &.{}, 0, false); + } }, } pane.mode = .insert; @@ -3257,10 +4035,15 @@ pub const Pardes = struct { /// materializes and grows it), so typing, Enter, joins and the kill runs /// mean exactly the same thing in a shell pane as in a document. fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void { + if (pane.nsel == 0) return p.insertKey(pane, key); + p.replaySels(pane, .insert, key); + } + + fn insertKey(p: *Pardes, pane: *Pane, key: Key) void { // helix aliases: normalize to the base key and re-dispatch - if (hit(key, config.insert_backspace_alias)) return p.handleInsert(pane, .{ .cp = Key.backspace }); - if (hit(key, config.insert_enter_alias)) return p.handleInsert(pane, .{ .cp = Key.enter }); - if (hit(key, config.insert_delete_alias)) return p.handleInsert(pane, .{ .cp = Key.delete }); + if (hit(key, config.insert_backspace_alias)) return p.insertKey(pane, .{ .cp = Key.backspace }); + if (hit(key, config.insert_enter_alias)) return p.insertKey(pane, .{ .cp = Key.enter }); + if (hit(key, config.insert_delete_alias)) return p.insertKey(pane, .{ .cp = Key.delete }); // a selection carried into insert (i/a) survives only until the next // key: helix maps it through every edit, pardes drops it instead — // its only pardes use (the acme chords) needs explicit selections @@ -3454,21 +4237,16 @@ pub const Pardes = struct { const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 }; + /// a range's two cells, normalized to document order + fn cellBounds(s: SelRange) Bounds { + if (s.row < s.arow or (s.row == s.arow and s.col < s.acol)) + return .{ .lo_row = s.row, .lo_col = s.col, .hi_row = s.arow, .hi_col = s.acol }; + return .{ .lo_row = s.arow, .lo_col = s.acol, .hi_row = s.row, .hi_col = s.col }; + } + /// the char selection [anchor, cursor] normalized to document order fn vselBounds(pane: *Pane) Bounds { - var ar = pane.vsel.row; - var ac = pane.vsel.col; - var br = pane.cur_row; - var bc = pane.cur_col; - if (br < ar or (br == ar and bc < ac)) { - const sr = ar; - const sc = ac; - ar = br; - ac = bc; - br = sr; - bc = sc; - } - return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc }; + return cellBounds(.{ .row = pane.cur_row, .col = pane.cur_col, .arow = pane.vsel.row, .acol = pane.vsel.col }); } /// the char selection as text. Read off the pane's SURFACE (file content / @@ -3852,9 +4630,9 @@ pub const Pardes = struct { p.setEditText(pane, new); } - /// a change to apply to file content: delete [from, to), insert `ins` - /// spaces at `from`. Ascending and disjoint. - const TextChange = struct { from: usize, to: usize, ins: usize }; + /// a change to apply to file content: delete [from, to), insert `ins` at + /// `from`. Ascending and disjoint. + const TextChange = struct { from: usize, to: usize, ins: []const u8 }; /// map an original-text offset through a change list (insertions AT a /// position push it right — helix Assoc::After; positions inside a @@ -3864,25 +4642,24 @@ pub const Pardes = struct { for (chs) |ch| { if (pos < ch.from) break; if (ch.from == ch.to) { - delta += @intCast(ch.ins); + delta += @intCast(ch.ins.len); continue; } if (pos == ch.from) break; if (pos < ch.to) return @intCast(@as(i64, @intCast(ch.from)) + delta); - delta += @as(i64, @intCast(ch.ins)) - @as(i64, @intCast(ch.to - ch.from)); + delta += @as(i64, @intCast(ch.ins.len)) - @as(i64, @intCast(ch.to - ch.from)); } return @intCast(@as(i64, @intCast(pos)) + delta); } - /// apply a change list; the insertions are spaces (join/indent only ever - /// insert those). gpa-owned result. + /// apply a change list. gpa-owned result. fn applyChanges(p: *Pardes, text: []const u8, chs: []const TextChange) ![]u8 { var out: std.ArrayList(u8) = .empty; errdefer out.deinit(p.gpa); var copied: usize = 0; for (chs) |ch| { try out.appendSlice(p.gpa, text[copied..ch.from]); - for (0..ch.ins) |_| try out.append(p.gpa, ' '); + try out.appendSlice(p.gpa, ch.ins); copied = ch.to; } try out.appendSlice(p.gpa, text[copied..]); @@ -3913,7 +4690,7 @@ pub const Pardes = struct { const from = modal.hxLineEndIdx(text, l); var to = if (l + 1 >= nlines) text.len else modal.lineStartOffset(text, l + 1); while (to < text.len and (text[to] == ' ' or text[to] == '\t')) to += 1; - const sep: usize = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) 0 else 1; + const sep: []const u8 = if (to == modal.hxLineEndIdx(text, @min(l + 1, nlines - 1))) "" else " "; chs.append(arena, .{ .from = from, .to = to, .ins = sep }) catch return; } if (chs.items.len == 0) return; @@ -3954,6 +4731,9 @@ pub const Pardes = struct { const range = paneRange(pane, text, eb.row0); const span = rangeLineSpan(text, range); const arena = p.scratch.allocator(); + // one run of spaces, sliced per line: `>` never inserts more than this + const pad = arena.alloc(u8, modal.INDENT_W * cnt) catch return; + @memset(pad, ' '); var chs: std.ArrayList(TextChange) = .empty; var l = span.start; while (l <= span.end) : (l += 1) { @@ -3964,7 +4744,7 @@ pub const Pardes = struct { if (nw == line.len) continue; // blank lines stay blank (helix) if (add) { const ins = modal.INDENT_W * cnt - (nw % modal.INDENT_W); - chs.append(arena, .{ .from = ls, .to = ls, .ins = ins }) catch return; + chs.append(arena, .{ .from = ls, .to = ls, .ins = pad[0..ins] }) catch return; } else { const want = modal.INDENT_W * cnt; var w: usize = 0; @@ -3978,7 +4758,7 @@ pub const Pardes = struct { pos += 1; if (w >= want) break; } - if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = 0 }) catch return; + if (pos > 0) chs.append(arena, .{ .from = ls, .to = ls + pos, .ins = "" }) catch return; } } if (chs.items.len == 0) return; @@ -4006,6 +4786,120 @@ pub const Pardes = struct { pane.ensureCursorVisible(); } + /// `Ctrl-c`: helix toggle_comments. Every line the selection touches gets + /// the language's line-comment token put in front of it — or taken off, + /// and WHICH of the two is decided once for the whole set: one uncommented + /// non-blank line among them and everything gets commented. That single + /// decision is why this is a wholeKey instead of a per-cursor replay; + /// replayed, a half-commented block would end up half-commented the other + /// way round. + /// + /// The rest is helix's find_line_comment, quirks included: the token goes + /// in at the SHALLOWEST indent in the set (so a deeper line is commented + /// mid-whitespace), all-blank lines are skipped entirely and do not vote, + /// and uncommenting also eats one space after the token unless some line + /// lacks it. + fn normalToggleComment(p: *Pardes, pane: *Pane) void { + const expl = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; + pane.select = false; // helix exit_select_mode + // the buffer must cover every cursor's rows, not just the primary's + var b = selRows(pane); + for (pane.sels[0..pane.nsel]) |s| { + b.lo_row = @min(b.lo_row, @min(s.row, s.arow)); + b.hi_row = @max(b.hi_row, @max(s.row, s.arow)); + } + // ...and ONE ROW PAST them, like normalJoin: a selection may end on + // its last line's newline cell, and a terminal buffer that stops at + // that line has nowhere to put it (a file's content always does). + // Never past the surface's own last row, though — materialising a row + // that does not exist yet would ADD a blank line to the pane, and this + // op may well decide to change nothing. + const pl0 = p.paneCursorLines(pane) catch return; + const last_row = pl0.row0 + @as(i32, @intCast(pl0.lines.len)) - 1; + const eb = p.editText(pane, b.lo_row, @min(b.hi_row + 1, last_row), -1) orelse return; + const text = eb.text; + var rs: [MAX_SELS]modal.HxRange = undefined; + const got = paneRanges(pane, text, eb.row0, &rs); + const nlines = modal.hxLineCount(text); + const arena = p.scratch.allocator(); + // the lines the ranges cover, each ONE ONCE and in order (helix's + // min_next_line: two cursors on one line comment it once) + var lines: std.ArrayList(usize) = .empty; + var next: usize = 0; + for (rs[0..got.n]) |r| { + const span = rangeLineSpan(text, r); + var l = @max(span.start, next); + const end = @min(span.end + 1, nlines); + while (l < end) : (l += 1) lines.append(arena, l) catch return; + next = @max(next, end); + } + // which token: the file's EXTENSION, which is the same thing + // src/syntax.zig tells languages apart by, read off the one table in + // config. A terminal and an output buffer have no extension and get + // the default, which is what helix does for a buffer with no language. + const ext = if (pane.file) |f| std.fs.path.extension(f.path) else ""; + var token: []const u8 = config.comment_token_default; + lang: for (config.comment_tokens) |row| { + for (row.exts) |e| if (std.ascii.eqlIgnoreCase(ext, e)) { + token = row.token; + break :lang; + }; + } + var commented = true; + var indent: usize = std.math.maxInt(usize); + var margin: usize = 1; + var live: usize = 0; + for (lines.items) |l| { + const line = text[modal.lineStartOffset(text, l)..modal.hxLineEndIdx(text, l)]; + const nw = modal.firstNonWs(line); + if (nw == line.len) continue; + indent = @min(indent, nw); + if (!std.mem.startsWith(u8, line[nw..], token)) commented = false; + if (nw + token.len >= line.len or line[nw + token.len] != ' ') margin = 0; + live += 1; + } + if (live == 0) return; // nothing but blank lines + const ins = std.fmt.allocPrint(arena, "{s} ", .{token}) catch return; + var chs: std.ArrayList(TextChange) = .empty; + for (lines.items) |l| { + const ls = modal.lineStartOffset(text, l); + const le = modal.hxLineEndIdx(text, l); + const line = text[ls..le]; + if (modal.firstNonWs(line) == line.len) continue; // blank lines untouched + const at = ls + indent; + // the @min can never bind: margin is 1 only when every line has a + // space after its own token, which is a byte past `at + token.len` + chs.append(arena, if (commented) + .{ .from = at, .to = @min(at + token.len + margin, le), .ins = "" } + else + .{ .from = at, .to = at, .ins = ins }) catch return; + } + p.pushUndo(pane); + // one edit, and the WHOLE selection rides through it (helix maps the + // selection with the transaction) + var cells: [MAX_SELS]SelRange = undefined; + const new = p.applyChanges(text, chs.items) catch return; + for (rs[0..got.n], 0..) |r, i| { + const c = rangeCells(new, .{ + .anchor = mapThroughChanges(chs.items, r.anchor), + .head = mapThroughChanges(chs.items, r.head), + }); + const cc = modal.hxPos(new, c.cur); + const ac = modal.hxPos(new, c.anc); + cells[i] = .{ + .row = @as(i32, @intCast(cc.row)) + eb.row0, + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)) + eb.row0, + .acol = @intCast(ac.col), + }; + } + p.setEditText(pane, new); + const pl2 = p.paneCursorLines(pane) catch return; + const t2 = p.flatSurface(pane, pl2) catch return; + for (cells[0..got.n], 0..) |s, i| rs[i] = cellRange(t2, s.arow - pl2.row0, s.acol, s.row - pl2.row0, s.col); + setPaneRanges(pane, pl2, t2, rs[0..got.n], &.{}, got.pri, expl); + } + /// `Ctrl-a` / `Ctrl-x`: increment/decrement the SELECTION as a decimal /// integer (helix: the selected fragment itself, no number scan around /// the cursor); a fragment that isn't an integer is a no-op. The new @@ -4201,6 +5095,10 @@ pub const Pardes = struct { } fn pushUndo(p: *Pardes, pane: *Pane) void { + // one keystroke, one undo step — even when it edited at ten cursors. + // (The content-equality guard below cannot do this on its own: by the + // second pass the buffer HAS changed, so it would push again.) + if (p.multi_on and !p.multi_first) return; if (pane.file) |*f| { if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1].content, f.content)) return; const snap: FileSnap = .{ @@ -4630,6 +5528,7 @@ pub const Pardes = struct { // dismisses it rather than dragging the anchored span // to the click (explicit v/x keeps vim's click-extend) if (!pane.vsel.explicit) pane.vsel.active = false; + pane.nsel = 0; // a click says WHERE the one cursor is // Ctrl-click IS `gd`, asked now that the cursor has // landed — the mouse spelling of the keyboard motion, // through the identical request. A ctrl-DRAG still @@ -4729,6 +5628,7 @@ pub const Pardes = struct { pane.pending = 0; if (!pane.isTerminal()) pane.mode = .normal; pane.vsel = .{ .active = row0 != row1 or col0 != col1, .row = row0, .col = col0, .explicit = false }; + pane.nsel = 0; } if (cut) { if (pane.vsel.active) p.normalDelete(pane, true); @@ -5286,6 +6186,7 @@ pub const Pardes = struct { /// relative to `id`) act on `id`. The null-pane check is the one guard /// every builtin used to share, so it stays here rather than in each. fn runBuiltin(p: *Pardes, b: Builtin, id: usize, txt: []const u8, arg: ?[]const u8) void { + if (!p.multiOnce()) return; // a builtin is per-keystroke, never per-cursor const pane = p.panes[id] orelse return; const c: builtins.Ctx = .{ .p = p, .pane = pane, .id = id, .txt = txt, .arg = arg }; inline for (comptime builtins.all(), 0..) |B, i| if (@intFromEnum(b) == i) return B.run(c); @@ -6072,23 +6973,59 @@ pub const Pardes = struct { } } } - // modal char selection (helix `v`): stream-shaped anchor..head highlight - if (pane.mode == .normal and pane.vsel.active) { - const bnd = vselBounds(pane); + // modal char selection (helix `v`): stream-shaped anchor..head + // highlight. The EXTRA cursors are the same shape drawn dimmer, and + // each of them also paints its own cursor cell bright: there is one + // hardware cursor and the primary owns it, so a secondary cursor has + // to be a cell colour or it is invisible. + // The extra cursors show in INSERT mode too — that is exactly when you + // need to see where your typing is landing — while the primary's + // selection highlight stays normal-mode-only, as it always was. + // ...and an armed `s`/`S` shows the primary WHATEVER shape it is, even + // though the pane is in insert mode for the tag and even when the match + // is a single cell: the hardware cursor is off in the tag, so a preview + // that leans on it shows every match except the one you are on. + const preview = selRegexArmed(pane) != null; + const show_prim = (pane.mode == .normal and pane.vsel.active) or preview; + const show_extra = pane.mode != .tty and pane.nsel > 0; + if (show_prim or show_extra) { const vpfx: i32 = if (pane.file != null) config.PREFIX_W else 0; const vhs: i32 = if (pane.file != null) pane.hscroll else 0; - var ar: i32 = bnd.lo_row; - while (ar <= bnd.hi_row) : (ar += 1) { - const prow = ar - off + @as(i32, BOX_H); - if (prow < BOX_H or prow >= @as(i32, r.h)) continue; - const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx; - const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1; - var col: i32 = @max(cstart, vpfx); - while (col <= cend and col < tw) : (col += 1) { - const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow))); + var si: usize = 0; + while (si <= pane.nsel) : (si += 1) { + const primary = si == pane.nsel; + if (if (primary) !show_prim else !show_extra) continue; + const sr = if (primary) SelRange{ + .row = pane.cur_row, + .col = pane.cur_col, + .arow = if (pane.vsel.active) pane.vsel.row else pane.cur_row, + .acol = if (pane.vsel.active) pane.vsel.col else pane.cur_col, + } else pane.sels[si]; + const bnd = cellBounds(sr); + const bg = if (primary) msel_bg else msel2_bg; + const fg = if (primary) msel_fg else msel2_fg; + var ar: i32 = bnd.lo_row; + while (ar <= bnd.hi_row) : (ar += 1) { + const prow = ar - off + @as(i32, BOX_H); + if (prow < BOX_H or prow >= @as(i32, r.h)) continue; + const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col - vhs + vpfx else vpfx; + const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col - vhs + vpfx else @as(i32, tw) - 1; + var col: i32 = @max(cstart, vpfx); + while (col <= cend and col < tw) : (col += 1) { + const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow))); + cell.default = false; + cell.style.bg = .{ .rgb = bg }; + cell.style.fg = .{ .rgb = fg }; + } + } + if (primary and !preview) continue; // the hardware cursor IS the primary's + const crow = sr.row - off + @as(i32, BOX_H); + const ccol = sr.col - vhs + vpfx; + if (crow >= BOX_H and crow < @as(i32, r.h) and ccol >= vpfx and ccol < tw) { + const cell = s.at(tx + @as(u16, @intCast(ccol)), r.y + @as(u16, @intCast(crow))); cell.default = false; - cell.style.bg = .{ .rgb = msel_bg }; - cell.style.fg = .{ .rgb = msel_fg }; + cell.style.bg = .{ .rgb = msel_fg }; + cell.style.fg = .{ .rgb = msel_bg }; } } } diff --git a/test/hxcases/cases.jsonl b/test/hxcases/cases.jsonl index 386013bf..b0a3aad4 100644 --- a/test/hxcases/cases.jsonl +++ b/test/hxcases/cases.jsonl @@ -358,3 +358,124 @@ {"name": "ins-arrows", "pane": "file", "text": "abc\ndef\n", "keys": "i<down><right>x<esc>"} {"name": "ins-tab-col0", "pane": "file", "text": "ab\n", "keys": "i<tab><esc>"} {"name": "ins-tab-mid", "pane": "file", "text": "ab\n", "keys": "li<tab><esc>"} +{"name": "msel-C-basic", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "lC"} +{"name": "msel-C-basic-tty", "pane": "tty", "text": "aaa\nbbb\nccc\n", "keys": "lC"} +{"name": "msel-C-count", "pane": "file", "text": "aaa\nbbb\nccc\nddd\n", "keys": "2C"} +{"name": "msel-C-count-tty", "pane": "tty", "text": "aaa\nbbb\nccc\nddd\n", "keys": "2C"} +{"name": "msel-C-skips-short", "pane": "file", "text": "abcdef\nab\nabcdef\n", "keys": "4lC"} +{"name": "msel-C-stops-at-eof", "pane": "file", "text": "aa\nbb\n", "keys": "CCC"} +{"name": "msel-C-of-selection", "pane": "file", "text": "abcd\nabcd\nabcd\n", "keys": "vllC"} +{"name": "msel-C-above", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "jjl<A-C>"} +{"name": "msel-C-above-tty", "pane": "tty", "text": "aaa\nbbb\nccc\n", "keys": "jjl<A-C>"} +{"name": "msel-C-above-at-top", "pane": "file", "text": "aaa\nbbb\n", "keys": "<A-C>"} +{"name": "msel-C-empty-line", "pane": "file", "text": "ab\n\ncd\n", "keys": "C"} +{"name": "msel-move-l", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "Cl"} +{"name": "msel-move-l-tty", "pane": "tty", "text": "aaa\nbbb\nccc\n", "keys": "Cl"} +{"name": "msel-move-jj", "pane": "file", "text": "aaaa\nbbbb\ncccc\ndddd\n", "keys": "2lCkk"} +{"name": "msel-word", "pane": "file", "text": "one two\nsix seven\n", "keys": "Cw"} +{"name": "msel-goto-line-end", "pane": "file", "text": "aaa\nbbbbb\n", "keys": "Cgl"} +{"name": "msel-keep-primary", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC,"} +{"name": "msel-keep-primary-tty", "pane": "tty", "text": "aaa\nbbb\nccc\n", "keys": "CC,"} +{"name": "msel-remove-primary", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC<A-,>"} +{"name": "msel-remove-primary-last", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC)<A-,>"} +{"name": "msel-rotate-fwd", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC)"} +{"name": "msel-rotate-fwd-tty", "pane": "tty", "text": "aaa\nbbb\nccc\n", "keys": "CC)"} +{"name": "msel-rotate-back", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC("} +{"name": "msel-rotate-wrap", "pane": "file", "text": "aaa\nbbb\n", "keys": "C))"} +{"name": "msel-split-newline", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "%<A-s>"} +{"name": "msel-split-partial", "pane": "file", "text": "abcd\nefgh\n", "keys": "vjl<A-s>"} +{"name": "msel-split-partial-tty", "pane": "tty", "text": "abcd\nefgh\n", "keys": "vjl<A-s>"} +{"name": "msel-merge", "pane": "file", "text": "aaa\nbbb\nccc\n", "keys": "CC<A-minus>"} +{"name": "msel-merge-consecutive", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "xC<A-_>"} +{"name": "msel-trim", "pane": "file", "text": " ab \n", "keys": "%_"} +{"name": "msel-trim-all-ws", "pane": "file", "text": " \n", "keys": "%_"} +{"name": "msel-trim-multi", "pane": "file", "text": " ab \n cd \n", "keys": "x<A-x>C_"} +{"name": "msel-insert", "pane": "file", "text": "aa\nbb\n", "keys": "Cix<esc>"} +{"name": "msel-insert-tty", "pane": "tty", "text": "aa\nbb\n", "keys": "Cix<esc>"} +{"name": "msel-append", "pane": "file", "text": "aa\nbb\n", "keys": "Caz<esc>"} +{"name": "msel-insert-two-chars", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "2Cixy<esc>"} +{"name": "msel-delete", "pane": "file", "text": "abc\nabc\n", "keys": "Cd"} +{"name": "msel-delete-tty", "pane": "tty", "text": "abc\nabc\n", "keys": "Cd"} +{"name": "msel-delete-selection", "pane": "file", "text": "abcd\nabcd\n", "keys": "vlCd"} +{"name": "msel-change", "pane": "file", "text": "abc\nabc\n", "keys": "Ccz<esc>"} +{"name": "msel-switch-case", "pane": "file", "text": "abc\nabc\n", "keys": "C~"} +{"name": "msel-switch-case-tty", "pane": "tty", "text": "abc\nabc\n", "keys": "C~"} +{"name": "msel-uppercase", "pane": "file", "text": "abc\nabc\n", "keys": "vlC<A-`>"} +{"name": "msel-indent", "pane": "file", "text": "aa\nbb\n", "keys": "C<gt>"} +{"name": "msel-replace-char", "pane": "file", "text": "abc\nabc\n", "keys": "Crz"} +{"name": "msel-undo", "pane": "file", "text": "abc\nabc\n", "keys": "Cdu"} +{"name": "msel-open-below", "pane": "file", "text": "aa\nbb\n", "keys": "Coz<esc>"} +{"name": "msel-line-select", "pane": "file", "text": "aa\nbb\ncc\n", "keys": "Cx"} +{"name": "msel-collapse", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC;"} +{"name": "msel-flip", "pane": "file", "text": "abcd\nabcd\n", "keys": "vllC<A-;>"} +{"name": "msel-yank-paste", "pane": "file", "text": "ab\ncd\n", "keys": "Cyp"} +{"name": "msel-esc-keeps", "pane": "file", "text": "aa\nbb\n", "keys": "C<esc>"} +{"name": "msel-select-mode", "pane": "file", "text": "abcd\nabcd\n", "keys": "Cvl"} +{"name": "msel-open-count", "pane": "file", "text": "ab\n", "keys": "2O"} +{"name": "msel-backspace", "pane": "file", "text": "abc\nabc\n", "keys": "Cla<backspace><esc>"} +{"name": "msel-backspace-tty", "pane": "tty", "text": "abc\nabc\n", "keys": "Cla<backspace><esc>"} +{"name": "sel-regex-basic", "pane": "file", "text": "alpha beta alpha\n", "keys": "%sa<ret>"} +{"name": "sel-regex-basic-tty", "pane": "tty", "text": "alpha beta alpha\n", "keys": "%sa<ret>"} +{"name": "sel-regex-word", "pane": "file", "text": "alpha beta alpha\n", "keys": "%salpha<ret>"} +{"name": "sel-regex-class", "pane": "file", "text": "a1b22c333\n", "keys": "%s[0-9]+<ret>"} +{"name": "sel-regex-class-tty", "pane": "tty", "text": "a1b22c333\n", "keys": "%s[0-9]+<ret>"} +{"name": "sel-regex-alt", "pane": "file", "text": "cat dog cat\n", "keys": "%scat|dog<ret>"} +{"name": "sel-regex-plus", "pane": "file", "text": "aa b aaa\n", "keys": "%sa+<ret>"} +{"name": "sel-regex-star-empty", "pane": "file", "text": "ab\n", "keys": "%sx*<ret>"} +{"name": "sel-regex-nomatch", "pane": "file", "text": "alpha beta\n", "keys": "%szzz<ret>"} +{"name": "sel-regex-esc", "pane": "file", "text": "alpha beta alpha\n", "keys": "%sa<esc>"} +{"name": "sel-regex-empty", "pane": "file", "text": "abc\n", "keys": "%s<ret>"} +{"name": "sel-regex-invalid", "pane": "file", "text": "abc\n", "keys": "%s[<ret>"} +{"name": "sel-regex-within-sels", "pane": "file", "text": "aXa\nbXb\n", "keys": "xxsX<ret>"} +{"name": "sel-regex-within-sels-tty", "pane": "tty", "text": "aXa\nbXb\n", "keys": "xxsX<ret>"} +{"name": "sel-regex-at-cursors", "pane": "file", "text": "foo bar\nfoo baz\n", "keys": "xxsfoo<ret>"} +{"name": "sel-regex-narrow", "pane": "file", "text": "alpha beta alpha\n", "keys": "%salpha<ret>sa<ret>"} +{"name": "sel-regex-select-mode", "pane": "file", "text": "alpha beta\n", "keys": "v5lsa<ret>"} +{"name": "sel-regex-multiline", "pane": "file", "text": "one\ntwo\nthree\n", "keys": "%so<ret>"} +{"name": "sel-regex-smart-case", "pane": "file", "text": "Alpha alpha ALPHA\n", "keys": "%salpha<ret>"} +{"name": "sel-regex-cased", "pane": "file", "text": "Alpha alpha ALPHA\n", "keys": "%sAlpha<ret>"} +{"name": "sel-regex-boundary", "pane": "file", "text": "cat cats\n", "keys": "%s\\bcat\\b<ret>"} +{"name": "sel-regex-whole", "pane": "file", "text": "abc\n", "keys": "%sabc<ret>"} +{"name": "sel-regex-then-motion", "pane": "file", "text": "foo bar foo baz\n", "keys": "%sfoo<ret>w"} +{"name": "sel-regex-then-delete", "pane": "file", "text": "a1b2c3\n", "keys": "%s[0-9]<ret>d"} +{"name": "sel-regex-dot-newline", "pane": "file", "text": "ab\n", "keys": "%s.<ret>"} +{"name": "sel-regex-caret", "pane": "file", "text": "ab\nab\n", "keys": "%s^a<ret>"} +{"name": "split-regex-comma", "pane": "file", "text": "a,b,c\n", "keys": "%S,<ret>"} +{"name": "split-regex-comma-tty", "pane": "tty", "text": "a,b,c\n", "keys": "%S,<ret>"} +{"name": "split-regex-lead", "pane": "file", "text": ",a,b\n", "keys": "%S,<ret>"} +{"name": "split-regex-trail", "pane": "file", "text": "a,b,\n", "keys": "%S,<ret>"} +{"name": "split-regex-adjacent", "pane": "file", "text": "a,,b\n", "keys": "%S,<ret>"} +{"name": "split-regex-ws", "pane": "file", "text": "one two three\n", "keys": "%S\\s+<ret>"} +{"name": "split-regex-ws-tty", "pane": "tty", "text": "one two three\n", "keys": "%S\\s+<ret>"} +{"name": "split-regex-nomatch", "pane": "file", "text": "abc\n", "keys": "%Sz<ret>"} +{"name": "split-regex-newline", "pane": "file", "text": "one\ntwo\nthree\n", "keys": "%S\\n<ret>"} +{"name": "split-regex-esc", "pane": "file", "text": "a,b,c\n", "keys": "%S,<esc>"} +{"name": "split-regex-within-sels", "pane": "file", "text": "aXa\nbXb\n", "keys": "xxSX<ret>"} +{"name": "split-regex-end", "pane": "file", "text": "abc\n", "keys": "%Sc<ret>"} +{"name": "comment-line", "pane": "file", "text": "alpha\nbeta\n", "keys": "<C-c>"} +{"name": "comment-toggle-back", "pane": "file", "text": "alpha\nbeta\n", "keys": "<C-c><C-c>"} +{"name": "comment-selection", "pane": "file", "text": "alpha\nbeta\ngamma\n", "keys": "xx<C-c>"} +{"name": "comment-indent", "pane": "file", "text": " alpha\n beta\n", "keys": "%<C-c>"} +{"name": "comment-indent-round-trip", "pane": "file", "text": " alpha\n beta\n", "keys": "%<C-c>%<C-c>"} +{"name": "comment-blank-skipped", "pane": "file", "text": "alpha\n\nbeta\n", "keys": "%<C-c>"} +{"name": "comment-partial", "pane": "file", "text": "# alpha\nbeta\ngamma\n", "keys": "%<C-c>"} +{"name": "comment-uncomment", "pane": "file", "text": "# alpha\n# beta\n", "keys": "%<C-c>"} +{"name": "comment-margin0", "pane": "file", "text": "#alpha\n# beta\n", "keys": "%<C-c>"} +{"name": "comment-token-only", "pane": "file", "text": "#\n#\n", "keys": "%<C-c>"} +{"name": "comment-uncomment-deep", "pane": "file", "text": "# alpha\n # beta\n", "keys": "%<C-c>"} +{"name": "comment-tab-indent", "pane": "file", "text": "\talpha\n\tbeta\n", "keys": "%<C-c>"} +{"name": "comment-other-token", "pane": "file", "text": "// alpha\n// beta\n", "keys": "%<C-c>"} +{"name": "comment-noeol", "pane": "file", "text": "alpha", "keys": "<C-c>"} +{"name": "comment-empty", "pane": "file", "text": "\n", "keys": "<C-c>"} +{"name": "comment-only-blank", "pane": "file", "text": " \n\t\n", "keys": "%<C-c>"} +{"name": "comment-select-mode", "pane": "file", "text": "alpha\nbeta\n", "keys": "vj<C-c>"} +{"name": "comment-count-ignored", "pane": "file", "text": "alpha\nbeta\ngamma\n", "keys": "3<C-c>"} +{"name": "comment-undo", "pane": "file", "text": "alpha\nbeta\n", "keys": "%<C-c>u"} +{"name": "comment-multi-cursor", "pane": "file", "text": "alpha\nbeta\ngamma\n", "keys": "C<C-c>"} +{"name": "comment-same-line-cursors", "pane": "file", "text": "alpha beta\n", "keys": "wC<C-c>"} +{"name": "comment-cursor-gap", "pane": "file", "text": "a\nb\nc\nd\n", "keys": "2C<C-c>"} +{"name": "comment-split-cursors", "pane": "file", "text": "alpha\nbeta\ngamma\n", "keys": "%<A-s><C-c>"} +{"name": "comment-last-line", "pane": "file", "text": "alpha\nbeta\n", "keys": "jj<C-c>"} +{"name": "comment-then-insert", "pane": "file", "text": "alpha\n", "keys": "<C-c>iz<esc>"} +{"name": "comment-after-sel-regex", "pane": "file", "text": "alpha\nbeta\ngamma\n", "keys": "%sa<ret><C-c>"} +{"name": "comment-after-split-regex", "pane": "file", "text": "a,b\nc,d\n", "keys": "%S,<ret><C-c>"} diff --git a/test/hxcases/goldens.jsonl b/test/hxcases/goldens.jsonl index baa45d34..eee7294b 100644 --- a/test/hxcases/goldens.jsonl +++ b/test/hxcases/goldens.jsonl @@ -323,7 +323,7 @@ {"name":"o-basic","text":"ab\nx\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} {"name":"o-copies-indent-level","text":" foo\n \n","mode":"insert","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}} {"name":"o-partial-indent-drops","text":" foo\n\n","mode":"insert","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} -{"name":"o-count","text":"ab\n\n\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}} +{"name":"o-count","text":"ab\n\n\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":0} {"name":"O-basic","text":"aa\nx\nbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}} {"name":"O-copies-indent","text":" \n foo\n","mode":"insert","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"i-type-esc","text":"hello alpha\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}} @@ -358,3 +358,124 @@ {"name":"ins-arrows","text":"abc\ndxef\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}} {"name":"ins-tab-col0","text":" ab\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} {"name":"ins-tab-mid","text":"a b\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}} +{"name":"msel-C-basic","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-C-basic-tty","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-C-count","text":"aaa\nbbb\nccc\nddd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":2} +{"name":"msel-C-count-tty","text":"aaa\nbbb\nccc\nddd\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":2} +{"name":"msel-C-skips-short","text":"abcdef\nab\nabcdef\n","mode":"normal","cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4},"sels":[{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":4}}],"primary":1} +{"name":"msel-C-stops-at-eof","text":"aa\nbb\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":2} +{"name":"msel-C-of-selection","text":"abcd\nabcd\nabcd\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-C-above","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":0} +{"name":"msel-C-above-tty","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":1}}],"primary":0} +{"name":"msel-C-above-at-top","text":"aaa\nbbb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-C-empty-line","text":"ab\n\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-move-l","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-move-l-tty","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-move-jj","text":"aaaa\nbbbb\ncccc\ndddd\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"msel-word","text":"one two\nsix seven\n","mode":"normal","cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-goto-line-end","text":"aaa\nbbbbb\n","mode":"normal","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}}],"primary":1} +{"name":"msel-keep-primary","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"msel-keep-primary-tty","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"msel-remove-primary","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-remove-primary-last","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"msel-rotate-fwd","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"msel-rotate-fwd-tty","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"msel-rotate-back","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}}],"primary":1} +{"name":"msel-rotate-wrap","text":"aaa\nbbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-split-newline","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":1},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"msel-split-partial","text":"abcd\nefgh\n","mode":"select","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"msel-split-partial-tty","text":"abcd\nefgh\n","mode":"select","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"msel-merge","text":"aaa\nbbb\nccc\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":0,"col":0}} +{"name":"msel-merge-consecutive","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":0}} +{"name":"msel-trim","text":" ab \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2}} +{"name":"msel-trim-all-ws","text":" \n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} +{"name":"msel-trim-multi","text":" ab \n cd \n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-insert","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-insert-tty","text":"xaa\nxbb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-append","text":"aza\nbzb\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-insert-two-chars","text":"xyaa\nxybb\nxycc\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}}],"primary":2} +{"name":"msel-delete","text":"bc\nbc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-delete-tty","text":"bc\nbc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-delete-selection","text":"cd\ncd\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-change","text":"zbc\nzbc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-switch-case","text":"Abc\nAbc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-switch-case-tty","text":"Abc\nAbc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-uppercase","text":"ABc\nABc\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-indent","text":" aa\n bb\n","mode":"normal","cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4},"sels":[{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":1,"col":4},"anchor":{"row":1,"col":4}}],"primary":1} +{"name":"msel-replace-char","text":"zbc\nzbc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-undo","text":"abc\nabc\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-open-below","text":"aa\nz\nbb\nz\n","mode":"normal","cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1},"sels":[{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}},{"cursor":{"row":3,"col":1},"anchor":{"row":3,"col":1}}],"primary":1} +{"name":"msel-line-select","text":"aa\nbb\ncc\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-collapse","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1} +{"name":"msel-flip","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":2}}],"primary":1} +{"name":"msel-yank-paste","text":"aab\nccd\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-esc-keeps","text":"aa\nbb\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-select-mode","text":"abcd\nabcd\n","mode":"select","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":0}}],"primary":1} +{"name":"msel-open-count","text":"\n\nab\n","mode":"insert","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"msel-backspace","text":"ac\nac\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"msel-backspace-tty","text":"ac\nac\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":1} +{"name":"sel-regex-basic","text":"alpha beta alpha\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":9}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":15}}],"primary":0} +{"name":"sel-regex-basic-tty","text":"alpha beta alpha\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":9},"anchor":{"row":0,"col":9}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":15}}],"primary":0} +{"name":"sel-regex-word","text":"alpha beta alpha\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":11}}],"primary":0} +{"name":"sel-regex-class","text":"a1b22c333\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6}}],"primary":0} +{"name":"sel-regex-class-tty","text":"a1b22c333\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}},{"cursor":{"row":0,"col":8},"anchor":{"row":0,"col":6}}],"primary":0} +{"name":"sel-regex-alt","text":"cat dog cat\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":8}}],"primary":0} +{"name":"sel-regex-plus","text":"aa b aaa\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":7},"anchor":{"row":0,"col":5}}],"primary":0} +{"name":"sel-regex-star-empty","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}}],"primary":0} +{"name":"sel-regex-nomatch","text":"alpha beta\n","mode":"normal","cursor":{"row":0,"col":10},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-esc","text":"alpha beta alpha\n","mode":"normal","cursor":{"row":0,"col":16},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-empty","text":"abc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-invalid","text":"abc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-within-sels","text":"aXa\nbXb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":0} +{"name":"sel-regex-within-sels-tty","text":"aXa\nbXb\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":1,"col":1},"anchor":{"row":1,"col":1}}],"primary":0} +{"name":"sel-regex-at-cursors","text":"foo bar\nfoo baz\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"sel-regex-narrow","text":"alpha beta alpha\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":11}},{"cursor":{"row":0,"col":15},"anchor":{"row":0,"col":15}}],"primary":0} +{"name":"sel-regex-select-mode","text":"alpha beta\n","mode":"select","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"sel-regex-multiline","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":0} +{"name":"sel-regex-smart-case","text":"Alpha alpha ALPHA\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":10},"anchor":{"row":0,"col":6}},{"cursor":{"row":0,"col":16},"anchor":{"row":0,"col":12}}],"primary":0} +{"name":"sel-regex-cased","text":"Alpha alpha ALPHA\n","mode":"normal","cursor":{"row":0,"col":4},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-boundary","text":"cat cats\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-whole","text":"abc\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}} +{"name":"sel-regex-then-motion","text":"foo bar foo baz\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":11},"anchor":{"row":0,"col":10}}],"primary":0} +{"name":"sel-regex-then-delete","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"sel-regex-dot-newline","text":"ab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}}],"primary":0} +{"name":"sel-regex-caret","text":"ab\nab\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":1,"col":0}}],"primary":0} +{"name":"split-regex-comma","text":"a,b,c\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"split-regex-comma-tty","text":"a,b,c\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":5},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"split-regex-lead","text":",a,b\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":1}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"split-regex-trail","text":"a,b,\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":4}}],"primary":0} +{"name":"split-regex-adjacent","text":"a,,b\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":4},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"split-regex-ws","text":"one two three\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":13},"anchor":{"row":0,"col":9}}],"primary":0} +{"name":"split-regex-ws-tty","text":"one two three\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":4}},{"cursor":{"row":0,"col":13},"anchor":{"row":0,"col":9}}],"primary":0} +{"name":"split-regex-nomatch","text":"abc\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":0}} +{"name":"split-regex-newline","text":"one\ntwo\nthree\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":0}},{"cursor":{"row":2,"col":4},"anchor":{"row":2,"col":0}}],"primary":0} +{"name":"split-regex-esc","text":"a,b,c\n","mode":"normal","cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} +{"name":"split-regex-within-sels","text":"aXa\nbXb\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}},{"cursor":{"row":1,"col":0},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":3},"anchor":{"row":1,"col":2}}],"primary":0} +{"name":"split-regex-end","text":"abc\n","mode":"normal","cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0},"sels":[{"cursor":{"row":0,"col":1},"anchor":{"row":0,"col":0}},{"cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}}],"primary":0} +{"name":"comment-line","text":"# alpha\nbeta\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"comment-toggle-back","text":"alpha\nbeta\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"comment-selection","text":"# alpha\n# beta\ngamma\n","mode":"normal","cursor":{"row":1,"col":6},"anchor":{"row":0,"col":2}} +{"name":"comment-indent","text":" # alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":10},"anchor":{"row":0,"col":0}} +{"name":"comment-indent-round-trip","text":" alpha\n beta\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":0,"col":0}} +{"name":"comment-blank-skipped","text":"# alpha\n\n# beta\n","mode":"normal","cursor":{"row":2,"col":6},"anchor":{"row":0,"col":2}} +{"name":"comment-partial","text":"# # alpha\n# beta\n# gamma\n","mode":"normal","cursor":{"row":2,"col":7},"anchor":{"row":0,"col":2}} +{"name":"comment-uncomment","text":"alpha\nbeta\n","mode":"normal","cursor":{"row":1,"col":4},"anchor":{"row":0,"col":0}} +{"name":"comment-margin0","text":"alpha\n beta\n","mode":"normal","cursor":{"row":1,"col":5},"anchor":{"row":0,"col":0}} +{"name":"comment-token-only","text":"\n\n","mode":"normal","cursor":{"row":1,"col":0},"anchor":{"row":0,"col":0}} +{"name":"comment-uncomment-deep","text":"alpha\n # beta\n","mode":"normal","cursor":{"row":1,"col":8},"anchor":{"row":0,"col":0}} +{"name":"comment-tab-indent","text":"\t# alpha\n\t# beta\n","mode":"normal","cursor":{"row":1,"col":7},"anchor":{"row":0,"col":0}} +{"name":"comment-other-token","text":"# // alpha\n# // beta\n","mode":"normal","cursor":{"row":1,"col":9},"anchor":{"row":0,"col":2}} +{"name":"comment-noeol","text":"# alpha","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"comment-empty","text":"\n","mode":"normal","cursor":{"row":0,"col":0},"anchor":{"row":0,"col":0}} +{"name":"comment-only-blank","text":" \n\t\n","mode":"normal","cursor":{"row":1,"col":1},"anchor":{"row":0,"col":0}} +{"name":"comment-select-mode","text":"# alpha\n# beta\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":0,"col":2}} +{"name":"comment-count-ignored","text":"# alpha\nbeta\ngamma\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}} +{"name":"comment-undo","text":"alpha\nbeta\n","mode":"normal","cursor":{"row":1,"col":4},"anchor":{"row":0,"col":0}} +{"name":"comment-multi-cursor","text":"# alpha\n# beta\ngamma\n","mode":"normal","cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}}],"primary":1} +{"name":"comment-same-line-cursors","text":"# alpha beta\n","mode":"normal","cursor":{"row":0,"col":7},"anchor":{"row":0,"col":2}} +{"name":"comment-cursor-gap","text":"# a\n# b\n# c\nd\n","mode":"normal","cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":2},"anchor":{"row":2,"col":2}}],"primary":2} +{"name":"comment-split-cursors","text":"# alpha\n# beta\n# gamma\n","mode":"normal","cursor":{"row":0,"col":6},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":2}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":2}}],"primary":0} +{"name":"comment-last-line","text":"alpha\nbeta\n","mode":"normal","cursor":{"row":2,"col":0},"anchor":{"row":2,"col":0}} +{"name":"comment-then-insert","text":"# zalpha\n","mode":"normal","cursor":{"row":0,"col":3},"anchor":{"row":0,"col":3}} +{"name":"comment-after-sel-regex","text":"# alpha\n# beta\n# gamma\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":0,"col":6},"anchor":{"row":0,"col":6}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":5}},{"cursor":{"row":2,"col":3},"anchor":{"row":2,"col":3}},{"cursor":{"row":2,"col":6},"anchor":{"row":2,"col":6}}],"primary":0} +{"name":"comment-after-split-regex","text":"# a,b\n# c,d\n","mode":"normal","cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2},"sels":[{"cursor":{"row":0,"col":2},"anchor":{"row":0,"col":2}},{"cursor":{"row":1,"col":2},"anchor":{"row":0,"col":4}},{"cursor":{"row":1,"col":5},"anchor":{"row":1,"col":4}}],"primary":0} diff --git a/test/hxcases/parity-waivers.jsonl b/test/hxcases/parity-waivers.jsonl index d48f4670..1a6fd269 100644 --- a/test/hxcases/parity-waivers.jsonl +++ b/test/hxcases/parity-waivers.jsonl @@ -8,3 +8,6 @@ {"name":"zj-noop-in-band","reason":"viewport geometry: same as zj-snaps-cursor - the terminal's view cannot scroll past the live grid bottom, so the cursor's band differs by one row."} {"name":"zk-bottom-snap","reason":"viewport geometry: zk scrolls the view up one row and snaps the cursor; the terminal's starting view offset differs because it could not scroll as far down in the first place."} {"name":"unindent-tab","reason":"the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and never the tab the file pane unindents. Not an editing divergence - unindent-basic, unindent-partial, unindent-realign and unindent-cursor all match; only a byte a terminal cannot hold verbatim."} +{"name": "comment-tab-indent", "reason": "the case text is not representable in a pty pane: a literal TAB fed through the terminal is expanded to the next tab stop by the emulator, so the shell rows hold spaces and the comment token goes in at a different column. Exactly the unindent-tab waiver's class - not an editing divergence; comment-indent, comment-uncomment-deep and the rest of the comment-* family match."} +{"name": "comment-noeol", "reason": "the case text is not representable in a pty pane: it is the one case in the corpus with NO trailing newline (that is the point - it pins helix's last-line handling), and the tty harness re-adds the newline ghostty's dump trims off the cursor row. The comment itself is identical on both sides; only the missing final byte differs."} +{"name": "comment-only-blank", "reason": "the case text is not representable in a pty pane: a literal TAB (unindent-tab's class) plus an all-whitespace last row, which the terminal dump trims off entirely. Both panes correctly change nothing - toggle_comments skips blank lines - so the only difference is what the pty could hold in the first place."} diff --git a/test/hxcases/waivers.jsonl b/test/hxcases/waivers.jsonl index 8110a84c..6b2f612c 100644 --- a/test/hxcases/waivers.jsonl +++ b/test/hxcases/waivers.jsonl @@ -1,2 +1,6 @@ {"name":"wiX-edit-drops-sel","reason":"helix maps the selection through every insert-mode edit; pardes drops it on the first edit instead (anchor-only divergence, text+cursor+mode match). Re-judged phase 5 and kept: mapping the anchor through all ~23 mutation sites in handleInsert (multi-line inserts, EOL joins, kill runs) is genuinely invasive, and nothing in pardes consumes an implicit selection after insert-mode typing - the acme chords need explicit selections, which never enter insert."} {"name":"alt-c-window-op","reason":"deliberate pardes binding: Alt-c is the move-pane-to-fresh-column window op in ANY mode (do-not-touch contract); helix Alt-c is change-noyank. Alt-d + i covers the helix behavior; the buffer is untouched on the pardes side."} +{"name":"msel-append","reason":"multiple cursors + `a`: pardes restores the appended-over span for the PRIMARY only, because Pane.append_at (where the append session began) is a single field and the other ranges have nowhere to keep their own origin. Every cursor lands on the right cell and the text matches; only the secondaries' anchors differ, so they read as bare cursors instead of spanning what was typed. Same family as wiX-edit-drops-sel: pardes does not map selections through insert-mode edits. Fix = an origin per SelRange."} +{"name":"msel-yank-paste","reason":"pardes has ONE yank register; helix's holds one VALUE PER RANGE and pastes value[i] back at range[i]. A multi-range `y` in pardes therefore joins the ranges' text with newlines and `p` puts the whole newline-joined blob at every cursor (which also makes it linewise). Deliberate: a per-range register is its own feature, not part of the selection model. Single-range yank/paste is unaffected and covered by the yank-* / paste-* cases."} +{"name": "sel-regex-dot-newline", "reason": "mvzr's `.` matches ANY byte, newlines included; helix builds its pattern with the Rust regex crate, where `.` excludes \\n unless asked. So `%s.` selects one range per byte in pardes and one per non-newline byte in helix. Deliberate: closing it means rewriting `.` to `[^\\n]` inside the pattern, and doing that correctly needs a real parse (`\\.` is a literal dot, a `.` inside `[...]` is already literal) - a regex parser written to work around the regex engine. `[^\\n]` typed at the prompt is the workaround, and it behaves identically in both editors."} +{"name": "sel-regex-caret", "reason": "helix compiles the `s`/`S` pattern with multi_line(true), so `^` and `$` are LINE anchors; mvzr has no such flag and asserts them against the START OF THE SLICE it is handed, which pardes advances to each match's end. `%s^a` therefore finds one match in pardes and one per line in helix. Same root cause for `$`. Fixing it means either searching line by line - which changes which ranges come out for every OTHER pattern, since a match may not span a line then - or an engine with multi-line assertions. Every other anchor-free pattern agrees, including \\b (sel-regex-boundary)."} diff --git a/test/hxdiff.zig b/test/hxdiff.zig index b04cc0b5..f6ab5c6f 100644 --- a/test/hxdiff.zig +++ b/test/hxdiff.zig @@ -8,6 +8,16 @@ // result: {"name":"w-basic","text":"alpha beta\n","mode":"normal", // "cursor":{"row":0,"col":5},"anchor":{"row":0,"col":0}} // +// MULTIPLE CURSORS extend that line with two more fields, and only when there +// is more than one selection: +// +// ...,"sels":[{"cursor":…,"anchor":…},…],"primary":1 +// +// `sels` is EVERY range in document order, each end measured exactly like the +// `cursor`/`anchor` pair above, and `primary` is the index of the one those +// two fields describe. Omitted entirely at one selection — which is why every +// golden written before multiple cursors existed is still byte-for-byte valid. +// // zig build hxdiff (diff the checked-in corpus) // zig build hxdiff -- <cases.jsonl> (results to stdout, no diff) // zig build hxparity (file-vs-pty parity, both corpora) @@ -53,7 +63,21 @@ const pardes = @import("pardes"); // drives feeds every case's text through ghostty-vt, which narrates each // sequence it does not implement. Cut the libraries back to errors; the // per-case report goes through std.debug, so a mismatch stays loud. -pub const std_options: std.Options = .{ .log_level = .err }; +// The regex engine is dropped outright rather than cut back: an `s`/`S` case +// types its pattern one key at a time, so mvzr is HANDED every incomplete +// prefix of it and logs each one at error level. src/main.zig drops the same +// scope for the same reason. +pub const std_options: std.Options = .{ .log_level = .err, .logFn = logFn }; + +fn logFn( + comptime level: std.log.Level, + comptime scope: @EnumLiteral(), + comptime format: []const u8, + args: anytype, +) void { + if (scope == .mvzr) return; + std.log.defaultLog(level, scope, format, args); +} const gpa = std.heap.page_allocator; @@ -152,7 +176,9 @@ pub fn main(init: std.process.Init) !void { } const res = runCase(arena, case, case.pane) catch |e| fatal("case {s}: {s}", .{ case.name, @errorName(e) }); if (goldens_path == null) { - const jl = std.json.Stringify.valueAlloc(arena, res, .{}) catch fatal("oom", .{}); + // null sels/primary are DROPPED, not written: that is what + // keeps a one-selection line identical to the old contract + const jl = std.json.Stringify.valueAlloc(arena, res, .{ .emit_null_optional_fields = false }) catch fatal("oom", .{}); out.appendSlice(arena, jl) catch fatal("oom", .{}); out.append(arena, '\n') catch fatal("oom", .{}); continue; @@ -185,14 +211,20 @@ const Case = struct { }; const Pos = struct { row: i32, col: i32 }; +const Sel = struct { cursor: Pos, anchor: Pos }; -// field order is the contract's emit order (Stringify writes declaration order) +// field order is the contract's emit order (Stringify writes declaration +// order). The two multi-cursor fields are OPTIONAL and emitted with +// .emit_null_optional_fields = false, so a single selection prints the +// original five-field line and nothing else. const Result = struct { name: []const u8, text: []const u8, mode: []const u8, cursor: Pos, anchor: Pos, + sels: ?[]const Sel = null, + primary: ?usize = null, }; const Waiver = struct { @@ -216,6 +248,35 @@ fn diffResult(arena: std.mem.Allocator, want: Result, got: Result, wl: []const u if (want.anchor.row != got.anchor.row or want.anchor.col != got.anchor.col) { appendf(arena, &buf, " anchor: {s} ({d},{d}) != {s} ({d},{d})\n", .{ wl, @as(i64, want.anchor.row), @as(i64, want.anchor.col), gl, @as(i64, got.anchor.row), @as(i64, got.anchor.col) }); } + // the whole selection: absent on both sides = one range, and the two + // fields above already said everything about it + const ws = want.sels orelse &.{}; + const gs = got.sels orelse &.{}; + var sel_diff = ws.len != gs.len or (want.primary orelse 0) != (got.primary orelse 0); + if (!sel_diff) for (ws, gs) |a, b| { + if (a.cursor.row != b.cursor.row or a.cursor.col != b.cursor.col or + a.anchor.row != b.anchor.row or a.anchor.col != b.anchor.col) sel_diff = true; + }; + if (sel_diff) { + appendf(arena, &buf, " sels: {s} {s} != {s} {s}\n", .{ wl, fmtSels(arena, ws, want.primary), gl, fmtSels(arena, gs, got.primary) }); + } + return buf.items; +} + +/// "2 of [(0,1)|(1,1) *(2,1)]" — every range as cursor|anchor (collapsed to +/// one pair when they are the same cell), the primary starred +fn fmtSels(arena: std.mem.Allocator, sels: []const Sel, primary: ?usize) []const u8 { + if (sels.len == 0) return "<one>"; + var buf: std.ArrayList(u8) = .empty; + appendf(arena, &buf, "{d} of [", .{sels.len}); + for (sels, 0..) |s, i| { + if (i > 0) appendf(arena, &buf, " ", .{}); + if (i == (primary orelse 0)) appendf(arena, &buf, "*", .{}); + appendf(arena, &buf, "({d},{d})", .{ @as(i64, s.cursor.row), @as(i64, s.cursor.col) }); + if (s.anchor.row != s.cursor.row or s.anchor.col != s.cursor.col) + appendf(arena, &buf, "|({d},{d})", .{ @as(i64, s.anchor.row), @as(i64, s.anchor.col) }); + } + appendf(arena, &buf, "]", .{}); return buf.items; } @@ -296,6 +357,11 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result } else if (std.mem.eql(u8, name, "gt")) { key.cp = '>'; if (!key.ctrl and !key.alt) key.text = ">"; + } else if (std.mem.eql(u8, name, "minus")) { + // helix spells Alt-'-' this way: a bare `-` after a modifier + // is a parse error in its own macro notation + key.cp = '-'; + if (!key.ctrl and !key.alt) key.text = "-"; } else if (std.mem.eql(u8, name, "up")) { key.cp = pardes.Key.up; } else if (std.mem.eql(u8, name, "down")) { @@ -351,12 +417,39 @@ fn runCase(arena: std.mem.Allocator, case: Case, pane_kind: []const u8) !Result // mode "select" ONLY while v-extend is on (helix keeps x/%/mi selections // in normal mode); pane.mode itself never holds a select state const mode = if (pane.mode == .normal and pane.select) "select" else @tagName(pane.mode); + // multiple cursors: the primary (cur_row/cur_col + the anchor above) and + // pane.sels, slotted together in document order. pane.sels is already + // ordered, so the primary is the only one that needs placing. + var sels: ?[]const Sel = null; + var primary: ?usize = null; + if (pane.nsel > 0) { + var out: std.ArrayList(Sel) = .empty; + const prim: Sel = .{ .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor }; + const pr_row = @min(prim.cursor.row, prim.anchor.row); + const pr_col = if (prim.cursor.row == prim.anchor.row) @min(prim.cursor.col, prim.anchor.col) else if (prim.cursor.row < prim.anchor.row) prim.cursor.col else prim.anchor.col; + for (pane.sels[0..pane.nsel]) |s| { + const s_row = @min(s.row, s.arow); + const s_col = if (s.row == s.arow) @min(s.col, s.acol) else if (s.row < s.arow) s.col else s.acol; + if (primary == null and (pr_row < s_row or (pr_row == s_row and pr_col <= s_col))) { + primary = out.items.len; + try out.append(arena, prim); + } + try out.append(arena, .{ .cursor = .{ .row = s.row, .col = s.col }, .anchor = .{ .row = s.arow, .col = s.acol } }); + } + if (primary == null) { + primary = out.items.len; + try out.append(arena, prim); + } + sels = out.items; + } return .{ .name = case.name, .text = try arena.dupe(u8, text), .mode = mode, .cursor = .{ .row = pane.cur_row, .col = pane.cur_col }, .anchor = anchor, + .sels = sels, + .primary = primary, }; } diff --git a/test/snapshots/lspdebug.golden b/test/snapshots/lspdebug.golden index 2bce4aa6..bb0fee41 100644 --- a/test/snapshots/lspdebug.golden +++ b/test/snapshots/lspdebug.golden @@ -203,16 +203,16 @@ | 3 zig lib dir: /usr/lib/zig [OK] | 4 offsets: utf-8 walk caps: 512 files, 2000 rows | 5 -| 6 dependency imports gd can follow (6): +| 6 dependency imports gd can follow (7): | 7 zls /home/goblin/00-projects/0x4200.cafe | 8 tree-sitter /home/goblin/00-projects/0x4200.cafe | 9 zstbi /home/goblin/00-projects/0x4200.cafe -| 10 ghostty-vt /home/goblin/00-projects/0x4200.cafe -| 11 vaxis /home/goblin/00-projects/0x4200.cafe -| 12 uucode /home/goblin/00-projects/0x4200.cafe -| 13 asked from: /tmp/pardes-snap/lspdebug/cwd/z.zig -| 14 -| 15 answers: definition, declaration, type_definition, im +| 10 mvzr /home/goblin/00-projects/0x4200.cafe +| 11 ghostty-vt /home/goblin/00-projects/0x4200.cafe +| 12 vaxis /home/goblin/00-projects/0x4200.cafe +| 13 uucode /home/goblin/00-projects/0x4200.cafe +| 14 asked from: /tmp/pardes-snap/lspdebug/cwd/z.zig +| 15 | NOR /tmp/pardes-snap/lspdebug/cwd/+Lsp Del | 1 lsp explain — the definition query at byte 29 of z.zi | 2 diff --git a/test/snapshots/multicursor.golden b/test/snapshots/multicursor.golden new file mode 100644 index 00000000..2be37dc2 --- /dev/null +++ b/test/snapshots/multicursor.golden @@ -0,0 +1,217 @@ +== snap three-cursors grid=150x30 cursor=7,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/multicursor/cwd/f.txt Save NOR /tmp/pardes-snap/multicursor/cwd Del NOR /tmp/pardes-snap/multicursor/cwd Del +| 1 line 1 +| 2 line 2 f.txt f.txt +| 3 line 3 +| 4 line 4 +| 5 line 5 +| 6 line 6 f.txt +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/multicursor/cwd Del +| +| f.txt +| +| +| +| +| +| +| +| +| +| +| +== style three-cursors +|0: 0-52 #949494,#121212, 53-149 d,#121212, +|1: 0-1 d,#626262, 2-49 #949494,#121212, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#262626, 102-141 #949494,#121212, 142-149 d,#121212, +|2: 0-1 d,#444444, 2-6 #626262,#080808, 7-7 #4a3a1e,#d8c8a8, 8-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-7 #4a3a1e,#d8c8a8, 8-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #949494,#121212, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|24: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|25: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|26: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|27: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|28: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|29: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +== snap edited-at-three grid=150x30 cursor=9,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/multicursor/cwd/f.txt Save NOR /tmp/pardes-snap/multicursor/cwd Del NOR /tmp/pardes-snap/multicursor/cwd Del +| 1 >>line 1 +| 2 >>line 2 f.txt f.txt +| 3 >>line 3 +| 4 line 4 +| 5 line 5 +| 6 line 6 f.txt +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/multicursor/cwd Del +| +| f.txt +| +| +| +| +| +| +| +| +| +| +| +== snap three-selections grid=150x30 cursor=8,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/multicursor/cwd/f.txt Save NOR /tmp/pardes-snap/multicursor/cwd Del NOR /tmp/pardes-snap/multicursor/cwd Del +| 1 >>line 1 +| 2 >>line 2 f.txt f.txt +| 3 >>line 3 +| 4 line 4 +| 5 line 5 +| 6 line 6 f.txt +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/multicursor/cwd Del +| +| f.txt +| +| +| +| +| +| +| +| +| +| +| +== style three-selections +|0: 0-52 #949494,#121212, 53-149 d,#121212, +|1: 0-1 d,#626262, 2-49 #949494,#121212, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#262626, 102-141 #949494,#121212, 142-149 d,#121212, +|2: 0-1 d,#444444, 2-6 #626262,#080808, 7-7 #a89c84,#2e2616, 8-8 #4a3a1e,#d8c8a8, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-7 #a89c84,#2e2616, 8-8 #4a3a1e,#d8c8a8, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #949494,#121212, 7-8 #d8c8a8,#4a3a1e, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|24: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|25: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|26: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|27: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|28: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|29: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +== snap keep-primary grid=150x30 cursor=8,4 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/multicursor/cwd/f.txt Save NOR /tmp/pardes-snap/multicursor/cwd Del NOR /tmp/pardes-snap/multicursor/cwd Del +| 1 >>line 1 +| 2 >>line 2 f.txt f.txt +| 3 >>line 3 +| 4 line 4 +| 5 line 5 +| 6 line 6 f.txt +| 7 line 7 +| 8 line 8 +| 9 line 9 +| 10 line 10 +| 11 line 11 +| 12 line 12 +| 13 +| +| NOR /tmp/pardes-snap/multicursor/cwd Del +| +| f.txt +| +| +| +| +| +| +| +| +| +| +| +== style keep-primary +|0: 0-52 #949494,#121212, 53-149 d,#121212, +|1: 0-1 d,#626262, 2-49 #949494,#121212, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#262626, 102-141 #949494,#121212, 142-149 d,#121212, +|2: 0-1 d,#444444, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #949494,#121212, 7-8 #d8c8a8,#4a3a1e, 9-14 #bdbdbd,#080808, 15-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-58 #bdbdbd,#080808, 59-99 d,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-12 #bdbdbd,#080808, 13-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-13 #bdbdbd,#080808, 14-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#262626, 52-91 #949494,#121212, 92-99 d,#121212, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|24: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|25: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|26: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|27: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|28: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, +|29: 0-1 d,#444444, 2-6 #626262,#080808, 7-49 d,#080808, 50-51 d,#444444, 52-99 #bdbdbd,#080808, 100-101 d,#444444, 102-149 #bdbdbd,#080808, diff --git a/test/snapshots/multicursor.snap b/test/snapshots/multicursor.snap new file mode 100644 index 00000000..2bfccb93 --- /dev/null +++ b/test/snapshots/multicursor.snap @@ -0,0 +1,46 @@ +# MULTIPLE CURSORS on a real file pane. The differential suite (hxdiff) proves +# the model against a real helix but is headless, so this is the part it cannot +# see: that every range is actually on screen, that the primary reads +# differently from the others, and that ONE keystroke edits at all of them. +lines f.txt 12 +start 30 150 -n 3 +wait 8000 f.txt +stable 700 20000 +# open f.txt in the left pane (type its name, right-click to Look) +press left 5 8 +release left 5 8 +stable 400 5000 +key i +text f.txt +key esc +settle 100 +press right 6 8 +release right 6 8 +wait 10000 line 3 +stable 700 15000 +# top of the file, then C C: three cursors, one per line, primary on line 3 +key g g +stable 400 5000 +key C C +stable 400 5000 +snap three-cursors +# the colours ARE the feature here: the primary's cell against the two dimmer +# ones, each with its own bright cursor cell +snapstyle three-cursors +# one keystroke, three edits +key i +text >> +key esc +settle 100 +stable 400 5000 +snap edited-at-three +# a selection per cursor: e selects to the end of the first word at each +key g h w +stable 400 5000 +snap three-selections +snapstyle three-selections +# `,` drops back to the primary alone; the two dim highlights must vanish +key , +stable 400 5000 +snap keep-primary +snapstyle keep-primary diff --git a/test/snapshots/selregex.golden b/test/snapshots/selregex.golden new file mode 100644 index 00000000..9a1d58d6 --- /dev/null +++ b/test/snapshots/selregex.golden @@ -0,0 +1,275 @@ +== snap prompt-armed grid=160x24 cursor=59,1 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| INS /tmp/pardes-snap/selregex/cwd/m.zig Save Del Select / NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap preview grid=160x24 cursor=64,1 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| INS /tmp/pardes-snap/selregex/cwd/m.zig Save Del Select /const NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== style preview +|0: 0-52 #949494,#121212, 53-159 d,#121212, +|1: 0-1 d,#626262, 2-63 #949494,#121212, 64-79 d,#121212, 80-81 d,#262626, 82-118 #949494,#121212, 119-159 d,#121212, +|2: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #d8c8a8,#4a3a1e, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-20 #bdbdbd,#080808, 21-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +== snap three-selected grid=160x24 cursor=11,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/selregex/cwd/m.zig Save Del NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== style three-selected +|0: 0-52 #949494,#121212, 53-159 d,#121212, +|1: 0-1 d,#626262, 2-49 #949494,#121212, 50-79 d,#121212, 80-81 d,#262626, 82-118 #949494,#121212, 119-159 d,#121212, +|2: 0-1 d,#444444, 2-6 #949494,#121212, 7-11 #d8c8a8,#4a3a1e, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-20 #bdbdbd,#080808, 21-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +== snap narrowed grid=160x24 cursor=60,1 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| INS /tmp/pardes-snap/selregex/cwd/m.zig Save Del Select /t NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== style narrowed +|0: 0-52 #949494,#121212, 53-159 d,#121212, +|1: 0-1 d,#626262, 2-59 #949494,#121212, 60-79 d,#121212, 80-81 d,#262626, 82-118 #949494,#121212, 119-159 d,#121212, +|2: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #bdbdbd,#080808, 11-11 #4a3a1e,#d8c8a8, 12-20 #bdbdbd,#080808, 21-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +== snap esc-restores grid=160x24 cursor=11,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/selregex/cwd/m.zig Save Del NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== style esc-restores +|0: 0-52 #949494,#121212, 53-159 d,#121212, +|1: 0-1 d,#626262, 2-49 #949494,#121212, 50-79 d,#121212, 80-81 d,#262626, 82-118 #949494,#121212, 119-159 d,#121212, +|2: 0-1 d,#444444, 2-6 #949494,#121212, 7-11 #d8c8a8,#4a3a1e, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|3: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-18 #bdbdbd,#080808, 19-19 #e3c78a,#080808, 20-20 #bdbdbd,#080808, 21-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|4: 0-1 d,#444444, 2-6 #626262,#080808, 7-10 #a89c84,#2e2616, 11-11 #4a3a1e,#d8c8a8, 12-20 #bdbdbd,#080808, 21-21 #e3c78a,#080808, 22-22 #bdbdbd,#080808, 23-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|5: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|6: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|7: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|8: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|9: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|10: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|11: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|12: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|13: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|14: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|15: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|16: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|17: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|18: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|19: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|20: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|21: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|22: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +|23: 0-1 d,#444444, 2-6 #626262,#080808, 7-79 d,#080808, 80-81 d,#444444, 82-159 #bdbdbd,#080808, +== snap commented grid=160x24 cursor=14,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/selregex/cwd/m.zig Save Del NOR /tmp/pardes-snap/selregex/cwd Del +| 1 // const one = 1; +| 2 // const two = 2; m.zig +| 3 // const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap uncommented grid=160x24 cursor=11,2 +|Kill Newcol Tutor Debug NextColor Dump Find Grep Help +| NOR /tmp/pardes-snap/selregex/cwd/m.zig Save Del NOR /tmp/pardes-snap/selregex/cwd Del +| 1 const one = 1; +| 2 const two = 2; m.zig +| 3 const three = 3; +| 4 +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/selregex.snap b/test/snapshots/selregex.snap new file mode 100644 index 00000000..193ab314 --- /dev/null +++ b/test/snapshots/selregex.snap @@ -0,0 +1,56 @@ +# Interactive regex selection (`s` / `S`) and the comment toggle, on a real +# file pane. hxdiff pins what they DO against a real helix, but it is headless, +# so this is the half it cannot see: the prompt living in the TAG rather than a +# popup, the matches lighting up under a pattern as it is typed, Esc putting +# back what was there, and Ctrl-c using the token this file's EXTENSION asks +# for — `//`, where a buffer with no language would get `#`. +file m.zig const one = 1;\nconst two = 2;\nconst three = 3;\n +# wide on purpose: the whole point of this script is a tag long enough to hold +# the marker AND the pattern, and a narrow pane simply truncates it away +start 24 160 m.zig +wait 8000 m.zig +stable 700 20000 +key g g +stable 400 5000 +# `%` takes the whole file, `s` arms the regex input: the marker goes into the +# tag tail after the path, and the pane is in insert mode ON THE TAG — the +# same one-line editor `/` and Find use, and nothing has moved in the body yet. +key % +key s +stable 400 5000 +snap prompt-armed +# ...and now the point: the selection follows the pattern as it is typed. Three +# `const` runs light up with Enter nowhere near, and the colours ARE the +# feature — the primary bright, the other two dim, each with its own cursor. +text const +stable 400 5000 +snap preview +snapstyle preview +key enter +stable 400 5000 +snap three-selected +snapstyle three-selected +# a second `s` narrows the three to one `t` apiece... +key s +text t +stable 400 5000 +snap narrowed +# the grid text cannot show a selection, so this is where the styles carry the +# whole point: three one-cell ranges here, three five-cell ones after the Esc +snapstyle narrowed +# ...and Esc throws that away for the selection the prompt opened on, which is +# free: the preview is re-derived from that snapshot on every keystroke, so +# cancelling is just applying the empty pattern. +key esc +settle 100 +stable 400 5000 +snap esc-restores +snapstyle esc-restores +# Ctrl-c comments every line the three cursors touch — one decision for the +# whole selection, not one per cursor — and again to take it back off. +key c-c +stable 400 5000 +snap commented +key c-c +stable 400 5000 +snap uncommented |
