const std = @import("std"); const vaxis = @import("vaxis"); const pardes = @import("pardes.zig"); const layout = @import("layout.zig"); const config = @import("config.zig"); const Pardes = pardes.Pardes; const modal = @import("modal.zig"); const filesystem = @import("fs.zig"); const syntax = @import("syntax.zig"); const tracy = @import("tracy.zig"); const dump = @import("dump.zig"); const limits = @import("memory.zig").limits; const builtins = @import("builtins.zig"); const effect_sources = @import("effect_sources.zig"); const build_options = @import("pardes_config"); const lsp = @import("lsp/lsp.zig"); const image = @import("image.zig"); const look = @import("look.zig"); const locations = @import("locations.zig"); const Key = pardes.Key; /// An owned editable buffer and the absolute surface row of its first line. /// Files use row zero; terminal overlays may begin anywhere in scrollback. 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 { state: enum { none, dragging, done } = .none, source_id: u64 = 0, c0: i32 = 0, c1: i32 = 0, r0: i32 = 0, r1: i32 = 0, }; /// A rectangle keeps one source span per rendered row. Wrapped segments /// remain separate rows when copied, even when they share a source line. pub const PointerRow = struct { row: i32, lo: usize, hi: usize, prefix: [32]u8 = @splat(0), prefix_len: u8 = 0, raw_terminal: bool = false, prompt_bytes: usize = 0, }; pub const PointerSelection = struct { gesture: Sel, 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) { none, search: u16, pipe: struct { at: u16, how: modal.Normal.PipeBehavior }, /// Save on a scratch buffer or a terminal: the tail is a path to write to. save: u16, /// Del from the keyboard with open panes both above and below: which /// one takes the rows. Nothing is typed; one key answers. del_side, }; pub const Cwd = union(enum) { none, inherited: *Pane, owned: []u8 }; terminal: ?*Terminal.State = null, gpa: std.mem.Allocator, serial: u32 = 0, /// What the control filesystem keeps of this pane -- addr and limit, the /// event queue and its readers, a run waiting on the shell -- lives and /// 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, vweight: f32 = 1, /// A folded pane keeps its expansion weight, but occupies only its tag row. collapsed: bool = false, cols: u16, rows: u16, greet: bool = false, pending_command: Terminal.PendingCommand = .{}, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, 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, nsel_snap: u8 = 0, sel_snap_pri: u8 = 0, sel_snap_expl: bool = false, /// the editable tag tail: a bounded one-line command buffer. Input that /// does not fit is refused atomically. tag_tail: [limits.max_tag_tail]u8 = undefined, tag_tail_len: usize = 0, tag_init: bool = false, tag_edit: bool = false, 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_col: u16 = 0, tag_saved_col: ?u16 = null, tag_anchor: u16 = 0, /// Filename edits are staged separately from the live buffer identity. /// Enter commits the name; leaving tag editing discards the draft. tag_name: [limits.host_path_cap]u8 = undefined, 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, wrap_col: [limits.wrap_rows]i32 = undefined, wrap_n: u16 = 0, /// A row map also exists for sticky headers when soft wrapping is off. body_wrapped: bool = false, context_rows: u16 = 0, body_rows: u16 = 0, body_visible_rows: u16 = 0, context_row_limit: ?u16 = null, sel: [3]Sel = @splat(.{}), pointer_selections: [3]?PointerSelection = @splat(null), next_pointer_selection: u64 = 0, /// terminals only: the typed-text buffer standing in for shell rows ovl: ?Terminal.EditBuffer = null, tty_filter: bool = false, msg: [256]u8 = undefined, msg_len: u16 = 0, /// Where the newest message line is in its life. msg_life: MessageLife = .{}, /// The row (among the notice rows) the newest line holds for its life. msg_slot: u8 = 0, /// Earlier message lines still on screen, oldest first, each running its /// own life: a new message stacks under them instead of replacing them. msg_older: [message_stack]OlderMessage = undefined, msg_older_len: u8 = 0, /// The newest line is a builtin announcing itself in this step: what /// the step says next replaces it rather than stacking under it. msg_announcement: bool = false, /// The newest line is a status (progress, a server's state): the next /// line replaces it rather than stacking under it. msg_status: bool = false, /// The transient lines this pane shows between its body and its tagline: /// a builtin's message, the pending leader chord, a prompt waiting for /// input. Rebuilt every frame from the state that owns each one, so a /// line that goes away is simply not added again and the rest close the /// gap -- there is no second copy of the truth to fall out of step. The /// body layer reserves exactly `len` rows for them, the way it already /// reserves rows for sticky context headers. notices: Notices = .{}, pub const MessagePhase = enum { entering, shown, lingering, leaving }; /// One message line's animation: falling in, shown, lingering after the /// key or click that dismissed it, dissolving. `frame` counts animation /// frames within the phase. pub const MessageLife = struct { phase: MessagePhase = .shown, frame: u16 = 0, /// Dismissed while still falling: it lingers once it has landed. dismissed: bool = false, }; /// Older message lines kept on screen under a new one. pub const message_stack = 3; pub const OlderMessage = struct { text: [256]u8 = undefined, len: u16 = 0, life: MessageLife = .{}, slot: u8 = 0, pub fn slice(line: *const OlderMessage) []const u8 { return line.text[0..line.len]; } }; /// Move the newest line onto the stack of older ones, dropping the oldest /// when it is full; the newest slot is left empty for what arrives. pub fn pushOlderMessage(pane: *Pane) void { if (pane.msg_older_len == message_stack) { std.mem.copyForwards(OlderMessage, pane.msg_older[0 .. message_stack - 1], pane.msg_older[1..]); pane.msg_older_len -= 1; } pane.msg_older[pane.msg_older_len] = .{ .text = pane.msg, .len = pane.msg_len, .life = pane.msg_life, .slot = pane.msg_slot }; pane.msg_older_len += 1; pane.msg_len = 0; pane.msg_life = .{}; } /// The first row no older line holds. A line keeps its row until it /// leaves, and a new one fills the first gap: nothing on screen moves. pub fn freeMessageSlot(pane: *const Pane) u8 { var used: u8 = 0; for (pane.msg_older[0..pane.msg_older_len]) |line| used |= @as(u8, 1) << @intCast(line.slot); var slot: u8 = 0; while (used & (@as(u8, 1) << @intCast(slot)) != 0) slot += 1; return slot; } pub fn removeOlderMessage(pane: *Pane, i: usize) void { std.mem.copyForwards(OlderMessage, pane.msg_older[i .. pane.msg_older_len - 1], pane.msg_older[i + 1 .. pane.msg_older_len]); pane.msg_older_len -= 1; } pub const Notices = struct { /// Painted bottom-up in this order, so a prompt keeps the row nearest /// the tagline that it has always had, and its cursor with it. /// `older` is an earlier message line still on screen; `index` says /// which (`Pane.msg_older`). pub const Kind = enum { older, message, leader, prompt }; pub const max = @typeInfo(Kind).@"enum".fields.len - 1 + message_stack; kinds: [max]Kind = undefined, index: [max]u8 = @splat(0), /// Each band's row below the body's top: message lines keep theirs, /// so a band that leaves leaves a gap rather than moving the rest. row: [max]u8 = @splat(0), /// Where each band's chip starts, as an absolute grid column. A chip /// is only as wide as its own text, so the rest of the row underneath /// is ordinary body text and a click there has to reach it. left: [max]u16 = @splat(std.math.maxInt(u16)), len: u8 = 0, /// A band on the next row after every band so far. pub fn push(n: *Notices, kind: Kind) void { n.pushAt(kind, 0, n.span()); } pub fn pushAt(n: *Notices, kind: Kind, index: u8, row: u8) void { if (n.len >= max) return; n.kinds[n.len] = kind; n.index[n.len] = index; n.row[n.len] = row; n.left[n.len] = std.math.maxInt(u16); n.len += 1; } /// Is this cell inside the chip drawn for band `i`? Nothing else on /// that row belongs to the notice. pub fn covers(n: *const Notices, row: usize, col: u16) bool { for (0..n.len) |i| if (n.row[i] == row) return col >= n.left[i]; return false; } /// Rows from the body's top down to the last band's. pub fn span(n: *const Notices) u8 { var rows: u8 = 0; for (n.row[0..n.len]) |row| rows = @max(rows, row + 1); return rows; } pub fn slice(n: *const Notices) []const Kind { return n.kinds[0..n.len]; } }; pub fn clearPointerSelection(pane: *Pane, slot: usize) void { if (pane.pointer_selections[slot]) |selection| { pane.gpa.free(selection.rows); if (selection.raw_text) |text| pane.gpa.free(text); } pane.pointer_selections[slot] = null; } pub fn rawPointerText(pane: *const Pane, gesture: Sel) ?[]const u8 { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.raw_text; } return null; } pub fn pointerSelection(pane: *const Pane, gesture: Sel) ?[]PointerRow { if (gesture.state != .done or gesture.source_id == 0) return null; for (pane.pointer_selections) |selection| { if (selection) |saved| if (std.meta.eql(saved.gesture, gesture)) return saved.rows; } return null; } pub fn tagSlice(p: *const Pane) []const u8 { return p.tag_tail[0..p.tag_tail_len]; } pub fn promptAt(p: *const Pane) ?u16 { return switch (p.prompt) { .none, .del_side => null, .search, .save => |at| at, .pipe => |pipe| pipe.at, }; } /// What an open prompt shows on its notice band: the one answer every /// painter of the band reads. pub fn promptText(p: *const Pane) ?[]const u8 { if (p.prompt == .del_side) return config.del_question; const at = p.promptAt() orelse return null; return p.tagSlice()[@min(at, p.tag_tail_len)..]; } pub fn appendTag(p: *Pane, text: []const u8) bool { if (text.len > p.tag_tail.len - p.tag_tail_len) return false; @memcpy(p.tag_tail[p.tag_tail_len..][0..text.len], text); p.tag_tail_len += text.len; return true; } pub fn insertTagByte(p: *Pane, at: usize, byte: u8) bool { if (at > p.tag_tail_len or p.tag_tail_len == p.tag_tail.len) return false; std.mem.copyBackwards(u8, p.tag_tail[at + 1 .. p.tag_tail_len + 1], p.tag_tail[at..p.tag_tail_len]); p.tag_tail[at] = byte; p.tag_tail_len += 1; return true; } pub fn removeTagByte(p: *Pane, at: usize) void { if (at >= p.tag_tail_len) return; std.mem.copyForwards(u8, p.tag_tail[at .. p.tag_tail_len - 1], p.tag_tail[at + 1 .. p.tag_tail_len]); p.tag_tail_len -= 1; } pub fn cwdSlice(p: *const Pane) []const u8 { return switch (p.cwd) { .none => "", .owned => |dir| dir, .inherited => |src| src.cwdSlice(), }; } pub fn clearCwd(pane: *Pane) void { if (pane.cwd == .owned) pane.gpa.free(pane.cwd.owned); pane.cwd = .none; } pub fn setOwnedCwd(pane: *Pane, dir: []const u8) !void { if (dir.len > limits.host_path_cap) return error.PathTooLong; const copy = try pane.gpa.dupe(u8, dir); pane.clearCwd(); pane.cwd = .{ .owned = copy }; } pub fn isTerminal(pane: *const Pane) bool { const no_pdf = if (comptime Pdf.enabled) pane.pdf == null else true; return pane.file == null and pane.image == null and no_pdf; } /// The one coloring choice keyed on what a pane IS, so the highlight /// producer (refreshHighlights) and the render pass agree on the algorithm. pub const ColorAlgo = enum { none, tty, source, diff, locations }; pub fn colorAlgo(pane: *const Pane) ColorAlgo { if (pane.isTerminal()) return .tty; if (pane.file) |f| { if (f.mini != null) return .source; if (std.mem.endsWith(u8, f.path, ".diff") or std.mem.endsWith(u8, f.path, ".patch")) return .diff; if (f.output != null) { const tr = Output.fileTraits(f.output); if (tr.locations) return .locations; if (!tr.saves) return .none; } return .source; } return .none; } pub fn pdfPath(pane: *const Pane) ?[]const u8 { if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.path; return null; } pub fn pdfPage(pane: *const Pane) ?usize { if (comptime Pdf.enabled) if (pane.pdf) |pv| return pv.page; return null; } pub fn surfRow(pane: *const Pane, g: i32) i32 { const o = pane.ovl orelse return g; if (g <= o.row) return g; const lines: i32 = @intCast(modal.lineCount(o.text)); if (g >= o.row + o.rows) return g + lines - o.rows; return @min(g, o.row + lines - 1); // inside the buffer: its own rows } /// the inverse; every surface row inside the edit buffer maps to its anchor pub fn gridRow(pane: *const Pane, s: i32) i32 { const o = pane.ovl orelse return s; if (s <= o.row) return s; const lines: i32 = @intCast(modal.lineCount(o.text)); if (s < o.row + lines) return o.row; return s - lines + o.rows; } /// current scroll offset: file top line, or the scrollback offset pub fn scroll(pane: *Pane) i32 { if (pane.file) |f| return @intCast(f.scroll); if (comptime Pdf.enabled) if (pane.pdf) |pv| return @intCast(pv.text_scroll); return pane.surfRow(Terminal.gridOffset(pane)); } pub fn wrapAt(pane: *Pane, vr: i32) struct { line: i32, at: i32 } { if (pane.wrap_n > 0 and vr >= 0 and vr < @as(i32, pane.wrap_n)) return .{ .line = pane.wrap_line[@intCast(vr)], .at = pane.wrap_col[@intCast(vr)] }; // unwrapped: rows ARE lines, and the byte column a row starts at is the // horizontal scroll (always 0 on a terminal, which never has one) return .{ .line = pane.scroll() + vr, .at = pane.hscroll }; } pub fn wrapRow(pane: *Pane, line: i32, col: i32) struct { row: i32, at: i32 } { if (pane.wrap_n == 0) return .{ .row = line - pane.scroll(), .at = pane.hscroll }; var i: u16 = 0; while (i < pane.wrap_n) : (i += 1) { if (pane.wrap_line[i] != line) continue; // the LAST row of a line owns every column past its start, so a // cursor parked on the trailing newline still has somewhere to draw if (i + 1 < pane.wrap_n and pane.wrap_line[i + 1] == line and col >= pane.wrap_col[i + 1]) continue; return .{ .row = @intCast(i), .at = pane.wrap_col[i] }; } return .{ .row = -1, .at = 0 }; } pub fn scrollBy(pane: *Pane, delta: i32) void { if (pane.file) |*f| { const max: i64 = @intCast(File.nlines(pane.gpa, f) -| 1); const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max); const next: usize = @intCast(n); if (next != f.scroll) { f.scroll = next; f.syntax_dirty = true; } } else if (hasPdf(pane)) { if (comptime Pdf.enabled) { const pv = &pane.pdf.?; const max: i64 = @intCast(modal.lineCount(pv.text) -| 1); pv.text_scroll = @intCast(std.math.clamp( @as(i64, @intCast(pv.text_scroll)) + delta, 0, max, )); } } else { // the vt scrolls in SHELL rows; convert through the edit buffer const off = Terminal.gridOffset(pane); Terminal.scrollGrid(pane, pane.gridRow(pane.surfRow(off) + delta) - off); } } pub fn ensureCursorVisible(pane: *Pane) void { const off = pane.scroll(); var in_context = false; // Maps survive until the next render. A jump may have changed scroll // 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) { in_context = true; break; } } } // Scrolloff is measured in the ordinary body, excluding sticky rows. var margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2)); var last = off + @as(i32, pane.rows) - 1; const visible_rows = if (pane.body_visible_rows > 0) @min(pane.wrap_n, pane.body_visible_rows) else pane.wrap_n; if (pane.context_rows < visible_rows) { const lines_shown = pane.wrap_line[visible_rows - 1] - pane.wrap_line[pane.context_rows]; last = off + lines_shown; 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.file) |*f| to = @min(to, @as(i32, @intCast(File.nlines(pane.gpa, f) -| 1))); if (comptime Pdf.enabled) { if (pane.pdf) |pv| to = @min(to, @as(i32, @intCast(modal.lineCount(pv.text) -| 1))); } pane.scrollBy(@max(0, to - last)); } } if (pane.file) |f| { 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 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))); var target = visual_scroll; if (cur < visual_scroll + hmargin) target = @max(0, cur - hmargin) else if (cur > visual_scroll + w - 1 - hmargin) target = cur - (w - 1 - hmargin); if (target != visual_scroll) pane.hscroll = @intCast(File.rawAtDisplay(line, @intCast(target))); } } pub fn pinCursor(pane: *Pane) void { if (pane.cur_pinned) return; if (pane.file != null or hasPdf(pane)) { pane.cur_row = pane.scroll(); pane.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.cur_pinned = true; } pub fn hasPdf(pane: *const Pane) bool { return if (comptime Pdf.enabled) pane.pdf != null else false; } pub fn hasPdfSelection(pane: *const Pane) bool { return if (comptime Pdf.enabled) if (pane.pdf) |pv| pv.selection != null and pv.selection_text.len > 0 else false 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 File = @import("File.zig"); pub const Output = @import("Output.zig"); pub const Mini = @import("Mini.zig"); pub const Image = @import("image.zig"); pub const Pdf = @import("pdf_view.zig"); pub const Terminal = @import("Terminal.zig"); test { _ = Pane; _ = File; _ = Output; _ = Mini; _ = Image; _ = Pdf; _ = Terminal; }