diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index eee96a8d..6d4093b2 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -318,6 +318,10 @@ Assert-Contract ($codespaceDialogSource -match 'IsDialogMessageW') ` "the codespace sheet must remain keyboard navigable" $zig = Resolve-TestZig +Invoke-Native "Accessibility contract executable tests" { + Push-Location $shellRoot + try { & $zig test src\Accessibility.zig } finally { Pop-Location } +} Invoke-Native "Wire executable tests" { Push-Location $shellRoot try { & $zig test src\Wire.zig } finally { Pop-Location } diff --git a/Tools/windows/uia-live-gate.ps1 b/Tools/windows/uia-live-gate.ps1 index 610701a0..627923a4 100644 --- a/Tools/windows/uia-live-gate.ps1 +++ b/Tools/windows/uia-live-gate.ps1 @@ -681,7 +681,7 @@ try { } if ($null -eq $root) { throw "shell did not expose graphcode-root through WM_GETOBJECT" } - $expectedRootIds = @("projects", "loops", "worktrees", "graph", "actions", "status") + $expectedRootIds = @("projects", "loops", "worktrees", "graph", "actions", "status", "workspaces") $rawWalker = [System.Windows.Automation.TreeWalker]::RawViewWalker $controlWalker = [System.Windows.Automation.TreeWalker]::ControlViewWalker $shellWindow = $process.MainWindowHandle @@ -734,6 +734,28 @@ try { $rawRootChildren = @(Assert-FragmentLinks $root $rawWalker $expectedRootIds "RawView root") $controlRootChildren = @(Assert-FragmentLinks $root $controlWalker $expectedRootIds "ControlView root") + $workspaces = Find-FragmentById $root "workspaces" $rawWalker + Require ($null -ne $workspaces) "Workspace lifecycle menu was not exposed" + $null = $workspaces.GetCurrentPattern([System.Windows.Automation.SelectionPattern]::Pattern) + $workspaceLifecycle = @( + @{ Id = "workspace-new"; Name = "New Workspace" }, + @{ Id = "workspace-rename"; Name = "Rename Workspace" }, + @{ Id = "workspace-delete"; Name = "Delete Workspace" } + ) + foreach ($expected in $workspaceLifecycle) { + $element = Find-FragmentById $workspaces $expected.Id $rawWalker + Require (($null -ne $element) -and ($element.Current.Name -eq $expected.Name)) ` + "$($expected.Name) was not exposed under the Workspace lifecycle menu" + $null = $element.GetCurrentPattern([System.Windows.Automation.InvokePattern]::Pattern) + } + $workspaceSwitches = @(Get-DirectChildren $workspaces $rawWalker | + Where-Object { $_.Current.AutomationId -match '^workspace-switch-' }) + Require ($workspaceSwitches.Count -ge 1) "Workspace lifecycle menu did not expose the current workspace" + $expectedWorkspaceIds = @($workspaceLifecycle.Id) + + @($workspaceSwitches | ForEach-Object { $_.Current.AutomationId }) + $null = @(Assert-FragmentLinks $workspaces $rawWalker $expectedWorkspaceIds "RawView Workspaces") + $null = @(Assert-FragmentLinks $workspaces $controlWalker $expectedWorkspaceIds "ControlView Workspaces") + $projects = Find-FragmentById $root "projects" $rawWalker $loops = Find-FragmentById $root "loops" $rawWalker $graph = Find-FragmentById $root "graph" $rawWalker diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index 8911203e..eb336e86 100644 --- a/graphcode-windows/src/Accessibility.zig +++ b/graphcode-windows/src/Accessibility.zig @@ -42,8 +42,8 @@ extern fn gc_uia_update( confirm_each_reclaim: c_int, ) c.HRESULT; -pub const Role = enum { window, navigation, list, list_item, button, card, menu, menu_item, text, terminal, status, dialog }; -pub const Pattern = enum { invoke, selection, selection_item, expand_collapse, scroll, value, text }; +pub const Role = enum { window, navigation, list, list_item, button, checkbox, card, menu, menu_item, text, terminal, status, dialog }; +pub const Pattern = enum { invoke, selection, selection_item, expand_collapse, scroll, value, text, toggle }; pub const Element = struct { id: []const u8, name: []const u8, @@ -80,6 +80,9 @@ pub const uia_zoom_out_command: usize = 23; pub const uia_actual_size_command: usize = 24; pub const uia_zoom_in_command: usize = 25; pub const uia_fit_command: usize = 26; +pub const uia_workspace_new_command: usize = 27; +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; @@ -264,41 +267,44 @@ pub const Provider = struct { for (self.elements.items[index].patterns) |candidate| if (candidate == pattern) return true; return false; } + pub fn indexOfId(self: *const Provider, id: []const u8) ?usize { + for (self.elements.items, 0..) |element, index| { + if (std.mem.eql(u8, element.id, id)) return index; + } + return null; + } }; pub fn defaultContract(allocator: std.mem.Allocator) !Provider { var provider = Provider.init(allocator); errdefer provider.deinit(); - const window = try provider.add(.{ .id = "window", .name = "GraphCode Windows", .role = .window }); - const sidebar = try provider.add(.{ .id = "sidebar", .name = "Navigation", .role = .navigation, .parent = window }); - _ = try provider.add(.{ .id = "projects", .name = "Projects", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); - _ = try provider.add(.{ .id = "loops", .name = "Loops", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); - _ = try provider.add(.{ .id = "worktrees", .name = "Worktrees", .role = .list, .parent = sidebar, .focusable = true, .patterns = &.{ .selection, .scroll } }); + const window = try provider.add(.{ .id = "graphcode-root", .name = "GraphCode UIA Root", .role = .window }); + const projects = try provider.add(.{ .id = "projects", .name = "Projects", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); + _ = try provider.add(.{ .id = "loops", .name = "Loops", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); + _ = try provider.add(.{ .id = "worktrees", .name = "Worktrees", .role = .list, .parent = window, .focusable = true, .patterns = &.{.selection} }); const graph = try provider.add(.{ .id = "graph", .name = "Graph", .role = .navigation, .parent = window }); - _ = try provider.add(.{ .id = "graph-card", .name = "Graph card", .role = .card, .parent = graph, .focusable = true, .patterns = &.{ .selection, .invoke } }); - _ = try provider.add(.{ .id = "overview-destination", .name = "Graph", .role = .button, .parent = sidebar, .focusable = true, .patterns = &.{.invoke} }); - _ = try provider.add(.{ .id = "quick-chats-destination", .name = "Quick Chats", .role = .button, .parent = sidebar, .focusable = true, .patterns = &.{.invoke} }); + const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window }); + _ = try provider.add(.{ .id = "status", .name = "Status", .role = .status, .parent = window }); + _ = try provider.add(.{ .id = "inspect-worktrees", .name = "Inspect worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "reclaim-worktrees", .name = "Reclaim selected worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "reveal-worktree", .name = "Reveal in Explorer", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "edit-worktree-policy", .name = "Edit worktree policy", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "save-worktree-policy", .name = "Save worktree policy", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "allow-reclaim", .name = "Allow reclaim", .role = .checkbox, .parent = menu, .patterns = &.{ .invoke, .toggle } }); + _ = try provider.add(.{ .id = "confirm-each-reclaim", .name = "Confirm each reclaim", .role = .checkbox, .parent = menu, .patterns = &.{ .invoke, .toggle } }); + _ = try provider.add(.{ .id = "overview-destination", .name = "Graph", .role = .button, .parent = projects, .focusable = true, .patterns = &.{.invoke} }); + _ = try provider.add(.{ .id = "quick-chats-destination", .name = "Quick Chats", .role = .button, .parent = projects, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "canvas-primary-action", .name = "New Loop or Chat", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "zoom-out", .name = "Zoom out", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "actual-size", .name = "Actual size", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "zoom-in", .name = "Zoom in", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); _ = try provider.add(.{ .id = "fit-canvas", .name = "Fit canvas", .role = .button, .parent = graph, .focusable = true, .patterns = &.{.invoke} }); - const menu = try provider.add(.{ .id = "actions", .name = "Actions", .role = .menu, .parent = window, .focusable = true, .patterns = &.{.expand_collapse} }); - _ = try provider.add(.{ .id = "inspect-worktrees", .name = "Inspect worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); - _ = try provider.add(.{ .id = "reclaim-worktrees", .name = "Reclaim selected worktrees", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); - _ = try provider.add(.{ .id = "reveal-worktree", .name = "Reveal in Explorer", .role = .menu_item, .parent = menu, .patterns = &.{.invoke} }); - _ = try provider.add(.{ .id = "terminal-a", .name = "Terminal A", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); - _ = try provider.add(.{ .id = "terminal-b", .name = "Terminal B", .role = .terminal, .parent = window, .focusable = true, .patterns = &.{ .text, .scroll } }); - _ = try provider.add(.{ .id = "status", .name = "Status", .role = .status, .parent = window }); - _ = try provider.add(.{ .id = "errors", .name = "Errors", .role = .status, .parent = window }); - _ = try provider.add(.{ .id = "move-project-unavailable", .name = "Move project unavailable: daemon support required", .role = .menu_item, .parent = menu, .patterns = &.{.text} }); const workspaces = try provider.add(.{ .id = "workspaces", .name = "Workspaces", .role = .menu, .parent = window, - .focusable = true, - .patterns = &.{ .selection, .expand_collapse }, + .patterns = &.{.selection}, }); _ = try provider.add(.{ .id = "workspace-new", @@ -343,27 +349,63 @@ pub fn log(announcement: Announcement) void { test "UIA contract exposes named roles patterns and deterministic focus order" { var provider = try defaultContract(std.testing.allocator); defer provider.deinit(); - try std.testing.expectEqual(Role.navigation, provider.elements.items[1].role); - try std.testing.expect(provider.hasPattern(2, .selection)); - try std.testing.expect(provider.hasPattern(22, .text)); - try std.testing.expect(!provider.hasPattern(22, .invoke)); - try std.testing.expectEqual(Role.menu_item, provider.elements.items[22].role); - try std.testing.expectEqualStrings("move-project-unavailable", provider.elements.items[22].id); - try std.testing.expectEqualStrings( - "Move project unavailable: daemon support required", - provider.elements.items[22].name, - ); - try std.testing.expectEqualStrings("workspaces", provider.elements.items[23].id); - try std.testing.expect(provider.hasPattern(23, .selection)); - try std.testing.expect(provider.hasPattern(23, .expand_collapse)); - try std.testing.expectEqualStrings("workspace-new", provider.elements.items[24].id); - try std.testing.expect(provider.hasPattern(24, .invoke)); - try std.testing.expectEqualStrings("workspace-rename", provider.elements.items[25].id); - try std.testing.expect(provider.hasPattern(25, .invoke)); - try std.testing.expectEqualStrings("workspace-delete", provider.elements.items[26].id); - try std.testing.expect(provider.hasPattern(26, .invoke)); - try std.testing.expectEqual(@as(?usize, 3), provider.nextFocus(2)); - try std.testing.expectEqual(@as(?usize, 4), provider.nextFocus(3)); + const projects = provider.indexOfId("projects").?; + const loops = provider.indexOfId("loops").?; + const worktrees = provider.indexOfId("worktrees").?; + const graph = provider.indexOfId("graph").?; + const workspaces = provider.indexOfId("workspaces").?; + const workspace_new = provider.indexOfId("workspace-new").?; + const workspace_rename = provider.indexOfId("workspace-rename").?; + const workspace_delete = provider.indexOfId("workspace-delete").?; + try std.testing.expectEqual(Role.list, provider.elements.items[projects].role); + try std.testing.expect(provider.hasPattern(projects, .selection)); + try std.testing.expectEqual(Role.navigation, provider.elements.items[graph].role); + try std.testing.expectEqual(Role.menu, provider.elements.items[workspaces].role); + try std.testing.expect(provider.hasPattern(workspaces, .selection)); + try std.testing.expect(provider.hasPattern(workspace_new, .invoke)); + try std.testing.expect(provider.hasPattern(workspace_rename, .invoke)); + try std.testing.expect(provider.hasPattern(workspace_delete, .invoke)); + try std.testing.expectEqual(@as(?usize, loops), provider.nextFocus(projects)); + try std.testing.expectEqual(@as(?usize, worktrees), provider.nextFocus(loops)); +} + +test "default contract ids match the native fixed element table" { + const native_source = @embedFile("AccessibilityProvider.cpp"); + const table_start_marker = "static const wchar_t *ids[] = {"; + const table_start = std.mem.indexOf(u8, native_source, table_start_marker) orelse + return error.NativeAutomationIdTableMissing; + const table_tail = native_source[table_start + table_start_marker.len ..]; + const table_end = std.mem.indexOf(u8, table_tail, "};") orelse + return error.NativeAutomationIdTableMissing; + const table = table_tail[0..table_end]; + + var provider = try defaultContract(std.testing.allocator); + defer provider.deinit(); + for (provider.elements.items) |element| { + const needle = try std.fmt.allocPrint(std.testing.allocator, "L\"{s}\"", .{element.id}); + defer std.testing.allocator.free(needle); + errdefer std.debug.print("defaultContract id has no native fixed element: {s}\n", .{element.id}); + try std.testing.expect(std.mem.indexOf(u8, table, needle) != null); + } + + var native_ids = std.array_list.Managed([]const u8).init(std.testing.allocator); + defer native_ids.deinit(); + var remaining = table; + while (std.mem.indexOf(u8, remaining, "L\"")) |id_start| { + const id_tail = remaining[id_start + 2 ..]; + const id_end = std.mem.indexOfScalar(u8, id_tail, '"') orelse + return error.NativeAutomationIdTableMalformed; + const native_id = id_tail[0..id_end]; + for (native_ids.items) |existing| { + errdefer std.debug.print("native fixed element id is duplicated: {s}\n", .{native_id}); + try std.testing.expect(!std.mem.eql(u8, existing, native_id)); + } + try native_ids.append(native_id); + errdefer std.debug.print("native fixed element has no defaultContract id: {s}\n", .{native_id}); + try std.testing.expect(provider.indexOfId(native_id) != null); + remaining = id_tail[id_end + 1 ..]; + } + try std.testing.expectEqual(provider.elements.items.len, native_ids.items.len); } test "status and error announcements are retained for screen readers" { diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index 64afd838..afa7d13e 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -99,7 +99,8 @@ class Node final : public IRawElementProviderSimple, *out = static_cast(this); else if (iid == __uuidof(IInvokeProvider) && supportsInvoke()) *out = static_cast(this); - else if (iid == __uuidof(ISelectionProvider) && id_ >= 1 && id_ <= 4) + else if (iid == __uuidof(ISelectionProvider) && + ((id_ >= 1 && id_ <= 4) || id_ == 21)) *out = static_cast(this); else if (iid == __uuidof(ISelectionItemProvider) && isAvailableRow()) *out = static_cast(this); @@ -132,7 +133,8 @@ class Node final : public IRawElementProviderSimple, *value = nullptr; if (id == UIA_InvokePatternId && supportsInvoke()) *value = static_cast(this); - else if (id == UIA_SelectionPatternId && id_ >= 1 && id_ <= 4) + else if (id == UIA_SelectionPatternId && + ((id_ >= 1 && id_ <= 4) || id_ == 21)) *value = static_cast(this); else if (id == UIA_SelectionItemPatternId && isAvailableRow()) *value = static_cast(this); @@ -391,7 +393,8 @@ class Node final : public IRawElementProviderSimple, id_ == 10 ? 9 : id_ == 11 ? 10 : id_ == 12 ? 12 : id_ == 13 ? 13 : id_ == 14 ? 20 : id_ == 15 ? 21 : id_ == 16 ? 22 : id_ == 17 ? 23 : id_ == 18 ? 24 : - id_ == 19 ? 25 : 26; + id_ == 19 ? 25 : id_ == 20 ? 26 : id_ == 22 ? 27 : + id_ == 23 ? 28 : 29; PostMessageW(hwnd, WM_COMMAND, command, 0); return S_OK; } @@ -410,7 +413,8 @@ class Node final : public IRawElementProviderSimple, std::vector selected; { std::lock_guard lock(state_->mutex); - if (!isAvailableLocked() || id_ < 1 || id_ > 4) { + if (!isAvailableLocked() || + !((id_ >= 1 && id_ <= 4) || id_ == 21)) { *value = nullptr; return UIA_E_ELEMENTNOTAVAILABLE; } @@ -646,7 +650,7 @@ class Node final : public IRawElementProviderSimple, bool retired_ = false; bool supportsInvoke() const { - if (id_ == 0 || (id_ >= 7 && id_ <= 20)) return true; + if ((id_ >= 7 && id_ <= 20) || (id_ >= 22 && id_ <= 24)) return true; std::lock_guard lock(state_->mutex); const auto row = state_->rows.find(id_); return row != state_->rows.end() && row->second.invokable; @@ -699,6 +703,8 @@ class Node final : public IRawElementProviderSimple, if (id_ >= 7 && id_ <= 13) return 5; if (id_ == 14 || id_ == 15) return 1; if (id_ >= 16 && id_ <= 20) return 4; + if (id_ == 21) return 0; + if (id_ >= 22 && id_ <= 24) return 21; return -1; } int64_t firstChildLocked() const { @@ -710,10 +716,11 @@ class Node final : public IRawElementProviderSimple, if (id_ == 5) return 7; if (id_ == 1) return 14; if (id_ == 4) return 16; + if (id_ == 21) return 22; return -1; } int64_t lastChildLocked() const { - if (id_ == 0) return 6; + if (id_ == 0) return 21; if (id_ == 2 || id_ == 3) { for (auto current = state_->row_order.rbegin(); current != state_->row_order.rend(); ++current) if (state_->rows.at(*current).parent == id_) return *current; @@ -721,6 +728,11 @@ class Node final : public IRawElementProviderSimple, if (id_ == 5) return 13; if (id_ == 1) return 15; if (id_ == 4) return 20; + if (id_ == 21) { + for (auto current = state_->row_order.rbegin(); current != state_->row_order.rend(); ++current) + if (state_->rows.at(*current).parent == id_) return *current; + return 24; + } return -1; } int64_t siblingLocked(int delta) const { @@ -732,6 +744,7 @@ class Node final : public IRawElementProviderSimple, const auto current = std::find(siblings.begin(), siblings.end(), id_); if (current == siblings.end()) return -1; const auto index = current - siblings.begin() + delta; + if (index < 0 && parent == 21) return 24; if (index < 0) return -1; if (index >= static_cast(siblings.size())) { return parent == 1 ? 14 : parent == 4 ? 16 : -1; @@ -739,9 +752,11 @@ class Node final : public IRawElementProviderSimple, return siblings[static_cast(index)]; } if (id_ >= 1 && id_ <= 6) { + if (id_ == 6 && delta > 0) return 21; const int64_t next = id_ + delta; return next >= 1 && next <= 6 ? next : -1; } + if (id_ == 21) return delta < 0 ? 6 : -1; if (id_ >= 7 && id_ <= 13) { const int64_t next = id_ + delta; return next >= 7 && next <= 13 ? next : -1; @@ -762,6 +777,14 @@ class Node final : public IRawElementProviderSimple, const int64_t next = id_ + delta; return next >= 16 && next <= 20 ? next : -1; } + if (id_ >= 22 && id_ <= 24) { + if (id_ == 24 && delta > 0) { + for (int64_t key : state_->row_order) + if (state_->rows.at(key).parent == 21) return key; + } + const int64_t next = id_ + delta; + return next >= 22 && next <= 24 ? next : -1; + } return -1; } std::wstring nameLocked() const { @@ -772,9 +795,10 @@ class Node final : public IRawElementProviderSimple, L"Status", L"Inspect worktrees", L"Reclaim selected worktrees", L"Reveal in Explorer", L"Edit worktree policy", L"Save worktree policy", L"Allow reclaim", L"Confirm each reclaim", L"Graph", L"Quick Chats", L"New Loop or Chat", L"Zoom out", - L"Actual size", L"Zoom in", L"Fit canvas", + L"Actual size", L"Zoom in", L"Fit canvas", L"Workspaces", + L"New Workspace", L"Rename Workspace", L"Delete Workspace", }; - return names[id_ >= 0 && id_ <= 20 ? id_ : 0]; + return names[id_ >= 0 && id_ <= 24 ? id_ : 0]; } std::wstring automationIdLocked() const { if (isRowKey(id_)) { @@ -812,6 +836,7 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("workspace-new-tab:", 0) == 0 ? L"workspace-new-tab-" : row.identity.rfind("workspace-split-right:", 0) == 0 ? L"workspace-split-right-" : row.identity.rfind("workspace-split-down:", 0) == 0 ? L"workspace-split-down-" : + row.identity.rfind("workspace-switch:", 0) == 0 ? L"workspace-switch-" : row.identity.rfind("sidebar-error-footer:", 0) == 0 ? L"sidebar-error-footer-" : parent == 1 ? L"project-row-" : parent == 2 ? L"loop-row-" : @@ -823,9 +848,10 @@ class Node final : public IRawElementProviderSimple, L"status", L"inspect-worktrees", L"reclaim-worktrees", L"reveal-worktree", L"edit-worktree-policy", L"save-worktree-policy", L"allow-reclaim", L"confirm-each-reclaim", L"overview-destination", L"quick-chats-destination", L"canvas-primary-action", - L"zoom-out", L"actual-size", L"zoom-in", L"fit-canvas", + L"zoom-out", L"actual-size", L"zoom-in", L"fit-canvas", L"workspaces", + L"workspace-new", L"workspace-rename", L"workspace-delete", }; - return ids[id_ >= 0 && id_ <= 20 ? id_ : 0]; + return ids[id_ >= 0 && id_ <= 24 ? id_ : 0]; } CONTROLTYPEID controlTypeLocked() const { if (id_ == 0) return UIA_WindowControlTypeId; @@ -850,9 +876,10 @@ class Node final : public IRawElementProviderSimple, row.identity.rfind("loop-disclosure:", 0) == 0; return row.parent == 4 || action ? UIA_ButtonControlTypeId : UIA_ListItemControlTypeId; } - if ((id_ >= 7 && id_ <= 11) || (id_ >= 14 && id_ <= 20)) return UIA_ButtonControlTypeId; + if ((id_ >= 7 && id_ <= 11) || (id_ >= 14 && id_ <= 20) || + (id_ >= 22 && id_ <= 24)) return UIA_ButtonControlTypeId; if (id_ == 12 || id_ == 13) return UIA_CheckBoxControlTypeId; - if (id_ == 5) return UIA_MenuControlTypeId; + if (id_ == 5 || id_ == 21) return UIA_MenuControlTypeId; if (id_ == 6) return UIA_StatusBarControlTypeId; return UIA_PaneControlTypeId; } diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index a7f49253..b41f7b1a 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -3904,7 +3904,7 @@ pub const App = struct { elements.append(.{ .identity = identity, .name = workspace.name, - .parent = 5, + .parent = 21, .selected = WorkspaceLifecycle.isSamePath(workspace.path, self.workspace_path), .eligible = true, .invokable = true, @@ -4667,6 +4667,9 @@ fn onWindowMessage( app.syncAccessibility(); _ = c.InvalidateRect(hwnd, null, 0); }, + Accessibility.uia_workspace_new_command => app.createWorkspace(), + Accessibility.uia_workspace_rename_command => app.renameWorkspace(), + Accessibility.uia_workspace_delete_command => app.deleteWorkspace(), else => if (wparam >= 1000 and wparam < 2000) { _ = app.toggleWorktreeRow(@intCast(wparam - 1000)); }, diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index 14e6c301..3b2f6fa2 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -151,7 +151,7 @@ Statuses: | macOS surface | Required visible behavior | Windows evidence | Status | |---|---|---|---| -| UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. `TerminalSurface.zig`'s `onAccessibilitySelection` and `onMetricsChanged` callbacks previously discarded every value winghostty passed them; they now record the reported selection range and cell metrics per surface so a future embedded-terminal-text UIA provider has real data to expose instead of none. A full `ITextProvider` for terminal content, daemon-to-model UIA integration, and several remaining dialogs still need end-to-end evidence | Partial | +| UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. The Workspace menu's New, Rename, and Delete lifecycle commands and dynamic workspace-switch rows are now part of that native provider tree instead of existing only in `Accessibility.zig`; the Zig contract was reduced to the native fixed table, and a pinned-Zig executable test now fails when any contract id lacks an exact native fixed-table id. The live gate now also traverses the Workspace menu in RawView and ControlView, checks its fixed lifecycle names and InvokePattern exposure, and verifies at least one `workspace-switch-*` row under the same parent. `TerminalSurface.zig`'s `onAccessibilitySelection` and `onMetricsChanged` callbacks previously discarded every value winghostty passed them; they now record the reported selection range and cell metrics per surface so a future embedded-terminal-text UIA provider has real data to expose instead of none. A full `ITextProvider` for terminal content, daemon-to-model UIA integration, and several remaining dialogs still need end-to-end evidence | Partial | | Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated | | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | Restored File, Loop, Terminal, View, and Help menus expose the primary project, graph, terminal, workspace, settings, update, and zoom commands with shortcut labels. Some context-only actions and canvas gestures still lack visible hints | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Winghostty gate covers terminal IME; generic EDIT controls cover forms | Partial |