summaryrefslogtreecommitdiff
path: root/ui_sdl.zig
diff options
context:
space:
mode:
Diffstat (limited to 'ui_sdl.zig')
-rw-r--r--ui_sdl.zig291
1 files changed, 273 insertions, 18 deletions
diff --git a/ui_sdl.zig b/ui_sdl.zig
index 4e0a5bb2..2e3f8afa 100644
--- a/ui_sdl.zig
+++ b/ui_sdl.zig
@@ -24,10 +24,12 @@ 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");
});
@@ -61,8 +63,10 @@ pub fn Loop(comptime EventType: type) type {
}
const font_ttf = @embedFile("assets/DejaVuSansMono.ttf");
-const vert_spv_bytes = @embedFile("ui.vert.spv");
-const frag_spv_bytes = @embedFile("ui.frag.spv");
+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);
@@ -104,6 +108,15 @@ 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,
@@ -189,6 +202,7 @@ fn envFlag(env_map: *std.process.Environ.Map, name: []const u8) bool {
}
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);
@@ -259,7 +273,7 @@ pub fn init(
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 (!is_emscripten and trace_enabled) {
if (env_map.get("PARDES_SDL_TRACE_FILE")) |path| {
trace_fd = openTraceFile(path);
} else if (capture_dir) |dir| {
@@ -278,10 +292,9 @@ pub fn init(
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;
+ 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,
@@ -291,6 +304,9 @@ pub fn init(
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,
@@ -410,6 +426,98 @@ pub fn init(
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.
@@ -447,6 +555,20 @@ pub fn deinit(self: *SdlBackend, alloc: ?std.mem.Allocator, writer: *std.Io.Writ
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);
@@ -476,7 +598,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write
self.screen.deinit(alloc);
self.screen = try vaxis.Screen.init(alloc, ws);
self.screen.width_method = .wcwidth;
- if (self.capture_images) {
+ if (self.capture_images or is_emscripten) {
_ = c.SDL_SetWindowSize(
self.sdl_window,
@intCast(@as(u32, ws.cols) * self.cell_w),
@@ -484,6 +606,7 @@ pub fn resize(self: *SdlBackend, alloc: std.mem.Allocator, writer: *std.Io.Write
);
_ = c.SDL_SyncWindow(self.sdl_window);
}
+ if (is_emscripten) return;
if (!self.grid_test_mode) try self.ensureVbuf(ws.cols * ws.rows);
}
@@ -501,7 +624,8 @@ pub fn window(self: *SdlBackend) vaxis.Window {
pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void {
_ = writer;
- if (self.grid_test_mode) return self.dumpGrid();
+ 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();
@@ -665,6 +789,69 @@ pub fn render(self: *SdlBackend, writer: *std.Io.Writer) !void {
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 {
@@ -876,6 +1063,7 @@ fn writeAllFdIgnore(fd: c_int, bytes: []const u8) void {
}
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;
@@ -1000,6 +1188,12 @@ fn ensureVbuf(self: *SdlBackend, cells: u32) !void {
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,
@@ -1201,6 +1395,13 @@ fn uploadAtlas(self: *SdlBackend, cmd: *c.SDL_GPUCommandBuffer) void {
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);
@@ -1257,6 +1458,44 @@ fn makeShader(
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
@@ -1264,17 +1503,26 @@ fn makeShader(
// =====================================================================
const SdlTty = struct {
- file: std.Io.File,
- tty_writer: std.Io.File.Writer,
+ 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;
}
};
@@ -1288,6 +1536,7 @@ const SdlTty = struct {
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,
@@ -1298,7 +1547,7 @@ fn SdlLoop(comptime EventType: type) type {
last_cols: u16 = 0,
last_rows: u16 = 0,
// test-mode stdin reader task + quit flag
- reader: ?std.Io.Future(void) = null,
+ reader: Reader = if (is_emscripten) {} else null,
should_quit: bool = false,
pub fn init(io: std.Io, tty: *SdlTty, vx: *SdlBackend) Self {
@@ -1307,7 +1556,7 @@ fn SdlLoop(comptime EventType: type) type {
}
pub fn start(self: *Self) !void {
- if (self.vx.test_mode) {
+ 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.
@@ -1320,7 +1569,7 @@ fn SdlLoop(comptime EventType: type) type {
self.postWinsize();
}
pub fn stop(self: *Self) void {
- if (self.vx.test_mode) {
+ if (!is_emscripten and self.vx.test_mode) {
self.should_quit = true;
if (self.reader) |*r| r.await(self.io);
self.reader = null;
@@ -1328,7 +1577,7 @@ fn SdlLoop(comptime EventType: type) type {
}
}
pub fn installResizeHandler(self: *Self) !void {
- if (self.vx.test_mode) {
+ if (!is_emscripten and self.vx.test_mode) {
var act = posix.Sigaction{
.handler = .{ .handler = winchHandler },
.mask = switch (builtin.os.tag) {
@@ -1355,7 +1604,7 @@ fn SdlLoop(comptime EventType: type) type {
}
pub fn nextEvent(self: *Self) !EventType {
- if (self.vx.test_mode) return try self.queue.pop();
+ 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| {
@@ -1379,11 +1628,17 @@ fn SdlLoop(comptime EventType: type) type {
}
pub fn tryEvent(self: *Self) !?EventType {
- return try self.queue.tryPop();
+ 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 (self.vx.test_mode) {
+ 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));