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