summaryrefslogtreecommitdiff
path: root/src/modal.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 20:21:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitde4def3548a6729b0dfd2120495a61beef8c8c2c (patch)
tree12144f0f64dc96bb4741459c1f9db57f44349330 /src/modal.zig
parent7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff)
downloadpardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz
pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'src/modal.zig')
-rw-r--r--src/modal.zig964
1 files changed, 964 insertions, 0 deletions
diff --git a/src/modal.zig b/src/modal.zig
new file mode 100644
index 00000000..2a195432
--- /dev/null
+++ b/src/modal.zig
@@ -0,0 +1,964 @@
+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);
+}