// 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; const is_emscripten = builtin.os.tag == .emscripten; pub const c = @cImport({ @cDefine("SDL_DISABLE_OLD_NAMES", "1"); @cInclude("SDL3/SDL.h"); if (is_emscripten) @cInclude("GLES3/gl3.h"); @cInclude("font.h"); }); // ---- 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 = if (is_emscripten) "" else @embedFile("ui.vert.spv"); const frag_spv_bytes = if (is_emscripten) "" else @embedFile("ui.frag.spv"); const vert_glsl_es = if (is_emscripten) @embedFile("ui.vert.es.glsl") else ""; const frag_glsl_es = if (is_emscripten) @embedFile("ui.frag.es.glsl") else ""; const 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, // Emscripten/browser path: SDL window + WebGL2 shader renderer. Handles are GL // GLuint/GLint values, kept as plain C integers so native builds need no GL API. gl_context: ?c.SDL_GLContext = null, gl_program: c_uint = 0, gl_atlas_tex: c_uint = 0, gl_vbo: c_uint = 0, gl_u_atlas: c_int = -1, gl_instances: []CellInstance = &.{}, // 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 { if (is_emscripten) return @intCast(c.SDL_GetTicksNS()); 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 (!is_emscripten and 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; var win_flags: c.SDL_WindowFlags = c.SDL_WINDOW_RESIZABLE; if (!test_mode) win_flags |= c.SDL_WINDOW_HIGH_PIXEL_DENSITY; if (is_emscripten) win_flags |= c.SDL_WINDOW_OPENGL; 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; }; if (is_emscripten) { return initGl(io, alloc, sdl_win, test_mode, capture_dir, trace_enabled, trace_fd, trace_every); } 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; } fn initGl( io: std.Io, alloc: std.mem.Allocator, sdl_win: *c.SDL_Window, test_mode: bool, capture_dir: ?[]const u8, trace_enabled: bool, trace_fd: c_int, trace_every: u32, ) !SdlBackend { _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_PROFILE_MASK, c.SDL_GL_CONTEXT_PROFILE_ES); _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MAJOR_VERSION, 3); _ = c.SDL_GL_SetAttribute(c.SDL_GL_CONTEXT_MINOR_VERSION, 0); _ = c.SDL_GL_SetAttribute(c.SDL_GL_DOUBLEBUFFER, 1); const gl_context = c.SDL_GL_CreateContext(sdl_win) orelse { log.err("SDL_GL_CreateContext: {s}", .{c.SDL_GetError()}); return error.SdlInit; }; if (!c.SDL_GL_MakeCurrent(sdl_win, gl_context)) { log.err("SDL_GL_MakeCurrent: {s}", .{c.SDL_GetError()}); return error.SdlInit; } _ = c.SDL_GL_SetSwapInterval(1); const font = c.ui_font_new(font_ttf.ptr, @intCast(font_ttf.len)) orelse { log.err("ui_font_new failed", .{}); return error.FontInit; }; const px: f32 = 18.0; const scale = c.ui_font_scale_for_height(font, px); var cw: c_int = 10; var chh: c_int = 20; var asc: c_int = 16; c.ui_font_cell_metrics(font, scale, &cw, &chh, &asc); const program = try makeGlProgram(); var atlas_tex: c_uint = 0; c.glGenTextures(1, &atlas_tex); c.glBindTexture(c.GL_TEXTURE_2D, atlas_tex); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MIN_FILTER, c.GL_LINEAR); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_MAG_FILTER, c.GL_LINEAR); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_S, c.GL_CLAMP_TO_EDGE); c.glTexParameteri(c.GL_TEXTURE_2D, c.GL_TEXTURE_WRAP_T, c.GL_CLAMP_TO_EDGE); c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); c.glTexImage2D(c.GL_TEXTURE_2D, 0, c.GL_R8, atlas_w, atlas_h, 0, c.GL_RED, c.GL_UNSIGNED_BYTE, null); var vbo: c_uint = 0; c.glGenBuffers(1, &vbo); 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 = 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, .gl_context = gl_context, .gl_program = program, .gl_atlas_tex = atlas_tex, .gl_vbo = vbo, .gl_u_atlas = c.glGetUniformLocation(program, "u_atlas"), .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 = false, .capture_dir = capture_dir, .trace_enabled = trace_enabled, .trace_fd = trace_fd, .trace_every = @max(trace_every, 1), }; _ = 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 browser=webgl2 trace_every={d}", .{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 (is_emscripten) { if (self.gl_vbo != 0) c.glDeleteBuffers(1, &self.gl_vbo); if (self.gl_atlas_tex != 0) c.glDeleteTextures(1, &self.gl_atlas_tex); if (self.gl_program != 0) c.glDeleteProgram(self.gl_program); self.alloc.free(self.gl_instances); 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); if (self.gl_context) |ctx| _ = c.SDL_GL_DestroyContext(ctx); c.SDL_DestroyWindow(self.sdl_window); c.SDL_Quit(); 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 or is_emscripten) { _ = 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 (is_emscripten) return; 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 (!is_emscripten and self.grid_test_mode) return self.dumpGrid(); if (is_emscripten) return self.renderGl(); 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; } fn renderGl(self: *SdlBackend) !void { var pw: c_int = 0; var ph: c_int = 0; _ = c.SDL_GetWindowSizeInPixels(self.sdl_window, &pw, &ph); const sw: u32 = @max(1, @as(u32, @intCast(pw))); const sh: u32 = @max(1, @as(u32, @intCast(ph))); c.glViewport(0, 0, @intCast(sw), @intCast(sh)); c.glClearColor( @as(f32, @floatFromInt(self.bg_default[0])) / 255.0, @as(f32, @floatFromInt(self.bg_default[1])) / 255.0, @as(f32, @floatFromInt(self.bg_default[2])) / 255.0, 1.0, ); c.glClear(c.GL_COLOR_BUFFER_BIT); const cols = self.screen.width; const rows = self.screen.height; const cells: u32 = @as(u32, cols) * @as(u32, rows); if (cells == 0) { _ = c.SDL_GL_SwapWindow(self.sdl_window); return; } try self.ensureGlInstances(cells); const cw_f: f32 = @floatFromInt(self.cell_w); const ch_f: f32 = @floatFromInt(self.cell_h); const win_w: f32 = @floatFromInt(sw); const win_h: f32 = @floatFromInt(sh); 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); self.emitInstance(self.gl_instances.ptr, cell_index, col, r, cw_f, ch_f, win_w, win_h, self.cellKey(self.screen.buf[cell_index])); } } if (self.atlas_dirty) self.uploadAtlasGl(); c.glUseProgram(self.gl_program); c.glActiveTexture(c.GL_TEXTURE0); c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); c.glUniform1i(self.gl_u_atlas, 0); c.glBindBuffer(c.GL_ARRAY_BUFFER, self.gl_vbo); c.glBufferData(c.GL_ARRAY_BUFFER, @intCast(cells * @sizeOf(CellInstance)), self.gl_instances.ptr, c.GL_STREAM_DRAW); const stride: c_int = @intCast(@sizeOf(CellInstance)); c.glEnableVertexAttribArray(0); c.glVertexAttribPointer(0, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "x0"))); c.glVertexAttribDivisor(0, 1); c.glEnableVertexAttribArray(1); c.glVertexAttribPointer(1, 4, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "u0"))); c.glVertexAttribDivisor(1, 1); c.glEnableVertexAttribArray(2); c.glVertexAttribPointer(2, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "fr"))); c.glVertexAttribDivisor(2, 1); c.glEnableVertexAttribArray(3); c.glVertexAttribPointer(3, 3, c.GL_FLOAT, c.GL_FALSE, stride, @ptrFromInt(@offsetOf(CellInstance, "br"))); c.glVertexAttribDivisor(3, 1); c.glDrawArraysInstanced(c.GL_TRIANGLES, 0, 6, @intCast(cells)); _ = c.SDL_GL_SwapWindow(self.sdl_window); } // 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 (is_emscripten) return; 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 ensureGlInstances(self: *SdlBackend, cells: u32) !void { if (self.gl_instances.len >= cells) return; self.alloc.free(self.gl_instances); self.gl_instances = try self.alloc.alloc(CellInstance, 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 uploadAtlasGl(self: *SdlBackend) void { c.glBindTexture(c.GL_TEXTURE_2D, self.gl_atlas_tex); c.glPixelStorei(c.GL_UNPACK_ALIGNMENT, 1); c.glTexSubImage2D(c.GL_TEXTURE_2D, 0, 0, 0, atlas_w, atlas_h, c.GL_RED, c.GL_UNSIGNED_BYTE, self.atlas_stage.ptr); 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; } fn makeGlProgram() !c_uint { const vs = try makeGlShader(c.GL_VERTEX_SHADER, vert_glsl_es); defer c.glDeleteShader(vs); const fs = try makeGlShader(c.GL_FRAGMENT_SHADER, frag_glsl_es); defer c.glDeleteShader(fs); const program = c.glCreateProgram(); if (program == 0) return error.GpuCreate; c.glAttachShader(program, vs); c.glAttachShader(program, fs); c.glLinkProgram(program); var ok: c_int = 0; c.glGetProgramiv(program, c.GL_LINK_STATUS, &ok); if (ok == 0) { log.err("WebGL program link failed", .{}); c.glDeleteProgram(program); return error.GpuCreate; } return program; } fn makeGlShader(kind: c_uint, source: []const u8) !c_uint { const shader = c.glCreateShader(kind); if (shader == 0) return error.GpuCreate; var src_ptr = [_][*c]const u8{@ptrCast(source.ptr)}; var src_len = [_]c_int{@intCast(source.len)}; c.glShaderSource(shader, 1, &src_ptr, &src_len); c.glCompileShader(shader); var ok: c_int = 0; c.glGetShaderiv(shader, c.GL_COMPILE_STATUS, &ok); if (ok == 0) { log.err("WebGL shader compile failed", .{}); c.glDeleteShader(shader); return error.GpuCreate; } return shader; } // ===================================================================== // 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 { const File = if (is_emscripten) void else std.Io.File; const Writer = if (is_emscripten) std.Io.Writer.Discarding else std.Io.File.Writer; file: File, tty_writer: Writer, pub fn init(io: std.Io, buffer: []u8) !SdlTty { if (is_emscripten) return .{ .file = {}, .tty_writer = .init(buffer), }; 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 { if (is_emscripten) return; self.file.close(io); } pub fn writer(self: *SdlTty) *std.Io.Writer { if (is_emscripten) return &self.tty_writer.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(); const Reader = if (is_emscripten) void else ?std.Io.Future(void); 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: Reader = if (is_emscripten) {} else 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 (!is_emscripten and 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 (!is_emscripten and 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 (!is_emscripten and 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 (!is_emscripten and 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 { if (try self.queue.tryPop()) |ev| return ev; if (!is_emscripten and self.vx.test_mode) return null; var sev = std.mem.zeroes(c.SDL_Event); while (c.SDL_PollEvent(&sev)) { if (try self.dispatch(&sev)) |ev| return ev; } return null; } fn postWinsize(self: *Self) void { if (!is_emscripten and 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; }