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..e9471c52 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,17 +35,42 @@ public enum ProviderPath { { if await FoundCache.shared.isFresh(executable) { return true } let invocation = probeInvocation(for: executable) - 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 + #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( + 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 } /// 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..40ce343c 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] + guard + let session = try? PTYProcessSession( + executable: executable, arguments: arguments, + workingDirectory: workingDirectory) + else { return } + _ = await session.waitUntilFinished() #endif - guard - let session = try? PTYProcessSession( - executable: executable, arguments: arguments, - workingDirectory: workingDirectory) - else { return } - _ = await session.waitUntilFinished() } } diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index d6ab215a..9c28dbf7 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/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 bacd6be9..e57fae5b 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"); @@ -64,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, @@ -72,6 +91,35 @@ 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 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) @@ -429,6 +477,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 +571,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 +1029,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 +1293,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 +1321,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(); @@ -1654,7 +1717,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 { @@ -3596,8 +3659,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; @@ -3609,8 +3671,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; } @@ -3734,8 +3796,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), @@ -3749,8 +3810,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( @@ -3823,11 +3883,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 @@ -3843,10 +3903,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, ); } @@ -3877,8 +3937,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; @@ -3902,7 +3962,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"); @@ -3920,10 +3988,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, ); } @@ -3935,7 +4003,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, @@ -3949,6 +4017,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 { @@ -3965,7 +4035,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(); @@ -4006,11 +4076,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| { @@ -4044,6 +4115,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); @@ -4051,8 +4126,6 @@ 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 canvas_bounds = inputBounds(client.right, client.bottom, self.workspace_controls).canvas; const canvas_rect = c.RECT{ .left = canvas_bounds.left, @@ -4060,7 +4133,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, @@ -4082,12 +4155,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, @@ -4096,7 +4169,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; @@ -4105,16 +4178,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( @@ -4124,7 +4197,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; @@ -4135,7 +4208,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( @@ -4145,7 +4218,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; @@ -4155,13 +4228,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( @@ -4171,7 +4244,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; @@ -4179,7 +4252,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 => {}, } @@ -4192,12 +4265,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", @@ -4214,7 +4287,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; @@ -4224,7 +4297,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, @@ -4232,7 +4305,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; @@ -4242,7 +4315,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, @@ -4250,7 +4323,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; @@ -4267,15 +4340,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, @@ -4283,10 +4357,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) { @@ -4304,7 +4378,7 @@ pub const App = struct { parent_id, back_name, 4, - GraphCanvas.compositeBreadcrumbBounds(canvas_rect), + .{ .logical = GraphCanvas.compositeBreadcrumbBounds(canvas_rect) }, false, false, ) catch return; @@ -4313,14 +4387,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) { @@ -4328,23 +4402,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; @@ -4352,24 +4426,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; } } } @@ -4379,11 +4451,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) @@ -4401,7 +4473,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, @@ -4423,6 +4495,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, @@ -4430,10 +4503,10 @@ 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; } } @@ -4449,10 +4522,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); @@ -5097,7 +5171,7 @@ fn onWindowMessage( } }, c.WM_INITMENUPOPUP => { - app.updateNativeChrome(); + app.updateNativeChrome(.popup_open); result.* = 0; return true; }, @@ -5164,8 +5238,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(); @@ -5177,16 +5250,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( @@ -5275,13 +5346,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); @@ -5294,12 +5398,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, @@ -5328,10 +5432,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; @@ -5340,12 +5449,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; @@ -5435,7 +5564,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) { @@ -5548,7 +5676,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; }, @@ -5563,15 +5691,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, @@ -5650,7 +5779,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, @@ -5660,7 +5789,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 {}, @@ -5985,9 +6114,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; @@ -6008,7 +6140,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, @@ -6021,7 +6153,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, @@ -6041,7 +6173,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, @@ -6053,7 +6185,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); }, @@ -6067,7 +6199,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); } @@ -6089,7 +6221,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 => app.showBackgroundContextMenu(screen.x, screen.y), @@ -6115,7 +6247,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; @@ -6124,7 +6260,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); @@ -6148,34 +6285,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, @@ -6197,11 +6333,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 => { @@ -6242,9 +6377,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)) { @@ -6522,6 +6657,222 @@ 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)); + } +} + +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(); @@ -6844,16 +7195,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 f4a04ecc..fc182549 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; @@ -719,8 +749,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, @@ -728,7 +763,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, @@ -872,58 +907,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 { @@ -1110,11 +1128,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 => { @@ -1136,13 +1155,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; @@ -1159,10 +1172,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, @@ -1172,7 +1185,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 => { @@ -1280,7 +1293,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 { @@ -1332,8 +1346,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 @@ -1343,7 +1357,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 { @@ -1370,17 +1383,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, @@ -1393,36 +1407,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 { @@ -1597,15 +1618,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; } @@ -1633,10 +1655,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), @@ -1650,10 +1672,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), @@ -1665,10 +1687,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), @@ -1676,6 +1698,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 => { @@ -1719,6 +1743,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 { @@ -1729,7 +1754,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 { @@ -1761,7 +1786,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; @@ -1771,7 +1796,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; @@ -1784,34 +1812,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); @@ -1822,13 +1849,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 { @@ -1838,16 +1865,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); } @@ -1860,9 +1887,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); @@ -1877,10 +1904,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) { @@ -1889,11 +1916,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); @@ -1901,14 +1928,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); } @@ -1926,16 +1975,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, @@ -1950,16 +2000,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); @@ -2270,8 +2321,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); @@ -2605,19 +2657,19 @@ test "node update initialization and result release every partial allocation" { } 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)); @@ -2646,13 +2698,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/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; 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; } 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)