diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-05 20:21:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | de4def3548a6729b0dfd2120495a61beef8c8c2c (patch) | |
| tree | 12144f0f64dc96bb4741459c1f9db57f44349330 /src | |
| parent | 7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff) | |
| download | pardes-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.zig | 218 | ||||
| -rw-r--r-- | src/gui.zig | 2399 | ||||
| -rw-r--r-- | src/image.zig | 53 | ||||
| -rw-r--r-- | src/look.zig | 123 | ||||
| -rw-r--r-- | src/main.zig | 102 | ||||
| -rw-r--r-- | src/modal.zig | 964 | ||||
| -rw-r--r-- | src/pardes.zig | 3728 | ||||
| -rw-r--r-- | src/petscii.zig | 446 | ||||
| -rw-r--r-- | src/syntax.zig | 173 | ||||
| -rw-r--r-- | src/tty.zig | 386 | ||||
| -rw-r--r-- | src/tutor.txt | 412 |
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. |
