diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index a523f65c..51c84603 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -226,6 +226,26 @@ the selected loop in the graph rather than opening its terminal. Native forms keep their own text editing, Tab navigation, and acceptance/cancellation; Enter in an already-open menu activates its highlighted item. +Help > **Keyboard Shortcuts** lists the remaining keyboard routes: sending a +loop, context-sensitive `Ctrl+E`, project or node identity navigation, +worktree-row selection, terminal focus, clone cancellation, terminal paste, +focused-toolbar navigation, and jump-palette navigation. The Loop, View, and +File menu items also show `Ctrl+Shift+G` for Show in Graph, `Ctrl+R` for +Reconnect, and `Ctrl+Shift+P` for Project Worktree Policy. `Ctrl+P` remains +unlisted because it duplicates the `Ctrl+J` jump-palette route; +`Ctrl+Shift+I` remains unlisted because it duplicates the `Ctrl+Shift+W` +Worktrees route. `Ctrl+Shift+C` is shown only for Clone Repository: the +terminal context also uses it to copy a selection, so Help does not present +the context-dependent collision as a second shortcut. + +Canvas and pointer actions still have no visible gesture hints: clicking +selects/opens canvas items; dragging blank canvas pans, dragging a node moves +it, and dragging a connector to another node creates an edge; wheel and +touchscreen pinch zoom the canvas; right-click opens item-specific context +menus; and dragging a top-level sidebar loop reorders it. Closing this gap +requires hints in the owning `GraphCanvas.zig` and `Sidebar.zig` rendering +surfaces, both outside this change's scope. + 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 diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 06bb2e40..ad730c64 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -521,6 +521,7 @@ pub fn installMenu(hwnd: c.HWND) !void { const terminal = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const view = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const help = c.CreatePopupMenu() orelse return error.MenuCreationFailed; + const discovery = c.CreatePopupMenu() orelse return error.MenuCreationFailed; const workspace = c.CreatePopupMenu() orelse return error.MenuCreationFailed; append(add_folder, "Open Folder...\tCtrl+O", @intFromEnum(Command.open_folder)); @@ -537,7 +538,7 @@ pub fn installMenu(hwnd: c.HWND) !void { append(file, "Worktrees...\tCtrl+Shift+W", @intFromEnum(Command.worktrees)); append(file, "Reclaim Selected Worktrees...", @intFromEnum(Command.reclaim_worktrees)); append(file, "Reveal Selected Worktree in Explorer\tCtrl+Shift+E", @intFromEnum(Command.reveal_worktree)); - append(file, "Project Worktree Policy...", @intFromEnum(Command.edit_worktree_policy)); + append(file, "Project Worktree Policy...\tCtrl+Shift+P", @intFromEnum(Command.edit_worktree_policy)); append(file, "Save Worktree Policy\tCtrl+Shift+S", @intFromEnum(Command.save_worktree_policy)); separator(file); append(file, "Exit", @intFromEnum(Command.exit)); @@ -550,7 +551,7 @@ pub fn installMenu(hwnd: c.HWND) !void { separator(loop); append(loop, "New Loop...\tCtrl+N", @intFromEnum(Command.create_node)); append(loop, "Create Edge...", @intFromEnum(Command.create_edge)); - append(loop, "Show in Graph", @intFromEnum(Command.show_graph)); + append(loop, "Show in Graph\tCtrl+Shift+G", @intFromEnum(Command.show_graph)); append(loop, "Stop Loop\tCtrl+S", @intFromEnum(Command.stop_loop)); append(terminal, "New Tab\tCtrl+T", @intFromEnum(Command.new_tab)); @@ -575,13 +576,24 @@ pub fn installMenu(hwnd: c.HWND) !void { append(view, "Zoom In\tCtrl+=", @intFromEnum(Command.zoom_in)); append(view, "Fit Canvas\tCtrl+9", @intFromEnum(Command.fit_canvas)); separator(view); - append(view, "Reconnect", @intFromEnum(Command.reconnect)); + append(view, "Reconnect\tCtrl+R", @intFromEnum(Command.reconnect)); append(view, "Settings...\tCtrl+Shift+,", @intFromEnum(Command.product_settings)); append(view, "Advanced Connection Settings...\tCtrl+,", @intFromEnum(Command.settings)); append(help, "GraphCode Basics\tF1", @intFromEnum(Command.onboarding)); append(help, "Check for Updates...", @intFromEnum(Command.check_updates)); separator(help); append(help, "About GraphCode", @intFromEnum(Command.about)); + appendInfo(discovery, "Send selected loop\tCtrl+M"); + appendInfo(discovery, "Rename selected loop / edit selected edge\tCtrl+E"); + appendInfo(discovery, "Navigate by project or node\tCtrl+Up / Ctrl+Down"); + appendInfo(discovery, "Select a worktree row\tUp / Down"); + appendInfo(discovery, "Focus Terminal A\t1"); + appendInfo(discovery, "Focus Terminal B\t2"); + appendInfo(discovery, "Cancel clone\tCtrl+Shift+X"); + appendInfo(discovery, "Paste terminal text\tCtrl+Shift+V"); + appendInfo(discovery, "Focused toolbar: Tab / arrows / Home / End move; Enter / Space activate; Esc exits"); + appendInfo(discovery, "Jump palette: Up / Down navigate; Enter opens the selected loop"); + appendPopup(help, "Keyboard Shortcuts", discovery); append(workspace, "New Workspace...", @intFromEnum(Command.workspace_new)); append(workspace, "Manage Workspaces...", @intFromEnum(Command.workspace_manage)); @@ -754,6 +766,10 @@ fn append(menu: c.HMENU, text: []const u8, id: usize) void { appendEnabled(menu, text, id, true); } +fn appendInfo(menu: c.HMENU, text: []const u8) void { + appendEnabled(menu, text, 0, false); +} + fn appendEnabled(menu: c.HMENU, text: []const u8, id: usize, enabled: bool) void { const wide = toWideZ(std.heap.c_allocator, text) catch return; defer std.heap.c_allocator.free(wide); @@ -888,6 +904,63 @@ test "native menu exposes the parity command groups" { try std.testing.expectEqual(@as(?Command, null), commandFromId(9999)); } +test "main and help menus expose missing shortcuts without canvas gesture claims" { + const hwnd = try hiddenWorkspaceTestWindow(); + defer _ = c.DestroyWindow(hwnd); + try installMenu(hwnd); + const root = c.GetMenu(hwnd); + const file = c.GetSubMenu(root, 0); + const loop = c.GetSubMenu(root, 1); + const view = c.GetSubMenu(root, 4); + const help = c.GetSubMenu(root, 5); + + const actual_labels = [_]struct { menu: c.HMENU, command: Command, expected: []const u8 }{ + .{ .menu = file, .command = .edit_worktree_policy, .expected = "Project Worktree Policy...\tCtrl+Shift+P" }, + .{ .menu = loop, .command = .show_graph, .expected = "Show in Graph\tCtrl+Shift+G" }, + .{ .menu = view, .command = .reconnect, .expected = "Reconnect\tCtrl+R" }, + }; + for (actual_labels) |item| { + var label: [128]u16 = undefined; + const length = c.GetMenuStringW(item.menu, @intFromEnum(item.command), &label, label.len, c.MF_BYCOMMAND); + try std.testing.expect(length > 0 and length < label.len - 1); + const actual = try std.unicode.utf16LeToUtf8Alloc(std.testing.allocator, label[0..@intCast(length)]); + defer std.testing.allocator.free(actual); + try std.testing.expectEqualStrings(item.expected, actual); + } + + const guide = c.GetSubMenu(help, 4); + try std.testing.expect(guide != null); + const expected = [_][]const u8{ + "Send selected loop\tCtrl+M", + "Rename selected loop / edit selected edge\tCtrl+E", + "Navigate by project or node\tCtrl+Up / Ctrl+Down", + "Select a worktree row\tUp / Down", + "Focus Terminal A\t1", + "Focus Terminal B\t2", + "Cancel clone\tCtrl+Shift+X", + "Paste terminal text\tCtrl+Shift+V", + "Focused toolbar: Tab / arrows / Home / End move; Enter / Space activate; Esc exits", + "Jump palette: Up / Down navigate; Enter opens the selected loop", + }; + try std.testing.expectEqual(@as(c_int, expected.len), c.GetMenuItemCount(guide)); + var guide_title: [128]u16 = undefined; + const title_length = c.GetMenuStringW(help, 4, &guide_title, guide_title.len, c.MF_BYPOSITION); + try std.testing.expect(title_length > 0 and title_length < guide_title.len - 1); + const actual_title = try std.unicode.utf16LeToUtf8Alloc(std.testing.allocator, guide_title[0..@intCast(title_length)]); + defer std.testing.allocator.free(actual_title); + try std.testing.expectEqualStrings("Keyboard Shortcuts", actual_title); + for (expected, 0..) |expected_label, expected_index| { + const index: c.UINT = @intCast(expected_index); + var label: [128]u16 = undefined; + const length = c.GetMenuStringW(guide, index, &label, label.len, c.MF_BYPOSITION); + try std.testing.expect(length > 0 and length < label.len - 1); + const actual = try std.unicode.utf16LeToUtf8Alloc(std.testing.allocator, label[0..@intCast(length)]); + defer std.testing.allocator.free(actual); + try std.testing.expectEqualStrings(expected_label, actual); + try std.testing.expect(c.GetMenuState(guide, index, c.MF_BYPOSITION) & c.MF_GRAYED != 0); + } +} + const NativeMenuDispatchTest = struct { const name = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeNativeF10DispatchTest"); var menu_commands: usize = 0; diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 94bf5f49..5acf718d 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -181,7 +181,7 @@ case proves ownership only. All existing Partial rows remain Partial. |---|---|---|---| | UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. The Workspace menu's New, Rename, and Delete lifecycle commands and dynamic workspace-switch rows are now part of that native provider tree instead of existing only in `Accessibility.zig`; the Zig contract was reduced to the native fixed table, and a pinned-Zig executable test now fails when any contract id lacks an exact native fixed-table id. The live gate now also traverses the Workspace menu in RawView and ControlView, checks its fixed lifecycle names and InvokePattern exposure, and verifies at least one `workspace-switch-*` row under the same parent. `TerminalSurface.zig` retains reported selection and cell metrics, but callback metadata does not prove applied terminal selection. Exact-pin Winghostty `f5abc059` source already creates an embedded child-HWND Text/Text2 provider and routes `WM_GETOBJECT` to it; the earlier missing-provider premise was inaccurate. GraphCode now feeds that provider owned UTF-8 from its unchanged rendered-cell grid with independent UTF-16 length/cursor offsets, rather than a rolling raw VT byte tail. Focused producer tests exercise overwritten text, chunk splits, reset/rollover, Unicode scalar representation, bounds, allocation/lifetime and injected publication failures without native APIs. This is fixed current-grid content, not transcript/scrollback or full Unicode terminal rendering. Render/text publication remains best-effort: failures are reported and the provider may retain its last successful, no-longer-current snapshot. Native terminal text results, applied selection, visible caret/geometry, range/HRESULT conformance, retained-generation behavior, atomicity, daemon-to-model UIA integration, and remaining dialogs still need separate evidence or implementation; keyboard discovery and HelpText residuals are not closed | Partial | | Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated | -| Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. The shared node popup no longer advertises Enter for Open Terminal or Ctrl+E for Edit Details: root Enter has no standalone open action, and Ctrl+E renames a selected loop or edits a selected edge. Two executable contracts failed on the old captions and pass on the corrected ones through the actual menu builder and `GetMenuStringW`, preserving command IDs, mappings, order, and enabled states across ordinary/resolved/composite/unwired targets. They inspect unattached menu handles without creating windows or displaying popups; mapper checks preserve toolbar Enter activation and existing Ctrl+E/F2 routes. The Windows README now distinguishes root, terminal, and dialog contexts and corrects toggle/chat-delete bindings. Pure mapper regressions cover Ctrl+Shift+OEM/legacy ASCII comma selecting product Settings, plain Ctrl+comma retaining Advanced Connection Settings, and unmodified/unrelated keys. An unattached native accelerator-table test checks the matching documented product binding and the unchanged original 15 bindings/order; this is not physical keyboard, focus, or dialog-opening evidence. Some context-only actions and canvas gestures still lack visible hints. No live keyboard or macOS runtime walkthrough was performed; remaining context-only/gesture discovery, other caption/routing discrepancies, and keyboard popup access still need evidence | Partial | +| Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | The Loop, View, and File menus label Show in Graph (Ctrl+Shift+G), Reconnect (Ctrl+R), and Project Worktree Policy (Ctrl+Shift+P); Help > Keyboard Shortcuts lists remaining useful mapped routes (Ctrl+M, context-sensitive Ctrl+E, Ctrl+Up/Down, worktree Up/Down, terminal focus 1/2, Ctrl+Shift+X, Ctrl+Shift+V) and toolbar/palette navigation. Context menus retain their applicable F2, Delete, Ctrl+S, Ctrl+N, and Quick Chat labels. No gesture hints are advertised. Canvas/pointer discovery remains incomplete: canvas click-to-select/open, blank-canvas drag-to-pan, node drag-to-move, connector drag-to-create-edge, wheel/pinch zoom, right-click context-menu access, and sidebar root-loop drag-to-reorder still lack visible gesture hints. Closing the canvas gesture gap requires rendering in the canvas-owning `GraphCanvas.zig`; sidebar reorder guidance belongs to excluded `Sidebar.zig`. Both are outside this PR's scope. The hidden-window regression inspects the production HMENU labels and verifies keyboard Help content has no gesture rows. RED: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter 'main and help menus expose missing shortcuts without canvas gesture claims'` -> failed because 10 keyboard-only rows were expected but the Help menu contained 18 rows, including gesture claims. GREEN: same focused pinned-Zig command after the change -> 1 test passed. REGRESSION: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include"` -> all 48 MainWindow and imported InputRouter tests passed; the 18-entry accelerator assertion and original 15-binding order remain unchanged. Ctrl+P duplicates Ctrl+J; Ctrl+Shift+I duplicates Ctrl+Shift+W; Ctrl+Shift+C conflicts with terminal copy and remains advertised only for Clone Repository. These are menu/source tests, not physical keyboard, touch-device, live popup-access, or macOS runtime evidence; the row remains Partial | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Fixed a real terminal input gap: Winghostty reports preedit and committed result text through `on_ime_update`, but `TerminalSurface.onImeUpdate` discarded both; it now ignores preedit and enqueues only committed UTF-8 through the same bounded terminal input queue as ordinary text. A focused executable test invokes that production callback and proves uncommitted text is absent while committed Japanese text reaches the correct surface queue. `MainWindow.Window.dispatchMessage` executable tests create a real Unicode `EDIT` control and observe `WM_IME_STARTCOMPOSITION` / `WM_IME_COMPOSITION` / `WM_IME_ENDCOMPOSITION` at its native window procedure, then activate US-International and French layouts and exercise the production `TranslateMessage`/`DispatchMessageW` path with physical scan codes, observing a real dead-key `WM_DEADCHAR` followed by `é` and French scan-code mapping to `a`; unavailable layouts are explicitly logged and skipped rather than silently weakened. These are hidden-window Win32 runtime tests, not a foreground UIA walkthrough or validation with a live IME candidate window, and terminal preedit rendering remains provider-owned, so the row remains Partial | Partial | | Clipboard/selection | Terminal copy/paste and mouse selection | Terminal-context Ctrl+Shift+C copies the active surface's reported accessibility selection range through Winghostty into Windows `CF_UNICODETEXT`; Ctrl+Shift+V reads only `CF_UNICODETEXT`, converts UTF-16 to UTF-8, and calls Winghostty's paste validator and paste entry point with `allow_unsafe=0`. Clipboard conversion tests preserve Unicode, CRLF, LF, and empty text; app routing tests prove the shortcuts are terminal-context-only and preserve the existing global Ctrl+Shift+C Clone Repository route. Unsafe multiline/control-containing pastes are rejected with a status message rather than forced through, and no confirmation UI is provided. Clipboard Win32 calls, mouse-driven selection, actual rendered selection extraction, provider callback notifications, and end-to-end paste/copy on a live desktop were not exercised here; this row remains Partial | Partial | | Per-monitor DPI | Layout and controls scale correctly across monitors | The process now declares real per-monitor-v2 DPI awareness at startup (`Win32.enablePerMonitorDpiAwareness()`, called before any window is created) instead of relying on system-DPI bitmap stretching; without this, Windows never delivers real per-monitor `WM_DPICHANGED` data to a DPI-unaware process. `App.zig` seeds the real startup DPI via `GetDpiForWindow` immediately after window creation (rather than assuming 96 DPI/100% until the first monitor move) and forwards every live `WM_DPICHANGED` to `TerminalWorkspace.Workspace.setDpi()`. Previously, `TerminalSurface.zig`'s `onDpiChanged` callback silently discarded the `dpi`/`scale` winghostty reported, and every terminal surface was created with `font_scale` hardcoded to `1.0`, so terminal text never actually rescaled on a DPI change or on a monitor with non-100% DPI at launch. `Workspace.setDpi()` now propagates the real runtime DPI to every live surface via winghostty's own `winghostty_surface_notify_dpi_changed` + `winghostty_surface_set_font_scale` (the two operations the provider actually exposes for this), and `surfaceOptions()` seeds new surfaces' `font_scale` from the workspace's last-known DPI instead of a fixed `1.0`. Deliberately does not also pre-scale `options.input.cell_width`/`cell_height` (kept at their 96-DPI logical baseline) so the DPI ratio is applied exactly once, through `font_scale`, avoiding double scaling. `onMetricsChanged`/`onAccessibilitySelection`, previously also fully discarded, now record the host's reported cell metrics and terminal text-selection range per surface instead of losing them. Verified with `zig build` (full app, pinned Zig 0.15.2 against the exact pinned Winghostty provider) and `zig test src/TerminalSurface.zig` (new `Dpi.fontScale` unit test plus all 13 pre-existing tests, 14/14). No live multi-monitor walkthrough was recorded (this environment has no interactive multi-DPI desktop), so this remains Partial pending that end-to-end evidence | Partial |