a73x

5cae59bc

Add text coverage comparison mode

a73x   2026-04-09 08:32


diff --git a/src/main.zig b/src/main.zig
index a67979c..78aa1d8 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -5,6 +5,7 @@ const wayland_client = @import("wayland-client");
const renderer = @import("renderer");
const font = @import("font");
const config = @import("config");
const vk = @field(renderer, "vk");

const c = @cImport({
    @cInclude("xkbcommon/xkbcommon-keysyms.h");
@@ -56,6 +57,10 @@ pub fn main() !void {
        return runDrawSmokeTest(alloc);
    }

    if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
        return runTextCoverageCompare(alloc);
    }

    return runTerminal(alloc);
}

@@ -494,6 +499,255 @@ fn comparisonPanelOrigins(panel_cols: u32, top_margin_rows: u32) [4][2]f32 {
    return origins;
}

const ComparisonPanelDraw = struct {
    instance_offset_instances: u32,
    instance_count: u32,
    coverage: [2]f32,
    origin_col: u32,
};

const TextCoverageCompareScene = struct {
    instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
    panel_draws: [4]ComparisonPanelDraw,
    panel_cols: u32,
    window_cols: u32,
    window_rows: u32,

    fn deinit(self: *TextCoverageCompareScene, alloc: std.mem.Allocator) void {
        self.instances.deinit(alloc);
    }
};

fn comparisonPanelCols(lines: []const []const u8) u32 {
    var max_cols: u32 = 0;
    for (lines) |line| {
        max_cols = @max(max_cols, @as(u32, @intCast(line.len)));
    }
    return max_cols + 4;
}

fn comparisonVisibleGlyphCount(lines: []const []const u8) u32 {
    var count: u32 = 0;
    for (lines) |line| {
        for (line) |char| {
            if (char != ' ') count += 1;
        }
    }
    return count;
}

fn buildTextCoverageCompareScene(
    alloc: std.mem.Allocator,
    face: *font.Face,
    atlas: *font.Atlas,
) !TextCoverageCompareScene {
    const specimen_lines = comparisonSpecimenLines();
    const variants = comparisonVariants();
    const top_margin_rows: u32 = 2;
    const panel_cols = comparisonPanelCols(specimen_lines);
    const panel_origins = comparisonPanelOrigins(panel_cols, top_margin_rows);
    const panel_glyph_count = comparisonVisibleGlyphCount(specimen_lines);

    var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
    errdefer instances.deinit(alloc);
    try instances.ensureTotalCapacity(alloc, @as(usize, panel_glyph_count) * variants.len);

    const baseline = face.baseline();
    const fg = [4]f32{ 1.0, 1.0, 1.0, 1.0 };
    const bg = [4]f32{ 0.0, 0.0, 0.0, 1.0 };

    var panel_draws: [4]ComparisonPanelDraw = undefined;
    for (variants, 0..) |variant, panel_idx| {
        const origin = panel_origins[panel_idx];
        const instance_offset_instances: u32 = @intCast(instances.items.len);

        for (specimen_lines, 0..) |line, row_idx| {
            for (line, 0..) |char, col_idx| {
                if (char == ' ') continue;

                const glyph_uv = try atlas.getOrInsert(face, char);
                try instances.append(alloc, .{
                    .cell_pos = .{
                        origin[0] + @as(f32, @floatFromInt(col_idx)),
                        origin[1] + @as(f32, @floatFromInt(row_idx)),
                    },
                    .glyph_size = .{
                        @floatFromInt(glyph_uv.width),
                        @floatFromInt(glyph_uv.height),
                    },
                    .glyph_bearing = .{
                        @floatFromInt(glyph_uv.bearing_x),
                        glyphTopOffset(baseline, glyph_uv.bearing_y),
                    },
                    .uv_rect = .{
                        glyph_uv.u0,
                        glyph_uv.v0,
                        glyph_uv.u1,
                        glyph_uv.v1,
                    },
                    .fg = fg,
                    .bg = bg,
                });
            }
        }

        panel_draws[panel_idx] = .{
            .instance_offset_instances = instance_offset_instances,
            .instance_count = @as(u32, @intCast(instances.items.len)) - instance_offset_instances,
            .coverage = variant.coverage,
            .origin_col = @as(u32, @intCast(panel_idx)) * panel_cols,
        };
    }

    return .{
        .instances = instances,
        .panel_draws = panel_draws,
        .panel_cols = panel_cols,
        .window_cols = panel_cols * @as(u32, @intCast(variants.len)),
        .window_rows = top_margin_rows + @as(u32, @intCast(specimen_lines.len)) + 2,
    };
}

