From cdedf2f5e2fe93767c759ce573c52868fa5c2128 Mon Sep 17 00:00:00 2001 From: Robert Sim Date: Thu, 24 Sep 2026 16:59:39 -0700 Subject: [PATCH 1/4] Stabilize and scale the Windows shell Fix daemon-facing shell diagnostics and template fallback, eliminate paint and menu flicker, and make the native UI consistently DPI-aware. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 27 ++ graphcode-windows/src/App.zig | 279 ++++++++++++----- graphcode-windows/src/AppFont.zig | 43 ++- graphcode-windows/src/Diagnostics.zig | 46 +++ graphcode-windows/src/GraphCanvas.zig | 6 +- graphcode-windows/src/MainWindow.zig | 18 +- graphcode-windows/src/NativeForms.zig | 343 ++++++++++++--------- graphcode-windows/src/TemplateLibrary.zig | 20 +- graphcode-windows/src/main.zig | 6 +- 9 files changed, 548 insertions(+), 240 deletions(-) create mode 100644 graphcode-windows/src/Diagnostics.zig diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 9420f680..e6cb8b17 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -39,6 +39,10 @@ $traySource = Get-Content (Join-Path $shellRoot "src\Tray.zig") -Raw $win32Source = Get-Content (Join-Path $shellRoot "src\Win32.zig") -Raw $inputSource = Get-Content (Join-Path $shellRoot "src\InputRouter.zig") -Raw $stubSource = Get-Content (Join-Path $repoRoot "Tools\windows\Stub-Daemon.ps1") -Raw +$menuTimerBlock = [regex]::Match( + $appSource, + '(?s)else if \(wparam == MainWindow\.timer_id\) \{.*?const updated_connection_state' +).Value Assert-Contract ($win32Source -match '(?s)pub fn opaquePointerFromInt.*?@setRuntimeSafety\(false\);.*?@ptrFromInt\(value\)' -and $win32Source -match 'pub fn messagePointer' -and @@ -66,6 +70,16 @@ Assert-Contract ($nativeFormsSource -match 'if \(active_state\) return error\.Fo $nativeFormsSource -match 'pub fn isModalActive\(\) bool' -and $appSource -match 'UpdateOfferPresentation\.decide\(completed_offer, self\.update_offer_pending, NativeForms\.isModalActive\(\)\)') ` "native forms must reject reentrancy and completed update offers must wait for the active modal" +Assert-Contract ($mainWindowSource -match 'pub const MenuRefresh = enum' -and + $mainWindowSource -match 'if \(redrawsMenuBar\(refresh\)\) _ = c\.DrawMenuBar\(hwnd\);' -and + $appSource -match '(?s)c\.WM_INITMENUPOPUP.*?updateNativeChrome\(\.popup_open\)' -and + $menuTimerBlock -notmatch 'app\.updateNativeChrome') ` + "timer polling must not rebuild open popup menus or continuously redraw the menu bar" +Assert-Contract ($appSource -match 'SetMapMode\(hdc, c\.MM_ANISOTROPIC\)' -and + $appSource -match 'SetWindowExtEx\(hdc, logical_right, logical_bottom' -and + $appSource -match 'logicalCoordinate\(mouseX\(lparam\), app\.dpi\)' -and + $appSource -match 'physicalCoordinate\(Tokens\.sidebar_width, self\.dpi\)') ` + "custom main-window painting, input, and child layout must share one DPI-scaled coordinate system" 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\(\);') ` @@ -182,6 +196,7 @@ foreach ($path in @( "src\main.zig", "src\Win32.zig", "src\App.zig", + "src\Diagnostics.zig", "src\MainWindow.zig", "src\DaemonClient.zig", "src\GraphModel.zig", @@ -297,6 +312,14 @@ Assert-Contract ($callbackIndex -ge 0 -and $defaultIndex -gt $callbackIndex) ` $appSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\App.zig") -Raw Assert-Contract ($appSource -match "GraphCanvas\.paint[\s\S]+workspace\.paintChrome\(hdc\)") ` "WM_PAINT must render both the GraphCode canvas and terminal workspace chrome" +Assert-Contract ($appSource -match + '(?s)c\.WM_ERASEBKGND\s*=>.*?result\.\*\s*=\s*1;.*?c\.WM_PAINT\s*=>.*?CreateCompatibleDC.*?CreateCompatibleBitmap.*?BitBlt') ` + "top-level painting must suppress background erase and present one buffered frame" +Assert-Contract ($appSource -match + 'TemplateLibrary\.load\(self\.allocator, path\) catch \|err\|' -and + $appSource -match 'Diagnostics\.record\(self\.allocator, "error", message\)' -and + $appSource -match '(?s)"Unable to load saved templates".*?NativeForms\.node') ` + "template failures must be logged and fall back to the plain New Loop form" $codespaceDialogSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\WindowsCodespaceDialog.zig") -Raw $codespaceClientSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\Codespaces.zig") -Raw @@ -580,6 +603,10 @@ Invoke-Native "Template library executable tests" { Push-Location $shellRoot try { & $zig test src\TemplateLibrary.zig } finally { Pop-Location } } +Invoke-Native "Windows shell diagnostics executable tests" { + Push-Location $shellRoot + try { & $zig test src\Diagnostics.zig } finally { Pop-Location } +} Invoke-Native "Workspace lifecycle executable tests" { Push-Location $shellRoot try { & $zig test src\WorkspaceLifecycle.zig } finally { Pop-Location } diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index b9f63915..5c57ede0 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -9,6 +9,7 @@ const GraphContextMenu = @import("GraphContextMenu.zig"); const Forms = @import("Forms.zig"); const NativeForms = @import("NativeForms.zig"); const TemplateLibrary = @import("TemplateLibrary.zig"); +const Diagnostics = @import("Diagnostics.zig"); const JumpPalette = @import("JumpPalette.zig"); const NativeDialogs = @import("WindowsNativeDialogs.zig"); const Sidebar = @import("Sidebar.zig"); @@ -18,6 +19,7 @@ const MainWindow = @import("MainWindow.zig"); const TerminalWorkspace = @import("TerminalWorkspace.zig"); const Tokens = @import("DesignTokens.zig"); const Dpi = @import("Dpi.zig"); +const AppFont = @import("AppFont.zig"); const Wire = @import("Wire.zig"); const WorktreeStatus = @import("WorktreeStatus.zig"); const TrayModule = @import("Tray.zig"); @@ -72,6 +74,22 @@ fn inputBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControl }; } +fn logicalClientRect(hwnd: c.HWND, dpi: u32) c.RECT { + var client: c.RECT = undefined; + if (c.GetClientRect(hwnd, &client) == 0) return std.mem.zeroes(c.RECT); + client.right = Dpi.unscale(client.right, dpi); + client.bottom = Dpi.unscale(client.bottom, dpi); + return client; +} + +fn logicalCoordinate(value: i32, dpi: u32) i32 { + return Dpi.unscale(value, dpi); +} + +fn physicalCoordinate(value: i32, dpi: u32) i32 { + return Dpi.scale(value, dpi); +} + fn wheelRegion(x: i32, y: i32, bounds: InputBounds, controls: WorkspaceControls.State) WheelRegion { if (controls.rail_visible and x < bounds.rail_left and y >= Tokens.header_height and y < bounds.canvas.bottom) @@ -429,6 +447,7 @@ pub const App = struct { } pub fn run(self: *App) !void { + Diagnostics.record(self.allocator, "startup", "GraphCode Windows shell starting"); const com_result = c.CoInitializeEx(null, c.COINIT_APARTMENTTHREADED); if (com_result < 0) return error.ComInitializationFailed; defer c.CoUninitialize(); @@ -522,7 +541,7 @@ pub const App = struct { } self.createEmptyStateControls(); self.refreshWorkspaceList(); - self.updateNativeChrome(); + self.updateNativeChrome(.state_change); if (std.process.getEnvVarOwned(self.allocator, "GRAPHCODE_UIA_FIXTURE_ROWS")) |fixture| { defer self.allocator.free(fixture); self.installUiaFixture(true); @@ -980,8 +999,6 @@ pub const App = struct { } fn sidebarRootDropIndex(self: *App, project_path: []const u8, y: i32) ?usize { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) return null; var rows = Sidebar.appendRows( self.allocator, &self.model, @@ -1246,6 +1263,7 @@ pub const App = struct { "graphcode://global" else return; + Diagnostics.record(self.allocator, "action", "create-node"); const path = self.allocator.dupe(u8, current_path) catch return; defer self.allocator.free(path); const settings = self.product_settings orelse return; @@ -1273,8 +1291,23 @@ pub const App = struct { return; }; defer self.allocator.free(choices); - var templates = TemplateLibrary.load(self.allocator, path) catch { + var templates = TemplateLibrary.load(self.allocator, path) catch |err| { + const detail = std.fmt.allocPrint( + self.allocator, + "template-load path={s} error={s}", + .{ path, @errorName(err) }, + ) catch null; + if (detail) |message| { + Diagnostics.record(self.allocator, "error", message); + self.allocator.free(message); + } self.setStatus("Unable to load saved templates"); + var draft = NativeForms.node(self.window.hwnd, self.allocator, path, &draft_id_buffer, choices, initial) catch |form_err| { + self.setStatus(nodeFormErrorStatus(form_err)); + return; + } orelse return; + defer draft.deinit(self.allocator); + self.client.sendCreateNodeDraft(path, draft); return; }; defer templates.deinit(); @@ -1638,7 +1671,7 @@ pub const App = struct { pub fn checkForUpdates(self: *App) void { self.setStatus("Checking for updates..."); self.requestUpdateCheck(true); - self.updateNativeChrome(); + self.updateNativeChrome(.state_change); } fn requestUpdateCheck(self: *App, user_initiated: bool) void { @@ -3538,8 +3571,7 @@ pub const App = struct { } fn ensureWorktreeVisible(self: *App, index: usize) void { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const client = logicalClientRect(self.window.hwnd, self.dpi); const loop_count = if (self.model.graph) |graph| graph.nodes.items.len else 0; const top = Sidebar.worktreeRowTopForModel(&self.model, loop_count, index) - self.sidebar_scroll; const bottom = top + 34; @@ -3551,8 +3583,8 @@ pub const App = struct { } fn clampSidebarScroll(self: *App) void { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) { + const client = logicalClientRect(self.window.hwnd, self.dpi); + if (client.right == 0 or client.bottom == 0) { self.sidebar_scroll = 0; return; } @@ -3676,8 +3708,7 @@ pub const App = struct { self.setStatus(if (self.workspace_controls.activity_enabled) "Activity enabled" else "Activity disabled"); }, .zoom_out, .zoom_in => { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const client = logicalClientRect(self.window.hwnd, self.dpi); const bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; self.canvas.zoomBy( @divTrunc(bounds.left + bounds.right, 2), @@ -3691,8 +3722,7 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); }, .fit_canvas => { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const client = logicalClientRect(self.window.hwnd, self.dpi); const bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; const content = GraphCanvas.contentSize(&self.model, self.surface); self.canvas.fit( @@ -3765,11 +3795,11 @@ pub const App = struct { if (c.GetClientRect(self.window.hwnd, &client) == 0) return; if (self.workspace) |workspace| { const full_workspace = self.surface == .workspace; - const activity_height = if (self.workspace_controls.activity_enabled) Tokens.activity_strip_height else 0; + const activity_height = if (self.workspace_controls.activity_enabled) physicalCoordinate(Tokens.activity_strip_height, self.dpi) else 0; const panel_height = if (full_workspace) - @max(0, client.bottom - Tokens.header_height - Tokens.loop_bar_height - activity_height) + @max(0, client.bottom - physicalCoordinate(Tokens.header_height + Tokens.loop_bar_height, self.dpi) - activity_height) else if (self.workspace_controls.panel_visible) - Tokens.workspace_height + physicalCoordinate(Tokens.workspace_height, self.dpi) else 0; // When the workspace has no visible presence at all (neither the full surface nor the @@ -3785,10 +3815,10 @@ pub const App = struct { return; } workspace.resize( - if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0, - if (full_workspace) Tokens.header_height + Tokens.loop_bar_height else @max(0, client.bottom - panel_height), - @max(0, client.right - (if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0) - - (if (full_workspace and self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0)), + if (self.workspace_controls.rail_visible) physicalCoordinate(Tokens.sidebar_width, self.dpi) else 0, + if (full_workspace) physicalCoordinate(Tokens.header_height + Tokens.loop_bar_height, self.dpi) else @max(0, client.bottom - panel_height), + @max(0, client.right - (if (self.workspace_controls.rail_visible) physicalCoordinate(Tokens.sidebar_width, self.dpi) else 0) - + (if (full_workspace and self.workspace_controls.panel_visible) physicalCoordinate(Tokens.loop_detail_width, self.dpi) else 0)), panel_height, ); } @@ -3819,8 +3849,8 @@ pub const App = struct { } fn layoutEmptyStateControls(self: *App) void { - var client: c.RECT = undefined; - if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const client = logicalClientRect(self.window.hwnd, self.dpi); + if (client.right == 0 or client.bottom == 0) return; const graph = self.model.graph; const is_quick_chats = self.surface == .quick_chats; const is_overview = self.surface == .overview; @@ -3844,7 +3874,15 @@ pub const App = struct { self.empty_open_folder_button, if (is_empty and !is_quick_chats and (is_overview or graph == null or is_global)) c.SW_SHOW else c.SW_HIDE, ); - _ = c.SetWindowPos(self.empty_open_folder_button, null, x, y, 220, 32, c.SWP_NOZORDER | c.SWP_NOACTIVATE); + _ = c.SetWindowPos( + self.empty_open_folder_button, + null, + physicalCoordinate(x, self.dpi), + physicalCoordinate(y, self.dpi), + physicalCoordinate(220, self.dpi), + physicalCoordinate(32, self.dpi), + c.SWP_NOZORDER | c.SWP_NOACTIVATE, + ); } if (self.empty_global_overview_button != null) { setButtonText(self.empty_global_overview_button, if (is_quick_chats) "New Chat" else "New Loop"); @@ -3862,10 +3900,10 @@ pub const App = struct { _ = c.SetWindowPos( self.empty_global_overview_button, null, - primary_x, - primary_y, - if (is_empty) 220 else 120, - 32, + physicalCoordinate(primary_x, self.dpi), + physicalCoordinate(primary_y, self.dpi), + physicalCoordinate(if (is_empty) 220 else 120, self.dpi), + physicalCoordinate(32, self.dpi), c.SWP_NOZORDER | c.SWP_NOACTIVATE, ); } @@ -3877,7 +3915,7 @@ pub const App = struct { const wide = std.heap.c_allocator.allocSentinel(u16, raw.len, 0) catch return null; defer std.heap.c_allocator.free(wide); @memcpy(wide[0..raw.len], raw); - return c.CreateWindowExW( + const button = c.CreateWindowExW( 0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, @@ -3891,6 +3929,8 @@ pub const App = struct { c.GetModuleHandleW(null), null, ); + AppFont.apply(button, AppFont.control_size, false); + return button; } fn setButtonText(button: c.HWND, text: []const u8) void { @@ -3907,7 +3947,7 @@ pub const App = struct { return @ptrFromInt(value); } - fn updateNativeChrome(self: *App) void { + fn updateNativeChrome(self: *App, refresh: MainWindow.MenuRefresh) void { self.update_lock.lock(); const update_checking = self.update_thread != null and !self.update_done; self.update_lock.unlock(); @@ -3948,11 +3988,12 @@ pub const App = struct { .update_checking = update_checking, .recent_folders = recent_menu, .workspaces = workspace_items, - }); + }, refresh); self.layoutEmptyStateControls(); } fn setStatus(self: *App, value: []const u8) void { + Diagnostics.record(self.allocator, "status", value); const copy = self.allocator.dupe(u8, value) catch return; self.replaceStatus(copy); if (self.accessibility) |*provider| { @@ -3993,8 +4034,7 @@ pub const App = struct { for (owned_identities.items) |value| self.allocator.free(value); owned_identities.deinit(); } - var client: c.RECT = undefined; - _ = c.GetClientRect(self.window.hwnd, &client); + const client = logicalClientRect(self.window.hwnd, self.dpi); const canvas_bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; const canvas_rect = c.RECT{ .left = canvas_bounds.left, @@ -4379,6 +4419,12 @@ pub const App = struct { }) catch return; } } + for (elements.items) |*element| { + element.left = physicalCoordinate(element.left, self.dpi); + element.top = physicalCoordinate(element.top, self.dpi); + element.right = physicalCoordinate(element.right, self.dpi); + element.bottom = physicalCoordinate(element.bottom, self.dpi); + } const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; provider.syncElements(self.status(), elements.items, policy); } @@ -5039,7 +5085,7 @@ fn onWindowMessage( } }, c.WM_INITMENUPOPUP => { - app.updateNativeChrome(); + app.updateNativeChrome(.popup_open); result.* = 0; return true; }, @@ -5106,8 +5152,7 @@ fn onWindowMessage( if (app.surface == .quick_chats) app.handleAction(.quick_chat) else app.handleAction(.create_node); }, Accessibility.uia_zoom_out_command => { - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; app.canvas.zoomBy(@divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2), 0.9); app.syncAccessibility(); @@ -5119,16 +5164,14 @@ fn onWindowMessage( _ = c.InvalidateRect(hwnd, null, 0); }, Accessibility.uia_zoom_in_command => { - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; app.canvas.zoomBy(@divTrunc(bounds.left + bounds.right, 2), @divTrunc(bounds.top + bounds.bottom, 2), 1.1); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); }, Accessibility.uia_fit_command => { - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); const bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; const content = GraphCanvas.contentSize(&app.model, app.surface); app.canvas.fit( @@ -5217,13 +5260,46 @@ fn onWindowMessage( .workspace_previous => app.cycleWorkspace(-1), } } - app.updateNativeChrome(); + app.updateNativeChrome(.state_change); result.* = 0; return true; }, + c.WM_ERASEBKGND => { + // WM_PAINT presents a complete off-screen frame, so erasing first would + // expose the background between GDI operations and cause visible flicker. + result.* = 1; + return true; + }, c.WM_PAINT => { var paint: c.PAINTSTRUCT = undefined; - const hdc = c.BeginPaint(hwnd, &paint); + const target_hdc = c.BeginPaint(hwnd, &paint); + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + var buffer_dc: c.HDC = null; + var buffer_bitmap: c.HBITMAP = null; + var previous_bitmap: c.HGDIOBJ = null; + var hdc = target_hdc; + if (client.right > client.left and client.bottom > client.top) { + buffer_dc = c.CreateCompatibleDC(target_hdc); + if (buffer_dc != null) { + buffer_bitmap = c.CreateCompatibleBitmap( + target_hdc, + client.right - client.left, + client.bottom - client.top, + ); + if (buffer_bitmap != null) { + previous_bitmap = c.SelectObject(buffer_dc, buffer_bitmap); + hdc = buffer_dc; + } + } + } + const logical_right = Dpi.unscale(client.right, app.dpi); + const logical_bottom = Dpi.unscale(client.bottom, app.dpi); + if (logical_right > 0 and logical_bottom > 0) { + _ = c.SetMapMode(hdc, c.MM_ANISOTROPIC); + _ = c.SetWindowExtEx(hdc, logical_right, logical_bottom, null); + _ = c.SetViewportExtEx(hdc, client.right, client.bottom, null); + } const inspection = if (app.worktree_inspection) |*value| blk: { const policy = WorktreeStatus.loadPolicy(app.allocator, value.project_path); const summary = WorktreeStatus.summarize(value.entries.items); @@ -5236,12 +5312,12 @@ fn onWindowMessage( app.update_lock.lock(); if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); const offered_version = if (app.update_state.state == .available) app.update_version else ""; - GraphCanvas.paint(hwnd, hdc, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface); + GraphCanvas.paint(hdc, logical_right, logical_bottom, &app.model, inspection, app.selected_worktree_path, app.sidebar_scroll, app.status(), offered_version, app.ingress_error, app.connectionFailureVisible(), app.declared_entry_ids.items, app.kept_worktree_paths.items, app.allocator, &app.canvas, &app.sidebar_state, app.sidebar_hover_y, app.workspace_controls, app.surface); app.update_lock.unlock(); if (app.workspace_controls.panel_visible or app.surface == .workspace) { if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { - const workspace_right = clientRight(hwnd) - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); + const workspace_right = logical_right - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); TerminalWorkspace.Workspace.paintWorkspaceToolbar( hdc, app.allocator, @@ -5270,10 +5346,15 @@ fn onWindowMessage( isResolvedLoopState(node.state), ); } - if (!app.workspace_controls.panel_visible) GraphCanvas.paintLoopDetailExpandControl(hdc, app.allocator, clientRight(hwnd)); + if (!app.workspace_controls.panel_visible) GraphCanvas.paintLoopDetailExpandControl(hdc, app.allocator, logical_right); } } - if (app.workspace) |workspace| workspace.paintChrome(hdc); + if (app.workspace) |workspace| { + const saved_mapping = c.SaveDC(hdc); + _ = c.SetMapMode(hdc, c.MM_TEXT); + workspace.paintChrome(hdc); + _ = c.RestoreDC(hdc, saved_mapping); + } if (app.surface == .workspace and app.workspace_controls.panel_visible) { if (workspaceGraph(&app.model)) |graph| { const index = app.model.selectedIndex() orelse graph.nodes.items.len; @@ -5282,12 +5363,32 @@ fn onWindowMessage( app.allocator, graph, index, - clientRight(hwnd), - clientBottom(hwnd), + logical_right, + logical_bottom, ); } } } + _ = c.SetMapMode(hdc, c.MM_TEXT); + if (hdc != target_hdc) { + const dirty = paint.rcPaint; + _ = c.BitBlt( + target_hdc, + dirty.left, + dirty.top, + dirty.right - dirty.left, + dirty.bottom - dirty.top, + hdc, + dirty.left, + dirty.top, + c.SRCCOPY, + ); + } + if (previous_bitmap != null and buffer_dc != null) { + _ = c.SelectObject(buffer_dc, previous_bitmap); + } + if (buffer_bitmap != null) _ = c.DeleteObject(buffer_bitmap); + if (buffer_dc != null) _ = c.DeleteDC(buffer_dc); _ = c.EndPaint(hwnd, &paint); result.* = 0; return true; @@ -5377,7 +5478,6 @@ fn onWindowMessage( app.restore_requested = true; app.client.sendRestoreOpenProjects(); } - app.updateNativeChrome(); } const updated_connection_state = app.client.connectionState(); if (updated_connection_state != app.last_connection_state) { @@ -5490,7 +5590,7 @@ fn onWindowMessage( const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; const shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0; app.handleAction(InputRouter.keyAction(wparam, ctrl, shift)); - app.updateNativeChrome(); + app.updateNativeChrome(.state_change); result.* = 0; return true; }, @@ -5505,15 +5605,16 @@ fn onWindowMessage( return true; }, c.WM_LBUTTONDOWN => { - const x = mouseX(lparam); - const y = mouseY(lparam); + const physical_x = mouseX(lparam); + const physical_y = mouseY(lparam); + const x = logicalCoordinate(physical_x, app.dpi); + const y = logicalCoordinate(physical_y, app.dpi); if (envFlag("GRAPHCODE_UIA_GATE") and x == 0 and y == 0) { _ = app.toggleWorktreeRow(0); result.* = 0; return true; } - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); if (GraphCanvas.headerActionAt( x, y, @@ -5592,7 +5693,7 @@ fn onWindowMessage( } } if (app.workspace) |workspace| { - if (workspace.chromeActionAt(x, y)) |action| { + if (workspace.chromeActionAt(physical_x, physical_y)) |action| { app.handleAction(switch (action) { .new_tab => .new_tab, .split_right => .split_horizontal, @@ -5602,7 +5703,7 @@ fn onWindowMessage( result.* = 0; return true; } - if (workspace.tabActionAt(x, y)) |tab_action| { + if (workspace.tabActionAt(physical_x, physical_y)) |tab_action| { switch (tab_action.action) { .select => workspace.selectTab(tab_action.index) catch {}, .close => workspace.closeTab(tab_action.index) catch {}, @@ -5927,9 +6028,12 @@ fn onWindowMessage( return true; }, c.WM_RBUTTONUP => { - const point = CanvasInput.decodeMouseMessage(lparam); - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const physical_point = CanvasInput.decodeMouseMessage(lparam); + const point = c.POINT{ + .x = logicalCoordinate(physical_point.x, app.dpi), + .y = logicalCoordinate(physical_point.y, app.dpi), + }; + const client = logicalClientRect(hwnd, app.dpi); const routing = inputBounds(client.right, client.bottom, app.workspace_controls); if (app.workspace_controls.rail_visible and point.x < routing.rail_left) { const inspection = if (app.worktree_inspection) |*value| value else null; @@ -5950,7 +6054,7 @@ fn onWindowMessage( else => {}, } if (project_path) |path| { - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); GraphContextMenu.show( hwnd, @@ -5963,7 +6067,7 @@ fn onWindowMessage( } else switch (row.kind) { .loop => if (row.project_path) |path| if (app.model.graphFor(path)) |graph| { if (row.index < graph.nodes.items.len) { - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); GraphContextMenu.show( hwnd, @@ -5982,7 +6086,7 @@ fn onWindowMessage( } }, .quick_chat => if (row.index < app.model.quick_chats.items.len) { - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); GraphContextMenu.show( hwnd, @@ -5994,7 +6098,7 @@ fn onWindowMessage( ); }, .quick_chat_overview => { - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); GraphContextMenu.show(hwnd, .quick_chats, screen.x, screen.y, app, &onContextAction); }, @@ -6008,7 +6112,7 @@ fn onWindowMessage( const bounds = c.RECT{ .left = routing.canvas.left, .top = routing.canvas.top, .right = routing.canvas.right, .bottom = routing.canvas.bottom }; if (app.surface == .quick_chats) { if (GraphCanvas.hitTestQuickChat(app.model.quick_chats.items.len, point.x, point.y, &app.canvas, bounds)) |index| { - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); app.showQuickChatContextMenu(index, screen.x, screen.y); } @@ -6030,7 +6134,7 @@ fn onWindowMessage( target_index = index; } } - var screen = c.POINT{ .x = point.x, .y = point.y }; + var screen = c.POINT{ .x = physical_point.x, .y = physical_point.y }; _ = c.ClientToScreen(hwnd, &screen); switch (target) { .background => GraphContextMenu.show(hwnd, .background, screen.x, screen.y, app, &onContextAction), @@ -6056,7 +6160,11 @@ fn onWindowMessage( return true; } if (app.canvas.edge_dragging) { - const point = CanvasInput.decodeMouseMessage(lparam); + const physical_point = CanvasInput.decodeMouseMessage(lparam); + const point = c.POINT{ + .x = logicalCoordinate(physical_point.x, app.dpi), + .y = logicalCoordinate(physical_point.y, app.dpi), + }; const source_id = app.copyEdgeDragSourceForDrop() orelse { app.cancelCanvasInteraction(); result.* = 0; @@ -6065,7 +6173,8 @@ fn onWindowMessage( defer app.allocator.free(source_id); _ = c.ReleaseCapture(); if (app.model.graph) |graph| { - const bounds = c.RECT{ .left = Tokens.sidebar_width, .top = Tokens.header_height, .right = clientRight(hwnd), .bottom = clientBottom(hwnd) - Tokens.workspace_height }; + const client = logicalClientRect(hwnd, app.dpi); + const bounds = c.RECT{ .left = Tokens.sidebar_width, .top = Tokens.header_height, .right = client.right, .bottom = client.bottom - Tokens.workspace_height }; if (GraphModel.findNodeIndexByID(graph.nodes.items, source_id)) |source| { if (GraphCanvas.hitTest(graph.nodes.items, point.x, point.y, &app.canvas, bounds)) |target| { if (target != source) app.createEdgeBetweenIDs(source_id, graph.nodes.items[target].id); @@ -6089,34 +6198,33 @@ fn onWindowMessage( return true; }, c.WM_MOUSEMOVE => { - const hover_y = mouseY(lparam); - const hover_x = mouseX(lparam); + const hover_y = logicalCoordinate(mouseY(lparam), app.dpi); + const hover_x = logicalCoordinate(mouseX(lparam), app.dpi); app.updateSidebarRootDrag(hover_y); - const next_hover = if (mouseX(lparam) >= 0 and mouseX(lparam) < Tokens.sidebar_width) hover_y else -1; + const next_hover = if (hover_x >= 0 and hover_x < Tokens.sidebar_width) hover_y else -1; if (next_hover != app.sidebar_hover_y) { app.sidebar_hover_y = next_hover; _ = c.InvalidateRect(hwnd, null, 0); } if (app.canvas.node_dragging) { - app.canvas.updateNodeDrag(mouseX(lparam), mouseY(lparam)); + app.canvas.updateNodeDrag(hover_x, hover_y); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } else if (app.canvas.edge_dragging) { - app.canvas.updateEdgeDrag(mouseX(lparam), mouseY(lparam)); + app.canvas.updateEdgeDrag(hover_x, hover_y); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } else if (app.canvas.dragging) { - app.canvas.updatePan(mouseX(lparam), mouseY(lparam)); + app.canvas.updatePan(hover_x, hover_y); _ = c.InvalidateRect(hwnd, null, 0); result.* = 0; return true; } if (app.model.graph) |graph| { - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); const canvas_render_bounds = inputBounds(client.right, client.bottom, app.workspace_controls).canvas; const canvas_bounds = c.RECT{ .left = canvas_render_bounds.left, @@ -6138,11 +6246,10 @@ fn onWindowMessage( result.* = 0; return true; }; - const x = mapped.x; - const y = mapped.y; + const x = logicalCoordinate(mapped.x, app.dpi); + const y = logicalCoordinate(mapped.y, app.dpi); const delta = wheel.delta; - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = logicalClientRect(hwnd, app.dpi); const routing = inputBounds(client.right, client.bottom, app.workspace_controls); switch (wheelRegion(x, y, routing, app.workspace_controls)) { .sidebar => { @@ -6463,6 +6570,13 @@ test "pinchGestureContext changes identity across project switches on the same s try std.testing.expectEqual(context_project_b, app.pinchGestureContext()); } +test "main shell coordinates round trip across common Windows DPI steps" { + for ([_]u32{ 96, 120, 144, 192 }) |dpi| { + try std.testing.expectEqual(@as(i32, 220), logicalCoordinate(physicalCoordinate(220, dpi), dpi)); + try std.testing.expectEqual(@as(i32, 34), logicalCoordinate(physicalCoordinate(34, dpi), dpi)); + } +} + test "jump matching ranks exact results across projects" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); @@ -6785,16 +6899,17 @@ fn smokeContractPassed(self: *const App) bool { }; var client: c.RECT = undefined; if (c.GetClientRect(self.window.hwnd, &client) == 0) return false; - const layout_width = @max(0, client.right - Tokens.sidebar_width); - const layout_height = Tokens.workspace_height; + const sidebar_width = physicalCoordinate(Tokens.sidebar_width, self.dpi); + const layout_width = @max(0, client.right - sidebar_width); + const layout_height = physicalCoordinate(Tokens.workspace_height, self.dpi); const workspace_ready = if (scripted_actions) workspace.tabCount() > 0 else workspace.hasSurface(0) and workspace.hasSurface(1) and workspace.hasAttach(0) and workspace.hasAttach(1); const layout_ok = workspace.layoutMatches( - Tokens.sidebar_width, - @max(0, client.bottom - Tokens.workspace_height), + sidebar_width, + @max(0, client.bottom - layout_height), layout_width, layout_height, ); diff --git a/graphcode-windows/src/AppFont.zig b/graphcode-windows/src/AppFont.zig index a7b44826..3af8304c 100644 --- a/graphcode-windows/src/AppFont.zig +++ b/graphcode-windows/src/AppFont.zig @@ -13,7 +13,9 @@ // 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 Win32 = @import("Win32.zig"); +const c = Win32.c; +const Dpi = @import("Dpi.zig"); const CacheEntry = struct { size: i32, bold: bool, font: c.HFONT }; @@ -22,18 +24,15 @@ const CacheEntry = struct { size: i32, bold: bool, font: c.HFONT }; // 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: [32]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 { +fn getScaled(scaled_size: i32, bold: bool) c.HFONT { for (cache[0..cache_len]) |entry| { - if (entry.size == size and entry.bold == bold) return entry.font; + if (entry.size == scaled_size and entry.bold == bold) return entry.font; } const font = c.CreateFontW( - -size, + -scaled_size, 0, 0, 0, @@ -49,12 +48,23 @@ pub fn get(size: i32, bold: bool) c.HFONT { std.unicode.utf8ToUtf16LeStringLiteral("Segoe UI").ptr, ); if (font != null and cache_len < cache.len) { - cache[cache_len] = .{ .size = size, .bold = bold, .font = font }; + cache[cache_len] = .{ .size = scaled_size, .bold = bold, .font = font }; cache_len += 1; } return font; } +/// Returns the app's standard ClearType-quality Segoe UI font at a logical +/// 96-DPI size. Use `apply` or `select` for live controls and paint DCs so the +/// logical size is scaled to the monitor automatically. +pub fn get(size: i32, bold: bool) c.HFONT { + return getForDpi(size, bold, Dpi.base_dpi); +} + +pub fn getForDpi(size: i32, bold: bool, dpi: u32) c.HFONT { + return getScaled(Dpi.scale(size, dpi), bold); +} + /// 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. @@ -66,7 +76,7 @@ pub const control_size: i32 = 14; /// -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); + _ = c.SendMessageW(control, c.WM_SETFONT, @intFromPtr(getForDpi(size, bold, Win32.dpiForWindow(control))), 1); } /// Selects the standard ClearType font into `hdc` for direct GDI text @@ -77,6 +87,19 @@ pub fn select(hdc: c.HDC, size: i32, bold: bool) c.HGDIOBJ { return c.SelectObject(hdc, font); } +/// Selects a logical-size font scaled for the target DC. Use this only when +/// the caller also scales its text rectangles and surrounding geometry. +pub fn selectForDpi(hdc: c.HDC, size: i32, bold: bool) c.HGDIOBJ { + const dpi: u32 = @intCast(c.GetDeviceCaps(hdc, c.LOGPIXELSY)); + return c.SelectObject(hdc, getForDpi(size, bold, dpi)); +} + +test "logical font sizes scale with monitor DPI" { + try std.testing.expectEqual(@as(i32, 14), Dpi.scale(14, 96)); + try std.testing.expectEqual(@as(i32, 21), Dpi.scale(14, 144)); + try std.testing.expectEqual(@as(i32, 28), Dpi.scale(14, 192)); +} + 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); diff --git a/graphcode-windows/src/Diagnostics.zig b/graphcode-windows/src/Diagnostics.zig new file mode 100644 index 00000000..5bd5d515 --- /dev/null +++ b/graphcode-windows/src/Diagnostics.zig @@ -0,0 +1,46 @@ +const std = @import("std"); + +const max_bytes = 2 * 1024 * 1024; +var mutex: std.Thread.Mutex = .{}; + +pub fn record(allocator: std.mem.Allocator, event: []const u8, detail: []const u8) void { + mutex.lock(); + defer mutex.unlock(); + + const directory = supportDirectory(allocator) catch return; + defer allocator.free(directory); + std.fs.cwd().makePath(directory) catch return; + const path = std.fs.path.join(allocator, &.{ directory, "graphcode-windows.log" }) catch return; + defer allocator.free(path); + + var file = std.fs.cwd().createFile(path, .{ .truncate = false }) catch return; + defer file.close(); + const size = file.getEndPos() catch 0; + if (size >= max_bytes) { + file.setEndPos(0) catch return; + file.seekTo(0) catch return; + } else { + file.seekFromEnd(0) catch return; + } + const line = std.fmt.allocPrint( + allocator, + "{d} event={s} detail={s}\r\n", + .{ std.time.timestamp(), event, detail }, + ) catch return; + defer allocator.free(line); + file.writeAll(line) catch {}; +} + +fn supportDirectory(allocator: std.mem.Allocator) ![]u8 { + if (std.process.getEnvVarOwned(allocator, "GRAPHCODE_SUPPORT_DIR")) |value| { + return value; + } else |_| {} + const profile = try std.process.getEnvVarOwned(allocator, "USERPROFILE"); + defer allocator.free(profile); + return std.fs.path.join(allocator, &.{ profile, ".graphcode" }); +} + +test "diagnostic log has a bounded filename and size" { + try std.testing.expectEqualStrings("graphcode-windows.log", std.fs.path.basename("x\\graphcode-windows.log")); + try std.testing.expect(max_bytes >= 1024 * 1024); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 7dbb7f79..d7e5e5d9 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -406,8 +406,9 @@ pub fn renderBounds(client_right: i32, client_bottom: i32, controls: WorkspaceCo } pub fn paint( - hwnd: c.HWND, hdc: c.HDC, + client_right: i32, + client_bottom: i32, model: *const GraphModel.Model, inspection: ?*const WorktreeStatus.Inspection, selected_worktree_path: []const u8, @@ -425,8 +426,7 @@ pub fn paint( controls: WorkspaceControls.State, surface: Surface, ) void { - var client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &client); + const client = c.RECT{ .left = 0, .top = 0, .right = client_right, .bottom = client_bottom }; fill(hdc, client, Tokens.canvas_tone); const visible_inspection = if (inspection) |value| if (model.graph) |graph| diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index af0ec6ee..9df8f17a 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -78,6 +78,15 @@ pub const WorkspaceItem = struct { is_current: bool, }; +pub const MenuRefresh = enum { + state_change, + popup_open, +}; + +fn redrawsMenuBar(refresh: MenuRefresh) bool { + return refresh == .state_change; +} + pub const MenuState = struct { has_project: bool, can_worktrees: bool, @@ -331,7 +340,7 @@ pub fn installMenu(hwnd: c.HWND) !void { _ = c.DrawMenuBar(hwnd); } -pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { +pub fn updateMenu(hwnd: c.HWND, state: MenuState, refresh: MenuRefresh) void { updateRecentFolderMenu(hwnd, state.recent_folders); updateWorkspaceMenu(hwnd, state.workspaces); setEnabled(hwnd, .open_global_overview, true); @@ -370,7 +379,7 @@ pub fn updateMenu(hwnd: c.HWND, state: MenuState) void { setChecked(hwnd, .toggle_sidebar, state.sidebar_visible); setChecked(hwnd, .toggle_workspace, state.workspace_visible); setChecked(hwnd, .toggle_activity, state.activity_visible); - _ = c.DrawMenuBar(hwnd); + if (redrawsMenuBar(refresh)) _ = c.DrawMenuBar(hwnd); } fn updateWorkspaceMenu(hwnd: c.HWND, workspaces: []const WorkspaceItem) void { @@ -649,6 +658,11 @@ test "workspace commands use a dedicated command range" { try std.testing.expectEqual(Command.workspace_new, commandFromId(4800).?); } +test "popup initialization updates menu state without redrawing the active menu bar" { + try std.testing.expect(redrawsMenuBar(.state_change)); + try std.testing.expect(!redrawsMenuBar(.popup_open)); +} + test "gate fixture messages and timers never collide with shell traffic" { try std.testing.expect(wm_uia_context_menu != wm_app_tick); try std.testing.expect(wm_uia_context_menu != wm_uia_fixture_mutate); diff --git a/graphcode-windows/src/NativeForms.zig b/graphcode-windows/src/NativeForms.zig index e0a8b8a6..23155753 100644 --- a/graphcode-windows/src/NativeForms.zig +++ b/graphcode-windows/src/NativeForms.zig @@ -5,6 +5,8 @@ const WorktreeStatus = @import("WorktreeStatus.zig"); const Tokens = @import("DesignTokens.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; +const Dpi = @import("Dpi.zig"); +const AppFont = @import("AppFont.zig"); const ModalTeardown = @import("ModalTeardown.zig"); extern fn graphcode_pick_files(owner: c.HWND, buffer: [*]u16, stride: c.DWORD, max_files: c.DWORD) callconv(.c) c_int; @@ -15,6 +17,7 @@ const DialogState = struct { parent: c.HWND, result: bool = false, closed: bool = false, + dpi: u32 = Dpi.base_dpi, scroll_offset: i32 = 0, checks: [3]c.HWND = .{ null, null, null }, labels: [256]c.HWND = .{null} ** 256, @@ -89,6 +92,33 @@ const cancel_id = 2; const reveal_id = 3; const templates_id = 4; +const form_width: i32 = 760; +const form_max_height: i32 = 760; +const form_min_height: i32 = 420; +const form_margin: i32 = 24; +const form_fields_top: i32 = 76; +const form_footer_height: i32 = 64; +const form_label_height: i32 = 22; +const form_input_height: i32 = 32; +const form_help_height: i32 = 20; +const form_row_height: i32 = 84; +const tile_row_height: i32 = 204; +const attachment_section_height: i32 = 176; + +fn scaled(state: *const DialogState, value: i32) i32 { + return Dpi.scale(value, state.dpi); +} + +fn formContentWidth(hwnd: c.HWND, state: *const DialogState) i32 { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + return @max(scaled(state, 240), client.right - scaled(state, form_margin * 2)); +} + +fn formViewportHeight(hwnd: c.HWND, state: *const DialogState) i32 { + return @max(scaled(state, 120), clientHeight(hwnd) - scaled(state, form_footer_height)); +} + var active_state: bool = false; var active_state_storage: DialogState = undefined; @@ -708,8 +738,13 @@ fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !boo active_state_storage = state.*; active_state_storage.closed = false; active_state_storage.result = false; + active_state_storage.dpi = Dpi.normalize(Win32.dpiForWindow(state.parent)); const screen_height = c.GetSystemMetrics(c.SM_CYSCREEN); - const dialog_height: i32 = if (state.kind == .worktree_policy) 430 else @max(320, @min(700, screen_height - 96)); + const desired_height = if (state.kind == .worktree_policy) 520 else form_max_height; + const dialog_height = @max( + Dpi.scale(form_min_height, active_state_storage.dpi), + @min(Dpi.scale(desired_height, active_state_storage.dpi), screen_height - Dpi.scale(64, active_state_storage.dpi)), + ); const hwnd = c.CreateWindowExW( c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, class_name.ptr, @@ -717,7 +752,7 @@ fn show(state: *DialogState, title: []const u8, labels: []const []const u8) !boo c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU | c.WS_VSCROLL, c.CW_USEDEFAULT, c.CW_USEDEFAULT, - 580, + Dpi.scale(form_width, active_state_storage.dpi), dialog_height, state.parent, null, @@ -861,58 +896,41 @@ fn drawTile(state: *DialogState, tile_index: usize, draw_item: *c.DRAWITEMSTRUCT } 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 = c.RECT{ + .left = bounds.left + scaled(state, 14), + .top = bounds.top + scaled(state, 15), + .right = bounds.left + scaled(state, 23), + .bottom = bounds.top + scaled(state, 24), + }; 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); + formDrawText(draw_item.hDC, choice.label, .{ + .left = bounds.left + scaled(state, 32), + .top = bounds.top + scaled(state, 9), + .right = bounds.right - scaled(state, 12), + .bottom = bounds.top + scaled(state, 32), + }, 14, Tokens.dialog_title_text, true); + formDrawText(draw_item.hDC, choice.description, .{ + .left = bounds.left + scaled(state, 14), + .top = bounds.top + scaled(state, 38), + .right = bounds.right - scaled(state, 12), + .bottom = bounds.bottom - scaled(state, 9), + }, 12, 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; + const old_font = AppFont.selectForDpi(hdc, size, bold); _ = c.SetTextColor(hdc, color); _ = c.SetBkMode(hdc, c.TRANSPARENT); var bounds = bounds_value; _ = c.DrawTextW(hdc, wide.ptr, @intCast(wide.len), &bounds, c.DT_LEFT | c.DT_WORDBREAK | c.DT_END_ELLIPSIS); - if (font != null) _ = c.SelectObject(hdc, old_font); + _ = c.SelectObject(hdc, old_font); } fn configureFields(state: *DialogState) void { @@ -1099,11 +1117,12 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) } var client: c.RECT = undefined; _ = c.GetClientRect(safe_hwnd, &client); - createButton(safe_hwnd, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 478, client.bottom - 38); + createButton(safe_hwnd, value, if (value.kind == .node) "Create" else if (value.kind == .worktree_policy) "Done" else if (value.kind == .worktree_sweep) "Remove Selected" else "OK", ok_id, 0, 0); if (value.kind == .node and value.templates_available) - createButton(safe_hwnd, "Templates", templates_id, 300, client.bottom - 38); - if (value.kind == .worktree_sweep) createButton(safe_hwnd, "Show in Explorer", reveal_id, 300, client.bottom - 38); - createButton(safe_hwnd, "Cancel", cancel_id, 393, client.bottom - 38); + createButton(safe_hwnd, value, "Templates", templates_id, 0, 0); + if (value.kind == .worktree_sweep) createButton(safe_hwnd, value, "Show in Explorer", reveal_id, 0, 0); + createButton(safe_hwnd, value, "Cancel", cancel_id, 0, 0); + layoutFooter(safe_hwnd, value); return 0; }, c.WM_ERASEBKGND => { @@ -1125,13 +1144,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) return 0; }, c.WM_SIZE => { - var client: c.RECT = undefined; - _ = c.GetClientRect(safe_hwnd, &client); - _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(ok_id)), 478, client.bottom - 38, 70, 26, 1); - _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(cancel_id)), 393, client.bottom - 38, 70, 26, 1); - if (value.kind == .node and value.templates_available) - _ = c.MoveWindow(c.GetDlgItem(safe_hwnd, @intCast(templates_id)), 300, client.bottom - 38, 82, 26, 1); - if (value.validation != null) _ = c.MoveWindow(value.validation, 18, client.bottom - 42, 360, 34, 1); + layoutFooter(safe_hwnd, value); if (value.kind != .worktree_policy) layoutForm(safe_hwnd, value); updateScrollBar(safe_hwnd, value); return 0; @@ -1148,10 +1161,10 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) return 0; } const delta: i32 = switch (command) { - c.SB_LINEUP => -48, - c.SB_LINEDOWN => 48, - c.SB_PAGEUP => -@as(i32, @intCast(@max(48, clientHeight(safe_hwnd) - 60))), - c.SB_PAGEDOWN => @as(i32, @intCast(@max(48, clientHeight(safe_hwnd) - 60))), + c.SB_LINEUP => -scaled(value, 48), + c.SB_LINEDOWN => scaled(value, 48), + c.SB_PAGEUP => -@as(i32, @intCast(@max(scaled(value, 48), formViewportHeight(safe_hwnd, value) - scaled(value, 12)))), + c.SB_PAGEDOWN => @as(i32, @intCast(@max(scaled(value, 48), formViewportHeight(safe_hwnd, value) - scaled(value, 12)))), c.SB_TOP => -100000, c.SB_BOTTOM => 100000, else => 0, @@ -1161,7 +1174,7 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) }, c.WM_MOUSEWHEEL => { const wheel_delta: i16 = @bitCast(@as(u16, @truncate(wparam >> 16))); - scrollFields(safe_hwnd, value, if (wheel_delta > 0) -48 else 48); + scrollFields(safe_hwnd, value, if (wheel_delta > 0) -scaled(value, 48) else scaled(value, 48)); return 0; }, c.WM_COMMAND => { @@ -1269,7 +1282,8 @@ fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) fn createStatic(hwnd: c.HWND, state: *DialogState, text: []const u8, x: i32, y: i32, width: i32, height: i32, output: *c.HWND) void { const wide = utf8ToWideZ(state.allocator, text) catch return; defer state.allocator.free(wide); - output.* = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, x, y, width, height, hwnd, null, c.GetModuleHandleW(null), null); + output.* = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("STATIC").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.SS_LEFT, scaled(state, x), scaled(state, y), scaled(state, width), scaled(state, height), hwnd, null, c.GetModuleHandleW(null), null); + AppFont.apply(output.*, AppFont.control_size, false); } fn isEndpointCombo(state: *const DialogState, index: usize) bool { @@ -1321,8 +1335,8 @@ fn endpointIndex(endpoints: []const EdgeEndpoint, value: []const u8) usize { return 0; } -fn inputControlHeight(kind: InputKind) i32 { - return if (kind == .combo) 180 else 24; +fn inputControlHeight(state: *const DialogState, kind: InputKind) i32 { + return scaled(state, if (kind == .combo) 220 else form_input_height); } // Attachments: unlike every other node field, this one is a variable-length list the @@ -1332,7 +1346,6 @@ fn inputControlHeight(kind: InputKind) i32 { // fixed-index field system `createField`/`InputKind` drive everything else through. // This keeps every existing field index (and the worktree/subgraph/createdBy // pass-through slots at 14-19) completely untouched. -const attachment_section_height: i32 = 132; const attachment_listbox_id = 6; fn attachmentsVisible(state: *const DialogState) bool { @@ -1359,17 +1372,18 @@ fn createAttachmentsSection(hwnd: c.HWND, state: *DialogState) void { @as(c.DWORD, @intCast(c.WS_CHILD)) | @as(c.DWORD, @intCast(c.WS_VISIBLE)) | @as(c.DWORD, @intCast(c.WS_TABSTOP)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)) | @as(c.DWORD, @intCast(c.LBS_NOTIFY)), - 18, + scaled(state, 18), 0, - 392, - 84, + scaled(state, 392), + scaled(state, 84), hwnd, childId(attachment_listbox_id), c.GetModuleHandleW(null), null, ); - state.attachment_attach_button = createButtonLabelled(hwnd, "Attach…", attachment_attach_id, 422, 0, 126, 26); - state.attachment_remove_button = createButtonLabelled(hwnd, "Remove", attachment_remove_id, 422, 30, 126, 26); + AppFont.apply(state.attachment_listbox, AppFont.control_size, false); + state.attachment_attach_button = createButtonLabelled(hwnd, state, "Attach…", attachment_attach_id, 422, 0, 126, 32); + state.attachment_remove_button = createButtonLabelled(hwnd, state, "Remove", attachment_remove_id, 422, 38, 126, 32); createStatic( hwnd, state, @@ -1382,36 +1396,43 @@ fn createAttachmentsSection(hwnd: c.HWND, state: *DialogState) void { ); } -fn createButtonLabelled(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32, width: i32, height: i32) c.HWND { +fn createButtonLabelled(hwnd: c.HWND, state: *const DialogState, text: []const u8, id: usize, x: i32, y: i32, width: i32, height: i32) c.HWND { const wide = utf8ToWideZ(std.heap.c_allocator, text) catch return null; defer std.heap.c_allocator.free(wide); - return c.CreateWindowExW( + const button = c.CreateWindowExW( 0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP, - x, - y, - width, - height, + scaled(state, x), + scaled(state, y), + scaled(state, width), + scaled(state, height), hwnd, childId(id), c.GetModuleHandleW(null), null, ); + AppFont.apply(button, AppFont.control_size, false); + return button; } -fn layoutAttachmentsSection(state: *DialogState, top: i32) void { +fn layoutAttachmentsSection(hwnd: c.HWND, state: *DialogState, top: i32) void { const shown = attachmentsVisible(state); const command = if (shown) c.SW_SHOW else c.SW_HIDE; for ([_]c.HWND{ state.attachment_label, state.attachment_listbox, state.attachment_attach_button, state.attachment_remove_button, state.attachment_help }) |control| _ = c.ShowWindow(control, command); if (!shown) return; - _ = c.MoveWindow(state.attachment_label, 18, top, 530, 18, 1); - _ = c.MoveWindow(state.attachment_listbox, 18, top + 18, 392, 84, 1); - _ = c.MoveWindow(state.attachment_attach_button, 422, top + 18, 126, 26, 1); - _ = c.MoveWindow(state.attachment_remove_button, 422, top + 48, 126, 26, 1); - _ = c.MoveWindow(state.attachment_help, 18, top + 106, 530, 18, 1); + const margin = scaled(state, form_margin); + const width = formContentWidth(hwnd, state); + const button_width = scaled(state, 132); + const gap = scaled(state, 12); + const list_width = width - button_width - gap; + _ = c.MoveWindow(state.attachment_label, margin, top, width, scaled(state, form_label_height), 1); + _ = c.MoveWindow(state.attachment_listbox, margin, top + scaled(state, 26), list_width, scaled(state, 106), 1); + _ = c.MoveWindow(state.attachment_attach_button, margin + list_width + gap, top + scaled(state, 26), button_width, scaled(state, 32), 1); + _ = c.MoveWindow(state.attachment_remove_button, margin + list_width + gap, top + scaled(state, 66), button_width, scaled(state, 32), 1); + _ = c.MoveWindow(state.attachment_help, margin, top + scaled(state, 140), width, scaled(state, form_help_height), 1); } fn refreshAttachmentListbox(state: *DialogState) void { @@ -1586,15 +1607,16 @@ fn createTileButtons(hwnd: c.HWND, state: *DialogState, index: usize) c.HWND { std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | @as(c.DWORD, @intCast(c.BS_OWNERDRAW)), - 18, + scaled(state, 18), 0, - 250, - 52, + scaled(state, 250), + scaled(state, 76), hwnd, childId(tile_base_id + i), c.GetModuleHandleW(null), null, ); + AppFont.apply(button, AppFont.control_size, false); state.tile_buttons[i] = button; if (first == null) first = button; } @@ -1622,10 +1644,10 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { std.unicode.utf8ToUtf16LeStringLiteral("COMBOBOX").ptr, null, style | @as(c.DWORD, @intCast(if (state.kind == .template_picker) c.CBS_DROPDOWN else c.CBS_DROPDOWNLIST)) | @as(c.DWORD, @intCast(c.WS_VSCROLL)), - 18, + scaled(state, 18), 0, - 530, - 180, + scaled(state, 530), + scaled(state, 180), hwnd, childId(9100 + index), c.GetModuleHandleW(null), @@ -1639,10 +1661,10 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, style | @as(c.DWORD, @intCast(c.BS_AUTOCHECKBOX)), - 18, + scaled(state, 18), 0, - 530, - 24, + scaled(state, 530), + scaled(state, form_input_height), hwnd, childId(9100 + index), c.GetModuleHandleW(null), @@ -1654,10 +1676,10 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, null, style | @as(c.DWORD, @intCast(c.ES_AUTOHSCROLL)) | (if (state.input_kinds[index] == .readonly) @as(c.DWORD, @intCast(c.ES_READONLY)) else 0), - 18, + scaled(state, 18), 0, - 530, - 24, + scaled(state, 530), + scaled(state, form_input_height), hwnd, childId(9100 + index), c.GetModuleHandleW(null), @@ -1665,6 +1687,8 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { ), } orelse return; state.edits[index] = input; + AppFont.apply(input, AppFont.control_size, false); + AppFont.apply(state.labels[index], AppFont.control_size, true); switch (state.input_kinds[index]) { .tiles => {}, .combo => { @@ -1708,6 +1732,7 @@ fn createField(hwnd: c.HWND, state: *DialogState, index: usize) void { }, } createStatic(hwnd, state, fieldHelp(state.kind, index), 18, 0, 530, 18, &state.helps[index]); + AppFont.apply(state.helps[index], 12, false); } fn setStaticText(state: *DialogState, hwnd: c.HWND, text: []const u8) void { @@ -1718,7 +1743,7 @@ fn setStaticText(state: *DialogState, hwnd: c.HWND, text: []const u8) void { } fn rowHeight(state: *const DialogState, index: usize) i32 { - return if (state.input_kinds[index] == .tiles) tile_row_height else 64; + return scaled(state, if (state.input_kinds[index] == .tiles) tile_row_height else form_row_height); } fn showsRecap(kind: Kind) bool { @@ -1750,7 +1775,7 @@ fn refreshRecap(state: *DialogState) void { } fn fieldTop(state: *const DialogState, target: usize) ?i32 { - var y: i32 = 54; + var y = scaled(state, form_fields_top); for (0..state.field_count) |index| { if (!state.visible[index]) continue; if (index == target) return y; @@ -1760,7 +1785,10 @@ fn fieldTop(state: *const DialogState, target: usize) ?i32 { } fn layoutForm(hwnd: c.HWND, state: *DialogState) void { - var y: i32 = 54; + var y = scaled(state, form_fields_top); + const margin = scaled(state, form_margin); + const width = formContentWidth(hwnd, state); + _ = c.MoveWindow(state.intro, margin, scaled(state, 16) - state.scroll_offset, width, scaled(state, 48), 1); for (0..state.field_count) |index| { const shown = state.visible[index]; const command = if (shown) c.SW_SHOW else c.SW_HIDE; @@ -1773,34 +1801,33 @@ fn layoutForm(hwnd: c.HWND, state: *DialogState) void { _ = c.ShowWindow(state.helps[index], if (state.input_kinds[index] == .tiles) c.SW_HIDE else command); if (!shown) continue; const top = y - state.scroll_offset; - _ = c.MoveWindow(state.labels[index], 18, top, 530, 18, 1); + _ = c.MoveWindow(state.labels[index], margin, top, width, scaled(state, form_label_height), 1); if (state.input_kinds[index] == .tiles) { - layoutTiles(state, index, 18, top + 20, 530); + layoutTiles(state, index, margin, top + scaled(state, 28), width); } 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); + _ = c.MoveWindow(state.edits[index], margin, top + scaled(state, 26), width, inputControlHeight(state, state.input_kinds[index]), 1); + _ = c.MoveWindow(state.helps[index], margin, top + scaled(state, 62), width, scaled(state, form_help_height), 1); } y += rowHeight(state, index); } if (state.kind == .node) { - layoutAttachmentsSection(state, y - state.scroll_offset); - if (attachmentsVisible(state)) y += attachment_section_height; + layoutAttachmentsSection(hwnd, state, y - state.scroll_offset); + if (attachmentsVisible(state)) y += scaled(state, attachment_section_height); } if (showsRecap(state.kind)) { _ = c.ShowWindow(state.recap, c.SW_SHOW); - _ = c.MoveWindow(state.recap, 18, y - state.scroll_offset, 530, 34, 1); + _ = c.MoveWindow(state.recap, margin, y - state.scroll_offset, width, scaled(state, 40), 1); } updateScrollBar(hwnd, state); } 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 gap = scaled(state, 12); const tile_width = @divTrunc(width - gap * (tile_columns - 1), tile_columns); - const tile_height: i32 = 58; + const tile_height = scaled(state, 76); for (0..state.tile_count) |i| { const col: i32 = @intCast(i % tile_columns); const tile_row: i32 = @intCast(i / tile_columns); @@ -1811,13 +1838,13 @@ fn layoutTiles(state: *DialogState, index: usize, x: i32, y: i32, width: i32) vo } fn contentHeight(state: *const DialogState) i32 { - var y: i32 = 54; + var y = scaled(state, form_fields_top); for (0..state.field_count) |index| { if (state.visible[index]) y += rowHeight(state, index); } - if (state.kind == .node and attachmentsVisible(state)) y += attachment_section_height; - if (showsRecap(state.kind)) y += 34; - return y + 12; + if (state.kind == .node and attachmentsVisible(state)) y += scaled(state, attachment_section_height); + if (showsRecap(state.kind)) y += scaled(state, 40); + return y + scaled(state, 16); } fn clientHeight(hwnd: c.HWND) i32 { @@ -1827,16 +1854,16 @@ fn clientHeight(hwnd: c.HWND) i32 { } fn scrollFields(hwnd: c.HWND, state: *DialogState, requested: i32) void { - const viewport = clientHeight(hwnd); + const viewport = formViewportHeight(hwnd, state); const content = contentHeight(state); const next = boundedScrollOffset(content, viewport, state.scroll_offset, requested); setScrollOffsetValue(hwnd, state, next); } fn setScrollOffset(hwnd: c.HWND, state: *DialogState, requested: i32) void { - const viewport = clientHeight(hwnd); + const viewport = formViewportHeight(hwnd, state); const content = contentHeight(state); - const next = std.math.clamp(requested, 0, @max(0, content - @max(120, viewport - 48))); + const next = std.math.clamp(requested, 0, @max(0, content - viewport)); setScrollOffsetValue(hwnd, state, next); } @@ -1849,9 +1876,9 @@ fn setScrollOffsetValue(hwnd: c.HWND, state: *DialogState, next: i32) void { } fn updateScrollBar(hwnd: c.HWND, state: *DialogState) void { - const viewport = clientHeight(hwnd); + const viewport = formViewportHeight(hwnd, state); const content = contentHeight(state); - const page: u32 = @intCast(@max(1, viewport - 48)); + const page: u32 = @intCast(@max(1, viewport)); const max_offset = @max(0, content - @as(i32, @intCast(page))); state.scroll_offset = std.math.clamp(state.scroll_offset, 0, max_offset); var info: c.SCROLLINFO = std.mem.zeroes(c.SCROLLINFO); @@ -1866,10 +1893,10 @@ fn updateScrollBar(hwnd: c.HWND, state: *DialogState) void { fn ensureControlVisible(hwnd: c.HWND, state: *DialogState, index: usize) void { const top = fieldTop(state, index) orelse return; - const viewport = clientHeight(hwnd); + const viewport = formViewportHeight(hwnd, state); const bottom = top + rowHeight(state, index) - 3; const visible_top = state.scroll_offset; - const visible_bottom = state.scroll_offset + @max(1, viewport - 48); + const visible_bottom = state.scroll_offset + @max(1, viewport); if (top < visible_top) { scrollFields(hwnd, state, top - visible_top); } else if (bottom > visible_bottom) { @@ -1878,11 +1905,11 @@ fn ensureControlVisible(hwnd: c.HWND, state: *DialogState, index: usize) void { } fn boundedScrollOffset(content: i32, viewport: i32, current: i32, requested: i32) i32 { - const max_offset = @max(0, content - @max(120, viewport - 48)); + const max_offset = @max(0, content - @max(120, viewport)); return std.math.clamp(current + requested, 0, max_offset); } -fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32) void { +fn createButton(hwnd: c.HWND, state: *const DialogState, text: []const u8, id: usize, x: i32, y: i32) void { const wide = utf8ToWideZ(std.heap.c_allocator, text) catch return; defer std.heap.c_allocator.free(wide); const button_style: c.DWORD = @intCast(if (id == ok_id) c.BS_DEFPUSHBUTTON else c.BS_PUSHBUTTON); @@ -1890,14 +1917,36 @@ fn createButton(hwnd: c.HWND, text: []const u8, id: usize, x: i32, y: i32) void @as(c.DWORD, @intCast(c.WS_VISIBLE)) | @as(c.DWORD, @intCast(c.WS_TABSTOP)) | button_style; - _ = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, style, x, y, 70, 26, hwnd, childId(id), c.GetModuleHandleW(null), null); + const button = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, style, scaled(state, x), scaled(state, y), scaled(state, 88), scaled(state, 34), hwnd, childId(id), c.GetModuleHandleW(null), null); + AppFont.apply(button, AppFont.control_size, false); +} + +fn layoutFooter(hwnd: c.HWND, state: *DialogState) void { + var client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &client); + const margin = scaled(state, form_margin); + const button_width = scaled(state, 96); + const button_height = scaled(state, 36); + const gap = scaled(state, 12); + const y = client.bottom - scaled(state, 50); + const ok_x = client.right - margin - button_width; + const cancel_x = ok_x - gap - button_width; + _ = c.MoveWindow(c.GetDlgItem(hwnd, @intCast(ok_id)), ok_x, y, button_width, button_height, 1); + _ = c.MoveWindow(c.GetDlgItem(hwnd, @intCast(cancel_id)), cancel_x, y, button_width, button_height, 1); + if ((state.kind == .node and state.templates_available) or state.kind == .worktree_sweep) { + const auxiliary_id: usize = if (state.kind == .worktree_sweep) reveal_id else templates_id; + _ = c.MoveWindow(c.GetDlgItem(hwnd, @intCast(auxiliary_id)), cancel_x - gap - scaled(state, 132), y, scaled(state, 132), button_height, 1); + } + if (state.validation != null) + _ = c.MoveWindow(state.validation, margin, client.bottom - scaled(state, 54), @max(scaled(state, 180), cancel_x - margin - gap), scaled(state, 42), 1); } fn createCheckBox(hwnd: c.HWND, state: *DialogState, text: []const u8, index: usize, y: i32) void { const wide = utf8ToWideZ(state.allocator, text) catch return; defer state.allocator.free(wide); - const check = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, 18, y, 380, 24, hwnd, childId(9200 + index), c.GetModuleHandleW(null), null) orelse return; + const check = c.CreateWindowExW(0, std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.BS_AUTOCHECKBOX, scaled(state, 18), scaled(state, y), scaled(state, 380), scaled(state, form_input_height), hwnd, childId(9200 + index), c.GetModuleHandleW(null), null) orelse return; state.checks[index] = check; + AppFont.apply(check, AppFont.control_size, false); const selected = if (index == 0) state.policy.allow_reclaim else state.policy.confirm_each_reclaim; _ = c.SendMessageW(check, c.BM_SETCHECK, if (selected) c.BST_CHECKED else c.BST_UNCHECKED, 0); } @@ -1915,16 +1964,17 @@ fn createPolicyRadio(hwnd: c.HWND, state: *DialogState, text: []const u8, index: std.unicode.utf8ToUtf16LeStringLiteral("BUTTON").ptr, wide.ptr, style, - 18, - y, - 530, - 24, + scaled(state, 18), + scaled(state, y), + scaled(state, 530), + scaled(state, form_input_height), hwnd, childId(9200 + index), c.GetModuleHandleW(null), null, ) orelse return; state.checks[index] = radio; + AppFont.apply(radio, AppFont.control_size, false); const selected_index: usize = switch (state.policy.effectiveResolveAction()) { .remove => 0, .ask => 1, @@ -1939,16 +1989,17 @@ fn createPolicyEdit(hwnd: c.HWND, state: *DialogState, index: usize, x: i32, y: std.unicode.utf8ToUtf16LeStringLiteral("EDIT").ptr, null, c.WS_CHILD | c.WS_VISIBLE | c.WS_TABSTOP | c.ES_AUTOHSCROLL | c.ES_NUMBER, - x, - y, - width, - 24, + scaled(state, x), + scaled(state, y), + scaled(state, width), + scaled(state, form_input_height), hwnd, childId(9100 + index), c.GetModuleHandleW(null), null, ) orelse return; state.edits[index] = edit; + AppFont.apply(edit, AppFont.control_size, false); const wide = utf8ToWideZ(state.allocator, state.values[index]) catch return; defer state.allocator.free(wide); _ = c.SetWindowTextW(edit, wide.ptr); @@ -2259,8 +2310,9 @@ test "guided choices map human labels to stable wire values" { .{ .id = "node-b", .title = "Beta" }, }; try std.testing.expectEqual(@as(usize, 1), endpointIndex(&endpoints, "node-b")); - try std.testing.expectEqual(@as(i32, 180), inputControlHeight(.combo)); - try std.testing.expectEqual(@as(i32, 24), inputControlHeight(.checkbox)); + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null }; + try std.testing.expectEqual(@as(i32, 220), inputControlHeight(&state, .combo)); + try std.testing.expectEqual(@as(i32, form_input_height), inputControlHeight(&state, .checkbox)); try std.testing.expect(attachment_attach_id != templates_id); try std.testing.expect(attachment_remove_id != templates_id); try std.testing.expect(attachment_attach_id != attachment_remove_id); @@ -2478,19 +2530,19 @@ test "graph form cancellation leaves draft values untouched" { } test "keyboard-sized guided form keeps every field reachable through bounded scrolling" { - const content: i32 = 54 + 10 * 64 + 12; - const viewport: i32 = 768 - 96; + const content: i32 = form_fields_top + 10 * form_row_height + 16; + const viewport: i32 = 768 - form_footer_height; const max_offset = boundedScrollOffset(content, viewport, 0, 100000); try std.testing.expectEqual(max_offset, boundedScrollOffset(content, viewport, max_offset, 48)); try std.testing.expectEqual(@as(i32, 0), boundedScrollOffset(content, viewport, 0, -48)); - const last_top: i32 = 54 + 9 * 64; - try std.testing.expect(last_top + 61 <= max_offset + viewport - 48); + const last_top: i32 = form_fields_top + 9 * form_row_height; + try std.testing.expect(last_top + form_row_height <= max_offset + viewport); } test "scrollbar thumb positions seek and clamp the dialog content" { - const content: i32 = 54 + 10 * 64 + 12; - const viewport: i32 = 768 - 96; - try std.testing.expectEqual(@as(i32, 0), std.math.clamp(@as(i32, 0), 0, content - (viewport - 48))); + const content: i32 = form_fields_top + 10 * form_row_height + 16; + const viewport: i32 = 768 - form_footer_height; + try std.testing.expectEqual(@as(i32, 0), std.math.clamp(@as(i32, 0), 0, content - viewport)); const max_offset = boundedScrollOffset(content, viewport, 0, 100000); 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)); @@ -2519,13 +2571,26 @@ test "tile rows reserve full teaching-tile height while other rows stay compact" 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, form_row_height), 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 + attachment_section_height + 34 + 12), contentHeight(&state)); + try std.testing.expectEqual(@as(i32, form_row_height), rowHeight(&state, 2)); + try std.testing.expectEqual(@as(i32, form_fields_top), fieldTop(&state, 0).?); + try std.testing.expectEqual(@as(i32, form_fields_top + form_row_height), fieldTop(&state, 1).?); + try std.testing.expectEqual(@as(i32, form_fields_top + form_row_height + tile_row_height), fieldTop(&state, 2).?); + try std.testing.expectEqual(@as(i32, form_fields_top + form_row_height + tile_row_height + form_row_height + attachment_section_height + 40 + 16), contentHeight(&state)); +} + +test "native form layout scales design units at common Windows DPI steps" { + var state = DialogState{ .allocator = std.testing.allocator, .kind = .node, .parent = null, .dpi = 144 }; + state.field_count = 1; + state.visible[0] = true; + try std.testing.expectEqual(@as(i32, 126), rowHeight(&state, 0)); + try std.testing.expectEqual(@as(i32, 114), fieldTop(&state, 0).?); + try std.testing.expectEqual(@as(i32, 48), inputControlHeight(&state, .edit)); + state.dpi = 192; + try std.testing.expectEqual(@as(i32, 168), rowHeight(&state, 0)); + try std.testing.expectEqual(@as(i32, 152), fieldTop(&state, 0).?); + try std.testing.expectEqual(@as(i32, 64), inputControlHeight(&state, .edit)); } test "blendColor tints toward the overlay color proportionally to strength" { diff --git a/graphcode-windows/src/TemplateLibrary.zig b/graphcode-windows/src/TemplateLibrary.zig index 088001a3..0d8898f1 100644 --- a/graphcode-windows/src/TemplateLibrary.zig +++ b/graphcode-windows/src/TemplateLibrary.zig @@ -33,9 +33,11 @@ pub const Library = struct { pub fn load(allocator: std.mem.Allocator, project_path: []const u8) !Library { var library = Library.init(allocator); errdefer library.deinit(); - const project = try projectDirectory(allocator, project_path); - defer allocator.free(project); - try loadDirectory(&library, project); + if (isFilesystemProject(project_path)) { + const project = try projectDirectory(allocator, project_path); + defer allocator.free(project); + try loadDirectory(&library, project); + } const home = try homeDirectory(allocator); defer allocator.free(home); try loadDirectory(&library, home); @@ -164,6 +166,10 @@ fn projectDirectory(allocator: std.mem.Allocator, project_path: []const u8) ![]u return std.fs.path.join(allocator, &.{ project_path, ".graphcode", "templates" }); } +fn isFilesystemProject(project_path: []const u8) bool { + return std.mem.indexOf(u8, project_path, "://") == null; +} + fn containsID(templates: []const Template, id: []const u8) bool { for (templates) |template| if (std.mem.eql(u8, template.id, id)) return true; return false; @@ -285,3 +291,11 @@ test "timed templates populate the scheduled prompt" { try std.testing.expectEqualStrings("Check the queue.", draft.trigger_prompt); try std.testing.expectEqualStrings("old", draft.first_instruction); } + +test "virtual projects do not produce filesystem template paths" { + try std.testing.expect(!isFilesystemProject("graphcode://global")); + try std.testing.expect(!isFilesystemProject("ssh://host/repository")); + try std.testing.expect(!isFilesystemProject("codespace://workspace/repository")); + try std.testing.expect(isFilesystemProject("C:\\src\\GraphCode")); + try std.testing.expect(isFilesystemProject("\\\\server\\share\\GraphCode")); +} diff --git a/graphcode-windows/src/main.zig b/graphcode-windows/src/main.zig index 7923d015..dbd0da8c 100644 --- a/graphcode-windows/src/main.zig +++ b/graphcode-windows/src/main.zig @@ -1,5 +1,6 @@ const std = @import("std"); const App = @import("App.zig").App; +const Diagnostics = @import("Diagnostics.zig"); const Win32 = @import("Win32.zig"); const c = Win32.c; const build_options = @import("build_options"); @@ -34,6 +35,9 @@ pub export fn WinMain( _: [*:0]u16, _: c.INT, ) callconv(.winapi) c.INT { - main() catch return 1; + main() catch |err| { + Diagnostics.record(std.heap.c_allocator, "fatal", @errorName(err)); + return 1; + }; return 0; } From bb2931d198e3c81cadac985e97eebe0ca060327d Mon Sep 17 00:00:00 2001 From: Robert Sim Date: Fri, 25 Sep 2026 10:58:29 -0700 Subject: [PATCH 2/4] Complete Windows daemon integration Add native Windows graph bundle, predicate, provider, and zmx launch support. Replace idle named-pipe polling with blocking reads and back off presence refreshes when no loops are running. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Sources/GraphExportBundle+ZIP.swift | 36 +++-- GraphcodeKit/Sources/GraphStore.swift | 17 ++- .../IPC/WindowsNamedPipeTransport.swift | 5 +- GraphcodeKit/Sources/ProjectRegistry.swift | 22 ++- .../Sources/Sessions/ProviderPath.swift | 43 +++++- .../Sessions/ShellPredicateEvaluator.swift | 22 ++- .../Sources/Sessions/ZmxLocator.swift | 6 +- .../Sources/Sessions/ZmxSessionLauncher.swift | 52 +++++-- graphcode/Tests/PresencePollingTests.swift | 2 + windows-tests/WindowsDaemonTests.swift | 133 ++++++++++++++++++ 10 files changed, 298 insertions(+), 40 deletions(-) diff --git a/GraphcodeKit/Sources/GraphExportBundle+ZIP.swift b/GraphcodeKit/Sources/GraphExportBundle+ZIP.swift index 8e57ffe1..df4893cf 100644 --- a/GraphcodeKit/Sources/GraphExportBundle+ZIP.swift +++ b/GraphcodeKit/Sources/GraphExportBundle+ZIP.swift @@ -186,12 +186,17 @@ extension GraphExportBundle { private static func createZipArchive(at destination: URL, from source: URL) throws { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") - // `--norsrc`, not `--sequesterRsrc`: sequestering writes AppleDouble copies into a - // `__MACOSX/` shadow tree, which doubled the archive's file count with junk every - // other platform shows the user. Nothing in a bundle has resource forks worth - // keeping. - process.arguments = ["-c", "-k", "--norsrc", source.path, destination.path] + #if os(Windows) + process.executableURL = windowsTarURL() + process.arguments = ["-a", "-cf", destination.path, "-C", source.path, "."] + #else + process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto") + // `--norsrc`, not `--sequesterRsrc`: sequestering writes AppleDouble copies into a + // `__MACOSX/` shadow tree, which doubled the archive's file count with junk every + // other platform shows the user. Nothing in a bundle has resource forks worth + // keeping. + process.arguments = ["-c", "-k", "--norsrc", source.path, destination.path] + #endif try? FileManager.default.removeItem(at: destination) try process.run() @@ -204,8 +209,13 @@ extension GraphExportBundle { private static func extractZipArchive(from source: URL, to destination: URL) throws { let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") - process.arguments = ["-q", source.path, "-d", destination.path] + #if os(Windows) + process.executableURL = windowsTarURL() + process.arguments = ["-xf", source.path, "-C", destination.path] + #else + process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") + process.arguments = ["-q", source.path, "-d", destination.path] + #endif try process.run() process.waitUntilExit() @@ -215,6 +225,16 @@ extension GraphExportBundle { } } + #if os(Windows) + private static func windowsTarURL() -> URL { + let environment = ProcessInfo.processInfo.environment + let root = environment["SystemRoot"] ?? environment["WINDIR"] ?? "C:\\Windows" + return URL(fileURLWithPath: root) + .appendingPathComponent("System32", isDirectory: true) + .appendingPathComponent("tar.exe") + } + #endif + private func readmeMarkdown(for manifest: ExportManifest) -> String { var lines: [String] = [ "# GraphCode Export Bundle", diff --git a/GraphcodeKit/Sources/GraphStore.swift b/GraphcodeKit/Sources/GraphStore.swift index 9410f8a5..c857c27f 100644 --- a/GraphcodeKit/Sources/GraphStore.swift +++ b/GraphcodeKit/Sources/GraphStore.swift @@ -3139,7 +3139,7 @@ public actor GraphStore { // the cycle may begin. if edge.fireCount > 0 { if let until = edge.cycleGuard?.effectiveUntil, let onEvaluatePredicate { - let workingDirectory = graph.nodes[id: edge.from]?.worktreeBinding?.worktreePath + let workingDirectory = graph.nodes[id: edge.from].flatMap(predicateWorkingDirectory) let satisfied = await onEvaluatePredicate( ShellPredicate(command: until, workingDirectory: workingDirectory)) // The condition holds, so the loop is done — stop without another pass. @@ -3175,7 +3175,7 @@ public actor GraphStore { let command = goal.effectiveMetricCommand, let onCaptureScript else { return } let output = await onCaptureScript( - ShellPredicate(command: command, workingDirectory: node.worktreeBinding?.worktreePath)) + ShellPredicate(command: command, workingDirectory: predicateWorkingDirectory(for: node))) guard let output, let value = MetricTrend.value(fromScriptOutput: output) else { recordMemory(nodeID, "metric: not measured (command failed or printed no number)") return @@ -3249,7 +3249,7 @@ public actor GraphStore { case .script(let command): guard let onCaptureScript else { return nil } return await onCaptureScript( - ShellPredicate(command: command, workingDirectory: source.worktreeBinding?.worktreePath)) + ShellPredicate(command: command, workingDirectory: predicateWorkingDirectory(for: source))) } } @@ -3949,7 +3949,7 @@ public actor GraphStore { return } let shellPredicate = ShellPredicate( - command: predicate, workingDirectory: node.worktreeBinding?.worktreePath) + command: predicate, workingDirectory: predicateWorkingDirectory(for: node)) var fingerprint: String? if goal.skipsUnchangedWorkspace, !forcePredicate, let onCaptureScript { @@ -3983,6 +3983,7 @@ public actor GraphStore { goalCache.setReawakened(fingerprint, for: nodeID) goalCache.clearFeedback(for: nodeID) } + } let outcome: PredicateOutcome @@ -4008,6 +4009,14 @@ public actor GraphStore { await relayPredicateFailure(to: current, predicate: predicate, outcome: outcome) } + /// Imported loops deliberately drop machine-specific worktree bindings. Local + /// predicates still belong to the graph's project, not the daemon's launch folder. + private func predicateWorkingDirectory(for node: LoopNode) -> String? { + if let worktreePath = node.worktreeBinding?.worktreePath { return worktreePath } + guard RemoteProjectLocation.parse(projectPath: graph.project.path) == nil else { return nil } + return graph.project.path + } + /// A verdict counts only for the goal it was recorded against. One dated before the goal /// was last replaced belongs to the earlier goal; an undated one is trusted only while the /// goal has never been replaced. diff --git a/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift b/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift index fd00e8be..c3799b19 100644 --- a/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift +++ b/GraphcodeKit/Sources/IPC/WindowsNamedPipeTransport.swift @@ -1034,11 +1034,8 @@ import Foundation _ timeout: TimeInterval = 5 ) async throws -> Data { try await withTaskCancellationHandler { - while try !stream.hasAvailableBytes() { - try await Task.sleep(for: .milliseconds(10)) - } return try await withCheckedThrowingContinuation { continuation in - DispatchQueue.global(qos: .utility).async { + Thread.detachNewThread { do { continuation.resume( returning: try self.receiveFrameWithPostHandshakeDeadlineSynchronously(timeout)) diff --git a/GraphcodeKit/Sources/ProjectRegistry.swift b/GraphcodeKit/Sources/ProjectRegistry.swift index 417b9918..7bc62cb4 100644 --- a/GraphcodeKit/Sources/ProjectRegistry.swift +++ b/GraphcodeKit/Sources/ProjectRegistry.swift @@ -291,10 +291,21 @@ public actor ProjectRegistry { /// labels at once (`list --where k=v` is in its help but returns every session whatever /// you filter on, so it cannot be used to batch this). /// - /// Fifteen seconds puts a handful of millisecond-long socket round-trips a minute - /// against a canvas that tells the truth within a glance. It is the one number to turn - /// up if a graph ever grows to hundreds of loops. + /// Fifteen seconds keeps active work current. Once every loaded graph has no running + /// loops, the poll backs off to a minute: terminal input can still wake a parked session + /// without making a canvas full of stalled or completed loops expensive to leave open. static let presencePollInterval: Duration = .seconds(15) + static let idlePresencePollInterval: Duration = .seconds(60) + + static func presencePollDelay(runningLoops: Int) -> Duration { + runningLoops > 0 ? presencePollInterval : idlePresencePollInterval + } + + private func presencePollDelay() async -> Duration { + var running = 0 + for store in stores.values { running += await store.runningLoopCount() } + return Self.presencePollDelay(runningLoops: running) + } /// Polling runs only while a client is attached — see `GraphStore.pollPresence` for why /// the same guard is repeated per store. Started by the first connection and cancelled @@ -303,9 +314,10 @@ public actor ProjectRegistry { guard presencePoller == nil, readPresence != nil else { return } presencePoller = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(for: Self.presencePollInterval) + guard let self else { return } + try? await Task.sleep(for: await self.presencePollDelay()) guard !Task.isCancelled else { return } - await self?.pollPresence() + await self.pollPresence() } } } diff --git a/GraphcodeKit/Sources/Sessions/ProviderPath.swift b/GraphcodeKit/Sources/Sessions/ProviderPath.swift index f9cec5a1..a98fe4e3 100644 --- a/GraphcodeKit/Sources/Sessions/ProviderPath.swift +++ b/GraphcodeKit/Sources/Sessions/ProviderPath.swift @@ -12,10 +12,20 @@ public enum ProviderPath { /// from `~/.zshrc`. `whence -p` rather than `command -v`: the launch `exec`s the agent, /// which only a file on PATH satisfies, so an alias of the same name must not count. public static func probeInvocation(for executable: String) -> [String] { - [ - "/bin/zsh", "-i", "-l", "-c", - "whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1", - ] + #if os(Windows) + let systemRoot = ProcessInfo.processInfo.environment["SystemRoot"] ?? "C:\\Windows" + return [ + URL(fileURLWithPath: systemRoot) + .appendingPathComponent("System32") + .appendingPathComponent("where.exe").path, + executable, + ] + #else + return [ + "/bin/zsh", "-i", "-l", "-c", + "whence -p -- \(RemoteProjectLocation.shellQuoted(executable)) >/dev/null 2>&1", + ] + #endif } /// `nil` when the shell did not answer in time: a slow `~/.zshrc` says nothing about @@ -25,6 +35,30 @@ public enum ProviderPath { { if await FoundCache.shared.isFresh(executable) { return true } let invocation = probeInvocation(for: executable) + #if os(Windows) + let process = Process() + process.executableURL = URL(fileURLWithPath: invocation[0]) + process.arguments = Array(invocation.dropFirst()) + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + } catch { + return nil + } + let found = await withDeadline(deadline) { + await Task.detached { + process.waitUntilExit() + return process.terminationStatus == 0 + }.value + } + guard let found else { + if process.isRunning { process.terminate() } + return nil + } + if found { await FoundCache.shared.record(executable) } + return found + #else guard let shell = invocation.first, let session = try? PTYProcessSession( @@ -36,6 +70,7 @@ public enum ProviderPath { } if found { await FoundCache.shared.record(executable) } return found + #endif } /// The failure launching `node` would hit, or `nil`. Always `nil` for a remote project: diff --git a/GraphcodeKit/Sources/Sessions/ShellPredicateEvaluator.swift b/GraphcodeKit/Sources/Sessions/ShellPredicateEvaluator.swift index d43d9e08..643ead2c 100644 --- a/GraphcodeKit/Sources/Sessions/ShellPredicateEvaluator.swift +++ b/GraphcodeKit/Sources/Sessions/ShellPredicateEvaluator.swift @@ -76,8 +76,26 @@ public enum ShellPredicateEvaluator { guard !trimmed.isEmpty else { return nil } let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/zsh") - process.arguments = ["-l", "-c", "eval \"$GRAPHCODE_PREDICATE\""] + #if os(Windows) + process.executableURL = WindowsShellStrategy().powerShell + process.arguments = [ + "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", + """ + $global:LASTEXITCODE = 0 + try { + Invoke-Expression $env:GRAPHCODE_PREDICATE + if (-not $?) { exit 1 } + exit $global:LASTEXITCODE + } catch { + [Console]::Error.WriteLine($_) + exit 1 + } + """, + ] + #else + process.executableURL = URL(fileURLWithPath: "/bin/zsh") + process.arguments = ["-l", "-c", "eval \"$GRAPHCODE_PREDICATE\""] + #endif if let workingDirectory = predicate.workingDirectory { process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) } diff --git a/GraphcodeKit/Sources/Sessions/ZmxLocator.swift b/GraphcodeKit/Sources/Sessions/ZmxLocator.swift index ef468ec3..7df5906e 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxLocator.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxLocator.swift @@ -6,7 +6,11 @@ import Foundation /// app's (often minimal, launchd-provided) `PATH`. public enum ZmxLocator { public static var binaryURL: URL { - SupportDirectory.binDirectory.appendingPathComponent("zmx") + #if os(Windows) + SupportDirectory.binDirectory.appendingPathComponent("zmx.exe") + #else + SupportDirectory.binDirectory.appendingPathComponent("zmx") + #endif } public static var isInstalled: Bool { diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 84159d7e..85982882 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -371,8 +371,26 @@ public enum ZmxSessionLauncher { static func loginShellInvocation( of command: String, arguments: [String], environment: [String: String] = [:], - scriptSuffix: String = "" + scriptSuffix: String = "", usesWindowsShell: Bool = false ) -> [String] { + #if os(Windows) + if usesWindowsShell { + let environmentPrefix = environment.keys.sorted().flatMap { key in + let value = environment[key] ?? "" + let inheritedPrefix = "${\(key):+$\(key),}" + let windowsValue: String + if value.hasPrefix(inheritedPrefix) { + let suffix = String(value.dropFirst(inheritedPrefix.count)) + windowsValue = + ProcessInfo.processInfo.environment[key].map { "\($0),\(suffix)" } ?? suffix + } else { + windowsValue = value + } + return ["set", "\(key)=\(windowsValue)", "&&"] + } + return environmentPrefix + [command] + arguments + } + #endif // `env K=V …` rather than exporting: it scopes the variables to this one process, and // keeps the script a single `exec` so the shell doesn't linger as a parent. Values are // double-quoted because a project path can contain spaces; the whole script is one @@ -1293,7 +1311,7 @@ public enum ZmxSessionLauncher { environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) + scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) // `zmx` types this command into the session's shell, and a tty in canonical mode // discards everything past `MAX_CANON` (1024 bytes on macOS). Overrunning it does not @@ -1324,7 +1342,7 @@ public enum ZmxSessionLauncher { environment: Self.environment( forBackend: node.backend, briefingPath: briefingPath, hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) + scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } let unbriefedCommand = shed(prompt: promptWithMemory, briefingPath: nil, extraPath: nil) @@ -1462,7 +1480,7 @@ public enum ZmxSessionLauncher { forBackend: node.backend, projectPath: projectPath, isRemote: remote != nil, settings: settings), hooksFile: hooksFile, remoteHooksPath: remoteEnvironmentPath), - scriptSuffix: remoteHooksSuffix) + scriptSuffix: remoteHooksSuffix, usesWindowsShell: remote == nil) } /// Stands in for a remote session ID that this machine cannot know: the ID was written @@ -2532,13 +2550,23 @@ public enum ZmxSessionLauncher { ) async { let run = quotedCommand([zmxPath] + runArguments) #if os(Windows) - // `logFragment` is POSIX shell — `mkdir -p`, `wc`, `printf`, `$HOME` — so it cannot - // ride inside a `cmd.exe` script. Windows ensures therefore run unlogged rather - // than with a fragment quoted into something that would not execute; the Swift-side - // `DialLog.record` is the path to route this through when it is wired up. - let script = "\(checkCommand) >NUL 2>&1 || \(run)" - let executable = "cmd.exe" - let arguments = ["/d", "/s", "/c", script] + // Windows zmx receives argv directly. POSIX shell quoting turns paths and prompts + // into literal single-quoted text under cmd.exe, so launch the provider without a + // shell. zmx rejects a duplicate session atomically, preserving the check-or-create + // race guarantee without translating the POSIX readiness probe. + let process = Process() + process.executableURL = URL(fileURLWithPath: zmxPath) + process.arguments = runArguments + if let workingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory) + } + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + do { + try process.run() + await Task.detached { process.waitUntilExit() }.value + } catch {} + return #else // The stamp rides in the run branch, after the launch it describes and only if // that launch was made. Repair precedes relaunch so an alive unlabelled session @@ -2552,13 +2580,13 @@ public enum ZmxSessionLauncher { ?? "\(checkCommand) >/dev/null 2>&1 || \(repair)\(launch)" let executable = "/bin/sh" let arguments = ["-c", script] - #endif guard let session = try? PTYProcessSession( executable: executable, arguments: arguments, workingDirectory: workingDirectory) else { return } _ = await session.waitUntilFinished() + #endif } } diff --git a/graphcode/Tests/PresencePollingTests.swift b/graphcode/Tests/PresencePollingTests.swift index d2deb0f2..df8f7442 100644 --- a/graphcode/Tests/PresencePollingTests.swift +++ b/graphcode/Tests/PresencePollingTests.swift @@ -288,5 +288,7 @@ struct PresencePollingTests { // saying so. Worth pinning: dropping it to a second would multiply the subprocess // count by fifteen for a difference nobody could perceive on a canvas. #expect(ProjectRegistry.presencePollInterval == .seconds(15)) + #expect(ProjectRegistry.presencePollDelay(runningLoops: 1) == .seconds(15)) + #expect(ProjectRegistry.presencePollDelay(runningLoops: 0) == .seconds(60)) } } diff --git a/windows-tests/WindowsDaemonTests.swift b/windows-tests/WindowsDaemonTests.swift index 11d7df34..90089a5e 100644 --- a/windows-tests/WindowsDaemonTests.swift +++ b/windows-tests/WindowsDaemonTests.swift @@ -8,6 +8,14 @@ import XCTest #endif final class WindowsDaemonTests: XCTestCase { + private actor PredicateCapture { + private(set) var workingDirectory: String? + + func record(_ value: String?) { + workingDirectory = value + } + } + func testGraphcodeSettingsPersistAllProductChoices() throws { let url = FileManager.default.temporaryDirectory .appendingPathComponent("graphcode-settings-\(UUID().uuidString)", isDirectory: true) @@ -29,6 +37,131 @@ final class WindowsDaemonTests: XCTestCase { } #if os(Windows) + func testZmxLocatorUsesWindowsExecutableName() { + XCTAssertEqual(ZmxLocator.binaryURL.lastPathComponent, "zmx.exe") + } + + func testWindowsProviderProbeUsesWhere() { + let invocation = ProviderPath.probeInvocation(for: "copilot") + XCTAssertEqual(URL(fileURLWithPath: invocation[0]).lastPathComponent, "where.exe") + XCTAssertEqual(invocation[1], "copilot") + } + + func testWindowsProviderProbeCompletesWithoutAPTY() async { + let present = await ProviderPath.isOnPath("where.exe") + let missing = await ProviderPath.isOnPath("graphcode-provider-that-does-not-exist.exe") + XCTAssertEqual(present, true) + XCTAssertEqual(missing, false) + } + + func testWindowsZmxLaunchUsesCmdTokensInsteadOfZsh() { + XCTAssertEqual( + ZmxSessionLauncher.loginShellInvocation( + of: "copilot", + arguments: ["--interactive", "inspect the project"], + environment: ["COPILOT_CUSTOM_INSTRUCTIONS_DIRS": "C:\\GraphCode Briefing"], + usesWindowsShell: true), + [ + "set", "COPILOT_CUSTOM_INSTRUCTIONS_DIRS=C:\\GraphCode Briefing", "&&", + "copilot", "--interactive", "inspect the project", + ]) + } + + func testWindowsZmxLaunchResolvesInheritedEnvironmentExpansion() { + XCTAssertEqual( + ZmxSessionLauncher.loginShellInvocation( + of: "copilot", + arguments: [], + environment: [ + "GRAPHCODE_TEST_PATH": + "${GRAPHCODE_TEST_PATH:+$GRAPHCODE_TEST_PATH,}C:\\GraphCode Briefing" + ], + usesWindowsShell: true), + [ + "set", "GRAPHCODE_TEST_PATH=C:\\GraphCode Briefing", "&&", "copilot", + ]) + } + + func testPresencePollingBacksOffWhenNoLoopsAreRunning() { + XCTAssertEqual(ProjectRegistry.presencePollDelay(runningLoops: 1), .seconds(15)) + XCTAssertEqual(ProjectRegistry.presencePollDelay(runningLoops: 0), .seconds(60)) + } + + func testShellPredicateUsesPowerShellAndProjectWorkingDirectory() async throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-predicate-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: root) } + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + try Data("ready".utf8).write(to: root.appendingPathComponent("marker.txt")) + + let passed = await ShellPredicateEvaluator.evaluate( + ShellPredicate( + command: "if ((Get-Content marker.txt -Raw) -eq 'ready') { exit 0 } else { exit 1 }", + workingDirectory: root.path)) + let failed = await ShellPredicateEvaluator.evaluate( + ShellPredicate(command: "exit 7", workingDirectory: root.path)) + let captured = await ShellPredicateEvaluator.capture( + ShellPredicate(command: "Write-Output 0.875", workingDirectory: root.path)) + + XCTAssertTrue(passed) + XCTAssertFalse(failed) + XCTAssertEqual(captured, "0.875") + } + + func testImportedGoalPredicateDefaultsToTargetProjectDirectory() async throws { + let project = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-project-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: project, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: project) } + let capture = PredicateCapture() + let store = GraphStore( + graph: LoopGraph(project: ProjectRef(path: project.path, name: "target")), + onEvaluatePredicate: { predicate in + await capture.record(predicate.workingDirectory) + return true + }) + let exported = LoopNode( + title: "Imported goal", loopType: .goalBased, + goal: GoalSpec( + summary: "marker exists", predicate: "exit 0", pollIntervalSeconds: 1), + state: .idle) + let snapshot = LoopGraph( + project: ProjectRef(path: "C:\\source", name: "source"), + nodes: [exported]) + + _ = await store.handle(.importNodes(GraphImportRequest(snapshot: snapshot))) + try await Task.sleep(for: .seconds(2)) + + let workingDirectory = await capture.workingDirectory + XCTAssertEqual(workingDirectory, project.path) + } + + func testGraphExportBundleRoundTripsThroughWindowsZipTools() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("graphcode-export-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let node = LoopNode(title: "Portable loop", loopType: .goalBased) + let graph = LoopGraph( + project: ProjectRef(path: root.path, name: "portable"), + nodes: [node]) + let bundle = GraphExportBundle( + manifest: ExportManifest( + createdBy: "test", + contents: ExportContents( + nodeIDs: [node.id.uuidString], isFullGraph: true, + sourceProject: root.path)), + graphSnapshot: graph, + memoryByNodeID: [node.id.uuidString: ["portable memory"]]) + let archive = root.appendingPathComponent("bundle.zip") + + XCTAssertEqual(bundle.writeToZip(at: archive.path), archive.path) + let decoded = GraphExportBundle.readFromZip(at: archive.path) + + XCTAssertEqual(decoded?.graphSnapshot.nodes.map(\.title), ["Portable loop"]) + XCTAssertEqual(decoded?.memoryByNodeID[node.id.uuidString], ["portable memory"]) + } + func testEndpointIsPerUserAndSupportDirectory() throws { let root = FileManager.default.temporaryDirectory .appendingPathComponent("graphcode-identity-\(UUID().uuidString)", isDirectory: true) From 14e26a221a5f191703626444e2eadaf204c0db54 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 25 Sep 2026 12:31:32 -0700 Subject: [PATCH 3/4] Fix Windows DPI boundaries for accessibility and pinch zoom Keep UIA output in physical client pixels with explicit logical/physical bounds. Reuse terminal chrome geometry without scaling it again, and normalize native gesture points and client bounds before routing and anchored zoom. Exercise the production sync and mapping paths at 96, 144, and 192 DPI. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/README.md | 9 + graphcode-windows/src/App.zig | 377 ++++++++++++++++---- graphcode-windows/src/TerminalSurface.zig | 4 +- graphcode-windows/src/TerminalWorkspace.zig | 2 + 4 files changed, 320 insertions(+), 72 deletions(-) diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index e72b0ca5..c65f367b 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -45,6 +45,15 @@ project/node identity, `Ctrl+Tab` advances attention, and `Ctrl+Shift+R`, activity settings. `Ctrl+Q` creates a daemon-owned Quick Chat; `Ctrl+Shift+Q` renames the selected chat and `Ctrl+Shift+X` deletes it. +Custom canvas, sidebar, header, and detail layout use 96-DPI logical units. +UI Automation receives physical client pixels: logical bounds, including the +fixed graph group, are scaled once at the reporting boundary. Terminal tab and +control bounds already use physical pixels shared with MM_TEXT painting and +hit-testing, so they must not be scaled again. Touch pinch locations and client +bounds are converted to logical units before graph routing and anchored zoom. +App regression tests exercise these production boundaries at 96, 144, and 192 +DPI; they do not substitute for live touch or multi-monitor validation. + ## Build From a fresh checkout, bootstrap the exact Zig toolchains, Swift 6.3.3, and diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 5c57ede0..0e7f77cd 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -66,6 +66,23 @@ const InputBounds = struct { const WheelRegion = enum { sidebar, canvas, none }; +const AccessibilityBounds = union(enum) { + logical: c.RECT, + physical: c.RECT, + + fn physicalRect(self: AccessibilityBounds, dpi: u32) c.RECT { + return switch (self) { + .logical => |bounds| .{ + .left = physicalCoordinate(bounds.left, dpi), + .top = physicalCoordinate(bounds.top, dpi), + .right = physicalCoordinate(bounds.right, dpi), + .bottom = physicalCoordinate(bounds.bottom, dpi), + }, + .physical => |bounds| bounds, + }; + } +}; + fn inputBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControls.State) InputBounds { return .{ .rail_left = if (controls.rail_visible) Tokens.sidebar_width else 0, @@ -90,6 +107,19 @@ fn physicalCoordinate(value: i32, dpi: u32) i32 { return Dpi.scale(value, dpi); } +fn gestureGeometry(point: ?c.POINT, client: c.RECT, dpi: u32, controls: WorkspaceControls.State) struct { + point: ?c.POINT, + bounds: InputBounds, +} { + return .{ + .point = if (point) |value| .{ + .x = logicalCoordinate(value.x, dpi), + .y = logicalCoordinate(value.y, dpi), + } else null, + .bounds = inputBounds(logicalCoordinate(client.right, dpi), logicalCoordinate(client.bottom, dpi), controls), + }; +} + fn wheelRegion(x: i32, y: i32, bounds: InputBounds, controls: WorkspaceControls.State) WheelRegion { if (controls.rail_visible and x < bounds.rail_left and y >= Tokens.header_height and y < bounds.canvas.bottom) @@ -4027,6 +4057,10 @@ pub const App = struct { fn syncAccessibility(self: *App) void { const provider = if (self.accessibility) |*value| value else return; + self.syncAccessibilityTo(provider, logicalClientRect(self.window.hwnd, self.dpi)); + } + + fn syncAccessibilityTo(self: *App, provider: anytype, client: c.RECT) void { var elements = std.array_list.Managed(Accessibility.DynamicElement).init(self.allocator); defer elements.deinit(); var owned_identities = std.array_list.Managed([]u8).init(self.allocator); @@ -4034,7 +4068,6 @@ pub const App = struct { for (owned_identities.items) |value| self.allocator.free(value); owned_identities.deinit(); } - const client = logicalClientRect(self.window.hwnd, self.dpi); const canvas_bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; const canvas_rect = c.RECT{ .left = canvas_bounds.left, @@ -4042,7 +4075,7 @@ pub const App = struct { .right = canvas_bounds.right, .bottom = canvas_bounds.bottom, }; - provider.syncCanvasBounds(canvas_rect); + provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); var sidebar_rows = Sidebar.appendRows( self.allocator, &self.model, @@ -4064,12 +4097,12 @@ pub const App = struct { // exhaustively-asserted child set with chrome that isn't part of the // canvas. if (self.model.attentionCount() != 0) { - self.appendAccessibilityElement(&elements, &owned_identities, "header-attention", "needs-you", "Review what needs you", 1, GraphCanvas.headerAttentionRect(), false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "header-attention", "needs-you", "Review what needs you", 1, .{ .logical = GraphCanvas.headerAttentionRect() }, false, true) catch return; } if (self.worktree_inspection != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "header-worktree", "worktrees", "Reclaimable worktrees", 1, GraphCanvas.headerWorktreeRect(), false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "header-worktree", "worktrees", "Reclaimable worktrees", 1, .{ .logical = GraphCanvas.headerWorktreeRect() }, false, true) catch return; } - self.appendAccessibilityElement(&elements, &owned_identities, "header-jump", "jump", "Jump to Loop", 1, GraphCanvas.headerJumpRect(client.right), false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "header-jump", "jump", "Jump to Loop", 1, .{ .logical = GraphCanvas.headerJumpRect(client.right) }, false, true) catch return; if (self.model.currentGraph() != null) { self.appendAccessibilityElement( &elements, @@ -4078,7 +4111,7 @@ pub const App = struct { "control", if (self.surface == .workspace) "Hide loop panel" else "Loop panel", 1, - GraphCanvas.headerPanelRect(client.right), + .{ .logical = GraphCanvas.headerPanelRect(client.right) }, false, true, ) catch return; @@ -4087,16 +4120,16 @@ pub const App = struct { for (sidebar_rows.items) |row| { const bounds = c.RECT{ .left = 12, .top = row.top - 3, .right = 232, .bottom = row.top + 23 }; switch (row.kind) { - .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", "Local Projects", 1, bounds, false, false) catch return, - .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", "Remote Repositories", 1, bounds, false, false) catch return, + .local_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "local", "Local Projects", 1, .{ .logical = bounds }, false, false) catch return, + .remote_heading => self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-section", "remote", "Remote Repositories", 1, .{ .logical = bounds }, false, false) catch return, .project => { const project = self.model.recent_projects.items[row.index]; - self.appendAccessibilityElement(&elements, &owned_identities, "project", project.path, project.name, 1, bounds, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "project", project.path, project.name, 1, .{ .logical = bounds }, false, false) catch return; }, .open_project => if (row.project_path) |path| if (self.model.graphFor(path)) |graph| { - self.appendAccessibilityElement(&elements, &owned_identities, "open-project", path, graph.project.name, 1, bounds, self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "open-project", path, graph.project.name, 1, .{ .logical = bounds }, self.model.selected_project_path != null and std.mem.eql(u8, self.model.selected_project_path.?, path), false) catch return; const new_bounds = c.RECT{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }; - self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", path, "New Loop", 1, new_bounds, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "project-new-loop", path, "New Loop", 1, .{ .logical = new_bounds }, false, false) catch return; if (row.has_children) { const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; self.appendAccessibilityElement( @@ -4106,7 +4139,7 @@ pub const App = struct { path, if (self.sidebar_state.isProjectCollapsed(path)) "Expand project" else "Collapse project", 1, - disclosure_bounds, + .{ .logical = disclosure_bounds }, false, false, ) catch return; @@ -4117,7 +4150,7 @@ pub const App = struct { const node = graph.nodes.items[row.index]; const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ path, node.id }) catch return; defer self.allocator.free(key); - self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, bounds, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, node.id), false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "loop", key, node.title, 2, .{ .logical = bounds }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, node.id), false) catch return; if (row.has_children) { const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; self.appendAccessibilityElement( @@ -4127,7 +4160,7 @@ pub const App = struct { key, if (self.sidebar_state.isNodeExpanded(node.id)) "Collapse loop children" else "Expand loop children", 2, - disclosure_bounds, + .{ .logical = disclosure_bounds }, false, false, ) catch return; @@ -4137,13 +4170,13 @@ pub const App = struct { .worktree => if (self.worktree_dialog) |dialog| { if (row.index < dialog.rows.items.len) { const worktree = dialog.rows.items[row.index]; - self.appendAccessibilityElement(&elements, &owned_identities, "worktree", worktree.entry.path, worktree.entry.path, 3, bounds, worktree.selected, WorktreeStatus.decision(worktree.entry) == .reclaimable) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "worktree", worktree.entry.path, worktree.entry.path, 3, .{ .logical = bounds }, worktree.selected, WorktreeStatus.decision(worktree.entry) == .reclaimable) catch return; } }, .quick_chat_overview => { - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, bounds, self.surface == .quick_chats, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chats-header", "quick-chats", "Quick Chats", 1, .{ .logical = bounds }, self.surface == .quick_chats, false) catch return; const new_bounds = c.RECT{ .left = 174, .top = row.top, .right = 198, .bottom = row.top + 24 }; - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-new", "quick-chats", "New Chat", 1, new_bounds, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-new", "quick-chats", "New Chat", 1, .{ .logical = new_bounds }, false, false) catch return; if (self.model.quick_chats.items.len != 0) { const disclosure_bounds = c.RECT{ .left = 198, .top = row.top, .right = 220, .bottom = row.top + 24 }; self.appendAccessibilityElement( @@ -4153,7 +4186,7 @@ pub const App = struct { "quick-chats", if (self.sidebar_state.chats_collapsed) "Expand Quick Chats" else "Collapse Quick Chats", 1, - disclosure_bounds, + .{ .logical = disclosure_bounds }, false, false, ) catch return; @@ -4161,7 +4194,7 @@ pub const App = struct { }, .quick_chat => if (row.index < self.model.quick_chats.items.len) { const chat = self.model.quick_chats.items[row.index]; - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, bounds, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-row", chat.id, chat.title, 1, .{ .logical = bounds }, false, false) catch return; }, else => {}, } @@ -4174,12 +4207,12 @@ pub const App = struct { self.allocator.free(identity); return; }; - const workspace_bounds = c.RECT{ + const workspace_bounds = (AccessibilityBounds{ .logical = .{ .left = canvas_rect.left, .top = inputBounds(client.right, client.bottom, self.workspace_controls).workspace_top, .right = canvas_rect.right, .bottom = client.bottom, - }; + } }).physicalRect(self.dpi); elements.append(.{ .identity = identity, .name = "Quick Chat terminal workspace", @@ -4196,7 +4229,7 @@ pub const App = struct { }; if (self.model.attention_entries.items.len != 0) { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); - self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-header", "needs-you", "Needs you", 1, .{ .logical = .{ .left = 12, .top = section + 4, .right = 232, .bottom = section + 28 } }, false, true) catch return; for (self.model.attention_entries.items[0..@min(self.model.attention_entries.items.len, 4)], 0..) |entry, index| { const row_offset = @as(i32, @intCast(index)) * 34; const identity = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ entry.project_path, entry.node.id }) catch return; @@ -4206,7 +4239,7 @@ pub const App = struct { self.allocator.free(name); return; }; - self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .left = 18, .top = section + 30 + row_offset, .right = 232, .bottom = section + 60 + row_offset }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "needs-you-row", identity, name, 1, .{ .logical = .{ .left = 18, .top = section + 30 + row_offset, .right = 232, .bottom = section + 60 + row_offset } }, self.model.selected_node_id != null and std.mem.eql(u8, self.model.selected_node_id.?, entry.node.id), true) catch return; self.appendAccessibilityElement( &elements, &owned_identities, @@ -4214,7 +4247,7 @@ pub const App = struct { identity, "Stop loop", 1, - Sidebar.needsYouStopBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, index), + .{ .logical = Sidebar.needsYouStopBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, index) }, false, true, ) catch return; @@ -4224,7 +4257,7 @@ pub const App = struct { const section = Sidebar.sidebarSectionBottom(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state); const attention_rows = @min(self.model.attentionCount(), 4); const activity_top = section + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; - self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-header", "activity", "Activity", 1, .{ .logical = .{ .left = 12, .top = activity_top, .right = 232, .bottom = activity_top + 24 } }, false, true) catch return; self.appendAccessibilityElement( &elements, &owned_identities, @@ -4232,7 +4265,7 @@ pub const App = struct { "attention", if (self.sidebar_state.activity_attention_only) "Show all activity" else "Show attention-only activity", 1, - Sidebar.activityFilterBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll), + .{ .logical = Sidebar.activityFilterBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll) }, false, true, ) catch return; @@ -4249,15 +4282,16 @@ pub const App = struct { identity, event.title, 1, - Sidebar.activityCardBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, visible_index), + .{ .logical = Sidebar.activityCardBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, visible_index) }, false, true, ) catch return; } - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .left), false, true) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .right), false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, .{ .logical = Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .left) }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, .{ .logical = Sidebar.activityControlBounds(&self.model, if (self.worktree_inspection) |*value| value else null, &self.sidebar_state, self.sidebar_scroll, .right) }, false, true) catch return; } if (self.ingress_error.len != 0) { + const bounds = (AccessibilityBounds{ .logical = Sidebar.errorFooterRect(client.bottom) }).physicalRect(self.dpi); elements.append(.{ .identity = "sidebar-error-footer:ingress", .name = self.ingress_error, @@ -4265,10 +4299,10 @@ pub const App = struct { .selected = false, .eligible = false, .invokable = false, - .left = Sidebar.errorFooterRect(client.bottom).left, - .top = Sidebar.errorFooterRect(client.bottom).top, - .right = Sidebar.errorFooterRect(client.bottom).right, - .bottom = Sidebar.errorFooterRect(client.bottom).bottom, + .left = bounds.left, + .top = bounds.top, + .right = bounds.right, + .bottom = bounds.bottom, }) catch return; } switch (self.surface) { @@ -4286,7 +4320,7 @@ pub const App = struct { parent_id, back_name, 4, - GraphCanvas.compositeBreadcrumbBounds(canvas_rect), + .{ .logical = GraphCanvas.compositeBreadcrumbBounds(canvas_rect) }, false, false, ) catch return; @@ -4295,14 +4329,14 @@ pub const App = struct { const bounds = GraphCanvas.nodeBounds(index, &self.canvas); const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); - self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, bounds, self.model.selected_index == index, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "project-card", key, node.title, 4, .{ .logical = bounds }, self.model.selected_index == index, false) catch return; if (GraphCanvas.hitTestAttentionAction(graph.nodes.items, graph.edges.items, bounds.right - 20, bounds.bottom - 12, &self.canvas) != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "attention-action", key, GraphCanvas.attentionActionLabel(node), 4, GraphCanvas.attentionActionBounds(bounds, &self.canvas), false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "attention-action", key, GraphCanvas.attentionActionLabel(node), 4, .{ .logical = GraphCanvas.attentionActionBounds(bounds, &self.canvas) }, false, false) catch return; } if (GraphCanvas.hasReclaimOffer(node, if (self.worktree_inspection) |*value| value else null, self.kept_worktree_paths.items)) { const offer = GraphCanvas.reclaimOfferBounds(bounds); - self.appendAccessibilityElement(&elements, &owned_identities, "reclaim", key, "Reclaim", 4, offer.reclaim, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "keep", key, "Keep", 4, offer.keep, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "reclaim", key, "Reclaim", 4, .{ .logical = offer.reclaim }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "keep", key, "Keep", 4, .{ .logical = offer.keep }, false, false) catch return; } } if (self.surface == .workspace) { @@ -4310,23 +4344,23 @@ pub const App = struct { const workspace_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0; const workspace_right = client.right - (if (self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); const selected_index = self.model.selectedIndex() orelse 0; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, graph.project.name, 4, .{ .left = workspace_left, .top = 0, .right = workspace_right, .bottom = Tokens.header_height }, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height }, false, false) catch return; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, graph.project.name, 4, .{ .logical = .{ .left = workspace_left, .top = 0, .right = workspace_right, .bottom = Tokens.header_height } }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .logical = .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height } }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 } }, false, false) catch return; if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .left = workspace_right - 196, .top = Tokens.header_height + 10, .right = workspace_right - 112, .bottom = Tokens.header_height + 36 }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-stop", graph.nodes.items[selected_index].id, "Stop loop", 4, .{ .logical = .{ .left = workspace_right - 196, .top = Tokens.header_height + 10, .right = workspace_right - 112, .bottom = Tokens.header_height + 36 } }, false, false) catch return; } const panel_toggle = if (self.workspace_controls.panel_visible) GraphCanvas.loopDetailCollapseBounds(client.right) else GraphCanvas.loopDetailExpandBounds(client.right); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, panel_toggle, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toggle-panel", "control", if (self.workspace_controls.panel_visible) "Collapse loop panel" else "Expand loop panel", 4, .{ .logical = panel_toggle }, false, true) catch return; if (self.workspace_controls.panel_visible and selected_index < graph.nodes.items.len) { const detail_left = client.right - Tokens.loop_detail_width; const selected_node = graph.nodes.items[selected_index]; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .left = detail_left + 18, .top = client.bottom - 102, .right = client.right - 18, .bottom = client.bottom - 70 }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-sparkline", selected_node.id, "Metric sparkline", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 102, .right = client.right - 18, .bottom = client.bottom - 70 } }, false, false) catch return; if (selected_node.created_at != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .left = detail_left + 18, .top = client.bottom - 64, .right = client.right - 18, .bottom = client.bottom - 44 }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-start", selected_node.id, "Start time", 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 64, .right = client.right - 18, .bottom = client.bottom - 44 } }, false, false) catch return; } if (selected_node.token_usage) |tokens| { const usage_name = std.fmt.allocPrint(self.allocator, "{d} tokens", .{tokens}) catch return; @@ -4334,24 +4368,22 @@ pub const App = struct { self.allocator.free(usage_name); return; }; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .left = detail_left + 18, .top = client.bottom - 44, .right = client.right - 18, .bottom = client.bottom - 24 }, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-detail-usage", selected_node.id, usage_name, 4, .{ .logical = .{ .left = detail_left + 18, .top = client.bottom - 44, .right = client.right - 18, .bottom = client.bottom - 24 } }, false, false) catch return; } } for (workspace.layout.tabs.items, 0..) |tab, tab_index| { const tab_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; defer self.allocator.free(tab_key); - const tab_left = workspace.layout_origin_x + @as(i32, @intCast(tab_index)) * 120; - const tab_bounds = c.RECT{ .left = tab_left, .top = workspace.layout_origin_y + 4, .right = tab_left + 112, .bottom = workspace.layout_origin_y + Tokens.tab_bar_height - 4 }; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab", tab_key, if (tab.panes.items.len > 1) "Split tab" else if (tab_index == 0) "Agent tab" else "Shell tab", 4, tab_bounds, tab_index == workspace.layout.selected_tab, true) catch return; + const tab_bounds = TerminalWorkspace.tabBounds(workspace.layout_origin_x, workspace.layout_origin_y, tab_index); + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab", tab_key, if (tab.panes.items.len > 1) "Split tab" else if (tab_index == 0) "Agent tab" else "Shell tab", 4, .{ .physical = tab_bounds }, tab_index == workspace.layout.selected_tab, true) catch return; const close_key = std.fmt.allocPrint(self.allocator, "{d}", .{tab_index}) catch return; defer self.allocator.free(close_key); - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .left = tab_bounds.right - 24, .top = tab_bounds.top, .right = tab_bounds.right, .bottom = tab_bounds.bottom }, false, workspace.canCloseTab()) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-tab-close", close_key, "Close tab", 4, .{ .physical = .{ .left = tab_bounds.right - 24, .top = tab_bounds.top, .right = tab_bounds.right, .bottom = tab_bounds.bottom } }, false, workspace.canCloseTab()) catch return; } - const controls_left = @max(workspace.layout_origin_x, workspace.layout_origin_x + workspace.layout_width - 220); for ([_][]const u8{ "New Tab", "Split Right", "Split Down" }, 0..) |label, control_index| { - const control_left = controls_left + @as(i32, @intCast(control_index)) * 72; + const control_bounds = TerminalWorkspace.chromeControlBounds(workspace.layout_origin_x, workspace.layout_origin_y, workspace.layout_width, control_index); const kind = if (control_index == 0) "workspace-new-tab" else if (control_index == 1) "workspace-split-right" else "workspace-split-down"; - self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .left = control_left, .top = workspace.layout_origin_y + 3, .right = control_left + 68, .bottom = workspace.layout_origin_y + Tokens.tab_bar_height - 3 }, false, true) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, kind, "control", label, 4, .{ .physical = control_bounds }, false, true) catch return; } } } @@ -4361,11 +4393,11 @@ pub const App = struct { const bounds = GraphCanvas.overviewCardBounds(&self.model, graph_index, node_index, canvas_rect, &self.canvas); const key = std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ graph.project.path, node.id }) catch return; defer self.allocator.free(key); - self.appendAccessibilityElement(&elements, &owned_identities, "overview-card", key, node.title, 4, bounds, false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "overview-card", key, node.title, 4, .{ .logical = bounds }, false, false) catch return; } }, .quick_chats => for (self.model.quick_chats.items, 0..) |chat, index| { - self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-card", chat.id, chat.title, 4, GraphCanvas.quickChatCardBounds(index, canvas_rect, &self.canvas), false, false) catch return; + self.appendAccessibilityElement(&elements, &owned_identities, "quick-chat-card", chat.id, chat.title, 4, .{ .logical = GraphCanvas.quickChatCardBounds(index, canvas_rect, &self.canvas) }, false, false) catch return; }, } const canvas_alert = if (self.ingress_error.len != 0) @@ -4383,7 +4415,7 @@ pub const App = struct { self.allocator.free(identity); return; }; - const bounds = GraphCanvas.inlineAlertBounds(canvas_rect); + const bounds = (AccessibilityBounds{ .logical = GraphCanvas.inlineAlertBounds(canvas_rect) }).physicalRect(self.dpi); elements.append(.{ .identity = identity, .name = canvas_alert, @@ -4405,6 +4437,7 @@ pub const App = struct { return; }; const row_top: i32 = 34 + @as(i32, @intCast(index * 30)); + const bounds = (AccessibilityBounds{ .logical = .{ .left = 250, .top = row_top, .right = 500, .bottom = row_top + 28 } }).physicalRect(self.dpi); elements.append(.{ .identity = identity, .name = workspace.name, @@ -4412,19 +4445,13 @@ pub const App = struct { .selected = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), .eligible = true, .invokable = true, - .left = 250, - .top = row_top, - .right = 500, - .bottom = row_top + 28, + .left = bounds.left, + .top = bounds.top, + .right = bounds.right, + .bottom = bounds.bottom, }) catch return; } } - for (elements.items) |*element| { - element.left = physicalCoordinate(element.left, self.dpi); - element.top = physicalCoordinate(element.top, self.dpi); - element.right = physicalCoordinate(element.right, self.dpi); - element.bottom = physicalCoordinate(element.bottom, self.dpi); - } const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; provider.syncElements(self.status(), elements.items, policy); } @@ -4437,10 +4464,11 @@ pub const App = struct { key: []const u8, name: []const u8, parent: c_int, - bounds: c.RECT, + coordinates: AccessibilityBounds, selected: bool, eligible: bool, ) !void { + const bounds = coordinates.physicalRect(self.dpi); const identity = try std.fmt.allocPrint(self.allocator, "{s}:{s}", .{ kind, key }); errdefer self.allocator.free(identity); try owned_identities.append(identity); @@ -6290,9 +6318,9 @@ fn onWindowMessage( return false; } const screen_point = c.POINT{ .x = info.ptsLocation.x, .y = info.ptsLocation.y }; - const mapped = CanvasInput.screenToClient(hwnd, screen_point); - const gesture_routing = inputBounds(gesture_client.right, gesture_client.bottom, app.workspace_controls); - const in_canvas = gestureInCanvas(app.surface, mapped, gesture_routing, app.workspace_controls); + const geometry = gestureGeometry(CanvasInput.screenToClient(hwnd, screen_point), gesture_client, app.dpi, app.workspace_controls); + const mapped = geometry.point; + const in_canvas = gestureInCanvas(app.surface, mapped, geometry.bounds, app.workspace_controls); const distance: u32 = @truncate(info.ullArguments); const pinch_context = app.pinchGestureContext(); switch (CanvasInput.classifyGesture(info.dwID, info.dwFlags, in_canvas)) { @@ -6577,6 +6605,215 @@ test "main shell coordinates round trip across common Windows DPI steps" { } } +const DpiExpectedElement = struct { + identity: []const u8, + bounds: [3][4]i32, +}; + +const DpiAccessibilitySink = struct { + expected: []const DpiExpectedElement, + dpi_index: usize, + canvas: ?c.RECT = null, + checked: bool = false, + failure: ?anyerror = null, + + fn syncCanvasBounds(self: *@This(), bounds: c.RECT) void { + self.canvas = bounds; + } + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy) void { + self.checked = true; + self.checkElements(elements) catch |err| { + self.failure = err; + }; + } + + fn checkElements(self: *@This(), elements: []const Accessibility.DynamicElement) !void { + for (self.expected) |expected| { + var found = false; + for (elements) |element| { + if (!std.mem.eql(u8, expected.identity, element.identity)) continue; + found = true; + const actual = [4]i32{ element.left, element.top, element.right, element.bottom }; + std.testing.expectEqualDeep(expected.bounds[self.dpi_index], actual) catch |err| { + std.debug.print("UIA bounds mismatch: {s}, DPI index {d}\n", .{ expected.identity, self.dpi_index }); + return err; + }; + } + if (!found) std.debug.print("Missing UIA element: {s}\n", .{expected.identity}); + try std.testing.expect(found); + } + } +}; + +fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement) !void { + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = .{ + .allocator = allocator, + .frame_buffer = try @import("FrameBuffer.zig").FrameBuffer.init(allocator, .v2), + }, + .daemon = undefined, + .model = GraphModel.Model.init(allocator), + .sidebar_state = Sidebar.State.init(allocator), + .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), + .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), + .surface = surface, + .workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }, + }; + defer app.client.deinit(); + defer app.model.deinit(); + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + app.ingress_error = try allocator.dupe(u8, "Fixture alert"); + defer allocator.free(app.ingress_error); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"loop","title":"Loop","state":"running","createdAt":1,"inputTokens":100,"presence":{"presence":"awaitingInput","confidence":"reported"}}],"edges":[]}}} + ); + try app.model.recent_projects.append(.{ .path = try allocator.dupe(u8, "C"), .name = try allocator.dupe(u8, "Recent") }); + try app.model.quick_chats.append(.{ + .id = try allocator.dupe(u8, "chat"), + .title = try allocator.dupe(u8, "Chat"), + .backend = try allocator.dupe(u8, "claudeCode"), + }); + app.selected_quick_chat = 0; + app.worktree_inspection = .{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(allocator), + .default_branch = try allocator.dupe(u8, "main"), + .project_path = try allocator.dupe(u8, "A"), + }; + defer WorktreeStatus.deinitInspection(allocator, &app.worktree_inspection.?); + var workspaces = [_]WorkspaceLifecycle.Workspace{ + .{ .name = "Fixture", .path = "B", .is_default = false }, + }; + app.workspace_list = .{ .items = &workspaces }; + var workspace: TerminalWorkspace.Workspace = .{ + .parent = null, + .allocator = allocator, + .zmx_path = &.{}, + .cwd = &.{}, + .input_queue = .{ .allocator = allocator }, + .layout = try @import("WorkspaceLayout.zig").Layout.init(allocator, "A"), + .layout_path = &.{}, + .project_key = &.{}, + }; + defer workspace.layout.deinit(); + try workspace.layout.addTab("agent", true); + try workspace.layout.addTab("shell", false); + app.workspace = &workspace; + const physical_layouts = [_][3]i32{ .{ 220, 80, 708 }, .{ 330, 120, 1062 }, .{ 440, 160, 1416 } }; + for ([_]u32{ 96, 144, 192 }, 0..) |dpi, index| { + app.dpi = dpi; + workspace.layout_origin_x = physical_layouts[index][0]; + workspace.layout_origin_y = physical_layouts[index][1]; + workspace.layout_width = physical_layouts[index][2]; + var sink: DpiAccessibilitySink = .{ .expected = expected, .dpi_index = index }; + app.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = 1200, .bottom = 900 }); + try std.testing.expect(sink.checked); + if (sink.failure) |err| return err; + if (canvas) |bounds| { + const actual = sink.canvas orelse return error.MissingCanvasBounds; + try std.testing.expectEqualDeep(bounds[index], [4]i32{ actual.left, actual.top, actual.right, actual.bottom }); + } + } +} + +test "DPI UIA fixed graph uses physical client bounds" { + try expectDpiAccessibility(.project, .{ .{ 220, 34, 1200, 650 }, .{ 330, 51, 1800, 975 }, .{ 440, 68, 2400, 1300 } }, &.{}); +} + +test "DPI UIA logical cards headers sidebar and direct inserts scale once" { + const common = [_]DpiExpectedElement{ + .{ .identity = "header-attention:needs-you", .bounds = .{ .{ 220, 5, 330, 29 }, .{ 330, 8, 495, 44 }, .{ 440, 10, 660, 58 } } }, + .{ .identity = "header-worktree:worktrees", .bounds = .{ .{ 338, 5, 458, 29 }, .{ 507, 8, 687, 44 }, .{ 676, 10, 916, 58 } } }, + .{ .identity = "header-jump:jump", .bounds = .{ .{ 640, 5, 800, 29 }, .{ 960, 8, 1200, 44 }, .{ 1280, 10, 1600, 58 } } }, + .{ .identity = "header-toggle-panel:control", .bounds = .{ .{ 810, 5, 920, 29 }, .{ 1215, 8, 1380, 44 }, .{ 1620, 10, 1840, 58 } } }, + .{ .identity = "sidebar-section:local", .bounds = .{ .{ 12, 109, 232, 135 }, .{ 18, 164, 348, 203 }, .{ 24, 218, 464, 270 } } }, + .{ .identity = "sidebar-error-footer:ingress", .bounds = .{ .{ 8, 816, 212, 858 }, .{ 12, 1224, 318, 1287 }, .{ 16, 1632, 424, 1716 } } }, + .{ .identity = "workspace-switch:B", .bounds = .{ .{ 250, 34, 500, 62 }, .{ 375, 51, 750, 93 }, .{ 500, 68, 1000, 124 } } }, + }; + for ([_]GraphCanvas.Surface{ .project, .overview, .quick_chats, .workspace }) |surface| { + try expectDpiAccessibility(surface, null, &common); + } + try expectDpiAccessibility(.project, null, &.{ + .{ .identity = "project-card:A:loop", .bounds = .{ .{ 252, 84, 502, 190 }, .{ 378, 126, 753, 285 }, .{ 504, 168, 1004, 380 } } }, + .{ .identity = "canvas-alert:ingress", .bounds = .{ .{ 244, 556, 1176, 612 }, .{ 366, 834, 1764, 918 }, .{ 488, 1112, 2352, 1224 } } }, + }); + try expectDpiAccessibility(.overview, null, &.{ + .{ .identity = "overview-card:A:loop", .bounds = .{ .{ 262, 118, 482, 204 }, .{ 393, 177, 723, 306 }, .{ 524, 236, 964, 408 } } }, + }); + try expectDpiAccessibility(.quick_chats, null, &.{ + .{ .identity = "quick-chat-card:chat", .bounds = .{ .{ 262, 88, 482, 152 }, .{ 393, 132, 723, 228 }, .{ 524, 176, 964, 304 } } }, + }); + try expectDpiAccessibility(.workspace, null, &.{ + .{ .identity = "workspace-toolbar:A", .bounds = .{ .{ 220, 0, 928, 34 }, .{ 330, 0, 1392, 51 }, .{ 440, 0, 1856, 68 } } }, + .{ .identity = "workspace-loop-bar:loop", .bounds = .{ .{ 220, 34, 928, 80 }, .{ 330, 51, 1392, 120 }, .{ 440, 68, 1856, 160 } } }, + .{ .identity = "workspace-show-graph:show-graph", .bounds = .{ .{ 824, 44, 916, 70 }, .{ 1236, 66, 1374, 105 }, .{ 1648, 88, 1832, 140 } } }, + .{ .identity = "workspace-stop:loop", .bounds = .{ .{ 732, 44, 816, 70 }, .{ 1098, 66, 1224, 105 }, .{ 1464, 88, 1632, 140 } } }, + .{ .identity = "workspace-toggle-panel:control", .bounds = .{ .{ 1100, 46, 1182, 68 }, .{ 1650, 69, 1773, 102 }, .{ 2200, 92, 2364, 136 } } }, + .{ .identity = "workspace-detail-sparkline:loop", .bounds = .{ .{ 946, 798, 1182, 830 }, .{ 1419, 1197, 1773, 1245 }, .{ 1892, 1596, 2364, 1660 } } }, + .{ .identity = "workspace-detail-start:loop", .bounds = .{ .{ 946, 836, 1182, 856 }, .{ 1419, 1254, 1773, 1284 }, .{ 1892, 1672, 2364, 1712 } } }, + .{ .identity = "workspace-detail-usage:loop", .bounds = .{ .{ 946, 856, 1182, 876 }, .{ 1419, 1284, 1773, 1314 }, .{ 1892, 1712, 2364, 1752 } } }, + .{ .identity = "quick-chat-workspace:chat", .bounds = .{ .{ 220, 650, 1200, 900 }, .{ 330, 975, 1800, 1350 }, .{ 440, 1300, 2400, 1800 } } }, + }); +} + +test "DPI UIA terminal tab close and controls retain physical geometry" { + try expectDpiAccessibility(.workspace, null, &.{ + .{ .identity = "workspace-tab:1", .bounds = .{ .{ 340, 84, 452, 106 }, .{ 450, 124, 562, 146 }, .{ 560, 164, 672, 186 } } }, + .{ .identity = "workspace-tab-close:1", .bounds = .{ .{ 428, 84, 452, 106 }, .{ 538, 124, 562, 146 }, .{ 648, 164, 672, 186 } } }, + .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, + .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, + .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, + }); +} + +test "DPI gesture mapper classifies scaled sidebar and graph boundaries" { + const controls = WorkspaceControls.State{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }; + const cases = [_]struct { dpi: u32, width: i32, height: i32, sidebar_x: i32, canvas_x: i32, top: i32, bottom: i32, y: i32 }{ + .{ .dpi = 96, .width = 1200, .height = 900, .sidebar_x = 200, .canvas_x = 600, .top = 34, .bottom = 650, .y = 300 }, + .{ .dpi = 144, .width = 1800, .height = 1350, .sidebar_x = 300, .canvas_x = 900, .top = 51, .bottom = 975, .y = 450 }, + .{ .dpi = 192, .width = 2400, .height = 1800, .sidebar_x = 400, .canvas_x = 1200, .top = 68, .bottom = 1300, .y = 600 }, + }; + for (cases) |case| { + const client = c.RECT{ .left = 0, .top = 0, .right = case.width, .bottom = case.height }; + for ([_]struct { point: c.POINT, region: WheelRegion }{ + .{ .point = .{ .x = case.sidebar_x, .y = case.y }, .region = .sidebar }, + .{ .point = .{ .x = case.canvas_x, .y = case.y }, .region = .canvas }, + .{ .point = .{ .x = case.canvas_x, .y = case.top }, .region = .canvas }, + .{ .point = .{ .x = case.canvas_x, .y = case.top - 2 }, .region = .none }, + .{ .point = .{ .x = case.width, .y = case.y }, .region = .none }, + .{ .point = .{ .x = case.canvas_x, .y = case.bottom }, .region = .none }, + }) |sample| { + const mapped = gestureGeometry(sample.point, client, case.dpi, controls); + try std.testing.expectEqual(sample.region, wheelRegion(mapped.point.?.x, mapped.point.?.y, mapped.bounds, controls)); + try std.testing.expectEqual(sample.region == .canvas, gestureInCanvas(.project, mapped.point, mapped.bounds, controls)); + try std.testing.expect(!gestureInCanvas(.workspace, mapped.point, mapped.bounds, controls)); + } + const failed = gestureGeometry(null, client, case.dpi, controls); + try std.testing.expect(!gestureInCanvas(.project, failed.point, failed.bounds, controls)); + } +} + +test "DPI gesture mapper preserves the logical world anchor through pinch updates" { + for ([_]struct { dpi: u32, point: c.POINT, width: i32, height: i32 }{ + .{ .dpi = 96, .point = .{ .x = 600, .y = 300 }, .width = 1200, .height = 900 }, + .{ .dpi = 144, .point = .{ .x = 900, .y = 450 }, .width = 1800, .height = 1350 }, + .{ .dpi = 192, .point = .{ .x = 1200, .y = 600 }, .width = 2400, .height = 1800 }, + }) |case| { + const mapped = gestureGeometry(case.point, .{ .left = 0, .top = 0, .right = case.width, .bottom = case.height }, case.dpi, .{}); + var state = GraphCanvas.CanvasState{ .zoom = 1.2, .pan_x = 24, .pan_y = -36 }; + state.beginPinchZoom(100, 7); + for ([_]u32{ 125, 125, 10000, 50 }) |distance| { + state.continuePinchZoom(mapped.point.?.x, mapped.point.?.y, distance, 7); + try std.testing.expectApproxEqAbs(@as(f32, 480), (600 - state.pan_x) / state.zoom, 0.001); + try std.testing.expectApproxEqAbs(@as(f32, 280), (300 - state.pan_y) / state.zoom, 0.001); + } + } +} + test "jump matching ranks exact results across projects" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 283dd3ac..89201d77 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -43,12 +43,12 @@ fn chromeActionForBounds(origin_x: i32, origin_y: i32, width: i32, x: i32, y: i3 return null; } -fn chromeControlBounds(origin_x: i32, origin_y: i32, width: i32, index: usize) c.RECT { +pub fn chromeControlBounds(origin_x: i32, origin_y: i32, width: i32, index: usize) c.RECT { const left = @max(origin_x, origin_x + width - 220) + @as(i32, @intCast(index)) * 72; return .{ .left = left, .top = origin_y + 3, .right = left + 68, .bottom = origin_y + Tokens.tab_bar_height - 3 }; } -fn tabBounds(origin_x: i32, origin_y: i32, index: usize) c.RECT { +pub fn tabBounds(origin_x: i32, origin_y: i32, index: usize) c.RECT { const left = origin_x + @as(i32, @intCast(index)) * 120; return .{ .left = left, .top = origin_y + 4, .right = left + 112, .bottom = origin_y + Tokens.tab_bar_height - 4 }; } diff --git a/graphcode-windows/src/TerminalWorkspace.zig b/graphcode-windows/src/TerminalWorkspace.zig index 7ab483ee..69111f28 100644 --- a/graphcode-windows/src/TerminalWorkspace.zig +++ b/graphcode-windows/src/TerminalWorkspace.zig @@ -3,3 +3,5 @@ const TerminalSurface = @import("TerminalSurface.zig"); pub const Workspace = TerminalSurface.Workspace; pub const LoopBarAction = TerminalSurface.LoopBarAction; pub const loopBarActionAt = TerminalSurface.loopBarActionAt; +pub const tabBounds = TerminalSurface.tabBounds; +pub const chromeControlBounds = TerminalSurface.chromeControlBounds; From 0f12fa2e6be4860be3b3c201b801bf9a0e011c69 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 25 Sep 2026 13:08:53 -0700 Subject: [PATCH 4/4] Format conditional Swift session branches Apply the pinned Swift 6.3.3 formatter to the two existing non-Windows conditional branches flagged by strict Linux lint. This changes only leading whitespace on seventeen lines, with no semantic changes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../Sources/Sessions/ProviderPath.swift | 22 +++++++++---------- .../Sources/Sessions/ZmxSessionLauncher.swift | 12 +++++----- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/GraphcodeKit/Sources/Sessions/ProviderPath.swift b/GraphcodeKit/Sources/Sessions/ProviderPath.swift index a98fe4e3..e9471c52 100644 --- a/GraphcodeKit/Sources/Sessions/ProviderPath.swift +++ b/GraphcodeKit/Sources/Sessions/ProviderPath.swift @@ -59,17 +59,17 @@ public enum ProviderPath { if found { await FoundCache.shared.record(executable) } return found #else - guard - let shell = invocation.first, - let session = try? PTYProcessSession( - executable: shell, arguments: Array(invocation.dropFirst())) - else { return nil } - guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else { - session.terminate() - return nil - } - if found { await FoundCache.shared.record(executable) } - return found + guard + let shell = invocation.first, + let session = try? PTYProcessSession( + executable: shell, arguments: Array(invocation.dropFirst())) + else { return nil } + guard let found = await withDeadline(deadline, { await session.waitUntilFinished() }) else { + session.terminate() + return nil + } + if found { await FoundCache.shared.record(executable) } + return found #endif } diff --git a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift index 85982882..40ce343c 100644 --- a/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift +++ b/GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift @@ -2580,12 +2580,12 @@ public enum ZmxSessionLauncher { ?? "\(checkCommand) >/dev/null 2>&1 || \(repair)\(launch)" let executable = "/bin/sh" let arguments = ["-c", script] - guard - let session = try? PTYProcessSession( - executable: executable, arguments: arguments, - workingDirectory: workingDirectory) - else { return } - _ = await session.waitUntilFinished() + guard + let session = try? PTYProcessSession( + executable: executable, arguments: arguments, + workingDirectory: workingDirectory) + else { return } + _ = await session.waitUntilFinished() #endif }