diff --git a/build.zig b/build.zig index bc78781f..f8df2a99 100644 --- a/build.zig +++ b/build.zig @@ -72,7 +72,10 @@ fn build_development( _: bool, // all_targets test_filters: []const []const u8, ) void { - const target = b.standardTargetOptions(.{ .default_target = .{ .abi = if (builtin.os.tag == .linux and !tracy_enabled) .musl else null } }); + // The gui renderer links system GL/X11 libraries which are not available + // via the musl sysroot, so use the native ABI when building it. + const force_musl = builtin.os.tag == .linux and !tracy_enabled and renderer != .gui; + const target = b.standardTargetOptions(.{ .default_target = .{ .abi = if (force_musl) .musl else null } }); const optimize = b.standardOptimizeOption(.{}); return build_exe( @@ -530,8 +533,71 @@ pub fn build_exe( break :blk mod; }, .gui => { - std.log.err("-Drenderer=gui is not yet implemented", .{}); - std.process.exit(0xff); + const wio_dep = b.lazyDependency("wio", .{ + .target = target, + .optimize = optimize_deps, + .enable_opengl = true, + }) orelse break :blk tui_renderer_mod; + const sokol_dep = b.lazyDependency("sokol", .{ + .target = target, + .optimize = optimize_deps, + .gl = true, + // Requires system packages: libgl-dev libx11-dev libxi-dev + // libxcursor-dev libasound2-dev (Debian/Ubuntu) + }) orelse break :blk tui_renderer_mod; + + const wio_mod = wio_dep.module("wio"); + const sokol_mod = sokol_dep.module("sokol"); + + const gui_xy_mod = b.createModule(.{ .root_source_file = b.path("src/gui/xy.zig") }); + const gui_cell_mod = b.createModule(.{ .root_source_file = b.path("src/gui/Cell.zig") }); + const gui_glyph_cache_mod = b.createModule(.{ .root_source_file = b.path("src/gui/GlyphIndexCache.zig") }); + const gui_xterm_mod = b.createModule(.{ .root_source_file = b.path("src/gui/xterm.zig") }); + + const stub_rasterizer_mod = b.createModule(.{ + .root_source_file = b.path("src/gui/rasterizer/stub.zig"), + .imports = &.{ + .{ .name = "xy", .module = gui_xy_mod }, + }, + }); + + const gpu_mod = b.createModule(.{ + .root_source_file = b.path("src/gui/gpu/gpu.zig"), + .imports = &.{ + .{ .name = "sokol", .module = sokol_mod }, + .{ .name = "rasterizer", .module = stub_rasterizer_mod }, + .{ .name = "xy", .module = gui_xy_mod }, + .{ .name = "Cell", .module = gui_cell_mod }, + .{ .name = "GlyphIndexCache", .module = gui_glyph_cache_mod }, + }, + }); + + const app_mod = b.createModule(.{ + .root_source_file = b.path("src/gui/wio/app.zig"), + .imports = &.{ + .{ .name = "wio", .module = wio_mod }, + .{ .name = "sokol", .module = sokol_mod }, + .{ .name = "gpu", .module = gpu_mod }, + .{ .name = "thespian", .module = thespian_mod }, + .{ .name = "cbor", .module = cbor_mod }, + .{ .name = "vaxis", .module = vaxis_mod }, + .{ .name = "xterm", .module = gui_xterm_mod }, + }, + }); + + const mod = b.createModule(.{ + .root_source_file = b.path("src/renderer/gui/renderer.zig"), + .imports = &.{ + .{ .name = "theme", .module = themes_dep.module("theme") }, + .{ .name = "cbor", .module = cbor_mod }, + .{ .name = "thespian", .module = thespian_mod }, + .{ .name = "input", .module = input_mod }, + .{ .name = "app", .module = app_mod }, + .{ .name = "tuirenderer", .module = tui_renderer_mod }, + .{ .name = "vaxis", .module = vaxis_mod }, + }, + }); + break :blk mod; }, } }; diff --git a/build.zig.zon b/build.zig.zon index 095e111d..c044a80e 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -42,6 +42,14 @@ .hash = "zigwin32-25.0.28-preview-AAAAAICM5AMResOGQnQ85mfe60TTOQeMtt7GRATUOKoP", .lazy = true, }, + .wio = .{ + .path = "../flow-deps/wio", + .lazy = true, + }, + .sokol = .{ + .path = "../flow-deps/sokol-zig", + .lazy = true, + }, .diffz = .{ .url = "git+https://github.com/ziglibs/diffz.git#fbdf690b87db6b1142bbce6d4906f90b09ce60bb", .hash = "diffz-0.0.1-G2tlIezMAQBwGNGDs7Hn_N25dWSjEzgR_FAx9GFAvCuZ", diff --git a/src/gui/gpu/builtin.glsl.zig b/src/gui/gpu/builtin.glsl.zig new file mode 100644 index 00000000..3f9dbf80 --- /dev/null +++ b/src/gui/gpu/builtin.glsl.zig @@ -0,0 +1,134 @@ +// Hand-crafted sokol_gfx ShaderDesc for the builtin grid shader. +// Targets the GLCORE backend only (OpenGL 4.10 core profile). +// +// Vertex stage: full-screen quad from gl_VertexID (no vertex buffer needed) +// Fragment stage: cell-grid renderer — reads a RGBA32UI cell texture and an +// R8 glyph-atlas texture and blends fg over bg per pixel. + +const sg = @import("sokol").gfx; + +// Uniform block slot 0, fragment stage. +// Individual GLSL uniforms (GLCORE uses glUniform* calls, not UBOs). +pub const FsParams = extern struct { + cell_size_x: i32, + cell_size_y: i32, + col_count: i32, + row_count: i32, +}; + +const vs_src = + \\#version 410 + \\void main() { + \\ int id = gl_VertexID; + \\ float x = 2.0 * (float(id & 1) - 0.5); + \\ float y = -(float(id >> 1) - 0.5) * 2.0; + \\ gl_Position = vec4(x, y, 0.0, 1.0); + \\} +; + +const fs_src = + \\#version 410 + \\uniform int cell_size_x; + \\uniform int cell_size_y; + \\uniform int col_count; + \\uniform int row_count; + \\uniform sampler2D glyph_tex_glyph_smp; + \\uniform usampler2D cell_tex_cell_smp; + \\out vec4 frag_color; + \\ + \\vec4 unpack_rgba(uint packed) { + \\ return vec4( + \\ float((packed >> 24u) & 255u) / 255.0, + \\ float((packed >> 16u) & 255u) / 255.0, + \\ float((packed >> 8u) & 255u) / 255.0, + \\ float( packed & 255u) / 255.0 + \\ ); + \\} + \\ + \\void main() { + \\ // Convert gl_FragCoord (bottom-left origin) to top-left origin. + \\ // row_count * cell_size_y >= viewport height (we ceil the division), + \\ // so this formula maps the top screen pixel to row 0. + \\ int px = int(gl_FragCoord.x); + \\ int py = row_count * cell_size_y - 1 - int(gl_FragCoord.y); + \\ int col = px / cell_size_x; + \\ int row = py / cell_size_y; + \\ + \\ if (col >= col_count || row >= row_count || row < 0 || col < 0) { + \\ frag_color = vec4(0.0, 0.0, 0.0, 1.0); + \\ return; + \\ } + \\ + \\ // Fetch cell: texel = (glyph_index, bg_packed, fg_packed, 0) + \\ uvec4 cell = texelFetch(cell_tex_cell_smp, ivec2(col, row), 0); + \\ vec4 bg = unpack_rgba(cell.g); + \\ vec4 fg = unpack_rgba(cell.b); + \\ + \\ // Pixel coordinates within the cell + \\ int cell_px_x = px % cell_size_x; + \\ int cell_px_y = py % cell_size_y; + \\ + \\ // Glyph atlas lookup + \\ ivec2 atlas_size = textureSize(glyph_tex_glyph_smp, 0); + \\ int cells_per_row = atlas_size.x / cell_size_x; + \\ int gi = int(cell.r); + \\ int gc = gi % cells_per_row; + \\ int gr = gi / cells_per_row; + \\ ivec2 atlas_coord = ivec2(gc * cell_size_x + cell_px_x, + \\ gr * cell_size_y + cell_px_y); + \\ float glyph_alpha = texelFetch(glyph_tex_glyph_smp, atlas_coord, 0).r; + \\ + \\ // Blend fg over bg + \\ vec3 color = mix(bg.rgb, fg.rgb, fg.a * glyph_alpha); + \\ frag_color = vec4(color, 1.0); + \\} +; + +pub fn shaderDesc(backend: sg.Backend) sg.ShaderDesc { + var desc: sg.ShaderDesc = .{}; + switch (backend) { + .GLCORE => { + desc.vertex_func.source = vs_src; + desc.fragment_func.source = fs_src; + + // Fragment uniform block: 4 individual INT uniforms + desc.uniform_blocks[0].stage = .FRAGMENT; + desc.uniform_blocks[0].size = @sizeOf(FsParams); + desc.uniform_blocks[0].layout = .NATIVE; + desc.uniform_blocks[0].glsl_uniforms[0] = .{ .type = .INT, .glsl_name = "cell_size_x" }; + desc.uniform_blocks[0].glsl_uniforms[1] = .{ .type = .INT, .glsl_name = "cell_size_y" }; + desc.uniform_blocks[0].glsl_uniforms[2] = .{ .type = .INT, .glsl_name = "col_count" }; + desc.uniform_blocks[0].glsl_uniforms[3] = .{ .type = .INT, .glsl_name = "row_count" }; + + // Glyph atlas texture: R8 → sample_type = FLOAT + desc.views[0].texture = .{ + .stage = .FRAGMENT, + .image_type = ._2D, + .sample_type = .FLOAT, + }; + desc.samplers[0] = .{ .stage = .FRAGMENT, .sampler_type = .NONFILTERING }; + desc.texture_sampler_pairs[0] = .{ + .stage = .FRAGMENT, + .view_slot = 0, + .sampler_slot = 0, + .glsl_name = "glyph_tex_glyph_smp", + }; + + // Cell texture: RGBA32UI → sample_type = UINT + desc.views[1].texture = .{ + .stage = .FRAGMENT, + .image_type = ._2D, + .sample_type = .UINT, + }; + desc.samplers[1] = .{ .stage = .FRAGMENT, .sampler_type = .NONFILTERING }; + desc.texture_sampler_pairs[1] = .{ + .stage = .FRAGMENT, + .view_slot = 1, + .sampler_slot = 1, + .glsl_name = "cell_tex_cell_smp", + }; + }, + else => {}, + } + return desc; +} diff --git a/src/gui/gpu/gpu.zig b/src/gui/gpu/gpu.zig new file mode 100644 index 00000000..b640c809 --- /dev/null +++ b/src/gui/gpu/gpu.zig @@ -0,0 +1,432 @@ +// sokol_gfx GPU backend for the wio-based renderer. +// +// Threading: all public functions must be called from the wio/GL thread +// (the thread that called sg.setup()). + +const std = @import("std"); +const sg = @import("sokol").gfx; +const Rasterizer = @import("rasterizer"); +const GlyphIndexCache = @import("GlyphIndexCache"); +const gui_cell = @import("Cell"); +const XY = @import("xy").XY; +const builtin_shader = @import("builtin.glsl.zig"); + +pub const Font = Rasterizer.Font; +pub const Cell = gui_cell.Cell; +pub const Color = gui_cell.Rgba8; +const Rgba8 = gui_cell.Rgba8; + +const log = std.log.scoped(.gpu); + +// Maximum glyph atlas dimension (matching D3D11_REQ_TEXTURE2D_U_OR_V_DIMENSION) +const max_atlas_dim: u16 = 16384; + +fn getAtlasCellCount(cell_size: XY(u16)) XY(u16) { + return .{ + .x = @intCast(@divTrunc(max_atlas_dim, cell_size.x)), + .y = @intCast(@divTrunc(max_atlas_dim, cell_size.y)), + }; +} + +// Shader cell layout for the RGBA32UI cell texture. +// Each texel encodes one terminal cell: +// .r = glyph_index (u32) +// .g = bg packed (Rgba8 bit-cast to u32: r<<24|g<<16|b<<8|a) +// .b = fg packed (same) +// .a = 0 (reserved) +const ShaderCell = extern struct { + glyph_index: u32, + bg: u32, + fg: u32, + _pad: u32 = 0, +}; + +const global = struct { + var init_called: bool = false; + var rasterizer: Rasterizer = undefined; + var pip: sg.Pipeline = .{}; + var glyph_sampler: sg.Sampler = .{}; + var cell_sampler: sg.Sampler = .{}; + var glyph_cache_arena: std.heap.ArenaAllocator = undefined; + var background: Rgba8 = .{ .r = 19, .g = 19, .b = 19, .a = 255 }; +}; + +pub fn init(allocator: std.mem.Allocator) !void { + std.debug.assert(!global.init_called); + global.init_called = true; + + global.rasterizer = try Rasterizer.init(allocator); + global.glyph_cache_arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + + // Build shader + pipeline + const shd = sg.makeShader(builtin_shader.shaderDesc(sg.queryBackend())); + + var pip_desc: sg.PipelineDesc = .{ .shader = shd }; + pip_desc.primitive_type = .TRIANGLE_STRIP; + pip_desc.color_count = 1; + global.pip = sg.makePipeline(pip_desc); + + // Nearest-neighbour samplers (no filtering) + global.glyph_sampler = sg.makeSampler(.{ + .min_filter = .NEAREST, + .mag_filter = .NEAREST, + .mipmap_filter = .NEAREST, + .wrap_u = .CLAMP_TO_EDGE, + .wrap_v = .CLAMP_TO_EDGE, + }); + global.cell_sampler = sg.makeSampler(.{ + .min_filter = .NEAREST, + .mag_filter = .NEAREST, + .mipmap_filter = .NEAREST, + .wrap_u = .CLAMP_TO_EDGE, + .wrap_v = .CLAMP_TO_EDGE, + }); +} + +pub fn deinit() void { + std.debug.assert(global.init_called); + global.init_called = false; + sg.destroyPipeline(global.pip); + sg.destroySampler(global.glyph_sampler); + sg.destroySampler(global.cell_sampler); + global.glyph_cache_arena.deinit(); + global.rasterizer.deinit(); +} + +pub fn loadFont(name: []const u8, size_px: u16) !Font { + return global.rasterizer.loadFont(name, size_px); +} + +pub fn setBackground(color: Rgba8) void { + global.background = color; +} + +// ── WindowState ──────────────────────────────────────────────────────────── + +pub const WindowState = struct { + // GL window size in pixels + size: XY(u32) = .{ .x = 0, .y = 0 }, + + // Glyph atlas (R8 2D texture + view) + glyph_image: sg.Image = .{}, + glyph_view: sg.View = .{}, + glyph_image_size: XY(u16) = .{ .x = 0, .y = 0 }, + + // Cell grid (RGBA32UI 2D texture + view), updated each frame + cell_image: sg.Image = .{}, + cell_view: sg.View = .{}, + cell_image_size: XY(u16) = .{ .x = 0, .y = 0 }, + cell_buf: std.ArrayListUnmanaged(ShaderCell) = .{}, + + // Glyph index cache + glyph_cache_cell_size: ?XY(u16) = null, + glyph_index_cache: ?GlyphIndexCache = null, + + pub fn init() WindowState { + std.debug.assert(global.init_called); + return .{}; + } + + pub fn deinit(state: *WindowState) void { + if (state.glyph_view.id != 0) sg.destroyView(state.glyph_view); + if (state.glyph_image.id != 0) sg.destroyImage(state.glyph_image); + if (state.cell_view.id != 0) sg.destroyView(state.cell_view); + if (state.cell_image.id != 0) sg.destroyImage(state.cell_image); + state.cell_buf.deinit(global.glyph_cache_arena.allocator()); + if (state.glyph_index_cache) |*c| { + c.deinit(global.glyph_cache_arena.allocator()); + } + state.* = undefined; + } + + // Ensure the glyph atlas image is (at least) the requested pixel size. + // Returns true if the image was retained, false if (re)created. + fn updateGlyphImage(state: *WindowState, pixel_size: XY(u16)) bool { + if (state.glyph_image_size.eql(pixel_size)) return true; + + if (state.glyph_view.id != 0) sg.destroyView(state.glyph_view); + if (state.glyph_image.id != 0) sg.destroyImage(state.glyph_image); + + state.glyph_image = sg.makeImage(.{ + .width = pixel_size.x, + .height = pixel_size.y, + .pixel_format = .R8, + .usage = .{ .dynamic_update = true }, + }); + state.glyph_view = sg.makeView(.{ + .texture = .{ .image = state.glyph_image }, + }); + state.glyph_image_size = pixel_size; + return false; + } + + // Ensure the cell texture is the requested size. + fn updateCellImage(state: *WindowState, allocator: std.mem.Allocator, cols: u16, rows: u16) void { + const needed: u32 = @as(u32, cols) * @as(u32, rows); + const sz: XY(u16) = .{ .x = cols, .y = rows }; + + if (!state.cell_image_size.eql(sz)) { + if (state.cell_view.id != 0) sg.destroyView(state.cell_view); + if (state.cell_image.id != 0) sg.destroyImage(state.cell_image); + + state.cell_image = sg.makeImage(.{ + .width = cols, + .height = rows, + .pixel_format = .RGBA32UI, + .usage = .{ .dynamic_update = true }, + }); + state.cell_view = sg.makeView(.{ + .texture = .{ .image = state.cell_image }, + }); + state.cell_image_size = sz; + } + + if (state.cell_buf.items.len < needed) { + state.cell_buf.resize(allocator, needed) catch |e| oom(e); + } + } + + pub fn generateGlyph( + state: *WindowState, + font: Font, + codepoint: u21, + kind: Rasterizer.GlyphKind, + ) u32 { + const atlas_cell_count = getAtlasCellCount(font.cell_size); + const atlas_total: u32 = @as(u32, atlas_cell_count.x) * @as(u32, atlas_cell_count.y); + const atlas_pixel_size: XY(u16) = .{ + .x = atlas_cell_count.x * font.cell_size.x, + .y = atlas_cell_count.y * font.cell_size.y, + }; + + const atlas_retained = state.updateGlyphImage(atlas_pixel_size); + + const cache_valid = if (state.glyph_cache_cell_size) |s| s.eql(font.cell_size) else false; + state.glyph_cache_cell_size = font.cell_size; + + if (!atlas_retained or !cache_valid) { + if (state.glyph_index_cache) |*c| { + c.deinit(global.glyph_cache_arena.allocator()); + _ = global.glyph_cache_arena.reset(.retain_capacity); + state.glyph_index_cache = null; + } + } + + const cache = blk: { + if (state.glyph_index_cache) |*c| break :blk c; + state.glyph_index_cache = GlyphIndexCache.init( + global.glyph_cache_arena.allocator(), + atlas_total, + ) catch |e| oom(e); + break :blk &(state.glyph_index_cache.?); + }; + + const right_half: bool = switch (kind) { + .single, .left => false, + .right => true, + }; + + switch (cache.reserve( + global.glyph_cache_arena.allocator(), + codepoint, + right_half, + ) catch |e| oom(e)) { + .newly_reserved => |reserved| { + // Rasterize into a staging buffer then upload the relevant + // portion to the atlas. + const staging_w: u32 = @as(u32, font.cell_size.x) * 2; + const staging_h: u32 = font.cell_size.y; + var staging_buf = global.glyph_cache_arena.allocator().alloc( + u8, + staging_w * staging_h, + ) catch |e| oom(e); + defer global.glyph_cache_arena.allocator().free(staging_buf); + @memset(staging_buf, 0); + + global.rasterizer.render(font, codepoint, kind, staging_buf); + + // Atlas cell position for this glyph index + const atlas_col: u16 = @intCast(reserved.index % atlas_cell_count.x); + const atlas_row: u16 = @intCast(reserved.index / atlas_cell_count.x); + const atlas_x: u16 = atlas_col * font.cell_size.x; + const atlas_y: u16 = atlas_row * font.cell_size.y; + + // Source region in the staging buffer + const src_x: u16 = if (right_half) font.cell_size.x else 0; + const glyph_w: u16 = font.cell_size.x; + const glyph_h: u16 = font.cell_size.y; + + // Build a sub-region buffer for sokol updateImage + var region_buf = global.glyph_cache_arena.allocator().alloc( + u8, + @as(u32, glyph_w) * @as(u32, glyph_h), + ) catch |e| oom(e); + defer global.glyph_cache_arena.allocator().free(region_buf); + + for (0..glyph_h) |row_i| { + const src_off = row_i * staging_w + src_x; + const dst_off = row_i * glyph_w; + @memcpy(region_buf[dst_off .. dst_off + glyph_w], staging_buf[src_off .. src_off + glyph_w]); + } + + // Upload to atlas via a temporary full-atlas image data struct. + // sokol's updateImage uploads the whole mip0 slice. We use a + // trick: create a temporary single-cell-sized image, upload it, + // then… actually, sokol doesn't expose sub-rect uploads. + // + // Workaround: upload the WHOLE atlas with only the new cell + // updated. For M2 this is acceptable; a smarter approach + // (ping-pong or persistent mapped buffer) can be added later. + uploadGlyphAtlasCell(state, atlas_x, atlas_y, glyph_w, glyph_h, region_buf); + + return reserved.index; + }, + .already_reserved => |index| return index, + } + } +}; + +// Upload one cell's pixels into the glyph atlas. +// Because sokol only supports full-image updates via sg.updateImage, we +// maintain a CPU-side copy of the atlas and re-upload it each time. +// (M2 budget approach — acceptable for the stub rasterizer that always +// produces blank glyphs anyway.) +var atlas_cpu: ?[]u8 = null; +var atlas_cpu_size: XY(u16) = .{ .x = 0, .y = 0 }; + +fn uploadGlyphAtlasCell( + state: *const WindowState, + x: u16, + y: u16, + w: u16, + h: u16, + pixels: []const u8, +) void { + const asz = state.glyph_image_size; + const total: usize = @as(usize, asz.x) * @as(usize, asz.y); + + // Resize cpu shadow if needed + if (!atlas_cpu_size.eql(asz)) { + if (atlas_cpu) |old| std.heap.page_allocator.free(old); + atlas_cpu = std.heap.page_allocator.alloc(u8, total) catch |e| oom(e); + @memset(atlas_cpu.?, 0); + atlas_cpu_size = asz; + } + + // Blit the cell into the cpu shadow + const buf = atlas_cpu.?; + for (0..h) |row_i| { + const src_off = row_i * w; + const dst_off = (@as(usize, y) + row_i) * asz.x + x; + @memcpy(buf[dst_off .. dst_off + w], pixels[src_off .. src_off + w]); + } + + // Re-upload the full atlas + var img_data: sg.ImageData = .{}; + img_data.mip_levels[0] = .{ .ptr = buf.ptr, .size = total }; + sg.updateImage(state.glyph_image, img_data); +} + +pub fn paint( + state: *WindowState, + client_size: XY(u32), + font: Font, + row_count: u16, + col_count: u16, + top: u16, + cells: []const Cell, +) void { + const shader_col_count: u16 = @intCast(@divTrunc(client_size.x + font.cell_size.x - 1, font.cell_size.x)); + const shader_row_count: u16 = @intCast(@divTrunc(client_size.y + font.cell_size.y - 1, font.cell_size.y)); + + const copy_col_count: u16 = @min(col_count, shader_col_count); + const blank_glyph_index = state.generateGlyph(font, ' ', .single); + + const alloc = global.glyph_cache_arena.allocator(); + state.updateCellImage(alloc, shader_col_count, shader_row_count); + + const shader_cells = state.cell_buf.items[0 .. @as(u32, shader_col_count) * @as(u32, shader_row_count)]; + + for (0..shader_row_count) |row_i| { + const src_row = blk: { + const r = top + @as(u16, @intCast(row_i)); + break :blk if (r < row_count) r else 0; + }; + const src_row_offset = @as(usize, src_row) * col_count; + const dst_row_offset = @as(usize, row_i) * shader_col_count; + const copy_len = if (row_i < row_count) copy_col_count else 0; + + for (0..copy_len) |ci| { + const src = cells[src_row_offset + ci]; + shader_cells[dst_row_offset + ci] = .{ + .glyph_index = src.glyph_index, + .bg = @bitCast(src.background), + .fg = @bitCast(src.foreground), + }; + } + for (copy_len..shader_col_count) |ci| { + shader_cells[dst_row_offset + ci] = .{ + .glyph_index = blank_glyph_index, + .bg = @bitCast(global.background), + .fg = @bitCast(global.background), + }; + } + } + + // Upload cell texture + var cell_data: sg.ImageData = .{}; + const cell_bytes = std.mem.sliceAsBytes(shader_cells); + cell_data.mip_levels[0] = .{ .ptr = cell_bytes.ptr, .size = cell_bytes.len }; + sg.updateImage(state.cell_image, cell_data); + + // Render pass + var pass_action: sg.PassAction = .{}; + pass_action.colors[0] = .{ + .load_action = .CLEAR, + .clear_value = .{ + .r = @as(f32, @floatFromInt(global.background.r)) / 255.0, + .g = @as(f32, @floatFromInt(global.background.g)) / 255.0, + .b = @as(f32, @floatFromInt(global.background.b)) / 255.0, + .a = 1.0, + }, + }; + + sg.beginPass(.{ + .swapchain = .{ + .width = @intCast(client_size.x), + .height = @intCast(client_size.y), + .sample_count = 1, + .color_format = .RGBA8, + .depth_format = .NONE, + .gl = .{ .framebuffer = 0 }, + }, + .action = pass_action, + }); + sg.applyPipeline(global.pip); + + var bindings: sg.Bindings = .{}; + bindings.views[0] = state.glyph_view; + bindings.views[1] = state.cell_view; + bindings.samplers[0] = global.glyph_sampler; + bindings.samplers[1] = global.cell_sampler; + sg.applyBindings(bindings); + + const fs_params = builtin_shader.FsParams{ + .cell_size_x = font.cell_size.x, + .cell_size_y = font.cell_size.y, + .col_count = shader_col_count, + .row_count = shader_row_count, + }; + sg.applyUniforms(0, .{ + .ptr = &fs_params, + .size = @sizeOf(builtin_shader.FsParams), + }); + + sg.draw(0, 4, 1); + sg.endPass(); + // Note: caller (app.zig) calls sg.commit() and window.swapBuffers() +} + +fn oom(e: error{OutOfMemory}) noreturn { + @panic(@errorName(e)); +} diff --git a/src/gui/rasterizer/stub.zig b/src/gui/rasterizer/stub.zig new file mode 100644 index 00000000..f1b1604d --- /dev/null +++ b/src/gui/rasterizer/stub.zig @@ -0,0 +1,49 @@ +// Stub rasterizer for M2 testing — renders blank (zeroed) glyphs. +const std = @import("std"); +const XY = @import("xy").XY; + +const Self = @This(); + +pub const Font = struct { + cell_size: XY(u16), +}; + +pub const Fonts = struct {}; + +pub const GlyphKind = enum { + single, + left, + right, +}; + +pub fn init(allocator: std.mem.Allocator) !Self { + _ = allocator; + return .{}; +} + +pub fn deinit(self: *Self) void { + _ = self; +} + +pub fn loadFont(self: *Self, name: []const u8, size_px: u16) !Font { + _ = self; + _ = name; + const cell_h = @max(size_px, 4); + const cell_w = @max(cell_h / 2, 2); + return Font{ .cell_size = .{ .x = cell_w, .y = cell_h } }; +} + +pub fn render( + self: *const Self, + font: Font, + codepoint: u21, + kind: GlyphKind, + staging_buf: []u8, +) void { + _ = self; + _ = font; + _ = codepoint; + _ = kind; + // Blank glyph — caller has already zeroed staging_buf. + _ = staging_buf; +} diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig new file mode 100644 index 00000000..f05a9c89 --- /dev/null +++ b/src/gui/wio/app.zig @@ -0,0 +1,358 @@ +// wio event-loop + sokol_gfx rendering for the GUI renderer. +// +// Threading model: +// - start() is called from the tui/actor thread; it clones the caller's +// thespian PID and spawns the wio loop on a new thread. +// - The wio thread owns the GL context and all sokol/GPU state. +// - requestRender() / updateScreen() can be called from any thread; they +// post data to shared state protected by a mutex and wake the wio thread. + +const std = @import("std"); +const wio = @import("wio"); +const sg = @import("sokol").gfx; +const slog = @import("sokol").log; +const gpu = @import("gpu"); +const thespian = @import("thespian"); +const cbor = @import("cbor"); +const vaxis = @import("vaxis"); + +const input_translate = @import("input.zig"); + +const log = std.log.scoped(.wio_app); + +// ── Shared state (protected by screen_mutex) ────────────────────────────── + +const ScreenSnapshot = struct { + cells: []gpu.Cell, + width: u16, + height: u16, + font: gpu.Font, +}; + +var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; +var screen_mutex: std.Thread.Mutex = .{}; +var screen_pending: std.atomic.Value(bool) = .init(false); +var screen_snap: ?ScreenSnapshot = null; +var tui_pid: thespian.pid = undefined; +var font_size_px: u16 = 16; +var font_name_buf: [256]u8 = undefined; +var font_name_len: usize = 0; + +// ── Public API (called from tui thread) ─────────────────────────────────── + +pub fn start() !std.Thread { + tui_pid = thespian.self_pid().clone(); + font_name_len = 0; + return std.Thread.spawn(.{}, wioLoop, .{}); +} + +pub fn stop() void { + // The wio thread will stop when the window's .close event arrives. + // We can't easily interrupt wio.wait() from outside without cancelWait. + wio.cancelWait(); +} + +/// Called from the tui thread to push a new screen to the GPU thread. +pub fn updateScreen(vx_screen: *const vaxis.Screen) void { + const allocator = gpa.allocator(); + const cell_count: usize = @as(usize, vx_screen.width) * @as(usize, vx_screen.height); + + const new_cells = allocator.alloc(gpu.Cell, cell_count) catch return; + const new_font = getFont(); + + // Convert vaxis cells → gpu.Cell (glyph + colours) + // Glyph indices are filled in on the GPU thread; here we just store 0. + for (vx_screen.buf[0..cell_count], new_cells) |*vc, *gc| { + gc.* = .{ + .glyph_index = 0, + .background = colorFromVaxis(vc.style.bg), + .foreground = colorFromVaxis(vc.style.fg), + }; + } + + screen_mutex.lock(); + defer screen_mutex.unlock(); + + // Free the previous snapshot + if (screen_snap) |old| allocator.free(old.cells); + screen_snap = .{ + .cells = new_cells, + .width = vx_screen.width, + .height = vx_screen.height, + .font = new_font, + }; + + screen_pending.store(true, .release); + wio.cancelWait(); +} + +pub fn requestRender() void { + screen_pending.store(true, .release); + wio.cancelWait(); +} + +pub fn setFontSize(size_px: f32) void { + font_size_px = @intFromFloat(@max(4, size_px)); + requestRender(); +} + +pub fn adjustFontSize(delta: f32) void { + const new: f32 = @as(f32, @floatFromInt(font_size_px)) + delta; + setFontSize(new); +} + +pub fn setFontFace(name: []const u8) void { + const copy_len = @min(name.len, font_name_buf.len); + @memcpy(font_name_buf[0..copy_len], name[0..copy_len]); + font_name_len = copy_len; + requestRender(); +} + +// ── Internal helpers ────────────────────────────────────────────────────── + +fn getFont() gpu.Font { + const name = if (font_name_len > 0) font_name_buf[0..font_name_len] else "monospace"; + return gpu.loadFont(name, font_size_px) catch gpu.Font{ .cell_size = .{ .x = 8, .y = 16 } }; +} + +fn colorFromVaxis(color: vaxis.Cell.Color) gpu.Color { + return switch (color) { + .default => gpu.Color.initRgb(0, 0, 0), + .index => |idx| blk: { + const xterm = @import("xterm"); + const rgb24 = xterm.colors[idx]; + break :blk gpu.Color.initRgb( + @truncate(rgb24 >> 16), + @truncate(rgb24 >> 8), + @truncate(rgb24), + ); + }, + .rgb => |rgb| gpu.Color.initRgb(rgb[0], rgb[1], rgb[2]), + }; +} + +// ── wio main loop (runs on dedicated thread) ────────────────────────────── + +fn wioLoop() void { + const allocator = gpa.allocator(); + + wio.init(allocator, .{}) catch |e| { + log.err("wio.init failed: {s}", .{@errorName(e)}); + tui_pid.send(.{"quit"}) catch {}; + return; + }; + defer wio.deinit(); + + var window = wio.createWindow(.{ + .title = "flow", + .size = .{ .width = 1280, .height = 720 }, + .scale = 1.0, + .opengl = .{ + .major_version = 3, + .minor_version = 3, + .profile = .core, + .forward_compatible = true, + }, + }) catch |e| { + log.err("wio.createWindow failed: {s}", .{@errorName(e)}); + tui_pid.send(.{"quit"}) catch {}; + return; + }; + defer window.destroy(); + + window.makeContextCurrent(); + + sg.setup(.{ + .logger = .{ .func = slog.func }, + }); + defer sg.shutdown(); + + gpu.init(allocator) catch |e| { + log.err("gpu.init failed: {s}", .{@errorName(e)}); + tui_pid.send(.{"quit"}) catch {}; + return; + }; + defer gpu.deinit(); + + var state = gpu.WindowState.init(); + defer state.deinit(); + + // Current window size in pixels (updated by size_physical events) + var win_size: wio.Size = .{ .width = 1280, .height = 720 }; + // Cell grid dimensions (updated on resize) + var cell_width: u16 = 80; + var cell_height: u16 = 24; + + // Notify the tui that the window is ready + sendResize(win_size, &state, &cell_width, &cell_height); + tui_pid.send(.{ "RDR", "WindowCreated", @as(usize, 0) }) catch {}; + + var held_buttons = input_translate.ButtonSet{}; + var mouse_pos: wio.Position = .{ .x = 0, .y = 0 }; + var running = true; + + while (running) { + wio.wait(.{}); + + while (window.getEvent()) |event| { + switch (event) { + .close => { + running = false; + }, + .size_physical => |sz| { + win_size = sz; + sendResize(sz, &state, &cell_width, &cell_height); + }, + .button_press => |btn| { + held_buttons.press(btn); + const mods = input_translate.Mods.fromButtons(held_buttons); + if (input_translate.mouseButtonId(btn)) |mb_id| { + const col: i32 = @intCast(mouse_pos.x); + const row: i32 = @intCast(mouse_pos.y); + const font = getFont(); + const col_cell: i32 = @intCast(@divTrunc(col, font.cell_size.x)); + const row_cell: i32 = @intCast(@divTrunc(row, font.cell_size.y)); + const xoff: i32 = @intCast(@mod(col, font.cell_size.x)); + const yoff: i32 = @intCast(@mod(row, font.cell_size.y)); + tui_pid.send(.{ + "RDR", "B", + @as(u8, 1), // press + mb_id, + col_cell, + row_cell, + xoff, + yoff, + }) catch {}; + } else { + const cp = input_translate.codepointFromButton(btn, mods); + sendKey(1, cp, cp, mods); + } + }, + .button_repeat => |btn| { + const mods = input_translate.Mods.fromButtons(held_buttons); + if (input_translate.mouseButtonId(btn) == null) { + const cp = input_translate.codepointFromButton(btn, mods); + sendKey(2, cp, cp, mods); + } + }, + .button_release => |btn| { + held_buttons.release(btn); + const mods = input_translate.Mods.fromButtons(held_buttons); + if (input_translate.mouseButtonId(btn)) |mb_id| { + const col: i32 = @intCast(mouse_pos.x); + const row: i32 = @intCast(mouse_pos.y); + const font = getFont(); + const col_cell: i32 = @intCast(@divTrunc(col, font.cell_size.x)); + const row_cell: i32 = @intCast(@divTrunc(row, font.cell_size.y)); + const xoff: i32 = @intCast(@mod(col, font.cell_size.x)); + const yoff: i32 = @intCast(@mod(row, font.cell_size.y)); + tui_pid.send(.{ + "RDR", "B", + @as(u8, 0), // release + mb_id, + col_cell, + row_cell, + xoff, + yoff, + }) catch {}; + } else { + const cp = input_translate.codepointFromButton(btn, mods); + sendKey(3, cp, cp, mods); + } + }, + .char => |cp| { + const mods = input_translate.Mods.fromButtons(held_buttons); + sendKey(1, cp, cp, mods); + }, + .mouse => |pos| { + mouse_pos = pos; + const font = getFont(); + const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.x)), font.cell_size.x)); + const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(pos.y)), font.cell_size.y)); + const xoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.x)), font.cell_size.x)); + const yoff: i32 = @intCast(@mod(@as(i32, @intCast(pos.y)), font.cell_size.y)); + tui_pid.send(.{ + "RDR", "M", + col_cell, row_cell, + xoff, yoff, + }) catch {}; + }, + .scroll_vertical => |dy| { + const btn_id: u8 = if (dy < 0) 64 else 65; // up / down scroll + const font = getFont(); + const col_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.x)), font.cell_size.x)); + const row_cell: i32 = @intCast(@divTrunc(@as(i32, @intCast(mouse_pos.y)), font.cell_size.y)); + tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, col_cell, row_cell, @as(i32, 0), @as(i32, 0) }) catch {}; + }, + else => {}, + } + } + + // Paint if the tui pushed new screen data + if (screen_pending.swap(false, .acq_rel)) { + screen_mutex.lock(); + const snap = screen_snap; + screen_mutex.unlock(); + + if (snap) |s| { + state.size = .{ .x = win_size.width, .y = win_size.height }; + const font = s.font; + + // Regenerate glyph indices using the GPU state + const cells_with_glyphs = allocator.alloc(gpu.Cell, s.cells.len) catch continue; + defer allocator.free(cells_with_glyphs); + @memcpy(cells_with_glyphs, s.cells); + + for (cells_with_glyphs) |*cell| { + // TODO: carry codepoint/width from the vaxis screen snapshot. + cell.glyph_index = state.generateGlyph(font, ' ', .single); + } + + gpu.paint( + &state, + .{ .x = win_size.width, .y = win_size.height }, + font, + s.height, + s.width, + 0, + cells_with_glyphs, + ); + sg.commit(); + window.swapBuffers(); + } + } + } + + tui_pid.send(.{"quit"}) catch {}; +} + +fn sendResize( + sz: wio.Size, + state: *gpu.WindowState, + cell_width: *u16, + cell_height: *u16, +) void { + const font = getFont(); + cell_width.* = @intCast(@divTrunc(sz.width, font.cell_size.x)); + cell_height.* = @intCast(@divTrunc(sz.height, font.cell_size.y)); + state.size = .{ .x = sz.width, .y = sz.height }; + tui_pid.send(.{ + "RDR", "Resize", + cell_width.*, cell_height.*, + @as(u16, @intCast(sz.width)), @as(u16, @intCast(sz.height)), + }) catch {}; +} + +fn sendKey(kind: u8, codepoint: u21, shifted_codepoint: u21, mods: input_translate.Mods) void { + var text_buf: [4]u8 = undefined; + const text_len = if (codepoint >= 0x20 and codepoint < 0x7f) + std.unicode.utf8Encode(codepoint, &text_buf) catch 0 + else + 0; + tui_pid.send(.{ + "RDR", "I", + kind, @as(u21, codepoint), + @as(u21, shifted_codepoint), text_buf[0..text_len], + @as(u8, @bitCast(mods)), + }) catch {}; +} diff --git a/src/gui/wio/input.zig b/src/gui/wio/input.zig new file mode 100644 index 00000000..e98ee55c --- /dev/null +++ b/src/gui/wio/input.zig @@ -0,0 +1,167 @@ +// Translate wio button events into the Flow keyboard/mouse event format +// that is forwarded to the tui thread via cbor messages. + +const std = @import("std"); +const wio = @import("wio"); + +// Modifiers bitmask (matches vaxis.Key.Modifiers packed struct layout used +// by the rest of Flow's input handling). +pub const Mods = packed struct(u8) { + shift: bool = false, + alt: bool = false, + ctrl: bool = false, + super: bool = false, + hyper: bool = false, + meta: bool = false, + _pad: u2 = 0, + + pub fn fromButtons(pressed: ButtonSet) Mods { + return .{ + .shift = pressed.has(.left_shift) or pressed.has(.right_shift), + .alt = pressed.has(.left_alt) or pressed.has(.right_alt), + .ctrl = pressed.has(.left_control) or pressed.has(.right_control), + .super = pressed.has(.left_gui) or pressed.has(.right_gui), + }; + } +}; + +// Simple set of currently held buttons (for modifier tracking) +pub const ButtonSet = struct { + bits: std.bit_set.IntegerBitSet(256) = .initEmpty(), + + pub fn press(self: *ButtonSet, b: wio.Button) void { + self.bits.set(@intFromEnum(b)); + } + pub fn release(self: *ButtonSet, b: wio.Button) void { + self.bits.unset(@intFromEnum(b)); + } + pub fn has(self: ButtonSet, b: wio.Button) bool { + return self.bits.isSet(@intFromEnum(b)); + } +}; + +// Translate a wio.Button that is a keyboard key into a Unicode codepoint +// (or 0 for non-printable keys) and a Flow key kind (press=1, repeat=2, +// release=3). +pub const KeyEvent = struct { + kind: u8, + codepoint: u21, + shifted_codepoint: u21, + text: []const u8, + mods: u8, +}; + +// Map a wio.Button to the primary codepoint for that key +pub fn codepointFromButton(b: wio.Button, mods: Mods) u21 { + return switch (b) { + .a => if (mods.ctrl) 0x01 else if (mods.shift) 'A' else 'a', + .b => if (mods.ctrl) 0x02 else if (mods.shift) 'B' else 'b', + .c => if (mods.ctrl) 0x03 else if (mods.shift) 'C' else 'c', + .d => if (mods.ctrl) 0x04 else if (mods.shift) 'D' else 'd', + .e => if (mods.ctrl) 0x05 else if (mods.shift) 'E' else 'e', + .f => if (mods.ctrl) 0x06 else if (mods.shift) 'F' else 'f', + .g => if (mods.ctrl) 0x07 else if (mods.shift) 'G' else 'g', + .h => if (mods.ctrl) 0x08 else if (mods.shift) 'H' else 'h', + .i => if (mods.ctrl) 0x09 else if (mods.shift) 'I' else 'i', + .j => if (mods.ctrl) 0x0A else if (mods.shift) 'J' else 'j', + .k => if (mods.ctrl) 0x0B else if (mods.shift) 'K' else 'k', + .l => if (mods.ctrl) 0x0C else if (mods.shift) 'L' else 'l', + .m => if (mods.ctrl) 0x0D else if (mods.shift) 'M' else 'm', + .n => if (mods.ctrl) 0x0E else if (mods.shift) 'N' else 'n', + .o => if (mods.ctrl) 0x0F else if (mods.shift) 'O' else 'o', + .p => if (mods.ctrl) 0x10 else if (mods.shift) 'P' else 'p', + .q => if (mods.ctrl) 0x11 else if (mods.shift) 'Q' else 'q', + .r => if (mods.ctrl) 0x12 else if (mods.shift) 'R' else 'r', + .s => if (mods.ctrl) 0x13 else if (mods.shift) 'S' else 's', + .t => if (mods.ctrl) 0x14 else if (mods.shift) 'T' else 't', + .u => if (mods.ctrl) 0x15 else if (mods.shift) 'U' else 'u', + .v => if (mods.ctrl) 0x16 else if (mods.shift) 'V' else 'v', + .w => if (mods.ctrl) 0x17 else if (mods.shift) 'W' else 'w', + .x => if (mods.ctrl) 0x18 else if (mods.shift) 'X' else 'x', + .y => if (mods.ctrl) 0x19 else if (mods.shift) 'Y' else 'y', + .z => if (mods.ctrl) 0x1A else if (mods.shift) 'Z' else 'z', + .@"0" => if (mods.shift) ')' else '0', + .@"1" => if (mods.shift) '!' else '1', + .@"2" => if (mods.shift) '@' else '2', + .@"3" => if (mods.shift) '#' else '3', + .@"4" => if (mods.shift) '$' else '4', + .@"5" => if (mods.shift) '%' else '5', + .@"6" => if (mods.shift) '^' else '6', + .@"7" => if (mods.shift) '&' else '7', + .@"8" => if (mods.shift) '*' else '8', + .@"9" => if (mods.shift) '(' else '9', + .space => ' ', + .enter => '\r', + .tab => '\t', + .backspace => 0x7f, + .escape => 0x1b, + .minus => if (mods.shift) '_' else '-', + .equals => if (mods.shift) '+' else '=', + .left_bracket => if (mods.shift) '{' else '[', + .right_bracket => if (mods.shift) '}' else ']', + .backslash => if (mods.shift) '|' else '\\', + .semicolon => if (mods.shift) ':' else ';', + .apostrophe => if (mods.shift) '"' else '\'', + .grave => if (mods.shift) '~' else '`', + .comma => if (mods.shift) '<' else ',', + .dot => if (mods.shift) '>' else '.', + .slash => if (mods.shift) '?' else '/', + // Navigation keys map to special Unicode private-use codepoints + // that Flow's input layer understands (matching kitty protocol). + .up => 0xF700, + .down => 0xF701, + .left => 0xF702, + .right => 0xF703, + .home => 0xF704, + .end => 0xF705, + .page_up => 0xF706, + .page_down => 0xF707, + .insert => 0xF708, + .delete => 0xF709, + .f1 => 0xF710, + .f2 => 0xF711, + .f3 => 0xF712, + .f4 => 0xF713, + .f5 => 0xF714, + .f6 => 0xF715, + .f7 => 0xF716, + .f8 => 0xF717, + .f9 => 0xF718, + .f10 => 0xF719, + .f11 => 0xF71A, + .f12 => 0xF71B, + .kp_0 => '0', + .kp_1 => '1', + .kp_2 => '2', + .kp_3 => '3', + .kp_4 => '4', + .kp_5 => '5', + .kp_6 => '6', + .kp_7 => '7', + .kp_8 => '8', + .kp_9 => '9', + .kp_dot => '.', + .kp_slash => '/', + .kp_star => '*', + .kp_minus => '-', + .kp_plus => '+', + .kp_enter => '\r', + .kp_equals => '=', + else => 0, + }; +} + +pub const mouse_button_left: u8 = 0; +pub const mouse_button_right: u8 = 1; +pub const mouse_button_middle: u8 = 2; + +pub fn mouseButtonId(b: wio.Button) ?u8 { + return switch (b) { + .mouse_left => mouse_button_left, + .mouse_right => mouse_button_right, + .mouse_middle => mouse_button_middle, + .mouse_back => 3, + .mouse_forward => 4, + else => null, + }; +} diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig new file mode 100644 index 00000000..d0967898 --- /dev/null +++ b/src/renderer/gui/renderer.zig @@ -0,0 +1,417 @@ +const Self = @This(); +pub const log_name = "renderer"; + +const std = @import("std"); +const cbor = @import("cbor"); +const vaxis = @import("vaxis"); +const Style = @import("theme").Style; +const Color = @import("theme").Color; +pub const CursorShape = vaxis.Cell.CursorShape; +pub const MouseCursorShape = vaxis.Mouse.Shape; + +const GraphemeCache = @import("tuirenderer").GraphemeCache; +pub const Plane = @import("tuirenderer").Plane; +pub const Layer = @import("tuirenderer").Layer; +const input = @import("input"); +const app = @import("app"); + +pub const Cell = @import("tuirenderer").Cell; +pub const StyleBits = @import("tuirenderer").style; +pub const style = StyleBits; +pub const styles = @import("tuirenderer").styles; + +pub const Error = error{ + UnexpectedRendererEvent, + OutOfMemory, + IntegerTooLarge, + IntegerTooSmall, + InvalidType, + TooShort, + Utf8CannotEncodeSurrogateHalf, + CodepointTooLarge, + VaxisResizeError, + InvalidFloatType, + InvalidArrayType, + InvalidPIntType, + JsonIncompatibleType, + NotAnObject, + BadArrayAllocExtract, + InvalidMapType, + InvalidUnion, + WriteFailed, +} || std.Thread.SpawnError; + +allocator: std.mem.Allocator, +vx: vaxis.Vaxis, +cache_storage: GraphemeCache.Storage = .{}, +event_buffer: std.Io.Writer.Allocating, + +handler_ctx: *anyopaque, +dispatch_initialized: *const fn (ctx: *anyopaque) void, +dispatch_input: ?*const fn (ctx: *anyopaque, cbor_msg: []const u8) void = null, +dispatch_mouse: ?*const fn (ctx: *anyopaque, y: i32, x: i32, cbor_msg: []const u8) void = null, +dispatch_mouse_drag: ?*const fn (ctx: *anyopaque, y: i32, x: i32, cbor_msg: []const u8) void = null, +dispatch_event: ?*const fn (ctx: *anyopaque, cbor_msg: []const u8) void = null, + +thread: ?std.Thread = null, +window_ready: bool = false, + +const global = struct { + var init_called: bool = false; +}; + +fn oom(e: error{OutOfMemory}) noreturn { + @panic(@errorName(e)); +} + +pub fn init( + allocator: std.mem.Allocator, + handler_ctx: *anyopaque, + no_alternate: bool, + dispatch_initialized: *const fn (ctx: *anyopaque) void, +) Error!Self { + std.debug.assert(!global.init_called); + global.init_called = true; + _ = no_alternate; + + const opts: vaxis.Vaxis.Options = .{ + .kitty_keyboard_flags = .{ + .disambiguate = true, + .report_events = true, + .report_alternate_keys = true, + .report_all_as_ctl_seqs = true, + .report_text = true, + }, + .system_clipboard_allocator = allocator, + }; + var result: Self = .{ + .allocator = allocator, + .vx = try vaxis.init(allocator, opts), + .event_buffer = .init(allocator), + .handler_ctx = handler_ctx, + .dispatch_initialized = dispatch_initialized, + }; + result.vx.caps.unicode = .unicode; + result.vx.screen.width_method = .unicode; + return result; +} + +pub fn deinit(self: *Self) void { + std.debug.assert(self.thread == null); + var drop: std.Io.Writer.Discarding = .init(&.{}); + self.vx.deinit(self.allocator, &drop.writer); + self.event_buffer.deinit(); +} + +pub fn run(self: *Self) Error!void { + if (self.thread) |_| return; + // Do a dummy resize to fully initialise vaxis internal state + var drop: std.Io.Writer.Discarding = .init(&.{}); + self.vx.resize( + self.allocator, + &drop.writer, + .{ .rows = 25, .cols = 80, .x_pixel = 0, .y_pixel = 0 }, + ) catch return error.VaxisResizeError; + self.thread = try app.start(); +} + +fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { + self.event_buffer.clearRetainingCapacity(); + try cbor.writeValue(&self.event_buffer.writer, value); + return self.event_buffer.written(); +} + +pub fn render(self: *Self) error{}!void { + if (!self.window_ready) return; + app.updateScreen(&self.vx.screen); +} + +pub fn sigwinch(self: *Self) !void { + _ = self; + // TODO: implement +} + +pub fn stop(self: *Self) void { + app.stop(); + if (self.thread) |thread| { + thread.join(); + self.thread = null; + } +} + +pub fn stdplane(self: *Self) Plane { + const name = "root"; + var plane: Plane = .{ + .window = self.vx.window(), + .cache = self.cache_storage.cache(), + .name_buf = undefined, + .name_len = name.len, + }; + @memcpy(plane.name_buf[0..name.len], name); + return plane; +} + +pub fn process_renderer_event(self: *Self, msg: []const u8) Error!void { + const Input = struct { + kind: u8, + codepoint: u21, + shifted_codepoint: u21, + text: []const u8, + mods: u8, + }; + const MousePos = struct { + col: i32, + row: i32, + xoffset: i32, + yoffset: i32, + }; + const Winsize = struct { + cell_width: u16, + cell_height: u16, + pixel_width: u16, + pixel_height: u16, + }; + + { + var args: Input = undefined; + if (try cbor.match(msg, .{ + cbor.any, + "I", + cbor.extract(&args.kind), + cbor.extract(&args.codepoint), + cbor.extract(&args.shifted_codepoint), + cbor.extract(&args.text), + cbor.extract(&args.mods), + })) { + const cbor_msg = try self.fmtmsg(.{ + "I", + args.kind, + args.codepoint, + args.shifted_codepoint, + args.text, + args.mods, + }); + if (self.dispatch_input) |f| f(self.handler_ctx, cbor_msg); + return; + } + } + + { + var args: Winsize = undefined; + if (try cbor.match(msg, .{ + cbor.any, + "Resize", + cbor.extract(&args.cell_width), + cbor.extract(&args.cell_height), + cbor.extract(&args.pixel_width), + cbor.extract(&args.pixel_height), + })) { + var drop: std.Io.Writer.Discarding = .init(&.{}); + self.vx.resize(self.allocator, &drop.writer, .{ + .rows = @intCast(args.cell_height), + .cols = @intCast(args.cell_width), + .x_pixel = @intCast(args.pixel_width), + .y_pixel = @intCast(args.pixel_height), + }) catch |err| std.debug.panic("resize failed with {s}", .{@errorName(err)}); + self.vx.queueRefresh(); + if (self.dispatch_event) |f| f(self.handler_ctx, try self.fmtmsg(.{"resize"})); + return; + } + } + + { + var args: MousePos = undefined; + if (try cbor.match(msg, .{ + cbor.any, + "M", + cbor.extract(&args.col), + cbor.extract(&args.row), + cbor.extract(&args.xoffset), + cbor.extract(&args.yoffset), + })) { + if (self.dispatch_mouse) |f| f( + self.handler_ctx, + @intCast(args.row), + @intCast(args.col), + try self.fmtmsg(.{ + "M", + args.col, + args.row, + args.xoffset, + args.yoffset, + }), + ); + return; + } + } + + { + var args: struct { + pos: MousePos, + button: struct { press: u8, id: u8 }, + } = undefined; + if (try cbor.match(msg, .{ + cbor.any, + "B", + cbor.extract(&args.button.press), + cbor.extract(&args.button.id), + cbor.extract(&args.pos.col), + cbor.extract(&args.pos.row), + cbor.extract(&args.pos.xoffset), + cbor.extract(&args.pos.yoffset), + })) { + if (self.dispatch_mouse) |f| f( + self.handler_ctx, + @intCast(args.pos.row), + @intCast(args.pos.col), + try self.fmtmsg(.{ + "B", + args.button.press, + args.button.id, + input.utils.button_id_string(@enumFromInt(args.button.id)), + args.pos.col, + args.pos.row, + args.pos.xoffset, + args.pos.yoffset, + }), + ); + return; + } + } + + { + var hwnd: usize = undefined; + if (try cbor.match(msg, .{ + cbor.any, + "WindowCreated", + cbor.extract(&hwnd), + })) { + self.window_ready = true; + self.dispatch_initialized(self.handler_ctx); + return; + } + } + + return error.UnexpectedRendererEvent; +} + +pub fn set_sgr_pixel_mode_support(self: *Self, enable: bool) void { + _ = self; + _ = enable; +} + +pub fn set_terminal_title(self: *Self, text: []const u8) void { + _ = self; + _ = text; +} + +pub fn set_terminal_style(self: *Self, style_: Style) void { + _ = self; + _ = style_; +} + +pub fn adjust_fontsize(self: *Self, amount: f32) void { + _ = self; + app.adjustFontSize(amount); +} + +pub fn set_fontsize(self: *Self, fontsize: f32) void { + _ = self; + app.setFontSize(fontsize); +} + +pub fn reset_fontsize(self: *Self) void { + _ = self; + app.setFontSize(16); +} + +pub fn set_fontface(self: *Self, fontface: []const u8) void { + _ = self; + app.setFontFace(fontface); +} + +pub fn reset_fontface(self: *Self) void { + _ = self; + app.setFontFace("monospace"); +} + +pub fn get_fontfaces(self: *Self) void { + _ = self; +} + +pub fn set_terminal_cursor_color(self: *Self, color: Color) void { + _ = self; + _ = color; +} + +pub fn set_terminal_secondary_cursor_color(self: *Self, color: Color) void { + _ = self; + _ = color; +} + +pub fn set_terminal_working_directory(self: *Self, absolute_path: []const u8) void { + _ = self; + _ = absolute_path; +} + +pub fn copy_to_windows_clipboard(self: *Self, text: []const u8) void { + _ = self; + _ = text; +} + +pub fn request_windows_clipboard(self: *Self) void { + _ = self; +} + +pub fn request_mouse_cursor(self: *Self, shape: MouseCursorShape, push_or_pop: bool) void { + _ = self; + _ = shape; + _ = push_or_pop; +} + +pub fn request_mouse_cursor_text(self: *Self, push_or_pop: bool) void { + _ = self; + _ = push_or_pop; +} + +pub fn request_mouse_cursor_pointer(self: *Self, push_or_pop: bool) void { + _ = self; + _ = push_or_pop; +} + +pub fn request_mouse_cursor_default(self: *Self, push_or_pop: bool) void { + _ = self; + _ = push_or_pop; +} + +pub fn cursor_enable(self: *Self, y: i32, x: i32, shape: CursorShape) !void { + _ = self; + _ = y; + _ = x; + _ = shape; +} + +pub fn cursor_disable(self: *Self) void { + _ = self; +} + +pub fn clear_all_multi_cursors(self: *Self) !void { + _ = self; +} + +pub fn show_multi_cursor_yx(self: *Self, y: i32, x: i32) !void { + _ = self; + _ = y; + _ = x; +} + +pub fn copy_to_system_clipboard(self: *Self, text: []const u8) void { + _ = self; + _ = text; + // TODO: implement +} + +pub fn request_system_clipboard(self: *Self) void { + _ = self; + // TODO: implement +}