From 5bf8d6dd077517270377e5d8551108ecf252374f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 05:04:26 -0300 Subject: multiple cursors, regex selection, and Ctrl-c comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 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. --- src/config.zig | 62 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) (limited to 'src/config.zig') 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 -- cgit v1.3