diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-28 16:57:01 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 68e30f560e46bb9a30433eb23b4edb1b93d69f46 (patch) | |
| tree | 3f5f3085051488635ec61c02e33062e1eb94bdd7 | |
| parent | cd563b1768c688444cc6d172b3679d95b495207c (diff) | |
| download | pardes-68e30f560e46bb9a30433eb23b4edb1b93d69f46.tar.gz pardes-68e30f560e46bb9a30433eb23b4edb1b93d69f46.zip | |
A selection's other ranges live out of line, made at the second range and freed at one
The ranges beside the primary were an inline array in every Text, which at
1024 grew each pane by about 60 KiB. They are now allocated when a second
range appears (Text.reserveSels) and given back when the selection is one
range again (Text.dropSels); a column's or the workspace's tag allocates from
Text.gpa, any other text from its pane's. A refused tag edit restores them by
copy. The Pane size budget is back to 128 KiB, and a test holds that a single
cursor allocates nothing for them.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | docs/helix-keys.md | 2 | ||||
| -rw-r--r-- | docs/selections.md | 5 | ||||
| -rw-r--r-- | src/Terminal.zig | 5 | ||||
| -rw-r--r-- | src/Text.zig | 42 | ||||
| -rw-r--r-- | src/edit.zig | 10 | ||||
| -rw-r--r-- | src/layout.zig | 6 | ||||
| -rw-r--r-- | src/normal.zig | 28 | ||||
| -rw-r--r-- | src/pardes.zig | 10 | ||||
| -rw-r--r-- | test/panes.zig | 1 |
9 files changed, 89 insertions, 20 deletions
diff --git a/docs/helix-keys.md b/docs/helix-keys.md index 88aca887..1fb1c67a 100644 --- a/docs/helix-keys.md +++ b/docs/helix-keys.md @@ -282,7 +282,7 @@ keeps the matches it found so far. | 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 `$` | hold at every line's start and end | helix compiles with `multi_line(true)`; pardes gets the same by searching each line as its own haystack (`src/regexp.zig`) | helix-verified | | `.` | never matches a newline | as the Rust regex crate: a pattern without `\n` searches line by line, and one that names `\n` has its `.`s made `[^\n]` (`src/regexp.zig`) | helix-verified | -| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The ranges are held inline in every `Text` (about 20 KiB each on the desktop) | helix-verified up to the limit | +| many matches | up to `memory.limits.selections` ranges: 1024 on the desktop, 64 on the board; matches past it are dropped | helix has no limit. The room for them is allocated when a second range appears and given back at one range, so a single cursor costs nothing | helix-verified up to the limit | | `K<pat>` / `Alt-K<pat>` Enter | keep only the ranges a match starts inside / only those none does; primary 0, and keeping none leaves the selection alone | helix `keep_selections` / `remove_selections`, on the same prompt as `s`/`S` (markers `Keep /`, `Remove /`). The range is searched as `s` searches it, line by line with its lines' context, where helix matches the range's text alone: a `^` right at a range that starts mid-line matches in helix and not here | helix-verified | ### `Ctrl-c` — toggle comments diff --git a/docs/selections.md b/docs/selections.md index 203cf1de..5cc1df1a 100644 --- a/docs/selections.md +++ b/docs/selections.md @@ -17,7 +17,10 @@ desktop, 64 on the board). head, `vsel.row`/`vsel.col` the anchor while `vsel.active`, and a bare cursor (inactive `vsel`) is the one-character range under it. `msel` is the older whole-line form (`r0`..`r1`), kept for search-result highlights. -- The others are `sels[0..nsel]`, each a `SelRange` of head and anchor plus +- The others are `sels[0..nsel]`, in room allocated when a second range + appears and given back when the selection is one range again (a column's + or the workspace's tag allocates from `Text.gpa`, any other text from its + pane's), each a `SelRange` of head and anchor plus its own `j`/`k` goal column, in document order. The primary is not in the list; `Text.ranges` slots it in and returns its index. - Ends are CELLS, the characters a block cursor sits on, where helix's are diff --git a/src/Terminal.zig b/src/Terminal.zig index 4c29e151..ba156ada 100644 --- a/src/Terminal.zig +++ b/src/Terminal.zig @@ -545,10 +545,7 @@ test "replay ownership is terminal-only and construction rolls back on allocatio try std.testing.expectEqual(terminal and enabled, pane.terminal != null); } }; - // Most of it is the three texts' selections, limits.selections ranges - // each held inline (about 20 KiB a text on the desktop), and the regex - // prompt's snapshot of them. - try std.testing.expect(@sizeOf(Pane) < 160 * 1024); + try std.testing.expect(@sizeOf(Pane) < 128 * 1024); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{false}); try std.testing.checkAllAllocationFailures(std.testing.allocator, Case.run, .{true}); } diff --git a/src/Text.zig b/src/Text.zig index 70ec5369..a44241ca 100644 --- a/src/Text.zig +++ b/src/Text.zig @@ -60,8 +60,14 @@ cur_row: i32 = 0, cur_col: i32 = 0, msel: LineSel = .{}, vsel: CharSel = .{}, -sels: [max_selections - 1]SelRange = undefined, +/// The ranges beside the primary, `nsel` of them: room for max_selections - 1 +/// made when a second range appears and given back when the selection is one +/// range again, so a single cursor holds nothing here. +sels: []SelRange = &.{}, nsel: u16 = 0, +/// What a column's or the workspace's tag allocates its ranges from; a text +/// with a pane uses the pane's. +gpa: ?std.mem.Allocator = null, select: bool = false, /// sticky goal column for j/k runs (helix old_visual_position): any /// non-vertical range write resets it to -1. @@ -101,10 +107,39 @@ pub fn pane(t: *Text) ?*Pane { /// Free what a text owns: its characters and the buffers its undo keeps. pub fn deinit(t: *Text, gpa: std.mem.Allocator) void { + if (t.sels.len > 0) gpa.free(t.sels); if (t.own) |own| gpa.free(own); for (t.ed_undo[0..t.ed_undo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); for (t.ed_redo[0..t.ed_redo_len]) |item| if (item.ovl) |overlay| gpa.free(overlay.text); - t.* = .{ .what = t.what }; + t.* = .{ .what = t.what, .gpa = t.gpa }; +} + +fn allocator(t: *Text) ?std.mem.Allocator { + return if (t.pane()) |w| w.gpa else t.gpa; +} + +/// Room for the ranges beside the primary; false when there is none to be had. +pub fn reserveSels(t: *Text) bool { + if (t.sels.len > 0) return true; + const gpa = t.allocator() orelse return false; + t.sels = gpa.alloc(SelRange, max_selections - 1) catch return false; + return true; +} + +/// The ranges beside the primary as they were (a refused edit's undoing). +pub fn restoreSels(t: *Text, kept: []const SelRange) void { + if (kept.len == 0) return t.dropSels(); + if (!t.reserveSels()) return; + @memcpy(t.sels[0..kept.len], kept); + t.nsel = @intCast(kept.len); +} + +/// Back to one range: the room for the others goes. +pub fn dropSels(t: *Text) void { + t.nsel = 0; + if (t.sels.len == 0) return; + if (t.allocator()) |gpa| gpa.free(t.sels); + t.sels = &.{}; } /// Bring the cursor into view (acme's textshow). Only a body scrolls to it; @@ -185,7 +220,7 @@ pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, expl .explicit = explicit or t.select, }; t.msel.active = false; - t.nsel = 0; // writing ONE range means the selection IS that range + t.dropSels(); // writing ONE range means the selection IS that range t.cur_pinned = true; t.sticky_col = -1; t.normal.clear(); @@ -262,6 +297,7 @@ pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, sticky } n = k + 1; setRange(t, text, 0, r[pri], explicit); + if (n > 1 and !t.reserveSels()) return; // no room: the primary alone t.sticky_col = st[pri]; var w: usize = 0; for (r[0..n], 0..) |x, idx| { diff --git a/src/edit.zig b/src/edit.zig index 01b34237..5437a67d 100644 --- a/src/edit.zig +++ b/src/edit.zig @@ -167,10 +167,16 @@ fn pasteInto(p: *Pardes, t: *Text, bytes: []const u8) void { if (bytes.len == 0) return; const typed: pardes.Key = .{ .cp = bytes[0], .text = bytes }; if (t.what == .tag and t.mode == .insert) if (t.pane()) |pane| if (tagline.typedIntoName(p, pane, typed)) return; - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; if (t.mode == .insert) insertKey(p, t, typed) else pasteText(p, t, bytes, false, 1); - if (t.refused) t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + if (t.refused) { + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); + } } pub fn clipYank(p: *Pardes, pane: *Pane, main_only: bool) void { diff --git a/src/layout.zig b/src/layout.zig index 8e44f1d9..be8bad67 100644 --- a/src/layout.zig +++ b/src/layout.zig @@ -947,7 +947,7 @@ pub fn removePane(p: *Pardes, id: usize) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } } @@ -971,7 +971,7 @@ pub fn joinCol(p: *Pardes) void { p.col_serial[j] = p.col_serial[j + 1]; } p.ncol -= 1; - p.col_tags[p.ncol] = .{ .what = .column }; + p.col_tags[p.ncol] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[p.ncol] = 0; } @@ -1021,7 +1021,7 @@ pub fn splitColumn(p: *Pardes, source_id: usize, id: usize, before: bool) bool { p.col_serial[j] = p.col_serial[j - 1]; } p.col_weight[c] = new_weight; - p.col_tags[c] = .{ .what = .column }; + p.col_tags[c] = .{ .what = .column, .gpa = p.gpa }; p.col_serial[c] = 0; p.col_panes[c][0] = id; p.col_n[c] = 1; diff --git a/src/normal.zig b/src/normal.zig index 4459ab53..2aea2e54 100644 --- a/src/normal.zig +++ b/src/normal.zig @@ -686,10 +686,7 @@ fn executeNormalAction(p: *Pardes, t: *Text, semantic: modal.Normal.Action) void .all => return t.setRange(text, 0, .{ .anchor = 0, .head = text.len }, false), }, .multi => |multi| { - if (multi.kind == .keep_primary) { - t.nsel = 0; - return; - } + if (multi.kind == .keep_primary) return t.dropSels(); return t.multiSelAction(text, multi.kind, multi.count); }, // s, S and | answer for the text they are pressed in; `/` searches @@ -922,6 +919,7 @@ test "flat text movement and selection replay need no scratch rows" { pane.body.cur_row = 1024; pane.body.cur_col = 0; pane.body.nsel = 1; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 3072, .col = 0, .arow = 3072, .acol = 0 }; handleNormal(p, &pane.body, .{ .cp = 'l' }); handleNormal(p, &pane.body, .{ .cp = 'j' }); @@ -1021,3 +1019,25 @@ test "tree-sitter selection keys do in a JSON file what hx 25.07.1 does" { }; } } + +test "one cursor holds no room for other ranges; a second range makes it and one again gives it back" { + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 60, .rows = 12 }); + defer p.deinit(); + const pane = try p.setTestFile("alpha beta\nalpha beta\n"); + for ("wxe%;gg") |cp| handleNormal(p, &pane.body, .{ .cp = cp }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + try std.testing.expect(pane.body.sels.len > 0); + handleNormal(p, &pane.body, .{ .cp = 'l' }); // replayed at both, still two + try std.testing.expectEqual(@as(u16, 1), pane.body.nsel); + handleNormal(p, &pane.body, .{ .cp = ',' }); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); + handleNormal(p, &pane.body, .{ .cp = 'C' }); + handleNormal(p, &pane.body, .{ .cp = ';' }); + handleNormal(p, &pane.body, .{ .cp = 'x' }); // x at both: still two lines + handleNormal(p, &pane.body, .{ .cp = '_', .alt = true }); // merged to one + try std.testing.expectEqual(@as(u16, 0), pane.body.nsel); + try std.testing.expectEqual(@as(usize, 0), pane.body.sels.len); +} diff --git a/src/pardes.zig b/src/pardes.zig index 6b397017..187f3ed3 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -4146,6 +4146,8 @@ pub const Pardes = struct { .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, + .col_tags = @splat(.{ .what = .column, .gpa = gpa }), + .global_tag = .{ .what = .workspace, .gpa = gpa }, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, @@ -5335,10 +5337,14 @@ pub const Pardes = struct { if (t.what == .tag and t.mode == .insert and tagline.typedIntoName(p, pane, key)) return; // A tag's prefix is read-only: a key whose edit reached into it // changes nothing, not even where the cursor is. - const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel }; + const kept = .{ t.cur_row, t.cur_col, t.vsel, t.msel }; + var kept_sels: [Text.max_selections - 1]Text.SelRange = undefined; + const kept_n = t.nsel; + @memcpy(kept_sels[0..kept_n], t.sels[0..kept_n]); t.refused = false; defer if (t.refused) { - t.cur_row, t.cur_col, t.vsel, t.msel, t.sels, t.nsel = kept; + t.cur_row, t.cur_col, t.vsel, t.msel = kept; + t.restoreSels(kept_sels[0..kept_n]); }; const was = t.mode; switch (t.mode) { diff --git a/test/panes.zig b/test/panes.zig index a56c07bf..181317ca 100644 --- a/test/panes.zig +++ b/test/panes.zig @@ -4560,6 +4560,7 @@ const LookResetTests = struct { pane.body.cur_col = 3; pane.body.vsel = .{ .active = true, .explicit = true, .row = 0, .col = 0 }; pane.body.msel = .{ .active = true, .r0 = 0, .r1 = 1 }; + _ = pane.body.reserveSels(); pane.body.sels[0] = .{ .row = 1, .col = 2, .arow = 0, .acol = 1 }; pane.body.nsel = 1; pane.body.select = true; |
