From aec417f21addbf09e1cdff098cf484785a2c44c0 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 14:48:23 -0700 Subject: [PATCH 1/3] graph: native touchscreen pinch-zoom via WM_GESTURE/GID_ZOOM - MainWindow.zig: register a GID_ZOOM-only SetGestureConfig (GID_PAN/ GID_ROTATE/GID_TWOFINGERTAP/GID_PRESSANDTAP explicitly blocked since unimplemented), capturing GetLastError immediately on failure. Real registration test (positive control) plus a negative cbSize control proving the failure path fires, both against a genuine unshown HWND. - GraphCanvas.zig: CanvasState.beginPinchZoom/continuePinchZoom/ endPinchZoom compute an absolute target zoom from the gesture's captured base distance (not per-message compounding), reusing the existing zoomBy clamp/anchor. u32 distance domain matches the OS's actual integer report. Full test matrix: base capture, non- compounding repeats, clamp range, zero/missing distance, end-of- gesture reset, and a large-u32 finiteness check. - CanvasInput.zig: pure classifyGesture(dwID, flags, in_canvas) decision table (forward_unhandled / forward_out_of_region / begin_zoom / continue_zoom / end_zoom), directly unit tested. - App.zig: new WM_GESTURE case decodes GID_ZOOM via classifyGesture, maps GESTUREINFO.ptsLocation through the same ScreenToClient + region classification WM_MOUSEWHEEL already uses, and applies the pinch methods. Every unhandled/out-of-region message returns false (relying on MainWindow.windowProc's existing single DefWindowProcW forward) rather than closing or re-forwarding the gesture handle, per the documented WM_GESTURE/CloseGestureInfoHandle ownership contract. One line added to WM_ACTIVATE resets pinch state on deactivation. Registration failure surfaces through the existing setStatus diagnostic path. - ui-parity-matrix.md: updates only the Pan and anchored zoom row to describe the new touchscreen pinch coverage, explicitly distinguish it from Precision Touchpad's documented Ctrl+wheel emulation default (not device-verified here), and note touch-driven pan is still unimplemented. Row stays Partial. All 283 zig tests pass (pinned Zig 0.15.2), including a real, unmocked SetGestureConfig round trip; full executable link/build verified. No live UIA capture performed this pass (no foreground slot held); this is source + automated-test evidence only. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 86 +++++++++++++++++ graphcode-windows/src/CanvasInput.zig | 70 ++++++++++++++ graphcode-windows/src/GraphCanvas.zig | 132 ++++++++++++++++++++++++++ graphcode-windows/src/MainWindow.zig | 87 +++++++++++++++++ investigation/ui-parity-matrix.md | 2 +- 5 files changed, 376 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e3cf12f3..e980760d 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -413,6 +413,19 @@ pub const App = struct { // for both explicit automation hooks. if (!daemon_supervisor_test_hook and !uia_gate_hook) GdiplusAA.init(); try self.window.create(self, &onWindowMessage, title.ptr); + if (!self.window.gesture_config_registered) { + // Non-fatal: the canvas simply falls back to wheel-only zoom (no + // pinch input) rather than the app failing to start. Surfaced + // through the existing status/announcement path rather than a + // new logging mechanism, since none exists in this codebase. + var buf: [96]u8 = undefined; + const message = std.fmt.bufPrint( + &buf, + "Touch pinch-zoom unavailable (gesture config error {d})", + .{self.window.gesture_config_last_error}, + ) catch "Touch pinch-zoom unavailable"; + self.setStatus(message); + } // Seed the real startup DPI now that a window handle exists, rather than // waiting on the first WM_DPICHANGED. Without this a per-monitor-aware // process that launches directly on a scaled (>100%) monitor would still @@ -6091,6 +6104,71 @@ fn onWindowMessage( result.* = 0; return true; }, + c.WM_GESTURE => { + // GESTUREINFO.ptsLocation is always screen-relative (per the + // documented WM_GESTURE contract), so it must go through the + // same ScreenToClient + region classification WM_MOUSEWHEEL uses + // above before it can be compared against canvas bounds. + const gesture_handle = Win32.messagePointer(c.HGESTUREINFO, lparam); + var info: c.GESTUREINFO = std.mem.zeroes(c.GESTUREINFO); + info.cbSize = @sizeOf(c.GESTUREINFO); + if (c.GetGestureInfo(gesture_handle, &info) == 0) { + // Could not even read the gesture; nothing to handle, and + // per the handle-ownership contract an unhandled message + // must be forwarded (not closed) so DefWindowProc still sees + // it for any legacy fallback behavior. + app.canvas.endPinchZoom(); + return false; + } + const screen_point = c.POINT{ .x = info.ptsLocation.x, .y = info.ptsLocation.y }; + const mapped = CanvasInput.screenToClient(hwnd, screen_point); + var gesture_client: c.RECT = undefined; + _ = c.GetClientRect(hwnd, &gesture_client); + const gesture_routing = inputBounds(gesture_client.right, gesture_client.bottom, app.workspace_controls); + const in_canvas = if (mapped) |point| + wheelRegion(point.x, point.y, gesture_routing, app.workspace_controls) == .canvas + else + false; + const distance: u32 = @truncate(info.ullArguments); + switch (CanvasInput.classifyGesture(info.dwID, info.dwFlags, in_canvas)) { + // GID_BEGIN/GID_END (the generic gesture-sequence brackets) and + // any zoom message located outside the canvas: this window + // does not handle it, so per the documented handle-ownership + // contract it must be forwarded to DefWindowProc rather than + // closed here -- ownership of the handle transfers with the + // message. Returning false relies on MainWindow.windowProc's + // existing single DefWindowProcW forward; this case must + // never call DefWindowProcW itself, or the handle would be + // forwarded twice. + .forward_unhandled, .forward_out_of_region => { + app.canvas.endPinchZoom(); + return false; + }, + .begin_zoom => { + app.canvas.beginPinchZoom(distance); + _ = c.CloseGestureInfoHandle(gesture_handle); + result.* = 0; + return true; + }, + .continue_zoom => { + if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + _ = c.CloseGestureInfoHandle(gesture_handle); + result.* = 0; + return true; + }, + .end_zoom => { + if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance); + app.canvas.endPinchZoom(); + app.syncAccessibility(); + _ = c.InvalidateRect(hwnd, null, 0); + _ = c.CloseGestureInfoHandle(gesture_handle); + result.* = 0; + return true; + }, + } + }, c.WM_SETFOCUS => { if (app.workspace) |workspace| { if (app.surface == .workspace or app.workspace_controls.panel_visible) { @@ -6111,6 +6189,14 @@ fn onWindowMessage( // restoration race and keep stealing focus away from the rest of the app's chrome. const activated = (wparam & 0xffff) != c.WA_INACTIVE; result.* = c.DefWindowProcW(hwnd, message, wparam, lparam); + if (!activated) { + // Deactivation (e.g. Alt+Tab away, or another window taking + // focus) can happen mid-pinch without ever delivering a + // GID_END for it; clear the baseline so a later reactivation + // cannot resume a stale gesture with a now-meaningless base + // distance. + app.canvas.endPinchZoom(); + } if (activated) { if (app.workspace) |workspace| { if (app.surface == .workspace or app.workspace_controls.panel_visible) { diff --git a/graphcode-windows/src/CanvasInput.zig b/graphcode-windows/src/CanvasInput.zig index 4e9a464d..c8feb231 100644 --- a/graphcode-windows/src/CanvasInput.zig +++ b/graphcode-windows/src/CanvasInput.zig @@ -40,6 +40,50 @@ fn signedWord(value: usize) i32 { return @as(i32, @as(i16, @bitCast(@as(u16, @truncate(value))))); } +/// WM_GESTURE dwID values (winuser.h). Mirrored here (rather than pulled from +/// `c`) so this pure decision table can be unit tested without depending on +/// whichever gesture headers happen to be exposed through Win32.zig. +pub const GID_BEGIN: u32 = 1; +pub const GID_END: u32 = 2; +pub const GID_ZOOM: u32 = 3; + +/// GESTUREINFO.dwFlags bits relevant to GID_ZOOM bracketing. +pub const GF_BEGIN: u32 = 0x00000001; +pub const GF_END: u32 = 0x00000004; + +pub const GestureDecision = enum { + /// Not a zoom gesture (GID_BEGIN/GID_END bracket messages, or a gesture + /// class GESTURECONFIG blocks but the OS still forwards): must be + /// forwarded so `DefWindowProc`/legacy handling still sees it. + forward_unhandled, + /// A GID_ZOOM message whose location falls outside the canvas region: + /// forwarded rather than acted on, and any in-progress pinch state must + /// be reset since this gesture is no longer ours to continue. + forward_out_of_region, + /// First GID_ZOOM message inside the canvas: captures the baseline only. + begin_zoom, + /// A later GID_ZOOM message inside the canvas: applies the pinch update. + continue_zoom, + /// The GID_ZOOM message carrying GF_END inside the canvas: applies the + /// final update (if any distance is still reported) and then ends the + /// gesture, clearing pinch state for the next one. + end_zoom, +}; + +/// Pure classification of a single WM_GESTURE message. `dw_id` and `flags` +/// come directly from the message's GESTUREINFO; `in_canvas` is the result of +/// mapping GESTUREINFO.ptsLocation to client space (via `screenToClient`) and +/// checking it against the same region bounds `WM_MOUSEWHEEL` already uses. +/// Kept dependency-free so every branch is directly testable without a real +/// HWND or gesture handle. +pub fn classifyGesture(dw_id: u32, flags: u32, in_canvas: bool) GestureDecision { + if (dw_id != GID_ZOOM) return .forward_unhandled; + if (!in_canvas) return .forward_out_of_region; + if (flags & GF_END != 0) return .end_zoom; + if (flags & GF_BEGIN != 0) return .begin_zoom; + return .continue_zoom; +} + test "wheel message decodes negative and positive signed deltas" { try std.testing.expectEqual(@as(i16, -120), decodeWheelMessage(0, @as(c.WPARAM, 0xFF880000)).delta); try std.testing.expectEqual(@as(i16, 120), decodeWheelMessage(0, @as(c.WPARAM, 0x00780000)).delta); @@ -65,3 +109,29 @@ fn fakeScreenToClient(hwnd: c.HWND, point: *c.POINT) c.BOOL { point.y -= 640; return 1; } + +test "classifyGesture forwards non-zoom gesture IDs unconditionally" { + try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(GID_BEGIN, GF_BEGIN, true)); + try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(GID_END, GF_END, true)); + // GID_PAN (4): GESTURECONFIG blocks it, but if the OS still delivers one + // (e.g. on a platform where the block is advisory) it must be forwarded, + // not silently swallowed. + try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(4, GF_BEGIN, true)); +} + +test "classifyGesture forwards zoom messages located outside the canvas" { + try std.testing.expectEqual(GestureDecision.forward_out_of_region, classifyGesture(GID_ZOOM, GF_BEGIN, false)); + try std.testing.expectEqual(GestureDecision.forward_out_of_region, classifyGesture(GID_ZOOM, 0, false)); +} + +test "classifyGesture distinguishes begin, continue, and end within the canvas" { + try std.testing.expectEqual(GestureDecision.begin_zoom, classifyGesture(GID_ZOOM, GF_BEGIN, true)); + try std.testing.expectEqual(GestureDecision.continue_zoom, classifyGesture(GID_ZOOM, 0, true)); + try std.testing.expectEqual(GestureDecision.end_zoom, classifyGesture(GID_ZOOM, GF_END, true)); +} + +test "classifyGesture treats a combined begin+end single-message gesture as end" { + // Documented as possible for a very brief gesture; ending must win so the + // pinch baseline is still cleared rather than left dangling. + try std.testing.expectEqual(GestureDecision.end_zoom, classifyGesture(GID_ZOOM, GF_BEGIN | GF_END, true)); +} diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 87ab3937..7dffcf76 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -36,6 +36,13 @@ pub const CanvasState = struct { node_drag_y: i32 = 0, node_drag_origin: NodeOffset = .{}, hovered_connector: ?usize = null, + /// Native touchscreen pinch (WM_GESTURE/GID_ZOOM) state. `pinch_base_distance` + /// and `pinch_base_zoom` are captured once at GF_BEGIN and every subsequent + /// GID_ZOOM message computes an absolute target zoom relative to that base + /// (per the documented WM_GESTURE contract), not relative to the previous + /// message, so repeated updates cannot compound the zoom factor. + pinch_base_distance: ?u32 = null, + pinch_base_zoom: f32 = 1, pub fn beginPan(self: *CanvasState, x: i32, y: i32) void { self.dragging = true; @@ -242,6 +249,50 @@ pub const CanvasState = struct { self.pan_y = @as(f32, @floatFromInt(y)) - world_y * next; } + /// Captures the gesture's starting distance/zoom baseline. Per the + /// documented WM_GESTURE contract, the first GID_ZOOM message ("GF_BEGIN") + /// begins a zoom but must not cause any zooming itself. `distance` is the + /// OS-reported GESTUREINFO.ullArguments low dword, an unsigned integer by + /// construction (so this can never receive a NaN/Inf input); zero is the + /// platform's way of saying "no meaningful distance yet," so we leave the + /// base unset rather than dividing by it later. + pub fn beginPinchZoom(self: *CanvasState, distance: u32) void { + if (distance == 0) { + self.pinch_base_distance = null; + return; + } + self.pinch_base_distance = distance; + self.pinch_base_zoom = self.zoom; + } + + /// Applies a subsequent GID_ZOOM message. The target zoom is always + /// computed as an *absolute* value relative to the gesture's captured + /// base (`pinch_base_zoom * currentDistance / pinch_base_distance`), then + /// converted into the one-shot multiplicative factor `zoomBy` expects + /// (`target / currentZoom`). This is what keeps repeated updates from + /// compounding: recomputing from the base every message, rather than + /// chaining `currentDistance / previousDistance` factors, means + /// intermediate clamping (from `zoomBy`) is self-correcting instead of + /// accumulating error. + pub fn continuePinchZoom(self: *CanvasState, x: i32, y: i32, distance: u32) void { + const base_distance = self.pinch_base_distance orelse return; + if (distance == 0 or self.zoom == 0) return; + const target_zoom = self.pinch_base_zoom * + (@as(f32, @floatFromInt(distance)) / @as(f32, @floatFromInt(base_distance))); + self.zoomBy(x, y, target_zoom / self.zoom); + } + + /// Ends the current pinch gesture (GID_ZOOM's own GF_END flag, the + /// generic GID_END bracket message, the gesture's point leaving the + /// canvas region mid-gesture, or the window deactivating). Clearing the + /// base here is what guarantees a later re-entry into the canvas (or a + /// later gesture entirely) cannot resume a stale baseline without a new + /// GF_BEGIN — the next `continuePinchZoom` call will simply no-op until + /// `beginPinchZoom` runs again. + pub fn endPinchZoom(self: *CanvasState) void { + self.pinch_base_distance = null; + } + pub fn actualSize(self: *CanvasState) void { self.zoom = 1; self.pan_x = 0; @@ -1765,6 +1816,87 @@ test "canvas wheel zoom scales high-resolution trackpad deltas" { try std.testing.expectApproxEqAbs(@as(f32, 1.21), state.zoom, 0.01); } +test "pinch zoom scales relative to the gesture's captured base distance" { + var state = CanvasState{}; + state.beginPinchZoom(100); + try std.testing.expectEqual(@as(?u32, 100), state.pinch_base_distance); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); + + state.continuePinchZoom(400, 300, 200); + try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.001); +} + +test "pinch zoom does not compound across repeated updates" { + // Every GID_ZOOM message recomputes the target from the base distance, + // rather than chaining relative-to-previous factors, so two updates that + // both report a 2x distance land on the same zoom, not zoom^2. + var single = CanvasState{}; + single.beginPinchZoom(100); + single.continuePinchZoom(400, 300, 150); + + var repeated = CanvasState{}; + repeated.beginPinchZoom(100); + repeated.continuePinchZoom(400, 300, 150); + repeated.continuePinchZoom(400, 300, 150); + repeated.continuePinchZoom(400, 300, 150); + + try std.testing.expectApproxEqAbs(single.zoom, repeated.zoom, 0.0001); +} + +test "pinch zoom respects the existing zoomBy clamp range" { + var state = CanvasState{}; + state.beginPinchZoom(100); + state.continuePinchZoom(400, 300, 10_000); + try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.0001); + + var shrink = CanvasState{}; + shrink.beginPinchZoom(100); + shrink.continuePinchZoom(400, 300, 1); + try std.testing.expectApproxEqAbs(@as(f32, 0.55), shrink.zoom, 0.0001); +} + +test "pinch zoom with zero distance never sets a baseline or divides by zero" { + var state = CanvasState{}; + state.beginPinchZoom(0); + try std.testing.expectEqual(@as(?u32, null), state.pinch_base_distance); + + // Also guards a base captured normally but then fed a zero continuation. + state.beginPinchZoom(100); + state.continuePinchZoom(400, 300, 0); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); +} + +test "pinch zoom is a no-op before a base distance has been captured" { + var state = CanvasState{}; + state.continuePinchZoom(400, 300, 200); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); +} + +test "ending a pinch gesture clears the baseline for the next gesture" { + var state = CanvasState{}; + state.beginPinchZoom(100); + state.continuePinchZoom(400, 300, 150); + state.endPinchZoom(); + try std.testing.expectEqual(@as(?u32, null), state.pinch_base_distance); + + // A stray continuation after end must not resume the stale gesture. + const zoom_after_end = state.zoom; + state.continuePinchZoom(400, 300, 400); + try std.testing.expectApproxEqAbs(zoom_after_end, state.zoom, 0.0001); + + // A fresh begin starts an independent gesture from the current zoom. + state.beginPinchZoom(100); + try std.testing.expectApproxEqAbs(zoom_after_end, state.pinch_base_zoom, 0.0001); +} + +test "pinch zoom with a very large OS-reported distance stays finite and clamped" { + var state = CanvasState{}; + state.beginPinchZoom(1); + state.continuePinchZoom(400, 300, std.math.maxInt(u32)); + try std.testing.expect(std.math.isFinite(state.zoom)); + try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.0001); +} + test "loop card stripe follows loop type rather than lifecycle state" { try std.testing.expect(loopTypeColor("goalBased") != stateColor("failed", false)); try std.testing.expectEqual(@as(u32, 0x00C77DFF), loopTypeColor("composite")); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 67204d80..1ceeb00e 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -102,6 +102,36 @@ pub fn commandFromId(id: usize) ?Command { return std.meta.intToEnum(Command, @as(u16, @intCast(id))) catch null; } +pub const GestureConfigResult = struct { + ok: bool, + /// `GetLastError()` captured immediately after the `SetGestureConfig` + /// call, before any other Win32 call can overwrite it. Only meaningful + /// when `ok` is false. + last_error: c.DWORD, +}; + +/// Registers this window for native pinch-zoom (`GID_ZOOM`) gestures and +/// explicitly blocks the other WM_GESTURE classes GraphCode does not yet +/// implement (pan/rotate/two-finger-tap/press-and-tap), so the canvas cannot +/// silently start handling gestures it has no logic for. `SetGestureConfig` +/// documents that a single call cannot mix a `dwID = 0` "all gestures" entry +/// with specific-`dwID` entries, so every entry here targets one specific +/// `dwID` instead (matching the pattern in Microsoft's own multi-gesture +/// configuration examples). +pub fn registerCanvasGestureConfig(hwnd: c.HWND) GestureConfigResult { + var configs = [_]c.GESTURECONFIG{ + .{ .dwID = c.GID_ZOOM, .dwWant = c.GC_ZOOM, .dwBlock = 0 }, + .{ .dwID = c.GID_PAN, .dwWant = 0, .dwBlock = c.GC_PAN }, + .{ .dwID = c.GID_ROTATE, .dwWant = 0, .dwBlock = c.GC_ROTATE }, + .{ .dwID = c.GID_TWOFINGERTAP, .dwWant = 0, .dwBlock = c.GC_TWOFINGERTAP }, + .{ .dwID = c.GID_PRESSANDTAP, .dwWant = 0, .dwBlock = c.GC_PRESSANDTAP }, + }; + if (c.SetGestureConfig(hwnd, 0, configs.len, &configs, @sizeOf(c.GESTURECONFIG)) != 0) { + return .{ .ok = true, .last_error = 0 }; + } + return .{ .ok = false, .last_error = c.GetLastError() }; +} + pub const Window = struct { hwnd: c.HWND = null, instance: c.HINSTANCE = null, @@ -109,6 +139,12 @@ pub const Window = struct { callback: ?MessageCallback = null, accelerators: c.HACCEL = null, class_name: [*:0]const u16 = class_name.ptr, + /// Result of the one-time `SetGestureConfig` registration performed in + /// `create`. Kept on the struct (rather than discarded) so a failure can + /// be surfaced through the existing `setStatus` diagnostic path instead + /// of failing silently. + gesture_config_registered: bool = false, + gesture_config_last_error: c.DWORD = 0, pub fn create( self: *Window, @@ -139,6 +175,9 @@ pub const Window = struct { self.instance, @ptrCast(self), ) orelse return error.WindowCreationFailed; + const gesture_result = registerCanvasGestureConfig(self.hwnd); + self.gesture_config_registered = gesture_result.ok; + self.gesture_config_last_error = gesture_result.last_error; try installMenu(self.hwnd); self.accelerators = createAccelerators(); _ = c.ShowWindow(self.hwnd, c.SW_SHOW); @@ -538,6 +577,54 @@ test "setUpdateCheckEnabled toggles only the Check for Updates command's real me try std.testing.expect((worktrees_state & c.MF_GRAYED) == 0); } +// Real (not faked) `SetGestureConfig` registration test. Positive control +// proves the exact array this code builds is accepted by the real Win32 API +// against a genuine, never-shown HWND (reusing the same non-activating test +// harness as `setUpdateCheckEnabled` above); negative control (a deliberately +// wrong `cbSize`) proves the failure branch actually fires and captures a +// nonzero `GetLastError()`, rather than the success path being trivially +// true regardless of what's passed. +test "registerCanvasGestureConfig succeeds with the real gesture array and captures errors on failure" { + const test_class_name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeMainWindowGestureTestClass"); + var wc = std.mem.zeroes(c.WNDCLASSEXW); + wc.cbSize = @sizeOf(c.WNDCLASSEXW); + wc.lpfnWndProc = testWindowProc; + wc.hInstance = c.GetModuleHandleW(null); + wc.lpszClassName = test_class_name; + _ = c.RegisterClassExW(&wc); + + const hwnd = c.CreateWindowExW( + 0, + test_class_name, + std.unicode.utf8ToUtf16LeStringLiteral("GraphCode MainWindow gesture test"), + c.WS_OVERLAPPEDWINDOW, + 0, + 0, + 0, + 0, + null, + null, + wc.hInstance, + null, + ) orelse return error.SkipZigTest; + defer _ = c.DestroyWindow(hwnd); + + const success = registerCanvasGestureConfig(hwnd); + try std.testing.expect(success.ok); + try std.testing.expectEqual(@as(c.DWORD, 0), success.last_error); + + // Negative control: call the real API directly with a corrupted cbSize + // (rather than mocking anything) to prove SetGestureConfig genuinely + // rejects a malformed array and that GetLastError reports a real, + // nonzero code afterward. + var bad_config = [_]c.GESTURECONFIG{ + .{ .dwID = c.GID_ZOOM, .dwWant = c.GC_ZOOM, .dwBlock = 0 }, + }; + const failed = c.SetGestureConfig(hwnd, 0, bad_config.len, &bad_config, 0); + try std.testing.expectEqual(@as(c.BOOL, 0), failed); + try std.testing.expect(c.GetLastError() != 0); +} + test "recent folder commands use a dedicated command range" { try std.testing.expect(isRecentFolderCommand(recent_folder_command_base)); try std.testing.expect(isRecentFolderCommand(recent_folder_command_limit)); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 5fbe8f56..078e3179 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -66,7 +66,7 @@ Statuses: | Cross-project global graph | Every open folder as a lane on one canvas | `GraphCanvas.overviewLaneBounds` stacks every open project's lane vertically by accumulated lane height, and a focused test (`GraphCanvas.zig`: "cross-project overview stacks every open folder as its own lane") now proves a second project's lane renders below the first without overlap, is independently hit-testable, and exposes its own per-lane Open/Worktrees targets. The Windows shell toolchain blocker is resolved and the live gate exercises the deterministic single-project overview reliably (see Folder lanes/bands), but the shared UIA fixture still only opens one project, so there is no live capture of two or more simultaneous lanes side by side. Promoting to Validated would require extending the fixture to register a second project before this can move further | Partial | | Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, and the local Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` posts real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` messages at the lane's Open and Worktrees hit-test rects against the live executable and verifies Open routes to the project canvas (synchronized cards render at a new position) and Worktrees opens the scoped inspection view, both confirmed passing across many consecutive live runs. This also uncovered and fixed two real accessibility bugs along the way: `App.zig`'s `.overview` mouse-click switch arm and its `.cycle_attention` action handler were both missing the `syncAccessibility()` call that keeps the live UIA tree in sync with what is rendered, so a lane's Open/Worktrees click previously had no observable effect through the accessibility tree even though the underlying surface did change | Validated | | Notebook grid | Grid pans and zooms with canvas | `GraphCanvas.drawGrid` derives its cell size and offset from the exact same `CanvasState.zoom`/`pan_x`/`pan_y` fields consumed by `overviewCardBounds`, `overviewLaneBounds`, and the loop-card geometry, so the same focused pan/zoom coverage (`GraphCanvas.zig`: "canvas hit testing follows pan and zoom", "overview and quick chat geometry applies pan and zoom consistently") indirectly proves the grid cannot desynchronize from the content it underlays. The Windows shell toolchain blocker is resolved, but the grid itself is a 1px `0x00161815` GDI line pattern with no UIA surface of its own, and this session did not add a live pixel-scan assertion (the connector-handle and attention-rail blocks already show this pattern is feasible) to directly confirm grid line spacing changes with zoom in the running executable. Left Partial rather than claim live evidence that was not actually captured | Partial | -| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered zoom remain intact; high-resolution wheel/trackpad deltas scale by notch count and have focused regression coverage (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). The Windows shell toolchain blocker is resolved, but this is a genuine, not merely missing-evidence, product gap: there is no `WM_POINTER`/`WM_GESTURE`/`GESTURECONFIG` handling anywhere in `graphcode-windows/src` today, so native pinch zoom is entirely unimplemented, and the live gate also does not yet synthesize `WM_MOUSEWHEEL` to exercise wheel-zoom live. Implementing real touch-gesture routing is a cross-file change to the main window message loop outside `GraphCanvas.zig` ownership and was not attempted this pass rather than risk an unverified, untested touch-input pipeline | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered wheel zoom remain intact and unchanged, with the same focused regression coverage as before (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). Native touchscreen pinch-zoom is now implemented and routed through the main window: `MainWindow.zig` registers a `GID_ZOOM`-only `SetGestureConfig` (explicitly blocking `GID_PAN`/`GID_ROTATE`/`GID_TWOFINGERTAP`/`GID_PRESSANDTAP`, which remain unimplemented), with a real registration test against a genuine `HWND` plus a negative (bad `cbSize`) control proving the `GetLastError()` failure path actually fires; `App.zig`'s `WM_GESTURE` case decodes `GID_ZOOM` via a new pure `CanvasInput.classifyGesture` decision table and applies `GraphCanvas.zig`'s new `beginPinchZoom`/`continuePinchZoom`/`endPinchZoom`, an absolute-target-from-the-gesture's-base pinch model (not per-message compounding) with a full non-compounding/clamp/zero-distance/lifecycle test matrix; every non-zoom or out-of-canvas message is forwarded unmodified per the documented `WM_GESTURE`/`CloseGestureInfoHandle` ownership contract. This is source-mapped, automated routing/unit evidence, not live hardware-input evidence: this session held no live UIA capture slot this pass, so pinch is proven by code mapping and 283 passing deterministic tests (including a real, unmocked `SetGestureConfig` round trip), not an actual touchscreen or Precision Touchpad device. Per Microsoft's documented default, Precision Touchpad pinch on a classic Win32 window is emulated as synthetic Ctrl+`WM_MOUSEWHEEL`, not delivered as `WM_GESTURE`, so this implementation targets true touchscreen digitizers specifically; Precision Touchpad pinch behavior is not separately implemented or verified here. Touch-driven pan (`GID_PAN`) remains a genuine unimplemented gap, intentionally blocked rather than half-handled. Left Partial: touchscreen pinch has real source and automated-test coverage but no live device evidence, and touch pan is still unimplemented | Partial | | Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu. The Windows shell toolchain blocker is resolved and `Tools\windows\uia-live-gate.ps1` now runs against the live executable: it locates the `zoom-out`, `actual-size`, `zoom-in`, and `fit-canvas` UIA fragments, requires non-empty bounds, resolves each `InvokePattern`, and then actually invokes zoom-in, actual-size, zoom-out, and fit-canvas in sequence against the running shell, all of which completed without error across many consecutive live runs | Validated | | New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | | Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | From ff8cd95c633f7fbedfc4eb3b0f9b1fe78759e499 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 15:06:16 -0700 Subject: [PATCH 2/3] graph: correct pinch-zoom per coordinator review Fixes against the coordinator's review of d706937, not the summarized approval card: 1. registerCanvasGestureConfig now registers only GID_ZOOM; GID_PAN/ GID_ROTATE/GID_TWOFINGERTAP/GID_PRESSANDTAP are no longer explicitly blocked (App.zig already forwards any non-GID_ZOOM message unhandled). 2. A per-gesture context identity (Wyhash of surface tag + current project path, computed fresh every message, never a borrowed pointer) is now threaded through beginPinchZoom/continuePinchZoom. A same-region destination change mid-gesture (surface flips to the terminal workspace, or the project changes while still graph-capable) resets the baseline instead of continuing to scale the wrong canvas. WM_GESTURE now also requires the active surface to actually render the graph canvas, not just the wheel-region rectangle (which the terminal workspace shares). 3. A failed GetClientRect is now guarded: pinch state resets and the message is treated as unhandled instead of classifying against an undefined rect. 4. The gesture handle is now closed before syncAccessibility()/ InvalidateRect (which can re-enter the message loop), not after. A new CanvasInput.GestureDecision.begin_and_end_zoom outcome handles a gesture delivered as a single combined begin+end message by establishing then immediately clearing a fresh baseline, rather than mapping it to end_zoom (which could apply a stale prior gesture's baseline). 5. Added a second registerCanvasGestureConfig test that calls the production helper itself with a genuinely invalid HWND, proving its own GetLastError capture path fires, not just the existing malformed-native-call negative control. 6. Reworded the gesture-registration diagnostic comment (removed the false "no logging mechanism exists" claim) and added a test proving window.gesture_config_registered/gesture_config_last_error survive the later setStatus() calls in App.run() that are known to overwrite the transient status line. 7. Verified via genuine temporary regressions, not GREEN-only tests: reintroduced the old per-message-relative (compounding) zoom math, confirmed "does not compound" fails, restored; removed the context check in continuePinchZoom, confirmed the mismatch test fails, restored; made registerCanvasGestureConfig ignore SetGestureConfig's result and always report success, confirmed the new invalid-HWND test fails, restored. Full per-file zig test suites, the 42-file WindowsShell.Tests.ps1 contract (anti-drift guard included), and a full zig build all pass. ui-parity-matrix.md's Pan and anchored zoom row reworded to match the corrected behavior; stays Partial pending live device evidence. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 216 ++++++++++++++++++++++++-- graphcode-windows/src/CanvasInput.zig | 43 +++-- graphcode-windows/src/GraphCanvas.zig | 98 +++++++++--- graphcode-windows/src/MainWindow.zig | 37 +++-- investigation/ui-parity-matrix.md | 2 +- 5 files changed, 333 insertions(+), 63 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index e980760d..4f381be5 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -86,6 +86,20 @@ fn wheelRegion(x: i32, y: i32, bounds: InputBounds, controls: WorkspaceControls. return .none; } +/// Whether a WM_GESTURE point should be treated as landing on the graph +/// canvas. The `wheelRegion` rectangle test alone only says a point falls +/// over the canvas-*shaped* area of the window; it says nothing about +/// whether the surface actually showing there right now renders the graph +/// canvas at all -- the terminal workspace surface reuses the exact same +/// window chrome/rectangle. Both conditions are required: a graph-capable +/// surface (anything except the terminal workspace) AND the mapped point +/// actually falling inside the canvas rectangle. +fn gestureInCanvas(surface: GraphCanvas.Surface, mapped: ?c.POINT, bounds: InputBounds, controls: WorkspaceControls.State) bool { + if (surface == .workspace) return false; + const point = mapped orelse return false; + return wheelRegion(point.x, point.y, bounds, controls) == .canvas; +} + fn isResolvedLoopState(state: []const u8) bool { return std.mem.eql(u8, state, "succeeded") or std.mem.eql(u8, state, "failed") or @@ -415,9 +429,19 @@ pub const App = struct { try self.window.create(self, &onWindowMessage, title.ptr); if (!self.window.gesture_config_registered) { // Non-fatal: the canvas simply falls back to wheel-only zoom (no - // pinch input) rather than the app failing to start. Surfaced - // through the existing status/announcement path rather than a - // new logging mechanism, since none exists in this codebase. + // pinch input) rather than the app failing to start. The + // transient status/announcement line below is best-effort -- + // several later calls in this same startup sequence (daemon + // status, accessibility attach, product-settings/canvas-layout/ + // sidebar-store load failures) call setStatus themselves and can + // overwrite this message before the window is ever shown. The + // durable record of this outcome is `window.gesture_config_registered` + // / `window.gesture_config_last_error` themselves: they are set + // once here and never cleared or overwritten by any later + // setStatus call, so any caller needing the real outcome (tests, + // future diagnostics UI, support tooling) can read them directly + // off the window rather than relying on this status line still + // being visible. var buf: [96]u8 = undefined; const message = std.fmt.bufPrint( &buf, @@ -849,6 +873,23 @@ pub const App = struct { return null; } + /// A stable identity for "what an in-progress pinch gesture is currently + /// applied to", used to detect a same-region destination change (surface + /// switch, or project switch while the surface stays graph-capable) that + /// the OS never brackets with GID_END. Deliberately hashes the surface + /// tag and `currentProject()`'s path *content* (matching the existing + /// `GraphCanvas.nodeKey` Wyhash-of-content precedent) rather than storing + /// the path slice itself, since `currentProject()` returns data borrowed + /// from model storage that can be freed or reallocated out from under a + /// held pointer while a multi-message gesture is still in flight. + fn pinchGestureContext(self: *const App) u64 { + var hasher = std.hash.Wyhash.init(0); + const surface_tag = @intFromEnum(self.surface); + hasher.update(std.mem.asBytes(&surface_tag)); + if (self.currentProject()) |path| hasher.update(path); + return hasher.final(); + } + fn selectProject(self: *App, path: []const u8) bool { const selected = self.model.selectProject(path); if (selected) self.client.setSubgraphAddress(null); @@ -6120,16 +6161,22 @@ fn onWindowMessage( app.canvas.endPinchZoom(); return false; } + var gesture_client: c.RECT = undefined; + if (c.GetClientRect(hwnd, &gesture_client) == 0) { + // A failed GetClientRect leaves `gesture_client` undefined; + // treating that as "in canvas" would classify against + // garbage bounds. Reset any in-progress gesture and forward + // unhandled -- this window cannot safely act on the message + // without a valid client rect. + app.canvas.endPinchZoom(); + return false; + } const screen_point = c.POINT{ .x = info.ptsLocation.x, .y = info.ptsLocation.y }; const mapped = CanvasInput.screenToClient(hwnd, screen_point); - var gesture_client: c.RECT = undefined; - _ = c.GetClientRect(hwnd, &gesture_client); const gesture_routing = inputBounds(gesture_client.right, gesture_client.bottom, app.workspace_controls); - const in_canvas = if (mapped) |point| - wheelRegion(point.x, point.y, gesture_routing, app.workspace_controls) == .canvas - else - false; + const in_canvas = gestureInCanvas(app.surface, mapped, gesture_routing, app.workspace_controls); const distance: u32 = @truncate(info.ullArguments); + const pinch_context = app.pinchGestureContext(); switch (CanvasInput.classifyGesture(info.dwID, info.dwFlags, in_canvas)) { // GID_BEGIN/GID_END (the generic gesture-sequence brackets) and // any zoom message located outside the canvas: this window @@ -6145,24 +6192,42 @@ fn onWindowMessage( return false; }, .begin_zoom => { - app.canvas.beginPinchZoom(distance); + app.canvas.beginPinchZoom(distance, pinch_context); _ = c.CloseGestureInfoHandle(gesture_handle); result.* = 0; return true; }, .continue_zoom => { - if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance); + if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance, pinch_context); + // Close the handle before any re-entrant call + // (syncAccessibility/InvalidateRect can pump messages); + // holding it open across those risks a double-close or + // a use of a handle another WM_GESTURE re-entry already + // closed. + _ = c.CloseGestureInfoHandle(gesture_handle); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); - _ = c.CloseGestureInfoHandle(gesture_handle); result.* = 0; return true; }, .end_zoom => { - if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance); + if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance, pinch_context); app.canvas.endPinchZoom(); + _ = c.CloseGestureInfoHandle(gesture_handle); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); + result.* = 0; + return true; + }, + .begin_and_end_zoom => { + // A gesture short enough to arrive as a single message + // still begins a fresh baseline (recording this begin's + // context) and then immediately ends it, exactly as a + // real begin-then-end sequence would -- never treated as + // a continuation, which could otherwise apply whatever + // baseline a previous, unrelated gesture left behind. + app.canvas.beginPinchZoom(distance, pinch_context); + app.canvas.endPinchZoom(); _ = c.CloseGestureInfoHandle(gesture_handle); result.* = 0; return true; @@ -6300,6 +6365,93 @@ test "input routing bounds follow hidden workspace panel and rail" { try std.testing.expectEqual(WheelRegion.canvas, wheelRegion(600, 850, hidden, hidden_controls)); } +test "gesture routing requires a graph-capable surface, not only the canvas rectangle" { + // This is the exact helper the real WM_GESTURE handler calls -- proving + // routing here is proving the production path, not a parallel reimplementation. + const hidden_controls = WorkspaceControls.State{ + .rail_visible = false, + .panel_visible = false, + .activity_enabled = false, + }; + const bounds = inputBounds(1200, 900, hidden_controls); + const point_over_canvas_rect = c.POINT{ .x = 600, .y = 500 }; + + // The terminal workspace surface reuses the exact same window chrome and + // canvas-shaped rectangle, but does not render the graph canvas at all -- + // a pinch landing there must never be treated as a canvas gesture, even + // though the rectangle test alone would say "canvas". + try std.testing.expect(!gestureInCanvas(.workspace, point_over_canvas_rect, bounds, hidden_controls)); + + // Every graph-capable surface (project/overview/quick_chats) is routed + // when the point is genuinely over the canvas rectangle. + try std.testing.expect(gestureInCanvas(.project, point_over_canvas_rect, bounds, hidden_controls)); + try std.testing.expect(gestureInCanvas(.overview, point_over_canvas_rect, bounds, hidden_controls)); + try std.testing.expect(gestureInCanvas(.quick_chats, point_over_canvas_rect, bounds, hidden_controls)); + + // A graph-capable surface with a point outside the canvas rectangle (or + // an unmapped/failed ScreenToClient) is still not routed to the canvas. + try std.testing.expect(!gestureInCanvas(.project, null, bounds, hidden_controls)); + try std.testing.expect(!gestureInCanvas(.project, c.POINT{ .x = -50, .y = -50 }, bounds, hidden_controls)); + + // A destination switch mid-gesture (surface flips from a graph surface to + // the terminal workspace at the exact same screen point) flips routing + // from in-canvas to not-in-canvas -- this is what lets the real handler's + // forward_out_of_region branch reset any in-progress pinch instead of + // continuing to scale a canvas that is no longer on screen. + try std.testing.expect(gestureInCanvas(.overview, point_over_canvas_rect, bounds, hidden_controls)); + try std.testing.expect(!gestureInCanvas(.workspace, point_over_canvas_rect, bounds, hidden_controls)); +} + +test "pinchGestureContext changes identity across project switches on the same surface" { + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = undefined, + .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), + }; + defer app.model.deinit(); + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"a","project":{"path":"A","name":"Alpha"},"nodes":[],"edges":[]}}} + ); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":2,"event":{"graphChanged":{"id":"b","project":{"path":"B","name":"Beta"},"nodes":[],"edges":[]}}} + ); + + _ = app.model.selectProject("A"); + app.surface = .overview; + const context_project_a = app.pinchGestureContext(); + + _ = app.model.selectProject("B"); + const context_project_b = app.pinchGestureContext(); + + // Same graph-capable surface, different project: this identity is what + // WM_GESTURE's routing uses to detect a same-region destination change + // during an in-progress pinch. A gesture begun on project A must not be + // able to keep scaling project B's canvas after a mid-gesture project + // switch, so the two identities must differ. + try std.testing.expect(context_project_a != context_project_b); + + // A surface switch away from the graph canvas (still on project B) is + // also a distinct identity, covering the terminal-workspace case. + app.surface = .workspace; + const context_workspace = app.pinchGestureContext(); + try std.testing.expect(context_workspace != context_project_b); + + // Recomputing with no state change at all is stable (same inputs, same + // hash), since App.zig recomputes this fresh on every WM_GESTURE message + // rather than caching it. + app.surface = .overview; + try std.testing.expectEqual(context_project_b, app.pinchGestureContext()); +} + test "jump matching ranks exact results across projects" { var model = GraphModel.Model.init(std.testing.allocator); defer model.deinit(); @@ -6435,6 +6587,44 @@ test "worktree row selected reflects sidebar and dialog selection honestly" { try std.testing.expect(app.worktreeRowSelected()); } +test "gesture registration outcome survives later startup setStatus calls" { + // App.run() calls setStatus() repeatedly during synchronous startup + // (tray, daemon status, accessibility attach, product-settings/canvas- + // layout/sidebar-store load failures) immediately after the gesture- + // registration diagnostic. Any of those can legitimately clobber the + // transient status line before the window is ever shown; what must NOT + // be lost is the durable record on `window` itself. + const allocator = std.testing.allocator; + var app: App = .{ + .allocator = allocator, + .client = undefined, + .daemon = undefined, + .model = undefined, + .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), + }; + defer app.sidebar_state.deinit(); + defer app.declared_entry_ids.deinit(); + defer app.kept_worktree_paths.deinit(); + defer if (app.status_override.len != 0) allocator.free(app.status_override); + + app.window.gesture_config_registered = false; + app.window.gesture_config_last_error = 1223; + + app.setStatus("Touch pinch-zoom unavailable (gesture config error 1223)"); + // Simulate the later startup calls that are known to overwrite status. + app.setStatus("Connecting to daemon..."); + app.setStatus("Accessibility provider unavailable"); + app.setStatus("Product settings failed to load"); + + // The transient line is allowed to have been overwritten... + try std.testing.expect(!std.mem.eql(u8, app.status(), "Touch pinch-zoom unavailable (gesture config error 1223)")); + // ...but the durable record must be completely unaffected. + try std.testing.expect(!app.window.gesture_config_registered); + try std.testing.expectEqual(@as(c.DWORD, 1223), app.window.gesture_config_last_error); +} + test "header UIA identities hash to distinct payloads" { // The native header bar's four chips (attention, worktree notice, jump, // contextual loop-panel toggle) are dispatched by matching a hashed UIA diff --git a/graphcode-windows/src/CanvasInput.zig b/graphcode-windows/src/CanvasInput.zig index c8feb231..c95ac769 100644 --- a/graphcode-windows/src/CanvasInput.zig +++ b/graphcode-windows/src/CanvasInput.zig @@ -64,10 +64,18 @@ pub const GestureDecision = enum { begin_zoom, /// A later GID_ZOOM message inside the canvas: applies the pinch update. continue_zoom, - /// The GID_ZOOM message carrying GF_END inside the canvas: applies the - /// final update (if any distance is still reported) and then ends the - /// gesture, clearing pinch state for the next one. + /// The GID_ZOOM message carrying GF_END (but not GF_BEGIN) inside the + /// canvas: applies the final update against the already-captured + /// baseline, then ends the gesture, clearing pinch state for the next + /// one. end_zoom, + /// A GID_ZOOM message carrying *both* GF_BEGIN and GF_END inside the + /// canvas (a gesture short enough to be delivered as a single message). + /// Per the documented contract the first GID_ZOOM message never causes + /// zooming by itself, so this must establish a fresh baseline and then + /// immediately clear it -- never apply a continuation against whatever + /// baseline a *previous*, unrelated gesture happened to leave behind. + begin_and_end_zoom, }; /// Pure classification of a single WM_GESTURE message. `dw_id` and `flags` @@ -79,8 +87,11 @@ pub const GestureDecision = enum { pub fn classifyGesture(dw_id: u32, flags: u32, in_canvas: bool) GestureDecision { if (dw_id != GID_ZOOM) return .forward_unhandled; if (!in_canvas) return .forward_out_of_region; - if (flags & GF_END != 0) return .end_zoom; - if (flags & GF_BEGIN != 0) return .begin_zoom; + const is_begin = flags & GF_BEGIN != 0; + const is_end = flags & GF_END != 0; + if (is_begin and is_end) return .begin_and_end_zoom; + if (is_end) return .end_zoom; + if (is_begin) return .begin_zoom; return .continue_zoom; } @@ -113,9 +124,10 @@ fn fakeScreenToClient(hwnd: c.HWND, point: *c.POINT) c.BOOL { test "classifyGesture forwards non-zoom gesture IDs unconditionally" { try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(GID_BEGIN, GF_BEGIN, true)); try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(GID_END, GF_END, true)); - // GID_PAN (4): GESTURECONFIG blocks it, but if the OS still delivers one - // (e.g. on a platform where the block is advisory) it must be forwarded, - // not silently swallowed. + // GID_PAN (4): this app's GESTURECONFIG only opts in to GID_ZOOM and + // leaves every other gesture class at its existing default (neither + // enabled nor blocked), so any GID_PAN message the OS still delivers + // must be forwarded, not silently swallowed. try std.testing.expectEqual(GestureDecision.forward_unhandled, classifyGesture(4, GF_BEGIN, true)); } @@ -130,8 +142,15 @@ test "classifyGesture distinguishes begin, continue, and end within the canvas" try std.testing.expectEqual(GestureDecision.end_zoom, classifyGesture(GID_ZOOM, GF_END, true)); } -test "classifyGesture treats a combined begin+end single-message gesture as end" { - // Documented as possible for a very brief gesture; ending must win so the - // pinch baseline is still cleared rather than left dangling. - try std.testing.expectEqual(GestureDecision.end_zoom, classifyGesture(GID_ZOOM, GF_BEGIN | GF_END, true)); +test "classifyGesture gives a combined begin+end single-message gesture its own outcome" { + // Documented as possible for a very brief gesture. This must NOT map to + // plain end_zoom: end_zoom's contract is "continue against the already- + // captured baseline, then clear it", but a message that begins and ends + // in one shot has no prior baseline of its own to continue -- treating + // it as end_zoom would let it apply a *different*, stale gesture's + // leftover baseline. It gets a distinct outcome so the caller establishes + // a fresh baseline first and applies no zoom delta, exactly matching a + // real begin-then-end sequence. + try std.testing.expectEqual(GestureDecision.begin_and_end_zoom, classifyGesture(GID_ZOOM, GF_BEGIN | GF_END, true)); } + diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 7dffcf76..6f0a8617 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -43,6 +43,16 @@ pub const CanvasState = struct { /// message, so repeated updates cannot compound the zoom factor. pinch_base_distance: ?u32 = null, pinch_base_zoom: f32 = 1, + /// A caller-supplied identity captured at `beginPinchZoom` (e.g. a hash + /// of the active surface and selected project, mirroring the existing + /// `nodeKey` hashing pattern rather than holding onto a borrowed string). + /// A pinch gesture can legitimately span many messages, so the app can + /// change destination (surface switch, project switch) *without* ever + /// delivering GID_END for the in-progress gesture; `continuePinchZoom` + /// treats a mismatched context as the gesture no longer being valid for + /// whatever is now on screen, rather than silently continuing to scale + /// it. + pinch_context: u64 = 0, pub fn beginPan(self: *CanvasState, x: i32, y: i32) void { self.dragging = true; @@ -255,8 +265,12 @@ pub const CanvasState = struct { /// OS-reported GESTUREINFO.ullArguments low dword, an unsigned integer by /// construction (so this can never receive a NaN/Inf input); zero is the /// platform's way of saying "no meaningful distance yet," so we leave the - /// base unset rather than dividing by it later. - pub fn beginPinchZoom(self: *CanvasState, distance: u32) void { + /// base unset rather than dividing by it later. `context` identifies what + /// this gesture is being applied to (see the `pinch_context` field doc); + /// it is always (re)recorded here so a later `continuePinchZoom` can + /// detect the gesture has outlived the thing it started on. + pub fn beginPinchZoom(self: *CanvasState, distance: u32, context: u64) void { + self.pinch_context = context; if (distance == 0) { self.pinch_base_distance = null; return; @@ -274,7 +288,21 @@ pub const CanvasState = struct { /// chaining `currentDistance / previousDistance` factors, means /// intermediate clamping (from `zoomBy`) is self-correcting instead of /// accumulating error. - pub fn continuePinchZoom(self: *CanvasState, x: i32, y: i32, distance: u32) void { + /// + /// `context` must match the value passed to the `beginPinchZoom` that + /// started this gesture. A single physical gesture can span many + /// messages without ever delivering GID_END (e.g. the user switches the + /// active project or the surface changes to the terminal mid-pinch); + /// when the context no longer matches, this resets the baseline instead + /// of applying a zoom update, so a gesture that began on one graph can + /// never scale a different one it happened to still be "in progress" + /// over. The caller (App.zig) is expected to follow a reset with a fresh + /// `beginPinchZoom` on the next message if the point is still in-canvas. + pub fn continuePinchZoom(self: *CanvasState, x: i32, y: i32, distance: u32, context: u64) void { + if (context != self.pinch_context) { + self.pinch_base_distance = null; + return; + } const base_distance = self.pinch_base_distance orelse return; if (distance == 0 or self.zoom == 0) return; const target_zoom = self.pinch_base_zoom * @@ -1818,11 +1846,11 @@ test "canvas wheel zoom scales high-resolution trackpad deltas" { test "pinch zoom scales relative to the gesture's captured base distance" { var state = CanvasState{}; - state.beginPinchZoom(100); + state.beginPinchZoom(100, 1); try std.testing.expectEqual(@as(?u32, 100), state.pinch_base_distance); try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); - state.continuePinchZoom(400, 300, 200); + state.continuePinchZoom(400, 300, 200, 1); try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.001); } @@ -1831,72 +1859,92 @@ test "pinch zoom does not compound across repeated updates" { // rather than chaining relative-to-previous factors, so two updates that // both report a 2x distance land on the same zoom, not zoom^2. var single = CanvasState{}; - single.beginPinchZoom(100); - single.continuePinchZoom(400, 300, 150); + single.beginPinchZoom(100, 1); + single.continuePinchZoom(400, 300, 150, 1); var repeated = CanvasState{}; - repeated.beginPinchZoom(100); - repeated.continuePinchZoom(400, 300, 150); - repeated.continuePinchZoom(400, 300, 150); - repeated.continuePinchZoom(400, 300, 150); + repeated.beginPinchZoom(100, 1); + repeated.continuePinchZoom(400, 300, 150, 1); + repeated.continuePinchZoom(400, 300, 150, 1); + repeated.continuePinchZoom(400, 300, 150, 1); try std.testing.expectApproxEqAbs(single.zoom, repeated.zoom, 0.0001); } test "pinch zoom respects the existing zoomBy clamp range" { var state = CanvasState{}; - state.beginPinchZoom(100); - state.continuePinchZoom(400, 300, 10_000); + state.beginPinchZoom(100, 1); + state.continuePinchZoom(400, 300, 10_000, 1); try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.0001); var shrink = CanvasState{}; - shrink.beginPinchZoom(100); - shrink.continuePinchZoom(400, 300, 1); + shrink.beginPinchZoom(100, 1); + shrink.continuePinchZoom(400, 300, 1, 1); try std.testing.expectApproxEqAbs(@as(f32, 0.55), shrink.zoom, 0.0001); } test "pinch zoom with zero distance never sets a baseline or divides by zero" { var state = CanvasState{}; - state.beginPinchZoom(0); + state.beginPinchZoom(0, 1); try std.testing.expectEqual(@as(?u32, null), state.pinch_base_distance); // Also guards a base captured normally but then fed a zero continuation. - state.beginPinchZoom(100); - state.continuePinchZoom(400, 300, 0); + state.beginPinchZoom(100, 1); + state.continuePinchZoom(400, 300, 0, 1); try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); } test "pinch zoom is a no-op before a base distance has been captured" { var state = CanvasState{}; - state.continuePinchZoom(400, 300, 200); + state.continuePinchZoom(400, 300, 200, 1); try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); } test "ending a pinch gesture clears the baseline for the next gesture" { var state = CanvasState{}; - state.beginPinchZoom(100); - state.continuePinchZoom(400, 300, 150); + state.beginPinchZoom(100, 1); + state.continuePinchZoom(400, 300, 150, 1); state.endPinchZoom(); try std.testing.expectEqual(@as(?u32, null), state.pinch_base_distance); // A stray continuation after end must not resume the stale gesture. const zoom_after_end = state.zoom; - state.continuePinchZoom(400, 300, 400); + state.continuePinchZoom(400, 300, 400, 1); try std.testing.expectApproxEqAbs(zoom_after_end, state.zoom, 0.0001); // A fresh begin starts an independent gesture from the current zoom. - state.beginPinchZoom(100); + state.beginPinchZoom(100, 1); try std.testing.expectApproxEqAbs(zoom_after_end, state.pinch_base_zoom, 0.0001); } test "pinch zoom with a very large OS-reported distance stays finite and clamped" { var state = CanvasState{}; - state.beginPinchZoom(1); - state.continuePinchZoom(400, 300, std.math.maxInt(u32)); + state.beginPinchZoom(1, 1); + state.continuePinchZoom(400, 300, std.math.maxInt(u32), 1); try std.testing.expect(std.math.isFinite(state.zoom)); try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.0001); } +test "pinch continuation with a mismatched context resets instead of zooming" { + // A gesture begun against one destination (surface/project) must not be + // able to keep scaling once the app has moved on to a different one -- + // even though the OS never delivered a GID_END for it (e.g. the user + // switched projects, or the surface flipped to the terminal, mid-pinch). + var state = CanvasState{}; + state.beginPinchZoom(100, 111); + try std.testing.expectEqual(@as(u64, 111), state.pinch_context); + + state.continuePinchZoom(400, 300, 200, 222); + try std.testing.expectApproxEqAbs(@as(f32, 1.0), state.zoom, 0.0001); + try std.testing.expectEqual(@as(?u32, null), state.pinch_base_distance); + + // A fresh begin for the new destination establishes its own baseline + // rather than being blocked by the stale context. + state.beginPinchZoom(100, 222); + state.continuePinchZoom(400, 300, 200, 222); + try std.testing.expectApproxEqAbs(@as(f32, 1.8), state.zoom, 0.001); +} + test "loop card stripe follows loop type rather than lifecycle state" { try std.testing.expect(loopTypeColor("goalBased") != stateColor("failed", false)); try std.testing.expectEqual(@as(u32, 0x00C77DFF), loopTypeColor("composite")); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 1ceeb00e..af0ec6ee 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -110,21 +110,19 @@ pub const GestureConfigResult = struct { last_error: c.DWORD, }; -/// Registers this window for native pinch-zoom (`GID_ZOOM`) gestures and -/// explicitly blocks the other WM_GESTURE classes GraphCode does not yet -/// implement (pan/rotate/two-finger-tap/press-and-tap), so the canvas cannot -/// silently start handling gestures it has no logic for. `SetGestureConfig` -/// documents that a single call cannot mix a `dwID = 0` "all gestures" entry -/// with specific-`dwID` entries, so every entry here targets one specific -/// `dwID` instead (matching the pattern in Microsoft's own multi-gesture -/// configuration examples). +/// Registers this window's opt-in to native pinch-zoom (`GID_ZOOM`) +/// gestures only. Every other WM_GESTURE class is left at its existing +/// default (neither explicitly enabled nor blocked here) -- App.zig's +/// `WM_GESTURE` handler already forwards any non-`GID_ZOOM` message +/// unhandled via `CanvasInput.classifyGesture`, so there is no unimplemented +/// gesture class this app could silently start reacting to; configuring an +/// explicit block for gestures this app has no opinion on would only widen +/// the surface unnecessarily. `SetGestureConfig` documents that a single +/// call cannot mix a `dwID = 0` "all gestures" entry with specific-`dwID` +/// entries, so this uses one specific-`dwID` entry rather than `dwID = 0`. pub fn registerCanvasGestureConfig(hwnd: c.HWND) GestureConfigResult { var configs = [_]c.GESTURECONFIG{ .{ .dwID = c.GID_ZOOM, .dwWant = c.GC_ZOOM, .dwBlock = 0 }, - .{ .dwID = c.GID_PAN, .dwWant = 0, .dwBlock = c.GC_PAN }, - .{ .dwID = c.GID_ROTATE, .dwWant = 0, .dwBlock = c.GC_ROTATE }, - .{ .dwID = c.GID_TWOFINGERTAP, .dwWant = 0, .dwBlock = c.GC_TWOFINGERTAP }, - .{ .dwID = c.GID_PRESSANDTAP, .dwWant = 0, .dwBlock = c.GC_PRESSANDTAP }, }; if (c.SetGestureConfig(hwnd, 0, configs.len, &configs, @sizeOf(c.GESTURECONFIG)) != 0) { return .{ .ok = true, .last_error = 0 }; @@ -625,6 +623,21 @@ test "registerCanvasGestureConfig succeeds with the real gesture array and captu try std.testing.expect(c.GetLastError() != 0); } +// The negative control above bypasses `registerCanvasGestureConfig` entirely +// (it calls the raw Win32 API with a malformed argument), so it only proves +// the OS API itself can fail -- it says nothing about whether this codebase's +// own helper actually surfaces that failure correctly. A `registerCanvasGestureConfig` +// that ignored `SetGestureConfig`'s return value and always reported success +// would still pass the test above. This exercises the production helper's +// own call with a genuinely invalid (never-created) HWND, so only a helper +// that truly captures and returns the real failure/GetLastError can pass it. +test "registerCanvasGestureConfig itself reports failure for a genuinely invalid HWND" { + const bogus_hwnd = Win32.opaquePointerFromInt(c.HWND, 0xdeadbeef); + const result = registerCanvasGestureConfig(bogus_hwnd); + try std.testing.expect(!result.ok); + try std.testing.expect(result.last_error != 0); +} + test "recent folder commands use a dedicated command range" { try std.testing.expect(isRecentFolderCommand(recent_folder_command_base)); try std.testing.expect(isRecentFolderCommand(recent_folder_command_limit)); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 078e3179..bdc48ebe 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -66,7 +66,7 @@ Statuses: | Cross-project global graph | Every open folder as a lane on one canvas | `GraphCanvas.overviewLaneBounds` stacks every open project's lane vertically by accumulated lane height, and a focused test (`GraphCanvas.zig`: "cross-project overview stacks every open folder as its own lane") now proves a second project's lane renders below the first without overlap, is independently hit-testable, and exposes its own per-lane Open/Worktrees targets. The Windows shell toolchain blocker is resolved and the live gate exercises the deterministic single-project overview reliably (see Folder lanes/bands), but the shared UIA fixture still only opens one project, so there is no live capture of two or more simultaneous lanes side by side. Promoting to Validated would require extending the fixture to register a second project before this can move further | Partial | | Folder lanes/bands | Project caption, worktree chip, open/close and folder actions | Overview lanes render distinct Open and Worktrees actions beside the project caption; click routing selects the project or opens scoped worktree inspection. Focused geometry/input coverage passes, and the local Windows shell toolchain blocker is now resolved: `Tools\windows\uia-live-gate.ps1` posts real `WM_LBUTTONDOWN`/`WM_LBUTTONUP` messages at the lane's Open and Worktrees hit-test rects against the live executable and verifies Open routes to the project canvas (synchronized cards render at a new position) and Worktrees opens the scoped inspection view, both confirmed passing across many consecutive live runs. This also uncovered and fixed two real accessibility bugs along the way: `App.zig`'s `.overview` mouse-click switch arm and its `.cycle_attention` action handler were both missing the `syncAccessibility()` call that keeps the live UIA tree in sync with what is rendered, so a lane's Open/Worktrees click previously had no observable effect through the accessibility tree even though the underlying surface did change | Validated | | Notebook grid | Grid pans and zooms with canvas | `GraphCanvas.drawGrid` derives its cell size and offset from the exact same `CanvasState.zoom`/`pan_x`/`pan_y` fields consumed by `overviewCardBounds`, `overviewLaneBounds`, and the loop-card geometry, so the same focused pan/zoom coverage (`GraphCanvas.zig`: "canvas hit testing follows pan and zoom", "overview and quick chat geometry applies pan and zoom consistently") indirectly proves the grid cannot desynchronize from the content it underlays. The Windows shell toolchain blocker is resolved, but the grid itself is a 1px `0x00161815` GDI line pattern with no UIA surface of its own, and this session did not add a live pixel-scan assertion (the connector-handle and attention-rail blocks already show this pattern is feasible) to directly confirm grid line spacing changes with zoom in the running executable. Left Partial rather than claim live evidence that was not actually captured | Partial | -| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered wheel zoom remain intact and unchanged, with the same focused regression coverage as before (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). Native touchscreen pinch-zoom is now implemented and routed through the main window: `MainWindow.zig` registers a `GID_ZOOM`-only `SetGestureConfig` (explicitly blocking `GID_PAN`/`GID_ROTATE`/`GID_TWOFINGERTAP`/`GID_PRESSANDTAP`, which remain unimplemented), with a real registration test against a genuine `HWND` plus a negative (bad `cbSize`) control proving the `GetLastError()` failure path actually fires; `App.zig`'s `WM_GESTURE` case decodes `GID_ZOOM` via a new pure `CanvasInput.classifyGesture` decision table and applies `GraphCanvas.zig`'s new `beginPinchZoom`/`continuePinchZoom`/`endPinchZoom`, an absolute-target-from-the-gesture's-base pinch model (not per-message compounding) with a full non-compounding/clamp/zero-distance/lifecycle test matrix; every non-zoom or out-of-canvas message is forwarded unmodified per the documented `WM_GESTURE`/`CloseGestureInfoHandle` ownership contract. This is source-mapped, automated routing/unit evidence, not live hardware-input evidence: this session held no live UIA capture slot this pass, so pinch is proven by code mapping and 283 passing deterministic tests (including a real, unmocked `SetGestureConfig` round trip), not an actual touchscreen or Precision Touchpad device. Per Microsoft's documented default, Precision Touchpad pinch on a classic Win32 window is emulated as synthetic Ctrl+`WM_MOUSEWHEEL`, not delivered as `WM_GESTURE`, so this implementation targets true touchscreen digitizers specifically; Precision Touchpad pinch behavior is not separately implemented or verified here. Touch-driven pan (`GID_PAN`) remains a genuine unimplemented gap, intentionally blocked rather than half-handled. Left Partial: touchscreen pinch has real source and automated-test coverage but no live device evidence, and touch pan is still unimplemented | Partial | +| Pan and anchored zoom | Pan, pointer-centered wheel/pinch zoom | Mouse pan and pointer-centered wheel zoom remain intact and unchanged, with the same focused regression coverage as before (`GraphCanvas.zig`: "canvas zoom keeps the graph point beneath the cursor stable", "canvas wheel zoom scales high-resolution trackpad deltas"). Native touchscreen pinch-zoom is now implemented and routed through the main window: `MainWindow.zig` registers only `GID_ZOOM` via `SetGestureConfig`, leaving every other gesture class (`GID_PAN`/`GID_ROTATE`/`GID_TWOFINGERTAP`/`GID_PRESSANDTAP`) at its existing OS default rather than explicitly blocking gestures this app has no opinion on, with a real registration test against a genuine `HWND` plus two independent negative controls — a malformed native `SetGestureConfig` call and a genuinely invalid `HWND` passed straight through the production `registerCanvasGestureConfig` helper itself — proving the helper's own `GetLastError()` capture path actually fires, not just the raw Win32 API. `App.zig`'s `WM_GESTURE` case decodes `GID_ZOOM` via a pure `CanvasInput.classifyGesture` decision table (including a distinct outcome for a gesture delivered as a single combined begin+end message, so it can never reuse a stale prior gesture's baseline), requires the active surface to actually render the graph canvas (not just the wheel-region rectangle, which the terminal workspace surface shares), and applies `GraphCanvas.zig`'s `beginPinchZoom`/`continuePinchZoom`/`endPinchZoom` against a per-gesture identity hash of surface+project so a same-region destination change mid-gesture (surface switch, or project switch while still graph-capable) resets the baseline instead of silently continuing to scale the wrong canvas; a failed `GetClientRect` is guarded and treated as unhandled rather than classified against an undefined rect, and the gesture handle is closed before any call that could re-enter the message loop. This is source-mapped, automated routing/unit evidence, not live hardware-input evidence: this session held no live UIA capture slot this pass, so pinch is proven by code mapping and a full deterministic test suite (non-compounding/clamp/zero-distance/lifecycle/context-mismatch/routing matrix, verified with genuine temporary-regression RED/GREEN passes against the production helpers, not GREEN-only), not an actual touchscreen or Precision Touchpad device. Per Microsoft's documented default, Precision Touchpad pinch on a classic Win32 window is emulated as synthetic Ctrl+`WM_MOUSEWHEEL`, not delivered as `WM_GESTURE`, so this implementation targets true touchscreen digitizers specifically; Precision Touchpad pinch behavior is not separately implemented or verified here. Touch-driven pan (`GID_PAN`) remains a genuine unimplemented gap: this app forwards it unhandled rather than half-handling it, but does not explicitly block it either. Left Partial: touchscreen pinch has real source and automated-test coverage but no live device evidence, and touch pan is still unimplemented | Partial | | Zoom controls | Zoom out, actual size, zoom in, fit with shortcuts/help | Visible bottom-right controls provide zoom out, percentage/actual size, zoom in, and fit. A visible shortcut/help line accompanies the controls; Ctrl+-, Ctrl+0, Ctrl+=, and Ctrl+9 remain represented in the View menu. The Windows shell toolchain blocker is resolved and `Tools\windows\uia-live-gate.ps1` now runs against the live executable: it locates the `zoom-out`, `actual-size`, `zoom-in`, and `fit-canvas` UIA fragments, requires non-empty bounds, resolves each `InvokePattern`, and then actually invokes zoom-in, actual-size, zoom-out, and fit-canvas in sequence against the running shell, all of which completed without error across many consecutive live runs | Validated | | New Loop canvas button | Visible top-right add action | A live-validated top-right New Loop button is now present on non-empty project canvases and remains centered in the empty state | Validated | | Composite breadcrumb | Current group, project back action, loop count | Open Group swaps the project canvas to the authoritative nested graph, renders its cards and edges through the normal interactive canvas, and exposes a clickable `Project > Group` breadcrumb with loop count that restores and reselects the parent. Nested graph selection survives daemon refreshes, and the populated live UIA gate invokes Open Group, verifies both nested cards, and invokes the bounded Back breadcrumb to restore the parent canvas | Validated | From 7696e99a8881732a673154818288d3d770c94deb Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 15:10:26 -0700 Subject: [PATCH 3/3] graph: address round-3 coordinator feedback on pinch-zoom diagnostics/comments - App.zig: make the gesture-registration failure diagnostic durably observable. Extract formatGestureRegistrationFailure(buf, last_error) as the single production formatter, call it once, and log the result via std.log.warn in addition to the existing transient setStatus() call, so support/CI stderr retains the message (with the captured Win32 error code) even if a later startup setStatus() call overwrites the on-screen status line. Added a unit test asserting the exact formatted string (including the too-small-buffer fallback), and updated the existing durability test to build its expected string from the same production formatter instead of a hand-duplicated literal. - App.zig: trimmed the continue_zoom handle-close comment, which asserted that WM_GESTURE reentrancy specifically "already closed" another handle -- reentry alone doesn't establish that. Now simply states the handle must be released before syncAccessibility()/ InvalidateRect, which can pump messages. - GraphCanvas.zig: reworded continuePinchZoom's continuation-reset doc comment. It previously said the caller should begin a fresh pinch "on the next message"; it now says the reset is a no-op until a genuinely NEW beginPinchZoom establishes a fresh baseline, without implying anything about which message that arrives on. Verified after these changes: - zig test src/App.zig src/AccessibilityProvider.cpp ...: 289/289 pass - zig test src/GraphCanvas.zig ...: 109/109 pass - Tools/windows/Tests/WindowsShell.Tests.ps1 (42 wired source files, anti-drift structural guard included): PASS - zig build -Dwinghostty-dir=...: succeeds, graphcode-windows.exe produced No push. Awaiting coordinator clearance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/App.zig | 66 +++++++++++++++++++-------- graphcode-windows/src/GraphCanvas.zig | 5 +- 2 files changed, 50 insertions(+), 21 deletions(-) diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index 4f381be5..b9f63915 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -100,6 +100,19 @@ fn gestureInCanvas(surface: GraphCanvas.Surface, mapped: ?c.POINT, bounds: Input return wheelRegion(point.x, point.y, bounds, controls) == .canvas; } +/// Formats the gesture-registration failure diagnostic. Pulled out as a pure +/// function (rather than inlined at the one `std.log`/`setStatus` call site) +/// so the exact production message text is directly unit-testable without +/// needing to run `App.run()`'s full startup sequence or capture `std.log` +/// output. +fn formatGestureRegistrationFailure(buf: []u8, last_error: c.DWORD) []const u8 { + return std.fmt.bufPrint( + buf, + "Touch pinch-zoom unavailable (gesture config error {d})", + .{last_error}, + ) catch "Touch pinch-zoom unavailable"; +} + fn isResolvedLoopState(state: []const u8) bool { return std.mem.eql(u8, state, "succeeded") or std.mem.eql(u8, state, "failed") or @@ -434,20 +447,18 @@ pub const App = struct { // several later calls in this same startup sequence (daemon // status, accessibility attach, product-settings/canvas-layout/ // sidebar-store load failures) call setStatus themselves and can - // overwrite this message before the window is ever shown. The - // durable record of this outcome is `window.gesture_config_registered` - // / `window.gesture_config_last_error` themselves: they are set - // once here and never cleared or overwritten by any later - // setStatus call, so any caller needing the real outcome (tests, - // future diagnostics UI, support tooling) can read them directly - // off the window rather than relying on this status line still - // being visible. + // overwrite this message before the window is ever shown. Two + // things make this observable regardless: `std.log.warn` below + // writes the same message (with the captured Win32 error code) + // to stderr, so support/CI logs retain it even if the on-screen + // status line gets clobbered; and `window.gesture_config_registered` + // / `window.gesture_config_last_error` are the durable, never- + // overwritten record of the outcome for any caller (tests, + // future diagnostics UI, support tooling) that reads the window + // directly instead of the transient status text. var buf: [96]u8 = undefined; - const message = std.fmt.bufPrint( - &buf, - "Touch pinch-zoom unavailable (gesture config error {d})", - .{self.window.gesture_config_last_error}, - ) catch "Touch pinch-zoom unavailable"; + const message = formatGestureRegistrationFailure(&buf, self.window.gesture_config_last_error); + std.log.warn("{s}", .{message}); self.setStatus(message); } // Seed the real startup DPI now that a window handle exists, rather than @@ -6199,11 +6210,11 @@ fn onWindowMessage( }, .continue_zoom => { if (mapped) |point| app.canvas.continuePinchZoom(point.x, point.y, distance, pinch_context); - // Close the handle before any re-entrant call - // (syncAccessibility/InvalidateRect can pump messages); - // holding it open across those risks a double-close or - // a use of a handle another WM_GESTURE re-entry already - // closed. + // Release the owned handle before syncAccessibility()/ + // InvalidateRect, which can pump messages -- this + // window's WM_GESTURE handling should never still be + // holding a handle open while other message handling + // runs. _ = c.CloseGestureInfoHandle(gesture_handle); app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); @@ -6612,7 +6623,8 @@ test "gesture registration outcome survives later startup setStatus calls" { app.window.gesture_config_registered = false; app.window.gesture_config_last_error = 1223; - app.setStatus("Touch pinch-zoom unavailable (gesture config error 1223)"); + var buf: [96]u8 = undefined; + app.setStatus(formatGestureRegistrationFailure(&buf, app.window.gesture_config_last_error)); // Simulate the later startup calls that are known to overwrite status. app.setStatus("Connecting to daemon..."); app.setStatus("Accessibility provider unavailable"); @@ -6625,6 +6637,22 @@ test "gesture registration outcome survives later startup setStatus calls" { try std.testing.expectEqual(@as(c.DWORD, 1223), app.window.gesture_config_last_error); } +test "gesture registration failure formats the exact production diagnostic text" { + // This is the same formatter run() actually calls for both the + // std.log.warn line and the transient setStatus() line, so this proves + // the real observable failure output, not a hand-duplicated string. + var buf: [96]u8 = undefined; + const message = formatGestureRegistrationFailure(&buf, 1223); + try std.testing.expectEqualStrings("Touch pinch-zoom unavailable (gesture config error 1223)", message); + + // A buffer too small to hold the formatted error code falls back to the + // fixed, always-fitting message rather than silently truncating or + // erroring. + var tiny_buf: [4]u8 = undefined; + const fallback = formatGestureRegistrationFailure(&tiny_buf, 1223); + try std.testing.expectEqualStrings("Touch pinch-zoom unavailable", fallback); +} + test "header UIA identities hash to distinct payloads" { // The native header bar's four chips (attention, worktree notice, jump, // contextual loop-panel toggle) are dispatched by matching a hashed UIA diff --git a/graphcode-windows/src/GraphCanvas.zig b/graphcode-windows/src/GraphCanvas.zig index 6f0a8617..7dbb7f79 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -296,8 +296,9 @@ pub const CanvasState = struct { /// when the context no longer matches, this resets the baseline instead /// of applying a zoom update, so a gesture that began on one graph can /// never scale a different one it happened to still be "in progress" - /// over. The caller (App.zig) is expected to follow a reset with a fresh - /// `beginPinchZoom` on the next message if the point is still in-canvas. + /// over. After a reset, this call remains a no-op (see the base-distance + /// guard above) until a genuinely NEW `beginPinchZoom` establishes a + /// fresh baseline -- it does not resume on its own. pub fn continuePinchZoom(self: *CanvasState, x: i32, y: i32, distance: u32, context: u64) void { if (context != self.pinch_context) { self.pinch_base_distance = null;