From 29ba387e402e2efd299d0298d45f509467ebf414 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Thu, 24 Sep 2026 21:00:40 -0700 Subject: [PATCH 1/2] Correct Windows toolbar destination and keyboard behavior Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/WindowsShell.Tests.ps1 | 2 +- graphcode-windows/README.md | 14 +- graphcode-windows/src/Accessibility.zig | 13 + .../src/AccessibilityProvider.cpp | 94 +++- graphcode-windows/src/App.zig | 406 ++++++++++++++---- graphcode-windows/src/GraphCanvas.zig | 300 +++++++++---- graphcode-windows/src/InputRouter.zig | 40 ++ graphcode-windows/src/JumpPalette.zig | 33 ++ graphcode-windows/src/MainWindow.zig | 180 ++++++++ investigation/ui-parity-matrix.md | 4 +- 10 files changed, 912 insertions(+), 174 deletions(-) diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 9420f680..b4c6face 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -404,7 +404,7 @@ Invoke-Native "Jump palette executable tests" { $include = Join-Path $winghosttyRoot "include" Push-Location $shellRoot try { - & $zig test src\JumpPalette.zig -target x86_64-windows-msvc -lc -luser32 "-I$include" + & $zig test src\JumpPalette.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 "-I$include" } finally { Pop-Location } } Invoke-Native "Onboarding executable tests" { diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index e72b0ca5..360477ae 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -18,8 +18,8 @@ not a synthetic terminal proof. The graph surface also provides native Win32 create/edit forms for nodes and edges, a settings dialog, context menus, and keyboard-accessible actions: -`Ctrl+N` creates a node, `Ctrl+E` edits the selected node, `Ctrl+J` advances -selection, and `Ctrl+,` opens settings. Mutations are sent as correlated v2 +`Ctrl+N` creates a node, `Ctrl+E` edits the selected node, `Ctrl+J` opens the +jump palette (also `Ctrl+P`), and `Ctrl+,` opens settings. Mutations are sent as correlated v2 daemon requests; daemon refusals remain visible as explicit status errors. The shell exposes a native File/Loop/Terminal/View/Help menu bar. Menu items @@ -28,6 +28,16 @@ actions use the Windows `IFileOpenDialog` folder picker. The no-project state also presents accessible native buttons for opening a folder or the global overview; recent projects remain selectable in the sidebar. +`F6` (or View > Focus Window Toolbar) enters the window toolbar; `Shift+F6` +enters at its last visible control. Within the toolbar, `Tab`/`Shift+Tab` and +Left/Right move between controls, Home/End select the first/last control, and +Enter/Space activate it. `F6`, `Shift+F6`, or Escape leave the toolbar and restore +the still-visible app-owned focus target. Outside the toolbar, Tab/Shift+Tab keep +their loop-navigation behavior and Ctrl+Tab still advances attention selection. +The header's loop-panel button is available only in a loop workspace with +supported detail content (connections or metric history); it collapses/expands +the detail rail without navigating away from the workspace. + Repository ingress covers four sources: a local folder, an HTTPS clone, an SSH remote (`Ctrl+Shift+R`), and a GitHub Codespace (`Ctrl+Shift+K`). The codespace sheet asks the GitHub CLI for the account's codespaces, validates the chosen diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index c2ad180e..5b236264 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -28,6 +28,7 @@ extern fn gc_uia_release(provider: *NativeProvider) void; extern fn gc_uia_get_object(hwnd: c.HWND, wparam: c.WPARAM, lparam: c.LPARAM, provider: *NativeProvider) c.LRESULT; extern fn gc_uia_set_status(provider: *NativeProvider, status: [*:0]const u8) c.HRESULT; extern fn gc_uia_set_canvas_bounds(provider: *NativeProvider, left: c_int, top: c_int, right: c_int, bottom: c_int) c.HRESULT; +extern fn gc_uia_set_header_focus(provider: *NativeProvider, identity: ?[*:0]const u8) c.HRESULT; extern fn gc_uia_update( provider: *NativeProvider, status: [*:0]const u8, @@ -86,6 +87,7 @@ pub const uia_workspace_rename_command: usize = 28; pub const uia_workspace_delete_command: usize = 29; pub const uia_dynamic_invoke_tag: usize = 0x8000000000000000; pub const uia_dynamic_invoke_mask: usize = 0xC000000000000000; +pub const wm_header_focus: u32 = 0x8000 + 46; pub fn worktreeIdentityPayload(path: []const u8) usize { var hash: u64 = 1469598103934665603; @@ -247,6 +249,17 @@ pub const Provider = struct { defer self.allocator.free(status_z); _ = gc_uia_set_status(native, status_z.ptr); } + pub fn syncHeaderFocus(self: *Provider, identity: ?[]const u8) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + const owned = if (identity) |value| self.allocator.dupeZ(u8, value) catch { + std.debug.print("Unable to allocate UIA header focus identity\n", .{}); + return; + } else null; + defer if (owned) |value| self.allocator.free(value); + const result = gc_uia_set_header_focus(native, if (owned) |value| value.ptr else null); + if (result < 0) std.debug.print("Unable to synchronize UIA header focus: {d}\n", .{result}); + } /// Reports the real, current client-relative rect of the rendered canvas /// so the "graph" fixed UIA element (id 4) exposes accurate /// BoundingRectangle geometry for automation and testing, instead of a diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 4effc613..bc6a33f5 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -19,6 +19,22 @@ constexpr WPARAM kSelectionCommandTag = 0xc000000000000000ULL; constexpr WPARAM kDynamicInvokeTag = 0x8000000000000000ULL; constexpr WPARAM kSelectionOperationMask = 0x3000000000000000ULL; constexpr int kSelectionOperationShift = 60; +constexpr UINT kHeaderFocusMessage = WM_APP + 46; + +static bool isHeader(const std::string &identity) { + return identity == "header-attention:needs-you" || + identity == "header-worktree:worktrees" || + identity == "header-jump:jump" || + identity == "header-toggle-panel:control"; +} + +static bool hasHeaderNativeFocus(HWND hwnd) { + GUITHREADINFO info{}; + info.cbSize = sizeof(info); + return GetGUIThreadInfo(GetWindowThreadProcessId(hwnd, nullptr), &info) && + info.hwndFocus == hwnd && GetForegroundWindow() == hwnd && + IsWindowEnabled(hwnd); +} enum SelectionOperation { kSelect = 0, kAdd = 1, kRemove = 2 }; @@ -108,7 +124,7 @@ class Node final : public IRawElementProviderSimple, else if (iid == __uuidof(ISelectionProvider) && ((id_ >= 1 && id_ <= 4) || id_ == 21)) *out = static_cast(this); - else if (iid == __uuidof(ISelectionItemProvider) && isAvailableRow()) + else if (iid == __uuidof(ISelectionItemProvider) && supportsSelectionItem()) *out = static_cast(this); else if (iid == __uuidof(IToggleProvider) && (id_ == 12 || id_ == 13)) *out = static_cast(this); @@ -142,7 +158,7 @@ class Node final : public IRawElementProviderSimple, else if (id == UIA_SelectionPatternId && ((id_ >= 1 && id_ <= 4) || id_ == 21)) *value = static_cast(this); - else if (id == UIA_SelectionItemPatternId && isAvailableRow()) + else if (id == UIA_SelectionItemPatternId && supportsSelectionItem()) *value = static_cast(this); else if (id == UIA_TogglePatternId && (id_ == 12 || id_ == 13)) *value = static_cast(this); @@ -177,16 +193,21 @@ class Node final : public IRawElementProviderSimple, if (isRowKey(id_)) { const Row &row = state_->rows.at(id_); const bool sidebar_error_footer = - row.identity.rfind("sidebar-error-footer:", 0) == 0; + row.identity.rfind("sidebar-error-footer:", 0) == 0 || + row.identity.rfind("workspace-toolbar:", 0) == 0; bool_value = sidebar_error_footer ? (property != UIA_IsKeyboardFocusablePropertyId) : true; + if (property == UIA_IsEnabledPropertyId && isHeader(row.identity)) + bool_value = IsWindowEnabled(state_->hwnd) != FALSE; } else { bool_value = true; } kind = kBool; } else if (property == UIA_HasKeyboardFocusPropertyId) { bool_value = state_->focused == id_; + if (isRowKey(id_) && isHeader(state_->rows.at(id_).identity)) + bool_value = bool_value && hasHeaderNativeFocus(state_->hwnd); kind = kBool; } else if (property == UIA_LiveSettingPropertyId && id_ == 6) { integer_value = 1; @@ -345,13 +366,28 @@ class Node final : public IRawElementProviderSimple, HRESULT STDMETHODCALLTYPE SetFocus() override { bool changed = false; HWND hwnd = nullptr; + bool header = false; { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; - changed = state_->focused != id_; - state_->focused = id_; + header = isRowKey(id_) && isHeader(state_->rows.at(id_).identity); + if (!header) { + changed = state_->focused != id_; + state_->focused = id_; + } hwnd = state_->hwnd; } + if (header) { + DWORD_PTR result = 0; + SetLastError(ERROR_SUCCESS); + if (!SendMessageTimeoutW(hwnd, kHeaderFocusMessage, + static_cast(id_) & kRowPayloadMask, 0, + SMTO_ABORTIFHUNG | SMTO_BLOCK, 2000, &result)) { + const DWORD error = GetLastError(); + return HRESULT_FROM_WIN32(error == ERROR_SUCCESS ? ERROR_TIMEOUT : error); + } + return result ? S_OK : UIA_E_INVALIDOPERATION; + } if (hwnd) { const DWORD current_thread = GetCurrentThreadId(); const DWORD window_thread = GetWindowThreadProcessId(hwnd, nullptr); @@ -502,6 +538,37 @@ class Node final : public IRawElementProviderSimple, return S_OK; } + HRESULT setHeaderFocus(const char *identity) { + Node *focused = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return UIA_E_ELEMENTNOTAVAILABLE; + int64_t next = 0; + if (identity) { + for (const auto &item : state_->rows) { + if (isHeader(item.second.identity) && item.second.identity == identity) { + next = item.first; + break; + } + } + if (!next) return UIA_E_ELEMENTNOTAVAILABLE; + } else { + const auto previous = state_->rows.find(state_->focused); + if (previous == state_->rows.end() || !isHeader(previous->second.identity)) + return S_OK; + } + if (state_->focused == next) return S_OK; + state_->focused = next; + focused = retainElementLocked(next); + } + if (focused) { + UiaRaiseAutomationEvent(static_cast(focused), + UIA_AutomationFocusChangedEventId); + focused->Release(); + } + return S_OK; + } + void update(const char *status, const char **identities, const char **names, const int *parents, const int *selected, const int *eligible, const int *invokable, const int *bounds, int count, bool allow_reclaim, @@ -702,6 +769,12 @@ class Node final : public IRawElementProviderSimple, std::lock_guard lock(state_->mutex); return isAvailableLocked() && isRowKey(id_); } + bool supportsSelectionItem() const { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked() || !isRowKey(id_)) return false; + const Row &row = state_->rows.at(id_); + return !isHeader(row.identity) && row.identity.rfind("workspace-toolbar:", 0) != 0; + } int64_t rowKeyForIdentityLocked( const std::string &identity, const std::unordered_map &pending) const { @@ -899,6 +972,8 @@ class Node final : public IRawElementProviderSimple, if (id_ >= 1 && id_ <= 3) return UIA_ListControlTypeId; if (isRowKey(id_)) { const Row &row = state_->rows.at(id_); + if (isHeader(row.identity)) return UIA_ButtonControlTypeId; + if (row.identity.rfind("workspace-toolbar:", 0) == 0) return UIA_TextControlTypeId; if (row.identity.rfind("sidebar-error-footer:", 0) == 0) { return UIA_TextControlTypeId; } @@ -931,6 +1006,9 @@ class Node final : public IRawElementProviderSimple, { std::lock_guard lock(state_->mutex); if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + const auto row = state_->rows.find(state_->focused); + if (row != state_->rows.end() && isHeader(row->second.identity) && + !hasHeaderNativeFocus(state_->hwnd)) return S_OK; focused = retainElementLocked(state_->focused); } if (focused) *value = static_cast(focused); @@ -982,6 +1060,7 @@ class Node final : public IRawElementProviderSimple, if (!isAvailableLocked() || !isRowKey(id_)) return UIA_E_ELEMENTNOTAVAILABLE; const auto selected = state_->rows.find(id_); + if (isHeader(selected->second.identity)) return UIA_E_INVALIDOPERATION; if (selected->second.parent != 3) { if (operation == kRemove || !selected->second.invokable) return UIA_E_INVALIDOPERATION; @@ -1042,6 +1121,11 @@ extern "C" void gc_uia_release(IRawElementProviderSimple *provider) { } } +extern "C" HRESULT gc_uia_set_header_focus(IRawElementProviderSimple *provider, + const char *identity) { + return provider ? static_cast(provider)->setHeaderFocus(identity) : E_POINTER; +} + extern "C" LRESULT gc_uia_get_object(HWND hwnd, WPARAM wparam, LPARAM lparam, IRawElementProviderSimple *provider) { if (!provider || lparam != UiaRootObjectId) return 0; diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index b9f63915..08d14a1b 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -252,6 +252,10 @@ pub const App = struct { workspace_controls: WorkspaceControls.State = .{ .panel_visible = false }, dpi: u32 = Dpi.base_dpi, surface: GraphCanvas.Surface = .project, + workspace_is_quick_chat: bool = false, + header_focus: ?GraphCanvas.HeaderAction = null, + header_return_focus: c.HWND = null, + header_focus_transition: bool = false, canvas_layout_store: ?CanvasLayoutStore.Store = null, quick_chats_requested: bool = false, selected_quick_chat: ?usize = null, @@ -440,6 +444,7 @@ 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); + self.window.key_callback = &onHeaderKey; 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. The @@ -1157,6 +1162,7 @@ pub const App = struct { for (self.model.quick_chats.items, 0..) |chat, index| { if (!std.mem.eql(u8, chat.id, id)) continue; self.selected_quick_chat = index; + self.workspace_is_quick_chat = true; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); @@ -1951,10 +1957,6 @@ pub const App = struct { } fn jumpToNode(self: *App) void { - if (self.model.graphs.items.len == 0) { - self.setStatus("No graph is open"); - return; - } var entries = std.array_list.Managed(JumpPalette.Entry).init(self.allocator); defer entries.deinit(); for (self.model.graphs.items) |graph| { @@ -2018,6 +2020,7 @@ pub const App = struct { return; } const workspace = if (self.workspace) |value| value else return; + self.workspace_is_quick_chat = false; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); @@ -3660,12 +3663,7 @@ pub const App = struct { self.setStatus(if (self.workspace_controls.rail_visible) "Workspace rail shown" else "Workspace rail hidden"); }, .toggle_panel => { - self.workspace_controls.apply(.toggle_panel); - if (self.workspace_controls.panel_visible) { - self.surface = .workspace; - } else if (self.surface == .workspace) { - self.surface = .project; - } + self.toggleWorkspacePanelState(); self.layoutWorkspace(); _ = c.InvalidateRect(self.window.hwnd, null, 0); self.setStatus(if (self.workspace_controls.panel_visible) "Workspace panel shown" else "Workspace panel hidden"); @@ -3751,9 +3749,23 @@ pub const App = struct { self.handleAction(InputRouter.keyAction(key, ctrl, shift)); } - fn toggleWorkspaceDetailPanel(self: *App) void { + fn toggleWorkspacePanelState(self: *App) void { + self.workspace_controls.apply(.toggle_panel); + if (self.workspace_controls.panel_visible) { + self.workspace_is_quick_chat = false; + self.surface = .workspace; + } else if (self.surface == .workspace) { + self.surface = .project; + } + } + + fn toggleWorkspaceDetailPanelState(self: *App) void { self.workspace_controls.panel_visible = !self.workspace_controls.panel_visible; if (self.workspace_controls.panel_visible) self.surface = .workspace; + } + + fn toggleWorkspaceDetailPanel(self: *App) void { + self.toggleWorkspaceDetailPanelState(); self.layoutWorkspace(); self.syncAccessibility(); _ = c.InvalidateRect(self.window.hwnd, null, 0); @@ -3763,6 +3775,13 @@ pub const App = struct { fn layoutWorkspace(self: *App) void { var client: c.RECT = undefined; if (c.GetClientRect(self.window.hwnd, &client) == 0) return; + const restore_header = self.header_focus != null and c.GetFocus() == self.window.hwnd; + const previous_transition = self.header_focus_transition; + self.header_focus_transition = previous_transition or restore_header; + defer { + if (restore_header) _ = c.SetFocus(self.window.hwnd); + self.header_focus_transition = previous_transition; + } if (self.workspace) |workspace| { const full_workspace = self.surface == .workspace; const activity_height = if (self.workspace_controls.activity_enabled) Tokens.activity_strip_height else 0; @@ -3984,6 +4003,146 @@ pub const App = struct { _ = c.InvalidateRect(self.window.hwnd, null, 0); } + fn headerPresentation(self: *const App) GraphCanvas.Header { + var header = GraphCanvas.Header{ .attention_count = self.model.attentionCount() }; + if (self.surface == .workspace and self.workspace_is_quick_chat) { + header.context = "Quick Chat workspace"; + if (self.selected_quick_chat) |index| if (index < self.model.quick_chats.items.len) { + header.title = self.model.quick_chats.items[index].title; + }; + } else if (self.surface == .overview) { + header.title = "Graph"; + header.context = "All projects"; + } else if (self.surface == .quick_chats) { + header.title = "Quick Chats"; + } else if (self.model.currentGraph()) |graph| { + header.title = graph.project.name; + header.context = if (self.surface == .workspace) + (if (graph.project.isRemote()) "Workspace / Remote" else if (graph.project.isGlobal()) "Workspace / Global" else "Workspace / Local folder") + else + (if (graph.project.isRemote()) "Remote" else if (graph.project.isGlobal()) "Global" else "Local folder"); + } + if (GraphCanvas.loopPanelHasContent(&self.model, self.surface, self.workspace_is_quick_chat)) { + header.panel_visible = self.workspace_controls.panel_visible; + } + if (self.worktree_inspection) |*inspection| { + const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; + if (GraphCanvas.headerWorktreeNotice(&self.model, inspection, policy)) { + header.notice = WorktreeStatus.summarize(inspection.entries.items); + header.notice_name = self.model.currentGraph().?.project.name; + } + } + return header; + } + + fn headerLayout(self: *const App) GraphCanvas.HeaderLayout { + return self.headerPresentation().layout(clientRight(self.window.hwnd)); + } + + fn headerOwnsFocus(self: *const App) bool { + return self.header_focus != null and c.GetFocus() == self.window.hwnd and + MainWindow.keyOwnerEligible(self.window.hwnd, self.window.hwnd); + } + + fn syncHeaderFocus(self: *App) void { + if (self.header_focus) |action| { + if (self.headerLayout().bounds(action) == null) self.header_focus = self.headerLayout().step(action, false); + } + if (self.accessibility) |*provider| { + provider.syncHeaderFocus(if (self.headerOwnsFocus()) GraphCanvas.headerIdentity(self.header_focus.?) else null); + } + _ = c.InvalidateRect(self.window.hwnd, null, 0); + } + + fn focusHeader(self: *App, action: GraphCanvas.HeaderAction) bool { + const hwnd = self.window.hwnd; + if (!MainWindow.keyOwnerEligible(hwnd, hwnd) or self.headerLayout().bounds(action) == null) return false; + if (self.header_focus == null) self.header_return_focus = c.GetFocus(); + self.header_focus = action; + _ = c.SetFocus(hwnd); + if (c.GetFocus() != hwnd) { + self.header_focus = null; + self.setStatus("Unable to focus window toolbar"); + return false; + } + self.syncHeaderFocus(); + return true; + } + + fn leaveHeader(self: *App, restore: bool) void { + const target = self.header_return_focus; + self.header_focus = null; + self.header_return_focus = null; + if (restore) { + if (MainWindow.keyOwnerEligible(self.window.hwnd, target) and target != self.window.hwnd) { + _ = c.SetFocus(target); + if (c.GetFocus() != target) self.setStatus("Unable to restore keyboard focus"); + } else if (self.workspace) |workspace| { + if (self.surface == .workspace or self.workspace_controls.panel_visible) workspace.focus(workspace.active_surface); + } + } + self.syncHeaderFocus(); + } + + fn onHeaderKey(context: ?*anyopaque, key: usize, ctrl: bool, shift: bool, alt: bool) bool { + const self: *App = @ptrCast(@alignCast(context orelse return false)); + const focused = self.headerOwnsFocus(); + const command = InputRouter.headerKey(key, ctrl, shift, alt, focused); + const layout = self.headerLayout(); + switch (command) { + .none => return false, + .enter => { + const action = layout.step(null, shift) orelse return false; + return self.focusHeader(action); + }, + .exit => self.leaveHeader(true), + .next, .previous, .first, .last => { + const current = if (command == .first or command == .last) null else self.header_focus; + const action = layout.step(current, command == .previous or command == .last) orelse return false; + _ = self.focusHeader(action); + }, + .activate => { + const action = self.header_focus orelse return false; + if (!self.invokeHeader(action)) self.setStatus("Toolbar action is no longer available"); + self.syncHeaderFocus(); + }, + } + return true; + } + + fn invokeHeader(self: *App, action: GraphCanvas.HeaderAction) bool { + if (self.headerLayout().bounds(action) == null) { + self.setStatus("Toolbar action is no longer available"); + return false; + } + switch (action) { + .review_attention => { + if (self.model.attention_entries.items.len == 0) return false; + self.selectNextAttention(); + const graph = self.model.currentGraph() orelse return false; + const index = self.model.selectedIndex() orelse return false; + if (index >= graph.nodes.items.len) return false; + const is_attention = for (self.model.attention_entries.items) |entry| { + if (std.mem.eql(u8, entry.project_path, graph.project.path) and + std.mem.eql(u8, entry.node.id, graph.nodes.items[index].id)) break true; + } else false; + if (!is_attention) return false; + const path = self.allocator.dupe(u8, graph.project.path) catch { + self.setStatus("Unable to open attention loop"); + return false; + }; + defer self.allocator.free(path); + self.openLoopFromAccessibility(path, index); + }, + .inspect_worktrees => self.inspectWorktrees(), + .jump => self.jumpToNode(), + .toggle_panel => self.toggleWorkspaceDetailPanel(), + } + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + fn syncAccessibility(self: *App) void { const provider = if (self.accessibility) |*value| value else return; var elements = std.array_list.Managed(Accessibility.DynamicElement).init(self.allocator); @@ -4011,37 +4170,25 @@ pub const App = struct { &self.sidebar_state, ) catch return; defer sidebar_rows.deinit(self.allocator); - // The native header bar (attention chip, reclaimable-worktree chip, - // jump affordance, contextual loop-panel toggle) is drawn on every - // destination via GraphCanvas.paint's unconditional header() call, so - // it is exposed here unconditionally too, mirroring the same - // gating GraphCanvas.headerActionAt uses for hit-testing. Parent - // group 1 (rather than 4, the Graph canvas group these chips visually - // sit above) matches the existing precedent set by - // needs-you-header/activity-header/activity-filter below: those are - // likewise global chrome rather than literal project rows, and - // reusing group 1 avoids polluting the Graph element's exact, - // exhaustively-asserted child set with chrome that isn't part of the - // canvas. - if (self.model.attentionCount() != 0) { - self.appendAccessibilityElement(&elements, &owned_identities, "header-attention", "needs-you", "Review what needs you", 1, GraphCanvas.headerAttentionRect(), false, true) catch return; - } - if (self.worktree_inspection != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "header-worktree", "worktrees", "Reclaimable worktrees", 1, GraphCanvas.headerWorktreeRect(), false, true) catch return; - } - self.appendAccessibilityElement(&elements, &owned_identities, "header-jump", "jump", "Jump to Loop", 1, GraphCanvas.headerJumpRect(client.right), false, true) catch return; - if (self.model.currentGraph() != null) { - self.appendAccessibilityElement( - &elements, - &owned_identities, - "header-toggle-panel", - "control", - if (self.surface == .workspace) "Hide loop panel" else "Loop panel", - 1, - GraphCanvas.headerPanelRect(client.right), - false, - true, - ) catch return; + const header = self.headerPresentation(); + const header_layout = header.layout(client.right); + for (GraphCanvas.header_actions) |action| { + const bounds = header_layout.bounds(action) orelse continue; + const name = header.label(self.allocator, action) catch return; + owned_identities.append(name) catch { + self.allocator.free(name); + return; + }; + elements.append(.{ + .identity = GraphCanvas.headerIdentity(action), + .name = name, + .parent = 1, + .eligible = true, + .left = bounds.left, + .top = bounds.top, + .right = bounds.right, + .bottom = bounds.bottom, + }) catch return; } for (sidebar_rows.items) |row| { @@ -4126,7 +4273,7 @@ pub const App = struct { else => {}, } } - if (self.surface == .workspace) if (self.selected_quick_chat) |chat_index| { + if (self.surface == .workspace and self.workspace_is_quick_chat) if (self.selected_quick_chat) |chat_index| { if (chat_index < self.model.quick_chats.items.len) { const chat = self.model.quick_chats.items[chat_index]; const identity = std.fmt.allocPrint(self.allocator, "quick-chat-workspace:{s}", .{chat.id}) catch return; @@ -4270,7 +4417,10 @@ pub const App = struct { const workspace_left = if (self.workspace_controls.rail_visible) Tokens.sidebar_width else 0; const workspace_right = client.right - (if (self.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); const selected_index = self.model.selectedIndex() orelse 0; - self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, graph.project.name, 4, .{ .left = workspace_left, .top = 0, .right = workspace_right, .bottom = Tokens.header_height }, false, false) catch return; + if (!self.workspace_is_quick_chat) { + self.appendAccessibilityElement(&elements, &owned_identities, "workspace-toolbar", graph.project.path, header.title, 4, header_layout.identity, false, false) catch return; + elements.items[elements.items.len - 1].invokable = false; + } self.appendAccessibilityElement(&elements, &owned_identities, "workspace-loop-bar", if (selected_index < graph.nodes.items.len) graph.nodes.items[selected_index].id else "none", "Selected loop workspace", 4, .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height }, false, false) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 }, false, false) catch return; if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) { @@ -4381,6 +4531,7 @@ pub const App = struct { } const policy = if (self.worktree_dialog) |dialog| dialog.policy else WorktreeStatus.Policy{}; provider.syncElements(self.status(), elements.items, policy); + self.syncHeaderFocus(); } fn appendAccessibilityElement( @@ -4669,6 +4820,7 @@ pub const App = struct { for (self.model.quick_chats.items, 0..) |chat, index| { if (!std.mem.eql(u8, chat.id, id)) continue; self.selected_quick_chat = index; + self.workspace_is_quick_chat = true; self.surface = .workspace; self.workspace_controls.panel_visible = true; self.layoutWorkspace(); @@ -4693,10 +4845,10 @@ pub const App = struct { if (index >= list.items.len) return false; self.launchWorkspace(list.items[index].path); }, - .header_attention => self.handleAction(.cycle_attention), - .header_worktree => self.inspectWorktrees(), - .header_jump => self.handleAction(.jump_next), - .header_toggle_panel => self.handleAction(.toggle_panel), + .header_attention => return self.invokeHeader(.review_attention), + .header_worktree => return self.invokeHeader(.inspect_worktrees), + .header_jump => return self.invokeHeader(.jump), + .header_toggle_panel => return self.invokeHeader(.toggle_panel), } self.clampSidebarScroll(); self.syncAccessibility(); @@ -4706,9 +4858,13 @@ pub const App = struct { fn openLoopFromAccessibility(self: *App, project_path: []const u8, index: usize) void { if (!self.selectProject(project_path)) return; + self.workspace_is_quick_chat = false; const graph = self.model.graph orelse return; if (index >= graph.nodes.items.len) return; - _ = self.selectNodeIndex(index); + if (!self.selectNodeIndex(index)) { + self.setStatus("Unable to select loop"); + return; + } if (std.mem.eql(u8, graph.nodes.items[index].loop_type, "composite") or std.mem.eql(u8, graph.nodes.items[index].loop_type, "proactive")) { @@ -5201,6 +5357,9 @@ fn onWindowMessage( .product_settings => app.handleAction(.product_settings), .toggle_sidebar => app.handleAction(.toggle_rail), .toggle_workspace => app.handleAction(.toggle_panel), + .focus_header => if (app.headerLayout().step(null, false)) |action| { + _ = app.focusHeader(action); + }, .toggle_activity => app.handleAction(.toggle_activity), .zoom_out => app.handleAction(.zoom_out), .actual_size => app.handleAction(.actual_size), @@ -5224,15 +5383,8 @@ fn onWindowMessage( c.WM_PAINT => { var paint: c.PAINTSTRUCT = undefined; const hdc = c.BeginPaint(hwnd, &paint); - const inspection = if (app.worktree_inspection) |*value| blk: { - const policy = WorktreeStatus.loadPolicy(app.allocator, value.project_path); - const summary = WorktreeStatus.summarize(value.entries.items); - var bytes: u64 = 0; - for (value.entries.items) |entry| bytes += entry.size_bytes; - const threshold_bytes = @as(u64, policy.notice_size_gb) * 1024 * 1024 * 1024; - if (summary.total >= policy.notice_count or bytes >= threshold_bytes) break :blk value; - break :blk null; - } else null; + const header = app.headerPresentation(); + const inspection = if (header.notice != null) &app.worktree_inspection.? else null; app.update_lock.lock(); if (app.model.currentGraph()) |graph| app.canvas.syncNodeOffsets(graph.nodes.items); const offered_version = if (app.update_state.state == .available) app.update_version else ""; @@ -5242,14 +5394,6 @@ fn onWindowMessage( if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { const workspace_right = clientRight(hwnd) - (if (app.workspace_controls.panel_visible) Tokens.loop_detail_width else 0); - TerminalWorkspace.Workspace.paintWorkspaceToolbar( - hdc, - app.allocator, - if (app.workspace_controls.rail_visible) Tokens.sidebar_width else 0, - workspace_right, - graph.project.name, - graph.project.path, - ); const index = app.model.selectedIndex() orelse 0; if (index < graph.nodes.items.len) { const node = graph.nodes.items[index]; @@ -5288,6 +5432,7 @@ fn onWindowMessage( } } } + GraphCanvas.paintHeader(hdc, app.allocator, clientRight(hwnd), app.status(), header, if (app.headerOwnsFocus()) app.header_focus else null); _ = c.EndPaint(hwnd, &paint); result.* = 0; return true; @@ -5481,14 +5626,29 @@ fn onWindowMessage( result.* = 0; return true; }, + Accessibility.wm_header_focus => { + result.* = 0; + for (GraphCanvas.header_actions) |action| { + if (Accessibility.worktreeIdentityPayload(GraphCanvas.headerIdentity(action)) == wparam) { + result.* = if (app.focusHeader(action)) 1 else 0; + break; + } + } + return true; + }, c.WM_KEYDOWN => { + const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; + const shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0; + const alt = (@as(i32, c.GetKeyState(c.VK_MENU)) & 0x8000) != 0; + if (MainWindow.keyOwnerEligible(hwnd, hwnd) and App.onHeaderKey(app, wparam, ctrl, shift, alt)) { + result.* = 0; + return true; + } if (wparam == c.VK_ESCAPE) { app.cancelCanvasInteraction(); result.* = 0; return true; } - const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; - const shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0; app.handleAction(InputRouter.keyAction(wparam, ctrl, shift)); app.updateNativeChrome(); result.* = 0; @@ -5514,24 +5674,13 @@ fn onWindowMessage( } var client: c.RECT = undefined; _ = c.GetClientRect(hwnd, &client); - if (GraphCanvas.headerActionAt( - x, - y, - client.right, - app.model.attentionCount() != 0, - app.worktree_inspection != null, - app.model.currentGraph() != null, - )) |action| { - switch (action) { - .review_attention => app.handleAction(.cycle_attention), - .inspect_worktrees => app.inspectWorktrees(), - .jump => app.handleAction(.jump_next), - .toggle_panel => app.handleAction(.toggle_panel), - } - _ = c.InvalidateRect(hwnd, null, 0); + if (app.headerLayout().actionAt(x, y)) |action| { + _ = app.focusHeader(action); + _ = app.invokeHeader(action); result.* = 0; return true; } + if (app.header_focus != null) app.leaveHeader(false); if (app.model.attentionCount() != 0 and GraphCanvas.hitTestAttentionRail(x, y, client.right)) { app.handleAction(.cycle_attention); _ = c.InvalidateRect(hwnd, null, 0); @@ -5657,6 +5806,7 @@ fn onWindowMessage( } else if (GraphCanvas.hitTestOverview(&app.model, x, y, &app.canvas, bounds)) |hit| { const graph = app.model.graphs.items[hit.graph_index]; if (app.selectProject(graph.project.path)) { + app.workspace_is_quick_chat = false; app.surface = .workspace; app.workspace_controls.panel_visible = true; app.layoutWorkspace(); @@ -5865,6 +6015,7 @@ fn onWindowMessage( return true; } if (!app.selectProject(path)) return true; + app.workspace_is_quick_chat = false; app.surface = .workspace; app.workspace_controls.panel_visible = true; app.layoutWorkspace(); @@ -6246,6 +6397,11 @@ fn onWindowMessage( } }, c.WM_SETFOCUS => { + if (app.header_focus != null) { + app.syncHeaderFocus(); + result.* = 0; + return true; + } if (app.workspace) |workspace| { if (app.surface == .workspace or app.workspace_controls.panel_visible) { workspace.focus(workspace.active_surface); @@ -6256,6 +6412,13 @@ fn onWindowMessage( result.* = 0; return true; }, + c.WM_KILLFOCUS => { + if (app.header_focus != null and !app.header_focus_transition and c.IsWindowEnabled(hwnd) != 0) { + app.leaveHeader(false); + } + result.* = 0; + return true; + }, c.WM_ACTIVATE => { // DefWindowProc's default WM_ACTIVATE handling restores keyboard focus to whichever // child HWND last held it -- which can be a hidden terminal surface, since that child @@ -6264,6 +6427,9 @@ fn onWindowMessage( // then reassert our own focus policy so a hidden workspace terminal can never win that // restoration race and keep stealing focus away from the rest of the app's chrome. const activated = (wparam & 0xffff) != c.WA_INACTIVE; + const previous_transition = app.header_focus_transition; + app.header_focus_transition = previous_transition or (activated and app.header_focus != null); + defer app.header_focus_transition = previous_transition; result.* = c.DefWindowProcW(hwnd, message, wparam, lparam); if (!activated) { // Deactivation (e.g. Alt+Tab away, or another window taking @@ -6273,7 +6439,10 @@ fn onWindowMessage( // distance. app.canvas.endPinchZoom(); } - if (activated) { + if (activated and app.header_focus != null) { + _ = c.SetFocus(hwnd); + app.syncHeaderFocus(); + } else if (activated) { if (app.workspace) |workspace| { if (app.surface == .workspace or app.workspace_controls.panel_visible) { workspace.focus(workspace.active_surface); @@ -6653,6 +6822,75 @@ test "gesture registration failure formats the exact production diagnostic text" try std.testing.expectEqualStrings("Touch pinch-zoom unavailable", fallback); } +test "header detail toggle preserves workspace instead of generic panel navigation" { + var app: App = .{ + .allocator = std.testing.allocator, + .client = undefined, + .daemon = undefined, + .model = undefined, + .sidebar_state = undefined, + .declared_entry_ids = undefined, + .kept_worktree_paths = undefined, + }; + for ([_]bool{ false, true }) |sidebar_visible| { + app.surface = .workspace; + app.workspace_controls = .{ .rail_visible = sidebar_visible, .panel_visible = true }; + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); + app.toggleWorkspaceDetailPanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.workspace, app.surface); + try std.testing.expect(app.workspace_controls.panel_visible); + try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); + + app.toggleWorkspacePanelState(); + try std.testing.expectEqual(GraphCanvas.Surface.project, app.surface); + try std.testing.expect(!app.workspace_controls.panel_visible); + try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); + } +} + +test "header presentation follows destinations and keeps sidebar independent" { + 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 std.testing.expectEqualStrings("GraphCode Windows", app.headerPresentation().title); + try std.testing.expect(app.headerPresentation().contains(.jump)); + try std.testing.expect(!app.headerPresentation().contains(.toggle_panel)); + _ = try app.model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\test","name":"Test"},"nodes":[{"id":"a","title":"A","state":"idle","metricHistory":[{"value":1},{"value":2}]}],"edges":[]}}} + ); + try std.testing.expect(app.model.setSelectedIndex(0)); + for ([_]GraphCanvas.Surface{ .project, .overview, .quick_chats, .workspace }) |surface| { + app.surface = surface; + for ([_]bool{ false, true }) |sidebar_visible| { + app.workspace_controls.rail_visible = sidebar_visible; + for ([_]bool{ false, true }) |panel_visible| { + app.workspace_controls.panel_visible = panel_visible; + const header = app.headerPresentation(); + try std.testing.expectEqual(surface == .workspace, header.contains(.toggle_panel)); + if (surface == .workspace) try std.testing.expectEqual(panel_visible, header.panel_visible.?); + try std.testing.expectEqual(sidebar_visible, app.workspace_controls.rail_visible); + } + } + } + app.workspace_is_quick_chat = true; + try std.testing.expect(!app.headerPresentation().contains(.toggle_panel)); + try std.testing.expectEqualStrings("Quick Chat workspace", app.headerPresentation().context); +} + 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 7dbb7f79..8d8b6ef2 100644 --- a/graphcode-windows/src/GraphCanvas.zig +++ b/graphcode-windows/src/GraphCanvas.zig @@ -62,20 +62,6 @@ pub const CanvasState = struct { self.start_pan_y = self.pan_y; } - test "toolbar actions require visible contextual controls" { - const attention = headerAttentionRect(); - try std.testing.expectEqual( - HeaderAction.review_attention, - headerActionAt(attention.left + 2, attention.top + 2, 1200, true, false, false).?, - ); - try std.testing.expect(headerActionAt(attention.left + 2, attention.top + 2, 1200, false, false, false) == null); - const jump = headerJumpRect(1200); - try std.testing.expectEqual(HeaderAction.jump, headerActionAt(jump.left + 2, jump.top + 2, 1200, false, false, false).?); - const panel = headerPanelRect(1200); - try std.testing.expect(headerActionAt(panel.left + 2, panel.top + 2, 1200, false, false, false) == null); - try std.testing.expectEqual(HeaderAction.toggle_panel, headerActionAt(panel.left + 2, panel.top + 2, 1200, false, false, true).?); - } - pub fn updatePan(self: *CanvasState, x: i32, y: i32) void { if (!self.dragging) return; self.pan_x = self.start_pan_x + @as(f32, @floatFromInt(x - self.drag_x)); @@ -361,6 +347,125 @@ pub const OverviewHit = struct { graph_index: usize, node_index: usize }; pub const OverviewLaneAction = enum { open_project, inspect_worktrees }; pub const ZoomControl = enum { out, actual, in, fit }; pub const HeaderAction = enum { review_attention, inspect_worktrees, jump, toggle_panel }; +pub const header_actions = [_]HeaderAction{ .review_attention, .inspect_worktrees, .jump, .toggle_panel }; + +pub fn headerIdentity(action: HeaderAction) []const u8 { + return switch (action) { + .review_attention => "header-attention:needs-you", + .inspect_worktrees => "header-worktree:worktrees", + .jump => "header-jump:jump", + .toggle_panel => "header-toggle-panel:control", + }; +} + +pub const Header = struct { + title: []const u8 = "GraphCode Windows", + context: []const u8 = "", + attention_count: usize = 0, + notice_name: []const u8 = "", + notice: ?WorktreeStatus.Summary = null, + panel_visible: ?bool = null, + + pub fn contains(self: Header, action: HeaderAction) bool { + return switch (action) { + .review_attention => self.attention_count != 0, + .inspect_worktrees => self.notice != null, + .jump => true, + .toggle_panel => self.panel_visible != null, + }; + } + + pub fn label(self: Header, allocator: std.mem.Allocator, action: HeaderAction) ![]u8 { + return switch (action) { + .review_attention => std.fmt.allocPrint(allocator, "{d} need you", .{self.attention_count}), + .inspect_worktrees => if (self.notice.?.reclaimable != 0) + std.fmt.allocPrint(allocator, "{s}: {d} reclaimable", .{ self.notice_name, self.notice.?.reclaimable }) + else + std.fmt.allocPrint(allocator, "{s}: {d} worktrees", .{ self.notice_name, self.notice.?.total }), + .jump => allocator.dupe(u8, "Jump to loop Ctrl+P"), + .toggle_panel => allocator.dupe(u8, if (self.panel_visible.?) "Hide loop panel" else "Show loop panel"), + }; + } + + pub fn layout(self: Header, width: i32) HeaderLayout { + var result = HeaderLayout{}; + const right = @max(0, width - 8); + const identity_right = @min(right, @max(0, @min(280, @divTrunc(width, 3)))); + result.identity = rect(@min(8, identity_right), 1, identity_right, Tokens.header_height - 1); + const status_width: i32 = if (width >= 1100) 220 else 0; + result.status = rect(right - status_width, 5, right, Tokens.header_height - 5); + const controls_right = @max(identity_right, right - status_width - (if (status_width != 0) @as(i32, 8) else 0)); + var total: i32 = 0; + var count: i32 = 0; + const preferred = [_]i32{ 112, 180, 164, 136 }; + for (header_actions, preferred) |action, preferred_width| { + if (!self.contains(action)) continue; + total += preferred_width; + count += 1; + } + const gap = @min(@as(i32, 8), @divTrunc(controls_right - identity_right, count * 2)); + const available = @max(0, controls_right - identity_right - gap * count); + var left = identity_right + gap; + for (header_actions, preferred, 0..) |action, preferred_width, index| { + if (!self.contains(action)) continue; + const control_width = @min(preferred_width, @divTrunc(available * preferred_width, total)); + if (control_width > 0) result.controls[index] = rect(left, 5, left + control_width, Tokens.header_height - 5); + left += control_width + gap; + } + return result; + } +}; + +pub const HeaderLayout = struct { + identity: c.RECT = std.mem.zeroes(c.RECT), + status: c.RECT = std.mem.zeroes(c.RECT), + controls: [header_actions.len]?c.RECT = .{null} ** header_actions.len, + + pub fn bounds(self: HeaderLayout, action: HeaderAction) ?c.RECT { + return self.controls[@intFromEnum(action)]; + } + + pub fn actionAt(self: HeaderLayout, x: i32, y: i32) ?HeaderAction { + for (header_actions, self.controls) |action, control| { + if (control) |bounds_value| if (insideGraph(x, y, bounds_value)) return action; + } + return null; + } + + pub fn step(self: HeaderLayout, current: ?HeaderAction, backwards: bool) ?HeaderAction { + const start: usize = if (current) |action| @intFromEnum(action) else if (backwards) 0 else header_actions.len - 1; + for (1..header_actions.len + 1) |offset| { + const index = if (backwards) + (start + header_actions.len - offset) % header_actions.len + else + (start + offset) % header_actions.len; + if (self.controls[index] != null) return header_actions[index]; + } + return null; + } +}; + +pub fn loopPanelHasContent(model: *const GraphModel.Model, surface: Surface, quick_chat: bool) bool { + if (surface != .workspace or quick_chat) return false; + const graph = model.currentGraph() orelse return false; + const index = model.selectedIndex() orelse return false; + if (index >= graph.nodes.items.len) return false; + const node = graph.nodes.items[index]; + if (node.metric_sample_count >= 2) return true; + for (graph.edges.items) |edge| { + if (std.mem.eql(u8, edge.from, node.id) or std.mem.eql(u8, edge.to, node.id)) return true; + } + return false; +} + +pub fn headerWorktreeNotice(model: *const GraphModel.Model, inspection: *const WorktreeStatus.Inspection, policy: WorktreeStatus.Policy) bool { + const graph = model.currentGraph() orelse return false; + if (!graph.project.isLocalFilesystem() or !std.mem.eql(u8, graph.project.path, inspection.project_path)) return false; + const summary = WorktreeStatus.summarize(inspection.entries.items); + var bytes: u64 = 0; + for (inspection.entries.items) |entry| bytes +|= entry.size_bytes; + return summary.total >= policy.notice_count or bytes >= @as(u64, policy.notice_size_gb) * 1024 * 1024 * 1024; +} pub const AttentionAction = enum { reply, inspect }; pub const ReclaimAction = enum { reclaim, keep }; pub const ReclaimHit = struct { node_index: usize, action: ReclaimAction }; @@ -435,7 +540,6 @@ pub fn paint( null else null; - header(hdc, allocator, client.right, status, model, inspection, surface); if (controls.rail_visible) { const sidebar_bottom = if (controls.panel_visible and surface != .workspace) client.bottom - Tokens.workspace_height @@ -833,84 +937,120 @@ test "inline canvas alerts remain inside the active detail surface" { try std.testing.expect(alert.bottom < bounds.bottom); } -fn header( +pub fn paintHeader( hdc: c.HDC, allocator: std.mem.Allocator, width: i32, status: []const u8, - model: *const GraphModel.Model, - inspection: ?*const WorktreeStatus.Inspection, - surface: Surface, + presentation: Header, + focused: ?HeaderAction, ) void { fill(hdc, rect(0, 0, width, Tokens.header_height), Tokens.window_tone); - if (surface == .workspace and model.currentGraph() != null) { - const project = model.currentGraph().?.project; - drawText(hdc, allocator, project.name, 16, 7, 15, 0x00FFFFFF); - drawText(hdc, allocator, if (project.isRemote()) "Remote" else "Local folder", 172, 10, 11, 0x008E8E93); - } else { - drawText(hdc, allocator, "GraphCode Windows", 16, 8, 15, 0x00FFFFFF); - } - if (model.attentionCount() != 0) { - const bounds = headerAttentionRect(); - fill(hdc, bounds, 0x00352B1C); - var buffer: [48]u8 = undefined; - const label = std.fmt.bufPrint(&buffer, "{d} need you", .{model.attentionCount()}) catch "Needs you"; - drawTextRect(hdc, allocator, label, bounds, 10, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - } - if (inspection) |value| { - const summary = WorktreeStatus.summarize(value.entries.items); - const bounds = headerWorktreeRect(); - fill(hdc, bounds, 0x002D2418); - var buffer: [64]u8 = undefined; - const label = if (summary.reclaimable != 0) - std.fmt.bufPrint(&buffer, "{d} reclaimable", .{summary.reclaimable}) catch "Worktrees" - else - std.fmt.bufPrint(&buffer, "{d} worktrees", .{summary.total}) catch "Worktrees"; - drawTextRect(hdc, allocator, label, bounds, 10, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + const layout = presentation.layout(width); + const flags = c.DT_SINGLELINE | c.DT_VCENTER | c.DT_END_ELLIPSIS | c.DT_NOPREFIX; + var title_bounds = layout.identity; + if (presentation.context.len != 0) { + var context_bounds = layout.identity; + context_bounds.top = 1; + context_bounds.bottom = 15; + title_bounds.top = 14; + title_bounds.bottom = Tokens.header_height - 1; + drawTextRect(hdc, allocator, presentation.context, context_bounds, 8, 0x00A8A8A8, flags); + } + drawTextRect(hdc, allocator, presentation.title, title_bounds, 11, 0x00FFFFFF, flags); + for (header_actions) |action| { + const bounds = layout.bounds(action) orelse continue; + const notice = action == .review_attention or action == .inspect_worktrees; + fill(hdc, bounds, if (notice) @as(c.COLORREF, 0x00352B1C) else 0x00282828); + const label = presentation.label(allocator, action) catch { + std.debug.print("Unable to allocate header control label\n", .{}); + continue; + }; + defer allocator.free(label); + var text_bounds = bounds; + text_bounds.left += 4; + text_bounds.right -= 4; + drawTextRect(hdc, allocator, label, text_bounds, 10, if (notice) @as(c.COLORREF, 0x00FFCD7A) else 0x00D8D8D8, flags | c.DT_CENTER); + if (focused == action) { + var ring = bounds; + _ = c.InflateRect(&ring, -1, -1); + _ = c.DrawFocusRect(hdc, &ring); + } } - const jump = headerJumpRect(width); - fill(hdc, jump, 0x00282828); - drawTextRect(hdc, allocator, "Jump to loop Ctrl+P", jump, 10, 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - if (model.currentGraph() != null) { - const panel = headerPanelRect(width); - fill(hdc, panel, 0x00282828); - drawTextRect(hdc, allocator, if (surface == .workspace) "Hide loop panel" else "Loop panel", panel, 10, 0x00D8D8D8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); + if (layout.status.right > layout.status.left) drawTextRect(hdc, allocator, status, layout.status, 10, 0x00A8A8A8, flags); +} + +test "header layout shares visibility hit testing and focus order at narrow widths" { + const header = Header{ .attention_count = 2, .notice = .{ .total = 8, .reclaimable = 3, .blocked = 5 }, .notice_name = "Project", .panel_visible = false }; + for ([_]i32{ 480, 640, 900, 1280 }) |width| { + const layout = header.layout(width); + var previous_right = layout.identity.right; + var focused: ?HeaderAction = null; + for (header_actions) |action| { + const bounds = layout.bounds(action).?; + try std.testing.expect(bounds.left > previous_right); + try std.testing.expect(bounds.right > bounds.left); + try std.testing.expect(bounds.right <= width); + try std.testing.expectEqual(action, layout.actionAt(bounds.left + 1, bounds.top + 1).?); + focused = layout.step(focused, false); + try std.testing.expectEqual(action, focused.?); + previous_right = bounds.right; + } + try std.testing.expect(previous_right <= layout.status.left); + try std.testing.expectEqual(HeaderAction.review_attention, layout.step(focused, false).?); + try std.testing.expectEqual(HeaderAction.toggle_panel, layout.step(null, true).?); } - drawText(hdc, allocator, status, width - 270, 9, 11, 0x00A8A8A8); + const empty = (Header{}).layout(640); + try std.testing.expectEqual(HeaderAction.jump, empty.step(.toggle_panel, false).?); + try std.testing.expect(empty.bounds(.toggle_panel) == null); + try std.testing.expect(empty.bounds(.inspect_worktrees) == null); + try std.testing.expect(empty.bounds(.review_attention) == null); + try std.testing.expect(empty.actionAt(0, Tokens.header_height) == null); } -pub fn headerAttentionRect() c.RECT { - return rect(220, 5, 330, Tokens.header_height - 5); -} - -pub fn headerWorktreeRect() c.RECT { - return rect(338, 5, 458, Tokens.header_height - 5); -} - -pub fn headerJumpRect(width: i32) c.RECT { - return rect(width - 560, 5, width - 400, Tokens.header_height - 5); -} - -pub fn headerPanelRect(width: i32) c.RECT { - return rect(width - 390, 5, width - 280, Tokens.header_height - 5); +test "header panel belongs only to a selected workspace loop with supported content" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\test","name":"Test"},"nodes":[{"id":"a","title":"A","state":"idle"},{"id":"b","title":"B","state":"idle","metricHistory":[{"value":1},{"value":2}]},{"id":"c","title":"C","state":"idle"}],"edges":[{"from":"a","to":"b"}]}}} + ); + try std.testing.expect(model.setSelectedIndex(0)); + for ([_]Surface{ .project, .overview, .quick_chats }) |surface| { + try std.testing.expect(!loopPanelHasContent(&model, surface, false)); + } + try std.testing.expect(loopPanelHasContent(&model, .workspace, false)); + try std.testing.expect(!loopPanelHasContent(&model, .workspace, true)); + try std.testing.expect(model.setSelectedIndex(1)); + try std.testing.expect(loopPanelHasContent(&model, .workspace, false)); + try std.testing.expect(model.setSelectedIndex(2)); + try std.testing.expect(!loopPanelHasContent(&model, .workspace, false)); + model.selected_index = null; + try std.testing.expect(!loopPanelHasContent(&model, .workspace, false)); } -pub fn headerActionAt( - x: i32, - y: i32, - width: i32, - has_attention: bool, - has_worktrees: bool, - has_graph: bool, -) ?HeaderAction { - if (y < 0 or y >= Tokens.header_height) return null; - if (has_attention and insideGraph(x, y, headerAttentionRect())) return .review_attention; - if (has_worktrees and insideGraph(x, y, headerWorktreeRect())) return .inspect_worktrees; - if (insideGraph(x, y, headerJumpRect(width))) return .jump; - if (has_graph and insideGraph(x, y, headerPanelRect(width))) return .toggle_panel; - return null; +test "header notice threshold and owner gating exclude invisible actions" { + var model = GraphModel.Model.init(std.testing.allocator); + defer model.deinit(); + _ = try model.updateFromFrame( + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"project":{"path":"C:\\test","name":"Test"},"nodes":[],"edges":[]}}} + ); + var inspection = WorktreeStatus.Inspection{ + .entries = std.array_list.Managed(WorktreeStatus.Entry).init(std.testing.allocator), + .project_path = @constCast("C:\\test"), + .default_branch = @constCast("main"), + }; + defer inspection.entries.deinit(); + try inspection.entries.append(.{ .path = @constCast("C:\\tree"), .branch = @constCast("topic"), .size_bytes = 1024 * 1024 * 1024 - 1 }); + const policy = WorktreeStatus.Policy{ .notice_count = 2, .notice_size_gb = 1 }; + try std.testing.expect(!headerWorktreeNotice(&model, &inspection, policy)); + inspection.entries.items[0].size_bytes += 1; + try std.testing.expect(headerWorktreeNotice(&model, &inspection, policy)); + inspection.entries.items[0].size_bytes = 0; + try inspection.entries.append(inspection.entries.items[0]); + try std.testing.expect(headerWorktreeNotice(&model, &inspection, policy)); + inspection.project_path = @constCast("C:\\foreign"); + try std.testing.expect(!headerWorktreeNotice(&model, &inspection, policy)); } - fn attentionRail( hdc: c.HDC, allocator: std.mem.Allocator, diff --git a/graphcode-windows/src/InputRouter.zig b/graphcode-windows/src/InputRouter.zig index 30aa330b..e325490d 100644 --- a/graphcode-windows/src/InputRouter.zig +++ b/graphcode-windows/src/InputRouter.zig @@ -1,5 +1,24 @@ const std = @import("std"); +pub const HeaderKey = enum { none, enter, exit, next, previous, first, last, activate }; + +pub fn headerKey(key: usize, ctrl: bool, shift: bool, alt: bool, focused: bool) HeaderKey { + if (ctrl or alt) return .none; + if (key == 0x75) return if (focused) .exit else .enter; + if (!focused) return .none; + if (key == 0x09) return if (shift) .previous else .next; + if (shift) return .none; + return switch (key) { + 0x25 => .previous, + 0x27 => .next, + 0x24 => .first, + 0x23 => .last, + 0x0D, 0x20 => .activate, + 0x1B => .exit, + else => .none, + }; +} + pub const Action = enum { none, reconnect, @@ -179,6 +198,27 @@ test "workspace shortcuts route to tabs splits and panes" { try std.testing.expectEqual(Action.select_next_tab, keyAction(0x22, true, false)); } +test "header keys are scoped and preserve modified and global navigation" { + try std.testing.expectEqual(HeaderKey.enter, headerKey(0x75, false, false, false, false)); + try std.testing.expectEqual(HeaderKey.enter, headerKey(0x75, false, true, false, false)); + try std.testing.expectEqual(HeaderKey.exit, headerKey(0x75, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.next, headerKey(0x09, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.previous, headerKey(0x09, false, true, false, true)); + try std.testing.expectEqual(HeaderKey.first, headerKey(0x24, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.last, headerKey(0x23, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.activate, headerKey(0x0D, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.activate, headerKey(0x20, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.exit, headerKey(0x1B, false, false, false, true)); + try std.testing.expectEqual(HeaderKey.none, headerKey('P', false, false, false, true)); + for ([_]usize{ 0x09, 0x25, 0x27, 0x24, 0x23, 0x0D, 0x20, 0x1B }) |key| { + try std.testing.expectEqual(HeaderKey.none, headerKey(key, false, false, false, false)); + } + for ([_]usize{ 0x75, 0x09, 0x25, 0x27, 0x24, 0x23, 0x0D, 0x20, 0x1B }) |key| { + try std.testing.expectEqual(HeaderKey.none, headerKey(key, true, false, false, true)); + try std.testing.expectEqual(HeaderKey.none, headerKey(key, false, false, true, true)); + } +} + test "attention and worktree shortcuts are distinct from ordinary selection" { try std.testing.expectEqual(Action.cycle_attention, keyAction(0x09, true, false)); try std.testing.expectEqual(Action.close_tab, keyAction('W', true, false)); diff --git a/graphcode-windows/src/JumpPalette.zig b/graphcode-windows/src/JumpPalette.zig index 14af2bde..5e3aeda1 100644 --- a/graphcode-windows/src/JumpPalette.zig +++ b/graphcode-windows/src/JumpPalette.zig @@ -196,6 +196,39 @@ pub fn show( }; } +test "empty palette creates native search controls and cancels without a selection" { + try registerClass(); + var dialog = Dialog{ .state = State.init(std.testing.allocator, &.{}) }; + defer dialog.state.deinit(); + try dialog.state.filter(""); + try std.testing.expect(active == null); + active = &dialog; + defer active = null; + const hwnd = c.CreateWindowExW( + c.WS_EX_DLGMODALFRAME | c.WS_EX_CONTROLPARENT, + class_name.ptr, + std.unicode.utf8ToUtf16LeStringLiteral("Hidden empty palette test").ptr, + c.WS_OVERLAPPED | c.WS_CAPTION | c.WS_SYSMENU, + 0, + 0, + 640, + 430, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.PaletteCreationFailed; + defer _ = c.DestroyWindow(hwnd); + try std.testing.expect(c.IsWindowVisible(hwnd) == 0); + try std.testing.expect(dialog.edit != null and dialog.list != null); + try std.testing.expectEqual(@as(c.LRESULT, 0), c.SendMessageW(dialog.list, c.LB_GETCOUNT, 0, 0)); + accept(&dialog); + try std.testing.expect(!dialog.accepted and !dialog.closed); + _ = c.SendMessageW(hwnd, c.WM_COMMAND, cancel_id, 0); + try std.testing.expect(dialog.closed and !dialog.accepted); + try std.testing.expect(dialog.state.selectedEntry() == null); +} + fn registerClass() !void { var window_class: c.WNDCLASSW = std.mem.zeroes(c.WNDCLASSW); window_class.lpfnWndProc = @ptrCast(&windowProc); diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index af0ec6ee..7206b0ab 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -11,6 +11,8 @@ pub const MessageCallback = *const fn ( result: *c.LRESULT, ) callconv(.c) bool; +pub const KeyCallback = *const fn (context: ?*anyopaque, key: usize, ctrl: bool, shift: bool, alt: bool) bool; + pub const Command = enum(u16) { open_folder = 4101, open_global_overview = 4102, @@ -47,6 +49,7 @@ pub const Command = enum(u16) { actual_size = 4408, zoom_in = 4409, fit_canvas = 4410, + focus_header = 4411, about = 4501, onboarding = 4502, check_updates = 4503, @@ -135,6 +138,7 @@ pub const Window = struct { instance: c.HINSTANCE = null, context: ?*anyopaque = null, callback: ?MessageCallback = null, + key_callback: ?KeyCallback = null, accelerators: c.HACCEL = null, class_name: [*:0]const u16 = class_name.ptr, /// Result of the one-time `SetGestureConfig` registration performed in @@ -200,14 +204,54 @@ pub const Window = struct { const result = c.GetMessageW(&message, null, 0, 0); if (result == 0) break; if (result == -1) return error.MessageLoopFailed; + if (self.pretranslateKey(&message, KeyContext.capture(self.hwnd, message.hwnd))) continue; if (self.accelerators != null and c.TranslateAcceleratorW(self.hwnd, self.accelerators, &message) != 0) continue; _ = c.TranslateMessage(&message); _ = c.DispatchMessageW(&message); } } + + pub fn pretranslateKey(self: *Window, message: *const c.MSG, keys: KeyContext) bool { + if (message.message != c.WM_KEYDOWN or !keys.eligible()) return false; + const callback = self.key_callback orelse return false; + return callback(self.context, message.wParam, keys.ctrl, keys.shift, keys.alt); + } +}; + +pub const KeyContext = struct { + active: bool = false, + owner_enabled: bool = false, + target_owned: bool = false, + target_visible: bool = false, + target_enabled: bool = false, + ctrl: bool = false, + shift: bool = false, + alt: bool = false, + + pub fn capture(owner: c.HWND, target: c.HWND) KeyContext { + if (owner == null or target == null) return .{}; + return .{ + .active = c.GetActiveWindow() == owner and c.GetForegroundWindow() == owner, + .owner_enabled = c.IsWindowEnabled(owner) != 0, + .target_owned = target == owner or c.IsChild(owner, target) != 0, + .target_visible = c.IsWindowVisible(target) != 0, + .target_enabled = c.IsWindowEnabled(target) != 0, + .ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0, + .shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0, + .alt = (@as(i32, c.GetKeyState(c.VK_MENU)) & 0x8000) != 0, + }; + } + + pub fn eligible(self: KeyContext) bool { + return self.active and self.owner_enabled and self.target_owned and self.target_visible and self.target_enabled; + } }; +pub fn keyOwnerEligible(owner: c.HWND, target: c.HWND) bool { + return KeyContext.capture(owner, target).eligible(); +} + pub const timer_id: usize = 41; pub const wm_app_tick: c.UINT = c.WM_APP + 41; pub var restore_message: c.UINT = 0; @@ -296,6 +340,7 @@ pub fn installMenu(hwnd: c.HWND) !void { append(terminal, "Focus Previous Pane\tCtrl+[", @intFromEnum(Command.focus_previous_pane)); append(view, "Global Overview", @intFromEnum(Command.open_global_overview)); + append(view, "Focus Window Toolbar\tF6", @intFromEnum(Command.focus_header)); append(view, "Show Application Sidebar\tCtrl+Shift+L", @intFromEnum(Command.toggle_sidebar)); append(view, "Show Terminal Workspace\tCtrl+Shift+B", @intFromEnum(Command.toggle_workspace)); append(view, "Show Activity Strip\tCtrl+Shift+A", @intFromEnum(Command.toggle_activity)); @@ -517,6 +562,141 @@ test "native menu exposes the parity command groups" { try std.testing.expectEqual(@as(?Command, null), commandFromId(9999)); } +test "pretranslation invokes the real header key classifier only for eligible input" { + const Probe = struct { + focused: bool = false, + calls: usize = 0, + fn callback(context: ?*anyopaque, key: usize, ctrl: bool, shift: bool, alt: bool) bool { + const self: *@This() = @ptrCast(@alignCast(context.?)); + self.calls += 1; + const action = @import("InputRouter.zig").headerKey(key, ctrl, shift, alt, self.focused); + if (action == .enter) self.focused = true; + if (action == .exit) self.focused = false; + return action != .none; + } + }; + var probe = Probe{}; + var window = Window{ .context = &probe, .key_callback = &Probe.callback }; + const eligible = KeyContext{ .active = true, .owner_enabled = true, .target_owned = true, .target_visible = true, .target_enabled = true }; + var message = std.mem.zeroes(c.MSG); + message.message = c.WM_KEYDOWN; + message.wParam = c.VK_TAB; + try std.testing.expect(!window.pretranslateKey(&message, eligible)); + message.wParam = c.VK_F6; + try std.testing.expect(window.pretranslateKey(&message, eligible)); + try std.testing.expect(probe.focused); + message.wParam = c.VK_TAB; + try std.testing.expect(window.pretranslateKey(&message, eligible)); + var modified = eligible; + modified.ctrl = true; + try std.testing.expect(!window.pretranslateKey(&message, modified)); + modified = eligible; + modified.alt = true; + try std.testing.expect(!window.pretranslateKey(&message, modified)); + message.wParam = c.VK_F6; + try std.testing.expect(!window.pretranslateKey(&message, modified)); + for ([_][]const u8{ "active", "owner_enabled", "target_owned", "target_visible", "target_enabled" }) |field| { + var excluded = eligible; + inline for (.{ "active", "owner_enabled", "target_owned", "target_visible", "target_enabled" }) |name| { + if (std.mem.eql(u8, field, name)) @field(excluded, name) = false; + } + const calls = probe.calls; + try std.testing.expect(!window.pretranslateKey(&message, excluded)); + try std.testing.expectEqual(calls, probe.calls); + } + for ([_]c.UINT{ c.WM_SYSKEYDOWN, c.WM_KEYUP, c.WM_COMMAND }) |message_type| { + message.message = message_type; + const calls = probe.calls; + try std.testing.expect(!window.pretranslateKey(&message, eligible)); + try std.testing.expectEqual(calls, probe.calls); + } + message.message = c.WM_KEYDOWN; + try std.testing.expect(window.pretranslateKey(&message, eligible)); + try std.testing.expect(!probe.focused); + message.wParam = c.VK_TAB; + try std.testing.expect(!window.pretranslateKey(&message, eligible)); +} + +test "toolbar routing rejects hidden windows without changing accelerator contracts" { + const DispatchProbe = struct { + var command: usize = 0; + fn windowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.c) c.LRESULT { + if (message == c.WM_COMMAND) { + command = wparam & 0xffff; + return 0; + } + return c.DefWindowProcW(hwnd, message, wparam, lparam); + } + }; + const test_class = std.unicode.utf8ToUtf16LeStringLiteral("GraphCodeHiddenAcceleratorTest"); + var window_class = std.mem.zeroes(c.WNDCLASSW); + window_class.hInstance = c.GetModuleHandleW(null); + window_class.lpszClassName = test_class; + window_class.lpfnWndProc = &DispatchProbe.windowProc; + if (c.RegisterClassW(&window_class) == 0) return error.WindowClassRegistrationFailed; + defer _ = c.UnregisterClassW(test_class, window_class.hInstance); + const hwnd = c.CreateWindowExW( + 0, + test_class, + std.unicode.utf8ToUtf16LeStringLiteral("Hidden toolbar routing test"), + c.WS_OVERLAPPED, + 0, + 0, + 100, + 100, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.WindowCreationFailed; + defer _ = c.DestroyWindow(hwnd); + const child = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("BUTTON"), + std.unicode.utf8ToUtf16LeStringLiteral("Child"), + c.WS_CHILD, + 0, + 0, + 20, + 20, + hwnd, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.WindowCreationFailed; + defer _ = c.DestroyWindow(child); + try std.testing.expect(!keyOwnerEligible(hwnd, hwnd)); + try std.testing.expect(!keyOwnerEligible(hwnd, child)); + try std.testing.expect(!keyOwnerEligible(hwnd, null)); + const accelerators = createAccelerators() orelse return error.AcceleratorCreationFailed; + defer _ = c.DestroyAcceleratorTable(accelerators); + var entries: [32]c.ACCEL = undefined; + const count = c.CopyAcceleratorTableW(accelerators, &entries, entries.len); + try std.testing.expect(count > 0); + var tab_count: usize = 0; + for (entries[0..@intCast(count)]) |entry| { + if (entry.key != c.VK_TAB) continue; + tab_count += 1; + const expected: Command = if ((entry.fVirt & c.FCONTROL) != 0) + .review_attention + else if ((entry.fVirt & c.FSHIFT) != 0) + .previous_loop + else + .next_loop; + try std.testing.expectEqual(@intFromEnum(expected), entry.cmd); + } + try std.testing.expectEqual(@as(usize, 3), tab_count); + try installMenu(hwnd); + try std.testing.expect(c.GetMenuState(c.GetMenu(hwnd), @intFromEnum(Command.focus_header), c.MF_BYCOMMAND) != 0xffffffff); + var message = std.mem.zeroes(c.MSG); + message.hwnd = hwnd; + message.message = c.WM_KEYDOWN; + message.wParam = c.VK_TAB; + DispatchProbe.command = 0; + try std.testing.expect(c.TranslateAcceleratorW(hwnd, accelerators, &message) != 0); + try std.testing.expectEqual(@as(usize, @intFromEnum(Command.next_loop)), DispatchProbe.command); +} + fn testWindowProc(hwnd: c.HWND, message: c.UINT, wparam: c.WPARAM, lparam: c.LPARAM) callconv(.c) c.LRESULT { return c.DefWindowProcW(hwnd, message, wparam, lparam); } diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 96e692a8..3c2afa75 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -20,8 +20,8 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit project, overview, Quick Chats, and workspace destinations now exist. Live stub walkthrough verified project → overview → full workspace → Show in Graph with the sidebar retained; destination-specific toolbar and accessibility semantics remain incomplete | Partial | -| Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | The native header now exposes clickable needs-you and reclaimable-worktree chips, a visible Ctrl+P jump affordance, and a contextual loop-panel toggle alongside status, each carrying its own dedicated UIA identity (no longer aliased into the Projects-row or Graph-card automation-id buckets, and correctly parented so they don't pollute either group's exact-child count). The live UIA gate verifies the Graph canvas and Projects grouped-recent-rows assertions still hold with these controls present. Native keyboard focus/tab order for the header controls and macOS visual treatment remain incomplete | Partial | +| Main split view | Persistent sidebar; detail switches among welcome, global graph, project canvas, Quick Chats canvas, and loop workspace | Explicit destinations exist; the earlier stub walkthrough covered project → overview → workspace → Show in Graph with sidebar retention. The shared header now follows destination identity, distinguishes a Quick Chat workspace from a previously selected project, and keeps detail-panel toggling inside the loop workspace. Production-state and layout tests cover the destinations, narrow widths, and sidebar/panel independence. The current live attempt stopped at foreground acquisition before UIA root access: no new live UIA SetFocus, F6, Jump, pixels, or sidebar-effect proof was obtained, and provider-backed workspace/panel behavior remains unverified | Partial | +| Window toolbar | Needs-you chip, worktree notice, jump field, contextual loop-panel toggle | Render, hit-test, UIA exposure, and focus order share header visibility/layout. Needs-you opens the selected attention target; cached local worktree notices honor the owning project and count/size threshold. Jump opens the existing palette even with no projects. The detail toggle is limited to loop workspaces with supported edge/metric content; workspace/project identity is no longer overpainted. F6 entry/exit, scoped traversal/activation, focus rendering, and marshaled UIA button focus are implemented, while ordinary Tab/Shift+Tab/Ctrl+Tab contracts remain. Production-helper tests, a hidden native Tab-to-command test, and a hidden empty-palette create/cancel test pass. Live focus/event-deadlock, keyboard activation, rendered pixels, sidebar effects, and provider-backed panel proof are still absent because foreground acquisition failed. Cross-project worktree-notice discovery/aggregation, summary/board/mailroom panel content, and macOS visual parity remain separate residuals; private live tooling is not CI coverage | Partial | | Jump palette | Search field, ranked cross-project results, type/state/project context, mouse and keyboard selection | Ctrl+P and Ctrl+J open a native modal palette with live exact-ID, exact-title, title-prefix, and substring ranking across projects. Results visibly include project, loop type, and state; Up/Down, Return, Escape, and mouse double-click are supported. The deterministic UIA gate verifies a visible search field, contextual cross-project results, and keyboard navigation changing the selected loop. | Validated | | File/Loop/Terminal menus | Discoverable project, worktree, navigation, workspace, update, settings, and help commands with state-aware enablement | Startup menu replacement and UTF-16 corruption are fixed and the five readable runtime groups were probed. Worktree commands are now contextually gated instead of always-enabled: Reclaim/Reveal Selected Worktree require a selected row and Save Worktree Policy requires the Worktrees dialog to be open, so a command that could never succeed is grayed out rather than surfacing a "select a row first" status message; `worktreeRowSelected()` has dedicated unit coverage. This closes part of the contextual-enablement gap, but it is unit-tested only — live UIA gate evidence for the Worktrees dialog itself is still outstanding, and broader project-management parity remains incomplete | Partial | | Workspace lifecycle | List/switch, create, rename, delete with fail-closed confirmation, and keyboard/UIA reachability | Windows now discovers `Default` plus `.graphcode-*` sibling directories, exposes a native Workspace menu with dynamic checked selection, New/Rename/Delete actions, and Ctrl+Alt paging. Creation and switching launch a support-directory-scoped app/daemon instance; rename/delete reject Default/current workspaces, and delete requires a cancellation-default warning. Focused lifecycle and menu tests pass; live multi-instance/UIA gate evidence remains outstanding this session due to unrelated shared-environment gate instability (see below) | Partial | From c44451e242de746900fb8202355fe7a9b2c5e562 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 25 Sep 2026 10:26:06 -0700 Subject: [PATCH 2/2] Report native accelerator table creation failures Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- graphcode-windows/src/MainWindow.zig | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/graphcode-windows/src/MainWindow.zig b/graphcode-windows/src/MainWindow.zig index 7206b0ab..a466b51a 100644 --- a/graphcode-windows/src/MainWindow.zig +++ b/graphcode-windows/src/MainWindow.zig @@ -552,7 +552,17 @@ fn createAccelerators() c.HACCEL { .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = c.VK_PRIOR, .cmd = @intFromEnum(Command.previous_tab) }, .{ .fVirt = c.FCONTROL | c.FVIRTKEY, .key = 0xBC, .cmd = @intFromEnum(Command.settings) }, }; - return c.CreateAcceleratorTableW(&entries, entries.len); + const accelerators = c.CreateAcceleratorTableW(&entries, entries.len); + if (accelerators == null) { + const last_error = c.GetLastError(); + std.log.err("CreateAcceleratorTableW failed: error={d}, count={d}, ACCEL size={d}, alignment={d}", .{ + last_error, entries.len, @sizeOf(c.ACCEL), @alignOf(c.ACCEL), + }); + for (entries, 0..) |entry, index| { + std.log.err("ACCEL[{d}]: fVirt=0x{x}, key=0x{x}, cmd={d}", .{ index, entry.fVirt, entry.key, entry.cmd }); + } + } + return accelerators; } test "native menu exposes the parity command groups" {