summaryrefslogtreecommitdiff
path: root/src/gui
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-30 23:35:45 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit19f7322100062b7c1adcde3376063ce6c1d8c72d (patch)
treeb76347dffe0347c29a38b4cad6031678683dba74 /src/gui
parent753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff)
downloadpardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz
pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a file being in no directory at all is what says it is core. Pane-kind bodies leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving it the layout, the event/effect machine and the generic render loop. Builtins are one struct each in builtins.zig, and the enum is folded out of the file's own declaration list at comptime — a zig file IS a struct, so the list of builtins and the builtins themselves are the same text. Adding one is writing a struct. Key paths deliberately stay one table for the config pass. Pure refactor: no golden moved.
Diffstat (limited to 'src/gui')
-rw-r--r--src/gui/crt.zig43
-rw-r--r--src/gui/deck.zig204
-rw-r--r--src/gui/gui.zig3103
3 files changed, 3350 insertions, 0 deletions
diff --git a/src/gui/crt.zig b/src/gui/crt.zig
new file mode 100644
index 00000000..9cf81593
--- /dev/null
+++ b/src/gui/crt.zig
@@ -0,0 +1,43 @@
+//! The Crt builtin's screen geometry, shared between the gui shell's input
+//! mapping and its shader: crt.frag.glsl shows the scene point
+//! uv' = cc*(1 + barrel*r2) at screen point cc, so a physical mouse position
+//! must pass through the SAME forward transform before it becomes a cell.
+//! Pure std, so the unit-test step can pin it against the shader formula.
+
+const std = @import("std");
+
+/// MUST match BARREL in shaders/crt.frag.glsl
+pub const barrel: f32 = 0.012;
+
+pub const Point = struct { x: f32, y: f32 };
+
+/// physical window pixels -> the scene pixels the shader displays there,
+/// clamped to the window. The shader's phosphor quantization and chromatic
+/// offset displace at most a few pixels — well under a cell — and are
+/// ignored here.
+pub fn map(x: f32, y: f32, w: f32, h: f32) Point {
+ const ccx = x / w * 2.0 - 1.0;
+ const ccy = y / h * 2.0 - 1.0;
+ const s = 1.0 + barrel * (ccx * ccx + ccy * ccy);
+ return .{
+ .x = std.math.clamp((ccx * s + 1.0) * 0.5 * w, 0, w - 1),
+ .y = std.math.clamp((ccy * s + 1.0) * 0.5 * h, 0, h - 1),
+ };
+}
+
+test "map matches the shader's barrel formula" {
+ // center is a fixed point
+ try std.testing.expectApproxEqAbs(@as(f32, 500), map(500, 250, 1000, 500).x, 0.001);
+ try std.testing.expectApproxEqAbs(@as(f32, 250), map(500, 250, 1000, 500).y, 0.001);
+ // corners: cc=(±1,±1), r2=2, scale 1.024 pushes past the tube -> clamped
+ try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).x);
+ try std.testing.expectEqual(@as(f32, 0), map(0, 0, 1000, 500).y);
+ try std.testing.expectEqual(@as(f32, 999), map(1000, 500, 1000, 500).x);
+ try std.testing.expectEqual(@as(f32, 499), map(1000, 500, 1000, 500).y);
+ // (100,100) of 1000x500: cc=(-0.8,-0.6), r2=1.0, s=1.012 ->
+ // cc'=(-0.8096,-0.6072) -> (95.2, 98.2)
+ try std.testing.expectApproxEqAbs(@as(f32, 95.2), map(100, 100, 1000, 500).x, 0.01);
+ try std.testing.expectApproxEqAbs(@as(f32, 98.2), map(100, 100, 1000, 500).y, 0.01);
+ // (900,250): cc=(0.8,0), r2=0.64, s=1.00768 -> x'=903.072
+ try std.testing.expectApproxEqAbs(@as(f32, 903.072), map(900, 250, 1000, 500).x, 0.01);
+}
diff --git a/src/gui/deck.zig b/src/gui/deck.zig
new file mode 100644
index 00000000..1859bae8
--- /dev/null
+++ b/src/gui/deck.zig
@@ -0,0 +1,204 @@
+//! Steam Deck controls -> editor input, as a pure state machine: gui.zig
+//! feeds SDL gamepad events in and applies the returned actions (cursor
+//! moves, synthetic clicks, wheel ticks, keys, rumble), so the whole
+//! mapping replays off-device from deckcap recordings (test/deck/).
+//!
+//! Layout: right pad moves the pointer (relative, laptop-touchpad-style) and
+//! a firm press on it = select (button 1); left pad scrolls and a firm press
+//! = look (button 3); R2 = select, L2 = execute (button 2), L1 = look, dpad
+//! down/up = n/N, A = Enter, Y = Tab, the back paddles (L4/R4/L5/R5) toggle
+//! the terminal (Ctrl-b); execute, look and pad-clicks request a brief rumble
+//! ack. (The GUI mirrors this on the sticks: right stick = pointer, left
+//! stick = scroll.)
+
+const std = @import("std");
+
+// SDL_GamepadButton / SDL_GamepadAxis / touchpad indices (SDL3), verified
+// against real deck recordings (test/deck/*.zon) and the pinned SDL's
+// hidapi deck driver (touchpad 0 = left pad, 1 = right pad).
+pub const BTN_A = 0; // SOUTH
+pub const BTN_Y = 3; // NORTH
+pub const BTN_L1 = 9; // LEFT_SHOULDER
+pub const BTN_DPAD_UP = 11;
+pub const BTN_DPAD_DOWN = 12;
+// rear grip paddles (from a deckcap deckmap capture): the two-digit indices
+pub const BTN_R4 = 16;
+pub const BTN_L4 = 17;
+pub const BTN_R5 = 18;
+pub const BTN_L5 = 19;
+pub const AXIS_L2 = 4; // LEFT_TRIGGER, analog 0..32767
+pub const AXIS_R2 = 5; // RIGHT_TRIGGER
+pub const PAD_LEFT = 0;
+pub const PAD_RIGHT = 1;
+
+/// feel knobs: a full-pad finger sweep covers this many screen pixels
+/// (the deck's screen is 1280x800) / this many wheel ticks
+pub const POINTER_SCALE: f32 = 600.0;
+pub const SCROLL_SCALE: f32 = 24.0;
+/// the pinned SDL 3.4.4 deck driver skips upstream's pad-Y negation, so
+/// touch y arrives 0 = pad bottom; flip this when the sdl dep is bumped
+pub const PAD_Y_SIGN: f32 = -1.0;
+// analog triggers click at 30% and release at 20%: the gap keeps a value
+// hovering near one threshold from chattering press/release pairs
+pub const TRIG_PRESS: i16 = 9830;
+pub const TRIG_RELEASE: i16 = 6553;
+// a firm pad press (pressure 0..1) counts as a click, with the same release
+// hysteresis. Real light-touch drags read ~0.03 in captures, so this is well
+// clear of them; tune on-device against a real click capture.
+pub const PAD_CLICK_PRESS: f32 = 0.10;
+pub const PAD_CLICK_RELEASE: f32 = 0.06;
+
+pub const Input = union(enum) {
+ button: struct { idx: u8, down: bool },
+ axis: struct { idx: u8, value: i16 },
+ touch: struct { pad: u8, phase: enum { down, motion, up }, x: f32, y: f32, pressure: f32 },
+};
+
+pub const Click = enum { select, execute, look }; // mouse buttons 1/2/3
+pub const Keytap = enum { n, cap_n, enter, tab, tty_toggle };
+
+pub const Action = union(enum) {
+ move: struct { dx: f32, dy: f32 }, // pointer delta in screen pixels
+ click: struct { which: Click, down: bool },
+ wheel: i32, // whole ticks, positive = wheel_down
+ key: Keytap,
+ rumble,
+};
+
+pub const Deck = struct {
+ rpad_on: bool = false,
+ rpad_x: f32 = 0,
+ rpad_y: f32 = 0,
+ lpad_on: bool = false,
+ lpad_y: f32 = 0,
+ scroll: f32 = 0, // fractional wheel ticks carried between motions
+ l2_held: bool = false,
+ r2_held: bool = false,
+ look_held: bool = false,
+ rpad_click: bool = false, // firm-press click state, per pad (hysteresis)
+ lpad_click: bool = false,
+
+ pub fn feed(d: *Deck, in: Input, out: *[3]Action) usize {
+ var n: usize = 0;
+ switch (in) {
+ .button => |b| switch (b.idx) {
+ BTN_A => if (b.down) {
+ out[0] = .{ .key = .enter };
+ n = 1;
+ },
+ BTN_Y => if (b.down) {
+ out[0] = .{ .key = .tab };
+ n = 1;
+ },
+ BTN_DPAD_UP => if (b.down) {
+ out[0] = .{ .key = .cap_n };
+ n = 1;
+ },
+ BTN_DPAD_DOWN => if (b.down) {
+ out[0] = .{ .key = .n };
+ n = 1;
+ },
+ // any back paddle toggles the terminal (Ctrl-b), applied in gui.zig
+ BTN_L4, BTN_R4, BTN_L5, BTN_R5 => if (b.down) {
+ out[0] = .{ .key = .tty_toggle };
+ n = 1;
+ },
+ BTN_L1 => {
+ d.look_held = b.down;
+ out[0] = .{ .click = .{ .which = .look, .down = b.down } };
+ n = 1;
+ if (b.down) {
+ out[1] = .rumble;
+ n = 2;
+ }
+ },
+ else => {},
+ },
+ .axis => |a| {
+ if (a.idx != AXIS_L2 and a.idx != AXIS_R2) return 0;
+ const held = if (a.idx == AXIS_L2) &d.l2_held else &d.r2_held;
+ const which: Click = if (a.idx == AXIS_L2) .execute else .select;
+ if (!held.* and a.value >= TRIG_PRESS) {
+ held.* = true;
+ out[0] = .{ .click = .{ .which = which, .down = true } };
+ n = 1;
+ if (which == .execute) {
+ out[1] = .rumble;
+ n = 2;
+ }
+ } else if (held.* and a.value <= TRIG_RELEASE) {
+ held.* = false;
+ out[0] = .{ .click = .{ .which = which, .down = false } };
+ n = 1;
+ }
+ },
+ .touch => |t| {
+ switch (t.pad) {
+ PAD_RIGHT => switch (t.phase) {
+ // a fresh touch only re-anchors — deltas start from the
+ // landing point, so the pointer never jumps to it
+ .down => {
+ d.rpad_on = true;
+ d.rpad_x = t.x;
+ d.rpad_y = t.y;
+ },
+ .motion => if (d.rpad_on) {
+ out[n] = .{ .move = .{
+ .dx = (t.x - d.rpad_x) * POINTER_SCALE,
+ .dy = (t.y - d.rpad_y) * PAD_Y_SIGN * POINTER_SCALE,
+ } };
+ n += 1;
+ d.rpad_x = t.x;
+ d.rpad_y = t.y;
+ },
+ .up => d.rpad_on = false,
+ },
+ PAD_LEFT => switch (t.phase) {
+ .down => {
+ d.lpad_on = true;
+ d.lpad_y = t.y;
+ },
+ .motion => if (d.lpad_on) {
+ d.scroll += (t.y - d.lpad_y) * PAD_Y_SIGN * SCROLL_SCALE;
+ d.lpad_y = t.y;
+ const ticks: i32 = @intFromFloat(d.scroll);
+ if (ticks != 0) {
+ d.scroll -= @floatFromInt(ticks);
+ out[n] = .{ .wheel = ticks };
+ n += 1;
+ }
+ },
+ .up => d.lpad_on = false,
+ },
+ else => {},
+ }
+ // a firm press on either pad is a click: right pad -> select
+ // (left button), left pad -> look (right button). The press
+ // rumbles as click feedback, mirroring the stock driver (SDL
+ // exposes no per-pad haptic, so it's a short whole-pad buzz).
+ if (t.pad == PAD_RIGHT or t.pad == PAD_LEFT) {
+ const which: Click = if (t.pad == PAD_RIGHT) .select else .look;
+ const held = if (t.pad == PAD_RIGHT) &d.rpad_click else &d.lpad_click;
+ if (t.phase == .up) {
+ if (held.*) {
+ held.* = false;
+ out[n] = .{ .click = .{ .which = which, .down = false } };
+ n += 1;
+ }
+ } else if (!held.* and t.pressure >= PAD_CLICK_PRESS) {
+ held.* = true;
+ out[n] = .{ .click = .{ .which = which, .down = true } };
+ n += 1;
+ out[n] = .rumble;
+ n += 1;
+ } else if (held.* and t.pressure <= PAD_CLICK_RELEASE) {
+ held.* = false;
+ out[n] = .{ .click = .{ .which = which, .down = false } };
+ n += 1;
+ }
+ }
+ },
+ }
+ return n;
+ }
+};
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
new file mode 100644
index 00000000..bca0ebb9
--- /dev/null
+++ b/src/gui/gui.zig
@@ -0,0 +1,3103 @@
+//! 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), 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 libc = std.c;
+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 look = @import("../look.zig");
+const deck = @import("deck.zig");
+const crt = @import("crt.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 setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int;
+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;
+
+// absolute shell path per OS: execv must not search PATH (no allocation
+// between fork and exec)
+const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash";
+// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize)
+const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467));
+
+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");
+const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv");
+const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.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 };
+
+// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/
+// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set
+// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap.
+// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white;
+// mask=0 transparent — so data = set and mask = set|clr.
+const p9_arrow_clr = [32]u8{
+ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C,
+ 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04,
+ 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04,
+ 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40,
+};
+const p9_arrow_set = [32]u8{
+ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0,
+ 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8,
+ 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8,
+ 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00,
+};
+const p9_arrow_mask = blk: {
+ var m: [32]u8 = undefined;
+ for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl;
+ break :blk m;
+};
+
+// 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;
+
+// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too
+const atlas_w: u32 = 2048;
+const atlas_h: u32 = 2048;
+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_click_flash_max_frames: u8 = 14;
+const touch_click_flash_vertices: usize = 1400;
+const touch_scroll_tick: f32 = 0.02;
+/// Web tap-vs-scroll heuristic, measured after normalized coordinates have
+/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale).
+const touch_tap_slop_px: f32 = 12.0;
+const max_overlay_vertices: usize = touch_click_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 + shared scroll/tap machines ----
+
+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 = .{},
+ } = .{},
+ // Web policy: one primary finger is either a body tap/scroll or a latched
+ // left-mouse gesture when it begins on layout chrome. A second simultaneous
+ // finger cancels a body tap rather than producing a click.
+ single: struct {
+ active: bool = false,
+ id: u64 = 0,
+ start: TouchNormPoint = .{},
+ last: TouchNormPoint = .{},
+ accum_y: f32 = 0,
+ scrolling: bool = false,
+ mouse_drag: bool = false,
+ multitouch: bool = false,
+ } = .{},
+ click_count: u32 = 0,
+ click_button: pardes.Mouse.Button = .middle,
+ click_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 activeCount(t: *const Touch) usize {
+ var count: usize = 0;
+ for (&t.points) |*tp| count += @intFromBool(tp.active);
+ return count;
+ }
+
+ 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;
+ const ticks = takeScrollTicks(&t.scroll.accum_y);
+ if (ticks == 0) return null;
+ t.scroll.scrolled = true;
+ return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
+ }
+
+ fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void {
+ if (t.single.active) {
+ if (t.single.id != f.id) t.single.multitouch = true;
+ return;
+ }
+ if (t.activeCount() != 1) return;
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag };
+ }
+
+ fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } {
+ if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null;
+ const center = TouchNormPoint{ .x = f.x, .y = f.y };
+ var delta_y = center.y - t.single.last.y;
+ t.single.last = center;
+ if (!t.single.scrolling) {
+ const dx = (center.x - t.single.start.x) * win_w;
+ const dy = (center.y - t.single.start.y) * win_h;
+ if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null;
+ // Once the pointer leaves the tap slop it can never click. Include
+ // all travel since finger-down so the first scroll tick is prompt.
+ t.single.scrolling = true;
+ delta_y = center.y - t.single.start.y;
+ }
+ t.single.accum_y += delta_y;
+ const ticks = takeScrollTicks(&t.single.accum_y);
+ if (ticks == 0) return null;
+ return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick };
+ }
+
+ fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint {
+ if (!t.single.active or t.single.id != f.id) return null;
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ const dx = (point.x - t.single.start.x) * win_w;
+ const dy = (point.y - t.single.start.y) * win_h;
+ const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px;
+ const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch;
+ t.single = .{};
+ return if (tap) point else null;
+ }
+};
+
+fn takeScrollTicks(accum: *f32) i32 {
+ var ticks: i32 = 0;
+ while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick;
+ while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick;
+ return ticks;
+}
+
+/// The SDL boundary owns touch policy: a web body uses one-finger natural
+/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag;
+/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse
+/// events and coordinate mapping.
+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);
+ if (is_emscripten)
+ return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h);
+ return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h);
+}
+
+fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ switch (f.kind) {
+ .down => {
+ t.updatePoint(f);
+ const point = TouchNormPoint{ .x = f.x, .y = f.y };
+ const col = normCell(point.x, win_w, cell_w);
+ const row = normCell(point.y, win_h, cell_h);
+ const chrome = webChromeTarget(core, col, row);
+ t.beginSingle(f, chrome != null);
+ if (chrome) |target|
+ core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } });
+ },
+ .motion => {
+ t.updatePoint(f);
+ if (t.single.active and t.single.id == f.id and t.single.mouse_drag) {
+ core.update(.{ .mouse = .{
+ .button = .left,
+ .kind = .drag,
+ .col = normCell(f.x, win_w, cell_w),
+ .row = normCell(f.y, win_h, cell_h),
+ } });
+ return;
+ }
+ const res = t.noteSingleMotion(f, win_w, win_h) orelse return;
+ emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h);
+ },
+ .up, .cancel => {
+ const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag;
+ const tap = t.finishSingle(f, win_w, win_h);
+ t.updatePoint(f);
+ if (mouse_drag) {
+ core.update(.{ .mouse = .{
+ .button = .left,
+ .kind = .release,
+ .col = normCell(f.x, win_w, cell_w),
+ .row = normCell(f.y, win_h, cell_h),
+ } });
+ } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h);
+ },
+ }
+}
+
+const WebChromeTarget = struct { col: u16, row: u16 };
+
+/// Fat-finger hit test for web layout chrome. A gesture is classified once at
+/// finger-down, then never changes into scrolling. Handle coordinates are
+/// snapped onto the core's one-cell resize edge; body rows remain untouched.
+fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget {
+ if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row };
+
+ // The move box wins over a horizontal handle when a pane is tag-only,
+ // matching the core's own mouse hit-test priority.
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER)
+ return .{ .col = col, .row = rect.y };
+ }
+
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (col == handle) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (row == handle) return .{ .col = col, .row = handle };
+ }
+ }
+
+ // The remainder of each tag row is tag editing, not a scrollable body.
+ for (core.panes, 0..) |slot, id| {
+ if (slot == null) continue;
+ const rect = core.rects[id];
+ if (row == rect.y and col >= rect.x and col < rect.x + rect.w)
+ return .{ .col = col, .row = rect.y };
+ }
+
+ // One-cell tolerance around thin separators, after exact tag rows have had
+ // first refusal so a fat-finger tag edit never turns into a resize.
+ for (0..core.ncol -| 1) |column| {
+ const handle = core.col_x[column] + core.col_w[column] -| 1;
+ if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row };
+ }
+ for (0..core.ncol) |column| {
+ if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue;
+ for (0..core.col_n[column] -| 1) |index| {
+ const rect = core.rects[core.col_terms[column][index]];
+ const handle = rect.y + rect.h -| 1;
+ if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle };
+ }
+ }
+ return null;
+}
+
+fn cellDistance(a: u16, b: u16) u16 {
+ return if (a > b) a - b else b - a;
+}
+
+fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ 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;
+ emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h);
+ },
+ .up, .cancel => {
+ const tap_center = t.finishPair(f);
+ t.updatePoint(f);
+ t.syncPair();
+ if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h);
+ },
+ }
+}
+
+fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ const col = normCell(center.x, win_w, cell_w);
+ const row = normCell(center.y, win_h, cell_h);
+ const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down;
+ var remaining: u32 = @abs(ticks);
+ while (remaining > 0) : (remaining -= 1)
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .touch_scroll = delta_y });
+}
+
+fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void {
+ t.click_count +%= 1;
+ t.click_button = button;
+ t.click_flash_frames = touch_click_flash_max_frames;
+ const col = normCell(point.x, win_w, cell_w);
+ const row = normCell(point.y, win_h, cell_h);
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } });
+ core.update(.{ .mouse = .{ .button = button, .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: libc.pid_t };
+
+const Msg = union(enum) {
+ output: struct { pane: u8, gen: u32, bytes: []u8 },
+ eof: struct { pane: u8, gen: u32, fd: c_int },
+ /// a language query finished on its own thread (see lspThread)
+ lsp: struct { id: u32, rows: []u8 },
+};
+
+/// One language query, owned by the thread running it — the gui twin of
+/// tty.zig's LspJob, and copied for the same reason: the core edits on.
+const LspJob = struct {
+ id: u32,
+ kind: pardes.lsp.Kind,
+ offset: u32,
+ path: []u8,
+ source: [:0]u8,
+ arg: []u8,
+ root: []u8,
+
+ fn free(j: *LspJob, gpa: std.mem.Allocator) void {
+ gpa.free(j.path);
+ gpa.free(j.source);
+ gpa.free(j.arg);
+ gpa.free(j.root);
+ gpa.destroy(j);
+ }
+};
+
+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),
+ .lsp => |l| q.gpa.free(l.rows),
+ .eof => {},
+ }
+ return;
+ }
+ q.items.append(q.gpa, m) catch {
+ q.mutex.unlock();
+ switch (m) {
+ .output => |o| q.gpa.free(o.bytes),
+ .lsp => |l| q.gpa.free(l.rows),
+ .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),
+ .lsp => |l| q.gpa.free(l.rows),
+ .eof => {},
+ };
+ q.items.deinit(q.gpa);
+ }
+};
+
+fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void {
+ var buf: [0x10000]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &buf, buf.len);
+ if (n < 0) {
+ if (libc.errno(n) == .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, .gen = gen, .bytes = bytes } });
+ }
+ q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } });
+}
+
+fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void {
+ const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return;
+ th.detach();
+}
+
+/// Answer a language query off the render loop and push the rows to the queue
+/// — the async execution model, spelled in the plumbing this shell already has
+/// (a detached thread and the mutex queue the pty readers use).
+fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void {
+ defer job.free(gpa);
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ // the shell owns the result buffer; the backend only ever writes to it
+ var out: std.Io.Writer.Allocating = .init(gpa);
+ defer out.deinit();
+ pardes.lsp.query(gpa, arena.allocator(), .{
+ .kind = job.kind,
+ .path = job.path,
+ .source = job.source,
+ .offset = job.offset,
+ .arg = job.arg,
+ .root = job.root,
+ }, &out.writer);
+ const rows = gpa.dupe(u8, out.written()) catch return;
+ q.push(.{ .lsp = .{ .id = job.id, .rows = rows } });
+}
+
+/// Copy the query out of the core and hand it to a thread. A detached thread
+/// per query is fine at this rate: one keystroke, one query, and the queue
+/// already tolerates a late push after close.
+fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void {
+ const pane = core.panes[e.pane] orelse return;
+ // a pane with no file still asks `status` (it is about the backend, not
+ // the buffer): empty path and source, root from the pane's cwd
+ const f = pane.file;
+ const job = gpa.create(LspJob) catch return;
+ job.* = .{
+ .id = e.id,
+ .kind = e.kind,
+ .offset = e.offset,
+ .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch {
+ gpa.destroy(job);
+ return;
+ },
+ .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch {
+ gpa.free(job.path);
+ gpa.destroy(job);
+ return;
+ },
+ .arg = gpa.dupe(u8, e.arg.slice()) catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.destroy(job);
+ return;
+ },
+ .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch {
+ gpa.free(job.path);
+ gpa.free(job.source);
+ gpa.free(job.arg);
+ gpa.destroy(job);
+ return;
+ },
+ };
+ const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch {
+ job.free(gpa);
+ 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,
+ crt_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,
+
+ // Crt builtin: the scene renders into this texture, then a fullscreen
+ // CRT pass warps it onto the real target
+ scene_tex: ?*c.SDL_GPUTexture = null,
+ scene_tex_w: u32 = 0,
+ scene_tex_h: u32 = 0,
+
+ // 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, // fractional vertical wheel ticks (left stick)
+ pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick)
+ deck: deck.Deck = .{},
+
+ // 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;
+ }
+
+ // The trackpads only exist through SDL's built-in HIDAPI Steam Deck
+ // driver (it registers the two touchpads and disables "lizard mode").
+ // It's default-on on Linux; pin it so intent is explicit. NOTE: this is
+ // NOT enough in Game Mode — Steam Input there hands the app a
+ // touchpad-less virtual gamepad instead of the real Neptune controller,
+ // so the pads go dead no matter what the app does. The only fix is to
+ // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller),
+ // after which the real controller enumerates and the touchpad events flow.
+ _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI
+ _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK
+ 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;
+ };
+ if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| {
+ _ = c.SDL_SetCursor(cur);
+ } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()});
+ // fullscreen deck use: keep the pointer inside the window. Absolute
+ // coords stay (no relative mode — clicks need positions).
+ if (!test_mode) _ = c.SDL_SetWindowMouseGrab(window, true);
+ 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 = 27.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 crt_pipeline = try makeCrtPipeline(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,
+ .crt_pipeline = crt_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)));
+ }
+ var 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);
+ // macos: apple's bash 3.2 prints the zsh-deprecation banner into every
+ // pane unless this is in the env BEFORE bash starts (the rc is too late)
+ if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ // per-slot spawn generation: drops a dead shell's late output/eof when its
+ // pane id has been respawned (see drainEffects .spawn)
+ var gens: [pardes.MAX_PANES]u32 = @splat(0);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = libc.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, &gens, gpa, &queue, &g, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[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| {
+ if (gens[o.pane] == o.gen)
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ },
+ .eof => |e| {
+ _ = libc.close(e.fd); // the dead reader's master — stale or current
+ if (gens[e.pane] == e.gen) {
+ ptys[e.pane] = null;
+ core.update(.{ .eof = .{ .pane = e.pane } });
+ }
+ },
+ .lsp => |l| {
+ core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
+ gpa.free(l.rows);
+ },
+ };
+ msgs.deinit(gpa);
+ // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
+ pollGamepad(&g, core);
+ // 4. effects
+ drainEffects(core, &ptys, &gens, gpa, &queue, &g, true);
+ if (core.quit) break;
+ // Restore builtin: swap in a core rebuilt from the dump; kill the live
+ // shells (their detached readers wake on child death; gens bumped so
+ // the stale eofs close the old fds without touching the replay panes)
+ if (core.takeRestore()) |rp| blk: {
+ const bytes = look.readFile(gpa, rp) catch break :blk;
+ defer gpa.free(bytes);
+ var o = core.opts;
+ o.cols = core.screen_w;
+ o.rows = core.screen_h; // pre-size: dump panes never greet
+ const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk;
+ for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = libc.kill(pt.pid, libc.SIG.KILL);
+ slot.* = null;
+ };
+ for (&gens) |*g2| g2.* +%= 1;
+ core.deinit();
+ core = nc;
+ }
+ // 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, core.crt_on, core.show_debug) 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,
+};
+var live_web_state: ?*WebState = null;
+
+// Browser snapshot tests drive real DOM touch input, then read the exact same
+// rendered Surface that WebGL consumed. These tiny exports keep the test
+// contract textual (the existing .snap convention) without adding test state
+// or browser concepts to the core.
+comptime {
+ if (is_emscripten) {
+ @export(&webTestCols, .{ .name = "pardes_web_test_cols" });
+ @export(&webTestRows, .{ .name = "pardes_web_test_rows" });
+ @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" });
+ @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" });
+ @export(&webTestCell, .{ .name = "pardes_web_test_cell" });
+ @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" });
+ @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" });
+ @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" });
+ @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" });
+ @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" });
+ }
+}
+
+fn webTestCols() callconv(.c) c_uint {
+ const st = live_web_state orelse return 0;
+ return st.core.surface.cols;
+}
+
+fn webTestRows() callconv(.c) c_uint {
+ const st = live_web_state orelse return 0;
+ return st.core.surface.rows;
+}
+
+fn webTestCursorX() callconv(.c) c_int {
+ const st = live_web_state orelse return -1;
+ return if (st.core.surface.cursor) |cursor| cursor.x else -1;
+}
+
+fn webTestCursorY() callconv(.c) c_int {
+ const st = live_web_state orelse return -1;
+ return if (st.core.surface.cursor) |cursor| cursor.y else -1;
+}
+
+fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return ' ';
+ const glyph = cell.grapheme();
+ if (glyph.len == 0) return ' ';
+ return std.unicode.utf8Decode(glyph) catch 0xfffd;
+}
+
+// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other
+// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs.
+fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0x01000000;
+ return webTestColor(cell.style.fg);
+}
+
+fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0x01000000;
+ return webTestColor(cell.style.bg);
+}
+
+fn webTestColor(color: pardes.Color) c_uint {
+ return switch (color) {
+ .default => 0x01000000,
+ .index => |idx| 0x02000000 | @as(c_uint, idx),
+ .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2],
+ };
+}
+
+fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint {
+ const cell = webTestCellAt(col, row) orelse return 0;
+ const style = cell.style;
+ var attrs: c_uint = 0;
+ if (style.bold) attrs |= 1 << 0;
+ if (style.dim) attrs |= 1 << 1;
+ if (style.italic) attrs |= 1 << 2;
+ if (style.blink) attrs |= 1 << 3;
+ if (style.reverse) attrs |= 1 << 4;
+ if (style.invisible) attrs |= 1 << 5;
+ if (style.strikethrough) attrs |= 1 << 6;
+ attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8;
+ return attrs;
+}
+
+fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell {
+ const st = live_web_state orelse return null;
+ const surface = &st.core.surface;
+ if (col >= surface.cols or row >= surface.rows) return null;
+ return surface.at(@intCast(col), @intCast(row));
+}
+
+fn webTestCellX(col: f32) callconv(.c) f32 {
+ return webTestCellCenter(col, true);
+}
+
+fn webTestCellY(row: f32) callconv(.c) f32 {
+ return webTestCellCenter(row, false);
+}
+
+fn webTestCellCenter(cell: f32, horizontal: bool) f32 {
+ const st = live_web_state orelse return 0;
+ var pixel_w: c_int = 0;
+ var pixel_h: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h);
+ const pw: u32 = @intCast(@max(pixel_w, 1));
+ const ph: u32 = @intCast(@max(pixel_h, 1));
+ var css_w: f64 = 0;
+ var css_h: f64 = 0;
+ if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0;
+ const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph);
+ if (horizontal) {
+ const backing = layout.x_off + (cell + 0.5) * layout.w;
+ return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw));
+ }
+ const backing = layout.y_off + (cell + 0.5) * layout.h;
+ return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph));
+}
+
+fn runWeb(opts_in: pardes.Options) !void {
+ web_fba = .init(&web_heap);
+ const gpa = web_fba.allocator();
+
+ // Our finger machine deliberately maps touch to scroll/LOOK. Suppress
+ // SDL's default synthetic left mouse event for the same browser touch.
+ _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0");
+ 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,
+ .crt_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);
+ live_web_state = st;
+
+ _ = 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, core.show_debug) 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);
+ // macos: apple's bash 3.2 prints the zsh-deprecation banner into every
+ // pane unless this is in the env BEFORE bash starts (the rc is too late)
+ if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1);
+
+ var ptys: [pardes.MAX_PANES]?Pty = @splat(null);
+ // per-slot spawn generation: drops a dead shell's late output/eof when its
+ // pane id has been respawned (see drainEffects .spawn)
+ var gens: [pardes.MAX_PANES]u32 = @splat(0);
+ defer for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = libc.close(pt.fd);
+ slot.* = null;
+ };
+ var queue: Queue = .{ .gpa = gpa, .sdl_wake = false };
+ defer queue.close();
+ drainEffects(core, &ptys, &gens, gpa, &queue, null, false);
+ for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[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| {
+ if (gens[o.pane] == o.gen)
+ core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } });
+ gpa.free(o.bytes);
+ n_events += 1;
+ },
+ .eof => |e| {
+ _ = libc.close(e.fd); // the dead reader's master — stale or current
+ if (gens[e.pane] == e.gen) {
+ ptys[e.pane] = null;
+ core.update(.{ .eof = .{ .pane = e.pane } });
+ }
+ n_events += 1;
+ },
+ .lsp => |l| {
+ core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
+ gpa.free(l.rows);
+ n_events += 1;
+ },
+ };
+ msgs.deinit(gpa);
+ drainEffects(core, &ptys, &gens, 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 rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill);
+ 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_in| {
+ var finger = finger_in;
+ if (g) |gp| {
+ var wpw: c_int = 0;
+ var wph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph);
+ const win_w: f32 = @floatFromInt(@max(wpw, 1));
+ const win_h: f32 = @floatFromInt(@max(wph, 1));
+ if (core.crt_on) {
+ // touch points are physical: same CRT mapping as the mouse
+ const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ finger.x = m.x / win_w;
+ finger.y = m.y / win_h;
+ }
+ handleFinger(&gp.touch, core, finger, win_w, win_h, @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,
+ .shift = key.mods.shift,
+ } });
+ 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,
+ .wheel_left => .wheel_left,
+ .wheel_right => .wheel_right,
+ .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)),
+ .ctrl = m.mods.ctrl,
+ } });
+ 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,
+ // asked of SDL directly rather than read off g.live_ctrl:
+ // that one is bookkeeping from KEY events, and a ctrl-click
+ // with no key pressed since startup would miss it
+ .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0,
+ },
+ });
+ },
+ c.SDL_EVENT_MOUSE_MOTION => {
+ const m = sev.motion;
+ g.mouse_x = m.x;
+ g.mouse_y = m.y;
+ // pad cursor continues from wherever the pointer last was
+ g.pad_x = m.x;
+ g.pad_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 and w.x == 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);
+ if (w.y != 0) core.update(.{ .mouse = .{
+ .button = if (w.y > 0) .wheel_up else .wheel_down,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ if (w.x != 0) core.update(.{ .mouse = .{
+ .button = if (w.x > 0) .wheel_right else .wheel_left,
+ .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 and core.crt_on) {
+ // touch points are physical too: same CRT mapping as the mouse
+ const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h);
+ finger.x = m.x / win_w;
+ finger.y = m.y / win_h;
+ }
+ if (is_emscripten) {
+ // remap normalized coords into the letterboxed grid so the
+ // shared touch 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;
+ }
+ },
+ // deck.zig maps buttons/triggers/trackpads to pointer, clicks,
+ // wheel, keys and rumble; this arm just applies its actions
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN,
+ c.SDL_EVENT_GAMEPAD_BUTTON_UP,
+ c.SDL_EVENT_GAMEPAD_AXIS_MOTION,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP,
+ => {
+ const in: deck.Input = switch (sev.type) {
+ c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{
+ .idx = sev.gbutton.button,
+ .down = sev.gbutton.down,
+ } },
+ c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{
+ .idx = sev.gaxis.axis,
+ .value = sev.gaxis.value,
+ } },
+ else => .{ .touch = .{
+ .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)),
+ .phase = switch (sev.type) {
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down,
+ c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up,
+ else => .motion,
+ },
+ .x = sev.gtouchpad.x,
+ .y = sev.gtouchpad.y,
+ .pressure = sev.gtouchpad.pressure,
+ } },
+ };
+ var acts: [3]deck.Action = undefined;
+ for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) {
+ .move => |mv| {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT
+ // mapping once at cell conversion, never into the pad
+ const old = mouseCell(g, core, g.pad_x, g.pad_y);
+ g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1);
+ g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1);
+ // the SDL cursor (plan9 arrow) rides along, so the pad
+ // cursor and a hardware mouse are one visible pointer
+ c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ if (mc.col == old.col and mc.row == old.row) continue;
+ // moving with a click held drags, so selections stretch
+ // (a firm right-pad press drags-selects like a laptop pad)
+ const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click)
+ .left
+ else if (g.deck.l2_held)
+ .middle
+ else if (g.deck.look_held)
+ .right
+ else
+ null;
+ core.update(.{ .mouse = .{
+ .button = held orelse .none,
+ .kind = if (held != null) .drag else .motion,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ .click => |ck| {
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ core.update(.{ .mouse = .{
+ .button = switch (ck.which) {
+ .select => .left,
+ .execute => .middle,
+ .look => .right,
+ },
+ .kind = if (ck.down) .press else .release,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ },
+ .wheel => |ticks| {
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ var left = ticks;
+ while (left != 0) {
+ left += if (ticks > 0) -1 else 1;
+ core.update(.{ .mouse = .{
+ .button = if (ticks > 0) .wheel_down else .wheel_up,
+ .kind = .press,
+ .col = mc.col,
+ .row = mc.row,
+ } });
+ }
+ },
+ .key => |k| core.update(.{
+ .key = switch (k) {
+ .n => .{ .cp = 'n', .text = "n" },
+ .cap_n => .{ .cp = 'N', .text = "N" },
+ .enter => .{ .cp = pardes.Key.enter },
+ .tab => .{ .cp = pardes.Key.tab },
+ // back paddle: Ctrl-<configured toggle key> flips tty mode
+ .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true },
+ },
+ }),
+ // a brief gentle ack for execute/look, not a buzz
+ .rumble => if (g.gamepad) |pad| {
+ _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80);
+ },
+ };
+ },
+ 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 shift = (mod & c.SDL_KMOD_SHIFT) != 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,
+ c.SDLK_DELETE => pardes.Key.delete,
+ 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, .shift = shift } });
+}
+
+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. With the Crt
+/// builtin on, the physical point first maps to the scene point the shader
+/// displays there (crt.map) — this is the one physical→logical spot shared
+/// by the real mouse and the deck's virtual cursor.
+fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } {
+ if (!is_emscripten) {
+ var mx = x;
+ var my = y;
+ if (core.crt_on) {
+ var pw: c_int = 0;
+ var ph: c_int = 0;
+ _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph);
+ const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1)));
+ mx = m.x;
+ my = m.y;
+ }
+ return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, 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, mirroring the trackpads —
+/// RIGHT stick moves the virtual cursor at ~cell granularity (emits
+/// button-less motion so hover works), LEFT stick accumulates into wheel
+/// ticks (both axes: vertical + horizontal) 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;
+ // right stick = pointer
+ const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX));
+ const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY));
+ const old = mouseCell(g, core, g.pad_x, g.pad_y);
+ // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the
+ // mouse-move sensitivity knob — raise for a faster pointer)
+ const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4;
+ 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);
+ // only on actual stick movement — an unconditional per-frame warp would
+ // pin the pointer and fight any hardware mouse
+ if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y);
+ const mc = mouseCell(g, core, g.pad_x, g.pad_y);
+ if (mc.col != old.col or mc.row != old.row)
+ core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } });
+ // left stick = scroll (both axes)
+ const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX));
+ const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY));
+ if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15;
+ if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 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 = mc.col, .row = mc.row } });
+ }
+ while (@abs(g.pad_scroll_h) >= 1.0) {
+ const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right;
+ g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0;
+ core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } });
+ }
+}
+
+// =====================================================================
+// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard
+// =====================================================================
+
+fn drainEffects(
+ core: *pardes.Pardes,
+ ptys: *[pardes.MAX_PANES]?Pty,
+ gens: *[pardes.MAX_PANES]u32,
+ 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| {
+ // the core reuses pane ids and there is no close effect: a deleted
+ // pane's shell lives in its slot until a respawn lands here. Kill
+ // it; its detached reader wakes on child death and the gen-guarded
+ // eof closes the old fd (not here — the reader still reads it).
+ if (ptys[sp.pane]) |old| {
+ _ = libc.kill(old.pid, libc.SIG.KILL);
+ ptys[sp.pane] = null;
+ }
+ gens[sp.pane] +%= 1;
+ 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;
+ if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd);
+ if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[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, TIOCSWINSZ, @intFromPtr(&ws));
+ }
+ },
+ .open_link => |url| look.openLink(url.slice()), // desktop browser
+ .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 fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) continue;
+ writeFd(fd, f.content);
+ _ = libc.close(fd);
+ },
+ .write_dump => {
+ const out = core.dump_out orelse continue;
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse continue;
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) continue;
+ writeFd(fd, out);
+ core.setLastDump(path);
+ _ = libc.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);
+ },
+ .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e),
+ .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 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null };
+ _ = execv(bash_path, &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;
+ if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd);
+ };
+}
+
+/// 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 — all no-ops
+ .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .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, crt_on: bool, debug_on: bool) !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);
+
+ // Crt on: the scene pass lands in an offscreen texture, then a second
+ // pass draws it through the CRT shader onto the real target
+ if (crt_on) try ensureSceneTexture(g, sw, sh);
+ const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target;
+
+ const overlay_count = buildTouchOverlay(g, sw, sh, debug_on);
+ 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 = scene;
+ 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;
+ const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
+ 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, cursor_bar);
+ }
+ }
+ 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 (crt_on) {
+ var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo);
+ crt_target.texture = target;
+ crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE;
+ crt_target.store_op = c.SDL_GPU_STOREOP_STORE;
+ const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null);
+ c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline);
+ const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.atlas_sampler };
+ c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1);
+ c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0);
+ 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,
+ cursor_bar: 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); an insert
+ // cursor keeps the cell colors and draws a bar glyph instead.
+ var fg = fg_default;
+ var bg = bg_default;
+ var reverse = is_cursor and !cursor_bar;
+ 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();
+ var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme);
+ // ponytail: the insert bar is the left-eighth block glyph over the cell
+ // (hides the char under it); a dedicated quad if that ever matters
+ if (is_cursor and cursor_bar) cp = 0x258F;
+ 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;
+}
+
+// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex
+// buffers at all (positions from gl_VertexIndex)
+fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline {
+ const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0);
+ defer c.SDL_ReleaseGPUShader(device, vs);
+ const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1);
+ defer c.SDL_ReleaseGPUShader(device, fs);
+
+ 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.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, debug_on: bool) !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, debug_on);
+ 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;
+ const cursor_bar = if (surface.cursor) |cu| cu.bar else false;
+ 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, cursor_bar);
+ }
+ }
+ 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 ensureSceneTexture(g: *Gui, width: u32, height: u32) !void {
+ if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return;
+ if (g.scene_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 | c.SDL_GPU_TEXTUREUSAGE_SAMPLER;
+ 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.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate;
+ g.scene_tex_w = width;
+ g.scene_tex_h = height;
+}
+
+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 fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return error.CaptureWriteFailed;
+ defer _ = libc.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);
+ }
+ if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed;
+}
+
+// =====================================================================
+// touch debug overlay: per-finger colored circles + trails + a click-action
+// flash HUD, alpha-blended over the grid only while the Debug builtin is on.
+// =====================================================================
+
+fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 {
+ if (!debug_on) {
+ g.touch.click_flash_frames = 0;
+ return 0;
+ }
+ 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.click_flash_frames != 0) {
+ const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames));
+ const label = touchClickLabel(t.click_count, t.click_button);
+ 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.click_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 touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 {
+ var out: [14]u8 = undefined;
+ @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #");
+ for (0..8) |i| {
+ const shift: u5 = @intCast((7 - i) * 4);
+ out[i + 6] = 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 },
+ 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 },
+ 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 },
+ 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 },
+ 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 },
+ 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,
+ vaxis.Key.delete => pardes.Key.delete,
+ 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 fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return;
+ defer _ = libc.close(fd);
+ writeFd(fd, data);
+}
+
+fn writeFd(fd: c_int, data: []const u8) void {
+ var off: usize = 0;
+ while (off < data.len) {
+ const n = libc.write(fd, data[off..].ptr, data.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return;
+ }
+ off += @intCast(n);
+ }
+}