diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-05 20:21:50 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | de4def3548a6729b0dfd2120495a61beef8c8c2c (patch) | |
| tree | 12144f0f64dc96bb4741459c1f9db57f44349330 /src/gui.zig | |
| parent | 7988d9bc6e31210ff13994f18d5622dd57ba9341 (diff) | |
| download | pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.tar.gz pardes-de4def3548a6729b0dfd2120495a61beef8c8c2c.zip | |
pardes v2: the rewrite, complete and organized. src/ (core + three shells), test/ (snapshot parity harness + 18 frozen goldens). One sans-IO core, vaxis tty + SDL3 GPU native + wasm web shells, 18/18 parity with the purged prototype, 7.6k lines vs 12.1k. Fix: gui shell pre-sized the core at init so the greet-releasing resize never fired (blank panes until first interaction); live sessions now init at defaults and get the real grid as a resize event (the shell contract, documented on Options).
Diffstat (limited to 'src/gui.zig')
| -rw-r--r-- | src/gui.zig | 2399 |
1 files changed, 2399 insertions, 0 deletions
diff --git a/src/gui.zig b/src/gui.zig new file mode 100644 index 00000000..516a6b79 --- /dev/null +++ b/src/gui.zig @@ -0,0 +1,2399 @@ +//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input +//! into core events, performs the core's effects (fork ptys, write them, +//! resize them — same duties as tty.zig, this is also native linux), and +//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an +//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, +//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the +//! real renderer, drives input from stdin, and captures frames to PPM. +//! +//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): +//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a +//! requestAnimationFrame callback instead of a blocking loop. Comptime +//! branches on is_emscripten, the way the stdlib branches on os.tag. +const std = @import("std"); +const builtin = @import("builtin"); +const posix = std.posix; +const linux = std.os.linux; +const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser +const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells +const pardes = @import("pardes.zig"); + +const is_emscripten = builtin.os.tag == .emscripten; + +pub const c = @cImport({ + @cDefine("SDL_DISABLE_OLD_NAMES", "1"); + @cInclude("SDL3/SDL.h"); + if (is_emscripten) @cInclude("GLES3/gl3.h"); + @cInclude("font.h"); +}); + +extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; +extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; +extern "c" fn chdir(path: [*:0]const u8) c_int; +extern "c" fn _exit(status: c_int) noreturn; +extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; +const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; + +const log = std.log.scoped(.gui); + +const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); +const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv"); +const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); +const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); +const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); +// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) +const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; +const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; +const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; +const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; +// web: no ptys — the whole state replays from a dump embedded at build time +const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; + +// default page colors — the same "terminal native dark" the prototype used +const bg_default = [3]u8{ 18, 18, 18 }; +const fg_default = [3]u8{ 204, 204, 204 }; + +// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays +// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never +// shipped non-default — hardcoded here instead of a build option). +const web_render_scale: f32 = 1.5; + +const atlas_w: u32 = if (is_emscripten) 2048 else 1024; +const atlas_h: u32 = if (is_emscripten) 2048 else 1024; +const Slot = struct { u: u32, v: u32 }; + +const max_touch_points: usize = 16; +const max_touch_trail_points: usize = 8; +const overlay_circle_vertices: usize = 16 * 3; +const touch_middle_flash_max_frames: u8 = 14; +const touch_middle_flash_vertices: usize = 1400; +const touch_scroll_tick: f32 = 0.02; +const max_overlay_vertices: usize = touch_middle_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); + +// One instance per cell; the vertex shader expands it to a 2-triangle quad +// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. +const CellInstance = extern struct { + x0: f32, + y0: f32, + x1: f32, + y1: f32, + u0: f32, + v0: f32, + u1: f32, + v1: f32, + fr: f32, + fg: f32, + fb: f32, + br: f32, + bg: f32, + bb: f32, +}; + +const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; +const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; + +// ---- touch: per-finger tracking + the two-finger scroll/tap machine ---- + +const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; + +const TouchPoint = struct { + active: bool = false, + id: u64 = 0, + x: f32 = 0, + y: f32 = 0, + pressure: f32 = 1, + trail: [max_touch_trail_points]TouchSample = @splat(.{}), + trail_len: usize = 0, + trail_next: usize = 0, +}; + +const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; + +/// A finger event with SDL's normalized 0..1 coordinates — the one shape both +/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. +const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; + +const Touch = struct { + points: [max_touch_points]TouchPoint = @splat(.{}), + scroll: struct { + active: bool = false, + ids: [2]u64 = .{ 0, 0 }, + accum_y: f32 = 0, + scrolled: bool = false, + last_center: TouchNormPoint = .{}, + } = .{}, + middle_clicks: u32 = 0, + middle_flash_frames: u8 = 0, + + fn findSlot(t: *const Touch, id: u64) ?usize { + for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; + return null; + } + + fn updatePoint(t: *Touch, f: Finger) void { + switch (f.kind) { + .up, .cancel => if (t.findSlot(f.id)) |i| { + t.points[i].active = false; + }, + .down, .motion => { + const idx = t.findSlot(f.id) orelse blk: { + for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; + break :blk @as(usize, @intCast(f.id % max_touch_points)); + }; + const tp = &t.points[idx]; + if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; + tp.x = f.x; + tp.y = f.y; + tp.pressure = f.pressure; + tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; + tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; + if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; + }, + } + } + + fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { + var sum: TouchNormPoint = .{}; + for (ids) |id| { + const i = t.findSlot(id) orelse return null; + sum.x += t.points[i].x; + sum.y += t.points[i].y; + } + return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; + } + + /// keep scroll state in sync with the set of active fingers: exactly two + /// active fingers begin (or re-key) a pair; anything else resets it. + fn syncPair(t: *Touch) void { + var ids: [2]u64 = .{ 0, 0 }; + var count: usize = 0; + for (&t.points) |*tp| { + if (!tp.active) continue; + if (count >= 2) { + t.scroll = .{}; + return; + } + ids[count] = tp.id; + count += 1; + } + if (count != 2) { + t.scroll = .{}; + return; + } + if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); + if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; + t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; + } + + /// finger lift: a pair that never scrolled is a two-finger TAP — returns + /// its center (computed with the lifting finger's final position). + fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { + if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; + const tap = f.kind == .up and !t.scroll.scrolled; + var center: ?TouchNormPoint = null; + if (tap) { + var sum: TouchNormPoint = .{}; + var ok = true; + for (t.scroll.ids) |id| { + if (id == f.id) { + sum.x += f.x; + sum.y += f.y; + } else if (t.findSlot(id)) |i| { + sum.x += t.points[i].x; + sum.y += t.points[i].y; + } else ok = false; + } + center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; + } + t.scroll = .{}; + return center; + } + + fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { + if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; + const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; + const delta_y = center.y - t.scroll.last_center.y; + t.scroll.last_center = center; + t.scroll.accum_y += delta_y; + var ticks: i32 = 0; + while (t.scroll.accum_y >= touch_scroll_tick) : (ticks += 1) t.scroll.accum_y -= touch_scroll_tick; + while (t.scroll.accum_y <= -touch_scroll_tick) : (ticks -= 1) t.scroll.accum_y += touch_scroll_tick; + if (ticks == 0) return null; + t.scroll.scrolled = true; + return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; + } +}; + +/// Two-finger drag → synthesized wheel presses + a touch_scroll event; a +/// two-finger tap → synthesized middle press+release. win/cell dims map +/// normalized coords to cells (grid test mode passes cols/rows with cell=1). +fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { + std.debug.assert(cell_w > 0 and cell_h > 0); + switch (f.kind) { + .down => { + t.updatePoint(f); + t.syncPair(); + }, + .motion => { + const had_pair = t.scroll.active; + t.updatePoint(f); + t.syncPair(); + if (!had_pair) return; + const res = t.noteMotion(f) orelse return; + const col = normCell(res.center.x, win_w, cell_w); + const row = normCell(res.center.y, win_h, cell_h); + const button: pardes.Mouse.Button = if (res.ticks > 0) .wheel_up else .wheel_down; + var remaining: u32 = @abs(res.ticks); + while (remaining > 0) : (remaining -= 1) + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + core.update(.{ .touch_scroll = res.delta_y }); + }, + .up, .cancel => { + const tap_center = t.finishPair(f); + t.updatePoint(f); + t.syncPair(); + if (tap_center) |center| { + t.middle_clicks +%= 1; + t.middle_flash_frames = touch_middle_flash_max_frames; + const col = normCell(center.x, win_w, cell_w); + const row = normCell(center.y, win_h, cell_h); + core.update(.{ .mouse = .{ .button = .middle, .kind = .press, .col = col, .row = row } }); + core.update(.{ .mouse = .{ .button = .middle, .kind = .release, .col = col, .row = row } }); + } + }, + } +} + +fn normCell(norm: f32, win: f32, cell: f32) u16 { + const px = std.math.clamp(norm, 0.0, 1.0) * win; + return @intFromFloat(@max(0, @floor(px / cell))); +} + +// ---- pty plumbing: reader threads feed a mutex-protected queue ---- + +const Pty = struct { fd: c_int, pid: linux.pid_t }; + +const Msg = union(enum) { output: struct { pane: u8, bytes: []u8 }, eof: u8 }; + +const Queue = struct { + gpa: std.mem.Allocator, + sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push + // 0.16 has no std.Thread.Mutex; critical sections here are a few + // instructions, so spinning on the lock-free std.atomic.Mutex is enough + mutex: std.atomic.Mutex = .unlocked, + items: std.ArrayList(Msg) = .empty, + closed: bool = false, + + fn lock(q: *Queue) void { + while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); + } + + fn push(q: *Queue, m: Msg) void { + q.lock(); + if (q.closed) { + q.mutex.unlock(); + switch (m) { + .output => |o| q.gpa.free(o.bytes), + .eof => {}, + } + return; + } + q.items.append(q.gpa, m) catch { + q.mutex.unlock(); + switch (m) { + .output => |o| q.gpa.free(o.bytes), + .eof => {}, + } + return; + }; + q.mutex.unlock(); + if (q.sdl_wake) { + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + /// take the pending messages (caller iterates + deinits outside the lock) + fn take(q: *Queue) std.ArrayList(Msg) { + q.lock(); + defer q.mutex.unlock(); + const local = q.items; + q.items = .empty; + return local; + } + + fn close(q: *Queue) void { + q.lock(); + defer q.mutex.unlock(); + q.closed = true; + for (q.items.items) |m| switch (m) { + .output => |o| q.gpa.free(o.bytes), + .eof => {}, + }; + q.items.deinit(q.gpa); + } +}; + +fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, q: *Queue) void { + var buf: [0x10000]u8 = undefined; + while (true) { + const rc = linux.read(fd, &buf, buf.len); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + break; // EIO when the child exits: treat as EOF + } + if (n == 0) break; + const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; + q.push(.{ .output = .{ .pane = pane, .bytes = bytes } }); + } + q.push(.{ .eof = pane }); +} + +fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, q: *Queue) void { + const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, q }) catch return; + th.detach(); +} + +// ---- the renderer state ---- + +const Gui = struct { + window: *c.SDL_Window, + device: *c.SDL_GPUDevice, + swapchain_format: c.SDL_GPUTextureFormat, + pipeline: *c.SDL_GPUGraphicsPipeline, + overlay_pipeline: *c.SDL_GPUGraphicsPipeline, + atlas_tex: *c.SDL_GPUTexture, + atlas_sampler: *c.SDL_GPUSampler, + atlas_xfer: *c.SDL_GPUTransferBuffer, + vbuf: ?*c.SDL_GPUBuffer = null, + vxfer: ?*c.SDL_GPUTransferBuffer = null, + vbuf_cells: u32 = 0, + overlay_vbuf: *c.SDL_GPUBuffer, + overlay_vxfer: *c.SDL_GPUTransferBuffer, + overlay_vertices: []OverlayVertex, + + font: *c.UIFont, + scale: f32, + cell_w: u32, + cell_h: u32, + ascent: i32, + + // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc + atlas_stage: []u8, + glyphs: std.AutoHashMap(u32, Slot), + pen_x: u32 = 0, + pen_y: u32 = 0, + atlas_dirty: bool = true, + space_slot: Slot = .{ .u = 0, .v = 0 }, + + touch: Touch = .{}, + mouse_x: f32 = 0, + mouse_y: f32 = 0, + live_ctrl: bool = false, + live_alt: bool = false, + + // PARDES_TEST frame capture (render into an offscreen target, dump PPM) + capture: bool = false, + capture_dir: []const u8 = "", + capture_tex: ?*c.SDL_GPUTexture = null, + capture_tex_w: u32 = 0, + capture_tex_h: u32 = 0, + capture_xfer: ?*c.SDL_GPUTransferBuffer = null, + capture_xfer_size: u32 = 0, + + // steamdeck: gamepad-driven virtual mouse cursor (pixel position) + gamepad: ?*c.SDL_Gamepad = null, + pad_x: f32 = 0, + pad_y: f32 = 0, + pad_scroll: f32 = 0, + + // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging + gl_program: c_uint = 0, + gl_overlay_program: c_uint = 0, + gl_atlas_tex: c_uint = 0, + gl_vbo: c_uint = 0, + gl_overlay_vbo: c_uint = 0, + gl_u_atlas: c_int = -1, + gl_instances: []CellInstance = &.{}, +}; + +/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the +/// canvas resize and the grid resize land on different frames, so the grid is +/// scaled to fit and centered (letterboxed) instead of clipping. +const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; + +fn fixedCellLayout(g: *const Gui) CellLayout { + return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; +} + +fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { + const fixed = fixedCellLayout(g); + if (!is_emscripten) return fixed; + if (cols == 0 or rows == 0) return fixed; + const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; + const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + const scale = @min(win_w / grid_w, win_h / grid_h); + return .{ + .w = fixed.w * scale, + .h = fixed.h * scale, + .x_off = @max(0, (win_w - grid_w * scale) * 0.5), + .y_off = @max(0, (win_h - grid_h * scale) * 0.5), + }; +} + +// ===================================================================== +// entry +// ===================================================================== + +pub const run = if (is_emscripten) runWeb else runNative; + +fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { + const io = init.io; + const gpa = init.gpa; + const env = init.environ_map; + if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in); + + const test_mode = env.get("PARDES_TEST") != null; + const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); + if (test_mode and capture_dir == null) { + log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); + return error.SdlInit; + } + + if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { + log.err("SDL_Init: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; + if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; + const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { + log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { + log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { + log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync + const present_mode: c.SDL_GPUPresentMode = blk: { + if (env.get("PARDES_SDL_PRESENT")) |raw| { + const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) + c.SDL_GPU_PRESENTMODE_IMMEDIATE + else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) + c.SDL_GPU_PRESENTMODE_MAILBOX + else if (std.ascii.eqlIgnoreCase(raw, "vsync")) + c.SDL_GPU_PRESENTMODE_VSYNC + else + null; + if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; + } + for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { + if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; + } + break :blk c.SDL_GPU_PRESENTMODE_VSYNC; + }; + if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); + _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); + const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); + + // ---- font + cell metrics ---- + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + const cell_w: u32 = @intCast(@max(cw, 1)); + const cell_h: u32 = @intCast(@max(chh, 1)); + if (test_mode) { + const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; + const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; + _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); + _ = c.SDL_SyncWindow(window); + } + + // ---- glyph atlas (R8) + pipelines ---- + var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; + tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; + tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; + tex_info.width = atlas_w; + tex_info.height = atlas_h; + tex_info.layer_count_or_depth = 1; + tex_info.num_levels = 1; + tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; + + var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); + samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; + samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; + samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; + samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; + const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; + + var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; + const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; + + const pipeline = try makePipeline(device, swapchain_format); + const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); + + const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); + var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; + const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; + var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; + const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; + + const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); + defer gpa.free(atlas_stage); + @memset(atlas_stage, 0); + const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); + defer gpa.free(overlay_vertices); + + var g: Gui = .{ + .window = window, + .device = device, + .swapchain_format = swapchain_format, + .pipeline = pipeline, + .overlay_pipeline = overlay_pipeline, + .atlas_tex = atlas_tex, + .atlas_sampler = atlas_sampler, + .atlas_xfer = atlas_xfer, + .overlay_vbuf = overlay_vbuf, + .overlay_vxfer = overlay_vxfer, + .overlay_vertices = overlay_vertices, + .font = font, + .scale = scale, + .cell_w = cell_w, + .cell_h = cell_h, + .ascent = asc, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(gpa), + .capture = test_mode, + .capture_dir = capture_dir orelse "", + }; + defer g.glyphs.deinit(); + // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); + g.pen_x = cell_w; + + // ---- the core ---- + pardes.image.start(io, gpa); // stb_image allocator for image panes + defer pardes.image.stop(); + // Live sessions initialize at the default 80x24 grid; the real window size + // arrives as a resize EVENT on the first loop pass. The core defers the + // shell greeting until after the first resize (so `ls` wraps to the real + // pane width) — pre-sizing at init would mean no resize ever fires and the + // greeting never runs (panes sat blank until the first interaction). + // Dump loads pre-size instead: replayed panes never greet, and sizing at + // init avoids reflowing their replayed content twice. + var opts = opts_in; + var pw: c_int = 0; + var ph: c_int = 0; + if (opts.load_path != null) { + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + } + const core = if (opts.load_path) |lp| blk: { + const bytes = try @import("look.zig").readFile(gpa, lp); + defer gpa.free(bytes); + break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); + } else try pardes.Pardes.init(gpa, opts); + defer core.deinit(); + // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images. + // ponytail: Surface.images pixel attachments could blit as textures later. + + // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) + writeFile(pardes.bash_rc); + + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + defer for (&ptys) |*slot| if (slot.*) |pt| { + _ = linux.close(pt.fd); + slot.* = null; + }; + var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; + defer queue.close(); + + // initial spawns BEFORE any worker thread exists: forkpty from a + // multithreaded process can wedge the child before exec (see tty.zig). + drainEffects(core, &ptys, gpa, &queue, &g, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue); + + _ = c.SDL_StartTextInput(window); + + var feed: StdinFeed = .{}; + if (test_mode) setStdinRaw() catch {}; + + var frame_arena: std.heap.ArenaAllocator = .init(gpa); + defer frame_arena.deinit(); + + while (!core.quit) { + // 1. SDL input: block briefly for the first event, then drain the rest + var sev = std.mem.zeroes(c.SDL_Event); + if (c.SDL_WaitEventTimeout(&sev, 16)) { + dispatch(&g, core, &sev); + while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev); + } + // test mode: input comes from stdin escape sequences instead + if (test_mode) { + const r = feed.pump(gpa, core, &g) catch break; + if (r.eof) break; + } + // 2. pty output from the reader threads + var msgs = queue.take(); + for (msgs.items) |m| switch (m) { + .output => |o| { + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + gpa.free(o.bytes); + }, + .eof => |id| { + if (ptys[id]) |pt| { + _ = linux.close(pt.fd); + ptys[id] = null; + } + core.update(.{ .eof = .{ .pane = id } }); + }, + }; + msgs.deinit(gpa); + // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events + pollGamepad(&g, core); + // 4. effects + drainEffects(core, &ptys, gpa, &queue, &g, true); + if (core.quit) break; + // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame + pollCwds(core, &ptys); + // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes) + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + if (cols != core.screen_w or rows != core.screen_h) + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + // 7. render + _ = frame_arena.reset(.retain_capacity); + const surface = try core.render(frame_arena.allocator()); + renderFrame(&g, gpa, surface) catch |err| log.err("render: {t}", .{err}); + } +} + +// ===================================================================== +// web entry: set everything up, register a rAF frame callback, return. +// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. +// ===================================================================== + +// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer +// is the whole heap, never freed (the shell lives for the page). +var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; +var web_fba: std.heap.FixedBufferAllocator = undefined; + +const WebState = struct { + g: Gui, + core: *pardes.Pardes, + gpa: std.mem.Allocator, + frame_arena: std.heap.ArenaAllocator, +}; + +fn runWeb(opts_in: pardes.Options) !void { + web_fba = .init(&web_heap); + const gpa = web_fba.allocator(); + + if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser + log.err("SDL_Init: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { + log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); + _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); + const gl_context = c.SDL_GL_CreateContext(window) orelse { + log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_GL_MakeCurrent(window, gl_context)) { + log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + _ = c.SDL_GL_SetSwapInterval(1); + + // ---- font + cell metrics, rastered at web_render_scale ---- + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const base_scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); + const scale = c.ui_font_scale_for_height(font, px * web_render_scale); + const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); + const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); + const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); + + // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- + const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); + const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); + var atlas_tex: c_uint = 0; + c.glGenTextures(1, &atlas_tex); + c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); + c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); + var vbo: c_uint = 0; + c.glGenBuffers(1, &vbo); + var overlay_vbo: c_uint = 0; + c.glGenBuffers(1, &overlay_vbo); + + const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); + @memset(atlas_stage, 0); + const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); + + const st = try gpa.create(WebState); + st.* = .{ + .g = .{ + .window = window, + .device = undefined, // web renders through GL, never the GPU device + .swapchain_format = undefined, + .pipeline = undefined, + .overlay_pipeline = undefined, + .atlas_tex = undefined, + .atlas_sampler = undefined, + .atlas_xfer = undefined, + .overlay_vbuf = undefined, + .overlay_vxfer = undefined, + .overlay_vertices = overlay_vertices, + .gl_program = program, + .gl_overlay_program = overlay_program, + .gl_atlas_tex = atlas_tex, + .gl_vbo = vbo, + .gl_overlay_vbo = overlay_vbo, + .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), + .font = font, + .scale = scale, + .cell_w = cell_w, + .cell_h = cell_h, + .ascent = ascent, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(gpa), + }, + .core = undefined, + .gpa = gpa, + .frame_arena = .init(gpa), + }; + const g = &st.g; + // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent); + g.pen_x = cell_w; + + // ---- the core: state replays from the embedded dump, no ptys ---- + pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm + syncCanvasSize(g); // size the backing canvas before deriving the grid + var opts = opts_in; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); + opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); + opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); + st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); + + _ = c.SDL_StartTextInput(window); + std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); +} + +fn webFrame(arg: ?*anyopaque) callconv(.c) void { + const st: *WebState = @ptrCast(@alignCast(arg.?)); + const g = &st.g; + const core = st.core; + // 1. input + var sev = std.mem.zeroes(c.SDL_Event); + while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); + // 2. effects + drainEffectsWeb(core, st.gpa, g); + if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); + // 3. the backing canvas follows the page, the grid follows the canvas + syncCanvasSize(g); + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); + if (cols != core.screen_w or rows != core.screen_h) + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + // 4. render + _ = st.frame_arena.reset(.retain_capacity); + const surface = core.render(st.frame_arena.allocator()) catch return; + renderFrameGl(g, st.gpa, surface) catch return; +} + +/// the canvas follows the page (CSS); the SDL window follows the canvas, +/// backed at web_render_scale (postWinsize in the prototype). +fn syncCanvasSize(g: *Gui) void { + var css_w: f64 = 0; + var css_h: f64 = 0; + if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; + const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); + const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); + var cur_w: c_int = 0; + var cur_h: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); + if (cur_w != want_w or cur_h != want_h) { + _ = c.SDL_SetWindowSize(g.window, want_w, want_h); + _ = c.SDL_SyncWindow(g.window); + } +} + +/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, +/// the rendered Surface out as text frames (same framing as the prototype). +fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { + const io = init.io; + const gpa = init.gpa; + const env = init.environ_map; + + pardes.image.start(io, gpa); + defer pardes.image.stop(); + + const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; + const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; + var opts = opts_in; + if (opts.load_path != null) { + opts.cols = grid_cols; + opts.rows = grid_rows; + } + const core = if (opts.load_path) |lp| blk: { + const bytes = try @import("look.zig").readFile(gpa, lp); + defer gpa.free(bytes); + break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); + } else try pardes.Pardes.init(gpa, opts); + defer core.deinit(); + // the requested grid arrives as a resize EVENT (not init opts) so the core + // counts a resize and releases the shell greeting on first output + if (opts.load_path == null) + core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); + + writeFile(pardes.bash_rc); + + var ptys: [pardes.MAX_PANES]?Pty = @splat(null); + defer for (&ptys) |*slot| if (slot.*) |pt| { + _ = linux.close(pt.fd); + slot.* = null; + }; + var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; + defer queue.close(); + drainEffects(core, &ptys, gpa, &queue, null, false); + for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), &queue); + + setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort + + var frame_arena: std.heap.ArenaAllocator = .init(gpa); + defer frame_arena.deinit(); + var feed: StdinFeed = .{}; + + // first frame before touching stdin, so `printf '' | pardes` still shows one + { + const surface = try core.render(frame_arena.allocator()); + try dumpGrid(gpa, surface); + } + while (!core.quit) { + const r = try feed.pump(gpa, core, null); + if (r.eof) break; + var n_events: usize = r.n_events; + var msgs = queue.take(); + for (msgs.items) |m| switch (m) { + .output => |o| { + core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); + gpa.free(o.bytes); + n_events += 1; + }, + .eof => |id| { + if (ptys[id]) |pt| { + _ = linux.close(pt.fd); + ptys[id] = null; + } + core.update(.{ .eof = .{ .pane = id } }); + n_events += 1; + }, + }; + msgs.deinit(gpa); + drainEffects(core, &ptys, gpa, &queue, null, true); + pollCwds(core, &ptys); + if (n_events == 0) continue; // idle tick: nothing changed, no frame + _ = frame_arena.reset(.retain_capacity); + const surface = try core.render(frame_arena.allocator()); + try dumpGrid(gpa, surface); + } +} + +// ===================================================================== +// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, +// plus the private synthetic finger OSC: +// ESC ] 777;finger;<type>;<id>;<x>;<y>;<pressure> BEL (normalized 0..1) +// ===================================================================== + +const StdinFeed = struct { + parser: vaxis.Parser = .{}, + cache: vaxis.GraphemeCache = .{}, + buf: [1024]u8 = undefined, + fill: usize = 0, + last_cols: u16 = 0, + last_rows: u16 = 0, + + const Result = struct { eof: bool = false, n_events: usize = 0 }; + + /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. + fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result { + var out: Result = .{}; + // a pty stdin also carries the winsize; poll it in place of SIGWINCH + var ws: posix.winsize = std.mem.zeroes(posix.winsize); + if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and + ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and + (ws.col != f.last_cols or ws.row != f.last_rows)) + { + f.last_cols = ws.col; + f.last_rows = ws.row; + f.applyResize(core, g, ws.col, ws.row); + out.n_events += 1; + } + + var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 16) catch return out; + if ((fds[0].revents & posix.POLL.IN) == 0) { + if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; + return out; + } + const rc = linux.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); + const rn: isize = @bitCast(rc); + if (rn < 0) return out; + if (rn == 0) { + out.eof = true; + return out; + } + const len = f.fill + @as(usize, @intCast(rn)); + f.fill = 0; + + var seq_start: usize = 0; + while (seq_start < len) { + // private finger OSC first (vaxis would swallow it as unknown OSC) + const prefix = "\x1b]777;finger;"; + if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { + const body = f.buf[seq_start + prefix.len .. len]; + const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + }; + if (parseFinger(body[0..end])) |finger| { + if (g) |gp| { + var wpw: c_int = 0; + var wph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); + handleFinger(&gp.touch, core, finger, @floatFromInt(@max(wpw, 1)), @floatFromInt(@max(wph, 1)), @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); + } else { + handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); + } + out.n_events += 1; + } + seq_start += prefix.len + end + 1; + continue; + } + if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + } + + const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; + if (result.n == 0) { // partial sequence: shift to front, read more + std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); + f.fill = len - seq_start; + break; + } + seq_start += result.n; + const event = result.event orelse continue; + switch (event) { + .key_press => |key| { + var text: []const u8 = ""; + if (key.text) |t| text = f.cache.put(t); + core.update(.{ .key = .{ + .cp = mapKey(effCp(key)), + .text = text, + .ctrl = key.mods.ctrl, + .alt = key.mods.alt, + } }); + out.n_events += 1; + }, + .mouse => |m| { + const button: ?pardes.Mouse.Button = switch (m.button) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .wheel_up, + .wheel_down => .wheel_down, + .none => .none, + else => null, + }; + if (button) |b| { + core.update(.{ .mouse = .{ + .button = b, + .kind = switch (m.type) { + .press => .press, + .release => .release, + .motion => .motion, + .drag => .drag, + }, + .col = @intCast(@max(m.col, 0)), + .row = @intCast(@max(m.row, 0)), + } }); + out.n_events += 1; + } + }, + .winsize => |vw| { + f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows)); + out.n_events += 1; + }, + .paste => |text| { + core.update(.{ .paste = text }); + gpa.free(@constCast(text)); + out.n_events += 1; + }, + else => {}, + } + } + return out; + } + + fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void { + _ = f; + if (g) |gp| { + // capture mode: resize the window; the frame loop resizes the core + _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); + _ = c.SDL_SyncWindow(gp.window); + } else { + core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); + } + } +}; + +var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off + +fn parseFinger(payload: []const u8) ?Finger { + var parts = std.mem.splitScalar(u8, payload, ';'); + const type_name = parts.next() orelse return null; + const id_s = parts.next() orelse return null; + const x_s = parts.next() orelse return null; + const y_s = parts.next() orelse return null; + const pressure_s = parts.next() orelse return null; + if (parts.next() != null) return null; + const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) + .down + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) + .motion + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) + .up + else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) + .cancel + else + return null; + return .{ + .kind = kind, + .id = std.fmt.parseInt(u64, id_s, 10) catch return null, + .x = std.fmt.parseFloat(f32, x_s) catch return null, + .y = std.fmt.parseFloat(f32, y_s) catch return null, + .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, + }; +} + +fn setStdinRaw() !void { + var term = try posix.tcgetattr(0); + term.iflag.BRKINT = false; + term.iflag.ICRNL = false; + term.iflag.INPCK = false; + term.iflag.ISTRIP = false; + term.iflag.IXON = false; + term.oflag.OPOST = false; + term.lflag.ECHO = false; + term.lflag.ICANON = false; + term.lflag.IEXTEN = false; + term.lflag.ISIG = false; + term.cc[@intFromEnum(posix.V.MIN)] = 1; + term.cc[@intFromEnum(posix.V.TIME)] = 0; + try posix.tcsetattr(0, .NOW, term); +} + +// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. +fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { + if (surface.cols == 0 or surface.rows == 0) return; + // build the whole frame in one buffer → one write → atomic snapshots + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(gpa); + const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; + const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; + var hdr: [128]u8 = undefined; + const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ + surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, + }) catch return; + try buf.appendSlice(gpa, hdr_str); + var y: u16 = 0; + while (y < surface.rows) : (y += 1) { + var x: u16 = 0; + while (x < surface.cols) : (x += 1) { + const g = surface.at(x, y).grapheme(); + if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); + } + try buf.append(gpa, '\n'); + } + try buf.appendSlice(gpa, "---ENDFRAME---\n"); + writeFd(1, buf.items); +} + +// ===================================================================== +// SDL event dispatch +// ===================================================================== + +fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { + switch (sev.type) { + c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, + // window resizes are picked up by the per-frame grid check + c.SDL_EVENT_KEY_DOWN => { + g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; + g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; + keyDown(core, sev.key.key, sev.key.mod); + }, + c.SDL_EVENT_KEY_UP => { + g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; + g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; + }, + c.SDL_EVENT_TEXT_INPUT => { + const tptr = sev.text.text orelse return; + var tlen: usize = 0; + while (tptr[tlen] != 0) : (tlen += 1) {} + if (tlen == 0) return; + const text: []const u8 = tptr[0..tlen]; + const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; + if (cplen > text.len) return; + const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; + core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const b = sev.button; + g.mouse_x = b.x; + g.mouse_y = b.y; + const button: pardes.Mouse.Button = switch (b.button) { + 1 => .left, + 2 => .middle, + 3 => .right, + else => return, + }; + const mc = mouseCell(g, core, b.x, b.y); + core.update(.{ .mouse = .{ + .button = button, + .kind = if (b.down) .press else .release, + .col = mc.col, + .row = mc.row, + } }); + }, + c.SDL_EVENT_MOUSE_MOTION => { + const m = sev.motion; + g.mouse_x = m.x; + g.mouse_y = m.y; + // drag = motion with a button held (selection extension keys off it) + const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) + .left + else if ((m.state & c.SDL_BUTTON_MMASK) != 0) + .middle + else if ((m.state & c.SDL_BUTTON_RMASK) != 0) + .right + else + null; + const mc = mouseCell(g, core, m.x, m.y); + core.update(.{ .mouse = .{ + .button = held orelse .none, + .kind = if (held != null) .drag else .motion, + .col = mc.col, + .row = mc.row, + } }); + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const w = sev.wheel; + if (w.y == 0) return; + g.mouse_x = w.mouse_x; + g.mouse_y = w.mouse_y; + const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); + core.update(.{ .mouse = .{ + .button = if (w.y > 0) .wheel_up else .wheel_down, + .kind = .press, + .col = mc.col, + .row = mc.row, + } }); + }, + c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { + const f = sev.tfinger; + var finger: Finger = .{ + .kind = switch (sev.type) { + c.SDL_EVENT_FINGER_DOWN => .down, + c.SDL_EVENT_FINGER_MOTION => .motion, + c.SDL_EVENT_FINGER_UP => .up, + else => .cancel, + }, + .id = @intCast(f.fingerID), + .x = f.x, + .y = f.y, + .pressure = f.pressure, + }; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + var win_w: f32 = @floatFromInt(@max(pw, 1)); + var win_h: f32 = @floatFromInt(@max(ph, 1)); + var fcw: f32 = @floatFromInt(g.cell_w); + var fch: f32 = @floatFromInt(g.cell_h); + if (is_emscripten) { + // remap normalized coords into the letterboxed grid so the + // shared two-finger machine's cell math stays exact + const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); + const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); + const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); + finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); + finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); + win_w = grid_w; + win_h = grid_h; + fcw = layout.w; + fch = layout.h; + } + handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); + }, + c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), + // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, + // axes polled per frame in pollGamepad) ---- + c.SDL_EVENT_GAMEPAD_ADDED => { + if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); + }, + c.SDL_EVENT_GAMEPAD_REMOVED => { + if (g.gamepad) |pad| { + c.SDL_CloseGamepad(pad); + g.gamepad = null; + } + }, + c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => { + const down = sev.type == c.SDL_EVENT_GAMEPAD_BUTTON_DOWN; + const col = pixelCell(g.pad_x, g.cell_w); + const row = pixelCell(g.pad_y, g.cell_h); + const button: pardes.Mouse.Button = switch (sev.gbutton.button) { + c.SDL_GAMEPAD_BUTTON_SOUTH => .left, // A + c.SDL_GAMEPAD_BUTTON_EAST => .right, // B + c.SDL_GAMEPAD_BUTTON_NORTH => .middle, // Y + c.SDL_GAMEPAD_BUTTON_LEFT_SHOULDER => .wheel_up, + c.SDL_GAMEPAD_BUTTON_RIGHT_SHOULDER => .wheel_down, + else => return, + }; + switch (button) { + .wheel_up, .wheel_down => if (down) core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }), + else => core.update(.{ .mouse = .{ .button = button, .kind = if (down) .press else .release, .col = col, .row = row } }), + } + }, + else => {}, + } +} + +/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as +/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). +fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { + const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; + const alt = (mod & c.SDL_KMOD_ALT) != 0; + const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; + const cp: u21 = switch (sym) { + c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, + c.SDLK_BACKSPACE => pardes.Key.backspace, + c.SDLK_TAB => pardes.Key.tab, + c.SDLK_ESCAPE => pardes.Key.escape, + c.SDLK_LEFT => pardes.Key.left, + c.SDLK_RIGHT => pardes.Key.right, + c.SDLK_UP => pardes.Key.up, + c.SDLK_DOWN => pardes.Key.down, + c.SDLK_HOME => pardes.Key.home, + c.SDLK_END => pardes.Key.end, + c.SDLK_PAGEUP => pardes.Key.page_up, + c.SDLK_PAGEDOWN => pardes.Key.page_down, + else => blk: { + // letters / digits / punctuation only as a modifier shortcut; + // plain printable (incl. space) goes via TEXT_INPUT + if (!(ctrl or alt or gui_mod)) break :blk 0; + if (sym < 128 and sym >= ' ') break :blk @intCast(sym); + break :blk 0; + }, + }; + if (cp == 0) return; + core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt } }); +} + +fn pixelCell(px: f32, cell: u32) u16 { + return pixelCellF(px, @floatFromInt(cell)); +} + +fn pixelCellF(px: f32, cell: f32) u16 { + const idx = @floor(@max(px, 0) / @max(cell, 1)); + return @intFromFloat(@min(idx, 10_000)); +} + +/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS +/// points into a letterboxed canvas backed at web_render_scale. +fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { + if (!is_emscripten) return .{ .col = pixelCell(x, g.cell_w), .row = pixelCell(y, g.cell_h) }; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + var ww: c_int = 0; + var wh: c_int = 0; + _ = c.SDL_GetWindowSize(g.window, &ww, &wh); + const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); + const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); + const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); + return .{ + .col = pixelCellF(px - layout.x_off, layout.w), + .row = pixelCellF(py - layout.y_off, layout.h), + }; +} + +/// steamdeck support: poll the sticks each frame. Left stick moves a virtual +/// cursor at ~cell granularity (emits button-less motion so hover works), +/// right stick Y accumulates into wheel ticks at the cursor position. +fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { + const pad = g.gamepad orelse return; + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const win_w: f32 = @floatFromInt(@max(pw, 1)); + const win_h: f32 = @floatFromInt(@max(ph, 1)); + const deadzone: f32 = 8000; + const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); + const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); + const old_col = pixelCell(g.pad_x, g.cell_w); + const old_row = pixelCell(g.pad_y, g.cell_h); + // full tilt ≈ one cell-height per frame; ~60fps makes that a brisk glide + const speed: f32 = @floatFromInt(g.cell_h); + if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); + if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); + const col = pixelCell(g.pad_x, g.cell_w); + const row = pixelCell(g.pad_y, g.cell_h); + if (col != old_col or row != old_row) + core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }); + const ry: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); + if (@abs(ry) > deadzone) g.pad_scroll += ry / 32767.0 * 0.15; + while (@abs(g.pad_scroll) >= 1.0) { + const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; + g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; + core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); + } +} + +// ===================================================================== +// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard +// ===================================================================== + +fn drainEffects( + core: *pardes.Pardes, + ptys: *[pardes.MAX_PANES]?Pty, + gpa: std.mem.Allocator, + queue: *Queue, + g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) + threads_ok: bool, +) void { + while (core.nextEffect()) |effect| switch (effect) { + .spawn => |sp| { + std.debug.assert(ptys[sp.pane] == null); + const cwd = sp.cwd.slice(); + var cwd_buf: [256:0]u8 = undefined; + var cwd_z: ?[*:0]const u8 = null; + if (cwd.len > 0) { + @memcpy(cwd_buf[0..cwd.len], cwd); + cwd_buf[cwd.len] = 0; + cwd_z = @ptrCast(&cwd_buf); + } + const pt = forkShell(cwd_z, core.screen_h, core.screen_w); + ptys[sp.pane] = pt; + // report the pane's starting directory back to the core (tags) + var lbuf: [1024]u8 = undefined; + var pbuf: [64]u8 = undefined; + if (std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch null) |path| { + const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); + const n: isize = @bitCast(rc); + if (n > 0) core.setCwd(sp.pane, lbuf[0..@intCast(n)]); + } + if (threads_ok) spawnReader(gpa, pt, sp.pane, queue); + }, + .write => |w| { + if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice()); + }, + .resize_pty => |rs| { + if (ptys[rs.pane]) |pt| { + const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; + _ = posix.system.ioctl(pt.fd, posix.T.IOCSWINSZ, @intFromPtr(&ws)); + } + }, + .open_link => {}, // native: look opens panes instead; nothing to launch + .save_file => |sf| { + const pane = core.panes[sf.pane] orelse continue; + const f = pane.file orelse continue; + var pathbuf: [4096:0]u8 = undefined; + if (f.path.len >= pathbuf.len) continue; + @memcpy(pathbuf[0..f.path.len], f.path); + pathbuf[f.path.len] = 0; + const rc = linux.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) continue; + const fd: c_int = @intCast(sfd); + writeFd(fd, f.content); + _ = linux.close(fd); + }, + .write_dump => { + const out = core.dump_out orelse continue; + const rc = linux.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) continue; + const fd: c_int = @intCast(sfd); + writeFd(fd, out); + _ = linux.close(fd); + }, + .set_clipboard => { + if (g == null) continue; + const y = core.yank orelse continue; + const z = gpa.dupeZ(u8, y) catch continue; + defer gpa.free(z); + _ = c.SDL_SetClipboardText(z.ptr); + }, + .get_clipboard => { + if (g == null) continue; + // synchronous, unlike the tty's OSC 52 round-trip: reply is a paste + const t = c.SDL_GetClipboardText(); + defer c.SDL_free(t); + var tlen: usize = 0; + while (t[tlen] != 0) : (tlen += 1) {} + if (tlen > 0) core.update(.{ .paste = t[0..tlen] }); + }, + .quit => {}, + }; +} + +fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { + var master: c_int = undefined; + const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const pid = forkpty(&master, null, null, &ws); + if (pid == 0) { + if (cwd) |cd| _ = chdir(cd); + const argv: [4:null]?[*:0]const u8 = .{ "/usr/bin/bash", "--rcfile", "/tmp/pardes-osc133.bash", null }; + _ = execv("/usr/bin/bash", &argv); + _exit(127); + } + return .{ .fd = master, .pid = pid }; +} + +fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { + for (ptys, 0..) |slot, id| if (slot) |pt| { + var lbuf: [1024]u8 = undefined; + var pbuf: [64]u8 = undefined; + const path = std.fmt.bufPrintSentinel(&pbuf, "/proc/{d}/cwd", .{pt.pid}, 0) catch continue; + const rc = linux.readlinkat(linux.AT.FDCWD, path, &lbuf, lbuf.len); + const n: isize = @bitCast(rc); + if (n > 0) core.setCwd(id, lbuf[0..@intCast(n)]); + }; +} + +/// web: no ptys, no fs — panes replay from the embedded dump. Only the +/// clipboard and open_link effects have a browser meaning. +fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { + _ = g; + while (core.nextEffect()) |effect| switch (effect) { + .set_clipboard => { + const y = core.yank orelse continue; + const z = gpa.dupeZ(u8, y) catch continue; + defer gpa.free(z); + _ = c.SDL_SetClipboardText(z.ptr); + }, + // look on a URL → a new tab + .open_link => |url| openLinkWeb(gpa, url.slice()), + // nothing to spawn/write/resize/save/dump into; get_clipboard would + // need an async permission round-trip — all no-ops + .spawn, .write, .resize_pty, .save_file, .write_dump, .get_clipboard, .quit => {}, + }; +} + +/// window.open in a tiny generated script; single quotes and backslashes are +/// %-escaped so the url can't break out of the JS string literal. +fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { + var safe: std.ArrayList(u8) = .empty; + defer safe.deinit(gpa); + for (url) |ch| switch (ch) { + '\'' => safe.appendSlice(gpa, "%27") catch return, + '\\' => safe.appendSlice(gpa, "%5C") catch return, + else => safe.append(gpa, ch) catch return, + }; + const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return; + defer gpa.free(script); + std.os.emscripten.emscripten_run_script(script.ptr); +} + +// ===================================================================== +// render: Surface → instanced quads → SDL GPU +// ===================================================================== + +fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void { + const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; + var sw: u32 = 0; + var sh: u32 = 0; + var target: *c.SDL_GPUTexture = undefined; + if (g.capture) { + // capture: render offscreen (the window may never present), dump PPM + sw = @as(u32, surface.cols) * g.cell_w; + sh = @as(u32, surface.rows) * g.cell_h; + if (sw == 0 or sh == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + try ensureCaptureTexture(g, sw, sh); + target = g.capture_tex.?; + } else { + var swap_tex: ?*c.SDL_GPUTexture = null; + if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + target = swap_tex orelse { // swapchain busy: skip the frame + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + } + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + + const overlay_count = buildTouchOverlay(g, sw, sh); + if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); + + const cells: u32 = @as(u32, surface.cols) * surface.rows; + if (cells != 0) try ensureVbuf(g, cells); + + var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + color_target.texture = target; + color_target.clear_color = .{ + .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0, + .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0, + .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0, + .a = 1.0, + }; + color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; + color_target.store_op = c.SDL_GPU_STOREOP_STORE; + + if (cells != 0 and g.vbuf != null) { + // ponytail: full re-upload every frame; the prototype's dirty-range + // diffing (cell_keys + coalesced ranges) is skipped for now. + const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }); + const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); + const layout = fixedCellLayout(g); + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + const idx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx); + } + } + c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); + + // emitInstance may have rasterized new glyphs into the staging atlas; + // upload after vertex generation so this frame has what it references + if (g.atlas_dirty) uploadAtlas(g, cmd); + + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; + const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) }; + c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); + c.SDL_EndGPUCopyPass(copy); + + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); + const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; + c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); + const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; + c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); + drawOverlayGpu(g, rp, overlay_count); + c.SDL_EndGPURenderPass(rp); + } else { + // no grid yet: just clear to the page bg + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + drawOverlayGpu(g, rp, overlay_count); + c.SDL_EndGPURenderPass(rp); + } + + if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh); + _ = c.SDL_SubmitGPUCommandBuffer(cmd); +} + +fn emitInstance( + g: *Gui, + instances: [*]CellInstance, + idx: u32, + col: u16, + row: u16, + layout: CellLayout, + win_w: f32, + win_h: f32, + cell: *const pardes.Cell, + is_cursor: bool, +) void { + // resolve the cell style to concrete fg/bg, matching the prototype's + // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell + // renders as reverse (cancelling an already-reversed cell). + var fg = fg_default; + var bg = bg_default; + var reverse = is_cursor; + if (!cell.default) { + const st = cell.style; + fg = switch (st.fg) { + .default => fg_default, + .index => |i| palColor(i), + .rgb => |rgb| rgb, + }; + bg = switch (st.bg) { + .default => bg_default, + .index => |i| palColor(i), + .rgb => |rgb| rgb, + }; + if (st.reverse) reverse = !reverse; + if (st.invisible) fg = bg; + if (st.dim) for (&fg) |*ch| { + ch.* = @intCast(@as(u16, ch.*) * 6 / 10); + }; + } + if (reverse) std.mem.swap([3]u8, &fg, &bg); + + // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web + const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; + const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; + const x0 = (px0 / win_w) * 2.0 - 1.0; + const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; + const y0 = 1.0 - (py0 / win_h) * 2.0; + const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0; + + const grapheme = cell.grapheme(); + const cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); + const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); + const aw_f: f32 = @floatFromInt(atlas_w); + const ah_f: f32 = @floatFromInt(atlas_h); + + instances[idx] = .{ + .x0 = x0, + .y0 = y0, + .x1 = x1, + .y1 = y1, + .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f, + .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, + .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, + .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, + .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, + .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, + .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, + .br = @as(f32, @floatFromInt(bg[0])) / 255.0, + .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, + .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, + }; +} + +fn palColor(idx: u8) [3]u8 { + const p = ghostty_vt.color.default[idx]; + return .{ p.r, p.g, p.b }; +} + +// first codepoint of a UTF-8 grapheme; space on failure/empty +fn firstCp(s: []const u8) u32 { + if (s.len == 0) return ' '; + const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; + if (n > s.len) return ' '; + return std.unicode.utf8Decode(s[0..n]) catch ' '; +} + +fn ensureGlyph(g: *Gui, cp: u32) Slot { + if (g.glyphs.get(cp)) |s| return s; + if (g.pen_x + g.cell_w > atlas_w) { + g.pen_x = 0; + g.pen_y += g.cell_h; + } + if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back + const s = Slot{ .u = g.pen_x, .v = g.pen_y }; + const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; + _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); + g.glyphs.put(cp, s) catch return g.space_slot; + g.pen_x += g.cell_w; + g.atlas_dirty = true; + return s; +} + +fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { + const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; + const dst: [*]u8 = @ptrCast(ptr); + @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); + c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); + + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; + const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; + c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); + c.SDL_EndGPUCopyPass(copy); + g.atlas_dirty = false; +} + +fn ensureVbuf(g: *Gui, cells: u32) !void { + if (g.vbuf_cells == cells and g.vbuf != null) return; + if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b); + if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); + g.vbuf = null; + g.vxfer = null; + const size = cells * @sizeOf(CellInstance); + var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; + g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; + var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; + g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; + g.vbuf_cells = cells; +} + +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); + defer c.SDL_ReleaseGPUShader(device, fs); + + var vbuf_desc = c.SDL_GPUVertexBufferDescription{ + .slot = 0, + .pitch = @sizeOf(CellInstance), + .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, + .instance_step_rate = 0, + }; + var attrs = [_]c.SDL_GPUVertexAttribute{ + .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, + .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, + .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, + .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, + }; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; + + var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.vertex_input_state = .{ + .vertex_buffer_descriptions = &vbuf_desc, + .num_vertex_buffers = 1, + .vertex_attributes = &attrs, + .num_vertex_attributes = attrs.len, + }; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0); + defer c.SDL_ReleaseGPUShader(device, fs); + + var vbuf_desc = c.SDL_GPUVertexBufferDescription{ + .slot = 0, + .pitch = @sizeOf(OverlayVertex), + .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, + .instance_step_rate = 0, + }; + var attrs = [_]c.SDL_GPUVertexAttribute{ + .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, + .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, + }; + var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); + blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; + blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; + blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; + blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; + blend.enable_blend = true; + var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; + + var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); + info.vertex_shader = vs; + info.fragment_shader = fs; + info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; + info.vertex_input_state = .{ + .vertex_buffer_descriptions = &vbuf_desc, + .num_vertex_buffers = 1, + .vertex_attributes = &attrs, + .num_vertex_attributes = attrs.len, + }; + info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; + info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; + info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; + return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; +} + +fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader { + var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); + info.code = code.ptr; + info.code_size = code.len; + info.entrypoint = "main"; + info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; + info.stage = stage; + info.num_samplers = n_samplers; + return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; +} + +// ===================================================================== +// web render: Surface → the same instanced quads → WebGL2. Full instance +// re-upload each frame; attribs re-specified per draw (no VAO to manage). +// ===================================================================== + +fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface) !void { + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); + const sw: u32 = @intCast(@max(pw, 1)); + const sh: u32 = @intCast(@max(ph, 1)); + c.glViewport(0, 0, @intCast(sw), @intCast(sh)); + c.glClearColor( + @as(f32, @floatFromInt(bg_default[0])) / 255.0, + @as(f32, @floatFromInt(bg_default[1])) / 255.0, + @as(f32, @floatFromInt(bg_default[2])) / 255.0, + 1.0, + ); + c.glClear(c.GL_COLOR_BUFFER_BIT); + + const overlay_count = buildTouchOverlay(g, sw, sh); + const cells: u32 = @as(u32, surface.cols) * surface.rows; + if (cells == 0) { + drawOverlayGl(g, overlay_count); + _ = c.SDL_GL_SwapWindow(g.window); + return; + } + if (g.gl_instances.len < cells) { + gpa.free(g.gl_instances); + g.gl_instances = try gpa.alloc(CellInstance, cells); + } + + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); + const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; + var row: u16 = 0; + while (row < surface.rows) : (row += 1) { + var col: u16 = 0; + while (col < surface.cols) : (col += 1) { + const idx: u32 = @as(u32, row) * surface.cols + col; + emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx); + } + } + if (g.atlas_dirty) uploadAtlasGl(g); + + c.glUseProgram(g.gl_program); + c.glActiveTexture(c.GL_TEXTURE0); + c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); + c.glUniform1i(g.gl_u_atlas, 0); + c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(CellInstance)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); + c.glVertexAttribDivisor(0, 1); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); + c.glVertexAttribDivisor(1, 1); + c.glEnableVertexAttribArray(2); + c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); + c.glVertexAttribDivisor(2, 1); + c.glEnableVertexAttribArray(3); + c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); + c.glVertexAttribDivisor(3, 1); + c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); + drawOverlayGl(g, overlay_count); + _ = c.SDL_GL_SwapWindow(g.window); +} + +fn uploadAtlasGl(g: *Gui) void { + c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); + c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); + c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); + g.atlas_dirty = false; +} + +fn drawOverlayGl(g: *Gui, vertex_count: u32) void { + if (vertex_count == 0) return; + c.glUseProgram(g.gl_overlay_program); + c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); + c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); + const stride: c_int = @intCast(@sizeOf(OverlayVertex)); + c.glEnableVertexAttribArray(0); + c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); + c.glVertexAttribDivisor(0, 0); + c.glEnableVertexAttribArray(1); + c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); + c.glVertexAttribDivisor(1, 0); + c.glDisableVertexAttribArray(2); + c.glDisableVertexAttribArray(3); + c.glEnable(c.GL_BLEND); + c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); + c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); + c.glDisable(c.GL_BLEND); +} + +fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { + const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); + defer c.glDeleteShader(vs); + const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); + defer c.glDeleteShader(fs); + const program = c.glCreateProgram(); + if (program == 0) return error.GpuCreate; + c.glAttachShader(program, vs); + c.glAttachShader(program, fs); + c.glLinkProgram(program); + var ok: c_int = 0; + c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); + if (ok == 0) { + log.err("WebGL program link failed", .{}); + c.glDeleteProgram(program); + return error.GpuCreate; + } + return program; +} + +fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { + const shader = c.glCreateShader(kind); + if (shader == 0) return error.GpuCreate; + var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; + var src_len = [_]c_int{@intCast(source.len)}; + c.glShaderSource(shader, 1, &src_ptr, &src_len); + c.glCompileShader(shader); + var ok: c_int = 0; + c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); + if (ok == 0) { + log.err("WebGL shader compile failed", .{}); + c.glDeleteShader(shader); + return error.GpuCreate; + } + return shader; +} + +// ===================================================================== +// PARDES_TEST frame capture: download the render target, write latest.ppm +// ===================================================================== + +fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { + if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; + if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); + var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + info.type = c.SDL_GPU_TEXTURETYPE_2D; + info.format = g.swapchain_format; + info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; + info.width = width; + info.height = height; + info.layer_count_or_depth = 1; + info.num_levels = 1; + info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; + g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; + g.capture_tex_w = width; + g.capture_tex_h = height; +} + +fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { + const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); + if (size == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.UnsupportedCaptureFormat; + } + if (g.capture_xfer == null or g.capture_xfer_size < size) { + if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); + var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; + g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.GpuCreate; + }; + g.capture_xfer_size = size; + } + const xfer = g.capture_xfer.?; + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; + const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; + c.SDL_DownloadFromGPUTexture(copy, &src, &dst); + c.SDL_EndGPUCopyPass(copy); + + const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; + defer c.SDL_ReleaseGPUFence(g.device, fence); + var fences = [_]*c.SDL_GPUFence{fence}; + if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; + + const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; + defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); + const pixels: [*]const u8 = @ptrCast(mapped); + try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); +} + +fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { + if (g.capture_dir.len == 0) return; + const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); + if (bpp != 4) return error.UnsupportedCaptureFormat; + const bgr = switch (g.swapchain_format) { + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, + else => return error.UnsupportedCaptureFormat, + }; + + var tmp_buf: [4096]u8 = undefined; + var final_buf: [4096]u8 = undefined; + const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; + + const orc = linux.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(orc); + if (sfd < 0) return error.CaptureWriteFailed; + const fd: c_int = @intCast(sfd); + defer _ = linux.close(fd); + + var header: [64]u8 = undefined; + const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; + writeFd(fd, hdr); + + const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); + defer gpa.free(row_rgb); + for (0..height) |row| { + const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; + for (0..width) |x| { + const si = x * 4; + const di = x * 3; + row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; + row_rgb[di + 1] = src[si + 1]; + row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; + } + writeFd(fd, row_rgb); + } + const rrc = linux.rename(tmp_path, final_path); + const rs: isize = @bitCast(rrc); + if (rs < 0) return error.CaptureWriteFailed; +} + +// ===================================================================== +// touch debug overlay: per-finger colored circles + trails + a middle-click +// flash HUD, alpha-blended over the grid. Always active on touch input. +// ===================================================================== + +fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32) u32 { + if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; + const t = &g.touch; + + if (t.middle_flash_frames != 0) { + const flash = @as(f32, @floatFromInt(t.middle_flash_frames)) / @as(f32, @floatFromInt(touch_middle_flash_max_frames)); + const label = touchMiddleClickLabel(t.middle_clicks); + const glyph_scale = 3.0; + const char_step = 4.0 * glyph_scale; + const pad = 5.0; + const stripe_w = 4.0; + const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; + const label_h = pad * 2.0 + 5.0 * glyph_scale; + builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); + builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); + builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); + builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); + t.middle_flash_frames -= 1; + } + + for (&t.points) |*tp| { + if (!tp.active) continue; + const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w; + const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h; + const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; + const color = touchColor(tp.id); + + if (tp.trail_len > 1) { + const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; + for (0..tp.trail_len - 1) |trail_i| { + const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; + const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); + var trail_color = color; + trail_color.a *= 0.42 * fade; + const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w; + const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h; + const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; + builder.addCircle(tx, ty, trail_radius, trail_color); + } + } + + builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); + builder.addCircle(cx, cy, radius, color); + builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); + builder.addIdTag(tp.id, cx, cy, color); + } + return @intCast(builder.len); +} + +fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { + const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); + const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return; + const dst: [*]u8 = @ptrCast(ptr); + const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); + @memcpy(dst[0..byte_len], src[0..byte_len]); + c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; + const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; + c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); + c.SDL_EndGPUCopyPass(copy); +} + +fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { + if (vertex_count == 0) return; + const pass = rp orelse return; + c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); + const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; + c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); + c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); +} + +fn touchColor(id: u64) OverlayColor { + const palette = [_]OverlayColor{ + .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, + .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, + .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, + .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, + .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, + .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, + .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, + .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, + }; + return palette[@intCast(id % palette.len)]; +} + +const OverlayBuilder = struct { + vertices: []OverlayVertex, + len: usize = 0, + win_w: f32, + win_h: f32, + + fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { + if (b.len >= b.vertices.len) return; + b.vertices[b.len] = .{ + .x = (px / b.win_w) * 2.0 - 1.0, + .y = 1.0 - (py / b.win_h) * 2.0, + .r = color.r, + .g = color.g, + .b = color.b, + .a = color.a, + }; + b.len += 1; + } + + fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { + if (b.len + 3 > b.vertices.len) return; + b.addVertex(x0, y0, color); + b.addVertex(x1, y1, color); + b.addVertex(x2, y2, color); + } + + fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { + if (b.len + 6 > b.vertices.len) return; + const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); + const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); + const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); + const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); + if (x1 <= x0 or y1 <= y0) return; + b.addTri(x0, y0, x1, y0, x1, y1, color); + b.addTri(x0, y0, x1, y1, x0, y1, color); + } + + fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { + var i: usize = 0; + while (i + 1 < circle_points.len) : (i += 1) { + const p0 = circle_points[i]; + const p1 = circle_points[i + 1]; + b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); + } + } + + fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { + const label = touchIdLabel(id); + const glyph_scale = 2.0; + const char_step = 4.0 * glyph_scale; + const pad = 4.0; + const stripe_w = 3.0; + const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; + const label_h = pad * 2.0 + 5.0 * glyph_scale; + + var x = cx + 20.0; + if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; + x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); + var y = cy - label_h - 10.0; + if (y < 2.0) y = cy + 20.0; + y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); + + b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); + b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); + b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); + } + + fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { + var cx = x; + for (text) |ch| { + const rows = miniGlyph(ch); + const masks = [_]u8{ 0b100, 0b010, 0b001 }; + for (rows, 0..) |bits, row| { + for (masks, 0..) |mask, colidx| { + if ((bits & mask) == 0) continue; + const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; + const y0 = y + @as(f32, @floatFromInt(row)) * scale; + b.addRect(x0, y0, x0 + scale, y0 + scale, color); + } + } + cx += 4.0 * scale; + } + } +}; + +const circle_points = [_][2]f32{ + .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, + .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, + .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, + .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, + .{ 1.0000000, 0.0000000 }, +}; + +fn touchIdLabel(id: u64) [9]u8 { + var out: [9]u8 = undefined; + out[0] = '#'; + const low: u32 = @truncate(id); + for (0..8) |i| { + const shift: u5 = @intCast((7 - i) * 4); + out[i + 1] = hexDigit((low >> shift) & 0xf); + } + return out; +} + +fn touchMiddleClickLabel(count: u32) [13]u8 { + var out: [13]u8 = undefined; + @memcpy(out[0..5], "MID #"); + for (0..8) |i| { + const shift: u5 = @intCast((7 - i) * 4); + out[i + 5] = hexDigit((count >> shift) & 0xf); + } + return out; +} + +fn hexDigit(n: u32) u8 { + return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); +} + +fn miniGlyph(ch: u8) [5]u8 { + return switch (ch) { + '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, + '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, + '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, + '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, + '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, + '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, + '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, + '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, + '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, + '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, + '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, + 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, + 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, + 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, + 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, + 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, + 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, + 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, + 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, + else => .{ 0, 0, 0, 0, 0 }, + }; +} + +// ===================================================================== +// shared plumbing (same shapes as tty.zig) +// ===================================================================== + +/// The effective codepoint the way vaxis Key.matches sees it: a single-char +/// text wins (shift resolved by the terminal), else the shifted codepoint. +fn effCp(key: vaxis.Key) u21 { + if (key.text) |t| { + const view = std.unicode.Utf8View.init(t) catch return key.codepoint; + var it = view.iterator(); + if (it.nextCodepoint()) |cp| { + if (it.nextCodepoint() == null) return cp; + } + } + return key.shifted_codepoint orelse key.codepoint; +} + +/// vaxis functional-key codepoints -> core Key constants (ASCII ones already +/// coincide: enter/tab/escape/backspace pass through). +fn mapKey(cp: u21) u21 { + return switch (cp) { + vaxis.Key.up => pardes.Key.up, + vaxis.Key.down => pardes.Key.down, + vaxis.Key.left => pardes.Key.left, + vaxis.Key.right => pardes.Key.right, + vaxis.Key.home => pardes.Key.home, + vaxis.Key.end => pardes.Key.end, + vaxis.Key.page_up => pardes.Key.page_up, + vaxis.Key.page_down => pardes.Key.page_down, + else => cp, + }; +} + +fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { + const raw = env.get(name) orelse return null; + return std.fmt.parseInt(u16, raw, 10) catch null; +} + +fn writeFile(data: []const u8) void { + const rc = linux.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const sfd: isize = @bitCast(rc); + if (sfd < 0) return; + const fd: c_int = @intCast(sfd); + defer _ = linux.close(fd); + writeFd(fd, data); +} + +fn writeFd(fd: c_int, data: []const u8) void { + var off: usize = 0; + while (off < data.len) { + const rc = linux.write(fd, data[off..].ptr, data.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return; + } + off += @intCast(n); + } +} |
