diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index e97f13db..0a686585 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_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') ` "smoke graph command must wait for connection and selection instead of a single tick" diff --git a/graphcode-windows/build.zig b/graphcode-windows/build.zig index bcaef4b3..d1a73e77 100644 --- a/graphcode-windows/build.zig +++ b/graphcode-windows/build.zig @@ -59,6 +59,8 @@ pub fn build(b: *std.Build) !void { for ([_][]const u8{ "user32", "gdi32", + "gdiplus", + "msimg32", "opengl32", "kernel32", "imm32", diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 1bd15418..0e78a6b9 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,13 @@ pub const App = struct { const com_result = c.CoInitializeEx(null, c.COINIT_APARTMENTTHREADED); if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); + const daemon_supervisor_test_hook = envFlag(daemon_supervisor_test_hook_environment); + 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_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"); @@ -381,7 +389,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, @@ -417,8 +425,8 @@ 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 - !envFlag(daemon_supervisor_test_hook_environment) and + if (!uia_gate_hook 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"; 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/DesignTokens.zig b/graphcode-windows/src/DesignTokens.zig index 64161c5f..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; @@ -23,6 +109,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/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/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..81694b3b 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 { @@ -490,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)); @@ -501,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); @@ -524,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); @@ -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); @@ -1061,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); @@ -1591,20 +1606,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 +1671,16 @@ 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; + // 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; + 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 +1707,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 +1726,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/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index 27c8e69a..fc8f80d1 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, @@ -46,16 +53,24 @@ const class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeForm") const ok_id = 1; const cancel_id = 2; const reveal_id = 3; + var active_state: bool = false; 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 +139,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 +589,164 @@ 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; +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 = darkPanelBrush(); + if (brush == null) return; + _ = c.FillRect(hdc, &bounds, 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))); +} + +/// 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)` +/// 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); +} + +// 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 = 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); +} + fn configureFields(state: *DialogState) void { state.field_count = switch (state.kind) { .node => 14, @@ -576,7 +760,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; @@ -699,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 => { @@ -732,6 +924,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 = deviceContextFrom(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 = 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; + } + return 0; + }, c.WM_SIZE => { var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); @@ -773,6 +983,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 +1084,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 +1131,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 +1181,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 +1222,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 +1332,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 +1433,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; @@ -1351,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); @@ -1512,3 +1814,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/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..7ad6e65f 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -2,6 +2,8 @@ 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 GdiGradient = @import("GdiGradient.zig"); const columns: usize = 120; const rows: usize = 40; @@ -702,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; @@ -718,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| { @@ -770,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, @@ -796,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; } @@ -1735,10 +1749,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 7c24a045..4f8ffa10 100644 --- a/graphcode-windows/src/WindowsRepositoryDialogs.zig +++ b/graphcode-windows/src/WindowsRepositoryDialogs.zig @@ -1,8 +1,54 @@ 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; +fn darkDialogEraseBackground(hwnd: c.HWND, wparam: c.WPARAM) c.LRESULT { + const hdc = deviceContextFrom(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 +689,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 +701,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); @@ -784,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; } @@ -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| { @@ -1144,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 7c73a6b5..7f2a8698 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,13 @@ 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 | +| 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