diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-27 23:06:11 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 95a3a1bb822e583988bfe0f312d0ef219b27a454 (patch) | |
| tree | 95306e8d3f36f8af05b3ea919f6cdef1e94a028b /src/Text.zig | |
| parent | c8f0c25a854b8e169db08a84d1fdcd915a70aaa0 (diff) | |
| download | pardes-95a3a1bb822e583988bfe0f312d0ef219b27a454.tar.gz pardes-95a3a1bb822e583988bfe0f312d0ef219b27a454.zip | |
Give a pane's body its own Text holding the cursor, selections, mode and undo
acme keeps what edits a text in its Text (dat.h:171-190) and the window
holds a body and a tag of that type. The cursor, the selections, the modal
state and the edit-buffer undo move off Pane into Text.zig, Pane holds
them as its body, and the edit and normal-mode operations take the Text
they edit. Nothing changes in behaviour; this is the step that lets the
tag become a second Text.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/Text.zig')
| -rw-r--r-- | src/Text.zig | 606 |
1 files changed, 606 insertions, 0 deletions
diff --git a/src/Text.zig b/src/Text.zig new file mode 100644 index 00000000..ad5aa186 --- /dev/null +++ b/src/Text.zig @@ -0,0 +1,606 @@ +//! One editable text, acme's Text (editors/acme/dat.h:171-190, text.c): the +//! cursor, the selections and the modal state that edit it, and the undo of +//! an edit buffer. A pane's body is one. Where its characters come from -- a +//! file's content, a terminal's edit overlay, a PDF's text -- is the window's +//! business (edit.zig's editText and flatSurface), the way acme's Text reads +//! its File; everything here works on the flat text it is handed. +const std = @import("std"); +const panes = @import("panes.zig"); +const modal = @import("modal.zig"); +const config = @import("config.zig"); +const Pane = panes.Pane; +const File = panes.File; +const Terminal = panes.Terminal; +const Text = @This(); + +pub const Mode = enum { normal, insert, tty }; + +/// A modal line selection (helix `x`): whole rows [r0, r1], absolute. +pub const LineSel = struct { + active: bool = false, + r0: i32 = 0, + r1: i32 = 0, +}; + +pub const CharSel = struct { + active: bool = false, + row: i32 = 0, + col: i32 = 0, + explicit: bool = false, +}; + +pub const max_selections = 64; + +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 Text.sticky_col + sticky: i32 = -1, +}; + +mode: Mode = .normal, +cur_pinned: bool = false, +cur_row: i32 = 0, +cur_col: i32 = 0, +msel: LineSel = .{}, +vsel: CharSel = .{}, +sels: [max_selections - 1]SelRange = undefined, +nsel: u8 = 0, +select: bool = false, +/// sticky goal column for j/k runs (helix old_visual_position): any +/// non-vertical range write resets it to -1. +sticky_col: i32 = -1, +append_at: ?struct { row: i32, col: i32 } = null, +normal: modal.Normal.State = .{}, +/// last f/F/t/T motion, for Alt-. repeat +find_op: u8 = 0, +find_ch: u21 = 0, +ed_undo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_undo_len: usize = 0, +ed_redo: [Terminal.history_max]Terminal.Snapshot = undefined, +ed_redo_len: usize = 0, + +/// The window this text belongs to (acme's t->w). +pub fn pane(t: *Text) *Pane { + return @alignCast(@fieldParentPtr("body", t)); +} + +pub fn toModalCursor(t: *Text) modal.Cursor { + return .{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) }; +} + +pub fn fromModalCursor(t: *Text, c: modal.Cursor) void { + t.cur_row = @as(i32, @intCast(c.row)); + t.cur_col = @intCast(c.col); + t.cur_pinned = true; +} + +pub fn insertVerticalCursor(lines: []const []const u8, c: modal.Cursor, down: bool) modal.Cursor { + if (lines.len == 0) return c; + const row = if (down) @min(c.row + 1, lines.len - 1) else c.row -| 1; + const target = lines[row]; + if (target.len == 0) return .{ .row = row, .col = 0 }; + const source = if (c.row < lines.len) lines[c.row] else ""; + const goal = File.rawDisplayCol(source, c.col); + const mapped = File.rawAtDisplay(target, goal); + const last = modal.prevGrapheme(target, target.len); + return .{ .row = row, .col = modal.graphemeStart(target, @min(mapped, last)) }; +} + +pub fn primaryRange(t: *Text, text: []const u8, row0: i32) modal.Selection { + const c = File.textOffset(t, text, .{ .row = @intCast(@max(0, t.cur_row - row0)), .col = @intCast(@max(0, t.cur_col)) }); + if (t.msel.active) { + // legacy line selection (file-search results highlight): linewise + const r0: usize = @intCast(@max(0, @min(t.msel.r0, t.msel.r1) - row0)); + const r1: usize = @intCast(@max(0, @max(t.msel.r0, t.msel.r1) - row0)); + const s = modal.lineStartOffset(text, r0); + const e = if (r1 + 1 >= modal.cursorLineCount(text)) text.len else modal.lineStartOffset(text, r1 + 1); + return .{ .anchor = s, .head = @max(e, modal.nextGrapheme(text, c)) }; + } + if (t.vsel.active) return cellRange(text, t.vsel.row - row0, t.vsel.col, t.cur_row - row0, t.cur_col); + return .{ .anchor = c, .head = modal.nextGrapheme(text, c) }; +} + +pub fn cellRange(text: []const u8, arow: i32, acol: i32, hrow: i32, hcol: i32) modal.Selection { + const a = modal.offsetAt(text, .{ .row = @intCast(@max(0, arow)), .col = @intCast(@max(0, acol)) }); + const c = modal.offsetAt(text, .{ .row = @intCast(@max(0, hrow)), .col = @intCast(@max(0, hcol)) }); + return cellOffRange(text, a, c); +} + +/// the same, from the two cells' gap offsets +pub fn cellOffRange(text: []const u8, a: usize, c: usize) modal.Selection { + if (a <= c) return .{ .anchor = a, .head = modal.nextGrapheme(text, c) }; + return .{ .anchor = modal.nextGrapheme(text, a), .head = c }; +} + +pub fn rangeCells(text: []const u8, r: modal.Selection) 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 }; +} + +pub fn setRange(t: *Text, text: []const u8, row0: i32, r0: modal.Selection, explicit: bool) void { + var r = r0; + if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1 + const off = rangeCells(text, r); + const cc = File.textPosition(t, text, off.cur); + // a bare block cursor has both cells on the same offset — the common + // case by far — and this conversion is not free even indexed + const ac = if (off.anc == off.cur) cc else File.textPosition(t, text, off.anc); + t.cur_row = @as(i32, @intCast(cc.row)) + row0; + t.cur_col = @intCast(cc.col); + t.vsel = .{ + .active = off.anc != off.cur or t.select, + .row = @as(i32, @intCast(ac.row)) + row0, + .col = @intCast(ac.col), + .explicit = explicit or t.select, + }; + t.msel.active = false; + t.nsel = 0; // writing ONE range means the selection IS that range + t.cur_pinned = true; + t.sticky_col = -1; + t.normal.clear(); + t.pane().ensureCursorVisible(); +} + +pub fn ranges(t: *Text, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } { + const pr = primaryRange(t, text, row0); + var n: usize = 0; + var pri: usize = 0; + var placed = false; + for (t.sels[0..t.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 }; +} + +pub fn setRanges(t: *Text, text: []const u8, in: []const modal.Selection, 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_selections]modal.Selection = undefined; + var st: [max_selections]i32 = undefined; + var n: usize = @min(in.len, max_selections); + 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.Selection, &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; + } + } + 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; + setRange(t, text, 0, r[pri], explicit); + t.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.positionAt(text, c.cur); + const ac = modal.positionAt(text, c.anc); + t.sels[w] = .{ + .row = @as(i32, @intCast(cc.row)), + .col = @intCast(cc.col), + .arow = @as(i32, @intCast(ac.row)), + .acol = @intCast(ac.col), + .sticky = st[idx], + }; + w += 1; + } + t.nsel = @intCast(w); +} + +pub fn multiSelAction(t: *Text, text: []const u8, kind: modal.Normal.Multi, cnt: usize) void { + var rs: [max_selections]modal.Selection = undefined; + const got = ranges(t, text, 0, &rs); + const n = got.n; + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + if (kind == .remove_primary) { + if (n < 2) return; // helix: "no selections remaining" + var out: [max_selections]modal.Selection = 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 setRanges(t, text, out[0..m], &.{}, @min(got.pri, m - 1), expl); + } + if (kind == .rotate_forward or kind == .rotate_backward) { + const step = cnt % n; + const pri = if (kind == .rotate_forward) (got.pri + step) % n else (got.pri + (n - step)) % n; + return setRanges(t, text, rs[0..n], &.{}, pri, expl); + } + if (kind == .merge) { + // 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.Selection = if (rev) .{ .anchor = hi, .head = lo } else .{ .anchor = lo, .head = hi }; + return setRanges(t, text, &.{one}, &.{}, 0, expl); + } + if (kind == .merge_consecutive) { + var out: [max_selections]modal.Selection = 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 setRanges(t, text, out[0..m], &.{}, pri, expl); + } + if (kind == .split_newline) { + // helix selection::split_on_newline — one range per line the + // selection covers, the newlines themselves left out + var out: [max_selections]modal.Selection = 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_selections) { + out[m] = r; + m += 1; + } + continue; + } + var start = from; + while (start < to and m < max_selections) { + const eol = modal.lineEndOffset(text, modal.lineAtOffset(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 setRanges(t, text, out[0..m], &.{}, 0, true); // helix keeps primary 0 + } + if (kind == .trim) { + // helix trim_selections: whitespace off both ends; ranges that are + // empty or all whitespace are dropped entirely + var out: [max_selections]modal.Selection = 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.selectionCursor(text, rs[got.pri]); + return setRange(t, text, 0, .{ .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 to = @max(r.anchor, r.head); + if (f == pf or (to > pf and pt > f)) { + pri = i; + break; + } + } + return setRanges(t, text, out[0..m], &.{}, pri, expl); + } + const below = kind == .copy_below; + var out: [max_selections]modal.Selection = undefined; + var m: usize = 0; + var pri: usize = 0; + const nlines = modal.cursorLineCount(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.positionAt(text, if (r.anchor < r.head) modal.prevGrapheme(text, r.head) else r.head); + const ap = modal.positionAt(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_selections) 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_selections) : (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.offsetAt(text, .{ .row = arow, .col = ap.col }); + const h2 = modal.offsetAt(text, .{ .row = hrow, .col = hp.col }); + // a line too short to reach the column is skipped, not clamped + if (modal.positionAt(text, a2).col == ap.col and modal.positionAt(text, h2).col == hp.col) { + if (is_pri) pri = m; + out[m] = modal.moveSelectionCursor(text, .{ .anchor = a2, .head = a2 }, h2, true); + m += 1; + made += 1; + } + if (arow == 0 and hrow == 0) break; + } + } + setRanges(t, text, out[0..m], &.{}, pri, expl); +} + +/// a range's start CELL (document order key) — the smaller of its two ends +pub 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 }; +} + +pub fn maxLine(text: []const u8) usize { + const nl = modal.cursorLineCount(text); + return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1; +} + +/// point-target motion: collapse there (extend in select mode) +pub fn pointMove(t: *Text, text: []const u8, range: modal.Selection, target: usize) void { + setRange(t, text, 0, modal.moveSelectionCursor(text, range, target, t.select), false); +} + +/// word motions select their traversed span (extend mode: head only) +pub fn wordMove(t: *Text, text: []const u8, range: modal.Selection, cnt: usize, target: modal.WordTarget) void { + const wr = modal.moveWord(text, range, cnt, target); + const res = if (t.select) modal.moveSelectionCursor(text, range, modal.selectionCursor(text, wr), true) else wr; + setRange(t, text, 0, res, false); +} + +/// f/t/F/T: anchor at the old cursor cell, head on the hit (not found: no move) +pub fn findMove(t: *Text, text: []const u8, range: modal.Selection, ch: u21, fwd: bool, till: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const target = modal.findTarget(text, cur, ch, fwd, till, cnt) orelse return; + const res = if (t.select) + modal.moveSelectionCursor(text, range, target, true) + else + modal.moveSelectionCursor(text, .{ .anchor = cur, .head = cur }, target, true); + setRange(t, text, 0, res, false); +} + +/// j/k and friends: sticky goal column, clamped onto short lines' newline +pub fn verticalMove(t: *Text, text: []const u8, range: modal.Selection, down: bool, cnt: usize) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(t, text, cur); + const goal: usize = if (t.sticky_col >= 0) + @intCast(t.sticky_col) + else + File.rawDisplayCol(File.textLine(t, text, pos.row), pos.col); + const last_row = File.textLineCount(t, text) - 1; + const nline = if (down) @min(pos.row + @max(1, cnt), last_row) else pos.row -| @max(1, cnt); + const target_col = File.rawAtDisplay(File.textLine(t, text, nline), goal); + const target = File.textOffset(t, text, .{ .row = nline, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (t.select and target == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(t, text, 0, modal.moveSelectionCursor(text, range, target, t.select), false); + t.sticky_col = @intCast(goal); +} + +pub fn visualMove( + t: *Text, + text: []const u8, + range: modal.Selection, + down: bool, + cnt: usize, + width: usize, +) void { + const cur = modal.selectionCursor(text, range); + const pos = File.textPosition(t, text, cur); + const last_row = File.textLineCount(t, text) - 1; + var row = pos.row; + var line = modal.lineSlice(text, row); + var vrow = File.visualRow(line, pos.col, width); + const goal: usize = if (t.sticky_col >= 0) + @intCast(t.sticky_col) + else + File.rawDisplayCol(line[vrow.start..vrow.end], pos.col -| vrow.start); + var steps = @max(1, cnt); + while (steps > 0) : (steps -= 1) { + if (down) { + if (vrow.end < line.len) { + vrow = File.visualRow(line, vrow.end, width); + continue; + } + if (row == last_row) break; + row += 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, 0, width); + } else { + if (vrow.start > 0) { + vrow = File.visualRow(line, vrow.start - 1, width); + continue; + } + if (row == 0) break; + row -= 1; + line = modal.lineSlice(text, row); + vrow = File.visualRow(line, line.len, width); + } + } + // The newline slot is a real cursor position, but the first byte of the + // NEXT visual row is not: landing there would read as two rows moved. + var target_col = vrow.start + File.rawAtDisplay(line[vrow.start..vrow.end], goal); + if (vrow.end < line.len and target_col >= vrow.end) + target_col = modal.graphemeStart(line, vrow.end - 1); + const target = File.textOffset(t, text, .{ .row = row, .col = target_col }); + // extend mode never walks onto the empty trailing line (helix) + if (t.select and target == text.len and text.len > 0 and text[text.len - 1] == '\n') return; + setRange(t, text, 0, modal.moveSelectionCursor(text, range, target, t.select), false); + t.sticky_col = @intCast(goal); +} + +/// Ctrl-d/u: scroll half a page AND move the cursor by the same rows +pub fn halfPageMove(t: *Text, text: []const u8, range: modal.Selection, down: bool) void { + const half: i32 = @max(1, @divTrunc(@as(i32, t.pane().rows), 2)); + t.pane().scrollBy(if (down) half else -half); + verticalMove(t, text, range, down, @intCast(half)); +} + +/// helix `scroll` without cursor sync (Ctrl-f/b, PgUp/PgDn, zj/zk): shift +/// the view, then snap a fallen-out cursor to the near scrolloff edge, col 0 +pub fn scrollViewMove(t: *Text, text: []const u8, range: modal.Selection, delta: i32) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, t.pane().rows) - 1, 2)); + t.pane().scrollBy(delta); + const top = t.pane().scroll(); + const last_row: i32 = @intCast(File.textLineCount(t, text) - 1); + const cur = modal.selectionCursor(text, range); + if (delta > 0) { + const snap: i32 = @max(0, @min(top + margin, last_row)); + const head = File.textLineStart(t, text, @intCast(snap)); + if (head <= cur) return; + const anchor = if (t.select) range.anchor else head; + setRange(t, text, 0, .{ .anchor = anchor, .head = head }, false); + } else { + const snap: i32 = @max(0, @min(top + @as(i32, t.pane().rows) - margin - 1, last_row)); + const head = File.textLineStart(t, text, @intCast(snap)); + if (head >= cur) return; + const anchor = if (t.select) range.anchor else head; + setRange(t, text, 0, .{ .anchor = anchor, .head = head }, false); + } +} + +/// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) +pub fn gotoWindow(t: *Text, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void { + const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, t.pane().rows) - 1, 2)); + const top = t.pane().scroll(); + const last_row: i32 = @intCast(File.textLineCount(t, text) - 1); + const last_vis: i32 = @min(@as(i32, t.pane().rows) - 1, last_row - top); + const n: i32 = @intCast(cnt - 1); + var vline: i32 = switch (which) { + .top => top + margin + n, + .center => top + @divTrunc(last_vis, 2), + .bottom => top + last_vis - (margin + n), + }; + vline = @max(vline, top + margin); + vline = @min(vline, top + last_vis - margin); + const row: i32 = std.math.clamp(vline, 0, last_row); + pointMove(t, text, range, File.textLineStart(t, text, @intCast(row))); +} + +/// helix Range::line_range — the inclusive line span a range covers +pub fn rangeLineSpan(text: []const u8, r: modal.Selection) struct { start: usize, end: usize } { + const from = @min(r.anchor, r.head); + const to = @max(r.anchor, r.head); + const to_adj = if (from == to) to else @max(modal.prevGrapheme(text, to), from); + return .{ .start = modal.lineAtOffset(text, from), .end = modal.lineAtOffset(text, to_adj) }; +} + +fn lineStartOrEof(text: []const u8, line: usize) usize { + if (line >= modal.cursorLineCount(text)) return text.len; + return modal.lineStartOffset(text, line); +} + +/// helix `x` extend_line_below: full lines incl. the newline, cursor ON +/// the last one's '\n'; an already-line-bounded selection grows downward +pub fn lineSelect(t: *Text, text: []const u8, range: modal.Selection, cnt: usize) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const full = @min(range.anchor, range.head) == start and @max(range.anchor, range.head) == end; + const head = lineStartOrEof(text, span.end + cnt + @intFromBool(full)); + setRange(t, text, 0, .{ .anchor = start, .head = head }, true); +} + +/// helix `X` extend_to_line_bounds (direction kept) +pub fn lineBoundsSelect(t: *Text, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + const start = modal.lineStartOffset(text, span.start); + const end = lineStartOrEof(text, span.end + 1); + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(t, text, 0, r, true); +} + +/// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) +pub fn shrinkSelToLineBounds(t: *Text, text: []const u8, range: modal.Selection) void { + const span = rangeLineSpan(text, range); + if (span.start == span.end) return; + const from = @min(range.anchor, range.head); + const to = @max(range.anchor, range.head); + var start = modal.lineStartOffset(text, span.start); + var end = lineStartOrEof(text, span.end + 1); + if (start != from) start = lineStartOrEof(text, span.start + 1); + if (end != to) end = modal.lineStartOffset(text, span.end); + const expl = (t.vsel.active and t.vsel.explicit) or t.msel.active; + const r: modal.Selection = if (range.head < range.anchor) + .{ .anchor = end, .head = start } + else + .{ .anchor = start, .head = end }; + setRange(t, text, 0, r, expl); +} + +/// pull the cursor back inside `text` after a rewrite; `row0` is the +/// absolute surface row of its first line (0 for a file) +pub fn clampCursor(t: *Text, text: []const u8, row0: i32) void { + const n = modal.lineCount(text); + const row: usize = @min(@as(usize, @intCast(@max(0, t.cur_row - row0))), if (n == 0) 0 else n - 1); + const llen = modal.lineSlice(text, row).len; + t.cur_row = @as(i32, @intCast(row)) + row0; + t.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, t.cur_col))), llen)); + t.cur_pinned = true; + t.vsel.active = false; + t.msel.active = false; + t.pane().ensureCursorVisible(); +} |