fn drawTextCoverageCompareFrame(
    ctx: *renderer.Context,
    scene: *const TextCoverageCompareScene,
    cell_w_px: u32,
    cell_h_px: u32,
    clear_color: [4]f32,
) !void {
    _ = try ctx.vkd.waitForFences(
        ctx.device,
        1,
        @ptrCast(&ctx.in_flight_fence),
        .true,
        std.math.maxInt(u64),
    );
    try ctx.vkd.resetFences(ctx.device, 1, @ptrCast(&ctx.in_flight_fence));

    const acquire = ctx.vkd.acquireNextImageKHR(
        ctx.device,
        ctx.swapchain,
        std.math.maxInt(u64),
        ctx.image_available,
        .null_handle,
    ) catch |err| switch (err) {
        error.OutOfDateKHR => return error.OutOfDateKHR,
        else => return err,
    };
    if (acquire.result == .suboptimal_khr) return error.OutOfDateKHR;
    const image_index = acquire.image_index;

    try ctx.vkd.resetCommandBuffer(ctx.command_buffer, .{});
    try ctx.vkd.beginCommandBuffer(ctx.command_buffer, &vk.CommandBufferBeginInfo{
        .flags = .{ .one_time_submit_bit = true },
    });

    const clear_value = vk.ClearValue{ .color = .{ .float_32 = clear_color } };
    ctx.vkd.cmdBeginRenderPass(ctx.command_buffer, &vk.RenderPassBeginInfo{
        .render_pass = ctx.render_pass,
        .framebuffer = ctx.framebuffers[image_index],
        .render_area = .{
            .offset = .{ .x = 0, .y = 0 },
            .extent = ctx.swapchain_extent,
        },
        .clear_value_count = 1,
        .p_clear_values = @ptrCast(&clear_value),
    }, .@"inline");

    ctx.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);

    const viewport = vk.Viewport{
        .x = 0.0,
        .y = 0.0,
        .width = @floatFromInt(ctx.swapchain_extent.width),
        .height = @floatFromInt(ctx.swapchain_extent.height),
        .min_depth = 0.0,
        .max_depth = 1.0,
    };
    ctx.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));

    ctx.vkd.cmdBindDescriptorSets(
        ctx.command_buffer,
        .graphics,
        ctx.pipeline_layout,
        0,
        1,
        @ptrCast(&ctx.descriptor_set),
        0,
        null,
    );

    const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
    const panel_width_px = scene.panel_cols * cell_w_px;

    for (scene.panel_draws) |panel_draw| {
        const scissor_x_px = panel_draw.origin_col * cell_w_px;
        if (scissor_x_px >= ctx.swapchain_extent.width) continue;

        const scissor = vk.Rect2D{
            .offset = .{ .x = @intCast(scissor_x_px), .y = 0 },
            .extent = .{
                .width = @min(panel_width_px, ctx.swapchain_extent.width - scissor_x_px),
                .height = ctx.swapchain_extent.height,
            },
        };
        ctx.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));

        const push_constants = renderer.PushConstants{
            .viewport_size = .{
                @floatFromInt(ctx.swapchain_extent.width),
                @floatFromInt(ctx.swapchain_extent.height),
            },
            .cell_size = .{
                @floatFromInt(cell_w_px),
                @floatFromInt(cell_h_px),
            },
            .coverage_params = panel_draw.coverage,
        };
        ctx.vkd.cmdPushConstants(
            ctx.command_buffer,
            ctx.pipeline_layout,
            .{ .vertex_bit = true, .fragment_bit = true },
            0,
            @sizeOf(renderer.PushConstants),
            @ptrCast(&push_constants),
        );

        const offsets = [_]vk.DeviceSize{
            0,
            @as(vk.DeviceSize, panel_draw.instance_offset_instances) * @sizeOf(renderer.Instance),
        };
        ctx.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);
        ctx.vkd.cmdDraw(ctx.command_buffer, 6, panel_draw.instance_count, 0, 0);
    }

    ctx.vkd.cmdEndRenderPass(ctx.command_buffer);
    try ctx.vkd.endCommandBuffer(ctx.command_buffer);

    const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
    try ctx.vkd.queueSubmit(ctx.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.image_available),
        .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
        .command_buffer_count = 1,
        .p_command_buffers = @ptrCast(&ctx.command_buffer),
        .signal_semaphore_count = 1,
        .p_signal_semaphores = @ptrCast(&ctx.render_finished),
    }), ctx.in_flight_fence);

    const present_result = ctx.vkd.queuePresentKHR(ctx.present_queue, &vk.PresentInfoKHR{
        .wait_semaphore_count = 1,
        .p_wait_semaphores = @ptrCast(&ctx.render_finished),
        .swapchain_count = 1,
        .p_swapchains = @ptrCast(&ctx.swapchain),
        .p_image_indices = @ptrCast(&image_index),
    }) catch |err| switch (err) {
        error.OutOfDateKHR => return error.OutOfDateKHR,
        else => return err,
    };
    if (present_result == .suboptimal_khr) return error.OutOfDateKHR;
}

