a73x

5cae59bc

Add text coverage comparison mode

a73x   2026-04-09 08:32

Commit message
Add text coverage comparison mode

src/main.zig
Old New
@@ -5,6 +5,7 @@ const wayland_client = @import("wayland-client");
5 const renderer = @import("renderer"); 5 const renderer = @import("renderer");
6 const font = @import("font"); 6 const font = @import("font");
7 const config = @import("config"); 7 const config = @import("config");
8 const vk = @field(renderer, "vk");
8 9
9 const c = @cImport({ 10 const c = @cImport({
10 @cInclude("xkbcommon/xkbcommon-keysyms.h"); 11 @cInclude("xkbcommon/xkbcommon-keysyms.h");
@@ -56,6 +57,10 @@ pub fn main() !void {
56 return runDrawSmokeTest(alloc); 57 return runDrawSmokeTest(alloc);
57 } 58 }
58 59
60 if (args.len >= 2 and std.mem.eql(u8, args[1], "--text-compare")) {
61 return runTextCoverageCompare(alloc);
62 }
63
59 return runTerminal(alloc); 64 return runTerminal(alloc);
60 } 65 }
61 66
@@ -494,6 +499,255 @@ fn comparisonPanelOrigins(panel_cols: u32, top_margin_rows: u32) [4][2]f32 {
494 return origins; 499 return origins;
495 } 500 }
496 501
502 const ComparisonPanelDraw = struct {
503 instance_offset_instances: u32,
504 instance_count: u32,
505 coverage: [2]f32,
506 origin_col: u32,
507 };
508
509 const TextCoverageCompareScene = struct {
510 instances: std.ArrayListUnmanaged(renderer.Instance) = .empty,
511 panel_draws: [4]ComparisonPanelDraw,
512 panel_cols: u32,
513 window_cols: u32,
514 window_rows: u32,
515
516 fn deinit(self: *TextCoverageCompareScene, alloc: std.mem.Allocator) void {
517 self.instances.deinit(alloc);
518 }
519 };
520
521 fn comparisonPanelCols(lines: []const []const u8) u32 {
522 var max_cols: u32 = 0;
523 for (lines) |line| {
524 max_cols = @max(max_cols, @as(u32, @intCast(line.len)));
525 }
526 return max_cols + 4;
527 }
528
529 fn comparisonVisibleGlyphCount(lines: []const []const u8) u32 {
530 var count: u32 = 0;
531 for (lines) |line| {
532 for (line) |char| {
533 if (char != ' ') count += 1;
534 }
535 }
536 return count;
537 }
538
539 fn buildTextCoverageCompareScene(
540 alloc: std.mem.Allocator,
541 face: *font.Face,
542 atlas: *font.Atlas,
543 ) !TextCoverageCompareScene {
544 const specimen_lines = comparisonSpecimenLines();
545 const variants = comparisonVariants();
546 const top_margin_rows: u32 = 2;
547 const panel_cols = comparisonPanelCols(specimen_lines);
548 const panel_origins = comparisonPanelOrigins(panel_cols, top_margin_rows);
549 const panel_glyph_count = comparisonVisibleGlyphCount(specimen_lines);
550
551 var instances: std.ArrayListUnmanaged(renderer.Instance) = .empty;
552 errdefer instances.deinit(alloc);
553 try instances.ensureTotalCapacity(alloc, @as(usize, panel_glyph_count) * variants.len);
554
555 const baseline = face.baseline();
556 const fg = [4]f32{ 1.0, 1.0, 1.0, 1.0 };
557 const bg = [4]f32{ 0.0, 0.0, 0.0, 1.0 };
558
559 var panel_draws: [4]ComparisonPanelDraw = undefined;
560 for (variants, 0..) |variant, panel_idx| {
561 const origin = panel_origins[panel_idx];
562 const instance_offset_instances: u32 = @intCast(instances.items.len);
563
564 for (specimen_lines, 0..) |line, row_idx| {
565 for (line, 0..) |char, col_idx| {
566 if (char == ' ') continue;
567
568 const glyph_uv = try atlas.getOrInsert(face, char);
569 try instances.append(alloc, .{
570 .cell_pos = .{
571 origin[0] + @as(f32, @floatFromInt(col_idx)),
572 origin[1] + @as(f32, @floatFromInt(row_idx)),
573 },
574 .glyph_size = .{
575 @floatFromInt(glyph_uv.width),
576 @floatFromInt(glyph_uv.height),
577 },
578 .glyph_bearing = .{
579 @floatFromInt(glyph_uv.bearing_x),
580 glyphTopOffset(baseline, glyph_uv.bearing_y),
581 },
582 .uv_rect = .{
583 glyph_uv.u0,
584 glyph_uv.v0,
585 glyph_uv.u1,
586 glyph_uv.v1,
587 },
588 .fg = fg,
589 .bg = bg,
590 });
591 }
592 }
593
594 panel_draws[panel_idx] = .{
595 .instance_offset_instances = instance_offset_instances,
596 .instance_count = @as(u32, @intCast(instances.items.len)) - instance_offset_instances,
597 .coverage = variant.coverage,
598 .origin_col = @as(u32, @intCast(panel_idx)) * panel_cols,
599 };
600 }
601
602 return .{
603 .instances = instances,
604 .panel_draws = panel_draws,
605 .panel_cols = panel_cols,
606 .window_cols = panel_cols * @as(u32, @intCast(variants.len)),
607 .window_rows = top_margin_rows + @as(u32, @intCast(specimen_lines.len)) + 2,
608 };
609 }
610
611 fn drawTextCoverageCompareFrame(
612 ctx: *renderer.Context,
613 scene: *const TextCoverageCompareScene,
614 cell_w_px: u32,
615 cell_h_px: u32,
616 clear_color: [4]f32,
617 ) !void {
618 _ = try ctx.vkd.waitForFences(
619 ctx.device,
620 1,
621 @ptrCast(&ctx.in_flight_fence),
622 .true,
623 std.math.maxInt(u64),
624 );
625 try ctx.vkd.resetFences(ctx.device, 1, @ptrCast(&ctx.in_flight_fence));
626
627 const acquire = ctx.vkd.acquireNextImageKHR(
628 ctx.device,
629 ctx.swapchain,
630 std.math.maxInt(u64),
631 ctx.image_available,
632 .null_handle,
633 ) catch |err| switch (err) {
634 error.OutOfDateKHR => return error.OutOfDateKHR,
635 else => return err,
636 };
637 if (acquire.result == .suboptimal_khr) return error.OutOfDateKHR;
638 const image_index = acquire.image_index;
639
640 try ctx.vkd.resetCommandBuffer(ctx.command_buffer, .{});
641 try ctx.vkd.beginCommandBuffer(ctx.command_buffer, &vk.CommandBufferBeginInfo{
642 .flags = .{ .one_time_submit_bit = true },
643 });
644
645 const clear_value = vk.ClearValue{ .color = .{ .float_32 = clear_color } };
646 ctx.vkd.cmdBeginRenderPass(ctx.command_buffer, &vk.RenderPassBeginInfo{
647 .render_pass = ctx.render_pass,
648 .framebuffer = ctx.framebuffers[image_index],
649 .render_area = .{
650 .offset = .{ .x = 0, .y = 0 },
651 .extent = ctx.swapchain_extent,
652 },
653 .clear_value_count = 1,
654 .p_clear_values = @ptrCast(&clear_value),
655 }, .@"inline");
656
657 ctx.vkd.cmdBindPipeline(ctx.command_buffer, .graphics, ctx.pipeline);
658
659 const viewport = vk.Viewport{
660 .x = 0.0,
661 .y = 0.0,
662 .width = @floatFromInt(ctx.swapchain_extent.width),
663 .height = @floatFromInt(ctx.swapchain_extent.height),
664 .min_depth = 0.0,
665 .max_depth = 1.0,
666 };
667 ctx.vkd.cmdSetViewport(ctx.command_buffer, 0, 1, @ptrCast(&viewport));
668
669 ctx.vkd.cmdBindDescriptorSets(
670 ctx.command_buffer,
671 .graphics,
672 ctx.pipeline_layout,
673 0,
674 1,
675 @ptrCast(&ctx.descriptor_set),
676 0,
677 null,
678 );
679
680 const buffers = [_]vk.Buffer{ ctx.quad_vertex_buffer, ctx.instance_buffer };
681 const panel_width_px = scene.panel_cols * cell_w_px;
682
683 for (scene.panel_draws) |panel_draw| {
684 const scissor_x_px = panel_draw.origin_col * cell_w_px;
685 if (scissor_x_px >= ctx.swapchain_extent.width) continue;
686
687 const scissor = vk.Rect2D{
688 .offset = .{ .x = @intCast(scissor_x_px), .y = 0 },
689 .extent = .{
690 .width = @min(panel_width_px, ctx.swapchain_extent.width - scissor_x_px),
691 .height = ctx.swapchain_extent.height,
692 },
693 };
694 ctx.vkd.cmdSetScissor(ctx.command_buffer, 0, 1, @ptrCast(&scissor));
695
696 const push_constants = renderer.PushConstants{
697 .viewport_size = .{
698 @floatFromInt(ctx.swapchain_extent.width),
699 @floatFromInt(ctx.swapchain_extent.height),
700 },
701 .cell_size = .{
702 @floatFromInt(cell_w_px),
703 @floatFromInt(cell_h_px),
704 },
705 .coverage_params = panel_draw.coverage,
706 };
707 ctx.vkd.cmdPushConstants(
708 ctx.command_buffer,
709 ctx.pipeline_layout,
710 .{ .vertex_bit = true, .fragment_bit = true },
711 0,
712 @sizeOf(renderer.PushConstants),
713 @ptrCast(&push_constants),
714 );
715
716 const offsets = [_]vk.DeviceSize{
717 0,
718 @as(vk.DeviceSize, panel_draw.instance_offset_instances) * @sizeOf(renderer.Instance),
719 };
720 ctx.vkd.cmdBindVertexBuffers(ctx.command_buffer, 0, 2, &buffers, &offsets);
721 ctx.vkd.cmdDraw(ctx.command_buffer, 6, panel_draw.instance_count, 0, 0);
722 }
723
724 ctx.vkd.cmdEndRenderPass(ctx.command_buffer);
725 try ctx.vkd.endCommandBuffer(ctx.command_buffer);
726
727 const wait_stage = vk.PipelineStageFlags{ .color_attachment_output_bit = true };
728 try ctx.vkd.queueSubmit(ctx.graphics_queue, 1, @ptrCast(&vk.SubmitInfo{
729 .wait_semaphore_count = 1,
730 .p_wait_semaphores = @ptrCast(&ctx.image_available),
731 .p_wait_dst_stage_mask = @ptrCast(&wait_stage),
732 .command_buffer_count = 1,
733 .p_command_buffers = @ptrCast(&ctx.command_buffer),
734 .signal_semaphore_count = 1,
735 .p_signal_semaphores = @ptrCast(&ctx.render_finished),
736 }), ctx.in_flight_fence);
737
738 const present_result = ctx.vkd.queuePresentKHR(ctx.present_queue, &vk.PresentInfoKHR{
739 .wait_semaphore_count = 1,
740 .p_wait_semaphores = @ptrCast(&ctx.render_finished),
741 .swapchain_count = 1,
742 .p_swapchains = @ptrCast(&ctx.swapchain),
743 .p_image_indices = @ptrCast(&image_index),
744 }) catch |err| switch (err) {
745 error.OutOfDateKHR => return error.OutOfDateKHR,
746 else => return err,
747 };
748 if (present_result == .suboptimal_khr) return error.OutOfDateKHR;
749 }
750
497 const RowRefreshState = enum { 751 const RowRefreshState = enum {
498 full, 752 full,
499 partial, 753 partial,
@@ -1469,6 +1723,86 @@ fn makeTestInstances(
1469 return instances; 1723 return instances;
1470 } 1724 }
1471 1725
1726 fn runTextCoverageCompare(alloc: std.mem.Allocator) !void {
1727 var conn = try wayland_client.Connection.init();
1728 defer conn.deinit();
1729
1730 const window = try conn.createWindow(alloc, "waystty-text-compare");
1731 defer window.deinit();
1732
1733 _ = conn.display.roundtrip();
1734
1735 var font_lookup = try font.lookupConfiguredFont(alloc);
1736 defer font_lookup.deinit(alloc);
1737
1738 var face = try font.Face.init(alloc, font_lookup.path, font_lookup.index, config.font_size_px);
1739 defer face.deinit();
1740
1741 var atlas = try font.Atlas.init(alloc, 1024, 1024);
1742 defer atlas.deinit();
1743
1744 var scene = try buildTextCoverageCompareScene(alloc, &face, &atlas);
1745 defer scene.deinit(alloc);
1746
1747 const cell_w = face.cellWidth();
1748 const cell_h = face.cellHeight();
1749 window.width = scene.window_cols * cell_w;
1750 window.height = scene.window_rows * cell_h;
1751
1752 var ctx = try renderer.Context.init(
1753 alloc,
1754 @ptrCast(conn.display),
1755 @ptrCast(window.surface),
1756 window.width,
1757 window.height,
1758 );
1759 defer ctx.deinit();
1760
1761 try ctx.uploadAtlas(atlas.pixels);
1762 atlas.dirty = false;
1763 try ctx.uploadInstances(scene.instances.items);
1764
1765 var last_window_w = window.width;
1766 var last_window_h = window.height;
1767
1768 while (!window.should_close) {
1769 _ = conn.display.flush();
1770 if (conn.display.prepareRead()) {
1771 _ = conn.display.readEvents();
1772 }
1773 _ = conn.display.dispatchPending();
1774
1775 if (window.width != last_window_w or window.height != last_window_h) {
1776 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1777 try ctx.recreateSwapchain(window.width, window.height);
1778 last_window_w = window.width;
1779 last_window_h = window.height;
1780 }
1781
1782 drawTextCoverageCompareFrame(
1783 &ctx,
1784 &scene,
1785 cell_w,
1786 cell_h,
1787 .{ 0.0, 0.0, 0.0, 1.0 },
1788 ) catch |err| switch (err) {
1789 error.OutOfDateKHR => {
1790 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1791 try ctx.recreateSwapchain(window.width, window.height);
1792 last_window_w = window.width;
1793 last_window_h = window.height;
1794 continue;
1795 },
1796 else => return err,
1797 };
1798
1799 _ = conn.display.flush();
1800 std.Thread.sleep(16 * std.time.ns_per_ms);
1801 }
1802
1803 _ = try ctx.vkd.deviceWaitIdle(ctx.device);
1804 }
1805
1472 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void { 1806 fn runDrawSmokeTest(alloc: std.mem.Allocator) !void {
1473 var conn = try wayland_client.Connection.init(); 1807 var conn = try wayland_client.Connection.init();
1474 defer conn.deinit(); 1808 defer conn.deinit();
@@ -1769,6 +2103,38 @@ test "comparisonSpecimenLines remains fixed and non-empty" {
1769 } 2103 }
1770 } 2104 }
1771 2105
2106 test "buildTextCoverageCompareScene repeats the same specimen in four panels" {
2107 var lookup = try font.lookupConfiguredFont(std.testing.allocator);
2108 defer lookup.deinit(std.testing.allocator);
2109
2110 var face = try font.Face.init(std.testing.allocator, lookup.path, lookup.index, config.font_size_px);
2111 defer face.deinit();
2112
2113 var atlas = try font.Atlas.init(std.testing.allocator, 256, 256);
2114 defer atlas.deinit();
2115
2116 var scene = try buildTextCoverageCompareScene(std.testing.allocator, &face, &atlas);
2117 defer scene.deinit(std.testing.allocator);
2118
2119 const variants = comparisonVariants();
2120 const first_draw = scene.panel_draws[0];
2121 try std.testing.expect(first_draw.instance_count > 0);
2122
2123 var idx: usize = 0;
2124 while (idx < scene.panel_draws.len) : (idx += 1) {
2125 try std.testing.expectEqual(first_draw.instance_count, scene.panel_draws[idx].instance_count);
2126 try std.testing.expectEqualDeep(variants[idx].coverage, scene.panel_draws[idx].coverage);
2127 }
2128
2129 const panel0_first = scene.instances.items[first_draw.instance_offset_instances];
2130 const panel1_first = scene.instances.items[scene.panel_draws[1].instance_offset_instances];
2131 try std.testing.expectEqual(panel0_first.cell_pos[1], panel1_first.cell_pos[1]);
2132 try std.testing.expectEqual(
2133 @as(f32, @floatFromInt(scene.panel_cols)),
2134 panel1_first.cell_pos[0] - panel0_first.cell_pos[0],
2135 );
2136 }
2137
1772 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void { 2138 fn runRenderSmokeTest(alloc: std.mem.Allocator) !void {
1773 var conn = try wayland_client.Connection.init(); 2139 var conn = try wayland_client.Connection.init();
1774 defer conn.deinit(); 2140 defer conn.deinit();