feat(gui): add gui cursor rendering

This commit is contained in:
CJ van den Berg 2026-04-07 15:28:47 +02:00
parent b0d32f3581
commit 546cf1f6dc
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
5 changed files with 156 additions and 21 deletions

View file

@ -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 = .{

View file

@ -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));
}

View file

@ -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();

View file

@ -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 {

View file

@ -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;
}