const RowRefreshState = enum {
    full,
    partial,
@@ -1469,6 +1723,86 @@ fn makeTestInstances(
    return instances;
}

fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();

    const window = try conn.createWindow(alloc, "waystty-text-compare");
    defer window.deinit();

    _ = conn.display.roundtrip();

    var font_lookup = try font.lookupConfiguredFont(alloc);
    defer font_lookup.deinit(alloc);

    var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(alloc, 1024, 1024);
    defer atlas.deinit();

    var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
    defer scene.deinit(alloc);

    const cell_w = face.cellWidth();
    const cell_h = face.cellHeight();
    window.width = scene.window_cols * cell_w;
    window.height = scene.window_rows * cell_h;

    var ctx = try renderer.Context.init(
        alloc,
        @ptrCast(conn.display),
        @ptrCast(window.surface),
        window.width,
        window.height,
    );
    defer ctx.deinit();

    try ctx.uploadAtlas(atlas.pixels);
    atlas.dirty = false;
    try ctx.uploadInstances(scene.instances.items);

    var last_window_w = window.width;
    var last_window_h = window.height;

    while (!window.should_close) {
        _ = conn.display.flush();
        if (conn.display.prepareRead()) {
            _ = conn.display.readEvents();
        }
        _ = conn.display.dispatchPending();

        if (window.width != last_window_w or window.height != last_window_h) {
            _ = try ctx.vkd.deviceWaitIdle(ctx.device);
            try ctx.recreateSwapchain(window.width, window.height);
            last_window_w = window.width;
            last_window_h = window.height;
        }

        drawTextCoverageCompareFrame(
            &ctx,
            &scene,
            cell_w,
            cell_h,
            .{ 0.0, 0.0, 0.0, 1.0 },
        ) catch |err| switch (err) {
            error.OutOfDateKHR => {
                _ = try ctx.vkd.deviceWaitIdle(ctx.device);
                try ctx.recreateSwapchain(window.width, window.height);
                last_window_w = window.width;
                last_window_h = window.height;
                continue;
            },
            else => return err,
        };

        _ = conn.display.flush();
        std.Thread.sleep(16 * std.time.ns_per_ms);
    }

    _ = try ctx.vkd.deviceWaitIdle(ctx.device);
}

fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();
@@ -1769,6 +2103,38 @@ test "comparisonSpecimenLines remains fixed and non-empty" {
    }
}

test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
    var lookup = try font.lookupConfiguredFont(std.testing.allocator);
    defer lookup.deinit(std.testing.allocator);

    var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
    defer face.deinit();

    var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
    defer atlas.deinit();

    var scene = try buildTextCoverageCompareScene(std.testing.allocator, &face, &atlas);
    defer scene.deinit(std.testing.allocator);

    const variants = comparisonVariants();
    const first_draw = scene.panel_draws[0];
    try std.testing.expect(first_draw.instance_count > 0);

    var idx: usize = 0;
    while (idx < scene.panel_draws.len) : (idx += 1) {
        try std.testing.expectEqual(first_draw.instance_count, scene.panel_draws[idx].instance_count);
        try std.testing.expectEqualDeep(variants[idx].coverage, scene.panel_draws[idx].coverage);
    }

    const panel0_first = scene.instances.items[first_draw.instance_offset_instances];
    const panel1_first = scene.instances.items[scene.panel_draws[1].instance_offset_instances];
    try std.testing.expectEqual(panel0_first.cell_pos[1], panel1_first.cell_pos[1]);
    try std.testing.expectEqual(
        @as(f32, @floatFromInt(scene.panel_cols)),
        panel1_first.cell_pos[0] - panel0_first.cell_pos[0],
    );
}

fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
    var conn = try wayland_client.Connection.init();
    defer conn.deinit();