Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Tools/windows/Tests/WindowsShell.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down
18 changes: 16 additions & 2 deletions graphcode-windows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down
13 changes: 13 additions & 0 deletions graphcode-windows/src/Accessibility.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
94 changes: 89 additions & 5 deletions graphcode-windows/src/AccessibilityProvider.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

Expand Down Expand Up @@ -108,7 +124,7 @@ class Node final : public IRawElementProviderSimple,
else if (iid == __uuidof(ISelectionProvider) &&
((id_ >= 1 && id_ <= 4) || id_ == 21))
*out = static_cast<ISelectionProvider *>(this);
else if (iid == __uuidof(ISelectionItemProvider) && isAvailableRow())
else if (iid == __uuidof(ISelectionItemProvider) && supportsSelectionItem())
*out = static_cast<ISelectionItemProvider *>(this);
else if (iid == __uuidof(IToggleProvider) && (id_ == 12 || id_ == 13))
*out = static_cast<IToggleProvider *>(this);
Expand Down Expand Up @@ -142,7 +158,7 @@ class Node final : public IRawElementProviderSimple,
else if (id == UIA_SelectionPatternId &&
((id_ >= 1 && id_ <= 4) || id_ == 21))
*value = static_cast<ISelectionProvider *>(this);
else if (id == UIA_SelectionItemPatternId && isAvailableRow())
else if (id == UIA_SelectionItemPatternId && supportsSelectionItem())
*value = static_cast<ISelectionItemProvider *>(this);
else if (id == UIA_TogglePatternId && (id_ == 12 || id_ == 13))
*value = static_cast<IToggleProvider *>(this);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<std::mutex> 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<WPARAM>(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);
Expand Down Expand Up @@ -502,6 +538,37 @@ class Node final : public IRawElementProviderSimple,
return S_OK;
}

HRESULT setHeaderFocus(const char *identity) {
Node *focused = nullptr;
{
std::lock_guard<std::mutex> 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<IRawElementProviderSimple *>(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,
Expand Down Expand Up @@ -702,6 +769,12 @@ class Node final : public IRawElementProviderSimple,
std::lock_guard<std::mutex> lock(state_->mutex);
return isAvailableLocked() && isRowKey(id_);
}
bool supportsSelectionItem() const {
std::lock_guard<std::mutex> 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<int64_t, Row> &pending) const {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -931,6 +1006,9 @@ class Node final : public IRawElementProviderSimple,
{
std::lock_guard<std::mutex> 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<IRawElementProviderFragment *>(focused);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<Node *>(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;
Expand Down
Loading
Loading