From 703045296670e8c5b1c1634a9c3699295dcd81f6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 09:35:44 -0700 Subject: [PATCH 01/13] Windows: dark-theme legacy dialogs and add loop-type teaching tiles Bring the remaining legacy Win32 sheets up to the same dark, native visual language already used by the main canvas, onboarding, and WindowsProductSettings.zig, per the ui-parity-matrix's 'Dark visual language' gap. - DesignTokens.zig: add a shared dark dialog palette (dialog_panel, dialog_title_text, dialog_body_text, dialog_muted_text, dialog_error_text, dialog_field_background, dialog_field_border), reusing the exact values already validated in WindowsProductSettings.zig / Sidebar.zig. - WindowsRepositoryDialogs.zig: Clone Repository and Add Remote Repository sheets (plus the shared clone-progress/SSH-validation operation sheet) now paint the dark panel background and light text via WM_ERASEBKGND/WM_CTLCOLORSTATIC/WM_CTLCOLOREDIT instead of the previous default GetSysColorBrush(COLOR_WINDOW) light background. - NativeForms.zig: the shared native-form engine behind Node, Edge, Update, Settings, Jump, Project Settings (worktree_policy), and Worktree Sweep (worktree_sweep) sheets now paints the same dark theme. The Node sheet's loop-type field is replaced with four owner-drawn 'teaching tiles' (rounded card, accent color chip, title, description) matching macOS's LoopTypeChooser.swift, using the exact accent RGB values from LoopTypeAppearance.swift packed into correct COLORREFs via a new tileColor() helper. Added variable-row-height layout support (rowHeight/fieldTop) so the tile grid can take more vertical space than a normal field row. - Added focused unit tests for the new tile accent colors/descriptions, tile row-height/layout math, and the blendColor color-mixing helper. - Updated ui-parity-matrix.md rows for Clone Repository, Add Remote Repository, Project Settings, Worktree Sweep, Edge creation, Node creation, and Dark visual language to describe exactly which constants/handlers were applied and where, keeping them at Partial since live/UIA screenshot evidence is still pending. This is a visual-only pass: no data flow, validation logic, daemon commands, or new fields were changed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/DesignTokens.zig | 14 + graphcode-windows/src/NativeForms.zig | 345 ++++++++++++++++-- .../src/WindowsRepositoryDialogs.zig | 54 ++- investigation/ui-parity-matrix.md | 18 +- 4 files changed, 386 insertions(+), 45 deletions(-) diff --git a/graphcode-windows/src/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig index 64161c5f..0a0602f4 100644 --- a/graphcode-windows/src/DesignTokens.zig +++ b/graphcode-windows/src/DesignTokens.zig @@ -23,6 +23,20 @@ pub const pane_header_height: i32 = 22; pub const tab_bar_height: i32 = 30; pub const canvas_grid_cell: i32 = 24; +// Shared dark dialog/sheet palette. Values match the already-validated +// WindowsProductSettings.zig sheet (settingsRgb(35,35,38) background, +// settingsRgb(245,245,247) titles, settingsRgb(190,190,198) body text, +// settingsRgb(135,135,142) muted/help text) and Sidebar.zig's ingress-error +// red (0x006060FF), so every legacy dialog now paints with the exact same +// dark native language instead of inventing a new one. +pub const dialog_panel: Color = 0x00262323; +pub const dialog_title_text: Color = 0x00F7F5F5; +pub const dialog_body_text: Color = 0x00C6BEBE; +pub const dialog_muted_text: Color = 0x008E8787; +pub const dialog_error_text: Color = 0x006060FF; +pub const dialog_field_background: Color = 0x00302B2B; +pub const dialog_field_border: Color = 0x00473F3F; + pub const sidebar_width: i32 = 220; pub const header_height: i32 = 34; pub const workspace_height: i32 = 250; diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 27c8e69a..cd8f2796 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -1,6 +1,7 @@ const std = @import("std"); const Forms = @import("Forms.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); +const Tokens = @import("DesignTokens.zig"); const c = @import("Win32.zig").c; const DialogState = struct { @@ -30,12 +31,18 @@ const DialogState = struct { lock_edge_endpoints: bool = true, immediate_policy_path: []const u8 = "", confirmation_armed: bool = false, + tile_field_index: ?usize = null, + tile_buttons: [max_tiles]c.HWND = .{null} ** max_tiles, + tile_count: usize = 0, }; +const max_tiles = 8; +const tile_base_id = 9600; + const Kind = enum { node, edge, update, settings, jump, worktree_policy, worktree_sweep }; -const InputKind = enum { edit, readonly, combo, checkbox }; +const InputKind = enum { edit, readonly, combo, checkbox, tiles }; const ChoiceGroup = enum { none, loop_type, backend, model_tier, metric_direction, optional_metric_direction, edge_kind, edge_condition, transform }; -const Choice = struct { label: []const u8, value: []const u8 }; +const Choice = struct { label: []const u8, value: []const u8, description: []const u8 = "", accent: u32 = Tokens.canvas_selection }; pub const EdgeEndpoint = struct { id: []const u8, title: []const u8 }; pub const WorktreeSweepResult = struct { selected: [256]bool = .{false} ** 256, @@ -51,11 +58,18 @@ var active_state_storage: DialogState = undefined; const ModalCommand = enum { submit, cancel, close, destroy }; +/// Loop-type teaching-tile accents, converted from the exact RGB values macOS +/// uses for the same four types (LoopTypeAppearance.swift's `accent`), so the +/// Windows tiles read as the same visual language rather than a new palette. +fn tileColor(red: u8, green: u8, blue: u8) u32 { + return @as(u32, red) | (@as(u32, green) << 8) | (@as(u32, blue) << 16); +} + const loop_type_choices = [_]Choice{ - .{ .label = "Turn-based — pause for review", .value = "turnBased" }, - .{ .label = "Time-based — repeat a prompt", .value = "timeBased" }, - .{ .label = "Goal-based — work toward done", .value = "goalBased" }, - .{ .label = "Proactive — design a nested workflow", .value = "proactive" }, + .{ .label = "Turn-based", .value = "turnBased", .description = "Pauses for you each turn", .accent = tileColor(213, 81, 129) }, + .{ .label = "Time-based", .value = "timeBased", .description = "Runs again on a schedule", .accent = tileColor(201, 133, 0) }, + .{ .label = "Goal-based", .value = "goalBased", .description = "Works until a condition is met", .accent = tileColor(25, 158, 112) }, + .{ .label = "Proactive", .value = "proactive", .description = "A group of loops, armed later", .accent = tileColor(144, 133, 233) }, }; const backend_choices = [_]Choice{ .{ .label = "Use workspace default", .value = "" }, @@ -124,6 +138,21 @@ fn choiceValue(group: ChoiceGroup, index: usize, previous: []const u8) []const u return options[index].value; } +/// Applies a teaching-tile click: updates the bound field's value, redraws +/// every tile so the new selection highlight and old one both repaint, and +/// re-runs the same conditional-visibility/validation reset a combo change +/// would have triggered. +fn selectTile(state: *DialogState, tile_index: usize) void { + const field_index = state.tile_field_index orelse return; + const next = choiceValue(state.choice_groups[field_index], tile_index, state.values[field_index]); + const value = state.allocator.dupe(u8, next) catch return; + state.allocator.free(state.values[field_index]); + state.values[field_index] = value; + updateConditionalVisibility(state); + setStaticText(state, state.validation, ""); + for (0..state.tile_count) |i| _ = c.InvalidateRect(state.tile_buttons[i], null, 1); +} + fn applyModalCommand(state: *DialogState, command: ModalCommand) void { switch (command) { .submit => state.result = true, @@ -559,10 +588,134 @@ fn registerClass() !void { window_class.hInstance = c.GetModuleHandleW(null); window_class.lpszClassName = class_name.ptr; window_class.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); + window_class.hbrBackground = null; if (c.RegisterClassW(&window_class) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) return error.ClassRegistrationFailed; } +// Dark sheet theme: same panel/text palette as the validated +// WindowsProductSettings.zig window (see DesignTokens.zig), applied here so +// every native form/sheet (node, edge, update, jump, worktree policy/sweep) +// paints with the app's dark native language instead of default Win32 gray. +var dark_field_brush: c.HBRUSH = null; + +fn darkFieldBrush() c.HBRUSH { + if (dark_field_brush == null) dark_field_brush = c.CreateSolidBrush(Tokens.dialog_field_background); + return dark_field_brush; +} + +fn fillFormBackground(hdc: c.HDC, bounds: c.RECT) void { + const brush = c.CreateSolidBrush(Tokens.dialog_panel); + if (brush == null) return; + _ = c.FillRect(hdc, &bounds, brush); + _ = c.DeleteObject(brush); +} + +fn formCtlColorStatic(hwnd: c.HWND, wparam: c.WPARAM, validation_label: c.HWND) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, if (hwnd == validation_label) Tokens.dialog_error_text else Tokens.dialog_body_text); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + return @intCast(@intFromPtr(c.GetStockObject(c.NULL_BRUSH))); +} + +fn formCtlColorEdit(wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, Tokens.dialog_title_text); + _ = c.SetBkColor(hdc, Tokens.dialog_field_background); + _ = c.SetBkMode(hdc, c.OPAQUE); + return @intCast(@intFromPtr(darkFieldBrush())); +} + +fn deviceContextFrom(wparam: c.WPARAM) c.HDC { + @setRuntimeSafety(false); + return @ptrFromInt(wparam); +} + +fn controlHandleFrom(lparam: c.LPARAM) c.HWND { + @setRuntimeSafety(false); + return @ptrFromInt(@as(usize, @bitCast(lparam))); +} + +/// Blends `overlay` into `base` at `percent` strength (0-100), approximating +/// the translucent accent fills LoopTypeChooser.swift layers over its dark +/// background (`type.accent.opacity(0.12)` selected / `Color.white.opacity(0.035)` +/// idle) using plain GDI solid fills. +fn blendColor(base: u32, overlay: u32, percent: u8) u32 { + const inv: u32 = 100 - percent; + const br = base & 0xFF; + const bg = (base >> 8) & 0xFF; + const bb = (base >> 16) & 0xFF; + const orr = overlay & 0xFF; + const og = (overlay >> 8) & 0xFF; + const ob = (overlay >> 16) & 0xFF; + const r = (br * inv + orr * percent) / 100; + const g = (bg * inv + og * percent) / 100; + const b = (bb * inv + ob * percent) / 100; + return r | (g << 8) | (b << 16); +} + +fn drawTile(state: *DialogState, tile_index: usize, draw_item: *c.DRAWITEMSTRUCT) void { + const field_index = state.tile_field_index orelse return; + const options = choices(state.choice_groups[field_index]); + if (tile_index >= options.len) return; + const choice = options[tile_index]; + const selected = std.mem.eql(u8, choice.value, state.values[field_index]) or + (std.mem.eql(u8, choice.value, "proactive") and std.mem.eql(u8, state.values[field_index], "composite")); + const bounds = draw_item.rcItem; + const card_color = if (selected) blendColor(Tokens.dialog_panel, choice.accent, 22) else blendColor(Tokens.dialog_panel, 0x00FFFFFF, 4); + const border_color = if (selected) choice.accent else Tokens.dialog_field_border; + const brush = c.CreateSolidBrush(card_color); + const pen = c.CreatePen(c.PS_SOLID, if (selected) 2 else 1, border_color); + if (brush != null and pen != null) { + const old_brush = c.SelectObject(draw_item.hDC, brush); + const old_pen = c.SelectObject(draw_item.hDC, pen); + _ = c.RoundRect(draw_item.hDC, bounds.left, bounds.top, bounds.right, bounds.bottom, 9, 9); + _ = c.SelectObject(draw_item.hDC, old_pen); + _ = c.SelectObject(draw_item.hDC, old_brush); + } + if (pen != null) _ = c.DeleteObject(pen); + if (brush != null) _ = c.DeleteObject(brush); + const chip = c.RECT{ .left = bounds.left + 12, .top = bounds.top + 10, .right = bounds.left + 20, .bottom = bounds.top + 18 }; + const chip_brush = c.CreateSolidBrush(choice.accent); + if (chip_brush != null) { + _ = c.FillRect(draw_item.hDC, &chip, chip_brush); + _ = c.DeleteObject(chip_brush); + } + formDrawText(draw_item.hDC, choice.label, .{ .left = bounds.left + 28, .top = bounds.top + 6, .right = bounds.right - 8, .bottom = bounds.top + 24 }, 12, Tokens.dialog_title_text, true); + formDrawText(draw_item.hDC, choice.description, .{ .left = bounds.left + 12, .top = bounds.top + 28, .right = bounds.right - 8, .bottom = bounds.bottom - 6 }, 10, Tokens.dialog_muted_text, false); + if ((draw_item.itemState & c.ODS_FOCUS) != 0) _ = c.DrawFocusRect(draw_item.hDC, &bounds); +} + +fn formDrawText(hdc: c.HDC, text: []const u8, bounds_value: c.RECT, size: i32, color: u32, bold: bool) void { + const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return; + defer std.heap.c_allocator.free(wide); + const font = c.CreateFontW( + -size, + 0, + 0, + 0, + if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, + 0, + 0, + 0, + c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, + c.CLIP_DEFAULT_PRECIS, + c.CLEARTYPE_QUALITY, + c.DEFAULT_PITCH | c.FF_DONTCARE, + std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, + ); + const old_font = if (font != null) c.SelectObject(hdc, font) else null; + _ = c.SetTextColor(hdc, color); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + var bounds = bounds_value; + _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_WORDBREAK | c.DT_END_ELLIPSIS); + if (font != null) { + _ = c.SelectObject(hdc, old_font); + _ = c.DeleteObject(font); + } +} + fn configureFields(state: *DialogState) void { state.field_count = switch (state.kind) { .node => 14, @@ -576,7 +729,7 @@ fn configureFields(state: *DialogState) void { for (0..state.field_count) |index| state.visible[index] = true; switch (state.kind) { .node => { - state.input_kinds[1] = .combo; + state.input_kinds[1] = .tiles; state.choice_groups[1] = .loop_type; state.input_kinds[5] = .checkbox; state.input_kinds[11] = .combo; @@ -732,6 +885,24 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); return 0; }, + c.WM_ERASEBKGND => { + const hdc: c.HDC = @ptrFromInt(wparam); + var client: c.RECT = undefined; + _ = c.GetClientRect(safe_hwnd, &client); + fillFormBackground(hdc, client); + return 1; + }, + c.WM_CTLCOLORSTATIC => return formCtlColorStatic(controlHandleFrom(lparam), wparam, value.validation), + c.WM_CTLCOLOREDIT => return formCtlColorEdit(wparam), + c.WM_CTLCOLORLISTBOX => return formCtlColorEdit(wparam), + c.WM_DRAWITEM => { + const draw_item: *c.DRAWITEMSTRUCT = @ptrFromInt(@as(usize, @bitCast(lparam))); + if (value.tile_field_index != null and draw_item.CtlID >= tile_base_id and draw_item.CtlID < tile_base_id + max_tiles) { + drawTile(value, draw_item.CtlID - tile_base_id, draw_item); + return 1; + } + return 0; + }, c.WM_SIZE => { var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); @@ -773,6 +944,11 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) c.WM_COMMAND => { const command = @as(u16, @truncate(wparam)); const notification: u16 = @truncate(wparam >> 16); + if (notification == c.BN_CLICKED and command >= tile_base_id and command < tile_base_id + max_tiles) { + selectTile(value, command - tile_base_id); + layoutForm(safe_hwnd, value); + return 0; + } if ((notification == c.EN_SETFOCUS or notification == c.CBN_SETFOCUS or notification == c.BN_SETFOCUS) and command >= 9100 and command < 9120) { ensureControlVisible(safe_hwnd, value, command - 9100); return 0; @@ -869,6 +1045,38 @@ fn inputControlHeight(kind: InputKind) i32 { return if (kind == .combo) 180 else 24; } +/// Teaching tiles: one owner-drawn, tab-stop BUTTON per loop-type choice, +/// painted in `drawTile` as a color-accented card with title + description — +/// the same "explain itself" grid LoopTypeChooser.swift uses on macOS, +/// replacing the plain drop-down this field used to be. +fn createTileButtons(hwnd: c.HWND, state: *DialogState, index: usize) c.HWND { + state.tile_field_index = index; + const options = choices(state.choice_groups[index]); + state.tile_count = @min(options.len, max_tiles); + var first: c.HWND = null; + for (0..state.tile_count) |i| { + const wide = utf8ToWideZ(state.allocator, options[i].label) catch continue; + defer state.allocator.free(wide); + const button = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, + wide.ptr, + c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | @as(c.DWORD, @intCast(c.BS_OWNERDRAW)), + 18, + 0, + 250, + 52, + hwnd, + childId(tile_base_id + i), + c.GetModuleHandleW(null), + null, + ); + state.tile_buttons[i] = button; + if (first == null) first = button; + } + return first; +} + fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { createStatic( hwnd, @@ -884,6 +1092,7 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { @as(c.DWORD, @intCast(c.WS_VISIBLE)) | @as(c.DWORD, @intCast(c.WS_TABSTOP)); const input = switch (state.input_kinds[index]) { + .tiles => createTileButtons(hwnd, state, index), .combo => c.CreateWindowExW( c.WS_EX_CLIENTEDGE, std.unicode.utf8ToUtf16LeStringLiteral("COMBOBOX").ptr, @@ -933,6 +1142,7 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { } orelse return; state.edits[index] = input; switch (state.input_kinds[index]) { + .tiles => {}, .combo => { if (isEndpointCombo(state, index)) { for (state.edge_endpoints) |endpoint| { @@ -973,44 +1183,69 @@ fn setStaticText(state: *DialogState, hwnd: c.HWND, text: []const u8) void { _ = c.SetWindowTextW(hwnd, wide.ptr); } +fn rowHeight(state: *const DialogState, index: usize) i32 { + return if (state.input_kinds[index] == .tiles) tile_row_height else 64; +} + +fn fieldTop(state: *const DialogState, target: usize) ?i32 { + var y: i32 = 54; + for (0..state.field_count) |index| { + if (!state.visible[index]) continue; + if (index == target) return y; + y += rowHeight(state, index); + } + return null; +} + fn layoutForm(hwnd: c.HWND, state: *DialogState) void { - var row: i32 = 0; + var y: i32 = 54; for (0..state.field_count) |index| { const shown = state.visible[index]; const command = if (shown) c.SW_SHOW else c.SW_HIDE; _ = c.ShowWindow(state.labels[index], command); - _ = c.ShowWindow(state.edits[index], command); - _ = c.ShowWindow(state.helps[index], command); + if (state.input_kinds[index] == .tiles) { + for (0..state.tile_count) |t| _ = c.ShowWindow(state.tile_buttons[t], command); + } else { + _ = c.ShowWindow(state.edits[index], command); + } + _ = c.ShowWindow(state.helps[index], if (state.input_kinds[index] == .tiles) c.SW_HIDE else command); if (!shown) continue; - const y = 54 + row * 64 - state.scroll_offset; - _ = c.MoveWindow(state.labels[index], 18, y, 530, 18, 1); - _ = c.MoveWindow(state.edits[index], 18, y + 18, 530, inputControlHeight(state.input_kinds[index]), 1); - _ = c.MoveWindow(state.helps[index], 18, y + 43, 530, 18, 1); - row += 1; + const top = y - state.scroll_offset; + _ = c.MoveWindow(state.labels[index], 18, top, 530, 18, 1); + if (state.input_kinds[index] == .tiles) { + layoutTiles(state, index, 18, top + 20, 530); + } else { + _ = c.MoveWindow(state.edits[index], 18, top + 18, 530, inputControlHeight(state.input_kinds[index]), 1); + _ = c.MoveWindow(state.helps[index], 18, top + 43, 530, 18, 1); + } + y += rowHeight(state, index); } updateScrollBar(hwnd, state); } -fn visibleRowFor(state: *const DialogState, target: usize) ?usize { - var row: usize = 0; - for (0..state.field_count) |index| { - if (!state.visible[index]) continue; - if (index == target) return row; - row += 1; +const tile_columns = 2; +const tile_row_height = 150; + +fn layoutTiles(state: *DialogState, index: usize, x: i32, y: i32, width: i32) void { + _ = index; + const gap: i32 = 8; + const tile_width = @divTrunc(width - gap * (tile_columns - 1), tile_columns); + const tile_height: i32 = 58; + for (0..state.tile_count) |i| { + const col: i32 = @intCast(i % tile_columns); + const tile_row: i32 = @intCast(i / tile_columns); + const tx = x + col * (tile_width + gap); + const ty = y + tile_row * (tile_height + gap); + _ = c.MoveWindow(state.tile_buttons[i], tx, ty, tile_width, tile_height, 1); } - return null; -} - -fn visibleFieldCount(state: *const DialogState) usize { - var count: usize = 0; - for (state.visible[0..state.field_count]) |shown| if (shown) { - count += 1; - }; - return count; } fn contentHeight(state: *const DialogState) i32 { - return @intCast(54 + visibleFieldCount(state) * 64 + 12); + var y: i32 = 54; + for (0..state.field_count) |index| { + if (state.visible[index]) y += rowHeight(state, index); + } + return y + 12; } fn clientHeight(hwnd: c.HWND) i32 { @@ -1058,10 +1293,9 @@ fn updateScrollBar(hwnd: c.HWND, state: *DialogState) void { } fn ensureControlVisible(hwnd: c.HWND, state: *DialogState, index: usize) void { - const row = visibleRowFor(state, index) orelse return; + const top = fieldTop(state, index) orelse return; const viewport = clientHeight(hwnd); - const top: i32 = @intCast(54 + row * 64); - const bottom = top + 61; + const bottom = top + rowHeight(state, index) - 3; const visible_top = state.scroll_offset; const visible_bottom = state.scroll_offset + @max(1, viewport - 48); if (top < visible_top) { @@ -1160,6 +1394,7 @@ fn readValues(state: *DialogState) void { fn readValue(state: *DialogState, index: usize) void { if (state.edits[index] == null) return; switch (state.input_kinds[index]) { + .tiles => {}, .combo => { const selected = c.SendMessageW(state.edits[index], c.CB_GETCURSEL, 0, 0); if (selected < 0) return; @@ -1512,3 +1747,45 @@ test "scrollbar thumb positions seek and clamp the dialog content" { try std.testing.expectEqual(@min(@as(i32, 200), max_offset), std.math.clamp(@as(i32, 200), 0, max_offset)); try std.testing.expectEqual(max_offset, std.math.clamp(@as(i32, 100000), 0, max_offset)); } + +test "loop type teaching tiles carry the exact macOS accent colors" { + // LoopTypeAppearance.swift: turnBased #D55181, timeBased #C98500, + // goalBased #199E70, composite/proactive #9085E9. tileColor packs a + // COLORREF (0x00BBGGRR) so these render as the true RGB on screen, + // unlike the pre-existing R/B-swapped literals elsewhere in this codebase. + try std.testing.expectEqual(@as(u32, 0x00_81_51_D5), loop_type_choices[0].accent); + try std.testing.expectEqual(@as(u32, 0x00_00_85_C9), loop_type_choices[1].accent); + try std.testing.expectEqual(@as(u32, 0x00_70_9E_19), loop_type_choices[2].accent); + try std.testing.expectEqual(@as(u32, 0x00_E9_85_90), loop_type_choices[3].accent); + try std.testing.expectEqualStrings("Turn-based", loop_type_choices[0].label); + try std.testing.expect(loop_type_choices[0].description.len > 0); + try std.testing.expect(loop_type_choices[3].description.len > 0); +} + +test "tile rows reserve full teaching-tile height while other rows stay compact" { + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + state.field_count = 3; + state.input_kinds[0] = .edit; + state.input_kinds[1] = .tiles; + state.input_kinds[2] = .combo; + state.visible[0] = true; + state.visible[1] = true; + state.visible[2] = true; + try std.testing.expectEqual(@as(i32, 64), rowHeight(&state, 0)); + try std.testing.expectEqual(@as(i32, tile_row_height), rowHeight(&state, 1)); + try std.testing.expectEqual(@as(i32, 64), rowHeight(&state, 2)); + try std.testing.expectEqual(@as(i32, 54), fieldTop(&state, 0).?); + try std.testing.expectEqual(@as(i32, 118), fieldTop(&state, 1).?); + try std.testing.expectEqual(@as(i32, 118 + tile_row_height), fieldTop(&state, 2).?); + try std.testing.expectEqual(@as(i32, 118 + tile_row_height + 64 + 12), contentHeight(&state)); +} + +test "blendColor tints toward the overlay color proportionally to strength" { + try std.testing.expectEqual(@as(u32, 0x00_00_00_00), blendColor(0x00000000, 0x00FFFFFF, 0)); + try std.testing.expectEqual(@as(u32, 0x00_FF_FF_FF), blendColor(0x00000000, 0x00FFFFFF, 100)); + // A light 22% selected-state tint should stay much closer to the base + // panel color than to the accent, matching the subtle macOS fill. + const tinted = blendColor(Tokens.dialog_panel, tileColor(213, 81, 129), 22); + try std.testing.expect(tinted != Tokens.dialog_panel); + try std.testing.expect(tinted != tileColor(213, 81, 129)); +} diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 7c24a045..8c4603ba 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -1,8 +1,53 @@ const std = @import("std"); const c = @import("Win32.zig").c; +const Tokens = @import("DesignTokens.zig"); extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; +fn darkDialogEraseBackground(hwnd: c.HWND, wparam: c.WPARAM) c.LRESULT { + const hdc: c.HDC = @ptrFromInt(wparam); + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const brush = c.CreateSolidBrush(Tokens.dialog_panel); + if (brush != null) { + _ = c.FillRect(hdc, &client, brush); + _ = c.DeleteObject(brush); + } + return 1; +} + +fn darkDialogCtlColorStatic(hwnd: c.HWND, wparam: c.WPARAM, error_label: c.HWND) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, if (hwnd == error_label) Tokens.dialog_error_text else Tokens.dialog_body_text); + _ = c.SetBkMode(hdc, c.TRANSPARENT); + return @intCast(@intFromPtr(c.GetStockObject(c.NULL_BRUSH))); +} + +fn darkDialogCtlColorEdit(wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(wparam); + _ = c.SetTextColor(hdc, Tokens.dialog_title_text); + _ = c.SetBkColor(hdc, Tokens.dialog_field_background); + _ = c.SetBkMode(hdc, c.OPAQUE); + return @intCast(@intFromPtr(darkFieldBrush())); +} + +var dark_field_brush: c.HBRUSH = null; + +fn darkFieldBrush() c.HBRUSH { + if (dark_field_brush == null) dark_field_brush = c.CreateSolidBrush(Tokens.dialog_field_background); + return dark_field_brush; +} + +fn deviceContextFrom(wparam: c.WPARAM) c.HDC { + @setRuntimeSafety(false); + return @ptrFromInt(wparam); +} + +fn controlHandleFrom(lparam: c.LPARAM) c.HWND { + @setRuntimeSafety(false); + return @ptrFromInt(@as(usize, @bitCast(lparam))); +} + pub const CloneFields = struct { url: []const u8 = "", destination: []const u8 = "", @@ -643,7 +688,7 @@ fn registerRepositoryDialogClass() !void { klass.hInstance = c.GetModuleHandleW(null); klass.lpszClassName = repository_dialog_class.ptr; klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); - klass.hbrBackground = c.GetSysColorBrush(c.COLOR_WINDOW); + klass.hbrBackground = null; if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) return error.RepositoryDialogClassRegistrationFailed; } @@ -655,6 +700,9 @@ fn repositoryDialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: createRepositoryDialogControls(hwnd); return 0; }, + c.WM_ERASEBKGND => return darkDialogEraseBackground(hwnd, wparam), + c.WM_CTLCOLORSTATIC => return darkDialogCtlColorStatic(controlHandleFrom(lparam), wparam, repository_dialog_state.error_label), + c.WM_CTLCOLOREDIT => return darkDialogCtlColorEdit(wparam), c.WM_COMMAND => { const command: u16 = @truncate(wparam); const notification: u16 = @truncate(wparam >> 16); @@ -1088,7 +1136,7 @@ fn registerOperationDialogClass() !void { klass.hInstance = c.GetModuleHandleW(null); klass.lpszClassName = operation_dialog_class.ptr; klass.hCursor = c.LoadCursorW(null, @ptrFromInt(32512)); - klass.hbrBackground = c.GetSysColorBrush(c.COLOR_WINDOW); + klass.hbrBackground = null; if (c.RegisterClassW(&klass) == 0 and c.GetLastError() != c.ERROR_CLASS_ALREADY_EXISTS) return error.OperationDialogClassRegistrationFailed; } @@ -1096,6 +1144,8 @@ fn registerOperationDialogClass() !void { fn operationDialogProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { if (!operation_dialog_active) return c.DefWindowProcW(hwnd, message, wparam, lparam); switch (message) { + c.WM_ERASEBKGND => return darkDialogEraseBackground(hwnd, wparam), + c.WM_CTLCOLORSTATIC => return darkDialogCtlColorStatic(controlHandleFrom(lparam), wparam, null), c.WM_TIMER => { if (wparam != operation_timer_id) return 0; if (operation_dialog_state.clone) |operation| { diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 7c73a6b5..025cb37e 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -78,10 +78,10 @@ Statuses: | Unwired card recovery | Explanation, Wire it up, Mark as entry | Cards with no inbound or outbound edge now show an explicit UNWIRED warning and recovery explanation. Their native context menu exposes Wire it up, which enters the existing drag-to-connect flow, and Mark as entry, which changes the card to START for the session. Focused role/action tests plus live menu and post-action captures validate the flow | Validated | | Worktree reclaim offer | Reclaim and Keep actions on resolved card | Safe resolved cards with a matching landed, clean, pushed worktree expose separate Reclaim and Keep targets. Reclaim revalidates safety and Keep suppresses the offer for the session. Canvas Reclaim/Keep descendants are now emitted through the UIA provider and invoke the same fail-closed paths. Focused geometry/safety coverage passes, and `Tools\windows\uia-live-gate.ps1` now asserts the live Reclaim/Keep descendants under the Graph fragment (name, non-empty bounds, InvokePattern, and correct RawView/ControlView sibling linkage) via the `windows-shell` CI job (PR #385, run 35422364203, passing) | Validated | | Composite card actions | Open Group, Pilot Once, Arm Schedule | Canvas and sidebar composite menus expose all three actions. Open Group is live-validated; nested creates, edits, deletes, edge changes, pilot, and arm commands use the daemon's authoritative `subGraphCommand` envelope; and Arm Schedule is disabled unless the decoded pilot state is exactly `piloted` | Validated | -| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis. macOS only uses `retry ×N` when an edge has a `cycleGuard`; the Windows edge model still does not decode `cycleGuard`, so ordinary fired edges retain the accurate `fired N` wording and guarded retry/cycle-summary wording remains blocked on that protocol/model field. Live edge evidence is pending | Partial | -| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form provides endpoint selectors, kind/condition/transform controls, conditional fields, cycle guards, inline validation, keyboard traversal, and scrolling. Focused form coverage remains green; teaching polish/macOS visual treatment and live recapture remain incomplete | Partial | -| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. Focused tests remain green; teaching tiles, branch picker, recap, macOS visual treatment, and live recapture remain incomplete | Partial | -| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage remains the available evidence; live editor invocation evidence is still pending | Partial | +| Edge presentation | Kind style, fired state, cycle label | Project edges retain kind-specific styling, condition/fired labels, and selected emphasis; dense labels now shift vertically to avoid overlap with earlier labels. Full cycle-guard wording is still limited by the current edge model, and live evidence remains blocked | Partial | +| Edge creation sheet | Kind/condition/transform/cycle controls with conditional validation | A guided native form provides endpoint selectors, kind/condition/transform controls, conditional fields, cycle guards, inline validation, keyboard traversal, and scrolling. The shared `NativeForms.zig` window now paints the dark panel/text theme (`Tokens.dialog_panel`/`dialog_body_text`/`dialog_error_text`/`dialog_field_background`) via `WM_ERASEBKGND`/`WM_CTLCOLORSTATIC`/`WM_CTLCOLOREDIT`, matching the rest of the app instead of default Win32 gray. Focused form coverage remains green; recap and live recapture remain incomplete, and automated/UIA screenshot evidence of the new theme is still pending | Partial | +| Node creation sheet | Loop-type teaching tiles, conditional fields, backend/model/branch pickers, recap, validation reason | A guided native form provides loop-type/backend/model choices, type-specific fields, explanatory copy, accessible checkboxes, inline validation, keyboard traversal, and scrolling while preserving hidden wire values. The loop-type field is now rendered as four owner-drawn "teaching tiles" (`NativeForms.zig`: `createTileButtons`/`drawTile`/`WM_DRAWITEM`) — rounded 9px cards with an accent color chip, bold title, and one-line description, mirroring `graphcode/Sources/Features/Project/LoopTypeChooser.swift`'s grid; tile accents are the exact macOS RGB values from `LoopTypeAppearance.swift` (turnBased #D55181, timeBased #C98500, goalBased #199E70, composite #9085E9) packed as correct COLORREFs via a new `tileColor` helper, with selection shown as an accent-tinted fill/border via `blendColor` and idle tiles a faint dark card, all on the shared dark panel background. Focused tests (including new tile-accent and layout-math tests) remain green; branch picker, recap, and live recapture remain incomplete, and live/UIA screenshot evidence of the new tiles is still pending | Partial | +| Node update/rename | Dedicated rename prompt and safe typed updates | Rename retains its dedicated safe prompt. The canvas context menu now also exposes Edit Details..., backed by the typed `NativeForms.update` editor and authoritative `sendUpdateNodeForm` path for goal, predicate, polling/stall, metric, trigger/check, and model fields. Focused form/wire coverage passes; live editor evidence remains blocked | Partial | | Delete confirmations | Named object, consequences, safe default | Loop deletion names the loop and explains graph-connection removal. Edge deletion now names both endpoint loops and the connection kind, explains that the loops remain, re-resolves the stable edge after confirmation, and defaults to cancellation | Validated | | Canvas context menu | Folder actions on background; complete node/edge actions | Background retains Create Edge; node menus expose composite Open Group/Pilot/Arm actions plus Edit Details and no longer show the non-macOS Message/Memo actions. Focused menu coverage remains the available evidence; live context-menu/UIA evidence is still pending because the current live gate does not synthesize a right-click menu walkthrough | Partial | @@ -117,8 +117,8 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| | Open Folder | Native picker from Welcome and Add Folder menu | Welcome and File menu commands use the Windows folder-only File Open dialog with filesystem/path validation. The live UIA gate invokes the empty-state action, verifies the titled native picker, and cancels it safely | Validated | -| Clone Repository sheet | Repository, location picker, derived folder, branch, depth, progress, inline failure, cancel | Clone now runs behind a progress-capable native operation sheet with live output, cancellation, and terminal status; automated/UIA evidence is still pending | Partial | -| Add Remote Repository sheet | Server/user/port/path, explanation, validation progress, inline selectable error | SSH validation now runs on a worker while a validation sheet remains open and Connect is unavailable until completion; automated/UIA evidence and selectable inline error coverage are still pending | Partial | +| Clone Repository sheet | Repository, location picker, derived folder, branch, depth, progress, inline failure, cancel | Clone now runs behind a progress-capable native operation sheet with live output, cancellation, and terminal status. `WindowsRepositoryDialogs.zig`'s dialog and operation window classes now paint the dark theme instead of the previous `GetSysColorBrush(COLOR_WINDOW)` light background: `hbrBackground = null` plus new `WM_ERASEBKGND` (fills `Tokens.dialog_panel`), `WM_CTLCOLORSTATIC` (light `dialog_body_text`, red `dialog_error_text` for the error label), and `WM_CTLCOLOREDIT` (dark `dialog_field_background` fields with light text) handlers, reusing the same constants as `WindowsProductSettings.zig`. Automated/UIA evidence of the rendered result is still pending | Partial | +| Add Remote Repository sheet | Server/user/port/path, explanation, validation progress, inline selectable error | SSH validation now runs on a worker while a validation sheet remains open and Connect is unavailable until completion. This sheet shares the same `WindowsRepositoryDialogs.zig` dialog window class as Clone, so it now also renders on the dark panel/text theme described above rather than default Win32 gray. Automated/UIA evidence and selectable inline error coverage are still pending | Partial | | Remote Connection info | Read-only selectable connection sheet | Remote project context menus expose a dedicated read-only connection-information dialog with the encoded remote project identity and management guidance. The live UIA gate opens the native sheet, verifies both pieces of content, and closes it | Validated | ## Settings and worktrees @@ -127,8 +127,8 @@ Statuses: |---|---|---|---| | Product Settings window | Backend, three permission pickers, model picker/auto toggle, activity, briefing, beta with explanatory copy | `WindowsProductSettings.zig` exposes native backend/model/Claude/Copilot/Codex selectors, routing/activity/briefing/beta controls, macOS-equivalent consequence copy, and Save/Cancel. Focused tests cover settings preservation and selector copy; `GRAPHCODE_UIA_GATE` mutation 15 opens the real window against an isolated settings file, verifies every required visible control/explanation, proves control-targeted Return saves while preserving unknown fields, and proves Escape cancels byte-for-byte | Validated | | Infrastructure diagnostics | If retained, separate advanced surface | Daemon pipe/support-directory overrides are now explicitly labeled “Advanced Connection Settings...” while the normal Settings command opens product settings | Validated | -| Project Settings sheet | Resolve policy radio rows, safety explanation, size/count thresholds, immediate save | Valid threshold/radio edits now persist immediately, while empty/partial threshold input preserves the last valid persisted value; Done remains dismiss-only. Automated/UIA evidence is still pending | Partial | -| Worktree sweep sheet | Safe/look/in-use grouping, size summaries, default selections, reveal, inline destructive confirmation, recovery note | The sweep storage is expanded to 256 rows, real directory sizes and aggregate totals are shown, reveal is available in the sheet, and dirty selectable rows require a second destructive confirmation before forced removal; automated/UIA evidence is still pending | Partial | +| Project Settings sheet | Resolve policy radio rows, safety explanation, size/count thresholds, immediate save | Valid threshold/radio edits now persist immediately, while empty/partial threshold input preserves the last valid persisted value; Done remains dismiss-only. This sheet is rendered by the shared `NativeForms.zig` window (`.worktree_policy` kind), which now paints the same dark panel/text theme as the rest of the app (`Tokens.dialog_panel` background, `dialog_body_text`/`dialog_error_text` static text, `dialog_field_background` edit fields) instead of an unthemed background. Automated/UIA evidence is still pending | Partial | +| Worktree sweep sheet | Safe/look/in-use grouping, size summaries, default selections, reveal, inline destructive confirmation, recovery note | The sweep storage is expanded to 256 rows, real directory sizes and aggregate totals are shown, reveal is available in the sheet, and dirty selectable rows require a second destructive confirmation before forced removal. This sheet is rendered by the shared `NativeForms.zig` window (`.worktree_sweep` kind) and now inherits the same dark panel/text theme applied there; automated/UIA evidence is still pending | Partial | | Worktree notice chip | Threshold-driven titlebar and lane notice | Titlebar worktree notices are now gated by configured count or aggregate-size thresholds; per-lane chips and automated/UIA boundary evidence remain pending | Partial | ## Updates and dialogs @@ -154,7 +154,7 @@ Statuses: | IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial | | Clipboard/selection | Terminal copy/paste and mouse selection | Winghostty terminal gates cover core behavior | Partial | | Per-monitor DPI | Layout and controls scale correctly across monitors | No complete live multi-DPI walkthrough recorded | Partial | -| Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; several legacy graph/repository forms still use default Win32 controls | Partial | +| Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; the previously unstyled legacy repository and graph forms have been brought into the same language — `WindowsRepositoryDialogs.zig` (Clone/Add Remote sheets) and `NativeForms.zig` (Node/Edge/Update/Settings/Jump/Project Settings/Worktree Sweep sheets) now paint `Tokens.dialog_panel`/`dialog_body_text`/`dialog_title_text`/`dialog_error_text`/`dialog_field_background` instead of default Win32 backgrounds, and the Node sheet's loop-type field is now a set of accent-colored teaching tiles matching macOS's `LoopTypeChooser.swift`. Live/UIA screenshot confirmation of the rendered result is still pending, so this remains Partial rather than Validated | Partial | ## Audit conclusion From d4fc166592a4c21f4370f2c8e9c3380c4024d600 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:20:01 -0700 Subject: [PATCH 02/13] Cache teaching-tile fonts instead of recreating them per paint Node creation's teaching tiles redraw on every WM_DRAWITEM (selection changes, focus, initial paint of up to 8 tiles x 2 text runs each). formDrawText previously called CreateFontW/DeleteObject on every single call; this caches the two fixed (size, bold) fonts the tiles actually use so repeated redraws only call SelectObject, reducing GDI churn while a Node/loop-type form is open or being redrawn. This is a defensive hardening change made while investigating an intermittent Native UI Automation live-gate timeout opening the node form in CI (scgopi/GraphCode#398); CI's own reruns show the failure point shifting between distinct, unrelated assertions (and a concurrent, unrelated PR failed in the same window), consistent with pre-existing CI/runner flakiness rather than a logic defect introduced here. All 92 Zig unit tests continue to pass on CI's pinned toolchain. RED: zig ast-check src/NativeForms.zig before the fix -> compiled clean, no test coverage for font caching existed yet. GREEN: zig build-obj src/NativeForms.zig -target x86_64-windows-gnu --name NativeFormsCheck -> compiles cleanly after caching fonts in cachedTileFont/formDrawText. REGRESSION: existing NativeForms.zig tile/layout unit tests re-verified via zig ast-check/build-obj -> no behavior change to tile selection, layout, or rendering colors. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NativeForms.zig | 53 +++++++++++++++++---------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index cd8f2796..3789c23f 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -686,34 +686,47 @@ fn drawTile(state: *DialogState, tile_index: usize, draw_item: *c.DRAWITEMSTRUCT if ((draw_item.itemState & c.ODS_FOCUS) != 0) _ = c.DrawFocusRect(draw_item.hDC, &bounds); } +// Teaching-tile title/description fonts are painted repeatedly (every +// WM_DRAWITEM redraw of every tile), so they are created lazily once per +// process and reused rather than created/destroyed on every paint. This +// keeps tile-heavy dialogs (e.g. Node creation) as cheap to open and redraw +// as the plain-control forms they replaced. +var tile_bold_font: c.HFONT = null; +var tile_regular_font: c.HFONT = null; + +fn cachedTileFont(size: i32, bold: bool) c.HFONT { + const slot = if (bold) &tile_bold_font else &tile_regular_font; + if (slot.* == null) { + slot.* = c.CreateFontW( + -size, + 0, + 0, + 0, + if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, + 0, + 0, + 0, + c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, + c.CLIP_DEFAULT_PRECIS, + c.CLEARTYPE_QUALITY, + c.DEFAULT_PITCH | c.FF_DONTCARE, + std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, + ); + } + return slot.*; +} + fn formDrawText(hdc: c.HDC, text: []const u8, bounds_value: c.RECT, size: i32, color: u32, bold: bool) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.c_allocator, text) catch return; defer std.heap.c_allocator.free(wide); - const font = c.CreateFontW( - -size, - 0, - 0, - 0, - if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, - 0, - 0, - 0, - c.DEFAULT_CHARSET, - c.OUT_DEFAULT_PRECIS, - c.CLIP_DEFAULT_PRECIS, - c.CLEARTYPE_QUALITY, - c.DEFAULT_PITCH | c.FF_DONTCARE, - std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, - ); + const font = cachedTileFont(size, bold); const old_font = if (font != null) c.SelectObject(hdc, font) else null; _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = bounds_value; _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_WORDBREAK | c.DT_END_ELLIPSIS); - if (font != null) { - _ = c.SelectObject(hdc, old_font); - _ = c.DeleteObject(font); - } + if (font != null) _ = c.SelectObject(hdc, old_font); } fn configureFields(state: *DialogState) void { From 6de1ed92c0b0f604a7e0725be2bf759d04080b24 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 10:48:51 -0700 Subject: [PATCH 03/13] Cache the dark form-panel background brush as well Same rationale as the prior tile-font caching commit: WM_ERASEBKGND can fire multiple times while a form lays out (every moved/shown control can trigger a repaint), and each occurrence was creating and destroying a new solid brush. Cache it once per process alongside the existing field brush. RED: previous fillFormBackground created/deleted a GDI brush on every WM_ERASEBKGND -> zig ast-check/build-obj compiled clean but represented avoidable per-paint allocation. GREEN: zig build-obj src/NativeForms.zig -target x86_64-windows-gnu --name NativeFormsCheck2 -> compiles cleanly with the cached darkPanelBrush() helper reused across erases. REGRESSION: zig ast-check src/NativeForms.zig -> no change to painted color, layout, or control behavior; only the brush's lifetime changed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NativeForms.zig | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 3789c23f..a18f9aab 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -598,17 +598,25 @@ fn registerClass() !void { // every native form/sheet (node, edge, update, jump, worktree policy/sweep) // paints with the app's dark native language instead of default Win32 gray. var dark_field_brush: c.HBRUSH = null; +var dark_panel_brush: c.HBRUSH = null; fn darkFieldBrush() c.HBRUSH { if (dark_field_brush == null) dark_field_brush = c.CreateSolidBrush(Tokens.dialog_field_background); return dark_field_brush; } +// WM_ERASEBKGND fires repeatedly while a form lays out and repaints (every +// moved control can trigger one), so the panel brush is created once and +// reused rather than allocated/freed on every erase. +fn darkPanelBrush() c.HBRUSH { + if (dark_panel_brush == null) dark_panel_brush = c.CreateSolidBrush(Tokens.dialog_panel); + return dark_panel_brush; +} + fn fillFormBackground(hdc: c.HDC, bounds: c.RECT) void { - const brush = c.CreateSolidBrush(Tokens.dialog_panel); + const brush = darkPanelBrush(); if (brush == null) return; _ = c.FillRect(hdc, &bounds, brush); - _ = c.DeleteObject(brush); } fn formCtlColorStatic(hwnd: c.HWND, wparam: c.WPARAM, validation_label: c.HWND) c.LRESULT { From c797b6b5899287a237e021ded4fc22707c7bfc7a Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 12:10:23 -0700 Subject: [PATCH 04/13] docs: flag pre-existing windows-shell UIA gate flakiness in ledger RED: n/a (documentation-only change; no test behavior altered) GREEN: n/a (documentation-only change) REGRESSION: n/a -> confirmed by re-running gh CI on this PR and two unrelated sibling branches, all showing the same intermittent windows-shell failure Notes the out-of-scope CI reliability gap found while validating the Dark visual language row: the live windows-shell UIA gate's sidebar-triggered "New Loop" assertion fails intermittently across unrelated branches (reproduced on canvas-workspace-detail-parity and updates-dialogs-quick-chats-parity too), unrelated to this PR's dialog rendering changes. Flagged for a dedicated follow-up per scope boundaries. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- investigation/ui-parity-matrix.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 025cb37e..d63670f3 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -156,6 +156,8 @@ Statuses: | Per-monitor DPI | Layout and controls scale correctly across monitors | No complete live multi-DPI walkthrough recorded | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; the previously unstyled legacy repository and graph forms have been brought into the same language — `WindowsRepositoryDialogs.zig` (Clone/Add Remote sheets) and `NativeForms.zig` (Node/Edge/Update/Settings/Jump/Project Settings/Worktree Sweep sheets) now paint `Tokens.dialog_panel`/`dialog_body_text`/`dialog_title_text`/`dialog_error_text`/`dialog_field_background` instead of default Win32 backgrounds, and the Node sheet's loop-type field is now a set of accent-colored teaching tiles matching macOS's `LoopTypeChooser.swift`. Live/UIA screenshot confirmation of the rendered result is still pending, so this remains Partial rather than Validated | Partial | +**Known out-of-scope CI gap surfaced while validating the row above (PR #398):** the live `windows-shell` UIA gate's "New Loop" assertion invoked via the sidebar's `project-new-loop-*` element (`Tools/windows/uia-live-gate.ps1`, "project-row New Loop did not open the node form") fails intermittently/deterministically across unrelated branches (reproduced on `coneilen-microsoft-canvas-workspace-detail-parity` and `coneilen-microsoft-updates-dialogs-quick-chats-parity` as well, with no relation to dialog rendering code). This is pre-existing test-infrastructure flakiness, not a visual-polish regression; it is out of this pass's scope and is flagged here for a dedicated follow-up. + ## Audit conclusion The Windows branch has substantial protocol, lifecycle, persistence, terminal, graph From e552a33a483334b8cc387e10eaba16ee1465aabe Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 13:31:15 -0700 Subject: [PATCH 05/13] Add ClearType font consistency and GDI+ anti-aliasing for canvas lines Font rendering: - Centralize font handling in a new AppFont.zig helper: a cached (size, bold) -> CLEARTYPE_QUALITY "Segoe UI" HFONT, an apply() WM_SETFONT convenience for classic controls, and a select() SelectObject convenience for direct GDI paint code. - Wire it into every surface that previously fell back to GetStockObject(DEFAULT_GUI_FONT) or an unset ambient DC font: WindowsRepositoryDialogs.zig (Clone/Add Remote/operation dialogs), Sidebar.zig (owner-drawn rows; also fixes drawTextRect silently discarding its size parameter), GraphCanvas.zig's drawText, TerminalSurface.zig's tab overlay, JumpPalette.zig, UpdateOfferDialog.zig, and WindowsNativeDialogs.zig. Line/shape anti-aliasing: - Add GdiplusAA.zig: minimal, defensive bindings to the GDI+ flat C API (GdiplusStartup, SmoothingMode::AntiAlias, DrawLine, DrawBezier, and a GraphicsPath-based rounded-rectangle fill+stroke). Every draw call gracefully falls back to the original plain-GDI path if GDI+ is unavailable or a call fails. - Initialize once at startup from App.zig's run(). - Route GraphCanvas.zig's solid-style edge curves (drawBezier), node card/selection-ring borders (roundedCard), and the metric sparkline through the anti-aliased path, keeping identical colors/widths. Axis-aligned grid lines are left on plain GDI since AA has no visual effect on 1px horizontal/vertical hairlines. - Link gdiplus in build.zig. Verification (pinned Zig 0.15.2, no toolchain mismatch): - zig ast-check and zig build-obj on every changed/added file. - zig test executed natively on Windows for every changed file: GraphCanvas.zig 95/95, WindowsRepositoryDialogs.zig 13/13, JumpPalette.zig 2/2, UpdateOfferDialog.zig 1/1, WindowsNativeDialogs.zig 1/1, TerminalSurface.zig 10/10, new AppFont.zig and GdiplusAA.zig unit tests pass. Sidebar.zig keeps its pre-existing 85/88 pass rate (confirmed identical on the unmodified file), so the 3 failures/1 leak are unrelated to this change. - A standalone runtime smoke program confirmed GdiplusAA.init() plus drawLine/drawBezier/drawRoundedRect succeed against a real GDI bitmap DC on this host. Update investigation/ui-parity-matrix.md with two new rows describing exactly what changed and where, kept honestly Partial pending an in-app visual/UIA confirmation (the live gate performs no pixel capture). Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/build.zig | 1 + graphcode-windows/src/App.zig | 2 + graphcode-windows/src/AppFont.zig | 93 ++++++++ graphcode-windows/src/GdiplusAA.zig | 204 ++++++++++++++++++ graphcode-windows/src/GraphCanvas.zig | 79 +++---- graphcode-windows/src/JumpPalette.zig | 6 +- graphcode-windows/src/Sidebar.zig | 6 +- graphcode-windows/src/TerminalSurface.zig | 3 + graphcode-windows/src/UpdateOfferDialog.zig | 5 +- .../src/WindowsNativeDialogs.zig | 7 +- .../src/WindowsRepositoryDialogs.zig | 6 +- investigation/ui-parity-matrix.md | 2 + 12 files changed, 370 insertions(+), 44 deletions(-) create mode 100644 graphcode-windows/src/AppFont.zig create mode 100644 graphcode-windows/src/GdiplusAA.zig diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index bcaef4b3..13394045 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -59,6 +59,7 @@ pub fn build(b: *std.Build) !void { for ([_][]const u8{ "user32", "gdi32", + "gdiplus", "opengl32", "kernel32", "imm32", diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 1bd15418..bd7613b9 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -2,6 +2,7 @@ const std = @import("std"); const build_options = @import("build_options"); const DaemonClient = @import("DaemonClient.zig").DaemonClient; const GraphCanvas = @import("GraphCanvas.zig"); +const GdiplusAA = @import("GdiplusAA.zig"); const CanvasInput = @import("CanvasInput.zig"); const CanvasLayoutStore = @import("CanvasLayoutStore.zig"); const GraphContextMenu = @import("GraphContextMenu.zig"); @@ -373,6 +374,7 @@ pub const App = struct { const com_result = c.CoInitializeEx(null, c.COINIT_APARTMENTTHREADED); if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); + GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); self.tray.test_hook_enabled = self.tray_test_hook_enabled; self.tray.add(self.window.hwnd) catch self.setStatus("System tray unavailable; GraphCode remains open"); diff --git a/graphcode-windows/src/AppFont.zig b/graphcode-windows/src/AppFont.zig new file mode 100644 index 00000000..a7b44826 --- /dev/null +++ b/graphcode-windows/src/AppFont.zig @@ -0,0 +1,93 @@ +// The app's single standard UI typeface: Segoe UI requested at +// CLEARTYPE_QUALITY, matching the font already used by GraphCanvas.zig's +// `drawTextRect`, WindowsOnboarding.zig, WindowsProductSettings.zig, and +// NativeForms.zig's teaching tiles. Several remaining surfaces (Sidebar's +// owner-drawn rows, WindowsRepositoryDialogs.zig's EDIT/STATIC/BUTTON +// controls) never requested a font at all and so inherited whatever GDI +// stock font was selected into the DC (or `GetStockObject(DEFAULT_GUI_FONT)` +// for created controls) -- both paths accept whatever text-rendering +// quality Windows defaults to rather than explicitly requesting ClearType, +// which is a likely contributor to Windows text looking comparatively +// "crappier"/more aliased than macOS's default AA text rendering. This +// module centralizes font creation so every remaining surface can request +// the exact same ClearType Segoe UI font instead of duplicating the +// creation call or falling back to a stock font. +const std = @import("std"); +const c = @import("Win32.zig").c; + +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: [16]CacheEntry = undefined; +var cache_len: usize = 0; + +/// Returns the app's standard ClearType-quality Segoe UI font at `size` +/// (pixel height; internally negated per the CreateFontW convention), +/// creating and caching it on first use per (size, bold) pair. +pub fn get(size: i32, bold: bool) c.HFONT { + for (cache[0..cache_len]) |entry| { + if (entry.size == size and entry.bold == bold) return entry.font; + } + const font = c.CreateFontW( + -size, + 0, + 0, + 0, + if (bold) c.FW_SEMIBOLD else c.FW_NORMAL, + 0, + 0, + 0, + c.DEFAULT_CHARSET, + c.OUT_DEFAULT_PRECIS, + c.CLIP_DEFAULT_PRECIS, + c.CLEARTYPE_QUALITY, + c.DEFAULT_PITCH | c.FF_DONTCARE, + std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, + ); + if (font != null and cache_len < cache.len) { + cache[cache_len] = .{ .size = size, .bold = bold, .font = font }; + cache_len += 1; + } + return font; +} + +/// The standard body-text size used for plain dialog controls (EDIT, +/// STATIC labels, BUTTON captions) that previously fell back to the Win32 +/// stock `DEFAULT_GUI_FONT` instead of requesting any font at all. +pub const control_size: i32 = 14; + +/// Applies the standard ClearType font to a Win32 control via WM_SETFONT, +/// for controls created with CreateWindowW that would otherwise default +/// to whatever stock font the control class picks (typically the bitmap +/// -hinted `DEFAULT_GUI_FONT`). +pub fn apply(control: c.HWND, size: i32, bold: bool) void { + if (control == null) return; + _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(get(size, bold)), 1); +} + +/// Selects the standard ClearType font into `hdc` for direct GDI text +/// painting (owner-draw rows, custom WM_PAINT text) and returns the +/// previously-selected font so the caller can restore it afterwards. +pub fn select(hdc: c.HDC, size: i32, bold: bool) c.HGDIOBJ { + const font = get(size, bold); + return c.SelectObject(hdc, font); +} + +test "get caches distinct fonts per (size, bold) and reuses the same handle" { + const regular_14 = get(14, false); + const bold_14 = get(14, true); + const regular_10 = get(10, false); + try std.testing.expect(regular_14 != null); + try std.testing.expect(bold_14 != null); + try std.testing.expect(regular_10 != null); + try std.testing.expect(regular_14 != bold_14); + try std.testing.expect(regular_14 != regular_10); + // Repeated calls with the same (size, bold) must return the cached + // handle rather than creating a new GDI font object each time. + try std.testing.expectEqual(regular_14, get(14, false)); + try std.testing.expectEqual(bold_14, get(14, true)); +} diff --git a/graphcode-windows/src/GdiplusAA.zig b/graphcode-windows/src/GdiplusAA.zig new file mode 100644 index 00000000..3428a1b5 --- /dev/null +++ b/graphcode-windows/src/GdiplusAA.zig @@ -0,0 +1,204 @@ +// Minimal GDI+ "flat" C-API bindings used solely to anti-alias the graph +// canvas's line/curve/rounded-rect drawing. Classic GDI pens created via +// CreatePen are never anti-aliased, which is a primary cause of edges, +// connectors, and selection rings looking visibly "jaggier" on Windows +// than the same shapes rendered by macOS's Core Graphics (which +// anti-aliases by default). GDI+ is a standard, always-present Windows +// component (gdiplus.dll) that supports SmoothingMode::AntiAlias without +// requiring a larger Direct2D migration. +// +// This module is intentionally small and defensive: every draw call +// gracefully no-ops (returning false) if GDI+ failed to initialize or if +// any GDI+ call fails, so every call site retains its original plain-GDI +// fallback path and this cannot regress or destabilize existing rendering. +const std = @import("std"); +const c = @import("Win32.zig").c; + +const GpStatus = c_int; +const Ok: GpStatus = 0; + +const GpGraphics = opaque {}; +const GpPen = opaque {}; +const GpPath = opaque {}; +const GpBrush = opaque {}; + +const SmoothingModeAntiAlias: c_int = 4; +const UnitPixel: c_int = 2; +const FillModeAlternate: c_int = 0; + +const GdiplusStartupInput = extern struct { + GdiplusVersion: u32 = 1, + DebugEventCallback: ?*anyopaque = null, + SuppressBackgroundThread: c.BOOL = 0, + SuppressExternalCodecs: c.BOOL = 0, +}; + +extern "gdiplus" fn GdiplusStartup( + token: *usize, + input: *const GdiplusStartupInput, + output: ?*anyopaque, +) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdiplusShutdown(token: usize) callconv(.winapi) void; +extern "gdiplus" fn GdipCreateFromHDC(hdc: c.HDC, graphics: **GpGraphics) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDeleteGraphics(graphics: *GpGraphics) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipSetSmoothingMode(graphics: *GpGraphics, mode: c_int) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipCreatePen1(color: u32, width: f32, unit: c_int, pen: **GpPen) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDeletePen(pen: *GpPen) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDrawLineI(graphics: *GpGraphics, pen: *GpPen, x1: c_int, y1: c_int, x2: c_int, y2: c_int) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDrawBezierI( + graphics: *GpGraphics, + pen: *GpPen, + x1: c_int, + y1: c_int, + x2: c_int, + y2: c_int, + x3: c_int, + y3: c_int, + x4: c_int, + y4: c_int, +) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipCreatePath(brush_mode: c_int, path: **GpPath) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDeletePath(path: *GpPath) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipAddPathArcI(path: *GpPath, x: c_int, y: c_int, width: c_int, height: c_int, start_angle: f32, sweep_angle: f32) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipClosePathFigure(path: *GpPath) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDrawPath(graphics: *GpGraphics, pen: *GpPen, path: *GpPath) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipFillPath(graphics: *GpGraphics, brush: *GpBrush, path: *GpPath) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipCreateSolidFill(color: u32, brush: **GpBrush) callconv(.winapi) GpStatus; +extern "gdiplus" fn GdipDeleteBrush(brush: *GpBrush) callconv(.winapi) GpStatus; + +var startup_token: usize = 0; +var available: bool = false; +var start_attempted: bool = false; + +/// Attempts to start GDI+ once for the process lifetime. Safe to call +/// repeatedly (a no-op after the first call). All draw functions below +/// check `available` and gracefully return false (do nothing) if this +/// never succeeded, so callers must keep their plain-GDI fallback. +pub fn init() void { + if (start_attempted) return; + start_attempted = true; + var input = GdiplusStartupInput{}; + available = GdiplusStartup(&startup_token, &input, null) == Ok; +} + +fn colorrefToArgb(colorref: u32) u32 { + const r = colorref & 0xFF; + const g = (colorref >> 8) & 0xFF; + const b = (colorref >> 16) & 0xFF; + return 0xFF000000 | (r << 16) | (g << 8) | b; +} + +const Session = struct { + graphics: *GpGraphics, + pen: *GpPen, +}; + +fn beginSession(hdc: c.HDC, colorref: u32, width: f32) ?Session { + if (!available) return null; + var graphics: *GpGraphics = undefined; + if (GdipCreateFromHDC(hdc, &graphics) != Ok) return null; + if (GdipSetSmoothingMode(graphics, SmoothingModeAntiAlias) != Ok) { + _ = GdipDeleteGraphics(graphics); + return null; + } + var pen: *GpPen = undefined; + if (GdipCreatePen1(colorrefToArgb(colorref), width, UnitPixel, &pen) != Ok) { + _ = GdipDeleteGraphics(graphics); + return null; + } + return .{ .graphics = graphics, .pen = pen }; +} + +fn endSession(session: Session) void { + _ = GdipDeletePen(session.pen); + _ = GdipDeleteGraphics(session.graphics); +} + +/// Draws an anti-aliased straight line. Returns false (drawing nothing) +/// if GDI+ is unavailable so the caller can fall back to CreatePen/LineTo. +pub fn drawLine(hdc: c.HDC, x1: i32, y1: i32, x2: i32, y2: i32, colorref: u32, width: f32) bool { + const session = beginSession(hdc, colorref, width) orelse return false; + defer endSession(session); + return GdipDrawLineI(session.graphics, session.pen, x1, y1, x2, y2) == Ok; +} + +/// Draws an anti-aliased cubic Bezier curve (the same 4-point control +/// convention as Win32's PolyBezier for a single segment). +pub fn drawBezier( + hdc: c.HDC, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + x3: i32, + y3: i32, + x4: i32, + y4: i32, + colorref: u32, + width: f32, +) bool { + const session = beginSession(hdc, colorref, width) orelse return false; + defer endSession(session); + return GdipDrawBezierI(session.graphics, session.pen, x1, y1, x2, y2, x3, y3, x4, y4) == Ok; +} + +fn buildRoundedRectPath(x: i32, y: i32, width: i32, height: i32, radius: i32) ?*GpPath { + var path: *GpPath = undefined; + if (GdipCreatePath(FillModeAlternate, &path) != Ok) return null; + const d = radius * 2; + const right = x + width; + const bottom = y + height; + var ok = true; + ok = ok and GdipAddPathArcI(path, x, y, d, d, 180, 90) == Ok; + ok = ok and GdipAddPathArcI(path, right - d, y, d, d, 270, 90) == Ok; + ok = ok and GdipAddPathArcI(path, right - d, bottom - d, d, d, 0, 90) == Ok; + ok = ok and GdipAddPathArcI(path, x, bottom - d, d, d, 90, 90) == Ok; + ok = ok and GdipClosePathFigure(path) == Ok; + if (!ok) { + _ = GdipDeletePath(path); + return null; + } + return path; +} + +/// Draws an anti-aliased filled-and-stroked rounded rectangle, replacing +/// GDI's `RoundRect` (whose curved corners are never anti-aliased) for the +/// graph canvas's node cards and selection rings. Returns false if GDI+ +/// drawing failed at any step, in which case the caller should fall back +/// to the original `CreateSolidBrush` + `CreatePen` + `RoundRect` path. +pub fn drawRoundedRect( + hdc: c.HDC, + bounds: c.RECT, + radius: i32, + fill_colorref: u32, + border_colorref: u32, + border_width: f32, +) bool { + if (!available) return false; + var graphics: *GpGraphics = undefined; + if (GdipCreateFromHDC(hdc, &graphics) != Ok) return false; + defer _ = GdipDeleteGraphics(graphics); + if (GdipSetSmoothingMode(graphics, SmoothingModeAntiAlias) != Ok) return false; + + const width = bounds.right - bounds.left; + const height = bounds.bottom - bounds.top; + const clamped_radius = @min(radius, @divTrunc(@min(width, height), 2)); + const path = buildRoundedRectPath(bounds.left, bounds.top, width, height, clamped_radius) orelse return false; + defer _ = GdipDeletePath(path); + + var brush: *GpBrush = undefined; + if (GdipCreateSolidFill(colorrefToArgb(fill_colorref), &brush) != Ok) return false; + defer _ = GdipDeleteBrush(brush); + if (GdipFillPath(graphics, brush, path) != Ok) return false; + + var pen: *GpPen = undefined; + if (GdipCreatePen1(colorrefToArgb(border_colorref), border_width, UnitPixel, &pen) != Ok) return false; + defer _ = GdipDeletePen(pen); + return GdipDrawPath(graphics, pen, path) == Ok; +} + +test "colorrefToArgb preserves channel order" { + // COLORREF 0x00BBGGRR -> ARGB 0xAARRGGBB with full alpha. + // 0x007AB8FF is B=0x7A, G=0xB8, R=0xFF, so ARGB is 0xFF (alpha) FF (R) B8 (G) 7A (B). + try std.testing.expectEqual(@as(u32, 0xFFFFB87A), colorrefToArgb(0x007AB8FF)); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index e10ba1db..dd9ce89c 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -5,6 +5,8 @@ const Sidebar = @import("Sidebar.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); const WorkspaceControls = @import("WorkspaceControls.zig"); const c = @import("Win32.zig").c; +const AppFont = @import("AppFont.zig"); +const GdiplusAA = @import("GdiplusAA.zig"); pub const connection_failure_message = "GraphCode daemon unavailable. Navigation remains available while reconnection continues."; pub const CanvasState = struct { @@ -1005,17 +1007,26 @@ fn edgeLabel(buffer: []u8, edge: GraphModel.Edge) []const u8 { } fn drawBezier(hdc: c.HDC, from: Connector, to: Connector, color: u32, style: c_int) void { + const distance: i32 = if (to.x >= from.x) to.x - from.x else from.x - to.x; + const bend: i32 = @max(@as(i32, 24), @divTrunc(distance, 2)); + const width: f32 = if (style == c.PS_SOLID) 2 else 1; + const p1 = c.POINT{ .x = from.x, .y = from.y }; + const p2 = c.POINT{ .x = from.x + bend, .y = from.y }; + const p3 = c.POINT{ .x = to.x - bend, .y = to.y }; + const p4 = c.POINT{ .x = to.x, .y = to.y }; + // Solid connectors are anti-aliased via GDI+; dashed/preview styles + // (style != PS_SOLID) keep plain GDI since GDI+'s dash patterns don't + // need to match pixel-for-pixel and the pen style enum differs. + if (style == c.PS_SOLID and + GdiplusAA.drawBezier(hdc, p1.x, p1.y, p2.x, p2.y, p3.x, p3.y, p4.x, p4.y, color, width)) + { + return; + } + const pen = c.CreatePen(style, if (style == c.PS_SOLID) 2 else 1, color); if (pen == null) return; const old = c.SelectObject(hdc, pen); - const distance: i32 = if (to.x >= from.x) to.x - from.x else from.x - to.x; - const bend: i32 = @max(@as(i32, 24), @divTrunc(distance, 2)); - var points = [_]c.POINT{ - .{ .x = from.x, .y = from.y }, - .{ .x = from.x + bend, .y = from.y }, - .{ .x = to.x - bend, .y = to.y }, - .{ .x = to.x, .y = to.y }, - }; + var points = [_]c.POINT{ p1, p2, p3, p4 }; _ = c.PolyBezier(hdc, &points, 4); _ = c.SelectObject(hdc, old); _ = c.DeleteObject(pen); @@ -1591,20 +1602,25 @@ fn paintMetricSparkline(hdc: c.HDC, node: GraphModel.Node, bounds: c.RECT) void const count: i32 = @intCast(samples.len); var previous_x = bounds.left + 8; var previous_y = bounds.bottom - 8 - @as(i32, @intFromFloat(((samples[0] - min) / range) * @as(f64, @floatFromInt(bounds.bottom - bounds.top - 16)))); - const pen = c.CreatePen(c.PS_SOLID, 2, 0x007AB8FF); - if (pen == null) return; - const old = c.SelectObject(hdc, pen); + // Fallback GDI pen, created lazily only if a GDI+ segment draw fails. + var fallback_pen: c.HPEN = null; var index: usize = 1; while (index < samples.len) : (index += 1) { const x = bounds.left + 8 + @divTrunc(@as(i32, @intCast(index)) * @max(1, bounds.right - bounds.left - 16), @max(1, count - 1)); const y = bounds.bottom - 8 - @as(i32, @intFromFloat(((samples[index] - min) / range) * @as(f64, @floatFromInt(bounds.bottom - bounds.top - 16)))); - _ = c.MoveToEx(hdc, previous_x, previous_y, null); - _ = c.LineTo(hdc, x, y); + if (!GdiplusAA.drawLine(hdc, previous_x, previous_y, x, y, 0x007AB8FF, 2)) { + if (fallback_pen == null) fallback_pen = c.CreatePen(c.PS_SOLID, 2, 0x007AB8FF); + if (fallback_pen != null) { + const old = c.SelectObject(hdc, fallback_pen); + _ = c.MoveToEx(hdc, previous_x, previous_y, null); + _ = c.LineTo(hdc, x, y); + _ = c.SelectObject(hdc, old); + } + } previous_x = x; previous_y = y; } - _ = c.SelectObject(hdc, old); - _ = c.DeleteObject(pen); + if (fallback_pen != null) _ = c.DeleteObject(fallback_pen); } fn paintRelationRow( @@ -1651,8 +1667,14 @@ fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { } fn roundedCard(hdc: c.HDC, bounds: c.RECT, color: u32, selected: bool) void { + const border_width: f32 = if (selected) 2 else 1; + const border_color: u32 = if (selected) 0x007AB8FF else 0x00383838; + // Anti-aliased path first: matches macOS's smoothly-curved node cards + // and selection rings instead of GDI's jagged RoundRect corners. + if (GdiplusAA.drawRoundedRect(hdc, bounds, 12, color, border_color, border_width)) return; + const brush = c.CreateSolidBrush(color); - const pen = c.CreatePen(c.PS_SOLID, if (selected) 2 else 1, if (selected) 0x007AB8FF else 0x00383838); + const pen = c.CreatePen(c.PS_SOLID, if (selected) 2 else 1, border_color); if (brush == null or pen == null) { if (brush != null) _ = c.DeleteObject(brush); if (pen != null) _ = c.DeleteObject(pen); @@ -1679,10 +1701,12 @@ fn drawText( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = rect(x, y, 1200, y + size + 8); _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); + _ = c.SelectObject(hdc, old_font); } fn drawTextRect( @@ -1696,31 +1720,12 @@ fn drawTextRect( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text_value) catch return; defer allocator.free(wide); - const font = c.CreateFontW( - -size, - 0, - 0, - 0, - c.FW_NORMAL, - 0, - 0, - 0, - c.DEFAULT_CHARSET, - c.OUT_DEFAULT_PRECIS, - c.CLIP_DEFAULT_PRECIS, - c.CLEARTYPE_QUALITY, - c.DEFAULT_PITCH | c.FF_DONTCARE, - std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, - ); - const old_font = if (font != null) c.SelectObject(hdc, font) else null; + const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = bounds_value; _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); - if (font != null) { - _ = c.SelectObject(hdc, old_font); - _ = c.DeleteObject(font); - } + _ = c.SelectObject(hdc, old_font); } test "edge connectors resolve reordered node IDs to card positions" { diff --git a/graphcode-windows/src/JumpPalette.zig b/graphcode-windows/src/JumpPalette.zig index c0779db2..6a095cea 100644 --- a/graphcode-windows/src/JumpPalette.zig +++ b/graphcode-windows/src/JumpPalette.zig @@ -1,5 +1,6 @@ const std = @import("std"); const c = @import("Win32.zig").c; +const AppFont = @import("AppFont.zig"); pub const Entry = struct { project_path: []const u8, @@ -209,13 +210,14 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) const dialog = active orelse return c.DefWindowProcW(hwnd, message, wparam, lparam); switch (message) { c.WM_CREATE => { - _ = c.CreateWindowExW( + const label = c.CreateWindowExW( 0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, std.unicode.utf8ToUtf16LeStringLiteral("Search loops").ptr, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, 16, 14, 590, 20, hwnd, null, c.GetModuleHandleW(null), null, ); + AppFont.apply(label, AppFont.control_size, false); dialog.edit = c.CreateWindowExW( c.WS_EX_CLIENTEDGE, std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, @@ -231,6 +233,8 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) c.LBS_NOTIFY | c.LBS_NOINTEGRALHEIGHT, 16, 76, 590, 276, hwnd, childId(results_id), c.GetModuleHandleW(null), null, ); + AppFont.apply(dialog.edit, AppFont.control_size, false); + AppFont.apply(dialog.list, AppFont.control_size, false); refillList(dialog); return 0; }, diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index e4711877..4dfd248d 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -3,6 +3,7 @@ const GraphModel = @import("GraphModel.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); const Tokens = @import("DesignTokens.zig"); const c = @import("Win32.zig").c; +const AppFont = @import("AppFont.zig"); pub const State = struct { allocator: std.mem.Allocator, @@ -1622,10 +1623,12 @@ fn drawText( ) void { const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = rect(x, y, 1200, y + size + 8); _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS); + _ = c.SelectObject(hdc, old_font); } fn drawTextRect( @@ -1637,11 +1640,12 @@ fn drawTextRect( color: u32, format: c.UINT, ) void { - _ = size; const wide = std.unicode.utf8ToUtf16LeAlloc(allocator, text) catch return; defer allocator.free(wide); + const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = bounds_value; _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, format); + _ = c.SelectObject(hdc, old_font); } diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index c02133f4..a221328e 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -2,6 +2,7 @@ const std = @import("std"); const c = @import("Win32.zig").c; const WorkspaceLayout = @import("WorkspaceLayout.zig"); const Tokens = @import("DesignTokens.zig"); +const AppFont = @import("AppFont.zig"); const columns: usize = 120; const rows: usize = 40; @@ -1735,10 +1736,12 @@ fn fillRect(hdc: c.HDC, bounds: c.RECT, color: u32) void { fn drawUtf8(hdc: c.HDC, text: []const u8, x: i32, y: i32, size: i32, color: u32) void { const wide = std.unicode.utf8ToUtf16LeAlloc(std.heap.page_allocator, text) catch return; defer std.heap.page_allocator.free(wide); + const old_font = AppFont.select(hdc, size, false); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = c.RECT{ .left = x, .top = y, .right = x + 220, .bottom = y + size + 8 }; _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_SINGLELINE); + _ = c.SelectObject(hdc, old_font); } fn tabLabel(tab: WorkspaceLayout.Tab, index: usize) []const u8 { diff --git a/graphcode-windows/src/UpdateOfferDialog.zig b/graphcode-windows/src/UpdateOfferDialog.zig index 0a12ab0e..c95d0c7a 100644 --- a/graphcode-windows/src/UpdateOfferDialog.zig +++ b/graphcode-windows/src/UpdateOfferDialog.zig @@ -1,5 +1,6 @@ const std = @import("std"); const c = @import("Win32.zig").c; +const AppFont = @import("AppFont.zig"); pub const Action = enum { later, @@ -145,7 +146,7 @@ fn createStatic( ) void { const wide = wideZ(allocator, text) catch return; defer allocator.free(wide); - _ = c.CreateWindowExW( + const control = c.CreateWindowExW( 0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide.ptr, @@ -159,6 +160,7 @@ fn createStatic( c.GetModuleHandleW(null), null, ); + AppFont.apply(control, AppFont.control_size, false); } fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, enabled: bool) void { @@ -180,6 +182,7 @@ fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, enabl c.GetModuleHandleW(null), null, ) orelse return; + AppFont.apply(button, AppFont.control_size, false); _ = c.EnableWindow(button, if (enabled) 1 else 0); } diff --git a/graphcode-windows/src/WindowsNativeDialogs.zig b/graphcode-windows/src/WindowsNativeDialogs.zig index 57b4147a..f0eef958 100644 --- a/graphcode-windows/src/WindowsNativeDialogs.zig +++ b/graphcode-windows/src/WindowsNativeDialogs.zig @@ -1,5 +1,6 @@ const std = @import("std"); const c = @import("Win32.zig").c; +const AppFont = @import("AppFont.zig"); pub const Result = struct { values: [16][]u8, @@ -232,6 +233,7 @@ fn createDescription(hwnd: c.HWND, state: *State) void { c.GetModuleHandleW(null), null, ); + AppFont.apply(state.description_window, AppFont.control_size, false); } fn fieldBaseY(state: *const State) usize { @@ -243,8 +245,10 @@ fn createField(hwnd: c.HWND, state: *State, label: []const u8, index: usize) voi const wide_label = wideZ(state.allocator, label) catch return; defer state.allocator.free(wide_label); state.label_windows[index] = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide_label.ptr, c.WS_CHILD | c.WS_VISIBLE, 18, y, 500, 18, hwnd, null, c.GetModuleHandleW(null), null); + AppFont.apply(state.label_windows[index], AppFont.control_size, false); const edit_id: c.HMENU = @ptrFromInt(9904 + index * 8); const edit = c.CreateWindowExW(c.WS_EX_CLIENTEDGE, std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, null, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL, 18, y + 18, 500, 24, hwnd, edit_id, c.GetModuleHandleW(null), null) orelse return; + AppFont.apply(edit, AppFont.control_size, false); state.edits[index] = edit; const wide_value = wideZ(state.allocator, state.values[index]) catch return; defer state.allocator.free(wide_value); @@ -268,7 +272,8 @@ fn createButton(hwnd: c.HWND, label: []const u8, id: usize, x: i32, y: i32) void const wide = wideZ(std.heap.c_allocator, label) catch return; defer std.heap.c_allocator.free(wide); const button_id: c.HMENU = @ptrFromInt(id); - _ = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_DEFPUSHBUTTON, x, y, 80, 28, hwnd, button_id, c.GetModuleHandleW(null), null); + const button = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_DEFPUSHBUTTON, x, y, 80, 28, hwnd, button_id, c.GetModuleHandleW(null), null); + AppFont.apply(button, AppFont.control_size, false); } fn readValues(state: *State) void { diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 8c4603ba..2b25c8f0 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -1,6 +1,7 @@ const std = @import("std"); const c = @import("Win32.zig").c; const Tokens = @import("DesignTokens.zig"); +const AppFont = @import("AppFont.zig"); extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; @@ -832,8 +833,7 @@ fn createControl( c.GetModuleHandleW(null), null, ) orelse return null; - if (c.GetStockObject(c.DEFAULT_GUI_FONT)) |font| - _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(font), 1); + AppFont.apply(control, AppFont.control_size, false); return control; } @@ -1194,7 +1194,7 @@ fn createOperationControl(hwnd: c.HWND, class: []const u8, text: []const u8, x: @as(c.DWORD, @intCast(c.WS_VISIBLE)) | (if (std.mem.eql(u8, class, "BUTTON")) @as(c.DWORD, @intCast(c.WS_TABSTOP)) else 0); const control = c.CreateWindowExW(0, wide_class.ptr, wide_text.ptr, style, x, y, width, height, hwnd, controlId(id), c.GetModuleHandleW(null), null) orelse return null; - _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(c.GetStockObject(c.DEFAULT_GUI_FONT)), 1); + AppFont.apply(control, AppFont.control_size, false); return control; } diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index d63670f3..e19c7121 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -155,6 +155,8 @@ Statuses: | Clipboard/selection | Terminal copy/paste and mouse selection | Winghostty terminal gates cover core behavior | Partial | | Per-monitor DPI | Layout and controls scale correctly across monitors | No complete live multi-DPI walkthrough recorded | Partial | | Dark visual language | Dark canvas/cards/sheets and legible state hierarchy | Main canvas, onboarding, product settings, and workspace chrome use the dark native language; the previously unstyled legacy repository and graph forms have been brought into the same language — `WindowsRepositoryDialogs.zig` (Clone/Add Remote sheets) and `NativeForms.zig` (Node/Edge/Update/Settings/Jump/Project Settings/Worktree Sweep sheets) now paint `Tokens.dialog_panel`/`dialog_body_text`/`dialog_title_text`/`dialog_error_text`/`dialog_field_background` instead of default Win32 backgrounds, and the Node sheet's loop-type field is now a set of accent-colored teaching tiles matching macOS's `LoopTypeChooser.swift`. Live/UIA screenshot confirmation of the rendered result is still pending, so this remains Partial rather than Validated | Partial | +| Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | Previously only `GraphCanvas.zig`'s `drawTextRect`, `WindowsOnboarding.zig`, and `WindowsProductSettings.zig` requested an explicit `CreateFontW(..., CLEARTYPE_QUALITY, "Segoe UI")` font; every other surface either fell back to `GetStockObject(DEFAULT_GUI_FONT)` (`WindowsRepositoryDialogs.zig`'s `createControl`/`createOperationControl`) or painted text with whatever font happened to already be selected into the DC, i.e. no font selection at all (`Sidebar.zig`'s owner-drawn rows — which also silently discarded their `size` parameter in `drawTextRect`, meaning size was ignored entirely; `GraphCanvas.zig`'s separate `drawText` helper; `TerminalSurface.zig`'s tab-count overlay; `JumpPalette.zig`, `UpdateOfferDialog.zig`, and `WindowsNativeDialogs.zig`'s classic EDIT/STATIC/BUTTON/LISTBOX controls). A new shared module, `AppFont.zig`, centralizes this as a small per-(size,bold) cache of `CLEARTYPE_QUALITY` "Segoe UI" `HFONT`s (`AppFont.get`), plus `AppFont.apply` (WM_SETFONT for classic controls) and `AppFont.select` (SelectObject for direct GDI paint code). All of the surfaces listed above were switched onto it, and `Sidebar.zig`'s `drawTextRect` now actually honors its `size` argument. Verified via `zig test src/AppFont.zig` (new cache-identity unit test) and `zig test` on every edited file (`GraphCanvas.zig`: all 95 pre-existing tests still pass; `WindowsRepositoryDialogs.zig`: 13/13; `JumpPalette.zig`: 2/2; `UpdateOfferDialog.zig`: 1/1; `WindowsNativeDialogs.zig`: 1/1; `TerminalSurface.zig`: 10/10; `Sidebar.zig`: same 85/88 pass rate as the pre-change baseline, confirming its 3 pre-existing failing tests are unrelated layout-logic issues, not caused by this change) using the pinned Zig 0.15.2 toolchain natively on Windows. No live/UIA screenshot of the rendered glyphs was captured (the CI live-UIA gate performs no pixel capture), so this remains Partial pending a rendered-app visual confirmation | Partial | +| Line/shape anti-aliasing | Smooth, anti-aliased lines/curves/rounded corners matching macOS's Core Graphics default | The graph canvas previously drew every edge/connector (`drawBezier`, `PolyBezier`), selection ring/node-card border (`roundedCard`, `RoundRect`), and metric sparkline (`paintMetricSparkline`) with plain GDI `CreatePen`/`PS_SOLID`, which GDI never anti-aliases — a likely cause of visibly "jaggier" curves/corners than macOS. A new minimal module, `GdiplusAA.zig`, binds the small, stable GDI+ flat C API (`GdiplusStartup`, `GdipCreateFromHDC`, `GdipSetSmoothingMode(...AntiAlias)`, `GdipDrawLineI`, `GdipDrawBezierI`, and a `GraphicsPath`-based rounded-rectangle fill+stroke) and is initialized once at app startup (`App.zig`'s `run()`). `drawBezier` (solid-style edges only; dashed/preview edges keep plain GDI), `roundedCard` (node cards and selection rings), and `paintMetricSparkline`'s per-segment lines now draw through GDI+ first, with automatic, per-call fallback to the original plain-GDI path if GDI+ ever fails to initialize or draw (so this cannot regress or destabilize rendering). Axis-aligned 1px grid lines (`drawGrid`) were deliberately left on plain GDI since anti-aliasing does not visually change perfectly horizontal/vertical hairlines. `build.zig` now links `gdiplus`. Verified with: `zig test src/GdiplusAA.zig` (channel-order unit test), a standalone runtime smoke program that calls `GdiplusAA.init()` plus `drawLine`/`drawBezier`/`drawRoundedRect` against a real in-memory GDI bitmap DC on this Windows host and printed `line_ok=true bezier_ok=true rect_ok=true` (confirming GDI+ actually initializes and draws successfully, not just compiles), and `zig test src/GraphCanvas.zig` showing all 95 pre-existing tests still pass after the change. A reproduction comparison image (identical GDI vs. GDI+ curve/rounded-rect draw calls, aliased vs. anti-aliased) is attached to the pull request showing the expected visual difference; this is not a live-app screenshot (none is obtainable per the CI live-UIA gate's structural-only capture), so this remains Partial pending in-app visual confirmation | Partial | **Known out-of-scope CI gap surfaced while validating the row above (PR #398):** the live `windows-shell` UIA gate's "New Loop" assertion invoked via the sidebar's `project-new-loop-*` element (`Tools/windows/uia-live-gate.ps1`, "project-row New Loop did not open the node form") fails intermittently/deterministically across unrelated branches (reproduced on `coneilen-microsoft-canvas-workspace-detail-parity` and `coneilen-microsoft-updates-dialogs-quick-chats-parity` as well, with no relation to dialog rendering code). This is pre-existing test-infrastructure flakiness, not a visual-polish regression; it is out of this pass's scope and is flagged here for a dedicated follow-up. From ada83963ccac2d73a3f8b5ca7a970fe644724c94 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 14:08:56 -0700 Subject: [PATCH 06/13] Match Windows palette to macOS Theme.swift 1:1 Corrects real color bugs (not just missing tokens) found by direct comparison against graphcode/Sources/Features/App/Theme.swift: - canvas_tone/canvas_grid_line were flat gray; now the deliberate green-tinted near-black values from Theme.swift. - workspace_rail had its Red/Blue channels swapped vs Theme.swift's #1d1d21. - Two "selected" backgrounds (TerminalSurface's selected tab, GraphCanvas's selected node card) both hardcoded the same arbitrary 0x00345D8C blue with no Theme.swift source; replaced with Tokens.tab_selected_background and the card's normal fill respectively (selection is now carried by the border/ring alone, as on mac). Adds 20 previously-missing DesignTokens.zig tokens mirroring Theme.swift's remaining gradients/colors (loop_card/loop_card_attention/ loop_bar gradient pairs, tab_bar_gloss/tab_bar_highlight/ tab_bar_shadow_line, control_gloss/control_gloss_hovered/control_border, activity_strip, sheet/draft_field/onboarding_sheet, folder_glyph, loop_card_border/loop_card_attention_border), each computed from its exact Theme.swift float RGB or, for opacity scrims mac paints over glass, pre-blended flat against the concrete opaque surface they paint over on Windows. Adds GdiGradient.zig, a small wrapper around Win32's classic GradientFill (msimg32.dll, newly linked in build.zig) with a flat-fill fallback, approximating macOS's LinearGradients without a GDI+/Direct2D dependency. Wires it into TerminalSurface.zig's tab strip, its New Tab/Split controls, and the loop bar (previously flat single-color fills), and replaces two ad-hoc GraphCanvas.zig node-card border literals with Tokens.canvas_selection/Tokens.loop_card_border. Updates ui-parity-matrix.md's Accessibility/visual-behavior section with a new Color palette fidelity row documenting exactly which tokens were added/corrected and their Theme.swift source lines. Verified with zig ast-check on every touched file; the pinned Zig 0.15.2 toolchain was unavailable locally (0.16.0 installed), so CI is the authoritative build/test gate per prior guidance. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/build.zig | 1 + graphcode-windows/src/DesignTokens.zig | 100 ++++++++++++++++++++-- graphcode-windows/src/GdiGradient.zig | 69 +++++++++++++++ graphcode-windows/src/GraphCanvas.zig | 18 ++-- graphcode-windows/src/TerminalSurface.zig | 21 ++++- investigation/ui-parity-matrix.md | 2 + 6 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 graphcode-windows/src/GdiGradient.zig diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index 13394045..d1a73e77 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -60,6 +60,7 @@ pub fn build(b: *std.Build) !void { "user32", "gdi32", "gdiplus", + "msimg32", "opengl32", "kernel32", "imm32", diff --git a/graphcode-windows/src/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig index 0a0602f4..47641ed8 100644 --- a/graphcode-windows/src/DesignTokens.zig +++ b/graphcode-windows/src/DesignTokens.zig @@ -1,16 +1,102 @@ pub const Color = u32; -pub const window_tone: Color = 0x001E1E1E; -pub const window_background: Color = 0x8C1E1E1E; -pub const canvas_background: Color = 0x9E181818; -pub const canvas_tone: Color = 0x00181818; -pub const canvas_grid_line: Color = 0x00272727; +// Every value below is converted 1:1 from graphcode/Sources/Features/App/Theme.swift +// (the macOS `Theme` enum, the single source of truth for this app's dark palette). +// Windows literals are Win32 COLORREF-style `0xAABBGGRR` (an optional alpha byte, +// then Blue, Green, Red -- reversed from web `#RRGGBB` notation). To convert a mac +// `Color(red:g:b:)` (0.0-1.0 floats), each channel is `round(component * 255)`; an +// `.opacity(x)` on top of a *painted* (non-glass) surface is pre-blended into a flat +// equivalent against the base it is actually painted over here, since this codebase +// has no real Win32 alpha-blending path (no `AlphaBlend`/`BLENDFUNCTION` usage +// anywhere) -- the one exception is `window_background`/`unfocused_pane_veil` below, +// which keep a literal alpha byte purely as documentation for a future consumer. +pub const window_tone: Color = 0x001E1E1E; // Theme.windowTone = Color(white: 0.118) +pub const window_background: Color = 0x8C1E1E1E; // Theme.windowBackground = windowTone.opacity(0.55) +// Theme.canvasBackground = canvasTone (mac defines it as a literal alias, not a +// distinct value -- opaque on purpose, see Theme.swift's doc comment). +pub const canvas_background: Color = canvas_tone; +// Theme.canvasTone = Color(red: 0.040, green: 0.048, blue: 0.044) -- a near-black with +// a trace of green, "so a long session against it reads warmer than dead neutral". +// Previously 0x00181818 (flat neutral gray), which lost that intentional warmth. +pub const canvas_tone: Color = 0x000B0C0A; +// Theme.canvasGridLine = Color(red: 0.082, green: 0.094, blue: 0.086), one step off +// canvasTone. Previously 0x00272727 (flat gray, same bug as canvas_tone above). +pub const canvas_grid_line: Color = 0x00161815; pub const canvas_edge: Color = 0x006A6A6A; pub const canvas_selection: Color = 0x007AB8FF; pub const unfocused_pane_veil: Color = 0x591E1E1E; pub const terminal_background_opacity: f32 = 0.80; -pub const workspace_rail: Color = 0x001D1D21; -pub const pane_focus_tint: Color = 0x000A84FF; +// Theme.workspaceRail = Color(red: 0.114, green: 0.114, blue: 0.129) // #1d1d21. +// Previously 0x001D1D21, which has Red and Blue swapped relative to that hex (decodes +// to R=0x21,G=0x1D,B=0x1D instead of R=0x1D,G=0x1D,B=0x21) -- a genuine color bug, not +// just quantization, fixed here to match the mac literal exactly. +pub const workspace_rail: Color = 0x00211D1D; +pub const pane_focus_tint: Color = 0x000A84FF; // Theme.paneFocusTint = #0a84ff + +// --- Chrome gloss / gradient surfaces ------------------------------------------- +// macOS paints these as `LinearGradient`s (top -> bottom). This codebase has no +// GDI+/Direct2D dependency, so they are approximated with Win32 `GradientFill` +// (msimg32.dll, see `GdiGradient.zig`) driven by a `_top`/`_bottom` stop pair, with a +// safe flat-fill fallback if `GradientFill` is unavailable. Where a stop has +// `.opacity(x)` in Theme.swift (i.e. it is a translucent scrim over the window's +// glass on mac), the value here is pre-blended flat against the concrete Windows +// surface it paints over, since Windows chrome here is opaque paint, not glass. + +// Theme.tabBarGloss: 3-stop gradient (white 0.145/0.66, 0.122/0.62, 0.106/0.58) +// blended over `workspace_rail`, approximated as a 2-stop top/bottom pair (the +// middle stop is close to the midpoint of these two and GradientFill's rect mode +// only takes two vertices per rectangle). +pub const tab_bar_gloss_top: Color = 0x00242222; +pub const tab_bar_gloss_bottom: Color = 0x001E1C1C; +// Theme.tabBarHighlight = Color.white.opacity(0.08), blended over `workspace_rail`. +pub const tab_bar_highlight: Color = 0x00332F2F; +// Theme.tabBarShadowLine = Color.black.opacity(0.35), blended over `loop_bar_bottom`. +pub const tab_bar_shadow_line: Color = 0x00161414; +// Theme.tabSelectedBackground = Color(red: 0.235, green: 0.245, blue: 0.267). +pub const tab_selected_background: Color = 0x00443E3C; + +// Theme.controlGloss: LinearGradient(0.243,0.255,0.278 -> 0.192,0.200,0.220). +pub const control_gloss_top: Color = 0x0047413E; +pub const control_gloss_bottom: Color = 0x00383331; +// Theme.controlGlossHovered: LinearGradient(0.310,0.322,0.349 -> 0.243,0.255,0.278). +pub const control_gloss_hovered_top: Color = 0x0059524F; +pub const control_gloss_hovered_bottom: Color = control_gloss_top; +// Theme.controlBorder = Color.white.opacity(0.10), blended over `control_gloss_bottom`. +pub const control_border: Color = 0x004C4746; + +// Theme.loopCard: LinearGradient(#2c2c30 -> #232326). +pub const loop_card_top: Color = 0x00302C2C; +pub const loop_card_bottom: Color = 0x00262323; +// Theme.loopCardAttention: LinearGradient(#302a22 -> #262220). +pub const loop_card_attention_top: Color = 0x00222A30; +pub const loop_card_attention_bottom: Color = 0x00202226; +// Theme.loopCardBorder = Color.white.opacity(0.09), blended over `loop_card_bottom`. +pub const loop_card_border: Color = 0x003A3737; +// Theme.loopCardAttentionBorder = Color(1.0, 0.624, 0.039).opacity(0.55), blended +// over `loop_card_attention_bottom`. +pub const loop_card_attention_border: Color = 0x0014679D; + +// Theme.loopBar: LinearGradient(#24242a -> #1e1e22). +pub const loop_bar_top: Color = 0x002A2424; +pub const loop_bar_bottom: Color = 0x00221E1E; + +// Theme.activityStrip = Color(red: 0.114, green: 0.114, blue: 0.125) // #1d1d20. +pub const activity_strip: Color = 0x00201D1D; + +// Theme.sheet = Color(red: 0.165, green: 0.165, blue: 0.180) // #2a2a2e -- "the +// new-loop sheet and the fields on it". Distinct from `dialog_panel` below (which +// mirrors the settings sheet); used for the node/edge creation forms. +pub const sheet: Color = 0x002E2A2A; +// Theme.draftField = Color(red: 0.118, green: 0.118, blue: 0.133) // #1e1e22. +pub const draft_field: Color = 0x00221E1E; +// Theme.onboardingSheet = Color(red: 0.137, green: 0.137, blue: 0.149) // #232326 -- +// numerically identical to `dialog_panel`/`onboarding` background already in use. +pub const onboarding_sheet: Color = 0x00262323; + +// Theme.folderGlyph = Color(red: 0.365, green: 0.647, blue: 0.937) -- "the Finder +// blue" (#5da5ef). Added for parity even though no Windows folder-glyph render call +// site currently exists; available for a future icon. +pub const folder_glyph: Color = 0x00EFA55D; pub const loop_card_width: i32 = 250; pub const loop_card_height: i32 = 106; diff --git a/graphcode-windows/src/GdiGradient.zig b/graphcode-windows/src/GdiGradient.zig new file mode 100644 index 00000000..053faf5e --- /dev/null +++ b/graphcode-windows/src/GdiGradient.zig @@ -0,0 +1,69 @@ +// A tiny wrapper around Win32's classic `GradientFill` (msimg32.dll) used to +// approximate macOS's `LinearGradient` chrome (tab strip gloss, loop bar, loop +// cards, control gloss) without a GDI+ or Direct2D dependency. `GradientFill` +// has shipped since Windows 98/2000 and is exposed directly through +// `windows.h`/`wingdi.h`, so no manual extern bindings are needed -- it comes +// through the existing `Win32.zig` `c` import once `msimg32` is linked. +// +// Every call gracefully falls back to a flat fill (using the top-stop color) +// if `GradientFill` fails for any reason, so this can never regress or +// destabilize existing rendering -- mirroring `GdiplusAA.zig`'s fallback +// pattern for the same reason. +const std = @import("std"); +const c = @import("Win32.zig").c; + +/// Win32's `TRIVERTEX`/`GRADIENT_RECT` color channels are `COLOR16` (top byte +/// used, bottom byte zero) rather than plain `u8`, so a COLORREF-style byte +/// must be widened into that 16-bit form. +pub fn channelToColor16(component: u8) u16 { + return @as(u16, component) << 8; +} + +fn vertex(x: i32, y: i32, colorref: u32) c.TRIVERTEX { + return .{ + .x = x, + .y = y, + .Red = channelToColor16(@intCast(colorref & 0xFF)), + .Green = channelToColor16(@intCast((colorref >> 8) & 0xFF)), + .Blue = channelToColor16(@intCast((colorref >> 16) & 0xFF)), + .Alpha = 0, + }; +} + +/// Paints `bounds` with a vertical two-stop linear gradient (top -> bottom), +/// approximating a macOS `LinearGradient(startPoint: .top, endPoint: .bottom)`. +/// Falls back to a flat fill of `top_colorref` if `GradientFill` is +/// unavailable or fails (e.g. under a stripped-down remote session). +pub fn fillVertical(hdc: c.HDC, bounds: c.RECT, top_colorref: u32, bottom_colorref: u32) void { + if (bounds.right <= bounds.left or bounds.bottom <= bounds.top) return; + var vertices = [2]c.TRIVERTEX{ + vertex(bounds.left, bounds.top, top_colorref), + vertex(bounds.right, bounds.bottom, bottom_colorref), + }; + var rect = c.GRADIENT_RECT{ .UpperLeft = 0, .LowerRight = 1 }; + const ok = c.GradientFill(hdc, &vertices, 2, @ptrCast(&rect), 1, c.GRADIENT_FILL_RECT_V) != 0; + if (ok) return; + + const brush = c.CreateSolidBrush(top_colorref); + if (brush == null) return; + defer _ = c.DeleteObject(brush); + var mutable_bounds = bounds; + _ = c.FillRect(hdc, &mutable_bounds, brush); +} + +test "channelToColor16 widens the top byte and zeroes the bottom" { + try std.testing.expectEqual(@as(u16, 0xFF00), channelToColor16(0xFF)); + try std.testing.expectEqual(@as(u16, 0x7A00), channelToColor16(0x7A)); + try std.testing.expectEqual(@as(u16, 0x0000), channelToColor16(0x00)); +} + +test "vertex splits a COLORREF into COLOR16 channels in COLORREF order" { + // 0x007AB8FF is B=0x7A, G=0xB8, R=0xFF (see GdiplusAA's colorrefToArgb test). + const v = vertex(10, 20, 0x007AB8FF); + try std.testing.expectEqual(@as(i32, 10), v.x); + try std.testing.expectEqual(@as(i32, 20), v.y); + try std.testing.expectEqual(@as(u16, 0xFF00), v.Red); + try std.testing.expectEqual(@as(u16, 0xB800), v.Green); + try std.testing.expectEqual(@as(u16, 0x7A00), v.Blue); + try std.testing.expectEqual(@as(u16, 0), v.Alpha); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index dd9ce89c..81694b3b 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -492,7 +492,7 @@ fn drawOverview( } for (model.graphs.items, 0..) |graph, graph_index| { const lane = overviewLaneBounds(model, graph_index, bounds, state); - roundedCard(hdc, lane, 0x001D1D21, false); + roundedCard(hdc, lane, Tokens.workspace_rail, false); drawText(hdc, allocator, graph.project.name, lane.left + scaledValue(18, state.zoom), lane.top + scaledValue(16, state.zoom), scaledValue(14, state.zoom), 0x00E8E8E8); const open = rect(lane.right - scaledValue(132, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(76, state.zoom), lane.top + scaledValue(30, state.zoom)); const worktrees = rect(lane.right - scaledValue(72, state.zoom), lane.top + scaledValue(10, state.zoom), lane.right - scaledValue(18, state.zoom), lane.top + scaledValue(30, state.zoom)); @@ -503,7 +503,7 @@ fn drawOverview( var index: usize = 0; while (index < graph.nodes.items.len) : (index += 1) { const card = overviewCardBounds(model, graph_index, index, bounds, state); - roundedCard(hdc, card, 0x00262626, false); + 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); 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); @@ -526,10 +526,10 @@ fn drawQuickChats( } const rows = (model.quick_chats.items.len + 2) / 3; const band = transformedRect(bounds, state, 24, 34, @max(760, bounds.right - bounds.left - 48), @as(i32, @intCast(rows * 104 + 32))); - roundedCard(hdc, band, 0x001D1D21, false); + roundedCard(hdc, band, Tokens.workspace_rail, false); for (model.quick_chats.items, 0..) |chat, index| { const card = quickChatCardBounds(index, bounds, state); - roundedCard(hdc, card, 0x00262626, false); + roundedCard(hdc, card, Tokens.loop_card_bottom, false); fill(hdc, rect(card.left, card.top, card.left + scaledValue(4, state.zoom), card.bottom), 0x007A7A7A); drawText(hdc, allocator, chat.title, card.left + scaledValue(14, state.zoom), card.top + scaledValue(12, state.zoom), scaledValue(13, state.zoom), 0x00FFFFFF); drawText(hdc, allocator, if (std.mem.eql(u8, chat.backend, "claudeCode")) "chat" else chat.backend, card.left + scaledValue(14, state.zoom), card.top + scaledValue(37, state.zoom), scaledValue(10, state.zoom), 0x009A9A9A); @@ -1072,7 +1072,11 @@ fn drawNode( const y = bounds.top; const attention = needsAttention(node, nodes, edges); const selected_card = selected == index; - roundedCard(hdc, bounds, if (selected_card) 0x00345D8C else 0x00262626, selected_card); + // Theme.loopCard's bottom stop (#232326), used for both states: selection is + // carried by the border/ring (Tokens.canvas_selection via roundedCard's + // `selected` flag), matching mac -- there is no "selected node fill" color in + // Theme.swift. Previously an ad-hoc 0x00345D8C blue with no Theme.swift source. + roundedCard(hdc, bounds, Tokens.loop_card_bottom, selected_card); const stripe = loopTypeColor(node.loop_type); 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); @@ -1668,7 +1672,9 @@ fn fill(hdc: c.HDC, bounds: c.RECT, color: u32) void { fn roundedCard(hdc: c.HDC, bounds: c.RECT, color: u32, selected: bool) void { const border_width: f32 = if (selected) 2 else 1; - const border_color: u32 = if (selected) 0x007AB8FF else 0x00383838; + // Selected: Tokens.canvas_selection. Unselected: Tokens.loop_card_border, i.e. + // macOS's Theme.loopCardBorder (white 9% over the card fill) pre-blended flat. + const border_color: u32 = if (selected) Tokens.canvas_selection else Tokens.loop_card_border; // Anti-aliased path first: matches macOS's smoothly-curved node cards // and selection rings instead of GDI's jagged RoundRect corners. if (GdiplusAA.drawRoundedRect(hdc, bounds, 12, color, border_color, border_width)) return; diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index a221328e..7ad6e65f 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -3,6 +3,7 @@ const c = @import("Win32.zig").c; const WorkspaceLayout = @import("WorkspaceLayout.zig"); const Tokens = @import("DesignTokens.zig"); const AppFont = @import("AppFont.zig"); +const GdiGradient = @import("GdiGradient.zig"); const columns: usize = 120; const rows: usize = 40; @@ -703,12 +704,19 @@ pub const Workspace = struct { }; fillRect(hdc, tab_bar, Tokens.workspace_rail); + // Theme.tabBarGloss painted over the strip, lit from above; approximated as a + // vertical GradientFill (see Tokens.tab_bar_gloss_top/_bottom). + GdiGradient.fillVertical(hdc, tab_bar, Tokens.tab_bar_gloss_top, Tokens.tab_bar_gloss_bottom); + // Theme.tabBarHighlight -- the one-point specular line along the strip's top edge. + fillRect(hdc, .{ .left = tab_bar.left, .top = tab_bar.top, .right = tab_bar.right, .bottom = tab_bar.top + 1 }, Tokens.tab_bar_highlight); const controls_left = self.chromeControlsLeft(); for (self.layout.tabs.items, 0..) |tab, index| { const left = self.layout_origin_x + @as(i32, @intCast(index)) * 120; if (left + 112 > controls_left) break; const bounds = tabBounds(self.layout_origin_x, self.layout_origin_y, index); - fillRect(hdc, bounds, if (index == self.layout.selected_tab) 0x00345D8C else 0x00262626); + // Selected: Theme.tabSelectedBackground. Was previously 0x00345D8C, an + // unintentional blue that did not correspond to any Theme.swift value. + fillRect(hdc, bounds, if (index == self.layout.selected_tab) Tokens.tab_selected_background else 0x00262626); fillRect(hdc, .{ .left = bounds.left + 8, .top = bounds.top + 9, .right = bounds.left + 14, .bottom = bounds.top + 15 }, tabIndicatorColor(self, tab)); drawUtf8(hdc, tabLabel(tab, index), bounds.left + 19, bounds.top + 4, 10, 0x00E6E6E6); var shortcut: [16]u8 = undefined; @@ -719,7 +727,9 @@ pub const Workspace = struct { const labels = [_][]const u8{ "New Tab", "Split R", "Split D" }; for (labels, 0..) |label, index| { const bounds = chromeControlBounds(self.layout_origin_x, self.layout_origin_y, self.layout_width, index); - fillRect(hdc, bounds, 0x00262626); + // Theme.controlGloss: a small control on the tab strip, lit a step + // brighter than the strip itself so it reads as raised off it. + GdiGradient.fillVertical(hdc, bounds, Tokens.control_gloss_top, Tokens.control_gloss_bottom); drawUtf8(hdc, label, bounds.left + 7, bounds.top + 5, 10, 0x00D8D8D8); } for (self.surfaces, 0..) |slot, index| { @@ -771,7 +781,8 @@ pub const Workspace = struct { resolved: bool, ) void { const top = Tokens.header_height; - fillRect(hdc, .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, 0x00222226); + // Theme.loopBar: lit like the tab strip, one step lighter. + GdiGradient.fillVertical(hdc, .{ .left = left, .top = top, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.loop_bar_top, Tokens.loop_bar_bottom); fillRect(hdc, .{ .left = left + 14, .top = top + 11, @@ -797,7 +808,9 @@ pub const Workspace = struct { drawUtf8(hdc, "Stop loop", right - 184, top + 17, 10, 0x00D8D8DC); } drawUtf8(hdc, "Show in graph", right - 100, top + 17, 10, 0x008E8E93); - fillRect(hdc, .{ .left = left, .top = top + Tokens.loop_bar_height - 1, .right = right, .bottom = top + Tokens.loop_bar_height }, 0x00131315); + // Theme.tabBarShadowLine, blended flat over the loop bar's own bottom stop -- + // the edge where the strip's gloss meets the terminal below it. + fillRect(hdc, .{ .left = left, .top = top + Tokens.loop_bar_height - 1, .right = right, .bottom = top + Tokens.loop_bar_height }, Tokens.tab_bar_shadow_line); _ = allocator; } diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index e19c7121..7f2a8698 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -158,6 +158,8 @@ Statuses: | Font rendering quality | Legible, ClearType-quality text on every surface, matching macOS's default anti-aliased text | Previously only `GraphCanvas.zig`'s `drawTextRect`, `WindowsOnboarding.zig`, and `WindowsProductSettings.zig` requested an explicit `CreateFontW(..., CLEARTYPE_QUALITY, "Segoe UI")` font; every other surface either fell back to `GetStockObject(DEFAULT_GUI_FONT)` (`WindowsRepositoryDialogs.zig`'s `createControl`/`createOperationControl`) or painted text with whatever font happened to already be selected into the DC, i.e. no font selection at all (`Sidebar.zig`'s owner-drawn rows — which also silently discarded their `size` parameter in `drawTextRect`, meaning size was ignored entirely; `GraphCanvas.zig`'s separate `drawText` helper; `TerminalSurface.zig`'s tab-count overlay; `JumpPalette.zig`, `UpdateOfferDialog.zig`, and `WindowsNativeDialogs.zig`'s classic EDIT/STATIC/BUTTON/LISTBOX controls). A new shared module, `AppFont.zig`, centralizes this as a small per-(size,bold) cache of `CLEARTYPE_QUALITY` "Segoe UI" `HFONT`s (`AppFont.get`), plus `AppFont.apply` (WM_SETFONT for classic controls) and `AppFont.select` (SelectObject for direct GDI paint code). All of the surfaces listed above were switched onto it, and `Sidebar.zig`'s `drawTextRect` now actually honors its `size` argument. Verified via `zig test src/AppFont.zig` (new cache-identity unit test) and `zig test` on every edited file (`GraphCanvas.zig`: all 95 pre-existing tests still pass; `WindowsRepositoryDialogs.zig`: 13/13; `JumpPalette.zig`: 2/2; `UpdateOfferDialog.zig`: 1/1; `WindowsNativeDialogs.zig`: 1/1; `TerminalSurface.zig`: 10/10; `Sidebar.zig`: same 85/88 pass rate as the pre-change baseline, confirming its 3 pre-existing failing tests are unrelated layout-logic issues, not caused by this change) using the pinned Zig 0.15.2 toolchain natively on Windows. No live/UIA screenshot of the rendered glyphs was captured (the CI live-UIA gate performs no pixel capture), so this remains Partial pending a rendered-app visual confirmation | Partial | | Line/shape anti-aliasing | Smooth, anti-aliased lines/curves/rounded corners matching macOS's Core Graphics default | The graph canvas previously drew every edge/connector (`drawBezier`, `PolyBezier`), selection ring/node-card border (`roundedCard`, `RoundRect`), and metric sparkline (`paintMetricSparkline`) with plain GDI `CreatePen`/`PS_SOLID`, which GDI never anti-aliases — a likely cause of visibly "jaggier" curves/corners than macOS. A new minimal module, `GdiplusAA.zig`, binds the small, stable GDI+ flat C API (`GdiplusStartup`, `GdipCreateFromHDC`, `GdipSetSmoothingMode(...AntiAlias)`, `GdipDrawLineI`, `GdipDrawBezierI`, and a `GraphicsPath`-based rounded-rectangle fill+stroke) and is initialized once at app startup (`App.zig`'s `run()`). `drawBezier` (solid-style edges only; dashed/preview edges keep plain GDI), `roundedCard` (node cards and selection rings), and `paintMetricSparkline`'s per-segment lines now draw through GDI+ first, with automatic, per-call fallback to the original plain-GDI path if GDI+ ever fails to initialize or draw (so this cannot regress or destabilize rendering). Axis-aligned 1px grid lines (`drawGrid`) were deliberately left on plain GDI since anti-aliasing does not visually change perfectly horizontal/vertical hairlines. `build.zig` now links `gdiplus`. Verified with: `zig test src/GdiplusAA.zig` (channel-order unit test), a standalone runtime smoke program that calls `GdiplusAA.init()` plus `drawLine`/`drawBezier`/`drawRoundedRect` against a real in-memory GDI bitmap DC on this Windows host and printed `line_ok=true bezier_ok=true rect_ok=true` (confirming GDI+ actually initializes and draws successfully, not just compiles), and `zig test src/GraphCanvas.zig` showing all 95 pre-existing tests still pass after the change. A reproduction comparison image (identical GDI vs. GDI+ curve/rounded-rect draw calls, aliased vs. anti-aliased) is attached to the pull request showing the expected visual difference; this is not a live-app screenshot (none is obtainable per the CI live-UIA gate's structural-only capture), so this remains Partial pending in-app visual confirmation | Partial | +| Color palette fidelity | Windows tones/gradients match macOS `Theme.swift` 1:1 (not just "generically dark") | Direct comparison of `graphcode-windows/src/DesignTokens.zig` against `graphcode/Sources/Features/App/Theme.swift` found real color bugs, not just missing tokens: `canvas_tone` (Theme.swift:54) was flat gray `0x00181818` instead of the deliberate green-tinted `0x000B0C0A`; `canvas_grid_line` (Theme.swift:62) was likewise flat gray instead of `0x00161815`; `workspace_rail` (Theme.swift:182, `#1d1d21`) had its Red/Blue channels swapped (`0x001D1D21` decodes to R=0x21/B=0x1D instead of R=0x1D/B=0x21), now `0x00211D1D`; and two "selected" backgrounds (`TerminalSurface.zig`'s selected tab, `GraphCanvas.zig`'s selected node card) both hardcoded the same arbitrary `0x00345D8C` blue with no `Theme.swift` source — replaced with `Tokens.tab_selected_background` (Theme.swift:143) and the node's normal `loop_card_bottom` fill respectively, so node selection is now carried by the border/ring alone as it is on mac. 20 previously-missing tokens were added mirroring `Theme.swift`'s remaining gradients/colors (`loop_card`/`loop_card_attention`/`loop_bar` gradient pairs, `tab_bar_gloss`/`tab_bar_highlight`/`tab_bar_shadow_line`, `control_gloss`/`control_gloss_hovered`/`control_border`, `activity_strip`, `sheet`/`draft_field`/`onboarding_sheet`, `folder_glyph`, `loop_card_border`/`loop_card_attention_border`), each computed from its exact `Theme.swift` float RGB (or, for `.opacity(x)` scrims that mac paints over glass, pre-blended flat against the concrete opaque Windows surface they paint over, documented per-token, since this codebase has no `AlphaBlend`/`BLENDFUNCTION` path). A new `GdiGradient.zig` wraps Win32's classic `GradientFill` (`msimg32.dll`, newly linked in `build.zig`) with a flat-fill fallback, and is now wired into the tab strip, its "New Tab"/"Split" controls, and the loop bar in `TerminalSurface.zig`'s `paintChrome`/`paintLoopBar` (previously flat single-color fills). This is source-level/math verification plus `zig ast-check` on every touched file (the pinned Zig 0.15.2 toolchain was unavailable locally; CI is authoritative), not a live rendered screenshot, so this remains Partial | Partial | + **Known out-of-scope CI gap surfaced while validating the row above (PR #398):** the live `windows-shell` UIA gate's "New Loop" assertion invoked via the sidebar's `project-new-loop-*` element (`Tools/windows/uia-live-gate.ps1`, "project-row New Loop did not open the node form") fails intermittently/deterministically across unrelated branches (reproduced on `coneilen-microsoft-canvas-workspace-detail-parity` and `coneilen-microsoft-updates-dialogs-quick-chats-parity` as well, with no relation to dialog rendering code). This is pre-existing test-infrastructure flakiness, not a visual-polish regression; it is out of this pass's scope and is flagged here for a dedicated follow-up. ## Audit conclusion From 723897d268a7f7b529b933178910851dcdb00b5d Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 16:34:18 -0700 Subject: [PATCH 07/13] Keep GDI+ helper window out of daemon handoff tests GdiplusStartup may create a process-owned helper window before GraphCode creates its main window. The daemon handoff live test locates the shell by enumerating the first top-level window for each process, so both supervisor-state probes read the helper window and remained at state 0 despite the daemon publishing successfully. Skip the visual-only GDI+ startup only when GRAPHCODE_DAEMON_SUPERVISOR_TEST_HOOK is enabled. Production anti-aliasing is unchanged, while the test process again has the single GraphCode window its explicit hook contract expects. RED: windows-shell attempts 1 and 2 on d0ea8f2 both expired after ~31s with owner=0 and contender=0 while the daemon and both shells remained alive. GREEN: pinned Zig 0.15.2 windows-shell validation reached Concurrent two-shell daemon handoff: PASS with this fix. REGRESSION: the staged DaemonHandoff.Live.Tests.ps1 test passed 5/5 consecutive runs; all 95 shell unit/contract tests also passed. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/App.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index bd7613b9..f6991751 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -374,7 +374,11 @@ pub const App = struct { const com_result = c.CoInitializeEx(null, c.COINIT_APARTMENTTHREADED); if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); - GdiplusAA.init(); + const daemon_supervisor_test_hook = envFlag(daemon_supervisor_test_hook_environment); + // GDI+ may create a process-owned helper window. The daemon handoff + // test intentionally identifies the shell through its sole top-level + // window, so keep that visual-only subsystem disabled for this hook. + if (!daemon_supervisor_test_hook) GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); self.tray.test_hook_enabled = self.tray_test_hook_enabled; self.tray.add(self.window.hwnd) catch self.setStatus("System tray unavailable; GraphCode remains open"); @@ -383,7 +387,7 @@ pub const App = struct { defer if (endpoint.len != 0) self.allocator.free(endpoint); defer if (lock_name.len != 0) self.allocator.free(lock_name); if (endpoint.len != 0 and lock_name.len != 0) self.daemon.start(endpoint, lock_name); - if (envFlag(daemon_supervisor_test_hook_environment)) { + if (daemon_supervisor_test_hook) { const state: usize = if (self.daemon.owned) 1 else if (self.daemon.status().len == 0) 2 else 3; _ = c.SetPropW( self.window.hwnd, @@ -420,7 +424,7 @@ pub const App = struct { const shell_test = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON") catch null; defer if (shell_test) |value| self.allocator.free(value); if (!envFlag("GRAPHCODE_UIA_GATE") and - !envFlag(daemon_supervisor_test_hook_environment) and + !daemon_supervisor_test_hook and (shell_test == null or !std.mem.eql(u8, shell_test.?, "1"))) { const initial_backend = if (self.product_settings) |settings| settings.default_backend else "claudeCode"; From 6332cc95561d63efd7d7a147cc115a6a772ce0c2 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 20:29:28 -0700 Subject: [PATCH 08/13] Keep GDI+ out of UIA live-test sessions GDI+ startup can create process-owned helper UI and alter foreground behavior. The live UIA gate already runs under an explicit environment hook and does not validate canvas anti-aliasing, so skip GDI+ startup there just as the daemon handoff hook does. Normal application launches retain GDI+ anti-aliasing unchanged. Add a Windows shell source contract so both automation hooks remain isolated from GDI+ helper-window startup. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 4 ++++ graphcode-windows/src/App.zig | 10 ++++++---- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index e97f13db..b5ce1a2e 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -40,6 +40,10 @@ Assert-Contract ($appSource -match $appSource -match 'if \(!envFlag\("GRAPHCODE_UIA_UPDATE_AVAILABLE"\)\) self\.requestUpdateCheck\(false\)' -and $appSource -match 'shouldPresentOffer\(self\.update_user_initiated\)') ` "explicit and background update checks must preserve their presentation intent" +Assert-Contract ($appSource -match + 'const uia_gate = envFlag\("GRAPHCODE_UIA_GATE"\);' -and + $appSource -match 'if \(!daemon_supervisor_test_hook and !uia_gate\) GdiplusAA\.init\(\);') ` + "GDI+ helper-window startup must remain outside daemon-handoff and UIA automation hooks" Assert-Contract ($appSource -match '(?s)app\.smoke_tick >= 16 and\s*app\.client\.connectionState\(\) == \.connected and\s*app\.currentProject\(\) != null and app\.model\.selected\(\) != null and\s*!app\.smoke_action_requested') ` "smoke graph command must wait for connection and selection instead of a single tick" diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index f6991751..a7f199f3 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -375,10 +375,12 @@ pub const App = struct { if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); const daemon_supervisor_test_hook = envFlag(daemon_supervisor_test_hook_environment); + const uia_gate = envFlag("GRAPHCODE_UIA_GATE"); // GDI+ may create a process-owned helper window. The daemon handoff - // test intentionally identifies the shell through its sole top-level - // window, so keep that visual-only subsystem disabled for this hook. - if (!daemon_supervisor_test_hook) GdiplusAA.init(); + // and UIA live tests depend on deterministic top-level window and + // foreground behavior, so keep that visual-only subsystem disabled + // for both explicit automation hooks. + if (!daemon_supervisor_test_hook and !uia_gate) GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); self.tray.test_hook_enabled = self.tray_test_hook_enabled; self.tray.add(self.window.hwnd) catch self.setStatus("System tray unavailable; GraphCode remains open"); @@ -423,7 +425,7 @@ pub const App = struct { if (self.onboarding_store) |store| { const shell_test = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON") catch null; defer if (shell_test) |value| self.allocator.free(value); - if (!envFlag("GRAPHCODE_UIA_GATE") and + if (!uia_gate and !daemon_supervisor_test_hook and (shell_test == null or !std.mem.eql(u8, shell_test.?, "1"))) { From 232d6bc5b62ebc567af6652ebf173098eebe4779 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 20:44:20 -0700 Subject: [PATCH 09/13] Fix uia_gate identifier collision from prior commit The previous commit introduced a second `uia_gate` local in App.run(), colliding with an existing owned-string `uia_gate` used later in the same function for workspace bring-up decisions. Rename the new boolean flag to `uia_gate_hook` to remove the shadow, and update the WindowsShell.Tests.ps1 source contract to match. Verified with a full local `zig build` (pinned Zig 0.15.2, pinned winghostty/zmx providers) producing graphcode-windows.exe successfully, plus ValidationRunner.Tests.ps1 and WindowsShell.Tests.ps1 (all 95 tests) passing. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 4 ++-- graphcode-windows/src/App.zig | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index b5ce1a2e..0a686585 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -41,8 +41,8 @@ Assert-Contract ($appSource -match $appSource -match 'shouldPresentOffer\(self\.update_user_initiated\)') ` "explicit and background update checks must preserve their presentation intent" Assert-Contract ($appSource -match - 'const uia_gate = envFlag\("GRAPHCODE_UIA_GATE"\);' -and - $appSource -match 'if \(!daemon_supervisor_test_hook and !uia_gate\) GdiplusAA\.init\(\);') ` + 'const uia_gate_hook = envFlag\("GRAPHCODE_UIA_GATE"\);' -and + $appSource -match 'if \(!daemon_supervisor_test_hook and !uia_gate_hook\) GdiplusAA\.init\(\);') ` "GDI+ helper-window startup must remain outside daemon-handoff and UIA automation hooks" Assert-Contract ($appSource -match '(?s)app\.smoke_tick >= 16 and\s*app\.client\.connectionState\(\) == \.connected and\s*app\.currentProject\(\) != null and app\.model\.selected\(\) != null and\s*!app\.smoke_action_requested') ` diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a7f199f3..0e78a6b9 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -375,12 +375,12 @@ pub const App = struct { if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); const daemon_supervisor_test_hook = envFlag(daemon_supervisor_test_hook_environment); - const uia_gate = envFlag("GRAPHCODE_UIA_GATE"); + const uia_gate_hook = envFlag("GRAPHCODE_UIA_GATE"); // GDI+ may create a process-owned helper window. The daemon handoff // and UIA live tests depend on deterministic top-level window and // foreground behavior, so keep that visual-only subsystem disabled // for both explicit automation hooks. - if (!daemon_supervisor_test_hook and !uia_gate) GdiplusAA.init(); + if (!daemon_supervisor_test_hook and !uia_gate_hook) GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); self.tray.test_hook_enabled = self.tray_test_hook_enabled; self.tray.add(self.window.hwnd) catch self.setStatus("System tray unavailable; GraphCode remains open"); @@ -425,7 +425,7 @@ pub const App = struct { if (self.onboarding_store) |store| { const shell_test = std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_SHELL_REQUIRE_DAEMON") catch null; defer if (shell_test) |value| self.allocator.free(value); - if (!uia_gate and + if (!uia_gate_hook and !daemon_supervisor_test_hook and (shell_test == null or !std.mem.eql(u8, shell_test.?, "1"))) { From dea35ae1abc72b2dbd24b5a4c5229dc53cc530dd Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 21:09:57 -0700 Subject: [PATCH 10/13] Reassert foreground repeatedly while the New Loop modal opens Root-cause analysis: the GDI+-isolation fix in the prior commit did not change this failure at all (identical foregroundProcess/foreground/expected handles across runs before and after), which rules out GDI+ startup as the cause of the CI-only "hosted-compute-agent repeatedly steals foreground during the New Loop modal-open wait" failure. The node-form modal's own paint path (NativeForms.zig's owner-drawn teaching tiles) is lightweight -- four BUTTON children with a cached font, no GDI+ -- so it was not delaying window-visible/SetForegroundWindow either. The modal previously called SetForegroundWindow exactly once, synchronously, right after ShowWindow. On a hosted CI runner that has its own periodic console-activation agent (`hosted-compute-agent`, observed re-stealing foreground roughly once a second for the full ~10s test-side recovery budget), a single attempt can lose that race indefinitely: the test's own in-wait recovery loop (added in #405) polls roughly once a second and always finds hosted-compute-agent back in front, because nothing on the product side ever contests it again after the first call. Fix: keep reasserting foreground from the product side for a short bounded window after the modal opens, using a WM_TIMER (120ms cadence, capped at 20 ticks / ~2.4s) that stops as soon as the modal genuinely holds the foreground or the tick budget is exhausted -- whichever comes first. This mirrors the existing AllowSetForegroundWindow + SetForegroundWindow pattern already used elsewhere in this codebase (MainWindow.zig's restoreExistingInstance) rather than inventing a new idiom, cannot loop forever, and never fights a user who deliberately switches away (it gives up the moment the window is no longer the target within GetForegroundWindow's observation, since it only re-asserts -- it does not repeatedly force focus after the user proves the window already had it and lost it legitimately by user action; the cap only guards against the pathological case). Verified with a full local `zig build` (pinned Zig 0.15.2, pinned winghostty provider) producing graphcode-windows.exe successfully, plus ValidationRunner.Tests.ps1 and WindowsShell.Tests.ps1 (all 95 tests) passing locally. The actual foreground-steal race can only be exercised against a live hosted-runner desktop session, so CI is the authoritative gate for confirming this resolves the failure; reported for a fresh CI run. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NativeForms.zig | 28 +++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index a18f9aab..4a7c9ee5 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -34,6 +34,7 @@ const DialogState = struct { tile_field_index: ?usize = null, tile_buttons: [max_tiles]c.HWND = .{null} ** max_tiles, tile_count: usize = 0, + foreground_reassert_ticks: usize = 0, }; const max_tiles = 8; @@ -53,6 +54,18 @@ const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm") const ok_id = 1; const cancel_id = 2; const reveal_id = 3; + +// A single SetForegroundWindow call right after ShowWindow can lose a race +// against another process that keeps re-stealing the foreground shortly +// after (observed in CI as a hosted runner agent's own periodic console +// activation). Rather than relying on one attempt, keep reasserting for a +// short window after the modal opens -- this is cheap, product-side, and +// gives up once the modal genuinely holds the foreground or a bounded +// number of ticks elapses, so it can never loop forever or fight a user +// who deliberately switches away. +const foreground_reassert_timer_id: usize = 771; +const foreground_reassert_interval_ms: c.UINT = 120; +const foreground_reassert_max_ticks: usize = 20; var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -904,6 +917,8 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); if (value.kind == .worktree_sweep) createButton(safe_hwnd, "Show in Explorer", reveal_id, 300, client.bottom - 38); createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); + value.foreground_reassert_ticks = 0; + _ = c.SetTimer(safe_hwnd, foreground_reassert_timer_id, foreground_reassert_interval_ms, null); return 0; }, c.WM_ERASEBKGND => { @@ -1026,6 +1041,18 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) applyModalCommand(value, .close); return 0; }, + c.WM_TIMER => { + if (wparam == foreground_reassert_timer_id) { + value.foreground_reassert_ticks += 1; + if (c.GetForegroundWindow() == safe_hwnd or value.foreground_reassert_ticks >= foreground_reassert_max_ticks) { + _ = c.KillTimer(safe_hwnd, foreground_reassert_timer_id); + } else { + _ = c.AllowSetForegroundWindow(c.GetCurrentProcessId()); + _ = c.SetForegroundWindow(safe_hwnd); + } + return 0; + } + }, c.WM_SETFOCUS => { if (c.GetFocus()) |focused| { for (0..20) |index| { @@ -1037,6 +1064,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } }, c.WM_DESTROY => { + _ = c.KillTimer(safe_hwnd, foreground_reassert_timer_id); applyModalCommand(value, .destroy); return 0; }, From 415fda7237231c8f3694ab89b45b9ca9d2696ad0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 21:28:26 -0700 Subject: [PATCH 11/13] Use AttachThreadInput to make foreground reassertion actually take effect Evidence from the previous commit's fresh CI run: the plain AllowSetForegroundWindow(GetCurrentProcessId()) + SetForegroundWindow retry loop had zero measurable effect -- the failure was byte-for-byte identical (same 9 recovery attempts, same ~10s timeline, same hosted-compute-agent foreground handle) whether that retry loop was present or not. That result rules out "not retrying enough/often enough" as the cause and points at Windows' foreground-lock heuristic outright refusing the calls: an app whose window is driven purely by UI Automation Invoke calls, without real keyboard/mouse input ever reaching its message queue, is treated as a background process and SetForegroundWindow silently no-ops for it. AllowSetForegroundWindow only grants a *different* process/thread permission to call SetForegroundWindow; calling it on our own process id grants nothing. The documented, reliable way around the lock is to temporarily attach this thread's input queue to the current foreground window's thread via AttachThreadInput, call SetForegroundWindow while attached (Windows treats attached threads as sharing input-queue/activation state), then detach immediately. Replace the previous no-op self-grant with this technique in the same bounded WM_TIMER loop (120ms cadence, capped at 20 ticks), unchanged in every other respect: it still stops the instant the modal genuinely holds the foreground or the tick budget is exhausted, so it remains bounded and never fights a user who deliberately switches away. Verified with a full local `zig build` (pinned Zig 0.15.2, pinned winghostty provider) producing graphcode-windows.exe successfully, plus ValidationRunner.Tests.ps1 and WindowsShell.Tests.ps1 (all 95 tests) passing locally. As before, the actual foreground-steal race can only be exercised against a live hosted-runner desktop session, so CI remains the authoritative gate; reporting for a fresh run with the specific evidence that ruled out the prior two hypotheses (GDI+ startup timing, and insufficiently-privileged SetForegroundWindow retries). Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- graphcode-windows/src/NativeForms.zig | 31 ++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 4a7c9ee5..09b3fd56 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -66,6 +66,31 @@ const reveal_id = 3; const foreground_reassert_timer_id: usize = 771; const foreground_reassert_interval_ms: c.UINT = 120; const foreground_reassert_max_ticks: usize = 20; + +/// A plain SetForegroundWindow call can be silently ignored by Windows' +/// foreground-lock heuristic when the calling process/thread hasn't +/// recently received real input -- exactly the situation for a window +/// driven purely by UI Automation Invoke calls rather than real keyboard +/// or mouse input. Temporarily attaching this thread's input queue to the +/// current foreground window's thread (the documented technique for this +/// exact restriction) makes SetForegroundWindow reliable regardless of +/// that heuristic, then detaches immediately afterward so no lasting +/// input-queue coupling remains between the two processes. +fn forceForeground(hwnd: c.HWND, current_foreground: c.HWND) void { + const our_thread = c.GetCurrentThreadId(); + var attached = false; + var foreground_thread: c.DWORD = 0; + if (current_foreground != null) { + foreground_thread = c.GetWindowThreadProcessId(current_foreground, null); + if (foreground_thread != 0 and foreground_thread != our_thread) { + attached = c.AttachThreadInput(our_thread, foreground_thread, 1) != 0; + } + } + _ = c.BringWindowToTop(hwnd); + _ = c.ShowWindow(hwnd, c.SW_SHOW); + _ = c.SetForegroundWindow(hwnd); + if (attached) _ = c.AttachThreadInput(our_thread, foreground_thread, 0); +} var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -1044,11 +1069,11 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) c.WM_TIMER => { if (wparam == foreground_reassert_timer_id) { value.foreground_reassert_ticks += 1; - if (c.GetForegroundWindow() == safe_hwnd or value.foreground_reassert_ticks >= foreground_reassert_max_ticks) { + const current = c.GetForegroundWindow(); + if (current == safe_hwnd or value.foreground_reassert_ticks >= foreground_reassert_max_ticks) { _ = c.KillTimer(safe_hwnd, foreground_reassert_timer_id); } else { - _ = c.AllowSetForegroundWindow(c.GetCurrentProcessId()); - _ = c.SetForegroundWindow(safe_hwnd); + forceForeground(safe_hwnd, current); } return 0; } From 62c7d616c0bb2ec3c9f1970fcb021fb18421bab9 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 21:56:59 -0700 Subject: [PATCH 12/13] Widen the New Loop node-form foreground-recovery budget to outlast sustained contention Three independently well-reasoned product-side fixes (GDI+ startup isolation, a bounded SetForegroundWindow retry timer, and finally the textbook-correct AttachThreadInput-based forced foreground technique) all produced a byte-for-byte identical CI failure: hosted-compute-agent wins 9/9 foreground recovery attempts over ~10s on every run of this branch, while the same test code passes cleanly with zero contention on other branches (e.g. #405's own). A wall-clock comparison shows this branch reaches the live UIA gate in the same ~13m15s baseline branches take (13m17s) - there is no rendering-cost regression delaying the modal. The difference is qualitative (100% recovery failure vs 0% on baseline), not a timing/slowness difference, which rules out "make the modal appear faster" as a viable fix. Since the live gate's own recovery loop already uses the strongest available technique (AttachThreadInput against both the current foreground window and the target window, i.e. the Alt-tap bypass added for the #404 fix) and it still loses every attempt within the previous 10s budget, the only evidence-based lever left is time: raise the ceiling so the recovery loop can outlast a longer window of sustained contention before giving up. Tools/windows/uia-live-gate.ps1: - Add $script:ForegroundRecoveryTimeoutMilliseconds = 30000 (3x the previous 10s default). - Wait-ForDesktopElement now applies this widened ceiling automatically when a caller opts into -RecoverForeground and leaves TimeoutMilliseconds at its default value, so the New Loop node-form waits (project-row, empty global, empty project) all get the wider budget without needing every call site edited individually, and without affecting any other wait's timeout. Tools/windows/Tests/WindowsShell.Tests.ps1: - Load uia-live-gate.ps1 as $liveGateSource and add three Assert-Contract regression checks: the widened constant exists, Wait-ForDesktopElement applies it only for RecoverForeground waits at the default timeout, and the project-row New Loop node-form wait still opts into recovery. This guards the fix itself and its scoping against future accidental reverts, without weakening any existing check. Verified locally with the pinned Zig 0.15.2 toolchain: - Tools/windows/Tests/WindowsShell.Tests.ps1 -ZigExecutable : 95/95 zig tests pass, all Assert-Contract checks (including the 3 new ones) pass. - Tools/windows/Tests/ValidationRunner.Tests.ps1: PASS. - PowerShell parser validation of uia-live-gate.ps1: no syntax errors. This change is scoped to the test harness only; no product/application code is touched. Per prior coordinator direction this file was previously off-limits without explicit authorization, which was given for this specific budget-widening change. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 10 ++++++++++ Tools/windows/uia-live-gate.ps1 | 21 +++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 0a686585..59d3713b 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -35,6 +35,7 @@ $mainWindowSource = Get-Content (Join-Path $shellRoot "src\MainWindow.zig") -Raw $nativeFormsSource = Get-Content (Join-Path $shellRoot "src\NativeForms.zig") -Raw $inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw $stubSource = Get-Content (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1") -Raw +$liveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw Assert-Contract ($appSource -match '(?s)pub fn checkForUpdates.*?requestUpdateCheck\(true\)' -and $appSource -match 'if \(!envFlag\("GRAPHCODE_UIA_UPDATE_AVAILABLE"\)\) self\.requestUpdateCheck\(false\)' -and @@ -68,6 +69,15 @@ Assert-Contract ($shellSource -match '(?s)\$evidence = .*?STUB_DAEMON_EVIDENCE_J "stub protocol evidence is not emitted before validation can fail" Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` "session tracking must not block on unrelated zmx namespaces" +Assert-Contract ($liveGateSource -match + '\$script:ForegroundRecoveryTimeoutMilliseconds = 30000') ` + "New Loop node-form waits must reserve a widened (3x default) foreground-recovery budget to outlast sustained hosted-runner foreground contention" +Assert-Contract ($liveGateSource -match + '(?s)if \(\$RecoverForeground -and \$TimeoutMilliseconds -eq 10000\) \{\s*\$TimeoutMilliseconds = \$script:ForegroundRecoveryTimeoutMilliseconds\s*\}') ` + "Wait-ForDesktopElement must apply the widened recovery budget only to waits that opt into foreground recovery, leaving other waits' timeouts unchanged" +Assert-Contract ($liveGateSource -match + '(?s)-condition \$sidebarNodeFormCondition.*?-label "project-row New Loop node form".*?-RecoverForeground') ` + "project-row New Loop node-form wait must keep opting into foreground recovery" & { $sessionPrefix = "gs-owned" $testSessionIds = @("11111111-1111-4111-8111-111111111111") diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 606b388f..7eab5873 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -7,6 +7,16 @@ param( ) $ErrorActionPreference = "Stop" + +# Sustained hosted-runner foreground contention (e.g. a hosted-compute-agent +# process reasserting foreground continuously, not just stealing it once) +# has been observed to exhaust the previous 10s foreground-recovery budget +# on every single attempt. This raises the ceiling specifically for waits +# that opt into foreground recovery (RecoverForeground), giving the retry +# loop meaningfully more time to find a gap in sustained contention, while +# leaving every other (non-recovering) wait's default timeout untouched. +$script:ForegroundRecoveryTimeoutMilliseconds = 30000 + Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes Add-Type -TypeDefinition @" @@ -327,6 +337,17 @@ function Wait-ForDesktopElement( [int] $PollMilliseconds = 50, [switch] $RecoverForeground ) { + # When recovering foreground ownership, a hosted CI runner agent has been + # observed asserting foreground *continuously* for several seconds at a + # time (not a single, brief steal), which can exhaust the default 10s + # budget with every single recovery attempt losing the race. Give the + # recovery loop meaningfully more wall-clock time to outlast a sustained + # contention window before giving up, without slowing down waits that + # never lose foreground in the first place (those still resolve on their + # first FindFirst() and never touch this larger ceiling). + if ($RecoverForeground -and $TimeoutMilliseconds -eq 10000) { + $TimeoutMilliseconds = $script:ForegroundRecoveryTimeoutMilliseconds + } $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) $element = $null $foregroundRecoveries = 0 From 84e86f40120ac15bacd920549f572e6dd4cb0ca3 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Mon, 21 Sep 2026 22:50:59 -0700 Subject: [PATCH 13/13] Fix HWND alignment panic that crashed the node form mid-layout The windows-shell/windows-spikes CI failures on this branch were never a foreground-contention problem. The `hosted-compute-agent` foreground diagnostics were a downstream symptom: the shell process was dying, so the node form never appeared and the OS fell back to whatever held foreground before. Root cause, captured by redirecting the shell's stderr during a local uia-live-gate.ps1 reproduction: thread 33580 panic: incorrect alignment NativeForms.zig:914: const safe_hwnd: c.HWND = @ptrFromInt(@intFromPtr(hwnd.?)); ... layoutForm -> MoveWindow -> windowProc -> show -> createNode `@ptrFromInt(@intFromPtr(x))` is a no-op round-trip that re-derives the same value but reintroduces Zig's pointer-alignment safety assertion. Win32 handles are opaque tokens, not aligned pointers, so a ReleaseSafe build aborts whenever the window manager hands back an unaligned HWND. The line predates this branch; the dark-theme dialogs add several extra child windows (teaching tiles, styled controls) which shift the handle allocation sequence into the unaligned case -- deterministically under CI's fixed window-creation order. Changes: - Use `hwnd` directly instead of the round-trip cast. - Route the WM_ERASEBKGND HDC casts in NativeForms.zig and WindowsRepositoryDialogs.zig through the existing `deviceContextFrom` helper, and add a matching `drawItemFrom` helper for WM_DRAWITEM, so every handle conversion sits behind `@setRuntimeSafety(false)`. - Add a focused regression test exercising all three helpers with deliberately unaligned values. Also reverts the mitigations built on the disproven foreground theory: the product-side SetForegroundWindow/AttachThreadInput reassert timer (an actual deadlock hazard against a foreign busy process) and the widened in-wait recovery budget in uia-live-gate.ps1. The GDI+ startup-ordering isolation from dea35ae is kept, since it stands on its own. Evidence: local uia-live-gate.ps1 reproduced the panic before the fix and now passes cleanly; WindowsShell.Tests.ps1 79/79 native-form tests (including the new one) and ValidationRunner.Tests.ps1 pass; full ReleaseSafe zig build clean. Note for follow-up: the same latent cast exists on main for other window procedures and may be worth auditing outside this branch's scope. Signed-off-by: Colin Neilens Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 10 -- Tools/windows/uia-live-gate.ps1 | 21 ---- graphcode-windows/src/NativeForms.zig | 103 ++++++++---------- .../src/WindowsRepositoryDialogs.zig | 2 +- 4 files changed, 49 insertions(+), 87 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 59d3713b..0a686585 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -35,7 +35,6 @@ $mainWindowSource = Get-Content (Join-Path $shellRoot "src\MainWindow.zig") -Raw $nativeFormsSource = Get-Content (Join-Path $shellRoot "src\NativeForms.zig") -Raw $inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw $stubSource = Get-Content (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1") -Raw -$liveGateSource = Get-Content (Join-Path $repoRoot "Tools\windows\uia-live-gate.ps1") -Raw Assert-Contract ($appSource -match '(?s)pub fn checkForUpdates.*?requestUpdateCheck\(true\)' -and $appSource -match 'if \(!envFlag\("GRAPHCODE_UIA_UPDATE_AVAILABLE"\)\) self\.requestUpdateCheck\(false\)' -and @@ -69,15 +68,6 @@ Assert-Contract ($shellSource -match '(?s)\$evidence = .*?STUB_DAEMON_EVIDENCE_J "stub protocol evidence is not emitted before validation can fail" Assert-Contract ($shellSource -notmatch '\$env:GRAPHCODE_ZMX list') ` "session tracking must not block on unrelated zmx namespaces" -Assert-Contract ($liveGateSource -match - '\$script:ForegroundRecoveryTimeoutMilliseconds = 30000') ` - "New Loop node-form waits must reserve a widened (3x default) foreground-recovery budget to outlast sustained hosted-runner foreground contention" -Assert-Contract ($liveGateSource -match - '(?s)if \(\$RecoverForeground -and \$TimeoutMilliseconds -eq 10000\) \{\s*\$TimeoutMilliseconds = \$script:ForegroundRecoveryTimeoutMilliseconds\s*\}') ` - "Wait-ForDesktopElement must apply the widened recovery budget only to waits that opt into foreground recovery, leaving other waits' timeouts unchanged" -Assert-Contract ($liveGateSource -match - '(?s)-condition \$sidebarNodeFormCondition.*?-label "project-row New Loop node form".*?-RecoverForeground') ` - "project-row New Loop node-form wait must keep opting into foreground recovery" & { $sessionPrefix = "gs-owned" $testSessionIds = @("11111111-1111-4111-8111-111111111111") diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 7eab5873..606b388f 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -7,16 +7,6 @@ param( ) $ErrorActionPreference = "Stop" - -# Sustained hosted-runner foreground contention (e.g. a hosted-compute-agent -# process reasserting foreground continuously, not just stealing it once) -# has been observed to exhaust the previous 10s foreground-recovery budget -# on every single attempt. This raises the ceiling specifically for waits -# that opt into foreground recovery (RecoverForeground), giving the retry -# loop meaningfully more time to find a gap in sustained contention, while -# leaving every other (non-recovering) wait's default timeout untouched. -$script:ForegroundRecoveryTimeoutMilliseconds = 30000 - Add-Type -AssemblyName UIAutomationClient Add-Type -AssemblyName UIAutomationTypes Add-Type -TypeDefinition @" @@ -337,17 +327,6 @@ function Wait-ForDesktopElement( [int] $PollMilliseconds = 50, [switch] $RecoverForeground ) { - # When recovering foreground ownership, a hosted CI runner agent has been - # observed asserting foreground *continuously* for several seconds at a - # time (not a single, brief steal), which can exhaust the default 10s - # budget with every single recovery attempt losing the race. Give the - # recovery loop meaningfully more wall-clock time to outlast a sustained - # contention window before giving up, without slowing down waits that - # never lose foreground in the first place (those still resolve on their - # first FindFirst() and never touch this larger ceiling). - if ($RecoverForeground -and $TimeoutMilliseconds -eq 10000) { - $TimeoutMilliseconds = $script:ForegroundRecoveryTimeoutMilliseconds - } $deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMilliseconds) $element = $null $foregroundRecoveries = 0 diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 09b3fd56..fc8f80d1 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -34,7 +34,6 @@ const DialogState = struct { tile_field_index: ?usize = null, tile_buttons: [max_tiles]c.HWND = .{null} ** max_tiles, tile_count: usize = 0, - foreground_reassert_ticks: usize = 0, }; const max_tiles = 8; @@ -55,42 +54,6 @@ const ok_id = 1; const cancel_id = 2; const reveal_id = 3; -// A single SetForegroundWindow call right after ShowWindow can lose a race -// against another process that keeps re-stealing the foreground shortly -// after (observed in CI as a hosted runner agent's own periodic console -// activation). Rather than relying on one attempt, keep reasserting for a -// short window after the modal opens -- this is cheap, product-side, and -// gives up once the modal genuinely holds the foreground or a bounded -// number of ticks elapses, so it can never loop forever or fight a user -// who deliberately switches away. -const foreground_reassert_timer_id: usize = 771; -const foreground_reassert_interval_ms: c.UINT = 120; -const foreground_reassert_max_ticks: usize = 20; - -/// A plain SetForegroundWindow call can be silently ignored by Windows' -/// foreground-lock heuristic when the calling process/thread hasn't -/// recently received real input -- exactly the situation for a window -/// driven purely by UI Automation Invoke calls rather than real keyboard -/// or mouse input. Temporarily attaching this thread's input queue to the -/// current foreground window's thread (the documented technique for this -/// exact restriction) makes SetForegroundWindow reliable regardless of -/// that heuristic, then detaches immediately afterward so no lasting -/// input-queue coupling remains between the two processes. -fn forceForeground(hwnd: c.HWND, current_foreground: c.HWND) void { - const our_thread = c.GetCurrentThreadId(); - var attached = false; - var foreground_thread: c.DWORD = 0; - if (current_foreground != null) { - foreground_thread = c.GetWindowThreadProcessId(current_foreground, null); - if (foreground_thread != 0 and foreground_thread != our_thread) { - attached = c.AttachThreadInput(our_thread, foreground_thread, 1) != 0; - } - } - _ = c.BringWindowToTop(hwnd); - _ = c.ShowWindow(hwnd, c.SW_SHOW); - _ = c.SetForegroundWindow(hwnd); - if (attached) _ = c.AttachThreadInput(our_thread, foreground_thread, 0); -} var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -682,6 +645,15 @@ fn controlHandleFrom(lparam: c.LPARAM) c.HWND { return @ptrFromInt(@as(usize, @bitCast(lparam))); } +/// Win32 passes the `DRAWITEMSTRUCT` address through `lparam`. Safety is +/// disabled for the same reason as the handle conversions above: the value +/// arrives as a raw integer and must not be subjected to Zig's pointer +/// alignment assertions, which abort the process rather than fail softly. +fn drawItemFrom(lparam: c.LPARAM) *c.DRAWITEMSTRUCT { + @setRuntimeSafety(false); + return @ptrFromInt(@as(usize, @bitCast(lparam))); +} + /// Blends `overlay` into `base` at `percent` strength (0-100), approximating /// the translucent accent fills LoopTypeChooser.swift layers over its dark /// background (`type.accent.opacity(0.12)` selected / `Color.white.opacity(0.035)` @@ -911,7 +883,15 @@ fn fieldHelp(kind: Kind, index: usize) []const u8 { fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.winapi) c.LRESULT { if (!active_state) return c.DefWindowProcW(hwnd, message, wparam, lparam); - const safe_hwnd: c.HWND = @ptrFromInt(@intFromPtr(hwnd.?)); + // `hwnd` is already a `c.HWND`; the previous `@ptrFromInt(@intFromPtr(...))` + // round-trip only re-derived the same value, but it reintroduced a + // pointer-alignment safety check against a value that is a Win32 *handle*, + // not a real aligned pointer. Window handles are opaque, arbitrarily + // valued tokens, so whenever the window manager happened to hand out an + // unaligned handle the round-trip aborted the process with + // "panic: incorrect alignment" while the form was laying out. Use the + // parameter directly so no alignment assumption is made about a handle. + const safe_hwnd: c.HWND = hwnd; const value = &active_state_storage; switch (message) { c.WM_CREATE => { @@ -942,12 +922,10 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); if (value.kind == .worktree_sweep) createButton(safe_hwnd, "Show in Explorer", reveal_id, 300, client.bottom - 38); createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); - value.foreground_reassert_ticks = 0; - _ = c.SetTimer(safe_hwnd, foreground_reassert_timer_id, foreground_reassert_interval_ms, null); return 0; }, c.WM_ERASEBKGND => { - const hdc: c.HDC = @ptrFromInt(wparam); + const hdc = deviceContextFrom(wparam); var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); fillFormBackground(hdc, client); @@ -957,7 +935,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) c.WM_CTLCOLOREDIT => return formCtlColorEdit(wparam), c.WM_CTLCOLORLISTBOX => return formCtlColorEdit(wparam), c.WM_DRAWITEM => { - const draw_item: *c.DRAWITEMSTRUCT = @ptrFromInt(@as(usize, @bitCast(lparam))); + const draw_item = drawItemFrom(lparam); if (value.tile_field_index != null and draw_item.CtlID >= tile_base_id and draw_item.CtlID < tile_base_id + max_tiles) { drawTile(value, draw_item.CtlID - tile_base_id, draw_item); return 1; @@ -1066,18 +1044,6 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) applyModalCommand(value, .close); return 0; }, - c.WM_TIMER => { - if (wparam == foreground_reassert_timer_id) { - value.foreground_reassert_ticks += 1; - const current = c.GetForegroundWindow(); - if (current == safe_hwnd or value.foreground_reassert_ticks >= foreground_reassert_max_ticks) { - _ = c.KillTimer(safe_hwnd, foreground_reassert_timer_id); - } else { - forceForeground(safe_hwnd, current); - } - return 0; - } - }, c.WM_SETFOCUS => { if (c.GetFocus()) |focused| { for (0..20) |index| { @@ -1089,7 +1055,6 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } }, c.WM_DESTROY => { - _ = c.KillTimer(safe_hwnd, foreground_reassert_timer_id); applyModalCommand(value, .destroy); return 0; }, @@ -1660,6 +1625,34 @@ fn utf8ToWideZ(allocator: std.mem.Allocator, value: []const u8) ![]u16 { return result; } +// Win32 handles (HWND/HDC) and message-supplied struct addresses are opaque +// integers, not guaranteed-aligned pointers. Converting them with a plain +// `@ptrFromInt` makes Zig assert pointer alignment and abort the process with +// "panic: incorrect alignment" whenever the window manager hands back an +// unaligned value -- which crashed the node form mid-layout. Every conversion +// must therefore go through a `@setRuntimeSafety(false)` helper. These odd +// (deliberately unaligned) values reproduce the original panic if that +// guarantee regresses. +test "win32 handle conversions tolerate unaligned handle values" { + const unaligned: usize = 0x0002_0311; + try std.testing.expectEqual(unaligned, @intFromPtr(deviceContextFrom(unaligned))); + try std.testing.expectEqual( + unaligned, + @intFromPtr(controlHandleFrom(@as(c.LPARAM, @bitCast(unaligned)))), + ); + try std.testing.expectEqual( + unaligned, + @intFromPtr(drawItemFrom(@as(c.LPARAM, @bitCast(unaligned)))), + ); + // A handle whose low bit is set is the exact shape that aborted before. + const odd: usize = 0x000b_0b0b; + try std.testing.expectEqual(odd, @intFromPtr(deviceContextFrom(odd))); + try std.testing.expectEqual( + odd, + @intFromPtr(controlHandleFrom(@as(c.LPARAM, @bitCast(odd)))), + ); +} + test "modal submit and cancel transitions always terminate the loop" { var state = DialogState{ .allocator = undefined, .kind = .node, .parent = null }; applyModalCommand(&state, .submit); diff --git a/graphcode-windows/src/WindowsRepositoryDialogs.zig b/graphcode-windows/src/WindowsRepositoryDialogs.zig index 2b25c8f0..4f8ffa10 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -6,7 +6,7 @@ const AppFont = @import("AppFont.zig"); extern fn graphcode_pick_folder(owner: c.HWND, buffer: [*]u16, capacity: c.DWORD) callconv(.c) c_int; fn darkDialogEraseBackground(hwnd: c.HWND, wparam: c.WPARAM) c.LRESULT { - const hdc: c.HDC = @ptrFromInt(wparam); + const hdc = deviceContextFrom(wparam); var client: c.RECT = undefined; _ = c.GetClientRect(hwnd, &client); const brush = c.CreateSolidBrush(Tokens.dialog_panel);