diff options
| author | Gabriel Schneider <[email protected]> | 2026-06-30 08:57:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-06-30 11:43:29 -0300 |
| commit | 8c54adee7d942606ff7c3bd8b293b11507f5b0a9 (patch) | |
| tree | 788ae4b5e9145cbb601d5b8a30b16bfcfacf5277 | |
| parent | b5b526f465924324e2cd4424f5bacc43e50b7673 (diff) | |
| download | pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.tar.gz pardes-8c54adee7d942606ff7c3bd8b293b11507f5b0a9.zip | |
chinese vibes modal editing
| -rw-r--r-- | build.zig | 70 | ||||
| -rw-r--r-- | e2e_harness.zig | 248 | ||||
| -rw-r--r-- | main.zig | 923 | ||||
| -rw-r--r-- | modal.zig | 909 | ||||
| -rw-r--r-- | term.zig | 36 | ||||
| -rw-r--r-- | tests.zig | 375 | ||||
| -rw-r--r-- | tutor.txt | 381 | ||||
| -rw-r--r-- | tutor_gen.zig | 386 |
8 files changed, 2996 insertions, 332 deletions
@@ -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); + } +} @@ -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); +} @@ -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, @@ -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); +} |
