Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions graphcode-windows/build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ pub fn build(b: *std.Build) !void {
for ([_][]const u8{
"user32",
"gdi32",
"gdiplus",
"msimg32",
"opengl32",
"kernel32",
"imm32",
Expand Down
14 changes: 11 additions & 3 deletions graphcode-windows/src/App.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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");
Expand All @@ -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,
Expand Down Expand Up @@ -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";
Expand Down
93 changes: 93 additions & 0 deletions graphcode-windows/src/AppFont.zig
Original file line number Diff line number Diff line change
@@ -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));
}
114 changes: 107 additions & 7 deletions graphcode-windows/src/DesignTokens.zig
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand Down
69 changes: 69 additions & 0 deletions graphcode-windows/src/GdiGradient.zig
Original file line number Diff line number Diff line change
@@ -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);
}
Loading
Loading