diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index 9c28dbf7..fcd2b3ac 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -427,7 +427,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 c65f367b..dd009064 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 @@ -53,6 +63,10 @@ hit-testing, so they must not be scaled again. Touch pinch locations and client bounds are converted to logical units before graph routing and anchored zoom. App regression tests exercise these production boundaries at 96, 144, and 192 DPI; they do not substitute for live touch or multi-monitor validation. +The destination toolbar and its focus ring render at the end of the buffered +logical pass, before the physical frame is copied to the window. Header UIA +controls use that same logical layout with one physical-boundary conversion; +the workspace identity region remains separate from the terminal's physical tabs. ## Build 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 e57fae5b..8d0d8955 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -300,6 +300,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, @@ -489,6 +493,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 @@ -1204,6 +1209,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(); @@ -2030,10 +2036,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| { @@ -2097,6 +2099,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(); @@ -2710,9 +2713,12 @@ pub const App = struct { const graph = self.model.graph orelse return; if (index >= graph.nodes.items.len) return; var result = NativeDialogs.textWithDescription( - self.window.hwnd, self.allocator, "Save as Template", + self.window.hwnd, + self.allocator, + "Save as Template", "Name this reusable prompt template. It is stored in your per-user GraphCode library.", - &.{"Template name"}, &.{graph.nodes.items[index].title}, + &.{"Template name"}, + &.{graph.nodes.items[index].title}, ) catch { self.setStatus("Unable to open template save form"); return; @@ -3780,12 +3786,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"); @@ -3869,9 +3870,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); @@ -3881,6 +3896,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) physicalCoordinate(Tokens.activity_strip_height, self.dpi) else 0; @@ -4113,9 +4135,150 @@ 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(logicalClientRect(self.window.hwnd, self.dpi).right); + } + + 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; self.syncAccessibilityTo(provider, logicalClientRect(self.window.hwnd, self.dpi)); + self.syncHeaderFocus(); } fn syncAccessibilityTo(self: *App, provider: anytype, client: c.RECT) void { @@ -4142,37 +4305,26 @@ 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, .{ .logical = GraphCanvas.headerAttentionRect() }, false, true) catch return; - } - if (self.worktree_inspection != null) { - self.appendAccessibilityElement(&elements, &owned_identities, "header-worktree", "worktrees", "Reclaimable worktrees", 1, .{ .logical = GraphCanvas.headerWorktreeRect() }, false, true) catch return; - } - self.appendAccessibilityElement(&elements, &owned_identities, "header-jump", "jump", "Jump to Loop", 1, .{ .logical = 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, - .{ .logical = 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 logical_bounds = header_layout.bounds(action) orelse continue; + const bounds = (AccessibilityBounds{ .logical = logical_bounds }).physicalRect(self.dpi); + 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| { @@ -4257,7 +4409,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; @@ -4402,7 +4554,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, .{ .logical = .{ .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, .{ .logical = 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, .{ .logical = .{ .left = workspace_left, .top = Tokens.header_height, .right = workspace_right, .bottom = Tokens.header_height + Tokens.loop_bar_height } }, false, false) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "workspace-show-graph", "show-graph", "Show in Graph", 4, .{ .logical = .{ .left = workspace_right - 104, .top = Tokens.header_height + 10, .right = workspace_right - 12, .bottom = Tokens.header_height + 36 } }, false, false) catch return; if (selected_index < graph.nodes.items.len and !isResolvedLoopState(graph.nodes.items[selected_index].state)) { @@ -4801,6 +4956,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(); @@ -4825,10 +4981,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(); @@ -4838,9 +4994,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")) { @@ -5330,6 +5490,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), @@ -5386,15 +5549,8 @@ fn onWindowMessage( _ = c.SetWindowExtEx(hdc, logical_right, logical_bottom, null); _ = c.SetViewportExtEx(hdc, client.right, client.bottom, null); } - const inspection = if (app.worktree_inspection) |*value| blk: { - const policy = WorktreeStatus.loadPolicy(app.allocator, value.project_path); - const summary = WorktreeStatus.summarize(value.entries.items); - 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 ""; @@ -5404,14 +5560,6 @@ fn onWindowMessage( if (app.surface == .workspace) { if (workspaceGraph(&app.model)) |graph| { const workspace_right = logical_right - (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]; @@ -5455,6 +5603,7 @@ fn onWindowMessage( } } } + GraphCanvas.paintHeader(hdc, app.allocator, logical_right, app.status(), header, if (app.headerOwnsFocus()) app.header_focus else null); _ = c.SetMapMode(hdc, c.MM_TEXT); if (hdc != target_hdc) { const dirty = paint.rcPaint; @@ -5667,14 +5816,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(.state_change); result.* = 0; @@ -5701,24 +5865,13 @@ fn onWindowMessage( return true; } const client = logicalClientRect(hwnd, app.dpi); - 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); @@ -5844,6 +5997,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(); @@ -6052,6 +6206,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(); @@ -6440,6 +6595,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); @@ -6450,6 +6610,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 @@ -6458,6 +6625,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 @@ -6467,7 +6637,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); @@ -6664,9 +6837,56 @@ test "main shell coordinates round trip across common Windows DPI steps" { } } +test "DPI header layout and pointer targets use the logical width of a hidden native client" { + var app: App = .{ + .allocator = std.testing.allocator, + .client = undefined, + .daemon = undefined, + .model = GraphModel.Model.init(std.testing.allocator), + .sidebar_state = undefined, + .declared_entry_ids = undefined, + .kept_worktree_paths = undefined, + }; + defer app.model.deinit(); + for ([_]struct { dpi: u32, width: i32, height: i32, logical_right: i32, jump: [4]i32 }{ + .{ .dpi = 96, .width = 1200, .height = 900, .logical_right = 1200, .jump = .{ 288, 5, 452, 29 } }, + .{ .dpi = 144, .width = 1800, .height = 1350, .logical_right = 1200, .jump = .{ 432, 8, 678, 44 } }, + .{ .dpi = 192, .width = 2400, .height = 1800, .logical_right = 1200, .jump = .{ 576, 10, 904, 58 } }, + .{ .dpi = 96, .width = 640, .height = 900, .logical_right = 640, .jump = .{ 221, 5, 385, 29 } }, + .{ .dpi = 144, .width = 960, .height = 1350, .logical_right = 640, .jump = .{ 332, 8, 578, 44 } }, + .{ .dpi = 192, .width = 1280, .height = 1800, .logical_right = 640, .jump = .{ 442, 10, 770, 58 } }, + }) |case| { + app.window.hwnd = c.CreateWindowExW( + 0, + std.unicode.utf8ToUtf16LeStringLiteral("STATIC"), + std.unicode.utf8ToUtf16LeStringLiteral("Hidden header DPI geometry"), + c.WS_POPUP, + 0, + 0, + case.width, + case.height, + null, + null, + c.GetModuleHandleW(null), + null, + ) orelse return error.WindowCreationFailed; + defer _ = c.DestroyWindow(app.window.hwnd); + app.dpi = case.dpi; + try std.testing.expect(c.IsWindowVisible(app.window.hwnd) == 0); + try std.testing.expectEqual(case.logical_right, logicalClientRect(app.window.hwnd, app.dpi).right); + const layout = app.headerLayout(); + const physical = (AccessibilityBounds{ .logical = layout.bounds(.jump).? }).physicalRect(app.dpi); + try std.testing.expectEqualDeep(case.jump, [4]i32{ physical.left, physical.top, physical.right, physical.bottom }); + const x = logicalCoordinate(case.jump[0] + 4, app.dpi); + const y = logicalCoordinate(case.jump[1] + 4, app.dpi); + try std.testing.expectEqual(GraphCanvas.HeaderAction.jump, layout.actionAt(x, y).?); + try std.testing.expect(layout.actionAt(logicalCoordinate(case.jump[2] + 4, app.dpi), y) == null); + } +} + const DpiExpectedElement = struct { identity: []const u8, - bounds: [3][4]i32, + bounds: ?[3][4]i32 = null, }; const DpiAccessibilitySink = struct { @@ -6693,19 +6913,23 @@ const DpiAccessibilitySink = struct { for (elements) |element| { if (!std.mem.eql(u8, expected.identity, element.identity)) continue; found = true; + const bounds = expected.bounds orelse { + std.debug.print("Unexpected UIA element: {s}\n", .{expected.identity}); + return error.UnexpectedAccessibilityElement; + }; const actual = [4]i32{ element.left, element.top, element.right, element.bottom }; - std.testing.expectEqualDeep(expected.bounds[self.dpi_index], actual) catch |err| { + std.testing.expectEqualDeep(bounds[self.dpi_index], actual) catch |err| { std.debug.print("UIA bounds mismatch: {s}, DPI index {d}\n", .{ expected.identity, self.dpi_index }); return err; }; } - if (!found) std.debug.print("Missing UIA element: {s}\n", .{expected.identity}); - try std.testing.expect(found); + if (!found and expected.bounds != null) std.debug.print("Missing UIA element: {s}\n", .{expected.identity}); + try std.testing.expectEqual(expected.bounds != null, found); } } }; -fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement) !void { +fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expected: []const DpiExpectedElement, quick_chat: bool) !void { const allocator = std.testing.allocator; var app: App = .{ .allocator = allocator, @@ -6719,6 +6943,7 @@ fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expe .declared_entry_ids = std.array_list.Managed([]u8).init(allocator), .kept_worktree_paths = std.array_list.Managed([]u8).init(allocator), .surface = surface, + .workspace_is_quick_chat = quick_chat, .workspace_controls = .{ .rail_visible = true, .panel_visible = true, .activity_enabled = false }, }; defer app.client.deinit(); @@ -6729,7 +6954,7 @@ fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expe app.ingress_error = try allocator.dupe(u8, "Fixture alert"); defer allocator.free(app.ingress_error); _ = try app.model.updateFromFrame( - \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"loop","title":"Loop","state":"running","createdAt":1,"inputTokens":100,"presence":{"presence":"awaitingInput","confidence":"reported"}}],"edges":[]}}} + \\{"version":2,"kind":"event","sequence":1,"event":{"graphChanged":{"id":"g","project":{"path":"A","name":"Alpha"},"nodes":[{"id":"loop","title":"Loop","state":"running","createdAt":1,"inputTokens":100,"metricHistory":[{"value":1},{"value":2}],"presence":{"presence":"awaitingInput","confidence":"reported"}}],"edges":[]}}} ); try app.model.recent_projects.append(.{ .path = try allocator.dupe(u8, "C"), .name = try allocator.dupe(u8, "Recent") }); try app.model.quick_chats.append(.{ @@ -6744,6 +6969,12 @@ fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expe .project_path = try allocator.dupe(u8, "A"), }; defer WorktreeStatus.deinitInspection(allocator, &app.worktree_inspection.?); + // A notice needs a threshold breach; an empty inspection is not visible chrome. + try app.worktree_inspection.?.entries.append(.{ + .path = try allocator.dupe(u8, "A-worktree"), + .branch = try allocator.dupe(u8, "topic"), + .size_bytes = 2 * 1024 * 1024 * 1024, + }); var workspaces = [_]WorkspaceLifecycle.Workspace{ .{ .name = "Fixture", .path = "B", .is_default = false }, }; @@ -6780,34 +7011,38 @@ fn expectDpiAccessibility(surface: GraphCanvas.Surface, canvas: ?[3][4]i32, expe } test "DPI UIA fixed graph uses physical client bounds" { - try expectDpiAccessibility(.project, .{ .{ 220, 34, 1200, 650 }, .{ 330, 51, 1800, 975 }, .{ 440, 68, 2400, 1300 } }, &.{}); + try expectDpiAccessibility(.project, .{ .{ 220, 34, 1200, 650 }, .{ 330, 51, 1800, 975 }, .{ 440, 68, 2400, 1300 } }, &.{}, false); } test "DPI UIA logical cards headers sidebar and direct inserts scale once" { const common = [_]DpiExpectedElement{ - .{ .identity = "header-attention:needs-you", .bounds = .{ .{ 220, 5, 330, 29 }, .{ 330, 8, 495, 44 }, .{ 440, 10, 660, 58 } } }, - .{ .identity = "header-worktree:worktrees", .bounds = .{ .{ 338, 5, 458, 29 }, .{ 507, 8, 687, 44 }, .{ 676, 10, 916, 58 } } }, - .{ .identity = "header-jump:jump", .bounds = .{ .{ 640, 5, 800, 29 }, .{ 960, 8, 1200, 44 }, .{ 1280, 10, 1600, 58 } } }, - .{ .identity = "header-toggle-panel:control", .bounds = .{ .{ 810, 5, 920, 29 }, .{ 1215, 8, 1380, 44 }, .{ 1620, 10, 1840, 58 } } }, + .{ .identity = "header-attention:needs-you", .bounds = .{ .{ 288, 5, 400, 29 }, .{ 432, 8, 600, 44 }, .{ 576, 10, 800, 58 } } }, + .{ .identity = "header-worktree:worktrees", .bounds = .{ .{ 408, 5, 588, 29 }, .{ 612, 8, 882, 44 }, .{ 816, 10, 1176, 58 } } }, + .{ .identity = "header-jump:jump", .bounds = .{ .{ 596, 5, 760, 29 }, .{ 894, 8, 1140, 44 }, .{ 1192, 10, 1520, 58 } } }, .{ .identity = "sidebar-section:local", .bounds = .{ .{ 12, 109, 232, 135 }, .{ 18, 164, 348, 203 }, .{ 24, 218, 464, 270 } } }, .{ .identity = "sidebar-error-footer:ingress", .bounds = .{ .{ 8, 816, 212, 858 }, .{ 12, 1224, 318, 1287 }, .{ 16, 1632, 424, 1716 } } }, .{ .identity = "workspace-switch:B", .bounds = .{ .{ 250, 34, 500, 62 }, .{ 375, 51, 750, 93 }, .{ 500, 68, 1000, 124 } } }, }; for ([_]GraphCanvas.Surface{ .project, .overview, .quick_chats, .workspace }) |surface| { - try expectDpiAccessibility(surface, null, &common); + try expectDpiAccessibility(surface, null, &common, false); + if (surface != .workspace) try expectDpiAccessibility(surface, null, &.{ + .{ .identity = "header-toggle-panel:control" }, + .{ .identity = "workspace-toolbar:A" }, + }, false); } try expectDpiAccessibility(.project, null, &.{ .{ .identity = "project-card:A:loop", .bounds = .{ .{ 252, 84, 502, 190 }, .{ 378, 126, 753, 285 }, .{ 504, 168, 1004, 380 } } }, .{ .identity = "canvas-alert:ingress", .bounds = .{ .{ 244, 556, 1176, 612 }, .{ 366, 834, 1764, 918 }, .{ 488, 1112, 2352, 1224 } } }, - }); + }, false); try expectDpiAccessibility(.overview, null, &.{ .{ .identity = "overview-card:A:loop", .bounds = .{ .{ 262, 118, 482, 204 }, .{ 393, 177, 723, 306 }, .{ 524, 236, 964, 408 } } }, - }); + }, false); try expectDpiAccessibility(.quick_chats, null, &.{ .{ .identity = "quick-chat-card:chat", .bounds = .{ .{ 262, 88, 482, 152 }, .{ 393, 132, 723, 228 }, .{ 524, 176, 964, 304 } } }, - }); + }, false); try expectDpiAccessibility(.workspace, null, &.{ - .{ .identity = "workspace-toolbar:A", .bounds = .{ .{ 220, 0, 928, 34 }, .{ 330, 0, 1392, 51 }, .{ 440, 0, 1856, 68 } } }, + .{ .identity = "header-toggle-panel:control", .bounds = .{ .{ 768, 5, 904, 29 }, .{ 1152, 8, 1356, 44 }, .{ 1536, 10, 1808, 58 } } }, + .{ .identity = "workspace-toolbar:A", .bounds = .{ .{ 8, 1, 280, 33 }, .{ 12, 2, 420, 50 }, .{ 16, 2, 560, 66 } } }, .{ .identity = "workspace-loop-bar:loop", .bounds = .{ .{ 220, 34, 928, 80 }, .{ 330, 51, 1392, 120 }, .{ 440, 68, 1856, 160 } } }, .{ .identity = "workspace-show-graph:show-graph", .bounds = .{ .{ 824, 44, 916, 70 }, .{ 1236, 66, 1374, 105 }, .{ 1648, 88, 1832, 140 } } }, .{ .identity = "workspace-stop:loop", .bounds = .{ .{ 732, 44, 816, 70 }, .{ 1098, 66, 1224, 105 }, .{ 1464, 88, 1632, 140 } } }, @@ -6815,8 +7050,13 @@ test "DPI UIA logical cards headers sidebar and direct inserts scale once" { .{ .identity = "workspace-detail-sparkline:loop", .bounds = .{ .{ 946, 798, 1182, 830 }, .{ 1419, 1197, 1773, 1245 }, .{ 1892, 1596, 2364, 1660 } } }, .{ .identity = "workspace-detail-start:loop", .bounds = .{ .{ 946, 836, 1182, 856 }, .{ 1419, 1254, 1773, 1284 }, .{ 1892, 1672, 2364, 1712 } } }, .{ .identity = "workspace-detail-usage:loop", .bounds = .{ .{ 946, 856, 1182, 876 }, .{ 1419, 1284, 1773, 1314 }, .{ 1892, 1712, 2364, 1752 } } }, + .{ .identity = "quick-chat-workspace:chat" }, + }, false); + try expectDpiAccessibility(.workspace, null, &.{ .{ .identity = "quick-chat-workspace:chat", .bounds = .{ .{ 220, 650, 1200, 900 }, .{ 330, 975, 1800, 1350 }, .{ 440, 1300, 2400, 1800 } } }, - }); + .{ .identity = "header-toggle-panel:control" }, + .{ .identity = "workspace-toolbar:A" }, + }, true); } test "DPI UIA terminal tab close and controls retain physical geometry" { @@ -6826,7 +7066,7 @@ test "DPI UIA terminal tab close and controls retain physical geometry" { .{ .identity = "workspace-new-tab:control", .bounds = .{ .{ 708, 83, 776, 107 }, .{ 1172, 123, 1240, 147 }, .{ 1636, 163, 1704, 187 } } }, .{ .identity = "workspace-split-right:control", .bounds = .{ .{ 780, 83, 848, 107 }, .{ 1244, 123, 1312, 147 }, .{ 1708, 163, 1776, 187 } } }, .{ .identity = "workspace-split-down:control", .bounds = .{ .{ 852, 83, 920, 107 }, .{ 1316, 123, 1384, 147 }, .{ 1780, 163, 1848, 187 } } }, - }); + }, false); } test "DPI gesture mapper classifies scaled sidebar and graph boundaries" { @@ -7063,6 +7303,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 d7e5e5d9..aafc8a4d 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 9df8f17a..ca7565d6 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, @@ -144,6 +147,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 @@ -209,14 +213,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; @@ -305,6 +349,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)); @@ -516,7 +561,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" { @@ -526,6 +581,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 24f86f63..111b1c69 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. The header paints inside the buffered logical pass; literal 96/144/192-DPI bounds and hidden native client tests cover logical layout/input and exactly-once physical UIA conversion without rescaling terminal tabs. 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 |