summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-06-30 08:57:43 -0300
committerGabriel Schneider <[email protected]>2026-06-30 11:43:29 -0300
commit8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch)
tree788ae4b5e9145cbb601d5b8a30b16bfcfacf5277
parentb5b526f465924324e2cd4424f5bacc43e50b7673 (diff)
downloadpardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz
pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip
chinese vibes modal editing
-rw-r--r--build.zig70
-rw-r--r--e2e_harness.zig248
-rw-r--r--main.zig923
-rw-r--r--modal.zig909
-rw-r--r--term.zig36
-rw-r--r--tests.zig375
-rw-r--r--tutor.txt381
-rw-r--r--tutor_gen.zig386
8 files changed, 2996 insertions, 332 deletions
diff --git a/build.zig b/build.zig
index 25520c27..c5c85857 100644
--- a/build.zig
+++ b/build.zig
@@ -4,6 +4,11 @@ pub fn build(b: *std.Build) void {
const optimize = b.standardOptimizeOption(.{});
const target = b.standardTargetOptions(.{});
+ // -Dtrace: make the e2e runner print the captured screen state (after each
+ // pump/waitFor and on every assertion) so failures can be inspected live.
+ // Usage: `zig build test -Dtrace`.
+ const trace = b.option(bool, "trace", "print captured tty/screen state during e2e tests") orelse false;
+
const exe = b.addExecutable(.{
.name = "pardes",
.root_module = b.createModule(.{
@@ -67,6 +72,10 @@ pub fn build(b: *std.Build) void {
const ghostty_vt = dep.module("ghostty-vt");
ghostty_vt.addImport("uucode", uucode_mod);
exe.root_module.addImport("ghostty-vt", ghostty_vt);
+ // The e2e test harness parses the app's pty output with its own ghostty
+ // terminal (reads the 2D screen grid, not raw bytes), so it needs the
+ // same module.
+ tests.root_module.addImport("ghostty-vt", ghostty_vt);
}
// Override uucode on vaxis last, so it wins over ghostty's build() above.
@@ -78,8 +87,69 @@ pub fn build(b: *std.Build) void {
const run = b.step("run", "descend to the pardes");
run.dependOn(&run_exe.step);
+ // tutor_gen: a separate binary that parses tutor.txt and writes
+ // tutor_cases.zig (a file of `test` blocks, one per practice block).
+ // `zig build tutor-gen` runs it; the unit-test step depends on the output.
+ const tutor_gen = b.addExecutable(.{
+ .name = "tutor-gen",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("tutor_gen.zig"),
+ }),
+ });
+ const run_tutor_gen = b.addRunArtifact(tutor_gen);
+ run_tutor_gen.addArg("tutor.txt");
+ const tutor_cases_lazy = run_tutor_gen.addOutputFileArg("tutor_cases.zig");
+ const tutor_gen_step = b.step("tutor-gen", "regenerate tutor_cases.zig from tutor.txt");
+ tutor_gen_step.dependOn(&run_tutor_gen.step);
+
const run_tests = b.addRunArtifact(tests);
run_tests.addArtifactArg(exe);
+
+ // Expose build-time flags (e.g. -Dtrace) to the test runner as an importable
+ // `config` module. tests.zig reads `config.trace` and hands it to the harness.
+ const test_opts = b.addOptions();
+ test_opts.addOption(bool, "trace", trace);
+ tests.root_module.addOptions("config", test_opts);
+
const test_step = b.step("test", "run deterministic tests");
test_step.dependOn(&run_tests.step);
+
+ // Unit tests for the pure modal-editing math (modal.zig has no vaxis/ghostty
+ // deps, so `zig test` on it is fast and needs no wired-in modules).
+ const unit_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("modal.zig"),
+ }),
+ });
+ const run_unit = b.addRunArtifact(unit_tests);
+ const unit_step = b.step("unit-test", "run modal.zig unit tests");
+ unit_step.dependOn(&run_unit.step);
+ test_step.dependOn(&run_unit.step);
+
+ // Tutor practice blocks as unit tests: a second `zig test` target over the
+ // generated tutor_cases.zig (which imports modal.zig). Depends on tutor-gen
+ // so the file is (re)generated first.
+ const tutor_tests = b.addTest(.{
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = tutor_cases_lazy,
+ }),
+ });
+ // The generated cases `@import("modal")` (a module name, not a relative
+ // path, since the generated file lives in the cache dir, not next to modal.zig).
+ const modal_for_tutor = b.createModule(.{
+ .target = target,
+ .optimize = optimize,
+ .root_source_file = b.path("modal.zig"),
+ });
+ tutor_tests.root_module.addImport("modal", modal_for_tutor);
+ const run_tutor = b.addRunArtifact(tutor_tests);
+ const tutor_step = b.step("tutor-test", "run the tutor practice-block tests");
+ tutor_step.dependOn(&run_tutor.step);
+ test_step.dependOn(&run_tutor.step);
}
diff --git a/e2e_harness.zig b/e2e_harness.zig
new file mode 100644
index 00000000..05b9c6e5
--- /dev/null
+++ b/e2e_harness.zig
@@ -0,0 +1,248 @@
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const ghostty_vt = @import("ghostty-vt");
+
+extern "c" fn forkpty(
+ amaster: *c_int,
+ name: ?[*:0]u8,
+ termp: ?*const anyopaque,
+ winp: ?*const posix.winsize,
+) c_int;
+extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+fn nowMs() i64 {
+ var ts: linux.timespec = undefined;
+ _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
+ return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000);
+}
+
+/// E2E test harness: forks the app in a pty and parses its output with a
+/// ghostty terminal emulator. Tests read the 2D screen GRID (via plainString),
+/// not raw byte streams — so they're decoupled from how the app renders
+/// (vaxis diff-rendering, repaints, etc). The grid is always current after
+/// `pump`/`waitFor`; no setWinsize-to-force-repaint hacks needed.
+///
+/// Input is sent as raw bytes to the pty (the app's vaxis parses them). The
+/// harness terminal only reads output.
+pub const Harness = struct {
+ gpa: std.mem.Allocator,
+ master: c_int,
+ pid: posix.pid_t,
+ /// heap-allocated so its address is stable: vtStream()'s handler holds a
+ /// pointer back into the Terminal, so it must not move (same reason main.zig
+ /// heap-allocates Term). Freed in deinit.
+ term: *ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+ rows: u16,
+ cols: u16,
+ /// when true, print the captured screen state after each pump/waitFor and on
+ /// every assertion, so live test runs can be inspected (zig build test -Dtrace).
+ trace: bool = false,
+
+ pub fn init(gpa: std.mem.Allocator, exe: [*:0]const u8, rows: u16, cols: u16) !Harness {
+ var master: c_int = undefined;
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ const pid = forkpty(&master, null, null, &ws);
+ if (pid == 0) {
+ const argv = [_:null]?[*:0]const u8{ exe, null };
+ _ = execvp(exe, &argv);
+ _exit(127);
+ }
+ const term = try gpa.create(ghostty_vt.Terminal);
+ errdefer gpa.destroy(term);
+ term.* = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = cols,
+ .rows = rows,
+ .max_scrollback = 1024 * 1024,
+ });
+ errdefer term.deinit(gpa);
+ // vtStream() captures a pointer to `term`; term is heap-allocated so the
+ // pointer stays valid for the life of the harness.
+ const stream = term.vtStream();
+ return .{
+ .gpa = gpa,
+ .master = master,
+ .pid = pid,
+ .term = term,
+ .stream = stream,
+ .rows = rows,
+ .cols = cols,
+ };
+ }
+
+ pub fn deinit(self: *Harness) void {
+ posix.kill(self.pid, posix.SIG.KILL) catch {};
+ _ = linux.close(self.master);
+ self.stream.deinit();
+ self.term.deinit(self.gpa);
+ self.gpa.destroy(self.term);
+ }
+
+ /// Write raw bytes to the pty (input to the app: keystrokes, mouse events).
+ pub fn send(self: *Harness, bytes: []const u8) !void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const rc = linux.write(self.master, bytes[off..].ptr, bytes.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.WriteFailed;
+ }
+ off += @intCast(n);
+ }
+ }
+
+ /// Read pty output and feed it to our ghostty terminal for `ms` ms. After
+ /// this, the grid reflects everything the app rendered so far.
+ pub fn pump(self: *Harness, ms: i64) !void {
+ const deadline = nowMs() + ms;
+ var buf: [4096]u8 = undefined;
+ while (nowMs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 50) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(self.master, &buf) catch break;
+ if (n == 0) break;
+ self.stream.nextSlice(buf[0..n]);
+ }
+ }
+ if (self.trace) self.traceScreen("pump");
+ }
+
+ /// Resize both the pty (SIGWINCH to the app) and our terminal (grid reflows
+ /// to the new size). Harness terminal first so it's ready for the app's
+ /// re-render at the new size.
+ pub fn resize(self: *Harness, rows: u16, cols: u16) !void {
+ self.rows = rows;
+ self.cols = cols;
+ self.term.resize(self.gpa, cols, rows) catch {};
+ const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(self.master, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+
+ /// The full viewport text (plainString), newline-joined rows. Caller frees.
+ pub fn screenText(self: *Harness) ![]const u8 {
+ return self.term.plainString(self.gpa);
+ }
+
+ /// Is `needle` present anywhere on the current screen?
+ pub fn contains(self: *Harness, needle: []const u8) bool {
+ const text = self.screenText() catch return false;
+ defer self.gpa.free(text);
+ return std.mem.indexOf(u8, text, needle) != null;
+ }
+
+ /// Wait up to `ms` for `needle` to appear on screen, pumping bytes the
+ /// whole time. Replaces pollFor + setWinsize-repaint: the grid is checked
+ /// directly each iteration.
+ pub fn waitFor(self: *Harness, needle: []const u8, ms: i64) !bool {
+ {
+ const text = try self.screenText();
+ defer self.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) {
+ if (self.trace) self.traceScreen("waitFor hit");
+ return true;
+ }
+ }
+ const deadline = nowMs() + ms;
+ var buf: [4096]u8 = undefined;
+ while (nowMs() < deadline) {
+ var fds = [_]posix.pollfd{.{ .fd = self.master, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 50) catch {};
+ if ((fds[0].revents & posix.POLL.IN) != 0) {
+ const n = posix.read(self.master, &buf) catch break;
+ if (n == 0) break;
+ self.stream.nextSlice(buf[0..n]);
+ }
+ const text = try self.screenText();
+ defer self.gpa.free(text);
+ if (std.mem.indexOf(u8, text, needle) != null) {
+ if (self.trace) self.traceScreen("waitFor hit");
+ return true;
+ }
+ }
+ if (self.trace) self.traceScreen("waitFor miss");
+ return false;
+ }
+
+ /// The visual cursor position (viewport coords): { x, y }.
+ pub fn cursor(self: *Harness) struct { x: u16, y: u16 } {
+ return .{
+ .x = self.term.screens.active.cursor.x,
+ .y = self.term.screens.active.cursor.y,
+ };
+ }
+
+ /// True if the shell is at a prompt (OSC 133 semantic prompt). Requires
+ /// shell integration; false if the shell is outputting or has no integration.
+ pub fn atPrompt(self: *Harness) bool {
+ return self.term.cursorIsAtPrompt();
+ }
+
+ /// Print the screen grid (clean text, not raw bytes) for debugging on
+ /// failure. Also shows the cursor position and screen size.
+ pub fn dump(self: *Harness, msg: []const u8) void {
+ const text = self.screenText() catch {
+ std.debug.print("E2E FAIL: {s}\n(could not read screen)\n", .{msg});
+ return;
+ };
+ defer self.gpa.free(text);
+ const c = self.cursor();
+ std.debug.print("E2E FAIL: {s}\nscreen {d}x{d} cursor=({d},{d}):\n{s}\n", .{
+ msg, self.cols, self.rows, c.x, c.y, text,
+ });
+ }
+
+ /// trace-mode screen snapshot: print the current grid + cursor + size to
+ /// stderr (always, not just on failure). Bounded by a header so the live
+ /// stream of snapshots is easy to scan.
+ pub fn traceScreen(self: *Harness, tag: []const u8) void {
+ const text = self.screenText() catch return;
+ defer self.gpa.free(text);
+ const c = self.cursor();
+ std.debug.print("\n[trace {s}] screen {d}x{d} cursor=({d},{d}):\n{s}\n[/trace]\n", .{
+ tag, self.cols, self.rows, c.x, c.y, text,
+ });
+ }
+
+ // ---- assertions (dump on failure, return error.ExpectFailed) ----
+
+ pub fn expectWaitFor(self: *Harness, needle: []const u8, ms: i64, msg: []const u8) !void {
+ if (!try self.waitFor(needle, ms)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (!self.contains(needle)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+
+ pub fn expectNotContains(self: *Harness, needle: []const u8, msg: []const u8) !void {
+ if (self.contains(needle)) {
+ self.dump(msg);
+ return error.ExpectFailed;
+ }
+ }
+};
+
+/// Write a file (helper for test setup, raw linux syscalls).
+pub fn writeFile(path: [*:0]const u8, data: []const u8) !void {
+ const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var off: usize = 0;
+ while (off < data.len) {
+ const w = linux.write(fd, data[off..].ptr, data.len - off);
+ const sw: isize = @bitCast(w);
+ if (sw <= 0) break;
+ off += @intCast(sw);
+ }
+}
diff --git a/main.zig b/main.zig
index c5ed5085..49413eec 100644
--- a/main.zig
+++ b/main.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const vaxis = @import("vaxis");
const ghostty_vt = @import("ghostty-vt");
const Term = @import("term.zig");
+const modal = @import("modal.zig");
extern "c" fn forkpty(
amaster: *c_int,
@@ -45,6 +46,18 @@ pub const Command = struct {
} = .nop;
};
+// Modal yank register: gpa-owned, freed on overwrite / app exit. Helix-style
+// `y` yanks the (line) selection into here; `p` pastes it after the cursor.
+var yank_reg: ?[]u8 = null;
+fn freeYank(gpa: std.mem.Allocator) void {
+ if (yank_reg) |y| gpa.free(y);
+ yank_reg = null;
+}
+fn setYank(gpa: std.mem.Allocator, text: []const u8) void {
+ freeYank(gpa);
+ yank_reg = gpa.dupe(u8, text) catch null;
+}
+
const Loop = vaxis.Loop(@TypeOf(Command.value));
// layout constants
@@ -54,6 +67,7 @@ const MINW: u16 = 10; // min pane width (cells)
const MINH: u16 = 3; // min pane height (cells)
const MAX_TERMS: usize = 16;
const MAX_COLS: usize = 6;
+const PREFIX_W: u16 = 5; // file-pane line-number prefix width: "%4d "
const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
@@ -97,15 +111,20 @@ const edit_bg = [3]u8{ 0x3a, 0x3a, 0x3a };
// selections are easy to tell apart against the otherwise-monochrome chrome.
const SEL_BG = [3][3]u8{ .{ 0x2c, 0x46, 0x74 }, .{ 0x2c, 0x60, 0x38 }, .{ 0x64, 0x2c, 0x58 } };
const sel_fg = [3]u8{ 0xd2, 0xd2, 0xd2 };
+// modal line-selection (helix `x`) highlight: a warm tan so it reads as
+// keyboard-driven, distinct from the mouse selections above.
+const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
+const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
// Tag builtins. Middle-clicking the name in a window's tag runs the action;
// `builtins_str` is the space-joined name list, produced at comptime.
-const SelectAction = enum { none, send, newcol, delcol, del };
+const SelectAction = enum { none, send, newcol, delcol, del, tutor };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Newcol", .action = .newcol },
.{ .name = "Delcol", .action = .delcol },
.{ .name = "Del", .action = .del },
+ .{ .name = "Tutor", .action = .tutor },
};
const builtins_str = blk: {
var s: []const u8 = "";
@@ -116,11 +135,14 @@ const builtins_str = blk: {
// Path of the generated bash rc that adds OSC 133 semantic prompts (shell
// integration). bash --rcfile reads it for interactive shells; it sources the
// user's ~/.bashrc first, then wraps PS1 so each prompt emits 133;A..B. That
-// marks the prompt rows so we can hide them in text mode.
+// marks the prompt rows so we can hide them in text mode. The A marker also
+// carries cl=line, opting in to ghostty's promptClickMove (arrow-key
+// repositioning of the shell cursor within the typed input) so enterTty can
+// land the shell cursor where the modal cursor navigated (start vs end).
const rc_path = "/tmp/pardes-osc133.bash";
const bash_rc =
\\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
- \\PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]'
+ \\PS1='\[\e]133;A;cl=line\a\]'"$PS1"'\[\e]133;B\a\]'
\\PROMPT_COMMAND='printf "\e]133;D\a"'"${PROMPT_COMMAND:+;$PROMPT_COMMAND}"
\\trap 'printf "\e]133;C\a"' DEBUG
\\
@@ -224,8 +246,12 @@ fn tagLine(alloc: std.mem.Allocator, t: *Term) ![]u8 {
if (t.file) |f| return std.fmt.allocPrint(alloc, "{s} {s}", .{ f.path, builtins_str });
var link: [1024]u8 = undefined;
const cwd = termCwd(t, &link);
- // leading 2-char mode indicator: "ch" (char/edit) or "sy" (sym/terminal).
- const mode = if (t.editing) "ch" else "sy";
+ // leading 2-char mode indicator: "nm" (normal), "in" (insert) or "sy" (sym/tty).
+ const mode = switch (t.mode) {
+ .normal => "nm",
+ .insert => "in",
+ .tty => "sy",
+ };
return std.fmt.allocPrint(alloc, "{s} {s} {s}", .{ mode, cwd, builtins_str });
}
@@ -345,6 +371,35 @@ fn openFileView(gpa: std.mem.Allocator, rp: [*:0]const u8, line: usize, cols: u1
return t;
}
+// Open the built-in Tutor as a read/editable FILE pane: same machinery as
+// openFileView, but the content is the tutor text embedded at comptime (so the
+// binary is self-contained — no external file needed) and the path is a fixed
+// label. Middle-clicking "Tutor" in any tag spawns this. Caller wires it into
+// the layout + frees later.
+const tutor_text = @embedFile("tutor.txt");
+fn openTutorView(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Term {
+ const content = try gpa.dupe(u8, tutor_text);
+ errdefer gpa.free(content);
+ const path = try gpa.dupe(u8, "/Tutor");
+ errdefer gpa.free(path);
+ const t = try gpa.create(Term);
+ errdefer gpa.destroy(t);
+ t.* = .{
+ .pty = .{ .handle = -1, .flags = .{ .nonblocking = false } },
+ .pid = 0,
+ .term = try ghostty_vt.Terminal.init(gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .reader = .{ .any_future = null, .result = {} },
+ .file = .{ .path = path, .content = content, .scroll = 0 },
+ .cols = cols,
+ .rows = rows,
+ .cur_pinned = true,
+ .cur_row = 0,
+ };
+ t.stream = t.term.vtStream();
+ return t;
+}
+
fn startReader(io: std.Io, gpa: std.mem.Allocator, loop: *Loop, t: *Term, id: usize) !void {
t.reader = try io.concurrent(readPty, .{ io, gpa, t.pty, id, loop });
}
@@ -583,15 +638,42 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
}
+ // modal line selection (helix `x`): highlight the selected body rows across
+ // the text width. Only in normal mode; r0/r1 are absolute body rows.
+ if (t.mode == .normal and t.msel.active) {
+ const lo = @min(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
+ const hi = @max(t.msel.r0, t.msel.r1) - off + @as(i32, BOX_H);
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < lo or @as(i32, row) > hi) continue;
+ var col: u16 = 0;
+ while (col < r.w - GUTTER) : (col += 1) {
+ if (text.readCell(col, row)) |cell| {
+ var nc = cell;
+ nc.default = false;
+ nc.style.bg = .{ .rgb = msel_bg };
+ nc.style.fg = .{ .rgb = msel_fg };
+ text.writeCell(col, row, nc);
+ }
+ }
+ }
+ }
if (active) {
- if (t.editing) {
- // text mode: cursor at the (scroll-adjusted) edit position. until the
- // user pins it (click/type) it tracks the shell cursor.
+ if (t.mode != .tty) {
+ // text mode (normal/insert): cursor at the (scroll-adjusted) edit
+ // position. until the user pins it (click/type) it tracks the shell cursor.
const crow = if (t.cur_pinned) t.cur_row else @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
const ccol = if (t.cur_pinned) t.cur_col else @as(i32, @intCast(t.term.screens.active.cursor.x));
const coff = if (t.cur_pinned) t.cur_off else 0;
const prow = crow - off + @as(i32, BOX_H);
- const cx = renderedCol(t, crow, ccol, coff);
+ // insert mode uses the edit-run coordinate -> visual mapping; normal
+ // mode keeps cur_col already-visual; file panes add the line-number prefix.
+ const cx = if (t.file != null)
+ @as(i32, PREFIX_W) + ccol + @as(i32, coff)
+ else if (t.mode == .insert)
+ renderedCol(t, crow, ccol, coff)
+ else
+ ccol;
if (prow >= 0 and cx >= 0 and prow < @as(i32, r.h) and cx < @as(i32, r.w - GUTTER))
text.showCursor(@intCast(cx), @intCast(prow));
} else {
@@ -638,6 +720,657 @@ fn renderPane(root: vaxis.Window, alloc: std.mem.Allocator, t: *Term, r: Rect, a
}
}
+// ---- modal editing helpers (wired onto Term by the key handler) ----
+
+const PaneLines = struct {
+ lines: []const []const u8,
+ row0: i32, // absolute row of lines[0]; cursor lines-row = cur_row - row0
+ body: ?[]u8, // terminal only: bodyText temp; arena-owned, freed by caller
+};
+
+// The lines the cursor moves over. File: all content lines (row0 = 0). Terminal:
+// the viewport rows (row0 = scroll offset). `alloc` is per-frame scratch.
+fn paneCursorLines(alloc: std.mem.Allocator, t: *Term) !PaneLines {
+ if (t.file) |f| {
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, f.content, '\n');
+ while (it.next()) |ln| try ls.append(alloc, ln);
+ return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = 0, .body = null };
+ }
+ const body = try bodyText(alloc, t);
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ while (it.next()) |ln| try ls.append(alloc, ln);
+ return .{ .lines = try ls.toOwnedSlice(alloc), .row0 = paneScroll(t), .body = body };
+}
+
+fn freePaneLines(alloc: std.mem.Allocator, pl: PaneLines) void {
+ alloc.free(pl.lines);
+ if (pl.body) |b| alloc.free(b);
+}
+
+fn toModalCursor(t: *Term, pl: PaneLines) modal.Cursor {
+ const r: i32 = t.cur_row - pl.row0;
+ return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, t.cur_col)) };
+}
+
+// write a modal Cursor back to the Term (absolute row, pin, clear run offset)
+fn fromModalCursor(t: *Term, pl: PaneLines, c: modal.Cursor) void {
+ t.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
+ t.cur_col = @intCast(c.col);
+ t.cur_off = 0;
+ t.cur_pinned = true;
+}
+
+// apply a motion result: move cursor, collapse any line selection, keep it visible
+fn applyMove(t: *Term, pl: PaneLines, c: modal.Cursor) void {
+ fromModalCursor(t, pl, c);
+ t.msel.active = false;
+ t.pending = 0;
+ ensureCursorVisible(t);
+}
+
+fn ensureCursorVisible(t: *Term) void {
+ const off = paneScroll(t);
+ const last = off + @as(i32, t.rows) - 1;
+ if (t.cur_row < off) paneScrollBy(t, t.cur_row - off)
+ else if (t.cur_row > last) paneScrollBy(t, t.cur_row - last);
+}
+
+fn alignViewportTop(t: *Term) void {
+ paneScrollBy(t, t.cur_row - paneScroll(t));
+}
+fn alignViewportBottom(t: *Term) void {
+ paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows) - 1));
+}
+fn alignViewportCenter(t: *Term) void {
+ paneScrollBy(t, t.cur_row - (paneScroll(t) + @as(i32, t.rows / 2)));
+}
+
+// pin the cursor to the shell's current cursor (terminal) or the scroll top
+// (file), the first time the user interacts in normal/insert mode.
+fn pinCursor(t: *Term) void {
+ if (t.cur_pinned) return;
+ const off: i32 = paneScroll(t);
+ if (t.file != null) {
+ t.cur_row = off;
+ t.cur_col = 0;
+ } else {
+ t.cur_row = @as(i32, @intCast(t.term.screens.active.cursor.y)) + off;
+ t.cur_col = @intCast(t.term.screens.active.cursor.x);
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+}
+
+// the last navigable row: file -> last content line; terminal -> viewport bottom.
+fn lastNavRow(t: *Term) i32 {
+ if (t.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ return paneScroll(t) + @as(i32, t.rows) - 1;
+}
+
+const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+// enter insert mode, positioning the cursor per `where`. For file panes,
+// open_below/open_above splice a newline into the content; terminals just move.
+fn enterInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, where: InsertAt) void {
+ pinCursor(t);
+ const pl = paneCursorLines(talloc, t) catch {
+ t.mode = .insert;
+ t.msel.active = false;
+ t.pending = 0;
+ return;
+ };
+ defer freePaneLines(talloc, pl);
+ const cur = toModalCursor(t, pl);
+ const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ switch (where) {
+ .at => {},
+ .append => fromModalCursor(t, pl, modal.charRight(pl.lines, cur)),
+ .line_start => fromModalCursor(t, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .line_end => {
+ t.cur_col = @intCast(llen);
+ t.cur_pinned = true;
+ },
+ .open_below => {
+ if (t.file) |*f| {
+ const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
+ t.cur_col = 0;
+ } else {
+ const bottom = paneScroll(t) + @as(i32, t.rows) - 1;
+ t.cur_row = @min(bottom, t.cur_row + 1);
+ t.cur_col = 0;
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ },
+ .open_above => {
+ if (t.file) |*f| {
+ const new = modal.insertAt(gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ // current line shifts down; the new empty line is at cur.row
+ t.cur_col = 0;
+ } else {
+ const top = paneScroll(t);
+ t.cur_row = @max(top, t.cur_row - 1);
+ t.cur_col = 0;
+ }
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ },
+ }
+ // run-offset for the terminal insert model: `i` inserts before the run at the
+ // cursor (off 0); the others append after any run already sitting there.
+ t.cur_off = if (where == .at) 0 else if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ t.mode = .insert;
+ t.msel.active = false;
+ t.pending = 0;
+ ensureCursorVisible(t);
+}
+
+// helix `x`: extend the line selection down by one (or start it at the cursor).
+fn lineSelect(t: *Term) void {
+ if (!t.msel.active) {
+ t.msel = .{ .active = true, .r0 = t.cur_row, .r1 = t.cur_row };
+ } else {
+ const last = lastNavRow(t);
+ t.msel.r1 = @min(t.msel.r1 + 1, last);
+ t.cur_row = t.msel.r1;
+ }
+ t.cur_col = 0;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.pending = 0;
+}
+
+// drop all insertion runs whose row is in [r0, r1] (terminal delete/change).
+fn clearEditsInRange(t: *Term, r0: i32, r1: i32) void {
+ var w: usize = 0;
+ for (t.edits[0..t.nedits]) |e| {
+ if (e.row >= r0 and e.row <= r1) continue;
+ t.edits[w] = e;
+ w += 1;
+ }
+ t.nedits = w;
+}
+
+// join the rendered body rows [r0, r1] (absolute) with '\n'. `alloc` is scratch;
+// caller (setYank) copies it into the gpa-owned register before the frame ends.
+fn yankRows(alloc: std.mem.Allocator, t: *Term, r0: i32, r1: i32) []const u8 {
+ const body = bodyText(alloc, t) catch return "";
+ defer alloc.free(body);
+ const off = paneScroll(t);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, body, "\n");
+ var i: i32 = 0;
+ while (it.next()) |ln| : (i += 1) {
+ const abs = i + off;
+ if (abs < r0 or abs > r1) continue;
+ if (abs > r0) out.append(alloc, '\n') catch return "";
+ out.appendSlice(alloc, ln) catch return "";
+ }
+ return out.toOwnedSlice(alloc) catch "";
+}
+
+fn normalDelete(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = d.content;
+ setYank(gpa, d.deleted);
+ gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ t.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
+ t.cur_col = 0;
+ } else {
+ clearEditsInRange(t, r0, r1);
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ }
+ t.msel.active = false;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ } else if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ const new = modal.deleteChar(gpa, f.content, c) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ } // terminal, no selection: can't delete shell output -> no-op
+}
+
+fn normalYank(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(gpa, '\n') catch break;
+ buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
+ }
+ setYank(gpa, buf.items);
+ buf.deinit(gpa);
+ } else {
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ }
+ t.cur_row = r0;
+ t.cur_col = 0;
+ t.msel.active = false;
+ } else if (t.file) |f| {
+ setYank(gpa, modal.lineSlice(f.content, @intCast(@max(0, t.cur_row))));
+ } else {
+ setYank(gpa, yankRows(talloc, t, t.cur_row, t.cur_row));
+ }
+}
+
+fn normalPaste(gpa: std.mem.Allocator, t: *Term) void {
+ const y = yank_reg orelse return;
+ if (t.file) |*f| {
+ const new = modal.pasteLineBelow(gpa, f.content, @intCast(@max(0, t.cur_row)), y) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row += 1;
+ t.cur_col = 0;
+ } else {
+ // terminal: spill the yanked text into an insertion run at the cursor
+ insertRunText(t, y);
+ }
+ t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+}
+
+fn normalChange(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term) void {
+ if (t.msel.active) {
+ const r0 = @min(t.msel.r0, t.msel.r1);
+ const r1 = @max(t.msel.r0, t.msel.r1);
+ if (t.file) |*f| {
+ // keep one empty line at r0, drop the rest; yank the removed text
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ const lo: usize = @intCast(@max(0, r0));
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(gpa, '\n') catch break;
+ buf.appendSlice(gpa, modal.lineSlice(f.content, r)) catch break;
+ }
+ setYank(gpa, buf.items);
+ buf.deinit(gpa);
+ if (r1 > r0) {
+ const d = modal.deleteLines(gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = d.content;
+ gpa.free(d.deleted);
+ }
+ const cl = modal.clearLine(gpa, f.content, @intCast(@max(0, r0))) catch {
+ t.msel.active = false;
+ return;
+ };
+ gpa.free(f.content);
+ f.content = cl;
+ t.cur_row = r0;
+ t.cur_col = 0;
+ } else {
+ clearEditsInRange(t, r0, r1);
+ setYank(gpa, yankRows(talloc, t, r0, r1));
+ t.cur_row = r0;
+ t.cur_col = 0;
+ }
+ t.msel.active = false;
+ t.cur_off = 0;
+ t.cur_pinned = true;
+ t.mode = .insert;
+ t.pending = 0;
+ ensureCursorVisible(t);
+ } else if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ const new = modal.deleteChar(gpa, f.content, c) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.mode = .insert;
+ t.cur_off = 0;
+ t.pending = 0;
+ } else {
+ // terminal, no selection: just enter insert at the cursor
+ t.mode = .insert;
+ t.cur_off = 0;
+ t.pending = 0;
+ }
+}
+
+// insert `text` into the terminal run model at the cursor (the existing char-mode
+// splice). Factored out so `p`aste reuses it.
+fn insertRunText(t: *Term, text: []const u8) void {
+ var idx: ?usize = runAt(t, t.cur_row, t.cur_col);
+ if (idx == null and t.nedits < Term.MAX_EDITS) {
+ t.edits[t.nedits] = .{ .row = t.cur_row, .col = t.cur_col };
+ t.nedits += 1;
+ idx = t.nedits - 1;
+ }
+ if (idx) |ei| {
+ const e = &t.edits[ei];
+ for (text) |ch| {
+ if (e.len >= Term.EDIT_CAP) break;
+ if (t.cur_off > e.len) t.cur_off = e.len;
+ var k: usize = e.len;
+ while (k > t.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
+ e.text[t.cur_off] = ch;
+ e.len += 1;
+ t.cur_off += 1;
+ }
+ }
+}
+
+// normal-mode (helix-style) key dispatch on a pane. The cursor is a block that
+// moves over the rendered text (file content lines, or the terminal viewport);
+// paneCursorLines builds that view per keystroke. Bindings (helix defaults,
+// adapted to the acme text model):
+// move h j k l (or arrows) word w b e / W B E (long)
+// line 0 $ ^ (or home/end) goto g g (top) G (last) g e g h g l
+// page Ctrl-d/u (half) Ctrl-f/b (full) PgUp/PgDn
+// viewport z t / z z / z b (top/center/bottom)
+// insert i a I A o O (at/append/line-start/line-end/below/above)
+// select line x (extend down) delete d change c yank y paste p
+// mode Esc -> insert exits to normal; Enter -> tty (terminals only)
+// window Ctrl-w h/j/k/l focus neighbor pane (Alt-n new term, Alt-c new col)
+// File panes mutate their content (real edits); terminals yank rendered text and
+// paste it as an insertion run (shell output can't be deleted, so d/c there only
+// clear pasted runs in the selection). Select mode (`v`, char ranges) is not yet
+// wired; use `x` for line-oriented selection.
+// Enter tty mode from normal (terminals only). tty-native: if the shell is at a
+// prompt and the modal cursor sits on the current input line, move the shell's
+// REAL cursor there via ghostty's promptClickMove (sends left/right arrows the
+// shell understands) — so dropping into tty lands where you navigated: at the
+// prompt start when the input is empty, or at/after typed text at the end. Then
+// hand the keyboard to the pty. If the shell isn't at a prompt (outputting) or
+// the cursor is above the prompt, just switch — the shell cursor stays put.
+// Requires OSC 133 with cl=line (our bash_rc emits it).
+fn enterTty(t: *Term) void {
+ const screen = t.term.screens.active;
+ if (t.term.cursorIsAtPrompt()) {
+ const off = paneScroll(t);
+ const vp_row: i32 = t.cur_row - off;
+ if (vp_row >= 0) {
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, t.cur_col)), .y = @intCast(vp_row) } })) |click_pin| {
+ const cursor_pin = screen.cursor.page_pin.*;
+ var pit = cursor_pin.promptIterator(.left_up, null);
+ if (pit.next()) |prompt_pin| {
+ if (!click_pin.before(prompt_pin)) {
+ const m = screen.promptClickMove(click_pin);
+ var i: usize = 0;
+ while (i < m.left) : (i += 1) termWrite(t, "\x1b[D");
+ i = 0;
+ while (i < m.right) : (i += 1) termWrite(t, "\x1b[C");
+ }
+ }
+ }
+ }
+ }
+ t.mode = .tty;
+ t.msel.active = false;
+ t.pending = 0;
+}
+
+fn handleNormal(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
+ pinCursor(t);
+ // terminals: Enter drops into tty, positioning the shell cursor at the modal
+ // cursor's spot on the input line (start if empty, end if typed text).
+ if (t.file == null and key.matches(vaxis.Key.enter, .{})) return enterTty(t);
+ const pl = paneCursorLines(talloc, t) catch return;
+ defer freePaneLines(talloc, pl);
+ const cur = toModalCursor(t, pl);
+ const lines = pl.lines;
+ const half: usize = @max(1, t.rows / 2);
+
+ // prefix continuations
+ if (t.pending == 'g') {
+ t.pending = 0;
+ if (key.matches('g', .{})) return applyMove(t, pl, modal.gotoFirst());
+ if (key.matches('e', .{})) return applyMove(t, pl, modal.gotoLast(lines));
+ if (key.matches('h', .{})) return applyMove(t, pl, modal.lineStart(cur));
+ if (key.matches('l', .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
+ return;
+ }
+ if (t.pending == 'z') {
+ t.pending = 0;
+ if (key.matches('t', .{})) return alignViewportTop(t);
+ if (key.matches('z', .{})) return alignViewportCenter(t);
+ if (key.matches('b', .{})) return alignViewportBottom(t);
+ return;
+ }
+ if (key.matches('g', .{})) {
+ t.pending = 'g';
+ return;
+ }
+ if (key.matches('z', .{})) {
+ t.pending = 'z';
+ return;
+ }
+
+ // movement
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) return applyMove(t, pl, modal.charLeft(cur));
+ if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) return applyMove(t, pl, modal.charRight(lines, cur));
+ if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) return applyMove(t, pl, modal.lineDown(lines, cur));
+ if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) return applyMove(t, pl, modal.lineUp(lines, cur));
+ if (key.matches('w', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, false));
+ if (key.matches('b', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, false));
+ if (key.matches('e', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, false));
+ if (key.matches('W', .{})) return applyMove(t, pl, modal.nextWordStart(lines, cur, true));
+ if (key.matches('B', .{})) return applyMove(t, pl, modal.prevWordStart(lines, cur, true));
+ if (key.matches('E', .{})) return applyMove(t, pl, modal.nextWordEnd(lines, cur, true));
+ if (key.matches('0', .{}) or key.matches(vaxis.Key.home, .{})) return applyMove(t, pl, modal.lineStart(cur));
+ if (key.matches('$', .{}) or key.matches(vaxis.Key.end, .{})) return applyMove(t, pl, modal.lineEnd(lines, cur));
+ if (key.matches('^', .{})) return applyMove(t, pl, modal.firstNonWsOf(lines, cur));
+ if (key.matches('G', .{})) return applyMove(t, pl, modal.gotoLast(lines));
+ if (key.matches('d', .{ .ctrl = true }) or key.matches(vaxis.Key.page_down, .{}))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
+ if (key.matches('u', .{ .ctrl = true }) or key.matches(vaxis.Key.page_up, .{}))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
+ if (key.matches('f', .{ .ctrl = true }))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, t.rows))));
+ if (key.matches('b', .{ .ctrl = true }))
+ return applyMove(t, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, t.rows))));
+
+ // insert entry
+ if (key.matches('i', .{})) return enterInsert(gpa, talloc, t, .at);
+ if (key.matches('a', .{})) return enterInsert(gpa, talloc, t, .append);
+ if (key.matches('I', .{})) return enterInsert(gpa, talloc, t, .line_start);
+ if (key.matches('A', .{})) return enterInsert(gpa, talloc, t, .line_end);
+ if (key.matches('o', .{})) return enterInsert(gpa, talloc, t, .open_below);
+ if (key.matches('O', .{})) return enterInsert(gpa, talloc, t, .open_above);
+
+ // line selection + edit (helix `x` then d/c/y; p pastes)
+ if (key.matches('x', .{})) return lineSelect(t);
+ if (key.matches('d', .{})) return normalDelete(gpa, talloc, t);
+ if (key.matches('c', .{})) return normalChange(gpa, talloc, t);
+ if (key.matches('y', .{})) return normalYank(gpa, talloc, t);
+ if (key.matches('p', .{})) return normalPaste(gpa, t);
+}
+
+// insert-mode key dispatch on a pane. File: real content editing; terminal: the
+// char-mode run splice (text shifts the underlying tty row right).
+fn handleInsert(gpa: std.mem.Allocator, talloc: std.mem.Allocator, t: *Term, key: vaxis.Key) void {
+ if (t.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, t.cur_row)), .col = @intCast(@max(0, t.cur_col)) };
+ if (key.text) |text| {
+ const new = modal.insertAt(gpa, f.content, c, text) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ for (text) |ch| {
+ if (ch == '\n') {
+ t.cur_row += 1;
+ t.cur_col = 0;
+ } else t.cur_col += 1;
+ }
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ return;
+ }
+ switch (key.codepoint) {
+ vaxis.Key.enter => {
+ const new = modal.insertAt(gpa, f.content, c, "\n") catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row += 1;
+ t.cur_col = 0;
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ },
+ vaxis.Key.backspace => {
+ if (t.cur_col > 0) {
+ const new = modal.deleteChar(gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_col -= 1;
+ } else if (t.cur_row > 0) {
+ const prevlen = modal.lineSlice(f.content, c.row - 1).len;
+ const new = modal.deleteChar(gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ gpa.free(f.content);
+ f.content = new;
+ t.cur_row -= 1;
+ t.cur_col = @intCast(prevlen);
+ }
+ t.cur_pinned = true;
+ ensureCursorVisible(t);
+ },
+ vaxis.Key.left, vaxis.Key.right, vaxis.Key.up, vaxis.Key.down => {
+ const pl = paneCursorLines(talloc, t) catch return;
+ defer freePaneLines(talloc, pl);
+ const cur0 = toModalCursor(t, pl);
+ const nc = switch (key.codepoint) {
+ vaxis.Key.left => modal.charLeft(cur0),
+ vaxis.Key.right => modal.charRight(pl.lines, cur0),
+ vaxis.Key.up => modal.lineUp(pl.lines, cur0),
+ vaxis.Key.down => modal.lineDown(pl.lines, cur0),
+ else => cur0,
+ };
+ fromModalCursor(t, pl, nc);
+ ensureCursorVisible(t);
+ },
+ else => {},
+ }
+ return;
+ }
+
+ // terminal: char-mode run insert (unchanged from the original editor)
+ if (!t.cur_pinned) pinCursor(t);
+ if (key.text) |text| {
+ insertRunText(t, text);
+ } else switch (key.codepoint) {
+ vaxis.Key.left => {
+ if (t.cur_off > 0) t.cur_off -= 1 else {
+ t.cur_col = @max(0, t.cur_col - 1);
+ t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ }
+ },
+ vaxis.Key.right => {
+ const rl: u16 = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
+ if (t.cur_off < rl) t.cur_off += 1 else {
+ t.cur_col += 1;
+ t.cur_off = 0;
+ }
+ },
+ vaxis.Key.up => {
+ const off = paneScroll(t);
+ t.cur_row = @max(off, t.cur_row - 1);
+ t.cur_off = 0;
+ },
+ vaxis.Key.down => {
+ const off = paneScroll(t);
+ t.cur_row = @min(off + @as(i32, t.rows) - 1, t.cur_row + 1);
+ t.cur_off = 0;
+ },
+ else => {},
+ }
+}
+
+// tty mode: forward the key to the pty as terminal input. Ctrl+letter is sent as
+// the raw control byte (so the shell sees Ctrl-D EOF, Ctrl-L clear, ...).
+fn forwardToPty(t: *Term, key: vaxis.Key) void {
+ var cbuf: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.mods.ctrl) {
+ const cp = key.codepoint;
+ if (cp >= 'a' and cp <= 'z') {
+ cbuf[0] = @intCast(cp - 0x60);
+ break :blk cbuf[0..1];
+ }
+ if (cp == '@') {
+ cbuf[0] = 0;
+ break :blk cbuf[0..1];
+ }
+ if (cp >= '[' and cp <= '_') {
+ cbuf[0] = @intCast(cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ if (cp >= 'A' and cp <= 'Z') {
+ cbuf[0] = @intCast(cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ }
+ if (key.text) |text| break :blk text;
+ break :blk switch (key.codepoint) {
+ vaxis.Key.enter => "\r",
+ vaxis.Key.backspace => "\x7f",
+ vaxis.Key.tab => "\t",
+ vaxis.Key.escape => "\x1b",
+ vaxis.Key.up => "\x1b[A",
+ vaxis.Key.down => "\x1b[B",
+ vaxis.Key.right => "\x1b[C",
+ vaxis.Key.left => "\x1b[D",
+ else => null,
+ };
+ };
+ if (bytes) |b| termWrite(t, b);
+}
+
+// move focus to the neighbor pane in `dir`, if any. Rects are the current tiling.
+fn focusDir(active: *usize, terms: *const [MAX_TERMS]?*Term, rects: *const [MAX_TERMS]Rect, dir: enum { left, right, up, down }) void {
+ const a = rects[active.*];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (terms, 0..) |slot, i| {
+ if (slot == null or i == active.*) continue;
+ const r = rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h; // vertical overlap
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w; // horizontal overlap
+ const ok = switch (dir) {
+ .left => r.x + r.w <= a.x and vov,
+ .right => r.x >= a.x + a.w and vov,
+ .up => r.y + r.h <= a.y and hov,
+ .down => r.y >= a.y + a.h and hov,
+ };
+ if (!ok) continue;
+ const d: i32 = switch (dir) {
+ .left => a.x - (r.x + r.w),
+ .right => r.x - (a.x + a.w),
+ .up => a.y - (r.y + r.h),
+ .down => r.y - (a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| active.* = b;
+}
+
pub fn main(init: std.process.Init) !void {
const io = init.io;
const gpa = init.gpa;
@@ -650,6 +1383,7 @@ pub fn main(init: std.process.Init) !void {
// the retained vaxis screen can keep referencing it through vx.render().
var frame_arena: std.heap.ArenaAllocator = .init(gpa);
defer frame_arena.deinit();
+ defer freeYank(gpa);
var ov_buf: [128]u8 = undefined; // overlay text; must outlive each vx.render()
var tty_buf: [0x10000]u8 = undefined;
@@ -715,6 +1449,7 @@ pub fn main(init: std.process.Init) !void {
var drag: Drag = .none;
var hover_col: u16 = 0; // last mouse position; used to tint the resize border under it
var hover_row: u16 = 0;
+ var ctrl_w_pending: bool = false; // Ctrl-w prefix for helix-style pane focus
while (true) {
const event = try loop.nextEvent();
@@ -1034,14 +1769,39 @@ pub fn main(init: std.process.Init) !void {
} else break;
}
},
+ .tutor => {
+ // spawn the Tutor as a file pane in the LEFT
+ // column (acme convention for files), stacked
+ // at the top with a fair share of the column.
+ const slot: ?usize = for (terms, 0..) |sl, i| {
+ if (sl == null) break i;
+ } else null;
+ if (slot) |free| {
+ if (openTutorView(gpa, screen_w, screen_h)) |nt| {
+ terms[free] = nt;
+ const lc = 0;
+ const ln = layout.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (terms[layout.col_terms[lc][i]]) |p| {
+ vsum += p.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ layoutInsert(&layout, lc, 0, free);
+ active = free;
+ evictLonePristineTty(&layout, &terms, io, gpa, lc, free, &active);
+ } else |_| {}
+ }
+ },
}
} else |_| {}
if (s.button == .left) {
- // left-click in the body enters char mode and
- // anchors the cursor at the selection end. the
- // row is stored absolute (visual + scroll off) so
- // it rides the scrollback. clicks on the tag (row
- // < BOX_H) don't edit.
+ // left-click in the body enters NORMAL mode and
+ // anchors the cursor at the selection end (a
+ // visual column). The row is stored absolute
+ // (visual + scroll off) so it rides the scrollback.
+ // clicks on the tag (row < BOX_H) don't edit.
const sl = t.sel[@intFromEnum(Button.left)];
const body_vis = sl.r1 - @as(i32, BOX_H);
if (body_vis >= 0) {
@@ -1050,7 +1810,9 @@ pub fn main(init: std.process.Init) !void {
t.cur_col = sl.c1;
t.cur_off = if (runAt(t, t.cur_row, t.cur_col)) |i| t.edits[i].len else 0;
t.cur_pinned = true;
- t.editing = true;
+ t.mode = .normal;
+ t.msel.active = false;
+ t.pending = 0;
}
}
}
@@ -1060,10 +1822,27 @@ pub fn main(init: std.process.Init) !void {
drag = .none;
}
},
- .key_press => |key| {
+ .key_press => |key| kpress: {
if (key.matches('c', .{ .ctrl = true })) break;
if (terms[active]) |at| {
+ // Ctrl-w prefix: helix-style pane focus (Ctrl-w h/j/k/l).
+ if (ctrl_w_pending) {
+ ctrl_w_pending = false;
+ if (key.matches('h', .{}) or key.matches(vaxis.Key.left, .{})) focusDir(&active, &terms, &rects, .left)
+ else if (key.matches('l', .{}) or key.matches(vaxis.Key.right, .{})) focusDir(&active, &terms, &rects, .right)
+ else if (key.matches('k', .{}) or key.matches(vaxis.Key.up, .{})) focusDir(&active, &terms, &rects, .up)
+ else if (key.matches('j', .{}) or key.matches(vaxis.Key.down, .{})) focusDir(&active, &terms, &rects, .down);
+ if (terms[active]) |nt| nt.pending = 0;
+ break :kpress;
+ }
+ if (key.matches('w', .{ .ctrl = true })) {
+ ctrl_w_pending = true;
+ break :kpress;
+ }
+
+ // global window ops (any mode): Alt-n new terminal below,
+ // Alt-c move active terminal into a fresh column.
if (key.matches('n', .{ .alt = true })) {
// new terminal below the active one, in its column.
// ponytail: forks from the now-multithreaded process;
@@ -1080,97 +1859,31 @@ pub fn main(init: std.process.Init) !void {
active = slot;
} else |_| {}
}
- } else if (key.matches('c', .{ .alt = true })) {
+ break :kpress;
+ }
+ if (key.matches('c', .{ .alt = true })) {
// move active terminal into a fresh column
const f = layoutFindTerm(&layout, active).?;
if (layout.ncol < MAX_COLS and layout.col_n[f.col] > 1) {
layoutRemove(&layout, active);
layoutAppendColumn(&layout, active);
}
- } else if (key.matches('d', .{ .ctrl = true })) {
- paneScrollBy(at, 15);
- } else if (key.matches('u', .{ .ctrl = true })) {
- paneScrollBy(at, -15);
- } else if (at.editing and at.file == null and key.matches(vaxis.Key.escape, .{})) {
- at.editing = false; // leave char mode (don't forward Esc); files have no tty mode
- } else if (at.editing) {
- // char mode: printable text inserts at the cursor (shifting
- // the current run); arrows move the cursor; backspace/etc.
- // are no-ops for now (deletion comes later).
- if (key.text) |text| {
- // first keystroke pins the cursor (it was tracking the
- // shell cursor until now).
- if (!at.cur_pinned) {
- const coff: i32 = paneScroll(at);
- if (at.file != null) {
- at.cur_row = coff;
- at.cur_col = 0;
- } else {
- at.cur_row = @as(i32, @intCast(at.term.screens.active.cursor.y)) + coff;
- at.cur_col = @intCast(at.term.screens.active.cursor.x);
- }
- at.cur_off = 0;
- at.cur_pinned = true;
- }
- // find or create the run at the cursor
- var idx: ?usize = runAt(at, at.cur_row, at.cur_col);
- if (idx == null and at.nedits < Term.MAX_EDITS) {
- at.edits[at.nedits] = .{ .row = at.cur_row, .col = at.cur_col };
- at.nedits += 1;
- idx = at.nedits - 1;
- }
- if (idx) |ei| {
- const e = &at.edits[ei];
- for (text) |ch| {
- if (e.len >= Term.EDIT_CAP) break;
- if (at.cur_off > e.len) at.cur_off = e.len;
- var k: usize = e.len;
- while (k > at.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
- e.text[at.cur_off] = ch;
- e.len += 1;
- at.cur_off += 1;
- }
- }
- } else switch (key.codepoint) {
- vaxis.Key.left => {
- if (at.cur_off > 0) at.cur_off -= 1 else {
- at.cur_col = @max(0, at.cur_col - 1);
- at.cur_off = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0;
- }
- },
- vaxis.Key.right => {
- const rl: u16 = if (runAt(at, at.cur_row, at.cur_col)) |i| at.edits[i].len else 0;
- if (at.cur_off < rl) at.cur_off += 1 else {
- at.cur_col += 1;
- at.cur_off = 0;
- }
- },
- vaxis.Key.up => {
- const off: i32 = paneScroll(at);
- at.cur_row = @max(off, at.cur_row - 1);
- at.cur_off = 0;
- },
- vaxis.Key.down => {
- const off: i32 = paneScroll(at);
- at.cur_row = @min(off + @as(i32, at.rows) - 1, at.cur_row + 1);
- at.cur_off = 0;
- },
- else => {},
- }
- } else {
- // sym mode: forward to the pty as terminal input.
- const bytes: ?[]const u8 = if (key.text) |text| text else switch (key.codepoint) {
- vaxis.Key.enter => "\r",
- vaxis.Key.backspace => "\x7f",
- vaxis.Key.tab => "\t",
- vaxis.Key.escape => "\x1b",
- vaxis.Key.up => "\x1b[A",
- vaxis.Key.down => "\x1b[B",
- vaxis.Key.right => "\x1b[C",
- vaxis.Key.left => "\x1b[D",
- else => null,
- };
- if (bytes) |b| termWrite(at, b);
+ break :kpress;
+ }
+
+ // per-mode dispatch. Esc: insert -> normal (normal Esc is a
+ // no-op, like helix). Enter from normal reaches tty on terminals
+ // (handled in handleNormal). tty forwards every key to the pty.
+ switch (at.mode) {
+ .normal => handleNormal(gpa, frame_arena.allocator(), at, key),
+ .insert => {
+ if (key.matches(vaxis.Key.escape, .{})) {
+ at.mode = .normal;
+ at.msel.active = false;
+ at.pending = 0;
+ } else handleInsert(gpa, frame_arena.allocator(), at, key);
+ },
+ .tty => forwardToPty(at, key),
}
}
},
@@ -1461,11 +2174,15 @@ fn bodyText(alloc: std.mem.Allocator, t: *Term) ![]u8 {
const off: i32 = @intCast(t.term.screens.active.pages.scrollbar().offset);
const body = try t.term.plainString(alloc);
defer alloc.free(body);
- // text mode (editing) hides shell prompt rows; tty mode shows everything.
+ // text mode hides shell prompt rows only in INSERT mode (the acme compose
+ // surface: a clean blank line to type into). NORMAL mode shows the real
+ // terminal — prompts + typed input visible — so the modal cursor can navigate
+ // to the input line and Enter drops into tty at that spot (start vs end).
+ // tty mode shows everything.
var flagbuf: [512]bool = undefined;
const nrows: usize = @min(t.rows, flagbuf.len);
const flags = flagbuf[0..nrows];
- if (t.editing) promptFlags(t, flags) else @memset(flags, false);
+ if (t.mode == .insert) promptFlags(t, flags) else @memset(flags, false);
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
// pad to the full grid height: plainString trims trailing blank rows, but we
diff --git a/modal.zig b/modal.zig
new file mode 100644
index 00000000..3038c430
--- /dev/null
+++ b/modal.zig
@@ -0,0 +1,909 @@
+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) };
+}
+
+// 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 "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);
+}
diff --git a/term.zig b/term.zig
index b0b424ee..6f43f586 100644
--- a/term.zig
+++ b/term.zig
@@ -11,6 +11,23 @@ pub const Sel = struct {
r1: i32 = 0,
};
+/// Modal input mode. normal/insert are the helix-style text-editing modes; tty
+/// forwards keys to the shell (terminal panes only — file panes never use it).
+/// normal -> helix NORMAL: hjkl/w/b/e/... move a block cursor; i/a/o -> insert;
+/// Esc -> tty (terminals) or stays (files).
+/// insert -> keys insert text at the cursor (acme click-and-type); Esc -> normal.
+/// tty -> keys -> pty as terminal sequences; a body left-click returns to normal.
+pub const Mode = enum { normal, insert, tty };
+
+/// A modal line selection (helix `x` extends the selection down line by line).
+/// r0/r1 are ABSOLUTE body rows (visual + scroll off). active == false means the
+/// cursor is a plain 1-char selection (normal mode default).
+pub const LineSel = struct {
+ active: bool = false,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
pub const EDIT_CAP = 128; // max chars in one insertion run
pub const MAX_EDITS = 16; // max concurrent insertion runs per window
@@ -62,16 +79,21 @@ vweight: f32 = 1,
/// One independent selection per mouse button: 0=left, 1=middle, 2=right.
sel: [3]Sel = .{ .{}, .{}, .{} },
-/// modal input. true = "text"/char mode (DEFAULT; keys insert text at the
-/// cursor, shell prompts hidden), false = "tty"/sym mode (keys -> pty as
-/// terminal sequences, prompts shown). Esc toggles to tty; a left-click returns
-/// to text mode and anchors the cursor.
-///
-/// The cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll
+/// modal input mode (see Mode). DEFAULT is normal (helix): a block cursor that
+/// moves with hjkl; i/a/o enter insert; Esc reaches tty on terminals.
+mode: Mode = .normal,
+/// one pending prefix codepoint (0 = none): `g` (gg/ge/...) and `z` (zt/zz/zb).
+pending: u21 = 0,
+/// modal line selection from `x` (helix extend-line). In absolute body rows.
+msel: LineSel = .{},
+/// cursor and edits are anchored at ABSOLUTE body rows (visual row + scroll
/// offset) so they ride the scrollback, not the screen. `cur_off` is the offset
/// into the run sitting at (cur_row, cur_col). `cur_pinned` is false until the
/// user clicks or types, while which the cursor tracks the shell's cursor.
-editing: bool = true,
+///
+/// For terminals in normal mode cur_col is a VISUAL column; in insert mode it is
+/// the original-tty column the run is anchored at (renderedCol maps it to visual).
+/// For file panes cur_col is a content column (no edit runs; edits stay empty).
cur_pinned: bool = false,
cur_row: i32 = 0,
cur_col: i32 = 0,
diff --git a/tests.zig b/tests.zig
index d9cc763b..c389b8cf 100644
--- a/tests.zig
+++ b/tests.zig
@@ -1,103 +1,20 @@
const std = @import("std");
-const posix = std.posix;
-const linux = std.os.linux;
+const h = @import("e2e_harness.zig");
+const Harness = h.Harness;
+const config = @import("config");
-extern "c" fn forkpty(
- amaster: *c_int,
- name: ?[*:0]u8,
- termp: ?*const anyopaque,
- winp: ?*const posix.winsize,
-) c_int;
-extern "c" fn execvp(file: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
-extern "c" fn _exit(status: c_int) noreturn;
-
-fn writeAll(fd: c_int, bytes: []const u8) !void {
- var off: usize = 0;
- while (off < bytes.len) {
- const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off);
- switch (posix.errno(rc)) {
- .SUCCESS => off += @intCast(rc),
- .INTR => {},
- else => |err| {
- std.debug.print("write failed: {}\n", .{err});
- return error.WriteFailed;
- },
- }
- }
-}
-
-fn writeFileE2E(path: [*:0]const u8, data: []const u8) !void {
- const rc = linux.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
- const sfd: isize = @bitCast(rc);
- if (sfd < 0) return error.OpenFailed;
- const fd: c_int = @intCast(sfd);
- defer _ = linux.close(fd);
- try writeAll(fd, data);
-}
-
-// TIOCSWINSZ on the pty master: changes the size and makes the kernel raise
-// SIGWINCH in the slave's foreground process group (i.e. the app under test).
-fn setWinsize(fd: c_int, rows: u16, cols: u16) !void {
- const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 };
- const rc = linux.ioctl(fd, linux.T.IOCSWINSZ, @intFromPtr(&ws));
- if (posix.errno(rc) != .SUCCESS) return error.IoctlFailed;
-}
-
-// Poll the pty for up to `iters` * 100ms, appending everything read into
-// `output`. Returns true as soon as `needle` is present in the accumulated
-// output (the app renders to the alt screen, so we just scan the byte stream).
-fn pollFor(
- gpa: std.mem.Allocator,
- master: c_int,
- output: *std.ArrayList(u8),
- needle: []const u8,
- iters: usize,
-) !bool {
- if (std.mem.indexOf(u8, output.items, needle) != null) return true;
- var buf: [4096]u8 = undefined;
- for (0..iters) |_| {
- var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 100) catch {};
- if ((fds[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR)) != 0) {
- const n = posix.read(master, &buf) catch break;
- if (n == 0) break;
- try output.appendSlice(gpa, buf[0..n]);
- if (std.mem.indexOf(u8, output.items, needle) != null) return true;
- }
- }
- return std.mem.indexOf(u8, output.items, needle) != null;
-}
-
-fn nowMs() i64 {
- var ts: linux.timespec = undefined;
- _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts);
- return ts.sec * 1000 + @divFloor(@as(i64, ts.nsec), 1_000_000);
-}
-
-// Drain the pty for a fixed WALL-CLOCK duration (a real settle). pollFor's
-// iter-count "settle" is unreliable for big bursts: reading caps at 4096/iter so
-// all iters can be spent reading with no idle wait, making it finish too soon.
-fn drainFor(gpa: std.mem.Allocator, master: c_int, output: *std.ArrayList(u8), ms: i64) !void {
- const deadline = nowMs() + ms;
- var buf: [4096]u8 = undefined;
- while (nowMs() < deadline) {
- var fds = [_]posix.pollfd{.{ .fd = master, .events = posix.POLL.IN, .revents = 0 }};
- _ = posix.poll(&fds, 50) catch {};
- if ((fds[0].revents & posix.POLL.IN) != 0) {
- const n = posix.read(master, &buf) catch break;
- if (n == 0) break;
- try output.appendSlice(gpa, buf[0..n]);
- }
- }
-}
-
-fn dump(items: []const u8, msg: []const u8) void {
- std.debug.print("E2E FAIL: {s}\ncaptured {d} bytes:\n{s}\n", .{
- msg,
- items.len,
- items[0..@min(items.len, 8192)],
- });
-}
+// E2E tests for pardes. The harness forks the app in a pty and parses its
+// output with a ghostty terminal emulator, so all assertions read the 2D screen
+// GRID (plainString) — not raw byte streams. This is decoupled from the app's
+// rendering (vaxis diff-rendering, repaints, escape sequences): the grid is
+// always current after pump/waitFor.
+//
+// Input is sent as raw bytes (keystrokes, SGR mouse sequences) that the app's
+// vaxis parses. The harness terminal only reads output.
+//
+// Mouse events use SGR pixel mode: \x1b[<button;col;row M (press) / m (release).
+// Cols/rows are 1-based screen coords. Button 0=left, 1=middle, 2=right.
+// A drag is press at start + release at end (with different coords).
pub fn main(init: std.process.Init) !void {
const gpa = init.gpa;
@@ -106,153 +23,167 @@ pub fn main(init: std.process.Init) !void {
std.debug.print("usage: {s} <pardes-exe>\n", .{args[0]});
return error.BadArgs;
}
-
const exe = args[1];
- // Boot the app in a small pty.
- var master: c_int = undefined;
- const ws = posix.winsize{ .row = 24, .col = 80, .xpixel = 0, .ypixel = 0 };
- const pid = forkpty(&master, null, null, &ws);
- if (pid == 0) {
- const child_argv = [_:null]?[*:0]const u8{ exe.ptr, null };
- _ = execvp(exe.ptr, &child_argv);
- _exit(127);
- }
- defer _ = linux.close(master);
- defer posix.kill(pid, posix.SIG.KILL) catch {};
-
- var output: std.ArrayList(u8) = .empty;
- defer output.deinit(gpa);
+ var hs = try Harness.init(gpa, exe.ptr, 24, 80);
+ hs.trace = config.trace;
+ defer hs.deinit();
// 1. The app posts an initial winsize on startup. Its winsize handler bumps
- // the resize counter to 1 and calls vx.resize, which forces a full
- // repaint, so "resizes 1" shows up in the rendered stream. This just
- // confirms the app is up and handled its first size.
- if (!try pollFor(gpa, master, &output, "resizes=1", 60)) {
- dump(output.items, "app never handled its initial winsize (no 'resizes=1')");
- return error.InitialResizeMissing;
- }
+ // the resize counter to 1 and repaints, so "resizes=1" shows in the debug
+ // overlay. Confirms the app is up and handled its first size.
+ try hs.expectWaitFor("resizes=1", 6000, "app never handled its initial winsize (no 'resizes=1')");
- // 2. Regression check for "resizes aren't happening": grow the outer pty.
- // This delivers SIGWINCH to the app. Only if the app installed its
- // resize handler will it post a new winsize event, bump the counter to
- // 2, and repaint -> "resizes 2". If the handler is missing the counter
- // stays at 1 forever and this times out.
- try setWinsize(master, 50, 200);
- if (!try pollFor(gpa, master, &output, "resizes=2", 80)) {
- dump(output.items, "SIGWINCH was not turned into a resize (counter stuck at 1)");
- return error.ResizeNotDelivered;
- }
+ // 2. Regression: grow the pty -> SIGWINCH -> app re-resizes. Counter bumps
+ // to 2 only if the resize handler is installed.
+ try hs.resize(30, 100);
+ try hs.expectWaitFor("resizes=2", 8000, "SIGWINCH was not turned into a resize (counter stuck at 1)");
- // NOTE on polls: vaxis diff-renders, and char-mode text is built one keystroke
- // per frame, so inserted chars are emitted across frames and never appear as a
- // contiguous byte run. We `settle` (drain so input is processed, also avoiding
- // a resize race), force a full repaint with setWinsize (re-emits whole rows
- // contiguously), then poll. Command *output* lands in one frame already.
-
- // 3. Default is TEXT mode: the tag shows "ch" and shell prompts are hidden.
- if (!try pollFor(gpa, master, &output, "ch /", 60)) {
- dump(output.items, "default tag is not 'ch' (text mode)");
- return error.TextModeNotDefault;
- }
+ // 3. Default is NORMAL mode: the tag shows "nm" (helix-style block cursor).
+ try hs.expectContains("nm /", "default tag is not 'nm' (normal mode not default)");
- // 4. Esc switches to TTY mode and forwards keystrokes to the shell. The settle
- // lets vaxis emit a *standalone* Escape (lone ESC + text would be Alt+char).
+ // 4. Enter switches to TTY mode from normal and forwards keystrokes to the
+ // shell. (Esc no longer reaches tty; Enter is the tty-enter key.)
const marker = "PARDES_E2E";
- try writeAll(master, "\x1b");
- _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
- try writeAll(master, "echo " ++ marker ++ "\r");
- if (!try pollFor(gpa, master, &output, marker, 80)) {
- dump(output.items, "tty mode did not forward the marker to the shell");
- return error.TtyModeNoForward;
- }
- try setWinsize(master, 50, 201); // repaint so the whole tag re-emits contiguously
- if (!try pollFor(gpa, master, &output, "sy /", 40)) {
- dump(output.items, "no 'sy' indicator after Esc (tty mode)");
- return error.TtyModeIndicatorMissing;
- }
+ try hs.send("\r"); // normal -> tty (Enter)
+ try hs.pump(600);
+ try hs.send("echo " ++ marker ++ "\r");
+ try hs.expectWaitFor(marker, 8000, "tty mode did not forward the marker to the shell");
+ try hs.expectContains("sy /", "no 'sy' indicator after Enter (tty mode)");
+
+ // 4b. Enter-to-tty POSITIONING (the start-vs-end distinction). Still in tty:
+ // clear to a clean screen, type input WITHOUT running it, go to normal,
+ // navigate the modal cursor to the last char of the typed text, then
+ // Enter. enterTty uses ghostty's promptClickMove to move the shell's real
+ // cursor there, so the next typed char inserts BEFORE that last char.
+ // Running it yields "ABCDEXF" — proving the shell cursor was repositioned
+ // mid-input. A no-op Enter (cursor stays at end) would append -> "ABCDEFX".
+ try hs.send("clear\r");
+ try hs.pump(800);
+ try hs.send("echo ABCDEF"); // typed input, NOT run (no \r)
+ try hs.pump(800);
+ try hs.send("\x1b[<0;3;2M\x1b[<0;3;2m"); // left-click body row 0, col 0 (col 3 = past gutter) -> normal
+ try hs.pump(600);
+ try hs.send("$"); // modal: end of row 0 (lands on 'F')
+ try hs.pump(600);
+ try hs.send("\r"); // Enter: normal -> tty, shell cursor moved onto 'F'
+ try hs.pump(600);
+ try hs.send("X\r"); // insert 'X' before 'F', then run -> "ABCDEXF"
+ try hs.expectWaitFor("ABCDEXF", 10000, "Enter did not reposition the shell cursor mid-input (no ABCDEXF)");
+ try hs.pump(600);
+ try hs.expectNotContains("ABCDEFX", "Enter left the shell cursor at the end (appended X -> ABCDEFX)");
// 5. Prompt hiding. In tty mode a command line + its output are both visible.
- // Switching back to text mode hides the prompt/command rows but keeps the
- // output. We clear the capture, switch with a left-click, force a repaint,
- // then check the fresh frame: output present, prompt + command absent.
- try writeAll(master, "echo CMDHIDEZZ\r");
- if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) {
- dump(output.items, "tty mode did not run the command");
- return error.TtyCommandFailed;
- }
- _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // drain all tty-mode frames
- output.clearRetainingCapacity(); // so the next poll only sees the text-mode repaint
- try writeAll(master, "\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> text mode
- _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle
- try setWinsize(master, 50, 202); // force a full repaint (text mode, prompts hidden)
- if (!try pollFor(gpa, master, &output, "CMDHIDEZZ", 80)) {
- dump(output.items, "command output not shown in text mode");
- return error.OutputHiddenInTextMode;
- }
- if (std.mem.indexOf(u8, output.items, "goblin@") != null) {
- dump(output.items, "shell prompt still visible in text mode");
- return error.PromptNotHidden;
- }
- if (std.mem.indexOf(u8, output.items, "echo CMDHIDEZZ") != null) {
- dump(output.items, "command line still visible in text mode");
- return error.CommandNotHidden;
- }
+ // Normal mode shows the real terminal (prompts visible); INSERT mode is the
+ // acme compose surface, hiding prompt/command rows but keeping the output.
+ // Run a command, left-click to normal, press `i` to enter insert, then
+ // check: output present, prompt + command absent.
+ try hs.send("echo CMDHIDEZZ\r");
+ try hs.expectWaitFor("CMDHIDEZZ", 8000, "tty mode did not run the command");
+ try hs.pump(800); // let tty-mode frames settle
+ try hs.send("\x1b[<0;6;3M\x1b[<0;6;3m"); // left-click body -> normal
+ try hs.pump(600);
+ try hs.send("i"); // normal -> insert (prompts now hidden)
+ try hs.pump(600);
+ try hs.expectContains("CMDHIDEZZ", "command output not shown in insert mode");
+ // The prompt row (with the typed command) is hidden in insert mode. We check
+ // "echo CMDHIDEZZ" (specific to this pane's prompt row) rather than
+ // "goblin@" because other panes in normal mode show their own prompts.
+ try hs.expectNotContains("echo CMDHIDEZZ", "command line still visible in insert mode (prompt row not hidden)");
- // 6. Text-mode editing + middle-click execute. Click a blank row to pin the
- // cursor at text col 0, compose a computed echo, then middle-select those
- // 15 cells (screen cols 2..16) and release. "Q7Z" appears only if the
- // SELECTED typed text ran — proving selection reads the rendered screen.
- try writeAll(master, "\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0
- try writeAll(master, "echo Q$((3+4))Z");
- _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 6); // settle (build the run)
- try writeAll(master, "\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
- if (!try pollFor(gpa, master, &output, "Q7Z", 80)) {
- dump(output.items, "middle-click did not execute the selected typed text");
- return error.MiddleExecFailed;
- }
+ // 6. Insert-mode editing + middle-click execute. Click a blank row to pin the
+ // cursor at text col 0, type a computed echo, then middle-select those 15
+ // cells and release. "Q7Z" appears only if the SELECTED typed text ran —
+ // proving selection reads the rendered screen.
+ try hs.send("\x1b[<0;3;14M\x1b[<0;3;14m"); // click blank row 13, col 0 -> normal + pin
+ try hs.pump(400);
+ try hs.send("i"); // normal -> insert so typing composes the command
+ try hs.pump(400);
+ try hs.send("echo Q$((3+4))Z");
+ try hs.pump(600); // let the run build
+ try hs.send("\x1b[<1;3;14M\x1b[<33;17;14M\x1b[<1;17;14m"); // middle-select it
+ try hs.expectWaitFor("Q7Z", 8000, "middle-click did not execute the selected typed text");
+ // leave insert -> normal so the next step can reach tty via Enter
+ try hs.send("\x1b");
+ try hs.pump(400);
// 7. Right-click a directory opens (acme "look") a terminal there and ls's
// it. Make a dir with a unique entry, then `clear` and echo its path so the
- // path lands as contiguous output on body row 0 (screen row 1). Right-click
- // (no drag) on it: the new terminal lists the dir, so the unique entry
- // appears only if the directory was actually opened.
- try writeAll(master, "\x1b");
- try drainFor(gpa, master, &output, 700); // settle (standalone Esc -> tty)
- try writeAll(master, "rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
- try drainFor(gpa, master, &output, 2000); // let it run + the screen fully settle
- try writeAll(master, "\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1
- if (!try pollFor(gpa, master, &output, "ZZUNIQDIR", 120)) {
- dump(output.items, "right-click on a directory did not open+ls it");
- return error.RightClickDirFailed;
- }
+ // path lands as contiguous output on body row 0. Right-click (no drag) on
+ // it: the new terminal lists the dir, so the unique entry appears only if
+ // the directory was actually opened.
+ try hs.send("\r"); // normal -> tty (Enter)
+ try hs.pump(700);
+ try hs.send("rm -rf /tmp/pardesE2E; mkdir -p /tmp/pardesE2E/ZZUNIQDIR; clear; echo /tmp/pardesE2E\r");
+ try hs.pump(2000); // let it run + settle
+ try hs.send("\x1b[<2;5;2M\x1b[<2;5;2m"); // right-click the path on screen row 1
+ try hs.expectWaitFor("ZZUNIQDIR", 12000, "right-click on a directory did not open+ls it");
// 8. Right-click a "path:line" opens a line-numbered FILE pane SCROLLED to
// that line. Build a tall file whose deep marker (line 150) is only
// reachable by scrolling, type the path:line on a blank row of the right
- // column, then right-click it. Typed text renders starting at the click
- // column, so the right-click lands on the path without knowing any pane
- // geometry. The file pane opens, scrolls to line 150 -> the marker shows.
- var fbuf: std.ArrayList(u8) = .empty;
- defer fbuf.deinit(gpa);
+ // column, then right-click it. The file pane opens, scrolls to line 150 ->
+ // the marker shows.
{
+ var fbuf: std.ArrayList(u8) = .empty;
+ defer fbuf.deinit(gpa);
var ln: usize = 1;
while (ln <= 200) : (ln += 1)
try fbuf.appendSlice(gpa, if (ln == 150) "ZZDEEPMARK\n" else "filler\n");
+ try h.writeFile("/tmp/pardesE2Efile.txt", fbuf.items);
}
- try writeFileE2E("/tmp/pardesE2Efile.txt", fbuf.items);
- try setWinsize(master, 50, 200); // clean, known geometry (col 149 is in the right column)
- try drainFor(gpa, master, &output, 700); // settle the reflow
- output.clearRetainingCapacity();
- try writeAll(master, "\x1b[<0;150;11M\x1b[<0;150;11m"); // left-click blank row, right column -> text mode + pin
- try writeAll(master, "/tmp/pardesE2Efile.txt:150"); // type the path:line (char mode)
- _ = try pollFor(gpa, master, &output, "ZZNEVERZZ", 8); // settle (build the run)
- try writeAll(master, "\x1b[<2;156;11M\x1b[<2;156;11m"); // right-click on the typed path
- if (!try pollFor(gpa, master, &output, "ZZDEEPMARK", 120)) {
- dump(output.items, "right-click 'path:150' did not open the file scrolled to line 150");
- return error.RightClickFileFailed;
- }
+ try hs.resize(30, 100); // clean geometry (right column starts ~col 51)
+ try hs.pump(700); // settle the reflow
+ try hs.send("\x1b[<0;60;11M\x1b[<0;60;11m"); // left-click blank row, right column -> normal + pin
+ try hs.pump(400);
+ try hs.send("i"); // normal -> insert so typing composes the path:line
+ try hs.pump(400);
+ try hs.send("/tmp/pardesE2Efile.txt:150");
+ try hs.pump(800); // build the run
+ try hs.send("\x1b[<2;70;11M\x1b[<2;70;11m"); // right-click on the typed path
+ try hs.expectWaitFor("ZZDEEPMARK", 12000, "right-click 'path:150' did not open the file scrolled to line 150");
+
+ // 9. Modal modes on the active FILE pane (left open & focused by step 8):
+ // `i` -> INSERT (tag shows "in"), Esc -> NORMAL ("nm"). Validates the
+ // 3-mode model end to end via the tag indicator. No content changes here.
+ try hs.send("i"); // normal -> insert
+ try hs.pump(600);
+ try hs.expectContains("in /", "no 'in' indicator after i (insert mode)");
+ try hs.send("\x1b"); // insert -> normal
+ try hs.pump(600);
+ try hs.expectContains("nm /", "no 'nm' indicator after Esc (back to normal)");
+
+ // 10. Helix-style file editing on that same file pane (still focused, normal,
+ // cursor on the ZZDEEPMARK line). `o` opens a line below and enters insert;
+ // type a unique marker, Esc to normal, then `x` (select line) + `d` (delete
+ // selection) removes it. Assert the marker appears then is gone, while the
+ // surrounding file content survives — proving real content mutation.
+ try hs.send("o"); // open line below -> insert
+ try hs.pump(600);
+ try hs.send("ZZMODALNEW");
+ try hs.pump(600);
+ try hs.send("\x1b"); // back to normal
+ try hs.pump(600);
+ try hs.expectContains("ZZMODALNEW", "'o' did not open a line / typed text not shown");
+ // cursor sits on the ZZMODALNEW line: `x` selects it, `d` deletes it.
+ try hs.send("x");
+ try hs.pump(400);
+ try hs.send("d");
+ try hs.pump(800);
+ try hs.expectNotContains("ZZMODALNEW", "ZZMODALNEW still present after x+d (line delete failed)");
+ // sanity: the deep marker (line 150, above the opened/deleted line) survives.
+ try hs.expectContains("ZZDEEPMARK", "ZZDEEPMARK vanished — file pane content was clobbered");
+
+ // 11. The Tutor builtin: middle-clicking "Tutor" in any tag spawns the tutor
+ // text as a new file pane. The tutor is embedded in the binary
+ // (@embedFile), so its content (the PARDES TUTOR header + the acme mouse
+ // intro) appears on screen. Middle-click is button 1 (SGR: <1;...M).
+ // Tag row 0 is the first row of any pane; "Tutor" sits at the end of the
+ // tag. Click somewhere in the tag's right half to hit it.
+ try hs.send("\x1b[<1;45;1M\x1b[<1;45;1m"); // middle-click the tag row (pane 0)
+ try hs.expectWaitFor("PARDES TUTOR", 10000, "middle-click 'Tutor' did not spawn the tutor pane");
+ try hs.expectContains("tmux + vi + acme", "tutor pane content is wrong (no tagline)");
// Tell the app to quit (ctrl-c is the app's own exit key).
- try writeAll(master, "\x03");
+ try hs.send("\x03");
}
diff --git a/tutor.txt b/tutor.txt
new file mode 100644
index 00000000..1bdb9a9a
--- /dev/null
+++ b/tutor.txt
@@ -0,0 +1,381 @@
+
+ PARDES TUTOR
+ tmux + vi + acme, from scratch.
+
+ The model is acme's: EVERYTHING is text, editable and executable
+ the same way. Modal editing (helix-style) lives on top of that.
+ Press j until you reach the introduction.
+
+
+=================================================================
+= INTRODUCTION =
+=================================================================
+
+ Pardes is tmux + vi + acme, made from scratch. The foundation is
+ the acme model: a pane is just text on screen. There is no separate
+ "buffer" type — what you see is what you edit. Some panes happen to
+ be live terminals (tty mode); most are text you can move a cursor
+ over. Modal editing (helix-style) is layered on top of that.
+
+ Three modes (the tag shows which):
+ nm NORMAL block cursor, keys move/edit. DEFAULT on startup.
+ in INSERT keys type text at the cursor.
+ sy TTY keys go straight to the shell. (terminals only)
+
+ This tutor is in THREE parts, ordered by what's most different from
+ editors you may know:
+ PART 1 — the MOUSE. Acme's chording; nothing like Vim/Helix.
+ PART 2 — the TTY. A terminal is just a pane; Enter positions you.
+ PART 3 — the KEYS. Helix-style modal (with the Pardes differences).
+
+ PRACTICE BLOCKS (part 3): the "# keys:" line lists keystrokes
+ (space-separated; esc/enter/bs are special, the rest type each char).
+ The clean lines under "# before" are what you practice on; the lines
+ under "# after" are what you should end up with. "# at: row,col"
+ (right after # keys:) sets where the cursor starts. These blocks are also run as unit
+ tests (generated from this file by tutor_gen).
+
+ Hold j to reach part 1.
+
+
+=================================================================
+= PART 1 — THE MOUSE (acme chording; the most different part) =
+=================================================================
+
+ Acme's central idea: the THREE mouse buttons are a chording language
+ over text. Pardes inherits this directly. (Vim and Helix have almost
+ none of this — their mouse is for selection/scroll only.)
+
+ LEFT (button 1) select text; a no-drag click also FOCUSES the
+ pane and PINS the modal cursor where you click
+ (so you can then edit there with the keyboard).
+ MIDDLE (button 2) "execute" the selected text. Selecting a builtin
+ name runs it; anything else is SENT to the shell
+ the pane runs (if any). This is how you run a
+ command you typed in text mode.
+ RIGHT (button 3) "look" — resolve the word under the cursor as a
+ path and open it: a directory opens/focuses a
+ terminal there and ls's it; a file opens a file
+ pane scrolled to a ":line" suffix if present.
+
+ CHORDING (the acme magic): hold one button, press another. The
+ classic is select-then-execute:
+ - drag with LEFT to select a shell command you've typed
+ - while STILL holding LEFT, press MIDDLE -> it runs
+ - release both
+ You can also select with LEFT then press RIGHT to "look" the selected
+ word up as a file. Two-button chords = select-and-act in one gesture.
+
+ THE TAG: every pane has a top bar (the "tag") — the pane's directory
+ followed by the builtin command names (Newcol Delcol Del Tutor). It's just
+ text: middle-click "Del" to close the window, "Newcol" to make a new
+ column, "Delcol" to close the column, "Tutor" to spawn this tutor.
+ Right-click a directory in any body to open a terminal there.
+
+ LAYOUT: columns split horizontally, windows stack in each column.
+ - drag the VERTICAL gap between columns to resize
+ - drag the HORIZONTAL gap between stacked windows to resize
+ - drag the red box (top-left of a pane) to MOVE a window between
+ columns / reorder it
+ - the scrollbar is the gutter below the box: left-click scrolls UP
+ to that point, right-click scrolls DOWN
+
+ (Mouse actions are hard to unit-test from text; they're covered by
+ the e2e suite: middle-click execute, right-click dir/file open, tag
+ builtins, drag-rearrange. See tests.zig steps 6-8.)
+
+
+=================================================================
+= PART 2 — THE TTY (a terminal is just a pane) =
+=================================================================
+
+ This is the headline difference from Vim/Helix: there is no "open a
+ terminal in a split" that's separate from "edit a file in a split".
+ A TERMINAL IS A PANE. The same modal keys that edit a file navigate
+ the terminal's screen, and dropping into tty REUSES the shell's own
+ prompt model rather than layering an editor cursor on top.
+
+ On a terminal pane:
+ NORMAL (nm) shows the REAL shell — prompts and typed input visible.
+ You navigate the screen with the same h/j/k/l/w/b/e
+ as a file pane.
+ INSERT (in) hides the prompt rows — a clean compose surface
+ (acme-style) where you type a command to run.
+ TTY (sy) keys go straight to the shell as terminal input.
+
+ ESC: insert -> normal. (Never reaches tty.)
+ ENTER: normal -> tty, AND moves the shell's REAL cursor to where
+ your modal cursor was on the input line:
+ - empty input -> shell cursor at the prompt START
+ - typed text -> shell cursor at/after the char you
+ navigated to (start-vs-end matters)
+ This is tty-native: it reuses the shell's OSC 133 semantic
+ prompt via ghostty's promptClickMove (our shell rc opts in
+ with cl=line). The shell moves its own cursor with arrow keys
+ it understands — Pardes doesn't fake a cursor on top.
+
+ So the flow is: navigate the shell screen in normal, hit Enter exactly
+ where you want to keep typing/editing, and you're IN the shell at that
+ spot. No separate "terminal mode cursor" to reconcile.
+
+ (Positioning needs a live shell + a pty; covered by the e2e suite,
+ tests.zig step 4b: type ABCDEF, navigate to the F, Enter, type X ->
+ ABCDEXF, proving the shell cursor moved mid-input.)
+
+
+=================================================================
+= PART 3 — THE KEYS (helix-style modal, Pardes differences) =
+=================================================================
+
+ Same core as Helix and Vim: a block cursor you move with h/j/k/l,
+ motions w/b/e, and you enter insert with i/a/o. The differences:
+
+ - NO select mode / multi-cursor (Helix). Selection is LINE-first:
+ `x` grows a line selection downward; d/c/y act on it.
+ - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word
+ you select its lines with x then d.
+ - ESC never reaches tty. Enter does (on terminals).
+ - Editing a file pane MUTATES real content; a terminal pane yanks
+ rendered text and pastes it as an insertion run (shell output
+ can't be deleted, only pasted text can).
+
+ PRACTICE: run the keys on the "# before" lines; you should get the
+ "# after" lines. (These blocks are unit tests too.)
+
+
+-----------------------------------------------------------------
+= 3.1 MOVING THE CURSOR (h j k l) =
+-----------------------------------------------------------------
+
+ k * h = left, l = right
+ h l * j = down, k = up (also: arrow keys)
+ j
+
+ The cursor sits ON a character (block). Motions only move.
+
+ # keys: l l l
+ # before
+ abcdef
+ # after
+ abcdef
+
+
+-----------------------------------------------------------------
+= 3.2 ENTERING INSERT: i a A I o O =
+-----------------------------------------------------------------
+
+ `i` insert at cursor. `a` insert AFTER the cursor.
+ `A` insert at end of line. `I` insert at first non-blank.
+ `o` open line BELOW + insert. `O` open line ABOVE + insert.
+ (All match Vim and Helix.)
+
+ # keys: i X esc
+ # before
+ bcdef
+ # after
+ Xbcdef
+
+ Cursor on 'b' (col 0). `i` inserts before 'b'.
+
+ # keys: a Z esc
+ # before
+ abc
+ # after
+ aZbc
+
+ Cursor on 'a' (col 0). `a` inserts after 'a'.
+
+ # keys: A Z esc
+ # before
+ abc
+ # after
+ abcZ
+
+ `A` jumps to end then inserts — appends 'Z'.
+
+ # keys: I Z esc
+ # before
+ abc
+ # after
+ Zabc
+
+ `I` goes to the first non-blank ('a'), inserts before it.
+
+ # keys: o line2 esc
+ # before
+ line1
+ # after
+ line1
+ line2
+
+ `o` makes a blank line below, enters insert, "line2" typed, Esc.
+
+ # keys: O top esc
+ # before
+ bot
+ # after
+ top
+ bot
+
+
+-----------------------------------------------------------------
+= 3.3 WORD MOTIONS: w b e (W B E long) =
+-----------------------------------------------------------------
+
+ `w` next word start, `b` prev word start, `e` next word END.
+ W/B/E treat punctuation as part of the word. Same as Vim & Helix.
+
+ # keys: w i Z esc
+ # before
+ foo bar
+ # after
+ foo Zbar
+
+ `w` from 'f'(col0) lands on 'b'(col4). Insert before 'b'.
+
+ # keys: e i Z esc
+ # before
+ foo bar
+ # after
+ foZo bar
+
+ `e` from 'f' lands on 'o'(col2, end of "foo"). Insert before it.
+
+ # keys: b i Z esc
+ # at: 0,4
+ # before
+ foo bar
+ # after
+ Zfoo bar
+
+ Cursor on 'b'(col4, "bar"). `b` -> start of previous word = 'f'(col0).
+
+
+-----------------------------------------------------------------
+= 3.4 LINE BOUNDS: 0 $ ^ g g G =
+-----------------------------------------------------------------
+
+ `0` start of line, `$` end, `^` first non-blank.
+ `gg` first line, `G` last line. Same as Vim/Helix.
+
+ # keys: $ i Z esc
+ # before
+ abcde
+ # after
+ abcdZe
+
+ `$` to last char 'e', insert before it. (Cursor ON a char, so "end" =
+ last char, not past it.)
+
+ # keys: 0 i Z esc
+ # before
+ abcde
+ # after
+ Zabcde
+
+ # keys: ^ i Z esc
+ # before
+ abcde
+ # after
+ Zabcde
+
+
+-----------------------------------------------------------------
+= 3.5 LINE SELECTION + EDIT: x d c y p =
+-----------------------------------------------------------------
+
+ The Pardes difference. No `v` select-mode: `x` grows a line selection
+ downward; d/c/y act on it.
+
+ `x` start/extend a line selection
+ `d` delete the selected lines (yanked)
+ `c` clear the line + insert (change)
+ `y` yank the selection (or current line)
+ `p` paste the yank as a new line below
+
+ # keys: x d
+ # at: 1,0
+ # before
+ keep
+ gone
+ # after
+ keep
+
+ Cursor on "gone" (row 1). `x` selects it, `d` deletes it.
+
+ # keys: x x d
+ # at: 1,0
+ # before
+ a
+ b
+ c
+ # after
+ a
+
+ `x` selects "b", `x` extends to "c", `d` removes both.
+
+ # keys: x y j p
+ # before
+ orig
+ dupe
+ # after
+ orig
+ dupe
+ orig
+
+ `x` selects "orig", `y` yanks it, `j` to "dupe", `p` pastes below.
+
+ # keys: x c typed esc
+ # before
+ old
+ # after
+ typed
+
+ `x` selects "old", `c` clears it to an empty line + insert, type.
+
+ # keys: c Q esc
+ # at: 0,1
+ # before
+ ab
+ # after
+ aQ
+
+ No selection: cursor on 'b'(col1). `c` deletes 'b' + insert; type 'Q'.
+
+
+-----------------------------------------------------------------
+= 3.6 VIEWPORT + PANES =
+-----------------------------------------------------------------
+
+ zt / zz / zb scroll so the cursor is at top/center/bottom
+ Ctrl-d / Ctrl-u half-page down / up
+ Ctrl-f / Ctrl-b full page down / up
+ Ctrl-w h/j/k/l focus the pane left/down/up/right
+ Alt-n new terminal below the active one
+ Alt-c move the active terminal into a fresh column
+
+ (No practice blocks: these are viewport/layout, not text edits.)
+
+
+=================================================================
+= SUMMARY =
+=================================================================
+
+ MOUSE (acme): L select/focus+pin M execute R look(open)
+ chord L+M = select-and-run; tag builtins (Del, ...)
+ TTY: terminal IS a pane; Enter from normal -> tty AT the
+ cursor's spot (start if empty, mid-text otherwise)
+ KEYS (helix): h j k l w b e 0 $ ^ gg G x d c y p i a I A o O
+ zt zz zb Ctrl-d/u/f/b Ctrl-w hjkl Alt-n/c
+
+ vs Helix: no select-mode, no multi-cursor; selection is LINE-first.
+ vs Vim: no verb+noun (dw); motions only move; Esc never reaches tty.
+ vs both: a terminal is just a pane; the same keys edit text and
+ navigate the shell screen, and Enter reuses the shell's
+ own prompt to position you precisely.
+
+ To spawn THIS tutor again from anywhere: middle-click "Tutor" in any
+ pane's tag (it's a builtin, next to Newcol/Delcol/Del).
+
+ Quit the tutor: this is a file pane — `:q` isn't wired; close the
+ window (middle-click "Del" in the tag) or Ctrl-c the app.
diff --git a/tutor_gen.zig b/tutor_gen.zig
new file mode 100644
index 00000000..09875e64
--- /dev/null
+++ b/tutor_gen.zig
@@ -0,0 +1,386 @@
+const std = @import("std");
+const modal = @import("modal.zig");
+
+// tutor_gen: a separate binary (NOT comptime) that parses tutor.txt and writes
+// tutor_cases.zig — a file of standard Zig `test` blocks, one per practice block.
+// `zig build tutor-gen` runs it (writing the file); `zig build unit-test` then
+// compiles+runs those tests against modal.stepFile.
+//
+// Format of tutor.txt practice blocks (all directive lines start with "# "):
+// # keys: l l l i X esc <- space-separated keystroke tokens
+// # at: 0,4 <- optional starting cursor "row,col" (default 0,0)
+// # before <- the lines below are the starting buffer
+// <clean line>
+// # after <- the lines below are the expected buffer
+// <clean line>
+// Everything not under a # before/# after is prose (ignored). A `# keys:` starts
+// a new case; cases end at the next `# keys:` or a non-practice line.
+//
+// Keystroke tokens: each char of a token is typed in turn, EXCEPT the named
+// specials `esc`, `enter`, `bs` (backspace), `left/right/up/down`, `home/end`,
+// `pgup/pgdn`. `Ctrl-d`/`Ctrl-u`/`Ctrl-f`/`Ctrl-b` map to half/full page.
+
+const Case = struct {
+ keys: []const u8,
+ at: ?[]const u8 = null,
+ before: []const u8,
+ after: []const u8,
+};
+
+fn parseCases(alloc: std.mem.Allocator, src: []const u8) ![]Case {
+ var cases: std.ArrayList(Case) = .empty;
+ var it = std.mem.splitScalar(u8, src, '\n');
+ // accumulate the current case
+ var cur_keys: ?[]const u8 = null;
+ var cur_at: ?[]const u8 = null;
+ var before: ?std.ArrayList(u8) = null;
+ var after: ?std.ArrayList(u8) = null;
+ // which section is actively collecting content (so # after stops before from
+ // absorbing the after lines). `none` = neither.
+ var section: enum { none, before, after } = .none;
+ defer if (before) |*b| b.deinit(alloc);
+ defer if (after) |*a| a.deinit(alloc);
+ // NOTE: cur_keys / cur_at ownership is handled explicitly at each flush/drop
+ // (moved into a case, or freed + nulled). We do NOT defer-free them here:
+ // a defer would double-free a value that was moved into a case (the move
+ // leaves the local pointing at the now-case-owned memory until it's nulled,
+ // and a function-exit defer can't tell).
+
+ while (it.next()) |raw_line| {
+ // Practice-block lines in tutor.txt are indented by one leading space
+ // (the tutor's prose style). Strip exactly that one space so directives
+ // match AND content lines don't carry the indent into the buffer.
+ var line = raw_line;
+ if (line.len > 0 and line[0] == ' ') line = line[1..];
+ line = std.mem.trimEnd(u8, line, "\r");
+ if (std.mem.startsWith(u8, line, "# keys:")) {
+ // flush previous case
+ if (cur_keys != null and before != null and after != null) {
+ try cases.append(alloc, .{
+ .keys = cur_keys.?,
+ .at = cur_at,
+ .before = try before.?.toOwnedSlice(alloc),
+ .after = try after.?.toOwnedSlice(alloc),
+ });
+ before = null;
+ after = null;
+ } else if (cur_keys != null) {
+ // a # keys: with no before/after — drop it (incomplete): free the
+ // owned keys/at so they don't leak.
+ if (cur_keys) |k| alloc.free(k);
+ if (cur_at) |at| alloc.free(at);
+ before = null;
+ after = null;
+ }
+ cur_keys = try alloc.dupe(u8, std.mem.trim(u8, line["# keys:".len..], " "));
+ // cur_at from a prior case was either moved (flushed) or freed (drop);
+ // start fresh.
+ cur_at = null;
+ before = null;
+ after = null;
+ section = .none;
+ continue;
+ }
+ if (std.mem.startsWith(u8, line, "# at:")) {
+ if (cur_at) |old| alloc.free(old);
+ cur_at = try alloc.dupe(u8, std.mem.trim(u8, line["# at:".len..], " "));
+ continue;
+ }
+ if (std.mem.startsWith(u8, line, "# before")) {
+ before = .empty;
+ after = null;
+ section = .before;
+ continue;
+ }
+ if (std.mem.startsWith(u8, line, "# after")) {
+ after = .empty;
+ section = .after;
+ continue;
+ }
+ // any other "# ..." directive that isn't keys/at/before/after ends a case
+ if (std.mem.startsWith(u8, line, "#")) {
+ if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
+ try cases.append(alloc, .{
+ .keys = cur_keys.?,
+ .at = cur_at,
+ .before = try before.?.toOwnedSlice(alloc),
+ .after = try after.?.toOwnedSlice(alloc),
+ });
+ } else {
+ // incomplete: free the owned keys/at + any partial content
+ if (cur_keys) |k| alloc.free(k);
+ if (cur_at) |at| alloc.free(at);
+ if (before) |*b| b.deinit(alloc);
+ if (after) |*a| a.deinit(alloc);
+ }
+ cur_keys = null;
+ cur_at = null;
+ before = null;
+ after = null;
+ section = .none;
+ continue;
+ }
+ // a blank line: if we have a complete case (keys + before + after with
+ // content), flush it now — the practice block is delimited by blank lines
+ // in the tutor, so prose after the blank line must NOT be absorbed.
+ if (line.len == 0) {
+ if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
+ try cases.append(alloc, .{
+ .keys = cur_keys.?,
+ .at = cur_at,
+ .before = try before.?.toOwnedSlice(alloc),
+ .after = try after.?.toOwnedSlice(alloc),
+ });
+ cur_keys = null;
+ before = null;
+ after = null;
+ section = .none;
+ } else {
+ section = .none; // a blank line closes the active section
+ }
+ continue;
+ }
+ // a content line: belongs to the ACTIVE section only
+ if (section == .before) {
+ if (before) |*b| {
+ if (b.items.len > 0) try b.append(alloc, '\n');
+ try b.appendSlice(alloc, line);
+ }
+ continue;
+ }
+ if (section == .after) {
+ if (after) |*a| {
+ if (a.items.len > 0) try a.append(alloc, '\n');
+ try a.appendSlice(alloc, line);
+ }
+ continue;
+ }
+ // prose line, ignore
+ }
+ // flush trailing case
+ if (cur_keys != null and before != null and after != null and after.?.items.len > 0) {
+ try cases.append(alloc, .{
+ .keys = cur_keys.?,
+ .at = cur_at,
+ .before = try before.?.toOwnedSlice(alloc),
+ .after = try after.?.toOwnedSlice(alloc),
+ });
+ // ownership transferred; clear so the defers above don't double-free.
+ cur_keys = null;
+ cur_at = null;
+ before = null;
+ after = null;
+ }
+ return cases.toOwnedSlice(alloc);
+}
+
+// Emit a Zig string literal for `s`, escaping per Zig string syntax.
+fn emitStringLiteral(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: []const u8) !void {
+ try out.append(alloc, '"');
+ for (s) |c| {
+ switch (c) {
+ '"' => try out.appendSlice(alloc, "\\\""),
+ '\\' => try out.appendSlice(alloc, "\\\\"),
+ '\n' => try out.appendSlice(alloc, "\\n"),
+ '\r' => try out.appendSlice(alloc, "\\r"),
+ '\t' => try out.appendSlice(alloc, "\\t"),
+ else => {
+ if (c >= 0x20 and c < 0x7f) {
+ try out.append(alloc, c);
+ } else {
+ try out.print(alloc, "\\x{x:0>2}", .{c});
+ }
+ },
+ }
+ }
+ try out.append(alloc, '"');
+}
+
+fn writeCases(io: std.Io, alloc: std.mem.Allocator, cases: []const Case, out_path: []const u8) !void {
+ // Build the generated source in a buffer, then write it in one shot.
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(alloc);
+
+ try out.appendSlice(alloc, "// AUTO-GENERATED by tutor_gen from tutor.txt. DO NOT EDIT.\n");
+ try out.appendSlice(alloc, "// One `test` block per practice block in the tutor. Re-run: zig build tutor-gen\n");
+ try out.appendSlice(alloc, "const std = @import(\"std\");\n");
+ try out.appendSlice(alloc, "const modal = @import(\"modal\");\n\n");
+
+ for (cases, 0..) |c, i| {
+ // Derive a short human label from the keys for the test name. Sanitize
+ // it to a valid Zig identifier fragment (spaces/specials -> '_').
+ var name_buf: [128]u8 = undefined;
+ const printed = std.fmt.bufPrint(&name_buf, "tutor_case_{d:0>2}_{s}", .{ i, c.keys }) catch "tutor_case";
+ var san_buf: [128]u8 = undefined;
+ const san_len = @min(printed.len, san_buf.len);
+ for (printed[0..san_len], 0..) |ch, j| {
+ san_buf[j] = switch (ch) {
+ 'a'...'z', 'A'...'Z', '0'...'9', '_' => ch,
+ else => '_',
+ };
+ }
+ const name_san = san_buf[0..san_len];
+ try out.print(alloc, "test \"{s}\" {{\n", .{name_san});
+ try out.appendSlice(alloc, " const a = std.testing.allocator;\n");
+ try out.appendSlice(alloc, " var yank: ?[]u8 = null;\n");
+ try out.appendSlice(alloc, " defer if (yank) |y| a.free(y);\n");
+ try out.appendSlice(alloc, " var scroll: usize = 0;\n");
+ try out.appendSlice(alloc, " var st = modal.FileState{};\n");
+ try out.appendSlice(alloc, " const view = modal.View{ .rows = 200, .scroll = &scroll };\n");
+
+ // starting content
+ try out.appendSlice(alloc, " var content: []u8 = try a.dupe(u8, ");
+ try emitStringLiteral(&out, alloc, c.before);
+ try out.appendSlice(alloc, ");\n");
+ try out.appendSlice(alloc, " defer a.free(content);\n");
+
+ // starting cursor
+ if (c.at) |at| {
+ try out.print(alloc, " st.cur = .{{ .row = {s}, .col = {s} }};\n", .{
+ splitField(at, 0),
+ splitField(at, 1),
+ });
+ }
+
+ // keystrokes
+ var tit = std.mem.tokenizeAny(u8, c.keys, " ");
+ while (tit.next()) |tok| {
+ try emitKey(&out, alloc, tok);
+ }
+
+ // assertion
+ try out.appendSlice(alloc, " try std.testing.expectEqualStrings(");
+ try emitStringLiteral(&out, alloc, c.after);
+ try out.appendSlice(alloc, ", content);\n");
+ try out.appendSlice(alloc, "}\n\n");
+ }
+ try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path, .data = out.items });
+}
+
+// "row,col" -> the row part (idx 0) or col part (idx 1).
+fn splitField(at: []const u8, idx: usize) []const u8 {
+ var it = std.mem.splitScalar(u8, at, ',');
+ var i: usize = 0;
+ while (it.next()) |part| : (i += 1) {
+ if (i == idx) return std.mem.trim(u8, part, " ");
+ }
+ return "0";
+}
+
+// Emit the stepFile calls for one keystroke token.
+// One stepFile call, as a generated line. `key_expr` is the Zig Key payload
+// expression (e.g. `.esc`, `.ch = 'x'`).
+fn emitStep(out: *std.ArrayList(u8), alloc: std.mem.Allocator, key_expr: []const u8) !void {
+ try out.print(alloc, " {{ const n = try modal.stepFile(a, content, &st, view, {s}, &yank); if (n) |nn| {{ a.free(content); content = nn; }} }}\n", .{key_expr});
+}
+
+fn emitKey(out: *std.ArrayList(u8), alloc: std.mem.Allocator, tok: []const u8) !void {
+ // named specials -> a single stepFile with that key
+ if (std.mem.eql(u8, tok, "esc")) return emitStep(out, alloc, ".esc");
+ if (std.mem.eql(u8, tok, "enter")) return emitStep(out, alloc, ".enter");
+ if (std.mem.eql(u8, tok, "bs")) return emitStep(out, alloc, ".backspace");
+ if (std.mem.eql(u8, tok, "left")) return emitStep(out, alloc, ".left");
+ if (std.mem.eql(u8, tok, "right")) return emitStep(out, alloc, ".right");
+ if (std.mem.eql(u8, tok, "up")) return emitStep(out, alloc, ".up");
+ if (std.mem.eql(u8, tok, "down")) return emitStep(out, alloc, ".down");
+ if (std.mem.eql(u8, tok, "home")) return emitStep(out, alloc, ".home");
+ if (std.mem.eql(u8, tok, "end")) return emitStep(out, alloc, ".end");
+ if (std.mem.eql(u8, tok, "pgdn") or std.mem.eql(u8, tok, "pagedown")) return emitStep(out, alloc, ".page_down");
+ if (std.mem.eql(u8, tok, "pgup") or std.mem.eql(u8, tok, "pageup")) return emitStep(out, alloc, ".page_up");
+ if (std.mem.eql(u8, tok, "Ctrl-d")) return emitStep(out, alloc, ".half_down");
+ if (std.mem.eql(u8, tok, "Ctrl-u")) return emitStep(out, alloc, ".half_up");
+ if (std.mem.eql(u8, tok, "Ctrl-f")) return emitStep(out, alloc, ".page_down");
+ if (std.mem.eql(u8, tok, "Ctrl-b")) return emitStep(out, alloc, ".page_up");
+ // otherwise: each byte of the token is a typed char (in order)
+ for (tok) |c| {
+ var expr_buf: [32]u8 = undefined;
+ const expr = std.fmt.bufPrint(&expr_buf, ".{{ .ch = {d} }}", .{c}) catch return;
+ try emitStep(out, alloc, expr);
+ }
+}
+
+pub fn main(init: std.process.Init) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const in_path = if (args.len > 1) args[1] else "tutor.txt";
+ const out_path = if (args.len > 2) args[2] else "tutor_cases.zig";
+
+ const src = try std.Io.Dir.cwd().readFileAlloc(io, in_path, gpa, .limited(1 << 20));
+ defer gpa.free(src);
+
+ const cases = try parseCases(gpa, src);
+ defer {
+ for (cases) |c| {
+ gpa.free(c.keys);
+ if (c.at) |at| gpa.free(at);
+ gpa.free(c.before);
+ gpa.free(c.after);
+ }
+ gpa.free(cases);
+ }
+
+ try writeCases(io, gpa, cases, out_path);
+ std.debug.print("tutor_gen: wrote {d} test cases to {s}\n", .{ cases.len, out_path });
+}
+
+// ---- self-test: the parser on tiny fixtures ----
+test "parseCases extracts a before/after block" {
+ const a = std.testing.allocator;
+ const src =
+ \\# keys: l l l
+ \\# before
+ \\abcdef
+ \\# after
+ \\abcdef
+ \\
+ ;
+ const cases = try parseCases(a, src);
+ defer {
+ for (cases) |c| {
+ a.free(c.keys);
+ if (c.at) |at| a.free(at);
+ a.free(c.before);
+ a.free(c.after);
+ }
+ a.free(cases);
+ }
+ try std.testing.expectEqual(@as(usize, 1), cases.len);
+ try std.testing.expectEqualStrings("l l l", cases[0].keys);
+ try std.testing.expectEqualStrings("abcdef", cases[0].before);
+ try std.testing.expectEqualStrings("abcdef", cases[0].after);
+}
+
+test "parseCases handles # at: and multiple cases" {
+ const a = std.testing.allocator;
+ const src =
+ \\# keys: b i Z esc
+ \\# at: 0,4
+ \\# before
+ \\foo bar
+ \\# after
+ \\Zfoo bar
+ \\
+ \\# keys: x d
+ \\# before
+ \\keep
+ \\gone
+ \\# after
+ \\keep
+ \\
+ ;
+ const cases = try parseCases(a, src);
+ defer {
+ for (cases) |c| {
+ a.free(c.keys);
+ if (c.at) |at| a.free(at);
+ a.free(c.before);
+ a.free(c.after);
+ }
+ a.free(cases);
+ }
+ try std.testing.expectEqual(@as(usize, 2), cases.len);
+ try std.testing.expectEqualStrings("0,4", cases[0].at.?);
+ try std.testing.expectEqualStrings("keep\ngone", cases[1].before);
+ try std.testing.expectEqualStrings("keep", cases[1].after);
+}