diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-30 23:35:45 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 19f7322100062b7c1adcde3376063ce6c1d8c72d (patch) | |
| tree | b76347dffe0347c29a38b4cad6031678683dba74 /src/gui.zig | |
| parent | 753d5451ca7d09dbc5ff44f3c2dcf9a47fb87f96 (diff) | |
| download | pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.tar.gz pardes-19f7322100062b7c1adcde3376063ce6c1d8c72d.zip | |
structure: backends into src/{tty,gui,lsp}, pane kinds and builtins into their own files
The core now lies FLAT at src/ and every subdirectory is one backend, so a
file being in no directory at all is what says it is core. Pane-kind bodies
leave pardes.zig for term_pane.zig / file_pane.zig / output_pane.zig, leaving
it the layout, the event/effect machine and the generic render loop.
Builtins are one struct each in builtins.zig, and the enum is folded out of
the file's own declaration list at comptime — a zig file IS a struct, so the
list of builtins and the builtins themselves are the same text. Adding one is
writing a struct. Key paths deliberately stay one table for the config pass.
Pure refactor: no golden moved.
Diffstat (limited to 'src/gui.zig')
| -rw-r--r-- | src/gui.zig | 3103 |
1 files changed, 0 insertions, 3103 deletions
diff --git a/src/gui.zig b/src/gui.zig deleted file mode 100644 index 4fa5d20e..00000000 --- a/src/gui.zig +++ /dev/null @@ -1,3103 +0,0 @@ -//! The SDL3 GPU shell: owns an SDL window + event loop, translates SDL input -//! into core events, performs the core's effects (fork ptys, write them, -//! resize them — same duties as tty.zig, this is also native), and -//! rasterizes the core's Surface: one instanced quad per cell, glyphs from an -//! stb_truetype R8 atlas. Test modes: PARDES_TEST_GRID=1 is headless (no SDL, -//! stdin escape sequences in, text grid frames out); PARDES_TEST=1 keeps the -//! real renderer, drives input from stdin, and captures frames to PPM. -//! -//! The same file is the BROWSER shell (-Dplatform=web, wasm32-emscripten): -//! WebGL2 instead of the GPU device, an embedded dump instead of ptys, and a -//! requestAnimationFrame callback instead of a blocking loop. Comptime -//! branches on is_emscripten, the way the stdlib branches on os.tag. -const std = @import("std"); -const builtin = @import("builtin"); -const posix = std.posix; -const libc = std.c; -const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser -const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells -const pardes = @import("pardes.zig"); -const look = @import("look.zig"); -const deck = @import("deck.zig"); -const crt = @import("crt.zig"); - -const is_emscripten = builtin.os.tag == .emscripten; - -pub const c = @cImport({ - @cDefine("SDL_DISABLE_OLD_NAMES", "1"); - @cInclude("SDL3/SDL.h"); - if (is_emscripten) @cInclude("GLES3/gl3.h"); - @cInclude("font.h"); -}); - -extern "c" fn forkpty(amaster: *c_int, name: ?[*:0]u8, termp: ?*const anyopaque, winp: ?*const posix.winsize) c_int; -extern "c" fn execv(path: [*:0]const u8, argv: [*:null]const ?[*:0]const u8) c_int; -extern "c" fn chdir(path: [*:0]const u8) c_int; -extern "c" fn _exit(status: c_int) noreturn; -extern "c" fn setenv(name: [*:0]const u8, value: [*:0]const u8, overwrite: c_int) c_int; -extern "c" fn emscripten_get_element_css_size(target: [*:0]const u8, width: *f64, height: *f64) c_int; -const EMSCRIPTEN_RESULT_SUCCESS: c_int = 0; - -// absolute shell path per OS: execv must not search PATH (no allocation -// between fork and exec) -const bash_path: [*:0]const u8 = if (builtin.os.tag == .linux) "/usr/bin/bash" else "/bin/bash"; -// TIOCSWINSZ: absent from std.c.T on darwin — _IOW('t', 103, winsize) -const TIOCSWINSZ: c_int = @bitCast(@as(u32, if (@hasDecl(posix.T, "IOCSWINSZ")) posix.T.IOCSWINSZ else 0x80087467)); - -const log = std.log.scoped(.gui); - -const font_ttf = @embedFile("AdwaitaMono-Regular.ttf"); -const vert_spv = if (is_emscripten) "" else @embedFile("ui.vert.spv"); -const frag_spv = if (is_emscripten) "" else @embedFile("ui.frag.spv"); -const overlay_vert_spv = if (is_emscripten) "" else @embedFile("overlay.vert.spv"); -const overlay_frag_spv = if (is_emscripten) "" else @embedFile("overlay.frag.spv"); -const crt_vert_spv = if (is_emscripten) "" else @embedFile("crt.vert.spv"); -const crt_frag_spv = if (is_emscripten) "" else @embedFile("crt.frag.spv"); -// web: GL ES shader SOURCES, compiled by WebGL at init (no SPIR-V there) -const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; -const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; -const overlay_vert_glsl_es = if (is_emscripten) @embedFile("overlay.vert.es.glsl") else ""; -const overlay_frag_glsl_es = if (is_emscripten) @embedFile("overlay.frag.es.glsl") else ""; -// web: no ptys — the whole state replays from a dump embedded at build time -const embedded_dump = if (is_emscripten) @embedFile("embedded.dump.zon") else ""; - -// default page colors — the same "terminal native dark" the prototype used -const bg_default = [3]u8{ 18, 18, 18 }; -const fg_default = [3]u8{ 204, 204, 204 }; - -// the plan9 arrow cursor, bytes verbatim from 9front /sys/src/9/port/ -// devmouse.c (Cursor arrow, 16x16 MSB-first): clr is the white outline, set -// the black ink, offset {-1,-1} puts the hot point at (1,1) in the bitmap. -// SDL_CreateCursor's scheme: data=1,mask=1 black; data=0,mask=1 white; -// mask=0 transparent — so data = set and mask = set|clr. -const p9_arrow_clr = [32]u8{ - 0xFF, 0xFF, 0x80, 0x01, 0x80, 0x02, 0x80, 0x0C, - 0x80, 0x10, 0x80, 0x10, 0x80, 0x08, 0x80, 0x04, - 0x80, 0x02, 0x80, 0x01, 0x80, 0x02, 0x8C, 0x04, - 0x92, 0x08, 0x91, 0x10, 0xA0, 0xA0, 0xC0, 0x40, -}; -const p9_arrow_set = [32]u8{ - 0x00, 0x00, 0x7F, 0xFE, 0x7F, 0xFC, 0x7F, 0xF0, - 0x7F, 0xE0, 0x7F, 0xE0, 0x7F, 0xF0, 0x7F, 0xF8, - 0x7F, 0xFC, 0x7F, 0xFE, 0x7F, 0xFC, 0x73, 0xF8, - 0x61, 0xF0, 0x60, 0xE0, 0x40, 0x40, 0x00, 0x00, -}; -const p9_arrow_mask = blk: { - var m: [32]u8 = undefined; - for (&m, p9_arrow_set, p9_arrow_clr) |*b, s, cl| b.* = s | cl; - break :blk m; -}; - -// web: glyphs raster at 1.5x and the canvas backs at CSS×1.5 so text stays -// sharp on scaled canvases (the prototype's -Dweb-render-scale, which never -// shipped non-default — hardcoded here instead of a build option). -const web_render_scale: f32 = 1.5; - -// 2048 fits ~2500 glyphs at the 2x native cell (~20x40); web needs it too -const atlas_w: u32 = 2048; -const atlas_h: u32 = 2048; -const Slot = struct { u: u32, v: u32 }; - -const max_touch_points: usize = 16; -const max_touch_trail_points: usize = 8; -const overlay_circle_vertices: usize = 16 * 3; -const touch_click_flash_max_frames: u8 = 14; -const touch_click_flash_vertices: usize = 1400; -const touch_scroll_tick: f32 = 0.02; -/// Web tap-vs-scroll heuristic, measured after normalized coordinates have -/// been mapped into backing pixels (12px = 8 CSS px at web_render_scale). -const touch_tap_slop_px: f32 = 12.0; -const max_overlay_vertices: usize = touch_click_flash_vertices + max_touch_points * (1100 + max_touch_trail_points * overlay_circle_vertices); - -// One instance per cell; the vertex shader expands it to a 2-triangle quad -// with gl_VertexIndex. Coords are NDC (y up), uv into the atlas, colors 0..1. -const CellInstance = extern struct { - x0: f32, - y0: f32, - x1: f32, - y1: f32, - u0: f32, - v0: f32, - u1: f32, - v1: f32, - fr: f32, - fg: f32, - fb: f32, - br: f32, - bg: f32, - bb: f32, -}; - -const OverlayVertex = extern struct { x: f32, y: f32, r: f32, g: f32, b: f32, a: f32 }; -const OverlayColor = struct { r: f32, g: f32, b: f32, a: f32 }; - -// ---- touch: per-finger tracking + shared scroll/tap machines ---- - -const TouchSample = struct { x: f32 = 0, y: f32 = 0, pressure: f32 = 1 }; - -const TouchPoint = struct { - active: bool = false, - id: u64 = 0, - x: f32 = 0, - y: f32 = 0, - pressure: f32 = 1, - trail: [max_touch_trail_points]TouchSample = @splat(.{}), - trail_len: usize = 0, - trail_next: usize = 0, -}; - -const TouchNormPoint = struct { x: f32 = 0, y: f32 = 0 }; - -/// A finger event with SDL's normalized 0..1 coordinates — the one shape both -/// real SDL_EVENT_FINGER_* and the synthetic test OSC feed into the machine. -const Finger = struct { kind: enum { down, motion, up, cancel }, id: u64, x: f32, y: f32, pressure: f32 }; - -const Touch = struct { - points: [max_touch_points]TouchPoint = @splat(.{}), - scroll: struct { - active: bool = false, - ids: [2]u64 = .{ 0, 0 }, - accum_y: f32 = 0, - scrolled: bool = false, - last_center: TouchNormPoint = .{}, - } = .{}, - // Web policy: one primary finger is either a body tap/scroll or a latched - // left-mouse gesture when it begins on layout chrome. A second simultaneous - // finger cancels a body tap rather than producing a click. - single: struct { - active: bool = false, - id: u64 = 0, - start: TouchNormPoint = .{}, - last: TouchNormPoint = .{}, - accum_y: f32 = 0, - scrolling: bool = false, - mouse_drag: bool = false, - multitouch: bool = false, - } = .{}, - click_count: u32 = 0, - click_button: pardes.Mouse.Button = .middle, - click_flash_frames: u8 = 0, - - fn findSlot(t: *const Touch, id: u64) ?usize { - for (&t.points, 0..) |*tp, i| if (tp.active and tp.id == id) return i; - return null; - } - - fn updatePoint(t: *Touch, f: Finger) void { - switch (f.kind) { - .up, .cancel => if (t.findSlot(f.id)) |i| { - t.points[i].active = false; - }, - .down, .motion => { - const idx = t.findSlot(f.id) orelse blk: { - for (&t.points, 0..) |*tp, i| if (!tp.active) break :blk i; - break :blk @as(usize, @intCast(f.id % max_touch_points)); - }; - const tp = &t.points[idx]; - if (!tp.active or tp.id != f.id) tp.* = .{ .active = true, .id = f.id }; - tp.x = f.x; - tp.y = f.y; - tp.pressure = f.pressure; - tp.trail[tp.trail_next] = .{ .x = f.x, .y = f.y, .pressure = f.pressure }; - tp.trail_next = (tp.trail_next + 1) % max_touch_trail_points; - if (tp.trail_len < max_touch_trail_points) tp.trail_len += 1; - }, - } - } - - fn activeCount(t: *const Touch) usize { - var count: usize = 0; - for (&t.points) |*tp| count += @intFromBool(tp.active); - return count; - } - - fn pairCenter(t: *const Touch, ids: [2]u64) ?TouchNormPoint { - var sum: TouchNormPoint = .{}; - for (ids) |id| { - const i = t.findSlot(id) orelse return null; - sum.x += t.points[i].x; - sum.y += t.points[i].y; - } - return .{ .x = sum.x * 0.5, .y = sum.y * 0.5 }; - } - - /// keep scroll state in sync with the set of active fingers: exactly two - /// active fingers begin (or re-key) a pair; anything else resets it. - fn syncPair(t: *Touch) void { - var ids: [2]u64 = .{ 0, 0 }; - var count: usize = 0; - for (&t.points) |*tp| { - if (!tp.active) continue; - if (count >= 2) { - t.scroll = .{}; - return; - } - ids[count] = tp.id; - count += 1; - } - if (count != 2) { - t.scroll = .{}; - return; - } - if (ids[0] > ids[1]) std.mem.swap(u64, &ids[0], &ids[1]); - if (t.scroll.active and t.scroll.ids[0] == ids[0] and t.scroll.ids[1] == ids[1]) return; - t.scroll = .{ .active = true, .ids = ids, .last_center = t.pairCenter(ids) orelse .{} }; - } - - /// finger lift: a pair that never scrolled is a two-finger TAP — returns - /// its center (computed with the lifting finger's final position). - fn finishPair(t: *Touch, f: Finger) ?TouchNormPoint { - if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; - const tap = f.kind == .up and !t.scroll.scrolled; - var center: ?TouchNormPoint = null; - if (tap) { - var sum: TouchNormPoint = .{}; - var ok = true; - for (t.scroll.ids) |id| { - if (id == f.id) { - sum.x += f.x; - sum.y += f.y; - } else if (t.findSlot(id)) |i| { - sum.x += t.points[i].x; - sum.y += t.points[i].y; - } else ok = false; - } - center = if (ok) .{ .x = sum.x * 0.5, .y = sum.y * 0.5 } else t.scroll.last_center; - } - t.scroll = .{}; - return center; - } - - fn noteMotion(t: *Touch, f: Finger) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { - if (!t.scroll.active or (t.scroll.ids[0] != f.id and t.scroll.ids[1] != f.id)) return null; - const center = t.pairCenter(t.scroll.ids) orelse t.scroll.last_center; - const delta_y = center.y - t.scroll.last_center.y; - t.scroll.last_center = center; - t.scroll.accum_y += delta_y; - const ticks = takeScrollTicks(&t.scroll.accum_y); - if (ticks == 0) return null; - t.scroll.scrolled = true; - return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; - } - - fn beginSingle(t: *Touch, f: Finger, mouse_drag: bool) void { - if (t.single.active) { - if (t.single.id != f.id) t.single.multitouch = true; - return; - } - if (t.activeCount() != 1) return; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - t.single = .{ .active = true, .id = f.id, .start = point, .last = point, .mouse_drag = mouse_drag }; - } - - fn noteSingleMotion(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?struct { center: TouchNormPoint, ticks: i32, delta_y: f32 } { - if (!t.single.active or t.single.id != f.id or t.single.multitouch) return null; - const center = TouchNormPoint{ .x = f.x, .y = f.y }; - var delta_y = center.y - t.single.last.y; - t.single.last = center; - if (!t.single.scrolling) { - const dx = (center.x - t.single.start.x) * win_w; - const dy = (center.y - t.single.start.y) * win_h; - if (dx * dx + dy * dy < touch_tap_slop_px * touch_tap_slop_px) return null; - // Once the pointer leaves the tap slop it can never click. Include - // all travel since finger-down so the first scroll tick is prompt. - t.single.scrolling = true; - delta_y = center.y - t.single.start.y; - } - t.single.accum_y += delta_y; - const ticks = takeScrollTicks(&t.single.accum_y); - if (ticks == 0) return null; - return .{ .center = center, .ticks = ticks, .delta_y = @as(f32, @floatFromInt(ticks)) * touch_scroll_tick }; - } - - fn finishSingle(t: *Touch, f: Finger, win_w: f32, win_h: f32) ?TouchNormPoint { - if (!t.single.active or t.single.id != f.id) return null; - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const dx = (point.x - t.single.start.x) * win_w; - const dy = (point.y - t.single.start.y) * win_h; - const left_slop = dx * dx + dy * dy >= touch_tap_slop_px * touch_tap_slop_px; - const tap = f.kind == .up and !left_slop and !t.single.scrolling and !t.single.mouse_drag and !t.single.multitouch; - t.single = .{}; - return if (tap) point else null; - } -}; - -fn takeScrollTicks(accum: *f32) i32 { - var ticks: i32 = 0; - while (accum.* >= touch_scroll_tick) : (ticks += 1) accum.* -= touch_scroll_tick; - while (accum.* <= -touch_scroll_tick) : (ticks -= 1) accum.* += touch_scroll_tick; - return ticks; -} - -/// The SDL boundary owns touch policy: a web body uses one-finger natural -/// scrolling and tap-as-LOOK, while layout chrome latches a left-mouse drag; -/// native keeps two-finger scroll/tap-as-execute. Both feed the same core mouse -/// events and coordinate mapping. -fn handleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - std.debug.assert(cell_w > 0 and cell_h > 0); - if (is_emscripten) - return handleSingleFinger(t, core, f, win_w, win_h, cell_w, cell_h); - return handlePairFinger(t, core, f, win_w, win_h, cell_w, cell_h); -} - -fn handleSingleFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - switch (f.kind) { - .down => { - t.updatePoint(f); - const point = TouchNormPoint{ .x = f.x, .y = f.y }; - const col = normCell(point.x, win_w, cell_w); - const row = normCell(point.y, win_h, cell_h); - const chrome = webChromeTarget(core, col, row); - t.beginSingle(f, chrome != null); - if (chrome) |target| - core.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = target.col, .row = target.row } }); - }, - .motion => { - t.updatePoint(f); - if (t.single.active and t.single.id == f.id and t.single.mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .drag, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - return; - } - const res = t.noteSingleMotion(f, win_w, win_h) orelse return; - emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); - }, - .up, .cancel => { - const mouse_drag = t.single.active and t.single.id == f.id and t.single.mouse_drag; - const tap = t.finishSingle(f, win_w, win_h); - t.updatePoint(f); - if (mouse_drag) { - core.update(.{ .mouse = .{ - .button = .left, - .kind = .release, - .col = normCell(f.x, win_w, cell_w), - .row = normCell(f.y, win_h, cell_h), - } }); - } else if (tap) |point| emitTouchClick(t, core, point, .right, win_w, win_h, cell_w, cell_h); - }, - } -} - -const WebChromeTarget = struct { col: u16, row: u16 }; - -/// Fat-finger hit test for web layout chrome. A gesture is classified once at -/// finger-down, then never changes into scrolling. Handle coordinates are -/// snapped onto the core's one-cell resize edge; body rows remain untouched. -fn webChromeTarget(core: *const pardes.Pardes, col: u16, row: u16) ?WebChromeTarget { - if (row < pardes.TOPBAR_H) return .{ .col = col, .row = row }; - - // The move box wins over a horizontal handle when a pane is tag-only, - // matching the core's own mouse hit-test priority. - for (core.panes, 0..) |slot, id| { - if (slot == null) continue; - const rect = core.rects[id]; - if (row == rect.y and col >= rect.x and col < rect.x + rect.w and col < rect.x + pardes.GUTTER) - return .{ .col = col, .row = rect.y }; - } - - for (0..core.ncol -| 1) |column| { - const handle = core.col_x[column] + core.col_w[column] -| 1; - if (col == handle) return .{ .col = handle, .row = row }; - } - for (0..core.ncol) |column| { - if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; - for (0..core.col_n[column] -| 1) |index| { - const rect = core.rects[core.col_terms[column][index]]; - const handle = rect.y + rect.h -| 1; - if (row == handle) return .{ .col = col, .row = handle }; - } - } - - // The remainder of each tag row is tag editing, not a scrollable body. - for (core.panes, 0..) |slot, id| { - if (slot == null) continue; - const rect = core.rects[id]; - if (row == rect.y and col >= rect.x and col < rect.x + rect.w) - return .{ .col = col, .row = rect.y }; - } - - // One-cell tolerance around thin separators, after exact tag rows have had - // first refusal so a fat-finger tag edit never turns into a resize. - for (0..core.ncol -| 1) |column| { - const handle = core.col_x[column] + core.col_w[column] -| 1; - if (cellDistance(col, handle) == 1) return .{ .col = handle, .row = row }; - } - for (0..core.ncol) |column| { - if (col < core.col_x[column] or col >= core.col_x[column] + core.col_w[column]) continue; - for (0..core.col_n[column] -| 1) |index| { - const rect = core.rects[core.col_terms[column][index]]; - const handle = rect.y + rect.h -| 1; - if (cellDistance(row, handle) == 1) return .{ .col = col, .row = handle }; - } - } - return null; -} - -fn cellDistance(a: u16, b: u16) u16 { - return if (a > b) a - b else b - a; -} - -fn handlePairFinger(t: *Touch, core: *pardes.Pardes, f: Finger, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - switch (f.kind) { - .down => { - t.updatePoint(f); - t.syncPair(); - }, - .motion => { - const had_pair = t.scroll.active; - t.updatePoint(f); - t.syncPair(); - if (!had_pair) return; - const res = t.noteMotion(f) orelse return; - emitTouchScroll(core, res.center, res.ticks, res.delta_y, win_w, win_h, cell_w, cell_h); - }, - .up, .cancel => { - const tap_center = t.finishPair(f); - t.updatePoint(f); - t.syncPair(); - if (tap_center) |center| emitTouchClick(t, core, center, .middle, win_w, win_h, cell_w, cell_h); - }, - } -} - -fn emitTouchScroll(core: *pardes.Pardes, center: TouchNormPoint, ticks: i32, delta_y: f32, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - const col = normCell(center.x, win_w, cell_w); - const row = normCell(center.y, win_h, cell_h); - const button: pardes.Mouse.Button = if (ticks > 0) .wheel_up else .wheel_down; - var remaining: u32 = @abs(ticks); - while (remaining > 0) : (remaining -= 1) - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); - core.update(.{ .touch_scroll = delta_y }); -} - -fn emitTouchClick(t: *Touch, core: *pardes.Pardes, point: TouchNormPoint, button: pardes.Mouse.Button, win_w: f32, win_h: f32, cell_w: f32, cell_h: f32) void { - t.click_count +%= 1; - t.click_button = button; - t.click_flash_frames = touch_click_flash_max_frames; - const col = normCell(point.x, win_w, cell_w); - const row = normCell(point.y, win_h, cell_h); - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = col, .row = row } }); - core.update(.{ .mouse = .{ .button = button, .kind = .release, .col = col, .row = row } }); -} - -fn normCell(norm: f32, win: f32, cell: f32) u16 { - const px = std.math.clamp(norm, 0.0, 1.0) * win; - return @intFromFloat(@max(0, @floor(px / cell))); -} - -// ---- pty plumbing: reader threads feed a mutex-protected queue ---- - -const Pty = struct { fd: c_int, pid: libc.pid_t }; - -const Msg = union(enum) { - output: struct { pane: u8, gen: u32, bytes: []u8 }, - eof: struct { pane: u8, gen: u32, fd: c_int }, - /// a language query finished on its own thread (see lspThread) - lsp: struct { id: u32, rows: []u8 }, -}; - -/// One language query, owned by the thread running it — the gui twin of -/// tty.zig's LspJob, and copied for the same reason: the core edits on. -const LspJob = struct { - id: u32, - kind: pardes.lsp.Kind, - offset: u32, - path: []u8, - source: [:0]u8, - arg: []u8, - root: []u8, - - fn free(j: *LspJob, gpa: std.mem.Allocator) void { - gpa.free(j.path); - gpa.free(j.source); - gpa.free(j.arg); - gpa.free(j.root); - gpa.destroy(j); - } -}; - -const Queue = struct { - gpa: std.mem.Allocator, - sdl_wake: bool, // wake a blocking SDL_WaitEventTimeout on cross-thread push - // 0.16 has no std.Thread.Mutex; critical sections here are a few - // instructions, so spinning on the lock-free std.atomic.Mutex is enough - mutex: std.atomic.Mutex = .unlocked, - items: std.ArrayList(Msg) = .empty, - closed: bool = false, - - fn lock(q: *Queue) void { - while (!q.mutex.tryLock()) std.atomic.spinLoopHint(); - } - - fn push(q: *Queue, m: Msg) void { - q.lock(); - if (q.closed) { - q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - } - return; - } - q.items.append(q.gpa, m) catch { - q.mutex.unlock(); - switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - } - return; - }; - q.mutex.unlock(); - if (q.sdl_wake) { - var sev = std.mem.zeroes(c.SDL_Event); - sev.type = c.SDL_EVENT_USER; - _ = c.SDL_PushEvent(&sev); - } - } - - /// take the pending messages (caller iterates + deinits outside the lock) - fn take(q: *Queue) std.ArrayList(Msg) { - q.lock(); - defer q.mutex.unlock(); - const local = q.items; - q.items = .empty; - return local; - } - - fn close(q: *Queue) void { - q.lock(); - defer q.mutex.unlock(); - q.closed = true; - for (q.items.items) |m| switch (m) { - .output => |o| q.gpa.free(o.bytes), - .lsp => |l| q.gpa.free(l.rows), - .eof => {}, - }; - q.items.deinit(q.gpa); - } -}; - -fn readPtyThread(gpa: std.mem.Allocator, fd: c_int, pane: u8, gen: u32, q: *Queue) void { - var buf: [0x10000]u8 = undefined; - while (true) { - const n = libc.read(fd, &buf, buf.len); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - break; // EIO when the child exits: treat as EOF - } - if (n == 0) break; - const bytes = gpa.dupe(u8, buf[0..@intCast(n)]) catch break; - q.push(.{ .output = .{ .pane = pane, .gen = gen, .bytes = bytes } }); - } - q.push(.{ .eof = .{ .pane = pane, .gen = gen, .fd = fd } }); -} - -fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) void { - const th = std.Thread.spawn(.{}, readPtyThread, .{ gpa, pt.fd, pane, gen, q }) catch return; - th.detach(); -} - -/// Answer a language query off the render loop and push the rows to the queue -/// — the async execution model, spelled in the plumbing this shell already has -/// (a detached thread and the mutex queue the pty readers use). -fn lspThread(gpa: std.mem.Allocator, job: *LspJob, q: *Queue) void { - defer job.free(gpa); - var arena: std.heap.ArenaAllocator = .init(gpa); - defer arena.deinit(); - // the shell owns the result buffer; the backend only ever writes to it - var out: std.Io.Writer.Allocating = .init(gpa); - defer out.deinit(); - pardes.lsp.query(gpa, arena.allocator(), .{ - .kind = job.kind, - .path = job.path, - .source = job.source, - .offset = job.offset, - .arg = job.arg, - .root = job.root, - }, &out.writer); - const rows = gpa.dupe(u8, out.written()) catch return; - q.push(.{ .lsp = .{ .id = job.id, .rows = rows } }); -} - -/// Copy the query out of the core and hand it to a thread. A detached thread -/// per query is fine at this rate: one keystroke, one query, and the queue -/// already tolerates a late push after close. -fn spawnLsp(core: *pardes.Pardes, gpa: std.mem.Allocator, q: *Queue, e: anytype) void { - const pane = core.panes[e.pane] orelse return; - // a pane with no file still asks `status` (it is about the backend, not - // the buffer): empty path and source, root from the pane's cwd - const f = pane.file; - const job = gpa.create(LspJob) catch return; - job.* = .{ - .id = e.id, - .kind = e.kind, - .offset = e.offset, - .path = gpa.dupe(u8, if (f) |ff| ff.path else "") catch { - gpa.destroy(job); - return; - }, - .source = gpa.dupeZ(u8, if (f) |ff| ff.content else "") catch { - gpa.free(job.path); - gpa.destroy(job); - return; - }, - .arg = gpa.dupe(u8, e.arg.slice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.destroy(job); - return; - }, - .root = gpa.dupe(u8, if (f) |ff| (std.fs.path.dirname(ff.path) orelse "/") else pane.cwdSlice()) catch { - gpa.free(job.path); - gpa.free(job.source); - gpa.free(job.arg); - gpa.destroy(job); - return; - }, - }; - const th = std.Thread.spawn(.{}, lspThread, .{ gpa, job, q }) catch { - job.free(gpa); - return; - }; - th.detach(); -} - -// ---- the renderer state ---- - -const Gui = struct { - window: *c.SDL_Window, - device: *c.SDL_GPUDevice, - swapchain_format: c.SDL_GPUTextureFormat, - pipeline: *c.SDL_GPUGraphicsPipeline, - overlay_pipeline: *c.SDL_GPUGraphicsPipeline, - crt_pipeline: *c.SDL_GPUGraphicsPipeline, - atlas_tex: *c.SDL_GPUTexture, - atlas_sampler: *c.SDL_GPUSampler, - atlas_xfer: *c.SDL_GPUTransferBuffer, - vbuf: ?*c.SDL_GPUBuffer = null, - vxfer: ?*c.SDL_GPUTransferBuffer = null, - vbuf_cells: u32 = 0, - overlay_vbuf: *c.SDL_GPUBuffer, - overlay_vxfer: *c.SDL_GPUTransferBuffer, - overlay_vertices: []OverlayVertex, - - font: *c.UIFont, - scale: f32, - cell_w: u32, - cell_h: u32, - ascent: i32, - - // glyph atlas: CPU staging bitmap + codepoint → texel slot, pen-walk alloc - atlas_stage: []u8, - glyphs: std.AutoHashMap(u32, Slot), - pen_x: u32 = 0, - pen_y: u32 = 0, - atlas_dirty: bool = true, - space_slot: Slot = .{ .u = 0, .v = 0 }, - - touch: Touch = .{}, - mouse_x: f32 = 0, - mouse_y: f32 = 0, - live_ctrl: bool = false, - live_alt: bool = false, - - // Crt builtin: the scene renders into this texture, then a fullscreen - // CRT pass warps it onto the real target - scene_tex: ?*c.SDL_GPUTexture = null, - scene_tex_w: u32 = 0, - scene_tex_h: u32 = 0, - - // PARDES_TEST frame capture (render into an offscreen target, dump PPM) - capture: bool = false, - capture_dir: []const u8 = "", - capture_tex: ?*c.SDL_GPUTexture = null, - capture_tex_w: u32 = 0, - capture_tex_h: u32 = 0, - capture_xfer: ?*c.SDL_GPUTransferBuffer = null, - capture_xfer_size: u32 = 0, - - // steamdeck: gamepad-driven virtual mouse cursor (pixel position) - gamepad: ?*c.SDL_Gamepad = null, - pad_x: f32 = 0, - pad_y: f32 = 0, - pad_scroll: f32 = 0, // fractional vertical wheel ticks (left stick) - pad_scroll_h: f32 = 0, // fractional horizontal wheel ticks (left stick) - deck: deck.Deck = .{}, - - // web: WebGL2 handles (plain GL ints) + the CPU-side instance staging - gl_program: c_uint = 0, - gl_overlay_program: c_uint = 0, - gl_atlas_tex: c_uint = 0, - gl_vbo: c_uint = 0, - gl_overlay_vbo: c_uint = 0, - gl_u_atlas: c_int = -1, - gl_instances: []CellInstance = &.{}, -}; - -/// A cell's on-screen rect. Native: exactly cell_w×cell_h at (0,0). Web: the -/// canvas resize and the grid resize land on different frames, so the grid is -/// scaled to fit and centered (letterboxed) instead of clipping. -const CellLayout = struct { w: f32, h: f32, x_off: f32, y_off: f32 }; - -fn fixedCellLayout(g: *const Gui) CellLayout { - return .{ .w = @floatFromInt(g.cell_w), .h = @floatFromInt(g.cell_h), .x_off = 0, .y_off = 0 }; -} - -fn cellLayout(g: *const Gui, cols: u16, rows: u16, sw: u32, sh: u32) CellLayout { - const fixed = fixedCellLayout(g); - if (!is_emscripten) return fixed; - if (cols == 0 or rows == 0) return fixed; - const grid_w = @as(f32, @floatFromInt(cols)) * fixed.w; - const grid_h = @as(f32, @floatFromInt(rows)) * fixed.h; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const scale = @min(win_w / grid_w, win_h / grid_h); - return .{ - .w = fixed.w * scale, - .h = fixed.h * scale, - .x_off = @max(0, (win_w - grid_w * scale) * 0.5), - .y_off = @max(0, (win_h - grid_h * scale) * 0.5), - }; -} - -// ===================================================================== -// entry -// ===================================================================== - -pub const run = if (is_emscripten) runWeb else runNative; - -fn runNative(init: std.process.Init, opts_in: pardes.Options) !void { - const io = init.io; - const gpa = init.gpa; - const env = init.environ_map; - if (env.get("PARDES_TEST_GRID") != null) return runGrid(init, opts_in); - - const test_mode = env.get("PARDES_TEST") != null; - const capture_dir = env.get("PARDES_TEST_CAPTURE_DIR"); - if (test_mode and capture_dir == null) { - log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); - return error.SdlInit; - } - - // The trackpads only exist through SDL's built-in HIDAPI Steam Deck - // driver (it registers the two touchpads and disables "lizard mode"). - // It's default-on on Linux; pin it so intent is explicit. NOTE: this is - // NOT enough in Game Mode — Steam Input there hands the app a - // touchpad-less virtual gamepad instead of the real Neptune controller, - // so the pads go dead no matter what the app does. The only fix is to - // set "Disable Steam Input" on pardes (Steam -> Properties -> Controller), - // after which the real controller enumerates and the touchpad events flow. - _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI", "1"); // SDL_HINT_JOYSTICK_HIDAPI - _ = c.SDL_SetHint("SDL_JOYSTICK_HIDAPI_STEAMDECK", "1"); // ..._STEAMDECK - if (!c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_GAMEPAD)) { - log.err("SDL_Init: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; - if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; - const window = c.SDL_CreateWindow("pardes", 1120, 720, win_flags) orelse { - log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (c.SDL_CreateCursor(&p9_arrow_set, &p9_arrow_mask, 16, 16, 1, 1)) |cur| { - _ = c.SDL_SetCursor(cur); - } else log.err("SDL_CreateCursor: {s}", .{c.SDL_GetError()}); - // fullscreen deck use: keep the pointer inside the window. Absolute - // coords stay (no relative mode — clicks need positions). - if (!test_mode) _ = c.SDL_SetWindowMouseGrab(window, true); - const device = c.SDL_CreateGPUDevice(c.SDL_GPU_SHADERFORMAT_SPIRV, true, null) orelse { - log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (!c.SDL_ClaimWindowForGPUDevice(device, window)) { - log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - // present mode: PARDES_SDL_PRESENT env override, else immediate → mailbox → vsync - const present_mode: c.SDL_GPUPresentMode = blk: { - if (env.get("PARDES_SDL_PRESENT")) |raw| { - const want: ?c.SDL_GPUPresentMode = if (std.ascii.eqlIgnoreCase(raw, "immediate")) - c.SDL_GPU_PRESENTMODE_IMMEDIATE - else if (std.ascii.eqlIgnoreCase(raw, "mailbox")) - c.SDL_GPU_PRESENTMODE_MAILBOX - else if (std.ascii.eqlIgnoreCase(raw, "vsync")) - c.SDL_GPU_PRESENTMODE_VSYNC - else - null; - if (want) |mode| if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; - } - for ([_]c.SDL_GPUPresentMode{ c.SDL_GPU_PRESENTMODE_IMMEDIATE, c.SDL_GPU_PRESENTMODE_MAILBOX, c.SDL_GPU_PRESENTMODE_VSYNC }) |mode| { - if (c.SDL_WindowSupportsGPUPresentMode(device, window, mode)) break :blk mode; - } - break :blk c.SDL_GPU_PRESENTMODE_VSYNC; - }; - if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); - _ = c.SDL_SetGPUSwapchainParameters(device, window, c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, present_mode); - const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, window); - - // ---- font + cell metrics ---- - const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { - log.err("ui_font_new failed", .{}); - return error.FontInit; - }; - const px: f32 = 27.0; - const scale = c.ui_font_scale_for_height(font, px); - var cw: c_int = 10; - var chh: c_int = 20; - var asc: c_int = 16; - c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); - const cell_w: u32 = @intCast(@max(cw, 1)); - const cell_h: u32 = @intCast(@max(chh, 1)); - if (test_mode) { - const cols = envU16(env, "PARDES_TEST_COLS") orelse 80; - const rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; - _ = c.SDL_SetWindowSize(window, @intCast(cols * cell_w), @intCast(rows * cell_h)); - _ = c.SDL_SyncWindow(window); - } - - // ---- glyph atlas (R8) + pipelines ---- - var tex_info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - tex_info.type = c.SDL_GPU_TEXTURETYPE_2D; - tex_info.format = c.SDL_GPU_TEXTUREFORMAT_R8_UNORM; - tex_info.usage = c.SDL_GPU_TEXTUREUSAGE_SAMPLER; - tex_info.width = atlas_w; - tex_info.height = atlas_h; - tex_info.layer_count_or_depth = 1; - tex_info.num_levels = 1; - tex_info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - const atlas_tex = c.SDL_CreateGPUTexture(device, &tex_info) orelse return error.GpuCreate; - - var samp_info = std.mem.zeroes(c.SDL_GPUSamplerCreateInfo); - samp_info.min_filter = c.SDL_GPU_FILTER_LINEAR; - samp_info.mag_filter = c.SDL_GPU_FILTER_LINEAR; - samp_info.mipmap_mode = c.SDL_GPU_FILTER_LINEAR; - samp_info.address_mode_u = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - samp_info.address_mode_v = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - samp_info.address_mode_w = c.SDL_GPU_SAMPLERADDRESSMODE_CLAMP_TO_EDGE; - const atlas_sampler = c.SDL_CreateGPUSampler(device, &samp_info) orelse return error.GpuCreate; - - var ax_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = atlas_w * atlas_h, .props = 0 }; - const atlas_xfer = c.SDL_CreateGPUTransferBuffer(device, &ax_info) orelse return error.GpuCreate; - - const pipeline = try makePipeline(device, swapchain_format); - const overlay_pipeline = try makeOverlayPipeline(device, swapchain_format); - const crt_pipeline = try makeCrtPipeline(device, swapchain_format); - - const overlay_buffer_size: u32 = @intCast(max_overlay_vertices * @sizeOf(OverlayVertex)); - var ovb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = overlay_buffer_size, .props = 0 }; - const overlay_vbuf = c.SDL_CreateGPUBuffer(device, &ovb_info) orelse return error.GpuCreate; - var ovx_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = overlay_buffer_size, .props = 0 }; - const overlay_vxfer = c.SDL_CreateGPUTransferBuffer(device, &ovx_info) orelse return error.GpuCreate; - - const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); - defer gpa.free(atlas_stage); - @memset(atlas_stage, 0); - const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); - defer gpa.free(overlay_vertices); - - var g: Gui = .{ - .window = window, - .device = device, - .swapchain_format = swapchain_format, - .pipeline = pipeline, - .overlay_pipeline = overlay_pipeline, - .crt_pipeline = crt_pipeline, - .atlas_tex = atlas_tex, - .atlas_sampler = atlas_sampler, - .atlas_xfer = atlas_xfer, - .overlay_vbuf = overlay_vbuf, - .overlay_vxfer = overlay_vxfer, - .overlay_vertices = overlay_vertices, - .font = font, - .scale = scale, - .cell_w = cell_w, - .cell_h = cell_h, - .ascent = asc, - .atlas_stage = atlas_stage, - .glyphs = std.AutoHashMap(u32, Slot).init(gpa), - .capture = test_mode, - .capture_dir = capture_dir orelse "", - }; - defer g.glyphs.deinit(); - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), asc); - g.pen_x = cell_w; - - // ---- the core ---- - pardes.image.start(io, gpa); // stb_image allocator for image panes - defer pardes.image.stop(); - // Live sessions initialize at the default 80x24 grid; the real window size - // arrives as a resize EVENT on the first loop pass. The core defers the - // shell greeting until after the first resize (so `ls` wraps to the real - // pane width) — pre-sizing at init would mean no resize ever fires and the - // greeting never runs (panes sat blank until the first interaction). - // Dump loads pre-size instead: replayed panes never greet, and sizing at - // init avoids reflowing their replayed content twice. - var opts = opts_in; - var pw: c_int = 0; - var ph: c_int = 0; - if (opts.load_path != null) { - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - } - var core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").readFile(gpa, lp); - defer gpa.free(bytes); - break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); - } else try pardes.Pardes.init(gpa, opts); - defer core.deinit(); - // core.kitty_ok stays false: no kitty graphics in SDL, petscii covers images. - // ponytail: Surface.images pixel attachments could blit as textures later. - - // shells emit OSC 133 prompt marks via this rc (prompt hiding, click-move) - writeFile(pardes.bash_rc); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - - var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see drainEffects .spawn) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - slot.* = null; - }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = true }; - defer queue.close(); - - // initial spawns BEFORE any worker thread exists: forkpty from a - // multithreaded process can wedge the child before exec (see tty.zig). - drainEffects(core, &ptys, &gens, gpa, &queue, &g, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - - _ = c.SDL_StartTextInput(window); - - var feed: StdinFeed = .{}; - if (test_mode) setStdinRaw() catch {}; - - var frame_arena: std.heap.ArenaAllocator = .init(gpa); - defer frame_arena.deinit(); - - while (!core.quit) { - // 1. SDL input: block briefly for the first event, then drain the rest - var sev = std.mem.zeroes(c.SDL_Event); - if (c.SDL_WaitEventTimeout(&sev, 16)) { - dispatch(&g, core, &sev); - while (c.SDL_PollEvent(&sev)) dispatch(&g, core, &sev); - } - // test mode: input comes from stdin escape sequences instead - if (test_mode) { - const r = feed.pump(gpa, core, &g) catch break; - if (r.eof) break; - } - // 2. pty output from the reader threads - var msgs = queue.take(); - for (msgs.items) |m| switch (m) { - .output => |o| { - if (gens[o.pane] == o.gen) - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); - gpa.free(o.bytes); - }, - .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current - if (gens[e.pane] == e.gen) { - ptys[e.pane] = null; - core.update(.{ .eof = .{ .pane = e.pane } }); - } - }, - .lsp => |l| { - core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); - }, - }; - msgs.deinit(gpa); - // 3. steamdeck: poll gamepad axes into virtual cursor / wheel events - pollGamepad(&g, core); - // 4. effects - drainEffects(core, &ptys, &gens, gpa, &queue, &g, true); - if (core.quit) break; - // Restore builtin: swap in a core rebuilt from the dump; kill the live - // shells (their detached readers wake on child death; gens bumped so - // the stale eofs close the old fds without touching the replay panes) - if (core.takeRestore()) |rp| blk: { - const bytes = look.readFile(gpa, rp) catch break :blk; - defer gpa.free(bytes); - var o = core.opts; - o.cols = core.screen_w; - o.rows = core.screen_h; // pre-size: dump panes never greet - const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk; - for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.kill(pt.pid, libc.SIG.KILL); - slot.* = null; - }; - for (&gens) |*g2| g2.* +%= 1; - core.deinit(); - core = nc; - } - // 5. live cwd for tags/look: cheap /proc readlink per pane, per frame - pollCwds(core, &ptys); - // 6. the grid follows the window (covers WINDOW_RESIZED and test resizes) - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 7. render - _ = frame_arena.reset(.retain_capacity); - const surface = try core.render(frame_arena.allocator()); - renderFrame(&g, gpa, surface, core.crt_on, core.show_debug) catch |err| log.err("render: {t}", .{err}); - } -} - -// ===================================================================== -// web entry: set everything up, register a rAF frame callback, return. -// EXIT_RUNTIME=0 keeps the wasm runtime (and these statics) alive after. -// ===================================================================== - -// std.process.Init provides no gpa on emscripten — one 512 MiB fixed buffer -// is the whole heap, never freed (the shell lives for the page). -var web_heap: [if (is_emscripten) 512 * 1024 * 1024 else 0]u8 align(16) = undefined; -var web_fba: std.heap.FixedBufferAllocator = undefined; - -const WebState = struct { - g: Gui, - core: *pardes.Pardes, - gpa: std.mem.Allocator, - frame_arena: std.heap.ArenaAllocator, -}; -var live_web_state: ?*WebState = null; - -// Browser snapshot tests drive real DOM touch input, then read the exact same -// rendered Surface that WebGL consumed. These tiny exports keep the test -// contract textual (the existing .snap convention) without adding test state -// or browser concepts to the core. -comptime { - if (is_emscripten) { - @export(&webTestCols, .{ .name = "pardes_web_test_cols" }); - @export(&webTestRows, .{ .name = "pardes_web_test_rows" }); - @export(&webTestCursorX, .{ .name = "pardes_web_test_cursor_x" }); - @export(&webTestCursorY, .{ .name = "pardes_web_test_cursor_y" }); - @export(&webTestCell, .{ .name = "pardes_web_test_cell" }); - @export(&webTestCellFg, .{ .name = "pardes_web_test_cell_fg" }); - @export(&webTestCellBg, .{ .name = "pardes_web_test_cell_bg" }); - @export(&webTestCellAttrs, .{ .name = "pardes_web_test_cell_attrs" }); - @export(&webTestCellX, .{ .name = "pardes_web_test_cell_x" }); - @export(&webTestCellY, .{ .name = "pardes_web_test_cell_y" }); - } -} - -fn webTestCols() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.cols; -} - -fn webTestRows() callconv(.c) c_uint { - const st = live_web_state orelse return 0; - return st.core.surface.rows; -} - -fn webTestCursorX() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.x else -1; -} - -fn webTestCursorY() callconv(.c) c_int { - const st = live_web_state orelse return -1; - return if (st.core.surface.cursor) |cursor| cursor.y else -1; -} - -fn webTestCell(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return ' '; - const glyph = cell.grapheme(); - if (glyph.len == 0) return ' '; - return std.unicode.utf8Decode(glyph) catch 0xfffd; -} - -// RGB occupies 0x000000-0xffffff; the high byte distinguishes the two other -// Color tags. The browser .snap harness renders these into d/pN/#rrggbb runs. -fn webTestCellFg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.fg); -} - -fn webTestCellBg(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0x01000000; - return webTestColor(cell.style.bg); -} - -fn webTestColor(color: pardes.Color) c_uint { - return switch (color) { - .default => 0x01000000, - .index => |idx| 0x02000000 | @as(c_uint, idx), - .rgb => |rgb| (@as(c_uint, rgb[0]) << 16) | (@as(c_uint, rgb[1]) << 8) | rgb[2], - }; -} - -fn webTestCellAttrs(col: c_uint, row: c_uint) callconv(.c) c_uint { - const cell = webTestCellAt(col, row) orelse return 0; - const style = cell.style; - var attrs: c_uint = 0; - if (style.bold) attrs |= 1 << 0; - if (style.dim) attrs |= 1 << 1; - if (style.italic) attrs |= 1 << 2; - if (style.blink) attrs |= 1 << 3; - if (style.reverse) attrs |= 1 << 4; - if (style.invisible) attrs |= 1 << 5; - if (style.strikethrough) attrs |= 1 << 6; - attrs |= @as(c_uint, @intFromEnum(style.ul)) << 8; - return attrs; -} - -fn webTestCellAt(col: c_uint, row: c_uint) ?*const pardes.Cell { - const st = live_web_state orelse return null; - const surface = &st.core.surface; - if (col >= surface.cols or row >= surface.rows) return null; - return surface.at(@intCast(col), @intCast(row)); -} - -fn webTestCellX(col: f32) callconv(.c) f32 { - return webTestCellCenter(col, true); -} - -fn webTestCellY(row: f32) callconv(.c) f32 { - return webTestCellCenter(row, false); -} - -fn webTestCellCenter(cell: f32, horizontal: bool) f32 { - const st = live_web_state orelse return 0; - var pixel_w: c_int = 0; - var pixel_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(st.g.window, &pixel_w, &pixel_h); - const pw: u32 = @intCast(@max(pixel_w, 1)); - const ph: u32 = @intCast(@max(pixel_h, 1)); - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS) return 0; - const layout = cellLayout(&st.g, st.core.surface.cols, st.core.surface.rows, pw, ph); - if (horizontal) { - const backing = layout.x_off + (cell + 0.5) * layout.w; - return backing * @as(f32, @floatCast(css_w)) / @as(f32, @floatFromInt(pw)); - } - const backing = layout.y_off + (cell + 0.5) * layout.h; - return backing * @as(f32, @floatCast(css_h)) / @as(f32, @floatFromInt(ph)); -} - -fn runWeb(opts_in: pardes.Options) !void { - web_fba = .init(&web_heap); - const gpa = web_fba.allocator(); - - // Our finger machine deliberately maps touch to scroll/LOOK. Suppress - // SDL's default synthetic left mouse event for the same browser touch. - _ = c.SDL_SetHint(c.SDL_HINT_TOUCH_MOUSE_EVENTS, "0"); - if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { // no gamepad in the browser - log.err("SDL_Init: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - const window = c.SDL_CreateWindow("pardes", 1120, 720, c.SDL_WINDOW_RESIZABLE | c.SDL_WINDOW_OPENGL) orelse { - log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); - _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); - const gl_context = c.SDL_GL_CreateContext(window) orelse { - log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - }; - if (!c.SDL_GL_MakeCurrent(window, gl_context)) { - log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); - return error.SdlInit; - } - _ = c.SDL_GL_SetSwapInterval(1); - - // ---- font + cell metrics, rastered at web_render_scale ---- - const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { - log.err("ui_font_new failed", .{}); - return error.FontInit; - }; - const px: f32 = 18.0; - const base_scale = c.ui_font_scale_for_height(font, px); - var cw: c_int = 10; - var chh: c_int = 20; - var asc: c_int = 16; - c.ui_font_cell_metrics(font, base_scale, &cw, &chh, &asc); - const scale = c.ui_font_scale_for_height(font, px * web_render_scale); - const cell_w: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(cw, 1))) * web_render_scale)); - const cell_h: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(chh, 1))) * web_render_scale)); - const ascent: i32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(@max(asc, 1))) * web_render_scale)); - - // ---- WebGL2: runtime-compiled ES shaders + atlas texture + vbos ---- - const program = try makeGlProgramFrom(vert_glsl_es, frag_glsl_es); - const overlay_program = try makeGlProgramFrom(overlay_vert_glsl_es, overlay_frag_glsl_es); - var atlas_tex: c_uint = 0; - c.glGenTextures(1, &atlas_tex); - c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); - c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); - var vbo: c_uint = 0; - c.glGenBuffers(1, &vbo); - var overlay_vbo: c_uint = 0; - c.glGenBuffers(1, &overlay_vbo); - - const atlas_stage = try gpa.alloc(u8, atlas_w * atlas_h); - @memset(atlas_stage, 0); - const overlay_vertices = try gpa.alloc(OverlayVertex, max_overlay_vertices); - - const st = try gpa.create(WebState); - st.* = .{ - .g = .{ - .window = window, - .device = undefined, // web renders through GL, never the GPU device - .swapchain_format = undefined, - .pipeline = undefined, - .overlay_pipeline = undefined, - .crt_pipeline = undefined, - .atlas_tex = undefined, - .atlas_sampler = undefined, - .atlas_xfer = undefined, - .overlay_vbuf = undefined, - .overlay_vxfer = undefined, - .overlay_vertices = overlay_vertices, - .gl_program = program, - .gl_overlay_program = overlay_program, - .gl_atlas_tex = atlas_tex, - .gl_vbo = vbo, - .gl_overlay_vbo = overlay_vbo, - .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), - .font = font, - .scale = scale, - .cell_w = cell_w, - .cell_h = cell_h, - .ascent = ascent, - .atlas_stage = atlas_stage, - .glyphs = std.AutoHashMap(u32, Slot).init(gpa), - }, - .core = undefined, - .gpa = gpa, - .frame_arena = .init(gpa), - }; - const g = &st.g; - // slot (0,0) is the space glyph (blank cells sample alpha=0 → bg only) - _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(cell_w), @intCast(cell_h), ascent); - g.pen_x = cell_w; - - // ---- the core: state replays from the embedded dump, no ptys ---- - pardes.image.start(std.Io.failing, gpa); // decode is synchronous; no io on wasm - syncCanvasSize(g); // size the backing canvas before deriving the grid - var opts = opts_in; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(window, &pw, &ph); - opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w))); - opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h))); - st.core = try pardes.Pardes.initFromDump(gpa, opts, embedded_dump); - live_web_state = st; - - _ = c.SDL_StartTextInput(window); - std.os.emscripten.emscripten_set_main_loop_arg(webFrame, st, 0, 0); -} - -fn webFrame(arg: ?*anyopaque) callconv(.c) void { - const st: *WebState = @ptrCast(@alignCast(arg.?)); - const g = &st.g; - const core = st.core; - // 1. input - var sev = std.mem.zeroes(c.SDL_Event); - while (c.SDL_PollEvent(&sev)) dispatch(g, core, &sev); - // 2. effects - drainEffectsWeb(core, st.gpa, g); - if (core.quit) return std.os.emscripten.emscripten_cancel_main_loop(); - // 3. the backing canvas follows the page, the grid follows the canvas - syncCanvasSize(g); - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), g.cell_w))); - const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), g.cell_h))); - if (cols != core.screen_w or rows != core.screen_h) - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - // 4. render - _ = st.frame_arena.reset(.retain_capacity); - const surface = core.render(st.frame_arena.allocator()) catch return; - renderFrameGl(g, st.gpa, surface, core.show_debug) catch return; -} - -/// the canvas follows the page (CSS); the SDL window follows the canvas, -/// backed at web_render_scale (postWinsize in the prototype). -fn syncCanvasSize(g: *Gui) void { - var css_w: f64 = 0; - var css_h: f64 = 0; - if (emscripten_get_element_css_size("#canvas", &css_w, &css_h) != EMSCRIPTEN_RESULT_SUCCESS or css_w <= 0 or css_h <= 0) return; - const want_w: c_int = @intFromFloat(@round(css_w * web_render_scale)); - const want_h: c_int = @intFromFloat(@round(css_h * web_render_scale)); - var cur_w: c_int = 0; - var cur_h: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &cur_w, &cur_h); - if (cur_w != want_w or cur_h != want_h) { - _ = c.SDL_SetWindowSize(g.window, want_w, want_h); - _ = c.SDL_SyncWindow(g.window); - } -} - -/// PARDES_TEST_GRID=1: headless. No SDL at all — stdin escape sequences in, -/// the rendered Surface out as text frames (same framing as the prototype). -fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void { - const io = init.io; - const gpa = init.gpa; - const env = init.environ_map; - - pardes.image.start(io, gpa); - defer pardes.image.stop(); - - const grid_cols = envU16(env, "PARDES_TEST_COLS") orelse 80; - const grid_rows = envU16(env, "PARDES_TEST_ROWS") orelse 24; - var opts = opts_in; - if (opts.load_path != null) { - opts.cols = grid_cols; - opts.rows = grid_rows; - } - const core = if (opts.load_path) |lp| blk: { - const bytes = try @import("look.zig").readFile(gpa, lp); - defer gpa.free(bytes); - break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes); - } else try pardes.Pardes.init(gpa, opts); - defer core.deinit(); - // the requested grid arrives as a resize EVENT (not init opts) so the core - // counts a resize and releases the shell greeting on first output - if (opts.load_path == null) - core.update(.{ .resize = .{ .cols = grid_cols, .rows = grid_rows } }); - - writeFile(pardes.bash_rc); - // macos: apple's bash 3.2 prints the zsh-deprecation banner into every - // pane unless this is in the env BEFORE bash starts (the rc is too late) - if (comptime builtin.os.tag.isDarwin()) _ = setenv("BASH_SILENCE_DEPRECATION_WARNING", "1", 1); - - var ptys: [pardes.MAX_PANES]?Pty = @splat(null); - // per-slot spawn generation: drops a dead shell's late output/eof when its - // pane id has been respawned (see drainEffects .spawn) - var gens: [pardes.MAX_PANES]u32 = @splat(0); - defer for (&ptys) |*slot| if (slot.*) |pt| { - _ = libc.close(pt.fd); - slot.* = null; - }; - var queue: Queue = .{ .gpa = gpa, .sdl_wake = false }; - defer queue.close(); - drainEffects(core, &ptys, &gens, gpa, &queue, null, false); - for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue); - - setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort - - var frame_arena: std.heap.ArenaAllocator = .init(gpa); - defer frame_arena.deinit(); - var feed: StdinFeed = .{}; - - // first frame before touching stdin, so `printf '' | pardes` still shows one - { - const surface = try core.render(frame_arena.allocator()); - try dumpGrid(gpa, surface); - } - while (!core.quit) { - const r = try feed.pump(gpa, core, null); - if (r.eof) break; - var n_events: usize = r.n_events; - var msgs = queue.take(); - for (msgs.items) |m| switch (m) { - .output => |o| { - if (gens[o.pane] == o.gen) - core.update(.{ .output = .{ .pane = o.pane, .bytes = o.bytes } }); - gpa.free(o.bytes); - n_events += 1; - }, - .eof => |e| { - _ = libc.close(e.fd); // the dead reader's master — stale or current - if (gens[e.pane] == e.gen) { - ptys[e.pane] = null; - core.update(.{ .eof = .{ .pane = e.pane } }); - } - n_events += 1; - }, - .lsp => |l| { - core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } }); - gpa.free(l.rows); - n_events += 1; - }, - }; - msgs.deinit(gpa); - drainEffects(core, &ptys, &gens, gpa, &queue, null, true); - pollCwds(core, &ptys); - if (n_events == 0) continue; // idle tick: nothing changed, no frame - _ = frame_arena.reset(.retain_capacity); - const surface = try core.render(frame_arena.allocator()); - try dumpGrid(gpa, surface); - } -} - -// ===================================================================== -// test-mode stdin: terminal escape sequences (vaxis.Parser) → core events, -// plus the private synthetic finger OSC: -// ESC ] 777;finger;<type>;<id>;<x>;<y>;<pressure> BEL (normalized 0..1) -// ===================================================================== - -const StdinFeed = struct { - parser: vaxis.Parser = .{}, - cache: vaxis.GraphemeCache = .{}, - buf: [1024]u8 = undefined, - fill: usize = 0, - last_cols: u16 = 0, - last_rows: u16 = 0, - - const Result = struct { eof: bool = false, n_events: usize = 0 }; - - /// Poll stdin briefly and translate what arrived. `g` is null in grid mode. - fn pump(f: *StdinFeed, gpa: std.mem.Allocator, core: *pardes.Pardes, g: ?*Gui) !Result { - var out: Result = .{}; - // a pty stdin also carries the winsize; poll it in place of SIGWINCH - var ws: posix.winsize = std.mem.zeroes(posix.winsize); - if (posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)) == 0 and - ws.col > 0 and ws.row > 0 and ws.col <= 1000 and ws.row <= 1000 and - (ws.col != f.last_cols or ws.row != f.last_rows)) - { - f.last_cols = ws.col; - f.last_rows = ws.row; - f.applyResize(core, g, ws.col, ws.row); - out.n_events += 1; - } - - var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; - _ = posix.poll(&fds, 16) catch return out; - if ((fds[0].revents & posix.POLL.IN) == 0) { - if ((fds[0].revents & (posix.POLL.HUP | posix.POLL.ERR)) != 0) out.eof = true; - return out; - } - const rn = libc.read(0, f.buf[f.fill..].ptr, f.buf.len - f.fill); - if (rn < 0) return out; - if (rn == 0) { - out.eof = true; - return out; - } - const len = f.fill + @as(usize, @intCast(rn)); - f.fill = 0; - - var seq_start: usize = 0; - while (seq_start < len) { - // private finger OSC first (vaxis would swallow it as unknown OSC) - const prefix = "\x1b]777;finger;"; - if (std.mem.startsWith(u8, f.buf[seq_start..len], prefix)) { - const body = f.buf[seq_start + prefix.len .. len]; - const end = std.mem.indexOfScalar(u8, body, 0x07) orelse { - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - }; - if (parseFinger(body[0..end])) |finger_in| { - var finger = finger_in; - if (g) |gp| { - var wpw: c_int = 0; - var wph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(gp.window, &wpw, &wph); - const win_w: f32 = @floatFromInt(@max(wpw, 1)); - const win_h: f32 = @floatFromInt(@max(wph, 1)); - if (core.crt_on) { - // touch points are physical: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); - finger.x = m.x / win_w; - finger.y = m.y / win_h; - } - handleFinger(&gp.touch, core, finger, win_w, win_h, @floatFromInt(gp.cell_w), @floatFromInt(gp.cell_h)); - } else { - handleFinger(&grid_touch, core, finger, @floatFromInt(core.screen_w), @floatFromInt(core.screen_h), 1, 1); - } - out.n_events += 1; - } - seq_start += prefix.len + end + 1; - continue; - } - if (len - seq_start < prefix.len and std.mem.startsWith(u8, prefix, f.buf[seq_start..len])) { - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - } - - const result = f.parser.parse(f.buf[seq_start..len], gpa) catch break; - if (result.n == 0) { // partial sequence: shift to front, read more - std.mem.copyForwards(u8, f.buf[0 .. len - seq_start], f.buf[seq_start..len]); - f.fill = len - seq_start; - break; - } - seq_start += result.n; - const event = result.event orelse continue; - switch (event) { - .key_press => |key| { - var text: []const u8 = ""; - if (key.text) |t| text = f.cache.put(t); - core.update(.{ .key = .{ - .cp = mapKey(effCp(key)), - .text = text, - .ctrl = key.mods.ctrl, - .alt = key.mods.alt, - .shift = key.mods.shift, - } }); - out.n_events += 1; - }, - .mouse => |m| { - const button: ?pardes.Mouse.Button = switch (m.button) { - .left => .left, - .middle => .middle, - .right => .right, - .wheel_up => .wheel_up, - .wheel_down => .wheel_down, - .wheel_left => .wheel_left, - .wheel_right => .wheel_right, - .none => .none, - else => null, - }; - if (button) |b| { - core.update(.{ .mouse = .{ - .button = b, - .kind = switch (m.type) { - .press => .press, - .release => .release, - .motion => .motion, - .drag => .drag, - }, - .col = @intCast(@max(m.col, 0)), - .row = @intCast(@max(m.row, 0)), - .ctrl = m.mods.ctrl, - } }); - out.n_events += 1; - } - }, - .winsize => |vw| { - f.applyResize(core, g, @intCast(vw.cols), @intCast(vw.rows)); - out.n_events += 1; - }, - .paste => |text| { - core.update(.{ .paste = text }); - gpa.free(@constCast(text)); - out.n_events += 1; - }, - else => {}, - } - } - return out; - } - - fn applyResize(f: *StdinFeed, core: *pardes.Pardes, g: ?*Gui, cols: u16, rows: u16) void { - _ = f; - if (g) |gp| { - // capture mode: resize the window; the frame loop resizes the core - _ = c.SDL_SetWindowSize(gp.window, @intCast(cols * gp.cell_w), @intCast(rows * gp.cell_h)); - _ = c.SDL_SyncWindow(gp.window); - } else { - core.update(.{ .resize = .{ .cols = cols, .rows = rows } }); - } - } -}; - -var grid_touch: Touch = .{}; // grid test mode has no Gui to hang it off - -fn parseFinger(payload: []const u8) ?Finger { - var parts = std.mem.splitScalar(u8, payload, ';'); - const type_name = parts.next() orelse return null; - const id_s = parts.next() orelse return null; - const x_s = parts.next() orelse return null; - const y_s = parts.next() orelse return null; - const pressure_s = parts.next() orelse return null; - if (parts.next() != null) return null; - const kind: @FieldType(Finger, "kind") = if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_DOWN") or std.mem.eql(u8, type_name, "down")) - .down - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_MOTION") or std.mem.eql(u8, type_name, "motion")) - .motion - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_UP") or std.mem.eql(u8, type_name, "up")) - .up - else if (std.mem.eql(u8, type_name, "SDL_EVENT_FINGER_CANCELED") or std.mem.eql(u8, type_name, "cancel")) - .cancel - else - return null; - return .{ - .kind = kind, - .id = std.fmt.parseInt(u64, id_s, 10) catch return null, - .x = std.fmt.parseFloat(f32, x_s) catch return null, - .y = std.fmt.parseFloat(f32, y_s) catch return null, - .pressure = std.fmt.parseFloat(f32, pressure_s) catch return null, - }; -} - -fn setStdinRaw() !void { - var term = try posix.tcgetattr(0); - term.iflag.BRKINT = false; - term.iflag.ICRNL = false; - term.iflag.INPCK = false; - term.iflag.ISTRIP = false; - term.iflag.IXON = false; - term.oflag.OPOST = false; - term.lflag.ECHO = false; - term.lflag.ICANON = false; - term.lflag.IEXTEN = false; - term.lflag.ISIG = false; - term.cc[@intFromEnum(posix.V.MIN)] = 1; - term.cc[@intFromEnum(posix.V.TIME)] = 0; - try posix.tcsetattr(0, .NOW, term); -} - -// PARDES_TEST_GRID: one text frame per render, prototype-compatible framing. -fn dumpGrid(gpa: std.mem.Allocator, surface: *pardes.Surface) !void { - if (surface.cols == 0 or surface.rows == 0) return; - // build the whole frame in one buffer → one write → atomic snapshots - var buf: std.ArrayList(u8) = .empty; - defer buf.deinit(gpa); - const cur_x: u16 = if (surface.cursor) |cu| cu.x else 0; - const cur_y: u16 = if (surface.cursor) |cu| cu.y else 0; - var hdr: [128]u8 = undefined; - const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ - surface.rows, surface.cols, cur_x, cur_y, surface.cursor != null, - }) catch return; - try buf.appendSlice(gpa, hdr_str); - var y: u16 = 0; - while (y < surface.rows) : (y += 1) { - var x: u16 = 0; - while (x < surface.cols) : (x += 1) { - const g = surface.at(x, y).grapheme(); - if (g.len > 0) try buf.appendSlice(gpa, g) else try buf.append(gpa, ' '); - } - try buf.append(gpa, '\n'); - } - try buf.appendSlice(gpa, "---ENDFRAME---\n"); - writeFd(1, buf.items); -} - -// ===================================================================== -// SDL event dispatch -// ===================================================================== - -fn dispatch(g: *Gui, core: *pardes.Pardes, sev: *const c.SDL_Event) void { - switch (sev.type) { - c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => core.quit = true, - // window resizes are picked up by the per-frame grid check - c.SDL_EVENT_KEY_DOWN => { - g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; - g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - keyDown(core, sev.key.key, sev.key.mod); - }, - c.SDL_EVENT_KEY_UP => { - g.live_ctrl = (sev.key.mod & c.SDL_KMOD_CTRL) != 0; - g.live_alt = (sev.key.mod & c.SDL_KMOD_ALT) != 0; - }, - c.SDL_EVENT_TEXT_INPUT => { - const tptr = sev.text.text orelse return; - var tlen: usize = 0; - while (tptr[tlen] != 0) : (tlen += 1) {} - if (tlen == 0) return; - const text: []const u8 = tptr[0..tlen]; - const cplen = std.unicode.utf8ByteSequenceLength(text[0]) catch return; - if (cplen > text.len) return; - const cp = std.unicode.utf8Decode(text[0..cplen]) catch return; - core.update(.{ .key = .{ .cp = cp, .text = text[0..cplen], .ctrl = g.live_ctrl, .alt = g.live_alt } }); - }, - c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { - const b = sev.button; - g.mouse_x = b.x; - g.mouse_y = b.y; - const button: pardes.Mouse.Button = switch (b.button) { - 1 => .left, - 2 => .middle, - 3 => .right, - else => return, - }; - const mc = mouseCell(g, core, b.x, b.y); - core.update(.{ - .mouse = .{ - .button = button, - .kind = if (b.down) .press else .release, - .col = mc.col, - .row = mc.row, - // asked of SDL directly rather than read off g.live_ctrl: - // that one is bookkeeping from KEY events, and a ctrl-click - // with no key pressed since startup would miss it - .ctrl = (c.SDL_GetModState() & c.SDL_KMOD_CTRL) != 0, - }, - }); - }, - c.SDL_EVENT_MOUSE_MOTION => { - const m = sev.motion; - g.mouse_x = m.x; - g.mouse_y = m.y; - // pad cursor continues from wherever the pointer last was - g.pad_x = m.x; - g.pad_y = m.y; - // drag = motion with a button held (selection extension keys off it) - const held: ?pardes.Mouse.Button = if ((m.state & c.SDL_BUTTON_LMASK) != 0) - .left - else if ((m.state & c.SDL_BUTTON_MMASK) != 0) - .middle - else if ((m.state & c.SDL_BUTTON_RMASK) != 0) - .right - else - null; - const mc = mouseCell(g, core, m.x, m.y); - core.update(.{ .mouse = .{ - .button = held orelse .none, - .kind = if (held != null) .drag else .motion, - .col = mc.col, - .row = mc.row, - } }); - }, - c.SDL_EVENT_MOUSE_WHEEL => { - const w = sev.wheel; - if (w.y == 0 and w.x == 0) return; - g.mouse_x = w.mouse_x; - g.mouse_y = w.mouse_y; - const mc = mouseCell(g, core, w.mouse_x, w.mouse_y); - if (w.y != 0) core.update(.{ .mouse = .{ - .button = if (w.y > 0) .wheel_up else .wheel_down, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - if (w.x != 0) core.update(.{ .mouse = .{ - .button = if (w.x > 0) .wheel_right else .wheel_left, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - }, - c.SDL_EVENT_FINGER_DOWN, c.SDL_EVENT_FINGER_MOTION, c.SDL_EVENT_FINGER_UP, c.SDL_EVENT_FINGER_CANCELED => { - const f = sev.tfinger; - var finger: Finger = .{ - .kind = switch (sev.type) { - c.SDL_EVENT_FINGER_DOWN => .down, - c.SDL_EVENT_FINGER_MOTION => .motion, - c.SDL_EVENT_FINGER_UP => .up, - else => .cancel, - }, - .id = @intCast(f.fingerID), - .x = f.x, - .y = f.y, - .pressure = f.pressure, - }; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var win_w: f32 = @floatFromInt(@max(pw, 1)); - var win_h: f32 = @floatFromInt(@max(ph, 1)); - var fcw: f32 = @floatFromInt(g.cell_w); - var fch: f32 = @floatFromInt(g.cell_h); - if (!is_emscripten and core.crt_on) { - // touch points are physical too: same CRT mapping as the mouse - const m = crt.map(finger.x * win_w, finger.y * win_h, win_w, win_h); - finger.x = m.x / win_w; - finger.y = m.y / win_h; - } - if (is_emscripten) { - // remap normalized coords into the letterboxed grid so the - // shared touch machine's cell math stays exact - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - const grid_w = @max(1.0, @as(f32, @floatFromInt(core.screen_w)) * layout.w); - const grid_h = @max(1.0, @as(f32, @floatFromInt(core.screen_h)) * layout.h); - finger.x = std.math.clamp((finger.x * win_w - layout.x_off) / grid_w, 0.0, 1.0); - finger.y = std.math.clamp((finger.y * win_h - layout.y_off) / grid_h, 0.0, 1.0); - win_w = grid_w; - win_h = grid_h; - fcw = layout.w; - fch = layout.h; - } - handleFinger(&g.touch, core, finger, win_w, win_h, fcw, fch); - }, - c.SDL_EVENT_PINCH_BEGIN, c.SDL_EVENT_PINCH_UPDATE => core.update(.{ .pinch = sev.pinch.scale }), - // ---- steamdeck: first gamepad drives a virtual mouse (buttons here, - // axes polled per frame in pollGamepad) ---- - c.SDL_EVENT_GAMEPAD_ADDED => { - if (g.gamepad == null) g.gamepad = c.SDL_OpenGamepad(sev.gdevice.which); - }, - c.SDL_EVENT_GAMEPAD_REMOVED => { - if (g.gamepad) |pad| { - c.SDL_CloseGamepad(pad); - g.gamepad = null; - } - }, - // deck.zig maps buttons/triggers/trackpads to pointer, clicks, - // wheel, keys and rumble; this arm just applies its actions - c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, - c.SDL_EVENT_GAMEPAD_BUTTON_UP, - c.SDL_EVENT_GAMEPAD_AXIS_MOTION, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_MOTION, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP, - => { - const in: deck.Input = switch (sev.type) { - c.SDL_EVENT_GAMEPAD_BUTTON_DOWN, c.SDL_EVENT_GAMEPAD_BUTTON_UP => .{ .button = .{ - .idx = sev.gbutton.button, - .down = sev.gbutton.down, - } }, - c.SDL_EVENT_GAMEPAD_AXIS_MOTION => .{ .axis = .{ - .idx = sev.gaxis.axis, - .value = sev.gaxis.value, - } }, - else => .{ .touch = .{ - .pad = @intCast(std.math.clamp(sev.gtouchpad.touchpad, 0, 255)), - .phase = switch (sev.type) { - c.SDL_EVENT_GAMEPAD_TOUCHPAD_DOWN => .down, - c.SDL_EVENT_GAMEPAD_TOUCHPAD_UP => .up, - else => .motion, - }, - .x = sev.gtouchpad.x, - .y = sev.gtouchpad.y, - .pressure = sev.gtouchpad.pressure, - } }, - }; - var acts: [3]deck.Action = undefined; - for (acts[0..g.deck.feed(in, &acts)]) |act| switch (act) { - .move => |mv| { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - // pad_x/pad_y stay PHYSICAL; mouseCell applies the CRT - // mapping once at cell conversion, never into the pad - const old = mouseCell(g, core, g.pad_x, g.pad_y); - g.pad_x = std.math.clamp(g.pad_x + mv.dx, 0, @as(f32, @floatFromInt(@max(pw, 1))) - 1); - g.pad_y = std.math.clamp(g.pad_y + mv.dy, 0, @as(f32, @floatFromInt(@max(ph, 1))) - 1); - // the SDL cursor (plan9 arrow) rides along, so the pad - // cursor and a hardware mouse are one visible pointer - c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col == old.col and mc.row == old.row) continue; - // moving with a click held drags, so selections stretch - // (a firm right-pad press drags-selects like a laptop pad) - const held: ?pardes.Mouse.Button = if (g.deck.r2_held or g.deck.rpad_click) - .left - else if (g.deck.l2_held) - .middle - else if (g.deck.look_held) - .right - else - null; - core.update(.{ .mouse = .{ - .button = held orelse .none, - .kind = if (held != null) .drag else .motion, - .col = mc.col, - .row = mc.row, - } }); - }, - .click => |ck| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - core.update(.{ .mouse = .{ - .button = switch (ck.which) { - .select => .left, - .execute => .middle, - .look => .right, - }, - .kind = if (ck.down) .press else .release, - .col = mc.col, - .row = mc.row, - } }); - }, - .wheel => |ticks| { - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - var left = ticks; - while (left != 0) { - left += if (ticks > 0) -1 else 1; - core.update(.{ .mouse = .{ - .button = if (ticks > 0) .wheel_down else .wheel_up, - .kind = .press, - .col = mc.col, - .row = mc.row, - } }); - } - }, - .key => |k| core.update(.{ - .key = switch (k) { - .n => .{ .cp = 'n', .text = "n" }, - .cap_n => .{ .cp = 'N', .text = "N" }, - .enter => .{ .cp = pardes.Key.enter }, - .tab => .{ .cp = pardes.Key.tab }, - // back paddle: Ctrl-<configured toggle key> flips tty mode - .tty_toggle => .{ .cp = core.opts.tty_toggle, .ctrl = true }, - }, - }), - // a brief gentle ack for execute/look, not a buzz - .rumble => if (g.gamepad) |pad| { - _ = c.SDL_RumbleGamepad(pad, 0x4000, 0x4000, 80); - }, - }; - }, - else => {}, - } -} - -/// Special keys + ctrl/alt shortcuts. Plain printable keys arrive as -/// SDL_EVENT_TEXT_INPUT instead (so shift/layout map correctly). -fn keyDown(core: *pardes.Pardes, sym: c.SDL_Keycode, mod: c.SDL_Keymod) void { - const ctrl = (mod & c.SDL_KMOD_CTRL) != 0; - const alt = (mod & c.SDL_KMOD_ALT) != 0; - const shift = (mod & c.SDL_KMOD_SHIFT) != 0; - const gui_mod = (mod & c.SDL_KMOD_GUI) != 0; - const cp: u21 = switch (sym) { - c.SDLK_RETURN, c.SDLK_KP_ENTER => pardes.Key.enter, - c.SDLK_BACKSPACE => pardes.Key.backspace, - c.SDLK_TAB => pardes.Key.tab, - c.SDLK_ESCAPE => pardes.Key.escape, - c.SDLK_LEFT => pardes.Key.left, - c.SDLK_RIGHT => pardes.Key.right, - c.SDLK_UP => pardes.Key.up, - c.SDLK_DOWN => pardes.Key.down, - c.SDLK_HOME => pardes.Key.home, - c.SDLK_END => pardes.Key.end, - c.SDLK_PAGEUP => pardes.Key.page_up, - c.SDLK_PAGEDOWN => pardes.Key.page_down, - c.SDLK_DELETE => pardes.Key.delete, - else => blk: { - // letters / digits / punctuation only as a modifier shortcut; - // plain printable (incl. space) goes via TEXT_INPUT - if (!(ctrl or alt or gui_mod)) break :blk 0; - if (sym < 128 and sym >= ' ') break :blk @intCast(sym); - break :blk 0; - }, - }; - if (cp == 0) return; - core.update(.{ .key = .{ .cp = cp, .ctrl = ctrl, .alt = alt, .shift = shift } }); -} - -fn pixelCell(px: f32, cell: u32) u16 { - return pixelCellF(px, @floatFromInt(cell)); -} - -fn pixelCellF(px: f32, cell: f32) u16 { - const idx = @floor(@max(px, 0) / @max(cell, 1)); - return @intFromFloat(@min(idx, 10_000)); -} - -/// mouse coords → cell. Native windows are pixel-exact; web SDL reports CSS -/// points into a letterboxed canvas backed at web_render_scale. With the Crt -/// builtin on, the physical point first maps to the scene point the shader -/// displays there (crt.map) — this is the one physical→logical spot shared -/// by the real mouse and the deck's virtual cursor. -fn mouseCell(g: *const Gui, core: *const pardes.Pardes, x: f32, y: f32) struct { col: u16, row: u16 } { - if (!is_emscripten) { - var mx = x; - var my = y; - if (core.crt_on) { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const m = crt.map(mx, my, @floatFromInt(@max(pw, 1)), @floatFromInt(@max(ph, 1))); - mx = m.x; - my = m.y; - } - return .{ .col = pixelCell(mx, g.cell_w), .row = pixelCell(my, g.cell_h) }; - } - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - var ww: c_int = 0; - var wh: c_int = 0; - _ = c.SDL_GetWindowSize(g.window, &ww, &wh); - const px = x * @as(f32, @floatFromInt(@max(pw, 1))) / @as(f32, @floatFromInt(@max(ww, 1))); - const py = y * @as(f32, @floatFromInt(@max(ph, 1))) / @as(f32, @floatFromInt(@max(wh, 1))); - const layout = cellLayout(g, core.screen_w, core.screen_h, @intCast(@max(pw, 1)), @intCast(@max(ph, 1))); - return .{ - .col = pixelCellF(px - layout.x_off, layout.w), - .row = pixelCellF(py - layout.y_off, layout.h), - }; -} - -/// steamdeck support: poll the sticks each frame, mirroring the trackpads — -/// RIGHT stick moves the virtual cursor at ~cell granularity (emits -/// button-less motion so hover works), LEFT stick accumulates into wheel -/// ticks (both axes: vertical + horizontal) at the cursor position. -fn pollGamepad(g: *Gui, core: *pardes.Pardes) void { - const pad = g.gamepad orelse return; - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const win_w: f32 = @floatFromInt(@max(pw, 1)); - const win_h: f32 = @floatFromInt(@max(ph, 1)); - const deadzone: f32 = 8000; - // right stick = pointer - const ax: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTX)); - const ay: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_RIGHTY)); - const old = mouseCell(g, core, g.pad_x, g.pad_y); - // full tilt ≈ 0.4 cell-heights per frame: a gentle, aimable glide (the - // mouse-move sensitivity knob — raise for a faster pointer) - const speed: f32 = @as(f32, @floatFromInt(g.cell_h)) * 0.4; - if (@abs(ax) > deadzone) g.pad_x = std.math.clamp(g.pad_x + ax / 32767.0 * speed, 0, win_w - 1); - if (@abs(ay) > deadzone) g.pad_y = std.math.clamp(g.pad_y + ay / 32767.0 * speed, 0, win_h - 1); - // only on actual stick movement — an unconditional per-frame warp would - // pin the pointer and fight any hardware mouse - if (@abs(ax) > deadzone or @abs(ay) > deadzone) c.SDL_WarpMouseInWindow(g.window, g.pad_x, g.pad_y); - const mc = mouseCell(g, core, g.pad_x, g.pad_y); - if (mc.col != old.col or mc.row != old.row) - core.update(.{ .mouse = .{ .button = .none, .kind = .motion, .col = mc.col, .row = mc.row } }); - // left stick = scroll (both axes) - const lx: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTX)); - const ly: f32 = @floatFromInt(c.SDL_GetGamepadAxis(pad, c.SDL_GAMEPAD_AXIS_LEFTY)); - if (@abs(ly) > deadzone) g.pad_scroll += ly / 32767.0 * 0.15; - if (@abs(lx) > deadzone) g.pad_scroll_h += lx / 32767.0 * 0.15; - while (@abs(g.pad_scroll) >= 1.0) { - const button: pardes.Mouse.Button = if (g.pad_scroll < 0) .wheel_up else .wheel_down; - g.pad_scroll += if (g.pad_scroll < 0) 1.0 else -1.0; - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); - } - while (@abs(g.pad_scroll_h) >= 1.0) { - const button: pardes.Mouse.Button = if (g.pad_scroll_h < 0) .wheel_left else .wheel_right; - g.pad_scroll_h += if (g.pad_scroll_h < 0) 1.0 else -1.0; - core.update(.{ .mouse = .{ .button = button, .kind = .press, .col = mc.col, .row = mc.row } }); - } -} - -// ===================================================================== -// effects — identical duties to tty.zig's drainEffects, plus SDL clipboard -// ===================================================================== - -fn drainEffects( - core: *pardes.Pardes, - ptys: *[pardes.MAX_PANES]?Pty, - gens: *[pardes.MAX_PANES]u32, - gpa: std.mem.Allocator, - queue: *Queue, - g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops) - threads_ok: bool, -) void { - while (core.nextEffect()) |effect| switch (effect) { - .spawn => |sp| { - // the core reuses pane ids and there is no close effect: a deleted - // pane's shell lives in its slot until a respawn lands here. Kill - // it; its detached reader wakes on child death and the gen-guarded - // eof closes the old fd (not here — the reader still reads it). - if (ptys[sp.pane]) |old| { - _ = libc.kill(old.pid, libc.SIG.KILL); - ptys[sp.pane] = null; - } - gens[sp.pane] +%= 1; - const cwd = sp.cwd.slice(); - var cwd_buf: [256:0]u8 = undefined; - var cwd_z: ?[*:0]const u8 = null; - if (cwd.len > 0) { - @memcpy(cwd_buf[0..cwd.len], cwd); - cwd_buf[cwd.len] = 0; - cwd_z = @ptrCast(&cwd_buf); - } - const pt = forkShell(cwd_z, core.screen_h, core.screen_w); - ptys[sp.pane] = pt; - // report the pane's starting directory back to the core (tags) - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |wd| core.setCwd(sp.pane, wd); - if (threads_ok) spawnReader(gpa, pt, sp.pane, gens[sp.pane], queue); - }, - .write => |w| { - if (ptys[w.pane]) |pt| writeFd(pt.fd, w.bytes.slice()); - }, - .resize_pty => |rs| { - if (ptys[rs.pane]) |pt| { - const ws: posix.winsize = .{ .row = rs.rows, .col = rs.cols, .xpixel = 0, .ypixel = 0 }; - _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); - } - }, - .open_link => |url| look.openLink(url.slice()), // desktop browser - .save_file => |sf| { - const pane = core.panes[sf.pane] orelse continue; - const f = pane.file orelse continue; - var pathbuf: [4096:0]u8 = undefined; - if (f.path.len >= pathbuf.len) continue; - @memcpy(pathbuf[0..f.path.len], f.path); - pathbuf[f.path.len] = 0; - const fd = libc.open(pathbuf[0..f.path.len :0], .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) continue; - writeFd(fd, f.content); - _ = libc.close(fd); - }, - .write_dump => { - const out = core.dump_out orelse continue; - var pbuf: [1024:0]u8 = undefined; - const path = pardes.dump.outPath(&pbuf) orelse continue; - const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) continue; - writeFd(fd, out); - core.setLastDump(path); - _ = libc.close(fd); - }, - .set_clipboard => { - if (g == null) continue; - const y = core.yank orelse continue; - const z = gpa.dupeZ(u8, y) catch continue; - defer gpa.free(z); - _ = c.SDL_SetClipboardText(z.ptr); - }, - .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), - .quit => {}, - }; -} - -fn forkShell(cwd: ?[*:0]const u8, rows: u16, cols: u16) Pty { - var master: c_int = undefined; - const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; - const pid = forkpty(&master, null, null, &ws); - if (pid == 0) { - if (cwd) |cd| _ = chdir(cd); - const argv: [4:null]?[*:0]const u8 = .{ bash_path, "--rcfile", "/tmp/pardes-osc133.bash", null }; - _ = execv(bash_path, &argv); - _exit(127); - } - return .{ .fd = master, .pid = pid }; -} - -fn pollCwds(core: *pardes.Pardes, ptys: *[pardes.MAX_PANES]?Pty) void { - for (ptys, 0..) |slot, id| if (slot) |pt| { - var lbuf: [1024]u8 = undefined; - if (look.shellCwd(pt.pid, &lbuf)) |cwd| core.setCwd(id, cwd); - }; -} - -/// web: no ptys, no fs — panes replay from the embedded dump. Only the -/// clipboard and open_link effects have a browser meaning. -fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { - _ = g; - while (core.nextEffect()) |effect| switch (effect) { - .set_clipboard => { - const y = core.yank orelse continue; - const z = gpa.dupeZ(u8, y) catch continue; - defer gpa.free(z); - _ = c.SDL_SetClipboardText(z.ptr); - }, - // look on a URL → a new tab - .open_link => |url| openLinkWeb(gpa, url.slice()), - // nothing to spawn/write/resize/save/dump into — all no-ops - .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {}, - }; -} - -/// window.open in a tiny generated script; single quotes and backslashes are -/// %-escaped so the url can't break out of the JS string literal. -fn openLinkWeb(gpa: std.mem.Allocator, url: []const u8) void { - var safe: std.ArrayList(u8) = .empty; - defer safe.deinit(gpa); - for (url) |ch| switch (ch) { - '\'' => safe.appendSlice(gpa, "%27") catch return, - '\\' => safe.appendSlice(gpa, "%5C") catch return, - else => safe.append(gpa, ch) catch return, - }; - const script = std.fmt.allocPrintSentinel(gpa, "window.open('{s}','_blank')", .{safe.items}, 0) catch return; - defer gpa.free(script); - std.os.emscripten.emscripten_run_script(script.ptr); -} - -// ===================================================================== -// render: Surface → instanced quads → SDL GPU -// ===================================================================== - -fn renderFrame(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, crt_on: bool, debug_on: bool) !void { - const cmd = c.SDL_AcquireGPUCommandBuffer(g.device) orelse return; - var sw: u32 = 0; - var sh: u32 = 0; - var target: *c.SDL_GPUTexture = undefined; - if (g.capture) { - // capture: render offscreen (the window may never present), dump PPM - sw = @as(u32, surface.cols) * g.cell_w; - sh = @as(u32, surface.rows) * g.cell_h; - if (sw == 0 or sh == 0) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - } - try ensureCaptureTexture(g, sw, sh); - target = g.capture_tex.?; - } else { - var swap_tex: ?*c.SDL_GPUTexture = null; - if (!c.SDL_AcquireGPUSwapchainTexture(cmd, g.window, &swap_tex, &sw, &sh)) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - } - target = swap_tex orelse { // swapchain busy: skip the frame - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - }; - } - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - - // Crt on: the scene pass lands in an offscreen texture, then a second - // pass draws it through the CRT shader onto the real target - if (crt_on) try ensureSceneTexture(g, sw, sh); - const scene: *c.SDL_GPUTexture = if (crt_on) g.scene_tex.? else target; - - const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); - if (overlay_count != 0) uploadOverlayGpu(g, cmd, overlay_count); - - const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells != 0) try ensureVbuf(g, cells); - - var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); - color_target.texture = scene; - color_target.clear_color = .{ - .r = @as(f32, @floatFromInt(bg_default[0])) / 255.0, - .g = @as(f32, @floatFromInt(bg_default[1])) / 255.0, - .b = @as(f32, @floatFromInt(bg_default[2])) / 255.0, - .a = 1.0, - }; - color_target.load_op = c.SDL_GPU_LOADOP_CLEAR; - color_target.store_op = c.SDL_GPU_STOREOP_STORE; - - if (cells != 0 and g.vbuf != null) { - // ponytail: full re-upload every frame; the prototype's dirty-range - // diffing (cell_keys + coalesced ranges) is skipped for now. - const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(g.device, g.vxfer.?, false) orelse { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return; - }); - const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); - const layout = fixedCellLayout(g); - const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; - const cursor_bar = if (surface.cursor) |cu| cu.bar else false; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, instances, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); - } - } - c.SDL_UnmapGPUTransferBuffer(g.device, g.vxfer.?); - - // emitInstance may have rasterized new glyphs into the staging atlas; - // upload after vertex generation so this frame has what it references - if (g.atlas_dirty) uploadAtlas(g, cmd); - - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.vxfer.?, .offset = 0 }; - const dst = c.SDL_GPUBufferRegion{ .buffer = g.vbuf.?, .offset = 0, .size = cells * @sizeOf(CellInstance) }; - c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); - c.SDL_EndGPUCopyPass(copy); - - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(rp, g.pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.atlas_tex, .sampler = g.atlas_sampler }; - c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - const vbinding = c.SDL_GPUBufferBinding{ .buffer = g.vbuf.?, .offset = 0 }; - c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); - c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); - drawOverlayGpu(g, rp, overlay_count); - c.SDL_EndGPURenderPass(rp); - } else { - // no grid yet: just clear to the page bg - const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); - drawOverlayGpu(g, rp, overlay_count); - c.SDL_EndGPURenderPass(rp); - } - - if (crt_on) { - var crt_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); - crt_target.texture = target; - crt_target.load_op = c.SDL_GPU_LOADOP_DONT_CARE; - crt_target.store_op = c.SDL_GPU_STOREOP_STORE; - const rp = c.SDL_BeginGPURenderPass(cmd, &crt_target, 1, null); - c.SDL_BindGPUGraphicsPipeline(rp, g.crt_pipeline); - const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = g.scene_tex.?, .sampler = g.atlas_sampler }; - c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); - c.SDL_DrawGPUPrimitives(rp, 3, 1, 0, 0); - c.SDL_EndGPURenderPass(rp); - } - - if (g.capture) return captureFrame(g, gpa, cmd, target, sw, sh); - _ = c.SDL_SubmitGPUCommandBuffer(cmd); -} - -fn emitInstance( - g: *Gui, - instances: [*]CellInstance, - idx: u32, - col: u16, - row: u16, - layout: CellLayout, - win_w: f32, - win_h: f32, - cell: *const pardes.Cell, - is_cursor: bool, - cursor_bar: bool, -) void { - // resolve the cell style to concrete fg/bg, matching the prototype's - // cellKey: reverse swaps, invisible hides, dim darkens. The cursor cell - // renders as reverse (cancelling an already-reversed cell); an insert - // cursor keeps the cell colors and draws a bar glyph instead. - var fg = fg_default; - var bg = bg_default; - var reverse = is_cursor and !cursor_bar; - if (!cell.default) { - const st = cell.style; - fg = switch (st.fg) { - .default => fg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - bg = switch (st.bg) { - .default => bg_default, - .index => |i| palColor(i), - .rgb => |rgb| rgb, - }; - if (st.reverse) reverse = !reverse; - if (st.invisible) fg = bg; - if (st.dim) for (&fg) |*ch| { - ch.* = @intCast(@as(u16, ch.*) * 6 / 10); - }; - } - if (reverse) std.mem.swap([3]u8, &fg, &bg); - - // cell pixel rect (top-left origin) → NDC (y up); the layout letterboxes on web - const px0 = layout.x_off + @as(f32, @floatFromInt(col)) * layout.w; - const py0 = layout.y_off + @as(f32, @floatFromInt(row)) * layout.h; - const x0 = (px0 / win_w) * 2.0 - 1.0; - const x1 = ((px0 + layout.w) / win_w) * 2.0 - 1.0; - const y0 = 1.0 - (py0 / win_h) * 2.0; - const y1 = 1.0 - ((py0 + layout.h) / win_h) * 2.0; - - const grapheme = cell.grapheme(); - var cp: u32 = if (cell.default or grapheme.len == 0) ' ' else firstCp(grapheme); - // ponytail: the insert bar is the left-eighth block glyph over the cell - // (hides the char under it); a dedicated quad if that ever matters - if (is_cursor and cursor_bar) cp = 0x258F; - const slot = if (cp == ' ') g.space_slot else ensureGlyph(g, cp); - const aw_f: f32 = @floatFromInt(atlas_w); - const ah_f: f32 = @floatFromInt(atlas_h); - - instances[idx] = .{ - .x0 = x0, - .y0 = y0, - .x1 = x1, - .y1 = y1, - .u0 = @as(f32, @floatFromInt(slot.u)) / aw_f, - .v0 = @as(f32, @floatFromInt(slot.v)) / ah_f, - .u1 = @as(f32, @floatFromInt(slot.u + g.cell_w)) / aw_f, - .v1 = @as(f32, @floatFromInt(slot.v + g.cell_h)) / ah_f, - .fr = @as(f32, @floatFromInt(fg[0])) / 255.0, - .fg = @as(f32, @floatFromInt(fg[1])) / 255.0, - .fb = @as(f32, @floatFromInt(fg[2])) / 255.0, - .br = @as(f32, @floatFromInt(bg[0])) / 255.0, - .bg = @as(f32, @floatFromInt(bg[1])) / 255.0, - .bb = @as(f32, @floatFromInt(bg[2])) / 255.0, - }; -} - -fn palColor(idx: u8) [3]u8 { - const p = ghostty_vt.color.default[idx]; - return .{ p.r, p.g, p.b }; -} - -// first codepoint of a UTF-8 grapheme; space on failure/empty -fn firstCp(s: []const u8) u32 { - if (s.len == 0) return ' '; - const n = std.unicode.utf8ByteSequenceLength(s[0]) catch return ' '; - if (n > s.len) return ' '; - return std.unicode.utf8Decode(s[0..n]) catch ' '; -} - -fn ensureGlyph(g: *Gui, cp: u32) Slot { - if (g.glyphs.get(cp)) |s| return s; - if (g.pen_x + g.cell_w > atlas_w) { - g.pen_x = 0; - g.pen_y += g.cell_h; - } - if (g.pen_y + g.cell_h > atlas_h) return g.space_slot; // atlas full: fall back - const s = Slot{ .u = g.pen_x, .v = g.pen_y }; - const out = g.atlas_stage.ptr + @as(usize, s.v) * atlas_w + s.u; - _ = c.ui_font_raster(g.font, g.scale, @intCast(cp), out, @intCast(atlas_w), @intCast(g.cell_w), @intCast(g.cell_h), g.ascent); - g.glyphs.put(cp, s) catch return g.space_slot; - g.pen_x += g.cell_w; - g.atlas_dirty = true; - return s; -} - -fn uploadAtlas(g: *Gui, cmd: *c.SDL_GPUCommandBuffer) void { - const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.atlas_xfer, false) orelse return; - const dst: [*]u8 = @ptrCast(ptr); - @memcpy(dst[0 .. atlas_w * atlas_h], g.atlas_stage); - c.SDL_UnmapGPUTransferBuffer(g.device, g.atlas_xfer); - - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = g.atlas_xfer, .offset = 0, .pixels_per_row = atlas_w, .rows_per_layer = atlas_h }; - const dstregion = c.SDL_GPUTextureRegion{ .texture = g.atlas_tex, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = atlas_w, .h = atlas_h, .d = 1 }; - c.SDL_UploadToGPUTexture(copy, &src, &dstregion, false); - c.SDL_EndGPUCopyPass(copy); - g.atlas_dirty = false; -} - -fn ensureVbuf(g: *Gui, cells: u32) !void { - if (g.vbuf_cells == cells and g.vbuf != null) return; - if (g.vbuf) |b| c.SDL_ReleaseGPUBuffer(g.device, b); - if (g.vxfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); - g.vbuf = null; - g.vxfer = null; - const size = cells * @sizeOf(CellInstance); - var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; - g.vbuf = c.SDL_CreateGPUBuffer(g.device, &vb_info) orelse return error.GpuCreate; - var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; - g.vxfer = c.SDL_CreateGPUTransferBuffer(g.device, &xf_info) orelse return error.GpuCreate; - g.vbuf_cells = cells; -} - -fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); - defer c.SDL_ReleaseGPUShader(device, fs); - - var vbuf_desc = c.SDL_GPUVertexBufferDescription{ - .slot = 0, - .pitch = @sizeOf(CellInstance), - .input_rate = c.SDL_GPU_VERTEXINPUTRATE_INSTANCE, - .instance_step_rate = 0, - }; - var attrs = [_]c.SDL_GPUVertexAttribute{ - .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "x0") }, - .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(CellInstance, "u0") }, - .{ .location = 2, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "fr") }, - .{ .location = 3, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT3, .offset = @offsetOf(CellInstance, "br") }, - }; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; - - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.vertex_input_state = .{ - .vertex_buffer_descriptions = &vbuf_desc, - .num_vertex_buffers = 1, - .vertex_attributes = &attrs, - .num_vertex_attributes = attrs.len, - }; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -fn makeOverlayPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, overlay_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, overlay_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 0); - defer c.SDL_ReleaseGPUShader(device, fs); - - var vbuf_desc = c.SDL_GPUVertexBufferDescription{ - .slot = 0, - .pitch = @sizeOf(OverlayVertex), - .input_rate = c.SDL_GPU_VERTEXINPUTRATE_VERTEX, - .instance_step_rate = 0, - }; - var attrs = [_]c.SDL_GPUVertexAttribute{ - .{ .location = 0, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT2, .offset = @offsetOf(OverlayVertex, "x") }, - .{ .location = 1, .buffer_slot = 0, .format = c.SDL_GPU_VERTEXELEMENTFORMAT_FLOAT4, .offset = @offsetOf(OverlayVertex, "r") }, - }; - var blend = std.mem.zeroes(c.SDL_GPUColorTargetBlendState); - blend.src_color_blendfactor = c.SDL_GPU_BLENDFACTOR_SRC_ALPHA; - blend.dst_color_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; - blend.color_blend_op = c.SDL_GPU_BLENDOP_ADD; - blend.src_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE; - blend.dst_alpha_blendfactor = c.SDL_GPU_BLENDFACTOR_ONE_MINUS_SRC_ALPHA; - blend.alpha_blend_op = c.SDL_GPU_BLENDOP_ADD; - blend.enable_blend = true; - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = blend }; - - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.vertex_input_state = .{ - .vertex_buffer_descriptions = &vbuf_desc, - .num_vertex_buffers = 1, - .vertex_attributes = &attrs, - .num_vertex_attributes = attrs.len, - }; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -// the CRT pass: a fullscreen triangle sampling the scene texture, no vertex -// buffers at all (positions from gl_VertexIndex) -fn makeCrtPipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { - const vs = try makeShader(device, crt_vert_spv, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); - defer c.SDL_ReleaseGPUShader(device, vs); - const fs = try makeShader(device, crt_frag_spv, c.SDL_GPU_SHADERSTAGE_FRAGMENT, 1); - defer c.SDL_ReleaseGPUShader(device, fs); - - var col_desc = c.SDL_GPUColorTargetDescription{ .format = color_format, .blend_state = std.mem.zeroes(c.SDL_GPUColorTargetBlendState) }; - var info = std.mem.zeroes(c.SDL_GPUGraphicsPipelineCreateInfo); - info.vertex_shader = vs; - info.fragment_shader = fs; - info.primitive_type = c.SDL_GPU_PRIMITIVETYPE_TRIANGLELIST; - info.rasterizer_state.fill_mode = c.SDL_GPU_FILLMODE_FILL; - info.rasterizer_state.cull_mode = c.SDL_GPU_CULLMODE_NONE; - info.multisample_state.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - info.target_info = .{ .color_target_descriptions = &col_desc, .num_color_targets = 1 }; - return c.SDL_CreateGPUGraphicsPipeline(device, &info) orelse error.GpuCreate; -} - -fn makeShader(device: *c.SDL_GPUDevice, code: []const u8, stage: c.SDL_GPUShaderStage, n_samplers: u32) !*c.SDL_GPUShader { - var info = std.mem.zeroes(c.SDL_GPUShaderCreateInfo); - info.code = code.ptr; - info.code_size = code.len; - info.entrypoint = "main"; - info.format = c.SDL_GPU_SHADERFORMAT_SPIRV; - info.stage = stage; - info.num_samplers = n_samplers; - return c.SDL_CreateGPUShader(device, &info) orelse error.GpuCreate; -} - -// ===================================================================== -// web render: Surface → the same instanced quads → WebGL2. Full instance -// re-upload each frame; attribs re-specified per draw (no VAO to manage). -// ===================================================================== - -fn renderFrameGl(g: *Gui, gpa: std.mem.Allocator, surface: *pardes.Surface, debug_on: bool) !void { - var pw: c_int = 0; - var ph: c_int = 0; - _ = c.SDL_GetWindowSizeInPixels(g.window, &pw, &ph); - const sw: u32 = @intCast(@max(pw, 1)); - const sh: u32 = @intCast(@max(ph, 1)); - c.glViewport(0, 0, @intCast(sw), @intCast(sh)); - c.glClearColor( - @as(f32, @floatFromInt(bg_default[0])) / 255.0, - @as(f32, @floatFromInt(bg_default[1])) / 255.0, - @as(f32, @floatFromInt(bg_default[2])) / 255.0, - 1.0, - ); - c.glClear(c.GL_COLOR_BUFFER_BIT); - - const overlay_count = buildTouchOverlay(g, sw, sh, debug_on); - const cells: u32 = @as(u32, surface.cols) * surface.rows; - if (cells == 0) { - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); - return; - } - if (g.gl_instances.len < cells) { - gpa.free(g.gl_instances); - g.gl_instances = try gpa.alloc(CellInstance, cells); - } - - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - const layout = cellLayout(g, surface.cols, surface.rows, sw, sh); - const cursor_idx: u32 = if (surface.cursor) |cu| @as(u32, cu.y) * surface.cols + cu.x else cells; - const cursor_bar = if (surface.cursor) |cu| cu.bar else false; - var row: u16 = 0; - while (row < surface.rows) : (row += 1) { - var col: u16 = 0; - while (col < surface.cols) : (col += 1) { - const idx: u32 = @as(u32, row) * surface.cols + col; - emitInstance(g, g.gl_instances.ptr, idx, col, row, layout, win_w, win_h, surface.at(col, row), idx == cursor_idx, cursor_bar); - } - } - if (g.atlas_dirty) uploadAtlasGl(g); - - c.glUseProgram(g.gl_program); - c.glActiveTexture(c.GL_TEXTURE0); - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glUniform1i(g.gl_u_atlas, 0); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), g.gl_instances.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(CellInstance)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); - c.glVertexAttribDivisor(0, 1); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); - c.glVertexAttribDivisor(1, 1); - c.glEnableVertexAttribArray(2); - c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); - c.glVertexAttribDivisor(2, 1); - c.glEnableVertexAttribArray(3); - c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); - c.glVertexAttribDivisor(3, 1); - c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); - drawOverlayGl(g, overlay_count); - _ = c.SDL_GL_SwapWindow(g.window); -} - -fn uploadAtlasGl(g: *Gui) void { - c.glBindTexture(c.GL_TEXTURE_2D, g.gl_atlas_tex); - c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); - c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, g.atlas_stage.ptr); - g.atlas_dirty = false; -} - -fn drawOverlayGl(g: *Gui, vertex_count: u32) void { - if (vertex_count == 0) return; - c.glUseProgram(g.gl_overlay_program); - c.glBindBuffer(c.GL_ARRAY_BUFFER, g.gl_overlay_vbo); - c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(vertex_count * @sizeOf(OverlayVertex)), g.overlay_vertices.ptr, c.GL_STREAM_DRAW); - const stride: c_int = @intCast(@sizeOf(OverlayVertex)); - c.glEnableVertexAttribArray(0); - c.glVertexAttribPointer(0, 2, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "x"))); - c.glVertexAttribDivisor(0, 0); - c.glEnableVertexAttribArray(1); - c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(OverlayVertex, "r"))); - c.glVertexAttribDivisor(1, 0); - c.glDisableVertexAttribArray(2); - c.glDisableVertexAttribArray(3); - c.glEnable(c.GL_BLEND); - c.glBlendFuncSeparate(c.GL_SRC_ALPHA, c.GL_ONE_MINUS_SRC_ALPHA, c.GL_ONE, c.GL_ONE_MINUS_SRC_ALPHA); - c.glDrawArrays(c.GL_TRIANGLES, 0, @intCast(vertex_count)); - c.glDisable(c.GL_BLEND); -} - -fn makeGlProgramFrom(vertex_source: []const u8, fragment_source: []const u8) !c_uint { - const vs = try makeGlShader(c.GL_VERTEX_SHADER, vertex_source); - defer c.glDeleteShader(vs); - const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, fragment_source); - defer c.glDeleteShader(fs); - const program = c.glCreateProgram(); - if (program == 0) return error.GpuCreate; - c.glAttachShader(program, vs); - c.glAttachShader(program, fs); - c.glLinkProgram(program); - var ok: c_int = 0; - c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); - if (ok == 0) { - log.err("WebGL program link failed", .{}); - c.glDeleteProgram(program); - return error.GpuCreate; - } - return program; -} - -fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { - const shader = c.glCreateShader(kind); - if (shader == 0) return error.GpuCreate; - var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; - var src_len = [_]c_int{@intCast(source.len)}; - c.glShaderSource(shader, 1, &src_ptr, &src_len); - c.glCompileShader(shader); - var ok: c_int = 0; - c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); - if (ok == 0) { - log.err("WebGL shader compile failed", .{}); - c.glDeleteShader(shader); - return error.GpuCreate; - } - return shader; -} - -// ===================================================================== -// PARDES_TEST frame capture: download the render target, write latest.ppm -// ===================================================================== - -fn ensureSceneTexture(g: *Gui, width: u32, height: u32) !void { - if (g.scene_tex != null and g.scene_tex_w == width and g.scene_tex_h == height) return; - if (g.scene_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); - var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - info.type = c.SDL_GPU_TEXTURETYPE_2D; - info.format = g.swapchain_format; - info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET | c.SDL_GPU_TEXTUREUSAGE_SAMPLER; - info.width = width; - info.height = height; - info.layer_count_or_depth = 1; - info.num_levels = 1; - info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.scene_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; - g.scene_tex_w = width; - g.scene_tex_h = height; -} - -fn ensureCaptureTexture(g: *Gui, width: u32, height: u32) !void { - if (g.capture_tex != null and g.capture_tex_w == width and g.capture_tex_h == height) return; - if (g.capture_tex) |t| c.SDL_ReleaseGPUTexture(g.device, t); - var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); - info.type = c.SDL_GPU_TEXTURETYPE_2D; - info.format = g.swapchain_format; - info.usage = c.SDL_GPU_TEXTUREUSAGE_COLOR_TARGET; - info.width = width; - info.height = height; - info.layer_count_or_depth = 1; - info.num_levels = 1; - info.sample_count = c.SDL_GPU_SAMPLECOUNT_1; - g.capture_tex = c.SDL_CreateGPUTexture(g.device, &info) orelse return error.GpuCreate; - g.capture_tex_w = width; - g.capture_tex_h = height; -} - -fn captureFrame(g: *Gui, gpa: std.mem.Allocator, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { - const size = c.SDL_CalculateGPUTextureFormatSize(g.swapchain_format, sw, sh, 1); - if (size == 0) { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return error.UnsupportedCaptureFormat; - } - if (g.capture_xfer == null or g.capture_xfer_size < size) { - if (g.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(g.device, x); - var info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, .size = size, .props = 0 }; - g.capture_xfer = c.SDL_CreateGPUTransferBuffer(g.device, &info) orelse { - _ = c.SDL_SubmitGPUCommandBuffer(cmd); - return error.GpuCreate; - }; - g.capture_xfer_size = size; - } - const xfer = g.capture_xfer.?; - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src = c.SDL_GPUTextureRegion{ .texture = target, .mip_level = 0, .layer = 0, .x = 0, .y = 0, .z = 0, .w = sw, .h = sh, .d = 1 }; - const dst = c.SDL_GPUTextureTransferInfo{ .transfer_buffer = xfer, .offset = 0, .pixels_per_row = sw, .rows_per_layer = sh }; - c.SDL_DownloadFromGPUTexture(copy, &src, &dst); - c.SDL_EndGPUCopyPass(copy); - - const fence = c.SDL_SubmitGPUCommandBufferAndAcquireFence(cmd) orelse return error.GpuSubmit; - defer c.SDL_ReleaseGPUFence(g.device, fence); - var fences = [_]*c.SDL_GPUFence{fence}; - if (!c.SDL_WaitForGPUFences(g.device, true, &fences, 1)) return error.GpuSubmit; - - const mapped = c.SDL_MapGPUTransferBuffer(g.device, xfer, false) orelse return error.GpuMap; - defer c.SDL_UnmapGPUTransferBuffer(g.device, xfer); - const pixels: [*]const u8 = @ptrCast(mapped); - try writeCapturePpm(g, gpa, pixels[0..size], sw, sh); -} - -fn writeCapturePpm(g: *Gui, gpa: std.mem.Allocator, pixels: []const u8, width: u32, height: u32) !void { - if (g.capture_dir.len == 0) return; - const bpp = c.SDL_GPUTextureFormatTexelBlockSize(g.swapchain_format); - if (bpp != 4) return error.UnsupportedCaptureFormat; - const bgr = switch (g.swapchain_format) { - c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB => false, - c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB => true, - else => return error.UnsupportedCaptureFormat, - }; - - var tmp_buf: [4096]u8 = undefined; - var final_buf: [4096]u8 = undefined; - const tmp_path = std.fmt.bufPrintSentinel(&tmp_buf, "{s}/latest.ppm.tmp", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - const final_path = std.fmt.bufPrintSentinel(&final_buf, "{s}/latest.ppm", .{g.capture_dir}, 0) catch return error.CapturePathTooLong; - - const fd = libc.open(tmp_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return error.CaptureWriteFailed; - defer _ = libc.close(fd); - - var header: [64]u8 = undefined; - const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; - writeFd(fd, hdr); - - const row_rgb = try gpa.alloc(u8, @as(usize, width) * 3); - defer gpa.free(row_rgb); - for (0..height) |row| { - const src = pixels[row * width * 4 ..][0 .. @as(usize, width) * 4]; - for (0..width) |x| { - const si = x * 4; - const di = x * 3; - row_rgb[di + 0] = src[si + if (bgr) @as(usize, 2) else 0]; - row_rgb[di + 1] = src[si + 1]; - row_rgb[di + 2] = src[si + if (bgr) @as(usize, 0) else 2]; - } - writeFd(fd, row_rgb); - } - if (libc.rename(tmp_path, final_path) != 0) return error.CaptureWriteFailed; -} - -// ===================================================================== -// touch debug overlay: per-finger colored circles + trails + a click-action -// flash HUD, alpha-blended over the grid only while the Debug builtin is on. -// ===================================================================== - -fn buildTouchOverlay(g: *Gui, sw: u32, sh: u32, debug_on: bool) u32 { - if (!debug_on) { - g.touch.click_flash_frames = 0; - return 0; - } - if (g.overlay_vertices.len == 0 or sw == 0 or sh == 0) return 0; - const win_w: f32 = @floatFromInt(sw); - const win_h: f32 = @floatFromInt(sh); - var builder = OverlayBuilder{ .vertices = g.overlay_vertices, .win_w = win_w, .win_h = win_h }; - const t = &g.touch; - - if (t.click_flash_frames != 0) { - const flash = @as(f32, @floatFromInt(t.click_flash_frames)) / @as(f32, @floatFromInt(touch_click_flash_max_frames)); - const label = touchClickLabel(t.click_count, t.click_button); - const glyph_scale = 3.0; - const char_step = 4.0 * glyph_scale; - const pad = 5.0; - const stripe_w = 4.0; - const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; - const label_h = pad * 2.0 + 5.0 * glyph_scale; - builder.addRect(0.0, 0.0, win_w, win_h, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.055 * flash }); - builder.addRect(8.0, 8.0, 8.0 + label_w, 8.0 + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.54 + 0.22 * flash }); - builder.addRect(8.0 + pad, 8.0 + pad, 8.0 + pad + stripe_w, 8.0 + label_h - pad, .{ .r = 1.0, .g = 0.78, .b = 0.18, .a = 0.96 }); - builder.addMiniText(label[0..], 8.0 + pad + stripe_w + 3.0, 8.0 + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.96 }); - t.click_flash_frames -= 1; - } - - for (&t.points) |*tp| { - if (!tp.active) continue; - const cx = std.math.clamp(tp.x, 0.0, 1.0) * win_w; - const cy = std.math.clamp(tp.y, 0.0, 1.0) * win_h; - const radius = (12.0 + std.math.clamp(tp.pressure, 0.0, 1.0) * 4.0) * 4.0; - const color = touchColor(tp.id); - - if (tp.trail_len > 1) { - const first = if (tp.trail_len == max_touch_trail_points) tp.trail_next else 0; - for (0..tp.trail_len - 1) |trail_i| { - const sample = tp.trail[(first + trail_i) % max_touch_trail_points]; - const fade = @as(f32, @floatFromInt(trail_i + 1)) / @as(f32, @floatFromInt(tp.trail_len)); - var trail_color = color; - trail_color.a *= 0.42 * fade; - const tx = std.math.clamp(sample.x, 0.0, 1.0) * win_w; - const ty = std.math.clamp(sample.y, 0.0, 1.0) * win_h; - const trail_radius = (12.0 + std.math.clamp(sample.pressure, 0.0, 1.0) * 4.0) * 2.8; - builder.addCircle(tx, ty, trail_radius, trail_color); - } - } - - builder.addCircle(cx, cy, radius + 4.0, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.62 }); - builder.addCircle(cx, cy, radius, color); - builder.addCircle(cx, cy, 2.4, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.92 }); - builder.addIdTag(tp.id, cx, cy, color); - } - return @intCast(builder.len); -} - -fn uploadOverlayGpu(g: *Gui, cmd: *c.SDL_GPUCommandBuffer, vertex_count: u32) void { - const byte_len: u32 = vertex_count * @sizeOf(OverlayVertex); - const ptr = c.SDL_MapGPUTransferBuffer(g.device, g.overlay_vxfer, false) orelse return; - const dst: [*]u8 = @ptrCast(ptr); - const src: [*]const u8 = @ptrCast(g.overlay_vertices.ptr); - @memcpy(dst[0..byte_len], src[0..byte_len]); - c.SDL_UnmapGPUTransferBuffer(g.device, g.overlay_vxfer); - const copy = c.SDL_BeginGPUCopyPass(cmd); - const src_loc = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = g.overlay_vxfer, .offset = 0 }; - const dst_region = c.SDL_GPUBufferRegion{ .buffer = g.overlay_vbuf, .offset = 0, .size = byte_len }; - c.SDL_UploadToGPUBuffer(copy, &src_loc, &dst_region, false); - c.SDL_EndGPUCopyPass(copy); -} - -fn drawOverlayGpu(g: *Gui, rp: ?*c.SDL_GPURenderPass, vertex_count: u32) void { - if (vertex_count == 0) return; - const pass = rp orelse return; - c.SDL_BindGPUGraphicsPipeline(pass, g.overlay_pipeline); - const binding = c.SDL_GPUBufferBinding{ .buffer = g.overlay_vbuf, .offset = 0 }; - c.SDL_BindGPUVertexBuffers(pass, 0, &binding, 1); - c.SDL_DrawGPUPrimitives(pass, vertex_count, 1, 0, 0); -} - -fn touchColor(id: u64) OverlayColor { - const palette = [_]OverlayColor{ - .{ .r = 0.10, .g = 0.72, .b = 1.00, .a = 0.88 }, - .{ .r = 1.00, .g = 0.32, .b = 0.50, .a = 0.88 }, - .{ .r = 0.35, .g = 0.90, .b = 0.38, .a = 0.88 }, - .{ .r = 1.00, .g = 0.78, .b = 0.18, .a = 0.88 }, - .{ .r = 0.78, .g = 0.52, .b = 1.00, .a = 0.88 }, - .{ .r = 0.10, .g = 0.88, .b = 0.75, .a = 0.88 }, - .{ .r = 1.00, .g = 0.48, .b = 0.18, .a = 0.88 }, - .{ .r = 0.78, .g = 0.90, .b = 1.00, .a = 0.88 }, - }; - return palette[@intCast(id % palette.len)]; -} - -const OverlayBuilder = struct { - vertices: []OverlayVertex, - len: usize = 0, - win_w: f32, - win_h: f32, - - fn addVertex(b: *OverlayBuilder, px: f32, py: f32, color: OverlayColor) void { - if (b.len >= b.vertices.len) return; - b.vertices[b.len] = .{ - .x = (px / b.win_w) * 2.0 - 1.0, - .y = 1.0 - (py / b.win_h) * 2.0, - .r = color.r, - .g = color.g, - .b = color.b, - .a = color.a, - }; - b.len += 1; - } - - fn addTri(b: *OverlayBuilder, x0: f32, y0: f32, x1: f32, y1: f32, x2: f32, y2: f32, color: OverlayColor) void { - if (b.len + 3 > b.vertices.len) return; - b.addVertex(x0, y0, color); - b.addVertex(x1, y1, color); - b.addVertex(x2, y2, color); - } - - fn addRect(b: *OverlayBuilder, x0_in: f32, y0_in: f32, x1_in: f32, y1_in: f32, color: OverlayColor) void { - if (b.len + 6 > b.vertices.len) return; - const x0 = std.math.clamp(@min(x0_in, x1_in), 0.0, b.win_w); - const x1 = std.math.clamp(@max(x0_in, x1_in), 0.0, b.win_w); - const y0 = std.math.clamp(@min(y0_in, y1_in), 0.0, b.win_h); - const y1 = std.math.clamp(@max(y0_in, y1_in), 0.0, b.win_h); - if (x1 <= x0 or y1 <= y0) return; - b.addTri(x0, y0, x1, y0, x1, y1, color); - b.addTri(x0, y0, x1, y1, x0, y1, color); - } - - fn addCircle(b: *OverlayBuilder, cx: f32, cy: f32, radius: f32, color: OverlayColor) void { - var i: usize = 0; - while (i + 1 < circle_points.len) : (i += 1) { - const p0 = circle_points[i]; - const p1 = circle_points[i + 1]; - b.addTri(cx, cy, cx + p0[0] * radius, cy + p0[1] * radius, cx + p1[0] * radius, cy + p1[1] * radius, color); - } - } - - fn addIdTag(b: *OverlayBuilder, id: u64, cx: f32, cy: f32, color: OverlayColor) void { - const label = touchIdLabel(id); - const glyph_scale = 2.0; - const char_step = 4.0 * glyph_scale; - const pad = 4.0; - const stripe_w = 3.0; - const label_w = pad * 2.0 + stripe_w + 3.0 + @as(f32, @floatFromInt(label.len)) * char_step - glyph_scale; - const label_h = pad * 2.0 + 5.0 * glyph_scale; - - var x = cx + 20.0; - if (x + label_w > b.win_w - 2.0) x = cx - label_w - 20.0; - x = std.math.clamp(x, 2.0, @max(2.0, b.win_w - label_w - 2.0)); - var y = cy - label_h - 10.0; - if (y < 2.0) y = cy + 20.0; - y = std.math.clamp(y, 2.0, @max(2.0, b.win_h - label_h - 2.0)); - - b.addRect(x, y, x + label_w, y + label_h, .{ .r = 0.0, .g = 0.0, .b = 0.0, .a = 0.72 }); - b.addRect(x + pad, y + pad, x + pad + stripe_w, y + label_h - pad, color); - b.addMiniText(label[0..], x + pad + stripe_w + 3.0, y + pad, glyph_scale, .{ .r = 1.0, .g = 1.0, .b = 1.0, .a = 0.95 }); - } - - fn addMiniText(b: *OverlayBuilder, text: []const u8, x: f32, y: f32, scale: f32, color: OverlayColor) void { - var cx = x; - for (text) |ch| { - const rows = miniGlyph(ch); - const masks = [_]u8{ 0b100, 0b010, 0b001 }; - for (rows, 0..) |bits, row| { - for (masks, 0..) |mask, colidx| { - if ((bits & mask) == 0) continue; - const x0 = cx + @as(f32, @floatFromInt(colidx)) * scale; - const y0 = y + @as(f32, @floatFromInt(row)) * scale; - b.addRect(x0, y0, x0 + scale, y0 + scale, color); - } - } - cx += 4.0 * scale; - } - } -}; - -const circle_points = [_][2]f32{ - .{ 1.0000000, 0.0000000 }, .{ 0.9238795, 0.3826834 }, .{ 0.7071068, 0.7071068 }, .{ 0.3826834, 0.9238795 }, - .{ 0.0000000, 1.0000000 }, .{ -0.3826834, 0.9238795 }, .{ -0.7071068, 0.7071068 }, .{ -0.9238795, 0.3826834 }, - .{ -1.0000000, 0.0000000 }, .{ -0.9238795, -0.3826834 }, .{ -0.7071068, -0.7071068 }, .{ -0.3826834, -0.9238795 }, - .{ 0.0000000, -1.0000000 }, .{ 0.3826834, -0.9238795 }, .{ 0.7071068, -0.7071068 }, .{ 0.9238795, -0.3826834 }, - .{ 1.0000000, 0.0000000 }, -}; - -fn touchIdLabel(id: u64) [9]u8 { - var out: [9]u8 = undefined; - out[0] = '#'; - const low: u32 = @truncate(id); - for (0..8) |i| { - const shift: u5 = @intCast((7 - i) * 4); - out[i + 1] = hexDigit((low >> shift) & 0xf); - } - return out; -} - -fn touchClickLabel(count: u32, button: pardes.Mouse.Button) [14]u8 { - var out: [14]u8 = undefined; - @memcpy(out[0..6], if (button == .right) "LOOK #" else "MID #"); - for (0..8) |i| { - const shift: u5 = @intCast((7 - i) * 4); - out[i + 6] = hexDigit((count >> shift) & 0xf); - } - return out; -} - -fn hexDigit(n: u32) u8 { - return if (n < 10) @intCast('0' + n) else @intCast('A' + (n - 10)); -} - -fn miniGlyph(ch: u8) [5]u8 { - return switch (ch) { - '#' => .{ 0b101, 0b111, 0b101, 0b111, 0b101 }, - '0' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, - '1' => .{ 0b010, 0b110, 0b010, 0b010, 0b111 }, - '2' => .{ 0b111, 0b001, 0b111, 0b100, 0b111 }, - '3' => .{ 0b111, 0b001, 0b111, 0b001, 0b111 }, - '4' => .{ 0b101, 0b101, 0b111, 0b001, 0b001 }, - '5' => .{ 0b111, 0b100, 0b111, 0b001, 0b111 }, - '6' => .{ 0b111, 0b100, 0b111, 0b101, 0b111 }, - '7' => .{ 0b111, 0b001, 0b010, 0b010, 0b010 }, - '8' => .{ 0b111, 0b101, 0b111, 0b101, 0b111 }, - '9' => .{ 0b111, 0b101, 0b111, 0b001, 0b111 }, - 'A' => .{ 0b111, 0b101, 0b111, 0b101, 0b101 }, - 'B' => .{ 0b110, 0b101, 0b110, 0b101, 0b110 }, - 'C' => .{ 0b111, 0b100, 0b100, 0b100, 0b111 }, - 'D' => .{ 0b110, 0b101, 0b101, 0b101, 0b110 }, - 'E' => .{ 0b111, 0b100, 0b111, 0b100, 0b111 }, - 'F' => .{ 0b111, 0b100, 0b111, 0b100, 0b100 }, - 'I' => .{ 0b111, 0b010, 0b010, 0b010, 0b111 }, - 'K' => .{ 0b101, 0b101, 0b110, 0b101, 0b101 }, - 'L' => .{ 0b100, 0b100, 0b100, 0b100, 0b111 }, - 'M' => .{ 0b101, 0b111, 0b111, 0b101, 0b101 }, - 'O' => .{ 0b111, 0b101, 0b101, 0b101, 0b111 }, - else => .{ 0, 0, 0, 0, 0 }, - }; -} - -// ===================================================================== -// shared plumbing (same shapes as tty.zig) -// ===================================================================== - -/// The effective codepoint the way vaxis Key.matches sees it: a single-char -/// text wins (shift resolved by the terminal), else the shifted codepoint. -fn effCp(key: vaxis.Key) u21 { - if (key.text) |t| { - const view = std.unicode.Utf8View.init(t) catch return key.codepoint; - var it = view.iterator(); - if (it.nextCodepoint()) |cp| { - if (it.nextCodepoint() == null) return cp; - } - } - return key.shifted_codepoint orelse key.codepoint; -} - -/// vaxis functional-key codepoints -> core Key constants (ASCII ones already -/// coincide: enter/tab/escape/backspace pass through). -fn mapKey(cp: u21) u21 { - return switch (cp) { - vaxis.Key.up => pardes.Key.up, - vaxis.Key.down => pardes.Key.down, - vaxis.Key.left => pardes.Key.left, - vaxis.Key.right => pardes.Key.right, - vaxis.Key.home => pardes.Key.home, - vaxis.Key.end => pardes.Key.end, - vaxis.Key.page_up => pardes.Key.page_up, - vaxis.Key.page_down => pardes.Key.page_down, - vaxis.Key.delete => pardes.Key.delete, - else => cp, - }; -} - -fn envU16(env: *std.process.Environ.Map, name: []const u8) ?u16 { - const raw = env.get(name) orelse return null; - return std.fmt.parseInt(u16, raw, 10) catch null; -} - -fn writeFile(data: []const u8) void { - const fd = libc.open("/tmp/pardes-osc133.bash", .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644)); - if (fd < 0) return; - defer _ = libc.close(fd); - writeFd(fd, data); -} - -fn writeFd(fd: c_int, data: []const u8) void { - var off: usize = 0; - while (off < data.len) { - const n = libc.write(fd, data[off..].ptr, data.len - off); - if (n < 0) { - if (libc.errno(n) == .INTR) continue; - return; - } - off += @intCast(n); - } -} |
