summaryrefslogtreecommitdiff
path: root/ui_sdl.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-02 20:06:25 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commitbead9f0b78380d1428f40fdab339c1c8f88fa626 (patch)
tree960255de5a2cef661ec5d0ad3220a4b2b1738842 /ui_sdl.zig
parent62ef1882ab7878210f05f06504796849e412c318 (diff)
downloadpardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.tar.gz
pardes-bead9f0b78380d1428f40fdab339c1c8f88fa626.zip
sdl ui backend
Diffstat (limited to 'ui_sdl.zig')
-rw-r--r--ui_sdl.zig1682
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;
+}