const std = @import("std"); // Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested // in isolation (see the `unit-test` build step). main.zig wires this onto the // pane's cursor + (for file panes) its content. // // The cursor sits ON a character: col is a char index in [0, line.len]; col == // line.len means "on the line terminator / after the last char". Motions are // written to land on real characters; main.zig clamps for display. pub const Cursor = struct { row: usize = 0, col: usize = 0, pub fn eql(a: Cursor, b: Cursor) bool { return a.row == b.row and a.col == b.col; } }; // word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other // non-ws, ws = space/tab/newline). pub const Kind = enum { word, punct, ws }; pub fn kindOf(c: u8) Kind { if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; if (std.ascii.isAlphanumeric(c) or c == '_') return .word; return .punct; } // "long word" (W/B/E): only whitespace separates; punct is part of a word. fn kindOfLong(c: u8) Kind { if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws; return .word; } fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind { if (c.row >= lines.len) return .ws; const line = lines[c.row]; if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace return if (long) kindOfLong(line[c.col]) else kindOf(line[c.col]); } fn lineLenOf(lines: []const []const u8, row: usize) usize { if (row >= lines.len) return 0; return lines[row].len; } // advance one position across line boundaries (line terminators are positions // too: col == line.len is the newline). Returns false at EOF. fn stepFwd(lines: []const []const u8, c: *Cursor) bool { if (c.row >= lines.len) return false; const llen = lineLenOf(lines, c.row); if (c.col < llen) { c.col += 1; return true; } // at the newline: move to next line start if (c.row + 1 < lines.len) { c.row += 1; c.col = 0; return true; } return false; // EOF } fn stepBwd(lines: []const []const u8, c: *Cursor) bool { if (c.col > 0) { c.col -= 1; return true; } if (c.row == 0) return false; c.row -= 1; c.col = lineLenOf(lines, c.row); // the previous line's newline return true; } // at EOF? (past the last line's last char) fn atEof(lines: []const []const u8, c: Cursor) bool { if (c.row >= lines.len) return true; if (c.row + 1 < lines.len) return false; return c.col >= lines[c.row].len; } pub fn firstNonWs(line: []const u8) usize { var i: usize = 0; while (i < line.len and (line[i] == ' ' or line[i] == '\t')) i += 1; return i; } // ---- per-line motions ---- pub fn lineStart(c: Cursor) Cursor { return .{ .row = c.row, .col = 0 }; } pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor { const llen = lineLenOf(lines, c.row); return .{ .row = c.row, .col = if (llen == 0) 0 else llen - 1 }; } pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor { return .{ .row = c.row, .col = firstNonWs(lines[c.row]) }; } // ---- char/line motions ---- pub fn charLeft(c: Cursor) Cursor { return .{ .row = c.row, .col = if (c.col > 0) c.col - 1 else 0 }; } pub fn charRight(lines: []const []const u8, c: Cursor) Cursor { const llen = lineLenOf(lines, c.row); const last = if (llen == 0) 0 else llen - 1; return .{ .row = c.row, .col = if (c.col < last) c.col + 1 else last }; } pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor { const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row; const llen = lineLenOf(lines, nr); const last = if (llen == 0) 0 else llen - 1; return .{ .row = nr, .col = if (c.col < last) c.col else last }; } pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor { const nr = if (c.row > 0) c.row - 1 else c.row; const llen = lineLenOf(lines, nr); const last = if (llen == 0) 0 else llen - 1; return .{ .row = nr, .col = if (c.col < last) c.col else last }; } // ---- word motions ---- // `w`/`W`: to the start of the next word. pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { var p = c; const start_kind = kindAt(lines, p, long); if (start_kind != .ws) { // skip the rest of the current word-class run while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) { if (!stepFwd(lines, &p)) break; } } // skip whitespace (incl. newlines) to the next word start while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { if (!stepFwd(lines, &p)) break; } // p now sits on the next word's first char (or EOF -> last valid pos) return clampToChar(lines, p); } // `b`/`B`: to the start of the previous word. pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor { var p = c; if (!stepBwd(lines, &p)) return c; // at buffer start // skip whitespace backward while (kindAt(lines, p, long) == .ws) { if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 }; } // now on the end of the previous word; walk back to its start const k = kindAt(lines, p, long); while (true) { var q = p; if (!stepBwd(lines, &q)) { p.col = 0; break; } if (kindAt(lines, q, long) != k) break; // crossed into prior class p = q; } return clampToChar(lines, p); } // `e`/`E`: to the end of the current/next word. pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor { var p = c; if (!stepFwd(lines, &p)) return clampToChar(lines, c); // skip whitespace forward while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) { if (!stepFwd(lines, &p)) break; } if (atEof(lines, p)) return clampToChar(lines, p); // now on a word's first char; advance to the last char of this run const k = kindAt(lines, p, long); while (!atEof(lines, p)) { var q = p; if (!stepFwd(lines, &q)) break; if (kindAt(lines, q, long) != k) break; p = q; } return clampToChar(lines, p); } // ---- goto ---- pub fn gotoFirst() Cursor { return .{ .row = 0, .col = 0 }; } pub fn gotoLast(lines: []const []const u8) Cursor { const r = if (lines.len == 0) 0 else lines.len - 1; return .{ .row = r, .col = 0 }; } // half/full page: target row, caller clamps + scrolls. pub fn pageDown(c: Cursor, page: usize) Cursor { return .{ .row = c.row + page, .col = c.col }; } pub fn pageUp(c: Cursor, page: usize) Cursor { return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col }; } // ---- helpers used by motions + main.zig ---- // clamp a (possibly terminator/EOF) position onto a real character. pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor { if (c.row >= lines.len) { return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 }; } const llen = lineLenOf(lines, c.row); if (llen == 0) return .{ .row = c.row, .col = 0 }; return .{ .row = c.row, .col = @min(c.col, llen - 1) }; } pub fn lineCount(content: []const u8) usize { if (content.len == 0) return 0; return std.mem.count(u8, content, "\n") + 1; } // byte offset of the start of line `row` (0-based). row may == lineCount() // (== content.len, the end). pub fn lineStartOffset(content: []const u8, row: usize) usize { var off: usize = 0; var r: usize = 0; while (r < row) : (r += 1) { const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len; off = nl + 1; } return off; } // the text of line `row` (no terminator), a slice into `content`. pub fn lineSlice(content: []const u8, row: usize) []const u8 { const start = lineStartOffset(content, row); if (start >= content.len) return ""; const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len; return content[start..nl]; } // ---- file content mutations. caller frees the returned slice + the old one. ---- // insert `text` at (row, col). col is clamped to the line length. pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 { const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row; const line = lineSlice(content, row); const col = @min(c.col, line.len); const off = lineStartOffset(content, row) + col; var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..off]); try out.appendSlice(alloc, text); try out.appendSlice(alloc, content[off..]); return out.toOwnedSlice(alloc); } // delete the character at (row, col). no-op if col is past the line end. pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 { const line = lineSlice(content, c.row); if (c.col >= line.len) return alloc.dupe(u8, content); const off = lineStartOffset(content, c.row) + c.col; var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..off]); try out.appendSlice(alloc, content[off + 1 ..]); return out.toOwnedSlice(alloc); } // delete whole lines [r0, r1] inclusive (the line content + their terminators). // returns the new content; `deleted` is the joined removed text (no terminators). pub const Deleted = struct { content: []u8, deleted: []u8 }; pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted { const n = lineCount(content); const lo = @min(r0, r1); const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1); if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") }; const start = lineStartOffset(content, lo); // end = start of line (hi+1), or content.len if hi is the last line. const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len; // if we're removing the last line and there's a preceding newline, also // drop that newline so we don't leave a trailing blank line. var cut_lo = start; const cut_hi = end; if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line var deleted: std.ArrayList(u8) = .empty; { var r = lo; while (r <= hi) : (r += 1) { if (r > lo) try deleted.append(alloc, '\n'); try deleted.appendSlice(alloc, lineSlice(content, r)); } } var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..cut_lo]); try out.appendSlice(alloc, content[cut_hi..]); return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) }; } // normalize two cursors into document order (lo <= hi), then byte offsets of the // INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty. fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } { var lo = a; var hi = b; if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) { lo = b; hi = a; } const s = lineStartOffset(content, lo.row) + @min(lo.col, lineSlice(content, lo.row).len); var e = lineStartOffset(content, hi.row) + @min(hi.col, lineSlice(content, hi.row).len); if (e < content.len) e += 1; // include the char under the head return .{ .s = s, .e = @max(s, e) }; } // the text of the inclusive char range [a, b] (cursors in either order). Caller frees. pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 { const r = rangeBytes(content, a, b); return alloc.dupe(u8, content[r.s..r.e]); } // delete the inclusive char range [a, b]. returns new content + the removed text. pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted { const r = rangeBytes(content, a, b); const deleted = try alloc.dupe(u8, content[r.s..r.e]); errdefer alloc.free(deleted); var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..r.s]); try out.appendSlice(alloc, content[r.e..]); return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted }; } // replace line `row`'s text with "" (keep the line, empty it). For `c`hange line. pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 { const line = lineSlice(content, row); const start = lineStartOffset(content, row); var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..start]); try out.appendSlice(alloc, content[start + line.len ..]); return out.toOwnedSlice(alloc); } // paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines. pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 { const n = lineCount(content); const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len; var out: std.ArrayList(u8) = .empty; try out.appendSlice(alloc, content[0..off]); if (off < content.len) { try out.appendSlice(alloc, text); try out.append(alloc, '\n'); } else { if (off > 0) try out.append(alloc, '\n'); try out.appendSlice(alloc, text); } try out.appendSlice(alloc, content[off..]); return out.toOwnedSlice(alloc); } // ---- file-pane modal step ---- // One keypress on an editable FILE pane. This is the single source of truth for // file-modal behavior: main.zig delegates file panes here, and the tutor-driven // unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) — // only std + the modal primitives above. Returns the new content (caller frees // the old), or null if the key only moved the cursor / was a no-op (caller keeps // the old content, no realloc). pub const Key = union(enum) { ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes) esc, enter, backspace, left, right, up, down, home, end, page_down, page_up, half_down, // Ctrl-d half_up, // Ctrl-u }; pub const FileMode = enum { normal, insert }; pub const FileState = struct { cur: Cursor = .{}, mode: FileMode = .normal, pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z' msel_active: bool = false, msel_r0: i32 = 0, msel_r1: i32 = 0, }; // The view the cursor must stay inside. `scroll` is read/written so viewport // alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to // the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll // so visibility is a no-op (content is what's asserted). pub const View = struct { rows: usize, scroll: *usize, }; fn chEq(key: Key, cp: u21) bool { return switch (key) { .ch => |c| c == cp, else => false, }; } fn tagOf(key: Key) std.meta.Tag(Key) { return std.meta.activeTag(key); } fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 { var ls: std.ArrayList([]const u8) = .empty; var it = std.mem.splitScalar(u8, content, '\n'); while (it.next()) |ln| try ls.append(alloc, ln); return ls.toOwnedSlice(alloc); } fn ensureVisible(view: View, row: usize) void { const s = view.scroll.*; if (row < s) { view.scroll.* = row; return; } const last = s +| (view.rows -| 1); if (row > last) view.scroll.* = row -| (view.rows -| 1); } // helix `x`: start a line selection at the cursor, or extend it down by one. fn lineSelectFile(st: *FileState, lines: []const []const u8) void { if (!st.msel_active) { st.msel_active = true; st.msel_r0 = @intCast(st.cur.row); st.msel_r1 = @intCast(st.cur.row); } else { const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1); st.msel_r1 = @min(st.msel_r1 + 1, last); st.cur.row = @intCast(st.msel_r1); } st.cur.col = 0; st.pending = 0; } // `d`: delete the selected lines (yanking them), or the char at the cursor. fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { if (st.msel_active) { const r0: i32 = @min(st.msel_r0, st.msel_r1); const r1: i32 = @max(st.msel_r0, st.msel_r1); const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1))); if (yank.*) |old| alloc.free(old); yank.* = try alloc.dupe(u8, d.deleted); alloc.free(d.deleted); const n = lineCount(d.content); st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 }; st.msel_active = false; st.pending = 0; return d.content; } // no selection: delete the char at the cursor (no-op if past end) const llen = lineLenOf(lines, st.cur.row); if (st.cur.col >= llen) { st.pending = 0; return null; } const new = try deleteChar(alloc, content, st.cur); st.pending = 0; return new; } // `y`: yank the selected lines, or the current line. Cursor moves to the // selection start; the selection is collapsed. fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void { if (st.msel_active) { const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); var buf: std.ArrayList(u8) = .empty; var r: usize = r0; while (r <= r1) : (r += 1) { if (r > r0) try buf.append(alloc, '\n'); try buf.appendSlice(alloc, lineSlice(content, r)); } if (yank.*) |old| alloc.free(old); yank.* = try buf.toOwnedSlice(alloc); st.cur = .{ .row = r0, .col = 0 }; st.msel_active = false; } else { if (yank.*) |old| alloc.free(old); yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row)); } st.pending = 0; } // `p`: paste the yanked text as a new line below the cursor. fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 { const y = yank.* orelse { st.pending = 0; return null; }; const new = try pasteLineBelow(alloc, content, st.cur.row, y); st.cur = .{ .row = st.cur.row + 1, .col = 0 }; st.pending = 0; return new; } // `c`: change the selection (delete + enter INSERT, keeping one empty line), or // change the char at the cursor. Yanks the removed text. fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 { if (st.msel_active) { const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1))); const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1))); // yank the original lines first (from the un-mutated content) var buf: std.ArrayList(u8) = .empty; var r: usize = r0; while (r <= r1) : (r += 1) { if (r > r0) try buf.append(alloc, '\n'); try buf.appendSlice(alloc, lineSlice(content, r)); } if (yank.*) |old| alloc.free(old); yank.* = try buf.toOwnedSlice(alloc); // drop the extra lines (keep one), then empty the kept line const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null; defer if (d) |dd| alloc.free(dd.deleted); const after_del: []const u8 = if (d) |dd| dd.content else content; const cl = try clearLine(alloc, after_del, r0); if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy) st.cur = .{ .row = r0, .col = 0 }; st.mode = .insert; st.msel_active = false; st.pending = 0; return cl; } // no selection: delete the char at the cursor (if any), then enter INSERT const llen = lineLenOf(lines, st.cur.row); if (st.cur.col >= llen) { st.mode = .insert; st.msel_active = false; st.pending = 0; return null; } const new = try deleteChar(alloc, content, st.cur); st.mode = .insert; st.msel_active = false; st.pending = 0; return new; } // INSERT-mode key on a file pane. Returns new content or null (no change). fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 { switch (key) { .esc => { st.mode = .normal; st.msel_active = false; st.pending = 0; return null; }, .ch => |cp| { if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter); var buf: [4]u8 = undefined; const n = std.unicode.utf8Encode(cp, &buf) catch return null; const new = try insertAt(alloc, content, st.cur, buf[0..n]); st.cur.col += n; // col is a byte offset; advance by the char's byte length st.pending = 0; return new; }, .enter => { const new = try insertAt(alloc, content, st.cur, "\n"); st.cur = .{ .row = st.cur.row + 1, .col = 0 }; st.pending = 0; return new; }, .backspace => { if (st.cur.col > 0) { const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 }); st.cur.col -= 1; return new; } else if (st.cur.row > 0) { const prevlen = lineSlice(content, st.cur.row - 1).len; const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen }); st.cur = .{ .row = st.cur.row - 1, .col = prevlen }; return new; } return null; }, .left => { st.cur = charLeft(st.cur); return null; }, .right => { st.cur = charRight(lines, st.cur); return null; }, .up => { st.cur = lineUp(lines, st.cur); return null; }, .down => { st.cur = lineDown(lines, st.cur); return null; }, .home => { st.cur = lineStart(st.cur); return null; }, .end => { st.cur = lineEnd(lines, st.cur); return null; }, else => return null, } } // The full NORMAL-mode file step (motions + insert entry + select/edit). See // handleNormal in main.zig for the terminal counterpart (which differs in its // edit ops: terminals can't delete shell output, only pasted runs). pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 { const lines = try splitLines(alloc, content); defer alloc.free(lines); if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key); var cur = clampToChar(lines, st.cur); // prefix continuations if (st.pending != 0) { const p = st.pending; st.pending = 0; if (p == 'g') { if (chEq(key, 'g')) cur = gotoFirst() else if (chEq(key, 'e')) cur = gotoLast(lines) else if (chEq(key, 'h')) cur = lineStart(cur) else if (chEq(key, 'l')) cur = lineEnd(lines, cur) else return null; // unknown continuation: consume, no-op st.cur = cur; st.msel_active = false; return null; } if (p == 'z') { if (chEq(key, 't')) { view.scroll.* = cur.row; } else if (chEq(key, 'z')) { view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2)))); } else if (chEq(key, 'b')) { view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1)); } else return null; st.msel_active = false; return null; } } if (chEq(key, 'g')) { st.pending = 'g'; return null; } if (chEq(key, 'z')) { st.pending = 'z'; return null; } // motions -> move cursor, collapse selection, keep visible var moved: ?Cursor = null; if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur) else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur) else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur) else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur) else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false) else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true) else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false) else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true) else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false) else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true) else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur) else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur) else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur) else if (chEq(key, 'G')) moved = gotoLast(lines) else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2))) else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows)) else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows)); if (moved) |m| { st.cur = m; st.msel_active = false; st.pending = 0; ensureVisible(view, m.row); return null; } // insert entry if (chEq(key, 'i')) { st.mode = .insert; st.cur = cur; st.msel_active = false; st.pending = 0; return null; } if (chEq(key, 'a')) { st.mode = .insert; st.cur = charRight(lines, cur); st.msel_active = false; st.pending = 0; return null; } if (chEq(key, 'I')) { st.mode = .insert; st.cur = firstNonWsOf(lines, cur); st.msel_active = false; st.pending = 0; return null; } if (chEq(key, 'A')) { st.mode = .insert; st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }; st.msel_active = false; st.pending = 0; return null; } if (chEq(key, 'o')) { const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n"); st.cur = .{ .row = cur.row + 1, .col = 0 }; st.mode = .insert; st.msel_active = false; st.pending = 0; ensureVisible(view, st.cur.row); return new; } if (chEq(key, 'O')) { const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n"); st.cur = .{ .row = cur.row, .col = 0 }; st.mode = .insert; st.msel_active = false; st.pending = 0; ensureVisible(view, st.cur.row); return new; } // select / edit if (chEq(key, 'x')) { lineSelectFile(st, lines); return null; } if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank); if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank); if (chEq(key, 'y')) { try yankFile(alloc, content, st, yank); return null; } if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank); // Enter / other keys: no-op on a file (no tty mode) return null; } // ---- tests ---- const testEq = struct { fn cur(lines: []const []const u8, s: []const u8) Cursor { var row: usize = 0; for (lines) |ln| { if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 }; row += 1; } unreachable; } }; test "kindOf" { try std.testing.expectEqual(Kind.word, kindOf('a')); try std.testing.expectEqual(Kind.word, kindOf('_')); try std.testing.expectEqual(Kind.word, kindOf('9')); try std.testing.expectEqual(Kind.punct, kindOf('.')); try std.testing.expectEqual(Kind.punct, kindOf('(')); try std.testing.expectEqual(Kind.ws, kindOf(' ')); try std.testing.expectEqual(Kind.ws, kindOf('\n')); } test "char/line motions" { const lines = [_][]const u8{ "alpha beta", " two words", "x" }; const c = Cursor{ .row = 0, .col = 5 }; try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(c)); try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c)); try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c)); try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 })); // line ends try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c)); try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 })); // first non-ws try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 })); } test "word motions w/b/e" { const lines = [_][]const u8{"this is a test"}; const w = &lines; // "this is a test", indices 0..13 try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is" try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a" try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test" try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws // b try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a" try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is" try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this" // e try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3] try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6] try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13] } test "word motions cross line" { const lines = [_][]const u8{ "foo bar", "", "baz" }; const w = &lines; // from end of "foo bar" (row0 col6) w crosses the blank line to "baz" try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false)); // b from "baz" crosses back to "bar" try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false)); // e from row0 col0 -> "foo" end (col2) try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); } test "long word W treats punct as word" { // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word) const lines = [_][]const u8{"foo.bar baz"}; const w = &lines; try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true)); // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix) try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); } test "goto" { const lines = [_][]const u8{ "a", "b", "c" }; try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst()); try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines)); } test "lineStartOffset + lineSlice" { const content = "alpha\nbeta\n\ngamma"; try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0)); try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1)); try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2)); try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3)); try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end try std.testing.expectEqualStrings("alpha", lineSlice(content, 0)); try std.testing.expectEqualStrings("beta", lineSlice(content, 1)); try std.testing.expectEqualStrings("", lineSlice(content, 2)); try std.testing.expectEqualStrings("gamma", lineSlice(content, 3)); try std.testing.expectEqual(@as(usize, 4), lineCount(content)); } test "insertAt mid-line and at end" { const content = "hello world"; const a = std.testing.allocator; const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!"); defer a.free(r1); try std.testing.expectEqualStrings("hello! world", r1); const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!"); defer a.free(r2); try std.testing.expectEqualStrings("hello world!", r2); } test "insertAt multiline creates lines" { const content = "a\nb"; const a = std.testing.allocator; const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY"); defer a.free(r); try std.testing.expectEqualStrings("aX\nY\nb", r); try std.testing.expectEqual(@as(usize, 3), lineCount(r)); } test "deleteChar" { const content = "abc"; const a = std.testing.allocator; const r = try deleteChar(a, content, .{ .row = 0, .col = 1 }); defer a.free(r); try std.testing.expectEqualStrings("ac", r); // past end: no-op const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 }); defer a.free(r2); try std.testing.expectEqualStrings("abc", r2); } test "deleteLines middle" { const content = "one\ntwo\nthree\nfour"; const a = std.testing.allocator; const d = try deleteLines(a, content, 1, 2); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("one\nfour", d.content); try std.testing.expectEqualStrings("two\nthree", d.deleted); } test "deleteLines last line drops preceding newline" { const content = "one\ntwo\nthree"; const a = std.testing.allocator; const d = try deleteLines(a, content, 2, 2); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("one\ntwo", d.content); try std.testing.expectEqualStrings("three", d.deleted); } test "deleteLines only line" { const content = "only"; const a = std.testing.allocator; const d = try deleteLines(a, content, 0, 0); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("", d.content); try std.testing.expectEqualStrings("only", d.deleted); } test "rangeText + deleteRange (char-wise select)" { const a = std.testing.allocator; const content = "hello\nworld\nfoo"; // same-line inclusive range: "hello"[1..3] -> "ell" const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 }); defer a.free(t1); try std.testing.expectEqualStrings("ell", t1); // reversed cursors give the same range const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 }); defer a.free(t2); try std.testing.expectEqualStrings("ell", t2); // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo" const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); defer a.free(t3); try std.testing.expectEqualStrings("lo\nwo", t3); // delete the same cross-line range const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 }); defer a.free(d.content); defer a.free(d.deleted); try std.testing.expectEqualStrings("helrld\nfoo", d.content); try std.testing.expectEqualStrings("lo\nwo", d.deleted); } test "clearLine" { const content = "keep\nzap me\nkeep2"; const a = std.testing.allocator; const r = try clearLine(a, content, 1); defer a.free(r); try std.testing.expectEqualStrings("keep\n\nkeep2", r); } test "pasteLineBelow" { const content = "one\ntwo"; const a = std.testing.allocator; const r = try pasteLineBelow(a, content, 0, "INSERTED"); defer a.free(r); try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r); // paste below last line const r2 = try pasteLineBelow(a, content, 1, "END"); defer a.free(r2); try std.testing.expectEqualStrings("one\ntwo\nEND", r2); // multiline yanked text const r3 = try pasteLineBelow(a, content, 0, "a\nb"); defer a.free(r3); try std.testing.expectEqualStrings("one\na\nb\ntwo", r3); }