diff options
Diffstat (limited to 'src/panes.zig')
| -rw-r--r-- | src/panes.zig | 623 |
1 files changed, 21 insertions, 602 deletions
diff --git a/src/panes.zig b/src/panes.zig index 9bf2d92f..e6634583 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -24,8 +24,6 @@ const Key = pardes.Key; pub const EditText = struct { text: []u8, row0: i32 }; pub const Pane = struct { - pub const Mode = enum { normal, insert, tty }; - /// One mouse selection (block-shaped), per button. c/r are text-area relative; /// r counts from the tag row (body starts at BOX_H). pub const Sel = struct { @@ -53,39 +51,12 @@ pub const Pane = struct { rows: []PointerRow, raw_text: ?[]u8 = null, }; - - /// 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 LookSpot = struct { row: i32, col0: i32, col1: i32, }; - 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 Pane.sticky_col - sticky: i32 = -1, - }; - const PdfSlot = if (Pdf.enabled) ?Pdf.State else void; pub const Prompt = union(enum) { @@ -108,7 +79,9 @@ pub const Pane = struct { /// dies with it, as acme keeps the same on its Window /// (editors/acme/dat.h:226-266). fs: pardes.ctlfs.pane.State = .{}, - mode: Mode = .normal, + /// The body: what the pane shows and edits, its cursor, selections, + /// mode and edit-buffer undo. + body: Text = .{}, vweight: f32 = 1, /// A folded pane keeps its expansion weight, but occupies only its tag row. collapsed: bool = false, @@ -121,26 +94,13 @@ pub const Pane = struct { file: ?File.State = null, image: ?Image.State = null, pdf: PdfSlot = if (Pdf.enabled) null else {}, - 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, /// Tag-tail input state. The tag text is presentation; this tag carries /// which operation owns it and the tail offset restored on submit/cancel. prompt: Prompt = .none, search_pane: ?usize = null, search_row: ?usize = null, look_at: ?LookSpot = null, - sel_snap: [max_selections]modal.Selection = undefined, + sel_snap: [Text.max_selections]modal.Selection = undefined, nsel_snap: u8 = 0, sel_snap_pri: u8 = 0, sel_snap_expl: bool = false, @@ -153,7 +113,7 @@ pub const Pane = struct { tag_sel: bool = false, /// the body mode a tag edit hijacked (tags are always insert); terminals /// restore it on exit so clicking the tag never changes the pane's mode - tag_mode: Mode = .normal, + tag_mode: Text.Mode = .normal, tag_col: u16 = 0, tag_saved_col: ?u16 = null, tag_anchor: u16 = 0, @@ -163,14 +123,7 @@ pub const Pane = struct { tag_name_len: ?u16 = null, /// Display columns hidden to reveal the tag caret; tag addresses stay whole. tag_scroll: u16 = 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, cwd: Cwd = .none, - cur_pinned: bool = false, - cur_row: i32 = 0, - cur_col: i32 = 0, hscroll: i32 = 0, // Visible rows map to source lines and raw/display column origins. wrap_line: [limits.wrap_rows]i32 = undefined, @@ -512,7 +465,7 @@ pub const Pane = struct { // already, so only treat an ancestor as pinned in the matching view. if (pane.context_rows < pane.wrap_n and pane.wrap_line[pane.context_rows] == off) { for (pane.wrap_line[0..pane.context_rows]) |row| { - if (row == pane.cur_row) { + if (row == pane.body.cur_row) { in_context = true; break; } @@ -528,10 +481,10 @@ pub const Pane = struct { margin = @min(margin, @divTrunc(@max(0, lines_shown), 2)); } if (!in_context) { - if (pane.cur_row < off + margin) { - pane.scrollBy(pane.cur_row - margin - off); - } else if (pane.cur_row > last - margin) { - var to = pane.cur_row + margin; + if (pane.body.cur_row < off + margin) { + pane.scrollBy(pane.body.cur_row - margin - off); + } else if (pane.body.cur_row > last - margin) { + var to = pane.body.cur_row + margin; if (pane.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1))); if (comptime Pdf.enabled) { if (pane.pdf) |pv| @@ -544,8 +497,8 @@ pub const Pane = struct { if (pane.body_wrapped) return; const w: i32 = @max(1, @as(i32, pane.cols) - @as(i32, File.gutterWidth(pane))); const hmargin: i32 = @min(config.scroll_off, @divTrunc(w - 1, 2)); - const line = modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))); - const raw_cur: usize = @intCast(@max(0, pane.cur_col)); + const line = modal.lineSlice(f.content, @intCast(@max(0, pane.body.cur_row))); + const raw_cur: usize = @intCast(@max(0, pane.body.cur_col)); const raw_scroll: usize = @intCast(@max(0, pane.hscroll)); const cur = @as(i32, @intCast(File.rawDisplayCol(line, raw_cur))); const visual_scroll = @as(i32, @intCast(File.rawDisplayCol(line, raw_scroll))); @@ -559,16 +512,16 @@ pub const Pane = struct { } pub fn pinCursor(pane: *Pane) void { - if (pane.cur_pinned) return; + if (pane.body.cur_pinned) return; if (pane.file != null or hasPdf(pane)) { - pane.cur_row = pane.scroll(); - pane.cur_col = 0; + pane.body.cur_row = pane.scroll(); + pane.body.cur_col = 0; } else { const cur = Terminal.gridCursor(pane); - pane.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); - pane.cur_col = @intCast(cur.x); + pane.body.cur_row = pane.surfRow(@as(i32, cur.y) + Terminal.gridOffset(pane)); + pane.body.cur_col = @intCast(cur.x); } - pane.cur_pinned = true; + pane.body.cur_pinned = true; } pub fn hasPdf(pane: *const Pane) bool { @@ -581,545 +534,10 @@ pub const Pane = struct { else false; } - - pub fn toModalCursor(pane: *Pane) modal.Cursor { - return .{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) }; - } - - pub fn fromModalCursor(pane: *Pane, c: modal.Cursor) void { - pane.cur_row = @as(i32, @intCast(c.row)); - pane.cur_col = @intCast(c.col); - pane.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(pane: *Pane, text: []const u8, row0: i32) modal.Selection { - const c = File.textOffset(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) }); - if (pane.msel.active) { - // legacy line selection (file-search results highlight): linewise - const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0)); - const r1: usize = @intCast(@max(0, @max(pane.msel.r0, pane.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 (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) }; - } - - 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(pane: *Pane, 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(pane, 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(pane, text, off.anc); - pane.cur_row = @as(i32, @intCast(cc.row)) + row0; - pane.cur_col = @intCast(cc.col); - pane.vsel = .{ - .active = off.anc != off.cur or pane.select, - .row = @as(i32, @intCast(ac.row)) + 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.normal.clear(); - pane.ensureCursorVisible(); - } - - pub fn ranges(pane: *Pane, text: []const u8, row0: i32, out: *[max_selections]modal.Selection) struct { n: usize, pri: usize } { - const pr = primaryRange(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 }; - } - - pub fn setRanges(pane: *Pane, 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(pane, text, 0, 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.positionAt(text, c.cur); - const ac = modal.positionAt(text, c.anc); - pane.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; - } - pane.nsel = @intCast(w); - } - - pub fn multiSelAction(pane: *Pane, text: []const u8, kind: modal.Normal.Multi, cnt: usize) void { - var rs: [max_selections]modal.Selection = undefined; - const got = ranges(pane, text, 0, &rs); - const n = got.n; - const expl = (pane.vsel.active and pane.vsel.explicit) or pane.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(pane, 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(pane, 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(pane, 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(pane, 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(pane, 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(pane, 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 t = @max(r.anchor, r.head); - if (f == pf or (t > pf and pt > f)) { - pri = i; - break; - } - } - return setRanges(pane, 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(pane, 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(pane: *Pane, text: []const u8, range: modal.Selection, target: usize) void { - setRange(pane, text, 0, modal.moveSelectionCursor(text, range, target, pane.select), false); - } - - /// word motions select their traversed span (extend mode: head only) - pub fn wordMove(pane: *Pane, text: []const u8, range: modal.Selection, cnt: usize, target: modal.WordTarget) void { - const wr = modal.moveWord(text, range, cnt, target); - const res = if (pane.select) modal.moveSelectionCursor(text, range, modal.selectionCursor(text, wr), true) else wr; - setRange(pane, 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(pane: *Pane, text: []const u8, range: modal.Selection, ch: u21, fwd: bool, till: bool, cnt: usize) void { - const cur = modal.selectionCursor(text, range); - const t = modal.findTarget(text, cur, ch, fwd, till, cnt) orelse return; - const res = if (pane.select) - modal.moveSelectionCursor(text, range, t, true) - else - modal.moveSelectionCursor(text, .{ .anchor = cur, .head = cur }, t, true); - setRange(pane, text, 0, res, false); - } - - /// j/k and friends: sticky goal column, clamped onto short lines' newline - pub fn verticalMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool, cnt: usize) void { - const cur = modal.selectionCursor(text, range); - const pos = File.textPosition(pane, text, cur); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.sticky_col) - else - File.rawDisplayCol(File.textLine(pane, text, pos.row), pos.col); - const last_row = File.textLineCount(pane, 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(pane, text, nline), goal); - const t = File.textOffset(pane, text, .{ .row = nline, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - pub fn visualMove( - pane: *Pane, - text: []const u8, - range: modal.Selection, - down: bool, - cnt: usize, - width: usize, - ) void { - const cur = modal.selectionCursor(text, range); - const pos = File.textPosition(pane, text, cur); - const last_row = File.textLineCount(pane, text) - 1; - var row = pos.row; - var line = modal.lineSlice(text, row); - var vrow = File.visualRow(line, pos.col, width); - const goal: usize = if (pane.sticky_col >= 0) - @intCast(pane.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 t = File.textOffset(pane, text, .{ .row = row, .col = target_col }); - // extend mode never walks onto the empty trailing line (helix) - if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return; - setRange(pane, text, 0, modal.moveSelectionCursor(text, range, t, pane.select), false); - pane.sticky_col = @intCast(goal); - } - - /// Ctrl-d/u: scroll half a page AND move the cursor by the same rows - pub fn halfPageMove(pane: *Pane, text: []const u8, range: modal.Selection, down: bool) void { - const half: i32 = @max(1, @divTrunc(@as(i32, pane.rows), 2)); - pane.scrollBy(if (down) half else -half); - verticalMove(pane, 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(pane: *Pane, text: []const u8, range: modal.Selection, delta: i32) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - pane.scrollBy(delta); - const top = pane.scroll(); - const last_row: i32 = @intCast(File.textLineCount(pane, 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(pane, text, @intCast(snap)); - if (head <= cur) return; - const anchor = if (pane.select) range.anchor else head; - setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); - } else { - const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row)); - const head = File.textLineStart(pane, text, @intCast(snap)); - if (head >= cur) return; - const anchor = if (pane.select) range.anchor else head; - setRange(pane, text, 0, .{ .anchor = anchor, .head = head }, false); - } - } - - /// gt/gc/gb: view-relative rows, col 0, scrolloff clamped (helix goto_window) - pub fn gotoWindow(pane: *Pane, text: []const u8, range: modal.Selection, which: enum { top, center, bottom }, cnt: usize) void { - const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); - const top = pane.scroll(); - const last_row: i32 = @intCast(File.textLineCount(pane, text) - 1); - const last_vis: i32 = @min(@as(i32, 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(pane, text, range, File.textLineStart(pane, 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(pane: *Pane, 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(pane, text, 0, .{ .anchor = start, .head = head }, true); - } - - /// helix `X` extend_to_line_bounds (direction kept) - pub fn lineBoundsSelect(pane: *Pane, 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(pane, text, 0, r, true); - } - - /// helix `Alt-x` shrink_to_line_bounds (single-line selections untouched) - pub fn shrinkSelToLineBounds(pane: *Pane, 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 = (pane.vsel.active and pane.vsel.explicit) or pane.msel.active; - const r: modal.Selection = if (range.head < range.anchor) - .{ .anchor = end, .head = start } - else - .{ .anchor = start, .head = end }; - setRange(pane, 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(pane: *Pane, text: []const u8, row0: i32) void { - const n = modal.lineCount(text); - const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row - row0))), if (n == 0) 0 else n - 1); - const llen = modal.lineSlice(text, row).len; - pane.cur_row = @as(i32, @intCast(row)) + row0; - pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen)); - pane.cur_pinned = true; - pane.vsel.active = false; - pane.msel.active = false; - pane.ensureCursorVisible(); - } }; +pub const Text = @import("Text.zig"); + pub const File = @import("File.zig"); pub const Output = @import("Output.zig"); @@ -1134,6 +552,7 @@ pub const Terminal = @import("Terminal.zig"); test { _ = Pane; + _ = Text; _ = File; _ = Output; _ = Mini; |
