From 908f7806057ba2295247671e66900dc4de71634d Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 3 Apr 2026 22:45:58 +0200 Subject: [PATCH 1/9] fix(gui): renderer.vaxis must be pub for terminal --- src/renderer/gui/renderer.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig index bb3d0675..49c4a73c 100644 --- a/src/renderer/gui/renderer.zig +++ b/src/renderer/gui/renderer.zig @@ -3,7 +3,7 @@ pub const log_name = "renderer"; const std = @import("std"); const cbor = @import("cbor"); -const vaxis = @import("vaxis"); +pub const vaxis = @import("vaxis"); const Style = @import("theme").Style; const Color = @import("theme").Color; pub const CursorShape = vaxis.Cell.CursorShape; From b0d32f35816737d36fb52d0073b4d49cbd4bd9fa Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 11:30:37 +0200 Subject: [PATCH 2/9] refactor(tui): centralize native cursor check in has_native_cursor() --- src/tui/InputBox.zig | 2 +- src/tui/editor.zig | 10 +++++----- src/tui/mode/overlay/completion_dropdown.zig | 2 +- src/tui/status/filestate.zig | 2 +- src/tui/terminal_view.zig | 2 +- src/tui/tui.zig | 8 ++++++-- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/tui/InputBox.zig b/src/tui/InputBox.zig index 8a06b094..3a4cd8af 100644 --- a/src/tui/InputBox.zig +++ b/src/tui/InputBox.zig @@ -51,7 +51,7 @@ pub fn Options(context: type) type { } if (self.cursor) |cursor| { const pos: c_int = @intCast(cursor); - if (tui.config().enable_terminal_cursor) { + if (tui.has_native_cursor()) { const y, const x = self.plane.rel_yx_to_abs(0, pos + self.opts.padding + self.icon_width); tui.rdr().cursor_enable(y, x, tui.get_cursor_shape()) catch {}; } else { diff --git a/src/tui/editor.zig b/src/tui/editor.zig index 9f49c1a9..23bd9f60 100644 --- a/src/tui/editor.zig +++ b/src/tui/editor.zig @@ -376,7 +376,7 @@ pub const Editor = struct { animation_lag: f64, animation_last_time: i64, - enable_terminal_cursor: bool, + software_rendered_cursor: bool, render_whitespace: WhitespaceMode, indent_size: usize, tab_width: usize, @@ -639,7 +639,7 @@ pub const Editor = struct { .animation_frame_rate = frame_rate, .animation_last_time = time.microTimestamp(), .enable_format_on_save = tui.config().enable_format_on_save, - .enable_terminal_cursor = tui.config().enable_terminal_cursor, + .software_rendered_cursor = !tui.has_native_cursor(), .render_whitespace = tui.config().whitespace_mode, }; self.add_default_symbol_triggers(); @@ -1307,7 +1307,7 @@ pub const Editor = struct { fn render_cursors(self: *Self, theme: *const Widget.Theme, cell_map: CellMap, focused: bool) !void { const frame = tracy.initZone(@src(), .{ .name = "editor render cursors" }); defer frame.deinit(); - if (focused and tui.config().enable_terminal_cursor and tui.rdr().vx.caps.multi_cursor) + if (focused and !self.software_rendered_cursor and tui.rdr().vx.caps.multi_cursor) tui.rdr().clear_all_multi_cursors() catch {}; for (self.cursels.items[0 .. self.cursels.items.len - 1]) |*cursel_| if (cursel_.*) |*cursel| { const cursor = cursel.cursor; @@ -1332,7 +1332,7 @@ pub const Editor = struct { const focused = focused_ or self.cursor_focus_override; - if (focused and self.enable_terminal_cursor) { + if (focused and !self.software_rendered_cursor) { if (screen_pos) |pos| { self.render_term_cursor(pos, cursor_shape); } else if (tui.is_mainview_focused() and tui.rdr().vx.caps.multi_cursor and self.has_secondary_cursors()) { @@ -1345,7 +1345,7 @@ pub const Editor = struct { fn render_cursor_secondary(self: *Self, cursor: *const Cursor, theme: *const Widget.Theme, cell_map: CellMap, focused: bool) !void { const pos = self.screen_cursor(cursor) orelse return; set_cell_map_cursor(cell_map, pos.row, pos.col); - if (focused and self.enable_terminal_cursor and tui.rdr().vx.caps.multi_cursor) + if (focused and !self.software_rendered_cursor and tui.rdr().vx.caps.multi_cursor) self.render_term_cursor_secondary(pos) else self.render_soft_cursor(pos, theme.editor_cursor_secondary); diff --git a/src/tui/mode/overlay/completion_dropdown.zig b/src/tui/mode/overlay/completion_dropdown.zig index a9a20786..94fc7e05 100644 --- a/src/tui/mode/overlay/completion_dropdown.zig +++ b/src/tui/mode/overlay/completion_dropdown.zig @@ -200,7 +200,7 @@ pub fn on_render_menu(self: *Type, button: *Type.ButtonType, theme: *const Widge const icon_: []const u8 = values.kind.icon(); const color: u24 = 0x0; - if (tui.config().enable_terminal_cursor) blk: { + if (tui.has_native_cursor()) blk: { const cursor = self.value.editor.get_primary_abs() orelse break :blk; tui.rdr().cursor_enable(@intCast(cursor.row), @intCast(cursor.col), tui.get_cursor_shape()) catch {}; } diff --git a/src/tui/status/filestate.zig b/src/tui/status/filestate.zig index 3425d59a..337e5cac 100644 --- a/src/tui/status/filestate.zig +++ b/src/tui/status/filestate.zig @@ -117,7 +117,7 @@ fn render_mini_mode(plane: *Plane, theme: *const Widget.Theme) void { _ = plane.putstr_unicode(mini_mode.text) catch {}; if (mini_mode.cursor) |cursor| { const pos: c_int = @intCast(cursor); - if (tui.config().enable_terminal_cursor) { + if (tui.has_native_cursor()) { const y, const x = plane.rel_yx_to_abs(0, pos + 1); tui.rdr().cursor_enable(y, x, tui.get_cursor_shape()) catch {}; } else { diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 8b80de76..7d79aace 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -379,7 +379,7 @@ pub fn render(self: *Self, theme: *const Widget.Theme) bool { } // Blit the terminal's front screen into our vaxis.Window. - const software_cursor = build_options.gui or !tui.config().enable_terminal_cursor; + const software_cursor = !tui.has_native_cursor(); const focused_cursor_color: ?[3]u8 = if (theme.editor_cursor.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else null; const unfocused_cursor_color: ?[3]u8 = if (theme.editor_cursor_secondary.bg) |bg| RGB.to_u8s(RGB.from_u24(bg.color)) else focused_cursor_color; self.vt.vt.draw(self.allocator, self.plane.window, self.focused and tui.terminal_has_focus(), software_cursor, focused_cursor_color, unfocused_cursor_color) catch |e| { diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 6626cf4d..502ae979 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -164,7 +164,6 @@ fn init(allocator: Allocator) InitError!*Self { conf.theme = dark_theme.name; const light_theme = Widget.get_theme_by_name(allocator, conf.light_theme) orelse Widget.get_theme_by_name(allocator, "default-light") orelse return error.UnknownTheme; conf.light_theme = light_theme.name; - if (build_options.gui) conf.enable_terminal_cursor = false; const frame_rate: usize = @intCast(tp.env.get().num("frame-rate")); if (frame_rate != 0) @@ -659,7 +658,7 @@ fn render(self: *Self) void { defer frame.deinit(); self.rdr_.stdplane().erase(); const theme_ = self.current_theme(); - if (self.config_.enable_terminal_cursor) { + if (has_native_cursor()) { self.rdr_.cursor_disable(); if (self.rdr_.vx.caps.multi_cursor) self.rdr_.clear_all_multi_cursors() catch {}; self.rdr_.set_terminal_cursor_color(theme_.editor_cursor.bg.?); @@ -2190,6 +2189,11 @@ pub fn is_cursor_beam() bool { }; } +pub fn has_native_cursor() bool { + if (build_options.gui) return false; + return current().config_.enable_terminal_cursor; +} + pub fn get_selection_style() @import("Buffer").Selection.Style { return if (current().input_mode_) |mode| mode.selection_style else .normal; } From 546cf1f6dc09aed24b5accc32b34c4bd16a73abb Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 15:28:47 +0200 Subject: [PATCH 3/9] feat(gui): add gui cursor rendering --- src/gui/gpu/builtin.glsl.zig | 52 +++++++++++++++++++++++++++++--- src/gui/gpu/gpu.zig | 39 ++++++++++++++++++++++++ src/gui/wio/app.zig | 28 ++++++++++++++++- src/renderer/gui/renderer.zig | 57 ++++++++++++++++++++++++++--------- src/tui/tui.zig | 1 - 5 files changed, 156 insertions(+), 21 deletions(-) diff --git a/src/gui/gpu/builtin.glsl.zig b/src/gui/gpu/builtin.glsl.zig index 88fb7501..eb1b3873 100644 --- a/src/gui/gpu/builtin.glsl.zig +++ b/src/gui/gpu/builtin.glsl.zig @@ -15,6 +15,14 @@ pub const FsParams = extern struct { col_count: i32, row_count: i32, viewport_height: i32, + // Primary cursor (position + appearance) + cursor_col: i32, + cursor_row: i32, + cursor_shape: i32, // 0=block, 1=beam, 2=underline + cursor_vis: i32, // 0=hidden, 1=visible + cursor_color: [4]f32, // RGBA normalized [0,1] + // Secondary cursor colour (positions encoded in ShaderCell._pad) + sec_cursor_color: [4]f32, // RGBA normalized [0,1] }; const vs_src = @@ -34,6 +42,12 @@ const fs_src = \\uniform int col_count; \\uniform int row_count; \\uniform int viewport_height; + \\uniform int cursor_col; + \\uniform int cursor_row; + \\uniform int cursor_shape; + \\uniform int cursor_vis; + \\uniform vec4 cursor_color; + \\uniform vec4 sec_cursor_color; \\uniform sampler2D glyph_tex_glyph_smp; \\uniform usampler2D cell_tex_cell_smp; \\out vec4 frag_color; @@ -59,7 +73,7 @@ const fs_src = \\ return; \\ } \\ - \\ // Fetch cell: texel = (glyph_index, bg_packed, fg_packed, 0) + \\ // Fetch cell: texel = (glyph_index, bg_packed, fg_packed, cursor_flag) \\ uvec4 cell = texelFetch(cell_tex_cell_smp, ivec2(col, row), 0); \\ vec4 bg = unpack_rgba(cell.g); \\ vec4 fg = unpack_rgba(cell.b); @@ -78,9 +92,31 @@ const fs_src = \\ 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); + \\ // Cursor detection + \\ bool is_primary = (cursor_vis != 0) && (col == cursor_col) && (row == cursor_row); + \\ bool is_secondary = (cell.a != 0u); + \\ + \\ vec3 final_bg = bg.rgb; + \\ vec3 final_fg = fg.rgb; + \\ + \\ if (is_primary || is_secondary) { + \\ vec4 cur = is_primary ? cursor_color : sec_cursor_color; + \\ int shape = cursor_shape; + \\ + \\ if (shape == 1) { + \\ // Beam: 2px vertical bar at left edge of cell + \\ if (cell_px_x < 2) { frag_color = vec4(cur.rgb, 1.0); return; } + \\ } else if (shape == 2) { + \\ // Underline: 2px horizontal bar at bottom of cell + \\ if (cell_px_y >= cell_size_y - 2) { frag_color = vec4(cur.rgb, 1.0); return; } + \\ } else { + \\ // Block: cursor colour as bg, inverted for glyph contrast + \\ final_bg = cur.rgb; + \\ final_fg = vec3(1.0) - cur.rgb; + \\ } + \\ } + \\ + \\ frag_color = vec4(mix(final_bg, final_fg, fg.a * glyph_alpha), 1.0); \\} ; @@ -91,7 +127,7 @@ pub fn shaderDesc(backend: sg.Backend) sg.ShaderDesc { desc.vertex_func.source = vs_src; desc.fragment_func.source = fs_src; - // Fragment uniform block: 4 individual INT uniforms + // Fragment uniform block: individual uniforms (GLCORE uses glUniform* calls) desc.uniform_blocks[0].stage = .FRAGMENT; desc.uniform_blocks[0].size = @sizeOf(FsParams); desc.uniform_blocks[0].layout = .NATIVE; @@ -100,6 +136,12 @@ pub fn shaderDesc(backend: sg.Backend) sg.ShaderDesc { 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" }; desc.uniform_blocks[0].glsl_uniforms[4] = .{ .type = .INT, .glsl_name = "viewport_height" }; + desc.uniform_blocks[0].glsl_uniforms[5] = .{ .type = .INT, .glsl_name = "cursor_col" }; + desc.uniform_blocks[0].glsl_uniforms[6] = .{ .type = .INT, .glsl_name = "cursor_row" }; + desc.uniform_blocks[0].glsl_uniforms[7] = .{ .type = .INT, .glsl_name = "cursor_shape" }; + desc.uniform_blocks[0].glsl_uniforms[8] = .{ .type = .INT, .glsl_name = "cursor_vis" }; + desc.uniform_blocks[0].glsl_uniforms[9] = .{ .type = .FLOAT4, .glsl_name = "cursor_color" }; + desc.uniform_blocks[0].glsl_uniforms[10] = .{ .type = .FLOAT4, .glsl_name = "sec_cursor_color" }; // Glyph atlas texture: R8 → sample_type = FLOAT desc.views[0].texture = .{ diff --git a/src/gui/gpu/gpu.zig b/src/gui/gpu/gpu.zig index d741adbc..7c75b181 100644 --- a/src/gui/gpu/gpu.zig +++ b/src/gui/gpu/gpu.zig @@ -18,6 +18,16 @@ pub const Cell = gui_cell.Cell; pub const Color = gui_cell.Rgba8; const Rgba8 = gui_cell.Rgba8; +pub const CursorShape = enum(i32) { block = 0, beam = 1, underline = 2 }; + +pub const CursorInfo = struct { + vis: bool = false, + row: u16 = 0, + col: u16 = 0, + shape: CursorShape = .block, + color: Color = Color.initRgb(255, 255, 255), +}; + const log = std.log.scoped(.gpu); // Maximum glyph atlas dimension. 4096 is universally supported and gives @@ -350,6 +360,8 @@ pub fn paint( col_count: u16, top: u16, cells: []const Cell, + cursor: CursorInfo, + secondary_cursors: []const CursorInfo, ) void { const shader_col_count: u16 = @intCast(@divTrunc(client_size.x, font.cell_size.x)); const shader_row_count: u16 = @intCast(@divTrunc(client_size.y, font.cell_size.y)); @@ -388,6 +400,13 @@ pub fn paint( } } + // Mark secondary cursor cells in the _pad field (read by fragment shader). + for (secondary_cursors) |sc| { + if (!sc.vis) continue; + if (sc.row >= shader_row_count or sc.col >= shader_col_count) continue; + shader_cells[@as(usize, sc.row) * shader_col_count + sc.col]._pad = 1; + } + // Upload glyph atlas to GPU if any new glyphs were rasterized this frame. if (state.glyph_atlas_dirty) flushGlyphAtlas(state); @@ -429,12 +448,23 @@ pub fn paint( bindings.samplers[1] = global.cell_sampler; sg.applyBindings(bindings); + const sec_color: Color = if (secondary_cursors.len > 0) + secondary_cursors[0].color + else + Color.initRgb(255, 255, 255); + 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, .viewport_height = @intCast(client_size.y), + .cursor_col = cursor.col, + .cursor_row = cursor.row, + .cursor_shape = @intFromEnum(cursor.shape), + .cursor_vis = if (cursor.vis) 1 else 0, + .cursor_color = colorToVec4(cursor.color), + .sec_cursor_color = colorToVec4(sec_color), }; sg.applyUniforms(0, .{ .ptr = &fs_params, @@ -446,6 +476,15 @@ pub fn paint( // Note: caller (app.zig) calls sg.commit() and window.swapBuffers() } +fn colorToVec4(c: Color) [4]f32 { + return .{ + @as(f32, @floatFromInt(c.r)) / 255.0, + @as(f32, @floatFromInt(c.g)) / 255.0, + @as(f32, @floatFromInt(c.b)) / 255.0, + @as(f32, @floatFromInt(c.a)) / 255.0, + }; +} + fn oom(e: error{OutOfMemory}) noreturn { @panic(@errorName(e)); } diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 0b1e06bd..9d1bb98a 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -22,6 +22,12 @@ const gui_config = @import("gui_config"); const log = std.log.scoped(.wio_app); +// Re-export cursor types so renderer.zig (which imports 'app' but not 'gpu') +// can use them without a direct dependency on the gpu module. +pub const CursorInfo = gpu.CursorInfo; +pub const CursorShape = gpu.CursorShape; +pub const GpuColor = gpu.Color; + // ── Shared state (protected by screen_mutex) ────────────────────────────── const ScreenSnapshot = struct { @@ -31,6 +37,9 @@ const ScreenSnapshot = struct { widths: []u8, width: u16, height: u16, + // Cursor state (set by renderer thread, consumed by wio thread) + cursor: gpu.CursorInfo, + secondary_cursors: []gpu.CursorInfo, // heap-allocated, freed with snapshot }; var gpa: std.heap.GeneralPurposeAllocator(.{}) = .{}; @@ -92,7 +101,11 @@ pub fn stop() void { } /// Called from the tui thread to push a new screen to the GPU thread. -pub fn updateScreen(vx_screen: *const vaxis.Screen) void { +pub fn updateScreen( + vx_screen: *const vaxis.Screen, + cursor: gpu.CursorInfo, + secondary_cursors: []const gpu.CursorInfo, +) void { const allocator = gpa.allocator(); const cell_count: usize = @as(usize, vx_screen.width) * @as(usize, vx_screen.height); @@ -106,6 +119,13 @@ pub fn updateScreen(vx_screen: *const vaxis.Screen) void { allocator.free(new_codepoints); return; }; + const new_sec = allocator.alloc(gpu.CursorInfo, secondary_cursors.len) catch { + allocator.free(new_cells); + allocator.free(new_codepoints); + allocator.free(new_widths); + return; + }; + @memcpy(new_sec, secondary_cursors); // Convert vaxis cells → gpu.Cell (colours only; glyph indices filled on GPU thread). for (vx_screen.buf[0..cell_count], new_cells, new_codepoints, new_widths) |*vc, *gc, *cp, *wt| { @@ -131,6 +151,7 @@ pub fn updateScreen(vx_screen: *const vaxis.Screen) void { allocator.free(old.cells); allocator.free(old.codepoints); allocator.free(old.widths); + allocator.free(old.secondary_cursors); } screen_snap = .{ .cells = new_cells, @@ -138,6 +159,8 @@ pub fn updateScreen(vx_screen: *const vaxis.Screen) void { .widths = new_widths, .width = vx_screen.width, .height = vx_screen.height, + .cursor = cursor, + .secondary_cursors = new_sec, }; screen_pending.store(true, .release); @@ -561,6 +584,7 @@ fn wioLoop() void { allocator.free(s.cells); allocator.free(s.codepoints); allocator.free(s.widths); + allocator.free(s.secondary_cursors); } state.size = .{ .x = win_size.width, .y = win_size.height }; @@ -595,6 +619,8 @@ fn wioLoop() void { s.width, 0, cells_with_glyphs, + s.cursor, + s.secondary_cursors, ); sg.commit(); window.swapBuffers(); diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig index 49c4a73c..7d0e53f4 100644 --- a/src/renderer/gui/renderer.zig +++ b/src/renderer/gui/renderer.zig @@ -56,6 +56,11 @@ dispatch_event: ?*const fn (ctx: *anyopaque, cbor_msg: []const u8) void = null, thread: ?std.Thread = null, window_ready: bool = false, +cursor_info: app.CursorInfo = .{}, +cursor_color: app.GpuColor = app.GpuColor.initRgb(255, 255, 255), +secondary_cursors: std.ArrayListUnmanaged(app.CursorInfo) = .{}, +secondary_color: app.GpuColor = app.GpuColor.initRgb(255, 255, 255), + const global = struct { var init_called: bool = false; }; @@ -92,6 +97,7 @@ pub fn init( .dispatch_initialized = dispatch_initialized, }; result.vx.caps.unicode = .unicode; + result.vx.caps.multi_cursor = true; result.vx.screen.width_method = .unicode; return result; } @@ -101,6 +107,7 @@ pub fn deinit(self: *Self) void { var drop: std.Io.Writer.Discarding = .init(&.{}); self.vx.deinit(self.allocator, &drop.writer); self.event_buffer.deinit(); + self.secondary_cursors.deinit(self.allocator); } pub fn run(self: *Self) Error!void { @@ -123,7 +130,7 @@ fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { pub fn render(self: *Self) error{}!void { if (!self.window_ready) return; - app.updateScreen(&self.vx.screen); + app.updateScreen(&self.vx.screen, self.cursor_info, self.secondary_cursors.items); } pub fn sigwinch(self: *Self) !void { @@ -414,13 +421,11 @@ pub fn get_fontfaces(self: *Self) void { } pub fn set_terminal_cursor_color(self: *Self, color: Color) void { - _ = self; - _ = color; + self.cursor_color = themeColorToGpu(color); } pub fn set_terminal_secondary_cursor_color(self: *Self, color: Color) void { - _ = self; - _ = color; + self.secondary_color = themeColorToGpu(color); } pub fn set_terminal_working_directory(self: *Self, absolute_path: []const u8) void { @@ -462,24 +467,48 @@ pub fn request_mouse_cursor_default(self: *Self, push_or_pop: bool) void { } pub fn cursor_enable(self: *Self, y: i32, x: i32, shape: CursorShape) !void { - _ = self; - _ = y; - _ = x; - _ = shape; + self.cursor_info = .{ + .vis = true, + .row = if (y < 0) 0 else @intCast(y), + .col = if (x < 0) 0 else @intCast(x), + .shape = vaxisCursorShape(shape), + .color = self.cursor_color, + }; } pub fn cursor_disable(self: *Self) void { - _ = self; + self.cursor_info.vis = false; } pub fn clear_all_multi_cursors(self: *Self) !void { - _ = self; + self.secondary_cursors.clearRetainingCapacity(); } pub fn show_multi_cursor_yx(self: *Self, y: i32, x: i32) !void { - _ = self; - _ = y; - _ = x; + try self.secondary_cursors.append(self.allocator, .{ + .vis = true, + .row = if (y < 0) 0 else @intCast(y), + .col = if (x < 0) 0 else @intCast(x), + .shape = self.cursor_info.shape, + .color = self.secondary_color, + }); +} + +fn themeColorToGpu(color: Color) app.GpuColor { + return .{ + .r = @truncate(color.color >> 16), + .g = @truncate(color.color >> 8), + .b = @truncate(color.color), + .a = color.alpha, + }; +} + +fn vaxisCursorShape(shape: CursorShape) app.CursorShape { + return switch (shape) { + .default, .block, .block_blink => .block, + .beam, .beam_blink => .beam, + .underline, .underline_blink => .underline, + }; } pub fn copy_to_system_clipboard(self: *Self, text: []const u8) void { diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 502ae979..bc92fa2b 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -2190,7 +2190,6 @@ pub fn is_cursor_beam() bool { } pub fn has_native_cursor() bool { - if (build_options.gui) return false; return current().config_.enable_terminal_cursor; } From 1c1886defca5bd7bce495960ffe0d3e34de5c746 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 16:17:35 +0200 Subject: [PATCH 4/9] feat(gui): add gui cursor blink rendering --- src/renderer/gui/renderer.zig | 61 +++++++++++++++++++++++++++++++-- src/renderer/vaxis/renderer.zig | 5 +-- src/tui/tui.zig | 12 ++++--- 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/src/renderer/gui/renderer.zig b/src/renderer/gui/renderer.zig index 7d0e53f4..2cc582f2 100644 --- a/src/renderer/gui/renderer.zig +++ b/src/renderer/gui/renderer.zig @@ -61,6 +61,15 @@ cursor_color: app.GpuColor = app.GpuColor.initRgb(255, 255, 255), secondary_cursors: std.ArrayListUnmanaged(app.CursorInfo) = .{}, secondary_color: app.GpuColor = app.GpuColor.initRgb(255, 255, 255), +cursor_blink: bool = false, +blink_on: bool = true, +blink_epoch: i64 = 0, +blink_period_us: i64 = 500_000, +blink_idle_us: i64 = 15_000_000, +blink_last_change: i64 = 0, +prev_cursor: app.CursorInfo = .{}, +prev_cursor_blink: bool = false, + const global = struct { var init_called: bool = false; }; @@ -128,9 +137,47 @@ fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { return self.event_buffer.written(); } -pub fn render(self: *Self) error{}!void { - if (!self.window_ready) return; - app.updateScreen(&self.vx.screen, self.cursor_info, self.secondary_cursors.items); +pub fn render(self: *Self) error{}!bool { + if (!self.window_ready) return false; + + var cursor = self.cursor_info; + + // Detect changes since the last rendered frame. Reset blink epoch and idle + // timer on any meaningful change so the cursor snaps to visible immediately. + if (cursor.vis != self.prev_cursor.vis or + cursor.row != self.prev_cursor.row or + cursor.col != self.prev_cursor.col or + cursor.shape != self.prev_cursor.shape or + self.cursor_blink != self.prev_cursor_blink) + { + const now = std.time.microTimestamp(); + if (cursor.vis) { + self.blink_epoch = now; + self.blink_on = true; + } + self.blink_last_change = now; + } + self.prev_cursor = cursor; + self.prev_cursor_blink = self.cursor_blink; + + // Apply blink unless the cursor has been idle for too long. + if (cursor.vis and self.cursor_blink) { + const now = std.time.microTimestamp(); + const idle = now - self.blink_last_change; + if (idle < self.blink_idle_us) { + const elapsed = @mod(now - self.blink_epoch, self.blink_period_us * 2); + self.blink_on = elapsed < self.blink_period_us; + cursor.vis = self.blink_on; + } else { + cursor.vis = true; // freeze visible after idle timeout + } + } + + app.updateScreen(&self.vx.screen, cursor, self.secondary_cursors.items); + + if (!self.cursor_info.vis or !self.cursor_blink) return false; + const idle = std.time.microTimestamp() - self.blink_last_change; + return idle < self.blink_idle_us; } pub fn sigwinch(self: *Self) !void { @@ -467,6 +514,7 @@ pub fn request_mouse_cursor_default(self: *Self, push_or_pop: bool) void { } pub fn cursor_enable(self: *Self, y: i32, x: i32, shape: CursorShape) !void { + self.cursor_blink = isBlink(shape); self.cursor_info = .{ .vis = true, .row = if (y < 0) 0 else @intCast(y), @@ -503,6 +551,13 @@ fn themeColorToGpu(color: Color) app.GpuColor { }; } +fn isBlink(shape: CursorShape) bool { + return switch (shape) { + .default, .block_blink, .beam_blink, .underline_blink => true, + else => false, + }; +} + fn vaxisCursorShape(shape: CursorShape) app.CursorShape { return switch (shape) { .default, .block, .block_blink => .block, diff --git a/src/renderer/vaxis/renderer.zig b/src/renderer/vaxis/renderer.zig index 95025892..30710f43 100644 --- a/src/renderer/vaxis/renderer.zig +++ b/src/renderer/vaxis/renderer.zig @@ -219,10 +219,11 @@ pub fn run(self: *Self) Error!void { try self.loop.start(); } -pub fn render(self: *Self) !void { - if (in_panic.load(.acquire)) return; +pub fn render(self: *Self) !bool { + if (in_panic.load(.acquire)) return false; try self.vx.render(self.tty.writer()); try self.tty.writer().flush(); + return false; } pub fn sigwinch(self: *Self) !void { diff --git a/src/tui/tui.zig b/src/tui/tui.zig index bc92fa2b..d457819d 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -681,13 +681,17 @@ fn render(self: *Self) void { top_layer_.draw(self.rdr_.stdplane()); } - { + const renderer_more = ret: { const frame = tracy.initZone(@src(), .{ .name = renderer.log_name ++ " render" }); defer frame.deinit(); - self.rdr_.render() catch |e| self.logger.err("render", e); + const m = self.rdr_.render() catch |e| blk: { + self.logger.err("render", e); + break :blk false; + }; tracy.frameMark(); self.unrendered_input_events_count = 0; - } + break :ret m; + }; self.top_layer_reset(); self.idle_frame_count = if (self.unrendered_input_events_count > 0) @@ -695,7 +699,7 @@ fn render(self: *Self) void { else self.idle_frame_count + 1; - if (more or self.idle_frame_count < idle_frames or self.no_sleep) { + if (more or renderer_more or self.idle_frame_count < idle_frames or self.no_sleep) { if (!self.frame_clock_running) { self.frame_clock.start() catch {}; self.frame_clock_running = true; From 32ed60bc642b321b09d010aa87b074234f3067e3 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 16:26:49 +0200 Subject: [PATCH 5/9] fix(win32): fix d3d11 gui build --- build.zig | 8 ++++++++ src/renderer/win32/renderer.zig | 5 +++-- src/win32/DwriteRenderer.zig | 2 +- src/win32/d3d11.zig | 6 +++--- src/win32/dwrite.zig | 2 +- src/win32/gui.zig | 4 ++-- 6 files changed, 18 insertions(+), 9 deletions(-) diff --git a/build.zig b/build.zig index 4df9709c..1bca8f31 100644 --- a/build.zig +++ b/build.zig @@ -506,6 +506,10 @@ pub fn build_exe( .d3d11 => { const win32_dep = b.lazyDependency("win32", .{}) orelse break :blk tui_renderer_mod; const win32_mod = win32_dep.module("win32"); + 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 gui_mod = b.createModule(.{ .root_source_file = b.path("src/win32/gui.zig"), .imports = &.{ @@ -519,6 +523,10 @@ pub fn build_exe( .{ .name = "color", .module = color_mod }, .{ .name = "gui_config", .module = gui_config_mod }, .{ .name = "tracy", .module = tracy_mod }, + .{ .name = "xy", .module = gui_xy_mod }, + .{ .name = "Cell", .module = gui_cell_mod }, + .{ .name = "GlyphIndexCache", .module = gui_glyph_cache_mod }, + .{ .name = "xterm", .module = gui_xterm_mod }, }, }); gui_mod.addIncludePath(b.path("src/win32")); diff --git a/src/renderer/win32/renderer.zig b/src/renderer/win32/renderer.zig index 94827b6e..40e4d6a1 100644 --- a/src/renderer/win32/renderer.zig +++ b/src/renderer/win32/renderer.zig @@ -165,9 +165,10 @@ fn fmtmsg(self: *Self, value: anytype) std.Io.Writer.Error![]const u8 { return self.event_buffer.written(); } -pub fn render(self: *Self) error{}!void { - const hwnd = self.hwnd orelse return; +pub fn render(self: *Self) error{}!bool { + const hwnd = self.hwnd orelse return false; _ = gui.updateScreen(hwnd, &self.vx.screen); + return false; } pub fn stop(self: *Self) void { // this is guaranteed because stop won't be called until after diff --git a/src/win32/DwriteRenderer.zig b/src/win32/DwriteRenderer.zig index 003a2949..943b08ac 100644 --- a/src/win32/DwriteRenderer.zig +++ b/src/win32/DwriteRenderer.zig @@ -5,7 +5,7 @@ const win32 = @import("win32").everything; const win32ext = @import("win32ext.zig"); const dwrite = @import("dwrite.zig"); -const XY = @import("../gui/xy.zig").XY; +const XY = @import("xy").XY; pub const Font = dwrite.Font; pub const Fonts = dwrite.Fonts; diff --git a/src/win32/d3d11.zig b/src/win32/d3d11.zig index d7cd4359..f36fc9cb 100644 --- a/src/win32/d3d11.zig +++ b/src/win32/d3d11.zig @@ -4,11 +4,11 @@ const win32 = @import("win32").everything; const win32ext = @import("win32ext.zig"); const dwrite = @import("dwrite.zig"); -const GlyphIndexCache = @import("../gui/GlyphIndexCache.zig"); +const GlyphIndexCache = @import("GlyphIndexCache"); const TextRenderer = @import("DwriteRenderer.zig"); -const XY = @import("../gui/xy.zig").XY; -const gui_cell = @import("../gui/Cell.zig"); +const XY = @import("xy").XY; +const gui_cell = @import("Cell"); pub const Font = TextRenderer.Font; pub const Fonts = TextRenderer.Fonts; diff --git a/src/win32/dwrite.zig b/src/win32/dwrite.zig index 5ac809d8..e6405689 100644 --- a/src/win32/dwrite.zig +++ b/src/win32/dwrite.zig @@ -2,7 +2,7 @@ const std = @import("std"); const win32 = @import("win32").everything; const FontFace = @import("FontFace.zig"); -const XY = @import("xy.zig").XY; +const XY = @import("xy").XY; const global = struct { var init_called: bool = false; diff --git a/src/win32/gui.zig b/src/win32/gui.zig index 6b8c4efb..766032a5 100644 --- a/src/win32/gui.zig +++ b/src/win32/gui.zig @@ -18,10 +18,10 @@ const input = @import("input"); const windowmsg = @import("windowmsg.zig"); const render = @import("d3d11.zig"); -const xterm = @import("../gui/xterm.zig"); +const xterm = @import("xterm"); const FontFace = @import("FontFace.zig"); -const XY = @import("../gui/xy.zig").XY; +const XY = @import("xy").XY; const WM_APP_EXIT = win32.WM_APP + 1; const WM_APP_SET_BACKGROUND = win32.WM_APP + 2; From c48d592edcf3b8e6e6831b2f15765357c62c467c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 18:57:35 +0200 Subject: [PATCH 6/9] fix(terminal): don't error when re-opening existing terminal --- src/tui/terminal_view.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/tui/terminal_view.zig b/src/tui/terminal_view.zig index 7d79aace..9f9d4350 100644 --- a/src/tui/terminal_view.zig +++ b/src/tui/terminal_view.zig @@ -87,6 +87,7 @@ pub fn run_cmd(self: *Self, ctx: command.Context) !void { var argv_list: std.ArrayListUnmanaged([]const u8) = .empty; defer argv_list.deinit(self.allocator); + var have_cmd = false; if (argv_msg) |msg| { var iter = msg.buf; var len = try cbor.decodeArrayHeader(&iter); @@ -94,6 +95,7 @@ pub fn run_cmd(self: *Self, ctx: command.Context) !void { var arg: []const u8 = undefined; if (try cbor.matchValue(&iter, cbor.extract(&arg))) try argv_list.append(self.allocator, arg); + have_cmd = true; } } else { const default_shell = if (builtin.os.tag == .windows) @@ -110,7 +112,7 @@ pub fn run_cmd(self: *Self, ctx: command.Context) !void { const rows: u16 = @intCast(@max(24, self.plane.dim_y())); if (global_vt) |*vt| { - if (!vt.process_exited) { + if (!vt.process_exited and have_cmd) { var msg: std.Io.Writer.Allocating = .init(self.allocator); defer msg.deinit(); try msg.writer.writeAll("terminal is already running '"); @@ -118,10 +120,9 @@ pub fn run_cmd(self: *Self, ctx: command.Context) !void { try msg.writer.writeAll("'"); return tp.exit(msg.written()); } - vt.deinit(self.allocator); - global_vt = null; + } else { + try Vt.init(self.allocator, argv_list.items, env, rows, cols, on_exit); } - try Vt.init(self.allocator, argv_list.items, env, rows, cols, on_exit); self.vt = &global_vt.?; if (self.last_cmd) |cmd| { From e1c90cbe260f79fdb42d5b982ce748d6022c6681 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 20:33:01 +0200 Subject: [PATCH 7/9] feat(gui): generate focus_in/_out events from wio app --- src/gui/wio/app.zig | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/gui/wio/app.zig b/src/gui/wio/app.zig index 9d1bb98a..0f8c4b2b 100644 --- a/src/gui/wio/app.zig +++ b/src/gui/wio/app.zig @@ -534,8 +534,14 @@ fn wioLoop() void { const cp = pixelToCellPos(mouse_pos); tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, cp.xoff, cp.yoff }) catch {}; }, - .focused => window.enableTextInput(.{}), - .unfocused => window.disableTextInput(), + .focused => { + window.enableTextInput(.{}); + tui_pid.send(.{"focus_in"}) catch {}; + }, + .unfocused => { + window.disableTextInput(); + tui_pid.send(.{"focus_out"}) catch {}; + }, else => {}, } } From 67595e676b634878417d797e07c66a27e25d03e1 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 20:33:53 +0200 Subject: [PATCH 8/9] fix(gui): default to .beam cursor in gui until idle rendering is more efficient --- src/tui/tui.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index d457819d..54896607 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -2174,7 +2174,12 @@ pub fn get_cursor_shape() renderer.CursorShape { default_cursor else default_cursor; - const shape = if (self.rdr_.vx.caps.multi_cursor and shape_ == .default) .beam_blink else shape_; + const shape = if (build_options.gui and shape_ == .default) + .beam + else if (self.rdr_.vx.caps.multi_cursor and shape_ == .default) + .beam_blink + else + shape_; return switch (shape) { .default => .default, .block_blink => .block_blink, From 8c9cee9facee0ac68d4836c036b3659e46757f22 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Tue, 7 Apr 2026 21:12:21 +0200 Subject: [PATCH 9/9] build: rework release build system to support gui targets --- build.zig | 103 +++++++++++++++++++++++------------------------------- 1 file changed, 44 insertions(+), 59 deletions(-) diff --git a/build.zig b/build.zig index 1bca8f31..b2c46749 100644 --- a/build.zig +++ b/build.zig @@ -114,17 +114,21 @@ fn build_release( all_targets: bool, test_filters: []const []const u8, ) void { - const targets: []const std.Target.Query = if (all_targets) &.{ - .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }, - .{ .cpu_arch = .x86, .os_tag = .linux, .abi = .musl }, - .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }, - .{ .cpu_arch = .arm, .os_tag = .linux, .abi = .musleabihf }, - .{ .cpu_arch = .x86_64, .os_tag = .macos }, - .{ .cpu_arch = .aarch64, .os_tag = .macos }, - .{ .cpu_arch = .x86_64, .os_tag = .windows }, - .{ .cpu_arch = .aarch64, .os_tag = .windows }, - .{ .cpu_arch = .x86_64, .os_tag = .freebsd }, - .{ .cpu_arch = .aarch64, .os_tag = .freebsd }, + const targets: []const struct { std.Target.Query, Renderer } = if (all_targets) &.{ + .{ .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .musl }, .terminal }, + // .{ .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = null }, .gui }, + .{ .{ .cpu_arch = .x86, .os_tag = .linux, .abi = .musl }, .terminal }, + .{ .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .musl }, .terminal }, + // .{ .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = null }, .gui }, + .{ .{ .cpu_arch = .arm, .os_tag = .linux, .abi = .musleabihf }, .terminal }, + .{ .{ .cpu_arch = .x86_64, .os_tag = .macos }, .terminal }, + .{ .{ .cpu_arch = .aarch64, .os_tag = .macos }, .terminal }, + .{ .{ .cpu_arch = .x86_64, .os_tag = .windows }, .terminal }, + .{ .{ .cpu_arch = .x86_64, .os_tag = .windows }, .d3d11 }, + .{ .{ .cpu_arch = .aarch64, .os_tag = .windows }, .terminal }, + .{ .{ .cpu_arch = .aarch64, .os_tag = .windows }, .d3d11 }, + .{ .{ .cpu_arch = .x86_64, .os_tag = .freebsd }, .terminal }, + .{ .{ .cpu_arch = .aarch64, .os_tag = .freebsd }, .terminal }, } else blk: { const maybe_triple = b.option( []const u8, @@ -133,8 +137,18 @@ fn build_release( ); const triple = maybe_triple orelse { const native_target = b.resolveTargetQuery(.{}).result; - break :blk &.{ - .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag }, + break :blk switch (native_target.os.tag) { + .linux => &.{ + .{ .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag, .abi = .musl }, .terminal }, + // .{ .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag, .abi = null }, .gui }, + }, + .windows => &.{ + .{ .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag }, .terminal }, + .{ .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag }, .d3d11 }, + }, + else => &.{ + .{ .{ .cpu_arch = native_target.cpu.arch, .os_tag = native_target.os.tag }, .terminal }, + }, }; }; const selected_target = std.Build.parseTargetQuery(.{ @@ -142,8 +156,18 @@ fn build_release( }) catch |err| switch (err) { error.ParseFailed => @panic("unknown target"), }; - break :blk &.{ - .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = selected_target.abi }, + break :blk switch (selected_target.os_tag.?) { + .linux => &.{ + .{ .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = .musl }, .terminal }, + // .{ .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = .gnu }, .gui }, + }, + .windows => &.{ + .{ .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = selected_target.abi }, .terminal }, + .{ .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = selected_target.abi }, .d3d11 }, + }, + else => &.{ + .{ .{ .cpu_arch = selected_target.cpu_arch, .os_tag = selected_target.os_tag, .abi = selected_target.abi }, .terminal }, + }, }; }; const optimize = b.standardOptimizeOption(.{}); @@ -155,8 +179,9 @@ fn build_release( b.getInstallStep().dependOn(&b.addInstallFile(version_file, "version").step); for (targets) |t| { - const target = b.resolveTargetQuery(t); - var triple = std.mem.splitScalar(u8, t.zigTriple(b.allocator) catch unreachable, '-'); + const renderer = t.@"1"; + const target = b.resolveTargetQuery(t.@"0"); + var triple = std.mem.splitScalar(u8, t.@"0".zigTriple(b.allocator) catch unreachable, '-'); const arch = triple.next() orelse unreachable; const os = triple.next() orelse unreachable; const target_path = std.mem.join(b.allocator, "-", &[_][]const u8{ os, arch }) catch unreachable; @@ -176,7 +201,7 @@ fn build_release( true, // strip release builds use_llvm, pie, - .terminal, + renderer, version, test_filters, ); @@ -195,50 +220,10 @@ fn build_release( false, // don't strip debug builds use_llvm, pie, - .terminal, + renderer, version, test_filters, ); - - if (t.os_tag == .windows) { - build_exe( - b, - run_step, - check_step, - test_step, - lint_step, - target, - optimize_release, - .{ .dest_dir = .{ .override = .{ .custom = target_path } } }, - tracy_enabled, - use_tree_sitter, - true, // strip release builds - use_llvm, - pie, - .d3d11, - version, - test_filters, - ); - - build_exe( - b, - run_step, - check_step, - test_step, - lint_step, - target, - optimize_debug, - .{ .dest_dir = .{ .override = .{ .custom = target_path_debug } } }, - tracy_enabled, - use_tree_sitter, - false, // don't strip debug builds - use_llvm, - pie, - .d3d11, - version, - test_filters, - ); - } } }