From d89bccc59ce1c0114a97b0123ecbdd676ac81e36 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 08:38:39 -0700 Subject: [PATCH 1/5] Unify compact loop-card presentation across canvases Share measured title/pill, owned Unicode live/meta rows and compact action geometry between project and overview cards while preserving existing action safety. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39e65c1f-fe48-4e45-b6f2-e26f161d9d6b Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 159 +++-- graphcode-windows/src/AppFont.zig | 99 ++- graphcode-windows/src/DesignTokens.zig | 2 +- graphcode-windows/src/GraphCanvas.zig | 914 +++++++++++++++++++++---- 4 files changed, 998 insertions(+), 176 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 71430abb7..af2df6932 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7543,7 +7543,8 @@ pub const App = struct { const sidebar_bottom = self.sidebarContentBottom(client.bottom); const footer_viewport_bottom = GraphCanvas.sidebarBottom(client.bottom, self.workspace_controls, self.surface); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); - provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); const current_inspection = self.currentWorktreeInspection(); + provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); + const current_inspection = self.currentWorktreeInspection(); // The scroll sink is optional so test sinks that only record elements need not declare it. if (comptime providerHasDecl(@TypeOf(provider), "syncSidebarScroll")) { provider.syncSidebarScroll( @@ -7868,7 +7869,7 @@ pub const App = struct { const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, .{ .logical = bounds }, self.model.selected_index == index, false) catch return; - if (GraphCanvas.hitTestAttentionAction(graph.nodes.items, graph.edges.items, bounds.right - 20, bounds.bottom - 12, &self.canvas) != null) { + if (GraphCanvas.attentionActionVisible(node, graph.nodes.items, graph.edges.items)) { self.appendAccessibilityElement(&elements, &owned_identities, "attention-action", key, GraphCanvas.attentionActionLabel(node), 4, .{ .logical = GraphCanvas.attentionActionBounds(bounds, &self.canvas) }, false, false) catch return; } if (GraphCanvas.hasReclaimOffer(node, current_inspection, self.kept_worktree_paths.items)) { @@ -11628,8 +11629,8 @@ test "sidebar UIA bounds and hit targets stay clear of the update banner at 1280 std.debug.print( "{s} bounds {d},{d},{d},{d} intersect banner {d},{d},{d},{d} at dpi {d} width {d} scroll {d}\n", .{ - sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, - banner.left, banner.top, banner.right, banner.bottom, case.dpi, + sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, + banner.left, banner.top, banner.right, banner.bottom, case.dpi, case.width, app.sidebar_scroll, }, ); @@ -13709,7 +13710,6 @@ const LiveTerminalFixture = struct { _ = onWindowMessage(&self.app, self.app.window.hwnd, c.WM_TIMER, MainWindow.timer_id, 0, &result); } - /// A daemon graph change through the production frame path (and its workspace refresh). fn deliver(self: *LiveTerminalFixture, loops: []const LiveLoop) !void { self.revision += 1; @@ -15621,7 +15621,7 @@ test "workspace layout: deleting a loop ends the shell sessions of its own tabs test "workspace layout: restore drops a pane whose session is gone instead of recreating it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15662,7 +15662,7 @@ fn restoredAliveShell(fixture: *LiveTerminalFixture, session: []const u8) bool { test "workspace layout: at startup an unreachable agent pane and shell tab keep their retry slots while a live saved pane restores, and both come back" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15747,7 +15747,7 @@ test "workspace layout: restore keeps a pane whose session zmx could not reach, test "workspace layout: a loop's shells are neither ended nor forgotten while zmx cannot reach their session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15769,7 +15769,7 @@ test "workspace layout: a loop's shells are neither ended nor forgotten while zm test "workspace layout: a shell whose daemon refused the connection is gone: its record is forgotten and nothing is killed" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15784,7 +15784,7 @@ test "workspace layout: a shell whose daemon refused the connection is gone: its test "live terminal: opening a loop whose session zmx could not reach waits and attaches only once it answers" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; @@ -15940,7 +15940,7 @@ const nodes_full = node_loop_a ++ "," ++ test "workspace layout: a project's first snapshot ends the shells of loops deleted while the shell was closed, once the daemon's own answer agrees, and only those" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const other_project = "C:\\another-project"; const alias_project = try std.mem.replaceOwned(u8, std.testing.allocator, fixture.project, "\\", "/"); @@ -15983,7 +15983,7 @@ test "workspace layout: a project's first snapshot ends the shells of loops dele test "workspace layout: an empty or partial first snapshot sweeps nothing that the full snapshot still holds" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-a", &.{ .{ .id = "loop-a", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_two } }); @@ -16018,7 +16018,7 @@ test "workspace layout: an empty or partial first snapshot sweeps nothing that t test "workspace layout: a layout saved after this shell started is another shell's and is never swept" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // Written while this shell runs, as a second shell sharing the support directory would. try fixture.saveLoopLayout(fixture.project, "other-shells-loop", &.{ .{ .id = "other-shells-loop", .agent = true }, .{ .id = shell_uuid_one } }); @@ -16625,7 +16625,7 @@ test "workspace layout: a stale or revisionless frame that names the loop again /// A layout older than this shell, with a recorded live shell session, for a loop the project's /// first usable snapshot will not name; the shell saw only an empty graph of the project before. fn emptyFirstGraphWithOldLayout(fixture: *LiveTerminalFixture) !i128 { - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); try fixture.setLive(&.{ "loop-a", "loop-d", shell_uuid_two }); @@ -16671,7 +16671,7 @@ test "workspace layout: after a reconnect a project the shell holds no snapshot test "workspace layout: a project first opened after a reconnect is left alone too" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16694,7 +16694,7 @@ test "workspace layout: a project first opened after a reconnect is left alone t test "workspace layout: a reconnect before the shell's first poll or snapshot still ends the age sweep" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16719,7 +16719,7 @@ test "workspace layout: a reconnect before the shell's first poll or snapshot st test "workspace layout: a snapshot queued before the transport failed arms nothing while the client is not connected" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16749,7 +16749,7 @@ test "workspace layout: a snapshot queued before the transport failed arms nothi test "workspace layout: a look the age sweep started for a layout the shell never held does not outlive the connection" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -17122,7 +17122,7 @@ test "workspace layout: an empty graph, and a project the shell does not hold, a test "workspace layout: a shell session whose uuid is a graph node's id is never ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // Node ids are uuids; the daemon names a loop's session after the uppercase one. const node_uuid = shell_uuid_seven; @@ -17147,7 +17147,7 @@ test "workspace layout: a shell session whose uuid is a graph node's id is never test "workspace layout: a layout that cannot be attributed to the deleted loop ends nothing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17170,7 +17170,7 @@ test "workspace layout: a layout that cannot be attributed to the deleted loop e test "workspace layout: deleting a loop ends only recorded, live, shell-shaped sessions nothing else names" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const foreign = "other-" ++ shell_uuid_two; // A layout from an earlier shell, a stale copy or a hand edit can record anything as a @@ -17200,7 +17200,7 @@ test "workspace layout: deleting a loop ends only recorded, live, shell-shaped s test "workspace layout: a loop's shells are ended only after a successful session listing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17324,7 +17324,7 @@ test "workspace layout: a corrupt, foreign or newer-schema loop layout is kept a test "workspace layout: a live session that only a layout names is never ended without proof the shell made it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // A foreign session that happens to look like a shell's: lowercase uuid, live, no node's // id, named by no other layout. The name shape is not provenance. @@ -17344,7 +17344,7 @@ test "workspace layout: a live session that only a layout names is never ended w test "workspace layout: a tab's session is recorded as the shell's own when minted and forgotten once ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; const workspace = &fixture.workspace; @@ -17375,7 +17375,7 @@ test "workspace layout: a tab's session is recorded as the shell's own when mint test "workspace layout: a layout scan cut short by its cap refuses the kill instead of assuming nothing claims it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17397,7 +17397,7 @@ test "workspace layout: a layout scan cut short by its cap refuses the kill inst test "workspace layout: a session another loop's record names is never ended by a different loop's retirement" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); // The window in a second shell sharing this layout directory: it has recorded a fresh // session for its own loop-a, while a stale layout of the deleted gone-loop names the uuid. @@ -17417,7 +17417,7 @@ test "workspace layout: a session another loop's record names is never ended by /// sequence waits for each attach to finish before the next save. fn expectClaimAndRecordSeenByAttach(hold_layout: bool) !void { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; @@ -17500,7 +17500,7 @@ fn attachStartCount(dir: std.fs.Dir) !usize { test "workspace layout: New Tab and Split with no free surface slot fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17540,7 +17540,7 @@ test "workspace layout: New Tab and Split with no free surface slot fail before test "workspace layout: New Tab and Split with every free slot reserved by a pending restore fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17622,7 +17622,7 @@ test "workspace layout: a failed rollback save leaves the claim on disk, never a test "workspace layout: restore drops a loop layout's claimed pane whose session was never started" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17649,7 +17649,7 @@ test "workspace layout: restore drops a loop layout's claimed pane whose session test "workspace layout: an unreadable unrelated layout defers the cleanup, and the next sweep ends the shell" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17673,7 +17673,7 @@ test "workspace layout: an unreadable unrelated layout defers the cleanup, and t test "workspace layout: a corrupt layout that cannot be set aside is left as it is and its loop is not opened" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{ .{ .id = "loop-a" } }); + try fixture.init(&.{.{ .id = "loop-a" }}); defer fixture.deinit(); const app = &fixture.app; const truncated = "{\"schemaVersion\":2,\"project\":\""; @@ -19752,11 +19752,11 @@ test "DPI UIA logical cards headers sidebar and direct inserts scale once" { }, false); } try expectDpiAccessibility(.project, null, &.{ - .{ .identity = "project-card:A:loop", .bounds = .{ .{ 252, 84, 502, 190 }, .{ 378, 126, 753, 285 }, .{ 504, 168, 1004, 380 } } }, + .{ .identity = "project-card:A:loop", .bounds = .{ .{ 252, 84, 502, 180 }, .{ 378, 126, 753, 270 }, .{ 504, 168, 1004, 360 } } }, .{ .identity = "canvas-alert:ingress", .bounds = .{ .{ 244, 556, 1176, 612 }, .{ 366, 834, 1764, 918 }, .{ 488, 1112, 2352, 1224 } } }, }, false); try expectDpiAccessibility(.overview, null, &.{ - .{ .identity = "overview-card:A:loop", .bounds = .{ .{ 262, 118, 482, 204 }, .{ 393, 177, 723, 306 }, .{ 524, 236, 964, 408 } } }, + .{ .identity = "overview-card:A:loop", .bounds = .{ .{ 262, 118, 512, 214 }, .{ 393, 177, 768, 321 }, .{ 524, 236, 1024, 428 } } }, }, false); try expectDpiAccessibility(.quick_chats, null, &.{ .{ .identity = "quick-chat-card:chat", .bounds = .{ .{ 262, 88, 482, 152 }, .{ 393, 132, 723, 228 }, .{ 524, 176, 964, 304 } } }, @@ -19889,6 +19889,85 @@ fn overviewTestApp(dpi: u32) !App { return app; } +test "loop card parity UIA actions share painted logical bounds and scale once" { + const Sink = struct { + app: *App, + seen: usize = 0, + checked: bool = false, + failure: ?anyerror = null, + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + self.checked = true; + self.check(elements) catch |err| { + self.failure = err; + }; + } + + fn check(self: *@This(), elements: []const Accessibility.DynamicElement) !void { + self.seen = 0; + const app = self.app; + const graph = app.model.graph.?; + const attention = GraphCanvas.attentionActionBounds(GraphCanvas.nodeBounds(0, &app.canvas), &app.canvas); + const reclaim = GraphCanvas.reclaimOfferBounds(GraphCanvas.nodeBounds(1, &app.canvas)); + const expected = [_]struct { identity: []const u8, bounds: c.RECT, present: bool }{ + .{ .identity = "attention-action:C:\\notice-a:question", .bounds = attention, .present = true }, + .{ .identity = "reclaim:C:\\notice-a:resolved", .bounds = reclaim.reclaim, .present = app.kept_worktree_paths.items.len == 0 }, + .{ .identity = "keep:C:\\notice-a:resolved", .bounds = reclaim.keep, .present = app.kept_worktree_paths.items.len == 0 }, + .{ .identity = "attention-action:C:\\notice-a:resolved", .bounds = .{}, .present = false }, + .{ .identity = "attention-action:C:\\notice-a:ordinary", .bounds = .{}, .present = false }, + .{ .identity = "reclaim:C:\\notice-a:unsafe", .bounds = .{}, .present = false }, + .{ .identity = "keep:C:\\notice-a:unsafe", .bounds = .{}, .present = false }, + }; + try std.testing.expect(GraphCanvas.attentionActionVisible(graph.nodes.items[0], graph.nodes.items, graph.edges.items)); + for (expected) |target| { + var matches: usize = 0; + for (elements) |element| { + if (!std.mem.eql(u8, element.identity, target.identity)) continue; + matches += 1; + try std.testing.expect(target.present); + try std.testing.expectEqualDeep([4]i32{ + physicalCoordinate(target.bounds.left, app.dpi), + physicalCoordinate(target.bounds.top, app.dpi), + physicalCoordinate(target.bounds.right, app.dpi), + physicalCoordinate(target.bounds.bottom, app.dpi), + }, [4]i32{ element.left, element.top, element.right, element.bottom }); + } + try std.testing.expectEqual(@as(usize, if (target.present) 1 else 0), matches); + self.seen += matches; + } + try std.testing.expect(self.seen > 0); + } + }; + for ([_]u32{ 96, 120, 144, 192 }) |dpi| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + app.surface = .project; + app.canvas = .{ .zoom = 1.25, .pan_x = -37, .pan_y = 23 }; + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\notice-a","name":"Card parity"},"nodes":[{"id":"question","title":"Named question","state":"running","presence":"awaitingInput"},{"id":"resolved","title":"Safe reclaim","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-1"}},{"id":"ordinary","title":"Ordinary idle","state":"idle"},{"id":"unsafe","title":"Primary checkout","state":"succeeded","worktreeBinding":{"path":"C:\\notice-a\\tree-0"}}],"edges":[]}}} + ); + try std.testing.expect(app.model.selectProject("C:\\notice-a")); + try installNoticeTestInspection(&app, "C:\\notice-a", 2, 1024); + const question = GraphCanvas.attentionActionBounds(GraphCanvas.nodeBounds(0, &app.canvas), &app.canvas); + try std.testing.expectEqual(@as(?usize, 0), GraphCanvas.hitTestAttentionAction(app.model.graph.?.nodes.items, app.model.graph.?.edges.items, question.left + 1, question.top + 1, &app.canvas)); + var sink = Sink{ .app = &app }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; + try std.testing.expectEqual(@as(usize, 3), sink.seen); + try app.kept_worktree_paths.append(try app.allocator.dupe(u8, "C:\\notice-a\\tree-1")); + sink.checked = false; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; + try std.testing.expectEqual(@as(usize, 1), sink.seen); + for (app.kept_worktree_paths.items) |path| app.allocator.free(path); + app.kept_worktree_paths.clearRetainingCapacity(); + } +} + fn loadActivationTestFixture(app: *App) !void { _ = try app.model.updateFromFrame( \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\activation-fixture","name":"Activation fixture"},"nodes":[{"id":"stopped-loop","title":"Stopped loop","loopType":"turnBased","state":{"running":{}}},{"id":"target-loop","title":"Visible idle loop","loopType":"sketch","state":{"idle":{}}},{"id":"other-loop","title":"Unrelated loop","loopType":"turnBased","state":{"stopped":{}}}],"edges":[]}}} @@ -20052,9 +20131,9 @@ test "cross-project overview Open actions select exact projects and emit DPI sco app.openGlobalOverview(); try expectOverviewSelection(&app, .overview, "A", "a4"); const beta_bounds: [4]i32 = switch (dpi) { - 96 => .{ 262, 442, 482, 528 }, - 144 => .{ 393, 663, 723, 792 }, - else => .{ 524, 884, 964, 1056 }, + 96 => .{ 262, 442, 512, 538 }, + 144 => .{ 393, 663, 768, 807 }, + else => .{ 524, 884, 1024, 1076 }, }; var overview = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ .{ .identity = "open-project:A", .selected = true }, @@ -20139,11 +20218,11 @@ test "cross-project overview refresh emits both projects without changing the se var sink = OverviewAccessibilitySink{ .card_count = 6, .expected = &.{ .{ .identity = "open-project:A" }, .{ .identity = "open-project:B", .selected = true }, - .{ .identity = "overview-card:A:a4", .name = "Alpha refreshed", .bounds = .{ 262, 118, 482, 204 } }, - .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 508, 118, 728, 204 } }, + .{ .identity = "overview-card:A:a4", .name = "Alpha refreshed", .bounds = .{ 262, 118, 512, 214 } }, + .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 538, 118, 788, 214 } }, .{ .identity = "overview-card:A:a2" }, .{ .identity = "overview-card:A:a3" }, - .{ .identity = "overview-card:B:b1", .bounds = .{ 262, 442, 482, 528 } }, + .{ .identity = "overview-card:B:b1", .bounds = .{ 262, 442, 512, 538 } }, .{ .identity = "overview-card:B:b2", .name = "Beta 2" }, } }; try sink.expect(&app); diff --git a/graphcode-windows/src/AppFont.zig b/graphcode-windows/src/AppFont.zig index 3af8304c5..1198b8c52 100644 --- a/graphcode-windows/src/AppFont.zig +++ b/graphcode-windows/src/AppFont.zig @@ -17,26 +17,30 @@ const Win32 = @import("Win32.zig"); const c = Win32.c; const Dpi = @import("Dpi.zig"); -const CacheEntry = struct { size: i32, bold: bool, font: c.HFONT }; - -// Small fixed cache: the app only ever requests a handful of distinct -// (size, bold) pairs across all surfaces, so a linear scan over a short -// array is simpler and cheaper than a hash map, and every font lives for -// the process lifetime exactly like the other cached GDI fonts/brushes -// already in this codebase (e.g. NativeForms.zig's `cachedTileFont`). -var cache: [32]CacheEntry = undefined; +pub const Style = enum { regular, semibold, card_title, card_mono, card_state }; +const Face = enum { segoe, consolas }; +const CacheEntry = struct { size: i32, face: Face, weight: i32, font: c.HFONT }; + +// Rounded card zoom/DPI sizes share this cache with native controls. Handles +// remain process-owned, including while a control has one selected. +var cache: [512]CacheEntry = undefined; var cache_len: usize = 0; -fn getScaled(scaled_size: i32, bold: bool) c.HFONT { +fn getScaled(scaled_size: i32, face: Face, weight: i32) c.HFONT { for (cache[0..cache_len]) |entry| { - if (entry.size == scaled_size and entry.bold == bold) return entry.font; + if (entry.size == scaled_size and entry.face == face and entry.weight == weight) return entry.font; + } + // Controls borrow these handles for their lifetime; never evict a cached font. + if (cache_len == cache.len) { + std.log.warn("UI font cache exhausted (size={d}, face={s}, weight={d})", .{ scaled_size, @tagName(face), weight }); + return null; } const font = c.CreateFontW( -scaled_size, 0, 0, 0, - if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, + weight, 0, 0, 0, @@ -45,11 +49,13 @@ fn getScaled(scaled_size: i32, bold: bool) c.HFONT { c.CLIP_DEFAULT_PRECIS, c.CLEARTYPE_QUALITY, c.DEFAULT_PITCH | c.FF_DONTCARE, - std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, + if (face == .consolas) std.unicode.utf8ToUtf16LeStringLiteral("Consolas").ptr else std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, ); - if (font != null and cache_len < cache.len) { - cache[cache_len] = .{ .size = scaled_size, .bold = bold, .font = font }; + if (font != null) { + cache[cache_len] = .{ .size = scaled_size, .face = face, .weight = weight, .font = font }; cache_len += 1; + } else { + std.log.warn("UI font creation failed (size={d}, face={s}, weight={d})", .{ scaled_size, @tagName(face), weight }); } return font; } @@ -62,7 +68,23 @@ pub fn get(size: i32, bold: bool) c.HFONT { } pub fn getForDpi(size: i32, bold: bool, dpi: u32) c.HFONT { - return getScaled(Dpi.scale(size, dpi), bold); + return getScaled(Dpi.scale(size, dpi), .segoe, if (bold) c.FW_SEMIBOLD else c.FW_NORMAL); +} + +pub fn styleHeight(size: f32, dpi: u32) i32 { + return @max(1, @as(i32, @intFromFloat(@round(size * @as(f32, @floatFromInt(dpi)) / 96.0)))); +} + +pub fn getStyle(size: f32, style: Style) c.HFONT { + return getStyleForDpi(size, style, Dpi.base_dpi); +} + +pub fn getStyleForDpi(size: f32, style: Style, dpi: u32) c.HFONT { + return getScaled(styleHeight(size, dpi), if (style == .card_mono) .consolas else .segoe, switch (style) { + .regular, .card_mono => c.FW_NORMAL, + .semibold, .card_title => c.FW_SEMIBOLD, + .card_state => c.FW_BOLD, + }); } /// The standard body-text size used for plain dialog controls (EDIT, @@ -114,3 +136,50 @@ test "get caches distinct fonts per (size, bold) and reuses the same handle" { try std.testing.expectEqual(regular_14, get(14, false)); try std.testing.expectEqual(bold_14, get(14, true)); } + +test "loop card parity font faces weights and cache growth" { + _ = get(14, false); + _ = get(14, true); + try std.testing.expectEqual(get(15, true), getStyle(14.5, .card_title)); + const starting_count = cache_len; + const styles = [_]Style{ .card_title, .card_mono, .card_state }; + const sizes = [_]f32{ 14.5, 10.5, 10 }; + var handles: [3 * 26 * 4]c.HFONT = undefined; + var key_count: usize = 0; + for (0..2) |sweep| { + var index: usize = 0; + for (0..26) |step| { + const zoom = 0.55 + @as(f32, @floatFromInt(step)) * 0.05; + for ([_]u32{ 96, 120, 144, 192 }) |dpi| { + for (styles, sizes) |style, size| { + const font = getStyleForDpi(size * zoom, style, dpi); + try std.testing.expect(font != null); + var info = std.mem.zeroes(c.LOGFONTW); + try std.testing.expect(c.GetObjectW(font, @sizeOf(c.LOGFONTW), &info) != 0); + try std.testing.expectEqual(-styleHeight(size * zoom, dpi), info.lfHeight); + try std.testing.expectEqual(@as(i32, if (style == .card_title) c.FW_SEMIBOLD else if (style == .card_state) c.FW_BOLD else c.FW_NORMAL), info.lfWeight); + const family = if (style == .card_mono) std.unicode.utf8ToUtf16LeStringLiteral("Consolas") else std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI"); + try std.testing.expectEqualSlices(u16, family, std.mem.sliceTo(&info.lfFaceName, 0)); + if (sweep == 0) handles[index] = font else try std.testing.expectEqual(handles[index], font); + index += 1; + } + } + } + if (sweep == 0) { + try std.testing.expect(cache_len > starting_count); + try std.testing.expect(cache_len - starting_count <= handles.len); + key_count = cache_len; + } else try std.testing.expectEqual(key_count, cache_len); + } + try std.testing.expectEqual(@as(i32, 15), styleHeight(14.5, 96)); + try std.testing.expectEqual(@as(i32, 11), styleHeight(10.5, 96)); +} + +test "loop card parity full cache refuses without creating uncached handles" { + const font = get(14, false); + const saved_len = cache_len; + defer cache_len = saved_len; + while (cache_len < cache.len) : (cache_len += 1) cache[cache_len] = cache[0]; + try std.testing.expectEqual(font, get(14, false)); + try std.testing.expect(getStyle(123, .card_mono) == null); +} diff --git a/graphcode-windows/src/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig index 85ae73ef8..32230a3db 100644 --- a/graphcode-windows/src/DesignTokens.zig +++ b/graphcode-windows/src/DesignTokens.zig @@ -100,7 +100,7 @@ pub const onboarding_sheet: Color = 0x00262323; pub const folder_glyph: Color = 0x00EFA55D; pub const loop_card_width: i32 = 250; -pub const loop_card_height: i32 = 106; +pub const loop_card_height: i32 = 96; pub const loop_card_radius: i32 = 11; pub const loop_card_stripe: i32 = 4; pub const workspace_rail_width: i32 = 212; diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 0ae4d6fef..4a1b486e9 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -387,11 +387,11 @@ fn nodeKey(id: []const u8) u64 { } pub const CardTextLayout = struct { - title_y: i32, - state_y: i32, - show_entry: bool, - show_activity: bool, - show_attention: bool, + title: c.RECT, + pill: c.RECT, + live: c.RECT, + detail: c.RECT, + meta: c.RECT, }; pub const RenderBounds = struct { left: i32, top: i32, right: i32, bottom: i32 }; @@ -848,8 +848,9 @@ fn drawOverview( const card = overviewCardBounds(model, graph_index, index, bounds, state); roundedCard(hdc, card, Tokens.loop_card_bottom, false); fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), loopTypeColor(graph.nodes.items[index].loop_type)); - drawText(hdc, allocator, graph.nodes.items[index].title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(16, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF, card.right - scaledValue(14, state.zoom)); - drawText(hdc, allocator, graph.nodes.items[index].state, card.left + scaledValue(14, state.zoom), card.top + scaledValue(46, state.zoom), scaledValue(10, state.zoom), 0x00B8B8B8, card.right - scaledValue(14, state.zoom)); + drawCardBody(hdc, allocator, graph.nodes.items[index], card, state.zoom, .interior, false, false) catch |err| { + std.log.warn("Overview card {s} presentation failed: {s}", .{ graph.nodes.items[index].id, @errorName(err) }); + }; } } } @@ -897,7 +898,7 @@ fn overviewLaneBounds( overviewLaneHeight(model.graphs.items[graph_index].nodes.items.len) else 0; - return transformedRect(bounds, state, 24, top, @max(760, bounds.right - bounds.left - 48), height); + return transformedRect(bounds, state, 24, top, @max(838, bounds.right - bounds.left - 48), height); } pub fn overviewCardBounds( @@ -911,10 +912,10 @@ pub fn overviewCardBounds( const column = @as(i32, @intCast(node_index % 3)); const row = @as(i32, @intCast(node_index / 3)); return rect( - lane.left + scaledValue(18 + column * 246, state.zoom), + lane.left + scaledValue(18 + column * 276, state.zoom), lane.top + scaledValue(46 + row * 122, state.zoom), - lane.left + scaledValue(238 + column * 246, state.zoom), - lane.top + scaledValue(132 + row * 122, state.zoom), + lane.left + scaledValue(18 + column * 276, state.zoom) + scaledValue(Tokens.loop_card_width, state.zoom), + lane.top + scaledValue(46 + row * 122, state.zoom) + scaledValue(Tokens.loop_card_height, state.zoom), ); } @@ -1022,7 +1023,7 @@ pub fn contentSize(model: *const GraphModel.Model, surface: Surface) struct { wi .overview => blk: { var height: i32 = 38; for (model.graphs.items) |graph| height += overviewLaneHeight(graph.nodes.items.len) + 20; - break :blk .{ .width = 808, .height = @max(1, height) }; + break :blk .{ .width = 886, .height = @max(1, height) }; }, .quick_chats => .{ .width = 808, @@ -1679,28 +1680,12 @@ fn drawNode( fill(hdc, rect(x, y, x + scaled(Tokens.loop_card_stripe, state), y + bounds.bottom - y), stripe); const role = nodeRole(edges, node.id, declared_entries); const reclaim_offer = hasReclaimOffer(node, inspection, kept_worktrees); - const layout = cardTextLayout(state.zoom, role == .entry, attention); - if (layout.show_entry) drawText(hdc, allocator, "START", x + scaled(14, state), y + layout.title_y - scaled(10, state), scaled(9, state), 0x008A8A8A, bounds.right - scaled(14, state)); - if (role == .unwired) drawText(hdc, allocator, "UNWIRED", x + scaled(14, state), y + layout.title_y - scaled(10, state), scaled(9, state), 0x00FFCD7A, bounds.right - scaled(14, state)); - drawText(hdc, allocator, node.title, x + scaled(14, state), y + layout.title_y, scaled(14, state), 0x00FFFFFF, bounds.right - scaled(14, state)); - drawText(hdc, allocator, node.state, x + scaled(14, state), y + layout.state_y, scaled(11, state), if (attention) 0x00FFB340 else 0x00B8B8B8, bounds.right - scaled(14, state)); - if (layout.show_activity) { - const primary = nodePrimaryDetail(node); - if (primary.len != 0) - drawText(hdc, allocator, primary, x + scaled(14, state), y + layout.state_y + scaled(20, state), scaled(9, state), 0x00A8A8A8, bounds.right - scaled(14, state)); - if (node.activity.len != 0 and !std.mem.eql(u8, node.activity, primary)) - drawText(hdc, allocator, node.activity, x + scaled(14, state), y + layout.state_y + scaled(35, state), scaled(9, state), 0x008A8A8A, bounds.right - scaled(14, state)); - var metadata_buffer: [128]u8 = undefined; - const metadata = nodeMetadata(&metadata_buffer, node); - if (metadata.len != 0 and (role != .unwired or reclaim_offer)) - drawText(hdc, allocator, metadata, x + scaled(14, state), y + layout.state_y + scaled(43, state), scaled(8, state), 0x007A7A7A, bounds.right - scaled(14, state)); - if (role == .unwired and !reclaim_offer) - drawText(hdc, allocator, "No connections · right-click to recover", x + scaled(14, state), y + layout.state_y + scaled(43, state), scaled(8, state), 0x00FFCD7A, bounds.right - scaled(14, state)); - } - if (layout.show_attention) { - drawText(hdc, allocator, "NEEDS YOU", bounds.right - scaled(88, state), y + scaled(8, state), scaled(9, state), 0x00FFB340, bounds.right - scaled(18, state)); + drawCardBody(hdc, allocator, node, bounds, state.zoom, role, attention, reclaim_offer) catch |err| { + std.log.warn("Project card {s} presentation failed: {s}", .{ node.id, @errorName(err) }); + }; + if (attentionActionVisible(node, nodes, edges)) { const action = attentionActionBounds(bounds, state); - fill(hdc, action, 0x00FF9F0A); + fill(hdc, action, 0x000A9FFF); drawTextRect(hdc, allocator, attentionActionLabel(node), action, scaled(9, state), 0x00241703, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); } if (state.hovered_connector == index) { @@ -1730,7 +1715,11 @@ pub fn attentionActionForNode(node: GraphModel.Node) AttentionAction { } pub fn attentionActionBounds(bounds: c.RECT, state: *const CanvasState) c.RECT { - return rect(bounds.right - scaled(78, state), bounds.bottom - scaled(28, state), bounds.right - scaled(12, state), bounds.bottom - scaled(8, state)); + return rect(bounds.right - scaled(77, state), bounds.top + scaled(51, state), bounds.right - scaled(11, state), bounds.top + scaled(68, state)); +} + +pub fn attentionActionVisible(node: GraphModel.Node, nodes: []const GraphModel.Node, edges: []const GraphModel.Edge) bool { + return needsAttention(node, nodes, edges); } pub fn hitTestAttentionAction( @@ -1743,7 +1732,7 @@ pub fn hitTestAttentionAction( var index = nodes.len; while (index > 0) { index -= 1; - if (!needsAttention(nodes[index], nodes, edges)) continue; + if (!attentionActionVisible(nodes[index], nodes, edges)) continue; if (insideGraph(x, y, attentionActionBounds(nodeBounds(index, state), state))) return index; } return null; @@ -1752,9 +1741,10 @@ pub fn hitTestAttentionAction( const ReclaimOfferBounds = struct { reclaim: c.RECT, keep: c.RECT }; pub fn reclaimOfferBounds(bounds: c.RECT) ReclaimOfferBounds { + const zoom = @as(f32, @floatFromInt(bounds.right - bounds.left)) / @as(f32, @floatFromInt(Tokens.loop_card_width)); return .{ - .reclaim = rect(bounds.left + 12, bounds.bottom - 24, bounds.left + 78, bounds.bottom - 5), - .keep = rect(bounds.left + 84, bounds.bottom - 24, bounds.left + 126, bounds.bottom - 5), + .reclaim = rect(bounds.left + scaledValue(11, zoom), bounds.top + scaledValue(51, zoom), bounds.left + scaledValue(77, zoom), bounds.top + scaledValue(68, zoom)), + .keep = rect(bounds.left + scaledValue(83, zoom), bounds.top + scaledValue(51, zoom), bounds.left + scaledValue(119, zoom), bounds.top + scaledValue(68, zoom)), }; } @@ -1793,65 +1783,125 @@ pub fn hitTestReclaimOffer( } fn nodePrimaryDetail(node: GraphModel.Node) []const u8 { - if (node.goal_summary.len != 0) return node.goal_summary; - if (node.trigger_prompt.len != 0) return node.trigger_prompt; - if (node.check_description.len != 0) return node.check_description; - return node.activity; -} - -fn nodeMetadata(buffer: []u8, node: GraphModel.Node) []const u8 { - var stream = std.io.fixedBufferStream(buffer); - const writer = stream.writer(); - var wrote = false; - if (node.metric_passes != 0) { - writer.print("pass {d}", .{node.metric_passes}) catch return buffer[0..stream.pos]; - wrote = true; - } - if (node.metric_sample_count >= 2) { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - writeMetricChange(writer, node) catch return buffer[0..stream.pos]; - wrote = true; + if (std.mem.trim(u8, node.activity, " \t\r\n").len != 0) return node.activity; + if (std.mem.eql(u8, node.loop_type, "goalBased")) return node.goal_summary; + if (std.mem.eql(u8, node.loop_type, "timeBased")) return node.trigger_prompt; + if (std.mem.eql(u8, node.loop_type, "turnBased")) return node.check_description; + if (std.mem.eql(u8, node.loop_type, "composite") or std.mem.eql(u8, node.loop_type, "proactive")) return node.pilot_state; + return node.first_instruction; +} + +fn cardLiveLine(allocator: std.mem.Allocator, node: GraphModel.Node) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + errdefer result.deinit(); + if (std.mem.trim(u8, node.activity, " \t\r\n").len == 0 and + (std.mem.eql(u8, node.loop_type, "composite") or std.mem.eql(u8, node.loop_type, "proactive"))) + { + const pilot = cardPilotName(node.pilot_state); + if (node.subgraph_json.len == 0) return allocator.dupe(u8, pilot); + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, node.subgraph_json, .{}); + defer parsed.deinit(); + if (parsed.value != .object) return error.InvalidCardSubgraph; + const nodes = parsed.value.object.get("nodes") orelse return error.InvalidCardSubgraph; + if (nodes != .array) return error.InvalidCardSubgraph; + try result.writer().print("{d} {s} · {s}", .{ nodes.array.items.len, if (nodes.array.items.len == 1) "loop" else "loops", pilot }); + return result.toOwnedSlice(); } - if (node.backend.len != 0) { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - writer.writeAll(node.backend) catch return buffer[0..stream.pos]; - wrote = true; + const source = nodePrimaryDetail(node); + if (std.mem.eql(u8, source, node.activity) and std.mem.trim(u8, source, " \t\r\n").len != 0 and node.metric_passes != 0) + try result.writer().print("pass {d} · ", .{node.metric_passes}); + var iterator = (try std.unicode.Utf8View.init(source)).iterator(); + var pending_space = false; + while (iterator.nextCodepointSlice()) |bytes| { + const cp = try std.unicode.utf8Decode(bytes); + const whitespace = cp == ' ' or (cp >= 9 and cp <= 13) or cp == 0x85 or cp == 0xa0 or cp == 0x1680 or + (cp >= 0x2000 and cp <= 0x200a) or cp == 0x2028 or cp == 0x2029 or cp == 0x202f or cp == 0x205f or cp == 0x3000; + if (whitespace) { + pending_space = result.items.len != 0; + } else { + if (pending_space and result.items.len != 0 and result.items[result.items.len - 1] != ' ') try result.append(' '); + try result.appendSlice(bytes); + pending_space = false; + } } - + return result.toOwnedSlice(); +} + +fn cardPilotName(pilot: []const u8) []const u8 { + if (std.mem.eql(u8, pilot, "notPiloted")) return "Not piloted"; + if (std.mem.eql(u8, pilot, "piloting")) return "Piloting"; + if (std.mem.eql(u8, pilot, "piloted")) return "Piloted"; + if (std.mem.eql(u8, pilot, "armed")) return "Armed"; + return pilot; +} + +fn cardTypeName(loop_type: []const u8) []const u8 { + if (std.mem.eql(u8, loop_type, "goalBased")) return "Goal"; + if (std.mem.eql(u8, loop_type, "timeBased")) return "Timed"; + if (std.mem.eql(u8, loop_type, "turnBased")) return "Turn"; + if (std.mem.eql(u8, loop_type, "composite") or std.mem.eql(u8, loop_type, "proactive")) return "Composite"; + return "Main"; +} + +fn cardBackendName(backend: []const u8) []const u8 { + if (std.mem.eql(u8, backend, "claudeCode") or backend.len == 0) return ""; + if (std.mem.eql(u8, backend, "copilotCLI")) return "Copilot CLI"; + if (std.mem.eql(u8, backend, "codex")) return "Codex"; + if (std.mem.eql(u8, backend, "openCode")) return "OpenCode"; + if (std.mem.eql(u8, backend, "pi")) return "Pi"; + if (std.mem.eql(u8, backend, "nod")) return "Nod"; + return backend; +} + +fn writeCardAge(writer: anytype, seconds: u64) !void { + const days = seconds / 86400; + const hours = seconds / 3600 % 24; + const minutes = seconds / 60 % 60; + if (days > 0) { + try writer.print("{d}d", .{days}); + if (hours > 0) try writer.print(" {d}h", .{hours}); + } else if (hours > 0) { + try writer.print("{d}h", .{hours}); + if (minutes > 0) try writer.print(" {d}m", .{minutes}); + } else if (minutes > 0) { + try writer.print("{d}m", .{minutes}); + } else try writer.print("{d}s", .{seconds}); +} + +fn nodeMetadata(allocator: std.mem.Allocator, node: GraphModel.Node, branch: []const u8, now: u64) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + errdefer result.deinit(); + const writer = result.writer(); + try writer.writeAll(cardTypeName(node.loop_type)); + if (branch.len != 0) try writer.print(" · {s}", .{branch}); + const backend = cardBackendName(node.backend); + if (backend.len != 0) try writer.print(" · {s}", .{backend}); if (node.created_at) |created| { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - writer.writeAll(elapsedLabel(created)) catch return buffer[0..stream.pos]; - wrote = true; - } - if (node.token_usage) |tokens| { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - formatTokenUsage(writer, tokens) catch return buffer[0..stream.pos]; - wrote = true; - } - if (node.worktree_branch.len != 0) { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - writer.writeAll(node.worktree_branch) catch return buffer[0..stream.pos]; - wrote = true; + try writer.writeAll(" · "); + try writeCardAge(writer, now -| created); } - if (node.model_tier.len != 0) { - if (wrote) writer.writeAll(" · ") catch return buffer[0..stream.pos]; - writer.writeAll(node.model_tier) catch return buffer[0..stream.pos]; - wrote = true; - } - if (node.metric_command.len != 0) - if (!wrote) return std.fmt.bufPrint(buffer, "metric · {s}", .{if (node.metric_direction.len != 0) node.metric_direction else "configured"}) catch "metric"; - return buffer[0..stream.pos]; + return result.toOwnedSlice(); } fn writeMetricChange(writer: anytype, node: GraphModel.Node) !void { const first = node.metric_samples[0]; const last = node.metric_samples[@as(usize, node.metric_sample_count) - 1]; - const gain = if (std.mem.eql(u8, node.metric_direction, "minimize")) first - last else last - first; - try writer.writeAll("metric "); - try writeMetricNumber(writer, first); - try writer.writeAll(" -> "); - try writeMetricNumber(writer, last); - try writer.writeAll(if (gain > 0) " better" else if (gain < 0) " worse" else " flat"); + if (!std.math.isFinite(first) or !std.math.isFinite(last)) return error.InvalidCardMetric; + const whole = first == @round(first) and last == @round(last); + if (whole) try writeMetricNumber(writer, first) else try writer.print("{d:.2}", .{first}); + try writer.writeAll(" → "); + if (whole) try writeMetricNumber(writer, last) else try writer.print("{d:.2}", .{last}); + const ratio = cardMetricRatio(node); + const percent: u32 = @intFromFloat(@round(@abs(ratio) * 100)); + if (percent == 0) try writer.writeAll(" · flat") else try writer.print(" · {d}% {s}", .{ percent, if (ratio > 0) "better" else "worse" }); +} + +fn cardMetricRatio(node: GraphModel.Node) f64 { + const first = node.metric_samples[0]; + const last = node.metric_samples[@as(usize, node.metric_sample_count) - 1]; + const scale = @max(@abs(first), @abs(last)); + if (scale == 0) return 0; + return if (std.mem.eql(u8, node.metric_direction, "minimize")) first / scale - last / scale else last / scale - first / scale; } fn writeMetricNumber(writer: anytype, value: f64) !void { @@ -1878,6 +1928,241 @@ fn formatTokenUsage(writer: anytype, tokens: u32) !void { return writer.print("{d} tok", .{tokens}); } +const CardState = struct { + word: []const u8, + tint: u32, + ink: u32, + fill: u32, + hollow: bool = false, + stroke: ?u32 = null, +}; + +fn blendCardColor(foreground: u32, background: u32, opacity: u32) u32 { + var result: u32 = 0; + inline for ([_]u5{ 0, 8, 16 }) |shift| { + const front = (foreground >> shift) & 255; + const back = (background >> shift) & 255; + result |= ((front * opacity + back * (100 - opacity) + 50) / 100) << shift; + } + return result; +} + +fn cardState(node: GraphModel.Node) CardState { + const state = if (std.mem.eql(u8, node.presence, "awaitingInput")) + "awaitingInput" + else if (std.mem.eql(u8, node.presence, "busy")) + "running" + else if (std.mem.eql(u8, node.state, "running") and std.mem.eql(u8, node.presence, "idle")) + "idle" + else + node.state; + const base = Tokens.loop_card_bottom; + if (std.mem.eql(u8, state, "running") or std.mem.eql(u8, state, "waiting")) return .{ + .word = if (std.mem.eql(u8, state, "waiting")) "WAITING" else "RUNNING", + .tint = 0x00FF840A, + .ink = 0x00FFC58C, + .fill = blendCardColor(0x00FF840A, base, if (std.mem.eql(u8, state, "waiting")) 10 else 16), + .hollow = std.mem.eql(u8, state, "waiting"), + }; + if (std.mem.eql(u8, state, "awaitingInput") or std.mem.eql(u8, state, "blocked")) return .{ + .word = if (std.mem.eql(u8, state, "blocked")) "BLOCKED" else "NEEDS YOU", + .tint = 0x000A9FFF, + .ink = 0x007ACDFF, + .fill = blendCardColor(0x000A9FFF, base, if (std.mem.eql(u8, state, "blocked")) 14 else 22), + .hollow = std.mem.eql(u8, state, "blocked"), + .stroke = if (std.mem.eql(u8, state, "blocked")) blendCardColor(0x000A9FFF, base, 40) else null, + }; + if (std.mem.eql(u8, state, "succeeded")) return .{ .word = "DONE", .tint = 0x0058D130, .ink = 0x009BE47E, .fill = blendCardColor(0x0058D130, base, 16) }; + if (std.mem.eql(u8, state, "failed")) return .{ .word = "FAILED", .tint = 0x003A45FF, .ink = 0x00808AFF, .fill = blendCardColor(0x003A45FF, base, 16) }; + if (std.mem.eql(u8, state, "stalled")) return .{ .word = "STALLED", .tint = 0x00F25ABF, .ink = 0x00F7A2D9, .fill = blendCardColor(0x00F25ABF, base, 16) }; + const neutral_fill = blendCardColor(0x00FFFFFF, base, 7); + return .{ + .word = if (std.mem.eql(u8, state, "stopped")) "STOPPED" else if (std.mem.eql(u8, node.loop_type, "timeBased")) "SCHEDULED" else "IDLE", + .tint = blendCardColor(0x00FFFFFF, neutral_fill, 35), + .ink = blendCardColor(0x00FFFFFF, neutral_fill, 50), + .fill = neutral_fill, + .hollow = std.mem.eql(u8, state, "stopped"), + }; +} + +fn selectCardFont(hdc: c.HDC, size: f32, style: AppFont.Style) !c.HGDIOBJ { + const font = AppFont.getStyle(size, style) orelse return error.CardFontUnavailable; + return c.SelectObject(hdc, font) orelse error.CardFontSelectionFailed; +} + +fn measuredWide(hdc: c.HDC, text: []const u16) !i32 { + if (text.len == 0) return 0; + var size: c.SIZE = undefined; + const length = std.math.cast(i32, text.len) orelse return error.CardTextTooLong; + if (c.GetTextExtentPoint32W(hdc, text.ptr, length, &size) == 0) return error.CardTextMeasurementFailed; + return size.cx; +} + +fn measuredText(hdc: c.HDC, allocator: std.mem.Allocator, text: []const u8) !i32 { + const wide = try std.unicode.utf8ToUtf16LeAlloc(allocator, text); + defer allocator.free(wide); + return measuredWide(hdc, wide); +} + +fn middleElide(hdc: c.HDC, allocator: std.mem.Allocator, text: []const u16, width: i32) ![]u16 { + if (try measuredWide(hdc, text) <= width) return allocator.dupe(u16, text); + var boundaries = std.array_list.Managed(usize).init(allocator); + defer boundaries.deinit(); + try boundaries.append(0); + var index: usize = 0; + while (index < text.len) { + index += if (std.unicode.utf16IsHighSurrogate(text[index])) @as(usize, 2) else 1; + try boundaries.append(index); + } + const count = boundaries.items.len - 1; + if (count < 3) return error.CardMetadataTooWide; + const scratch = try allocator.alloc(u16, text.len + 1); + defer allocator.free(scratch); + var low: usize = 2; + var high: usize = count - 1; + var best: ?usize = null; + while (low <= high) { + const keep = low + (high - low) / 2; + const candidate = elidedCandidate(scratch, text, boundaries.items, keep); + if (try measuredWide(hdc, candidate) <= width) { + best = keep; + low = keep + 1; + } else high = keep - 1; + } + const keep = best orelse return error.CardMetadataTooWide; + return allocator.dupe(u16, elidedCandidate(scratch, text, boundaries.items, keep)); +} + +fn elidedCandidate(scratch: []u16, text: []const u16, boundaries: []const usize, keep: usize) []u16 { + const head = boundaries[(keep + 1) / 2]; + const tail = boundaries[boundaries.len - 1 - keep / 2]; + @memcpy(scratch[0..head], text[0..head]); + scratch[head] = 0x2026; + @memcpy(scratch[head + 1 ..][0 .. text.len - tail], text[tail..]); + return scratch[0 .. head + 1 + text.len - tail]; +} + +fn cardMetaForWidth(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, now: u64, width: i32) ![]u8 { + const full = try nodeMetadata(allocator, node, node.worktree_branch, now); + errdefer allocator.free(full); + if (try measuredText(hdc, allocator, full) <= width) return full; + const fixed = try nodeMetadata(allocator, node, "", now); + defer allocator.free(fixed); + const fixed_width = try measuredText(hdc, allocator, fixed); + if (fixed_width > width or node.worktree_branch.len == 0) return error.CardMetadataTooWide; + var branch_width = width - fixed_width - try measuredText(hdc, allocator, " · "); + const branch = try std.unicode.utf8ToUtf16LeAlloc(allocator, node.worktree_branch); + defer allocator.free(branch); + while (branch_width > 0) { + const shortened = try middleElide(hdc, allocator, branch, branch_width); + defer allocator.free(shortened); + const utf8 = try std.unicode.utf16LeToUtf8Alloc(allocator, shortened); + defer allocator.free(utf8); + const result = try nodeMetadata(allocator, node, utf8, now); + errdefer allocator.free(result); + const measured = try measuredText(hdc, allocator, result); + if (measured <= width) { + allocator.free(full); + return result; + } + allocator.free(result); + // A mapped DC rounds each measured run, so verify the assembled line too. + branch_width -= @max(1, measured - width); + } + return error.CardMetadataTooWide; +} + +fn drawCardText(hdc: c.HDC, allocator: std.mem.Allocator, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32, ellipsis: bool) !void { + if (text.len == 0 or bounds.right <= bounds.left) return; + const wide = try std.unicode.utf8ToUtf16LeAlloc(allocator, text); + defer allocator.free(wide); + const old_font = try selectCardFont(hdc, size, style); + defer _ = c.SelectObject(hdc, old_font); + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var target = bounds; + const format: c.UINT = @intCast(c.DT_LEFT | c.DT_SINGLELINE | c.DT_VCENTER | c.DT_NOPREFIX | @as(c.INT, if (ellipsis) c.DT_END_ELLIPSIS else 0)); + if (c.DrawTextW(hdc, wide.ptr, std.math.cast(i32, wide.len) orelse return error.CardTextTooLong, &target, format) == 0) + return error.CardTextPaintFailed; +} + +fn cardMetricDetail(allocator: std.mem.Allocator, node: GraphModel.Node) ![]u8 { + var result = std.array_list.Managed(u8).init(allocator); + errdefer result.deinit(); + if (node.metric_sample_count >= 2 and node.metric_direction.len != 0) + try writeMetricChange(result.writer(), node); + return result.toOwnedSlice(); +} + +fn drawCardPill(hdc: c.HDC, allocator: std.mem.Allocator, presentation: CardState, bounds: c.RECT, zoom: f32) !void { + const brush = c.CreateSolidBrush(presentation.fill) orelse return error.CardBrushCreationFailed; + defer _ = c.DeleteObject(brush); + const pen = c.CreatePen(c.PS_SOLID, 1, presentation.stroke orelse presentation.fill) orelse return error.CardPenCreationFailed; + defer _ = c.DeleteObject(pen); + const old_brush = c.SelectObject(hdc, brush); + defer _ = c.SelectObject(hdc, old_brush); + const old_pen = c.SelectObject(hdc, pen); + defer _ = c.SelectObject(hdc, old_pen); + _ = c.RoundRect(hdc, bounds.left, bounds.top, bounds.right, bounds.bottom, scaledValue(10, zoom), scaledValue(10, zoom)); + const diameter = scaledValue(6, zoom); + const dot_left = bounds.left + scaledValue(5, zoom); + const dot_top = @divTrunc(bounds.top + bounds.bottom - diameter, 2); + const dot = c.CreateSolidBrush(if (presentation.hollow) presentation.fill else presentation.tint) orelse return error.CardBrushCreationFailed; + defer _ = c.DeleteObject(dot); + const dot_pen = c.CreatePen(c.PS_SOLID, scaledValue(1, zoom), presentation.tint) orelse return error.CardPenCreationFailed; + defer _ = c.DeleteObject(dot_pen); + _ = c.SelectObject(hdc, dot); + _ = c.SelectObject(hdc, dot_pen); + _ = c.Ellipse(hdc, dot_left, dot_top, dot_left + diameter, dot_top + diameter); + _ = c.SelectObject(hdc, brush); + _ = c.SelectObject(hdc, pen); + try drawCardText(hdc, allocator, presentation.word, rect(dot_left + diameter + scaledValue(4, zoom), bounds.top, bounds.right - scaledValue(7, zoom), bounds.bottom), 10 * zoom, .card_state, presentation.ink, false); +} + +fn drawCardBody(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, bounds: c.RECT, zoom: f32, role: NodeRole, attention: bool, reclaim: bool) !void { + const presentation = cardState(node); + const pill_width = blk: { + const old = try selectCardFont(hdc, 10 * zoom, .card_state); + defer _ = c.SelectObject(hdc, old); + break :blk (try measuredText(hdc, allocator, presentation.word)) + scaledValue(22, zoom); + }; + const layout = cardTextLayout(bounds, zoom, pill_width); + const live = try cardLiveLine(allocator, node); + defer allocator.free(live); + const metric = try cardMetricDetail(allocator, node); + defer allocator.free(metric); + const meta = blk: { + const old = try selectCardFont(hdc, 10.5 * zoom, .card_mono); + defer _ = c.SelectObject(hdc, old); + break :blk try cardMetaForWidth(hdc, allocator, node, @intCast(@max(0, std.time.timestamp())), layout.meta.right - layout.meta.left - scaledValue(10, zoom)); + }; + defer allocator.free(meta); + try drawCardText(hdc, allocator, node.title, layout.title, 14.5 * zoom, .card_title, 0x00F4F4F4, true); + try drawCardPill(hdc, allocator, presentation, layout.pill, zoom); + try drawCardText(hdc, allocator, live, layout.live, 10.5 * zoom, .card_mono, 0x009F9C9C, true); + if (!attention and !reclaim) { + const entry_detail = if (role == .entry and metric.len != 0) try std.fmt.allocPrint(allocator, "START · {s}", .{metric}) else null; + defer if (entry_detail) |text| allocator.free(text); + const detail = if (role == .unwired) "UNWIRED · No connections · right-click to recover" else if (role == .entry) entry_detail orelse "START" else metric; + var detail_bounds = layout.detail; + if (metric.len != 0 and role != .unwired) detail_bounds.bottom -= scaledValue(4, zoom); + try drawCardText(hdc, allocator, detail, detail_bounds, 10.5 * zoom, .card_mono, if (role == .unwired) 0x007ACDFF else 0x009F9C9C, true); + if (metric.len != 0 and role != .unwired) { + const track = rect(layout.detail.left, layout.detail.bottom - scaledValue(2, zoom), layout.detail.right, layout.detail.bottom); + fill(hdc, track, 0x00444141); + const fraction = std.math.clamp(cardMetricRatio(node), 0, 1); + const end = track.left + @as(i32, @intFromFloat(@as(f64, @floatFromInt(track.right - track.left)) * fraction)); + if (end > track.left) fill(hdc, rect(track.left, track.top, end, track.bottom), loopTypeColor(node.loop_type)); + } + } else if (role != .interior) { + const role_bounds = rect(if (reclaim) reclaimOfferBounds(bounds).keep.right + scaledValue(6, zoom) else layout.live.left, layout.detail.top, if (attention) attentionActionBounds(bounds, &.{ .zoom = zoom }).left - scaledValue(6, zoom) else layout.detail.right, layout.detail.bottom); + try drawCardText(hdc, allocator, if (role == .entry) "START" else "UNWIRED", role_bounds, 10.5 * zoom, .card_mono, 0x009F9C9C, true); + } + const square_top = @divTrunc(layout.meta.top + layout.meta.bottom - scaledValue(6, zoom), 2); + fill(hdc, rect(layout.meta.left, square_top, layout.meta.left + scaledValue(6, zoom), square_top + scaledValue(6, zoom)), loopTypeColor(node.loop_type)); + try drawCardText(hdc, allocator, meta, rect(layout.meta.left + scaledValue(10, zoom), layout.meta.top, layout.meta.right, layout.meta.bottom), 10.5 * zoom, .card_mono, 0x008C8989, false); +} + pub fn nodeBounds(index: usize, state: *const CanvasState) c.RECT { const column = @as(i32, @intCast(index % 3)); const row = @as(i32, @intCast(index / 3)); @@ -2047,20 +2332,16 @@ fn segmentDistanceSquared(a: Connector, b: Connector, x: i32, y: i32) i32 { return @intFromFloat(ex * ex + ey * ey); } -fn cardTextLayout(zoom: f32, entry: bool, attention: bool) CardTextLayout { - if (zoom < 0.75) return .{ - .title_y = scaledValue(14, zoom), - .state_y = scaledValue(40, zoom), - .show_entry = false, - .show_activity = false, - .show_attention = false, - }; +fn cardTextLayout(bounds: c.RECT, zoom: f32, pill_width: i32) CardTextLayout { + const left = bounds.left + scaledValue(11, zoom); + const right = bounds.right - scaledValue(11, zoom); + const pill_left = right - pill_width; return .{ - .title_y = scaledValue(if (entry) 20 else 14, zoom), - .state_y = scaledValue(if (entry) 47 else 43, zoom), - .show_entry = entry, - .show_activity = true, - .show_attention = attention, + .title = rect(left, bounds.top + scaledValue(8, zoom), @max(left, pill_left - scaledValue(6, zoom)), bounds.top + scaledValue(26, zoom)), + .pill = rect(pill_left, bounds.top + scaledValue(9, zoom), right, bounds.top + scaledValue(25, zoom)), + .live = rect(left, bounds.top + scaledValue(32, zoom), right, bounds.top + scaledValue(45, zoom)), + .detail = rect(left, bounds.top + scaledValue(51, zoom), right, bounds.top + scaledValue(68, zoom)), + .meta = rect(left, bounds.bottom - scaledValue(21, zoom), right, bounds.bottom - scaledValue(8, zoom)), }; } @@ -2823,8 +3104,8 @@ test "cross-project overview stacks every open folder as its own lane" { const identity = CanvasState{}; try std.testing.expectEqualDeep(rect(244, 72, 1176, 376), overviewLaneBounds(&model, 0, bounds, &identity)); try std.testing.expectEqualDeep(rect(244, 396, 1176, 572), overviewLaneBounds(&model, 1, bounds, &identity)); - try std.testing.expectEqualDeep(rect(262, 240, 482, 326), overviewCardBounds(&model, 0, 3, bounds, &identity)); - try std.testing.expectEqualDeep(rect(262, 442, 482, 528), overviewCardBounds(&model, 1, 0, bounds, &identity)); + try std.testing.expectEqualDeep(rect(262, 240, 512, 336), overviewCardBounds(&model, 0, 3, bounds, &identity)); + try std.testing.expectEqualDeep(rect(262, 442, 512, 538), overviewCardBounds(&model, 1, 0, bounds, &identity)); for ([_]CanvasState{ .{}, @@ -2910,12 +3191,402 @@ test "zoom controls expose every action and fit content" { try std.testing.expect(state.zoom < 1); } -test "minimum zoom hides overflow-prone card content" { - const layout = cardTextLayout(0.55, true, true); - try std.testing.expect(!layout.show_entry); - try std.testing.expect(!layout.show_activity); - try std.testing.expect(!layout.show_attention); - try std.testing.expect(layout.state_y < @as(i32, @intFromFloat(106 * 0.55))); +test "loop card parity project metrics match the shipping card" { + const bounds = nodeBounds(0, &.{}); + try std.testing.expectEqual(@as(i32, 250), bounds.right - bounds.left); + try std.testing.expectEqual(@as(i32, 96), bounds.bottom - bounds.top); +} + +test "loop card parity overview metrics match the project card" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + const bounds = overviewCardBounds(&model, 0, 0, rect(220, 34, 1200, 900), &.{}); + try std.testing.expectEqual(@as(i32, 250), bounds.right - bounds.left); + try std.testing.expectEqual(@as(i32, 96), bounds.bottom - bounds.top); +} + +test "loop card parity activity outranks a populated goal" { + const node = GraphModel.Node{ + .id = @constCast("card"), + .title = @constCast("Card"), + .loop_type = @constCast("goalBased"), + .state = @constCast("running"), + .presence = @constCast("busy"), + .activity = @constCast("checking tests"), + .goal_summary = @constCast("All tests pass"), + }; + try std.testing.expectEqualStrings("checking tests", nodePrimaryDetail(node)); +} + +test "loop card parity meta uses type and backend display name without tokens" { + const node = GraphModel.Node{ + .id = @constCast("card"), + .title = @constCast("Card"), + .loop_type = @constCast("goalBased"), + .state = @constCast("running"), + .presence = @constCast("busy"), + .activity = @constCast(""), + .backend = @constCast("copilotCLI"), + .model_tier = @constCast("capable"), + .token_usage = 12_345, + }; + const meta = try nodeMetadata(std.testing.allocator, node, node.worktree_branch, 0); + defer std.testing.allocator.free(meta); + try std.testing.expectEqualStrings("Goal · Copilot CLI", meta); +} + +test "loop card parity existing actions stay above the bottom meta row" { + for ([_]f32{ 0.55, 0.75, 1, 1.25, 1.8 }) |zoom| { + const state = CanvasState{ .zoom = zoom, .pan_x = 1200, .pan_y = -20 }; + const bounds = nodeBounds(0, &state); + const meta_top = bounds.bottom - scaledValue(21, zoom); + const action = attentionActionBounds(bounds, &state); + const offer = reclaimOfferBounds(bounds); + try std.testing.expect(action.bottom <= meta_top); + try std.testing.expect(offer.reclaim.bottom <= meta_top); + try std.testing.expect(offer.keep.bottom <= meta_top); + try std.testing.expect(offer.reclaim.right < offer.keep.left); + try std.testing.expect(offer.keep.right < action.left); + } +} + +fn parityTestNode() GraphModel.Node { + return .{ + .id = @constCast("parity-card"), + .title = @constCast("A long title that must yield to the state pill"), + .loop_type = @constCast("goalBased"), + .state = @constCast("running"), + .presence = @constCast("busy"), + .activity = @constCast("checking tests"), + .backend = @constCast("copilotCLI"), + .created_at = 0, + }; +} + +test "loop card parity metrics and rows fit the owning card" { + const dc = c.CreateCompatibleDC(null) orelse return error.TestDcCreationFailed; + defer _ = c.DeleteDC(dc); + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + for ([_]f32{ 0.55, 0.75, 1, 1.25, 1.8 }) |zoom| { + const state = CanvasState{ .zoom = zoom, .pan_x = 1300, .pan_y = -17 }; + const project = nodeBounds(0, &state); + const overview = overviewCardBounds(&model, 0, 0, rect(220, 34, 1200, 900), &state); + for ([_][]const u8{ "NEEDS YOU", "SCHEDULED" }) |word| { + const old = try selectCardFont(dc, 10 * zoom, .card_state); + defer _ = c.SelectObject(dc, old); + const pill_width = try measuredText(dc, std.testing.allocator, word) + scaledValue(22, zoom); + for ([_]c.RECT{ project, overview }) |bounds| { + const rows = cardTextLayout(bounds, zoom, pill_width); + for ([_]c.RECT{ rows.title, rows.pill, rows.live, rows.detail, rows.meta }) |row| { + try std.testing.expect(row.left >= bounds.left + scaledValue(11, zoom)); + try std.testing.expect(row.right <= bounds.right - scaledValue(11, zoom)); + try std.testing.expect(row.top >= bounds.top + scaledValue(8, zoom)); + try std.testing.expect(row.bottom <= bounds.bottom - scaledValue(8, zoom)); + try std.testing.expect(row.right > row.left and row.bottom > row.top); + } + try std.testing.expect(rows.title.right < rows.pill.left); + try std.testing.expect(rows.title.bottom < rows.live.top); + try std.testing.expect(rows.live.bottom < rows.detail.top); + try std.testing.expect(rows.detail.bottom < rows.meta.top); + } + } + const adjacent = overviewCardBounds(&model, 0, 1, rect(220, 34, 1200, 900), &state); + const next_row = overviewCardBounds(&model, 0, 3, rect(220, 34, 1200, 900), &state); + try std.testing.expect(overview.right < adjacent.left); + try std.testing.expect(overview.bottom < next_row.top); + } +} + +test "loop card parity live and meta content and duration boundaries" { + const allocator = std.testing.allocator; + var node = parityTestNode(); + node.activity = @constCast(" \tchecking \r\n tests "); + node.goal_summary = @constCast("ignored goal"); + node.metric_passes = 4; + const live = try cardLiveLine(allocator, node); + defer allocator.free(live); + try std.testing.expectEqualStrings("pass 4 · checking tests", live); + node.activity = @constCast(""); + node.created_at = null; + node.backend = @constCast("claudeCode"); + for ([_][2][]const u8{ + .{ "sketch", "Main" }, .{ "goalBased", "Goal" }, .{ "timeBased", "Timed" }, + .{ "turnBased", "Turn" }, .{ "composite", "Composite" }, + }) |kind| { + node.loop_type = @constCast(kind[0]); + const meta = try nodeMetadata(allocator, node, "", 0); + defer allocator.free(meta); + try std.testing.expectEqualStrings(kind[1], meta); + } + for ([_]struct { kind: []const u8, expected: []const u8 }{ + .{ .kind = "goalBased", .expected = "ignored goal" }, + .{ .kind = "timeBased", .expected = "Run on schedule" }, + .{ .kind = "turnBased", .expected = "Pause for approval" }, + .{ .kind = "composite", .expected = "Piloted" }, + }) |sample| { + node.loop_type = @constCast(sample.kind); + node.trigger_prompt = @constCast("Run on schedule"); + node.check_description = @constCast("Pause for approval"); + node.pilot_state = @constCast("piloted"); + const fallback = try cardLiveLine(allocator, node); + defer allocator.free(fallback); + try std.testing.expectEqualStrings(sample.expected, fallback); + } + node.subgraph_json = @constCast("{\"nodes\":[{},{}]}"); + const composite = try cardLiveLine(allocator, node); + defer allocator.free(composite); + try std.testing.expectEqualStrings("2 loops · Piloted", composite); + node.subgraph_json = @constCast(""); + node.loop_type = @constCast("sketch"); + node.first_instruction = @constCast(" First\ninstruction "); + const handed = try cardLiveLine(allocator, node); + defer allocator.free(handed); + try std.testing.expectEqualStrings("First instruction", handed); + node.created_at = 1000; + for ([_]struct { age: u64, text: []const u8 }{ + .{ .age = 0, .text = "Main · 0s" }, .{ .age = 59, .text = "Main · 59s" }, + .{ .age = 60, .text = "Main · 1m" }, .{ .age = 3599, .text = "Main · 59m" }, + .{ .age = 3600, .text = "Main · 1h" }, .{ .age = 3660, .text = "Main · 1h 1m" }, + .{ .age = 86400, .text = "Main · 1d" }, .{ .age = 93600, .text = "Main · 1d 2h" }, + }) |sample| { + const meta = try nodeMetadata(allocator, node, "", 1000 + sample.age); + defer allocator.free(meta); + try std.testing.expectEqualStrings(sample.text, meta); + } + node.created_at = null; + for ([_][2][]const u8{ + .{ "copilotCLI", "Copilot CLI" }, .{ "codex", "Codex" }, .{ "openCode", "OpenCode" }, + .{ "pi", "Pi" }, .{ "nod", "Nod" }, .{ "futureBackend", "futureBackend" }, + }) |backend| { + try std.testing.expectEqualStrings(backend[1], cardBackendName(backend[0])); + } + node.metric_sample_count = 2; + node.metric_samples[0] = 1; + node.metric_samples[1] = 3; + node.metric_direction = @constCast("maximize"); + const rising = try cardMetricDetail(allocator, node); + defer allocator.free(rising); + try std.testing.expectEqualStrings("1 → 3 · 67% better", rising); + node.metric_samples[0] = 3; + node.metric_samples[1] = 1; + node.metric_direction = @constCast("minimize"); + const falling = try cardMetricDetail(allocator, node); + defer allocator.free(falling); + try std.testing.expectEqualStrings("3 → 1 · 67% better", falling); + node.metric_sample_count = 0; + const absent = try cardMetricDetail(allocator, node); + defer allocator.free(absent); + try std.testing.expectEqual(@as(usize, 0), absent.len); +} + +test "loop card parity pill words indicators and source colors" { + var node = parityTestNode(); + node.presence = @constCast(""); + for ([_]struct { state: []const u8, word: []const u8, hollow: bool, tint: u32 }{ + .{ .state = "idle", .word = "IDLE", .hollow = false, .tint = 0 }, + .{ .state = "running", .word = "RUNNING", .hollow = false, .tint = 0x00FF840A }, + .{ .state = "waiting", .word = "WAITING", .hollow = true, .tint = 0x00FF840A }, + .{ .state = "awaitingInput", .word = "NEEDS YOU", .hollow = false, .tint = 0x000A9FFF }, + .{ .state = "blocked", .word = "BLOCKED", .hollow = true, .tint = 0x000A9FFF }, + .{ .state = "succeeded", .word = "DONE", .hollow = false, .tint = 0x0058D130 }, + .{ .state = "failed", .word = "FAILED", .hollow = false, .tint = 0x003A45FF }, + .{ .state = "stalled", .word = "STALLED", .hollow = false, .tint = 0x00F25ABF }, + .{ .state = "stopped", .word = "STOPPED", .hollow = true, .tint = 0 }, + }) |sample| { + node.state = @constCast(sample.state); + const presentation = cardState(node); + try std.testing.expectEqualStrings(sample.word, presentation.word); + try std.testing.expectEqual(sample.hollow, presentation.hollow); + if (sample.tint != 0) try std.testing.expectEqual(sample.tint, presentation.tint); + try std.testing.expect(presentation.fill != presentation.ink); + try std.testing.expect(presentation.fill != presentation.tint); + try std.testing.expectEqual(std.mem.eql(u8, sample.state, "blocked"), presentation.stroke != null); + } + for ([_][]const u8{ "succeeded", "blocked", "running" }) |stored| { + node.state = @constCast(stored); + node.presence = @constCast("awaitingInput"); + try std.testing.expectEqualStrings("NEEDS YOU", cardState(node).word); + } + node.state = @constCast("running"); + node.presence = @constCast("idle"); + try std.testing.expectEqualStrings("IDLE", cardState(node).word); + node.loop_type = @constCast("timeBased"); + try std.testing.expectEqualStrings("SCHEDULED", cardState(node).word); + node.presence = @constCast("busy"); + try std.testing.expectEqualStrings("RUNNING", cardState(node).word); +} + +fn parityOwnedTextExercise(allocator: std.mem.Allocator, dc: c.HDC) !void { + var node = parityTestNode(); + const long_activity = try std.mem.join(allocator, " ", &.{ "工具 🧪 activity" ** 30, "tail" }); + defer allocator.free(long_activity); + node.activity = long_activity; + node.metric_passes = 4; + const live = try cardLiveLine(allocator, node); + defer allocator.free(live); + try std.testing.expect(live.len > 256 and std.mem.endsWith(u8, live, "tail")); + try std.testing.expect(std.unicode.utf8ValidateSlice(live)); + const branch = try std.mem.concat(allocator, u8, &.{ "feature/工具/", "long/" ** 40, "🧪/tail" }); + defer allocator.free(branch); + node.worktree_branch = branch; + const full = try nodeMetadata(allocator, node, node.worktree_branch, 93600); + defer allocator.free(full); + try std.testing.expect(std.mem.indexOf(u8, full, branch) != null); + try std.testing.expect(full.len > 128); + const meta = try cardMetaForWidth(dc, allocator, node, 93600, 218); + defer allocator.free(meta); + try std.testing.expect(std.mem.startsWith(u8, meta, "Goal · f")); + try std.testing.expect(std.mem.endsWith(u8, meta, "tail · Copilot CLI · 1d 2h") or std.mem.endsWith(u8, meta, "l · Copilot CLI · 1d 2h")); + try std.testing.expect(std.mem.indexOf(u8, meta, "…") != null); + try std.testing.expect(std.unicode.utf8ValidateSlice(meta)); + try std.testing.expect(try measuredText(dc, allocator, meta) <= 218); + node.metric_sample_count = 2; + node.metric_direction = @constCast("maximize"); + node.metric_samples[0] = 0.125; + node.metric_samples[1] = 1e150; + const metric = try cardMetricDetail(allocator, node); + defer allocator.free(metric); + try std.testing.expect(metric.len > 128); + try std.testing.expect(std.mem.endsWith(u8, metric, "100% better")); +} + +test "loop card parity long Unicode text measured middle elision and allocation cleanup" { + const dc = c.CreateCompatibleDC(null) orelse return error.TestDcCreationFailed; + defer _ = c.DeleteDC(dc); + const old = try selectCardFont(dc, 10.5, .card_mono); + defer _ = c.SelectObject(dc, old); + try std.testing.checkAllAllocationFailures(std.testing.allocator, parityOwnedTextExercise, .{dc}); + var node = parityTestNode(); + node.backend = @constCast("a backend identifier too wide to fit without lying"); + try std.testing.expectError(error.CardMetadataTooWide, cardMetaForWidth(dc, std.testing.allocator, node, 93600, 218)); +} + +const ParityDib = struct { + dc: c.HDC, + bitmap: c.HBITMAP, + old_bitmap: c.HGDIOBJ, + + fn init(width: i32, height: i32) !ParityDib { + const dc = c.CreateCompatibleDC(null) orelse return error.TestDcCreationFailed; + errdefer _ = c.DeleteDC(dc); + var info = std.mem.zeroes(c.BITMAPINFO); + info.bmiHeader.biSize = @sizeOf(c.BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = c.BI_RGB; + var bits: ?*anyopaque = null; + const bitmap = c.CreateDIBSection(dc, &info, c.DIB_RGB_COLORS, &bits, null, 0) orelse return error.TestBitmapCreationFailed; + return .{ .dc = dc, .bitmap = bitmap, .old_bitmap = c.SelectObject(dc, bitmap) }; + } + + fn deinit(self: ParityDib) void { + _ = c.SelectObject(self.dc, self.old_bitmap); + _ = c.DeleteObject(self.bitmap); + _ = c.DeleteDC(self.dc); + } + + fn count(self: ParityDib, bounds: c.RECT, color: u32) usize { + var result: usize = 0; + var y = bounds.top; + while (y < bounds.bottom) : (y += 1) { + var x = bounds.left; + while (x < bounds.right) : (x += 1) { + if (c.GetPixel(self.dc, x, y) == color) result += 1; + } + } + return result; + } +}; + +test "loop card parity both production renderers paint named pill and mono meta regions" { + const allocator = std.testing.allocator; + const dib = try ParityDib.init(2200, 500); + defer dib.deinit(); + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"parity","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"parity-card","title":"A long title must yield to NEEDS YOU","loopType":"goalBased","state":"succeeded","presence":"awaitingInput","activity":"reading tools","backend":"copilotCLI","createdAt":0}],"edges":[]}}} + ); + const node = model.graphs.items[0].nodes.items[0]; + const state = CanvasState{ .pan_x = 1200 }; + const viewport = rect(220, 34, 2200, 500); + fill(dib.dc, rect(0, 0, 2200, 500), 0); + drawNode(dib.dc, allocator, node, 0, null, &.{node}, &.{}, null, &.{}, &.{}, &state); + const project = nodeBounds(0, &state); + const presentation = cardState(node); + const old = try selectCardFont(dib.dc, 10, .card_state); + const pill_width = try measuredText(dib.dc, allocator, presentation.word) + 22; + _ = c.SelectObject(dib.dc, old); + const project_rows = cardTextLayout(project, 1, pill_width); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expect(dib.count(project_rows.pill, presentation.fill) > 20); + try std.testing.expect(dib.count(project_rows.pill, presentation.tint) > 0); + try std.testing.expect(dib.count(project_rows.title, 0x00F4F4F4) > 0); + try std.testing.expect(dib.count(project_rows.live, 0x009F9C9C) > 0); + try std.testing.expect(dib.count(project_rows.meta, loopTypeColor(node.loop_type)) == 36); + try std.testing.expect(dib.count(project_rows.meta, 0x008C8989) > 0); + const project_pill_pixels = dib.count(project_rows.pill, presentation.fill); + fill(dib.dc, rect(0, 0, 2200, 500), 0); + drawOverview(dib.dc, allocator, &model, viewport, &state); + const overview = overviewCardBounds(&model, 0, 0, viewport, &state); + const overview_rows = cardTextLayout(overview, 1, pill_width); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expectEqual(project_pill_pixels, dib.count(overview_rows.pill, presentation.fill)); + try std.testing.expect(dib.count(overview_rows.meta, loopTypeColor(node.loop_type)) == 36); + try std.testing.expect(dib.count(overview_rows.meta, 0x008C8989) > 0); + try std.testing.expect(dib.count(overview_rows.detail, 0x000A9FFF) == 0); +} + +test "loop card parity production long text and hollow pills paint across zoom and mapped DPI" { + const allocator = std.testing.allocator; + const dib = try ParityDib.init(2400, 1000); + defer dib.deinit(); + var node = parityTestNode(); + const activity = try std.mem.concat(allocator, u8, &.{ "工具 🧪 " ** 50, "complete activity tail" }); + defer allocator.free(activity); + const branch = try std.mem.concat(allocator, u8, &.{ "feature/", "branch/工具/" ** 30, "🧪/tail" }); + defer allocator.free(branch); + node.activity = activity; + node.worktree_branch = branch; + node.created_at = @intCast(@max(0, std.time.timestamp() - 93600)); + node.state = @constCast("stopped"); + node.presence = @constCast(""); + for ([_]u32{ 96, 120, 144, 192 }) |dpi| { + const saved = c.SaveDC(dib.dc); + defer _ = c.RestoreDC(dib.dc, saved); + _ = c.SetMapMode(dib.dc, c.MM_ANISOTROPIC); + _ = c.SetWindowExtEx(dib.dc, 96, 96, null); + _ = c.SetViewportExtEx(dib.dc, @intCast(dpi), @intCast(dpi), null); + for ([_]f32{ 0.55, 0.75, 1, 1.25, 1.8 }) |zoom| { + const state = CanvasState{ .zoom = zoom, .pan_x = 25, .pan_y = 19 }; + const bounds = nodeBounds(0, &state); + fill(dib.dc, rect(0, 0, 1000, 450), 0); + roundedCard(dib.dc, bounds, Tokens.loop_card_bottom, false); + try drawCardBody(dib.dc, allocator, node, bounds, zoom, .interior, false, false); + const old = try selectCardFont(dib.dc, 10 * zoom, .card_state); + const pill_width = try measuredText(dib.dc, allocator, "STOPPED") + scaledValue(22, zoom); + _ = c.SelectObject(dib.dc, old); + const rows = cardTextLayout(bounds, zoom, pill_width); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expect(dib.count(rows.pill, cardState(node).fill) > 0); + try std.testing.expect(dib.count(rows.meta, loopTypeColor(node.loop_type)) > 0); + const center_x = rows.pill.left + scaledValue(5, zoom) + @divTrunc(scaledValue(6, zoom), 2); + const center_y = @divTrunc(rows.pill.top + rows.pill.bottom, 2); + try std.testing.expectEqual(cardState(node).fill, c.GetPixel(dib.dc, center_x, center_y)); + } + } +} + +test "minimum zoom keeps compact rows separated inside a 96 point card" { + const bounds = nodeBounds(0, &.{ .zoom = 0.55 }); + const layout = cardTextLayout(bounds, 0.55, 48); + try std.testing.expect(layout.title.bottom <= layout.live.top); + try std.testing.expect(layout.live.bottom <= layout.detail.top); + try std.testing.expect(layout.detail.bottom <= layout.meta.top); + try std.testing.expect(layout.meta.bottom < bounds.bottom); } test "bounded text rectangles stop at the owning container" { @@ -3108,7 +3779,7 @@ test "loop detail status includes explicit and presence-based requests for input try std.testing.expectEqual(@as(u32, 0x0058D130), panelStateColor(node)); } -test "loop card detail prioritizes goal and preserves metadata" { +test "loop card detail prioritizes activity and keeps metadata card-specific" { const node = GraphModel.Node{ .id = @constCast("node"), .title = @constCast("Goal"), @@ -3120,12 +3791,13 @@ test "loop card detail prioritizes goal and preserves metadata" { .model_tier = @constCast("capable"), .worktree_branch = @constCast("feature/parity"), }; - try std.testing.expectEqualStrings("All tests pass", nodePrimaryDetail(node)); - var buffer: [128]u8 = undefined; - try std.testing.expectEqualStrings("feature/parity · capable", nodeMetadata(&buffer, node)); + try std.testing.expectEqualStrings("checking tests", nodePrimaryDetail(node)); + const meta = try nodeMetadata(std.testing.allocator, node, node.worktree_branch, 0); + defer std.testing.allocator.free(meta); + try std.testing.expectEqualStrings("Goal · feature/parity", meta); } -test "loop card metadata includes backend elapsed and token usage when reported" { +test "loop card metadata excludes progress and token usage but keeps backend and real age" { const node = GraphModel.Node{ .id = @constCast("node"), .title = @constCast("Live"), @@ -3141,12 +3813,12 @@ test "loop card metadata includes backend elapsed and token usage when reported" .metric_direction = @constCast("maximize"), .token_usage = 12_345, }; - var buffer: [128]u8 = undefined; - const metadata = nodeMetadata(&buffer, node); - try std.testing.expect(std.mem.indexOf(u8, metadata, "pass 3") != null); - try std.testing.expect(std.mem.indexOf(u8, metadata, "metric 1 -> 3 better") != null); - try std.testing.expect(std.mem.indexOf(u8, metadata, "copilotCLI") != null); - try std.testing.expect(std.mem.indexOf(u8, metadata, "12k tok") != null); + const metadata = try nodeMetadata(std.testing.allocator, node, node.worktree_branch, 93600); + defer std.testing.allocator.free(metadata); + try std.testing.expectEqualStrings("Goal · Copilot CLI · 1d 2h", metadata); + const detail = try cardMetricDetail(std.testing.allocator, node); + defer std.testing.allocator.free(detail); + try std.testing.expectEqualStrings("1 → 3 · 67% better", detail); } test "loop age labels show a just-created daemon loop as recent" { @@ -3164,7 +3836,9 @@ test "loop age labels show a just-created daemon loop as recent" { _ = try model.updateFromFrame(frame); const node = model.currentGraph().?.nodes.items[0]; var buffer: [128]u8 = undefined; - try std.testing.expectEqualStrings("0m", nodeMetadata(&buffer, node)); + const meta = try nodeMetadata(allocator, node, node.worktree_branch, node.created_at.?); + defer allocator.free(meta); + try std.testing.expectEqualStrings("Turn · 0s", meta); const footer = loopDetailFooter(&buffer, node); try std.testing.expect(std.mem.startsWith(u8, footer, "started ")); try std.testing.expect(footer.len > "started ".len); From 3ded808c05bfaf06b005f52618bd1c1b4a2583e7 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 08:42:01 -0700 Subject: [PATCH 2/5] Keep App changes within the approved card lease Restore unrelated whitespace changed by the whole-file formatter. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39e65c1f-fe48-4e45-b6f2-e26f161d9d6b Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 62 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index af2df6932..602c81c74 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -7543,8 +7543,7 @@ pub const App = struct { const sidebar_bottom = self.sidebarContentBottom(client.bottom); const footer_viewport_bottom = GraphCanvas.sidebarBottom(client.bottom, self.workspace_controls, self.surface); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); - provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); - const current_inspection = self.currentWorktreeInspection(); + provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); const current_inspection = self.currentWorktreeInspection(); // The scroll sink is optional so test sinks that only record elements need not declare it. if (comptime providerHasDecl(@TypeOf(provider), "syncSidebarScroll")) { provider.syncSidebarScroll( @@ -11629,8 +11628,8 @@ test "sidebar UIA bounds and hit targets stay clear of the update banner at 1280 std.debug.print( "{s} bounds {d},{d},{d},{d} intersect banner {d},{d},{d},{d} at dpi {d} width {d} scroll {d}\n", .{ - sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, - banner.left, banner.top, banner.right, banner.bottom, case.dpi, + sink.name(index), bounds.left, bounds.top, bounds.right, bounds.bottom, + banner.left, banner.top, banner.right, banner.bottom, case.dpi, case.width, app.sidebar_scroll, }, ); @@ -13710,6 +13709,7 @@ const LiveTerminalFixture = struct { _ = onWindowMessage(&self.app, self.app.window.hwnd, c.WM_TIMER, MainWindow.timer_id, 0, &result); } + /// A daemon graph change through the production frame path (and its workspace refresh). fn deliver(self: *LiveTerminalFixture, loops: []const LiveLoop) !void { self.revision += 1; @@ -15621,7 +15621,7 @@ test "workspace layout: deleting a loop ends the shell sessions of its own tabs test "workspace layout: restore drops a pane whose session is gone instead of recreating it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15662,7 +15662,7 @@ fn restoredAliveShell(fixture: *LiveTerminalFixture, session: []const u8) bool { test "workspace layout: at startup an unreachable agent pane and shell tab keep their retry slots while a live saved pane restores, and both come back" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -15747,7 +15747,7 @@ test "workspace layout: restore keeps a pane whose session zmx could not reach, test "workspace layout: a loop's shells are neither ended nor forgotten while zmx cannot reach their session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15769,7 +15769,7 @@ test "workspace layout: a loop's shells are neither ended nor forgotten while zm test "workspace layout: a shell whose daemon refused the connection is gone: its record is forgotten and nothing is killed" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -15784,7 +15784,7 @@ test "workspace layout: a shell whose daemon refused the connection is gone: its test "live terminal: opening a loop whose session zmx could not reach waits and attaches only once it answers" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const app = &fixture.app; @@ -15940,7 +15940,7 @@ const nodes_full = node_loop_a ++ "," ++ test "workspace layout: a project's first snapshot ends the shells of loops deleted while the shell was closed, once the daemon's own answer agrees, and only those" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const other_project = "C:\\another-project"; const alias_project = try std.mem.replaceOwned(u8, std.testing.allocator, fixture.project, "\\", "/"); @@ -15983,7 +15983,7 @@ test "workspace layout: a project's first snapshot ends the shells of loops dele test "workspace layout: an empty or partial first snapshot sweeps nothing that the full snapshot still holds" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-a", &.{ .{ .id = "loop-a", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_two } }); @@ -16018,7 +16018,7 @@ test "workspace layout: an empty or partial first snapshot sweeps nothing that t test "workspace layout: a layout saved after this shell started is another shell's and is never swept" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); // Written while this shell runs, as a second shell sharing the support directory would. try fixture.saveLoopLayout(fixture.project, "other-shells-loop", &.{ .{ .id = "other-shells-loop", .agent = true }, .{ .id = shell_uuid_one } }); @@ -16625,7 +16625,7 @@ test "workspace layout: a stale or revisionless frame that names the loop again /// A layout older than this shell, with a recorded live shell session, for a loop the project's /// first usable snapshot will not name; the shell saw only an empty graph of the project before. fn emptyFirstGraphWithOldLayout(fixture: *LiveTerminalFixture) !i128 { - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); try fixture.setLive(&.{ "loop-a", "loop-d", shell_uuid_two }); @@ -16671,7 +16671,7 @@ test "workspace layout: after a reconnect a project the shell holds no snapshot test "workspace layout: a project first opened after a reconnect is left alone too" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16694,7 +16694,7 @@ test "workspace layout: a project first opened after a reconnect is left alone t test "workspace layout: a reconnect before the shell's first poll or snapshot still ends the age sweep" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16719,7 +16719,7 @@ test "workspace layout: a reconnect before the shell's first poll or snapshot st test "workspace layout: a snapshot queued before the transport failed arms nothing while the client is not connected" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -16749,7 +16749,7 @@ test "workspace layout: a snapshot queued before the transport failed arms nothi test "workspace layout: a look the age sweep started for a layout the shell never held does not outlive the connection" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "loop-d", &.{ .{ .id = "loop-d", .agent = true }, .{ .id = shell_uuid_two } }); try fixture.markOwned("loop-d", &.{shell_uuid_two}); @@ -17122,7 +17122,7 @@ test "workspace layout: an empty graph, and a project the shell does not hold, a test "workspace layout: a shell session whose uuid is a graph node's id is never ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); // Node ids are uuids; the daemon names a loop's session after the uppercase one. const node_uuid = shell_uuid_seven; @@ -17147,7 +17147,7 @@ test "workspace layout: a shell session whose uuid is a graph node's id is never test "workspace layout: a layout that cannot be attributed to the deleted loop ends nothing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17170,7 +17170,7 @@ test "workspace layout: a layout that cannot be attributed to the deleted loop e test "workspace layout: deleting a loop ends only recorded, live, shell-shaped sessions nothing else names" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const foreign = "other-" ++ shell_uuid_two; // A layout from an earlier shell, a stale copy or a hand edit can record anything as a @@ -17200,7 +17200,7 @@ test "workspace layout: deleting a loop ends only recorded, live, shell-shaped s test "workspace layout: a loop's shells are ended only after a successful session listing" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17324,7 +17324,7 @@ test "workspace layout: a corrupt, foreign or newer-schema loop layout is kept a test "workspace layout: a live session that only a layout names is never ended without proof the shell made it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); // A foreign session that happens to look like a shell's: lowercase uuid, live, no node's // id, named by no other layout. The name shape is not provenance. @@ -17344,7 +17344,7 @@ test "workspace layout: a live session that only a layout names is never ended w test "workspace layout: a tab's session is recorded as the shell's own when minted and forgotten once ended" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const app = &fixture.app; const workspace = &fixture.workspace; @@ -17375,7 +17375,7 @@ test "workspace layout: a tab's session is recorded as the shell's own when mint test "workspace layout: a layout scan cut short by its cap refuses the kill instead of assuming nothing claims it" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17397,7 +17397,7 @@ test "workspace layout: a layout scan cut short by its cap refuses the kill inst test "workspace layout: a session another loop's record names is never ended by a different loop's retirement" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); // The window in a second shell sharing this layout directory: it has recorded a fresh // session for its own loop-a, while a stale layout of the deleted gone-loop names the uuid. @@ -17417,7 +17417,7 @@ test "workspace layout: a session another loop's record names is never ended by /// sequence waits for each attach to finish before the next save. fn expectClaimAndRecordSeenByAttach(hold_layout: bool) !void { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const app = &fixture.app; @@ -17500,7 +17500,7 @@ fn attachStartCount(dir: std.fs.Dir) !usize { test "workspace layout: New Tab and Split with no free surface slot fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17540,7 +17540,7 @@ test "workspace layout: New Tab and Split with no free surface slot fail before test "workspace layout: New Tab and Split with every free slot reserved by a pending restore fail before claiming a session" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const workspace = &fixture.workspace; @@ -17622,7 +17622,7 @@ test "workspace layout: a failed rollback save leaves the claim on disk, never a test "workspace layout: restore drops a loop layout's claimed pane whose session was never started" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const allocator = std.testing.allocator; const WorkspaceLayoutFile = @import("WorkspaceLayout.zig"); @@ -17649,7 +17649,7 @@ test "workspace layout: restore drops a loop layout's claimed pane whose session test "workspace layout: an unreadable unrelated layout defers the cleanup, and the next sweep ends the shell" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); try fixture.saveLoopLayout(fixture.project, "gone-loop", &.{ .{ .id = "gone-loop", .agent = true }, .{ .id = shell_uuid_one } }); try fixture.markOwned("gone-loop", &.{shell_uuid_one}); @@ -17673,7 +17673,7 @@ test "workspace layout: an unreadable unrelated layout defers the cleanup, and t test "workspace layout: a corrupt layout that cannot be set aside is left as it is and its loop is not opened" { var fixture: LiveTerminalFixture = undefined; - try fixture.init(&.{.{ .id = "loop-a" }}); + try fixture.init(&.{ .{ .id = "loop-a" } }); defer fixture.deinit(); const app = &fixture.app; const truncated = "{\"schemaVersion\":2,\"project\":\""; From 2bd8e3fe434e3d18b0549af01638148a9d0df0ba Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 09:15:34 -0700 Subject: [PATCH 3/5] Preserve card rows at metadata limits and align overview gate geometry Use measured branch elision without blanking the card, reuse the existing child counter, keep narrow overview actions reachable with a 260 pitch, and update only the approved gate geometry and executable positive/negative fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39e65c1f-fe48-4e45-b6f2-e26f161d9d6b Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 76 ++++++- Tools/windows/uia-live-gate.ps1 | 16 +- graphcode-windows/src/App.zig | 2 +- graphcode-windows/src/GraphCanvas.zig | 199 ++++++++++++++++-- 4 files changed, 252 insertions(+), 41 deletions(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 15ee57d76..4033d9437 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -36,6 +36,33 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate } throw "RED: multi-project expected actual $prefix rejection" } + Invoke-MultiCase "compact overview narrow client uses the 806 lane floor" { + $geometry = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(262,118,512,214) + Assert-MultiCase (($geometry.band -join ",") -ceq "244,72,1050,248" -and + ($geometry.open -join ",") -ceq "918,82,974,102") "250x96 card or 806-pixel floor differs" + $worktrees = @(($geometry.band[2] - 72),82,($geometry.band[2] - 18),102) + $clipped = Get-MultiProjectClippedRectangle $worktrees @(220,34,1028,900) + Assert-MultiCase (($worktrees -join ",") -ceq "978,82,1032,102" -and + ($clipped -join ",") -ceq "978,82,1028,102") "narrow Worktrees clipping was hidden" + return "1028-client boundary: Open fully visible, Worktrees has 50 of 54 pixels visible; not whole-button visibility proof" + } + Invoke-MultiCase "compact overview wide client and DPI preserve exact geometry" { + $geometry = Get-MultiProjectLaneGeometry @(220,34,1200,900) @(262,118,512,214) + Assert-MultiCase (($geometry.band -join ",") -ceq "244,72,1176,248") "wide lane changed its viewport-derived width" + $scaled = Get-MultiProjectLaneGeometry @(330,51,1542,1350) @(393,177,768,321) + Assert-MultiCase (($scaled.band -join ",") -ceq "366,108,1575,372" -and + ($scaled.open -join ",") -ceq "1377,123,1461,153") "144-DPI narrow geometry was scaled more than once" + return "Literal narrow/wide and once-only 144-DPI source geometry" + } + Invoke-MultiCase "compact overview rejects obsolete 220x86 card geometry" -Negative { + return Reject-MultiCase { Get-MultiProjectLaneGeometry @(220,34,1200,900) @(262,118,482,204) } "MULTIPROJECT_BOUNDS" + } + Invoke-MultiCase "compact overview rejects the obsolete 760-pixel lane floor" -Negative { + $geometry = Get-MultiProjectLaneGeometry @(220,34,1028,900) @(262,118,512,214) + Assert-MultiCase (($geometry.band -join ",") -cne "244,72,1004,248" -and + ($geometry.open -join ",") -cne "872,82,928,102") "old 760-pixel action geometry was accepted" + return "New source geometry rejects the old narrow-window lane/action coordinates" + } $eventStart = [DateTime]::Parse("2026-10-01T18:29:43.1462496Z").ToUniversalTime() $eventEnd = $eventStart.AddSeconds(1) $eventProcess = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } @@ -1456,10 +1483,17 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate foreignCardFragmentCount = 0; foreignProjectFragmentCount = 0 canvasBounds = @(228,85,1036,780) } - Invoke-MultiCase "retained hosted four-fragment source-supported overview" { - Assert-MultiCase (Test-MultiProjectObservedRoster "overview" $retainedProjection $retainedOwners $null 3140) ` - "retained hosted 716 overview rejected legitimate two-node/two-summary complete roster" - return "Exact hosted identities/names/node bounds; summary rectangles independently source-derived, not a native bounds measurement" + Invoke-MultiCase "retained hosted identities with current compact geometry" { + Assert-MultiCase (-not (Test-MultiProjectObservedRoster "overview" $retainedProjection $retainedOwners $null 3140)) ` + "historical 220x86 hosted geometry passed the current compact-card predicate" + $compactProjection = Copy-MultiProjectValue $retainedProjection + $compactProjection.cards[0].bounds = @(632,133,918,153) + $compactProjection.cards[1].bounds = @(270,169,520,265) + $compactProjection.cards[2].bounds = @(632,329,918,349) + $compactProjection.cards[3].bounds = @(270,365,520,461) + Assert-MultiCase (Test-MultiProjectObservedRoster "overview" $compactProjection $retainedOwners $null 3140) ` + "compact geometry rejected the same exact two-node/two-summary identities and names" + return "Original hosted bounds remain unchanged and are rejected; synthetic current-geometry projection of the same four identities is accepted, not a new native capture" } Invoke-MultiCase "source summary identity independent fixture and hosted goldens" { Assert-MultiCase ((Get-MultiProjectAutomationId "overview-worktree-notice" "A") -ceq @@ -1545,9 +1579,9 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate ) cards = @( [ordered]@{ automationId = "canvas-card-1294898434078301384"; name = "Worktrees not inspected"; processId = 4242; bounds = @(750,82,1036,102) }, - [ordered]@{ automationId = "canvas-card-1929002346198335884"; name = "Alpha loop"; processId = 4242; bounds = @(262,118,482,204) }, + [ordered]@{ automationId = "canvas-card-1929002346198335884"; name = "Alpha loop"; processId = 4242; bounds = @(262,118,512,214) }, [ordered]@{ automationId = "canvas-card-1294901732613186017"; name = "Worktrees not inspected"; processId = 4242; bounds = @(750,278,1036,298) }, - [ordered]@{ automationId = "canvas-card-1616358459734844849"; name = "Beta loop"; processId = 4242; bounds = @(262,314,482,400) } + [ordered]@{ automationId = "canvas-card-1616358459734844849"; name = "Beta loop"; processId = 4242; bounds = @(262,314,512,410) } ) foreignCardFragmentCount = 0; foreignProjectFragmentCount = 0 canvasBounds = @(220,34,1200,900) @@ -1558,6 +1592,26 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate "literal two-owner/two-node plus two-summary whole roster rejected" return "Historical two-card case counts NODE cards: complete owned canvas roster is four (two nodes plus two summaries); no live app claim" } + Invoke-MultiCase "compact overview roster rejects obsolete card dimensions" -Negative { + $projection = New-MultiRosterProjection + $projection.cards[1].bounds = @(262,118,482,204) + Assert-MultiCase (-not (Test-MultiProjectObservedRoster "overview" $projection $rosterOwners $null 4242)) ` + "old 220x86 card passed the actual complete-roster predicate" + return "Named Alpha node geometry rejected; no broad canvas count" + } + Invoke-MultiCase "compact overview roster rejects obsolete narrow summary position" -Negative { + $projection = New-MultiRosterProjection + $projection.canvasBounds = @(220,34,1028,900) + $projection.cards[0].bounds = @(578,82,864,102) + $projection.cards[2].bounds = @(578,278,864,298) + Assert-MultiCase (-not (Test-MultiProjectObservedRoster "overview" $projection $rosterOwners $null 4242)) ` + "old 760-pixel floor summary positions passed the actual complete-roster predicate" + $projection.cards[0].bounds = @(624,82,910,102) + $projection.cards[2].bounds = @(624,278,910,298) + Assert-MultiCase (Test-MultiProjectObservedRoster "overview" $projection $rosterOwners $null 4242) ` + "corrected 806-pixel floor summary positions were rejected" + return "Same exact four named fragments: old floor rejected, current narrow geometry accepted" + } Invoke-MultiCase "fresh owned content cannot bless an overview missing its peer card" -Negative { $mock = New-MultiCaptureMock @((New-MultiCaptureState),(New-MultiCaptureState),(New-MultiCaptureState)) $evidence = Get-MultiProjectElementEvidence $mock 4242 $capturePrior "functional/missing-peer" $captureReader @@ -1613,9 +1667,9 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate return "Full roster four, node two, per-owner summary two" } Invoke-MultiCase "source summary geometry is separate from node Open geometry" { - $geometry = Get-MultiProjectLaneGeometry @(228,85,1036,780) @(270,169,490,255) - Assert-MultiCase (($geometry.summary -join ",") -ceq "586,133,872,153" -and - ($geometry.open -join ",") -ceq "880,133,936,153") "actual-canvas source summary/Open rectangles differ" + $geometry = Get-MultiProjectLaneGeometry @(228,85,1036,780) @(270,169,520,265) + Assert-MultiCase (($geometry.summary -join ",") -ceq "632,133,918,153" -and + ($geometry.open -join ",") -ceq "926,133,982,153") "actual-canvas source summary/Open rectangles differ" return "Derived from actual NODE bounds and canvas; not summary-as-node geometry" } foreach ($mutation in @("two-nodes-unknown", "unknown-summary", "duplicate-summary", "wrong-summary-owner", @@ -1642,10 +1696,10 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate } } Invoke-MultiCase "literal lane geometry and DPI scaling" { - $geometry = Get-MultiProjectLaneGeometry @(220,34,1200,900) @(262,118,482,204) + $geometry = Get-MultiProjectLaneGeometry @(220,34,1200,900) @(262,118,512,214) Assert-MultiCase (($geometry.band -join ",") -ceq "244,72,1176,248" -and ($geometry.open -join ",") -ceq "1044,82,1100,102") "96-DPI literal source-derived bounds differ" - $scaled = Get-MultiProjectLaneGeometry @(330,51,1800,1350) @(393,177,723,306) + $scaled = Get-MultiProjectLaneGeometry @(330,51,1800,1350) @(393,177,768,321) Assert-MultiCase (($scaled.open -join ",") -ceq "1566,123,1650,153") "144-DPI source-derived bounds differ" return "literal band/Open bounds; not UIA caption evidence" } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 16a553fba..c318e53ab 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -3275,8 +3275,8 @@ function Test-MultiProjectObservedRoster([string] $surface, $projection, $expect $summaryId = Get-MultiProjectAutomationId "overview-worktree-notice" $owner.path $node = @($projection.cards | Where-Object { $_.automationId -ceq $nodeId })[0] $summary = @($projection.cards | Where-Object { $_.automationId -ceq $summaryId })[0] - $scale = ($node.bounds[2] - $node.bounds[0]) / 220 - if ([Math]::Abs(($node.bounds[3] - $node.bounds[1]) / $scale - 86) -gt 1) { return $false } + $scale = ($node.bounds[2] - $node.bounds[0]) / 250 + if ([Math]::Abs(($node.bounds[3] - $node.bounds[1]) / $scale - 96) -gt 1) { return $false } $geometry = Get-MultiProjectLaneGeometry $projection.canvasBounds $node.bounds for ($index = 0; $index -lt 4; $index++) { if ([Math]::Abs($summary.bounds[$index] - $geometry.summary[$index]) -gt 1) { return $false } @@ -4181,13 +4181,13 @@ function Get-MultiProjectLaneGeometry([double[]] $canvas, [double[]] $card) { $canvas[3] -le $canvas[1] -or $card[2] -le $card[0] -or $card[3] -le $card[1]) { throw "MULTIPROJECT_BOUNDS: positive live canvas/card rectangles required" } - $scale = ($card[2] - $card[0]) / 220 - if ([Math]::Abs(($card[3] - $card[1]) / $scale - 86) -gt 1) { + $scale = ($card[2] - $card[0]) / 250 + if ([Math]::Abs(($card[3] - $card[1]) / $scale - 96) -gt 1) { throw "MULTIPROJECT_BOUNDS: overview-card scale differs from source geometry" } $laneLeft = $canvas[0] + 24 * $scale $laneTop = $card[1] - 46 * $scale - $laneRight = $laneLeft + [Math]::Max(760, ($canvas[2] - $canvas[0]) / $scale - 48) * $scale + $laneRight = $laneLeft + [Math]::Max(806, ($canvas[2] - $canvas[0]) / $scale - 48) * $scale return [ordered]@{ provenance = "GraphCanvas source-derived one-root lane; not a native UIA caption fragment" scale = $scale @@ -6010,7 +6010,7 @@ try { # Folder lanes/bands: the lane's Open and Worktrees actions have no dedicated UIA # elements (GraphCanvas.overviewLaneActionAt is a pure hit test painted by GDI), so # this derives their real screen position from GraphCanvas.overviewLaneBounds' - # actual formula (lane.left = graph.Left + 24, lane width = max(760, + # actual formula (lane.left = graph.Left + 24, lane width = max(806, # graph.Width - 48)) and posts real WM_LBUTTONDOWN/UP client-coordinate clicks at # those points, exercising the exact App.zig click routing a physical mouse would # drive. A genuine surface transition (not merely re-invoking the same overview @@ -6019,7 +6019,7 @@ try { # the free-form project-canvas layout. # # An earlier version of this block instead tried to force the shell window wide - # enough that graph.Width - 48 would exceed the 760 floor, first via SW_MAXIMIZE, + # enough that graph.Width - 48 would exceed the then-current 760 floor, first via SW_MAXIMIZE, # then via a growing SetWindowPos request -- but on a CI runner with a small # virtual desktop, requesting an ever-larger window rect does not produce an # ever-larger client rect: repeated attempts up to a 6000x2687 request all landed @@ -6050,7 +6050,7 @@ try { ((@($overviewCards | ForEach-Object { $_.Current.Name }) -join "|") -eq "UIA loop A|UIA loop B")) ` "Overview did not expose synchronized cards after resetting zoom/pan to actual size" $graphBounds = $graph.Current.BoundingRectangle - $laneWidth = [Math]::Max(760, [int]$graphBounds.Width - 48) + $laneWidth = [Math]::Max(806, [int]$graphBounds.Width - 48) $laneRight = [int]$graphBounds.Left + 24 + $laneWidth $laneGridCardTop = $overviewCards[0].Current.BoundingRectangle.Top $laneOpenScreenX = $laneRight - 104 diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 602c81c74..b9054ec46 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -20219,7 +20219,7 @@ test "cross-project overview refresh emits both projects without changing the se .{ .identity = "open-project:A" }, .{ .identity = "open-project:B", .selected = true }, .{ .identity = "overview-card:A:a4", .name = "Alpha refreshed", .bounds = .{ 262, 118, 512, 214 } }, - .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 538, 118, 788, 214 } }, + .{ .identity = "overview-card:A:a1", .name = "Alpha 1", .bounds = .{ 522, 118, 772, 214 } }, .{ .identity = "overview-card:A:a2" }, .{ .identity = "overview-card:A:a3" }, .{ .identity = "overview-card:B:b1", .bounds = .{ 262, 442, 512, 538 } }, diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 4a1b486e9..454e3d4d1 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -898,7 +898,7 @@ fn overviewLaneBounds( overviewLaneHeight(model.graphs.items[graph_index].nodes.items.len) else 0; - return transformedRect(bounds, state, 24, top, @max(838, bounds.right - bounds.left - 48), height); + return transformedRect(bounds, state, 24, top, @max(806, bounds.right - bounds.left - 48), height); } pub fn overviewCardBounds( @@ -912,9 +912,9 @@ pub fn overviewCardBounds( const column = @as(i32, @intCast(node_index % 3)); const row = @as(i32, @intCast(node_index / 3)); return rect( - lane.left + scaledValue(18 + column * 276, state.zoom), + lane.left + scaledValue(18 + column * 260, state.zoom), lane.top + scaledValue(46 + row * 122, state.zoom), - lane.left + scaledValue(18 + column * 276, state.zoom) + scaledValue(Tokens.loop_card_width, state.zoom), + lane.left + scaledValue(18 + column * 260, state.zoom) + scaledValue(Tokens.loop_card_width, state.zoom), lane.top + scaledValue(46 + row * 122, state.zoom) + scaledValue(Tokens.loop_card_height, state.zoom), ); } @@ -1023,7 +1023,7 @@ pub fn contentSize(model: *const GraphModel.Model, surface: Surface) struct { wi .overview => blk: { var height: i32 = 38; for (model.graphs.items) |graph| height += overviewLaneHeight(graph.nodes.items.len) + 20; - break :blk .{ .width = 886, .height = @max(1, height) }; + break :blk .{ .width = 854, .height = @max(1, height) }; }, .quick_chats => .{ .width = 808, @@ -1799,12 +1799,8 @@ fn cardLiveLine(allocator: std.mem.Allocator, node: GraphModel.Node) ![]u8 { { const pilot = cardPilotName(node.pilot_state); if (node.subgraph_json.len == 0) return allocator.dupe(u8, pilot); - const parsed = try std.json.parseFromSlice(std.json.Value, allocator, node.subgraph_json, .{}); - defer parsed.deinit(); - if (parsed.value != .object) return error.InvalidCardSubgraph; - const nodes = parsed.value.object.get("nodes") orelse return error.InvalidCardSubgraph; - if (nodes != .array) return error.InvalidCardSubgraph; - try result.writer().print("{d} {s} · {s}", .{ nodes.array.items.len, if (nodes.array.items.len == 1) "loop" else "loops", pilot }); + const count = GraphModel.subgraphNodeCount(node.subgraph_json); + try result.writer().print("{d} {s} · {s}", .{ count, if (count == 1) "loop" else "loops", pilot }); return result.toOwnedSlice(); } const source = nodePrimaryDetail(node); @@ -2042,6 +2038,15 @@ fn elidedCandidate(scratch: []u16, text: []const u16, boundaries: []const usize, return scratch[0 .. head + 1 + text.len - tail]; } +fn cardBranchForWidth(hdc: c.HDC, allocator: std.mem.Allocator, branch: []const u16, width: i32) !?[]u16 { + const ellipsis = [_]u16{0x2026}; + if (try measuredWide(hdc, &ellipsis) > width) return null; + return middleElide(hdc, allocator, branch, width) catch |err| switch (err) { + error.CardMetadataTooWide => try allocator.dupe(u16, &ellipsis), + else => return err, + }; +} + fn cardMetaForWidth(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, now: u64, width: i32) ![]u8 { const full = try nodeMetadata(allocator, node, node.worktree_branch, now); errdefer allocator.free(full); @@ -2054,7 +2059,7 @@ fn cardMetaForWidth(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.N const branch = try std.unicode.utf8ToUtf16LeAlloc(allocator, node.worktree_branch); defer allocator.free(branch); while (branch_width > 0) { - const shortened = try middleElide(hdc, allocator, branch, branch_width); + const shortened = try cardBranchForWidth(hdc, allocator, branch, branch_width) orelse break; defer allocator.free(shortened); const utf8 = try std.unicode.utf16LeToUtf8Alloc(allocator, shortened); defer allocator.free(utf8); @@ -2069,7 +2074,10 @@ fn cardMetaForWidth(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.N // A mapped DC rounds each measured run, so verify the assembled line too. branch_width -= @max(1, measured - width); } - return error.CardMetadataTooWide; + // Complete visual elision of the branch is preferable to clipping backend/age. + const result = try allocator.dupe(u8, fixed); + allocator.free(full); + return result; } fn drawCardText(hdc: c.HDC, allocator: std.mem.Allocator, text: []const u8, bounds: c.RECT, size: f32, style: AppFont.Style, color: u32, ellipsis: bool) !void { @@ -2127,19 +2135,13 @@ fn drawCardBody(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, break :blk (try measuredText(hdc, allocator, presentation.word)) + scaledValue(22, zoom); }; const layout = cardTextLayout(bounds, zoom, pill_width); + try drawCardText(hdc, allocator, node.title, layout.title, 14.5 * zoom, .card_title, 0x00F4F4F4, true); + try drawCardPill(hdc, allocator, presentation, layout.pill, zoom); const live = try cardLiveLine(allocator, node); defer allocator.free(live); + try drawCardText(hdc, allocator, live, layout.live, 10.5 * zoom, .card_mono, 0x009F9C9C, true); const metric = try cardMetricDetail(allocator, node); defer allocator.free(metric); - const meta = blk: { - const old = try selectCardFont(hdc, 10.5 * zoom, .card_mono); - defer _ = c.SelectObject(hdc, old); - break :blk try cardMetaForWidth(hdc, allocator, node, @intCast(@max(0, std.time.timestamp())), layout.meta.right - layout.meta.left - scaledValue(10, zoom)); - }; - defer allocator.free(meta); - try drawCardText(hdc, allocator, node.title, layout.title, 14.5 * zoom, .card_title, 0x00F4F4F4, true); - try drawCardPill(hdc, allocator, presentation, layout.pill, zoom); - try drawCardText(hdc, allocator, live, layout.live, 10.5 * zoom, .card_mono, 0x009F9C9C, true); if (!attention and !reclaim) { const entry_detail = if (role == .entry and metric.len != 0) try std.fmt.allocPrint(allocator, "START · {s}", .{metric}) else null; defer if (entry_detail) |text| allocator.free(text); @@ -2158,6 +2160,19 @@ fn drawCardBody(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, const role_bounds = rect(if (reclaim) reclaimOfferBounds(bounds).keep.right + scaledValue(6, zoom) else layout.live.left, layout.detail.top, if (attention) attentionActionBounds(bounds, &.{ .zoom = zoom }).left - scaledValue(6, zoom) else layout.detail.right, layout.detail.bottom); try drawCardText(hdc, allocator, if (role == .entry) "START" else "UNWIRED", role_bounds, 10.5 * zoom, .card_mono, 0x009F9C9C, true); } + const meta = blk: { + const old = try selectCardFont(hdc, 10.5 * zoom, .card_mono); + defer _ = c.SelectObject(hdc, old); + break :blk cardMetaForWidth(hdc, allocator, node, @intCast(@max(0, std.time.timestamp())), layout.meta.right - layout.meta.left - scaledValue(10, zoom)) catch |err| switch (err) { + error.CardMetadataTooWide => { + std.log.warn("Card {s} fixed metadata does not fit: {s}", .{ node.id, @errorName(err) }); + try drawCardText(hdc, allocator, "Metadata unavailable", layout.meta, 10.5 * zoom, .card_mono, Tokens.dialog_error_text, true); + return; + }, + else => return err, + }; + }; + defer allocator.free(meta); const square_top = @divTrunc(layout.meta.top + layout.meta.bottom - scaledValue(6, zoom), 2); fill(hdc, rect(layout.meta.left, square_top, layout.meta.left + scaledValue(6, zoom), square_top + scaledValue(6, zoom)), loopTypeColor(node.loop_type)); try drawCardText(hdc, allocator, meta, rect(layout.meta.left + scaledValue(10, zoom), layout.meta.top, layout.meta.right, layout.meta.bottom), 10.5 * zoom, .card_mono, 0x008C8989, false); @@ -3298,6 +3313,45 @@ test "loop card parity metrics and rows fit the owning card" { } } +test "loop card parity overview narrow client retains action hits and pannable card extents" { + const allocator = std.testing.allocator; + var model = GraphModel.Model.init(allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"A","name":"Alpha"},"nodes":[{"id":"a","title":"A"},{"id":"b","title":"B"},{"id":"c","title":"C"},{"id":"d","title":"D"}],"edges":[]}}} + ); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"project":{"path":"B","name":"Beta"},"nodes":[{"id":"e","title":"E"}],"edges":[]}}} + ); + try std.testing.expectEqual(@as(i32, 854), contentSize(&model, .overview).width); + for ([_]i32{ 1028, 1440 }) |right| { + const viewport = rect(220, 34, right, 900); + for ([_]CanvasState{ .{}, .{ .zoom = 0.75, .pan_x = 15, .pan_y = -12 }, .{ .zoom = 1.25, .pan_x = -120, .pan_y = 20 } }) |state| { + const lane = overviewLaneBounds(&model, 0, viewport, &state); + const next_lane = overviewLaneBounds(&model, 1, viewport, &state); + const first = overviewCardBounds(&model, 0, 0, viewport, &state); + const second = overviewCardBounds(&model, 0, 1, viewport, &state); + const third = overviewCardBounds(&model, 0, 2, viewport, &state); + const fourth = overviewCardBounds(&model, 0, 3, viewport, &state); + try std.testing.expect(first.right < second.left and second.right < third.left); + try std.testing.expect(first.bottom < fourth.top and fourth.bottom < lane.bottom); + try std.testing.expect(third.right < lane.right and lane.bottom < next_lane.top); + } + } + const narrow = rect(220, 34, 1028, 900); + const caption = overviewLaneCaption(&model, 0, narrow, &.{}); + try std.testing.expectEqualDeep(rect(918, 82, 974, 102), caption.open); + try std.testing.expectEqualDeep(rect(978, 82, 1032, 102), caption.worktrees); + try std.testing.expect(caption.worktrees.left < narrow.right and caption.worktrees.right > narrow.right); + try std.testing.expectEqual(OverviewLaneAction.inspect_worktrees, overviewLaneActionAt(&model, 0, 1005, 92, narrow, &.{}).?); + const panned = CanvasState{ .pan_x = -30 }; + const shifted = overviewLaneCaption(&model, 0, narrow, &panned); + const third = overviewCardBounds(&model, 0, 2, narrow, &panned); + try std.testing.expect(shifted.worktrees.right <= narrow.right); + try std.testing.expect(third.right <= narrow.right); + try std.testing.expectEqual(OverviewLaneAction.inspect_worktrees, overviewLaneActionAt(&model, 0, shifted.worktrees.left + 1, shifted.worktrees.top + 1, narrow, &panned).?); +} + test "loop card parity live and meta content and duration boundaries" { const allocator = std.testing.allocator; var node = parityTestNode(); @@ -3449,6 +3503,15 @@ fn parityOwnedTextExercise(allocator: std.mem.Allocator, dc: c.HDC) !void { defer allocator.free(metric); try std.testing.expect(metric.len > 128); try std.testing.expect(std.mem.endsWith(u8, metric, "100% better")); + var compact = parityTestNode(); + compact.loop_type = @constCast("composite"); + compact.worktree_branch = @constCast("main"); + for ([_]i32{ 218, 200 }) |width| { + const short = try cardMetaForWidth(dc, allocator, compact, 3900, width); + defer allocator.free(short); + try std.testing.expect(try measuredText(dc, allocator, short) <= width); + try std.testing.expect(std.mem.endsWith(u8, short, "Copilot CLI · 1h 5m")); + } } test "loop card parity long Unicode text measured middle elision and allocation cleanup" { @@ -3580,6 +3643,100 @@ test "loop card parity production long text and hollow pills paint across zoom a } } +test "loop card parity compact composite metadata never blanks title or state" { + const allocator = std.testing.allocator; + const dib = try ParityDib.init(600, 240); + defer dib.deinit(); + var node = parityTestNode(); + node.loop_type = @constCast("composite"); + node.worktree_branch = @constCast("main"); + node.created_at = @intCast(@max(0, std.time.timestamp() - 3900)); + const bounds = rect(20, 20, 270, 116); + fill(dib.dc, rect(0, 0, 600, 240), 0); + try drawCardBody(dib.dc, allocator, node, bounds, 1, .interior, false, false); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expect(dib.count(rect(31, 28, 170, 46), 0x00F4F4F4) > 0); + try std.testing.expect(dib.count(rect(170, 28, 259, 46), cardState(node).fill) > 0); + const old = try selectCardFont(dib.dc, 10.5, .card_mono); + defer _ = c.SelectObject(dib.dc, old); + const metadata = try cardMetaForWidth(dib.dc, allocator, node, node.created_at.? + 3900, 218); + defer allocator.free(metadata); + try std.testing.expectEqualStrings("Composite · … · Copilot CLI · 1h 5m", metadata); + const minimum = try measuredText(dib.dc, allocator, metadata); + const exact = try cardMetaForWidth(dib.dc, allocator, node, node.created_at.? + 3900, minimum); + defer allocator.free(exact); + try std.testing.expectEqualStrings(metadata, exact); + const below = try cardMetaForWidth(dib.dc, allocator, node, node.created_at.? + 3900, minimum - 1); + defer allocator.free(below); + try std.testing.expectEqualStrings("Composite · Copilot CLI · 1h 5m", below); + const fixed_width = try measuredText(dib.dc, allocator, below); + const exact_fixed = try cardMetaForWidth(dib.dc, allocator, node, node.created_at.? + 3900, fixed_width); + defer allocator.free(exact_fixed); + try std.testing.expectEqualStrings(below, exact_fixed); + try std.testing.expectError(error.CardMetadataTooWide, cardMetaForWidth(dib.dc, allocator, node, node.created_at.? + 3900, fixed_width - 1)); + node.backend = @constCast("a backend identifier too wide to fit without lying"); + fill(dib.dc, rect(0, 0, 600, 240), 0); + try drawCardBody(dib.dc, allocator, node, bounds, 1, .interior, false, false); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expect(dib.count(rect(31, 28, 170, 46), 0x00F4F4F4) > 0); + try std.testing.expect(dib.count(rect(31, 52, 259, 65), 0x009F9C9C) > 0); + try std.testing.expect(dib.count(rect(31, 95, 259, 108), Tokens.dialog_error_text) > 0); +} + +test "loop card parity middle elision cuts only between complete surrogate pairs" { + const allocator = std.testing.allocator; + const dc = c.CreateCompatibleDC(null) orelse return error.TestDcCreationFailed; + defer _ = c.DeleteDC(dc); + const old = try selectCardFont(dc, 10.5, .card_mono); + defer _ = c.SelectObject(dc, old); + const source = try std.unicode.utf8ToUtf16LeAlloc(allocator, "🧪abcdefghijkl🧪"); + defer allocator.free(source); + const expected = try std.unicode.utf8ToUtf16LeAlloc(allocator, "🧪…🧪"); + defer allocator.free(expected); + const result = try middleElide(dc, allocator, source, try measuredWide(dc, expected)); + defer allocator.free(result); + try std.testing.expectEqualSlices(u16, expected, result); + const utf8 = try std.unicode.utf16LeToUtf8Alloc(allocator, result); + defer allocator.free(utf8); + try std.testing.expectEqualStrings("🧪…🧪", utf8); +} + +test "loop card parity existing action fills use hit bounds and never paint over meta" { + const allocator = std.testing.allocator; + const dib = try ParityDib.init(1200, 500); + defer dib.deinit(); + var nodes = [_]GraphModel.Node{ parityTestNode(), parityTestNode() }; + nodes[0].presence = @constCast("awaitingInput"); + nodes[1].id = @constCast("resolved"); + nodes[1].state = @constCast("succeeded"); + nodes[1].presence = @constCast("idle"); + nodes[1].worktree_path = @constCast("fixture-safe"); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator), + .default_branch = @constCast("main"), + .project_path = @constCast("fixture"), + }; + defer inspection.entries.deinit(); + try inspection.entries.append(.{ .path = @constCast("fixture-safe"), .branch = @constCast("main"), .pushed = true, .landed = true }); + for ([_]f32{ 0.55, 0.75, 1, 1.25, 1.8 }) |zoom| { + const state = CanvasState{ .zoom = zoom }; + fill(dib.dc, rect(0, 0, 1200, 500), 0); + for (nodes, 0..) |node, index| drawNode(dib.dc, allocator, node, index, null, &nodes, &.{}, &inspection, &.{}, &.{}, &state); + const attention = attentionActionBounds(nodeBounds(0, &state), &state); + const offer = reclaimOfferBounds(nodeBounds(1, &state)); + try std.testing.expect(c.GdiFlush() != 0); + try std.testing.expect(dib.count(attention, 0x000A9FFF) > 0); + try std.testing.expect(dib.count(offer.reclaim, 0x003A3A44) > 0); + try std.testing.expectEqual(@as(?usize, 0), hitTestAttentionAction(&nodes, &.{}, attention.left + 1, attention.top + 1, &state)); + try std.testing.expectEqual(ReclaimAction.reclaim, hitTestReclaimOffer(&nodes, &inspection, &.{}, offer.reclaim.left + 1, offer.reclaim.top + 1, &state).?.action); + for (0..2) |index| { + const meta = cardTextLayout(nodeBounds(index, &state), zoom, scaledValue(80, zoom)).meta; + try std.testing.expectEqual(@as(usize, 0), dib.count(meta, 0x000A9FFF)); + try std.testing.expectEqual(@as(usize, 0), dib.count(meta, 0x003A3A44)); + } + } +} + test "minimum zoom keeps compact rows separated inside a 96 point card" { const bounds = nodeBounds(0, &.{ .zoom = 0.55 }); const layout = cardTextLayout(bounds, 0.55, 48); From 6cd93944e7a0ecf8ddc49f4401e12ec70ca26fbb Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 09:32:07 -0700 Subject: [PATCH 4/5] Derive the early Worktrees click from the current lane floor Exercise the actual production assignment AST at narrow, wide and translated bounds, rejecting the obsolete point that hits Open while preserving named ownership and outcome guards. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39e65c1f-fe48-4e45-b6f2-e26f161d9d6b Signed-off-by: Colin Neilens --- .../windows/Tests/ValidationRunner.Tests.ps1 | 33 +++++++++++++++++++ Tools/windows/uia-live-gate.ps1 | 2 +- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/ValidationRunner.Tests.ps1 b/Tools/windows/Tests/ValidationRunner.Tests.ps1 index 4033d9437..70349c2fc 100644 --- a/Tools/windows/Tests/ValidationRunner.Tests.ps1 +++ b/Tools/windows/Tests/ValidationRunner.Tests.ps1 @@ -63,6 +63,39 @@ function Test-MultiProjectProtocolContracts([string] $stubSource, [string] $gate ($geometry.open -join ",") -cne "872,82,928,102") "old 760-pixel action geometry was accepted" return "New source geometry rejects the old narrow-window lane/action coordinates" } + Invoke-MultiCase "actual early Worktrees click assignment uses current narrow and wide lane geometry" { + $gateAst = [Management.Automation.Language.Parser]::ParseInput($gateSource, [ref]$null, [ref]$null) + $assignments = @($gateAst.FindAll({ + param($node) + $node -is [Management.Automation.Language.AssignmentStatementAst] -and + $node.Left -is [Management.Automation.Language.VariableExpressionAst] -and + $node.Left.VariablePath.UserPath -ceq "laneWorktreesScreenX" + }, $true)) + Assert-MultiCase ($assignments.Count -eq 1) "actual Worktrees click assignment missing or ambiguous" + $evaluate = [scriptblock]::Create('param($graph) ' + $assignments[0].Extent.Text + '; return $laneWorktreesScreenX') + foreach ($case in @( + @{ left = 220; right = 1028; expected = 1005; minimum = 978; maximum = 1032 }, + @{ left = 220; right = 1200; expected = 1131; minimum = 1104; maximum = 1158 }, + @{ left = 228; right = 1036; expected = 1013; minimum = 986; maximum = 1040 } + )) { + $graph = [pscustomobject]@{ Current = [pscustomobject]@{ BoundingRectangle = [pscustomobject]@{ + Left = $case.left; Right = $case.right; Width = $case.right - $case.left + } } } + $actual = & $evaluate $graph + Assert-MultiCase ($actual -eq $case.expected -and $actual -ge $case.minimum -and $actual -lt $case.maximum) ` + "actual production Worktrees point expected $($case.expected), found $actual" + $geometry = Get-MultiProjectLaneGeometry @($case.left,34,$case.right,900) @(($case.left+42),118,($case.left+292),214) + $control = @(($geometry.band[2]-72),82,($geometry.band[2]-18),102) + $visible = Get-MultiProjectClippedRectangle $control @($case.left,34,$case.right,900) + Assert-MultiCase ($actual -ge $visible[0] -and $actual -lt $visible[2]) "actual Worktrees point misses its visible clipped control" + if ($case.right - $case.left -eq 808) { + $obsolete = $case.right - 69 + Assert-MultiCase ($obsolete -ge $geometry.open[0] -and $obsolete -lt $geometry.open[2] -and + ($obsolete -lt $control[0] -or $obsolete -ge $control[2])) "obsolete narrow point was not rejected as Open rather than Worktrees" + } + } + return "Executed the production assignment AST at 1028-client, wide and translated bounds; no desktop invocation" + } $eventStart = [DateTime]::Parse("2026-10-01T18:29:43.1462496Z").ToUniversalTime() $eventEnd = $eventStart.AddSeconds(1) $eventProcess = [pscustomobject]@{ Id = 5832; StartTime = $eventStart } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index c318e53ab..d2ac7f076 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -6084,7 +6084,7 @@ try { $_.Current.AutomationId -match '^canvas-card-' -and $_.Current.Name -match '^UIA loop ' }) Require ($overviewCards.Count -eq 2) "overview did not restore synchronized cards before the Worktrees lane check" - $laneWorktreesScreenX = [int]$graph.Current.BoundingRectangle.Right - 69 + $laneWorktreesScreenX = [int]$graph.Current.BoundingRectangle.Left + 24 + [Math]::Max(806, [int]$graph.Current.BoundingRectangle.Width - 48) - 45 $laneWorktreesScreenY = [int]$overviewCards[0].Current.BoundingRectangle.Top - 26 $worktrees = Find-FragmentById $root "worktrees" $rawWalker Require ($null -ne $worktrees) "missing Worktrees fragment before the overview lane Worktrees check" From 156345accb529384e200e1667e1945e857784cd1 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Wed, 14 Oct 2026 09:41:38 -0700 Subject: [PATCH 5/5] Match the source metadata ink and marker spacing Use LoopCardView's 52 percent white metadata ink over the card surface and its six-point marker plus five-point gap, sharing the measured and painted text bounds. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 39e65c1f-fe48-4e45-b6f2-e26f161d9d6b Signed-off-by: Colin Neilens --- graphcode-windows/src/GraphCanvas.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 454e3d4d1..637e73e0c 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -2160,10 +2160,11 @@ fn drawCardBody(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, const role_bounds = rect(if (reclaim) reclaimOfferBounds(bounds).keep.right + scaledValue(6, zoom) else layout.live.left, layout.detail.top, if (attention) attentionActionBounds(bounds, &.{ .zoom = zoom }).left - scaledValue(6, zoom) else layout.detail.right, layout.detail.bottom); try drawCardText(hdc, allocator, if (role == .entry) "START" else "UNWIRED", role_bounds, 10.5 * zoom, .card_mono, 0x009F9C9C, true); } + const meta_text_bounds = rect(layout.meta.left + scaledValue(11, zoom), layout.meta.top, layout.meta.right, layout.meta.bottom); const meta = blk: { const old = try selectCardFont(hdc, 10.5 * zoom, .card_mono); defer _ = c.SelectObject(hdc, old); - break :blk cardMetaForWidth(hdc, allocator, node, @intCast(@max(0, std.time.timestamp())), layout.meta.right - layout.meta.left - scaledValue(10, zoom)) catch |err| switch (err) { + break :blk cardMetaForWidth(hdc, allocator, node, @intCast(@max(0, std.time.timestamp())), meta_text_bounds.right - meta_text_bounds.left) catch |err| switch (err) { error.CardMetadataTooWide => { std.log.warn("Card {s} fixed metadata does not fit: {s}", .{ node.id, @errorName(err) }); try drawCardText(hdc, allocator, "Metadata unavailable", layout.meta, 10.5 * zoom, .card_mono, Tokens.dialog_error_text, true); @@ -2175,7 +2176,7 @@ fn drawCardBody(hdc: c.HDC, allocator: std.mem.Allocator, node: GraphModel.Node, defer allocator.free(meta); const square_top = @divTrunc(layout.meta.top + layout.meta.bottom - scaledValue(6, zoom), 2); fill(hdc, rect(layout.meta.left, square_top, layout.meta.left + scaledValue(6, zoom), square_top + scaledValue(6, zoom)), loopTypeColor(node.loop_type)); - try drawCardText(hdc, allocator, meta, rect(layout.meta.left + scaledValue(10, zoom), layout.meta.top, layout.meta.right, layout.meta.bottom), 10.5 * zoom, .card_mono, 0x008C8989, false); + try drawCardText(hdc, allocator, meta, meta_text_bounds, 10.5 * zoom, .card_mono, blendCardColor(0x00FFFFFF, Tokens.loop_card_bottom, 52), false); } pub fn nodeBounds(index: usize, state: *const CanvasState) c.RECT { @@ -3590,7 +3591,7 @@ test "loop card parity both production renderers paint named pill and mono meta try std.testing.expect(dib.count(project_rows.title, 0x00F4F4F4) > 0); try std.testing.expect(dib.count(project_rows.live, 0x009F9C9C) > 0); try std.testing.expect(dib.count(project_rows.meta, loopTypeColor(node.loop_type)) == 36); - try std.testing.expect(dib.count(project_rows.meta, 0x008C8989) > 0); + try std.testing.expect(dib.count(project_rows.meta, 0x00979595) > 0); const project_pill_pixels = dib.count(project_rows.pill, presentation.fill); fill(dib.dc, rect(0, 0, 2200, 500), 0); drawOverview(dib.dc, allocator, &model, viewport, &state); @@ -3599,7 +3600,7 @@ test "loop card parity both production renderers paint named pill and mono meta try std.testing.expect(c.GdiFlush() != 0); try std.testing.expectEqual(project_pill_pixels, dib.count(overview_rows.pill, presentation.fill)); try std.testing.expect(dib.count(overview_rows.meta, loopTypeColor(node.loop_type)) == 36); - try std.testing.expect(dib.count(overview_rows.meta, 0x008C8989) > 0); + try std.testing.expect(dib.count(overview_rows.meta, 0x00979595) > 0); try std.testing.expect(dib.count(overview_rows.detail, 0x000A9FFF) == 0); }