//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input //! into core events, performs the core's effects (fork ptys, write them, //! resize them — same duties as tty.zig, this is also native), and //! rasterizes the core's Surface: one instanced quad per cell, glyphs from an //! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, //! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the //! real renderer, drives input from stdin, and captures frames to PPM. //! //! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): //! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a //! requestAnimationFrame callback instead of a blocking loop. Comptime //! branches on is_emscripten, the way the stdlib branches on os.tag. const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; const linux = std.os.linux; // inotify constants; referenced only on 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 config = @import("../config.zig"); const look = @import("../look.zig"); const deck = @import("deck.zig"); const crt = @import("crt.zig"); const fonts = @import("fonts.zig"); // the Font builtin's half of the seam const selection_pipe = @import("../selection_pipe.zig"); const is_emscripten = builtin.os.tag == .emscripten; const temp_file = if (is_emscripten) struct {} else @import("../temp_file.zig"); pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); if (is_emscripten) @cInclude("GLES3/gl3.h"); @cInclude("font.h"); }); extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; extern "c" fn chdir(path: [*:0]const u8) c_int; extern "c" fn _exit(status: c_int) noreturn; extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; // absolute shell path per OS: execv must not search PATH (no allocation // between fork and exec) const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; // TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); const log = std.log.scoped(.gui); const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv"); const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); const image_vert_spv = if (is_emscripten) "" else @embedFile("image.vert.spv"); const image_frag_spv = if (is_emscripten) "" else @embedFile("image.frag.spv"); const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv"); const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv"); // web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; // web: no ptys — the whole state replays from a dump embedded at build time const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; // default page colors — the same "terminal native dark" the prototype used const bg_default = [3]u8{ 18, 18, 18 }; const fg_default = [3]u8{ 204, 204, 204 }; // the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ // devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set // the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. // SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; // mask=0 transparent — so data = set and mask = set|clr. const p9_arrow_clr = [32]u8{ 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, }; const p9_arrow_set = [32]u8{ 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, }; const p9_arrow_mask = blk: { var m: [32]u8 = undefined; for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; break :blk m; }; // web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays // sharp on scaled canvases (the prototype's -Dweb-render-scale, which never // shipped non-default — hardcoded here instead of a build option). const web_render_scale: f32 = 1.5; // 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too const atlas_w: u32 = 2048; const atlas_h: u32 = 2048; const Slot = struct { u: u32, v: u32 }; const max_touch_points: usize = 16; const max_touch_trail_points: usize = 8; const overlay_circle_vertices: usize = 16 * 3; const touch_click_flash_max_frames: u8 = 14; const touch_click_flash_vertices: usize = 1400; const touch_scroll_tick: f32 = 0.02; /// SDL input can wake the loop faster than display cadence, so ticking once /// per pass would make animation duration depend on pty traffic or mouse /// motion. This monotonic gate keeps it near 60 Hz without sleeping the event /// loop; the first frame after activation displays the exact source palette. const AnimationClock = struct { next_ns: u64 = 0, fn due(clock: *AnimationClock, active: bool, now_ns: u64) bool { if (!active) { clock.next_ns = 0; return false; } if (clock.next_ns == 0) { clock.next_ns = now_ns +| pardes.animation.frame_ns; return false; } if (now_ns < clock.next_ns) return false; clock.next_ns = now_ns +| pardes.animation.frame_ns; return true; } }; test "GUI animation clock is active-only and cadence gated" { var clock: AnimationClock = .{}; try std.testing.expect(!clock.due(false, 100)); try std.testing.expect(!clock.due(true, 100)); try std.testing.expect(!clock.due(true, 100 + pardes.animation.frame_ns - 1)); try std.testing.expect(clock.due(true, 100 + pardes.animation.frame_ns)); try std.testing.expect(!clock.due(false, 100 + 2 * pardes.animation.frame_ns)); try std.testing.expectEqual(@as(u64, 0), clock.next_ns); } /// Web tap-vs-scroll heuristic, measured after normalized coordinates have /// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). const touch_tap_slop_px: f32 = 12.0; const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); // Ctrl+ / Ctrl-: how far one press moves g.px, and the two sizes it stops at. // ONE size for the whole window, not one per pane: the core lays every pane // out on a single uniform cell grid and Surface is one flat cols×rows array, // so a second cell size would be a different core, not a different font. // // The step is 2 and not 1 because 1 is a press that sometimes does nothing — // cell_w is round(advance × scale), the shipped face advances ~0.51px per px // of size, and half the 1px steps therefore round to the same column width. A // key that visibly works only every other press reads as a broken key. At 2 // both axes move at every size in the range, on both parities of the ladder; // that is what the test below walks. // // The ends are where a terminal stops being one. 8px is a 4×8 cell — the // smallest thing with a glyph still in it — and 72px is 37×76, about thirty // columns across a laptop screen. Past either the grid is not small or large, // it is wedged, and there is no reset binding to get back out of it. const font_px_step: f32 = 2.0; const font_px_min: f32 = 8.0; const font_px_max: f32 = 72.0; test "every font size step moves the cell, and the ends are reachable exactly" { const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)).?; defer c.ui_font_free(font); // walk the WHOLE range rather than one ladder: dispatch starts from 27 // native and 18 web, so both the odd and the even rungs get pressed, and // a stall on either is a dead keystroke for somebody. var px = font_px_min + font_px_step; while (px <= font_px_max) : (px += 1.0) { var cw: c_int = 0; var ch: c_int = 0; var asc: c_int = 0; var pw: c_int = 0; var ph: c_int = 0; c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px), &cw, &ch, &asc); c.ui_font_cell_metrics(font, c.ui_font_scale_for_height(font, px - font_px_step), &pw, &ph, &asc); try std.testing.expect(cw > pw and ch > ph); } // ...and the clamp dispatch runs parks on each end instead of walking off // it, from any size a press can leave g.px on try std.testing.expectEqual(font_px_max, std.math.clamp(font_px_max + font_px_step, font_px_min, font_px_max)); try std.testing.expectEqual(font_px_min, std.math.clamp(font_px_min - font_px_step, font_px_min, font_px_max)); } // 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 }; const ImageInstance = extern struct { x0: f32, y0: f32, x1: f32, y1: f32, u0: f32, v0: f32, u1: f32, v1: f32, }; const initial_image_capacity: u32 = pardes.MAX_PANES; fn updateCoreResize(core: *pardes.Pardes, cols: u16, rows: u16, cell_w: u32, cell_h: u32) bool { if (comptime pardes.pdf_enabled) { const cell_px_w: u16 = @intCast(@min(@max(1, cell_w), std.math.maxInt(u16))); const cell_px_h: u16 = @intCast(@min(@max(1, cell_h), std.math.maxInt(u16))); if (cols == core.screen_w and rows == core.screen_h and cell_px_w == core.cell_pixels.w and cell_px_h == core.cell_pixels.h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = .{ .w = cell_px_w, .h = cell_px_h }, } }); } else { if (cols == core.screen_w and rows == core.screen_h) return false; core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); } return true; } // ---- touch: per-finger tracking + shared scroll/tap machines ---- const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; const TouchPoint = struct { active: bool = false, id: u64 = 0, x: f32 = 0, y: f32 = 0, pressure: f32 = 1, trail: [max_touch_trail_points]TouchSample = @splat(.{}), trail_len: usize = 0, trail_next: usize = 0, }; const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; /// A finger event with SDL's normalized 0..1 coordinates — the one shape both /// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; const Touch = struct { points: [max_touch_points]TouchPoint = @splat(.{}), scroll: struct { active: bool = false, ids: [2]u64 = .{ 0, 0 }, accum_y: f32 = 0, scrolled: bool = false, last_center: TouchNormPoint = .{}, } = .{}, // Web policy: one primary finger is either a body tap/scroll or a latched // left-mouse gesture when it begins on layout chrome. A second simultaneous // finger cancels a body tap rather than producing a click. single: struct { active: bool = false, id: u64 = 0, start: TouchNormPoint = .{}, last: TouchNormPoint = .{}, accum_y: f32 = 0, scrolling: bool = false, mouse_drag: bool = false, multitouch: bool = false, } = .{}, click_count: u32 = 0, click_button: pardes.Mouse.Button = .middle, click_flash_frames: u8 = 0, fn findSlot(t: *const Touch, id: u64) ?usize { for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; return null; } fn updatePoint(t: *Touch, f: Finger) void { switch (f.kind) { .up, .cancel => if (t.findSlot(f.id)) |i| { t.points[i].active = false; }, .down, .motion => { const idx = t.findSlot(f.id) orelse blk: { for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; break :blk @as(usize, @intCast(f.id % max_touch_points)); }; const tp = &t.points[idx]; if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; tp.x = f.x; tp.y = f.y; tp.pressure = f.pressure; tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; }, } } fn activeCount(t: *const Touch) usize { var count: usize = 0; for (&t.points) |*tp| count += @intFromBool(tp.active); return count; } fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { var sum: TouchNormPoint = .{}; for (ids) |id| { const i = t.findSlot(id) orelse return null; sum.x += t.points[i].x; sum.y += t.points[i].y; } return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; } /// keep scroll state in sync with the set of active fingers: exactly two /// active fingers begin (or re-key) a pair; anything else resets it. fn syncPair(t: *Touch) void { var ids: [2]u64 = .{ 0, 0 }; var count: usize = 0; for (&t.points) |*tp| { if (!tp.active) continue; if (count >= 2) { t.scroll = .{}; return; } ids[count] = tp.id; count += 1; } if (count != 2) { t.scroll = .{}; return; } if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; } /// finger lift: a pair that never scrolled is a two-finger TAP — returns /// its center (computed with the lifting finger's final position). fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const tap = f.kind == .up and !t.scroll.scrolled; var center: ?TouchNormPoint = null; if (tap) { var sum: TouchNormPoint = .{}; var ok = true; for (t.scroll.ids) |id| { if (id == f.id) { sum.x += f.x; sum.y += f.y; } else if (t.findSlot(id)) |i| { sum.x += t.points[i].x; sum.y += t.points[i].y; } else ok = false; } center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; } t.scroll = .{}; return center; } fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; const delta_y = center.y - t.scroll.last_center.y; t.scroll.last_center = center; t.scroll.accum_y += delta_y; const ticks = takeScrollTicks(&t.scroll.accum_y); if (ticks == 0) return null; t.scroll.scrolled = true; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { if (t.single.active) { if (t.single.id != f.id) t.single.multitouch = true; return; } if (t.activeCount() != 1) return; const point = TouchNormPoint{ .x = f.x, .y = f.y }; t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; } fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; const center = TouchNormPoint{ .x = f.x, .y = f.y }; var delta_y = center.y - t.single.last.y; t.single.last = center; if (!t.single.scrolling) { const dx = (center.x - t.single.start.x) * win_w; const dy = (center.y - t.single.start.y) * win_h; if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; // Once the pointer leaves the tap slop it can never click. Include // all travel since finger-down so the first scroll tick is prompt. t.single.scrolling = true; delta_y = center.y - t.single.start.y; } t.single.accum_y += delta_y; const ticks = takeScrollTicks(&t.single.accum_y); if (ticks == 0) return null; return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; } fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { if (!t.single.active or t.single.id != f.id) return null; const point = TouchNormPoint{ .x = f.x, .y = f.y }; const dx = (point.x - t.single.start.x) * win_w; const dy = (point.y - t.single.start.y) * win_h; const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; t.single = .{}; return if (tap) point else null; } }; fn takeScrollTicks(accum: *f32) i32 { var ticks: i32 = 0; while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; return ticks; } /// The SDL boundary owns touch policy: a web body uses one-finger natural /// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; /// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse /// events and coordinate mapping. fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { std.debug.assert(cell_w > 0 and cell_h > 0); if (is_emscripten) return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); } fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { switch (f.kind) { .down => { t.updatePoint(f); const point = TouchNormPoint{ .x = f.x, .y = f.y }; const col = normCell(point.x, win_w, cell_w); const row = normCell(point.y, win_h, cell_h); const chrome = webChromeTarget(core, col, row); t.beginSingle(f, chrome != null); if (chrome) |target| core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); }, .motion => { t.updatePoint(f); if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { core.update(.{ .mouse = .{ .button = .left, .kind = .drag, .col = normCell(f.x, win_w, cell_w), .row = normCell(f.y, win_h, cell_h), } }); return; } const res = t.noteSingleMotion(f, win_w, win_h) orelse return; emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); }, .up, .cancel => { const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; const tap = t.finishSingle(f, win_w, win_h); t.updatePoint(f); if (mouse_drag) { core.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = normCell(f.x, win_w, cell_w), .row = normCell(f.y, win_h, cell_h), } }); } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); }, } } const WebChromeTarget = struct { col: u16, row: u16 }; /// Fat-finger hit test for web layout chrome. A gesture is classified once at /// finger-down, then never changes into scrolling. Handle coordinates are /// snapped onto the core's one-cell resize edge; body rows remain untouched. fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget { if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row }; // The move box wins over a horizontal handle when a pane is tag-only, // matching the core's own mouse hit-test priority. for (core.panes, 0..) |slot, id| { if (slot == null) continue; const rect = core.rects[id]; if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + config.GUTTER) return .{ .col = col, .row = rect.y }; } for (0..core.ncol -| 1) |column| { const handle = core.col_x[column] + core.col_w[column] -| 1; if (col == handle) return .{ .col = handle, .row = row }; } for (0..core.ncol) |column| { if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; for (0..core.col_n[column] -| 1) |index| { const rect = core.rects[core.col_terms[column][index]]; const handle = rect.y + rect.h -| 1; if (row == handle) return .{ .col = col, .row = handle }; } } // The remainder of each tag row is tag editing, not a scrollable body. for (core.panes, 0..) |slot, id| { if (slot == null) continue; const rect = core.rects[id]; if (row == rect.y and col >= rect.x and col < rect.x + rect.w) return .{ .col = col, .row = rect.y }; } // One-cell tolerance around thin separators, after exact tag rows have had // first refusal so a fat-finger tag edit never turns into a resize. for (0..core.ncol -| 1) |column| { const handle = core.col_x[column] + core.col_w[column] -| 1; if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row }; } for (0..core.ncol) |column| { if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; for (0..core.col_n[column] -| 1) |index| { const rect = core.rects[core.col_terms[column][index]]; const handle = rect.y + rect.h -| 1; if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle }; } } return null; } fn cellDistance(a: u16, b: u16) u16 { return if (a > b) a - b else b - a; } fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { switch (f.kind) { .down => { t.updatePoint(f); t.syncPair(); }, .motion => { const had_pair = t.scroll.active; t.updatePoint(f); t.syncPair(); if (!had_pair) return; const res = t.noteMotion(f) orelse return; emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); }, .up, .cancel => { const tap_center = t.finishPair(f); t.updatePoint(f); t.syncPair(); if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h); }, } } fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { const col = normCell(center.x, win_w, cell_w); const row = normCell(center.y, win_h, cell_h); const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; var remaining: u32 = @abs(ticks); while (remaining > 0) : (remaining -= 1) core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); core.update(.{ .touch_scroll = delta_y }); } fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { t.click_count +%= 1; t.click_button = button; t.click_flash_frames = touch_click_flash_max_frames; const col = normCell(point.x, win_w, cell_w); const row = normCell(point.y, win_h, cell_h); core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } }); } fn normCell(norm: f32, win: f32, cell: f32) u16 { const px = std.math.clamp(norm, 0.0, 1.0) * win; return @intFromFloat(@max(0, @floor(px / cell))); } // ---- pty plumbing: reader threads feed a mutex-protected queue ---- const Pty = struct { fd: c_int, pid: libc.pid_t }; const Msg = union(enum) { output: struct { pane: u8, gen: u32, bytes: []u8 }, eof: struct { pane: u8, gen: u32, fd: c_int }, /// a language query finished on its own thread (see lspThread) lsp: struct { id: u32, rows: []u8 }, /// a selection-filter worker finished; every stdout is gpa-owned pipe: selection_pipe.Response, /// something happened in a watched directory (see watchThread) files_changed, }; /// The files on open panes, watched through ONE inotify instance. Same shape /// and same reasoning as tty.zig's Watch, which spells it out: the mark goes on /// the containing DIRECTORY because nothing rewrites a file in place — a /// formatter, a checkout, an editor all rename a temp file over the target and /// swap the inode — and `hash` is what we last saw ON DISK, so our own Save /// never reads as an external change. const Watch = struct { wd: c_int, hash: u64 }; /// One language query, owned by the thread running it — the gui twin of /// tty.zig's LspJob, and copied for the same reason: the core edits on. const LspJob = struct { id: u32, kind: pardes.lsp.Kind, offset: u32, path: []u8, source: [:0]u8, arg: []u8, root: []u8, fn free(j: *LspJob, gpa: std.mem.Allocator) void { gpa.free(j.path); gpa.free(j.source); gpa.free(j.arg); gpa.free(j.root); gpa.destroy(j); } }; const PipeTask = struct { id: u32, future: std.Io.Future(anyerror!void), }; const Queue = struct { gpa: std.mem.Allocator, sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push // 0.16 has no std.Thread.Mutex; critical sections here are a few // instructions, so spinning on the lock-free std.atomic.Mutex is enough mutex: std.atomic.Mutex = .unlocked, items: std.ArrayList(Msg) = .empty, closed: bool = false, fn lock(q: *Queue) void { while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); } fn push(q: *Queue, m: Msg) void { q.lock(); if (q.closed) { q.mutex.unlock(); switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), .pipe => |response_value| { var response = response_value; response.deinit(q.gpa); }, .eof, .files_changed => {}, } return; } q.items.append(q.gpa, m) catch { q.mutex.unlock(); switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), .pipe => |response_value| { var response = response_value; response.deinit(q.gpa); }, .eof, .files_changed => {}, } return; }; q.mutex.unlock(); if (q.sdl_wake) { var sev = std.mem.zeroes(c.SDL_Event); sev.type = c.SDL_EVENT_USER; _ = c.SDL_PushEvent(&sev); } } /// take the pending messages (caller iterates + deinits outside the lock) fn take(q: *Queue) std.ArrayList(Msg) { q.lock(); defer q.mutex.unlock(); const local = q.items; q.items = .empty; return local; } fn close(q: *Queue) void { q.lock(); defer q.mutex.unlock(); q.closed = true; for (q.items.items) |m| switch (m) { .output => |o| q.gpa.free(o.bytes), .lsp => |l| q.gpa.free(l.rows), .pipe => |response_value| { var response = response_value; response.deinit(q.gpa); }, .eof, .files_changed => {}, }; q.items.deinit(q.gpa); } }; fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { var buf: [0x10000]u8 = undefined; while (true) { const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; break; // EIO when the child exits: treat as EOF } if (n == 0) break; const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); } q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); } fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; th.detach(); } /// Mark or unmark one pane's file (`path` null = unmark). Linux only: anywhere /// else this returns silently, the core never receives a file_changed event, /// and the feature is simply off — which the core already has to tolerate, /// since the browser build of this same shell has no filesystem at all. /// ponytail: darwin wants the FSEvents half of std.Build.Watch here. fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { if (comptime builtin.os.tag != .linux) return; if (fd < 0) return; if (watches[id]) |old| { // inotify hands out ONE descriptor per directory, so two panes on // files in the same directory share it: drop the mark only when the // last of them lets go, or closing one blinds the other. var shared = false; for (watches, 0..) |other, i| { const o = other orelse continue; if (i != id and o.wd == old.wd) shared = true; } if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); watches[id] = null; } const p = path orelse return; const dir = std.fs.path.dirname(p) orelse "."; var dbuf: [4096:0]u8 = undefined; if (dir.len >= dbuf.len) return; @memcpy(dbuf[0..dir.len], dir); dbuf[dir.len] = 0; // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one // per write(2). MOVED_TO and CREATE catch the rename-over and the // delete-then-recreate that are how files are actually replaced. const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); if (wd < 0) return; watches[id] = .{ .wd = wd, .hash = hash }; } /// Block on the inotify fd and wake the loop. Deliberately does NOT parse the /// events: the loop re-reads every watched pane anyway, so the only thing an /// event carries that we need is THAT something happened, and parsing would /// mean sharing the watch table with the thread that mutates it. Detached like /// the pty readers, and ended the same way — teardown closes the fd, the read /// fails, the thread returns. fn watchThread(fd: c_int, q: *Queue) void { var buf: [4096]u8 = undefined; while (true) { const n = libc.read(fd, &buf, buf.len); if (n < 0) { if (libc.errno(n) == .INTR) continue; break; } if (n == 0) break; q.push(.files_changed); } } /// Hand the core every watched pane whose bytes moved on disk. The wake says /// only THAT something happened, so this re-reads the lot; the hash comparison /// is what keeps our own Save — and any write that lands on identical content /// — out of the undo stack. Read on the loop rather than on the watcher thread /// because the core is the only thing that knows which pane a path belongs to. fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes.MAX_PANES]?Watch) void { for (watches, 0..) |*slot, id| { if (slot.* == null) continue; const pane = core.panes[id] orelse continue; const f = pane.file orelse continue; const bytes = look.readFile(gpa, f.path) catch continue; defer gpa.free(bytes); const h = std.hash.Wyhash.hash(0, bytes); if (h == slot.*.?.hash) continue; slot.*.?.hash = h; core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); } } /// Answer a language query off the render loop and push the rows to the queue /// — the async execution model, spelled in the plumbing this shell already has /// (a detached thread and the mutex queue the pty readers use). fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void { defer job.free(gpa); var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); // the shell owns the result buffer; the backend only ever writes to it var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); pardes.lsp.query(gpa, arena.allocator(), .{ .kind = job.kind, .path = job.path, .source = job.source, .offset = job.offset, .arg = job.arg, .root = job.root, }, &out.writer); const rows = gpa.dupe(u8, out.written()) catch return; q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); } /// Copy the query out of the core and hand it to a thread. A detached thread /// per query is fine at this rate: one keystroke, one query, and the queue /// already tolerates a late push after close. fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void { const pane = core.panes[e.pane] orelse return; // a pane with no file still asks `status` (it is about the backend, not // the buffer): empty path and source, root from the pane's cwd const f = pane.file; const job = gpa.create(LspJob) catch return; job.* = .{ .id = e.id, .kind = e.kind, .offset = e.offset, .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { gpa.destroy(job); return; }, .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { gpa.free(job.path); gpa.destroy(job); return; }, .arg = gpa.dupe(u8, e.arg.slice()) catch { gpa.free(job.path); gpa.free(job.source); gpa.destroy(job); return; }, .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { gpa.free(job.path); gpa.free(job.source); gpa.free(job.arg); gpa.destroy(job); return; }, }; const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch { job.free(gpa); return; }; th.detach(); } fn pipeThread(io: std.Io, gpa: std.mem.Allocator, job: *selection_pipe.Job, q: *Queue) anyerror!void { defer job.deinit(gpa); const response = selection_pipe.runJob(gpa, io, job); q.push(.{ .pipe = response }); } /// Copy every borrowed core byte before the tracked worker starts. The queue /// owns the response and already has close-time disposal for a late answer. fn spawnPipe( core: *pardes.Pardes, io: std.Io, gpa: std.mem.Allocator, q: *Queue, tasks: *std.ArrayList(PipeTask), request: anytype, ) void { const view = core.pipeRequest(request.id) orelse return; const job = selection_pipe.Job.copy(gpa, view) catch return; tasks.ensureUnusedCapacity(gpa, 1) catch { job.deinit(gpa); return; }; const future = io.concurrent(pipeThread, .{ io, gpa, job, q }) catch { job.deinit(gpa); return; }; tasks.appendAssumeCapacity(.{ .id = request.id, .future = future }); } // ---- 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, image_pipeline: *c.SDL_GPUGraphicsPipeline, crt_pipeline: *c.SDL_GPUGraphicsPipeline, atlas_tex: *c.SDL_GPUTexture, atlas_sampler: *c.SDL_GPUSampler, linear_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, image_vbuf: *c.SDL_GPUBuffer, image_vxfer: *c.SDL_GPUTransferBuffer, image_capacity: u32 = 0, native_images: std.AutoHashMapUnmanaged(pardes.ImageCacheKey, *c.SDL_GPUTexture) = .empty, font: *c.UIFont, /// the file behind `font`, when it is one the Font builtin loaded. Empty /// for the font the binary ships with, which is @embedFile'd and not ours /// to free — stb keeps a pointer into these bytes, so they outlive nothing. font_bytes: []u8 = &.{}, /// the cell height the metrics are asked for, in pixels. A field and not /// the local constant it used to be because refitFont reads it: changing /// the FACE has to re-ask at the same size, and changing the SIZE (the /// Ctrl+/Ctrl- this leaves the path for) is writing here and calling that. px: f32, 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, // Fractional wheel scroll, one pane at a time. SDL's exact floating-point // distance is batched until the next render. The core still moves only at // whole-row boundaries; scroll_lag retains the sub-row picture position. // // ponytail: one accumulator, so exactly one pane can be offset and only the // MOUSE wheel fills it — the deck's left stick and a two-finger touch // scroll still hand the core their whole rows on the spot (they have // their own sub-tick accumulators, and neither aims well enough to miss // the fractional rendering). Both are one call site each: point them at // scroll_delta the way the wheel arm of dispatch does. scroll_pane: ?usize = null, scroll_rect: pardes.Rect = .{ .x = 0, .y = 0, .w = 0, .h = 0 }, scroll_col: u16 = 0, // where the wheel turned: crossed rows are delivered scroll_row: u16 = 0, // THERE, not wherever the pointer has drifted to since scroll_delta: f32 = 0, // finite raw distance waiting for the render batch scroll_lag: f32 = 0, // picture position - core position, in rows scroll_edge: []pardes.Cell = &.{}, // the row that just left the pane scroll_edge_len: u16 = 0, // Crt builtin: the scene renders into this texture, then a fullscreen // CRT pass warps it onto the real target scene_tex: ?*c.SDL_GPUTexture = null, scene_tex_w: u32 = 0, scene_tex_h: u32 = 0, // PARDES_TEST frame capture (render into an offscreen target, dump PPM) capture: bool = false, capture_dir: []const u8 = "", capture_tex: ?*c.SDL_GPUTexture = null, capture_tex_w: u32 = 0, capture_tex_h: u32 = 0, capture_xfer: ?*c.SDL_GPUTransferBuffer = null, capture_xfer_size: u32 = 0, // steamdeck: gamepad-driven virtual mouse cursor (pixel position) gamepad: ?*c.SDL_Gamepad = null, pad_x: f32 = 0, pad_y: f32 = 0, pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) deck: deck.Deck = .{}, // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging gl_program: c_uint = 0, gl_overlay_program: c_uint = 0, gl_atlas_tex: c_uint = 0, gl_vbo: c_uint = 0, gl_overlay_vbo: c_uint = 0, gl_u_atlas: c_int = -1, gl_instances: []CellInstance = &.{}, }; /// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the /// canvas resize and the grid resize land on different frames, so the grid is /// scaled to fit and centered (letterboxed) instead of clipping. const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; fn fixedCellLayout(g: *const Gui) CellLayout { return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; } fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { const fixed = fixedCellLayout(g); if (!is_emscripten) return fixed; if (cols == 0 or rows == 0) return fixed; const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); const scale = @min(win_w / grid_w, win_h / grid_h); return .{ .w = fixed.w * scale, .h = fixed.h * scale, .x_off = @max(0, (win_w - grid_w * scale) * 0.5), .y_off = @max(0, (win_h - grid_h * scale) * 0.5), }; } // ===================================================================== // entry // ===================================================================== pub const run = if (is_emscripten) runWeb else runNative; fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in); const test_mode = env.get("PARDES_TEST") != null; const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); if (test_mode and capture_dir == null) { log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); return error.SdlInit; } // The trackpads only exist through SDL's built-in HIDAPI Steam Deck // driver (it registers the two touchpads and disables "lizard mode"). // It's default-on on Linux; pin it so intent is explicit. NOTE: this is // NOT enough in Game Mode — Steam Input there hands the app a // touchpad-less virtual gamepad instead of the real Neptune controller, // so the pads go dead no matter what the app does. The only fix is to // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), // after which the real controller enumerates and the touchpad events flow. _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { log.err("SDL_Init: {s}", .{c.SDL_GetError()}); return error.SdlInit; } var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { _ = c.SDL_SetCursor(cur); } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); // Keep the window's mouse ungrabbed: desktop users must be able to move // the pointer out normally. The deck's virtual pointer is clamped and // warped explicitly only when its controls move it (see dispatch and // pollGamepad), so it does not need window-wide confinement. const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); return error.SdlInit; } // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync const present_mode: c.SDL_GPUPresentMode = blk: { if (env.get("PARDES_SDL_PRESENT")) |raw| { const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) c.SDL_GPU_PRESENTMODE_IMMEDIATE else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) c.SDL_GPU_PRESENTMODE_MAILBOX else if (std.ascii.eqlIgnoreCase(raw, "vsync")) c.SDL_GPU_PRESENTMODE_VSYNC else null; if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; } break :blk c.SDL_GPU_PRESENTMODE_VSYNC; }; if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); // ---- font + cell metrics ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; }; const px: f32 = 27.0; const scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); const cell_w: u32 = @intCast(@max(cw, 1)); const cell_h: u32 = @intCast(@max(chh, 1)); if (test_mode) { const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); _ = c.SDL_SyncWindow(window); } // ---- glyph atlas (R8) + pipelines ---- var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = atlas_w; tex_info.height = atlas_h; tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); samp_info.min_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mag_filter = c.SDL_GPU_FILTER_NEAREST; samp_info.mipmap_mode = c.SDL_GPU_FILTER_NEAREST; 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; 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; const linear_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 image_pipeline = try makeImagePipeline(device, swapchain_format); const crt_pipeline = try makeCrtPipeline(device, swapchain_format); const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; const image_buffer_size: u32 = initial_image_capacity * @sizeOf(ImageInstance); var ivb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = image_buffer_size, .props = 0 }; const image_vbuf = c.SDL_CreateGPUBuffer(device, &ivb_info) orelse return error.GpuCreate; var ivx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = image_buffer_size, .props = 0 }; const image_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ivx_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, .image_pipeline = image_pipeline, .crt_pipeline = crt_pipeline, .atlas_tex = atlas_tex, .atlas_sampler = atlas_sampler, .linear_sampler = linear_sampler, .atlas_xfer = atlas_xfer, .overlay_vbuf = overlay_vbuf, .overlay_vxfer = overlay_vxfer, .overlay_vertices = overlay_vertices, .image_vbuf = image_vbuf, .image_vxfer = image_vxfer, .image_capacity = initial_image_capacity, .font = font, .px = px, .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(); defer { clearNativeImages(&g); g.native_images.deinit(gpa); } defer c.SDL_ReleaseGPUTransferBuffer(device, g.image_vxfer); defer c.SDL_ReleaseGPUBuffer(device, g.image_vbuf); defer c.SDL_ReleaseGPUGraphicsPipeline(device, image_pipeline); defer if (g.font_bytes.len != 0) gpa.free(g.font_bytes); // set by Font, if it ran defer gpa.free(g.scroll_edge); // grown on demand by stepScroll // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); g.pen_x = cell_w; // ---- the core ---- pardes.image.start(io, gpa); // stb_image allocator for image panes defer pardes.image.stop(); // Live sessions initialize at the default 80x24 grid; the real window size // arrives as a resize EVENT on the first loop pass. The core defers the // shell greeting until after the first resize (so `ls` wraps to the real // pane width) — pre-sizing at init would mean no resize ever fires and the // greeting never runs (panes sat blank until the first interaction). // Dump loads pre-size instead: replayed panes never greet, and sizing at // init avoids reflowing their replayed content twice. var opts = opts_in; var pw: c_int = 0; var ph: c_int = 0; if (opts.load_path != null) { _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); } var core = if (opts.load_path) |lp| blk: { const bytes = try @import("../look.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); } else try pardes.Pardes.init(gpa, opts); defer core.deinit(); // SDL is itself a native-pixel backend. This is deliberately set after // construction: argv image panes no longer freeze the startup capability // into their PETSCII preference, so their first render emits attachments. core.native_images = true; // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) writeFile(pardes.bash_rc); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its // pane id has been respawned (see drainEffects .spawn) var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; defer queue.close(); var pipe_tasks: std.ArrayList(PipeTask) = .empty; defer { for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; pipe_tasks.deinit(gpa); } // One inotify instance for every watched pane, opened here — before any // thread exists — so the pre-loop drain below can already mark the file a // positional path argument opened. -1 off linux: watchPane goes quiet and // the core simply never gets a file_changed event. var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; defer if (inotify_fd >= 0) { _ = libc.close(inotify_fd); // ends the detached watcher's read inotify_fd = -1; }; var watches: [pardes.MAX_PANES]?Watch = @splat(null); // initial spawns BEFORE any worker thread exists: forkpty from a // multithreaded process can wedge the child before exec (see tty.zig). drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, false); for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); // ...and the one file watcher. Started even with nothing marked yet: the fd // already exists and an unwatched inotify instance just parks in read(2) — // one thread for the process, however many panes come and go. if (inotify_fd >= 0) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {}; _ = c.SDL_StartTextInput(window); var feed: StdinFeed = .{}; if (test_mode) setStdinRaw() catch {}; var frame_arena: std.heap.ArenaAllocator = .init(gpa); defer frame_arena.deinit(); var animation_clock: AnimationClock = .{}; 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(); var check_files = false; for (msgs.items) |m| switch (m) { .output => |o| { if (gens[o.pane] == o.gen) core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); gpa.free(o.bytes); }, .eof => |e| { _ = libc.close(e.fd); // the dead reader's master — stale or current if (gens[e.pane] == e.gen) { ptys[e.pane] = null; core.update(.{ .eof = .{ .pane = e.pane } }); } }, .lsp => |l| { core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); gpa.free(l.rows); }, .pipe => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, } }); response.deinit(gpa); for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { task.future.await(io) catch {}; _ = pipe_tasks.orderedRemove(i); break; }; }, // Coalesced on purpose: a burst of writes (a formatter, a build, a // `git checkout`) collapses into ONE pass below, so it cannot queue // a reload — or an undo entry — per write. .files_changed => check_files = true, }; msgs.deinit(gpa); if (check_files) reloadChanged(core, gpa, &watches); // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events pollGamepad(&g, core); // 4. effects drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, &g, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; kill the live // shells (their detached readers wake on child death; gens bumped so // the stale eofs close the old fds without touching the replay panes) if (core.takeRestore()) |rp| blk: { const bytes = look.readFile(gpa, rp) catch break :blk; defer gpa.free(bytes); var o = core.opts; o.cols = core.screen_w; o.rows = core.screen_h; // pre-size: dump panes never greet const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk; for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.kill(pt.pid, libc.SIG.KILL); slot.* = null; }; for (&gens) |*g2| g2.* +%= 1; // the replay core's pane ids mean new things, and the dying core's // `watch off` effects go into a queue nobody drains — drop the lot // here. The new core emits its own `on`s as it builds its panes. for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); clearNativeImages(&g); nc.native_images = true; core.deinit(); core = nc; } // Font builtin: the core resolved a name to a path and asked for it — // it cannot load a font itself, having no rasterizer, no atlas and no // window. Inline here beside Restore because it is the same kind of // thing and this is the flat loop. if (fonts.want) |path| blk: { fonts.want = null; const bytes = look.readFile(gpa, path) catch break :blk; const nf = c.ui_font_new(bytes.ptr, @intCast(bytes.len)) orelse { // stb turned it down (an .otf whose outlines it cannot read). // Keep wearing the one that works: a font pardes cannot // rasterize is a blank window with no way back out of it. log.err("ui_font_new failed: {s}", .{path}); gpa.free(bytes); break :blk; }; c.ui_font_free(g.font); if (g.font_bytes.len != 0) gpa.free(g.font_bytes); g.font = nf; g.font_bytes = bytes; // stb reads them for as long as the font lives refitFont(&g, core); } // 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). Off g.cell_w/h, not the init locals: a font change moves // them, and this is the line that would go on dividing by the old cell. _ = c.SDL_GetWindowSizeInPixels(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 (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(&g); // 7. render — apply the wheel batch LAST before it, while // core.surface still holds the frame the last pass drew stepScroll(&g, core, gpa); if (animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) core.update(.tick); _ = frame_arena.reset(.retain_capacity); const surface = try core.render(frame_arena.allocator()); renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); } } // ===================================================================== // web entry: set everything up, register a rAF frame callback, return. // EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. // ===================================================================== // std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer // is the whole heap, never freed (the shell lives for the page). var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; var web_fba: std.heap.FixedBufferAllocator = undefined; const WebState = struct { g: Gui, core: *pardes.Pardes, gpa: std.mem.Allocator, frame_arena: std.heap.ArenaAllocator, animation_clock: AnimationClock = .{}, }; var live_web_state: ?*WebState = null; // Browser snapshot tests drive real DOM touch input, then read the exact same // rendered Surface that WebGL consumed. These tiny exports keep the test // contract textual (the existing .snap convention) without adding test state // or browser concepts to the core. comptime { if (is_emscripten) { @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); } } fn webTestCols() callconv(.c) c_uint { const st = live_web_state orelse return 0; return st.core.surface.cols; } fn webTestRows() callconv(.c) c_uint { const st = live_web_state orelse return 0; return st.core.surface.rows; } fn webTestCursorX() callconv(.c) c_int { const st = live_web_state orelse return -1; return if (st.core.surface.cursor) |cursor| cursor.x else -1; } fn webTestCursorY() callconv(.c) c_int { const st = live_web_state orelse return -1; return if (st.core.surface.cursor) |cursor| cursor.y else -1; } fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { const cell = webTestCellAt(col, row) orelse return ' '; const glyph = cell.grapheme(); if (glyph.len == 0) return ' '; return std.unicode.utf8Decode(glyph) catch 0xfffd; } // RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other // Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { const cell = webTestCellAt(col, row) orelse return 0x01000000; return webTestColor(cell.style.fg); } fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { const cell = webTestCellAt(col, row) orelse return 0x01000000; return webTestColor(cell.style.bg); } fn webTestColor(color: pardes.Color) c_uint { return switch (color) { .default => 0x01000000, .index => |idx| 0x02000000 | @as(c_uint, idx), .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], }; } fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { const cell = webTestCellAt(col, row) orelse return 0; const style = cell.style; var attrs: c_uint = 0; if (style.bold) attrs |= 1 << 0; if (style.dim) attrs |= 1 << 1; if (style.italic) attrs |= 1 << 2; if (style.blink) attrs |= 1 << 3; if (style.reverse) attrs |= 1 << 4; if (style.invisible) attrs |= 1 << 5; if (style.strikethrough) attrs |= 1 << 6; attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; return attrs; } fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { const st = live_web_state orelse return null; const surface = &st.core.surface; if (col >= surface.cols or row >= surface.rows) return null; return surface.at(@intCast(col), @intCast(row)); } fn webTestCellX(col: f32) callconv(.c) f32 { return webTestCellCenter(col, true); } fn webTestCellY(row: f32) callconv(.c) f32 { return webTestCellCenter(row, false); } fn webTestCellCenter(cell: f32, horizontal: bool) f32 { const st = live_web_state orelse return 0; var pixel_w: c_int = 0; var pixel_h: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); const pw: u32 = @intCast(@max(pixel_w, 1)); const ph: u32 = @intCast(@max(pixel_h, 1)); var css_w: f64 = 0; var css_h: f64 = 0; if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); if (horizontal) { const backing = layout.x_off + (cell + 0.5) * layout.w; return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); } const backing = layout.y_off + (cell + 0.5) * layout.h; return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); } fn runWeb(opts_in: pardes.Options) !void { web_fba = .init(&web_heap); const gpa = web_fba.allocator(); // Our finger machine deliberately maps touch to scroll/LOOK. Suppress // SDL's default synthetic left mouse event for the same browser touch. _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser log.err("SDL_Init: {s}", .{c.SDL_GetError()}); return error.SdlInit; } const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); const gl_context = c.SDL_GL_CreateContext(window) orelse { log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; if (!c.SDL_GL_MakeCurrent(window, gl_context)) { log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); return error.SdlInit; } _ = c.SDL_GL_SetSwapInterval(1); // ---- font + cell metrics, rastered at web_render_scale ---- const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; }; const px: f32 = 18.0; const base_scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); const scale = c.ui_font_scale_for_height(font, px * web_render_scale); const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); var atlas_tex: c_uint = 0; c.glGenTextures(1, &atlas_tex); c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); var vbo: c_uint = 0; c.glGenBuffers(1, &vbo); var overlay_vbo: c_uint = 0; c.glGenBuffers(1, &overlay_vbo); const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); @memset(atlas_stage, 0); const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); const st = try gpa.create(WebState); st.* = .{ .g = .{ .window = window, .device = undefined, // web renders through GL, never the GPU device .swapchain_format = undefined, .pipeline = undefined, .overlay_pipeline = undefined, .image_pipeline = undefined, .crt_pipeline = undefined, .atlas_tex = undefined, .atlas_sampler = undefined, .linear_sampler = undefined, .atlas_xfer = undefined, .overlay_vbuf = undefined, .overlay_vxfer = undefined, .overlay_vertices = overlay_vertices, .image_vbuf = undefined, .image_vxfer = undefined, .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, .px = px, .scale = scale, .cell_w = cell_w, .cell_h = cell_h, .ascent = ascent, .atlas_stage = atlas_stage, .glyphs = std.AutoHashMap(u32, Slot).init(gpa), }, .core = undefined, .gpa = gpa, .frame_arena = .init(gpa), }; const g = &st.g; // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent); g.pen_x = cell_w; // ---- the core: state replays from the embedded dump, no ptys ---- pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm syncCanvasSize(g); // size the backing canvas before deriving the grid var opts = opts_in; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); live_web_state = st; _ = c.SDL_StartTextInput(window); std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); } fn webFrame(arg: ?*anyopaque) callconv(.c) void { const st: *WebState = @ptrCast(@alignCast(arg.?)); const g = &st.g; const core = st.core; // 1. input var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); // 2. effects drainEffectsWeb(core, st.gpa, g); if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); // 3. the backing canvas follows the page, the grid follows the canvas syncCanvasSize(g); var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); if (updateCoreResize(core, cols, rows, g.cell_w, g.cell_h)) resetScroll(g); // 4. render — apply the wheel batch last, see runNative stepScroll(g, core, st.gpa); if (st.animation_clock.due(core.themeAnimationActive(), c.SDL_GetTicksNS())) core.update(.tick); _ = st.frame_arena.reset(.retain_capacity); const surface = core.render(st.frame_arena.allocator()) catch return; renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; } /// the canvas follows the page (CSS); the SDL window follows the canvas, /// backed at web_render_scale (postWinsize in the prototype). fn syncCanvasSize(g: *Gui) void { var css_w: f64 = 0; var css_h: f64 = 0; if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); var cur_w: c_int = 0; var cur_h: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); if (cur_w != want_w or cur_h != want_h) { _ = c.SDL_SetWindowSize(g.window, want_w, want_h); _ = c.SDL_SyncWindow(g.window); } } /// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, /// the rendered Surface out as text frames (same framing as the prototype). fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { const io = init.io; const gpa = init.gpa; const env = init.environ_map; pardes.image.start(io, gpa); defer pardes.image.stop(); const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; var opts = opts_in; if (opts.load_path != null) { opts.cols = grid_cols; opts.rows = grid_rows; } const core = if (opts.load_path) |lp| blk: { const bytes = try @import("../look.zig").readFile(gpa, lp); defer gpa.free(bytes); break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); } else try pardes.Pardes.init(gpa, opts); defer core.deinit(); // the requested grid arrives as a resize EVENT (not init opts) so the core // counts a resize and releases the shell greeting on first output if (opts.load_path == null) core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); writeFile(pardes.bash_rc); // macos: apple's bash 3.2 prints the zsh-deprecation banner into every // pane unless this is in the env BEFORE bash starts (the rc is too late) if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); var ptys: [pardes.MAX_PANES]?Pty = @splat(null); // per-slot spawn generation: drops a dead shell's late output/eof when its // pane id has been respawned (see drainEffects .spawn) var gens: [pardes.MAX_PANES]u32 = @splat(0); defer for (&ptys) |*slot| if (slot.*) |pt| { _ = libc.close(pt.fd); slot.* = null; }; var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; defer queue.close(); var pipe_tasks: std.ArrayList(PipeTask) = .empty; defer { for (pipe_tasks.items) |*task| task.future.cancel(io) catch {}; pipe_tasks.deinit(gpa); } // no inotify here on purpose: this mode's whole contract is one frame per // scripted input event, and a reload that arrives on its own clock would // put a frame in the stream nothing asked for. -1 makes watchPane a no-op. var watches: [pardes.MAX_PANES]?Watch = @splat(null); drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, false); for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort var frame_arena: std.heap.ArenaAllocator = .init(gpa); defer frame_arena.deinit(); var feed: StdinFeed = .{}; // first frame before touching stdin, so `printf '' | pardes` still shows one { const surface = try core.render(frame_arena.allocator()); try dumpGrid(gpa, surface); } while (!core.quit) { const r = try feed.pump(gpa, core, null); if (r.eof) break; var n_events: usize = r.n_events; var msgs = queue.take(); for (msgs.items) |m| switch (m) { .output => |o| { if (gens[o.pane] == o.gen) core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); gpa.free(o.bytes); n_events += 1; }, .eof => |e| { _ = libc.close(e.fd); // the dead reader's master — stale or current if (gens[e.pane] == e.gen) { ptys[e.pane] = null; core.update(.{ .eof = .{ .pane = e.pane } }); } n_events += 1; }, .lsp => |l| { core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); gpa.free(l.rows); n_events += 1; }, .pipe => |response_value| { var response = response_value; core.update(.{ .pipe_resp = .{ .id = response.id, .success = response.success, .outputs = response.outputs, } }); response.deinit(gpa); n_events += 1; for (pipe_tasks.items, 0..) |*task, i| if (task.id == response_value.id) { task.future.await(io) catch {}; _ = pipe_tasks.orderedRemove(i); break; }; }, .files_changed => {}, // unreachable: no watcher thread in this mode }; msgs.deinit(gpa); drainEffects(core, &ptys, &gens, io, gpa, &queue, &pipe_tasks, null, -1, &watches, true); pollCwds(core, &ptys); // The grid harness polls stdin at the same 16 ms cadence as native // SDL. Advancing here lets `stable` wait for exact endpoint colors; // once inactive it resumes the old event-only frame contract. if (core.themeAnimationActive()) { core.update(.tick); n_events += 1; } 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;;;;; BEL (normalized 0..1) // ===================================================================== const StdinFeed = struct { parser: vaxis.Parser = .{}, cache: vaxis.GraphemeCache = .{}, buf: [1024]u8 = undefined, fill: usize = 0, last_cols: u16 = 0, last_rows: u16 = 0, const Result = struct { eof: bool = false, n_events: usize = 0 }; /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result { var out: Result = .{}; // a pty stdin also carries the winsize; poll it in place of SIGWINCH var ws: posix.winsize = std.mem.zeroes(posix.winsize); if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and (ws.col != f.last_cols or ws.row != f.last_rows)) { f.last_cols = ws.col; f.last_rows = ws.row; f.applyResize(core, g, ws.col, ws.row); out.n_events += 1; } var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; _ = posix.poll(&fds, 16) catch return out; if ((fds[0].revents & posix.POLL.IN) == 0) { if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; return out; } const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); if (rn < 0) return out; if (rn == 0) { out.eof = true; return out; } const len = f.fill + @as(usize, @intCast(rn)); f.fill = 0; var seq_start: usize = 0; while (seq_start < len) { // private finger OSC first (vaxis would swallow it as unknown OSC) const prefix = "\x1b]777;finger;"; if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { const body = f.buf[seq_start + prefix.len .. len]; const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; }; if (parseFinger(body[0..end])) |finger_in| { var finger = finger_in; if (g) |gp| { var wpw: c_int = 0; var wph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); const win_w: f32 = @floatFromInt(@max(wpw, 1)); const win_h: f32 = @floatFromInt(@max(wph, 1)); if (core.crt_on) { // touch points are physical: same CRT mapping as the mouse const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); finger.x = m.x / win_w; finger.y = m.y / win_h; } handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); } else { handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); } out.n_events += 1; } seq_start += prefix.len + end + 1; continue; } if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; if (result.n == 0) { // partial sequence: shift to front, read more std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); f.fill = len - seq_start; break; } seq_start += result.n; const event = result.event orelse continue; switch (event) { .key_press => |key| { var text: []const u8 = ""; if (key.text) |t| text = f.cache.put(t); core.update(.{ .key = .{ .cp = mapKey(effCp(key)), .text = text, .ctrl = key.mods.ctrl, .alt = key.mods.alt, .shift = key.mods.shift, } }); out.n_events += 1; }, .mouse => |m| { const button: ?pardes.Mouse.Button = switch (m.button) { .left => .left, .middle => .middle, .right => .right, .wheel_up => .wheel_up, .wheel_down => .wheel_down, .wheel_left => .wheel_left, .wheel_right => .wheel_right, .none => .none, else => null, }; if (button) |b| { core.update(.{ .mouse = .{ .button = b, .kind = switch (m.type) { .press => .press, .release => .release, .motion => .motion, .drag => .drag, }, .col = @intCast(@max(m.col, 0)), .row = @intCast(@max(m.row, 0)), .ctrl = m.mods.ctrl, } }); out.n_events += 1; } }, .winsize => |vw| { f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows)); out.n_events += 1; }, .paste => |text| { core.update(.{ .paste = text }); gpa.free(@constCast(text)); out.n_events += 1; }, else => {}, } } return out; } fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void { _ = f; if (g) |gp| { // capture mode: resize the window; the frame loop resizes the core _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); _ = c.SDL_SyncWindow(gp.window); } else { core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); } } }; var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off fn parseFinger(payload: []const u8) ?Finger { var parts = std.mem.splitScalar(u8, payload, ';'); const type_name = parts.next() orelse return null; const id_s = parts.next() orelse return null; const x_s = parts.next() orelse return null; const y_s = parts.next() orelse return null; const pressure_s = parts.next() orelse return null; if (parts.next() != null) return null; const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) .down else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) .motion else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) .up else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) .cancel else return null; return .{ .kind = kind, .id = std.fmt.parseInt(u64, id_s, 10) catch return null, .x = std.fmt.parseFloat(f32, x_s) catch return null, .y = std.fmt.parseFloat(f32, y_s) catch return null, .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, }; } fn setStdinRaw() !void { var term = try posix.tcgetattr(0); term.iflag.BRKINT = false; term.iflag.ICRNL = false; term.iflag.INPCK = false; term.iflag.ISTRIP = false; term.iflag.IXON = false; term.oflag.OPOST = false; term.lflag.ECHO = false; term.lflag.ICANON = false; term.lflag.IEXTEN = false; term.lflag.ISIG = false; term.cc[@intFromEnum(posix.V.MIN)] = 1; term.cc[@intFromEnum(posix.V.TIME)] = 0; try posix.tcsetattr(0, .NOW, term); } // PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { if (surface.cols == 0 or surface.rows == 0) return; // build the whole frame in one buffer → one write → atomic snapshots var buf: std.ArrayList(u8) = .empty; defer buf.deinit(gpa); const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; var hdr: [128]u8 = undefined; const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, }) catch return; try buf.appendSlice(gpa, hdr_str); var y: u16 = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; while (x < surface.cols) : (x += 1) { const g = surface.at(x, y).grapheme(); if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); } try buf.append(gpa, '\n'); } try buf.appendSlice(gpa, "---ENDFRAME---\n"); writeFd(1, buf.items); } // ===================================================================== // SDL event dispatch // ===================================================================== fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { switch (sev.type) { c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, // window resizes are picked up by the per-frame grid check c.SDL_EVENT_KEY_DOWN => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; // Ctrl+ / Ctrl-: the font size. Here rather than in keyDown // because it is the shell's business and not the core's — the // core has no font — and because this is the only side of the // wall where `g` is in scope anyway. // // SIX keycodes for two keys, and every one of them is a key // somebody actually presses: // - `+` on a US layout IS Shift-`=`, and SDL reports the // UNSHIFTED keycode, so Ctrl-+ arrives as SDLK_EQUALS. Binding // only SDLK_PLUS is the usual way to ship this dead. // - SDLK_PLUS is nonetheless real: on the German/Nordic layouts // `+` is its own unshifted key. Same for `_` under `-`. // - the numpad is separate. SDL_HINT_KEYCODE_OPTIONS defaults to // "french_numbers,latin_letters" — no "hide_numpad" — so KP_+ // stays SDLK_KP_PLUS (0x40000057) forever and never reaches // keyDown's `sym < 128` line at all. // // Nothing is taken away from anyone by claiming these. forwardKey // encodes Ctrl only for a-z, A-Z, `@` and `[`..`_`, and both `=` // (0x3d) and `-` (0x2d) fall below that last range, so a pane in // tty mode already sent the pty NO bytes for either — including // Ctrl-Shift-minus, which arrives here as SDLK_MINUS and reached // the core as `-`, never as the `_` that would have been 0x1f. No // chord in config.zig pairs ctrl with any of these codepoints // either (`=` is Format and `_` is trim_sels, both unmodified; // Alt-- and Alt-_ are the selection merges). And the numpad pair // did nothing at all: keyDown drops every sym above 128. // // Returning here is the whole interception, with no TEXT_INPUT // twin to also swallow: SDL only sends text when neither ctrl nor // alt is down (SDL_x11events.c, `!(SDL_GetModState() & (CTRL|ALT))`). const step: f32 = if (!g.live_ctrl) 0 else switch (sev.key.key) { c.SDLK_EQUALS, c.SDLK_PLUS, c.SDLK_KP_PLUS => font_px_step, c.SDLK_MINUS, c.SDLK_UNDERSCORE, c.SDLK_KP_MINUS => -font_px_step, else => 0, }; if (step != 0) { const want = std.math.clamp(g.px + step, font_px_min, font_px_max); // at either end the key is inert rather than a re-raster of // the size already on screen if (want != g.px) { g.px = want; refitFont(g, core); } return; } keyDown(core, sev.key.key, sev.key.mod); }, c.SDL_EVENT_KEY_UP => { g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; }, c.SDL_EVENT_TEXT_INPUT => { const tptr = sev.text.text orelse return; var tlen: usize = 0; while (tptr[tlen] != 0) : (tlen += 1) {} if (tlen == 0) return; const text: []const u8 = tptr[0..tlen]; const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; if (cplen > text.len) return; const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); }, c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { const b = sev.button; g.mouse_x = b.x; g.mouse_y = b.y; const button: pardes.Mouse.Button = switch (b.button) { 1 => .left, 2 => .middle, 3 => .right, else => return, }; const mc = mouseCell(g, core, b.x, b.y); core.update(.{ .mouse = .{ .button = button, .kind = if (b.down) .press else .release, .col = mc.col, .row = mc.row, // asked of SDL directly rather than read off g.live_ctrl: // that one is bookkeeping from KEY events, and a ctrl-click // with no key pressed since startup would miss it .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, }, }); }, c.SDL_EVENT_MOUSE_MOTION => { const m = sev.motion; g.mouse_x = m.x; g.mouse_y = m.y; // pad cursor continues from wherever the pointer last was g.pad_x = m.x; g.pad_y = m.y; // drag = motion with a button held (selection extension keys off it) const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) .left else if ((m.state & c.SDL_BUTTON_MMASK) != 0) .middle else if ((m.state & c.SDL_BUTTON_RMASK) != 0) .right else null; const mc = mouseCell(g, core, m.x, m.y); core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); }, c.SDL_EVENT_MOUSE_WHEEL => { const w = sev.wheel; if (w.y == 0 and w.x == 0) return; g.mouse_x = w.mouse_x; g.mouse_y = w.mouse_y; const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); if (w.y != 0 and std.math.isFinite(w.y)) { // Preserve SDL's floating-point distance. Input events drained // in this loop naturally form one render batch; stepScroll // applies their exact sum and tells the core only about whole // row boundaries. One accumulator belongs to one pane, so a // wheel event over another pane first retires the old offset. const hit: ?usize = for (core.panes, 0..) |slot, i| { if (slot == null) continue; const r = core.rects[i]; if (mc.col >= r.x and mc.col < r.x + r.w and mc.row >= r.y and mc.row < r.y + r.h) break i; } else null; if (g.scroll_pane) |old| if (hit == null or hit.? != old) { // There is one fractional overlay, not one per pane. Retire // the old one at its already boundary-rounded core state; // carrying its lag into `hit` would move the wrong pane. resetScroll(g); }; if (hit) |id| { const pdf_target = if (comptime pardes.pdf_enabled) core.native_images and core.panes[id].?.pdfPage() != null else false; if (pdf_target) { // PDF placements live in physical document space, so // preserve SDL's raw magnitude directly instead of // quantizing through synthetic wheel buttons/rows. resetScroll(g); core.update(.{ .pdf_scroll = .{ .pane = @intCast(id), .delta_pixels = -w.y * @as(f32, @floatFromInt(g.cell_h)), } }); } else { g.scroll_pane = id; g.scroll_col = mc.col; g.scroll_row = mc.row; g.scroll_delta = accumulateWheelDelta(g.scroll_delta, w.y); } } } if (w.x != 0) core.update(.{ .mouse = .{ .button = if (w.x > 0) .wheel_right else .wheel_left, .kind = .press, .col = mc.col, .row = mc.row, } }); }, c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { const f = sev.tfinger; var finger: Finger = .{ .kind = switch (sev.type) { c.SDL_EVENT_FINGER_DOWN => .down, c.SDL_EVENT_FINGER_MOTION => .motion, c.SDL_EVENT_FINGER_UP => .up, else => .cancel, }, .id = @intCast(f.fingerID), .x = f.x, .y = f.y, .pressure = f.pressure, }; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); var win_w: f32 = @floatFromInt(@max(pw, 1)); var win_h: f32 = @floatFromInt(@max(ph, 1)); var fcw: f32 = @floatFromInt(g.cell_w); var fch: f32 = @floatFromInt(g.cell_h); if (!is_emscripten and core.crt_on) { // touch points are physical too: same CRT mapping as the mouse const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); finger.x = m.x / win_w; finger.y = m.y / win_h; } if (is_emscripten) { // remap normalized coords into the letterboxed grid so the // shared touch machine's cell math stays exact const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); win_w = grid_w; win_h = grid_h; fcw = layout.w; fch = layout.h; } handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); }, c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, // axes polled per frame in pollGamepad) ---- c.SDL_EVENT_GAMEPAD_ADDED => { if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); }, c.SDL_EVENT_GAMEPAD_REMOVED => { if (g.gamepad) |pad| { c.SDL_CloseGamepad(pad); g.gamepad = null; } }, // deck.zig maps buttons/triggers/trackpads to pointer, clicks, // wheel, keys and rumble; this arm just applies its actions c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP, c.SDL_EVENT_GAMEPAD_AXIS_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, => { const in: deck.Input = switch (sev.type) { c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ .idx = sev.gbutton.button, .down = sev.gbutton.down, } }, c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ .idx = sev.gaxis.axis, .value = sev.gaxis.value, } }, else => .{ .touch = .{ .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), .phase = switch (sev.type) { c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, else => .motion, }, .x = sev.gtouchpad.x, .y = sev.gtouchpad.y, .pressure = sev.gtouchpad.pressure, } }, }; var acts: [3]deck.Action = undefined; for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) { .move => |mv| { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT // mapping once at cell conversion, never into the pad const old = mouseCell(g, core, g.pad_x, g.pad_y); g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1); g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1); // the SDL cursor (plan9 arrow) rides along, so the pad // cursor and a hardware mouse are one visible pointer c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); const mc = mouseCell(g, core, g.pad_x, g.pad_y); if (mc.col == old.col and mc.row == old.row) continue; // moving with a click held drags, so selections stretch // (a firm right-pad press drags-selects like a laptop pad) const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click) .left else if (g.deck.l2_held) .middle else if (g.deck.look_held) .right else null; core.update(.{ .mouse = .{ .button = held orelse .none, .kind = if (held != null) .drag else .motion, .col = mc.col, .row = mc.row, } }); }, .click => |ck| { const mc = mouseCell(g, core, g.pad_x, g.pad_y); core.update(.{ .mouse = .{ .button = switch (ck.which) { .select => .left, .execute => .middle, .look => .right, }, .kind = if (ck.down) .press else .release, .col = mc.col, .row = mc.row, } }); }, .wheel => |ticks| { const mc = mouseCell(g, core, g.pad_x, g.pad_y); var left = ticks; while (left != 0) { left += if (ticks > 0) -1 else 1; core.update(.{ .mouse = .{ .button = if (ticks > 0) .wheel_down else .wheel_up, .kind = .press, .col = mc.col, .row = mc.row, } }); } }, .key => |k| core.update(.{ .key = switch (k) { .n => .{ .cp = 'n', .text = "n" }, .cap_n => .{ .cp = 'N', .text = "N" }, .enter => .{ .cp = pardes.Key.enter }, .tab => .{ .cp = pardes.Key.tab }, // back paddle: Ctrl- flips tty mode .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true }, }, }), // a brief gentle ack for execute/look, not a buzz .rumble => if (g.gamepad) |pad| { _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); }, }; }, else => {}, } } /// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as /// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; const alt = (mod & c.SDL_KMOD_ALT) != 0; const shift = (mod & c.SDL_KMOD_SHIFT) != 0; const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; const cp: u21 = switch (sym) { c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, c.SDLK_BACKSPACE => pardes.Key.backspace, c.SDLK_TAB => pardes.Key.tab, c.SDLK_ESCAPE => pardes.Key.escape, c.SDLK_LEFT => pardes.Key.left, c.SDLK_RIGHT => pardes.Key.right, c.SDLK_UP => pardes.Key.up, c.SDLK_DOWN => pardes.Key.down, c.SDLK_HOME => pardes.Key.home, c.SDLK_END => pardes.Key.end, c.SDLK_PAGEUP => pardes.Key.page_up, c.SDLK_PAGEDOWN => pardes.Key.page_down, c.SDLK_DELETE => pardes.Key.delete, else => blk: { // letters / digits / punctuation only as a modifier shortcut; // plain printable (incl. space) goes via TEXT_INPUT if (!(ctrl or alt or gui_mod)) break :blk 0; if (sym < 128 and sym >= ' ') break :blk @intCast(sym); break :blk 0; }, }; if (cp == 0) return; core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); } fn pixelCell(px: f32, cell: u32) u16 { return pixelCellF(px, @floatFromInt(cell)); } fn pixelCellF(px: f32, cell: f32) u16 { const idx = @floor(@max(px, 0) / @max(cell, 1)); return @intFromFloat(@min(idx, 10_000)); } /// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS /// points into a letterboxed canvas backed at web_render_scale. With the Crt /// builtin on, the physical point first maps to the scene point the shader /// displays there (crt.map) — this is the one physical→logical spot shared /// by the real mouse and the deck's virtual cursor. fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { if (!is_emscripten) { var mx = x; var my = y; if (core.crt_on) { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1))); mx = m.x; my = m.y; } return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) }; } var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); var ww: c_int = 0; var wh: c_int = 0; _ = c.SDL_GetWindowSize(g.window, &ww, &wh); const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); return .{ .col = pixelCellF(px - layout.x_off, layout.w), .row = pixelCellF(py - layout.y_off, layout.h), }; } /// steamdeck support: poll the sticks each frame, mirroring the trackpads — /// RIGHT stick moves the virtual cursor at ~cell granularity (emits /// button-less motion so hover works), LEFT stick accumulates into wheel /// ticks (both axes: vertical + horizontal) at the cursor position. fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { const pad = g.gamepad orelse return; var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const win_w: f32 = @floatFromInt(@max(pw, 1)); const win_h: f32 = @floatFromInt(@max(ph, 1)); const deadzone: f32 = 8000; // right stick = pointer const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); const old = mouseCell(g, core, g.pad_x, g.pad_y); // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the // mouse-move sensitivity knob — raise for a faster pointer) const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); // only on actual stick movement — an unconditional per-frame warp would // pin the pointer and fight any hardware mouse if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); const mc = mouseCell(g, core, g.pad_x, g.pad_y); if (mc.col != old.col or mc.row != old.row) core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } }); // left stick = scroll (both axes) const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; while (@abs(g.pad_scroll) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); } while (@abs(g.pad_scroll_h) >= 1.0) { const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); } } // ===================================================================== // effects — identical duties to tty.zig's drainEffects, plus SDL clipboard // ===================================================================== fn drainEffects( core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty, gens: *[pardes.MAX_PANES]u32, io: std.Io, gpa: std.mem.Allocator, queue: *Queue, pipe_tasks: *std.ArrayList(PipeTask), g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) inotify_fd: c_int, watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { .spawn => |sp| { // the core reuses pane ids and there is no close effect: a deleted // pane's shell lives in its slot until a respawn lands here. Kill // it; its detached reader wakes on child death and the gen-guarded // eof closes the old fd (not here — the reader still reads it). if (ptys[sp.pane]) |old| { _ = libc.kill(old.pid, libc.SIG.KILL); ptys[sp.pane] = null; } gens[sp.pane] +%= 1; const cwd = sp.cwd.slice(); var cwd_buf: [256:0]u8 = undefined; var cwd_z: ?[*:0]const u8 = null; if (cwd.len > 0) { @memcpy(cwd_buf[0..cwd.len], cwd); cwd_buf[cwd.len] = 0; cwd_z = @ptrCast(&cwd_buf); } const pt = forkShell(cwd_z, core.screen_h, core.screen_w); ptys[sp.pane] = pt; // report the pane's starting directory back to the core (tags) var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); }, .write => |w| { if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice()); }, .resize_pty => |rs| { if (ptys[rs.pane]) |pt| { const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } }, .open_link => |url| look.openLink(url.slice()), // desktop browser .save_file => |sf| { const pane = core.panes[sf.pane] orelse continue; const f = pane.file orelse continue; var pathbuf: [4096:0]u8 = undefined; if (f.path.len >= pathbuf.len) continue; @memcpy(pathbuf[0..f.path.len], f.path); pathbuf[f.path.len] = 0; const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) continue; writeFd(fd, f.content); _ = libc.close(fd); // our own write is about to come back as a watch event: restamp // from the bytes we just put there so it reads as "no change" if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, .new_file => |request| { var path_buf: [4096:0]u8 = undefined; const made = temp_file.create(&path_buf) orelse continue; if (core.openNewFile(request.pane, request.serial, made.path)) made.adopt() else made.discard(); }, .write_dump => { const out = core.dump_out orelse continue; var pbuf: [1024:0]u8 = undefined; const path = pardes.dump.outPath(&pbuf) orelse continue; const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) continue; writeFd(fd, out); core.setLastDump(path); _ = libc.close(fd); }, .set_clipboard => { if (g == null) continue; const y = core.yank orelse continue; const z = gpa.dupeZ(u8, y) catch continue; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); }, .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), .pipe => |e| if (threads_ok) spawnPipe(core, io, gpa, queue, pipe_tasks, e), .watch => |w| { // starting, the path and the on-disk bytes are read off the core // (same split as save_file); stopping, the pane is already gone var path: ?[]const u8 = null; var hash: u64 = 0; if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { path = f.path; hash = std.hash.Wyhash.hash(0, f.content); }; watchPane(inotify_fd, watches, w.pane, path, hash); }, .quit => {}, }; } fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { var master: c_int = undefined; const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; const pid = forkpty(&master, null, null, &ws); if (pid == 0) { if (cwd) |cd| _ = chdir(cd); const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; _ = execv(bash_path, &argv); _exit(127); } return .{ .fd = master, .pid = pid }; } fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { for (ptys, 0..) |slot, id| if (slot) |pt| { var lbuf: [1024]u8 = undefined; if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); }; } /// web: no ptys, no fs — panes replay from the embedded dump. Only the /// clipboard and open_link effects have a browser meaning. fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { _ = g; while (core.nextEffect()) |effect| switch (effect) { .set_clipboard => { const y = core.yank orelse continue; const z = gpa.dupeZ(u8, y) catch continue; defer gpa.free(z); _ = c.SDL_SetClipboardText(z.ptr); }, // look on a URL → a new tab .open_link => |url| openLinkWeb(gpa, url.slice()), // nothing to spawn/write/resize/save/dump into, and no filesystem to // watch — all no-ops .spawn, .write, .resize_pty, .save_file, .new_file, .write_dump, .lsp, .pipe, .watch, .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); } // ===================================================================== // fractional scroll: whole rows for the core, sub-row offsets for the picture // ===================================================================== /// Add one raw SDL vertical-wheel value to this render batch. SDL calls up /// positive; the picture coordinate below calls down positive. Non-finite /// input, including an addition that overflows, cannot enter persistent state. fn accumulateWheelDelta(pending: f32, raw_y: f32) f32 { if (!std.math.isFinite(raw_y)) return pending; const next = pending - raw_y; return if (std.math.isFinite(next)) next else pending; } /// The old surface can supply at most one body-height of historical rows. /// Clamp only pathological per-frame batches to that renderable range; normal /// SDL deltas pass through unchanged. Including lag in the bound guarantees /// applyScrollDelta cannot cross more than `body_rows` core boundaries. fn boundScrollDelta(lag: f32, delta: f32, body_rows: u16) f32 { const limit: f32 = @floatFromInt(body_rows); return std.math.clamp(lag + delta, -limit, limit) - lag; } /// Apply an exact picture displacement and return the whole core rows it /// crosses (positive = down) plus the retained sub-row picture/core offset. /// /// Crossing in the direction of travel rounds the core one row ahead of the /// picture and leaves lag pointing back at it. Therefore the exposed strip is /// always the one historical edge row which the previous surface still owns; /// the renderer never needs a row from the future. fn applyScrollDelta(lag: f32, delta: f32) struct { lag: f32, rows: i32 } { var l = lag + delta; var rows: i32 = 0; while ((delta > 0 and l > 0) or (delta < 0 and l < 0)) { const down = l > 0; l += if (down) -1 else 1; rows += if (down) 1 else -1; } return .{ .lag = l, .rows = rows }; } test "fractional wheel delta moves the picture by the same fraction" { const delta = accumulateWheelDelta(0, -0.125); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 0.125), delta); try std.testing.expectEqual(@as(f32, 0.125), picture); try std.testing.expectEqual(@as(i32, 1), s.rows); try std.testing.expectEqual(@as(f32, -0.875), s.lag); } test "repeated fractional deltas cross exactly one core row" { var core_row: i32 = 0; var lag: f32 = 0; var crossed: u32 = 0; for (0..8) |_| { const s = applyScrollDelta(lag, 0.125); core_row += s.rows; crossed += @intCast(@abs(s.rows)); lag = s.lag; } try std.testing.expectEqual(@as(u32, 1), crossed); try std.testing.expectEqual(@as(i32, 1), core_row); try std.testing.expectEqual(@as(f32, 0), lag); } test "wheel magnitude is preserved without quantization" { const delta = accumulateWheelDelta(0, -2.75); const s = applyScrollDelta(0, delta); const picture = @as(f32, @floatFromInt(s.rows)) + s.lag; try std.testing.expectEqual(@as(f32, 2.75), delta); try std.testing.expectEqual(@as(f32, 2.75), picture); try std.testing.expectEqual(@as(i32, 3), s.rows); try std.testing.expectEqual(@as(f32, -0.25), s.lag); try std.testing.expectEqual(@as(f32, 0), accumulateWheelDelta(0, std.math.inf(f32))); // A finite but nonsensical device value is bounded before the reducer, // avoiding an unbounded loop while keeping the largest renderable move. const bounded = boundScrollDelta(0, 3.0e38, 24); const safe = applyScrollDelta(0, bounded); try std.testing.expectEqual(@as(f32, 24), bounded); try std.testing.expectEqual(@as(i32, 24), safe.rows); } test "fractional scroll reversals preserve signed distance" { var core_row: i32 = 0; const forward = applyScrollDelta(0, 0.375); core_row += forward.rows; try std.testing.expectEqual(@as(f32, 0.375), @as(f32, @floatFromInt(core_row)) + forward.lag); const reverse = applyScrollDelta(forward.lag, -0.125); core_row += reverse.rows; try std.testing.expectEqual(@as(i32, -1), reverse.rows); try std.testing.expectEqual(@as(f32, 0.25), @as(f32, @floatFromInt(core_row)) + reverse.lag); try std.testing.expect(reverse.lag >= 0 and reverse.lag < 1); } fn resetScroll(g: *Gui) void { g.scroll_pane = null; g.scroll_delta = 0; g.scroll_lag = 0; g.scroll_edge_len = 0; } /// Apply this frame's exact wheel batch, hand the core every whole row it /// crossed, and retain the one historical row exposed by the residual offset. /// Called immediately before core.render(), while core.surface still holds /// what the previous frame drew. fn stepScroll(g: *Gui, core: *pardes.Pardes, gpa: std.mem.Allocator) void { const id = g.scroll_pane orelse return; const pane = core.panes[id] orelse { resetScroll(g); return; }; const queued_delta = g.scroll_delta; g.scroll_delta = 0; if (queued_delta == 0) { if (g.scroll_lag == 0) resetScroll(g); return; } // the rect the last frame was painted through — what the snapshot below // indexes. Nothing between here and render() moves it. const r = core.rects[id]; if (r.h <= pardes.BOX_H) { resetScroll(g); return; } const delta = boundScrollDelta(g.scroll_lag, queued_delta, r.h - pardes.BOX_H); const st = applyScrollDelta(g.scroll_lag, delta); var left = st.rows; while (left != 0) { const down = left > 0; left += if (down) -1 else 1; const was = pane.scroll(); core.update(.{ .mouse = .{ .button = if (down) .wheel_down else .wheel_up, .kind = .press, .col = g.scroll_col, .row = g.scroll_row, } }); // the document ran out under us (top of a file, bottom of a live // terminal): there is no travel left to draw, so stop dead rather // than slide the pane against a view that is not moving if (pane.scroll() == was) { resetScroll(g); return; } } g.scroll_lag = st.lag; g.scroll_rect = r; if (st.rows != 0) { // The offset opens a gap at the trailing edge of the travel, and what // belongs in it is the row that just left the pane: already gone from // the surface the core is about to paint, still in the one it painted // last frame. k rows in, that row is the k-1'th body row from the top // going down, the k'th from the bottom going up. const s = &core.surface; g.scroll_edge_len = 0; if (r.w > config.GUTTER and r.h > pardes.BOX_H and r.x + r.w <= s.cols) { const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; const k: u16 = @intCast(@min(@abs(st.rows), @as(i32, bh))); const srow = r.y + pardes.BOX_H + (if (st.rows > 0) k - 1 else bh - k); if (srow < s.rows) { if (g.scroll_edge.len < bw) { gpa.free(g.scroll_edge); g.scroll_edge = gpa.alloc(pardes.Cell, bw) catch &.{}; } if (g.scroll_edge.len >= bw) { @memcpy(g.scroll_edge[0..bw], s.cells[@as(usize, srow) * s.cols + r.x + config.GUTTER ..][0..bw]); g.scroll_edge_len = bw; } } } } if (g.scroll_lag == 0) resetScroll(g); } /// The fractional pane body, emitted a SECOND time at its sub-row offset, /// plus the one row of history that fills the gap the offset opens. Writes /// instances at `base` and returns how many; the caller draws them scissored /// to the body, which is the whole of the clipping — the shell draws the grid /// in one flat pass, so without it the overhanging rows would land on the /// pane's own tag and on whatever is below it. fn emitScrollRows(g: *Gui, instances: [*]CellInstance, base: u32, surface: *pardes.Surface, layout: CellLayout, win_w: f32, win_h: f32) u32 { if (g.scroll_pane == null or g.scroll_lag == 0) return 0; const r = g.scroll_rect; if (r.w <= config.GUTTER or r.h <= pardes.BOX_H) return 0; const x0 = r.x + config.GUTTER; const y0 = r.y + pardes.BOX_H; const bw = r.w - config.GUTTER; const bh = r.h - pardes.BOX_H; if (x0 + bw > surface.cols or y0 + bh > surface.rows) return 0; // resized under us // the same layout, one sub-row up: emitInstance needs to know nothing var shifted = layout; shifted.y_off -= g.scroll_lag * layout.h; const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else std.math.maxInt(u32); const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var n: u32 = 0; var row = y0; while (row < y0 + bh) : (row += 1) { var col = x0; while (col < x0 + bw) : (col += 1) { const sidx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, instances, base + n, col, row, shifted, win_w, win_h, surface.at(col, row), sidx == cursor_idx, cursor_bar); n += 1; } } // ...and the row that just left, one row outside the body on the side the // travel came from. The scissor keeps all of it but the exposed strip. if (g.scroll_edge_len >= bw) { const erow: u16 = if (g.scroll_lag > 0) y0 + bh else y0 - 1; var i: u16 = 0; while (i < bw) : (i += 1) { emitInstance(g, instances, base + n, x0 + i, erow, shifted, win_w, win_h, &g.scroll_edge[i], false, false); n += 1; } } return n; } /// That body rect in target pixels, clamped to the target — the scissor both /// shells set around the draw above. fn scrollScissor(g: *const Gui, layout: CellLayout, sw: u32, sh: u32) c.SDL_Rect { const r = g.scroll_rect; const px = layout.x_off + @as(f32, @floatFromInt(r.x + config.GUTTER)) * layout.w; const py = layout.y_off + @as(f32, @floatFromInt(r.y + pardes.BOX_H)) * layout.h; const pw = @as(f32, @floatFromInt(r.w - config.GUTTER)) * layout.w; const ph = @as(f32, @floatFromInt(r.h - pardes.BOX_H)) * layout.h; const x0 = std.math.clamp(@as(i32, @intFromFloat(@floor(px))), 0, @as(i32, @intCast(sw))); const y0 = std.math.clamp(@as(i32, @intFromFloat(@floor(py))), 0, @as(i32, @intCast(sh))); const x1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(px + pw))), x0, @as(i32, @intCast(sw))); const y1 = std.math.clamp(@as(i32, @intFromFloat(@ceil(py + ph))), y0, @as(i32, @intCast(sh))); return .{ .x = x0, .y = y0, .w = x1 - x0, .h = y1 - y0 }; } // ===================================================================== // render: Surface → instanced quads → SDL GPU // ===================================================================== fn releaseNativeImage(g: *Gui, key: pardes.ImageCacheKey) void { if (g.native_images.fetchRemove(key)) |removed| c.SDL_ReleaseGPUTexture(g.device, removed.value); } fn clearNativeImages(g: *Gui) void { var iterator = g.native_images.valueIterator(); while (iterator.next()) |texture| c.SDL_ReleaseGPUTexture(g.device, texture.*); g.native_images.clearRetainingCapacity(); } fn surfaceHasNativeKey(surface: *const pardes.Surface, key: pardes.ImageCacheKey) bool { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) != null and place.cacheKey().eql(key)) return true; } return false; } fn ensureImageBuffers(g: *Gui, needed: u32) bool { if (needed <= g.image_capacity) return true; var capacity = @max(@as(u32, 1), g.image_capacity); while (capacity < needed) capacity = std.math.mul(u32, capacity, 2) catch return false; const buffer_size = std.math.mul( u32, capacity, @as(u32, @intCast(@sizeOf(ImageInstance))), ) catch return false; var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = buffer_size, .props = 0, }; const vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return false; var vx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = buffer_size, .props = 0, }; const vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &vx_info) orelse { c.SDL_ReleaseGPUBuffer(g.device, vbuf); return false; }; c.SDL_ReleaseGPUTransferBuffer(g.device, g.image_vxfer); c.SDL_ReleaseGPUBuffer(g.device, g.image_vbuf); g.image_vbuf = vbuf; g.image_vxfer = vxfer; g.image_capacity = capacity; return true; } fn validImageBytes(place: pardes.ImagePlace) ?u32 { if (place.iw == 0 or place.ih == 0 or place.iw > std.math.maxInt(u32) or place.ih > std.math.maxInt(u32)) return null; const pixels = std.math.mul(usize, place.iw, place.ih) catch return null; const bytes = std.math.mul(usize, pixels, 4) catch return null; if (place.rgba.len != bytes or bytes > std.math.maxInt(u32)) return null; return @intCast(bytes); } const NativeBounds = struct { w: u32, h: u32 }; fn nativeBounds(g: *const Gui, place: pardes.ImagePlace) ?NativeBounds { const bound_w = std.math.mul(u32, place.w, g.cell_w) catch return null; const bound_h = std.math.mul(u32, place.h, g.cell_h) catch return null; return .{ .w = bound_w, .h = bound_h }; } fn nativePlaceGeometry(g: *const Gui, place: pardes.ImagePlace) ?pardes.image.NativeGeometry { if (comptime pardes.pdf_enabled) { if (place.native.geometry) |geometry| return geometry; const bounds = nativeBounds(g, place) orelse return null; return pardes.image.nativeGeometry( place.iw, place.ih, bounds.w, bounds.h, place.native.fit, place.native.pan_x, place.native.pan_y, ); } return null; } fn nativePlaceDrawable(g: *const Gui, place: pardes.ImagePlace) bool { if (comptime pardes.pdf_enabled) return nativePlaceGeometry(g, place) != null; const bounds = nativeBounds(g, place) orelse return false; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); return fit.w != 0 and fit.h != 0; } fn uploadNativeTexture( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, place: pardes.ImagePlace, ) !void { const byte_len = validImageBytes(place) orelse return error.BadImage; const key = place.cacheKey(); if (g.native_images.contains(key)) return; var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM; tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; tex_info.width = @intCast(place.iw); tex_info.height = @intCast(place.ih); tex_info.layer_count_or_depth = 1; tex_info.num_levels = 1; tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; const texture = c.SDL_CreateGPUTexture(g.device, &tex_info) orelse return error.GpuCreate; errdefer c.SDL_ReleaseGPUTexture(g.device, texture); var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = byte_len, .props = 0, }; const transfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; defer c.SDL_ReleaseGPUTransferBuffer(g.device, transfer); const mapped: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, transfer, false) orelse return error.GpuMap); @memcpy(mapped[0..byte_len], place.rgba); c.SDL_UnmapGPUTransferBuffer(g.device, transfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = transfer, .offset = 0, .pixels_per_row = @intCast(place.iw), .rows_per_layer = @intCast(place.ih), }; const dst = c.SDL_GPUTextureRegion{ .texture = texture, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = @intCast(place.iw), .h = @intCast(place.ih), .d = 1, }; c.SDL_UploadToGPUTexture(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); // SDL defers destruction until the submitted copy is done; the staging // allocation is never needed again, so do not retain a second full image // beside the texture for the life of the pane. try g.native_images.put(gpa, key, texture); } /// Upload new pixel generations and the small per-frame placement buffer. /// Returns the number of image instances drawNativeImagesGpu will consume. fn prepareNativeImages( g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, surface: *pardes.Surface, sw: u32, sh: u32, ) u32 { for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (validImageBytes(place) == null) continue; uploadNativeTexture(g, gpa, cmd, place) catch continue; } var stale: std.ArrayList(pardes.ImageCacheKey) = .empty; defer stale.deinit(gpa); var iterator = g.native_images.iterator(); while (iterator.next()) |entry| if (!surfaceHasNativeKey(surface, entry.key_ptr.*)) stale.append(gpa, entry.key_ptr.*) catch {}; for (stale.items) |key| releaseNativeImage(g, key); var count: u32 = 0; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (g.native_images.contains(place.cacheKey()) and nativePlaceDrawable(g, place)) count += 1; } if (count == 0) return 0; if (!ensureImageBuffers(g, count)) return 0; const ptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.image_vxfer, false) orelse return 0); const instances: [*]ImageInstance = @ptrCast(@alignCast(ptr)); const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var idx: u32 = 0; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; if (!g.native_images.contains(place.cacheKey())) continue; const body_x = @as(u32, place.x) * g.cell_w; const body_y = @as(u32, place.y) * g.cell_h; if (comptime pardes.pdf_enabled) { const geometry = nativePlaceGeometry(g, place) orelse continue; const px0 = @as(f32, @floatFromInt(body_x + geometry.dst.x)); const py0 = @as(f32, @floatFromInt(body_y + geometry.dst.y)) + place.native.pixel_offset_y; const px1 = px0 + @as(f32, @floatFromInt(geometry.dst.w)); const py1 = py0 + @as(f32, @floatFromInt(geometry.dst.h)); const image_w: f32 = @floatFromInt(place.iw); const image_h: f32 = @floatFromInt(place.ih); instances[idx] = .{ .x0 = (px0 / win_w) * 2.0 - 1.0, .y0 = 1.0 - (py0 / win_h) * 2.0, .x1 = (px1 / win_w) * 2.0 - 1.0, .y1 = 1.0 - (py1 / win_h) * 2.0, .u0 = @as(f32, @floatFromInt(geometry.src.x)) / image_w, .v0 = @as(f32, @floatFromInt(geometry.src.y)) / image_h, .u1 = @as(f32, @floatFromInt(geometry.src.x + geometry.src.w)) / image_w, .v1 = @as(f32, @floatFromInt(geometry.src.y + geometry.src.h)) / image_h, }; } else { const bounds = nativeBounds(g, place) orelse continue; const fit = pardes.image.contain(place.iw, place.ih, bounds.w, bounds.h); if (fit.w == 0 or fit.h == 0) continue; instances[idx] = .{ .x0 = (@as(f32, @floatFromInt(body_x)) / win_w) * 2.0 - 1.0, .y0 = 1.0 - (@as(f32, @floatFromInt(body_y)) / win_h) * 2.0, .x1 = (@as(f32, @floatFromInt(body_x + fit.w)) / win_w) * 2.0 - 1.0, .y1 = 1.0 - (@as(f32, @floatFromInt(body_y + fit.h)) / win_h) * 2.0, .u0 = 0, .v0 = 0, .u1 = 1, .v1 = 1, }; } idx += 1; } c.SDL_UnmapGPUTransferBuffer(g.device, g.image_vxfer); if (idx == 0) return 0; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.image_vxfer, .offset = 0 }; const dst = c.SDL_GPUBufferRegion{ .buffer = g.image_vbuf, .offset = 0, .size = idx * @sizeOf(ImageInstance) }; c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); c.SDL_EndGPUCopyPass(copy); return idx; } fn drawNativeImagesGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, surface: *pardes.Surface) void { const pass = rp orelse return; c.SDL_BindGPUGraphicsPipeline(pass, g.image_pipeline); var idx: u32 = 0; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse continue; const texture = g.native_images.get(place.cacheKey()) orelse continue; if (!nativePlaceDrawable(g, place)) continue; const clip = c.SDL_Rect{ .x = @intCast(@as(u32, place.x) * g.cell_w), .y = @intCast(@as(u32, place.y) * g.cell_h), .w = @intCast(@as(u32, place.w) * g.cell_w), .h = @intCast(@as(u32, place.h) * g.cell_h), }; c.SDL_SetGPUScissor(pass, &clip); const sampler = c.SDL_GPUTextureSamplerBinding{ .texture = texture, .sampler = g.linear_sampler }; c.SDL_BindGPUFragmentSamplers(pass, 0, &sampler, 1); const binding = c.SDL_GPUBufferBinding{ .buffer = g.image_vbuf, .offset = idx * @sizeOf(ImageInstance) }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); c.SDL_DrawGPUPrimitives(pass, 6, 1, 0, 0); idx += 1; } const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(@as(u32, surface.cols) * g.cell_w), .h = @intCast(@as(u32, surface.rows) * g.cell_h), }; c.SDL_SetGPUScissor(pass, &whole); } fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void { const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; var sw: u32 = 0; var sh: u32 = 0; var target: *c.SDL_GPUTexture = undefined; if (g.capture) { // capture: render offscreen (the window may never present), dump PPM sw = @as(u32, surface.cols) * g.cell_w; sh = @as(u32, surface.rows) * g.cell_h; if (sw == 0 or sh == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; } try ensureCaptureTexture(g, sw, sh); target = g.capture_tex.?; } else { var swap_tex: ?*c.SDL_GPUTexture = null; if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; } target = swap_tex orelse { // swapchain busy: skip the frame _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; }; } const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); // Crt on: the scene pass lands in an offscreen texture, then a second // pass draws it through the CRT shader onto the real target if (crt_on) try ensureSceneTexture(g, sw, sh); const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); const image_count = prepareNativeImages(g, gpa, cmd, surface, sw, sh); const cells: u32 = @as(u32, surface.cols) * surface.rows; // ponytail: ×2, because the fractional-scroll pane is drawn a second time, // offset, out of the tail of the same buffer — and a pane is never bigger // than the grid. The ceiling is one grid's worth of vertex memory nobody // uses when nothing is sliding; sizing it to the actual pane instead means // re-creating the buffer on every frame that starts or ends a slide. if (cells != 0) try ensureVbuf(g, cells * 2); var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); color_target.texture = scene; color_target.clear_color = .{ .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0, .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0, .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0, .a = 1.0, }; color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; color_target.store_op = c.SDL_GPU_STOREOP_STORE; if (cells != 0 and g.vbuf != null) { // ponytail: full re-upload every frame; the prototype's dirty-range // diffing (cell_keys + coalesced ranges) is skipped for now. const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return; }); const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); const layout = fixedCellLayout(g); const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var row: u16 = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { const idx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); } } const shifted = emitScrollRows(g, instances, cells, surface, layout, win_w, win_h); 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 + shifted) * @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); if (shifted != 0) { // second pass, clipped to the pane body: it overdraws the same // pane's un-offset cells, and its overhang stops at the body edge const clip = scrollScissor(g, layout, sw, sh); c.SDL_SetGPUScissor(rp, &clip); const sbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = cells * @sizeOf(CellInstance) }; c.SDL_BindGPUVertexBuffers(rp, 0, &sbinding, 1); c.SDL_DrawGPUPrimitives(rp, 6, shifted, 0, 0); const whole = c.SDL_Rect{ .x = 0, .y = 0, .w = @intCast(sw), .h = @intCast(sh) }; c.SDL_SetGPUScissor(rp, &whole); } c.SDL_EndGPURenderPass(rp); } else { // no grid yet: just clear to the page bg const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); c.SDL_EndGPURenderPass(rp); } // Images belong above the opaque cell backgrounds and below interaction // overlays. A second load pass expresses that ordering without teaching // the canonical cell surface about alpha or backend textures. if (image_count != 0 or overlay_count != 0) { color_target.load_op = c.SDL_GPU_LOADOP_LOAD; const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); if (image_count != 0) drawNativeImagesGpu(g, rp, surface); drawOverlayGpu(g, rp, overlay_count); c.SDL_EndGPURenderPass(rp); } if (crt_on) { var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); crt_target.texture = target; crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; crt_target.store_op = c.SDL_GPU_STOREOP_STORE; const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline); const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.linear_sampler }; c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); c.SDL_EndGPURenderPass(rp); } if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh); _ = c.SDL_SubmitGPUCommandBuffer(cmd); } fn emitInstance( g: *Gui, instances: [*]CellInstance, idx: u32, col: u16, row: u16, layout: CellLayout, win_w: f32, win_h: f32, cell: *const pardes.Cell, is_cursor: bool, cursor_bar: bool, ) void { // resolve the cell style to concrete fg/bg, matching the prototype's // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell // renders as reverse (cancelling an already-reversed cell); an insert // cursor keeps the cell colors and draws a bar glyph instead. var fg = fg_default; var bg = bg_default; var reverse = is_cursor and !cursor_bar; if (!cell.default) { const st = cell.style; fg = switch (st.fg) { .default => fg_default, .index => |i| palColor(i), .rgb => |rgb| rgb, }; bg = switch (st.bg) { .default => bg_default, .index => |i| palColor(i), .rgb => |rgb| rgb, }; if (st.reverse) reverse = !reverse; if (st.invisible) fg = bg; if (st.dim) for (&fg) |*ch| { ch.* = @intCast(@as(u16, ch.*) * 6 / 10); }; } if (reverse) std.mem.swap([3]u8, &fg, &bg); // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; const x0 = (px0 / win_w) * 2.0 - 1.0; const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; const y0 = 1.0 - (py0 / win_h) * 2.0; const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0; const grapheme = cell.grapheme(); var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); // ponytail: the insert bar is the left-eighth block glyph over the cell // (hides the char under it); a dedicated quad if that ever matters if (is_cursor and cursor_bar) cp = 0x258F; const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); const aw_f: f32 = @floatFromInt(atlas_w); const ah_f: f32 = @floatFromInt(atlas_h); instances[idx] = .{ .x0 = x0, .y0 = y0, .x1 = x1, .y1 = y1, .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f, .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, .br = @as(f32, @floatFromInt(bg[0])) / 255.0, .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, }; } fn palColor(idx: u8) [3]u8 { const p = ghostty_vt.color.default[idx]; return .{ p.r, p.g, p.b }; } // first codepoint of a UTF-8 grapheme; space on failure/empty fn firstCp(s: []const u8) u32 { if (s.len == 0) return ' '; const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; if (n > s.len) return ' '; return std.unicode.utf8Decode(s[0..n]) catch ' '; } /// Re-measure the cell, throw the glyph atlas away, and re-fit the grid to the /// window. THE path for any change to what a cell LOOKS like: point g.font at /// a different face (the Font builtin, above) or write a different g.px (the /// Ctrl+/Ctrl- in dispatch) and call this — those are one line each, and /// everything that has to follow from them is here. /// /// The atlas is the part that must not be skipped, and the reason the whole /// thing is a function rather than three lines at a call site. It is keyed by /// CODEPOINT ALONE — one face, one size, a pen walking rows of cell_w×cell_h /// slots — so after a change every slot in it holds the wrong picture at the /// wrong metrics, and every codepoint already in the map would keep being /// drawn from that slot forever, because ensureGlyph's first line is a cache /// hit. Clearing the map, zeroing the staging bitmap and rewinding the pen put /// it back to exactly what init built, and ensureGlyph refills it as the next /// frame draws. The zeroing is not tidiness: the upload is the WHOLE texture, /// the new cell size is a different grid over the same 2048², and a leftover /// bitmap no slot points at any more would still be sampled by whatever new /// slot overlaps it. fn refitFont(g: *Gui, core: *pardes.Pardes) void { // Native rasters at the size it measures. Web MEASURES at the CSS size and // RASTERS web_render_scale bigger, so text stays sharp on a scaled canvas // and cellLayout scales the quads back down — the same two scales initGl // takes, asked again because a different face answers differently. const measure = c.ui_font_scale_for_height(g.font, g.px); g.scale = if (is_emscripten) c.ui_font_scale_for_height(g.font, g.px * web_render_scale) else measure; var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(g.font, measure, &cw, &chh, &asc); const grow: f32 = if (is_emscripten) web_render_scale else 1.0; g.cell_w = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * grow)); g.cell_h = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * grow)); g.ascent = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * grow)); g.glyphs.clearRetainingCapacity(); @memset(g.atlas_stage, 0); // slot (0,0) is the space glyph, exactly as init lays it out _ = c.ui_font_raster(g.font, g.scale, ' ', g.atlas_stage.ptr, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); g.space_slot = .{ .u = 0, .v = 0 }; g.pen_x = g.cell_w; g.pen_y = 0; g.atlas_dirty = true; // ...and the grid: the same window is a different number of cells now. The // shells re-derive this every frame anyway, so this is only the frame the // change happens on — but it is the frame the surface is about to be // rendered for, and a stale screen_w here is a row of cells drawn off the // right edge of the window. 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))); _ = updateCoreResize(core, cols, rows, g.cell_w, g.cell_h); // ...and a fractional scroll is measured in the OLD grid: scroll_rect // is a rect of the pane the last frame drew, and scroll_edge is a saved row // of exactly that rect's body WIDTH. The resize above moves both under it, // and emitScrollRows only checks that the old rect still FITS inside the new // surface — which it does whenever the font got smaller — so the next frame // would paint last frame's strip over cells that are no longer the same // text. Retire the offset instead; the next wheel event starts in the new // grid. resetScroll(g); } 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 makeImagePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, image_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, image_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); defer c.SDL_ReleaseGPUShader(device, fs); var vbuf_desc = c.SDL_GPUVertexBufferDescription{ .slot = 0, .pitch = @sizeOf(ImageInstance), .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(ImageInstance, "x0") }, .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(ImageInstance, "u0") }, }; 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; info.vertex_input_state.num_vertex_buffers = 1; info.vertex_input_state.vertex_attributes = &attrs; info.vertex_input_state.num_vertex_attributes = attrs.len; info.target_info.color_target_descriptions = &col_desc; info.target_info.num_color_targets = 1; 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; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } // the CRT pass: a fullscreen triangle sampling the scene texture, no vertex // buffers at all (positions from gl_VertexIndex) fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); defer c.SDL_ReleaseGPUShader(device, vs); const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); defer c.SDL_ReleaseGPUShader(device, fs); var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); info.vertex_shader = vs; info.fragment_shader = fs; info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; } fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader { var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); info.code = code.ptr; info.code_size = code.len; info.entrypoint = "main"; info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; info.stage = stage; info.num_samplers = n_samplers; return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; } // ===================================================================== // web render: Surface → the same instanced quads → WebGL2. Full instance // re-upload each frame; attribs re-specified per draw (no VAO to manage). // ===================================================================== fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); const sw: u32 = @intCast(@max(pw, 1)); const sh: u32 = @intCast(@max(ph, 1)); c.glViewport(0, 0, @intCast(sw), @intCast(sh)); c.glClearColor( @as(f32, @floatFromInt(bg_default[0])) / 255.0, @as(f32, @floatFromInt(bg_default[1])) / 255.0, @as(f32, @floatFromInt(bg_default[2])) / 255.0, 1.0, ); c.glClear(c.GL_COLOR_BUFFER_BIT); const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); const cells: u32 = @as(u32, surface.cols) * surface.rows; if (cells == 0) { drawOverlayGl(g, overlay_count); _ = c.SDL_GL_SwapWindow(g.window); return; } // ×2 for the fractional-scroll pane's offset copy, as in renderFrame if (g.gl_instances.len < cells * 2) { gpa.free(g.gl_instances); g.gl_instances = try gpa.alloc(CellInstance, cells * 2); } const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; const cursor_bar = if (surface.cursor) |cu| cu.bar else false; var row: u16 = 0; while (row < surface.rows) : (row += 1) { var col: u16 = 0; while (col < surface.cols) : (col += 1) { const idx: u32 = @as(u32, row) * surface.cols + col; emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); } } const shifted = emitScrollRows(g, g.gl_instances.ptr, cells, surface, layout, win_w, win_h); 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 + shifted) * @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)); if (shifted != 0) { // the same second pass renderFrame does: re-point the attributes at // the tail of the buffer, clip to the pane body (GL counts scissor // rows from the BOTTOM), draw the offset copy over the flat one const tail: usize = cells * @sizeOf(CellInstance); c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "x0"))); c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "u0"))); c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "fr"))); c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(tail + @offsetOf(CellInstance, "br"))); const clip = scrollScissor(g, layout, sw, sh); c.glEnable(c.GL_SCISSOR_TEST); c.glScissor(clip.x, @as(c_int, @intCast(sh)) - (clip.y + clip.h), clip.w, clip.h); c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(shifted)); c.glDisable(c.GL_SCISSOR_TEST); } drawOverlayGl(g, overlay_count); _ = c.SDL_GL_SwapWindow(g.window); } fn uploadAtlasGl(g: *Gui) void { c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); g.atlas_dirty = false; } fn drawOverlayGl(g: *Gui, vertex_count: u32) void { if (vertex_count == 0) return; c.glUseProgram(g.gl_overlay_program); c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); const stride: c_int = @intCast(@sizeOf(OverlayVertex)); c.glEnableVertexAttribArray(0); c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); c.glVertexAttribDivisor(0, 0); c.glEnableVertexAttribArray(1); c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); c.glVertexAttribDivisor(1, 0); c.glDisableVertexAttribArray(2); c.glDisableVertexAttribArray(3); c.glEnable(c.GL_BLEND); c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); c.glDisable(c.GL_BLEND); } fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); defer c.glDeleteShader(vs); const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); defer c.glDeleteShader(fs); const program = c.glCreateProgram(); if (program == 0) return error.GpuCreate; c.glAttachShader(program, vs); c.glAttachShader(program, fs); c.glLinkProgram(program); var ok: c_int = 0; c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); if (ok == 0) { log.err("WebGL program link failed", .{}); c.glDeleteProgram(program); return error.GpuCreate; } return program; } fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { const shader = c.glCreateShader(kind); if (shader == 0) return error.GpuCreate; var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; var src_len = [_]c_int{@intCast(source.len)}; c.glShaderSource(shader, 1, &src_ptr, &src_len); c.glCompileShader(shader); var ok: c_int = 0; c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); if (ok == 0) { log.err("WebGL shader compile failed", .{}); c.glDeleteShader(shader); return error.GpuCreate; } return shader; } // ===================================================================== // PARDES_TEST frame capture: download the render target, write latest.ppm // ===================================================================== fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; g.scene_tex_w = width; g.scene_tex_h = height; } fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); info.type = c.SDL_GPU_TEXTURETYPE_2D; info.format = g.swapchain_format; info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; info.width = width; info.height = height; info.layer_count_or_depth = 1; info.num_levels = 1; info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; g.capture_tex_w = width; g.capture_tex_h = height; } fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); if (size == 0) { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.UnsupportedCaptureFormat; } if (g.capture_xfer == null or g.capture_xfer_size < size) { if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { _ = c.SDL_SubmitGPUCommandBuffer(cmd); return error.GpuCreate; }; g.capture_xfer_size = size; } const xfer = g.capture_xfer.?; const copy = c.SDL_BeginGPUCopyPass(cmd); const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; c.SDL_DownloadFromGPUTexture(copy, &src, &dst); c.SDL_EndGPUCopyPass(copy); const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; defer c.SDL_ReleaseGPUFence(g.device, fence); var fences = [_]*c.SDL_GPUFence{fence}; if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); const pixels: [*]const u8 = @ptrCast(mapped); try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); } fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { if (g.capture_dir.len == 0) return; const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); if (bpp != 4) return error.UnsupportedCaptureFormat; const bgr = switch (g.swapchain_format) { c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, else => return error.UnsupportedCaptureFormat, }; var tmp_buf: [4096]u8 = undefined; var final_buf: [4096]u8 = undefined; const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return error.CaptureWriteFailed; defer _ = libc.close(fd); var header: [64]u8 = undefined; const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; writeFd(fd, hdr); const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); defer gpa.free(row_rgb); for (0..height) |row| { const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; for (0..width) |x| { const si = x * 4; const di = x * 3; row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; row_rgb[di + 1] = src[si + 1]; row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; } writeFd(fd, row_rgb); } if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; } // ===================================================================== // touch debug overlay: per-finger colored circles + trails + a click-action // flash HUD, alpha-blended over the grid only while the Debug builtin is on. // ===================================================================== fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 { if (!debug_on) { g.touch.click_flash_frames = 0; return 0; } if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; const t = &g.touch; if (t.click_flash_frames != 0) { const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); const label = touchClickLabel(t.click_count, t.click_button); const glyph_scale = 3.0; const char_step = 4.0 * glyph_scale; const pad = 5.0; const stripe_w = 4.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); t.click_flash_frames -= 1; } for (&t.points) |*tp| { if (!tp.active) continue; const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w; const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h; const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; const color = touchColor(tp.id); if (tp.trail_len > 1) { const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; for (0..tp.trail_len - 1) |trail_i| { const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); var trail_color = color; trail_color.a *= 0.42 * fade; const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w; const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h; const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; builder.addCircle(tx, ty, trail_radius, trail_color); } } builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); builder.addCircle(cx, cy, radius, color); builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); builder.addIdTag(tp.id, cx, cy, color); } return @intCast(builder.len); } fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return; const dst: [*]u8 = @ptrCast(ptr); const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); @memcpy(dst[0..byte_len], src[0..byte_len]); c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); const copy = c.SDL_BeginGPUCopyPass(cmd); const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); c.SDL_EndGPUCopyPass(copy); } fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { if (vertex_count == 0) return; const pass = rp orelse return; c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); } fn touchColor(id: u64) OverlayColor { const palette = [_]OverlayColor{ .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, }; return palette[@intCast(id % palette.len)]; } const OverlayBuilder = struct { vertices: []OverlayVertex, len: usize = 0, win_w: f32, win_h: f32, fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { if (b.len >= b.vertices.len) return; b.vertices[b.len] = .{ .x = (px / b.win_w) * 2.0 - 1.0, .y = 1.0 - (py / b.win_h) * 2.0, .r = color.r, .g = color.g, .b = color.b, .a = color.a, }; b.len += 1; } fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { if (b.len + 3 > b.vertices.len) return; b.addVertex(x0, y0, color); b.addVertex(x1, y1, color); b.addVertex(x2, y2, color); } fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { if (b.len + 6 > b.vertices.len) return; const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); if (x1 <= x0 or y1 <= y0) return; b.addTri(x0, y0, x1, y0, x1, y1, color); b.addTri(x0, y0, x1, y1, x0, y1, color); } fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { var i: usize = 0; while (i + 1 < circle_points.len) : (i += 1) { const p0 = circle_points[i]; const p1 = circle_points[i + 1]; b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); } } fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { const label = touchIdLabel(id); const glyph_scale = 2.0; const char_step = 4.0 * glyph_scale; const pad = 4.0; const stripe_w = 3.0; const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; const label_h = pad * 2.0 + 5.0 * glyph_scale; var x = cx + 20.0; if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); var y = cy - label_h - 10.0; if (y < 2.0) y = cy + 20.0; y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); } fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { var cx = x; for (text) |ch| { const rows = miniGlyph(ch); const masks = [_]u8{ 0b100, 0b010, 0b001 }; for (rows, 0..) |bits, row| { for (masks, 0..) |mask, colidx| { if ((bits & mask) == 0) continue; const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; const y0 = y + @as(f32, @floatFromInt(row)) * scale; b.addRect(x0, y0, x0 + scale, y0 + scale, color); } } cx += 4.0 * scale; } } }; const circle_points = [_][2]f32{ .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, .{ 1.0000000, 0.0000000 }, }; fn touchIdLabel(id: u64) [9]u8 { var out: [9]u8 = undefined; out[0] = '#'; const low: u32 = @truncate(id); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 1] = hexDigit((low >> shift) & 0xf); } return out; } fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { var out: [14]u8 = undefined; @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); for (0..8) |i| { const shift: u5 = @intCast((7 - i) * 4); out[i + 6] = hexDigit((count >> shift) & 0xf); } return out; } fn hexDigit(n: u32) u8 { return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); } fn miniGlyph(ch: u8) [5]u8 { return switch (ch) { '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, else => .{ 0, 0, 0, 0, 0 }, }; } // ===================================================================== // shared plumbing (same shapes as tty.zig) // ===================================================================== /// The effective codepoint the way vaxis Key.matches sees it: a single-char /// text wins (shift resolved by the terminal), else the shifted codepoint. fn effCp(key: vaxis.Key) u21 { if (key.text) |t| { const view = std.unicode.Utf8View.init(t) catch return key.codepoint; var it = view.iterator(); if (it.nextCodepoint()) |cp| { if (it.nextCodepoint() == null) return cp; } } return key.shifted_codepoint orelse key.codepoint; } /// vaxis functional-key codepoints -> core Key constants (ASCII ones already /// coincide: enter/tab/escape/backspace pass through). fn mapKey(cp: u21) u21 { return switch (cp) { vaxis.Key.up => pardes.Key.up, vaxis.Key.down => pardes.Key.down, vaxis.Key.left => pardes.Key.left, vaxis.Key.right => pardes.Key.right, vaxis.Key.home => pardes.Key.home, vaxis.Key.end => pardes.Key.end, vaxis.Key.page_up => pardes.Key.page_up, vaxis.Key.page_down => pardes.Key.page_down, vaxis.Key.delete => pardes.Key.delete, else => cp, }; } fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { const raw = env.get(name) orelse return null; return std.fmt.parseInt(u16, raw, 10) catch null; } fn writeFile(data: []const u8) void { const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); if (fd < 0) return; defer _ = libc.close(fd); writeFd(fd, data); } fn writeFd(fd: c_int, data: []const u8) void { var off: usize = 0; while (off < data.len) { const n = libc.write(fd, data[off..].ptr, data.len - off); if (n < 0) { if (libc.errno(n) == .INTR) continue; return; } off += @intCast(n); } }