a73x

73e2fd1c

cursor: extract cursorInstance helper with DECSCUSR shape dispatch

a73x   2026-04-19 06:57

Pure helper returning a renderer.Instance for block/underline/bar.
Replaces the inline instance build in main.zig so shape geometry
is unit-testable without a Vulkan device.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

diff --git a/build.zig b/build.zig
index 09778c1..a6ed382 100644
--- a/build.zig
+++ b/build.zig
@@ -328,7 +328,9 @@ pub fn build(b: *std.Build) void {
    });
    vk_sync_mod.addImport("vulkan", vulkan_module);
    renderer_mod.addImport("vk_sync", vk_sync_mod);
    renderer_mod.addImport("font", font_mod);
    renderer_test_mod.addImport("vk_sync", vk_sync_mod);
    renderer_test_mod.addImport("font", font_mod);
    exe_mod.addImport("vk_sync", vk_sync_mod);
    main_test_mod.addImport("vk_sync", vk_sync_mod);

diff --git a/src/main.zig b/src/main.zig
index ab6bbfd..49ca018 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -554,26 +554,23 @@ fn runTerminal(alloc: std.mem.Allocator) !void {
            var cursor_instances_buf: [1]renderer.Instance = undefined;
            var cursor_instances: []const renderer.Instance = &.{};
            if (term.render_state.cursor.viewport) |cursor| {
                const cursor_uv = atlas.cursorUV();
                cursor_instances_buf[0] = .{
                    .cell_pos = .{
                        @floatFromInt(cursor.x),
                        @floatFromInt(cursor.y),
                    },
                    .glyph_size = .{
                        @floatFromInt(cell_w),
                        @floatFromInt(cell_h),
                    },
                    .glyph_bearing = .{ 0, 0 },
                    .uv_rect = .{
                        cursor_uv.u0,
                        cursor_uv.v0,
                        cursor_uv.u1,
                        cursor_uv.v1,
                    },
                    .fg = .{ 1.0, 1.0, 1.0, 0.5 },
                    .bg = .{ 0, 0, 0, 0 },
                const shape: renderer.CursorShape = switch (term.render_state.cursor.visual_style) {
                    .block, .block_hollow => .block,
                    .underline => .underline,
                    .bar => .bar,
                };
                var inst = renderer.cursorInstance(
                    shape,
                    cell_w,
                    cell_h,
                    @as(u32, @intCast(geom.buffer_scale)),
                    atlas.cursorUV(),
                );
                inst.cell_pos = .{
                    @floatFromInt(cursor.x),
                    @floatFromInt(cursor.y),
                };
                cursor_instances_buf[0] = inst;
                cursor_instances = cursor_instances_buf[0..1];
            }
            const previous_total_instance_count = render_cache.total_instance_count;
diff --git a/src/renderer.zig b/src/renderer.zig
index 912fb33..1958b25 100644
--- a/src/renderer.zig
+++ b/src/renderer.zig
@@ -2,6 +2,8 @@ const std = @import("std");
const vk = @import("vulkan");
const vk_sync = @import("vk_sync");

const GlyphUV = @import("font").GlyphUV;

const dl = @cImport({
    @cInclude("dlfcn.h");
});
@@ -2192,3 +2194,79 @@ test "uploadInstanceRange contract reports invalid ranges explicitly" {
    const action = planUploadInstanceRangeAction(std.math.maxInt(u32), std.math.maxInt(u32), 1);
    try std.testing.expectEqual(InstanceRangeUploadAction.invalid_range, action);
}

// --- cursor instance (pure, unit-tested) ---

pub const CursorShape = enum { block, underline, bar };

pub fn cursorInstance(
    shape: CursorShape,
    cell_w: u32,
    cell_h: u32,
    buffer_scale: u32,
    uv: GlyphUV,
) Instance {
    const cell_w_f: f32 = @floatFromInt(cell_w);
    const cell_h_f: f32 = @floatFromInt(cell_h);
    const line_w_f: f32 = @floatFromInt(2 * buffer_scale);

    const size_xy: [2]f32 = switch (shape) {
        .block => .{ cell_w_f, cell_h_f },
        .underline => .{ cell_w_f, line_w_f },
        .bar => .{ line_w_f, cell_h_f },
    };
    const bearing_xy: [2]f32 = switch (shape) {
        .block, .bar => .{ 0, 0 },
        .underline => .{ 0, cell_h_f - line_w_f },
    };

    return .{
        .cell_pos = .{ 0, 0 }, // caller overwrites with actual grid position
        .glyph_size = size_xy,
        .glyph_bearing = bearing_xy,
        .uv_rect = .{ uv.u0, uv.v0, uv.u1, uv.v1 },
        .fg = .{ 1.0, 1.0, 1.0, 0.5 },
        .bg = .{ 0, 0, 0, 0 },
    };
}

test "cursorInstance: block fills the whole cell at scale 1" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.block, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 20.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: underline is a 2px bar at the cell bottom (scale 1)" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.underline, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 10.0, 2.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 18.0 }, &inst.glyph_bearing);
}

test "cursorInstance: bar is a 2px column at cell left (scale 1)" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.bar, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 2.0, 20.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: underline line width scales with buffer_scale" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.underline, 20, 40, 2, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 20.0, 4.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 36.0 }, &inst.glyph_bearing);
}

test "cursorInstance: bar line width scales with buffer_scale" {
    const uv: GlyphUV = .{ .u0 = 0, .v0 = 0, .u1 = 0.01, .v1 = 0.01, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.bar, 20, 40, 2, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 4.0, 40.0 }, &inst.glyph_size);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.0, 0.0 }, &inst.glyph_bearing);
}

test "cursorInstance: uv_rect comes from the provided UV" {
    const uv: GlyphUV = .{ .u0 = 0.1, .v0 = 0.2, .u1 = 0.3, .v1 = 0.4, .width = 1, .height = 1, .bearing_x = 0, .bearing_y = 0, .advance_x = 1 };
    const inst = cursorInstance(.block, 10, 20, 1, uv);
    try std.testing.expectEqualSlices(f32, &[_]f32{ 0.1, 0.2, 0.3, 0.4 }, &inst.uv_rect);
}