diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-02 20:06:25 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | bead9f0b78380d1428f40fdab339c1c8f88fa626 (patch) | |
| tree | 960255de5a2cef661ec5d0ad3220a4b2b1738842 /ui_sdl.zig | |
| parent | 62ef1882ab7878210f05f06504796849e412c318 (diff) | |
| download | pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.tar.gz pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.zip | |
sdl ui backend
Diffstat (limited to 'ui_sdl.zig')
| -rw-r--r-- | ui_sdl.zig | 1682 |
1 files changed, 1682 insertions, 0 deletions
diff --git a/ui_sdl.zig b/ui_sdl.zig new file mode 100644 index 00000000..4e0a5bb2 --- /dev/null +++ b/ui_sdl.zig @@ -0,0 +1,1682 @@ +// The SDL3 GPU UI backend (`-Dui=sdl`). Same surface as `ui_vaxis.zig` — the +// app (main.zig / image.zig) imports `"ui"` and talks to `Vaxis`/`Tty`/`Loop`/ +// `init` plus the shared cell types — but here those are backed by an SDL +// window + SDL3 GPU renderer instead of a real terminal. +// +// The retained model is identical to libvaxis: `main` builds a 2D grid of +// `Cell`s each frame (via `vaxis.Window` over a `vaxis.Screen` — both pure, +// I/O-free libvaxis types we reuse verbatim) and hands it to `render()`. Instead +// of diffing it into escape sequences, `render()` rasterizes the grid with SDL3 +// GPU: one textured-quad draw per cell, glyphs pulled from an stb_truetype +// monospace atlas. Input flows the other way through `SdlLoop`, which polls SDL +// events and converts them to the same `Key`/`Mouse`/`Winsize` the app already +// handles, posting them on a thread-safe queue (so the per-terminal pty readers +// work unchanged). Kitty image panes are unsupported (caps.kitty_graphics is +// false), so image.zig falls back to its PETSCII cell-art path — which renders +// fine here, since it just writes cells. +// +// This is the quick-and-dirty first cut: ASCII + box-drawing + block glyphs via +// a fixed 1024² R8 atlas (dynamic, grows as new codepoints appear), reverse / +// invisible / dim honored, bold/italic/underline/strikethrough ignored. Terminal +// appearance is preserved: a monospace grid of styled cells on a dark page. +const std = @import("std"); +const builtin = @import("builtin"); +const vaxis = @import("vaxis"); +const posix = std.posix; +const linux = std.os.linux; + +pub const c = @cImport({ + @cDefine("SDL_DISABLE_OLD_NAMES", "1"); + @cInclude("SDL3/SDL.h"); + @cInclude("font.h"); +}); + +// ---- shared (backend-agnostic) types: re-exported straight from libvaxis ---- +pub const Key = vaxis.Key; +pub const Mouse = vaxis.Mouse; +pub const Winsize = vaxis.Winsize; +pub const Color = vaxis.Color; +pub const Style = vaxis.Style; +pub const Cell = vaxis.Cell; +pub const Segment = vaxis.Segment; +pub const Window = vaxis.Window; +pub const Screen = vaxis.Screen; +pub const Image = vaxis.Image; +pub const Event = vaxis.Event; +pub const Capabilities = vaxis.Vaxis.Capabilities; +pub const CursorShape = vaxis.Cell.CursorShape; + +// `Options` mirrors the subset of vaxis.Vaxis.Options the app constructs. Only +// system_clipboard_allocator is used (for OSC 52 paste in the terminal backend); +// here it's accepted and unused — SDL reads the clipboard synchronously. +pub const Options = struct { + system_clipboard_allocator: ?std.mem.Allocator = null, +}; + +// The backend struct is named `Vaxis` so `image.zig`'s `*vaxis.Vaxis` resolves. +pub const Vaxis = SdlBackend; +pub const Tty = SdlTty; +pub fn Loop(comptime EventType: type) type { + return SdlLoop(EventType); +} + +const font_ttf = @embedFile("assets/DejaVuSansMono.ttf"); +const vert_spv_bytes = @embedFile("ui.vert.spv"); +const frag_spv_bytes = @embedFile("ui.frag.spv"); + +const log = std.log.scoped(.ui_sdl); + +// A standard 16-color ANSI palette (xterm), used to resolve vaxis.Color.index +// cells — the dark theme passes palette indices through (palColor), and there's +// no real terminal here to resolve them. +const ansi = [16][3]u8{ + .{ 0, 0, 0 }, .{ 205, 0, 0 }, .{ 0, 205, 0 }, .{ 205, 205, 0 }, + .{ 0, 0, 238 }, .{ 205, 0, 205 }, .{ 0, 205, 205 }, .{ 229, 229, 229 }, + .{ 127, 127, 127 }, .{ 255, 0, 0 }, .{ 0, 255, 0 }, .{ 255, 255, 0 }, + .{ 92, 92, 255 }, .{ 255, 0, 255 }, .{ 0, 255, 255 }, .{ 255, 255, 255 }, +}; + +// ===================================================================== +// Backend +// ===================================================================== + +const SdlBackend = @This(); + +io: std.Io, +alloc: std.mem.Allocator, + +sdl_window: *c.SDL_Window, +device: *c.SDL_GPUDevice, +swapchain_format: c.SDL_GPUTextureFormat, +present_mode: c.SDL_GPUPresentMode, + +pipeline: *c.SDL_GPUGraphicsPipeline, +atlas_tex: *c.SDL_GPUTexture, +atlas_sampler: *c.SDL_GPUSampler, +atlas_xfer: *c.SDL_GPUTransferBuffer, + +vbuf: *c.SDL_GPUBuffer, +vxfer: *c.SDL_GPUTransferBuffer, +vbuf_cells: u32 = 0, // capacity (cols*rows) the vbuf/vxfer were sized for +cell_keys: []CellKey = &.{}, +dirty_ranges: []DirtyRange = &.{}, +cell_cache_valid: bool = false, +last_render_w: u32 = 0, +last_render_h: u32 = 0, + +// font + cell metrics +font: ?*c.UIFont, +scale: f32, +cell_w: u32, +cell_h: u32, +ascent: i32, + +// retained cell grid (the same type vaxis uses; main writes here via window()) +screen: vaxis.Screen, +caps: vaxis.Vaxis.Capabilities = .{}, // kitty_graphics stays false → PETSCII images +test_mode: bool = false, // PARDES_TEST=1: stdin→events for e2e +grid_test_mode: bool = false, // PARDES_TEST_GRID=1: old no-SDL text-grid dump path +capture_images: bool = false, // PARDES_TEST=1: capture real rendered framebuffer images +capture_dir: ?[]const u8 = null, +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, +capture_frame: u64 = 0, + +trace_enabled: bool = false, +trace_fd: c_int = -1, +trace_every: u32 = 1, +trace_frame: u64 = 0, + +// glyph atlas: a 1024² R8 staging bitmap + a map codepoint → texel slot. +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 }, + +// default page colors (the dark-theme "terminal native" the app expects when a +// cell's color is .default). +bg_default: [3]u8 = .{ 18, 18, 18 }, +fg_default: [3]u8 = .{ 204, 204, 204 }, + +// last grid size we reported to the app (so SDL only posts winsize on change) +reported_cols: u16 = 0, +reported_rows: u16 = 0, + +// ---- constants + helper types (must come after all fields in Zig 0.16) ---- +pub const atlas_w: u32 = 1024; +pub const atlas_h: u32 = 1024; +pub const Slot = struct { u: u32, v: u32 }; +pub const CellKey = packed struct { cp: u32, fg: u32, bg: u32 }; +pub const DirtyRange = struct { start: u32, count: u32 }; + +// One instance per cell. The vertex shader expands each instance into a 2-triangle +// quad using gl_VertexIndex, so dirty uploads move 56 B/cell instead of 240 B/cell. +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, +}; + +fn envU16(env_map: *std.process.Environ.Map, name: []const u8) ?u16 { + const raw = env_map.get(name) orelse return null; + return std.fmt.parseInt(u16, raw, 10) catch null; +} + +fn envU32(env_map: *std.process.Environ.Map, name: []const u8) ?u32 { + const raw = env_map.get(name) orelse return null; + return std.fmt.parseInt(u32, raw, 10) catch null; +} + +fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool { + const raw = env_map.get(name) orelse return false; + return !(raw.len == 0 or std.mem.eql(u8, raw, "0") or std.ascii.eqlIgnoreCase(raw, "false")); +} + +fn nowNs() i128 { + var ts: linux.timespec = undefined; + _ = linux.clock_gettime(linux.CLOCK.MONOTONIC, &ts); + return @as(i128, ts.sec) * 1_000_000_000 + @as(i128, ts.nsec); +} + +fn nsToUs(delta: i128) u64 { + if (delta <= 0) return 0; + return @intCast(@divTrunc(delta, 1000)); +} + +fn parsePresentMode(raw: []const u8) ?c.SDL_GPUPresentMode { + if (std.ascii.eqlIgnoreCase(raw, "immediate")) return c.SDL_GPU_PRESENTMODE_IMMEDIATE; + if (std.ascii.eqlIgnoreCase(raw, "mailbox")) return c.SDL_GPU_PRESENTMODE_MAILBOX; + if (std.ascii.eqlIgnoreCase(raw, "vsync")) return c.SDL_GPU_PRESENTMODE_VSYNC; + return null; +} + +fn presentModeName(mode: c.SDL_GPUPresentMode) []const u8 { + return switch (mode) { + c.SDL_GPU_PRESENTMODE_IMMEDIATE => "immediate", + c.SDL_GPU_PRESENTMODE_MAILBOX => "mailbox", + c.SDL_GPU_PRESENTMODE_VSYNC => "vsync", + else => "unknown", + }; +} + +fn choosePresentMode(env_map: *std.process.Environ.Map, device: *c.SDL_GPUDevice, sdl_win: *c.SDL_Window) c.SDL_GPUPresentMode { + if (env_map.get("PARDES_SDL_PRESENT")) |raw| { + if (parsePresentMode(raw)) |mode| { + if (c.SDL_WindowSupportsGPUPresentMode(device, sdl_win, mode)) return mode; + } + } + const preferred = [_]c.SDL_GPUPresentMode{ + c.SDL_GPU_PRESENTMODE_IMMEDIATE, + c.SDL_GPU_PRESENTMODE_MAILBOX, + c.SDL_GPU_PRESENTMODE_VSYNC, + }; + for (preferred) |mode| { + if (c.SDL_WindowSupportsGPUPresentMode(device, sdl_win, mode)) return mode; + } + return c.SDL_GPU_PRESENTMODE_VSYNC; +} + +pub fn init( + io: std.Io, + alloc: std.mem.Allocator, + env_map: *std.process.Environ.Map, + opts: Options, +) !SdlBackend { + _ = opts; + + // ---- test modes ------------------------------------------------------- + // PARDES_TEST=1 keeps the real SDL/GPU/window renderer active, but drives + // input from stdin (raw terminal escape sequences parsed by vaxis.Parser) + // and writes rendered-frame captures into PARDES_TEST_CAPTURE_DIR. + // + // PARDES_TEST_GRID=1 is retained as a debug escape hatch: no SDL/GPU/window, + // stdin→events, retained cell grid→stdout. The real SDL e2e suite does not + // use it because it bypasses the renderer. + const test_mode = env_map.get("PARDES_TEST") != null; + const grid_test_mode = env_map.get("PARDES_TEST_GRID") != null; + if (grid_test_mode) return initTest(io, alloc); + const capture_dir = env_map.get("PARDES_TEST_CAPTURE_DIR"); + if (test_mode and capture_dir == null) { + log.err("PARDES_TEST requires PARDES_TEST_CAPTURE_DIR", .{}); + return error.SdlInit; + } + const trace_enabled = envFlag(env_map, "PARDES_SDL_TRACE"); + const trace_every = envU32(env_map, "PARDES_SDL_TRACE_EVERY") orelse 1; + var trace_fd: c_int = -1; + if (trace_enabled) { + if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| { + trace_fd = openTraceFile(path); + } else if (capture_dir) |dir| { + var path_buf: [4096]u8 = undefined; + const path = std.fmt.bufPrintZ(&path_buf, "{s}/perf.log", .{dir}) catch null; + trace_fd = if (path) |p| openTraceFile(p) else 2; + } else { + trace_fd = 2; + } + } + + if (!c.SDL_Init(c.SDL_INIT_VIDEO)) { + log.err("SDL_Init: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + + const win_w: c_int = 1120; + const win_h: c_int = 720; + const win_flags: c.SDL_WindowFlags = if (test_mode) + c.SDL_WINDOW_RESIZABLE + else + c.SDL_WINDOW_HIGH_PIXEL_DENSITY | c.SDL_WINDOW_RESIZABLE; + const sdl_win = c.SDL_CreateWindow( + "pardes", + win_w, + win_h, + win_flags, + ) orelse { + log.err("SDL_CreateWindow: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + + const device = c.SDL_CreateGPUDevice( + c.SDL_GPU_SHADERFORMAT_SPIRV, + true, + null, + ) orelse { + log.err("SDL_CreateGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + }; + if (!c.SDL_ClaimWindowForGPUDevice(device, sdl_win)) { + log.err("ClaimWindowForGPUDevice: {s}", .{c.SDL_GetError()}); + return error.SdlInit; + } + const present_mode = choosePresentMode(env_map, device, sdl_win); + if (!test_mode) _ = c.SDL_SetGPUAllowedFramesInFlight(device, 1); + _ = c.SDL_SetGPUSwapchainParameters( + device, + sdl_win, + c.SDL_GPU_SWAPCHAINCOMPOSITION_SDR, + present_mode, + ); + const swapchain_format = c.SDL_GetGPUSwapchainTextureFormat(device, sdl_win); + + // ---- font + cell metrics ---- + const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { + log.err("ui_font_new failed", .{}); + return error.FontInit; + }; + const px: f32 = 18.0; + const scale = c.ui_font_scale_for_height(font, px); + var cw: c_int = 10; + var chh: c_int = 20; + var asc: c_int = 16; + c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); + if (test_mode) { + const cols = envU16(env_map, "PARDES_TEST_COLS") orelse 80; + const rows = envU16(env_map, "PARDES_TEST_ROWS") orelse 24; + _ = c.SDL_SetWindowSize( + sdl_win, + @intCast(@as(u32, cols) * @as(u32, @intCast(@max(cw, 1)))), + @intCast(@as(u32, rows) * @as(u32, @intCast(@max(chh, 1)))), + ); + _ = c.SDL_SyncWindow(sdl_win); + } + + // ---- glyph atlas (R8) ---- + 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; + + // ---- pipeline ---- + const pipeline = try makePipeline(device, swapchain_format); + + const atlas_stage = try alloc.alloc(u8, atlas_w * atlas_h); + @memset(atlas_stage, 0); + + var self: SdlBackend = .{ + .io = io, + .alloc = alloc, + .sdl_window = sdl_win, + .device = device, + .swapchain_format = swapchain_format, + .present_mode = present_mode, + .pipeline = pipeline, + .atlas_tex = atlas_tex, + .atlas_sampler = atlas_sampler, + .atlas_xfer = atlas_xfer, + .vbuf = undefined, + .vxfer = undefined, + .font = font, + .scale = scale, + .cell_w = @intCast(@max(cw, 1)), + .cell_h = @intCast(@max(chh, 1)), + .ascent = asc, + .screen = .{}, + .atlas_stage = atlas_stage, + .glyphs = std.AutoHashMap(u32, Slot).init(alloc), + .test_mode = test_mode, + .capture_images = test_mode, + .capture_dir = capture_dir, + .trace_enabled = trace_enabled, + .trace_fd = trace_fd, + .trace_every = @max(trace_every, 1), + }; + // reserve slot (0,0) for the space glyph (an empty cell), start the pen after it. + _ = c.ui_font_raster(font, scale, ' ', atlas_stage.ptr, @intCast(atlas_w), @intCast(self.cell_w), @intCast(self.cell_h), asc); + self.space_slot = .{ .u = 0, .v = 0 }; + self.pen_x = self.cell_w; + self.tracef("sdl.init present={s} frames_in_flight={d} trace_every={d}", .{ + presentModeName(self.present_mode), + if (test_mode) @as(u32, 2) else @as(u32, 1), + self.trace_every, + }); + return self; +} + +// Headless debug init for PARDES_TEST_GRID=1: no SDL/GPU/window, just the cell +// grid. Input comes from stdin (the pty), output goes to stdout (grid text +// snapshots). Cell metrics are dummies: the pty gives us rows/cols directly. +fn initTest(io: std.Io, alloc: std.mem.Allocator) !SdlBackend { + return .{ + .io = io, + .alloc = alloc, + .sdl_window = undefined, + .device = undefined, + .swapchain_format = undefined, + .present_mode = c.SDL_GPU_PRESENTMODE_VSYNC, + .pipeline = undefined, + .atlas_tex = undefined, + .atlas_sampler = undefined, + .atlas_xfer = undefined, + .vbuf = undefined, + .vxfer = undefined, + .font = null, + .scale = 1, + .cell_w = 10, + .cell_h = 20, + .ascent = 16, + .screen = .{}, + .atlas_stage = &.{}, + .glyphs = std.AutoHashMap(u32, Slot).init(alloc), + .test_mode = true, + .grid_test_mode = true, + }; +} + +pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writer) void { + _ = writer; + if (self.grid_test_mode) { + self.screen.deinit(self.alloc); + self.glyphs.deinit(); + return; + } + if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t); + if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x); + if (self.trace_fd >= 0 and self.trace_fd != 2) _ = linux.close(self.trace_fd); + c.SDL_ReleaseGPUBuffer(self.device, self.vbuf); + c.SDL_ReleaseGPUTransferBuffer(self.device, self.vxfer); + self.alloc.free(self.cell_keys); + self.alloc.free(self.dirty_ranges); + c.SDL_ReleaseGPUTransferBuffer(self.device, self.atlas_xfer); + c.SDL_ReleaseGPUSampler(self.device, self.atlas_sampler); + c.SDL_ReleaseGPUTexture(self.device, self.atlas_tex); + c.SDL_ReleaseGPUGraphicsPipeline(self.device, self.pipeline); + if (self.font) |f| c.ui_font_free(f); + self.screen.deinit(self.alloc); + self.glyphs.deinit(); + if (alloc) |a| a.free(self.atlas_stage); + c.SDL_ReleaseWindowFromGPUDevice(self.device, self.sdl_window); + c.SDL_DestroyWindow(self.sdl_window); + c.SDL_DestroyGPUDevice(self.device); + c.SDL_Quit(); +} + +// ---- vaxis-compatible surface ---- + +pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Writer, ws: Winsize) !void { + _ = writer; + if (ws.cols == 0 or ws.rows == 0) return; // ignore zero-size (no pty yet) + self.screen.deinit(alloc); + self.screen = try vaxis.Screen.init(alloc, ws); + self.screen.width_method = .wcwidth; + if (self.capture_images) { + _ = c.SDL_SetWindowSize( + self.sdl_window, + @intCast(@as(u32, ws.cols) * self.cell_w), + @intCast(@as(u32, ws.rows) * self.cell_h), + ); + _ = c.SDL_SyncWindow(self.sdl_window); + } + if (!self.grid_test_mode) try self.ensureVbuf(ws.cols * ws.rows); +} + +pub fn window(self: *SdlBackend) vaxis.Window { + return .{ + .x_off = 0, + .y_off = 0, + .parent_x_off = 0, + .parent_y_off = 0, + .width = self.screen.width, + .height = self.screen.height, + .screen = &self.screen, + }; +} + +pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void { + _ = writer; + if (self.grid_test_mode) return self.dumpGrid(); + + const trace_this = self.trace_enabled and (self.trace_frame % self.trace_every == 0); + const t0 = nowNs(); + const cmd = c.SDL_AcquireGPUCommandBuffer(self.device) orelse return; + const t_cmd = nowNs(); + var sw: u32 = 0; + var sh: u32 = 0; + var target: *c.SDL_GPUTexture = undefined; + if (self.capture_images) { + sw = @as(u32, self.screen.width) * self.cell_w; + sh = @as(u32, self.screen.height) * self.cell_h; + if (sw == 0 or sh == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + try self.ensureCaptureTexture(sw, sh); + target = self.capture_tex orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + } else { + var swap_tex: ?*c.SDL_GPUTexture = null; + const t_acquire0 = nowNs(); + if (!c.SDL_AcquireGPUSwapchainTexture(cmd, self.sdl_window, &swap_tex, &sw, &sh)) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + const t_acquire1 = nowNs(); + if (trace_this) self.tracef("sdl.event swapchain_acquire_us={d}", .{nsToUs(t_acquire1 - t_acquire0)}); + target = swap_tex orelse { + if (trace_this) { + self.tracef("sdl.frame n={d} skipped=swapchain_busy acquire_us={d}", .{ self.trace_frame, nsToUs(t_acquire1 - t_acquire0) }); + } + self.trace_frame += 1; + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }; + } + const t_acquired = nowNs(); + const win_w: f32 = @floatFromInt(sw); + const win_h: f32 = @floatFromInt(sh); + + const cols = self.screen.width; + const rows = self.screen.height; + const cells: u32 = @as(u32, cols) * @as(u32, rows); + var t_mapped = t_acquired; + var t_built = t_acquired; + var t_atlas = t_acquired; + var t_uploaded = t_acquired; + var t_drawn = t_acquired; + var dirty_cells: u32 = 0; + var dirty_ranges: u32 = 0; + var atlas_uploaded = false; + + // 1. build + upload the vertex buffer (one quad per cell). + if (cells != 0 and self.vbuf_cells >= cells) { + try self.ensureVbuf(cells); // no-op if already sized; safe guard + const force_full_upload = !self.cell_cache_valid or self.last_render_w != sw or self.last_render_h != sh; + const vptr: [*]u8 = @ptrCast(c.SDL_MapGPUTransferBuffer(self.device, self.vxfer, false) orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + }); + const instances: [*]CellInstance = @ptrCast(@alignCast(vptr)); + t_mapped = nowNs(); + const cw_f: f32 = @floatFromInt(self.cell_w); + const ch_f: f32 = @floatFromInt(self.cell_h); + var r: u16 = 0; + while (r < rows) : (r += 1) { + var col: u16 = 0; + while (col < cols) : (col += 1) { + const cell_index: u32 = @as(u32, r) * @as(u32, cols) + @as(u32, col); + const key = self.cellKey(self.screen.buf[cell_index]); + if (!force_full_upload and sameCellKey(self.cell_keys[cell_index], key)) continue; + self.cell_keys[cell_index] = key; + self.addDirtyRange(&dirty_ranges, cell_index); + dirty_cells += 1; + self.emitInstance(instances, cell_index, col, r, cw_f, ch_f, win_w, win_h, key); + } + } + c.SDL_UnmapGPUTransferBuffer(self.device, self.vxfer); + t_built = nowNs(); + self.cell_cache_valid = true; + self.last_render_w = sw; + self.last_render_h = sh; + + // `emitQuad` calls ensureGlyph, which may rasterize new glyphs into the + // atlas staging buffer. Upload after vertex generation so the first + // rendered frame has the glyphs it references. + if (self.atlas_dirty) { + self.uploadAtlas(cmd); + atlas_uploaded = true; + } + t_atlas = nowNs(); + + if (dirty_cells != 0) { + const copy = c.SDL_BeginGPUCopyPass(cmd); + var i: u32 = 0; + while (i < dirty_ranges) : (i += 1) { + const range = self.dirty_ranges[i]; + const byte_offset: u32 = range.start * @sizeOf(CellInstance); + const byte_size: u32 = range.count * @sizeOf(CellInstance); + const src = c.SDL_GPUTransferBufferLocation{ .transfer_buffer = self.vxfer, .offset = byte_offset }; + const dst = c.SDL_GPUBufferRegion{ .buffer = self.vbuf, .offset = byte_offset, .size = byte_size }; + c.SDL_UploadToGPUBuffer(copy, &src, &dst, false); + } + c.SDL_EndGPUCopyPass(copy); + } + t_uploaded = nowNs(); + + // 3. render pass: clear to the page bg, draw every cell's quad. + var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + color_target.texture = target; + color_target.clear_color = .{ + .r = @as(f32, @floatFromInt(self.bg_default[0])) / 255.0, + .g = @as(f32, @floatFromInt(self.bg_default[1])) / 255.0, + .b = @as(f32, @floatFromInt(self.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; + + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + c.SDL_BindGPUGraphicsPipeline(rp, self.pipeline); + const samp_binding = c.SDL_GPUTextureSamplerBinding{ .texture = self.atlas_tex, .sampler = self.atlas_sampler }; + c.SDL_BindGPUFragmentSamplers(rp, 0, &samp_binding, 1); + const vbinding = c.SDL_GPUBufferBinding{ .buffer = self.vbuf, .offset = 0 }; + c.SDL_BindGPUVertexBuffers(rp, 0, &vbinding, 1); + c.SDL_DrawGPUPrimitives(rp, 6, cells, 0, 0); + c.SDL_EndGPURenderPass(rp); + t_drawn = nowNs(); + } else { + // no grid yet (or vbuf not sized): just clear the swapchain to the page bg. + var color_target = std.mem.zeroes(c.SDL_GPUColorTargetInfo); + color_target.texture = target; + color_target.clear_color = .{ + .r = @as(f32, @floatFromInt(self.bg_default[0])) / 255.0, + .g = @as(f32, @floatFromInt(self.bg_default[1])) / 255.0, + .b = @as(f32, @floatFromInt(self.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; + const rp = c.SDL_BeginGPURenderPass(cmd, &color_target, 1, null); + c.SDL_EndGPURenderPass(rp); + t_drawn = nowNs(); + } + + if (self.capture_images) { + const t_capture0 = nowNs(); + try self.captureSwapchain(cmd, target, sw, sh); + const t_done = nowNs(); + if (trace_this) self.traceFrame(cells, dirty_cells, dirty_ranges, sw, sh, atlas_uploaded, t0, t_cmd, t_acquired, t_mapped, t_built, t_atlas, t_uploaded, t_drawn, t_capture0, t_done); + self.trace_frame += 1; + return; + } + + const t_submit0 = nowNs(); + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + const t_done = nowNs(); + if (trace_this) self.traceFrame(cells, dirty_cells, dirty_ranges, sw, sh, atlas_uploaded, t0, t_cmd, t_acquired, t_mapped, t_built, t_atlas, t_uploaded, t_drawn, t_submit0, t_done); + self.trace_frame += 1; +} + +// PARDES_TEST_GRID debug mode: dump the retained cell grid as text to stdout +// (fd 1). One frame per render, delimited by ---FRAME---/---ENDFRAME--- markers. +fn dumpGrid(self: *SdlBackend) !void { + const w = self.screen.width; + const h = self.screen.height; + if (w == 0 or h == 0) return; + + // Build the whole frame into one buffer, then write it in one syscall so + // the harness sees an atomic snapshot (no torn frames). + var buf: std.ArrayList(u8) = .empty; + defer buf.deinit(self.alloc); + // header: rows cols cursor_x cursor_y cursor_vis + var hdr: [128]u8 = undefined; + const hdr_str = std.fmt.bufPrint(&hdr, "\n---FRAME:{d}:{d}:{d}:{d}:{}---\n", .{ + h, w, self.screen.cursor.col, self.screen.cursor.row, self.screen.cursor_vis, + }) catch return; + buf.appendSlice(self.alloc, hdr_str) catch return; + for (0..h) |row| { + for (0..w) |col| { + const cell = self.screen.buf[row * w + col]; + const g = cell.char.grapheme; + if (g.len > 0) buf.appendSlice(self.alloc, g) catch return else buf.append(self.alloc, ' ') catch return; + } + buf.append(self.alloc, '\n') catch return; + } + buf.appendSlice(self.alloc, "---ENDFRAME---\n") catch return; + + // write to stdout (fd 1) + var off: usize = 0; + while (off < buf.items.len) { + const rc = linux.write(1, buf.items[off..].ptr, buf.items.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return; + } + off += @intCast(n); + } +} + +fn captureSwapchain(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer, target: *c.SDL_GPUTexture, sw: u32, sh: u32) !void { + if (sw == 0 or sh == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return; + } + const size = c.SDL_CalculateGPUTextureFormatSize(self.swapchain_format, sw, sh, 1); + if (size == 0) { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.UnsupportedCaptureFormat; + } + try self.ensureCaptureXfer(size); + + const xfer = self.capture_xfer orelse { + _ = c.SDL_SubmitGPUCommandBuffer(cmd); + return error.GpuCreate; + }; + 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(self.device, fence); + var fences = [_]*c.SDL_GPUFence{fence}; + if (!c.SDL_WaitForGPUFences(self.device, true, &fences, 1)) return error.GpuSubmit; + + const mapped = c.SDL_MapGPUTransferBuffer(self.device, xfer, false) orelse return error.GpuMap; + defer c.SDL_UnmapGPUTransferBuffer(self.device, xfer); + const pixels: [*]const u8 = @ptrCast(mapped); + try self.writeCapturePpm(pixels[0..size], sw, sh); +} + +fn ensureCaptureTexture(self: *SdlBackend, width: u32, height: u32) !void { + if (self.capture_tex != null and self.capture_tex_w == width and self.capture_tex_h == height) return; + if (self.capture_tex) |t| c.SDL_ReleaseGPUTexture(self.device, t); + var info = std.mem.zeroes(c.SDL_GPUTextureCreateInfo); + info.type = c.SDL_GPU_TEXTURETYPE_2D; + info.format = self.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; + self.capture_tex = c.SDL_CreateGPUTexture(self.device, &info) orelse return error.GpuCreate; + self.capture_tex_w = width; + self.capture_tex_h = height; +} + +fn ensureCaptureXfer(self: *SdlBackend, size: u32) !void { + if (self.capture_xfer != null and self.capture_xfer_size >= size) return; + if (self.capture_xfer) |x| c.SDL_ReleaseGPUTransferBuffer(self.device, x); + var info = c.SDL_GPUTransferBufferCreateInfo{ + .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_DOWNLOAD, + .size = size, + .props = 0, + }; + self.capture_xfer = c.SDL_CreateGPUTransferBuffer(self.device, &info) orelse return error.GpuCreate; + self.capture_xfer_size = size; +} + +fn writeCapturePpm(self: *SdlBackend, pixels: []const u8, width: u32, height: u32) !void { + const dir = self.capture_dir orelse return; + const bpp = c.SDL_GPUTextureFormatTexelBlockSize(self.swapchain_format); + if (bpp != 4) return error.UnsupportedCaptureFormat; + + var tmp_buf: [4096]u8 = undefined; + var final_buf: [4096]u8 = undefined; + const tmp_path = std.fmt.bufPrintZ(&tmp_buf, "{s}/latest.ppm.tmp", .{dir}) catch return error.CapturePathTooLong; + const final_path = std.fmt.bufPrintZ(&final_buf, "{s}/latest.ppm", .{dir}) catch return error.CapturePathTooLong; + + const fd_rc = linux.open(tmp_path.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const fd_s: isize = @bitCast(fd_rc); + if (fd_s < 0) return error.CaptureWriteFailed; + const fd: c_int = @intCast(fd_s); + defer _ = linux.close(fd); + + var header: [64]u8 = undefined; + const hdr = std.fmt.bufPrint(&header, "P6\n{d} {d}\n255\n", .{ width, height }) catch return error.CaptureWriteFailed; + try writeAllFd(fd, hdr); + + const row_rgb = try self.alloc.alloc(u8, @as(usize, width) * 3); + defer self.alloc.free(row_rgb); + for (0..height) |row| { + const row_start = @as(usize, row) * @as(usize, width) * 4; + const src = pixels[row_start .. row_start + @as(usize, width) * 4]; + var x: usize = 0; + while (x < @as(usize, width)) : (x += 1) { + const si = x * 4; + const di = x * 3; + switch (self.swapchain_format) { + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM, + c.SDL_GPU_TEXTUREFORMAT_R8G8B8A8_UNORM_SRGB, + => { + row_rgb[di + 0] = src[si + 0]; + row_rgb[di + 1] = src[si + 1]; + row_rgb[di + 2] = src[si + 2]; + }, + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM, + c.SDL_GPU_TEXTUREFORMAT_B8G8R8A8_UNORM_SRGB, + => { + row_rgb[di + 0] = src[si + 2]; + row_rgb[di + 1] = src[si + 1]; + row_rgb[di + 2] = src[si + 0]; + }, + else => return error.UnsupportedCaptureFormat, + } + } + try writeAllFd(fd, row_rgb); + } + + const rr = linux.rename(tmp_path.ptr, final_path.ptr); + const rs: isize = @bitCast(rr); + if (rs < 0) return error.CaptureWriteFailed; + self.capture_frame += 1; +} + +fn writeAllFd(fd: c_int, bytes: []const u8) !void { + var off: usize = 0; + while (off < bytes.len) { + const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return error.CaptureWriteFailed; + } + if (n == 0) return error.CaptureWriteFailed; + off += @intCast(n); + } +} + +fn openTraceFile(path: []const u8) c_int { + var buf: [4096]u8 = undefined; + const z = std.fmt.bufPrintZ(&buf, "{s}", .{path}) catch return 2; + const fd_rc = linux.open(z.ptr, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, 0o644); + const fd_s: isize = @bitCast(fd_rc); + if (fd_s < 0) return 2; + return @intCast(fd_s); +} + +fn writeAllFdIgnore(fd: c_int, bytes: []const u8) void { + var off: usize = 0; + while (off < bytes.len) { + const rc = linux.write(fd, bytes[off..].ptr, bytes.len - off); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + return; + } + if (n == 0) return; + off += @intCast(n); + } +} + +fn tracef(self: *SdlBackend, comptime fmt: []const u8, args: anytype) void { + if (!self.trace_enabled or self.trace_fd < 0) return; + var buf: [1024]u8 = undefined; + const line = std.fmt.bufPrint(&buf, fmt ++ "\n", args) catch return; + writeAllFdIgnore(self.trace_fd, line); +} + +fn traceFrame( + self: *SdlBackend, + cells: u32, + dirty_cells: u32, + dirty_ranges: u32, + sw: u32, + sh: u32, + atlas_uploaded: bool, + t0: i128, + t_cmd: i128, + t_acquired: i128, + t_mapped: i128, + t_built: i128, + t_atlas: i128, + t_uploaded: i128, + t_drawn: i128, + t_submit0: i128, + t_done: i128, +) void { + self.tracef( + "sdl.frame n={d} size={d}x{d} cells={d} dirty={d} ranges={d} atlas={} cmd_us={d} acquire_us={d} map_us={d} build_us={d} atlas_us={d} upload_us={d} draw_us={d} submit_or_capture_us={d} total_us={d}", + .{ + self.trace_frame, + sw, + sh, + cells, + dirty_cells, + dirty_ranges, + atlas_uploaded, + nsToUs(t_cmd - t0), + nsToUs(t_acquired - t_cmd), + nsToUs(t_mapped - t_acquired), + nsToUs(t_built - t_mapped), + nsToUs(t_atlas - t_built), + nsToUs(t_uploaded - t_atlas), + nsToUs(t_drawn - t_uploaded), + nsToUs(t_done - t_submit0), + nsToUs(t_done - t0), + }, + ); +} + +// no-ops for the terminal-protocol surface; SDL needs none of them. +pub fn setMouseMode(self: *SdlBackend, writer: *std.Io.Writer, enable: bool) !void { + _ = self; + _ = writer; + _ = enable; +} +pub fn enterAltScreen(self: *SdlBackend, writer: *std.Io.Writer) !void { + _ = self; + _ = writer; +} +pub fn exitAltScreen(self: *SdlBackend, writer: *std.Io.Writer) !void { + _ = self; + _ = writer; +} +pub fn queryTerminal(self: *SdlBackend, writer: *std.Io.Writer, timeout: std.Io.Duration) !void { + _ = self; + _ = writer; + _ = timeout; +} +pub fn requestSystemClipboard(self: *SdlBackend, writer: *std.Io.Writer) !void { + _ = self; + _ = writer; + // ponytail: the terminal backend gets the clipboard asynchronously via an + // OSC 52 reply (.paste event). SDL could read it synchronously here, but the + // result can't be posted without a loop pointer — left as a TODO for now. +} +pub fn copyToSystemClipboard(self: *SdlBackend, writer: *std.Io.Writer, text: []const u8, encode_allocator: std.mem.Allocator) !void { + _ = self; + _ = writer; + _ = encode_allocator; + var buf: [4096]u8 = undefined; + const z = std.fmt.bufPrintZ(&buf, "{s}", .{text}) catch return; + _ = c.SDL_SetClipboardText(z.ptr); +} +pub fn transmitPreEncodedImage( + self: *SdlBackend, + writer: *std.Io.Writer, + bytes: []const u8, + width: u16, + height: u16, + format: vaxis.Image.TransmitFormat, +) !vaxis.Image { + _ = self; + _ = writer; + _ = bytes; + _ = width; + _ = height; + _ = format; + return error.NoGraphicsCapability; +} + +// ---- internals ---- + +fn ensureVbuf(self: *SdlBackend, cells: u32) !void { + if (self.vbuf_cells == cells and cells != 0) return; + if (self.vbuf_cells != 0) { + c.SDL_ReleaseGPUBuffer(self.device, self.vbuf); + c.SDL_ReleaseGPUTransferBuffer(self.device, self.vxfer); + } + self.alloc.free(self.cell_keys); + self.alloc.free(self.dirty_ranges); + self.cell_keys = &.{}; + self.dirty_ranges = &.{}; + self.cell_cache_valid = false; + self.last_render_w = 0; + self.last_render_h = 0; + const size = cells * @sizeOf(CellInstance); + var vb_info = c.SDL_GPUBufferCreateInfo{ .usage = c.SDL_GPU_BUFFERUSAGE_VERTEX, .size = size, .props = 0 }; + self.vbuf = c.SDL_CreateGPUBuffer(self.device, &vb_info) orelse return error.GpuCreate; + var xf_info = c.SDL_GPUTransferBufferCreateInfo{ .usage = c.SDL_GPU_TRANSFERBUFFERUSAGE_UPLOAD, .size = size, .props = 0 }; + self.vxfer = c.SDL_CreateGPUTransferBuffer(self.device, &xf_info) orelse return error.GpuCreate; + self.cell_keys = try self.alloc.alloc(CellKey, cells); + self.dirty_ranges = try self.alloc.alloc(DirtyRange, cells); + self.vbuf_cells = cells; +} + +fn resolveColor(self: *const SdlBackend, col: vaxis.Color, is_bg: bool) [3]u8 { + return switch (col) { + .default => if (is_bg) self.bg_default else self.fg_default, + .index => |i| ansi[i & 15], + .rgb => |r| r, + }; +} + +fn packRgb(rgb: [3]u8) u32 { + return (@as(u32, rgb[0]) << 16) | (@as(u32, rgb[1]) << 8) | @as(u32, rgb[2]); +} + +fn unpackRgb(rgb: u32) [3]u8 { + return .{ + @intCast((rgb >> 16) & 0xff), + @intCast((rgb >> 8) & 0xff), + @intCast(rgb & 0xff), + }; +} + +fn isDefaultColor(color: vaxis.Color) bool { + return switch (color) { + .default => true, + else => false, + }; +} + +// Decode the first codepoint of a UTF-8 grapheme; 0x20 (" ") on failure/empty. +fn firstCp(s: []const u8) u32 { + if (s.len == 0) return ' '; + const cp = std.unicode.utf8Decode(s[0 .. std.unicode.utf8ByteSequenceLength(s[0]) catch return ' ']) catch return ' '; + return @intCast(cp); +} + +fn cellKey(self: *const SdlBackend, cell: vaxis.Cell) CellKey { + if (cell.char.grapheme.len == 0 and + isDefaultColor(cell.style.fg) and + isDefaultColor(cell.style.bg) and + !cell.style.reverse and + !cell.style.invisible and + !cell.style.dim) + { + return .{ + .cp = ' ', + .fg = packRgb(self.fg_default), + .bg = packRgb(self.bg_default), + }; + } + + var fg = self.resolveColor(cell.style.fg, false); + var bg = self.resolveColor(cell.style.bg, true); + if (cell.style.reverse) { + const t = fg; + fg = bg; + bg = t; + } + if (cell.style.invisible) fg = bg; + if (cell.style.dim) { + fg[0] = fg[0] * 6 / 10; + fg[1] = fg[1] * 6 / 10; + fg[2] = fg[2] * 6 / 10; + } + const cp = if (cell.char.grapheme.len == 0) ' ' else firstCp(cell.char.grapheme); + return .{ .cp = cp, .fg = packRgb(fg), .bg = packRgb(bg) }; +} + +fn sameCellKey(a: CellKey, b: CellKey) bool { + return a.cp == b.cp and a.fg == b.fg and a.bg == b.bg; +} + +fn addDirtyRange(self: *SdlBackend, range_count: *u32, cell_index: u32) void { + if (range_count.* > 0) { + const last = &self.dirty_ranges[range_count.* - 1]; + if (last.start + last.count == cell_index) { + last.count += 1; + return; + } + } + self.dirty_ranges[range_count.*] = .{ .start = cell_index, .count = 1 }; + range_count.* += 1; +} + +fn ensureGlyph(self: *SdlBackend, cp: u32) Slot { + if (self.glyphs.get(cp)) |s| return s; + if (self.pen_x + self.cell_w > atlas_w) { + self.pen_x = 0; + self.pen_y += self.cell_h; + } + if (self.pen_y + self.cell_h > atlas_h) return self.space_slot; // atlas full: fall back + const su = self.pen_x; + const sv = self.pen_y; + const out = self.atlas_stage.ptr + @as(usize, sv) * atlas_w + su; + _ = c.ui_font_raster( + self.font.?, + self.scale, + @intCast(cp), + out, + @intCast(atlas_w), + @intCast(self.cell_w), + @intCast(self.cell_h), + self.ascent, + ); + const s = Slot{ .u = su, .v = sv }; + self.glyphs.put(cp, s) catch return self.space_slot; + self.pen_x += self.cell_w; + self.atlas_dirty = true; + return s; +} + +fn emitInstance( + self: *SdlBackend, + instances: [*]CellInstance, + cell_index: u32, + col: u16, + row: u16, + cw_f: f32, + ch_f: f32, + win_w: f32, + win_h: f32, + key: CellKey, +) void { + // cell pixel rect (top-left origin) + const px0 = @as(f32, @floatFromInt(col)) * cw_f; + const py0 = @as(f32, @floatFromInt(row)) * ch_f; + const px1 = px0 + cw_f; + const py1 = py0 + ch_f; + // -> NDC (SDL GPU clip space: x right, y up; framebuffer row 0 = top) + const x0 = (px0 / win_w) * 2.0 - 1.0; + const x1 = (px1 / win_w) * 2.0 - 1.0; + const y0 = 1.0 - (py0 / win_h) * 2.0; + const y1 = 1.0 - (py1 / win_h) * 2.0; + + const fg = unpackRgb(key.fg); + const bg = unpackRgb(key.bg); + const fgn: [3]f32 = .{ + @as(f32, @floatFromInt(fg[0])) / 255.0, + @as(f32, @floatFromInt(fg[1])) / 255.0, + @as(f32, @floatFromInt(fg[2])) / 255.0, + }; + const bgn: [3]f32 = .{ + @as(f32, @floatFromInt(bg[0])) / 255.0, + @as(f32, @floatFromInt(bg[1])) / 255.0, + @as(f32, @floatFromInt(bg[2])) / 255.0, + }; + + // glyph slot (the space slot covers blanks, which sample alpha=0 → bg only) + const slot = if (key.cp == ' ') self.space_slot else self.ensureGlyph(key.cp); + const aw_f: f32 = @floatFromInt(atlas_w); + const ah_f: f32 = @floatFromInt(atlas_h); + const u_start = @as(f32, @floatFromInt(slot.u)) / aw_f; + const v_start = @as(f32, @floatFromInt(slot.v)) / ah_f; + const u_end = @as(f32, @floatFromInt(slot.u + self.cell_w)) / aw_f; + const v_end = @as(f32, @floatFromInt(slot.v + self.cell_h)) / ah_f; + + instances[cell_index] = .{ + .x0 = x0, + .y0 = y0, + .x1 = x1, + .y1 = y1, + .u0 = u_start, + .v0 = v_start, + .u1 = u_end, + .v1 = v_end, + .fr = fgn[0], + .fg = fgn[1], + .fb = fgn[2], + .br = bgn[0], + .bg = bgn[1], + .bb = bgn[2], + }; +} + +fn uploadAtlas(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer) void { + const ptr = c.SDL_MapGPUTransferBuffer(self.device, self.atlas_xfer, false) orelse return; + const dst: [*]u8 = @ptrCast(ptr); + @memcpy(dst[0 .. atlas_w * atlas_h], self.atlas_stage); + c.SDL_UnmapGPUTransferBuffer(self.device, self.atlas_xfer); + + const copy = c.SDL_BeginGPUCopyPass(cmd); + const src = c.SDL_GPUTextureTransferInfo{ + .transfer_buffer = self.atlas_xfer, + .offset = 0, + .pixels_per_row = atlas_w, + .rows_per_layer = atlas_h, + }; + const dstregion = c.SDL_GPUTextureRegion{ + .texture = self.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); + self.atlas_dirty = false; +} + +fn makePipeline(device: *c.SDL_GPUDevice, color_format: c.SDL_GPUTextureFormat) !*c.SDL_GPUGraphicsPipeline { + const vs = try makeShader(device, vert_spv_bytes, c.SDL_GPU_SHADERSTAGE_VERTEX, 0); + defer c.SDL_ReleaseGPUShader(device, vs); + const fs = try makeShader(device, frag_spv_bytes, 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 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; +} + +// ===================================================================== +// Tty stub — the app threads a `*std.Io.Writer` through every backend call; SDL +// needs none of it, so we back it with /dev/null. The pointer just has to stay +// valid for the app's lifetime. +// ===================================================================== + +const SdlTty = struct { + file: std.Io.File, + tty_writer: std.Io.File.Writer, + + pub fn init(io: std.Io, buffer: []u8) !SdlTty { + const f = try std.Io.Dir.openFileAbsolute(io, "/dev/null", .{ .mode = .read_write }); + return .{ .file = f, .tty_writer = f.writerStreaming(io, buffer) }; + } + pub fn deinit(self: SdlTty, io: std.Io) void { + self.file.close(io); + } + pub fn writer(self: *SdlTty) *std.Io.Writer { + return &self.tty_writer.interface; + } +}; + +// ===================================================================== +// Event loop — polls SDL on the main thread and feeds the same `Event` union +// the app expects onto a thread-safe queue (so per-terminal pty readers, which +// call postEvent from their own threads, keep working unchanged). +// ===================================================================== + +fn SdlLoop(comptime EventType: type) type { + return struct { + const Self = @This(); + + io: std.Io, + vx: *SdlBackend, + queue: vaxis.Queue(EventType, 512), + // live keyboard modifier state (updated on every key down/up), so + // SDL_TEXT_INPUT — which carries no modifiers — can attach them. + live_mods: vaxis.Key.Modifiers = .{}, + last_cols: u16 = 0, + last_rows: u16 = 0, + // test-mode stdin reader task + quit flag + reader: ?std.Io.Future(void) = null, + should_quit: bool = false, + + pub fn init(io: std.Io, tty: *SdlTty, vx: *SdlBackend) Self { + _ = tty; + return .{ .io = io, .vx = vx, .queue = .init(io) }; + } + + pub fn start(self: *Self) !void { + if (self.vx.test_mode) { + setStdinRaw() catch {}; + // e2e: spawn a stdin reader that parses terminal escape sequences + // (vaxis.Parser) into Key/Mouse/Winsize events and posts them. + self.reader = try self.io.concurrent(Self.stdinRun, .{self}); + return; + } + // start delivering text input events (for printable keys) + _ = c.SDL_StartTextInput(self.vx.sdl_window); + // report the initial window size so the app sizes its grid + self.postWinsize(); + } + pub fn stop(self: *Self) void { + if (self.vx.test_mode) { + self.should_quit = true; + if (self.reader) |*r| r.await(self.io); + self.reader = null; + return; + } + } + pub fn installResizeHandler(self: *Self) !void { + if (self.vx.test_mode) { + var act = posix.Sigaction{ + .handler = .{ .handler = winchHandler }, + .mask = switch (builtin.os.tag) { + .macos => 0, + else => posix.sigemptyset(), + }, + .flags = 0, + }; + posix.sigaction(posix.SIG.WINCH, &act, null); + return; + } + } + + pub fn postEvent(self: *Self, event: EventType) !void { + try self.queue.push(event); + if (!self.vx.test_mode) { + // wake a blocking SDL_WaitEventTimeout in nextEvent (pty reads come + // from other threads; without this the app wouldn't notice them + // until the timeout elapses). + var sev = std.mem.zeroes(c.SDL_Event); + sev.type = c.SDL_EVENT_USER; + _ = c.SDL_PushEvent(&sev); + } + } + + pub fn nextEvent(self: *Self) !EventType { + if (self.vx.test_mode) return try self.queue.pop(); + while (true) { + // pty/queued events first (they're already decoded) + if (try self.queue.tryPop()) |ev| { + if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=queue wait_us=0", .{}); + return ev; + } + var sev = std.mem.zeroes(c.SDL_Event); + const t0 = nowNs(); + if (c.SDL_WaitEventTimeout(&sev, 16)) { + const t1 = nowNs(); + if (try self.dispatch(&sev)) |ev| { + if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=sdl type={d} wait_us={d}", .{ sev.type, nsToUs(t1 - t0) }); + return ev; + } + // else: an SDL event we ignore — loop and re-check the queue + } else { + if (self.vx.trace_enabled) self.vx.tracef("sdl.event source=tick wait_us={d}", .{nsToUs(nowNs() - t0)}); + return .tick; // timeout: a no-op that drives a render + } + } + } + + pub fn tryEvent(self: *Self) !?EventType { + return try self.queue.tryPop(); + } + + fn postWinsize(self: *Self) void { + if (self.vx.test_mode) { + // read winsize from stdin (fd 0 = the pty slave) + var ws: posix.winsize = std.mem.zeroes(posix.winsize); + const iorc = posix.system.ioctl(0, posix.T.IOCGWINSZ, @intFromPtr(&ws)); + if (iorc < 0) return; // fd 0 is not a pty + const cols: u16 = ws.col; + const rows: u16 = ws.row; + if (cols == 0 or rows == 0 or cols > 1000 or rows > 1000) return; + if (cols == self.last_cols and rows == self.last_rows) return; + self.last_cols = cols; + self.last_rows = rows; + self.postEvent(.{ .winsize = .{ + .cols = cols, + .rows = rows, + .x_pixel = ws.xpixel, + .y_pixel = ws.ypixel, + } }) catch {}; + return; + } + var pw: c_int = 0; + var ph: c_int = 0; + _ = c.SDL_GetWindowSizeInPixels(self.vx.sdl_window, &pw, &ph); + const cw: u32 = self.vx.cell_w; + const ch: u32 = self.vx.cell_h; + const cols: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(pw)), cw))); + const rows: u16 = @intCast(@max(1, @divTrunc(@as(u32, @intCast(ph)), ch))); + if (cols == self.last_cols and rows == self.last_rows) return; + self.last_cols = cols; + self.last_rows = rows; + self.postEvent(.{ .winsize = .{ + .cols = cols, + .rows = rows, + .x_pixel = @intCast(pw), + .y_pixel = @intCast(ph), + } }) catch {}; + } + + fn dispatch(self: *Self, sev: *const c.SDL_Event) !?EventType { + switch (sev.type) { + c.SDL_EVENT_QUIT, c.SDL_EVENT_WINDOW_CLOSE_REQUESTED => return .quit, + c.SDL_EVENT_WINDOW_RESIZED, c.SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED => { + self.postWinsize(); + return null; + }, + c.SDL_EVENT_WINDOW_EXPOSED => return .tick, + c.SDL_EVENT_KEY_DOWN => { + const k = sev.key; + self.live_mods = sdlMods(k.mod); + return self.keyDown(k.key, k.mod); + }, + c.SDL_EVENT_KEY_UP => { + self.live_mods = sdlMods(sev.key.mod); + return null; + }, + c.SDL_EVENT_TEXT_INPUT => { + const tptr: [*c]const u8 = sev.text.text; + if (tptr == null) return null; + var tlen: usize = 0; + while (tptr[tlen] != 0) : (tlen += 1) {} + const text: []const u8 = tptr[0..tlen]; + if (text.len == 0) return null; + const len = std.unicode.utf8ByteSequenceLength(text[0]) catch return null; + if (len > text.len) return null; + const cp = std.unicode.utf8Decode(text[0..len]) catch return null; + return EventType{ .key_press = .{ + .codepoint = @intCast(cp), + .text = text[0..len], + .mods = self.live_mods, + } }; + }, + c.SDL_EVENT_MOUSE_BUTTON_DOWN, c.SDL_EVENT_MOUSE_BUTTON_UP => { + const b = sev.button; + const t: vaxis.Mouse.Type = if (b.down) .press else .release; + const button = mouseButton(b.button) orelse return null; + const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(b.x)), @as(i32, @intCast(self.vx.cell_w))))); + const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(b.y)), @as(i32, @intCast(self.vx.cell_h))))); + return EventType{ .mouse = .{ + .col = col, + .row = row, + .button = button, + .mods = sdlMouseMods(self.live_mods), + .type = t, + } }; + }, + c.SDL_EVENT_MOUSE_MOTION => { + const m = sev.motion; + // drag = motion with a button held; the app keys selection + // extension off .drag (it never reads .motion). + const held = heldButton(m.state); + const t: vaxis.Mouse.Type = if (held != null) .drag else .motion; + const button = held orelse .none; + const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(m.x)), @as(i32, @intCast(self.vx.cell_w))))); + const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(m.y)), @as(i32, @intCast(self.vx.cell_h))))); + return EventType{ .mouse = .{ + .col = col, + .row = row, + .button = button, + .mods = sdlMouseMods(self.live_mods), + .type = t, + } }; + }, + c.SDL_EVENT_MOUSE_WHEEL => { + const w = sev.wheel; + const dy = w.y; + const button: vaxis.Mouse.Button = if (dy > 0) .wheel_up else if (dy < 0) .wheel_down else return null; + const col = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(w.mouse_x)), @as(i32, @intCast(self.vx.cell_w))))); + const row = @as(i16, @intCast(@divTrunc(@as(i32, @intFromFloat(w.mouse_y)), @as(i32, @intCast(self.vx.cell_h))))); + return EventType{ .mouse = .{ + .col = col, + .row = row, + .button = button, + .mods = sdlMouseMods(self.live_mods), + .type = .press, + } }; + }, + else => return null, + } + } + + // KEY_DOWN: special keys + ctrl/alt shortcuts. Plain printable keys + // arrive as SDL_TEXT_INPUT instead (so shift/layout map correctly). + fn keyDown(self: *Self, sym: c.SDL_Keycode, mod: c.SDL_Keymod) ?EventType { + const mods = sdlMods(mod); + const cp: u21 = switch (sym) { + c.SDLK_RETURN => vaxis.Key.enter, + c.SDLK_KP_ENTER => vaxis.Key.enter, + c.SDLK_BACKSPACE => vaxis.Key.backspace, + c.SDLK_TAB => vaxis.Key.tab, + c.SDLK_ESCAPE => vaxis.Key.escape, + c.SDLK_SPACE => ' ', + c.SDLK_LEFT => vaxis.Key.left, + c.SDLK_RIGHT => vaxis.Key.right, + c.SDLK_UP => vaxis.Key.up, + c.SDLK_DOWN => vaxis.Key.down, + c.SDLK_HOME => vaxis.Key.home, + c.SDLK_END => vaxis.Key.end, + c.SDLK_PAGEUP => vaxis.Key.page_up, + c.SDLK_PAGEDOWN => vaxis.Key.page_down, + c.SDLK_INSERT => vaxis.Key.insert, + c.SDLK_DELETE => vaxis.Key.delete, + c.SDLK_F1 => vaxis.Key.f1, + c.SDLK_F2 => vaxis.Key.f2, + c.SDLK_F3 => vaxis.Key.f3, + c.SDLK_F4 => vaxis.Key.f4, + c.SDLK_F5 => vaxis.Key.f5, + c.SDLK_F6 => vaxis.Key.f6, + c.SDLK_F7 => vaxis.Key.f7, + c.SDLK_F8 => vaxis.Key.f8, + c.SDLK_F9 => vaxis.Key.f9, + c.SDLK_F10 => vaxis.Key.f10, + c.SDLK_F11 => vaxis.Key.f11, + c.SDLK_F12 => vaxis.Key.f12, + else => blk: { + // letters / digits / punctuation: only emit on a modifier + // shortcut (ctrl/alt/gui); plain printable goes via TEXT_INPUT. + const has_mod = (mod & (c.SDL_KMOD_CTRL | c.SDL_KMOD_ALT | c.SDL_KMOD_GUI)) != 0; + if (!has_mod) break :blk 0; + if (sym >= 'a' and sym <= 'z') break :blk @intCast(sym); + if (sym >= '0' and sym <= '9') break :blk @intCast(sym); + if (sym < 128) break :blk @intCast(sym); + break :blk 0; + }, + }; + if (cp == 0) return null; + _ = self; + return EventType{ .key_press = .{ .codepoint = cp, .mods = mods } }; + } + + // ---- test-mode stdin reader ---- + // Parses terminal escape sequences from fd 0 (the pty slave) into the + // app's Event union and posts them to the queue. Identical to vaxis's + // ttyRun but reading from stdin instead of /dev/tty, and checking a + // SIGWINCH flag for resizes. + fn stdinRun(self: *Self) void { + self.stdinRunTyped() catch {}; + } + + fn stdinRunTyped(self: *Self) !void { + // post the initial winsize (read from stdin) + self.postWinsize(); + + var parser: vaxis.Parser = .{}; + var cache: vaxis.GraphemeCache = .{}; + var buf: [1024]u8 = undefined; + var read_start: usize = 0; + + while (!self.should_quit) { + // check for a pending SIGWINCH (flag set by the signal handler) + if (g_winsize_dirty) { + g_winsize_dirty = false; + self.postWinsize(); + } + + var fds = [_]posix.pollfd{.{ .fd = 0, .events = posix.POLL.IN, .revents = 0 }}; + _ = posix.poll(&fds, 50) catch continue; + if ((fds[0].revents & posix.POLL.IN) == 0) continue; + + const rc = linux.read(0, buf[read_start..].ptr, buf.len - read_start); + const n: isize = @bitCast(rc); + if (n < 0) { + if (n == -@as(isize, @intFromEnum(linux.E.INTR))) continue; + break; + } + if (n == 0) break; + const len: usize = read_start + @as(usize, @intCast(n)); + var seq_start: usize = 0; + while (seq_start < len) { + const result = parser.parse(buf[seq_start..len], null) catch break; + if (result.n == 0) { + // partial sequence — shift to front of buffer + const initial = seq_start; + while (seq_start < len) : (seq_start += 1) { + buf[seq_start - initial] = buf[seq_start]; + } + read_start = len - initial; + break; + } + read_start = 0; + seq_start += result.n; + const event = result.event orelse continue; + // convert vaxis.Event → app EventType and post + switch (event) { + .key_press => |key| { + var mut_key = key; + if (key.text) |text| mut_key.text = cache.put(text); + try self.postEvent(.{ .key_press = mut_key }); + }, + .mouse => |mouse| try self.postEvent(.{ .mouse = mouse }), + .winsize => |ws| try self.postEvent(.{ .winsize = ws }), + .paste => |text| { + try self.postEvent(.{ .paste = text }); + }, + else => {}, + } + } + } + } + }; +} + +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(linux.V.MIN)] = 1; + term.cc[@intFromEnum(linux.V.TIME)] = 0; + try posix.tcsetattr(0, .NOW, term); +} + +// ---- test-mode SIGWINCH handler ---- +// Signal handlers can't take closures, so we just set a flag; the stdin reader +// (SdlLoop.stdinRunTyped) checks it and reads + posts the new winsize. +var g_winsize_dirty: bool = false; + +fn winchHandler(_: posix.SIG) callconv(.c) void { + g_winsize_dirty = true; +} + +fn sdlMouseMods(km: vaxis.Key.Modifiers) vaxis.Mouse.Modifiers { + return .{ .shift = km.shift, .alt = km.alt, .ctrl = km.ctrl }; +} + +fn sdlMods(mod: c.SDL_Keymod) vaxis.Key.Modifiers { + return .{ + .shift = (mod & c.SDL_KMOD_SHIFT) != 0, + .ctrl = (mod & c.SDL_KMOD_CTRL) != 0, + .alt = (mod & c.SDL_KMOD_ALT) != 0, + .super = (mod & c.SDL_KMOD_GUI) != 0, + }; +} + +fn mouseButton(b: u8) ?vaxis.Mouse.Button { + return switch (b) { + 1 => .left, + 2 => .middle, + 3 => .right, + else => null, + }; +} + +// the SDL mouse button bitmask (`state` in a motion event) → the single button +// being dragged with, if any. Left wins on a chord, matching the app's +// "later buttons win" only loosely — good enough for selection drags. +fn heldButton(state: u32) ?vaxis.Mouse.Button { + if ((state & c.SDL_BUTTON_LMASK) != 0) return .left; + if ((state & c.SDL_BUTTON_MMASK) != 0) return .middle; + if ((state & c.SDL_BUTTON_RMASK) != 0) return .right; + return null; +} |
