summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-05 20:21:50 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitde4def3548a6729b0dfd2120495a61beef8c8c2c (patch)
tree12144f0f64dc96bb4741459c1f9db57f44349330 /src
parent7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff)
downloadpardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz
pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'src')
-rw-r--r--src/dump.zig218
-rw-r--r--src/gui.zig2399
-rw-r--r--src/image.zig53
-rw-r--r--src/look.zig123
-rw-r--r--src/main.zig102
-rw-r--r--src/modal.zig964
-rw-r--r--src/pardes.zig3728
-rw-r--r--src/petscii.zig446
-rw-r--r--src/syntax.zig173
-rw-r--r--src/tty.zig386
-rw-r--r--src/tutor.txt412
11 files changed, 9004 insertions, 0 deletions
diff --git a/src/dump.zig b/src/dump.zig
new file mode 100644
index 00000000..9c1dceec
--- /dev/null
+++ b/src/dump.zig
@@ -0,0 +1,218 @@
+const std = @import("std");
+const builtin = @import("builtin");
+
+pub const default_path = "/tmp/pardes-dump.zon";
+pub const magic = "pardes-dump";
+pub const version: u32 = 1;
+pub const max_panes: usize = 16;
+pub const max_cols: usize = 6;
+
+pub const Size = struct {
+ cols: u16,
+ rows: u16,
+};
+
+pub const Cursor = struct {
+ col: u16 = 0,
+ row: u16 = 0,
+};
+
+pub const PaneKind = enum {
+ terminal,
+ file,
+ image,
+};
+
+pub const Terminal = struct {
+ cwd: []const u8 = "",
+ stream: []const u8 = "",
+ stream_b64: []const u8 = "",
+ cursor: Cursor = .{},
+};
+
+pub const File = struct {
+ path: []const u8 = "",
+ content: []const u8 = "",
+ content_b64: []const u8 = "",
+};
+
+pub const Image = struct {
+ path: []const u8 = "",
+ bytes_b64: []const u8 = "",
+};
+
+pub const Pane = struct {
+ kind: PaneKind,
+ tag: []const u8,
+ body: []const u8,
+ scroll: usize = 0,
+ cols: u16 = 0,
+ rows: u16 = 0,
+ vweight: f32 = 1,
+ terminal: ?Terminal = null,
+ file: ?File = null,
+ image: ?Image = null,
+};
+
+pub const Column = struct {
+ weight: f32 = 1,
+ panes: []const usize = &.{},
+};
+
+pub const State = struct {
+ magic: []const u8 = magic,
+ version: u32 = version,
+ screen: Size,
+ active: usize = 0,
+ topbar: []const u8 = "",
+ theme: []const u8 = "dark",
+ columns: []const Column = &.{},
+ panes: []const Pane = &.{},
+};
+
+pub fn validate(state: State) !void {
+ if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic;
+ if (state.version != version) return error.BadDumpVersion;
+ if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes;
+ if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns;
+ if (state.active >= state.panes.len) return error.BadDumpActive;
+ for (state.columns) |col| {
+ if (col.panes.len == 0 or col.panes.len > max_panes) return error.BadDumpColumns;
+ for (col.panes) |pane| {
+ if (pane >= state.panes.len) return error.BadDumpPaneRef;
+ }
+ }
+ for (state.panes) |pane| {
+ switch (pane.kind) {
+ .terminal => if (pane.terminal == null) return error.BadDumpPaneKind,
+ .file => if (pane.file == null) return error.BadDumpPaneKind,
+ .image => if (pane.image == null) return error.BadDumpPaneKind,
+ }
+ }
+}
+
+pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void {
+ try validate(state);
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+
+ var file = try std.Io.Dir.createFileAbsolute(io, path, .{});
+ defer file.close(io);
+ try file.writeStreamingAll(io, out.written());
+}
+
+pub fn readFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8) !State {
+ const bytes = try std.Io.Dir.cwd().readFileAlloc(io, path, gpa, .limited(64 * 1024 * 1024));
+ defer gpa.free(bytes);
+ return readZon(gpa, bytes, path);
+}
+
+pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !State {
+ const source = try gpa.dupeZ(u8, bytes);
+ defer gpa.free(source);
+ return readZonZ(gpa, source, label);
+}
+
+pub fn readZonZ(gpa: std.mem.Allocator, source: [:0]const u8, label: []const u8) !State {
+ if (builtin.os.tag == .emscripten) {
+ const state = std.zon.parse.fromSliceAlloc(State, gpa, source, null, .{}) catch |err| {
+ std.log.err("parse dump {s}: {s}", .{ label, @errorName(err) });
+ return err;
+ };
+ errdefer std.zon.parse.free(gpa, state);
+ try validate(state);
+ return state;
+ }
+
+ var diag: std.zon.parse.Diagnostics = .{};
+ defer diag.deinit(gpa);
+ const state = std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{}) catch |err| {
+ std.log.err("parse dump {s}: {f}", .{ label, diag });
+ return err;
+ };
+ errdefer std.zon.parse.free(gpa, state);
+ try validate(state);
+ return state;
+}
+
+pub fn free(gpa: std.mem.Allocator, state: State) void {
+ std.zon.parse.free(gpa, state);
+}
+
+pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 {
+ const enc = std.base64.standard.Encoder;
+ const out = try alloc.alloc(u8, enc.calcSize(bytes.len));
+ return enc.encode(out, bytes);
+}
+
+pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 {
+ const dec = std.base64.standard.Decoder;
+ const n = try dec.calcSizeForSlice(b64);
+ const out = try alloc.alloc(u8, n);
+ errdefer alloc.free(out);
+ try dec.decode(out, b64);
+ return out;
+}
+
+test "dump zon roundtrip" {
+ const gpa = std.testing.allocator;
+ const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld");
+ defer gpa.free(tty_b64);
+ const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n");
+ defer gpa.free(file_b64);
+ const panes = [_]Pane{
+ .{
+ .kind = .terminal,
+ .tag = "nm /tmp Del",
+ .body = "hello\nworld",
+ .scroll = 1,
+ .cols = 40,
+ .rows = 10,
+ .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } },
+ },
+ .{
+ .kind = .file,
+ .tag = "nm /tmp/a.txt Save Del",
+ .body = " 1 alpha",
+ .cols = 20,
+ .rows = 5,
+ .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64 },
+ },
+ };
+ const col_panes = [_]usize{ 0, 1 };
+ const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }};
+ const state: State = .{
+ .screen = .{ .cols = 80, .rows = 24 },
+ .topbar = "Kill Newcol Dump",
+ .theme = "dark",
+ .columns = &columns,
+ .panes = &panes,
+ };
+
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ const source = try out.toOwnedSliceSentinel(0);
+ defer gpa.free(source);
+
+ var diag: std.zon.parse.Diagnostics = .{};
+ defer diag.deinit(gpa);
+ const parsed = try std.zon.parse.fromSliceAlloc(State, gpa, source, &diag, .{});
+ defer std.zon.parse.free(gpa, parsed);
+ try validate(parsed);
+ try std.testing.expectEqual(@as(usize, 2), parsed.panes.len);
+ try std.testing.expectEqualStrings("dark", parsed.theme);
+ try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream);
+ try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content);
+ {
+ const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64);
+ defer gpa.free(bytes);
+ try std.testing.expectEqualStrings("old\nhello\nworld", bytes);
+ }
+ {
+ const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64);
+ defer gpa.free(bytes);
+ try std.testing.expectEqualStrings("alpha\nbeta\n", bytes);
+ }
+}
diff --git a/src/gui.zig b/src/gui.zig
new file mode 100644
index 00000000..516a6b79
--- /dev/null
+++ b/src/gui.zig
@@ -0,0 +1,2399 @@
+//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input
+//! into core events, performs the core's effects (fork ptys, write them,
+//! resize them — same duties as tty.zig, this is also native linux), and
+//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an
+//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL,
+//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the
+//! real renderer, drives input from stdin, and captures frames to PPM.
+//!
+//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten):
+//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a
+//! requestAnimationFrame callback instead of a blocking loop. Comptime
+//! branches on is_emscripten, the way the stdlib branches on os.tag.
+const std = @import("std");
+const builtin = @import("builtin");
+const posix = std.posix;
+const linux = std.os.linux;
+const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
+const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
+const pardes = @import("pardes.zig");
+
+const is_emscripten = builtin.os.tag == .emscripten;
+
+pub const c = @cImport({
+ @cDefine("SDL_DISABLE_OLD_NAMES", "1");
+ @cInclude("SDL3/SDL.h");
+ if (is_emscripten) @cInclude("GLES3/gl3.h");
+ @cInclude("font.h");
+});
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int;
+const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0;
+
+const log = std.log.scoped(.gui);
+
+const font_ttf = @embedFile("AdwaitaMono-Regular.ttf");
+const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv");
+const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv");
+const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv");
+const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv");
+// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there)
+const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else "";
+const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else "";
+const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else "";
+const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else "";
+// web: no ptys — the whole state replays from a dump embedded at build time
+const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else "";
+
+// default page colors — the same "terminal native dark" the prototype used
+const bg_default = [3]u8{ 18, 18, 18 };
+const fg_default = [3]u8{ 204, 204, 204 };
+
+// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays
+// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never
+// shipped non-default — hardcoded here instead of a build option).
+const web_render_scale: f32 = 1.5;
+
+const atlas_w: u32 = if (is_emscripten) 2048 else 1024;
+const atlas_h: u32 = if (is_emscripten) 2048 else 1024;
+const Slot = struct { u: u32, v: u32 };
+
+const max_touch_points: usize = 16;
+const max_touch_trail_points: usize = 8;
+const overlay_circle_vertices: usize = 16 * 3;
+const touch_middle_flash_max_frames: u8 = 14;
+const touch_middle_flash_vertices: usize = 1400;
+const touch_scroll_tick: f32 = 0.02;
+const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices);
+
+// One instance per cell; the vertex shader expands it to a 2-triangle quad
+// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1.
+const CellInstance = extern struct {
+ x0: f32,
+ y0: f32,
+ x1: f32,
+ y1: f32,
+ u0: f32,
+ v0: f32,
+ u1: f32,
+ v1: f32,
+ fr: f32,
+ fg: f32,
+ fb: f32,
+ br: f32,
+ bg: f32,
+ bb: f32,
+};
+
+const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 };
+const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 };
+
+// ---- touch: per-finger tracking + the two-finger scroll/tap machine ----
+
+const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 };
+
+const TouchPoint = struct {
+ active: bool = false,
+ id: u64 = 0,
+ x: f32 = 0,
+ y: f32 = 0,
+ pressure: f32 = 1,
+ trail: [max_touch_trail_points]TouchSample = @splat(.{}),
+ trail_len: usize = 0,
+ trail_next: usize = 0,
+};
+
+const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 };
+
+/// A finger event with SDL's normalized 0..1 coordinates — the one shape both
+/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine.
+const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 };
+
+const Touch = struct {
+ points: [max_touch_points]TouchPoint = @splat(.{}),
+ scroll: struct {
+ active: bool = false,
+ ids: [2]u64 = .{ 0, 0 },
+ accum_y: f32 = 0,
+ scrolled: bool = false,
+ last_center: TouchNormPoint = .{},
+ } = .{},
+ middle_clicks: u32 = 0,
+ middle_flash_frames: u8 = 0,
+
+ fn findSlot(t: *const Touch, id: u64) ?usize {
+ for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i;
+ return null;
+ }
+
+ fn updatePoint(t: *Touch, f: Finger) void {
+ switch (f.kind) {
+ .up, .cancel => if (t.findSlot(f.id)) |i| {
+ t.points[i].active = false;
+ },
+ .down, .motion => {
+ const idx = t.findSlot(f.id) orelse blk: {
+ for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i;
+ break :blk @as(usize, @intCast(f.id % max_touch_points));
+ };
+ const tp = &t.points[idx];
+ if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id };
+ tp.x = f.x;
+ tp.y = f.y;
+ tp.pressure = f.pressure;
+ tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure };
+ tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points;
+ if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1;
+ },
+ }
+ }
+
+ fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint {
+ var sum: TouchNormPoint = .{};
+ for (ids) |id| {
+ const i = t.findSlot(id) orelse return null;
+ sum.x += t.points[i].x;
+ sum.y += t.points[i].y;
+ }
+ return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 };
+ }
+
+ /// keep scroll state in sync with the set of active fingers: exactly two
+ /// active fingers begin (or re-key) a pair; anything else resets it.
+ fn syncPair(t: *Touch) void {
+ var ids: [2]u64 = .{ 0, 0 };
+ var count: usize = 0;
+ for (&t.points) |*tp| {
+ if (!tp.active) continue;
+ if (count >= 2) {
+ t.scroll = .{};
+ return;
+ }
+ ids[count] = tp.id;
+ count += 1;
+ }
+ if (count != 2) {
+ t.scroll = .{};
+ return;
+ }
+ if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]);
+ if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return;
+ t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} };
+ }
+
+ /// finger lift: a pair that never scrolled is a two-finger TAP — returns
+ /// its center (computed with the lifting finger's final position).
+ fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint {
+ if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null;
+ const tap = f.kind == .up and !t.scroll.scrolled;
+ var center: ?TouchNormPoint = null;
+ if (tap) {
+ var sum: TouchNormPoint = .{};
+ var ok = true;
+ for (t.scroll.ids) |id| {
+ if (id == f.id) {
+ sum.x += f.x;
+ sum.y += f.y;
+ } else if (t.findSlot(id)) |i| {
+ sum.x += t.points[i].x;
+ sum.y += t.points[i].y;
+ } else ok = false;
+ }
+ center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center;
+ }
+ t.scroll = .{};
+ return center;
+ }
+
+ fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } {
+ if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null;
+ const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center;
+ const delta_y = center.y - t.scroll.last_center.y;
+ t.scroll.last_center = center;
+ t.scroll.accum_y += delta_y;
+ var ticks: i32 = 0;
+ while (t.scroll.accum_y >= touch_scroll_tick) : (ticks += 1) t.scroll.accum_y -= touch_scroll_tick;
+ while (t.scroll.accum_y <= -touch_scroll_tick) : (ticks -= 1) t.scroll.accum_y += touch_scroll_tick;
+ if (ticks == 0) return null;
+ t.scroll.scrolled = true;
+ return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
+ }
+};
+
+/// Two-finger drag → synthesized wheel presses + a touch_scroll event; a
+/// two-finger tap → synthesized middle press+release. win/cell dims map
+/// normalized coords to cells (grid test mode passes cols/rows with cell=1).
+fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ std.debug.assert(cell_w > 0 and cell_h > 0);
+ switch (f.kind) {
+ .down => {
+ t.updatePoint(f);
+ t.syncPair();
+ },
+ .motion => {
+ const had_pair = t.scroll.active;
+ t.updatePoint(f);
+ t.syncPair();
+ if (!had_pair) return;
+ const res = t.noteMotion(f) orelse return;
+ const col = normCell(res.center.x, win_w, cell_w);
+ const row = normCell(res.center.y, win_h, cell_h);
+ const button: pardes.Mouse.Button = if (res.ticks > 0) .wheel_up else .wheel_down;
+ var remaining: u32 = @abs(res.ticks);
+ while (remaining > 0) : (remaining -= 1)
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .touch_scroll = res.delta_y });
+ },
+ .up, .cancel => {
+ const tap_center = t.finishPair(f);
+ t.updatePoint(f);
+ t.syncPair();
+ if (tap_center) |center| {
+ t.middle_clicks +%= 1;
+ t.middle_flash_frames = touch_middle_flash_max_frames;
+ const col = normCell(center.x, win_w, cell_w);
+ const row = normCell(center.y, win_h, cell_h);
+ core.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = col, .row = row } });
+ }
+ },
+ }
+}
+
+fn normCell(norm: f32, win: f32, cell: f32) u16 {
+ const px = std.math.clamp(norm, 0.0, 1.0) * win;
+ return @intFromFloat(@max(0, @floor(px / cell)));
+}
+
+// ---- pty plumbing: reader threads feed a mutex-protected queue ----
+
+const Pty = struct { fd: c_int, pid: linux.pid_t };
+
+const Msg = union(enum) { output: struct { pane: u8, bytes: []u8 }, eof: u8 };
+
+const Queue = struct {
+ gpa: std.mem.Allocator,
+ sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push
+ // 0.16 has no std.Thread.Mutex; critical sections here are a few
+ // instructions, so spinning on the lock-free std.atomic.Mutex is enough
+ mutex: std.atomic.Mutex = .unlocked,
+ items: std.ArrayList(Msg) = .empty,
+ closed: bool = false,
+
+ fn lock(q: *Queue) void {
+ while (!q.mutex.tryLock()) std.atomic.spinLoopHint();
+ }
+
+ fn push(q: *Queue, m: Msg) void {
+ q.lock();
+ if (q.closed) {
+ q.mutex.unlock();
+ switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ }
+ return;
+ }
+ q.items.append(q.gpa, m) catch {
+ q.mutex.unlock();
+ switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ }
+ return;
+ };
+ q.mutex.unlock();
+ if (q.sdl_wake) {
+ var sev = std.mem.zeroes(c.SDL_Event);
+ sev.type = c.SDL_EVENT_USER;
+ _ = c.SDL_PushEvent(&sev);
+ }
+ }
+
+ /// take the pending messages (caller iterates + deinits outside the lock)
+ fn take(q: *Queue) std.ArrayList(Msg) {
+ q.lock();
+ defer q.mutex.unlock();
+ const local = q.items;
+ q.items = .empty;
+ return local;
+ }
+
+ fn close(q: *Queue) void {
+ q.lock();
+ defer q.mutex.unlock();
+ q.closed = true;
+ for (q.items.items) |m| switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .eof => {},
+ };
+ q.items.deinit(q.gpa);
+ }
+};
+
+fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, q: *Queue) void {
+ var buf: [0x10000]u8 = undefined;
+ while (true) {
+ const rc = linux.read(fd, &buf, buf.len);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ break; // EIO when the child exits: treat as EOF
+ }
+ if (n == 0) break;
+ const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break;
+ q.push(.{ .output = .{ .pane = pane, .bytes = bytes } });
+ }
+ q.push(.{ .eof = pane });
+}
+
+fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, q: *Queue) void {
+ const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, q }) catch return;
+ th.detach();
+}
+
+// ---- the renderer state ----
+
+const Gui = struct {
+ window: *c.SDL_Window,
+ device: *c.SDL_GPUDevice,
+ swapchain_format: c.SDL_GPUTextureFormat,
+ pipeline: *c.SDL_GPUGraphicsPipeline,
+ overlay_pipeline: *c.SDL_GPUGraphicsPipeline,
+ atlas_tex: *c.SDL_GPUTexture,
+ atlas_sampler: *c.SDL_GPUSampler,
+ atlas_xfer: *c.SDL_GPUTransferBuffer,
+ vbuf: ?*c.SDL_GPUBuffer = null,
+ vxfer: ?*c.SDL_GPUTransferBuffer = null,
+ vbuf_cells: u32 = 0,
+ overlay_vbuf: *c.SDL_GPUBuffer,
+ overlay_vxfer: *c.SDL_GPUTransferBuffer,
+ overlay_vertices: []OverlayVertex,
+
+ font: *c.UIFont,
+ scale: f32,
+ cell_w: u32,
+ cell_h: u32,
+ ascent: i32,
+
+ // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc
+ atlas_stage: []u8,
+ glyphs: std.AutoHashMap(u32, Slot),
+ pen_x: u32 = 0,
+ pen_y: u32 = 0,
+ atlas_dirty: bool = true,
+ space_slot: Slot = .{ .u = 0, .v = 0 },
+
+ touch: Touch = .{},
+ mouse_x: f32 = 0,
+ mouse_y: f32 = 0,
+ live_ctrl: bool = false,
+ live_alt: bool = false,
+
+ // PARDES_TEST frame capture (render into an offscreen target, dump PPM)
+ capture: bool = false,
+ capture_dir: []const u8 = "",
+ capture_tex: ?*c.SDL_GPUTexture = null,
+ capture_tex_w: u32 = 0,
+ capture_tex_h: u32 = 0,
+ capture_xfer: ?*c.SDL_GPUTransferBuffer = null,
+ capture_xfer_size: u32 = 0,
+
+ // steamdeck: gamepad-driven virtual mouse cursor (pixel position)
+ gamepad: ?*c.SDL_Gamepad = null,
+ pad_x: f32 = 0,
+ pad_y: f32 = 0,
+ pad_scroll: f32 = 0,
+
+ // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging
+ gl_program: c_uint = 0,
+ gl_overlay_program: c_uint = 0,
+ gl_atlas_tex: c_uint = 0,
+ gl_vbo: c_uint = 0,
+ gl_overlay_vbo: c_uint = 0,
+ gl_u_atlas: c_int = -1,
+ gl_instances: []CellInstance = &.{},
+};
+
+/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the
+/// canvas resize and the grid resize land on different frames, so the grid is
+/// scaled to fit and centered (letterboxed) instead of clipping.
+const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 };
+
+fn fixedCellLayout(g: *const Gui) CellLayout {
+ return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 };
+}
+
+fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout {
+ const fixed = fixedCellLayout(g);
+ if (!is_emscripten) return fixed;
+ if (cols == 0 or rows == 0) return fixed;
+ const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w;
+ const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h;
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ const scale = @min(win_w / grid_w, win_h / grid_h);
+ return .{
+ .w = fixed.w * scale,
+ .h = fixed.h * scale,
+ .x_off = @max(0, (win_w - grid_w * scale) * 0.5),
+ .y_off = @max(0, (win_h - grid_h * scale) * 0.5),
+ };
+}
+
+// =====================================================================
+// entry
+// =====================================================================
+
+pub const run = if (is_emscripten) runWeb else runNative;
+
+fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const env = init.environ_map;
+ if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in);
+
+ const test_mode = env.get("PARDES_TEST") != null;
+ const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR");
+ if (test_mode and capture_dir == null) {
+ log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{});
+ return error.SdlInit;
+ }
+
+ if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) {
+ log.err("SDL_Init: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE;
+ if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY;
+ const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse {
+ log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse {
+ log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ if (!c.SDL_ClaimWindowForGPUDevice(device, window)) {
+ log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync
+ const present_mode: c.SDL_GPUPresentMode = blk: {
+ if (env.get("PARDES_SDL_PRESENT")) |raw| {
+ const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate"))
+ c.SDL_GPU_PRESENTMODE_IMMEDIATE
+ else if (std.ascii.eqlIgnoreCase(raw, "mailbox"))
+ c.SDL_GPU_PRESENTMODE_MAILBOX
+ else if (std.ascii.eqlIgnoreCase(raw, "vsync"))
+ c.SDL_GPU_PRESENTMODE_VSYNC
+ else
+ null;
+ if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode;
+ }
+ for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| {
+ if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode;
+ }
+ break :blk c.SDL_GPU_PRESENTMODE_VSYNC;
+ };
+ if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1);
+ _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode);
+ const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window);
+
+ // ---- font + cell metrics ----
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse {
+ log.err("ui_font_new failed", .{});
+ return error.FontInit;
+ };
+ const px: f32 = 18.0;
+ const scale = c.ui_font_scale_for_height(font, px);
+ var cw: c_int = 10;
+ var chh: c_int = 20;
+ var asc: c_int = 16;
+ c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc);
+ const cell_w: u32 = @intCast(@max(cw, 1));
+ const cell_h: u32 = @intCast(@max(chh, 1));
+ if (test_mode) {
+ const cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
+ const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
+ _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h));
+ _ = c.SDL_SyncWindow(window);
+ }
+
+ // ---- glyph atlas (R8) + pipelines ----
+ var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ tex_info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM;
+ tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ tex_info.width = atlas_w;
+ tex_info.height = atlas_h;
+ tex_info.layer_count_or_depth = 1;
+ tex_info.num_levels = 1;
+ tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate;
+
+ var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo);
+ samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR;
+ samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE;
+ const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate;
+
+ var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 };
+ const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate;
+
+ const pipeline = try makePipeline(device, swapchain_format);
+ const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format);
+
+ const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex));
+ var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 };
+ const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate;
+ var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 };
+ const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate;
+
+ const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
+ defer gpa.free(atlas_stage);
+ @memset(atlas_stage, 0);
+ const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices);
+ defer gpa.free(overlay_vertices);
+
+ var g: Gui = .{
+ .window = window,
+ .device = device,
+ .swapchain_format = swapchain_format,
+ .pipeline = pipeline,
+ .overlay_pipeline = overlay_pipeline,
+ .atlas_tex = atlas_tex,
+ .atlas_sampler = atlas_sampler,
+ .atlas_xfer = atlas_xfer,
+ .overlay_vbuf = overlay_vbuf,
+ .overlay_vxfer = overlay_vxfer,
+ .overlay_vertices = overlay_vertices,
+ .font = font,
+ .scale = scale,
+ .cell_w = cell_w,
+ .cell_h = cell_h,
+ .ascent = asc,
+ .atlas_stage = atlas_stage,
+ .glyphs = std.AutoHashMap(u32, Slot).init(gpa),
+ .capture = test_mode,
+ .capture_dir = capture_dir orelse "",
+ };
+ defer g.glyphs.deinit();
+ // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
+ _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc);
+ g.pen_x = cell_w;
+
+ // ---- the core ----
+ pardes.image.start(io, gpa); // stb_image allocator for image panes
+ defer pardes.image.stop();
+ // Live sessions initialize at the default 80x24 grid; the real window size
+ // arrives as a resize EVENT on the first loop pass. The core defers the
+ // shell greeting until after the first resize (so `ls` wraps to the real
+ // pane width) — pre-sizing at init would mean no resize ever fires and the
+ // greeting never runs (panes sat blank until the first interaction).
+ // Dump loads pre-size instead: replayed panes never greet, and sizing at
+ // init avoids reflowing their replayed content twice.
+ var opts = opts_in;
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ if (opts.load_path != null) {
+ _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
+ opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
+ opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ }
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+ // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images.
+ // ponytail: Surface.images pixel attachments could blit as textures later.
+
+ // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
+ writeFile(pardes.bash_rc);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = linux.close(pt.fd);
+ slot.* = null;
+ };
+ var queue: Queue = .{ .gpa = gpa, .sdl_wake = true };
+ defer queue.close();
+
+ // initial spawns BEFORE any worker thread exists: forkpty from a
+ // multithreaded process can wedge the child before exec (see tty.zig).
+ drainEffects(core, &ptys, gpa, &queue, &g, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue);
+
+ _ = c.SDL_StartTextInput(window);
+
+ var feed: StdinFeed = .{};
+ if (test_mode) setStdinRaw() catch {};
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+
+ while (!core.quit) {
+ // 1. SDL input: block briefly for the first event, then drain the rest
+ var sev = std.mem.zeroes(c.SDL_Event);
+ if (c.SDL_WaitEventTimeout(&sev, 16)) {
+ dispatch(&g, core, &sev);
+ while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev);
+ }
+ // test mode: input comes from stdin escape sequences instead
+ if (test_mode) {
+ const r = feed.pump(gpa, core, &g) catch break;
+ if (r.eof) break;
+ }
+ // 2. pty output from the reader threads
+ var msgs = queue.take();
+ for (msgs.items) |m| switch (m) {
+ .output => |o| {
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ },
+ .eof => |id| {
+ if (ptys[id]) |pt| {
+ _ = linux.close(pt.fd);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = id } });
+ },
+ };
+ msgs.deinit(gpa);
+ // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
+ pollGamepad(&g, core);
+ // 4. effects
+ drainEffects(core, &ptys, gpa, &queue, &g, true);
+ if (core.quit) break;
+ // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
+ pollCwds(core, &ptys);
+ // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes)
+ _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
+ const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
+ const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ if (cols != core.screen_w or rows != core.screen_h)
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ // 7. render
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ renderFrame(&g, gpa, surface) catch |err| log.err("render: {t}", .{err});
+ }
+}
+
+// =====================================================================
+// web entry: set everything up, register a rAF frame callback, return.
+// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after.
+// =====================================================================
+
+// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer
+// is the whole heap, never freed (the shell lives for the page).
+var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined;
+var web_fba: std.heap.FixedBufferAllocator = undefined;
+
+const WebState = struct {
+ g: Gui,
+ core: *pardes.Pardes,
+ gpa: std.mem.Allocator,
+ frame_arena: std.heap.ArenaAllocator,
+};
+
+fn runWeb(opts_in: pardes.Options) !void {
+ web_fba = .init(&web_heap);
+ const gpa = web_fba.allocator();
+
+ if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser
+ log.err("SDL_Init: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse {
+ log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES);
+ _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3);
+ _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0);
+ _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1);
+ const gl_context = c.SDL_GL_CreateContext(window) orelse {
+ log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ };
+ if (!c.SDL_GL_MakeCurrent(window, gl_context)) {
+ log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()});
+ return error.SdlInit;
+ }
+ _ = c.SDL_GL_SetSwapInterval(1);
+
+ // ---- font + cell metrics, rastered at web_render_scale ----
+ const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse {
+ log.err("ui_font_new failed", .{});
+ return error.FontInit;
+ };
+ const px: f32 = 18.0;
+ const base_scale = c.ui_font_scale_for_height(font, px);
+ var cw: c_int = 10;
+ var chh: c_int = 20;
+ var asc: c_int = 16;
+ c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc);
+ const scale = c.ui_font_scale_for_height(font, px * web_render_scale);
+ const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale));
+ const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale));
+ const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale));
+
+ // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ----
+ const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es);
+ const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es);
+ var atlas_tex: c_uint = 0;
+ c.glGenTextures(1, &atlas_tex);
+ c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex);
+ c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR);
+ c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR);
+ c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE);
+ c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE);
+ c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1);
+ c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null);
+ var vbo: c_uint = 0;
+ c.glGenBuffers(1, &vbo);
+ var overlay_vbo: c_uint = 0;
+ c.glGenBuffers(1, &overlay_vbo);
+
+ const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h);
+ @memset(atlas_stage, 0);
+ const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices);
+
+ const st = try gpa.create(WebState);
+ st.* = .{
+ .g = .{
+ .window = window,
+ .device = undefined, // web renders through GL, never the GPU device
+ .swapchain_format = undefined,
+ .pipeline = undefined,
+ .overlay_pipeline = undefined,
+ .atlas_tex = undefined,
+ .atlas_sampler = undefined,
+ .atlas_xfer = undefined,
+ .overlay_vbuf = undefined,
+ .overlay_vxfer = undefined,
+ .overlay_vertices = overlay_vertices,
+ .gl_program = program,
+ .gl_overlay_program = overlay_program,
+ .gl_atlas_tex = atlas_tex,
+ .gl_vbo = vbo,
+ .gl_overlay_vbo = overlay_vbo,
+ .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"),
+ .font = font,
+ .scale = scale,
+ .cell_w = cell_w,
+ .cell_h = cell_h,
+ .ascent = ascent,
+ .atlas_stage = atlas_stage,
+ .glyphs = std.AutoHashMap(u32, Slot).init(gpa),
+ },
+ .core = undefined,
+ .gpa = gpa,
+ .frame_arena = .init(gpa),
+ };
+ const g = &st.g;
+ // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only)
+ _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent);
+ g.pen_x = cell_w;
+
+ // ---- the core: state replays from the embedded dump, no ptys ----
+ pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm
+ syncCanvasSize(g); // size the backing canvas before deriving the grid
+ var opts = opts_in;
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph);
+ opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
+ opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
+ st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump);
+
+ _ = c.SDL_StartTextInput(window);
+ std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0);
+}
+
+fn webFrame(arg: ?*anyopaque) callconv(.c) void {
+ const st: *WebState = @ptrCast(@alignCast(arg.?));
+ const g = &st.g;
+ const core = st.core;
+ // 1. input
+ var sev = std.mem.zeroes(c.SDL_Event);
+ while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev);
+ // 2. effects
+ drainEffectsWeb(core, st.gpa, g);
+ if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop();
+ // 3. the backing canvas follows the page, the grid follows the canvas
+ syncCanvasSize(g);
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w)));
+ const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h)));
+ if (cols != core.screen_w or rows != core.screen_h)
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ // 4. render
+ _ = st.frame_arena.reset(.retain_capacity);
+ const surface = core.render(st.frame_arena.allocator()) catch return;
+ renderFrameGl(g, st.gpa, surface) catch return;
+}
+
+/// the canvas follows the page (CSS); the SDL window follows the canvas,
+/// backed at web_render_scale (postWinsize in the prototype).
+fn syncCanvasSize(g: *Gui) void {
+ var css_w: f64 = 0;
+ var css_h: f64 = 0;
+ if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return;
+ const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale));
+ const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale));
+ var cur_w: c_int = 0;
+ var cur_h: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h);
+ if (cur_w != want_w or cur_h != want_h) {
+ _ = c.SDL_SetWindowSize(g.window, want_w, want_h);
+ _ = c.SDL_SyncWindow(g.window);
+ }
+}
+
+/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in,
+/// the rendered Surface out as text frames (same framing as the prototype).
+fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+ const env = init.environ_map;
+
+ pardes.image.start(io, gpa);
+ defer pardes.image.stop();
+
+ const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80;
+ const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24;
+ var opts = opts_in;
+ if (opts.load_path != null) {
+ opts.cols = grid_cols;
+ opts.rows = grid_rows;
+ }
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+ // the requested grid arrives as a resize EVENT (not init opts) so the core
+ // counts a resize and releases the shell greeting on first output
+ if (opts.load_path == null)
+ core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } });
+
+ writeFile(pardes.bash_rc);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = linux.close(pt.fd);
+ slot.* = null;
+ };
+ var queue: Queue = .{ .gpa = gpa, .sdl_wake = false };
+ defer queue.close();
+ drainEffects(core, &ptys, gpa, &queue, null, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue);
+
+ setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+ var feed: StdinFeed = .{};
+
+ // first frame before touching stdin, so `printf '' | pardes` still shows one
+ {
+ const surface = try core.render(frame_arena.allocator());
+ try dumpGrid(gpa, surface);
+ }
+ while (!core.quit) {
+ const r = try feed.pump(gpa, core, null);
+ if (r.eof) break;
+ var n_events: usize = r.n_events;
+ var msgs = queue.take();
+ for (msgs.items) |m| switch (m) {
+ .output => |o| {
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ n_events += 1;
+ },
+ .eof => |id| {
+ if (ptys[id]) |pt| {
+ _ = linux.close(pt.fd);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = id } });
+ n_events += 1;
+ },
+ };
+ msgs.deinit(gpa);
+ drainEffects(core, &ptys, gpa, &queue, null, true);
+ pollCwds(core, &ptys);
+ if (n_events == 0) continue; // idle tick: nothing changed, no frame
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ try dumpGrid(gpa, surface);
+ }
+}
+
+// =====================================================================
+// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events,
+// plus the private synthetic finger OSC:
+// ESC ] 777;finger;<type>;<id>;<x>;<y>;<pressure> BEL (normalized 0..1)
+// =====================================================================
+
+const StdinFeed = struct {
+ parser: vaxis.Parser = .{},
+ cache: vaxis.GraphemeCache = .{},
+ buf: [1024]u8 = undefined,
+ fill: usize = 0,
+ last_cols: u16 = 0,
+ last_rows: u16 = 0,
+
+ const Result = struct { eof: bool = false, n_events: usize = 0 };
+
+ /// Poll stdin briefly and translate what arrived. `g` is null in grid mode.
+ fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result {
+ var out: Result = .{};
+ // a pty stdin also carries the winsize; poll it in place of SIGWINCH
+ var ws: posix.winsize = std.mem.zeroes(posix.winsize);
+ if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and
+ ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and
+ (ws.col != f.last_cols or ws.row != f.last_rows))
+ {
+ f.last_cols = ws.col;
+ f.last_rows = ws.row;
+ f.applyResize(core, g, ws.col, ws.row);
+ out.n_events += 1;
+ }
+
+ var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }};
+ _ = posix.poll(&fds, 16) catch return out;
+ if ((fds[0].revents & posix.POLL.IN) == 0) {
+ if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true;
+ return out;
+ }
+ const rc = linux.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill);
+ const rn: isize = @bitCast(rc);
+ if (rn < 0) return out;
+ if (rn == 0) {
+ out.eof = true;
+ return out;
+ }
+ const len = f.fill + @as(usize, @intCast(rn));
+ f.fill = 0;
+
+ var seq_start: usize = 0;
+ while (seq_start < len) {
+ // private finger OSC first (vaxis would swallow it as unknown OSC)
+ const prefix = "\x1b]777;finger;";
+ if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) {
+ const body = f.buf[seq_start + prefix.len .. len];
+ const end = std.mem.indexOfScalar(u8, body, 0x07) orelse {
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ };
+ if (parseFinger(body[0..end])) |finger| {
+ if (g) |gp| {
+ var wpw: c_int = 0;
+ var wph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
+ handleFinger(&gp.touch, core, finger, @floatFromInt(@max(wpw, 1)), @floatFromInt(@max(wph, 1)), @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h));
+ } else {
+ handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1);
+ }
+ out.n_events += 1;
+ }
+ seq_start += prefix.len + end + 1;
+ continue;
+ }
+ if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) {
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ }
+
+ const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break;
+ if (result.n == 0) { // partial sequence: shift to front, read more
+ std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]);
+ f.fill = len - seq_start;
+ break;
+ }
+ seq_start += result.n;
+ const event = result.event orelse continue;
+ switch (event) {
+ .key_press => |key| {
+ var text: []const u8 = "";
+ if (key.text) |t| text = f.cache.put(t);
+ core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = text,
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } });
+ out.n_events += 1;
+ },
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none,
+ else => null,
+ };
+ if (button) |b| {
+ core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(@max(m.col, 0)),
+ .row = @intCast(@max(m.row, 0)),
+ } });
+ out.n_events += 1;
+ }
+ },
+ .winsize => |vw| {
+ f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows));
+ out.n_events += 1;
+ },
+ .paste => |text| {
+ core.update(.{ .paste = text });
+ gpa.free(@constCast(text));
+ out.n_events += 1;
+ },
+ else => {},
+ }
+ }
+ return out;
+ }
+
+ fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void {
+ _ = f;
+ if (g) |gp| {
+ // capture mode: resize the window; the frame loop resizes the core
+ _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h));
+ _ = c.SDL_SyncWindow(gp.window);
+ } else {
+ core.update(.{ .resize = .{ .cols = cols, .rows = rows } });
+ }
+ }
+};
+
+var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off
+
+fn parseFinger(payload: []const u8) ?Finger {
+ var parts = std.mem.splitScalar(u8, payload, ';');
+ const type_name = parts.next() orelse return null;
+ const id_s = parts.next() orelse return null;
+ const x_s = parts.next() orelse return null;
+ const y_s = parts.next() orelse return null;
+ const pressure_s = parts.next() orelse return null;
+ if (parts.next() != null) return null;
+ const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down"))
+ .down
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion"))
+ .motion
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up"))
+ .up
+ else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel"))
+ .cancel
+ else
+ return null;
+ return .{
+ .kind = kind,
+ .id = std.fmt.parseInt(u64, id_s, 10) catch return null,
+ .x = std.fmt.parseFloat(f32, x_s) catch return null,
+ .y = std.fmt.parseFloat(f32, y_s) catch return null,
+ .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null,
+ };
+}
+
+fn setStdinRaw() !void {
+ var term = try posix.tcgetattr(0);
+ term.iflag.BRKINT = false;
+ term.iflag.ICRNL = false;
+ term.iflag.INPCK = false;
+ term.iflag.ISTRIP = false;
+ term.iflag.IXON = false;
+ term.oflag.OPOST = false;
+ term.lflag.ECHO = false;
+ term.lflag.ICANON = false;
+ term.lflag.IEXTEN = false;
+ term.lflag.ISIG = false;
+ term.cc[@intFromEnum(posix.V.MIN)] = 1;
+ term.cc[@intFromEnum(posix.V.TIME)] = 0;
+ try posix.tcsetattr(0, .NOW, term);
+}
+
+// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing.
+fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+ if (surface.cols == 0 or surface.rows == 0) return;
+ // build the whole frame in one buffer → one write → atomic snapshots
+ var buf: std.ArrayList(u8) = .empty;
+ defer buf.deinit(gpa);
+ const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0;
+ const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0;
+ var hdr: [128]u8 = undefined;
+ const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{
+ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null,
+ }) catch return;
+ try buf.appendSlice(gpa, hdr_str);
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const g = surface.at(x, y).grapheme();
+ if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' ');
+ }
+ try buf.append(gpa, '\n');
+ }
+ try buf.appendSlice(gpa, "---ENDFRAME---\n");
+ writeFd(1, buf.items);
+}
+
+// =====================================================================
+// SDL event dispatch
+// =====================================================================
+
+fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void {
+ switch (sev.type) {
+ c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true,
+ // window resizes are picked up by the per-frame grid check
+ c.SDL_EVENT_KEY_DOWN => {
+ g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
+ g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
+ keyDown(core, sev.key.key, sev.key.mod);
+ },
+ c.SDL_EVENT_KEY_UP => {
+ g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0;
+ g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0;
+ },
+ c.SDL_EVENT_TEXT_INPUT => {
+ const tptr = sev.text.text orelse return;
+ var tlen: usize = 0;
+ while (tptr[tlen] != 0) : (tlen += 1) {}
+ if (tlen == 0) return;
+ const text: []const u8 = tptr[0..tlen];
+ const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return;
+ if (cplen > text.len) return;
+ const cp = std.unicode.utf8Decode(text[0..cplen]) catch return;
+ core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } });
+ },
+ c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => {
+ const b = sev.button;
+ g.mouse_x = b.x;
+ g.mouse_y = b.y;
+ const button: pardes.Mouse.Button = switch (b.button) {
+ 1 => .left,
+ 2 => .middle,
+ 3 => .right,
+ else => return,
+ };
+ const mc = mouseCell(g, core, b.x, b.y);
+ core.update(.{ .mouse = .{
+ .button = button,
+ .kind = if (b.down) .press else .release,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_MOUSE_MOTION => {
+ const m = sev.motion;
+ g.mouse_x = m.x;
+ g.mouse_y = m.y;
+ // drag = motion with a button held (selection extension keys off it)
+ const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0)
+ .left
+ else if ((m.state & c.SDL_BUTTON_MMASK) != 0)
+ .middle
+ else if ((m.state & c.SDL_BUTTON_RMASK) != 0)
+ .right
+ else
+ null;
+ const mc = mouseCell(g, core, m.x, m.y);
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_MOUSE_WHEEL => {
+ const w = sev.wheel;
+ if (w.y == 0) return;
+ g.mouse_x = w.mouse_x;
+ g.mouse_y = w.mouse_y;
+ const mc = mouseCell(g, core, w.mouse_x, w.mouse_y);
+ core.update(.{ .mouse = .{
+ .button = if (w.y > 0) .wheel_up else .wheel_down,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => {
+ const f = sev.tfinger;
+ var finger: Finger = .{
+ .kind = switch (sev.type) {
+ c.SDL_EVENT_FINGER_DOWN => .down,
+ c.SDL_EVENT_FINGER_MOTION => .motion,
+ c.SDL_EVENT_FINGER_UP => .up,
+ else => .cancel,
+ },
+ .id = @intCast(f.fingerID),
+ .x = f.x,
+ .y = f.y,
+ .pressure = f.pressure,
+ };
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ var win_w: f32 = @floatFromInt(@max(pw, 1));
+ var win_h: f32 = @floatFromInt(@max(ph, 1));
+ var fcw: f32 = @floatFromInt(g.cell_w);
+ var fch: f32 = @floatFromInt(g.cell_h);
+ if (is_emscripten) {
+ // remap normalized coords into the letterboxed grid so the
+ // shared two-finger machine's cell math stays exact
+ const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1)));
+ const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w);
+ const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h);
+ finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0);
+ finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0);
+ win_w = grid_w;
+ win_h = grid_h;
+ fcw = layout.w;
+ fch = layout.h;
+ }
+ handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch);
+ },
+ c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }),
+ // ---- steamdeck: first gamepad drives a virtual mouse (buttons here,
+ // axes polled per frame in pollGamepad) ----
+ c.SDL_EVENT_GAMEPAD_ADDED => {
+ if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which);
+ },
+ c.SDL_EVENT_GAMEPAD_REMOVED => {
+ if (g.gamepad) |pad| {
+ c.SDL_CloseGamepad(pad);
+ g.gamepad = null;
+ }
+ },
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => {
+ const down = sev.type == c.SDL_EVENT_GAMEPAD_BUTTON_DOWN;
+ const col = pixelCell(g.pad_x, g.cell_w);
+ const row = pixelCell(g.pad_y, g.cell_h);
+ const button: pardes.Mouse.Button = switch (sev.gbutton.button) {
+ c.SDL_GAMEPAD_BUTTON_SOUTH => .left, // A
+ c.SDL_GAMEPAD_BUTTON_EAST => .right, // B
+ c.SDL_GAMEPAD_BUTTON_NORTH => .middle, // Y
+ c.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER => .wheel_up,
+ c.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER => .wheel_down,
+ else => return,
+ };
+ switch (button) {
+ .wheel_up, .wheel_down => if (down) core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }),
+ else => core.update(.{ .mouse = .{ .button = button, .kind = if (down) .press else .release, .col = col, .row = row } }),
+ }
+ },
+ else => {},
+ }
+}
+
+/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as
+/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly).
+fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void {
+ const ctrl = (mod & c.SDL_KMOD_CTRL) != 0;
+ const alt = (mod & c.SDL_KMOD_ALT) != 0;
+ const gui_mod = (mod & c.SDL_KMOD_GUI) != 0;
+ const cp: u21 = switch (sym) {
+ c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter,
+ c.SDLK_BACKSPACE => pardes.Key.backspace,
+ c.SDLK_TAB => pardes.Key.tab,
+ c.SDLK_ESCAPE => pardes.Key.escape,
+ c.SDLK_LEFT => pardes.Key.left,
+ c.SDLK_RIGHT => pardes.Key.right,
+ c.SDLK_UP => pardes.Key.up,
+ c.SDLK_DOWN => pardes.Key.down,
+ c.SDLK_HOME => pardes.Key.home,
+ c.SDLK_END => pardes.Key.end,
+ c.SDLK_PAGEUP => pardes.Key.page_up,
+ c.SDLK_PAGEDOWN => pardes.Key.page_down,
+ else => blk: {
+ // letters / digits / punctuation only as a modifier shortcut;
+ // plain printable (incl. space) goes via TEXT_INPUT
+ if (!(ctrl or alt or gui_mod)) break :blk 0;
+ if (sym < 128 and sym >= ' ') break :blk @intCast(sym);
+ break :blk 0;
+ },
+ };
+ if (cp == 0) return;
+ core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt } });
+}
+
+fn pixelCell(px: f32, cell: u32) u16 {
+ return pixelCellF(px, @floatFromInt(cell));
+}
+
+fn pixelCellF(px: f32, cell: f32) u16 {
+ const idx = @floor(@max(px, 0) / @max(cell, 1));
+ return @intFromFloat(@min(idx, 10_000));
+}
+
+/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS
+/// points into a letterboxed canvas backed at web_render_scale.
+fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } {
+ if (!is_emscripten) return .{ .col = pixelCell(x, g.cell_w), .row = pixelCell(y, g.cell_h) };
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ var ww: c_int = 0;
+ var wh: c_int = 0;
+ _ = c.SDL_GetWindowSize(g.window, &ww, &wh);
+ const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1)));
+ const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1)));
+ const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1)));
+ return .{
+ .col = pixelCellF(px - layout.x_off, layout.w),
+ .row = pixelCellF(py - layout.y_off, layout.h),
+ };
+}
+
+/// steamdeck support: poll the sticks each frame. Left stick moves a virtual
+/// cursor at ~cell granularity (emits button-less motion so hover works),
+/// right stick Y accumulates into wheel ticks at the cursor position.
+fn pollGamepad(g: *Gui, core: *pardes.Pardes) void {
+ const pad = g.gamepad orelse return;
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const win_w: f32 = @floatFromInt(@max(pw, 1));
+ const win_h: f32 = @floatFromInt(@max(ph, 1));
+ const deadzone: f32 = 8000;
+ const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX));
+ const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY));
+ const old_col = pixelCell(g.pad_x, g.cell_w);
+ const old_row = pixelCell(g.pad_y, g.cell_h);
+ // full tilt ≈ one cell-height per frame; ~60fps makes that a brisk glide
+ const speed: f32 = @floatFromInt(g.cell_h);
+ if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1);
+ if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1);
+ const col = pixelCell(g.pad_x, g.cell_w);
+ const row = pixelCell(g.pad_y, g.cell_h);
+ if (col != old_col or row != old_row)
+ core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } });
+ const ry: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
+ if (@abs(ry) > deadzone) g.pad_scroll += ry / 32767.0 * 0.15;
+ while (@abs(g.pad_scroll) >= 1.0) {
+ const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down;
+ g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0;
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ }
+}
+
+// =====================================================================
+// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard
+// =====================================================================
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ gpa: std.mem.Allocator,
+ queue: *Queue,
+ g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
+ threads_ok: bool,
+) void {
+ while (core.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| {
+ std.debug.assert(ptys[sp.pane] == null);
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const pt = forkShell(cwd_z, core.screen_h, core.screen_w);
+ ptys[sp.pane] = pt;
+ // report the pane's starting directory back to the core (tags)
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch null) |path| {
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]);
+ }
+ if (threads_ok) spawnReader(gpa, pt, sp.pane, queue);
+ },
+ .write => |w| {
+ if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.fd, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => {}, // native: look opens panes instead; nothing to launch
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, f.content);
+ _ = linux.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, out);
+ _ = linux.close(fd);
+ },
+ .set_clipboard => {
+ if (g == null) continue;
+ const y = core.yank orelse continue;
+ const z = gpa.dupeZ(u8, y) catch continue;
+ defer gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+ },
+ .get_clipboard => {
+ if (g == null) continue;
+ // synchronous, unlike the tty's OSC 52 round-trip: reply is a paste
+ const t = c.SDL_GetClipboardText();
+ defer c.SDL_free(t);
+ var tlen: usize = 0;
+ while (t[tlen] != 0) : (tlen += 1) {}
+ if (tlen > 0) core.update(.{ .paste = t[0..tlen] });
+ },
+ .quit => {},
+ };
+}
+
+fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty {
+ 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) {
+ if (cwd) |cd| _ = chdir(cd);
+ const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv("/usr/bin/bash", &argv);
+ _exit(127);
+ }
+ return .{ .fd = master, .pid = pid };
+}
+
+fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void {
+ for (ptys, 0..) |slot, id| if (slot) |pt| {
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue;
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]);
+ };
+}
+
+/// web: no ptys, no fs — panes replay from the embedded dump. Only the
+/// clipboard and open_link effects have a browser meaning.
+fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void {
+ _ = g;
+ while (core.nextEffect()) |effect| switch (effect) {
+ .set_clipboard => {
+ const y = core.yank orelse continue;
+ const z = gpa.dupeZ(u8, y) catch continue;
+ defer gpa.free(z);
+ _ = c.SDL_SetClipboardText(z.ptr);
+ },
+ // look on a URL → a new tab
+ .open_link => |url| openLinkWeb(gpa, url.slice()),
+ // nothing to spawn/write/resize/save/dump into; get_clipboard would
+ // need an async permission round-trip — all no-ops
+ .spawn, .write, .resize_pty, .save_file, .write_dump, .get_clipboard, .quit => {},
+ };
+}
+
+/// window.open in a tiny generated script; single quotes and backslashes are
+/// %-escaped so the url can't break out of the JS string literal.
+fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void {
+ var safe: std.ArrayList(u8) = .empty;
+ defer safe.deinit(gpa);
+ for (url) |ch| switch (ch) {
+ '\'' => safe.appendSlice(gpa, "%27") catch return,
+ '\\' => safe.appendSlice(gpa, "%5C") catch return,
+ else => safe.append(gpa, ch) catch return,
+ };
+ const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return;
+ defer gpa.free(script);
+ std.os.emscripten.emscripten_run_script(script.ptr);
+}
+
+// =====================================================================
+// render: Surface → instanced quads → SDL GPU
+// =====================================================================
+
+fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+ const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return;
+ var sw: u32 = 0;
+ var sh: u32 = 0;
+ var target: *c.SDL_GPUTexture = undefined;
+ if (g.capture) {
+ // capture: render offscreen (the window may never present), dump PPM
+ sw = @as(u32, surface.cols) * g.cell_w;
+ sh = @as(u32, surface.rows) * g.cell_h;
+ if (sw == 0 or sh == 0) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ }
+ try ensureCaptureTexture(g, sw, sh);
+ target = g.capture_tex.?;
+ } else {
+ var swap_tex: ?*c.SDL_GPUTexture = null;
+ if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ }
+ target = swap_tex orelse { // swapchain busy: skip the frame
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ };
+ }
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+
+ const overlay_count = buildTouchOverlay(g, sw, sh);
+ if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count);
+
+ const cells: u32 = @as(u32, surface.cols) * surface.rows;
+ if (cells != 0) try ensureVbuf(g, cells);
+
+ var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ color_target.texture = target;
+ color_target.clear_color = .{
+ .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0,
+ .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0,
+ .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0,
+ .a = 1.0,
+ };
+ color_target.load_op = c.SDL_GPU_LOADOP_CLEAR;
+ color_target.store_op = c.SDL_GPU_STOREOP_STORE;
+
+ if (cells != 0 and g.vbuf != null) {
+ // ponytail: full re-upload every frame; the prototype's dirty-range
+ // diffing (cell_keys + coalesced ranges) is skipped for now.
+ const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return;
+ });
+ const instances: [*]CellInstance = @ptrCast(@alignCast(vptr));
+ const layout = fixedCellLayout(g);
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
+ var row: u16 = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ const idx: u32 = @as(u32, row) * surface.cols + col;
+ emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx);
+ }
+ }
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?);
+
+ // emitInstance may have rasterized new glyphs into the staging atlas;
+ // upload after vertex generation so this frame has what it references
+ if (g.atlas_dirty) uploadAtlas(g, cmd);
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 };
+ const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) };
+ c.SDL_UploadToGPUBuffer(copy, &src, &dst, false);
+ c.SDL_EndGPUCopyPass(copy);
+
+ const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline);
+ const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
+ const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 };
+ c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0);
+ drawOverlayGpu(g, rp, overlay_count);
+ c.SDL_EndGPURenderPass(rp);
+ } else {
+ // no grid yet: just clear to the page bg
+ const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null);
+ drawOverlayGpu(g, rp, overlay_count);
+ c.SDL_EndGPURenderPass(rp);
+ }
+
+ if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh);
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+}
+
+fn emitInstance(
+ g: *Gui,
+ instances: [*]CellInstance,
+ idx: u32,
+ col: u16,
+ row: u16,
+ layout: CellLayout,
+ win_w: f32,
+ win_h: f32,
+ cell: *const pardes.Cell,
+ is_cursor: bool,
+) void {
+ // resolve the cell style to concrete fg/bg, matching the prototype's
+ // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell
+ // renders as reverse (cancelling an already-reversed cell).
+ var fg = fg_default;
+ var bg = bg_default;
+ var reverse = is_cursor;
+ if (!cell.default) {
+ const st = cell.style;
+ fg = switch (st.fg) {
+ .default => fg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ bg = switch (st.bg) {
+ .default => bg_default,
+ .index => |i| palColor(i),
+ .rgb => |rgb| rgb,
+ };
+ if (st.reverse) reverse = !reverse;
+ if (st.invisible) fg = bg;
+ if (st.dim) for (&fg) |*ch| {
+ ch.* = @intCast(@as(u16, ch.*) * 6 / 10);
+ };
+ }
+ if (reverse) std.mem.swap([3]u8, &fg, &bg);
+
+ // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web
+ const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w;
+ const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h;
+ const x0 = (px0 / win_w) * 2.0 - 1.0;
+ const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0;
+ const y0 = 1.0 - (py0 / win_h) * 2.0;
+ const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0;
+
+ const grapheme = cell.grapheme();
+ const cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
+ const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp);
+ const aw_f: f32 = @floatFromInt(atlas_w);
+ const ah_f: f32 = @floatFromInt(atlas_h);
+
+ instances[idx] = .{
+ .x0 = x0,
+ .y0 = y0,
+ .x1 = x1,
+ .y1 = y1,
+ .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f,
+ .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f,
+ .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f,
+ .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f,
+ .fr = @as(f32, @floatFromInt(fg[0])) / 255.0,
+ .fg = @as(f32, @floatFromInt(fg[1])) / 255.0,
+ .fb = @as(f32, @floatFromInt(fg[2])) / 255.0,
+ .br = @as(f32, @floatFromInt(bg[0])) / 255.0,
+ .bg = @as(f32, @floatFromInt(bg[1])) / 255.0,
+ .bb = @as(f32, @floatFromInt(bg[2])) / 255.0,
+ };
+}
+
+fn palColor(idx: u8) [3]u8 {
+ const p = ghostty_vt.color.default[idx];
+ return .{ p.r, p.g, p.b };
+}
+
+// first codepoint of a UTF-8 grapheme; space on failure/empty
+fn firstCp(s: []const u8) u32 {
+ if (s.len == 0) return ' ';
+ const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' ';
+ if (n > s.len) return ' ';
+ return std.unicode.utf8Decode(s[0..n]) catch ' ';
+}
+
+fn ensureGlyph(g: *Gui, cp: u32) Slot {
+ if (g.glyphs.get(cp)) |s| return s;
+ if (g.pen_x + g.cell_w > atlas_w) {
+ g.pen_x = 0;
+ g.pen_y += g.cell_h;
+ }
+ if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back
+ const s = Slot{ .u = g.pen_x, .v = g.pen_y };
+ const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u;
+ _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent);
+ g.glyphs.put(cp, s) catch return g.space_slot;
+ g.pen_x += g.cell_w;
+ g.atlas_dirty = true;
+ return s;
+}
+
+fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void {
+ const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return;
+ const dst: [*]u8 = @ptrCast(ptr);
+ @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage);
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer);
+
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h };
+ const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 };
+ c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false);
+ c.SDL_EndGPUCopyPass(copy);
+ g.atlas_dirty = false;
+}
+
+fn ensureVbuf(g: *Gui, cells: u32) !void {
+ if (g.vbuf_cells == cells and g.vbuf != null) return;
+ if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b);
+ if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x);
+ g.vbuf = null;
+ g.vxfer = null;
+ const size = cells * @sizeOf(CellInstance);
+ var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 };
+ g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate;
+ var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 };
+ g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate;
+ g.vbuf_cells = cells;
+}
+
+fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(CellInstance),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") },
+ .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") },
+ .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") },
+ .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") },
+ };
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) };
+
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state = .{
+ .vertex_buffer_descriptions = &vbuf_desc,
+ .num_vertex_buffers = 1,
+ .vertex_attributes = &attrs,
+ .num_vertex_attributes = attrs.len,
+ };
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
+fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ var vbuf_desc = c.SDL_GPUVertexBufferDescription{
+ .slot = 0,
+ .pitch = @sizeOf(OverlayVertex),
+ .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX,
+ .instance_step_rate = 0,
+ };
+ var attrs = [_]c.SDL_GPUVertexAttribute{
+ .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") },
+ .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") },
+ };
+ var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState);
+ blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA;
+ blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE;
+ blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA;
+ blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD;
+ blend.enable_blend = true;
+ var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend };
+
+ var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo);
+ info.vertex_shader = vs;
+ info.fragment_shader = fs;
+ info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST;
+ info.vertex_input_state = .{
+ .vertex_buffer_descriptions = &vbuf_desc,
+ .num_vertex_buffers = 1,
+ .vertex_attributes = &attrs,
+ .num_vertex_attributes = attrs.len,
+ };
+ info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL;
+ info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE;
+ info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 };
+ return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate;
+}
+
+fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader {
+ var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo);
+ info.code = code.ptr;
+ info.code_size = code.len;
+ info.entrypoint = "main";
+ info.format = c.SDL_GPU_SHADERFORMAT_SPIRV;
+ info.stage = stage;
+ info.num_samplers = n_samplers;
+ return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate;
+}
+
+// =====================================================================
+// web render: Surface → the same instanced quads → WebGL2. Full instance
+// re-upload each frame; attribs re-specified per draw (no VAO to manage).
+// =====================================================================
+
+fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const sw: u32 = @intCast(@max(pw, 1));
+ const sh: u32 = @intCast(@max(ph, 1));
+ c.glViewport(0, 0, @intCast(sw), @intCast(sh));
+ c.glClearColor(
+ @as(f32, @floatFromInt(bg_default[0])) / 255.0,
+ @as(f32, @floatFromInt(bg_default[1])) / 255.0,
+ @as(f32, @floatFromInt(bg_default[2])) / 255.0,
+ 1.0,
+ );
+ c.glClear(c.GL_COLOR_BUFFER_BIT);
+
+ const overlay_count = buildTouchOverlay(g, sw, sh);
+ const cells: u32 = @as(u32, surface.cols) * surface.rows;
+ if (cells == 0) {
+ drawOverlayGl(g, overlay_count);
+ _ = c.SDL_GL_SwapWindow(g.window);
+ return;
+ }
+ if (g.gl_instances.len < cells) {
+ gpa.free(g.gl_instances);
+ g.gl_instances = try gpa.alloc(CellInstance, cells);
+ }
+
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ const layout = cellLayout(g, surface.cols, surface.rows, sw, sh);
+ const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells;
+ var row: u16 = 0;
+ while (row < surface.rows) : (row += 1) {
+ var col: u16 = 0;
+ while (col < surface.cols) : (col += 1) {
+ const idx: u32 = @as(u32, row) * surface.cols + col;
+ emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx);
+ }
+ }
+ if (g.atlas_dirty) uploadAtlasGl(g);
+
+ c.glUseProgram(g.gl_program);
+ c.glActiveTexture(c.GL_TEXTURE0);
+ c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex);
+ c.glUniform1i(g.gl_u_atlas, 0);
+ c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo);
+ c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW);
+ const stride: c_int = @intCast(@sizeOf(CellInstance));
+ c.glEnableVertexAttribArray(0);
+ c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0")));
+ c.glVertexAttribDivisor(0, 1);
+ c.glEnableVertexAttribArray(1);
+ c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0")));
+ c.glVertexAttribDivisor(1, 1);
+ c.glEnableVertexAttribArray(2);
+ c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr")));
+ c.glVertexAttribDivisor(2, 1);
+ c.glEnableVertexAttribArray(3);
+ c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br")));
+ c.glVertexAttribDivisor(3, 1);
+ c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells));
+ drawOverlayGl(g, overlay_count);
+ _ = c.SDL_GL_SwapWindow(g.window);
+}
+
+fn uploadAtlasGl(g: *Gui) void {
+ c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex);
+ c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1);
+ c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr);
+ g.atlas_dirty = false;
+}
+
+fn drawOverlayGl(g: *Gui, vertex_count: u32) void {
+ if (vertex_count == 0) return;
+ c.glUseProgram(g.gl_overlay_program);
+ c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo);
+ c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW);
+ const stride: c_int = @intCast(@sizeOf(OverlayVertex));
+ c.glEnableVertexAttribArray(0);
+ c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x")));
+ c.glVertexAttribDivisor(0, 0);
+ c.glEnableVertexAttribArray(1);
+ c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r")));
+ c.glVertexAttribDivisor(1, 0);
+ c.glDisableVertexAttribArray(2);
+ c.glDisableVertexAttribArray(3);
+ c.glEnable(c.GL_BLEND);
+ c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA);
+ c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count));
+ c.glDisable(c.GL_BLEND);
+}
+
+fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint {
+ const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source);
+ defer c.glDeleteShader(vs);
+ const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source);
+ defer c.glDeleteShader(fs);
+ const program = c.glCreateProgram();
+ if (program == 0) return error.GpuCreate;
+ c.glAttachShader(program, vs);
+ c.glAttachShader(program, fs);
+ c.glLinkProgram(program);
+ var ok: c_int = 0;
+ c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok);
+ if (ok == 0) {
+ log.err("WebGL program link failed", .{});
+ c.glDeleteProgram(program);
+ return error.GpuCreate;
+ }
+ return program;
+}
+
+fn makeGlShader(kind: c_uint, source: []const u8) !c_uint {
+ const shader = c.glCreateShader(kind);
+ if (shader == 0) return error.GpuCreate;
+ var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)};
+ var src_len = [_]c_int{@intCast(source.len)};
+ c.glShaderSource(shader, 1, &src_ptr, &src_len);
+ c.glCompileShader(shader);
+ var ok: c_int = 0;
+ c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok);
+ if (ok == 0) {
+ log.err("WebGL shader compile failed", .{});
+ c.glDeleteShader(shader);
+ return error.GpuCreate;
+ }
+ return shader;
+}
+
+// =====================================================================
+// PARDES_TEST frame capture: download the render target, write latest.ppm
+// =====================================================================
+
+fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void {
+ if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return;
+ if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t);
+ var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo);
+ info.type = c.SDL_GPU_TEXTURETYPE_2D;
+ info.format = g.swapchain_format;
+ info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET;
+ info.width = width;
+ info.height = height;
+ info.layer_count_or_depth = 1;
+ info.num_levels = 1;
+ info.sample_count = c.SDL_GPU_SAMPLECOUNT_1;
+ g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ g.capture_tex_w = width;
+ g.capture_tex_h = height;
+}
+
+fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void {
+ const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1);
+ if (size == 0) {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return error.UnsupportedCaptureFormat;
+ }
+ if (g.capture_xfer == null or g.capture_xfer_size < size) {
+ if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x);
+ var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 };
+ g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse {
+ _ = c.SDL_SubmitGPUCommandBuffer(cmd);
+ return error.GpuCreate;
+ };
+ g.capture_xfer_size = size;
+ }
+ const xfer = g.capture_xfer.?;
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 };
+ const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh };
+ c.SDL_DownloadFromGPUTexture(copy, &src, &dst);
+ c.SDL_EndGPUCopyPass(copy);
+
+ const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit;
+ defer c.SDL_ReleaseGPUFence(g.device, fence);
+ var fences = [_]*c.SDL_GPUFence{fence};
+ if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit;
+
+ const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap;
+ defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer);
+ const pixels: [*]const u8 = @ptrCast(mapped);
+ try writeCapturePpm(g, gpa, pixels[0..size], sw, sh);
+}
+
+fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void {
+ if (g.capture_dir.len == 0) return;
+ const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format);
+ if (bpp != 4) return error.UnsupportedCaptureFormat;
+ const bgr = switch (g.swapchain_format) {
+ c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false,
+ c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true,
+ else => return error.UnsupportedCaptureFormat,
+ };
+
+ var tmp_buf: [4096]u8 = undefined;
+ var final_buf: [4096]u8 = undefined;
+ const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
+ const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong;
+
+ const orc = linux.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(orc);
+ if (sfd < 0) return error.CaptureWriteFailed;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+
+ var header: [64]u8 = undefined;
+ const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed;
+ writeFd(fd, hdr);
+
+ const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3);
+ defer gpa.free(row_rgb);
+ for (0..height) |row| {
+ const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4];
+ for (0..width) |x| {
+ const si = x * 4;
+ const di = x * 3;
+ row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0];
+ row_rgb[di + 1] = src[si + 1];
+ row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2];
+ }
+ writeFd(fd, row_rgb);
+ }
+ const rrc = linux.rename(tmp_path, final_path);
+ const rs: isize = @bitCast(rrc);
+ if (rs < 0) return error.CaptureWriteFailed;
+}
+
+// =====================================================================
+// touch debug overlay: per-finger colored circles + trails + a middle-click
+// flash HUD, alpha-blended over the grid. Always active on touch input.
+// =====================================================================
+
+fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 {
+ if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0;
+ const win_w: f32 = @floatFromInt(sw);
+ const win_h: f32 = @floatFromInt(sh);
+ var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h };
+ const t = &g.touch;
+
+ if (t.middle_flash_frames != 0) {
+ const flash = @as(f32, @floatFromInt(t.middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames));
+ const label = touchMiddleClickLabel(t.middle_clicks);
+ const glyph_scale = 3.0;
+ const char_step = 4.0 * glyph_scale;
+ const pad = 5.0;
+ const stripe_w = 4.0;
+ const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
+ const label_h = pad * 2.0 + 5.0 * glyph_scale;
+ builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash });
+ builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash });
+ builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 });
+ builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 });
+ t.middle_flash_frames -= 1;
+ }
+
+ for (&t.points) |*tp| {
+ if (!tp.active) continue;
+ const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w;
+ const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h;
+ const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0;
+ const color = touchColor(tp.id);
+
+ if (tp.trail_len > 1) {
+ const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0;
+ for (0..tp.trail_len - 1) |trail_i| {
+ const sample = tp.trail[(first + trail_i) % max_touch_trail_points];
+ const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len));
+ var trail_color = color;
+ trail_color.a *= 0.42 * fade;
+ const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w;
+ const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h;
+ const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8;
+ builder.addCircle(tx, ty, trail_radius, trail_color);
+ }
+ }
+
+ builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 });
+ builder.addCircle(cx, cy, radius, color);
+ builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 });
+ builder.addIdTag(tp.id, cx, cy, color);
+ }
+ return @intCast(builder.len);
+}
+
+fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void {
+ const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex);
+ const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return;
+ const dst: [*]u8 = @ptrCast(ptr);
+ const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr);
+ @memcpy(dst[0..byte_len], src[0..byte_len]);
+ c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer);
+ const copy = c.SDL_BeginGPUCopyPass(cmd);
+ const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 };
+ const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len };
+ c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false);
+ c.SDL_EndGPUCopyPass(copy);
+}
+
+fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void {
+ if (vertex_count == 0) return;
+ const pass = rp orelse return;
+ c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline);
+ const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 };
+ c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1);
+ c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0);
+}
+
+fn touchColor(id: u64) OverlayColor {
+ const palette = [_]OverlayColor{
+ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 },
+ .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 },
+ .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 },
+ .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 },
+ .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 },
+ .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 },
+ };
+ return palette[@intCast(id % palette.len)];
+}
+
+const OverlayBuilder = struct {
+ vertices: []OverlayVertex,
+ len: usize = 0,
+ win_w: f32,
+ win_h: f32,
+
+ fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void {
+ if (b.len >= b.vertices.len) return;
+ b.vertices[b.len] = .{
+ .x = (px / b.win_w) * 2.0 - 1.0,
+ .y = 1.0 - (py / b.win_h) * 2.0,
+ .r = color.r,
+ .g = color.g,
+ .b = color.b,
+ .a = color.a,
+ };
+ b.len += 1;
+ }
+
+ fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void {
+ if (b.len + 3 > b.vertices.len) return;
+ b.addVertex(x0, y0, color);
+ b.addVertex(x1, y1, color);
+ b.addVertex(x2, y2, color);
+ }
+
+ fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void {
+ if (b.len + 6 > b.vertices.len) return;
+ const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w);
+ const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w);
+ const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h);
+ const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h);
+ if (x1 <= x0 or y1 <= y0) return;
+ b.addTri(x0, y0, x1, y0, x1, y1, color);
+ b.addTri(x0, y0, x1, y1, x0, y1, color);
+ }
+
+ fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void {
+ var i: usize = 0;
+ while (i + 1 < circle_points.len) : (i += 1) {
+ const p0 = circle_points[i];
+ const p1 = circle_points[i + 1];
+ b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color);
+ }
+ }
+
+ fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void {
+ const label = touchIdLabel(id);
+ const glyph_scale = 2.0;
+ const char_step = 4.0 * glyph_scale;
+ const pad = 4.0;
+ const stripe_w = 3.0;
+ const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale;
+ const label_h = pad * 2.0 + 5.0 * glyph_scale;
+
+ var x = cx + 20.0;
+ if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0;
+ x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0));
+ var y = cy - label_h - 10.0;
+ if (y < 2.0) y = cy + 20.0;
+ y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0));
+
+ b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 });
+ b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color);
+ b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 });
+ }
+
+ fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void {
+ var cx = x;
+ for (text) |ch| {
+ const rows = miniGlyph(ch);
+ const masks = [_]u8{ 0b100, 0b010, 0b001 };
+ for (rows, 0..) |bits, row| {
+ for (masks, 0..) |mask, colidx| {
+ if ((bits & mask) == 0) continue;
+ const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale;
+ const y0 = y + @as(f32, @floatFromInt(row)) * scale;
+ b.addRect(x0, y0, x0 + scale, y0 + scale, color);
+ }
+ }
+ cx += 4.0 * scale;
+ }
+ }
+};
+
+const circle_points = [_][2]f32{
+ .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 },
+ .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 },
+ .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 },
+ .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 },
+ .{ 1.0000000, 0.0000000 },
+};
+
+fn touchIdLabel(id: u64) [9]u8 {
+ var out: [9]u8 = undefined;
+ out[0] = '#';
+ const low: u32 = @truncate(id);
+ for (0..8) |i| {
+ const shift: u5 = @intCast((7 - i) * 4);
+ out[i + 1] = hexDigit((low >> shift) & 0xf);
+ }
+ return out;
+}
+
+fn touchMiddleClickLabel(count: u32) [13]u8 {
+ var out: [13]u8 = undefined;
+ @memcpy(out[0..5], "MID #");
+ for (0..8) |i| {
+ const shift: u5 = @intCast((7 - i) * 4);
+ out[i + 5] = hexDigit((count >> shift) & 0xf);
+ }
+ return out;
+}
+
+fn hexDigit(n: u32) u8 {
+ return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10));
+}
+
+fn miniGlyph(ch: u8) [5]u8 {
+ return switch (ch) {
+ '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 },
+ '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 },
+ '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 },
+ '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 },
+ '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 },
+ '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 },
+ '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 },
+ '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 },
+ '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 },
+ '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 },
+ '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 },
+ 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 },
+ 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 },
+ 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 },
+ 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 },
+ 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 },
+ 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 },
+ 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 },
+ 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 },
+ else => .{ 0, 0, 0, 0, 0 },
+ };
+}
+
+// =====================================================================
+// shared plumbing (same shapes as tty.zig)
+// =====================================================================
+
+/// The effective codepoint the way vaxis Key.matches sees it: a single-char
+/// text wins (shift resolved by the terminal), else the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |cp| {
+ if (it.nextCodepoint() == null) return cp;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
+/// coincide: enter/tab/escape/backspace pass through).
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ else => cp,
+ };
+}
+
+fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 {
+ const raw = env.get(name) orelse return null;
+ return std.fmt.parseInt(u16, raw, 10) catch null;
+}
+
+fn writeFile(data: []const u8) void {
+ const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const rc = linux.write(fd, data[off..].ptr, data.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}
diff --git a/src/image.zig b/src/image.zig
new file mode 100644
index 00000000..a3415019
--- /dev/null
+++ b/src/image.zig
@@ -0,0 +1,53 @@
+//! Image panes. Decoding is zstbi (C stb_image) to RGBA, downscaled; the kept
+//! pixels render as PETSCII glyph art directly into the Surface — or, when the
+//! host has kitty graphics (and Petscii isn't toggled), ride the Surface as a
+//! pixel attachment the shell transmits/places (tty: vaxis kitty; SDL: blit).
+const std = @import("std");
+const zstbi = @import("zstbi");
+const ghostty_vt = @import("ghostty-vt");
+pub const petscii = @import("petscii.zig");
+
+/// the terminal's own 16 ANSI colors — what the `terminal` palette mode
+/// scores against; cells then paint as indexed colors so the real terminal
+/// resolves the RGB
+pub fn ansiPalette() [16][3]u8 {
+ var p: [16][3]u8 = undefined;
+ for (0..16) |i| {
+ const c = ghostty_vt.color.default[i];
+ p[i] = .{ c.r, c.g, c.b };
+ }
+ return p;
+}
+
+/// cap the longest side before keeping/transmitting: a pane is at most a
+/// screenful of cells, multi-thousand-pixel photos waste decode/transmit time
+const MAX_DIM: u32 = 1280;
+
+pub const PaletteMode = enum { commodore, terminal };
+
+/// call once at startup / exit (stb_image's allocator shim)
+pub fn start(io: std.Io, gpa: std.mem.Allocator) void {
+ zstbi.init(io, gpa);
+}
+pub fn stop() void {
+ zstbi.deinit();
+}
+
+/// decode + downscale to RGBA, gpa-owned. Returns null on any failure — the
+/// pane then simply shows a blank body.
+pub fn decode(gpa: std.mem.Allocator, bytes: []const u8) ?struct { rgba: []u8, w: usize, h: usize } {
+ if (bytes.len == 0) return null;
+ var img = zstbi.Image.loadFromMemory(bytes, 4) catch return null; // 4 = RGBA
+ defer img.deinit();
+ var scaled: ?zstbi.Image = null;
+ defer if (scaled) |*s| s.deinit();
+ const longest = @max(img.width, img.height);
+ const src: *const zstbi.Image = if (longest > MAX_DIM) blk: {
+ const nw = @max(1, img.width * MAX_DIM / longest);
+ const nh = @max(1, img.height * MAX_DIM / longest);
+ scaled = img.resize(nw, nh);
+ break :blk &scaled.?;
+ } else &img;
+ const rgba = gpa.dupe(u8, src.data) catch return null;
+ return .{ .rgba = rgba, .w = src.width, .h = src.height };
+}
diff --git a/src/look.zig b/src/look.zig
new file mode 100644
index 00000000..67fbf8c5
--- /dev/null
+++ b/src/look.zig
@@ -0,0 +1,123 @@
+//! What a click on text MEANS. The acme "look" (right click / Enter): expand
+//! the click to a file-ish word, then resolve it against the pane's directory.
+//! This is the one deliberately platform-divergent file — the divergence is a
+//! comptime switch on pardes.platform, used the way the stdlib switches on
+//! os.tag, so every platform's behavior sits in the same screenful:
+//! tty/gui — the word resolves through the real filesystem (realpath,
+//! open(O_DIRECTORY)); dirs open shells, files open file panes.
+//! web — there is no filesystem; only URLs mean anything, and a URL
+//! opens in a new tab (the open_link effect).
+const std = @import("std");
+const linux = std.os.linux;
+const pardes = @import("pardes.zig");
+
+extern "c" fn realpath(path: [*:0]const u8, resolved: [*]u8) ?[*:0]u8;
+
+/// file-ish word chars (acme isfilec): alnum + . - + / : @ _ ~
+pub fn isFileChar(c: u8) bool {
+ return std.ascii.isAlphanumeric(c) or switch (c) {
+ '.', '-', '+', '/', ':', '@', '_', '~' => true,
+ else => false,
+ };
+}
+
+/// peel a trailing :NNN (or :NNN:) line suffix: main.zig:100 -> {main.zig, 100}
+pub fn parsePathLine(tok: []const u8) struct { path: []const u8, line: usize } {
+ var sep: usize = 0;
+ while (sep < tok.len) : (sep += 1) {
+ if (tok[sep] != ':') continue;
+ var j = sep + 1;
+ var line: usize = 0;
+ while (j < tok.len and std.ascii.isDigit(tok[j])) : (j += 1) line = line * 10 + (tok[j] - '0');
+ if (j == sep + 1) continue; // no digits after ':'
+ if (j < tok.len and tok[j] != ':') continue; // junk after the number
+ return .{ .path = tok[0..sep], .line = line };
+ }
+ return .{ .path = tok, .line = 0 };
+}
+
+pub const Target = union(enum) {
+ none,
+ dir: []const u8, // resolved absolute path, in caller's buf
+ file: struct { path: []const u8, line: usize },
+ image: struct { path: []const u8 },
+ url: []const u8,
+};
+
+const image_exts = [_][]const u8{ ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ppm", ".pgm", ".tga" };
+
+pub fn isImagePath(path: []const u8) bool {
+ for (image_exts) |ext| {
+ if (std.ascii.endsWithIgnoreCase(path, ext)) return true;
+ }
+ return false;
+}
+
+/// Resolve a looked-at word against the pane's directory. `realbuf` must
+/// outlive the returned Target (dir/file/image paths point into it).
+pub fn resolve(word_raw: []const u8, cwd: []const u8, realbuf: *[4096]u8) Target {
+ const trimmed = std.mem.trim(u8, word_raw, " \t\r\n");
+ const pl = parsePathLine(trimmed);
+ const word = pl.path;
+ if (word.len == 0) return .none;
+
+ if (platform_has_fs) {
+ var joinbuf: [2048]u8 = undefined;
+ const joined: ?[:0]u8 = if (word[0] == '/')
+ (std.fmt.bufPrintSentinel(&joinbuf, "{s}", .{word}, 0) catch null)
+ else
+ (std.fmt.bufPrintSentinel(&joinbuf, "{s}/{s}", .{ cwd, word }, 0) catch null);
+ const jz = joined orelse return .none;
+ const rp = realpath(jz.ptr, realbuf) orelse return .none;
+ const resolved = std.mem.span(rp);
+ if (isDir(rp)) return .{ .dir = resolved };
+ if (isImagePath(resolved)) return .{ .image = .{ .path = resolved } };
+ return .{ .file = .{ .path = resolved, .line = pl.line } };
+ } else {
+ // web: no filesystem. A URL opens in a new tab; everything else is inert.
+ if (std.mem.startsWith(u8, trimmed, "http://") or std.mem.startsWith(u8, trimmed, "https://"))
+ return .{ .url = trimmed };
+ return .none;
+ }
+}
+
+const platform_has_fs = switch (pardes.platform) {
+ .tty, .gui => true,
+ .web => false,
+};
+
+/// true if `path` exists and is a directory (open(O_DIRECTORY), no stat needed)
+fn isDir(path: [*:0]const u8) bool {
+ const rc = linux.open(path, .{ .ACCMODE = .RDONLY, .DIRECTORY = true, .CLOEXEC = true }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return false;
+ _ = linux.close(@intCast(sfd));
+ return true;
+}
+
+/// read a whole file (gpa-owned) — the look side of opening a file pane.
+/// Compiled out on web (no fs); dumps provide file content there instead.
+pub fn readFile(gpa: std.mem.Allocator, path: []const u8) ![]u8 {
+ if (!platform_has_fs) return error.NoFilesystem;
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0) catch return error.PathTooLong;
+ const rc = linux.open(path_z, .{ .ACCMODE = .RDONLY }, 0);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return error.OpenFailed;
+ const fd: i32 = @intCast(sfd);
+ defer _ = linux.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ errdefer buf.deinit(gpa);
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const r = linux.read(fd, &chunk, chunk.len);
+ const n: isize = @bitCast(r);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ }
+ return buf.toOwnedSlice(gpa);
+}
diff --git a/src/main.zig b/src/main.zig
new file mode 100644
index 00000000..ff151323
--- /dev/null
+++ b/src/main.zig
@@ -0,0 +1,102 @@
+const std = @import("std");
+const builtin = @import("builtin");
+const pardes = @import("pardes.zig");
+
+const is_emscripten = builtin.os.tag == .emscripten;
+
+extern "c" fn emscripten_console_error(utf8: [*:0]const u8) void;
+
+// emscripten: std.debug's default threaded-io singleton doesn't run on wasm;
+// fail it (init errors surface via emscripten_console_error below). Native
+// values match the std defaults. Same shim as the prototype's replay.zig.
+pub const std_options_debug_threaded_io: ?*std.Io.Threaded = if (is_emscripten)
+ null
+else
+ std.Io.Threaded.global_single_threaded;
+pub const std_options_debug_io: std.Io = if (is_emscripten)
+ std.Io.failing
+else
+ std_options_debug_threaded_io.?.io();
+
+const help_text =
+ \\Usage: pardes [options]
+ \\
+ \\Options:
+ \\ --tty start as one shell pane already in tty mode
+ \\ --tty-toggle <key> use Ctrl-<key> to enter/leave tty mode
+ \\ -l <dump.zon> load a dump of another instance (see Dump)
+ \\ -h, --help show this help and exit
+ \\
+;
+
+// The browser runtime calls a C main (exported below); everything else keeps
+// the std.process.Init entry.
+pub const main = if (is_emscripten) webMain else nativeMain;
+
+comptime {
+ if (is_emscripten) @export(&emscriptenMain, .{ .name = "main" });
+}
+
+fn emscriptenMain(argc: c_int, argv: [*]?[*:0]u8) callconv(.c) c_int {
+ _ = argc;
+ _ = argv;
+ webMain() catch |err| {
+ var buf: [128]u8 = undefined;
+ const msg = std.fmt.bufPrintSentinel(&buf, "pardes web init failed: {t}", .{err}, 0) catch "pardes web init failed";
+ emscripten_console_error(msg.ptr);
+ return 1;
+ };
+ return 0;
+}
+
+// no argv in the browser: options stay default, state comes from the dump
+fn webMain() !void {
+ try @import("gui.zig").run(.{});
+}
+
+fn nativeMain(init: std.process.Init) !void {
+ var opts: pardes.Options = .{};
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const a = args[i];
+ if (std.mem.eql(u8, a, "--tty")) {
+ opts.tty_only = true;
+ } else if (std.mem.startsWith(u8, a, "--tty-toggle=")) {
+ opts.tty_toggle = parseCtrlKey(a["--tty-toggle=".len..]) orelse return error.BadArgs;
+ } else if (std.mem.eql(u8, a, "--tty-toggle")) {
+ i += 1;
+ if (i >= args.len) return error.BadArgs;
+ opts.tty_toggle = parseCtrlKey(args[i]) orelse return error.BadArgs;
+ } else if (std.mem.eql(u8, a, "-l")) {
+ i += 1;
+ if (i >= args.len) return error.BadArgs;
+ opts.load_path = args[i];
+ } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) {
+ try std.Io.File.stdout().writeStreamingAll(init.io, help_text);
+ return;
+ } else {
+ return error.BadArgs;
+ }
+ }
+ switch (pardes.platform) {
+ .tty => try @import("tty.zig").run(init, opts),
+ .gui => try @import("gui.zig").run(init, opts),
+ .web => unreachable, // web enters through webMain, never here
+ }
+}
+
+fn parseCtrlKey(raw: []const u8) ?u21 {
+ var value = std.mem.trim(u8, raw, " \t\r\n");
+ if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl-")) {
+ value = value[5..];
+ } else if (value.len >= 5 and std.ascii.eqlIgnoreCase(value[0..5], "ctrl+")) {
+ value = value[5..];
+ } else if (value.len == 2 and value[0] == '^') {
+ value = value[1..];
+ }
+ if (value.len != 1) return null;
+ const c = std.ascii.toLower(value[0]);
+ if (c < 'a' or c > 'z' or c == 'c') return null;
+ return c;
+}
diff --git a/src/modal.zig b/src/modal.zig
new file mode 100644
index 00000000..2a195432
--- /dev/null
+++ b/src/modal.zig
@@ -0,0 +1,964 @@
+const std = @import("std");
+
+// Modal-editing text math, kept free of vaxis/ghostty so it can be unit-tested
+// in isolation (see the `unit-test` build step). main.zig wires this onto the
+// pane's cursor + (for file panes) its content.
+//
+// The cursor sits ON a character: col is a char index in [0, line.len]; col ==
+// line.len means "on the line terminator / after the last char". Motions are
+// written to land on real characters; main.zig clamps for display.
+
+pub const Cursor = struct {
+ row: usize = 0,
+ col: usize = 0,
+
+ pub fn eql(a: Cursor, b: Cursor) bool {
+ return a.row == b.row and a.col == b.col;
+ }
+};
+
+// word char classes (matches ad/vim/kakoune: word = alnum + _, punct = other
+// non-ws, ws = space/tab/newline).
+pub const Kind = enum { word, punct, ws };
+
+pub fn kindOf(c: u8) Kind {
+ if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws;
+ if (std.ascii.isAlphanumeric(c) or c == '_') return .word;
+ return .punct;
+}
+
+// "long word" (W/B/E): only whitespace separates; punct is part of a word.
+fn kindOfLong(c: u8) Kind {
+ if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return .ws;
+ return .word;
+}
+
+fn kindAt(lines: []const []const u8, c: Cursor, long: bool) Kind {
+ if (c.row >= lines.len) return .ws;
+ const line = lines[c.row];
+ if (c.col >= line.len) return .ws; // line terminator / EOF = whitespace
+ return if (long) kindOfLong(line[c.col]) else kindOf(line[c.col]);
+}
+
+fn lineLenOf(lines: []const []const u8, row: usize) usize {
+ if (row >= lines.len) return 0;
+ return lines[row].len;
+}
+
+// advance one position across line boundaries (line terminators are positions
+// too: col == line.len is the newline). Returns false at EOF.
+fn stepFwd(lines: []const []const u8, c: *Cursor) bool {
+ if (c.row >= lines.len) return false;
+ const llen = lineLenOf(lines, c.row);
+ if (c.col < llen) {
+ c.col += 1;
+ return true;
+ }
+ // at the newline: move to next line start
+ if (c.row + 1 < lines.len) {
+ c.row += 1;
+ c.col = 0;
+ return true;
+ }
+ return false; // EOF
+}
+
+fn stepBwd(lines: []const []const u8, c: *Cursor) bool {
+ if (c.col > 0) {
+ c.col -= 1;
+ return true;
+ }
+ if (c.row == 0) return false;
+ c.row -= 1;
+ c.col = lineLenOf(lines, c.row); // the previous line's newline
+ return true;
+}
+
+// at EOF? (past the last line's last char)
+fn atEof(lines: []const []const u8, c: Cursor) bool {
+ if (c.row >= lines.len) return true;
+ if (c.row + 1 < lines.len) return false;
+ return c.col >= lines[c.row].len;
+}
+
+pub fn firstNonWs(line: []const u8) usize {
+ var i: usize = 0;
+ while (i < line.len and (line[i] == ' ' or line[i] == '\t')) i += 1;
+ return i;
+}
+
+// ---- per-line motions ----
+
+pub fn lineStart(c: Cursor) Cursor {
+ return .{ .row = c.row, .col = 0 };
+}
+
+pub fn lineEnd(lines: []const []const u8, c: Cursor) Cursor {
+ const llen = lineLenOf(lines, c.row);
+ return .{ .row = c.row, .col = if (llen == 0) 0 else llen - 1 };
+}
+
+pub fn firstNonWsOf(lines: []const []const u8, c: Cursor) Cursor {
+ return .{ .row = c.row, .col = firstNonWs(lines[c.row]) };
+}
+
+// ---- char/line motions ----
+
+pub fn charLeft(c: Cursor) Cursor {
+ return .{ .row = c.row, .col = if (c.col > 0) c.col - 1 else 0 };
+}
+
+pub fn charRight(lines: []const []const u8, c: Cursor) Cursor {
+ const llen = lineLenOf(lines, c.row);
+ const last = if (llen == 0) 0 else llen - 1;
+ return .{ .row = c.row, .col = if (c.col < last) c.col + 1 else last };
+}
+
+pub fn lineDown(lines: []const []const u8, c: Cursor) Cursor {
+ const nr = if (c.row + 1 < lines.len) c.row + 1 else c.row;
+ const llen = lineLenOf(lines, nr);
+ const last = if (llen == 0) 0 else llen - 1;
+ return .{ .row = nr, .col = if (c.col < last) c.col else last };
+}
+
+pub fn lineUp(lines: []const []const u8, c: Cursor) Cursor {
+ const nr = if (c.row > 0) c.row - 1 else c.row;
+ const llen = lineLenOf(lines, nr);
+ const last = if (llen == 0) 0 else llen - 1;
+ return .{ .row = nr, .col = if (c.col < last) c.col else last };
+}
+
+// ---- word motions ----
+
+// `w`/`W`: to the start of the next word.
+pub fn nextWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor {
+ var p = c;
+ const start_kind = kindAt(lines, p, long);
+ if (start_kind != .ws) {
+ // skip the rest of the current word-class run
+ while (!atEof(lines, p) and kindAt(lines, p, long) == start_kind) {
+ if (!stepFwd(lines, &p)) break;
+ }
+ }
+ // skip whitespace (incl. newlines) to the next word start
+ while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) {
+ if (!stepFwd(lines, &p)) break;
+ }
+ // p now sits on the next word's first char (or EOF -> last valid pos)
+ return clampToChar(lines, p);
+}
+
+// `b`/`B`: to the start of the previous word.
+pub fn prevWordStart(lines: []const []const u8, c: Cursor, long: bool) Cursor {
+ var p = c;
+ if (!stepBwd(lines, &p)) return c; // at buffer start
+ // skip whitespace backward
+ while (kindAt(lines, p, long) == .ws) {
+ if (!stepBwd(lines, &p)) return .{ .row = 0, .col = 0 };
+ }
+ // now on the end of the previous word; walk back to its start
+ const k = kindAt(lines, p, long);
+ while (true) {
+ var q = p;
+ if (!stepBwd(lines, &q)) {
+ p.col = 0;
+ break;
+ }
+ if (kindAt(lines, q, long) != k) break; // crossed into prior class
+ p = q;
+ }
+ return clampToChar(lines, p);
+}
+
+// `e`/`E`: to the end of the current/next word.
+pub fn nextWordEnd(lines: []const []const u8, c: Cursor, long: bool) Cursor {
+ var p = c;
+ if (!stepFwd(lines, &p)) return clampToChar(lines, c);
+ // skip whitespace forward
+ while (!atEof(lines, p) and kindAt(lines, p, long) == .ws) {
+ if (!stepFwd(lines, &p)) break;
+ }
+ if (atEof(lines, p)) return clampToChar(lines, p);
+ // now on a word's first char; advance to the last char of this run
+ const k = kindAt(lines, p, long);
+ while (!atEof(lines, p)) {
+ var q = p;
+ if (!stepFwd(lines, &q)) break;
+ if (kindAt(lines, q, long) != k) break;
+ p = q;
+ }
+ return clampToChar(lines, p);
+}
+
+// ---- goto ----
+
+pub fn gotoFirst() Cursor {
+ return .{ .row = 0, .col = 0 };
+}
+
+pub fn gotoLast(lines: []const []const u8) Cursor {
+ const r = if (lines.len == 0) 0 else lines.len - 1;
+ return .{ .row = r, .col = 0 };
+}
+
+// half/full page: target row, caller clamps + scrolls.
+pub fn pageDown(c: Cursor, page: usize) Cursor {
+ return .{ .row = c.row + page, .col = c.col };
+}
+
+pub fn pageUp(c: Cursor, page: usize) Cursor {
+ return .{ .row = if (c.row > page) c.row - page else 0, .col = c.col };
+}
+
+// ---- helpers used by motions + main.zig ----
+
+// clamp a (possibly terminator/EOF) position onto a real character.
+pub fn clampToChar(lines: []const []const u8, c: Cursor) Cursor {
+ if (c.row >= lines.len) {
+ return .{ .row = if (lines.len == 0) 0 else lines.len - 1, .col = 0 };
+ }
+ const llen = lineLenOf(lines, c.row);
+ if (llen == 0) return .{ .row = c.row, .col = 0 };
+ return .{ .row = c.row, .col = @min(c.col, llen - 1) };
+}
+
+pub fn lineCount(content: []const u8) usize {
+ if (content.len == 0) return 0;
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+// byte offset of the start of line `row` (0-based). row may == lineCount()
+// (== content.len, the end).
+pub fn lineStartOffset(content: []const u8, row: usize) usize {
+ var off: usize = 0;
+ var r: usize = 0;
+ while (r < row) : (r += 1) {
+ const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len;
+ off = nl + 1;
+ }
+ return off;
+}
+
+// the text of line `row` (no terminator), a slice into `content`.
+pub fn lineSlice(content: []const u8, row: usize) []const u8 {
+ const start = lineStartOffset(content, row);
+ if (start >= content.len) return "";
+ const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len;
+ return content[start..nl];
+}
+
+// ---- file content mutations. caller frees the returned slice + the old one. ----
+
+// insert `text` at (row, col). col is clamped to the line length.
+pub fn insertAt(alloc: std.mem.Allocator, content: []const u8, c: Cursor, text: []const u8) ![]u8 {
+ const row = if (c.row >= lineCount(content)) lineCount(content) -| 1 else c.row;
+ const line = lineSlice(content, row);
+ const col = @min(c.col, line.len);
+ const off = lineStartOffset(content, row) + col;
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..off]);
+ try out.appendSlice(alloc, text);
+ try out.appendSlice(alloc, content[off..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// delete the character at (row, col). no-op if col is past the line end.
+pub fn deleteChar(alloc: std.mem.Allocator, content: []const u8, c: Cursor) ![]u8 {
+ const line = lineSlice(content, c.row);
+ if (c.col >= line.len) return alloc.dupe(u8, content);
+ const off = lineStartOffset(content, c.row) + c.col;
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..off]);
+ try out.appendSlice(alloc, content[off + 1 ..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// delete whole lines [r0, r1] inclusive (the line content + their terminators).
+// returns the new content; `deleted` is the joined removed text (no terminators).
+pub const Deleted = struct { content: []u8, deleted: []u8 };
+
+pub fn deleteLines(alloc: std.mem.Allocator, content: []const u8, r0: usize, r1: usize) !Deleted {
+ const n = lineCount(content);
+ const lo = @min(r0, r1);
+ const hi = @min(@max(r0, r1), if (n == 0) 0 else n - 1);
+ if (n == 0 or hi < lo) return .{ .content = try alloc.dupe(u8, content), .deleted = try alloc.dupe(u8, "") };
+
+ const start = lineStartOffset(content, lo);
+ // end = start of line (hi+1), or content.len if hi is the last line.
+ const end = if (hi + 1 < n) lineStartOffset(content, hi + 1) else content.len;
+ // if we're removing the last line and there's a preceding newline, also
+ // drop that newline so we don't leave a trailing blank line.
+ var cut_lo = start;
+ const cut_hi = end;
+ if (hi + 1 == n and start > 0) cut_lo -= 1; // remove the '\n' before the last line
+
+ var deleted: std.ArrayList(u8) = .empty;
+ {
+ var r = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) try deleted.append(alloc, '\n');
+ try deleted.appendSlice(alloc, lineSlice(content, r));
+ }
+ }
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..cut_lo]);
+ try out.appendSlice(alloc, content[cut_hi..]);
+ return .{ .content = try out.toOwnedSlice(alloc), .deleted = try deleted.toOwnedSlice(alloc) };
+}
+
+// normalize two cursors into document order (lo <= hi), then byte offsets of the
+// INCLUSIVE range [lo .. hi] (the char under hi is included). e == s means empty.
+fn rangeBytes(content: []const u8, a: Cursor, b: Cursor) struct { s: usize, e: usize } {
+ var lo = a;
+ var hi = b;
+ if (hi.row < lo.row or (hi.row == lo.row and hi.col < lo.col)) {
+ lo = b;
+ hi = a;
+ }
+ const s = lineStartOffset(content, lo.row) + @min(lo.col, lineSlice(content, lo.row).len);
+ var e = lineStartOffset(content, hi.row) + @min(hi.col, lineSlice(content, hi.row).len);
+ if (e < content.len) e += 1; // include the char under the head
+ return .{ .s = s, .e = @max(s, e) };
+}
+
+// the text of the inclusive char range [a, b] (cursors in either order). Caller frees.
+pub fn rangeText(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) ![]u8 {
+ const r = rangeBytes(content, a, b);
+ return alloc.dupe(u8, content[r.s..r.e]);
+}
+
+// delete the inclusive char range [a, b]. returns new content + the removed text.
+pub fn deleteRange(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: Cursor) !Deleted {
+ const r = rangeBytes(content, a, b);
+ const deleted = try alloc.dupe(u8, content[r.s..r.e]);
+ errdefer alloc.free(deleted);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..r.s]);
+ try out.appendSlice(alloc, content[r.e..]);
+ return .{ .content = try out.toOwnedSlice(alloc), .deleted = deleted };
+}
+
+// replace line `row`'s text with "" (keep the line, empty it). For `c`hange line.
+pub fn clearLine(alloc: std.mem.Allocator, content: []const u8, row: usize) ![]u8 {
+ const line = lineSlice(content, row);
+ const start = lineStartOffset(content, row);
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..start]);
+ try out.appendSlice(alloc, content[start + line.len ..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// paste `text` as a new line BELOW `row`. Multiline `text` becomes several lines.
+pub fn pasteLineBelow(alloc: std.mem.Allocator, content: []const u8, row: usize, text: []const u8) ![]u8 {
+ const n = lineCount(content);
+ const off = if (row + 1 < n) lineStartOffset(content, row + 1) else content.len;
+ var out: std.ArrayList(u8) = .empty;
+ try out.appendSlice(alloc, content[0..off]);
+ if (off < content.len) {
+ try out.appendSlice(alloc, text);
+ try out.append(alloc, '\n');
+ } else {
+ if (off > 0) try out.append(alloc, '\n');
+ try out.appendSlice(alloc, text);
+ }
+ try out.appendSlice(alloc, content[off..]);
+ return out.toOwnedSlice(alloc);
+}
+
+// ---- file-pane modal step ----
+// One keypress on an editable FILE pane. This is the single source of truth for
+// file-modal behavior: main.zig delegates file panes here, and the tutor-driven
+// unit tests (below) exercise it directly. Pure (no vaxis/ghostty/term.zig) —
+// only std + the modal primitives above. Returns the new content (caller frees
+// the old), or null if the key only moved the cursor / was a no-op (caller keeps
+// the old content, no realloc).
+
+pub const Key = union(enum) {
+ ch: u21, // a printable codepoint (insert inserts its UTF-8 bytes)
+ esc,
+ enter,
+ backspace,
+ left,
+ right,
+ up,
+ down,
+ home,
+ end,
+ page_down,
+ page_up,
+ half_down, // Ctrl-d
+ half_up, // Ctrl-u
+};
+
+pub const FileMode = enum { normal, insert };
+
+pub const FileState = struct {
+ cur: Cursor = .{},
+ mode: FileMode = .normal,
+ pending: u21 = 0, // prefix codepoint in flight: 'g' or 'z'
+ msel_active: bool = false,
+ msel_r0: i32 = 0,
+ msel_r1: i32 = 0,
+};
+
+// The view the cursor must stay inside. `scroll` is read/written so viewport
+// alignment (zt/zz/zb) and page motions can adjust it; the app syncs it back to
+// the file pane's scroll. The unit-test Sim passes a large rows + a dummy scroll
+// so visibility is a no-op (content is what's asserted).
+pub const View = struct {
+ rows: usize,
+ scroll: *usize,
+};
+
+fn chEq(key: Key, cp: u21) bool {
+ return switch (key) {
+ .ch => |c| c == cp,
+ else => false,
+ };
+}
+
+fn tagOf(key: Key) std.meta.Tag(Key) {
+ return std.meta.activeTag(key);
+}
+
+fn splitLines(alloc: std.mem.Allocator, content: []const u8) ![][]const u8 {
+ var ls: std.ArrayList([]const u8) = .empty;
+ var it = std.mem.splitScalar(u8, content, '\n');
+ while (it.next()) |ln| try ls.append(alloc, ln);
+ return ls.toOwnedSlice(alloc);
+}
+
+fn ensureVisible(view: View, row: usize) void {
+ const s = view.scroll.*;
+ if (row < s) {
+ view.scroll.* = row;
+ return;
+ }
+ const last = s +| (view.rows -| 1);
+ if (row > last) view.scroll.* = row -| (view.rows -| 1);
+}
+
+// helix `x`: start a line selection at the cursor, or extend it down by one.
+fn lineSelectFile(st: *FileState, lines: []const []const u8) void {
+ if (!st.msel_active) {
+ st.msel_active = true;
+ st.msel_r0 = @intCast(st.cur.row);
+ st.msel_r1 = @intCast(st.cur.row);
+ } else {
+ const last: i32 = @intCast(if (lines.len == 0) 0 else lines.len - 1);
+ st.msel_r1 = @min(st.msel_r1 + 1, last);
+ st.cur.row = @intCast(st.msel_r1);
+ }
+ st.cur.col = 0;
+ st.pending = 0;
+}
+
+// `d`: delete the selected lines (yanking them), or the char at the cursor.
+fn deleteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 {
+ if (st.msel_active) {
+ const r0: i32 = @min(st.msel_r0, st.msel_r1);
+ const r1: i32 = @max(st.msel_r0, st.msel_r1);
+ const d = try deleteLines(alloc, content, @intCast(@max(0, r0)), @intCast(@max(0, r1)));
+ if (yank.*) |old| alloc.free(old);
+ yank.* = try alloc.dupe(u8, d.deleted);
+ alloc.free(d.deleted);
+ const n = lineCount(d.content);
+ st.cur = .{ .row = @intCast(@min(@as(usize, @intCast(@max(0, r0))), if (n == 0) 0 else n - 1)), .col = 0 };
+ st.msel_active = false;
+ st.pending = 0;
+ return d.content;
+ }
+ // no selection: delete the char at the cursor (no-op if past end)
+ const llen = lineLenOf(lines, st.cur.row);
+ if (st.cur.col >= llen) {
+ st.pending = 0;
+ return null;
+ }
+ const new = try deleteChar(alloc, content, st.cur);
+ st.pending = 0;
+ return new;
+}
+
+// `y`: yank the selected lines, or the current line. Cursor moves to the
+// selection start; the selection is collapsed.
+fn yankFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !void {
+ if (st.msel_active) {
+ const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
+ const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = r0;
+ while (r <= r1) : (r += 1) {
+ if (r > r0) try buf.append(alloc, '\n');
+ try buf.appendSlice(alloc, lineSlice(content, r));
+ }
+ if (yank.*) |old| alloc.free(old);
+ yank.* = try buf.toOwnedSlice(alloc);
+ st.cur = .{ .row = r0, .col = 0 };
+ st.msel_active = false;
+ } else {
+ if (yank.*) |old| alloc.free(old);
+ yank.* = try alloc.dupe(u8, lineSlice(content, st.cur.row));
+ }
+ st.pending = 0;
+}
+
+// `p`: paste the yanked text as a new line below the cursor.
+fn pasteFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, yank: *?[]u8) !?[]u8 {
+ const y = yank.* orelse {
+ st.pending = 0;
+ return null;
+ };
+ const new = try pasteLineBelow(alloc, content, st.cur.row, y);
+ st.cur = .{ .row = st.cur.row + 1, .col = 0 };
+ st.pending = 0;
+ return new;
+}
+
+// `c`: change the selection (delete + enter INSERT, keeping one empty line), or
+// change the char at the cursor. Yanks the removed text.
+fn changeFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, yank: *?[]u8) !?[]u8 {
+ if (st.msel_active) {
+ const r0: usize = @intCast(@max(0, @min(st.msel_r0, st.msel_r1)));
+ const r1: usize = @intCast(@max(0, @max(st.msel_r0, st.msel_r1)));
+ // yank the original lines first (from the un-mutated content)
+ var buf: std.ArrayList(u8) = .empty;
+ var r: usize = r0;
+ while (r <= r1) : (r += 1) {
+ if (r > r0) try buf.append(alloc, '\n');
+ try buf.appendSlice(alloc, lineSlice(content, r));
+ }
+ if (yank.*) |old| alloc.free(old);
+ yank.* = try buf.toOwnedSlice(alloc);
+ // drop the extra lines (keep one), then empty the kept line
+ const d = if (r1 > r0) try deleteLines(alloc, content, r0 + 1, r1) else null;
+ defer if (d) |dd| alloc.free(dd.deleted);
+ const after_del: []const u8 = if (d) |dd| dd.content else content;
+ const cl = try clearLine(alloc, after_del, r0);
+ if (d) |dd| alloc.free(dd.content); // free the intermediate (after_del copy)
+ st.cur = .{ .row = r0, .col = 0 };
+ st.mode = .insert;
+ st.msel_active = false;
+ st.pending = 0;
+ return cl;
+ }
+ // no selection: delete the char at the cursor (if any), then enter INSERT
+ const llen = lineLenOf(lines, st.cur.row);
+ if (st.cur.col >= llen) {
+ st.mode = .insert;
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ }
+ const new = try deleteChar(alloc, content, st.cur);
+ st.mode = .insert;
+ st.msel_active = false;
+ st.pending = 0;
+ return new;
+}
+
+// INSERT-mode key on a file pane. Returns new content or null (no change).
+fn stepInsertFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, lines: []const []const u8, key: Key) !?[]u8 {
+ switch (key) {
+ .esc => {
+ st.mode = .normal;
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ },
+ .ch => |cp| {
+ if (cp == '\n') return try stepInsertFile(alloc, content, st, lines, .enter);
+ var buf: [4]u8 = undefined;
+ const n = std.unicode.utf8Encode(cp, &buf) catch return null;
+ const new = try insertAt(alloc, content, st.cur, buf[0..n]);
+ st.cur.col += n; // col is a byte offset; advance by the char's byte length
+ st.pending = 0;
+ return new;
+ },
+ .enter => {
+ const new = try insertAt(alloc, content, st.cur, "\n");
+ st.cur = .{ .row = st.cur.row + 1, .col = 0 };
+ st.pending = 0;
+ return new;
+ },
+ .backspace => {
+ if (st.cur.col > 0) {
+ const new = try deleteChar(alloc, content, .{ .row = st.cur.row, .col = st.cur.col - 1 });
+ st.cur.col -= 1;
+ return new;
+ } else if (st.cur.row > 0) {
+ const prevlen = lineSlice(content, st.cur.row - 1).len;
+ const new = try deleteChar(alloc, content, .{ .row = st.cur.row - 1, .col = prevlen });
+ st.cur = .{ .row = st.cur.row - 1, .col = prevlen };
+ return new;
+ }
+ return null;
+ },
+ .left => {
+ st.cur = charLeft(st.cur);
+ return null;
+ },
+ .right => {
+ st.cur = charRight(lines, st.cur);
+ return null;
+ },
+ .up => {
+ st.cur = lineUp(lines, st.cur);
+ return null;
+ },
+ .down => {
+ st.cur = lineDown(lines, st.cur);
+ return null;
+ },
+ .home => {
+ st.cur = lineStart(st.cur);
+ return null;
+ },
+ .end => {
+ st.cur = lineEnd(lines, st.cur);
+ return null;
+ },
+ else => return null,
+ }
+}
+
+// The full NORMAL-mode file step (motions + insert entry + select/edit). See
+// handleNormal in main.zig for the terminal counterpart (which differs in its
+// edit ops: terminals can't delete shell output, only pasted runs).
+pub fn stepFile(alloc: std.mem.Allocator, content: []const u8, st: *FileState, view: View, key: Key, yank: *?[]u8) !?[]u8 {
+ const lines = try splitLines(alloc, content);
+ defer alloc.free(lines);
+ if (st.mode == .insert) return try stepInsertFile(alloc, content, st, lines, key);
+
+ var cur = clampToChar(lines, st.cur);
+
+ // prefix continuations
+ if (st.pending != 0) {
+ const p = st.pending;
+ st.pending = 0;
+ if (p == 'g') {
+ if (chEq(key, 'g')) cur = gotoFirst()
+ else if (chEq(key, 'e')) cur = gotoLast(lines)
+ else if (chEq(key, 'h')) cur = lineStart(cur)
+ else if (chEq(key, 'l')) cur = lineEnd(lines, cur)
+ else return null; // unknown continuation: consume, no-op
+ st.cur = cur;
+ st.msel_active = false;
+ return null;
+ }
+ if (p == 'z') {
+ if (chEq(key, 't')) {
+ view.scroll.* = cur.row;
+ } else if (chEq(key, 'z')) {
+ view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows / 2))));
+ } else if (chEq(key, 'b')) {
+ view.scroll.* = @intCast(@max(0, @as(i32, @intCast(cur.row)) - @as(i32, @intCast(view.rows)) + 1));
+ } else return null;
+ st.msel_active = false;
+ return null;
+ }
+ }
+ if (chEq(key, 'g')) {
+ st.pending = 'g';
+ return null;
+ }
+ if (chEq(key, 'z')) {
+ st.pending = 'z';
+ return null;
+ }
+
+ // motions -> move cursor, collapse selection, keep visible
+ var moved: ?Cursor = null;
+ if (chEq(key, 'h') or tagOf(key) == .left) moved = charLeft(cur)
+ else if (chEq(key, 'l') or tagOf(key) == .right) moved = charRight(lines, cur)
+ else if (chEq(key, 'j') or tagOf(key) == .down) moved = lineDown(lines, cur)
+ else if (chEq(key, 'k') or tagOf(key) == .up) moved = lineUp(lines, cur)
+ else if (chEq(key, 'w')) moved = nextWordStart(lines, cur, false)
+ else if (chEq(key, 'W')) moved = nextWordStart(lines, cur, true)
+ else if (chEq(key, 'b')) moved = prevWordStart(lines, cur, false)
+ else if (chEq(key, 'B')) moved = prevWordStart(lines, cur, true)
+ else if (chEq(key, 'e')) moved = nextWordEnd(lines, cur, false)
+ else if (chEq(key, 'E')) moved = nextWordEnd(lines, cur, true)
+ else if (chEq(key, '0') or tagOf(key) == .home) moved = lineStart(cur)
+ else if (chEq(key, '$') or tagOf(key) == .end) moved = lineEnd(lines, cur)
+ else if (chEq(key, '^')) moved = firstNonWsOf(lines, cur)
+ else if (chEq(key, 'G')) moved = gotoLast(lines)
+ else if (tagOf(key) == .half_down) moved = clampToChar(lines, pageDown(cur, @max(1, view.rows / 2)))
+ else if (tagOf(key) == .half_up) moved = clampToChar(lines, pageUp(cur, @max(1, view.rows / 2)))
+ else if (tagOf(key) == .page_down) moved = clampToChar(lines, pageDown(cur, view.rows))
+ else if (tagOf(key) == .page_up) moved = clampToChar(lines, pageUp(cur, view.rows));
+ if (moved) |m| {
+ st.cur = m;
+ st.msel_active = false;
+ st.pending = 0;
+ ensureVisible(view, m.row);
+ return null;
+ }
+
+ // insert entry
+ if (chEq(key, 'i')) {
+ st.mode = .insert;
+ st.cur = cur;
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ }
+ if (chEq(key, 'a')) {
+ st.mode = .insert;
+ st.cur = charRight(lines, cur);
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ }
+ if (chEq(key, 'I')) {
+ st.mode = .insert;
+ st.cur = firstNonWsOf(lines, cur);
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ }
+ if (chEq(key, 'A')) {
+ st.mode = .insert;
+ st.cur = .{ .row = cur.row, .col = lineLenOf(lines, cur.row) };
+ st.msel_active = false;
+ st.pending = 0;
+ return null;
+ }
+ if (chEq(key, 'o')) {
+ const new = try insertAt(alloc, content, .{ .row = cur.row, .col = lineLenOf(lines, cur.row) }, "\n");
+ st.cur = .{ .row = cur.row + 1, .col = 0 };
+ st.mode = .insert;
+ st.msel_active = false;
+ st.pending = 0;
+ ensureVisible(view, st.cur.row);
+ return new;
+ }
+ if (chEq(key, 'O')) {
+ const new = try insertAt(alloc, content, .{ .row = cur.row, .col = 0 }, "\n");
+ st.cur = .{ .row = cur.row, .col = 0 };
+ st.mode = .insert;
+ st.msel_active = false;
+ st.pending = 0;
+ ensureVisible(view, st.cur.row);
+ return new;
+ }
+
+ // select / edit
+ if (chEq(key, 'x')) {
+ lineSelectFile(st, lines);
+ return null;
+ }
+ if (chEq(key, 'd')) return try deleteFile(alloc, content, st, lines, yank);
+ if (chEq(key, 'c')) return try changeFile(alloc, content, st, lines, yank);
+ if (chEq(key, 'y')) {
+ try yankFile(alloc, content, st, yank);
+ return null;
+ }
+ if (chEq(key, 'p')) return try pasteFile(alloc, content, st, yank);
+
+ // Enter / other keys: no-op on a file (no tty mode)
+ return null;
+}
+
+// ---- tests ----
+
+const testEq = struct {
+ fn cur(lines: []const []const u8, s: []const u8) Cursor {
+ var row: usize = 0;
+ for (lines) |ln| {
+ if (std.mem.eql(u8, ln, s)) return .{ .row = row, .col = 0 };
+ row += 1;
+ }
+ unreachable;
+ }
+};
+
+test "kindOf" {
+ try std.testing.expectEqual(Kind.word, kindOf('a'));
+ try std.testing.expectEqual(Kind.word, kindOf('_'));
+ try std.testing.expectEqual(Kind.word, kindOf('9'));
+ try std.testing.expectEqual(Kind.punct, kindOf('.'));
+ try std.testing.expectEqual(Kind.punct, kindOf('('));
+ try std.testing.expectEqual(Kind.ws, kindOf(' '));
+ try std.testing.expectEqual(Kind.ws, kindOf('\n'));
+}
+
+test "char/line motions" {
+ const lines = [_][]const u8{ "alpha beta", " two words", "x" };
+ const c = Cursor{ .row = 0, .col = 5 };
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, charLeft(c));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, charRight(&lines, c));
+ try std.testing.expectEqual(Cursor{ .row = 1, .col = 5 }, lineDown(&lines, c));
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, lineUp(&lines, Cursor{ .row = 1, .col = 5 }));
+ // line ends
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 9 }, lineEnd(&lines, c));
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, lineEnd(&lines, Cursor{ .row = 2, .col = 0 }));
+ // first non-ws
+ try std.testing.expectEqual(Cursor{ .row = 1, .col = 2 }, firstNonWsOf(&lines, Cursor{ .row = 1, .col = 0 }));
+}
+
+test "word motions w/b/e" {
+ const lines = [_][]const u8{"this is a test"};
+ const w = &lines;
+ // "this is a test", indices 0..13
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false)); // t->next word "is"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "a"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "test"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 10 }, nextWordStart(w, Cursor{ .row = 0, .col = 9 }, false)); // from ws
+ // b
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, prevWordStart(w, Cursor{ .row = 0, .col = 10 }, false)); // test -> "a"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 5 }, prevWordStart(w, Cursor{ .row = 0, .col = 8 }, false)); // -> "is"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, prevWordStart(w, Cursor{ .row = 0, .col = 5 }, false)); // -> "this"
+ // e
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false)); // this[3]
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 6 }, nextWordEnd(w, Cursor{ .row = 0, .col = 3 }, false)); // -> "is"[6]
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 13 }, nextWordEnd(w, Cursor{ .row = 0, .col = 10 }, false)); // -> "test"[13]
+}
+
+test "word motions cross line" {
+ const lines = [_][]const u8{ "foo bar", "", "baz" };
+ const w = &lines;
+ // from end of "foo bar" (row0 col6) w crosses the blank line to "baz"
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, nextWordStart(w, Cursor{ .row = 0, .col = 6 }, false));
+ // b from "baz" crosses back to "bar"
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 4 }, prevWordStart(w, Cursor{ .row = 2, .col = 0 }, false));
+ // e from row0 col0 -> "foo" end (col2)
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 2 }, nextWordEnd(w, Cursor{ .row = 0, .col = 0 }, false));
+}
+
+test "long word W treats punct as word" {
+ // "foo.bar baz" : W from 0 -> "baz" at 8 (foo.bar is one long word)
+ const lines = [_][]const u8{"foo.bar baz"};
+ const w = &lines;
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 8 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, true));
+ // w (non-long) from 0 -> '.' at 3 (punct is its own word, like vim/helix)
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 3 }, nextWordStart(w, Cursor{ .row = 0, .col = 0 }, false));
+}
+
+test "goto" {
+ const lines = [_][]const u8{ "a", "b", "c" };
+ try std.testing.expectEqual(Cursor{ .row = 0, .col = 0 }, gotoFirst());
+ try std.testing.expectEqual(Cursor{ .row = 2, .col = 0 }, gotoLast(&lines));
+}
+
+test "lineStartOffset + lineSlice" {
+ const content = "alpha\nbeta\n\ngamma";
+ try std.testing.expectEqual(@as(usize, 0), lineStartOffset(content, 0));
+ try std.testing.expectEqual(@as(usize, 6), lineStartOffset(content, 1));
+ try std.testing.expectEqual(@as(usize, 11), lineStartOffset(content, 2));
+ try std.testing.expectEqual(@as(usize, 12), lineStartOffset(content, 3));
+ try std.testing.expectEqual(@as(usize, 17), lineStartOffset(content, 4)); // past end
+ try std.testing.expectEqualStrings("alpha", lineSlice(content, 0));
+ try std.testing.expectEqualStrings("beta", lineSlice(content, 1));
+ try std.testing.expectEqualStrings("", lineSlice(content, 2));
+ try std.testing.expectEqualStrings("gamma", lineSlice(content, 3));
+ try std.testing.expectEqual(@as(usize, 4), lineCount(content));
+}
+
+test "insertAt mid-line and at end" {
+ const content = "hello world";
+ const a = std.testing.allocator;
+ const r1 = try insertAt(a, content, .{ .row = 0, .col = 5 }, "!");
+ defer a.free(r1);
+ try std.testing.expectEqualStrings("hello! world", r1);
+ const r2 = try insertAt(a, content, .{ .row = 0, .col = 99 }, "!");
+ defer a.free(r2);
+ try std.testing.expectEqualStrings("hello world!", r2);
+}
+
+test "insertAt multiline creates lines" {
+ const content = "a\nb";
+ const a = std.testing.allocator;
+ const r = try insertAt(a, content, .{ .row = 0, .col = 1 }, "X\nY");
+ defer a.free(r);
+ try std.testing.expectEqualStrings("aX\nY\nb", r);
+ try std.testing.expectEqual(@as(usize, 3), lineCount(r));
+}
+
+test "deleteChar" {
+ const content = "abc";
+ const a = std.testing.allocator;
+ const r = try deleteChar(a, content, .{ .row = 0, .col = 1 });
+ defer a.free(r);
+ try std.testing.expectEqualStrings("ac", r);
+ // past end: no-op
+ const r2 = try deleteChar(a, content, .{ .row = 0, .col = 5 });
+ defer a.free(r2);
+ try std.testing.expectEqualStrings("abc", r2);
+}
+
+test "deleteLines middle" {
+ const content = "one\ntwo\nthree\nfour";
+ const a = std.testing.allocator;
+ const d = try deleteLines(a, content, 1, 2);
+ defer a.free(d.content);
+ defer a.free(d.deleted);
+ try std.testing.expectEqualStrings("one\nfour", d.content);
+ try std.testing.expectEqualStrings("two\nthree", d.deleted);
+}
+
+test "deleteLines last line drops preceding newline" {
+ const content = "one\ntwo\nthree";
+ const a = std.testing.allocator;
+ const d = try deleteLines(a, content, 2, 2);
+ defer a.free(d.content);
+ defer a.free(d.deleted);
+ try std.testing.expectEqualStrings("one\ntwo", d.content);
+ try std.testing.expectEqualStrings("three", d.deleted);
+}
+
+test "deleteLines only line" {
+ const content = "only";
+ const a = std.testing.allocator;
+ const d = try deleteLines(a, content, 0, 0);
+ defer a.free(d.content);
+ defer a.free(d.deleted);
+ try std.testing.expectEqualStrings("", d.content);
+ try std.testing.expectEqualStrings("only", d.deleted);
+}
+
+test "rangeText + deleteRange (char-wise select)" {
+ const a = std.testing.allocator;
+ const content = "hello\nworld\nfoo";
+ // same-line inclusive range: "hello"[1..3] -> "ell"
+ const t1 = try rangeText(a, content, .{ .row = 0, .col = 1 }, .{ .row = 0, .col = 3 });
+ defer a.free(t1);
+ try std.testing.expectEqualStrings("ell", t1);
+ // reversed cursors give the same range
+ const t2 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 0, .col = 1 });
+ defer a.free(t2);
+ try std.testing.expectEqualStrings("ell", t2);
+ // cross-line range includes the newline: row0 col3 .. row1 col1 -> "lo\nwo"
+ const t3 = try rangeText(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 });
+ defer a.free(t3);
+ try std.testing.expectEqualStrings("lo\nwo", t3);
+ // delete the same cross-line range
+ const d = try deleteRange(a, content, .{ .row = 0, .col = 3 }, .{ .row = 1, .col = 1 });
+ defer a.free(d.content);
+ defer a.free(d.deleted);
+ try std.testing.expectEqualStrings("helrld\nfoo", d.content);
+ try std.testing.expectEqualStrings("lo\nwo", d.deleted);
+}
+
+test "clearLine" {
+ const content = "keep\nzap me\nkeep2";
+ const a = std.testing.allocator;
+ const r = try clearLine(a, content, 1);
+ defer a.free(r);
+ try std.testing.expectEqualStrings("keep\n\nkeep2", r);
+}
+
+test "pasteLineBelow" {
+ const content = "one\ntwo";
+ const a = std.testing.allocator;
+ const r = try pasteLineBelow(a, content, 0, "INSERTED");
+ defer a.free(r);
+ try std.testing.expectEqualStrings("one\nINSERTED\ntwo", r);
+ // paste below last line
+ const r2 = try pasteLineBelow(a, content, 1, "END");
+ defer a.free(r2);
+ try std.testing.expectEqualStrings("one\ntwo\nEND", r2);
+ // multiline yanked text
+ const r3 = try pasteLineBelow(a, content, 0, "a\nb");
+ defer a.free(r3);
+ try std.testing.expectEqualStrings("one\na\nb\ntwo", r3);
+}
diff --git a/src/pardes.zig b/src/pardes.zig
new file mode 100644
index 00000000..c4893b25
--- /dev/null
+++ b/src/pardes.zig
@@ -0,0 +1,3728 @@
+//! The pardes core: a text environment as a library, the way ghostty-vt is a
+//! library. The platform shell owns the event loop and all IO; it feeds this
+//! core events (input, pty bytes, resizes) and reads back two plain values:
+//! a Surface — the canonical cell-grid interface, which the tty shell hands to
+//! vaxis nearly verbatim and the SDL shells rasterize — and a queue of Effects,
+//! the IO the core wants performed (spawn a shell, write a pty, open a link).
+//! The core never does IO and never blocks; it is a state machine.
+//!
+//! Platform divergence inside the core is the `platform` comptime tag, used
+//! the way the stdlib uses os.tag. Click-on-text semantics live in look.zig.
+const std = @import("std");
+const ghostty_vt = @import("ghostty-vt");
+const uucode = @import("uucode");
+const modal = @import("modal.zig");
+const look = @import("look.zig");
+const syntax = @import("syntax.zig");
+pub const image = @import("image.zig");
+pub const dump = @import("dump.zig");
+
+const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
+const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
+
+pub const Platform = enum { tty, gui, web };
+pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
+
+pub const MAX_PANES = 16;
+pub const MAX_COLS = 6;
+pub const TOPBAR_H: u16 = 1;
+pub const GUTTER: u16 = 2;
+pub const BOX_H: u16 = 1;
+pub const MINW: u16 = 10;
+pub const MINH: u16 = 3;
+pub const PREFIX_W: u16 = 5;
+
+pub const topbar_str = "Kill Newcol Tutor Debug Colors NextColor Dump";
+const pane_builtins_str = "Del";
+const file_pane_builtins_str = "Save Del";
+const pane_tail = " " ++ pane_builtins_str;
+const file_pane_tail = " " ++ file_pane_builtins_str;
+
+// Builtins: executing the name (middle-click / Tab) runs the action through the
+// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears.
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump };
+const Builtin = struct { name: []const u8, action: SelectAction };
+const builtins = [_]Builtin{
+ .{ .name = "Kill", .action = .kill },
+ .{ .name = "Newcol", .action = .newcol },
+ .{ .name = "Tutor", .action = .tutor },
+ .{ .name = "Debug", .action = .debug },
+ .{ .name = "Colors", .action = .colors },
+ .{ .name = "NextColor", .action = .nextcolor },
+ .{ .name = "Dump", .action = .dump },
+ .{ .name = "Save", .action = .save },
+ .{ .name = "Del", .action = .del },
+ .{ .name = "Delcol", .action = .delcol },
+};
+
+fn onSelect(button: Mouse.Button, text: []const u8) SelectAction {
+ if (button != .middle) return .none;
+ const cmd = std.mem.trim(u8, text, " \t\r\n");
+ if (cmd.len == 0) return .none;
+ inline for (builtins) |b| {
+ if (std.mem.eql(u8, cmd, b.name)) return b.action;
+ }
+ return .send;
+}
+
+/// the whitespace-delimited word covering `col` in `str` (topbar dispatch)
+fn wordAtCol(str: []const u8, col: u16) []const u8 {
+ if (col >= str.len or str[col] == ' ') return "";
+ var lo: usize = col;
+ while (lo > 0 and str[lo - 1] != ' ') lo -= 1;
+ var hi: usize = col;
+ while (hi < str.len and str[hi] != ' ') hi += 1;
+ return str[lo..hi];
+}
+
+fn fileLineCount(content: []const u8) usize {
+ return std.mem.count(u8, content, "\n") + 1;
+}
+
+const tutor_text = @embedFile("tutor.txt");
+const rc_path = "/tmp/pardes-osc133.bash";
+pub const bash_rc =
+ \\[ -f "$HOME/.bashrc" ] && source "$HOME/.bashrc"
+ \\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
+ \\
+;
+
+// ---- theme ----
+
+pub const accent = [3]u8{ 0x6e, 0x4f, 0xd0 };
+pub const accent_dim = [3]u8{ 0x38, 0x3c, 0x54 };
+// per-button selection highlight: distinct muted colors per mouse button
+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/char selection: warm tan, reads as keyboard-driven
+const msel_bg = [3]u8{ 0x4a, 0x3a, 0x1e };
+const msel_fg = [3]u8{ 0xd8, 0xc8, 0xa8 };
+const UNDO_MAX = 256;
+const EDIT_UNDO_MAX = 64; // terminal snapshots are ~2KB each; cap tighter
+const gray = struct {
+ const bar = [3]u8{ 0x2a, 0x2a, 0x2a }; // dim bars (overlay bg)
+ const mid = [3]u8{ 0x52, 0x52, 0x52 }; // move preview
+ const txt = [3]u8{ 0x96, 0x96, 0x96 }; // muted text
+};
+
+pub const Theme = struct {
+ name: []const u8,
+ bg: ?[3]u8,
+ fg: ?[3]u8,
+ tag_bg: [3]u8,
+ tag_fg: [3]u8,
+ kw: [3]u8,
+ str: [3]u8,
+ num: [3]u8,
+ comment: [3]u8,
+ lineno: [3]u8,
+ scroll_track: [3]u8,
+ scroll_thumb: [3]u8,
+ palette: ?[16][3]u8,
+};
+
+pub const themes = [_]Theme{
+ .{
+ .name = "dark",
+ .bg = null,
+ .fg = null,
+ .tag_bg = .{ 0x2c, 0x2a, 0x3e },
+ .tag_fg = .{ 0x96, 0x96, 0x96 },
+ .kw = .{ 0xcc, 0x70, 0xd8 },
+ .str = .{ 0x8a, 0xb8, 0x7a },
+ .num = .{ 0xd0, 0xc0, 0x70 },
+ .comment = .{ 0xf0, 0xf0, 0xf0 },
+ .lineno = .{ 0x5a, 0x5a, 0x5a },
+ .scroll_track = .{ 0x2a, 0x2a, 0x2a },
+ .scroll_thumb = .{ 0x52, 0x52, 0x52 },
+ .palette = null,
+ },
+ .{
+ .name = "acme",
+ .bg = .{ 0xff, 0xff, 0xea },
+ .fg = .{ 0x00, 0x00, 0x00 },
+ .tag_bg = .{ 0xea, 0xff, 0xff },
+ .tag_fg = .{ 0x00, 0x00, 0x00 },
+ .kw = .{ 0x88, 0x22, 0x99 },
+ .str = .{ 0x00, 0x77, 0x33 },
+ .num = .{ 0xa0, 0x52, 0x00 },
+ .comment = .{ 0x55, 0x55, 0x55 },
+ .lineno = .{ 0x99, 0x99, 0x80 },
+ .scroll_track = .{ 0x99, 0x99, 0x4c },
+ .scroll_thumb = .{ 0xff, 0xff, 0xea },
+ .palette = .{
+ .{ 0x00, 0x00, 0x00 }, .{ 0xaa, 0x22, 0x22 }, .{ 0x22, 0x80, 0x22 }, .{ 0x88, 0x66, 0x00 },
+ .{ 0x22, 0x44, 0xbb }, .{ 0x99, 0x33, 0x99 }, .{ 0x11, 0x88, 0x88 }, .{ 0x55, 0x55, 0x55 },
+ .{ 0x88, 0x88, 0x88 }, .{ 0xcc, 0x33, 0x33 }, .{ 0x33, 0xaa, 0x33 }, .{ 0xaa, 0x77, 0x00 },
+ .{ 0x33, 0x55, 0xdd }, .{ 0xbb, 0x44, 0xbb }, .{ 0x22, 0xaa, 0xaa }, .{ 0x00, 0x00, 0x00 },
+ },
+ },
+};
+
+// ---- the boundary types ----
+
+pub const Color = union(enum) { default, index: u8, rgb: [3]u8 };
+
+pub const CellStyle = struct {
+ fg: Color = .default,
+ bg: Color = .default,
+ bold: bool = false,
+ dim: bool = false,
+ italic: bool = false,
+ blink: bool = false,
+ reverse: bool = false,
+ invisible: bool = false,
+ strikethrough: bool = false,
+ ul: enum { off, single, double, curly, dotted, dashed } = .off,
+};
+
+/// One surface cell. `default = true` means "never painted this frame": the
+/// shell renders it as the terminal's default cell (vaxis clear semantics).
+pub const Cell = struct {
+ text: [7]u8 = @splat(' '),
+ len: u8 = 1,
+ style: CellStyle = .{},
+ default: bool = true,
+
+ pub fn grapheme(c: *const Cell) []const u8 {
+ return c.text[0..c.len];
+ }
+};
+
+/// A pixel image riding the surface: the shell transmits/places it over the
+/// given cell rect (tty: kitty graphics; SDL: texture blit).
+pub const ImagePlace = struct {
+ pane: u8, // stable key for the shell's transmit-once cache
+ x: u16,
+ y: u16,
+ w: u16,
+ h: u16,
+ rgba: []const u8,
+ iw: usize,
+ ih: usize,
+};
+
+/// The canonical interface: what a frame of pardes IS. The tty shell writes
+/// these cells to vaxis one-to-one; the SDL shells rasterize them.
+pub const Surface = struct {
+ cols: u16 = 0,
+ rows: u16 = 0,
+ cells: []Cell = &.{},
+ cursor: ?struct { x: u16, y: u16 } = null,
+ images: [MAX_PANES]?ImagePlace = @splat(null),
+ nimages: usize = 0,
+
+ pub fn at(s: *Surface, x: u16, y: u16) *Cell {
+ std.debug.assert(x < s.cols and y < s.rows);
+ return &s.cells[@as(usize, y) * s.cols + x];
+ }
+
+ fn set(s: *Surface, x: u16, y: u16, text: []const u8, style: CellStyle) void {
+ const c = s.at(x, y);
+ std.debug.assert(text.len <= c.text.len);
+ @memcpy(c.text[0..text.len], text);
+ c.len = @intCast(text.len);
+ c.style = style;
+ c.default = false;
+ }
+
+ /// Print UTF-8 text into a row, no wrap, clipped to [x, x+w). Returns the
+ /// column after the last written cell. Wide glyphs take two cells.
+ fn print(s: *Surface, x: u16, y: u16, w: u16, text: []const u8, style: CellStyle) u16 {
+ var col = x;
+ const end = x + w;
+ var it = std.unicode.Utf8View.initUnchecked(text).iterator();
+ while (it.nextCodepointSlice()) |cp_slice| {
+ if (col >= end) break;
+ const cp = std.unicode.utf8Decode(cp_slice) catch continue;
+ if (cp == '\r') continue;
+ const width: u16 = if (cp < 0x80) 1 else uucode.get(.width, cp);
+ if (width == 0) continue;
+ s.set(col, y, cp_slice, style);
+ if (width == 2 and col + 1 < end) {
+ // spacer: empty cell under the wide glyph's tail
+ s.set(col + 1, y, "", style);
+ }
+ col += width;
+ }
+ return col;
+ }
+
+ fn fill(s: *Surface, x: u16, y: u16, w: u16, h: u16, style: CellStyle) void {
+ var yy = y;
+ while (yy < y + h) : (yy += 1) {
+ var xx = x;
+ while (xx < x + w) : (xx += 1) s.set(xx, yy, " ", style);
+ }
+ }
+
+ fn clearRect(s: *Surface, x: u16, y: u16, w: u16, h: u16) void {
+ var yy = y;
+ while (yy < y + h) : (yy += 1) {
+ var xx = x;
+ while (xx < x + w) : (xx += 1) s.at(xx, yy).* = .{};
+ }
+ }
+
+ /// swap ONLY the glyph, keeping the cell's colors — the resize-handle hint
+ fn overlayDash(s: *Surface, x: u16, y: u16, glyph: []const u8) void {
+ const c = s.at(x, y);
+ @memcpy(c.text[0..glyph.len], glyph);
+ c.len = @intCast(glyph.len);
+ c.default = false;
+ }
+};
+
+pub const Mouse = struct {
+ // sel indexing relies on left/middle/right being 0/1/2
+ pub const Button = enum { left, middle, right, wheel_up, wheel_down, none };
+ pub const Kind = enum { press, release, motion, drag };
+ button: Button,
+ kind: Kind,
+ col: u16,
+ row: u16,
+};
+
+pub const Key = struct {
+ cp: u21, // codepoint or one of the specials below
+ text: []const u8 = "",
+ ctrl: bool = false,
+ alt: bool = false,
+
+ pub const enter: u21 = 0x0D;
+ pub const escape: u21 = 0x1B;
+ pub const tab: u21 = 0x09;
+ pub const backspace: u21 = 0x7F;
+ // private-use plane for non-text keys, matching no real codepoint
+ pub const up: u21 = 0xF0001;
+ pub const down: u21 = 0xF0002;
+ pub const left: u21 = 0xF0003;
+ pub const right: u21 = 0xF0004;
+ pub const home: u21 = 0xF0005;
+ pub const end: u21 = 0xF0006;
+ pub const page_up: u21 = 0xF0007;
+ pub const page_down: u21 = 0xF0008;
+};
+
+fn is(key: Key, cp: u21) bool {
+ return key.cp == cp and !key.ctrl and !key.alt;
+}
+
+fn isC(key: Key, cp: u21) bool {
+ return key.cp == cp and key.ctrl and !key.alt;
+}
+
+pub const Event = union(enum) {
+ key: Key,
+ mouse: Mouse,
+ resize: struct { cols: u16, rows: u16 },
+ output: struct { pane: u8, bytes: []const u8 },
+ eof: struct { pane: u8 },
+ paste: []const u8,
+ pinch: f32,
+ touch_scroll: f32,
+ tick,
+};
+
+/// IO the core wants done. Payloads are inline (fixed buffers): effects are
+/// queued values with no lifetime ties back into the core.
+pub const Effect = union(enum) {
+ spawn: struct { pane: u8, cwd: Buf(256) },
+ write: struct { pane: u8, bytes: Buf(64) },
+ resize_pty: struct { pane: u8, cols: u16, rows: u16 },
+ open_link: Buf(256),
+ /// write this pane's file content to its path; the shell reads both off
+ /// the core (content is unbounded, effects are fixed-size values)
+ save_file: struct { pane: u8 },
+ /// a serialized state dump is ready in core.dump_out; write it to
+ /// dump.default_path (acme-style: another instance loads it with -l)
+ write_dump,
+ /// the yank register changed; the shell reads it off the core (OSC 52 out)
+ set_clipboard,
+ /// a normal-mode `p` wants the system clipboard; the shell requests it and
+ /// the reply comes back as Event.paste
+ get_clipboard,
+ quit,
+
+ fn Buf(comptime n: usize) type {
+ return struct {
+ data: [n]u8 = undefined,
+ len: u16 = 0,
+ pub fn slice(b: *const @This()) []const u8 {
+ return b.data[0..b.len];
+ }
+ fn from(text: []const u8) @This() {
+ var b: @This() = .{};
+ std.debug.assert(text.len <= n);
+ @memcpy(b.data[0..text.len], text);
+ b.len = @intCast(text.len);
+ return b;
+ }
+ };
+ }
+};
+
+pub const Mode = enum { normal, insert, tty };
+
+pub const EDIT_CAP = 128; // max chars in one insertion run
+pub const MAX_EDITS = 16; // max concurrent insertion runs per pane
+
+/// A char-mode insertion run, anchored at an ABSOLUTE body row (rides the
+/// scrollback, not the screen). Rendering splices it into the row, shifting
+/// the shell's text right — typed text never overwrites output.
+pub const Edit = struct {
+ row: i32 = 0,
+ col: i32 = 0,
+ len: u16 = 0,
+ text: [EDIT_CAP]u8 = undefined,
+};
+
+/// One mouse selection (block-shaped), per button. c/r are text-area relative;
+/// r counts from the tag row (body starts at BOX_H).
+pub const Sel = struct {
+ state: enum { none, dragging, done } = .none,
+ c0: i32 = 0,
+ c1: i32 = 0,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal line selection (helix `x`): whole rows [r0, r1], absolute.
+pub const LineSel = struct {
+ active: bool = false,
+ r0: i32 = 0,
+ r1: i32 = 0,
+};
+
+/// A modal char-range selection (helix `v`): the anchor lives here, the head is
+/// the pane cursor. Mutually exclusive with LineSel.
+pub const CharSel = struct {
+ active: bool = false,
+ row: i32 = 0,
+ col: i32 = 0,
+};
+
+/// Undo snapshot of a terminal pane's edit state (whole-state, dumb on purpose).
+pub const EditSnap = struct {
+ edits: [MAX_EDITS]Edit,
+ nedits: usize,
+ cur_row: i32,
+ cur_col: i32,
+ cur_off: u16,
+};
+
+/// A file pane's backing: content is gpa-owned; undo/redo are content snapshots.
+pub const File = struct {
+ path: []u8,
+ content: []u8,
+ scroll: usize = 0,
+ /// one syntax.Syn byte per content byte in the highlighted window
+ highlights: []u8 = &.{},
+ highlight_start: usize = 0,
+ syntax_dirty: bool = true,
+ undo: std.ArrayList([]u8) = .empty,
+ redo: std.ArrayList([]u8) = .empty,
+};
+
+/// An image pane's backing: the decoded+downscaled RGBA (kept so the petscii
+/// grid can re-render on resize/toggle) and the cached glyph grid.
+pub const Image = struct {
+ path: []u8,
+ petscii: bool = false,
+ pmode: image.PaletteMode = .commodore,
+ ascii: bool = true,
+ tried: bool = false, // decode attempted — exactly once
+ rgba: []u8 = &.{},
+ iw: usize = 0,
+ ih: usize = 0,
+ /// dump-loaded image bytes (gpa-owned), decoded lazily like a file read
+ raw: []u8 = &.{},
+ grid: []image.petscii.Cell = &.{},
+ gw: usize = 0,
+ gh: usize = 0,
+ grid_w: u16 = 0,
+ grid_h: u16 = 0,
+ grid_mode: image.PaletteMode = .commodore,
+ grid_ascii: bool = true,
+};
+
+pub const Pane = struct {
+ vt: ghostty_vt.Terminal,
+ stream: ghostty_vt.TerminalStream,
+ mode: Mode = .normal,
+ vweight: f32 = 1,
+ cols: u16,
+ rows: u16,
+ greet: bool = false,
+ file: ?File = null,
+ image: ?Image = null,
+ msel: LineSel = .{},
+ vsel: CharSel = .{},
+ /// one pending prefix codepoint (0 = none): `g` and `z`
+ pending: u21 = 0,
+ /// the editable tag tail: seeded with the default builtins on first touch;
+ /// edited in place by a modal one-line editor sharing the pane's mode
+ tag_tail: std.ArrayList(u8) = .empty,
+ tag_init: bool = false,
+ tag_edit: bool = false,
+ tag_sel: bool = false,
+ tag_col: u16 = 0,
+ tag_anchor: u16 = 0,
+ ed_undo: std.ArrayList(EditSnap) = .empty,
+ ed_redo: std.ArrayList(EditSnap) = .empty,
+ /// live working directory, shell-reported (setCwd); shown in the tag and
+ /// the base for look-resolution of relative paths.
+ cwd: [1024]u8 = undefined,
+ cwd_len: u16 = 0,
+ /// modal cursor, anchored at ABSOLUTE body rows. Tracks the shell cursor
+ /// until pinned by a click or a key. In normal mode cur_col is a visual
+ /// column; in insert mode it is the run anchor and cur_off the offset in it.
+ cur_pinned: bool = false,
+ cur_row: i32 = 0,
+ cur_col: i32 = 0,
+ cur_off: u16 = 0,
+ sel: [3]Sel = @splat(.{}),
+ edits: [MAX_EDITS]Edit = @splat(.{}),
+ nedits: usize = 0,
+ /// every raw pty byte, in order — the dump/replay record.
+ tty_stream: std.ArrayList(u8) = .empty,
+ /// query replies ghostty computed (DSR, DA, kitty); the stream handler has
+ /// no path to the effect queue, so they land here and sync() drains them
+ /// into write effects. Bounded: replies are tiny escape sequences.
+ reply: [256]u8 = undefined,
+ reply_len: u16 = 0,
+
+ fn cwdSlice(p: *const Pane) []const u8 {
+ return p.cwd[0..p.cwd_len];
+ }
+
+ fn isTerminal(pane: *const Pane) bool {
+ return pane.file == null and pane.image == null;
+ }
+
+ /// current scroll offset: file top line, or the scrollback offset
+ fn scroll(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(f.scroll);
+ return @intCast(pane.vt.screens.active.pages.scrollbar().offset);
+ }
+
+ fn scrollBy(pane: *Pane, delta: i32) void {
+ if (pane.file) |*f| {
+ const max: i64 = @intCast(modal.lineCount(f.content) -| 1);
+ const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
+ const next: usize = @intCast(n);
+ if (next != f.scroll) {
+ f.scroll = next;
+ f.syntax_dirty = true;
+ }
+ } else {
+ pane.vt.screens.active.scroll(.{ .delta_row = @intCast(delta) });
+ }
+ }
+
+ fn ensureCursorVisible(pane: *Pane) void {
+ const off = pane.scroll();
+ const last = off + @as(i32, pane.rows) - 1;
+ if (pane.cur_row < off) pane.scrollBy(pane.cur_row - off) else if (pane.cur_row > last) pane.scrollBy(pane.cur_row - last);
+ }
+
+ /// the last navigable row: file -> last content line; terminal -> viewport bottom
+ fn lastNavRow(pane: *Pane) i32 {
+ if (pane.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ return pane.scroll() + @as(i32, pane.rows) - 1;
+ }
+
+ /// the insertion run anchored exactly at (row, col), if any
+ fn runAt(pane: *Pane, row: i32, col: i32) ?usize {
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == row and e.col == col) return i;
+ }
+ return null;
+ }
+
+ fn pinCursor(pane: *Pane) void {
+ if (pane.cur_pinned) return;
+ const off = pane.scroll();
+ if (pane.file != null) {
+ pane.cur_row = off;
+ pane.cur_col = 0;
+ } else {
+ pane.cur_row = @as(i32, @intCast(pane.vt.screens.active.cursor.y)) + off;
+ pane.cur_col = @intCast(pane.vt.screens.active.cursor.x);
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// rendered column of a position: original col + earlier-on-row insertions
+ /// + the offset into the run at this col.
+ fn renderedCol(pane: *Pane, row: i32, col: i32, off: u16) i32 {
+ var rc: i32 = col + @as(i32, off);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row == row and e.col < col) rc += @as(i32, e.len);
+ }
+ return rc;
+ }
+};
+
+const Drag = union(enum) {
+ none,
+ border_v: struct { left_col: usize, cur_x: u16 },
+ border_h: struct { col: usize, top_idx: usize, cur_y: u16 },
+ move: struct { id: usize, cur_x: u16, cur_y: u16 },
+ select: struct { id: usize, button: Mouse.Button },
+};
+
+const Rect = struct { x: u16, y: u16, w: u16, h: u16 };
+
+pub const Options = struct {
+ tty_only: bool = false,
+ tty_toggle: u21 = 'b',
+ /// load a dump of another instance instead of spawning shells (acme -l)
+ load_path: ?[]const u8 = null,
+ /// Initial grid. Shell contract: for LIVE sessions leave these at the
+ /// defaults and deliver the real size as the first resize EVENT — the core
+ /// defers each shell's greeting until after a resize (so `ls` wraps to the
+ /// real pane width); pre-sizing here means that resize never fires and the
+ /// greeting never runs. Pre-size only for dump loads (nothing greets, and
+ /// it avoids reflowing replayed content twice).
+ cols: u16 = 80,
+ rows: u16 = 24,
+};
+
+pub const Pardes = struct {
+ gpa: std.mem.Allocator,
+ opts: Options,
+ screen_w: u16,
+ screen_h: u16,
+ resize_count: usize = 0,
+
+ panes: [MAX_PANES]?*Pane = @splat(null),
+ // layout: columns own x by weight; panes own y by vweight within a column.
+ ncol: usize = 0,
+ col_weight: [MAX_COLS]f32 = @splat(1),
+ col_terms: [MAX_COLS][MAX_PANES]usize = undefined,
+ col_n: [MAX_COLS]usize = @splat(0),
+ // derived each sync
+ rects: [MAX_PANES]Rect = undefined,
+ col_x: [MAX_COLS]u16 = undefined,
+ col_w: [MAX_COLS]u16 = undefined,
+
+ active: usize = 0,
+ theme_idx: usize = 0,
+ colors_on: bool = true,
+ show_debug: bool = false,
+ /// kitty graphics support, reported by the shell (image panes fall back
+ /// to the petscii matcher without it)
+ kitty_ok: bool = false,
+ quit: bool = false,
+ drag: Drag = .none,
+ hover_col: u16 = 0,
+ hover_row: u16 = 0,
+ ctrl_w_pending: bool = false,
+ ov_pinch_scale: f32 = 1.0,
+ ov_touch_scroll_delta: f32 = 0.0,
+ /// middle+left chord argument: a kept left selection captured by a left
+ /// press during an active middle select-drag; consumed on middle release
+ chord_arg: ?[]u8 = null,
+
+ effects: [64]Effect = undefined,
+ effects_head: usize = 0,
+ effects_tail: usize = 0,
+
+ /// modal yank register (gpa-owned); a yank also mirrors out to the system
+ /// clipboard via the set_clipboard effect.
+ yank: ?[]u8 = null,
+ /// the last serialized dump (gpa-owned), read by the write_dump effect
+ dump_out: ?[]u8 = null,
+
+ surface: Surface = .{},
+ /// per-update scratch (paneCursorLines, selection text); reset each update
+ scratch: std.heap.ArenaAllocator,
+
+ pub fn init(gpa: std.mem.Allocator, opts: Options) !*Pardes {
+ syntax.compileStartupQueries();
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ if (opts.tty_only) {
+ _ = try p.newPane(0);
+ p.panes[0].?.mode = .tty;
+ p.ncol = 1;
+ p.col_n[0] = 1;
+ p.col_terms[0][0] = 0;
+ } else {
+ // editor default: two columns, the left one split in two.
+ _ = try p.newPane(0);
+ _ = try p.newPane(1);
+ _ = try p.newPane(2);
+ for (p.panes[0..3]) |slot| slot.?.greet = true;
+ p.ncol = 2;
+ p.col_n[0] = 2;
+ p.col_terms[0][0] = 0;
+ p.col_terms[0][1] = 1;
+ p.col_n[1] = 1;
+ p.col_terms[1][0] = 2;
+ }
+ p.sync();
+ return p;
+ }
+
+ pub fn deinit(p: *Pardes) void {
+ for (&p.panes) |*slot| if (slot.*) |pane| {
+ p.deinitPane(pane);
+ slot.* = null;
+ };
+ const gpa = p.gpa;
+ if (p.yank) |y| gpa.free(y);
+ if (p.dump_out) |d| gpa.free(d);
+ if (p.chord_arg) |a| gpa.free(a);
+ p.scratch.deinit();
+ gpa.free(p.surface.cells);
+ gpa.destroy(p);
+ }
+
+ fn deinitPane(p: *Pardes, pane: *Pane) void {
+ if (pane.image) |*iv| {
+ p.gpa.free(iv.path);
+ if (iv.rgba.len > 0) p.gpa.free(iv.rgba);
+ if (iv.raw.len > 0) p.gpa.free(iv.raw);
+ if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ }
+ if (pane.file) |*f| {
+ p.gpa.free(f.path);
+ p.gpa.free(f.content);
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ for (f.undo.items) |s| p.gpa.free(s);
+ f.undo.deinit(p.gpa);
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.deinit(p.gpa);
+ }
+ pane.tag_tail.deinit(p.gpa);
+ pane.ed_undo.deinit(p.gpa);
+ pane.ed_redo.deinit(p.gpa);
+ pane.stream.deinit();
+ pane.vt.deinit(p.gpa);
+ pane.tty_stream.deinit(p.gpa);
+ p.gpa.destroy(pane);
+ }
+
+ /// Allocate a pane slot with a live terminal emulator and queue the spawn
+ /// effect (optionally in a directory); the shell answers by forking a pty
+ /// and wiring reads back as Event.output for this pane id.
+ fn newPane(p: *Pardes, id: usize) !*Pane {
+ return p.newShell(id, "");
+ }
+
+ fn newShell(p: *Pardes, id: usize, cwd: []const u8) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ };
+ pane.stream = pane.vt.vtStream();
+ // answer terminal queries (DSR/DA/kitty) back into the pty, else
+ // crossterm apps (nushell, helix, fzf) block on the reply forever.
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[id] = pane;
+ p.emit(.{ .spawn = .{ .pane = @intCast(id), .cwd = .from(cwd) } });
+ return pane;
+ }
+
+ /// a doc pane (file/image): no pty, no spawn; a stub 1x1 emulator only
+ /// because the shared machinery touches its allocator-owned bits.
+ fn newDocPane(p: *Pardes, id: usize) !*Pane {
+ std.debug.assert(p.panes[id] == null);
+ const pane = try p.gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }),
+ .stream = undefined,
+ .cols = p.screen_w,
+ .rows = p.screen_h,
+ };
+ pane.stream = pane.vt.vtStream();
+ p.panes[id] = pane;
+ return pane;
+ }
+
+ fn freeSlot(p: *Pardes) ?usize {
+ return for (p.panes, 0..) |slot, i| {
+ if (slot == null) break i;
+ } else null;
+ }
+
+ /// chunk arbitrary-length bytes into fixed-size write effects (order kept)
+ fn emitWrite(p: *Pardes, id: usize, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = @min(bytes.len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(bytes[off .. off + n]) } });
+ off += n;
+ }
+ }
+
+ /// The shell reports the spawned pane's working directory (and later cwd
+ /// changes it observes, e.g. via /proc/<pid>/cwd before each frame).
+ pub fn setCwd(p: *Pardes, id: usize, cwd: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const n = @min(cwd.len, pane.cwd.len);
+ @memcpy(pane.cwd[0..n], cwd[0..n]);
+ pane.cwd_len = @intCast(n);
+ }
+
+ fn emit(p: *Pardes, e: Effect) void {
+ std.debug.assert(p.effects_tail - p.effects_head < p.effects.len);
+ p.effects[p.effects_tail % p.effects.len] = e;
+ p.effects_tail += 1;
+ }
+
+ pub fn nextEffect(p: *Pardes) ?Effect {
+ if (p.effects_head == p.effects_tail) return null;
+ const e = p.effects[p.effects_head % p.effects.len];
+ p.effects_head += 1;
+ return e;
+ }
+
+ pub fn update(p: *Pardes, ev: Event) void {
+ switch (ev) {
+ .resize => |sz| {
+ p.screen_w = sz.cols;
+ p.screen_h = sz.rows;
+ p.resize_count += 1;
+ },
+ .output => |o| {
+ const pane = p.panes[o.pane] orelse return;
+ pane.tty_stream.appendSlice(p.gpa, o.bytes) catch {};
+ pane.stream.nextSlice(o.bytes);
+ // follow new output to the bottom — except in the pane being
+ // actively navigated in a text mode.
+ if (!(o.pane == p.active and pane.mode != .tty))
+ pane.vt.screens.active.scroll(.active);
+ },
+ .eof => |e| p.removePane(e.pane),
+ .key => |key| p.handleKey(key),
+ .mouse => |m| p.handleMouse(m),
+ .paste => |bytes| {
+ // the OSC 52 system-clipboard reply for a normal-mode `p`
+ p.setYank(bytes);
+ if (p.panes[p.active]) |pane| p.normalPaste(pane);
+ },
+ .pinch => |scale| p.ov_pinch_scale = scale,
+ .touch_scroll => |delta| p.ov_touch_scroll_delta = delta,
+ .tick => {},
+ }
+ _ = p.scratch.reset(.retain_capacity);
+ p.sync();
+ }
+
+ fn setYank(p: *Pardes, text: []const u8) void {
+ if (p.yank) |y| p.gpa.free(y);
+ p.yank = p.gpa.dupe(u8, text) catch null;
+ p.emit(.{ .set_clipboard = {} });
+ }
+
+ // ---- tag + selection text (chord sources) ----
+
+ /// the live tag prefix: mode + cwd/path (image: the whole toggle tag)
+ fn tagPrefix(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ if (pane.image) |iv| {
+ const mode = if (iv.pmode == .commodore) "C64" else "Term";
+ return std.fmt.allocPrint(arena, "img {s} Petscii {s} Ascii {s}", .{ iv.path, mode, pane_builtins_str });
+ }
+ if (pane.file) |f| {
+ const fmode = if (pane.mode == .insert) "in" else "nm";
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ fmode, f.path });
+ }
+ const mode = switch (pane.mode) {
+ .normal => "nm",
+ .insert => "in",
+ .tty => "sy",
+ };
+ return std.fmt.allocPrint(arena, "{s} {s}", .{ mode, pane.cwdSlice() });
+ }
+
+ /// the editable tail: the user's edited buffer once touched, else defaults
+ fn curTail(pane: *Pane) []const u8 {
+ if (pane.image != null) return "";
+ if (pane.tag_init) return pane.tag_tail.items;
+ return if (pane.file != null) file_pane_tail else pane_tail;
+ }
+
+ /// focus the tag for editing, seeding the tail on first touch and placing
+ /// the cursor under the click (a column within the text area)
+ fn enterTagEdit(p: *Pardes, pane: *Pane, click_col: i32) void {
+ if (pane.image != null) return; // images keep a live toggle tag
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!pane.tag_init) {
+ pane.tag_tail.appendSlice(p.gpa, if (pane.file != null) file_pane_tail else pane_tail) catch return;
+ pane.tag_init = true;
+ }
+ pane.tag_edit = true;
+ pane.tag_sel = false;
+ pane.mode = .normal;
+ pane.pending = 0;
+ const cc = std.math.clamp(click_col - @as(i32, @intCast(pfx.len)), 0, @as(i32, @intCast(pane.tag_tail.items.len)));
+ pane.tag_col = @intCast(cc);
+ }
+
+ fn exitTagEdit(pane: *Pane) void {
+ pane.tag_edit = false;
+ pane.tag_sel = false;
+ }
+
+ fn tagSelBounds(pane: *Pane) struct { lo: usize, hi: usize } {
+ const a: usize = pane.tag_anchor;
+ const c: usize = pane.tag_col;
+ return .{ .lo = @min(a, c), .hi = @max(a, c) };
+ }
+
+ /// the text a tag Enter/Tab chord acts on: the char selection, else the
+ /// file-ish word under the cursor
+ fn tagChordText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ const arena = p.scratch.allocator();
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ if (hi > b.lo) return arena.dupe(u8, tail[b.lo..hi]) catch null;
+ return null;
+ }
+ const col: usize = @min(@as(usize, pane.tag_col), tail.len);
+ var lo = col;
+ while (lo > 0 and look.isFileChar(tail[lo - 1])) lo -= 1;
+ var hi = col;
+ while (hi < tail.len and look.isFileChar(tail[hi])) hi += 1;
+ if (hi > lo) return arena.dupe(u8, tail[lo..hi]) catch null;
+ return null;
+ }
+
+ /// tag-edit key dispatch: a modal one-line editor over the tail, sharing
+ /// the pane's mode. Newlines are always dropped.
+ fn handleTagKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.mode == .insert) return p.tagInsertKey(pane, key);
+ const lines = [1][]const u8{pane.tag_tail.items};
+ const cur: modal.Cursor = .{ .row = 0, .col = pane.tag_col };
+
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ const m: modal.Cursor = if (is(key, 'g')) modal.gotoFirst() else if (is(key, 'e')) modal.gotoLast(&lines) else if (is(key, 'h')) modal.lineStart(cur) else if (is(key, 'l')) modal.lineEnd(&lines, cur) else cur;
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ const moved: ?modal.Cursor =
+ if (is(key, 'h') or is(key, Key.left)) modal.charLeft(cur) else if (is(key, 'l') or is(key, Key.right)) modal.charRight(&lines, cur) else if (is(key, 'w')) modal.nextWordStart(&lines, cur, false) else if (is(key, 'W')) modal.nextWordStart(&lines, cur, true) else if (is(key, 'b')) modal.prevWordStart(&lines, cur, false) else if (is(key, 'B')) modal.prevWordStart(&lines, cur, true) else if (is(key, 'e')) modal.nextWordEnd(&lines, cur, false) else if (is(key, 'E')) modal.nextWordEnd(&lines, cur, true) else if (is(key, '0') or is(key, Key.home)) modal.lineStart(cur) else if (is(key, '$') or is(key, Key.end)) modal.lineEnd(&lines, cur) else if (is(key, '^')) modal.firstNonWsOf(&lines, cur) else null;
+ if (moved) |m| {
+ pane.tag_col = @intCast(m.col);
+ return;
+ }
+ if (is(key, 'i')) return tagEnterInsert(pane, pane.tag_col);
+ if (is(key, 'a')) return tagEnterInsert(pane, @intCast(modal.charRight(&lines, cur).col));
+ if (is(key, 'I')) return tagEnterInsert(pane, @intCast(modal.firstNonWsOf(&lines, cur).col));
+ if (is(key, 'A')) return tagEnterInsert(pane, @intCast(pane.tag_tail.items.len));
+ if (is(key, 'v')) {
+ pane.tag_sel = !pane.tag_sel;
+ if (pane.tag_sel) pane.tag_anchor = pane.tag_col;
+ return;
+ }
+ if (is(key, 'x')) { // select the whole one-line tail
+ const len = pane.tag_tail.items.len;
+ if (len > 0) {
+ pane.tag_sel = true;
+ pane.tag_anchor = 0;
+ pane.tag_col = @intCast(len - 1);
+ }
+ return;
+ }
+ if (is(key, 'd')) return p.tagDelete(pane, false);
+ if (is(key, 'c')) return p.tagDelete(pane, true);
+ if (is(key, 'y')) return p.tagYank(pane);
+ if (is(key, 'p')) return p.tagPaste(pane);
+ // Esc escalates: clear the selection, else hand focus back to the body
+ if (is(key, Key.escape)) {
+ if (pane.tag_sel) pane.tag_sel = false else exitTagEdit(pane);
+ return;
+ }
+ }
+
+ fn tagEnterInsert(pane: *Pane, col: u16) void {
+ pane.tag_col = @min(col, @as(u16, @intCast(pane.tag_tail.items.len)));
+ pane.tag_sel = false;
+ pane.mode = .insert;
+ pane.pending = 0;
+ }
+
+ fn tagInsertKey(p: *Pardes, pane: *Pane, key: Key) void {
+ if (is(key, Key.escape)) {
+ pane.mode = .normal;
+ return;
+ }
+ if (key.text.len > 0) {
+ for (key.text) |ch| {
+ if (ch == '\n' or ch == '\r') continue; // one-line tag
+ pane.tag_tail.insert(p.gpa, pane.tag_col, ch) catch return;
+ pane.tag_col += 1;
+ }
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => if (pane.tag_col > 0) {
+ _ = pane.tag_tail.orderedRemove(pane.tag_col - 1);
+ pane.tag_col -= 1;
+ },
+ Key.left => if (pane.tag_col > 0) {
+ pane.tag_col -= 1;
+ },
+ Key.right => if (pane.tag_col < pane.tag_tail.items.len) {
+ pane.tag_col += 1;
+ },
+ Key.home => pane.tag_col = 0,
+ Key.end => pane.tag_col = @intCast(pane.tag_tail.items.len),
+ else => {},
+ }
+ }
+
+ /// d/c: delete the selection (or the char under the cursor), yanking it;
+ /// c then enters insert
+ fn tagDelete(p: *Pardes, pane: *Pane, change: bool) void {
+ const tail = pane.tag_tail.items;
+ var lo: usize = undefined;
+ var count: usize = undefined;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ lo = b.lo;
+ count = @min(b.hi + 1, tail.len) - b.lo;
+ } else {
+ lo = @min(@as(usize, pane.tag_col), tail.len);
+ count = if (lo < tail.len) 1 else 0;
+ }
+ if (count > 0) {
+ p.setYank(tail[lo .. lo + count]);
+ pane.tag_tail.replaceRange(p.gpa, lo, count, "") catch return;
+ }
+ pane.tag_col = @intCast(lo);
+ pane.tag_sel = false;
+ if (change) pane.mode = .insert;
+ }
+
+ fn tagYank(p: *Pardes, pane: *Pane) void {
+ const tail = pane.tag_tail.items;
+ if (pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ const hi = @min(b.hi + 1, tail.len);
+ p.setYank(tail[b.lo..hi]);
+ pane.tag_col = @intCast(b.lo);
+ pane.tag_sel = false;
+ } else p.setYank(tail); // whole tail, like a line yank
+ }
+
+ fn tagPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ const col: usize = @min(@as(usize, pane.tag_col), pane.tag_tail.items.len);
+ var at = col;
+ for (y) |ch| {
+ if (ch == '\n' or ch == '\r') continue;
+ pane.tag_tail.insert(p.gpa, at, ch) catch break;
+ at += 1;
+ }
+ pane.tag_col = @intCast(at);
+ }
+
+ /// tag + '\n' + body: the full selectable pane text (row 0 = the tag)
+ fn paneText(p: *Pardes, pane: *Pane) ![]u8 {
+ const arena = p.scratch.allocator();
+ const pfx = try p.tagPrefix(pane);
+ const body = try p.bodyText(arena, pane);
+ return std.fmt.allocPrint(arena, "{s}{s}\n{s}", .{ pfx, curTail(pane), body });
+ }
+
+ /// block-selected text, newline-joined per row; reads the rendered screen
+ /// so typed runs and tty output select identically. Scratch-owned.
+ fn selectionText(p: *Pardes, pane: *Pane, sl: Sel) ![]const u8 {
+ const arena = p.scratch.allocator();
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0: usize = @intCast(@max(0, @min(sl.c0, sl.c1)));
+ const c1: usize = @intCast(@max(0, @max(sl.c0, sl.c1)));
+ const text = try p.paneText(pane);
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ var first = true;
+ while (it.next()) |line| : (v += 1) {
+ if (v < r0 or v > r1) continue;
+ if (!first) try out.append(arena, '\n');
+ first = false;
+ const b0 = @min(c0, line.len);
+ const b1 = @min(c1 + 1, line.len);
+ try out.appendSlice(arena, line[b0..b1]);
+ }
+ return out.items;
+ }
+
+ /// acme: a no-drag middle/right click expands to the file-ish word under it
+ fn expandSel(p: *Pardes, pane: *Pane, sl: *Sel) void {
+ if (sl.c0 != sl.c1 or sl.r0 != sl.r1) return;
+ const text = p.paneText(pane) catch return;
+ var it = std.mem.splitAny(u8, text, "\n");
+ var v: i32 = 0;
+ while (it.next()) |line| : (v += 1) {
+ if (v != sl.r0) continue;
+ const col: usize = @intCast(@max(0, sl.c0));
+ if (col > line.len) return;
+ var hi = col;
+ while (hi < line.len and look.isFileChar(line[hi])) hi += 1;
+ var lo = col;
+ while (lo > 0 and look.isFileChar(line[lo - 1])) lo -= 1;
+ if (hi > lo) {
+ sl.c0 = @intCast(lo);
+ sl.c1 = @intCast(hi - 1);
+ }
+ return;
+ }
+ }
+
+ /// the word under the modal cursor as a pane-local selection (paneText
+ /// coords: row 0 is the tag; file panes carry the line-number prefix)
+ fn cursorWordSel(pane: *Pane) Sel {
+ const vrow = (pane.cur_row - pane.scroll()) + @as(i32, BOX_H);
+ const vcol = if (pane.file != null) pane.cur_col + @as(i32, PREFIX_W) else pane.cur_col;
+ return .{ .state = .done, .c0 = vcol, .c1 = vcol, .r0 = vrow, .r1 = vrow };
+ }
+
+ /// the active modal selection as text (v range or x lines), if any
+ fn currentSelText(p: *Pardes, pane: *Pane) ?[]const u8 {
+ if (pane.vsel.active) return p.vselText(pane);
+ if (!pane.msel.active) return null;
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ const arena = p.scratch.allocator();
+ var buf: std.ArrayList(u8) = .empty;
+ const lo: usize = @intCast(@max(0, r0));
+ const hi: usize = @intCast(@max(0, r1));
+ var r: usize = lo;
+ while (r <= hi) : (r += 1) {
+ if (r > lo) buf.append(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ return buf.items;
+ }
+ return p.yankRows(pane, r0, r1);
+ }
+
+ fn handleKey(p: *Pardes, key: Key) void {
+ const pane = p.panes[p.active] orelse return;
+ // Ctrl-w prefix: helix-style directional pane focus (h/j/k/l)
+ if (p.ctrl_w_pending) {
+ p.ctrl_w_pending = false;
+ if (is(key, 'h') or is(key, Key.left)) p.focusDir(.left) else if (is(key, 'l') or is(key, Key.right)) p.focusDir(.right) else if (is(key, 'k') or is(key, Key.up)) p.focusDir(.up) else if (is(key, 'j') or is(key, Key.down)) p.focusDir(.down);
+ if (p.panes[p.active]) |nt| nt.pending = 0;
+ return;
+ }
+ if (isC(key, 'w')) {
+ p.ctrl_w_pending = true;
+ return;
+ }
+ // global window ops (any mode): Alt-n new terminal below, Alt-c move
+ // the active terminal into a fresh column
+ if (key.alt and !key.ctrl and key.cp == 'n') {
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(p.active).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.active = free;
+ return;
+ }
+ if (key.alt and !key.ctrl and key.cp == 'c') {
+ const f = p.layoutFindTerm(p.active).?;
+ if (p.ncol < MAX_COLS and p.col_n[f.col] > 1) {
+ p.layoutRemove(p.active);
+ p.layoutAppendColumn(p.active);
+ }
+ return;
+ }
+ // the configured Ctrl-key toggles raw tty mode in and out (terminals
+ // only); tty is deliberately off the normal editing path
+ if (pane.isTerminal() and key.ctrl and key.cp == p.opts.tty_toggle) {
+ if (pane.mode == .tty) {
+ pane.mode = .normal;
+ pane.pending = 0;
+ } else p.enterTty(p.active);
+ return;
+ }
+ // normal-mode Enter/Tab: the acme chords — look at (Enter) or execute
+ // (Tab) the modal selection, else the file-ish word under the cursor
+ // (on the tag tail when it's focused for editing, else the body).
+ if (pane.mode == .normal and (is(key, Key.enter) or is(key, Key.tab))) {
+ const btn: Mouse.Button = if (is(key, Key.tab)) .middle else .right;
+ if (pane.tag_edit) {
+ if (p.tagChordText(pane)) |txt| {
+ pane.tag_sel = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ }
+ return;
+ }
+ pane.pinCursor();
+ if (p.currentSelText(pane)) |txt| {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ p.actOnSelection(btn, p.active, txt, null);
+ } else {
+ var sel = cursorWordSel(pane);
+ p.expandSel(pane, &sel);
+ const word = p.selectionText(pane, sel) catch return;
+ p.actOnSelection(btn, p.active, word, null);
+ }
+ return;
+ }
+ // tag editing intercepts every other key: a modal one-line editor
+ // over the tail, sharing the pane's mode.
+ if (pane.tag_edit) {
+ p.handleTagKey(pane, key);
+ return;
+ }
+ // normal-mode `p` pastes the SYSTEM clipboard: an async OSC 52 round
+ // trip — request here, the reply arrives later as Event.paste.
+ if (pane.mode == .normal and is(key, 'p')) {
+ p.emit(.get_clipboard);
+ return;
+ }
+ switch (pane.mode) {
+ .normal => p.handleNormal(pane, key),
+ .insert => {
+ if (key.cp == Key.escape and !key.ctrl and !key.alt) {
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ p.handleInsert(pane, key);
+ },
+ .tty => p.forwardKey(p.active, key),
+ }
+ }
+
+ /// Enter raw tty. If the shell is at a prompt and the modal cursor sits on
+ /// the input line, move the shell's REAL cursor there via ghostty's
+ /// promptClickMove (arrow keys the shell understands) — dropping into tty
+ /// lands where you navigated. Requires OSC 133 cl=line (our bash rc).
+ fn enterTty(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ const screen = pane.vt.screens.active;
+ if (pane.vt.cursorIsAtPrompt()) {
+ const off = pane.scroll();
+ const vp_row: i32 = pane.cur_row - off;
+ if (vp_row >= 0) {
+ if (screen.pages.pin(.{ .viewport = .{ .x = @intCast(@max(0, pane.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) p.emitWrite(id, "\x1b[D");
+ i = 0;
+ while (i < m.right) : (i += 1) p.emitWrite(id, "\x1b[C");
+ }
+ }
+ }
+ }
+ }
+ pane.mode = .tty;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ }
+
+ /// move focus to the nearest pane in `dir` (overlap-aware nearest edge)
+ fn focusDir(p: *Pardes, dir: enum { left, right, up, down }) void {
+ const a = p.rects[p.active];
+ var best: ?usize = null;
+ var best_d: i32 = 0;
+ for (p.panes, 0..) |slot, i| {
+ if (slot == null or i == p.active) continue;
+ const r = p.rects[i];
+ const vov = a.y < r.y + r.h and r.y < a.y + a.h;
+ const hov = a.x < r.x + r.w and r.x < a.x + a.w;
+ 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 => @as(i32, a.x) - @as(i32, r.x + r.w),
+ .right => @as(i32, r.x) - @as(i32, a.x + a.w),
+ .up => @as(i32, a.y) - @as(i32, r.y + r.h),
+ .down => @as(i32, r.y) - @as(i32, a.y + a.h),
+ };
+ if (best == null or d < best_d) {
+ best = i;
+ best_d = d;
+ }
+ }
+ if (best) |b| p.active = b;
+ }
+
+ // ---- move-drag placement ----
+
+ const MovePlacement = struct {
+ preview_col: usize,
+ above_id: usize,
+ row: u16,
+ above_y: u16,
+ above_h: u16,
+ };
+
+ fn targetColumn(p: *Pardes, cur_x: u16) usize {
+ var tc: usize = if (p.ncol > 0) p.ncol - 1 else 0;
+ for (0..p.ncol) |c| {
+ if (cur_x >= p.col_x[c] and cur_x < p.col_x[c] + p.col_w[c]) {
+ tc = c;
+ break;
+ }
+ }
+ return tc;
+ }
+
+ fn splitRowForExtent(y: u16, h: u16, cur_y: u16) ?u16 {
+ if (h < 2) return null;
+ const min_each: u16 = if (h >= MINH * 2) MINH else 1;
+ const lo = y +| min_each;
+ const hi = y + h - min_each;
+ if (lo > hi) return y + h / 2;
+ return std.math.clamp(cur_y, lo, hi);
+ }
+
+ fn movePlacement(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) ?MovePlacement {
+ const src = p.layoutFindTerm(id) orelse return null;
+ const tc = p.targetColumn(cur_x);
+ if (tc == src.col and p.col_n[src.col] == 1) return null;
+ if (tc == src.col) {
+ const sr = p.rects[id];
+ if (cur_y >= sr.y and cur_y < sr.y + sr.h) return null;
+ }
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ if (p.col_n[src.col] > 1) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx + 1];
+ heights[sib] +|= p.rects[id].h;
+ }
+ var y: u16 = TOPBAR_H;
+ var last: ?MovePlacement = null;
+ for (0..p.col_n[tc]) |k| {
+ const pid = p.col_terms[tc][k];
+ if (pid == id) continue;
+ const h = heights[pid];
+ const row = splitRowForExtent(y, h, cur_y) orelse {
+ y +|= h;
+ continue;
+ };
+ const placement: MovePlacement = .{
+ .preview_col = tc,
+ .above_id = pid,
+ .row = row,
+ .above_y = y,
+ .above_h = h,
+ };
+ last = placement;
+ if (cur_y < y + h) return placement;
+ y +|= h;
+ }
+ return last;
+ }
+
+ /// drop pane `id` below the pane under the cursor, converting on-screen
+ /// heights to weights so ONLY the split pane changes size
+ fn moveTerm(p: *Pardes, id: usize, cur_x: u16, cur_y: u16) void {
+ const placement = p.movePlacement(id, cur_x, cur_y) orelse return;
+ const src = p.layoutFindTerm(id) orelse return;
+ const source_multi = p.col_n[src.col] > 1;
+ var heights: [MAX_PANES]u16 = @splat(0);
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ const pid = p.col_terms[c][k];
+ heights[pid] = p.rects[pid].h;
+ }
+ }
+ p.layoutRemove(id);
+ if (source_multi and src.col < p.ncol and p.col_n[src.col] > 0) {
+ const sib = if (src.idx > 0) p.col_terms[src.col][src.idx - 1] else p.col_terms[src.col][src.idx];
+ heights[sib] +|= p.rects[id].h;
+ }
+ const af = p.layoutFindTerm(placement.above_id) orelse return;
+ const upper_h = @max(1, placement.row -| placement.above_y);
+ const lower_h = @max(1, placement.above_h -| upper_h);
+ heights[placement.above_id] = upper_h;
+ heights[id] = lower_h;
+ p.layoutInsert(af.col, af.idx + 1, id);
+ p.setColumnWeights(af.col, &heights);
+ if (source_multi and src.col < p.ncol and src.col != af.col) p.setColumnWeights(src.col, &heights);
+ }
+
+ fn setColumnWeights(p: *Pardes, col: usize, heights: *const [MAX_PANES]u16) void {
+ if (col >= p.ncol) return;
+ for (0..p.col_n[col]) |k| {
+ const pid = p.col_terms[col][k];
+ if (p.panes[pid]) |pane| pane.vweight = @floatFromInt(@max(1, heights[pid]));
+ }
+ }
+
+ // ---- helix-modal normal mode ----
+
+ const PaneLines = struct {
+ lines: []const []const u8,
+ row0: i32, // absolute row of lines[0]
+ };
+
+ /// The lines the cursor moves over, absolute rows. File: all content lines.
+ /// Terminal: the WHOLE history+active grid (prompt rows hidden, runs
+ /// spliced) so motions ride the scrollback. Scratch-arena backed.
+ fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
+ const arena = p.scratch.allocator();
+ if (pane.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(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+ // ponytail: O(scrollback) dump+scan per keystroke — window it around the
+ // viewport if a multi-MB scrollback ever makes navigation lag.
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var out: std.ArrayList(u8) = .empty;
+ var it = std.mem.splitAny(u8, full, "\n");
+ var pit = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |raw| : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const is_prompt = if (pit.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false;
+ try spliceRow(arena, &out, pane, @intCast(i), if (is_prompt) "" else raw);
+ }
+ var ls: std.ArrayList([]const u8) = .empty;
+ var lit = std.mem.splitScalar(u8, out.items, '\n');
+ while (lit.next()) |ln| try ls.append(arena, ln);
+ return .{ .lines = ls.items, .row0 = 0 };
+ }
+
+ fn toModalCursor(pane: *Pane, pl: PaneLines) modal.Cursor {
+ const r: i32 = pane.cur_row - pl.row0;
+ return .{ .row = @intCast(@max(0, r)), .col = @intCast(@max(0, pane.cur_col)) };
+ }
+
+ fn fromModalCursor(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ pane.cur_row = @as(i32, @intCast(c.row)) + pl.row0;
+ pane.cur_col = @intCast(c.col);
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ }
+
+ /// apply a motion: move the cursor, keep it visible. A line selection
+ /// collapses on motion; a char selection extends (its head is the cursor).
+ fn applyMove(pane: *Pane, pl: PaneLines, c: modal.Cursor) void {
+ fromModalCursor(pane, pl, c);
+ pane.msel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ fn handleNormal(p: *Pardes, pane: *Pane, key: Key) void {
+ pane.pinCursor();
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur = toModalCursor(pane, pl);
+ const lines = pl.lines;
+ const half: usize = @max(1, pane.rows / 2);
+
+ // Esc collapses any selection / pending prefix; it does NOT drop to tty
+ if (is(key, Key.escape)) {
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.pending = 0;
+ return;
+ }
+ // prefix continuations
+ if (pane.pending == 'g') {
+ pane.pending = 0;
+ if (is(key, 'g')) return applyMove(pane, pl, modal.gotoFirst());
+ if (is(key, 'e')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (is(key, 'h')) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, 'l')) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ return;
+ }
+ if (pane.pending == 'z') {
+ pane.pending = 0;
+ if (is(key, 't')) return pane.scrollBy(pane.cur_row - pane.scroll());
+ if (is(key, 'z')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows / 2)));
+ if (is(key, 'b')) return pane.scrollBy(pane.cur_row - (pane.scroll() + @as(i32, pane.rows) - 1));
+ return;
+ }
+ if (is(key, 'g')) {
+ pane.pending = 'g';
+ return;
+ }
+ if (is(key, 'z')) {
+ pane.pending = 'z';
+ return;
+ }
+ // movement
+ if (is(key, 'h') or is(key, Key.left)) return applyMove(pane, pl, modal.charLeft(cur));
+ if (is(key, 'l') or is(key, Key.right)) return applyMove(pane, pl, modal.charRight(lines, cur));
+ if (is(key, 'j') or is(key, Key.down)) return applyMove(pane, pl, modal.lineDown(lines, cur));
+ if (is(key, 'k') or is(key, Key.up)) return applyMove(pane, pl, modal.lineUp(lines, cur));
+ if (is(key, 'w')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, false));
+ if (is(key, 'b')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, false));
+ if (is(key, 'e')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, false));
+ if (is(key, 'W')) return applyMove(pane, pl, modal.nextWordStart(lines, cur, true));
+ if (is(key, 'B')) return applyMove(pane, pl, modal.prevWordStart(lines, cur, true));
+ if (is(key, 'E')) return applyMove(pane, pl, modal.nextWordEnd(lines, cur, true));
+ if (is(key, '0') or is(key, Key.home)) return applyMove(pane, pl, modal.lineStart(cur));
+ if (is(key, '$') or is(key, Key.end)) return applyMove(pane, pl, modal.lineEnd(lines, cur));
+ if (is(key, '^')) return applyMove(pane, pl, modal.firstNonWsOf(lines, cur));
+ if (is(key, 'G')) return applyMove(pane, pl, modal.gotoLast(lines));
+ if (isC(key, 'd') or is(key, Key.page_down))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, half)));
+ if (isC(key, 'u') or is(key, Key.page_up))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, half)));
+ if (isC(key, 'f'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageDown(cur, @as(usize, pane.rows))));
+ if (isC(key, 'b'))
+ return applyMove(pane, pl, modal.clampToChar(lines, modal.pageUp(cur, @as(usize, pane.rows))));
+ // insert entry
+ if (is(key, 'i')) return p.enterInsert(pane, .at);
+ if (is(key, 'a')) return p.enterInsert(pane, .append);
+ if (is(key, 'I')) return p.enterInsert(pane, .line_start);
+ if (is(key, 'A')) return p.enterInsert(pane, .line_end);
+ if (is(key, 'o')) return p.enterInsert(pane, .open_below);
+ if (is(key, 'O')) return p.enterInsert(pane, .open_above);
+ // selection + edit
+ if (is(key, 'v')) return charSelect(pane);
+ if (is(key, 'x')) return lineSelect(pane);
+ if (is(key, 'd')) return p.normalDelete(pane);
+ if (is(key, 'c')) return p.normalChange(pane);
+ if (is(key, 'y')) return p.normalYank(pane);
+ if (is(key, 'u')) return p.doUndo(pane);
+ if (is(key, 'U')) return p.doRedo(pane);
+ }
+
+ const InsertAt = enum { at, append, line_start, line_end, open_below, open_above };
+
+ fn enterInsert(p: *Pardes, pane: *Pane, where: InsertAt) void {
+ pane.pinCursor();
+ // snapshot once per insert session so `u` undoes the whole session
+ p.pushUndo(pane);
+ const pl = p.paneCursorLines(pane) catch {
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ return;
+ };
+ const cur = toModalCursor(pane, pl);
+ const llen: usize = if (cur.row < pl.lines.len) pl.lines[cur.row].len else 0;
+ switch (where) {
+ .at => {},
+ .append => fromModalCursor(pane, pl, modal.charRight(pl.lines, cur)),
+ .line_start => fromModalCursor(pane, pl, modal.firstNonWsOf(pl.lines, cur)),
+ .line_end => {
+ pane.cur_col = @intCast(llen);
+ pane.cur_pinned = true;
+ },
+ .open_below => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = llen }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row = @as(i32, @intCast(cur.row + 1)) + pl.row0;
+ pane.cur_col = 0;
+ } else {
+ const bottom = pane.scroll() + @as(i32, pane.rows) - 1;
+ pane.cur_row = @min(bottom, pane.cur_row + 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ .open_above => {
+ if (pane.file) |*f| {
+ const new = modal.insertAt(p.gpa, f.content, .{ .row = cur.row, .col = 0 }, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_col = 0;
+ } else {
+ const top = pane.scroll();
+ pane.cur_row = @max(top, pane.cur_row - 1);
+ pane.cur_col = 0;
+ }
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ },
+ }
+ // `i` inserts before the run at the cursor; the others append after it
+ pane.cur_off = if (where == .at) 0 else if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.mode = .insert;
+ pane.msel.active = false;
+ pane.vsel.active = false;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ }
+
+ /// insert mode: file panes get real content edits; terminals get run
+ /// splices — edits only ever touch YOUR runs, shell output is immutable.
+ fn handleInsert(p: *Pardes, pane: *Pane, key: Key) void {
+ if (pane.file) |*f| {
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ if (key.text.len > 0) {
+ const new = modal.insertAt(p.gpa, f.content, c, key.text) catch return;
+ p.setFileContent(f, new);
+ for (key.text) |ch| {
+ if (ch == '\n') {
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else pane.cur_col += 1;
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ return;
+ }
+ switch (key.cp) {
+ Key.enter => {
+ const new = modal.insertAt(p.gpa, f.content, c, "\n") catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.backspace => {
+ if (pane.cur_col > 0) {
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row, .col = c.col - 1 }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_col -= 1;
+ } else if (pane.cur_row > 0) {
+ const prevlen = modal.lineSlice(f.content, c.row - 1).len;
+ const new = modal.deleteChar(p.gpa, f.content, .{ .row = c.row - 1, .col = prevlen }) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row -= 1;
+ pane.cur_col = @intCast(prevlen);
+ }
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ },
+ Key.left, Key.right, Key.up, Key.down => {
+ const pl = p.paneCursorLines(pane) catch return;
+ const cur0 = toModalCursor(pane, pl);
+ const nc = switch (key.cp) {
+ Key.left => modal.charLeft(cur0),
+ Key.right => modal.charRight(pl.lines, cur0),
+ Key.up => modal.lineUp(pl.lines, cur0),
+ Key.down => modal.lineDown(pl.lines, cur0),
+ else => cur0,
+ };
+ fromModalCursor(pane, pl, nc);
+ pane.ensureCursorVisible();
+ },
+ else => {},
+ }
+ return;
+ }
+
+ if (!pane.cur_pinned) pane.pinCursor();
+ if (key.text.len > 0) {
+ insertRunText(pane, key.text);
+ return;
+ }
+ switch (key.cp) {
+ Key.backspace => {
+ const ei = pane.runAt(pane.cur_row, pane.cur_col) orelse return;
+ const e = &pane.edits[ei];
+ if (pane.cur_off > 0 and e.len > 0) {
+ var k: usize = pane.cur_off - 1;
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_off -= 1;
+ }
+ },
+ else => {},
+ }
+ }
+
+ fn insertRunText(pane: *Pane, text: []const u8) void {
+ var idx: ?usize = pane.runAt(pane.cur_row, pane.cur_col);
+ if (idx == null and pane.nedits < MAX_EDITS) {
+ pane.edits[pane.nedits] = .{ .row = pane.cur_row, .col = pane.cur_col };
+ pane.nedits += 1;
+ idx = pane.nedits - 1;
+ }
+ const ei = idx orelse return;
+ const e = &pane.edits[ei];
+ for (text) |ch| {
+ if (e.len >= EDIT_CAP) break;
+ if (pane.cur_off > e.len) pane.cur_off = e.len;
+ var k: usize = e.len;
+ while (k > pane.cur_off) : (k -= 1) e.text[k] = e.text[k - 1];
+ e.text[pane.cur_off] = ch;
+ e.len += 1;
+ pane.cur_off += 1;
+ }
+ }
+
+ // ---- selections ----
+
+ /// helix `v`: toggle char-range select, anchored at the cursor
+ fn charSelect(pane: *Pane) void {
+ if (pane.vsel.active) {
+ pane.vsel.active = false;
+ } else {
+ pane.vsel = .{ .active = true, .row = pane.cur_row, .col = pane.cur_col };
+ }
+ pane.msel.active = false;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ /// helix `x`: extend the line selection down by one (or start it)
+ fn lineSelect(pane: *Pane) void {
+ if (!pane.msel.active) {
+ pane.msel = .{ .active = true, .r0 = pane.cur_row, .r1 = pane.cur_row };
+ } else {
+ const last = pane.lastNavRow();
+ pane.msel.r1 = @min(pane.msel.r1 + 1, last);
+ pane.cur_row = pane.msel.r1;
+ }
+ pane.vsel.active = false;
+ pane.cur_col = 0;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.pending = 0;
+ }
+
+ const Bounds = struct { lo_row: i32, lo_col: i32, hi_row: i32, hi_col: i32 };
+
+ /// the char selection [anchor, cursor] normalized to document order
+ fn vselBounds(pane: *Pane) Bounds {
+ var ar = pane.vsel.row;
+ var ac = pane.vsel.col;
+ var br = pane.cur_row;
+ var bc = pane.cur_col;
+ if (br < ar or (br == ar and bc < ac)) {
+ const sr = ar;
+ const sc = ac;
+ ar = br;
+ ac = bc;
+ br = sr;
+ bc = sc;
+ }
+ return .{ .lo_row = ar, .lo_col = ac, .hi_row = br, .hi_col = bc };
+ }
+
+ fn vselText(p: *Pardes, pane: *Pane) []const u8 {
+ const arena = p.scratch.allocator();
+ const b = vselBounds(pane);
+ if (pane.file) |f| {
+ return modal.rangeText(arena, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch "";
+ }
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ 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 < b.lo_row or abs > b.hi_row) continue;
+ const lo: usize = if (abs == b.lo_row) @intCast(@max(0, b.lo_col)) else 0;
+ const hi_excl: usize = if (abs == b.hi_row) @as(usize, @intCast(@max(0, b.hi_col))) + 1 else ln.len;
+ if (abs > b.lo_row) out.append(arena, '\n') catch return "";
+ out.appendSlice(arena, ln[@min(lo, ln.len)..@min(hi_excl, ln.len)]) catch return "";
+ }
+ return out.items;
+ }
+
+ /// join the rendered body rows [r0, r1] (absolute) with '\n'; scratch-owned
+ fn yankRows(p: *Pardes, pane: *Pane, r0: i32, r1: i32) []const u8 {
+ const arena = p.scratch.allocator();
+ const body = p.bodyText(arena, pane) catch return "";
+ const off = pane.scroll();
+ 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(arena, '\n') catch return "";
+ out.appendSlice(arena, ln) catch return "";
+ }
+ return out.items;
+ }
+
+ /// drop all insertion runs whose row is in [r0, r1]
+ fn clearEditsInRange(pane: *Pane, r0: i32, r1: i32) void {
+ var w: usize = 0;
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.row >= r0 and e.row <= r1) continue;
+ pane.edits[w] = e;
+ w += 1;
+ }
+ pane.nedits = w;
+ }
+
+ // ---- edit operations (d / c / y / p) ----
+
+ fn normalDelete(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane)); // read the range before the move
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ // terminal: can't delete shell output — only drop typed runs
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.setYank(d.deleted);
+ p.gpa.free(d.deleted);
+ const n = modal.lineCount(f.content);
+ pane.cur_row = @intCast(@min(@as(usize, @intCast(r0)), if (n == 0) 0 else n - 1));
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ } else {
+ p.deleteRunCharAtCursor(pane);
+ }
+ }
+
+ /// delete the inserted char under the (visual) cursor from its run, if any
+ fn deleteRunCharAtCursor(p: *Pardes, pane: *Pane) void {
+ for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.row != pane.cur_row or e.len == 0) continue;
+ const vstart = pane.renderedCol(e.row, e.col, 0);
+ if (pane.cur_col < vstart or pane.cur_col >= vstart + @as(i32, e.len)) continue;
+ p.pushUndo(pane);
+ var k: usize = @intCast(pane.cur_col - vstart);
+ while (k + 1 < e.len) : (k += 1) e.text[k] = e.text[k + 1];
+ e.len -= 1;
+ pane.cur_pinned = true;
+ return;
+ }
+ }
+
+ fn normalYank(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ if (pane.file) |f| {
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ 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(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ } else {
+ p.setYank(p.yankRows(pane, r0, r1));
+ }
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ pane.msel.active = false;
+ } else if (pane.file) |f| {
+ p.setYank(modal.lineSlice(f.content, @intCast(@max(0, pane.cur_row))));
+ } else {
+ p.setYank(p.yankRows(pane, pane.cur_row, pane.cur_row));
+ }
+ }
+
+ fn normalPaste(p: *Pardes, pane: *Pane) void {
+ const y = p.yank orelse return;
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const new = modal.pasteLineBelow(p.gpa, f.content, @intCast(@max(0, pane.cur_row)), y) catch return;
+ p.setFileContent(f, new);
+ pane.cur_row += 1;
+ pane.cur_col = 0;
+ } else {
+ insertRunText(pane, y);
+ }
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.ensureCursorVisible();
+ }
+
+ fn normalChange(p: *Pardes, pane: *Pane) void {
+ if (pane.vsel.active) {
+ const b = vselBounds(pane);
+ p.setYank(p.vselText(pane));
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ const d = modal.deleteRange(p.gpa, f.content, .{ .row = @intCast(@max(0, b.lo_row)), .col = @intCast(@max(0, b.lo_col)) }, .{ .row = @intCast(@max(0, b.hi_row)), .col = @intCast(@max(0, b.hi_col)) }) catch {
+ pane.vsel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ clampCursorToFile(pane, f);
+ } else {
+ clearEditsInRange(pane, b.lo_row, b.hi_row);
+ pane.cur_row = b.lo_row;
+ pane.cur_col = b.lo_col;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.ensureCursorVisible();
+ }
+ pane.mode = .insert;
+ pane.pending = 0;
+ return;
+ }
+ if (pane.msel.active) {
+ const r0 = @min(pane.msel.r0, pane.msel.r1);
+ const r1 = @max(pane.msel.r0, pane.msel.r1);
+ p.pushUndo(pane);
+ if (pane.file) |*f| {
+ // keep one empty line at r0, drop the rest; yank the removed
+ var buf: std.ArrayList(u8) = .empty;
+ const arena = p.scratch.allocator();
+ 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(arena, '\n') catch break;
+ buf.appendSlice(arena, modal.lineSlice(f.content, r)) catch break;
+ }
+ p.setYank(buf.items);
+ if (r1 > r0) {
+ const d = modal.deleteLines(p.gpa, f.content, @intCast(@max(0, r0 + 1)), @intCast(@max(0, r1))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, d.content);
+ p.gpa.free(d.deleted);
+ }
+ const cl = modal.clearLine(p.gpa, f.content, @intCast(@max(0, r0))) catch {
+ pane.msel.active = false;
+ return;
+ };
+ p.setFileContent(f, cl);
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ } else {
+ clearEditsInRange(pane, r0, r1);
+ p.setYank(p.yankRows(pane, r0, r1));
+ pane.cur_row = r0;
+ pane.cur_col = 0;
+ }
+ pane.msel.active = false;
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.mode = .insert;
+ pane.pending = 0;
+ pane.ensureCursorVisible();
+ } else if (pane.file) |*f| {
+ p.pushUndo(pane);
+ const c = modal.Cursor{ .row = @intCast(@max(0, pane.cur_row)), .col = @intCast(@max(0, pane.cur_col)) };
+ const new = modal.deleteChar(p.gpa, f.content, c) catch return;
+ p.setFileContent(f, new);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ } else {
+ p.pushUndo(pane);
+ pane.mode = .insert;
+ pane.cur_off = 0;
+ pane.pending = 0;
+ }
+ }
+
+ // ---- dumb undo/redo: whole-state snapshots, one per edit op ----
+
+ fn setFileContent(p: *Pardes, f: *File, new: []u8) void {
+ p.gpa.free(f.content);
+ f.content = new;
+ f.syntax_dirty = true;
+ }
+
+ fn clampCursorToFile(pane: *Pane, f: *File) void {
+ const n = modal.lineCount(f.content);
+ const row: usize = @min(@as(usize, @intCast(@max(0, pane.cur_row))), if (n == 0) 0 else n - 1);
+ const llen = modal.lineSlice(f.content, row).len;
+ pane.cur_row = @intCast(row);
+ pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, pane.cur_col))), llen));
+ pane.cur_off = 0;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn snapTerm(pane: *Pane) EditSnap {
+ return .{ .edits = pane.edits, .nedits = pane.nedits, .cur_row = pane.cur_row, .cur_col = pane.cur_col, .cur_off = pane.cur_off };
+ }
+
+ fn editSnapEq(a: EditSnap, b: EditSnap) bool {
+ if (a.nedits != b.nedits) return false;
+ for (a.edits[0..a.nedits], b.edits[0..b.nedits]) |x, y| {
+ if (x.row != y.row or x.col != y.col or x.len != y.len) return false;
+ if (!std.mem.eql(u8, x.text[0..x.len], y.text[0..y.len])) return false;
+ }
+ return true;
+ }
+
+ fn pushUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len > 0 and std.mem.eql(u8, f.undo.items[f.undo.items.len - 1], f.content)) return;
+ const snap = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, snap) catch {
+ p.gpa.free(snap);
+ return;
+ };
+ if (f.undo.items.len > UNDO_MAX) p.gpa.free(f.undo.orderedRemove(0));
+ for (f.redo.items) |s| p.gpa.free(s);
+ f.redo.clearRetainingCapacity();
+ } else {
+ const snap = snapTerm(pane);
+ if (pane.ed_undo.items.len > 0 and editSnapEq(pane.ed_undo.items[pane.ed_undo.items.len - 1], snap)) return;
+ pane.ed_undo.append(p.gpa, snap) catch return;
+ if (pane.ed_undo.items.len > EDIT_UNDO_MAX) _ = pane.ed_undo.orderedRemove(0);
+ pane.ed_redo.clearRetainingCapacity();
+ }
+ }
+
+ fn doUndo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.undo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.redo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const prev = f.undo.pop() orelse return;
+ p.setFileContent(f, prev);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_undo.items.len == 0) return;
+ pane.ed_redo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_undo.pop() orelse return);
+ }
+
+ fn doRedo(p: *Pardes, pane: *Pane) void {
+ if (pane.file) |*f| {
+ if (f.redo.items.len == 0) return;
+ const cur = p.gpa.dupe(u8, f.content) catch return;
+ f.undo.append(p.gpa, cur) catch {
+ p.gpa.free(cur);
+ return;
+ };
+ const next = f.redo.pop() orelse return;
+ p.setFileContent(f, next);
+ clampCursorToFile(pane, f);
+ return;
+ }
+ if (pane.ed_redo.items.len == 0) return;
+ pane.ed_undo.append(p.gpa, snapTerm(pane)) catch return;
+ restoreTerm(pane, pane.ed_redo.pop() orelse return);
+ }
+
+ fn restoreTerm(pane: *Pane, snap: EditSnap) void {
+ pane.edits = snap.edits;
+ pane.nedits = snap.nedits;
+ pane.cur_row = snap.cur_row;
+ pane.cur_col = snap.cur_col;
+ pane.cur_off = snap.cur_off;
+ pane.cur_pinned = true;
+ pane.vsel.active = false;
+ pane.msel.active = false;
+ pane.ensureCursorVisible();
+ }
+
+ fn handleMouse(p: *Pardes, m: Mouse) void {
+ const mcol = @min(m.col, p.screen_w -| 1);
+ const mrow = @min(m.row, p.screen_h -| 1);
+ p.hover_col = mcol;
+ p.hover_row = mrow;
+
+ const hovered: ?usize = for (p.panes, 0..) |slot, i| {
+ if (slot == null) continue;
+ const r = p.rects[i];
+ if (mcol >= r.x and mcol < r.x + r.w and mrow >= r.y and mrow < r.y + r.h) break i;
+ } else null;
+
+ // any press that is NOT the middle+left chord abandons a pending
+ // chord argument
+ if (m.kind == .press and
+ !(m.button == .left and p.drag == .select and p.drag.select.button == .middle))
+ {
+ if (p.chord_arg) |a| {
+ p.gpa.free(a);
+ p.chord_arg = null;
+ }
+ }
+
+ switch (m.button) {
+ .none => {}, // hover tracked above; motion has no other meaning
+ .wheel_up, .wheel_down => {
+ if (m.kind != .press) return;
+ const id = hovered orelse return;
+ p.panes[id].?.scrollBy(if (m.button == .wheel_up) -1 else 1);
+ },
+ .left => switch (m.kind) {
+ .press => {
+ // middle+left chord: a left press during a middle select-
+ // drag captures the KEPT left selection as the execute's
+ // argument; the middle drag keeps running to its release.
+ if (p.drag == .select and p.drag.select.button == .middle) {
+ if (p.panes[p.drag.select.id]) |t| {
+ const sl = t.sel[0];
+ if (sl.state == .done) {
+ if (p.chord_arg) |old| p.gpa.free(old);
+ const txt = p.selectionText(t, sl) catch null;
+ p.chord_arg = if (txt) |tx| (p.gpa.dupe(u8, tx) catch null) else null;
+ }
+ }
+ return;
+ }
+ if (mrow < TOPBAR_H) return; // topbar: left click deliberately inert
+ // resize handles first: a pane's own trailing edge (v: the
+ // left column's last col; h: the upper pane's last row)
+ for (0..p.ncol -| 1) |c| {
+ if (mcol == p.col_x[c] + p.col_w[c] -| 1) {
+ p.drag = .{ .border_v = .{ .left_col = c, .cur_x = mcol } };
+ return;
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (mcol < p.col_x[cc] or mcol >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (mrow == r.y + r.h -| 1) {
+ p.drag = .{ .border_h = .{ .col = cc, .top_idx = k, .cur_y = mrow } };
+ return;
+ }
+ }
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow < r.y + BOX_H) {
+ p.drag = .{ .move = .{ .id = id, .cur_x = mcol, .cur_y = mrow } };
+ } else if (mcol < r.x + GUTTER) {
+ // gutter scrollbar: a left click scrolls UP to that row
+ p.active = id;
+ pane.scrollBy(-(@as(i32, mrow) - @as(i32, r.y + BOX_H)));
+ } else if (mrow < r.y + BOX_H and pane.image == null) {
+ // left click on the tag row: edit the tail in place
+ p.active = id;
+ p.enterTagEdit(pane, @as(i32, mcol) - @as(i32, r.x + GUTTER));
+ } else {
+ p.active = id;
+ exitTagEdit(pane); // clicking the body leaves tag editing
+ const sc: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[0] = .{ .state = .dragging, .c0 = sc, .c1 = sc, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = .left } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(.left),
+ .motion => {},
+ },
+ .middle, .right => switch (m.kind) {
+ .press => {
+ if (mrow < TOPBAR_H) {
+ // global tagbar: MIDDLE runs the command under the click
+ // (a stray left click must never Kill)
+ if (m.button == .middle) {
+ const word = wordAtCol(topbar_str, mcol);
+ if (word.len > 0) p.actOnSelection(.middle, p.active, word, null);
+ }
+ return;
+ }
+ const id = hovered orelse return;
+ const r = p.rects[id];
+ const pane = p.panes[id] orelse return;
+ if (mcol < r.x + GUTTER and mrow >= r.y + BOX_H) {
+ // gutter: right scrolls DOWN to here, mirroring left's up;
+ // middle matches left. Right focuses (look lands you
+ // there); middle does not.
+ const local = @as(i32, mrow) - @as(i32, r.y + BOX_H);
+ if (m.button == .right) p.active = id;
+ pane.scrollBy(if (m.button == .right) local else -local);
+ } else if (mcol >= r.x + GUTTER) {
+ if (m.button == .right) p.active = id;
+ const c: i32 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ const v: i32 = @as(i32, mrow) - @as(i32, r.y);
+ pane.sel[@intFromEnum(m.button)] = .{ .state = .dragging, .c0 = c, .c1 = c, .r0 = v, .r1 = v };
+ p.drag = .{ .select = .{ .id = id, .button = m.button } };
+ }
+ },
+ .drag => p.dragUpdate(mcol, mrow),
+ .release => p.dragRelease(m.button),
+ .motion => {},
+ },
+ }
+ }
+
+ fn dragUpdate(p: *Pardes, mcol: u16, mrow: u16) void {
+ switch (p.drag) {
+ .border_v => |*d| {
+ const c = d.left_col;
+ if (c + 1 < p.ncol) {
+ const lo = p.col_x[c] + MINW;
+ const hi = p.col_x[c] +| p.col_w[c] +| p.col_w[c + 1] -| MINW;
+ d.cur_x = if (lo <= hi) std.math.clamp(mcol, lo, hi) else mcol;
+ } else d.cur_x = mcol;
+ },
+ .border_h => |*d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.rects[p.col_terms[cc][k]];
+ const b = p.rects[p.col_terms[cc][k + 1]];
+ const lo = a.y + MINH;
+ const hi = a.y +| a.h +| b.h -| MINH;
+ d.cur_y = if (lo <= hi) std.math.clamp(mrow, lo, hi) else mrow;
+ } else d.cur_y = mrow;
+ },
+ .move => |*d| {
+ d.cur_x = mcol;
+ d.cur_y = mrow;
+ },
+ .select => |s| {
+ if (p.panes[s.id]) |pane| {
+ const r = p.rects[s.id];
+ const b = @intFromEnum(s.button);
+ pane.sel[b].c1 = @as(i32, mcol) - @as(i32, r.x + GUTTER);
+ pane.sel[b].r1 = @as(i32, mrow) - @as(i32, r.y);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ fn dragRelease(p: *Pardes, button: Mouse.Button) void {
+ // a release of a button OTHER than the one driving a select drag is
+ // the middle+left chord's left release: the middle drag keeps going
+ if (p.drag == .select and button != p.drag.select.button) return;
+ defer p.drag = .none;
+ switch (p.drag) {
+ .border_v => |d| {
+ const c = d.left_col;
+ if (c + 1 < p.ncol) {
+ const combined: f32 = @floatFromInt(p.col_w[c] + p.col_w[c + 1]);
+ // the handle is the left pane's LAST column (inclusive), so
+ // the new width includes it — a no-drag click changes nothing
+ var nl: f32 = @floatFromInt((d.cur_x + 1) -| p.col_x[c]);
+ nl = std.math.clamp(nl, @as(f32, MINW), @max(@as(f32, MINW), combined - MINW));
+ const pair = p.col_weight[c] + p.col_weight[c + 1];
+ p.col_weight[c] = pair * (nl / combined);
+ p.col_weight[c + 1] = pair - p.col_weight[c];
+ }
+ },
+ .border_h => |d| {
+ const cc = d.col;
+ const k = d.top_idx;
+ if (k + 1 < p.col_n[cc]) {
+ const a = p.panes[p.col_terms[cc][k]] orelse return;
+ const b = p.panes[p.col_terms[cc][k + 1]] orelse return;
+ const ra = p.rects[p.col_terms[cc][k]];
+ const rb = p.rects[p.col_terms[cc][k + 1]];
+ const combined: f32 = @floatFromInt(ra.h + rb.h);
+ var nt: f32 = @floatFromInt((d.cur_y + 1) -| ra.y);
+ nt = std.math.clamp(nt, @as(f32, MINH), @max(@as(f32, MINH), combined - MINH));
+ const pair = a.vweight + b.vweight;
+ a.vweight = pair * (nt / combined);
+ b.vweight = pair - a.vweight;
+ }
+ },
+ .move => |d| {
+ p.moveTerm(d.id, d.cur_x, d.cur_y);
+ // a file moved into the left column evicts a lone unused shell
+ if (p.panes[d.id]) |mt| if (mt.file != null) {
+ if (p.layoutFindTerm(d.id)) |f| if (f.col == 0)
+ p.evictLonePristineTty(0, d.id);
+ };
+ },
+ .select => |s| {
+ const pane = p.panes[s.id] orelse return;
+ const b = @intFromEnum(s.button);
+ pane.sel[b].state = .done;
+ if (s.button == .left) {
+ // keep a dragged selection highlighted; a plain click clears
+ // it. Either way pin the cursor + enter normal mode on text
+ // panes (tty keeps the mouse usable without dropping modes).
+ const sl = pane.sel[0];
+ const dragged = sl.c0 != sl.c1 or sl.r0 != sl.r1;
+ if (!dragged) pane.sel[0].state = .none;
+ const body_vis = sl.r1 - @as(i32, BOX_H);
+ if (body_vis >= 0 and pane.mode != .tty) {
+ pane.cur_row = body_vis + pane.scroll();
+ pane.cur_col = if (pane.file != null) @max(0, sl.c1 - @as(i32, PREFIX_W)) else sl.c1;
+ pane.cur_off = if (pane.runAt(pane.cur_row, pane.cur_col)) |i| pane.edits[i].len else 0;
+ pane.cur_pinned = true;
+ pane.mode = .normal;
+ pane.msel.active = false;
+ pane.pending = 0;
+ }
+ } else {
+ // acme execute (middle) / look (right): a no-drag click
+ // expands to the file-ish word first; a captured chord
+ // argument rides along and is consumed here.
+ p.expandSel(pane, &pane.sel[b]);
+ const txt = p.selectionText(pane, pane.sel[b]) catch null;
+ const arg = p.chord_arg;
+ p.chord_arg = null;
+ defer if (arg) |a| p.gpa.free(a);
+ if (txt) |tx| p.actOnSelection(s.button, s.id, tx, arg);
+ }
+ },
+ .none => {},
+ }
+ }
+
+ /// tty mode: encode the key the way a terminal would and send it to the pty.
+ fn forwardKey(p: *Pardes, id: usize, key: Key) void {
+ var cbuf: [1]u8 = undefined;
+ const bytes: ?[]const u8 = blk: {
+ if (key.ctrl) {
+ if (key.cp >= 'a' and key.cp <= 'z') {
+ cbuf[0] = @intCast(key.cp - 0x60);
+ break :blk cbuf[0..1];
+ }
+ if (key.cp == '@') {
+ cbuf[0] = 0;
+ break :blk cbuf[0..1];
+ }
+ if (key.cp >= '[' and key.cp <= '_') {
+ cbuf[0] = @intCast(key.cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ if (key.cp >= 'A' and key.cp <= 'Z') {
+ cbuf[0] = @intCast(key.cp - 0x40);
+ break :blk cbuf[0..1];
+ }
+ }
+ if (key.text.len > 0) break :blk key.text;
+ break :blk switch (key.cp) {
+ Key.enter => "\r",
+ Key.backspace => "\x7f",
+ Key.tab => "\t",
+ Key.escape => "\x1b",
+ Key.up => "\x1b[A",
+ Key.down => "\x1b[B",
+ Key.right => "\x1b[C",
+ Key.left => "\x1b[D",
+ else => null,
+ };
+ };
+ if (bytes) |b| p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(b) } });
+ }
+
+ // ---- layout surgery ----
+
+ fn layoutFindTerm(p: *Pardes, id: usize) ?struct { col: usize, idx: usize } {
+ for (0..p.ncol) |c| {
+ for (0..p.col_n[c]) |k| {
+ if (p.col_terms[c][k] == id) return .{ .col = c, .idx = k };
+ }
+ }
+ return null;
+ }
+
+ fn layoutInsert(p: *Pardes, c: usize, idx: usize, id: usize) void {
+ var k = p.col_n[c];
+ while (k > idx) : (k -= 1) p.col_terms[c][k] = p.col_terms[c][k - 1];
+ p.col_terms[c][idx] = id;
+ p.col_n[c] += 1;
+ }
+
+ /// Remove from the layout. An emptied column hands its width to a neighbor
+ /// (else every surviving column reflows sideways) before shifting down.
+ fn layoutRemove(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ const c = f.col;
+ var k = f.idx;
+ while (k + 1 < p.col_n[c]) : (k += 1) p.col_terms[c][k] = p.col_terms[c][k + 1];
+ p.col_n[c] -= 1;
+ if (p.col_n[c] == 0) {
+ if (p.ncol > 1) p.col_weight[if (c > 0) c - 1 else c + 1] += p.col_weight[c];
+ var j = c;
+ while (j + 1 < p.ncol) : (j += 1) {
+ p.col_terms[j] = p.col_terms[j + 1];
+ p.col_n[j] = p.col_n[j + 1];
+ p.col_weight[j] = p.col_weight[j + 1];
+ }
+ p.ncol -= 1;
+ }
+ }
+
+ fn layoutAppendColumn(p: *Pardes, id: usize) void {
+ if (p.ncol >= MAX_COLS) return;
+ const c = p.ncol;
+ p.col_weight[c] = 1;
+ p.col_terms[c][0] = id;
+ p.col_n[c] = 1;
+ p.ncol += 1;
+ }
+
+ /// Hand a dying pane's vertical weight to ONE sibling (the pane above, or
+ /// below for the topmost) so the rest of the column keeps its sizes — the
+ /// deletion mirror of splitBelow. Call while `id` is still in the layout.
+ fn absorbVWeight(p: *Pardes, id: usize) void {
+ const f = p.layoutFindTerm(id) orelse return;
+ if (p.col_n[f.col] <= 1) return;
+ const gone = p.panes[id] orelse return;
+ const sib = if (f.idx > 0) p.col_terms[f.col][f.idx - 1] else p.col_terms[f.col][f.idx + 1];
+ if (p.panes[sib]) |s| s.vweight += gone.vweight;
+ }
+
+ /// Open a window BELOW `src` (acme-style) without rebalancing the column:
+ /// shrink ONLY src to its content height (cursor row kept visible) and hand
+ /// the freed rows to `nw` — together they fill src's old slot.
+ fn splitBelow(p: *Pardes, src_id: usize, nw: *Pane) void {
+ const src = p.panes[src_id] orelse return;
+ const src_h = p.rects[src_id].h;
+ const body: u16 = if (src_h > BOX_H) src_h - BOX_H else 1;
+ const cur: u16 = if (src.file != null) body / 2 else @as(u16, @intCast(src.vt.screens.active.cursor.y)) + 1;
+ const keep = std.math.clamp(cur, 1, @max(1, body -| 1));
+ const w = src.vweight;
+ src.vweight = w * @as(f32, @floatFromInt(BOX_H + keep)) / @as(f32, @floatFromInt(src_h));
+ nw.vweight = w - src.vweight;
+ }
+
+ /// a shell nobody has touched: no typing, cursor on the first prompt line,
+ /// no scrollback — a throwaway placeholder a file may replace
+ fn isPristineTty(pane: *Pane) bool {
+ if (!pane.isTerminal()) return false;
+ if (pane.nedits != 0) return false;
+ if (pane.vt.screens.active.cursor.y != 0) return false;
+ return pane.vt.screens.active.pages.scrollbar().total <= pane.rows;
+ }
+
+ /// when a doc lands in `col`, a lone pristine shell there is clutter — drop
+ /// it; absorbVWeight hands its space to the doc
+ fn evictLonePristineTty(p: *Pardes, col: usize, keep_id: usize) void {
+ var n_tty: usize = 0;
+ var tty_id: usize = 0;
+ for (0..p.col_n[col]) |k| {
+ const cid = p.col_terms[col][k];
+ if (p.panes[cid]) |ct| if (ct.file == null) {
+ n_tty += 1;
+ tty_id = cid;
+ };
+ }
+ if (n_tty != 1) return;
+ const tt = p.panes[tty_id] orelse return;
+ if (!isPristineTty(tt)) return;
+ p.absorbVWeight(tty_id);
+ p.layoutRemove(tty_id);
+ p.deinitPane(tt);
+ p.panes[tty_id] = null;
+ if (p.active == tty_id) p.active = keep_id;
+ }
+
+ /// a terminal already in `dir`, else a fresh shell there at the bottom of
+ /// the rightmost column. Backs middle-click send from a file pane. Does NOT
+ /// focus (execute keeps you where you were; look focuses).
+ fn ttyForDir(p: *Pardes, dir: []const u8) ?usize {
+ for (p.panes, 0..) |slot, i| if (slot) |tt| {
+ if (tt.file != null or tt.image != null) continue;
+ if (std.mem.eql(u8, tt.cwdSlice(), dir)) return i;
+ };
+ const free = p.freeSlot() orelse return null;
+ const nt = p.newShell(free, dir) catch return null;
+ nt.greet = false;
+ const rc = if (p.ncol > 0) p.ncol - 1 else 0;
+ p.layoutInsert(rc, p.col_n[rc], free);
+ return free;
+ }
+
+ /// executing at a prompt with typed runs below the prompt: pad the output
+ /// area with newlines so the command's output doesn't overwrite the runs
+ fn padOutputBelowEdits(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ if (!pane.isTerminal() or pane.nedits == 0) return;
+ if (!pane.vt.cursorIsAtPrompt()) return;
+ var max_row: i32 = std.math.minInt(i32);
+ for (pane.edits[0..pane.nedits]) |e| {
+ if (e.len > 0 and e.row > max_row) max_row = e.row;
+ }
+ if (max_row == std.math.minInt(i32)) return;
+ const cursor_abs: i32 = pane.scroll() + @as(i32, @intCast(pane.vt.screens.active.cursor.y));
+ const pad = std.math.clamp(max_row - cursor_abs, 0, @as(i32, pane.rows));
+ var i: i32 = 0;
+ while (i < pad) : (i += 1) p.emitWrite(id, "\r");
+ }
+
+ // ---- doc panes ----
+
+ fn openFileView(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
+ const content = try look.readFile(p.gpa, path);
+ errdefer p.gpa.free(content);
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ const total = fileLineCount(content);
+ const scroll: usize = if (line > 0 and line <= total) line - 1 else 0;
+ pane.file = .{ .path = path_copy, .content = content, .scroll = scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(scroll);
+ return pane;
+ }
+
+ fn openTutorView(p: *Pardes, id: usize) !*Pane {
+ const content = try p.gpa.dupe(u8, tutor_text);
+ errdefer p.gpa.free(content);
+ const path = try p.gpa.dupe(u8, "/Tutor");
+ errdefer p.gpa.free(path);
+ const pane = try p.newDocPane(id);
+ pane.file = .{ .path = path, .content = content };
+ pane.cur_pinned = true;
+ return pane;
+ }
+
+ fn openImageView(p: *Pardes, id: usize, path: []const u8, petscii: bool) !*Pane {
+ const path_copy = try p.gpa.dupe(u8, path);
+ errdefer p.gpa.free(path_copy);
+ const pane = try p.newDocPane(id);
+ pane.image = .{ .path = path_copy, .petscii = petscii };
+ return pane;
+ }
+
+ /// stack a fresh doc pane at the top of the LEFT column (acme convention:
+ /// files left, terminals right) with a fair share, then evict a leftover
+ /// pristine shell
+ fn stackDocLeft(p: *Pardes, free: usize, nt: *Pane) void {
+ const lc = 0;
+ const ln = p.col_n[lc];
+ if (ln > 0) {
+ var vsum: f32 = 0;
+ for (0..ln) |i| if (p.panes[p.col_terms[lc][i]]) |pp| {
+ vsum += pp.vweight;
+ };
+ nt.vweight = vsum / @as(f32, @floatFromInt(ln));
+ }
+ p.layoutInsert(lc, 0, free);
+ p.active = free;
+ p.evictLonePristineTty(lc, free);
+ }
+
+ // ---- the ONE dispatcher: look (right/Enter) and execute (middle/Tab) ----
+
+ fn actOnSelection(p: *Pardes, button: Mouse.Button, id: usize, txt: []const u8, arg: ?[]const u8) void {
+ const pane = p.panes[id] orelse return;
+ // image tagline toggles (execute), before the builtin/run dispatch
+ if (button == .middle) if (pane.image) |*iv| {
+ const w = std.mem.trim(u8, txt, " \t\r\n");
+ if (std.mem.eql(u8, w, "Petscii")) {
+ iv.petscii = !iv.petscii;
+ return;
+ }
+ if (std.mem.eql(u8, w, "C64") or std.mem.eql(u8, w, "Term")) {
+ iv.pmode = if (iv.pmode == .commodore) .terminal else .commodore;
+ return;
+ }
+ if (std.mem.eql(u8, w, "Ascii")) {
+ iv.ascii = !iv.ascii;
+ return;
+ }
+ };
+ if (button == .right) {
+ var realbuf: [4096]u8 = undefined;
+ var cwdbuf: [1024]u8 = undefined;
+ const cwd: []const u8 = if (pane.file) |f|
+ (std.fs.path.dirname(f.path) orelse "/")
+ else blk: {
+ const c = pane.cwdSlice();
+ @memcpy(cwdbuf[0..c.len], c);
+ break :blk cwdbuf[0..c.len];
+ };
+ switch (look.resolve(txt, cwd, &realbuf)) {
+ .none => {},
+ .url => |u| p.emit(.{ .open_link = .from(u) }),
+ .dir => |dir| {
+ // focus an existing terminal on this dir, else fork one below
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.isTerminal() and std.mem.eql(u8, tt.cwdSlice(), dir)) {
+ p.active = i;
+ p.emitWrite(i, "ls\r");
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.newShell(free, dir) catch return;
+ nt.greet = true;
+ const f = p.layoutFindTerm(id).?;
+ p.layoutInsert(f.col, f.idx + 1, free);
+ p.splitBelow(id, nt);
+ p.active = free;
+ },
+ .file => |target| {
+ // focus an existing pane on this path (rescrolled), else open
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.file) |*ff| if (std.mem.eql(u8, ff.path, target.path)) {
+ p.active = i;
+ if (target.line > 0 and target.line <= fileLineCount(ff.content)) {
+ const next = target.line - 1;
+ if (next != ff.scroll) {
+ ff.scroll = next;
+ ff.syntax_dirty = true;
+ }
+ }
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ const nt = p.openFileView(free, target.path, target.line) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ .image => |target| {
+ for (p.panes, 0..) |slot, i| {
+ if (slot) |tt| if (tt.image) |iv| if (std.mem.eql(u8, iv.path, target.path)) {
+ p.active = i;
+ return;
+ };
+ }
+ const free = p.freeSlot() orelse return;
+ // petscii by default when the host has no kitty graphics
+ const nt = p.openImageView(free, target.path, !p.kitty_ok) catch return;
+ p.placeDoc(id, free, nt);
+ },
+ }
+ return;
+ }
+ switch (onSelect(button, txt)) {
+ .none => {},
+ .debug => p.show_debug = !p.show_debug,
+ .colors => p.colors_on = !p.colors_on,
+ .nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len,
+ .dump => p.dumpState() catch {},
+ .kill => {
+ p.quit = true;
+ p.emit(.quit);
+ },
+ .save => {
+ if (pane.file != null) p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ },
+ .send => {
+ // terminal: run in itself. file pane: run in a shell in the
+ // file's directory (found by cwd, else freshly forked).
+ const cmd = std.mem.trim(u8, txt, " \t\r\n");
+ const dst: ?usize = if (pane.file) |f|
+ p.ttyForDir(std.fs.path.dirname(f.path) orelse "/")
+ else
+ id;
+ if (dst) |di| {
+ p.padOutputBelowEdits(di);
+ p.emitWrite(di, cmd);
+ // a chord argument (kept left selection) rides as a
+ // trailing CLI argument
+ if (arg) |a| {
+ const at = std.mem.trim(u8, a, " \t\r\n");
+ if (at.len > 0) {
+ p.emitWrite(di, " ");
+ p.emitWrite(di, at);
+ }
+ }
+ p.emitWrite(di, "\r");
+ }
+ },
+ .newcol => {
+ const free = p.freeSlot() orelse return;
+ if (p.ncol >= MAX_COLS) return;
+ const nt = p.newShell(free, "") catch return;
+ nt.greet = true;
+ p.layoutAppendColumn(free);
+ p.active = free;
+ },
+ .del => {
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .delcol => {
+ const f = p.layoutFindTerm(id) orelse return;
+ var ids: [MAX_PANES]usize = undefined;
+ const nids = p.col_n[f.col];
+ for (0..nids) |k| ids[k] = p.col_terms[f.col][k];
+ for (ids[0..nids]) |tid| {
+ if (p.panes[tid]) |tt| {
+ p.layoutRemove(tid);
+ p.deinitPane(tt);
+ p.panes[tid] = null;
+ }
+ }
+ if (p.panes[p.active] == null) p.active = for (p.panes, 0..) |sl, i| {
+ if (sl != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ },
+ .tutor => {
+ const free = p.freeSlot() orelse return;
+ const nt = p.openTutorView(free) catch return;
+ p.stackDocLeft(free, nt);
+ },
+ }
+ }
+
+ /// place a fresh doc pane: split an existing doc (the source if it's one,
+ /// else the first found), or take the left column when no doc exists yet
+ fn placeDoc(p: *Pardes, from_id: usize, free: usize, nt: *Pane) void {
+ var src_id: ?usize = null;
+ if (p.panes[from_id]) |src| if (src.file != null or src.image != null) {
+ src_id = from_id;
+ };
+ if (src_id == null) for (p.panes, 0..) |sl, i| {
+ if (sl) |pp| if ((pp.file != null or pp.image != null) and i != free) {
+ src_id = i;
+ break;
+ };
+ };
+ if (src_id) |sid| {
+ const sf = p.layoutFindTerm(sid).?;
+ p.layoutInsert(sf.col, sf.idx + 1, free);
+ p.splitBelow(sid, nt);
+ p.active = free;
+ } else {
+ p.stackDocLeft(free, nt);
+ }
+ }
+
+ // ---- dump / load (acme-style: `pardes -l state.zon` restores a session) ----
+
+ fn dumpState(p: *Pardes) !void {
+ const arena = p.scratch.allocator();
+ var slot_to_pane: [MAX_PANES]?usize = @splat(null);
+ var panes: std.ArrayList(dump.Pane) = .empty;
+ for (p.panes, 0..) |slot, id| {
+ const pane = slot orelse continue;
+ slot_to_pane[id] = panes.items.len;
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ const body = try p.bodyText(arena, pane);
+ const scroll: usize = @intCast(@max(0, pane.scroll()));
+ const dp: dump.Pane = if (pane.file) |f| .{
+ .kind = .file,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .file = .{
+ .path = f.path,
+ .content = f.content,
+ .content_b64 = try dump.encodeBytes(arena, f.content),
+ },
+ } else if (pane.image) |iv| blk: {
+ const bytes = look.readFile(arena, iv.path) catch "";
+ break :blk .{
+ .kind = .image,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .image = .{
+ .path = iv.path,
+ .bytes_b64 = if (bytes.len > 0) try dump.encodeBytes(arena, bytes) else "",
+ },
+ };
+ } else blk: {
+ // the stream text: whole history, prompts hidden, runs spliced
+ const full = try pane.vt.screens.active.dumpStringAlloc(arena, .{ .screen = .{} });
+ var stream_text: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitAny(u8, full, "\n");
+ var prompts = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .screen = .{} }, null);
+ var row: usize = 0;
+ while (lines.next()) |raw| : (row += 1) {
+ if (row > 0) try stream_text.append(arena, '\n');
+ const is_prompt = if (pane.mode != .tty)
+ if (prompts.next()) |pin| pin.rowAndCell().row.semantic_prompt != .none else false
+ else
+ false;
+ const line = if (is_prompt) "" else raw;
+ if (pane.mode == .tty) {
+ try stream_text.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &stream_text, pane, @intCast(row), line);
+ }
+ }
+ break :blk .{
+ .kind = .terminal,
+ .tag = tag,
+ .body = body,
+ .scroll = scroll,
+ .cols = pane.cols,
+ .rows = pane.rows,
+ .vweight = pane.vweight,
+ .terminal = .{
+ .cwd = try arena.dupe(u8, pane.cwdSlice()),
+ .stream = stream_text.items,
+ .stream_b64 = try dump.encodeBytes(arena, pane.tty_stream.items),
+ .cursor = .{
+ .col = pane.vt.screens.active.cursor.x,
+ .row = pane.vt.screens.active.cursor.y,
+ },
+ },
+ };
+ };
+ try panes.append(arena, dp);
+ }
+
+ var columns: std.ArrayList(dump.Column) = .empty;
+ for (0..p.ncol) |c| {
+ var ids: std.ArrayList(usize) = .empty;
+ for (0..p.col_n[c]) |k| {
+ if (slot_to_pane[p.col_terms[c][k]]) |compact| try ids.append(arena, compact);
+ }
+ if (ids.items.len > 0)
+ try columns.append(arena, .{ .weight = p.col_weight[c], .panes = ids.items });
+ }
+
+ const state: dump.State = .{
+ .screen = .{ .cols = p.screen_w, .rows = p.screen_h },
+ .active = slot_to_pane[p.active] orelse 0,
+ .topbar = topbar_str,
+ .theme = p.theme().name,
+ .columns = columns.items,
+ .panes = panes.items,
+ };
+ try dump.validate(state);
+ var out: std.Io.Writer.Allocating = .init(p.gpa);
+ errdefer out.deinit();
+ try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer);
+ if (p.dump_out) |d| p.gpa.free(d);
+ p.dump_out = try out.toOwnedSlice();
+ p.emit(.write_dump);
+ }
+
+ /// Initialize from another instance's dump: panes reconstructed (terminals
+ /// by replaying their raw VT streams into fresh emulators), no spawns —
+ /// loaded terminals are dead replays, scrollable and selectable.
+ pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes {
+ const p = try gpa.create(Pardes);
+ p.* = .{ .gpa = gpa, .opts = opts, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa) };
+ errdefer p.deinit();
+ const st = try dump.readZon(gpa, zon_bytes, "load");
+ defer dump.free(gpa, st);
+
+ for (themes, 0..) |t, i| {
+ if (std.mem.eql(u8, t.name, st.theme)) p.theme_idx = i;
+ }
+ for (st.panes, 0..) |src, i| {
+ if (i >= MAX_PANES) break;
+ switch (src.kind) {
+ .terminal => {
+ const t = src.terminal.?;
+ const pane = try gpa.create(Pane);
+ pane.* = .{
+ .vt = try ghostty_vt.Terminal.init(gpa, .{
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ .max_scrollback = 16 * 1024 * 1024,
+ }),
+ .stream = undefined,
+ .cols = @max(1, src.cols),
+ .rows = @max(1, src.rows),
+ };
+ pane.stream = pane.vt.vtStream();
+ pane.stream.handler.effects.write_pty = ptyReport;
+ pane.stream.handler.effects.device_attributes = ptyDeviceAttrs;
+ p.panes[i] = pane;
+ if (t.stream_b64.len > 0) {
+ const bytes = try dump.decodeBytes(p.scratch.allocator(), t.stream_b64);
+ pane.tty_stream.appendSlice(gpa, bytes) catch {};
+ pane.stream.nextSlice(bytes);
+ pane.vt.screens.active.scroll(.active);
+ if (src.scroll > 0) pane.vt.screens.active.scroll(.{ .delta_row = -@as(isize, @intCast(src.scroll)) });
+ }
+ p.setCwd(i, t.cwd);
+ if (std.mem.startsWith(u8, src.tag, "sy ")) pane.mode = .tty;
+ p.restoreTail(pane, src.tag);
+ },
+ .file => {
+ const f = src.file.?;
+ const content: []u8 = if (f.content_b64.len > 0)
+ try dump.decodeBytes(gpa, f.content_b64)
+ else
+ try gpa.dupe(u8, f.content);
+ errdefer gpa.free(content);
+ const path = try gpa.dupe(u8, f.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
+ pane.file = .{ .path = path, .content = content, .scroll = src.scroll };
+ pane.cur_pinned = true;
+ pane.cur_row = @intCast(src.scroll);
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ p.restoreTail(pane, src.tag);
+ },
+ .image => {
+ const im = src.image.?;
+ const path = try gpa.dupe(u8, im.path);
+ errdefer gpa.free(path);
+ const pane = try p.newDocPane(i);
+ const raw: []u8 = if (im.bytes_b64.len > 0)
+ dump.decodeBytes(gpa, im.bytes_b64) catch &.{}
+ else
+ &.{};
+ pane.image = .{ .path = path, .petscii = true, .raw = raw };
+ pane.cols = @max(1, src.cols);
+ pane.rows = @max(1, src.rows);
+ },
+ }
+ p.panes[i].?.vweight = src.vweight;
+ }
+ p.ncol = @min(st.columns.len, MAX_COLS);
+ for (st.columns[0..p.ncol], 0..) |col, c| {
+ p.col_weight[c] = col.weight;
+ p.col_n[c] = @min(col.panes.len, MAX_PANES);
+ for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_terms[c][k] = pid;
+ }
+ p.active = @min(st.active, MAX_PANES - 1);
+ p.sync();
+ return p;
+ }
+
+ /// restore a custom tag tail from a saved tag: whatever follows the live
+ /// prefix, unless it's just the defaults
+ fn restoreTail(p: *Pardes, pane: *Pane, saved_tag: []const u8) void {
+ const pfx = p.tagPrefix(pane) catch return;
+ if (!std.mem.startsWith(u8, saved_tag, pfx)) return;
+ const rest = saved_tag[pfx.len..];
+ const default_tail: []const u8 = if (pane.file != null) file_pane_tail else pane_tail;
+ if (std.mem.eql(u8, rest, default_tail)) return;
+ pane.tag_tail.appendSlice(p.gpa, rest) catch return;
+ pane.tag_init = true;
+ }
+
+ fn removePane(p: *Pardes, id: usize) void {
+ const pane = p.panes[id] orelse return;
+ p.absorbVWeight(id);
+ p.layoutRemove(id);
+ p.deinitPane(pane);
+ p.panes[id] = null;
+ if (p.active == id) p.active = for (p.panes, 0..) |slot, i| {
+ if (slot != null) break i;
+ } else {
+ p.quit = true;
+ p.emit(.quit);
+ return;
+ };
+ }
+
+ /// Recompute geometry, push grid-size changes to each emulator + pty, fire
+ /// deferred greetings. The mirror of the prototype's loop epilogue.
+ fn sync(p: *Pardes) void {
+ p.computeGeom();
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ if (pane.reply_len > 0) {
+ var off: u16 = 0;
+ while (off < pane.reply_len) {
+ const n = @min(pane.reply_len - off, 64);
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from(pane.reply[off .. off + n]) } });
+ off += n;
+ }
+ pane.reply_len = 0;
+ }
+ const r = p.rects[id];
+ const cols = @max(1, r.w -| GUTTER);
+ const rows = @max(1, r.h -| BOX_H); // the tag steals the top row
+ if (cols != pane.cols or rows != pane.rows) {
+ // doc panes have no pty/emulator grid to reflow; just record
+ // the size so bodyText renders the right number of rows
+ if (pane.isTerminal()) {
+ pane.vt.resize(p.gpa, cols, rows) catch {};
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = cols, .rows = rows } });
+ }
+ pane.cols = cols;
+ pane.rows = rows;
+ }
+ // greet a fresh shell with `ls` once it has its real size (after
+ // the first winsize) AND has drawn its first prompt (the pty has
+ // produced output — bash sourced the rc and readline owns echo, so
+ // the greeting echoes on an OSC 133-marked row and hides cleanly;
+ // earlier, the kernel would echo it onto an unmarked row).
+ if (pane.greet and pane.isTerminal() and p.resize_count > 0 and pane.tty_stream.items.len > 0) {
+ p.emit(.{ .resize_pty = .{ .pane = @intCast(id), .cols = pane.cols, .rows = pane.rows } });
+ p.emit(.{ .write = .{ .pane = @intCast(id), .bytes = .from("ls\r") } });
+ pane.greet = false;
+ }
+ }
+ }
+
+ fn computeGeom(p: *Pardes) void {
+ if (p.ncol == 0) return;
+ var wsum: f32 = 0;
+ for (0..p.ncol) |c| wsum += p.col_weight[c];
+ if (wsum <= 0) wsum = 1;
+
+ var x: u16 = 0;
+ for (0..p.ncol) |c| {
+ const last = c + 1 == p.ncol;
+ const fw = @as(f32, @floatFromInt(p.screen_w)) * p.col_weight[c] / wsum;
+ const cw: u16 = if (last) (p.screen_w -| x) else @max(1, @as(u16, @intFromFloat(@round(fw))));
+ p.col_x[c] = x;
+ p.col_w[c] = cw;
+
+ var vsum: f32 = 0;
+ for (0..p.col_n[c]) |k| {
+ if (p.panes[p.col_terms[c][k]]) |pane| vsum += pane.vweight;
+ }
+ if (vsum <= 0) vsum = 1;
+
+ var y: u16 = TOPBAR_H;
+ const avail_h = p.screen_h -| TOPBAR_H;
+ for (0..p.col_n[c]) |k| {
+ const id = p.col_terms[c][k];
+ const pane = p.panes[id] orelse continue;
+ const lastk = k + 1 == p.col_n[c];
+ const fh = @as(f32, @floatFromInt(avail_h)) * pane.vweight / vsum;
+ const ch: u16 = if (lastk) (p.screen_h -| y) else @max(1, @as(u16, @intFromFloat(@round(fh))));
+ p.rects[id] = .{ .x = x, .y = y, .w = cw, .h = ch };
+ y +|= ch;
+ }
+ x +|= cw;
+ }
+ }
+
+ fn theme(p: *const Pardes) *const Theme {
+ return &themes[p.theme_idx];
+ }
+
+ // ---- render: build the canonical surface ----
+
+ /// re-highlight the visible window of any file whose syntax went stale
+ /// (edit, scroll, load) — visible-range-first so big files stay snappy
+ fn refreshDirtyHighlights(p: *Pardes) void {
+ for (p.panes) |slot| {
+ const pane = slot orelse continue;
+ if (pane.file == null) continue;
+ const f = &pane.file.?;
+ if (!f.syntax_dirty) continue;
+ if (!p.colors_on) {
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
+ f.syntax_dirty = false;
+ continue;
+ }
+ const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
+ const start = modal.lineStartOffset(f.content, start_row);
+ const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS;
+ const end = @max(start, modal.lineStartOffset(f.content, end_row));
+ const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch {
+ f.syntax_dirty = false;
+ continue;
+ };
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = new_highlights;
+ f.highlight_start = if (f.highlights.len > 0) start else 0;
+ f.syntax_dirty = false;
+ }
+ }
+
+ pub fn render(p: *Pardes, arena: std.mem.Allocator) !*Surface {
+ p.refreshDirtyHighlights();
+ const s = &p.surface;
+ const ncells = @as(usize, p.screen_w) * p.screen_h;
+ if (s.cells.len != ncells) {
+ p.gpa.free(s.cells);
+ s.cells = try p.gpa.alloc(Cell, ncells);
+ }
+ s.cols = p.screen_w;
+ s.rows = p.screen_h;
+ s.cursor = null;
+ s.images = @splat(null);
+ s.nimages = 0;
+ const th = p.theme();
+ // gaps between panes read as chrome (scrollbar track), not raw default
+ s.fill(0, 0, s.cols, s.rows, .{ .bg = .{ .rgb = th.scroll_track } });
+
+ for (&p.panes, 0..) |*slot, id| {
+ const pane = slot.* orelse continue;
+ try p.renderPane(arena, pane, p.rects[id], id == p.active);
+ }
+
+ // global tagbar: full width, top row
+ s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ _ = s.print(0, 0, s.cols, topbar_str, .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
+
+ // resize-handle hint / drag previews: a dash overlay that keeps the
+ // underlying colors (border drags + hover), or the move indicator
+ switch (p.drag) {
+ .border_v => |d| {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(d.cur_x, row, "╎");
+ },
+ .border_h => |d| {
+ var col = p.col_x[d.col];
+ while (col < p.col_x[d.col] + p.col_w[d.col]) : (col += 1) s.overlayDash(col, d.cur_y, "╌");
+ },
+ .move => |d| {
+ if (p.movePlacement(d.id, d.cur_x, d.cur_y)) |placement| {
+ var col: u16 = p.col_x[placement.preview_col];
+ while (col < p.col_x[placement.preview_col] + p.col_w[placement.preview_col]) : (col += 1) {
+ s.set(col, placement.row, "╌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ s.set(d.cur_x, placement.row, "▌", .{ .fg = .{ .rgb = gray.mid } });
+ }
+ },
+ .none, .select => {
+ // hover hints on a pane's own trailing edge
+ var c: usize = 0;
+ while (c + 1 < p.ncol) : (c += 1) {
+ if (p.hover_col == p.col_x[c] + p.col_w[c] -| 1) {
+ var row: u16 = TOPBAR_H;
+ while (row < s.rows) : (row += 1) s.overlayDash(p.hover_col, row, "╎");
+ }
+ }
+ for (0..p.ncol) |cc| {
+ if (p.hover_col < p.col_x[cc] or p.hover_col >= p.col_x[cc] + p.col_w[cc]) continue;
+ for (0..p.col_n[cc] -| 1) |k| {
+ const r = p.rects[p.col_terms[cc][k]];
+ if (p.hover_row == r.y + r.h -| 1) {
+ var col = p.col_x[cc];
+ while (col < p.col_x[cc] + p.col_w[cc]) : (col += 1) s.overlayDash(col, p.hover_row, "╌");
+ }
+ }
+ }
+ },
+ }
+
+ // debug overlay: a small stats box drawn last, top-right ("Debug")
+ if (p.show_debug) if (p.panes[p.active]) |at| {
+ var nlive: usize = 0;
+ for (p.panes) |slot| {
+ if (slot != null) nlive += 1;
+ }
+ var sb_off: usize = undefined;
+ var sb_total: usize = undefined;
+ if (at.file) |f| {
+ sb_off = f.scroll;
+ sb_total = fileLineCount(f.content);
+ } else {
+ const sb = at.vt.screens.active.pages.scrollbar();
+ sb_off = sb.offset;
+ sb_total = sb.total;
+ }
+ var ov_buf: [192]u8 = undefined;
+ const text = std.fmt.bufPrint(
+ &ov_buf,
+ "pardes resizes={d}\nterms {d} cols {d}\nactive #{d} {d}x{d}\nscroll {d}/{d}\npinch {d:6.4}\ntscroll {d:7.4}",
+ .{ p.resize_count, nlive, p.ncol, p.active, at.cols, at.rows, sb_off, sb_total, p.ov_pinch_scale, p.ov_touch_scroll_delta },
+ ) catch "";
+ const ow: u16 = 30;
+ const oh: u16 = 8;
+ const ox: u16 = if (s.cols > ow) s.cols - ow else 0;
+ if (ox + ow <= s.cols and TOPBAR_H + oh <= s.rows) {
+ // vaxis single_rounded border ring, default style
+ s.set(ox, TOPBAR_H, "╭", .{});
+ s.set(ox + ow - 1, TOPBAR_H, "╮", .{});
+ s.set(ox, TOPBAR_H + oh - 1, "╰", .{});
+ s.set(ox + ow - 1, TOPBAR_H + oh - 1, "╯", .{});
+ var bx = ox + 1;
+ while (bx < ox + ow - 1) : (bx += 1) {
+ s.set(bx, TOPBAR_H, "─", .{});
+ s.set(bx, TOPBAR_H + oh - 1, "─", .{});
+ }
+ var by = TOPBAR_H + 1;
+ while (by < TOPBAR_H + oh - 1) : (by += 1) {
+ s.set(ox, by, "│", .{});
+ s.set(ox + ow - 1, by, "│", .{});
+ }
+ s.fill(ox + 1, TOPBAR_H + 1, ow - 2, oh - 2, .{ .bg = .{ .rgb = gray.bar } });
+ var lines = std.mem.splitScalar(u8, text, '\n');
+ var ly: u16 = TOPBAR_H + 1;
+ while (lines.next()) |line| : (ly += 1) {
+ if (ly >= TOPBAR_H + oh - 1) break;
+ _ = s.print(ox + 1, ly, ow - 2, line, .{ .fg = .{ .rgb = gray.txt } });
+ }
+ }
+ };
+ return s;
+ }
+
+ fn renderPane(p: *Pardes, arena: std.mem.Allocator, pane: *Pane, r: Rect, active: bool) !void {
+ if (r.w <= GUTTER or r.h == 0) return;
+ const s = &p.surface;
+ const th = p.theme();
+ const tx = r.x + GUTTER; // text area (tag + body), right of the gutter
+ const tw = r.w - GUTTER;
+
+ // text area resets to terminal-default cells (vaxis clear semantics);
+ // light themes paint the page over it.
+ s.clearRect(tx, r.y, tw, r.h);
+ if (th.bg) |bg| s.fill(tx, r.y, tw, r.h, .{ .bg = .{ .rgb = bg } });
+
+ // tag: live prefix (mode + cwd/path) + editable tail, on a dim bar
+ const tag_style: CellStyle = .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } };
+ s.fill(tx, r.y, tw, BOX_H, .{ .bg = .{ .rgb = th.tag_bg } });
+ const pfx = try p.tagPrefix(pane);
+ const tag = try std.fmt.allocPrint(arena, "{s}{s}", .{ pfx, curTail(pane) });
+ _ = s.print(tx, r.y, tw, tag, tag_style);
+ // tag-tail char selection highlight (helix v/x), inclusive [lo, hi]
+ if (pane.tag_edit and pane.tag_sel) {
+ const b = tagSelBounds(pane);
+ var col: usize = pfx.len + b.lo;
+ const end: usize = pfx.len + b.hi;
+ while (col <= end and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ // cursor while editing the tail: prefix length + column within it
+ if (active and pane.tag_edit) {
+ const cx: usize = pfx.len + pane.tag_col;
+ if (cx < tw) s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y };
+ }
+
+ // image pane: the picture fills the body — petscii glyph art into the
+ // cells, or a pixel attachment the shell places (kitty). Plain
+ // thumbless gutter so it reads like any other pane.
+ if (pane.image != null) {
+ p.drawImage(pane, r, tx, tw);
+ const box_bg2 = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg2 } });
+ if (r.h > BOX_H) s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ return;
+ }
+
+ // body: emulator rows, prompt rows hidden outside tty mode
+ const body_h = if (r.h > BOX_H) r.h - BOX_H else 0;
+ const body_style: CellStyle = .{
+ .fg = if (th.fg) |c| .{ .rgb = c } else .default,
+ .bg = if (th.bg) |c| .{ .rgb = c } else .default,
+ };
+ const body = try p.bodyText(arena, pane);
+ var it = std.mem.splitScalar(u8, body, '\n');
+ var i: u16 = 0;
+ while (it.next()) |line| : (i += 1) {
+ if (i >= body_h) break;
+ _ = s.print(tx, r.y + BOX_H + i, tw, line, body_style);
+ }
+
+ // file line-number gutter: mute the first PREFIX_W columns. Cheap
+ // chrome, not gated on colors_on; selection/cursor passes still win.
+ if (pane.file != null) {
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < PREFIX_W and c < tw) : (c += 1) {
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ cell.default = false; // paints blank gutter rows too
+ cell.style.fg = .{ .rgb = th.lineno };
+ }
+ }
+ }
+ // syntax colors (file panes): recolor each content cell from its
+ // tree-sitter style byte; content starts after the lineno gutter
+ if (p.colors_on) if (pane.file) |*f| {
+ if (f.highlights.len > 0) {
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skip: usize = 0;
+ while (skip < f.scroll) : (skip += 1) {
+ if (flines.next() == null) break;
+ }
+ var vr: u16 = 0;
+ while (vr < body_h) : (vr += 1) {
+ const line = flines.next() orelse break;
+ const base = @intFromPtr(line.ptr) - @intFromPtr(f.content.ptr);
+ var c: usize = 0;
+ while (c < line.len and PREFIX_W + c < tw) : (c += 1) {
+ const idx = base + c;
+ if (idx < f.highlight_start) continue;
+ const hidx = idx - f.highlight_start;
+ if (hidx >= f.highlights.len) continue;
+ const ss = p.synStyle(@enumFromInt(f.highlights[hidx])) orelse continue;
+ const cell = s.at(tx + @as(u16, @intCast(PREFIX_W + c)), r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style.fg = .{ .rgb = ss.fg };
+ cell.style.bold = ss.bold;
+ }
+ }
+ }
+ };
+ // tty colors: recolor each body cell from the emulator's own style so
+ // shell output keeps its ansi colors (gated on the Colors toggle).
+ if (p.colors_on and pane.isTerminal()) {
+ var vr: u16 = 0;
+ while (vr < body_h and vr < pane.rows) : (vr += 1) {
+ var c: u16 = 0;
+ while (c < tw) : (c += 1) {
+ const ci = pane.vt.screens.active.pages.getCell(.{ .viewport = .{ .x = @intCast(c), .y = @intCast(vr) } }) orelse continue;
+ if (ci.cell.wide == .spacer_tail) continue;
+ const cell = s.at(tx + c, r.y + BOX_H + vr);
+ if (cell.default) continue;
+ cell.style = p.cellStyle(ci);
+ }
+ }
+ }
+
+ // repaint inserted cells with the plain body style so typed text reads
+ // exactly like normal text: the tty recolor above painted cell (c,vr)
+ // from emulator column c, but runs shifted the row right.
+ const off = pane.scroll();
+ if (pane.mode != .tty) for (pane.edits[0..pane.nedits]) |*e| {
+ if (e.len == 0) continue;
+ const prow = e.row - off + @as(i32, BOX_H);
+ if (prow < BOX_H or prow >= r.h) continue;
+ const start = pane.renderedCol(e.row, e.col, 0);
+ var k: i32 = 0;
+ while (k < e.len) : (k += 1) {
+ const cx = start + k;
+ if (cx < 0 or cx >= tw) continue;
+ const cell = s.at(tx + @as(u16, @intCast(cx)), r.y + @as(u16, @intCast(prow)));
+ if (!cell.default) cell.style = body_style;
+ }
+ };
+
+ // mouse selections (pane-local coords), one pass per button — later
+ // buttons win on overlap. A left .done stays highlighted after release;
+ // middle/right .done are transient (they fire their action on release).
+ for (pane.sel, 0..) |sl, b| {
+ if (sl.state == .none) continue;
+ if (sl.state == .done and b != 0) continue;
+ const r0 = @min(sl.r0, sl.r1);
+ const r1 = @max(sl.r0, sl.r1);
+ const c0 = @max(0, @min(sl.c0, sl.c1));
+ const c1 = @max(0, @max(sl.c0, sl.c1));
+ var row: u16 = 0;
+ while (row < r.h) : (row += 1) {
+ if (@as(i32, row) < r0 or @as(i32, row) > r1) continue;
+ var col: i32 = if (pane.file != null) @max(c0, @as(i32, PREFIX_W)) else c0;
+ while (col <= c1 and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = SEL_BG[b] };
+ cell.style.fg = .{ .rgb = sel_fg };
+ }
+ }
+ }
+ // modal line selection (helix `x`): whole body rows, normal mode only
+ if (pane.mode == .normal and pane.msel.active) {
+ const lo = @min(pane.msel.r0, pane.msel.r1) - off + @as(i32, BOX_H);
+ const hi = @max(pane.msel.r0, pane.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 = if (pane.file != null) PREFIX_W else 0;
+ while (col < tw) : (col += 1) {
+ const cell = s.at(tx + col, r.y + row);
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+ // modal char selection (helix `v`): stream-shaped anchor..head highlight
+ if (pane.mode == .normal and pane.vsel.active) {
+ const bnd = vselBounds(pane);
+ const vpfx: i32 = if (pane.file != null) PREFIX_W else 0;
+ var ar: i32 = bnd.lo_row;
+ while (ar <= bnd.hi_row) : (ar += 1) {
+ const prow = ar - off + @as(i32, BOX_H);
+ if (prow < 0 or prow >= @as(i32, r.h)) continue;
+ const cstart: i32 = if (ar == bnd.lo_row) bnd.lo_col + vpfx else vpfx;
+ const cend: i32 = if (ar == bnd.hi_row) bnd.hi_col + vpfx else @as(i32, tw) - 1;
+ var col: i32 = @max(cstart, vpfx);
+ while (col <= cend and col < tw) : (col += 1) {
+ const cell = s.at(tx + @as(u16, @intCast(col)), r.y + @as(u16, @intCast(prow)));
+ cell.default = false;
+ cell.style.bg = .{ .rgb = msel_bg };
+ cell.style.fg = .{ .rgb = msel_fg };
+ }
+ }
+ }
+
+ // cursor: tracks the shell cursor until pinned by a click or a key
+ // (the tag cursor above wins while the tag is focused)
+ if (active and !pane.tag_edit) {
+ const cur = pane.vt.screens.active.cursor;
+ if (pane.mode != .tty) {
+ const crow = if (pane.cur_pinned) pane.cur_row else @as(i32, @intCast(cur.y)) + off;
+ const ccol = if (pane.cur_pinned) pane.cur_col else @as(i32, @intCast(cur.x));
+ const coff = if (pane.cur_pinned) pane.cur_off else 0;
+ const prow = crow - off + @as(i32, BOX_H);
+ // files: cur_col is content-relative, add the lineno prefix;
+ // insert mode maps the run anchor to its visual column; normal
+ // mode keeps cur_col already-visual.
+ const cx = if (pane.file != null)
+ @as(i32, PREFIX_W) + ccol + @as(i32, coff)
+ else if (pane.mode == .insert)
+ pane.renderedCol(crow, ccol, coff)
+ else
+ ccol;
+ if (prow >= 0 and cx >= 0 and prow < r.h and cx < tw)
+ s.cursor = .{ .x = tx + @as(u16, @intCast(cx)), .y = r.y + @as(u16, @intCast(prow)) };
+ } else if (cur.y + BOX_H < r.h and cur.x < tw) {
+ s.cursor = .{ .x = tx + cur.x, .y = r.y + cur.y + BOX_H };
+ }
+ }
+
+ // gutter: move box on top, scrollbar track + thumb below
+ const box_bg = if (active) accent else accent_dim;
+ s.fill(r.x, r.y, GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
+ if (r.h > BOX_H) {
+ s.fill(r.x, r.y + BOX_H, GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{
+ .total = fileLineCount(f.content),
+ .offset = f.scroll,
+ .len = pane.rows,
+ } else blk: {
+ const gsb = pane.vt.screens.active.pages.scrollbar();
+ break :blk .{ .total = gsb.total, .offset = gsb.offset, .len = gsb.len };
+ };
+ const track_h: usize = r.h - BOX_H;
+ const total = if (sb.total == 0) 1 else sb.total;
+ const len = @max(1, (track_h * @max(1, sb.len)) / total);
+ const pos = (track_h * sb.offset) / total;
+ var sy: usize = pos;
+ while (sy < track_h and sy < pos + len) : (sy += 1) {
+ s.fill(r.x, r.y + BOX_H + @as(u16, @intCast(sy)), GUTTER, 1, .{ .bg = .{ .rgb = th.scroll_thumb } });
+ }
+ }
+ }
+
+ /// Image pane body: decode once (path via look, or dump-loaded raw bytes),
+ /// rebuild the petscii grid when the size/toggles changed, then either
+ /// blit the glyph art into cells or attach the pixels for the shell.
+ fn drawImage(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) void {
+ const iv = &pane.image.?;
+ const s = &p.surface;
+ if (!iv.tried) {
+ iv.tried = true;
+ const bytes: []const u8 = if (iv.raw.len > 0) iv.raw else (look.readFile(p.scratch.allocator(), iv.path) catch "");
+ if (image.decode(p.gpa, bytes)) |d| {
+ iv.rgba = d.rgba;
+ iv.iw = d.w;
+ iv.ih = d.h;
+ }
+ }
+ if (iv.rgba.len == 0 or r.h <= BOX_H) return;
+ const body_cols = tw;
+ const body_rows = r.h - BOX_H;
+ const use_petscii = iv.petscii or !p.kitty_ok;
+ if (!use_petscii) {
+ s.images[s.nimages] = .{
+ .pane = @intCast(for (p.panes, 0..) |slot, i| {
+ if (slot == pane) break i;
+ } else 0),
+ .x = tx,
+ .y = r.y + BOX_H,
+ .w = body_cols,
+ .h = body_rows,
+ .rgba = iv.rgba,
+ .iw = iv.iw,
+ .ih = iv.ih,
+ };
+ s.nimages += 1;
+ return;
+ }
+ // (re)build the cached glyph grid if the size or toggles changed
+ if (iv.grid.len == 0 or iv.grid_w != body_cols or iv.grid_h != body_rows or
+ iv.grid_mode != iv.pmode or iv.grid_ascii != iv.ascii)
+ {
+ if (iv.grid.len > 0) p.gpa.free(iv.grid);
+ const pal = switch (iv.pmode) {
+ .commodore => image.petscii.commodore,
+ .terminal => image.ansiPalette(),
+ };
+ const g = image.petscii.render(p.gpa, iv.rgba, iv.iw, iv.ih, body_cols, body_rows, pal, iv.ascii) catch image.petscii.Grid{ .cells = &.{}, .gw = 0, .gh = 0 };
+ iv.grid = g.cells;
+ iv.gw = g.gw;
+ iv.gh = g.gh;
+ iv.grid_w = body_cols;
+ iv.grid_h = body_rows;
+ iv.grid_mode = iv.pmode;
+ iv.grid_ascii = iv.ascii;
+ }
+ if (iv.grid.len == 0) return;
+ const offx = if (body_cols > iv.gw) (@as(usize, body_cols) - iv.gw) / 2 else 0;
+ const offy = if (body_rows > iv.gh) (@as(usize, body_rows) - iv.gh) / 2 else 0;
+ var cy: usize = 0;
+ while (cy < iv.gh) : (cy += 1) {
+ var cx: usize = 0;
+ while (cx < iv.gw) : (cx += 1) {
+ const c = &iv.grid[cy * iv.gw + cx];
+ const fg: Color = switch (iv.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[c.fg] },
+ .terminal => .{ .index = c.fg },
+ };
+ const bg: Color = switch (iv.pmode) {
+ .commodore => .{ .rgb = image.petscii.commodore[c.bg] },
+ .terminal => .{ .index = c.bg },
+ };
+ const sx = tx + @as(u16, @intCast(offx + cx));
+ const sy = r.y + BOX_H + @as(u16, @intCast(offy + cy));
+ if (sx < s.cols and sy < s.rows)
+ s.set(sx, sy, c.glyph[0..c.glen], .{ .fg = fg, .bg = bg });
+ }
+ }
+ }
+
+ /// The pane body as text. Image: blank rows (the picture draws over it).
+ /// File: line-numbered content from f.scroll, edits spliced like the tty.
+ /// Terminal: viewport rows, padded to the grid height, prompt rows blanked
+ /// outside tty mode (OSC 133), typed insertion runs spliced in.
+ fn bodyText(p: *Pardes, arena: std.mem.Allocator, pane: *Pane) ![]const u8 {
+ _ = p;
+ if (pane.image != null) {
+ const buf = try arena.alloc(u8, pane.rows -| 1);
+ @memset(buf, '\n');
+ return buf;
+ }
+ if (pane.file) |f| {
+ var out: std.ArrayList(u8) = .empty;
+ var flines = std.mem.splitScalar(u8, f.content, '\n');
+ var skipped: usize = 0;
+ while (skipped < f.scroll) : (skipped += 1) {
+ if (flines.next() == null) break;
+ }
+ var i: usize = 0;
+ while (i < pane.rows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const abs: i32 = @as(i32, @intCast(f.scroll)) + @as(i32, @intCast(i));
+ if (flines.next()) |text| {
+ var lbuf: [16]u8 = undefined;
+ // unsigned: {d} prints a leading '+' for signed ints
+ const lineno: usize = @intCast(abs + 1);
+ const prefix = std.fmt.bufPrint(&lbuf, "{d: >4} ", .{lineno}) catch " ";
+ var base: std.ArrayList(u8) = .empty;
+ try base.appendSlice(arena, prefix);
+ try base.appendSlice(arena, text);
+ try spliceRow(arena, &out, pane, abs, base.items);
+ } else try spliceRow(arena, &out, pane, abs, "");
+ }
+ return out.items;
+ }
+ const raw = try pane.vt.plainString(arena);
+ var flags: [512]bool = @splat(false);
+ const nrows: usize = @min(pane.rows, flags.len);
+ if (pane.mode != .tty) {
+ var it = pane.vt.screens.active.pages.rowIterator(.right_down, .{ .viewport = .{} }, null);
+ var i: usize = 0;
+ while (it.next()) |pin| : (i += 1) {
+ if (i >= nrows) break;
+ flags[i] = pin.rowAndCell().row.semantic_prompt != .none;
+ }
+ }
+ const off = pane.scroll();
+ var out: std.ArrayList(u8) = .empty;
+ var lines = std.mem.splitScalar(u8, raw, '\n');
+ var i: usize = 0;
+ while (i < nrows) : (i += 1) {
+ if (i > 0) try out.append(arena, '\n');
+ const line = if (flags[i]) "" else lines.next() orelse "";
+ if (flags[i]) _ = lines.next();
+ if (pane.mode == .tty) {
+ try out.appendSlice(arena, line);
+ } else {
+ try spliceRow(arena, &out, pane, @as(i32, @intCast(i)) + off, line);
+ }
+ }
+ return out.items;
+ }
+
+ fn cellStyle(p: *Pardes, ci: ghostty_vt.PageList.Cell) CellStyle {
+ const style = ci.style();
+ var cs: CellStyle = .{
+ .fg = p.ghostColor(style.fg_color, false),
+ .bg = p.ghostColor(style.bg_color, true),
+ .bold = style.flags.bold,
+ .dim = style.flags.faint,
+ .italic = style.flags.italic,
+ .blink = style.flags.blink,
+ .reverse = style.flags.inverse,
+ .invisible = style.flags.invisible,
+ .strikethrough = style.flags.strikethrough,
+ .ul = switch (style.flags.underline) {
+ .none => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+ switch (ci.cell.content_tag) {
+ .bg_color_palette => cs.bg = p.palColor(ci.cell.content.color_palette.data),
+ .bg_color_rgb => {
+ const rgb = ci.cell.content.color_rgb;
+ cs.bg = .{ .rgb = .{ rgb.r, rgb.g, rgb.b } };
+ },
+ else => {},
+ }
+ return cs;
+ }
+
+ const SynStyle = struct { fg: [3]u8, bold: bool };
+
+ fn synStyle(p: *Pardes, sy: syntax.Syn) ?SynStyle {
+ return switch (sy) {
+ .none => null,
+ .keyword => .{ .fg = p.theme().kw, .bold = true },
+ .string => .{ .fg = p.theme().str, .bold = false },
+ .number => .{ .fg = p.theme().num, .bold = false },
+ .comment => .{ .fg = p.theme().comment, .bold = true },
+ };
+ }
+
+ fn palColor(p: *Pardes, idx: u8) Color {
+ if (p.theme().palette) |pal| if (idx < 16) return .{ .rgb = pal[idx] };
+ return .{ .index = idx };
+ }
+
+ fn ghostColor(p: *Pardes, color: ghostty_vt.Style.Color, is_bg: bool) Color {
+ return switch (color) {
+ .none => blk: {
+ const t = if (is_bg) p.theme().bg else p.theme().fg;
+ break :blk if (t) |c| .{ .rgb = c } else .default;
+ },
+ .palette => |idx| p.palColor(idx),
+ .rgb => |rgb| .{ .rgb = .{ rgb.r, rgb.g, rgb.b } },
+ };
+ }
+};
+
+// ghostty calls this with a reply (cursor-position report, DA, ...) to send
+// back to the child as if it typed it. The handler's `terminal` is our Pane.vt
+// field; recover the Pane and stash the bytes — sync() drains them into write
+// effects (the callback has no path to the effect queue).
+fn ptyReport(handler: *ghostty_vt.TerminalStream.Handler, data: [:0]const u8) void {
+ const pane: *Pane = @fieldParentPtr("vt", handler.terminal);
+ const room = pane.reply.len - pane.reply_len;
+ const n = @min(room, data.len);
+ @memcpy(pane.reply[pane.reply_len..][0..n], data[0..n]);
+ pane.reply_len += @intCast(n);
+}
+
+/// Splice this row's insertion runs into `line` (the raw tty text for absolute
+/// row `abs_row`), shifting the underlying text right rather than overwriting.
+fn spliceRow(arena: std.mem.Allocator, out: *std.ArrayList(u8), pane: *Pane, abs_row: i32, line: []const u8) !void {
+ var idxs: [MAX_EDITS]usize = undefined;
+ var n: usize = 0;
+ for (pane.edits[0..pane.nedits], 0..) |e, i| {
+ if (e.row == abs_row and e.len > 0) {
+ idxs[n] = i;
+ n += 1;
+ }
+ }
+ // insertion-sort the runs by column (n is tiny)
+ var a: usize = 1;
+ while (a < n) : (a += 1) {
+ const key = idxs[a];
+ var b = a;
+ while (b > 0 and pane.edits[idxs[b - 1]].col > pane.edits[key].col) : (b -= 1) idxs[b] = idxs[b - 1];
+ idxs[b] = key;
+ }
+ var vpos: usize = 0; // original-line columns consumed so far
+ for (idxs[0..n]) |i| {
+ const e = &pane.edits[i];
+ const c: usize = @intCast(@max(0, e.col));
+ if (c <= line.len) {
+ if (c > vpos) try out.appendSlice(arena, line[vpos..c]);
+ vpos = c;
+ } else {
+ // run anchored past the text: emit the rest, then pad with spaces
+ if (vpos < line.len) {
+ try out.appendSlice(arena, line[vpos..]);
+ vpos = line.len;
+ }
+ while (vpos < c) : (vpos += 1) try out.append(arena, ' ');
+ }
+ try out.appendSlice(arena, e.text[0..e.len]);
+ }
+ if (vpos < line.len) try out.appendSlice(arena, line[vpos..]);
+}
+
+const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo(
+ @FieldType(ghostty_vt.TerminalStream.Handler.Effects, "device_attributes"),
+).optional.child).pointer.child).@"fn".return_type.?;
+fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs {
+ return .{};
+}
diff --git a/src/petscii.zig b/src/petscii.zig
new file mode 100644
index 00000000..136c4ead
--- /dev/null
+++ b/src/petscii.zig
@@ -0,0 +1,446 @@
+// PETSCII image rendering — a fallback when the terminal has no kitty graphics,
+// and a per-pane toggle. Ported in spirit from caioluders/petsciinator: split the
+// image into character cells and match each cell to the Unicode block/sextant glyph
+// + a C64 foreground/background color pair that best reproduces the cell's pixels
+// (minimum per-pixel color error). Pure: only std + the raw RGBA bytes (no vaxis),
+// so the matcher is unit-testable on its own.
+const std = @import("std");
+
+// The match palette is supplied by the caller (mode-dependent): the Commodore 64
+// colors below by default, or the terminal's own ANSI palette. Cells store fg/bg as
+// palette indices; image.draw paints them as C64 RGB or as indexed colors per mode.
+// This is Pepto's canonical C64 palette.
+pub const commodore = [16][3]u8{
+ .{ 0x00, 0x00, 0x00 }, // 0 black
+ .{ 0xff, 0xff, 0xff }, // 1 white
+ .{ 0x68, 0x37, 0x2b }, // 2 red
+ .{ 0x70, 0xa4, 0xb2 }, // 3 cyan
+ .{ 0x6f, 0x3d, 0x86 }, // 4 purple
+ .{ 0x58, 0x8d, 0x43 }, // 5 green
+ .{ 0x35, 0x28, 0x79 }, // 6 blue
+ .{ 0xb8, 0xc7, 0x6f }, // 7 yellow
+ .{ 0x6f, 0x4f, 0x25 }, // 8 orange
+ .{ 0x43, 0x39, 0x00 }, // 9 brown
+ .{ 0x9a, 0x67, 0x59 }, // 10 light red
+ .{ 0x44, 0x44, 0x44 }, // 11 dark grey
+ .{ 0x6c, 0x6c, 0x6c }, // 12 grey
+ .{ 0x9a, 0xd2, 0x84 }, // 13 light green
+ .{ 0x6c, 0x5e, 0xb5 }, // 14 light blue
+ .{ 0x95, 0x95, 0x95 }, // 15 light grey
+};
+
+const Palette = [16][3]u8;
+
+// One rendered character cell: the UTF-8 bytes of the chosen glyph (stored inline
+// so the slice handed to the retained vaxis screen outlives the frame) + palette
+// fg/bg indices.
+pub const Cell = struct {
+ glyph: [4]u8 = .{ ' ', 0, 0, 0 },
+ glen: u3 = 1,
+ fg: u4 = 1,
+ bg: u4 = 0,
+};
+
+pub const Grid = struct { cells: []Cell, gw: usize, gh: usize };
+
+fn dist2(a: [3]u8, b: [3]u8) u32 {
+ const dr = @as(i32, a[0]) - b[0];
+ const dg = @as(i32, a[1]) - b[1];
+ const db = @as(i32, a[2]) - b[2];
+ return @intCast(dr * dr + dg * dg + db * db);
+}
+
+fn nearest(px: [3]u8, pal: Palette) u4 {
+ var best: u4 = 0;
+ var bestd: u32 = std.math.maxInt(u32);
+ for (pal, 0..) |c, i| {
+ const d = dist2(px, c);
+ if (d < bestd) {
+ bestd = d;
+ best = @intCast(i);
+ }
+ }
+ return best;
+}
+
+// ---- glyph set: each is a codepoint + an 8x8 ink bitmap (bit y*8+x set = fg) ----
+const Glyph = struct { cp: u21, bits: u64 };
+
+// The Unicode codepoint for a 2x3 sextant pattern. Bits: 1=upper-left, 2=upper-right,
+// 4=mid-left, 8=mid-right, 16=lower-left, 32=lower-right. The four patterns that
+// coincide with existing block characters are mapped to those instead.
+fn sextantCp(p: u6) u21 {
+ return switch (p) {
+ 0 => ' ',
+ 21 => 0x258C, // left half ▌
+ 42 => 0x2590, // right half ▐
+ 63 => 0x2588, // full block █
+ else => blk: {
+ var off: u21 = @as(u21, p) - 1;
+ if (p > 21) off -= 1;
+ if (p > 42) off -= 1;
+ break :blk 0x1FB00 + off; // Symbols for Legacy Computing sextants
+ },
+ };
+}
+
+// the 8x8 ink bitmap for a sextant pattern (2 cols x 3 rows of subcells).
+fn sextantBits(p: u6) u64 {
+ var bits: u64 = 0;
+ var y: usize = 0;
+ while (y < 8) : (y += 1) {
+ const band = (y * 3) / 8; // 0,0,0,1,1,1,2,2
+ var x: usize = 0;
+ while (x < 8) : (x += 1) {
+ const col: usize = if (x < 4) 0 else 1;
+ const sub: u6 = @intCast(band * 2 + col);
+ if ((p >> sub) & 1 != 0) bits |= @as(u64, 1) << @intCast(y * 8 + x);
+ }
+ }
+ return bits;
+}
+
+// pack 8 row-bytes (bit x set, x=0 leftmost) into the 8x8 bitmap.
+fn rows(r: [8]u8) u64 {
+ var b: u64 = 0;
+ for (r, 0..) |row, y| b |= @as(u64, row) << @intCast(y * 8);
+ return b;
+}
+
+// the horizontal half blocks and the ten 2x2 quadrants — sextants are 2x3, so they
+// can't express an exact 4-row half or a quarter; these fill that gap.
+const block_glyphs = [_]Glyph{
+ .{ .cp = 0x2580, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0, 0, 0, 0 }) }, // ▀ top half
+ .{ .cp = 0x2584, .bits = rows(.{ 0, 0, 0, 0, 0xff, 0xff, 0xff, 0xff }) }, // ▄ bottom half
+ .{ .cp = 0x2598, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0, 0, 0, 0 }) }, // ▘ TL
+ .{ .cp = 0x259d, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0, 0, 0, 0 }) }, // ▝ TR
+ .{ .cp = 0x2596, .bits = rows(.{ 0, 0, 0, 0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▖ BL
+ .{ .cp = 0x2597, .bits = rows(.{ 0, 0, 0, 0, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▗ BR
+ .{ .cp = 0x259a, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▚ TL+BR
+ .{ .cp = 0x259e, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▞ TR+BL
+ .{ .cp = 0x259b, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0x0f, 0x0f, 0x0f, 0x0f }) }, // ▛ ¬BR
+ .{ .cp = 0x259c, .bits = rows(.{ 0xff, 0xff, 0xff, 0xff, 0xf0, 0xf0, 0xf0, 0xf0 }) }, // ▜ ¬BL
+ .{ .cp = 0x2599, .bits = rows(.{ 0x0f, 0x0f, 0x0f, 0x0f, 0xff, 0xff, 0xff, 0xff }) }, // ▙ ¬TR
+ .{ .cp = 0x259f, .bits = rows(.{ 0xf0, 0xf0, 0xf0, 0xf0, 0xff, 0xff, 0xff, 0xff }) }, // ▟ ¬TL
+};
+
+// line/diagonal glyphs add the characteristic PETSCII "drawn" look on edges.
+const line_glyphs = [_]Glyph{
+ .{ .cp = 0x2500, .bits = rows(.{ 0, 0, 0, 0xff, 0xff, 0, 0, 0 }) }, // ─
+ .{ .cp = 0x2502, .bits = rows(.{ 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18, 0x18 }) }, // │
+ .{ .cp = 0x253c, .bits = rows(.{ 0x18, 0x18, 0x18, 0xff, 0xff, 0x18, 0x18, 0x18 }) }, // ┼
+ .{ .cp = 0x2572, .bits = rows(.{ 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80 }) }, // ╲
+ .{ .cp = 0x2571, .bits = rows(.{ 0x80, 0x40, 0x20, 0x10, 0x08, 0x04, 0x02, 0x01 }) }, // ╱
+ .{ .cp = 0x2573, .bits = rows(.{ 0x81, 0x42, 0x24, 0x18, 0x18, 0x24, 0x42, 0x81 }) }, // ╳
+};
+
+// Real 8x8 font bitmaps for the printable ASCII range (extracted from the cp850-8x8
+// console font). This is how petsciinator's charset works: the FULL character set is
+// matched, each glyph scored against the cell by its actual bitmap (ink = fg), then
+// rendered by its codepoint — the terminal draws the letter in its own font. (bit
+// y*8+x set = ink; x=0 leftmost. Generated, not hand-drawn.)
+const ascii_glyphs = [_]Glyph{
+ .{ .cp = 0x21, .bits = 0x00180018183c3c18 }, // !
+ .{ .cp = 0x22, .bits = 0x0000000000246666 }, // "
+ .{ .cp = 0x23, .bits = 0x0036367f367f3636 }, // #
+ .{ .cp = 0x24, .bits = 0x00183e603c067c18 }, // $
+ .{ .cp = 0x25, .bits = 0x0063660c18336300 }, // %
+ .{ .cp = 0x26, .bits = 0x006e333b6e1c361c }, // &
+ .{ .cp = 0x27, .bits = 0x00000000000c1818 }, // '
+ .{ .cp = 0x28, .bits = 0x0030180c0c0c1830 }, // (
+ .{ .cp = 0x29, .bits = 0x000c18303030180c }, // )
+ .{ .cp = 0x2a, .bits = 0x0000663cff3c6600 }, // *
+ .{ .cp = 0x2b, .bits = 0x000018187e181800 }, // +
+ .{ .cp = 0x2c, .bits = 0x0c18180000000000 }, // ,
+ .{ .cp = 0x2d, .bits = 0x000000007e000000 }, // -
+ .{ .cp = 0x2e, .bits = 0x0018180000000000 }, // .
+ .{ .cp = 0x2f, .bits = 0x000103060c183060 }, // /
+ .{ .cp = 0x30, .bits = 0x001c36636b63361c }, // 0
+ .{ .cp = 0x31, .bits = 0x007e181818181c18 }, // 1
+ .{ .cp = 0x32, .bits = 0x007f660c3860633e }, // 2
+ .{ .cp = 0x33, .bits = 0x003e63603c60633e }, // 3
+ .{ .cp = 0x34, .bits = 0x0078307f33363c38 }, // 4
+ .{ .cp = 0x35, .bits = 0x003e63603f03037f }, // 5
+ .{ .cp = 0x36, .bits = 0x003e63633f03061c }, // 6
+ .{ .cp = 0x37, .bits = 0x000c0c0c1830637f }, // 7
+ .{ .cp = 0x38, .bits = 0x003e63633e63633e }, // 8
+ .{ .cp = 0x39, .bits = 0x001e30607e63633e }, // 9
+ .{ .cp = 0x3a, .bits = 0x0018180000181800 }, // :
+ .{ .cp = 0x3b, .bits = 0x0c18180000181800 }, // ;
+ .{ .cp = 0x3c, .bits = 0x006030180c183060 }, // <
+ .{ .cp = 0x3d, .bits = 0x00007e00007e0000 }, // =
+ .{ .cp = 0x3e, .bits = 0x00060c1830180c06 }, // >
+ .{ .cp = 0x3f, .bits = 0x001800181830633e }, // ?
+ .{ .cp = 0x40, .bits = 0x001e037b7b7b633e }, // @
+ .{ .cp = 0x41, .bits = 0x006363637f63361c }, // A
+ .{ .cp = 0x42, .bits = 0x003f66663e66663f }, // B
+ .{ .cp = 0x43, .bits = 0x003c66030303663c }, // C
+ .{ .cp = 0x44, .bits = 0x001f36666666361f }, // D
+ .{ .cp = 0x45, .bits = 0x007f46161e16467f }, // E
+ .{ .cp = 0x46, .bits = 0x000f06161e16467f }, // F
+ .{ .cp = 0x47, .bits = 0x005c66730303663c }, // G
+ .{ .cp = 0x48, .bits = 0x006363637f636363 }, // H
+ .{ .cp = 0x49, .bits = 0x003c18181818183c }, // I
+ .{ .cp = 0x4a, .bits = 0x001e333330303078 }, // J
+ .{ .cp = 0x4b, .bits = 0x006766361e366667 }, // K
+ .{ .cp = 0x4c, .bits = 0x007f66460606060f }, // L
+ .{ .cp = 0x4d, .bits = 0x0063636b7f7f7763 }, // M
+ .{ .cp = 0x4e, .bits = 0x006363737b6f6763 }, // N
+ .{ .cp = 0x4f, .bits = 0x003e63636363633e }, // O
+ .{ .cp = 0x50, .bits = 0x000f06063e66663f }, // P
+ .{ .cp = 0x51, .bits = 0x703e73636363633e }, // Q
+ .{ .cp = 0x52, .bits = 0x006766363e66663f }, // R
+ .{ .cp = 0x53, .bits = 0x003c6630180c663c }, // S
+ .{ .cp = 0x54, .bits = 0x003c1818185a7e7e }, // T
+ .{ .cp = 0x55, .bits = 0x003e636363636363 }, // U
+ .{ .cp = 0x56, .bits = 0x001c366363636363 }, // V
+ .{ .cp = 0x57, .bits = 0x00367f6b6b636363 }, // W
+ .{ .cp = 0x58, .bits = 0x006363361c366363 }, // X
+ .{ .cp = 0x59, .bits = 0x003c18183c666666 }, // Y
+ .{ .cp = 0x5a, .bits = 0x007f664c1831637f }, // Z
+ .{ .cp = 0x5b, .bits = 0x003c0c0c0c0c0c3c }, // [
+ .{ .cp = 0x5c, .bits = 0x00406030180c0603 }, // \\
+ .{ .cp = 0x5d, .bits = 0x003c30303030303c }, // ]
+ .{ .cp = 0x5e, .bits = 0x0000000063361c08 }, // ^
+ .{ .cp = 0x5f, .bits = 0xff00000000000000 }, // _
+ .{ .cp = 0x60, .bits = 0x000000000030180c }, // `
+ .{ .cp = 0x61, .bits = 0x006e333e301e0000 }, // a
+ .{ .cp = 0x62, .bits = 0x003b6666663e0607 }, // b
+ .{ .cp = 0x63, .bits = 0x003e6303633e0000 }, // c
+ .{ .cp = 0x64, .bits = 0x006e3333333e3038 }, // d
+ .{ .cp = 0x65, .bits = 0x003e037f633e0000 }, // e
+ .{ .cp = 0x66, .bits = 0x000f06061f06663c }, // f
+ .{ .cp = 0x67, .bits = 0x1f303e33336e0000 }, // g
+ .{ .cp = 0x68, .bits = 0x006766666e360607 }, // h
+ .{ .cp = 0x69, .bits = 0x003c1818181c0018 }, // i
+ .{ .cp = 0x6a, .bits = 0x3c66666060600060 }, // j
+ .{ .cp = 0x6b, .bits = 0x0067361e36660607 }, // k
+ .{ .cp = 0x6c, .bits = 0x003c18181818181c }, // l
+ .{ .cp = 0x6d, .bits = 0x006b6b6b7f370000 }, // m
+ .{ .cp = 0x6e, .bits = 0x00666666663b0000 }, // n
+ .{ .cp = 0x6f, .bits = 0x003e6363633e0000 }, // o
+ .{ .cp = 0x70, .bits = 0x0f063e66663b0000 }, // p
+ .{ .cp = 0x71, .bits = 0x78303e33336e0000 }, // q
+ .{ .cp = 0x72, .bits = 0x000f06066e3b0000 }, // r
+ .{ .cp = 0x73, .bits = 0x003f603e037e0000 }, // s
+ .{ .cp = 0x74, .bits = 0x00386c0c0c3f0c0c }, // t
+ .{ .cp = 0x75, .bits = 0x006e333333330000 }, // u
+ .{ .cp = 0x76, .bits = 0x001c366363630000 }, // v
+ .{ .cp = 0x77, .bits = 0x00367f6b6b630000 }, // w
+ .{ .cp = 0x78, .bits = 0x0063361c36630000 }, // x
+ .{ .cp = 0x79, .bits = 0x3f607e6363630000 }, // y
+ .{ .cp = 0x7a, .bits = 0x007e4c18327e0000 }, // z
+ .{ .cp = 0x7b, .bits = 0x007018180e181870 }, // {
+ .{ .cp = 0x7c, .bits = 0x0018181818181818 }, // |
+ .{ .cp = 0x7d, .bits = 0x000e18187018180e }, // }
+ .{ .cp = 0x7e, .bits = 0x0000000000003b6e }, // ~
+};
+
+// the block glyph table (sextants + half/quadrant blocks + line glyphs), built at
+// comptime, and the same set extended with the ASCII glyphs. `render(ascii=…)` picks.
+const glyphs = blk: {
+ @setEvalBranchQuota(100000);
+ var list: [64 + block_glyphs.len + line_glyphs.len]Glyph = undefined;
+ var p: usize = 0;
+ while (p < 64) : (p += 1) list[p] = .{ .cp = sextantCp(@intCast(p)), .bits = sextantBits(@intCast(p)) };
+ for (block_glyphs, 0..) |bg, i| list[64 + i] = bg;
+ for (line_glyphs, 0..) |lg, i| list[64 + block_glyphs.len + i] = lg;
+ break :blk list;
+};
+const glyphs_ascii = glyphs ++ ascii_glyphs;
+
+// match one 8x8 RGB cell to the best (glyph, fg, bg). Candidate colors are the
+// most-common palette colors among the 64 pixels; for each ordered pair the glyph
+// cost is base(all-bg) + sum over the glyph's ink bits of (dist_fg - dist_bg).
+fn matchCell(cell: *const [64][3]u8, pal: Palette, gset: []const Glyph) Cell {
+ var counts = [_]u16{0} ** 16;
+ for (cell) |px| counts[nearest(px, pal)] += 1;
+ var cand: [4]u4 = undefined;
+ var ncand: usize = 0;
+ var used = [_]bool{false} ** 16;
+ while (ncand < 4) : (ncand += 1) {
+ var best: ?usize = null;
+ for (counts, 0..) |c, i| {
+ if (used[i] or c == 0) continue;
+ if (best == null or c > counts[best.?]) best = i;
+ }
+ if (best) |bi| {
+ cand[ncand] = @intCast(bi);
+ used[bi] = true;
+ } else break;
+ }
+ if (ncand == 0) return .{}; // can't happen (64 pixels), but keep it total
+ if (ncand == 1) return encode(' ', cand[0], cand[0]); // solid color
+
+ var best_cost: i64 = std.math.maxInt(i64);
+ var best = encode(' ', cand[0], cand[0]);
+ var fi: usize = 0;
+ while (fi < ncand) : (fi += 1) {
+ var bi: usize = 0;
+ while (bi < ncand) : (bi += 1) {
+ if (fi == bi) continue;
+ const fg = cand[fi];
+ const bg = cand[bi];
+ var dfg: [64]u32 = undefined;
+ var dbg: [64]u32 = undefined;
+ var base: i64 = 0;
+ for (cell, 0..) |px, p| {
+ dfg[p] = dist2(px, pal[fg]);
+ dbg[p] = dist2(px, pal[bg]);
+ base += dbg[p];
+ }
+ for (gset) |g| {
+ var delta: i64 = 0;
+ var bits = g.bits;
+ while (bits != 0) : (bits &= bits - 1) {
+ const p: usize = @ctz(bits);
+ delta += @as(i64, dfg[p]) - @as(i64, dbg[p]);
+ }
+ const cost = base + delta;
+ if (cost < best_cost) {
+ best_cost = cost;
+ best = encode(g.cp, fg, bg);
+ }
+ }
+ }
+ }
+ return best;
+}
+
+fn encode(cp: u21, fg: u4, bg: u4) Cell {
+ var c = Cell{ .fg = fg, .bg = bg };
+ const n = std.unicode.utf8Encode(cp, &c.glyph) catch 1;
+ c.glen = @intCast(n);
+ return c;
+}
+
+// Render `rgba` (iw x ih, 4 bytes/px) into a grid of at most cols x rows cells,
+// preserving the image aspect with the terminal cell aspect (~1:2) corrected.
+// Caller owns Grid.cells (gpa).
+pub fn render(gpa: std.mem.Allocator, rgba: []const u8, iw: usize, ih: usize, cols: usize, rows_: usize, pal: Palette, ascii: bool) !Grid {
+ if (iw == 0 or ih == 0 or cols == 0 or rows_ == 0) return .{ .cells = try gpa.alloc(Cell, 0), .gw = 0, .gh = 0 };
+ const gset: []const Glyph = if (ascii) &glyphs_ascii else &glyphs;
+ // contain-fit; cells are ~twice as tall as wide, so a row spans 2 width-units.
+ var gw = cols;
+ var gh = (cols * ih) / (2 * iw);
+ if (gh > rows_) {
+ gh = rows_;
+ gw = (rows_ * 2 * iw) / ih;
+ }
+ gw = std.math.clamp(gw, 1, cols);
+ gh = std.math.clamp(gh, 1, rows_);
+
+ const cells = try gpa.alloc(Cell, gw * gh);
+ var cy: usize = 0;
+ while (cy < gh) : (cy += 1) {
+ const ry0 = cy * ih / gh;
+ const ry1 = @max(ry0 + 1, (cy + 1) * ih / gh);
+ var cx: usize = 0;
+ while (cx < gw) : (cx += 1) {
+ const rx0 = cx * iw / gw;
+ const rx1 = @max(rx0 + 1, (cx + 1) * iw / gw);
+ var cell: [64][3]u8 = undefined;
+ var sy: usize = 0;
+ while (sy < 8) : (sy += 1) {
+ const py0 = ry0 + sy * (ry1 - ry0) / 8;
+ const py1 = @max(py0 + 1, ry0 + (sy + 1) * (ry1 - ry0) / 8);
+ var sx: usize = 0;
+ while (sx < 8) : (sx += 1) {
+ const px0 = rx0 + sx * (rx1 - rx0) / 8;
+ const px1 = @max(px0 + 1, rx0 + (sx + 1) * (rx1 - rx0) / 8);
+ var rs: usize = 0;
+ var gs: usize = 0;
+ var bs: usize = 0;
+ var n: usize = 0;
+ var yy = py0;
+ while (yy < py1 and yy < ih) : (yy += 1) {
+ var xx = px0;
+ while (xx < px1 and xx < iw) : (xx += 1) {
+ const i = (yy * iw + xx) * 4;
+ rs += rgba[i];
+ gs += rgba[i + 1];
+ bs += rgba[i + 2];
+ n += 1;
+ }
+ }
+ if (n == 0) n = 1;
+ cell[sy * 8 + sx] = .{ @intCast(rs / n), @intCast(gs / n), @intCast(bs / n) };
+ }
+ }
+ cells[cy * gw + cx] = matchCell(&cell, pal, gset);
+ }
+ }
+ return .{ .cells = cells, .gw = gw, .gh = gh };
+}
+
+test "sextant codepoints: blocks + endpoints" {
+ try std.testing.expectEqual(@as(u21, ' '), sextantCp(0));
+ try std.testing.expectEqual(@as(u21, 0x2588), sextantCp(63));
+ try std.testing.expectEqual(@as(u21, 0x258C), sextantCp(21));
+ try std.testing.expectEqual(@as(u21, 0x2590), sextantCp(42));
+ try std.testing.expectEqual(@as(u21, 0x1FB00), sextantCp(1)); // first sextant
+ try std.testing.expectEqual(@as(u21, 0x1FB3B), sextantCp(62)); // last sextant
+}
+
+test "matchCell: solid color -> space on that bg" {
+ var cell: [64][3]u8 = undefined;
+ for (&cell) |*p| p.* = commodore[5]; // all green
+ const m = matchCell(&cell, commodore, &glyphs);
+ try std.testing.expectEqual(@as(u4, 5), m.bg);
+ try std.testing.expectEqual(@as(u8, ' '), m.glyph[0]);
+}
+
+test "matchCell: clean top/bottom split picks the two colors" {
+ var cell: [64][3]u8 = undefined;
+ for (0..64) |p| cell[p] = if (p < 32) commodore[1] else commodore[6]; // white over blue
+ const m = matchCell(&cell, commodore, &glyphs);
+ // both palette colors must be chosen (in some fg/bg order)
+ const a = @as(u4, @min(m.fg, m.bg));
+ const b = @as(u4, @max(m.fg, m.bg));
+ try std.testing.expectEqual(@as(u4, 1), a);
+ try std.testing.expectEqual(@as(u4, 6), b);
+ // a clean top/bottom split must resolve to a real block glyph (the top-4-rows
+ // half block ▀), never a blank cell.
+ const cp = std.unicode.utf8Decode(m.glyph[0..m.glen]) catch 0;
+ try std.testing.expectEqual(@as(u21, 0x2580), cp);
+}
+
+test "ascii option only enables the ascii glyphs" {
+ // the ascii glyph codepoints must be reachable exactly when ascii is on.
+ var seen_block = false;
+ var seen_ascii = false;
+ for (glyphs) |g| if (g.cp == '#') {
+ seen_block = true;
+ };
+ for (glyphs_ascii) |g| if (g.cp == '#') {
+ seen_ascii = true;
+ };
+ try std.testing.expect(!seen_block); // '#' is an ascii-only glyph
+ try std.testing.expect(seen_ascii);
+ try std.testing.expectEqual(glyphs.len + ascii_glyphs.len, glyphs_ascii.len);
+}
+
+test "ascii glyph is chosen when a cell has its exact shape" {
+ // a cell shaped exactly like the font 'S' (ink=white on black) must match 'S'
+ // with ascii on (cost 0), and fall back to some block glyph with ascii off.
+ const s_bits: u64 = 0x003c6630180c663c;
+ var cell: [64][3]u8 = undefined;
+ for (0..64) |p| cell[p] = if ((s_bits >> @intCast(p)) & 1 != 0) commodore[1] else commodore[0];
+ const on = matchCell(&cell, commodore, &glyphs_ascii);
+ try std.testing.expectEqual(@as(u21, 'S'), std.unicode.utf8Decode(on.glyph[0..on.glen]) catch 0);
+ const off = matchCell(&cell, commodore, &glyphs);
+ try std.testing.expect((std.unicode.utf8Decode(off.glyph[0..off.glen]) catch 0) != 'S');
+}
+
+test "render: tiny image produces a grid within bounds" {
+ const a = std.testing.allocator;
+ // 2x2 checker, RGBA
+ var img = [_]u8{0} ** (2 * 2 * 4);
+ img[0] = 255; img[1] = 255; img[2] = 255; img[3] = 255; // (0,0) white
+ img[(3) * 4 + 0] = 255; img[(3) * 4 + 1] = 255; img[(3) * 4 + 2] = 255; img[(3) * 4 + 3] = 255; // (1,1) white
+ const g = try render(a, &img, 2, 2, 10, 10, commodore, true);
+ defer a.free(g.cells);
+ try std.testing.expect(g.gw >= 1 and g.gw <= 10);
+ try std.testing.expect(g.gh >= 1 and g.gh <= 10);
+ try std.testing.expectEqual(g.gw * g.gh, g.cells.len);
+}
diff --git a/src/syntax.zig b/src/syntax.zig
new file mode 100644
index 00000000..a9914fd7
--- /dev/null
+++ b/src/syntax.zig
@@ -0,0 +1,173 @@
+//! Tree-sitter syntax highlighting: one style byte per content byte, filled by
+//! running each grammar's highlights.scm query (slurped at build time into the
+//! ts_queries options module). Grammar set is tiered: `minimal` (c, cpp, zig)
+//! compiles queries at startup; `full` adds ~23 languages lazily on first use.
+const std = @import("std");
+const config = @import("pardes_config");
+
+pub const enabled = config.syntax_highlighting;
+const minimal_grammars = config.syntax_minimal_grammars;
+const full_grammars = config.syntax_full_grammars;
+
+const ts = if (enabled) @import("tree-sitter") else struct {
+ pub const Language = opaque {};
+ pub const Query = opaque {};
+};
+const ts_queries = if (enabled) @import("ts_queries") else struct {};
+
+extern fn tree_sitter_ada() callconv(.c) *const ts.Language;
+extern fn tree_sitter_bash() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c() callconv(.c) *const ts.Language;
+extern fn tree_sitter_c_sharp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_clojure() callconv(.c) *const ts.Language;
+extern fn tree_sitter_cpp() callconv(.c) *const ts.Language;
+extern fn tree_sitter_css() callconv(.c) *const ts.Language;
+extern fn tree_sitter_elixir() callconv(.c) *const ts.Language;
+extern fn tree_sitter_erlang() callconv(.c) *const ts.Language;
+extern fn tree_sitter_fortran() callconv(.c) *const ts.Language;
+extern fn tree_sitter_go() callconv(.c) *const ts.Language;
+extern fn tree_sitter_haskell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_html() callconv(.c) *const ts.Language;
+extern fn tree_sitter_java() callconv(.c) *const ts.Language;
+extern fn tree_sitter_javascript() callconv(.c) *const ts.Language;
+extern fn tree_sitter_json() callconv(.c) *const ts.Language;
+extern fn tree_sitter_kotlin() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ocaml() callconv(.c) *const ts.Language;
+extern fn tree_sitter_pascal() callconv(.c) *const ts.Language;
+extern fn tree_sitter_php() callconv(.c) *const ts.Language;
+extern fn tree_sitter_powershell() callconv(.c) *const ts.Language;
+extern fn tree_sitter_python() callconv(.c) *const ts.Language;
+extern fn tree_sitter_ruby() callconv(.c) *const ts.Language;
+extern fn tree_sitter_rust() callconv(.c) *const ts.Language;
+extern fn tree_sitter_scala() callconv(.c) *const ts.Language;
+extern fn tree_sitter_zig() callconv(.c) *const ts.Language;
+
+pub const Syn = enum(u8) { none, keyword, string, number, comment };
+
+const Spec = struct {
+ exts: []const []const u8,
+ language: *const fn () callconv(.c) *const ts.Language,
+ query_src: []const u8,
+ compiled_query: ?*ts.Query = null,
+};
+
+var minimal_specs = if (minimal_grammars) [_]Spec{
+ .{ .exts = &.{ ".c", ".h" }, .language = tree_sitter_c, .query_src = ts_queries.c_highlights },
+ .{ .exts = &.{ ".cpp", ".cc", ".cxx", ".hpp", ".hh", ".hxx" }, .language = tree_sitter_cpp, .query_src = ts_queries.cpp_highlights },
+ .{ .exts = &.{ ".zig", ".zon" }, .language = tree_sitter_zig, .query_src = ts_queries.zig_highlights },
+} else [_]Spec{};
+
+var full_specs = if (full_grammars) [_]Spec{
+ .{ .exts = &.{ ".adb", ".ads", ".ada" }, .language = tree_sitter_ada, .query_src = ts_queries.ada_highlights },
+ .{ .exts = &.{ ".sh", ".bash", ".zsh" }, .language = tree_sitter_bash, .query_src = ts_queries.bash_highlights },
+ .{ .exts = &.{ ".cs", ".csx" }, .language = tree_sitter_c_sharp, .query_src = ts_queries.c_sharp_highlights },
+ .{ .exts = &.{ ".clj", ".cljs", ".cljc", ".edn" }, .language = tree_sitter_clojure, .query_src = ts_queries.clojure_highlights },
+ .{ .exts = &.{".css"}, .language = tree_sitter_css, .query_src = ts_queries.css_highlights },
+ .{ .exts = &.{ ".ex", ".exs" }, .language = tree_sitter_elixir, .query_src = ts_queries.elixir_highlights },
+ .{ .exts = &.{ ".erl", ".hrl" }, .language = tree_sitter_erlang, .query_src = ts_queries.erlang_highlights },
+ .{ .exts = &.{ ".f", ".for", ".ftn", ".f90", ".f95", ".f03", ".f08" }, .language = tree_sitter_fortran, .query_src = ts_queries.fortran_highlights },
+ .{ .exts = &.{".go"}, .language = tree_sitter_go, .query_src = ts_queries.go_highlights },
+ .{ .exts = &.{ ".hs", ".lhs" }, .language = tree_sitter_haskell, .query_src = ts_queries.haskell_highlights },
+ .{ .exts = &.{ ".html", ".htm" }, .language = tree_sitter_html, .query_src = ts_queries.html_highlights },
+ .{ .exts = &.{".java"}, .language = tree_sitter_java, .query_src = ts_queries.java_highlights },
+ .{ .exts = &.{ ".js", ".jsx", ".mjs", ".cjs" }, .language = tree_sitter_javascript, .query_src = ts_queries.javascript_highlights },
+ .{ .exts = &.{".json"}, .language = tree_sitter_json, .query_src = ts_queries.json_highlights },
+ .{ .exts = &.{ ".kt", ".kts" }, .language = tree_sitter_kotlin, .query_src = ts_queries.kotlin_highlights },
+ .{ .exts = &.{ ".ml", ".mli" }, .language = tree_sitter_ocaml, .query_src = ts_queries.ocaml_highlights },
+ .{ .exts = &.{ ".pas", ".pp", ".p" }, .language = tree_sitter_pascal, .query_src = ts_queries.pascal_highlights },
+ .{ .exts = &.{ ".php", ".phtml", ".php3", ".php4", ".php5" }, .language = tree_sitter_php, .query_src = ts_queries.php_highlights },
+ .{ .exts = &.{ ".ps1", ".psm1", ".psd1" }, .language = tree_sitter_powershell, .query_src = ts_queries.powershell_highlights },
+ .{ .exts = &.{ ".py", ".pyw" }, .language = tree_sitter_python, .query_src = ts_queries.python_highlights },
+ .{ .exts = &.{ ".rb", ".rake" }, .language = tree_sitter_ruby, .query_src = ts_queries.ruby_highlights },
+ .{ .exts = &.{".rs"}, .language = tree_sitter_rust, .query_src = ts_queries.rust_highlights },
+ .{ .exts = &.{ ".scala", ".sc" }, .language = tree_sitter_scala, .query_src = ts_queries.scala_highlights },
+} else [_]Spec{};
+
+const Selected = struct { lang: *const ts.Language, query: *ts.Query };
+
+// NOTE: don't lang.destroy() — the tree_sitter_*() languages are static
+// singletons reused on every open; destroying one use-after-frees the next.
+fn ensure(spec: *Spec) !Selected {
+ const lang = spec.language();
+ if (spec.compiled_query) |query| return .{ .lang = lang, .query = query };
+ var error_offset: u32 = 0;
+ const query = try ts.Query.create(lang, spec.query_src, &error_offset);
+ spec.compiled_query = query;
+ return .{ .lang = lang, .query = query };
+}
+
+fn forExt(ext: []const u8) !?Selected {
+ for (&minimal_specs) |*spec| {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
+ }
+ }
+ for (&full_specs) |*spec| {
+ for (spec.exts) |choice| {
+ if (std.ascii.eqlIgnoreCase(ext, choice)) return try ensure(spec);
+ }
+ }
+ return null;
+}
+
+/// compile the minimal-tier queries eagerly so the first .zig/.c open is fast
+pub fn compileStartupQueries() void {
+ if (!enabled) return;
+ for (&minimal_specs) |*spec| _ = ensure(spec) catch {};
+}
+
+fn synFor(name: []const u8) Syn {
+ for ([_]struct { []const u8, Syn }{
+ .{ "comment", .comment },
+ .{ "string", .string },
+ .{ "character", .string },
+ .{ "number", .number },
+ .{ "float", .number },
+ .{ "boolean", .number },
+ .{ "keyword", .keyword },
+ .{ "include", .keyword },
+ .{ "conditional", .keyword },
+ .{ "repeat", .keyword },
+ }) |m| {
+ if (std.mem.indexOf(u8, name, m[0]) != null) return m[1];
+ }
+ return .none;
+}
+
+pub fn highlightFile(gpa: std.mem.Allocator, path: []const u8, content: []const u8) ![]u8 {
+ return highlightFileRange(gpa, path, content, 0, content.len);
+}
+
+/// One Syn byte per content byte in [start, end). Caller frees.
+pub fn highlightFileRange(gpa: std.mem.Allocator, path: []const u8, content: []const u8, start_byte_raw: usize, end_byte_raw: usize) ![]u8 {
+ if (!enabled) return &.{};
+ const ext = std.fs.path.extension(path);
+ const selected = (forExt(ext) catch return &.{}) orelse return &.{};
+
+ const start_byte = @min(start_byte_raw, content.len);
+ const end_byte = @max(start_byte, @min(end_byte_raw, content.len));
+ const source = content[start_byte..end_byte];
+ const styles = try gpa.alloc(u8, source.len);
+ errdefer gpa.free(styles);
+ @memset(styles, 0);
+
+ const parser = ts.Parser.create();
+ defer parser.destroy();
+ parser.setLanguage(selected.lang) catch return styles;
+ const tree = parser.parseString(source, null) orelse return styles;
+ defer tree.destroy();
+
+ const cursor = ts.QueryCursor.create();
+ defer cursor.destroy();
+ cursor.exec(selected.query, tree.rootNode());
+ while (cursor.nextMatch()) |match| {
+ for (match.captures) |cap| {
+ const syn = synFor(selected.query.captureNameForId(cap.index) orelse "");
+ if (syn == .none) continue;
+ var b: usize = cap.node.startByte();
+ const end = @min(@as(usize, cap.node.endByte()), styles.len);
+ while (b < end) : (b += 1) styles[b] = @intFromEnum(syn);
+ }
+ }
+ return styles;
+}
diff --git a/src/tty.zig b/src/tty.zig
new file mode 100644
index 00000000..d9821343
--- /dev/null
+++ b/src/tty.zig
@@ -0,0 +1,386 @@
+//! The terminal shell: owns the event loop and all IO. Translates vaxis
+//! events into core events, performs the core's effects (fork ptys, write
+//! them, resize them), and hands the core's Surface to vaxis cell-for-cell —
+//! the canonical interface rendered with no interpretation.
+const std = @import("std");
+const posix = std.posix;
+const linux = std.os.linux;
+const vaxis = @import("vaxis");
+const pardes = @import("pardes.zig");
+
+extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int;
+extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int;
+extern "c" fn chdir(path: [*:0]const u8) c_int;
+extern "c" fn _exit(status: c_int) noreturn;
+
+pub const Command = struct {
+ pub var value: union(enum) {
+ nop,
+ quit,
+ tick,
+ key_press: vaxis.Key,
+ pty_read: struct { id: usize, bytes: []u8 },
+ pty_eof: usize,
+ winsize: vaxis.Winsize,
+ mouse: vaxis.Mouse,
+ paste: []const u8,
+ } = .nop;
+};
+const Loop = vaxis.Loop(@TypeOf(Command.value));
+
+const Pty = struct {
+ file: std.Io.File,
+ pid: posix.pid_t,
+ reader: std.Io.Future(anyerror!void),
+};
+
+pub fn run(init: std.process.Init, opts: pardes.Options) !void {
+ const io = init.io;
+ const gpa = init.gpa;
+
+ var tty_buf: [0x10000]u8 = undefined;
+ var tty = try vaxis.Tty.init(io, &tty_buf);
+ var vx = try vaxis.init(io, gpa, init.environ_map, .{ .system_clipboard_allocator = gpa });
+ defer vx.deinit(gpa, tty.writer());
+ try vx.enterAltScreen(tty.writer());
+ defer vx.exitAltScreen(tty.writer()) catch {};
+ try vx.setMouseMode(tty.writer(), true);
+
+ var frame_arena: std.heap.ArenaAllocator = .init(gpa);
+ defer frame_arena.deinit();
+
+ // stb_image allocator for image panes
+ pardes.image.start(io, gpa);
+ defer pardes.image.stop();
+
+ const core = if (opts.load_path) |lp| blk: {
+ const bytes = try @import("look.zig").readFile(gpa, lp);
+ defer gpa.free(bytes);
+ break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
+ } else try pardes.Pardes.init(gpa, opts);
+ defer core.deinit();
+
+ // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move)
+ writeFile(pardes.bash_rc);
+
+ var kitty_handles: [pardes.MAX_PANES]?vaxis.Image = @splat(null);
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ defer for (&ptys) |*slot| if (slot.*) |*pt| {
+ _ = linux.close(pt.file.handle);
+ slot.* = null;
+ };
+
+ // Perform the initial spawns BEFORE any worker thread exists: forkpty from
+ // a multithreaded process can wedge the child before exec.
+ var loop: Loop = .init(io, &tty, &vx);
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, false);
+
+ try loop.start();
+ defer loop.stop();
+ try loop.installResizeHandler();
+ vx.queryTerminal(tty.writer(), std.Io.Duration.fromMilliseconds(2)) catch {};
+
+ // now threads are fine: start a reader task per pty
+ for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| {
+ pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, &loop });
+ };
+
+ var pending: ?@TypeOf(Command.value) = .tick;
+ while (!core.quit) {
+ const event = if (pending) |ev| blk: {
+ pending = null;
+ break :blk ev;
+ } else try loop.nextEvent();
+ switch (event) {
+ .nop, .tick => {},
+ .quit => break,
+ .winsize => |ws| {
+ try vx.resize(gpa, tty.writer(), ws);
+ core.update(.{ .resize = .{ .cols = @intCast(ws.cols), .rows = @intCast(ws.rows) } });
+ },
+ .pty_read => |pr| {
+ core.update(.{ .output = .{ .pane = @intCast(pr.id), .bytes = pr.bytes } });
+ gpa.free(pr.bytes);
+ },
+ .pty_eof => |id| {
+ if (ptys[id]) |*pt| {
+ _ = linux.close(pt.file.handle);
+ ptys[id] = null;
+ }
+ core.update(.{ .eof = .{ .pane = @intCast(id) } });
+ },
+ .key_press => |key| core.update(.{ .key = .{
+ .cp = mapKey(effCp(key)),
+ .text = key.text orelse "",
+ .ctrl = key.mods.ctrl,
+ .alt = key.mods.alt,
+ } }),
+ .mouse => |m| {
+ const button: ?pardes.Mouse.Button = switch (m.button) {
+ .left => .left,
+ .middle => .middle,
+ .right => .right,
+ .wheel_up => .wheel_up,
+ .wheel_down => .wheel_down,
+ .none => .none, // button-less motion: hover tracking
+ else => null,
+ };
+ if (button) |b| core.update(.{ .mouse = .{
+ .button = b,
+ .kind = switch (m.type) {
+ .press => .press,
+ .release => .release,
+ .motion => .motion,
+ .drag => .drag,
+ },
+ .col = @intCast(m.col),
+ .row = @intCast(m.row),
+ } });
+ },
+ .paste => |bytes| {
+ core.update(.{ .paste = bytes });
+ gpa.free(@constCast(bytes));
+ },
+ }
+
+ drainEffects(core, &ptys, io, gpa, &loop, &vx, &tty, true);
+ if (core.quit) break;
+
+ // live cwd for tags/look: cheap /proc readlink per pane, per frame
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| {
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue;
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]);
+ };
+
+ // ---- render: surface -> vaxis, cell for cell ----
+ _ = frame_arena.reset(.retain_capacity);
+ const surface = try core.render(frame_arena.allocator());
+ const win = vx.window();
+ win.clear();
+ var y: u16 = 0;
+ while (y < surface.rows) : (y += 1) {
+ var x: u16 = 0;
+ while (x < surface.cols) : (x += 1) {
+ const cell = surface.at(x, y);
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
+ }
+ }
+ // pixel attachments (kitty graphics): transmit once per pane, then
+ // re-place every frame (placements aren't persistent)
+ for (surface.images[0..surface.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ if (kitty_handles[place.pane] == null and vx.caps.kitty_graphics) {
+ const enc = std.base64.standard.Encoder;
+ if (gpa.alloc(u8, enc.calcSize(place.rgba.len))) |b64| {
+ defer gpa.free(b64);
+ _ = enc.encode(b64, place.rgba);
+ kitty_handles[place.pane] = vx.transmitPreEncodedImage(tty.writer(), b64, @intCast(place.iw), @intCast(place.ih), .rgba) catch null;
+ } else |_| {}
+ }
+ if (kitty_handles[place.pane]) |h| {
+ const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h });
+ h.draw(child, .{ .scale = .contain }) catch {};
+ }
+ }
+ if (surface.cursor) |cur| win.showCursor(cur.x, cur.y);
+ try vx.render(tty.writer());
+ }
+}
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ loop: *Loop,
+ vx: *vaxis.Vaxis,
+ tty: *vaxis.Tty,
+ threads_ok: bool,
+) void {
+ while (core.nextEffect()) |effect| switch (effect) {
+ .spawn => |sp| {
+ std.debug.assert(ptys[sp.pane] == null);
+ const cwd = sp.cwd.slice();
+ var cwd_buf: [256:0]u8 = undefined;
+ var cwd_z: ?[*:0]const u8 = null;
+ if (cwd.len > 0) {
+ @memcpy(cwd_buf[0..cwd.len], cwd);
+ cwd_buf[cwd.len] = 0;
+ cwd_z = @ptrCast(&cwd_buf);
+ }
+ const child = forkShell(cwd_z, core.screen_h, core.screen_w);
+ ptys[sp.pane] = .{ .file = child.file, .pid = child.pid, .reader = .{ .any_future = null, .result = {} } };
+ // report the pane's starting directory back to the core (tags)
+ var lbuf: [1024]u8 = undefined;
+ var pbuf: [64]u8 = undefined;
+ if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{child.pid}, 0) catch null) |path| {
+ const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len);
+ const n: isize = @bitCast(rc);
+ if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]);
+ }
+ if (threads_ok) {
+ if (ptys[sp.pane]) |*pt| {
+ pt.reader = io.concurrent(readPty, .{ io, gpa, pt.file, @as(usize, sp.pane), loop }) catch pt.reader;
+ }
+ }
+ },
+ .write => |w| {
+ if (ptys[w.pane]) |pt| writeFd(pt.file.handle, w.bytes.slice());
+ },
+ .resize_pty => |rs| {
+ if (ptys[rs.pane]) |pt| {
+ const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 };
+ _ = posix.system.ioctl(pt.file.handle, posix.T.IOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => {}, // tty: no link handling (look opens panes instead)
+ .save_file => |sf| {
+ const pane = core.panes[sf.pane] orelse continue;
+ const f = pane.file orelse continue;
+ var pathbuf: [4096:0]u8 = undefined;
+ if (f.path.len >= pathbuf.len) continue;
+ @memcpy(pathbuf[0..f.path.len], f.path);
+ pathbuf[f.path.len] = 0;
+ const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, f.content);
+ _ = linux.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) continue;
+ const fd: c_int = @intCast(sfd);
+ writeFd(fd, out);
+ _ = linux.close(fd);
+ },
+ .set_clipboard => {
+ // mirror the core's yank register out via OSC 52
+ if (core.yank) |y| vx.copyToSystemClipboard(tty.writer(), y, gpa) catch {};
+ },
+ .get_clipboard => vx.requestSystemClipboard(tty.writer()) catch {},
+ .quit => {},
+ };
+}
+
+fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) struct { file: std.Io.File, pid: posix.pid_t } {
+ 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) {
+ if (cwd) |c| _ = chdir(c);
+ const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv("/usr/bin/bash", &argv);
+ _exit(127);
+ }
+ return .{ .file = .{ .handle = master, .flags = .{ .nonblocking = false } }, .pid = pid };
+}
+
+fn readPty(io: std.Io, gpa: std.mem.Allocator, pty: std.Io.File, id: usize, loop: *Loop) anyerror!void {
+ var read_buf: [0x10000]u8 = undefined;
+ var reader = pty.readerStreaming(io, &read_buf);
+ while (true) {
+ var buf: [0x10000]u8 = undefined;
+ var vec = [_][]u8{&buf};
+ const n = reader.interface.readVec(&vec) catch break;
+ if (n == 0) break;
+ const bytes = try gpa.dupe(u8, buf[0..n]);
+ loop.postEvent(.{ .pty_read = .{ .id = id, .bytes = bytes } }) catch {
+ gpa.free(bytes);
+ break;
+ };
+ }
+ loop.postEvent(.{ .pty_eof = id }) catch {};
+}
+
+/// The effective codepoint the way vaxis Key.matches sees it: a single-char
+/// text wins (shift resolved by the terminal), else the shifted codepoint.
+fn effCp(key: vaxis.Key) u21 {
+ if (key.text) |t| {
+ const view = std.unicode.Utf8View.init(t) catch return key.codepoint;
+ var it = view.iterator();
+ if (it.nextCodepoint()) |c| {
+ if (it.nextCodepoint() == null) return c;
+ }
+ }
+ return key.shifted_codepoint orelse key.codepoint;
+}
+
+/// vaxis functional-key codepoints -> core Key constants (ASCII ones already
+/// coincide: enter/tab/escape/backspace pass through).
+fn mapKey(cp: u21) u21 {
+ return switch (cp) {
+ vaxis.Key.up => pardes.Key.up,
+ vaxis.Key.down => pardes.Key.down,
+ vaxis.Key.left => pardes.Key.left,
+ vaxis.Key.right => pardes.Key.right,
+ vaxis.Key.home => pardes.Key.home,
+ vaxis.Key.end => pardes.Key.end,
+ vaxis.Key.page_up => pardes.Key.page_up,
+ vaxis.Key.page_down => pardes.Key.page_down,
+ else => cp,
+ };
+}
+
+fn vaxisStyle(s: pardes.CellStyle) vaxis.Style {
+ return .{
+ .fg = vaxisColor(s.fg),
+ .bg = vaxisColor(s.bg),
+ .bold = s.bold,
+ .dim = s.dim,
+ .italic = s.italic,
+ .blink = s.blink,
+ .reverse = s.reverse,
+ .invisible = s.invisible,
+ .strikethrough = s.strikethrough,
+ .ul_style = switch (s.ul) {
+ .off => .off,
+ .single => .single,
+ .double => .double,
+ .curly => .curly,
+ .dotted => .dotted,
+ .dashed => .dashed,
+ },
+ };
+}
+
+fn vaxisColor(c: pardes.Color) vaxis.Color {
+ return switch (c) {
+ .default => .default,
+ .index => |i| .{ .index = i },
+ .rgb => |rgb| .{ .rgb = rgb },
+ };
+}
+
+fn writeFile(data: []const u8) void {
+ const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644);
+ const sfd: isize = @bitCast(rc);
+ if (sfd < 0) return;
+ const fd: c_int = @intCast(sfd);
+ defer _ = linux.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const rc = linux.write(fd, data[off..].ptr, data.len - off);
+ const n: isize = @bitCast(rc);
+ if (n < 0) {
+ if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}
diff --git a/src/tutor.txt b/src/tutor.txt
new file mode 100644
index 00000000..5a93d388
--- /dev/null
+++ b/src/tutor.txt
@@ -0,0 +1,412 @@
+
+ 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 three buttons; nothing like Vim/Helix.
+ PART 2 — the TTY. A terminal is just a pane; Ctrl-b drops into it
+ by default.
+ 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 each ACT on text, and the
+ keyboard mirrors them. Pardes inherits this. (Vim and Helix have almost
+ none of it — their mouse is selection/scroll only.)
+
+ ┌─────────┬─────────┬─────────┐
+ │ L │ M │ R │
+ │ (1) │ (2) │ (3) │
+ ├─────────┴─────────┴─────────┤
+ │ │
+ │ ( o ) │ the wheel scrolls the pane
+ │ │
+ └─────────────────────────────┘
+
+ L select a plain click also FOCUSES the pane and PINS the modal
+ cursor where you click, so you can edit there next.
+ M execute run the selected text: a builtin name runs it, anything
+ else is SENT to the shell the pane runs. This is how you
+ run a command you typed in a text mode.
+ R look open the word under the cursor as a path: a directory
+ opens/focuses a terminal there and ls's it; a file opens
+ a file pane (scrolled to a ":line" suffix if present).
+
+ SELECT-THEN-ACT: there is no held-button chord. Instead:
+ - MIDDLE-drag over text selects AND runs it on release (one gesture);
+ a no-drag middle click auto-expands to the word under the cursor.
+ - or select with the keyboard (`v` chars / `x` lines), then press
+ Tab to execute or Enter to look — the acme chords on the keyboard.
+
+ THE TAG: each pane has a one-line tag: its MODE (nm/in/sy) + directory
+ or file path + builtins. File panes show "Save Del" by default; Save
+ writes the current file to disk, Del closes the window. Terminal and
+ image panes show "Del". "Delcol" still exists as a command: type it in
+ a tag or body and execute it to close the whole column. A SEPARATE bar
+ across the top of the screen holds the window-agnostic builtins:
+ Kill Newcol Tutor Debug Colors NextColor
+ Middle-click "Newcol" for a new column, "Tutor" to spawn this tutor,
+ "Kill" to quit; Debug/Colors/NextColor toggle the stats overlay, the
+ syntax/ansi recolor, and the theme.
+ Right-click a directory in any body to open a terminal there.
+
+ LAYOUT: columns split the screen; windows stack within a column. Panes
+ abut with no wasted gap — a pane's own trailing edge (its last column, or
+ last row above the next tag) IS the resize handle: hover it and a dashed
+ line overlays that edge (keeping the underlying colors) to mark the drag.
+ - drag a column's right edge to resize it against its neighbour
+ - drag a window's bottom edge to resize it against the one below
+ - drag the accent box (top-left of a pane; indigo-purple, brighter
+ when the pane is focused) to MOVE a window between columns / reorder
+ - 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) prompt rows are HIDDEN — a clean acme-style page. You
+ navigate it with the same h/j/k/l/w/b/e as a file.
+ INSERT (in) same clean page; keys type an insertion overlay (the
+ command you're composing). Prompts still hidden.
+ TTY (sy) the REAL shell — prompts + typed input shown, and keys
+ go straight to the pty as terminal input.
+
+ ESC: insert -> normal. (Esc never reaches tty.)
+ Ctrl-b: toggles TTY on a terminal by default — the ONLY way in or out.
+ Use `pardes --tty-toggle=g` (or another letter) to make Ctrl-g
+ the toggle instead. Entering is tty-native: if the shell is at
+ a prompt and your modal cursor sits on the input line, it first
+ moves the shell's REAL cursor to that spot —
+ - empty input -> shell cursor at the prompt START
+ - typed text -> shell cursor at/after the char you were on
+ via the shell's OSC 133 semantic prompt (ghostty promptClickMove;
+ our rc opts in with cl=line). The shell moves its own cursor with
+ arrow keys it understands — Pardes doesn't fake one on top.
+
+ ENTER / TAB (in normal) are NOT a way into tty — they're the acme mouse
+ chords on the keyboard: Enter = "look" (open the path under the cursor),
+ Tab = "execute". Both act on the selection if one is active.
+
+ So the flow is: navigate the hidden-prompt page in normal, hit the tty
+ toggle where you want to keep typing, and you're IN the live shell at
+ that spot. No separate "terminal mode cursor" to reconcile.
+
+ (The cursor positioning is enterTty's promptClickMove; it needs a live
+ shell at a prompt. Mouse/tty behavior is exercised by the e2e suite.)
+
+
+=================================================================
+= 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:
+
+ - Selection is LINE-first: `x` grows a line selection downward.
+ There's also `v` for a CHARACTER range. No multi-cursor. d/c/y act
+ on whichever selection is active (else the current line).
+ - NO verb+noun (Vim's dw, cw). Motions only MOVE. To delete a word,
+ select it (`v` then motions, or `x` for whole lines) then `d`.
+ - ESC never reaches tty; it only does insert -> normal. Dropping a
+ terminal into the live shell is the tty toggle (Ctrl-b by default;
+ see Part 2).
+ - Enter / Tab in normal mirror the mouse: Enter = look (open the path
+ under the cursor), Tab = execute. `u` undo, `U` redo.
+ - 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 is LINE-first selection: `x` grows a line
+ selection downward; d/c/y act on it. `v` is still available for a
+ character range when you need one.
+
+ `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 full page down. (Ctrl-b pages up on a file pane;
+ on a terminal it is the default tty toggle. If you
+ start with --tty-toggle=g, Ctrl-g takes that role.)
+ 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)
+ select-then-act: middle-drag, or v/x then Tab
+ file tag = mode + path + "Save Del"; other tags show Del
+ top bar = Kill Newcol Tutor Debug Colors NextColor
+ TTY: a terminal IS a pane; Ctrl-b toggles the live shell by default,
+ landing its cursor where you navigated (prompt start if
+ the input is empty, mid-text otherwise)
+ KEYS (helix): h j k l w b e 0 $ ^ gg G v x d c y p i a I A o O
+ u undo U redo Enter=look Tab=execute
+ zt zz zb Ctrl-d/u Ctrl-f Ctrl-w hjkl Alt-n/c
+
+ vs Helix: no multi-cursor; selection is LINE-first (x), plus v chars.
+ 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 the tty toggle reuses the
+ shell's own prompt to position you precisely.
+
+ To spawn THIS tutor again from anywhere: middle-click "Tutor" in the
+ top bar (next to Kill / Newcol).
+
+ Quit the tutor: this is a file pane — `:q` isn't wired; close the
+ window (middle-click "Del" in its tag), "Kill" (top bar) to quit
+ everything, or Ctrl-c the app.