From 7d6d663f94eda7ca39c89e00958c493888283c59 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Fri, 9 Oct 2026 23:38:40 -0700 Subject: [PATCH 1/3] Fix Windows sidebar scroll painting and UIA hit-testing, scrolling, and short windows Scrolled Needs-you Stop buttons and the Activity filter, arrows and cards were painted with a zero scroll offset while click handling and UIA used the real one, so after scrolling they were clipped or drawn away from where UIA and the mouse believed they were. Sidebar.draw now passes the real offset. The right-hand Activity control also extended 10 px past the 220 px rail, so it was painted half-clipped but published and clickable in full; both arrows now sit inside the rail. The native UIA provider's ElementProviderFromPoint returned the focused element for every point. It now resolves the point against the published rows, destinations, banner, header, and canvas with paint-order tiers and returns nothing where nothing is drawn. The provider had no IScrollProvider or IScrollItemProvider, so keyboard and assistive clients could not bring an offscreen row into view. The sidebar lists now expose a vertical-only IScrollProvider over the one sidebar offset and every scrolling sidebar element exposes IScrollItemProvider; the shell answers a new wm_sidebar_scroll message by moving that offset. On short windows the footer could leave no sidebar rows at all. The sidebar now keeps two rows of scroll region by collapsing the error footer, then the update banner, and the window's minimum height is the smallest at which the status line, Activity strip and Workspace panel still leave that region. Tests: native provider tests for point resolution (rows, destinations, nested controls, header and banner z-order, scrolled-out rows, canvas), the scroll patterns and their shell messages, and the banner's COM Invoke command; App tests that diff painted pixels against published UIA rects and click targets for scrolled Needs-you and Activity at 96 and 144 DPI and heights 820 to 300, footer collapse, minimum height, scroll-into-view of every sidebar element, and banner click versus the invoke command opening the same offer. No Dev Box pixels or screen reader were involved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../AccessibilityProvider.Native.Tests.cpp | 335 ++++++++ graphcode-windows/README.md | 26 + graphcode-windows/src/Accessibility.zig | 25 +- .../src/AccessibilityProvider.cpp | 247 +++++- graphcode-windows/src/App.zig | 763 +++++++++++++++++- graphcode-windows/src/Sidebar.zig | 86 +- investigation/ui-parity-matrix.md | 4 +- 7 files changed, 1444 insertions(+), 42 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 836048fa..3e552769 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -2,8 +2,11 @@ #include #include #include +#include +#include #include #include +#include extern "C" IRawElementProviderSimple *gc_uia_create(HWND hwnd); extern "C" void gc_uia_release(IRawElementProviderSimple *provider); @@ -97,6 +100,97 @@ static void checkRect(IRawElementProviderFragment *fragment, POINT origin, check(ok, description); } +extern "C" HRESULT gc_uia_set_canvas_bounds(IRawElementProviderSimple *provider, int left, int top, + int right, int bottom); +extern "C" HRESULT gc_uia_set_sidebar_scroll(IRawElementProviderSimple *provider, int offset, + int max_offset, int viewport); + +// Resolves a client-relative point through the root fragment, as UIA does for coordinate +// hit-testing, and checks the element it names (nullptr: no element under the point). +static void expectHit(IRawElementProviderFragmentRoot *root, POINT origin, double x, double y, + const wchar_t *expected_name, const char *description) { + IRawElementProviderFragment *hit = nullptr; + const HRESULT result = root->ElementProviderFromPoint(origin.x + x, origin.y + y, &hit); + bool ok = result == S_OK; + if (ok) { + if (!expected_name) { + ok = hit == nullptr; + } else { + ok = hit != nullptr && stringProperty(hit, UIA_NamePropertyId) == expected_name; + } + } + if (!ok) { + std::fwprintf(stderr, L" point (%.0f,%.0f): expected %ls, got %ls (hr=0x%08lx)\n", x, y, + expected_name ? expected_name : L"nothing", + hit ? stringProperty(hit, UIA_NamePropertyId).c_str() : L"nothing", + static_cast(result)); + } + check(ok, description); + if (hit) hit->Release(); +} + +// UIA coordinate hit-testing must resolve to the row, destination, banner or header under the +// point (not the focused element), honour z-order, and return nothing where nothing is drawn. +static void nativeHitTestChecks(IRawElementProviderSimple *provider, HWND hwnd) { + POINT origin{0, 0}; + ClientToScreen(hwnd, &origin); + IRawElementProviderFragmentRoot *root = nullptr; + check(provider->QueryInterface(IID_PPV_ARGS(&root)) == S_OK && root, + "provider is a fragment root for hit-testing"); + if (!root) return; + const char *identities[] = { + "header-attention:needs-you", "loop:under-header", "overview-destination:graph", + "quick-chats-destination:quick-chats", "loop:visible", "loop:scrolled-out", + "needs-you-row:a", "needs-you-stop:a", "loop:under-banner", "sidebar-update-banner:update"}; + const char *names[] = {"Needs you header", "Row under header", "Graph destination", + "Quick Chats destination", "Visible row", "Scrolled-out row", + "Needs you row", "Stop loop", "Row under banner", "Update available"}; + const int parents[] = {1, 2, 1, 1, 2, 2, 1, 1, 2, 1}; + const int none[] = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0}; + const int actions[] = {1, 1, 1, 1, 1, 1, 1, 1, 1, 1}; + const int bounds[] = { + 100, 10, 200, 40, // header + 12, 20, 232, 60, // row published (wrongly) under the header: the header must win + 12, 150, 232, 176, // Graph destination + 0, 0, 0, 0, // Quick Chats destination, scrolled out + 12, 200, 232, 226, // visible loop row + 0, 0, 0, 0, // scrolled-out loop row + 12, 300, 232, 330, // needs-you row + 166, 306, 218, 326, // its Stop button, nested inside the row + 12, 480, 232, 520, // row published (wrongly) under the banner: the banner must win + 8, 500, 212, 550, // update banner + }; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Hit testing", identities, names, parents, none, none, actions, bounds, + 10, 0, 1, 0, 0, 0, 0) == S_OK, + "hit-testing publication succeeds"); + + expectHit(root, origin, 100, 210, L"Visible row", "a point over a visible row resolves to that row"); + expectHit(root, origin, 100, 160, L"Graph", "a point over the Graph destination resolves to the fixed Graph destination"); + expectHit(root, origin, 190, 315, L"Stop loop", "a nested control wins over the row that contains it"); + expectHit(root, origin, 50, 315, L"Needs you row", "the row resolves where its control is not"); + expectHit(root, origin, 150, 30, L"Needs you header", "the header is above a row published under it"); + expectHit(root, origin, 50, 30, L"Row under header", "the row resolves where the header does not cover it"); + expectHit(root, origin, 100, 510, L"Update available", "the banner is above a row published under it"); + expectHit(root, origin, 20, 490, L"Row under banner", "the row resolves where the banner does not cover it"); + expectHit(root, origin, 100, 300 - 40, nullptr, "an empty region resolves to nothing, not the focused element"); + expectHit(root, origin, 100, 100, nullptr, "where a scrolled-out row would have been resolves to nothing"); + expectHit(root, origin, 500, 600, nullptr, "a point outside every published element resolves to nothing"); + expectHit(root, origin, 232, 210, nullptr, "an element's right edge is exclusive"); + expectHit(root, origin, 12, 200, L"Visible row", "an element's left/top edge is inclusive"); + + // The real canvas rect is the lowest layer: only reported bounds make it hit-testable. + expectHit(root, origin, 600, 400, nullptr, "an unreported canvas is not hit-testable"); + check(gc_uia_set_canvas_bounds(provider, 240, 34, 1200, 800) == S_OK, "canvas bounds publish"); + IRawElementProviderFragment *canvas = nullptr; + check(root->ElementProviderFromPoint(origin.x + 600, origin.y + 400, &canvas) == S_OK && canvas && + stringProperty(canvas, UIA_AutomationIdPropertyId) == L"graph", + "a point over the reported canvas resolves to the canvas element"); + if (canvas) canvas->Release(); + expectHit(root, origin, 100, 210, L"Visible row", "rows still win outside the canvas"); + root->Release(); +} + // The sidebar's Graph and Quick Chats destinations, its scrolled rows, and its update // banner must publish exactly their drawn rect, and an element scrolled out of the visible // region must be reported offscreen rather than sharing another element's rect. @@ -169,11 +263,251 @@ static void nativeSidebarGeometryChecks(IRawElementProviderSimple *provider, HWN if (projects) projects->Release(); } +// A window that records the commands and sidebar-scroll requests the provider sends it, the way +// the shell's window procedure receives them. +static const UINT kSidebarScrollMessage = WM_APP + 47; +enum ScrollOperation { kScrollSetPercent = 5, kScrollIntoView = 6 }; +struct SentMessage { WPARAM wparam; LPARAM lparam; }; +static std::vector posted_commands; +static std::vector sidebar_scroll_requests; +static LRESULT sidebar_scroll_result = 1; + +static LRESULT CALLBACK captureProc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { + if (message == WM_COMMAND) posted_commands.push_back(wparam); + if (message == kSidebarScrollMessage) { + sidebar_scroll_requests.push_back({wparam, lparam}); + return sidebar_scroll_result; + } + return DefWindowProcW(window, message, wparam, lparam); +} + +static HWND createCaptureWindow() { + static ATOM atom = 0; + if (!atom) { + WNDCLASSW window_class{}; + window_class.lpfnWndProc = captureProc; + window_class.hInstance = GetModuleHandleW(nullptr); + window_class.lpszClassName = L"GraphCodeUiaNativeCapture"; + atom = RegisterClassW(&window_class); + } + return CreateWindowW(L"GraphCodeUiaNativeCapture", L"UIA native capture", WS_OVERLAPPED, + 0, 0, 100, 100, nullptr, nullptr, GetModuleHandleW(nullptr), nullptr); +} + +static void drainMessages(HWND window) { + MSG message; + while (PeekMessageW(&message, window, 0, 0, PM_REMOVE)) DispatchMessageW(&message); +} + +static uint64_t identityPayload(const char *identity) { + uint64_t hash = 1469598103934665603ULL; + for (const char *cursor = identity; *cursor; ++cursor) { + hash ^= static_cast(*cursor); + hash *= 1099511628211ULL; + } + return hash & 0x0fffffffffffffffULL; +} + +static IUnknown *pattern(IRawElementProviderFragment *fragment, PATTERNID id) { + IRawElementProviderSimple *simple = nullptr; + if (!fragment || fragment->QueryInterface(IID_PPV_ARGS(&simple)) != S_OK) return nullptr; + IUnknown *result = nullptr; + if (simple->GetPatternProvider(id, &result) != S_OK) result = nullptr; + simple->Release(); + return result; +} + +// The update banner must be invokable through COM, and Invoke must post exactly the command +// the shell maps back to the banner's click action (the same update offer). +static void nativeBannerInvokeChecks(IRawElementProviderSimple *provider, HWND hwnd, + IRawElementProviderFragment *root) { + const char *identities[] = {"sidebar-update-banner:update"}; + const char *names[] = {"Update available"}; + const int parents[] = {1}; + const int none[] = {0}; + const int actions[] = {1}; + const int bounds[] = {8, 500, 212, 550}; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Banner", identities, names, parents, none, none, actions, bounds, + 1, 0, 1, 0, 0, 0, 0) == S_OK, + "banner publication succeeds"); + auto *projects = child(root, NavigateDirection_FirstChild); + auto *banner = child(projects, NavigateDirection_FirstChild); + checkName(banner, L"Update available"); + auto *invoke = static_cast(pattern(banner, UIA_InvokePatternId)); + check(invoke != nullptr, "the banner exposes IInvokeProvider through GetPatternProvider"); + posted_commands.clear(); + if (invoke) { + check(invoke->Invoke() == S_OK, "invoking the banner through COM succeeds"); + invoke->Release(); + } + check(posted_commands.empty(), "Invoke posts rather than running the offer inside the UIA call"); + drainMessages(hwnd); + // 0x83fd6a5f429785e1 is the dynamic-invoke tag over FNV-1a("sidebar-update-banner:update"), + // the value graphcode-windows App.zig asserts maps to the banner's click action. + check(posted_commands.size() == 1 && posted_commands[0] == 0x83fd6a5f429785e1ULL, + "Invoke posts the exact dynamic-invoke command for the banner identity"); + if (banner) banner->Release(); + if (projects) projects->Release(); +} + +// The sidebar lists scroll as one viewport, and every row that scrolls with it can be brought +// into view through UIA, so keyboard and assistive clients can reach rows that are offscreen. +static void nativeScrollChecks(IRawElementProviderSimple *provider, HWND hwnd, + IRawElementProviderFragment *root) { + const char *identities[] = {"loop:far", "overview-destination:graph", "header-jump:jump", + "sidebar-update-banner:update"}; + const char *names[] = {"Far loop", "Graph", "Jump", "Update available"}; + const int parents[] = {2, 1, 1, 1}; + const int none[] = {0, 0, 0, 0}; + // Bit 0 invokable, bit 1 scrolls with the sidebar. + const int actions[] = {3, 3, 1, 1}; + const int bounds[] = {0, 0, 0, 0, 12, 150, 232, 176, 100, 10, 200, 40, 8, 500, 212, 550}; + auto update = reinterpret_cast(&gc_uia_update); + check(update(provider, "Scroll", identities, names, parents, none, none, actions, bounds, + 4, 0, 1, 0, 0, 0, 0) == S_OK, + "scroll publication succeeds"); + + auto *projects = child(root, NavigateDirection_FirstChild); + auto *loops = child(projects, NavigateDirection_NextSibling); + auto *worktrees = child(loops, NavigateDirection_NextSibling); + IRawElementProviderFragment *containers[] = {projects, loops, worktrees}; + for (auto *container : containers) { + auto *scroll = static_cast(pattern(container, UIA_ScrollPatternId)); + check(scroll != nullptr, "each sidebar list exposes IScrollProvider"); + if (scroll) scroll->Release(); + } + auto *root_scroll = pattern(root, UIA_ScrollPatternId); + check(root_scroll == nullptr, "the window root is not a scroll container"); + + auto *scroll = static_cast(pattern(loops, UIA_ScrollPatternId)); + sidebar_scroll_requests.clear(); + sidebar_scroll_result = 1; + if (scroll) { + // Until the shell publishes a scrollable extent there is nothing to scroll. + BOOL scrollable = TRUE; + double percent = 0, view = 0, horizontal_percent = 0, horizontal_view = 0; + BOOL horizontal = TRUE; + check(scroll->get_VerticallyScrollable(&scrollable) == S_OK && !scrollable, + "a sidebar that fits its viewport is not vertically scrollable"); + check(scroll->get_VerticalScrollPercent(&percent) == S_OK && percent == UIA_ScrollPatternNoScroll, + "a sidebar that fits reports UIA_ScrollPatternNoScroll"); + check(scroll->Scroll(ScrollAmount_NoAmount, ScrollAmount_LargeIncrement) == UIA_E_INVALIDOPERATION, + "scrolling a sidebar that fits its viewport is an invalid operation"); + + // 200 of a possible 800 with a 400 high viewport: 25% down, a third of the content visible. + check(gc_uia_set_sidebar_scroll(provider, 200, 800, 400) == S_OK, "sidebar scroll state publishes"); + check(scroll->get_VerticallyScrollable(&scrollable) == S_OK && scrollable, + "a sidebar taller than its viewport is vertically scrollable"); + check(scroll->get_VerticalScrollPercent(&percent) == S_OK && std::fabs(percent - 25.0) < 0.001, + "the vertical scroll percent is offset over maximum offset"); + check(scroll->get_VerticalViewSize(&view) == S_OK && std::fabs(view - 100.0 / 3.0) < 0.001, + "the vertical view size is the visible fraction of the content"); + check(scroll->get_HorizontalScrollPercent(&horizontal_percent) == S_OK && + horizontal_percent == UIA_ScrollPatternNoScroll && + scroll->get_HorizontalViewSize(&horizontal_view) == S_OK && horizontal_view == 100.0 && + scroll->get_HorizontallyScrollable(&horizontal) == S_OK && !horizontal, + "the sidebar reports no horizontal scrolling"); + check(gc_uia_set_sidebar_scroll(provider, 5000, 800, 400) == S_OK && + scroll->get_VerticalScrollPercent(&percent) == S_OK && percent == 100.0, + "an offset beyond the maximum is clamped"); + check(gc_uia_set_sidebar_scroll(provider, 200, 800, 400) == S_OK, "sidebar scroll state republishes"); + + check(scroll->Scroll(ScrollAmount_NoAmount, ScrollAmount_LargeIncrement) == S_OK, + "a vertical page scroll succeeds"); + check(scroll->Scroll(ScrollAmount_NoAmount, ScrollAmount_SmallDecrement) == S_OK, + "a vertical line scroll succeeds"); + check(scroll->Scroll(ScrollAmount_SmallIncrement, ScrollAmount_NoAmount) == UIA_E_INVALIDOPERATION, + "the sidebar does not scroll horizontally"); + check(scroll->SetScrollPercent(UIA_ScrollPatternNoScroll, 50.0) == S_OK, + "setting the vertical scroll percent succeeds"); + check(scroll->SetScrollPercent(UIA_ScrollPatternNoScroll, 150.0) == E_INVALIDARG, + "an out-of-range scroll percent is rejected"); + check(scroll->SetScrollPercent(10.0, UIA_ScrollPatternNoScroll) == UIA_E_INVALIDOPERATION, + "a horizontal scroll percent is rejected"); + scroll->Release(); + } + check(sidebar_scroll_requests.size() == 3, "only the three valid scroll requests reach the shell"); + if (sidebar_scroll_requests.size() == 3) { + check(sidebar_scroll_requests[0].wparam == ScrollAmount_LargeIncrement, + "the page scroll carries its UIA scroll amount"); + check(sidebar_scroll_requests[1].wparam == ScrollAmount_SmallDecrement, + "the line scroll carries its UIA scroll amount"); + check(sidebar_scroll_requests[2].wparam == kScrollSetPercent && + sidebar_scroll_requests[2].lparam == 5000, + "the scroll percent is carried in hundredths of a percent"); + } + + auto *far_row = child(loops, NavigateDirection_FirstChild); + // The sidebar's own children are its published rows first, then the fixed Graph destination. + auto *jump = child(projects, NavigateDirection_FirstChild); + auto *banner = child(jump, NavigateDirection_NextSibling); + auto *graph = child(banner, NavigateDirection_NextSibling); + checkName(far_row, L"Far loop"); + checkName(jump, L"Jump"); + checkName(banner, L"Update available"); + checkName(graph, L"Graph"); + + sidebar_scroll_requests.clear(); + auto *far_item = static_cast(pattern(far_row, UIA_ScrollItemPatternId)); + check(far_item != nullptr, "an offscreen sidebar row exposes IScrollItemProvider"); + if (far_item) { + check(far_item->ScrollIntoView() == S_OK, "scrolling an offscreen row into view succeeds"); + far_item->Release(); + } + check(sidebar_scroll_requests.size() == 1 && + sidebar_scroll_requests[0].wparam == kScrollIntoView && + static_cast(sidebar_scroll_requests[0].lparam) == identityPayload("loop:far"), + "ScrollIntoView asks the shell to reveal exactly that row's identity"); + sidebar_scroll_result = 0; + far_item = static_cast(pattern(far_row, UIA_ScrollItemPatternId)); + if (far_item) { + check(far_item->ScrollIntoView() == UIA_E_INVALIDOPERATION, + "a shell that cannot reveal the row fails ScrollIntoView instead of reporting success"); + far_item->Release(); + } + sidebar_scroll_result = 1; + + auto *destination_item = static_cast(pattern(graph, UIA_ScrollItemPatternId)); + check(destination_item != nullptr, "the Graph destination exposes IScrollItemProvider"); + if (destination_item) destination_item->Release(); + check(pattern(jump, UIA_ScrollItemPatternId) == nullptr, "a fixed header does not scroll into view"); + check(pattern(banner, UIA_ScrollItemPatternId) == nullptr, "the fixed update banner does not scroll into view"); + + if (banner) banner->Release(); + if (jump) jump->Release(); + if (graph) graph->Release(); + if (far_row) far_row->Release(); + if (worktrees) worktrees->Release(); + if (loops) loops->Release(); + if (projects) projects->Release(); +} + +static void nativeCommandAndScrollChecks() { + HWND capture = createCaptureWindow(); + check(capture != nullptr, "capture window exists"); + if (!capture) return; + IRawElementProviderSimple *provider = gc_uia_create(capture); + check(provider != nullptr, "capture provider exists"); + if (provider) { + IRawElementProviderFragment *root = nullptr; + check(provider->QueryInterface(IID_PPV_ARGS(&root)) == S_OK, "capture root fragment exists"); + if (root) { + nativeBannerInvokeChecks(provider, capture, root); + nativeScrollChecks(provider, capture, root); + root->Release(); + } + gc_uia_release(provider); + } + DestroyWindow(capture); +} + int main() { HWND hwnd = CreateWindowW(L"STATIC", L"UIA native test", WS_OVERLAPPED, 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr); check(hwnd != nullptr, "native window exists"); if (!hwnd) return 1; + nativeCommandAndScrollChecks(); auto *provider = gc_uia_create(hwnd); check(provider != nullptr, "native provider exists"); @@ -264,6 +598,7 @@ int main() { } if (replacement) replacement->Release(); nativeSidebarGeometryChecks(provider, hwnd, root); + nativeHitTestChecks(provider, hwnd); if (status) status->Release(); if (actions) actions->Release(); if (graph) graph->Release(); diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 63096083..80cd96fa 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -326,6 +326,32 @@ 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. +### Sidebar scrolling and short windows + +The sidebar rows, Needs you, and Activity scroll under the fixed GRAPH/Projects +header and above the footer (status line, update banner, inline error footer). +What is painted, what UIA publishes, and what a click hits are the same rect, +clipped to that scroll region; the right-hand Activity control sits inside the +220-pixel rail. + +Keyboard and assistive clients reach offscreen rows through UIA, not a hidden +shortcut: each sidebar list (Projects, Loops, Worktrees) exposes a vertical-only +`IScrollProvider` over the one sidebar offset, and every element that scrolls +with it (rows, controls, the Graph and Quick Chats destinations) exposes +`IScrollItemProvider`; the shell reveals the element through +`Accessibility.wm_sidebar_scroll`. The fixed header, update banner, and error +footer do not scroll and expose no scroll item. The root's coordinate hit-test +returns the element under the point (banner above header above scrolled rows). + +Short windows: the sidebar keeps at least two rows (`Sidebar.min_content_region`) +of scroll region. If the footer would leave less, the error footer collapses +first, then the update banner; the status line is the floor. A collapsed part is +not painted, published, or clickable. The window's minimum height is the smallest +client height at which the status line, Activity strip, and open Workspace panel +still leave that region (`min_client_height`, enforced through +`WM_GETMINMAXINFO`; width keeps the system minimum). This is layout and message +evidence from App and native provider tests, not a screen-reader or Dev Box run. + ## Workspace lifecycle The Workspace menu lists `Default` and `.graphcode-*` directories under diff --git a/graphcode-windows/src/Accessibility.zig b/graphcode-windows/src/Accessibility.zig index 95a65aee..915ed0c0 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_sidebar_scroll(provider: *NativeProvider, offset: c_int, max_offset: c_int, viewport: 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, @@ -75,6 +76,8 @@ pub const DynamicElement = struct { selected: bool = false, eligible: bool = false, invokable: bool = true, + /// The element is part of the scrolling sidebar content, so UIA can scroll it into view. + sidebar_scroll: bool = false, left: i32, top: i32, right: i32, @@ -98,6 +101,19 @@ 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; +/// Sent by the UIA scroll patterns. wParam is a `SidebarScrollOperation`; lParam is its argument. +/// The window answers 1 when it performed the request and 0 when it could not. +pub const wm_sidebar_scroll: u32 = 0x8000 + 47; +pub const SidebarScrollOperation = struct { + pub const large_decrement: usize = 0; + pub const small_decrement: usize = 1; + pub const large_increment: usize = 3; + pub const small_increment: usize = 4; + /// lParam: the vertical scroll percent in hundredths of a percent. + pub const set_percent: usize = 5; + /// lParam: the identity payload (`worktreeIdentityPayload`) of the element to reveal. + pub const into_view: usize = 6; +}; pub fn worktreeIdentityPayload(path: []const u8) usize { var hash: u64 = 1469598103934665603; @@ -232,7 +248,7 @@ pub const Provider = struct { parents[index] = element.parent; selected[index] = if (element.selected) 1 else 0; eligible[index] = if (element.eligible) 1 else 0; - invokable[index] = if (element.invokable) 1 else 0; + invokable[index] = (if (element.invokable) @as(c_int, 1) else 0) | (if (element.sidebar_scroll) @as(c_int, 2) else 0); bounds[index * 4] = element.left; bounds[index * 4 + 1] = element.top; bounds[index * 4 + 2] = element.right; @@ -287,6 +303,13 @@ pub const Provider = struct { const native = self.native_provider orelse return; _ = gc_uia_set_canvas_bounds(native, bounds.left, bounds.top, bounds.right, bounds.bottom); } + /// Reports the sidebar's scroll offset, its maximum, and the visible height so the sidebar + /// lists can answer UIA scroll-pattern properties and raise changes. + pub fn syncSidebarScroll(self: *Provider, offset: i32, max_offset: i32, viewport: i32) void { + if (!builtin.link_libc) return; + const native = self.native_provider orelse return; + _ = gc_uia_set_sidebar_scroll(native, offset, max_offset, viewport); + } pub fn add(self: *Provider, element: Element) !usize { const index = self.elements.items.len; try self.elements.append(element); diff --git a/graphcode-windows/src/AccessibilityProvider.cpp b/graphcode-windows/src/AccessibilityProvider.cpp index d59ae376..b5bbae9c 100644 --- a/graphcode-windows/src/AccessibilityProvider.cpp +++ b/graphcode-windows/src/AccessibilityProvider.cpp @@ -20,6 +20,16 @@ constexpr WPARAM kDynamicInvokeTag = 0x8000000000000000ULL; constexpr WPARAM kSelectionOperationMask = 0x3000000000000000ULL; constexpr int kSelectionOperationShift = 60; constexpr UINT kHeaderFocusMessage = WM_APP + 46; +// Sidebar scroll requests from UIA. wParam is the operation: 0-4 are UIA ScrollAmount values +// for a vertical Scroll, kScrollSetPercent carries hundredths of a percent in lParam, and +// kScrollIntoView carries the row identity payload in lParam. The shell answers 1 when it +// performed the request and 0 when it could not. +constexpr UINT kSidebarScrollMessage = WM_APP + 47; +constexpr WPARAM kScrollSetPercent = 5; +constexpr WPARAM kScrollIntoView = 6; +// Bits of a published row's flag word. +constexpr int kRowInvokable = 1; +constexpr int kRowScrollsWithSidebar = 2; static bool isHeader(const std::string &identity) { return identity == "header-attention:needs-you" || @@ -28,6 +38,14 @@ static bool isHeader(const std::string &identity) { identity == "header-toggle-panel:control"; } +// Paint order for coordinate hit-testing: the fixed footer (update banner, error footer) +// is above the fixed header, which is above everything that scrolls beneath them. +static int hitTier(const std::string &identity) { + if (identity.rfind("sidebar-update-banner:", 0) == 0 || + identity.rfind("sidebar-error-footer:", 0) == 0) return 3; + return isHeader(identity) ? 2 : 1; +} + static bool hasHeaderNativeFocus(HWND hwnd) { GUITHREADINFO info{}; info.cbSize = sizeof(info); @@ -46,6 +64,7 @@ struct Row { bool selected = false; bool eligible = false; bool invokable = false; + bool scrolls = false; RECT bounds{}; }; struct State { @@ -74,6 +93,11 @@ struct State { // below. Until the app reports them the fixed elements use placeholder rects. RECT destination_bounds[2]{}; bool has_destination_bounds[2]{}; + // The sidebar's single scroll viewport, delivered through gc_uia_set_sidebar_scroll: + // current offset, maximum offset and visible height, in the same units. + int scroll_offset = 0; + int scroll_max = 0; + int scroll_viewport = 0; }; static const char kOverviewDestinationIdentity[] = "overview-destination:graph"; @@ -118,7 +142,9 @@ class Node final : public IRawElementProviderSimple, public IInvokeProvider, public ISelectionProvider, public ISelectionItemProvider, - public IToggleProvider { + public IToggleProvider, + public IScrollProvider, + public IScrollItemProvider { public: Node(std::shared_ptr state, int64_t id) : state_(std::move(state)), id_(id), refs_(1) { if (id_ == 0) { @@ -149,6 +175,10 @@ class Node final : public IRawElementProviderSimple, *out = static_cast(this); else if (iid == __uuidof(IToggleProvider) && supportsToggle()) *out = static_cast(this); + else if (iid == __uuidof(IScrollProvider) && supportsScroll()) + *out = static_cast(this); + else if (iid == __uuidof(IScrollItemProvider) && supportsScrollItem()) + *out = static_cast(this); else return E_NOINTERFACE; AddRef(); @@ -183,6 +213,10 @@ class Node final : public IRawElementProviderSimple, *value = static_cast(this); else if (id == UIA_TogglePatternId && supportsToggle()) *value = static_cast(this); + else if (id == UIA_ScrollPatternId && supportsScroll()) + *value = static_cast(this); + else if (id == UIA_ScrollItemPatternId && supportsScrollItem()) + *value = static_cast(this); else return S_FALSE; AddRef(); @@ -465,9 +499,53 @@ class Node final : public IRawElementProviderSimple, *value = static_cast(state_->root); return S_OK; } - HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double, double, + HRESULT STDMETHODCALLTYPE ElementProviderFromPoint(double x, double y, IRawElementProviderFragment **value) override { - return focusedElement(value); + if (!value) return E_POINTER; + *value = nullptr; + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + hwnd = state_->hwnd; + } + POINT origin{0, 0}; + ClientToScreen(hwnd, &origin); + const double px = x - origin.x; + const double py = y - origin.y; + Node *hit = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + int64_t best = -1; + int best_tier = 0; + long long best_area = 0; + // Higher tiers paint above lower ones; within a tier the smallest rect is the most + // specific (a control nested in its row), and a later-published element wins a tie. + auto consider = [&](int64_t id, int tier, const RECT &bounds) { + if (isEmptyRect(bounds)) return; + if (px < bounds.left || px >= bounds.right || py < bounds.top || py >= bounds.bottom) return; + const long long area = static_cast(bounds.right - bounds.left) * + static_cast(bounds.bottom - bounds.top); + if (best < 0 || tier > best_tier || (tier == best_tier && area <= best_area)) { + best = id; + best_tier = tier; + best_area = area; + } + }; + if (state_->has_canvas_bounds) consider(4, 0, state_->canvas_bounds); + for (int slot = 0; slot < 2; ++slot) { + if (state_->has_destination_bounds[slot]) + consider(14 + slot, 1, state_->destination_bounds[slot]); + } + for (int64_t key : state_->row_order) { + const Row &row = state_->rows.at(key); + consider(key, hitTier(row.identity), row.bounds); + } + if (best >= 0) hit = retainElementLocked(best); + } + if (hit) *value = static_cast(hit); + return S_OK; } HRESULT STDMETHODCALLTYPE GetFocus(IRawElementProviderFragment **value) override { return focusedElement(value); @@ -582,6 +660,124 @@ class Node final : public IRawElementProviderSimple, return S_OK; } + HRESULT STDMETHODCALLTYPE Scroll(ScrollAmount horizontal, ScrollAmount vertical) override { + if (horizontal != ScrollAmount_NoAmount) return UIA_E_INVALIDOPERATION; + if (vertical == ScrollAmount_NoAmount) return S_OK; + if (vertical != ScrollAmount_LargeDecrement && vertical != ScrollAmount_SmallDecrement && + vertical != ScrollAmount_LargeIncrement && vertical != ScrollAmount_SmallIncrement) + return E_INVALIDARG; + return sendSidebarScroll(static_cast(vertical), 0); + } + HRESULT STDMETHODCALLTYPE SetScrollPercent(double horizontal, double vertical) override { + if (horizontal != UIA_ScrollPatternNoScroll) return UIA_E_INVALIDOPERATION; + if (vertical == UIA_ScrollPatternNoScroll) return S_OK; + if (!(vertical >= 0.0 && vertical <= 100.0)) return E_INVALIDARG; + return sendSidebarScroll(kScrollSetPercent, static_cast(vertical * 100.0 + 0.5)); + } + HRESULT STDMETHODCALLTYPE get_HorizontalScrollPercent(double *value) override { + if (!value) return E_POINTER; + *value = UIA_ScrollPatternNoScroll; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_VerticalScrollPercent(double *value) override { + if (!value) return E_POINTER; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + *value = verticalScrollPercentLocked(); + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_HorizontalViewSize(double *value) override { + if (!value) return E_POINTER; + *value = 100.0; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_VerticalViewSize(double *value) override { + if (!value) return E_POINTER; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + *value = verticalViewSizeLocked(); + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_HorizontallyScrollable(BOOL *value) override { + if (!value) return E_POINTER; + *value = FALSE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE get_VerticallyScrollable(BOOL *value) override { + if (!value) return E_POINTER; + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + *value = state_->scroll_max > 0 ? TRUE : FALSE; + return S_OK; + } + HRESULT STDMETHODCALLTYPE ScrollIntoView() override { + std::string identity; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (isRowKey(id_)) { + const Row &row = state_->rows.at(id_); + if (!row.scrolls) return UIA_E_INVALIDOPERATION; + identity = row.identity; + } else if ((id_ == 14 || id_ == 15) && state_->has_destination_bounds[id_ - 14]) { + identity = id_ == 14 ? kOverviewDestinationIdentity : kQuickChatsDestinationIdentity; + } else { + return UIA_E_INVALIDOPERATION; + } + } + return sendSidebarScroll(kScrollIntoView, + static_cast(hashPath(identity) & kRowPayloadMask)); + } + + void setSidebarScroll(int offset, int max_offset, int viewport) { + struct Changed { Node *node; PROPERTYID property; double old_value; double new_value; }; + std::vector changes; + { + std::lock_guard lock(state_->mutex); + if (!state_->active) return; + const double old_percent = verticalScrollPercentLocked(); + const double old_view = verticalViewSizeLocked(); + const bool old_scrollable = state_->scroll_max > 0; + state_->scroll_max = (std::max)(max_offset, 0); + state_->scroll_offset = (std::min)((std::max)(offset, 0), state_->scroll_max); + state_->scroll_viewport = (std::max)(viewport, 0); + const double new_percent = verticalScrollPercentLocked(); + const double new_view = verticalViewSizeLocked(); + const bool new_scrollable = state_->scroll_max > 0; + for (int64_t list = 1; list <= 3; ++list) { + const auto node_for = [&](PROPERTYID property, double old_value, double new_value) { + if (old_value == new_value) return; + if (Node *node = retainElementLocked(list)) + changes.push_back({node, property, old_value, new_value}); + }; + node_for(UIA_ScrollVerticalScrollPercentPropertyId, old_percent, new_percent); + node_for(UIA_ScrollVerticalViewSizePropertyId, old_view, new_view); + if (old_scrollable != new_scrollable) { + if (Node *node = retainElementLocked(list)) + changes.push_back({node, UIA_ScrollVerticallyScrollablePropertyId, + old_scrollable ? 1.0 : 0.0, new_scrollable ? 1.0 : 0.0}); + } + } + } + for (const Changed &change : changes) { + VARIANT old_value, new_value; + VariantInit(&old_value); + VariantInit(&new_value); + if (change.property == UIA_ScrollVerticallyScrollablePropertyId) { + old_value.vt = new_value.vt = VT_BOOL; + old_value.boolVal = change.old_value != 0.0 ? VARIANT_TRUE : VARIANT_FALSE; + new_value.boolVal = change.new_value != 0.0 ? VARIANT_TRUE : VARIANT_FALSE; + } else { + old_value.vt = new_value.vt = VT_R8; + old_value.dblVal = change.old_value; + new_value.dblVal = change.new_value; + } + UiaRaiseAutomationPropertyChangedEvent( + static_cast(change.node), change.property, old_value, new_value); + change.node->Release(); + } + } + HRESULT setHeaderFocus(const char *identity) { Node *focused = nullptr; { @@ -678,7 +874,8 @@ class Node final : public IRawElementProviderSimple, parents ? parents[index] : 3, selected && selected[index] != 0, eligible && eligible[index] != 0, - invokable && invokable[index] != 0, + invokable && (invokable[index] & kRowInvokable) != 0, + invokable && (invokable[index] & kRowScrollsWithSidebar) != 0, bounds ? RECT{bounds[index * 4], bounds[index * 4 + 1], bounds[index * 4 + 2], bounds[index * 4 + 3]} : RECT{}, }); @@ -824,6 +1021,41 @@ class Node final : public IRawElementProviderSimple, bool supportsToggle() const { return id_ == 12 || id_ == 13; } + bool supportsScroll() const { return id_ >= 1 && id_ <= 3; } + bool supportsScrollItem() const { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return false; + if (isRowKey(id_)) return state_->rows.at(id_).scrolls; + return (id_ == 14 || id_ == 15) && state_->has_destination_bounds[id_ - 14]; + } + double verticalScrollPercentLocked() const { + if (state_->scroll_max <= 0) return UIA_ScrollPatternNoScroll; + return 100.0 * state_->scroll_offset / state_->scroll_max; + } + double verticalViewSizeLocked() const { + if (state_->scroll_max <= 0) return 100.0; + const double viewport = (std::max)(state_->scroll_viewport, 1); + return 100.0 * viewport / (viewport + state_->scroll_max); + } + // Scrolling is delegated to the shell, which owns the one sidebar offset; a refusal or a + // hung shell is reported rather than pretending the viewport moved. + HRESULT sendSidebarScroll(WPARAM operation, LPARAM argument) { + HWND hwnd = nullptr; + { + std::lock_guard lock(state_->mutex); + if (!isAvailableLocked()) return UIA_E_ELEMENTNOTAVAILABLE; + if (operation != kScrollIntoView && state_->scroll_max <= 0) return UIA_E_INVALIDOPERATION; + hwnd = state_->hwnd; + } + DWORD_PTR result = 0; + SetLastError(ERROR_SUCCESS); + if (!SendMessageTimeoutW(hwnd, kSidebarScrollMessage, operation, argument, + 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; + } bool worktreeFixedEnabledLocked(int64_t id) const { if (id == 3) return state_->worktrees_available; if (id == 7 || id == 10) return state_->worktrees_available && !state_->worktree_busy; @@ -1249,4 +1481,11 @@ extern "C" HRESULT gc_uia_set_canvas_bounds(IRawElementProviderSimple *provider, return S_OK; } +extern "C" HRESULT gc_uia_set_sidebar_scroll(IRawElementProviderSimple *provider, int offset, + int max_offset, int viewport) { + if (!provider) return E_INVALIDARG; + static_cast(provider)->setSidebarScroll(offset, max_offset, viewport); + return S_OK; +} + #include "OnboardingAccessibilityProvider.cpp" diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index c3709f7e..d2baf4c6 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -62,6 +62,10 @@ const c = Win32.c; /// Lets tests observe each production UIA publication (`syncAccessibility`); absent from product builds. var accessibility_publication_observer: if (builtin.is_test) ?*const fn (*App) void else void = if (builtin.is_test) null else {}; +/// Lets tests observe the update offer a banner click or UIA invoke opens, instead of raising the +/// modal dialog; absent from product builds. +var update_offer_observer: if (builtin.is_test) ?*const fn (*App, []const u8, []const u8) void else void = if (builtin.is_test) null else {}; + const title = std.unicode.utf8ToUtf16LeStringLiteral("GraphCode Windows"); const workspace_restart_message = "Workspace identity changed or could not be verified. Restart GraphCode before managing workspaces."; const WorktreeInspectRunner = *const fn ( @@ -1057,6 +1061,29 @@ const AccessibilityBounds = union(enum) { } }; +/// Smallest client height at which the sidebar keeps `Sidebar.min_content_region` of rows under +/// the fixed header with the Workspace panel, Activity strip, and status line stacked below it. +const min_client_height: i32 = Sidebar.content_top + Sidebar.min_content_region + + Sidebar.footerReserve(false, false) + Tokens.workspace_height + Tokens.activity_strip_height; + +extern "user32" fn AdjustWindowRectExForDpi( + rect: *c.RECT, + style: c.DWORD, + has_menu: c.BOOL, + ex_style: c.DWORD, + dpi: c.UINT, +) callconv(.winapi) c.BOOL; + +/// The outer window height whose client area is `min_client_height` at `dpi`, in physical pixels. +fn minimumWindowHeight(hwnd: c.HWND, dpi: u32) i32 { + var frame = c.RECT{ .left = 0, .top = 0, .right = 0, .bottom = physicalCoordinate(min_client_height, dpi) }; + const style: c.DWORD = @truncate(@as(usize, @bitCast(c.GetWindowLongPtrW(hwnd, c.GWL_STYLE)))); + const ex_style: c.DWORD = @truncate(@as(usize, @bitCast(c.GetWindowLongPtrW(hwnd, c.GWL_EXSTYLE)))); + const has_menu: c.BOOL = if (c.GetMenu(hwnd) != null) 1 else 0; + if (AdjustWindowRectExForDpi(&frame, style, has_menu, ex_style, Dpi.normalize(dpi)) == 0) return 0; + return frame.bottom - frame.top; +} + fn clipSidebarAccessibilityBounds(bounds: c.RECT, viewport_bottom: i32) c.RECT { const top = @max(bounds.top, Sidebar.content_top); const bottom = @min(bounds.bottom, viewport_bottom); @@ -1064,6 +1091,45 @@ fn clipSidebarAccessibilityBounds(bounds: c.RECT, viewport_bottom: i32) c.RECT { return .{ .left = bounds.left, .top = top, .right = bounds.right, .bottom = bottom }; } +/// UIA element kinds that move with the sidebar scroll offset. The fixed header, the update +/// banner, and the error footer are not here: scrolling never reveals them. +const sidebar_scroll_kinds = [_][]const u8{ + "sidebar-section", + "project", + "open-project", + "project-new-loop", + "project-disclosure", + "loop", + "loop-disclosure", + "worktree", + "overview-destination", + "quick-chats-destination", + "quick-chats-header", + "quick-chats-disclosure", + "quick-chat-new", + "quick-chat-row", + "needs-you-header", + "needs-you-row", + "needs-you-stop", + "activity-header", + "activity-filter", + "activity-row", + "activity-control", +}; + +fn sidebarScrollsKind(kind: []const u8) bool { + for (sidebar_scroll_kinds) |candidate| if (std.mem.eql(u8, kind, candidate)) return true; + return false; +} + +fn providerHasDecl(comptime Provider: type, comptime name: []const u8) bool { + return switch (@typeInfo(Provider)) { + .pointer => |pointer| @hasDecl(pointer.child, name), + .@"struct", .@"union", .@"enum", .@"opaque" => @hasDecl(Provider, name), + else => false, + }; +} + fn inputBounds(client_right: i32, client_bottom: i32, controls: WorkspaceControls.State) InputBounds { return .{ .rail_left = if (controls.rail_visible) Tokens.sidebar_width else 0, @@ -3647,6 +3713,10 @@ pub const App = struct { self.setStatus("Unable to prepare the update offer"); return; } + if (builtin.is_test) if (update_offer_observer) |observe| { + observe(self, version.?, release_url.?); + return; + }; self.showAvailableUpdate(version.?, release_url.?); } @@ -6078,6 +6148,82 @@ pub const App = struct { self.clampSidebarScroll(); } + const SidebarScrollSpan = struct { top: i32, bottom: i32 }; + + /// Collects the unclipped extent of the one scrolling sidebar element whose identity payload + /// matches, from the same elements UIA publishes. + const SidebarScrollSpanSink = struct { + dpi: u32, + payload: usize, + span: ?SidebarScrollSpan = null, + + fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { + for (elements) |element| { + if (!element.sidebar_scroll) continue; + if (Accessibility.worktreeIdentityPayload(element.identity) != self.payload) continue; + self.span = .{ .top = logicalCoordinate(element.top, self.dpi), .bottom = logicalCoordinate(element.bottom, self.dpi) }; + return; + } + } + }; + + /// The element's extent in unscrolled sidebar coordinates, or null when no scrolling sidebar + /// element has that identity. It is read from the published elements with the offset at zero + /// and a client tall enough that nothing is clipped, so it cannot drift from what UIA publishes. + fn sidebarScrollSpan(self: *App, payload: usize) ?SidebarScrollSpan { + const saved = self.sidebar_scroll; + self.sidebar_scroll = 0; + defer self.sidebar_scroll = saved; + var sink = SidebarScrollSpanSink{ .dpi = self.dpi, .payload = payload }; + const client = logicalClientRect(self.window.hwnd, self.dpi); + self.syncAccessibilityTo(&sink, .{ .left = 0, .top = 0, .right = client.right, .bottom = client.bottom + sidebar_unclipped_extent }); + return sink.span; + } + + const sidebar_line_scroll: i32 = 24; + const sidebar_unclipped_extent: i32 = 100_000; + + /// Performs a UIA scroll request on the sidebar's single scroll offset. Returns false when + /// the request cannot be honoured (no scrollable viewport, or no such element), so the UIA + /// caller sees a failure instead of a viewport that did not move. + fn applySidebarScroll(self: *App, operation: usize, argument: isize) bool { + const client = logicalClientRect(self.window.hwnd, self.dpi); + const content_bottom = self.sidebarContentBottom(client.bottom); + const viewport = content_bottom - Sidebar.content_top; + if (viewport <= 0) return false; + const inspection = if (self.worktree_inspection) |*value| value else null; + const maximum = Sidebar.maxScroll(&self.model, inspection, content_bottom, &self.sidebar_state); + var next = self.sidebar_scroll; + switch (operation) { + Accessibility.SidebarScrollOperation.large_decrement => next -= viewport, + Accessibility.SidebarScrollOperation.large_increment => next += viewport, + Accessibility.SidebarScrollOperation.small_decrement => next -= sidebar_line_scroll, + Accessibility.SidebarScrollOperation.small_increment => next += sidebar_line_scroll, + Accessibility.SidebarScrollOperation.set_percent => { + if (argument < 0 or argument > 10_000) return false; + next = @intCast(@divTrunc(@as(i64, maximum) * argument + 5_000, 10_000)); + }, + Accessibility.SidebarScrollOperation.into_view => { + if (argument < 0) return false; + const span = self.sidebarScrollSpan(@intCast(argument)) orelse return false; + const top = span.top - next; + const bottom = span.bottom - next; + if (span.bottom - span.top > viewport or top < Sidebar.content_top) { + next = span.top - Sidebar.content_top; + } else if (bottom > content_bottom) { + next = span.bottom - content_bottom; + } + }, + else => return false, + } + self.sidebar_scroll = Sidebar.clampScroll(next, maximum); + self.syncAccessibility(); + _ = c.InvalidateRect(self.window.hwnd, null, 0); + return true; + } + fn updateBannerShown(self: *App) bool { self.update_lock.lock(); defer self.update_lock.unlock(); @@ -6087,12 +6233,9 @@ pub const App = struct { /// Bottom edge of the scrollable sidebar content: the sidebar viewport minus the footer /// (status line, update banner, error footer) that is drawn over its lower edge. fn sidebarContentBottom(self: *App, client_bottom: i32) i32 { - const update_shown = self.updateBannerShown(); - return Sidebar.contentViewportBottom( - GraphCanvas.sidebarBottom(client_bottom, self.workspace_controls, self.surface), - update_shown, - self.ingress_error.len != 0, - ); + const viewport_bottom = GraphCanvas.sidebarBottom(client_bottom, self.workspace_controls, self.surface); + const footer = Sidebar.effectiveFooter(viewport_bottom, self.updateBannerShown(), self.ingress_error.len != 0); + return Sidebar.contentViewportBottom(viewport_bottom, footer.update, footer.ingress); } fn clampSidebarScroll(self: *App) void { @@ -6983,6 +7126,14 @@ pub const App = struct { const footer_viewport_bottom = GraphCanvas.sidebarBottom(client.bottom, self.workspace_controls, self.surface); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); provider.syncCanvasBounds((AccessibilityBounds{ .logical = canvas_rect }).physicalRect(self.dpi)); const current_inspection = self.currentWorktreeInspection(); + // The scroll sink is optional so test sinks that only record elements need not declare it. + if (comptime providerHasDecl(@TypeOf(provider), "syncSidebarScroll")) { + provider.syncSidebarScroll( + self.sidebar_scroll, + Sidebar.maxScroll(&self.model, current_inspection, sidebar_bottom, &self.sidebar_state), + @max(sidebar_bottom - Sidebar.content_top, 0), + ); + } var sidebar_rows = Sidebar.appendRows( self.allocator, &self.model, @@ -7149,8 +7300,9 @@ pub const App = struct { }, } } - if (self.updateBannerShown()) { - const banner = Sidebar.updateBannerRect(footer_viewport_bottom, self.ingress_error.len != 0); + const footer = Sidebar.effectiveFooter(footer_viewport_bottom, self.updateBannerShown(), self.ingress_error.len != 0); + if (footer.update) { + const banner = Sidebar.updateBannerRect(footer_viewport_bottom, footer.ingress); self.appendAccessibilityElement(&elements, &owned_identities, "sidebar-update-banner", "update", "Update available", 1, .{ .logical = banner }, false, false) catch return; } if (self.surface == .workspace and self.workspace_is_quick_chat) if (self.selected_quick_chat) |chat_index| { @@ -7244,7 +7396,7 @@ pub const App = struct { self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-left", "Scroll activity left", 1, .{ .logical = clipSidebarAccessibilityBounds(Sidebar.activityControlBounds(&self.model, current_inspection, &self.sidebar_state, self.sidebar_scroll, .left), sidebar_bottom) }, false, true) catch return; self.appendAccessibilityElement(&elements, &owned_identities, "activity-control", "scroll-right", "Scroll activity right", 1, .{ .logical = clipSidebarAccessibilityBounds(Sidebar.activityControlBounds(&self.model, current_inspection, &self.sidebar_state, self.sidebar_scroll, .right), sidebar_bottom) }, false, true) catch return; } - if (self.ingress_error.len != 0 and Sidebar.errorFooterVisible(footer_viewport_bottom)) { + if (footer.ingress and Sidebar.errorFooterVisible(footer_viewport_bottom)) { const bounds = (AccessibilityBounds{ .logical = Sidebar.errorFooterRect(footer_viewport_bottom) }).physicalRect(self.dpi); elements.append(.{ .identity = "sidebar-error-footer:ingress", @@ -7467,6 +7619,7 @@ pub const App = struct { .selected = selected, .eligible = eligible, .invokable = parent != 3, + .sidebar_scroll = sidebarScrollsKind(kind), .left = bounds.left, .top = bounds.top, .right = bounds.right, @@ -8323,6 +8476,16 @@ fn onWindowMessage( return true; } switch (message) { + c.WM_GETMINMAXINFO => { + // Below this the sidebar cannot keep two rows visible beneath the fixed header with the + // Workspace panel, Activity strip, and status line stacked under it. Only the height is + // constrained; width keeps the system minimum. + const info = Win32.messagePointer(*c.MINMAXINFO, lparam); + const minimum = minimumWindowHeight(hwnd, app.dpi); + if (minimum > info.ptMinTrackSize.y) info.ptMinTrackSize.y = minimum; + result.* = 0; + return true; + }, c.WM_GETOBJECT => if (app.accessibility) |*provider| { const object = provider.getObject(hwnd, wparam, lparam); if (object != 0) { @@ -8844,6 +9007,10 @@ fn onWindowMessage( } return true; }, + Accessibility.wm_sidebar_scroll => { + result.* = if (app.applySidebarScroll(wparam, lparam)) 1 else 0; + return true; + }, c.WM_KEYDOWN, c.WM_SYSKEYDOWN => { const ctrl = (@as(i32, c.GetKeyState(c.VK_CONTROL)) & 0x8000) != 0; const shift = (@as(i32, c.GetKeyState(c.VK_SHIFT)) & 0x8000) != 0; @@ -10854,14 +11021,20 @@ const sidebar_uia_prefixes = [_][]const u8{ const SidebarUiaSink = struct { count: usize = 0, rects: [192]c.RECT = undefined, - names: [192][40]u8 = undefined, + names: [192][160]u8 = undefined, name_lens: [192]usize = undefined, invokable: [192]bool = undefined, + scrolls: [192]bool = undefined, labels: [192][40]u8 = undefined, label_lens: [192]usize = undefined, + scroll_state: ?struct { offset: i32, max_offset: i32, viewport: i32 } = null, fn syncCanvasBounds(_: *@This(), _: c.RECT) void {} + fn syncSidebarScroll(self: *@This(), offset: i32, max_offset: i32, viewport: i32) void { + self.scroll_state = .{ .offset = offset, .max_offset = max_offset, .viewport = viewport }; + } + fn syncElements(self: *@This(), _: []const u8, elements: []const Accessibility.DynamicElement, _: WorktreeStatus.Policy, _: Accessibility.WorktreeCapabilities) void { self.count = 0; for (elements) |element| { @@ -10877,6 +11050,7 @@ const SidebarUiaSink = struct { @memcpy(self.labels[self.count][0..label_len], element.name[0..label_len]); self.label_lens[self.count] = label_len; self.invokable[self.count] = element.invokable; + self.scrolls[self.count] = element.sidebar_scroll; self.count += 1; } } @@ -10919,6 +11093,7 @@ const footer_layout_controls = [_]WorkspaceControls.State{ fn deinitFooterLayoutApp(app: *App) void { if (app.update_version.len != 0) app.allocator.free(app.update_version); + if (app.update_release_url.len != 0) app.allocator.free(app.update_release_url); if (app.ingress_error.len != 0) app.allocator.free(app.ingress_error); deinitOverviewTestApp(app); } @@ -10949,6 +11124,7 @@ fn footerLayoutApp(case: FooterLayoutCase, controls: WorkspaceControls.State, up if (update) { app.update_state.state = .available; app.update_version = try app.allocator.dupe(u8, "9.9.9-test"); + app.update_release_url = try app.allocator.dupe(u8, "https://example.invalid/releases/tag/v9.9.9-test"); } if (ingress) app.ingress_error = try app.allocator.dupe(u8, "Daemon ingress is unavailable"); return app; @@ -10961,7 +11137,10 @@ test "sidebar UIA bounds and hit targets stay clear of the update banner at 1280 defer deinitFooterLayoutApp(&app); const client = logicalClientRect(app.window.hwnd, app.dpi); const sidebar_bottom = GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface); - const banner = (AccessibilityBounds{ .logical = Sidebar.updateBannerRect(sidebar_bottom, ingress) }).physicalRect(app.dpi); + // On a viewport too short for two rows beside the banner, the banner (and error footer) + // collapse and content may extend to where they would have been. + const footer = Sidebar.effectiveFooter(sidebar_bottom, true, ingress); + const banner = (AccessibilityBounds{ .logical = Sidebar.updateBannerRect(sidebar_bottom, footer.ingress) }).physicalRect(app.dpi); var published_anywhere: usize = 0; for ([_]i32{ 0, 37, 1_000_000 }) |requested_scroll| { app.sidebar_scroll = requested_scroll; @@ -10973,7 +11152,7 @@ test "sidebar UIA bounds and hit targets stay clear of the update banner at 1280 if (rectIsEmpty(bounds)) continue; if (std.mem.startsWith(u8, sink.name(index), "sidebar-update-banner:")) continue; if (!std.mem.startsWith(u8, sink.name(index), "sidebar-error-footer:")) published_anywhere += 1; - if (rectsIntersect(bounds, banner)) { + if (footer.update and rectsIntersect(bounds, banner)) { std.debug.print( "{s} bounds {d},{d},{d},{d} intersect banner {d},{d},{d},{d} at dpi {d} width {d} scroll {d}\n", .{ @@ -11150,12 +11329,14 @@ test "sidebar status text, CHATS label, and Needs you stay separate and reachabl const content_bottom = app.sidebarContentBottom(client.bottom); const inspection = app.currentWorktreeInspection(); - const status = Sidebar.statusTextRect(sidebar_bottom, footer.update, footer.ingress); + // Footer parts that do not leave two rows of room collapse; only the rest are laid out. + const effective = Sidebar.effectiveFooter(sidebar_bottom, footer.update, footer.ingress); + const status = Sidebar.statusTextRect(sidebar_bottom, effective.update, effective.ingress); try std.testing.expect(status.top >= content_bottom); try std.testing.expect(status.bottom <= sidebar_bottom); try std.testing.expect(status.left >= 0 and status.right <= Tokens.sidebar_width); - if (footer.update) try std.testing.expect(!rectsIntersect(status, Sidebar.updateBannerRect(sidebar_bottom, footer.ingress))); - if (footer.ingress) try std.testing.expect(!rectsIntersect(status, Sidebar.errorFooterRect(sidebar_bottom))); + if (effective.update) try std.testing.expect(!rectsIntersect(status, Sidebar.updateBannerRect(sidebar_bottom, effective.ingress))); + if (effective.ingress) try std.testing.expect(!rectsIntersect(status, Sidebar.errorFooterRect(sidebar_bottom))); // The production clamp must agree with the content viewport, so the last row is reachable. const max_scroll = Sidebar.maxScroll(&app.model, inspection, content_bottom, &app.sidebar_state); @@ -11337,6 +11518,546 @@ test "sidebar UIA bounds are the drawn rect clipped to the scroll region at shor try std.testing.expect(destinations >= 32); } +/// A pixel-addressable capture of `Sidebar.draw`, so a test can compare what is painted with +/// the rects UIA publishes and clicks hit. +const SidebarBitmap = struct { + dc: c.HDC, + bitmap: c.HBITMAP, + previous: c.HGDIOBJ, + bits: [*]const u32, + width: i32, + height: i32, + + fn deinit(self: *SidebarBitmap) void { + _ = c.SelectObject(self.dc, self.previous); + _ = c.DeleteObject(self.bitmap); + _ = c.DeleteDC(self.dc); + } + + fn pixel(self: *const SidebarBitmap, x: i32, y: i32) u32 { + if (x < 0 or y < 0 or x >= self.width or y >= self.height) return 0xFFFFFFFF; + return self.bits[@as(usize, @intCast(y)) * @as(usize, @intCast(self.width)) + @as(usize, @intCast(x))] & 0x00FFFFFF; + } +}; + +/// A 32-bit DIB stores 0x00RRGGBB; a GDI COLORREF is 0x00BBGGRR. +fn dibColor(colorref: u32) u32 { + return ((colorref & 0xFF) << 16) | (colorref & 0xFF00) | ((colorref >> 16) & 0xFF); +} + +fn paintSidebarBitmap(app: *App, viewport_bottom: i32, update: bool, scroll: i32) !SidebarBitmap { + const width: i32 = Tokens.sidebar_width + 40; + const height: i32 = @as(i32, @max(viewport_bottom, Tokens.header_height)) + 100; + var info = std.mem.zeroes(c.BITMAPINFO); + info.bmiHeader.biSize = @sizeOf(c.BITMAPINFOHEADER); + info.bmiHeader.biWidth = width; + info.bmiHeader.biHeight = -height; + info.bmiHeader.biPlanes = 1; + info.bmiHeader.biBitCount = 32; + info.bmiHeader.biCompression = c.BI_RGB; + var bits: ?*anyopaque = null; + const dc = c.CreateCompatibleDC(null) orelse return error.TestUnexpectedResult; + errdefer _ = c.DeleteDC(dc); + const bitmap = c.CreateDIBSection(dc, &info, c.DIB_RGB_COLORS, &bits, null, 0) orelse return error.TestUnexpectedResult; + errdefer _ = c.DeleteObject(bitmap); + const previous = c.SelectObject(dc, bitmap); + Sidebar.draw( + dc, + &app.model, + app.currentWorktreeInspection(), + "", + scroll, + "status", + viewport_bottom, + if (update) app.update_version else "", + app.ingress_error, + &app.sidebar_state, + -1, + app.allocator, + ); + _ = c.GdiFlush(); + return .{ + .dc = dc, + .bitmap = bitmap, + .previous = previous, + .bits = @ptrCast(@alignCast(bits orelse return error.TestUnexpectedResult)), + .width = width, + .height = height, + }; +} + +const SidebarControlKind = enum { stop, filter, left, right, card }; +const SidebarControl = struct { + kind: SidebarControlKind, + index: usize, + /// Unclipped logical rect at the scroll offset the control was asked for. + bounds: c.RECT, + fill: u32, +}; +const stop_fill: u32 = 0x00353224; +const activity_fill: u32 = 0x0026262B; + +fn collectSidebarControls(app: *App, scroll: i32, out: *std.array_list.Managed(SidebarControl)) !void { + const inspection = app.currentWorktreeInspection(); + out.clearRetainingCapacity(); + for (0..@min(app.model.attentionCount(), 4)) |index| try out.append(.{ + .kind = .stop, + .index = index, + .bounds = Sidebar.needsYouStopBounds(&app.model, inspection, &app.sidebar_state, scroll, index), + .fill = stop_fill, + }); + if (app.model.activity.items.len == 0) return; + try out.append(.{ + .kind = .filter, + .index = 0, + .bounds = Sidebar.activityFilterBounds(&app.model, inspection, &app.sidebar_state, scroll), + .fill = if (app.sidebar_state.activity_attention_only) 0x00302B1D else activity_fill, + }); + try out.append(.{ .kind = .left, .index = 0, .bounds = Sidebar.activityControlBounds(&app.model, inspection, &app.sidebar_state, scroll, .left), .fill = activity_fill }); + try out.append(.{ .kind = .right, .index = 0, .bounds = Sidebar.activityControlBounds(&app.model, inspection, &app.sidebar_state, scroll, .right), .fill = activity_fill }); + const viewport = Sidebar.activityViewport(&app.model, &app.sidebar_state); + for (0..viewport.visible_count) |visible| try out.append(.{ + .kind = .card, + .index = visible, + .bounds = Sidebar.activityCardBounds(&app.model, inspection, &app.sidebar_state, scroll, visible), + .fill = activity_fill, + }); +} + +test "scrolled Needs you and Activity controls are painted where UIA publishes them and clicks land, 96 and 144 DPI" { + var painted_visible: usize = 0; + var painted_clipped: usize = 0; + var fully_hidden: usize = 0; + var clicked_stops: usize = 0; + for ([_]u32{ 96, 144 }) |dpi| for ([_]i32{ 820, 600, 500, 400, 300 }) |logical_height| for ([_]bool{ false, true }) |update| { + const case = FooterLayoutCase{ .dpi = dpi, .width = physicalCoordinate(960, dpi), .height = physicalCoordinate(logical_height, dpi) }; + var app = try footerLayoutApp(case, footer_layout_controls[0], update, false); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + try std.testing.expect(app.model.attentionCount() >= 2); + try std.testing.expect(app.model.activity.items.len != 0); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const sidebar_bottom = GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface); + const region_top = Sidebar.content_top; + const region_bottom = app.sidebarContentBottom(client.bottom); + const inspection = app.currentWorktreeInspection(); + if (region_bottom - region_top < 20) continue; + + var controls = std.array_list.Managed(SidebarControl).init(app.allocator); + defer controls.deinit(); + try collectSidebarControls(&app, 0, &controls); + // Scroll offsets that leave the first control of each group fully visible, clipped by the + // header, clipped by the footer, and scrolled away entirely. + var offsets = std.array_list.Managed(i32).init(app.allocator); + defer offsets.deinit(); + try offsets.appendSlice(&.{ 0, 37, 1_000_000 }); + for (controls.items) |control| { + if (control.kind == .right or control.kind == .left or control.kind == .card) continue; + try offsets.append(control.bounds.top - region_top - 6); + try offsets.append(control.bounds.bottom - region_top - 6); + try offsets.append(control.bounds.top - region_bottom + 6); + try offsets.append(control.bounds.bottom - region_bottom + 6); + try offsets.append(control.bounds.top - region_top + 40); + } + + for (offsets.items) |requested| { + app.sidebar_scroll = requested; + app.clampSidebarScroll(); + const scroll = app.sidebar_scroll; + try collectSidebarControls(&app, scroll, &controls); + const context = .{ dpi, logical_height, update, scroll }; + + var bitmap = try paintSidebarBitmap(&app, sidebar_bottom, update, scroll); + defer bitmap.deinit(); + + // Nothing in a control's fill colour may be painted outside the control's clipped rect. + for ([_]u32{ stop_fill, activity_fill, 0x00302B1D }) |fill_color| { + const dib = dibColor(fill_color); + var y: i32 = 0; + while (y < bitmap.height) : (y += 1) { + var x: i32 = 0; + while (x < Tokens.sidebar_width) : (x += 1) { + if (bitmap.pixel(x, y) != dib) continue; + const inside = for (controls.items) |control| { + if (control.fill != fill_color) continue; + const clipped = clipSidebarAccessibilityBounds(control.bounds, region_bottom); + if (x >= clipped.left and x < clipped.right and y >= clipped.top and y < clipped.bottom) break true; + } else false; + if (!inside) { + std.debug.print("pixel {d},{d} painted in control fill {x} outside every control's clipped rect {any}\n", .{ x, y, fill_color, context }); + return error.SidebarControlPaintedOutsideItsRect; + } + } + } + } + + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + var stop_seen: usize = 0; + var card_seen: usize = 0; + for (controls.items) |control| { + const clipped = clipSidebarAccessibilityBounds(control.bounds, region_bottom); + const published_index: ?usize = switch (control.kind) { + .filter => sink.indexOfName("activity-filter:attention"), + .left => sink.indexOfName("activity-control:scroll-left"), + .right => sink.indexOfName("activity-control:scroll-right"), + .stop, .card => blk: { + const prefix: []const u8 = if (control.kind == .stop) "needs-you-stop:" else "activity-row:"; + const wanted = if (control.kind == .stop) &stop_seen else &card_seen; + var found: ?usize = null; + var seen: usize = 0; + for (0..sink.count) |index| { + if (!std.mem.startsWith(u8, sink.name(index), prefix)) continue; + if (seen == wanted.*) { + found = index; + break; + } + seen += 1; + } + wanted.* += 1; + break :blk found; + }, + }; + const published = sink.rects[published_index orelse return error.SidebarControlNotPublished]; + const expected = (AccessibilityBounds{ .logical = clipped }).physicalRect(app.dpi); + if (published.left != expected.left or published.top != expected.top or published.right != expected.right or published.bottom != expected.bottom) { + std.debug.print("{s} {d} UIA {d},{d},{d},{d} != clipped drawn rect {d},{d},{d},{d} {any}\n", .{ @tagName(control.kind), control.index, published.left, published.top, published.right, published.bottom, expected.left, expected.top, expected.right, expected.bottom, context }); + return error.SidebarControlUiaMismatch; + } + if (rectIsEmpty(clipped)) { + fully_hidden += 1; + continue; + } + // The painted fill reaches the rect's right edge on its first and last visible rows. + const dib = dibColor(control.fill); + if (bitmap.pixel(clipped.right - 2, clipped.top) != dib or bitmap.pixel(clipped.right - 2, clipped.bottom - 1) != dib) { + std.debug.print("{s} {d} not painted over its clipped rect {d},{d},{d},{d} {any}\n", .{ @tagName(control.kind), control.index, clipped.left, clipped.top, clipped.right, clipped.bottom, context }); + return error.SidebarControlNotPaintedWhereItIsPublished; + } + if (clipped.top == control.bounds.top and clipped.bottom == control.bounds.bottom) painted_visible += 1 else painted_clipped += 1; + + const center_x = @divTrunc(clipped.left + clipped.right, 2); + const center_y = @divTrunc(clipped.top + clipped.bottom, 2); + switch (control.kind) { + .stop => try std.testing.expectEqual(@as(?usize, control.index), Sidebar.needsYouStopAt(center_x, center_y, &app.model, inspection, &app.sidebar_state, scroll)), + .filter => try std.testing.expectEqual(@as(?Sidebar.ActivityControl, .filter), Sidebar.activityControlAt(center_x, center_y, &app.model, inspection, &app.sidebar_state, scroll)), + .left => try std.testing.expectEqual(@as(?Sidebar.ActivityControl, .left), Sidebar.activityControlAt(center_x, center_y, &app.model, inspection, &app.sidebar_state, scroll)), + .right => try std.testing.expectEqual(@as(?Sidebar.ActivityControl, .right), Sidebar.activityControlAt(center_x, center_y, &app.model, inspection, &app.sidebar_state, scroll)), + .card => try std.testing.expectEqual(Sidebar.activityEventAtVisible(&app.model, &app.sidebar_state, control.index), Sidebar.activityCardAt(center_x, center_y, &app.model, inspection, &app.sidebar_state, scroll)), + } + if (control.kind == .stop) { + const before = app.client.outbound_count; + try nativeClick(&app, physicalCoordinate(center_x, app.dpi), physicalCoordinate(center_y, app.dpi)); + try std.testing.expectEqual(before + 1, app.client.outbound_count); + const newest = app.client.outbound[(app.client.outbound_head + app.client.outbound_count - 1) % app.client.outbound.len]; + try std.testing.expect(std.mem.indexOf(u8, newest, "stopNode") != null); + clicked_stops += 1; + } + } + } + }; + // Each outcome must be exercised, or a fixture that paints nothing would pass vacuously. + try std.testing.expect(painted_visible >= 40); + try std.testing.expect(painted_clipped >= 10); + try std.testing.expect(fully_hidden >= 40); + try std.testing.expect(clicked_stops >= 10); +} + +test "sidebar footer collapses the error footer then the banner to keep two rows, and a collapsed part is not painted, published or clickable" { + const banner_fill: u32 = 0x00352B1C; + const error_fill: u32 = 0x00242448; + var collapsed_error: usize = 0; + var collapsed_banner: usize = 0; + var showing_both: usize = 0; + var reachable: usize = 0; + for ([_]u32{ 96, 144 }) |dpi| for ([_]i32{ 300, 400, 517, 520, 560, 640, 820 }) |logical_height| for (footer_layout_controls) |controls| for ([_]bool{ false, true }) |update| for ([_]bool{ false, true }) |ingress| { + const case = FooterLayoutCase{ .dpi = dpi, .width = physicalCoordinate(960, dpi), .height = physicalCoordinate(logical_height, dpi) }; + var app = try footerLayoutApp(case, controls, update, ingress); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const vb = GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface); + const footer = Sidebar.effectiveFooter(vb, update, ingress); + const region_bottom = app.sidebarContentBottom(client.bottom); + const context = .{ dpi, logical_height, controls.panel_visible, update, ingress }; + + // Two rows stay visible whenever the status line alone leaves room for them. + if (vb - Sidebar.footerReserve(false, false) - Sidebar.content_top >= Sidebar.min_content_region) { + reachable += 1; + if (region_bottom - Sidebar.content_top < Sidebar.min_content_region) { + std.debug.print("only {d}px of sidebar content {any}\n", .{ region_bottom - Sidebar.content_top, context }); + return error.SidebarTooShortToShowTwoRows; + } + } + if (ingress and !footer.ingress) collapsed_error += 1; + if (update and !footer.update) collapsed_banner += 1; + if (footer.update and footer.ingress) showing_both += 1; + // A part collapses only to make room: the error footer first, then the banner. + if (footer.update != update) try std.testing.expect(!footer.ingress); + if (vb - Sidebar.footerReserve(footer.update, footer.ingress) >= Tokens.header_height) + try std.testing.expect(Sidebar.statusTextRect(vb, footer.update, footer.ingress).top >= region_bottom); + + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + const banner_rect = Sidebar.updateBannerRect(vb, footer.ingress); + const error_rect = Sidebar.errorFooterRect(vb); + try std.testing.expectEqual(footer.update, sink.indexOfName("sidebar-update-banner:update") != null); + try std.testing.expectEqual(footer.ingress and Sidebar.errorFooterVisible(vb), sink.indexOfName("sidebar-error-footer:ingress") != null); + const center_x = @divTrunc(banner_rect.left + banner_rect.right, 2); + const center_y = @divTrunc(banner_rect.top + banner_rect.bottom, 2); + try std.testing.expectEqual(footer.update, Sidebar.updateBannerAt(center_x, center_y, vb, update, ingress)); + + var bitmap = try paintSidebarBitmap(&app, vb, update, 0); + defer bitmap.deinit(); + const banner_painted = banner_rect.top >= Tokens.header_height and bitmap.pixel(banner_rect.left + 1, banner_rect.top + 1) == dibColor(banner_fill); + try std.testing.expectEqual(footer.update, banner_painted); + const error_painted = Sidebar.errorFooterVisible(vb) and bitmap.pixel(error_rect.left + 1, error_rect.top + 1) == dibColor(error_fill); + try std.testing.expectEqual(footer.ingress and Sidebar.errorFooterVisible(vb), error_painted); + }; + // Each outcome must be exercised, or a layout that never collapses would pass vacuously. + try std.testing.expect(collapsed_error >= 4); + try std.testing.expect(collapsed_banner >= 4); + try std.testing.expect(showing_both >= 8); + try std.testing.expect(reachable >= 48); +} + +test "the minimum window height keeps a two-row sidebar scroll region with the panel, strip, banner and error footer" { + for ([_]u32{ 96, 144 }) |dpi| { + var app = try overviewTestApp(dpi); + defer deinitOverviewTestApp(&app); + var info = std.mem.zeroes(c.MINMAXINFO); + info.ptMinTrackSize.y = 100; + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_GETMINMAXINFO, 0, @bitCast(@intFromPtr(&info)), &result)); + // The test window is a frameless popup, so its outer height is its client height. + try std.testing.expectEqual(physicalCoordinate(min_client_height, dpi), info.ptMinTrackSize.y); + try std.testing.expectEqual(@as(i32, 517), min_client_height); + // A larger system minimum is never reduced. + info.ptMinTrackSize.y = physicalCoordinate(min_client_height, dpi) + 50; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_GETMINMAXINFO, 0, @bitCast(@intFromPtr(&info)), &result)); + try std.testing.expectEqual(physicalCoordinate(min_client_height, dpi) + 50, info.ptMinTrackSize.y); + } + var covered: usize = 0; + for ([_]u32{ 96, 144 }) |dpi| for (footer_layout_controls) |controls| for ([_]bool{ false, true }) |update| for ([_]bool{ false, true }) |ingress| { + const case = FooterLayoutCase{ .dpi = dpi, .width = physicalCoordinate(960, dpi), .height = physicalCoordinate(min_client_height, dpi) }; + var app = try footerLayoutApp(case, controls, update, ingress); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + const client = logicalClientRect(app.window.hwnd, app.dpi); + try std.testing.expectEqual(min_client_height, client.bottom); + const region = app.sidebarContentBottom(client.bottom) - Sidebar.content_top; + try std.testing.expect(region >= Sidebar.min_content_region); + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + var rows: usize = 0; + for (0..sink.count) |index| { + if (sink.scrolls[index] and !rectIsEmpty(sink.rects[index])) rows += 1; + } + try std.testing.expect(rows >= 1); + covered += 1; + }; + try std.testing.expectEqual(@as(usize, 16), covered); +} + +const OfferProbe = struct { + var count: usize = 0; + var version: [32]u8 = undefined; + var version_len: usize = 0; + var url: [96]u8 = undefined; + var url_len: usize = 0; + + fn observe(_: *App, offered_version: []const u8, offered_url: []const u8) void { + count += 1; + version_len = @min(offered_version.len, version.len); + @memcpy(version[0..version_len], offered_version[0..version_len]); + url_len = @min(offered_url.len, url.len); + @memcpy(url[0..url_len], offered_url[0..url_len]); + } + + fn reset() void { + count = 0; + version_len = 0; + url_len = 0; + } +}; + +test "banner click and the UIA dynamic invoke command open the same update offer" { + // The exact wParam the native provider's IInvokeProvider::Invoke posts for the banner: + // the dynamic-invoke tag over FNV-1a("sidebar-update-banner:update"). The native provider test + // (AccessibilityProvider.Native.Tests.cpp) asserts the same literal is what Invoke posts. + const uia_banner_command: usize = 0x83fd6a5f429785e1; + try std.testing.expectEqual( + uia_banner_command, + Accessibility.uia_dynamic_invoke_tag | Accessibility.worktreeIdentityPayload("sidebar-update-banner:update"), + ); + update_offer_observer = OfferProbe.observe; + defer update_offer_observer = null; + var offers: usize = 0; + var collapsed: usize = 0; + for (footer_layout_cases) |case| for (footer_layout_controls) |controls| for ([_]bool{ false, true }) |ingress| { + var app = try footerLayoutApp(case, controls, true, ingress); + defer deinitFooterLayoutApp(&app); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const vb = GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface); + const footer = Sidebar.effectiveFooter(vb, true, ingress); + const banner = Sidebar.updateBannerRect(vb, footer.ingress); + const click_x = physicalCoordinate(@divTrunc(banner.left + banner.right, 2), app.dpi); + const click_y = physicalCoordinate(@divTrunc(banner.top + banner.bottom, 2), app.dpi); + + OfferProbe.reset(); + if (!footer.update) { + // A banner collapsed on a short viewport is not a click target. + try nativeClick(&app, click_x, click_y); + try std.testing.expectEqual(@as(usize, 0), OfferProbe.count); + collapsed += 1; + continue; + } + try nativeClick(&app, click_x, click_y); + try std.testing.expectEqual(@as(usize, 1), OfferProbe.count); + try std.testing.expectEqualStrings("9.9.9-test", OfferProbe.version[0..OfferProbe.version_len]); + try std.testing.expectEqualStrings(app.update_release_url, OfferProbe.url[0..OfferProbe.url_len]); + + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_COMMAND, uia_banner_command, 0, &result)); + try std.testing.expectEqual(@as(usize, 2), OfferProbe.count); + try std.testing.expectEqualStrings("9.9.9-test", OfferProbe.version[0..OfferProbe.version_len]); + try std.testing.expectEqualStrings(app.update_release_url, OfferProbe.url[0..OfferProbe.url_len]); + + // No available update: neither route opens an offer. + app.update_state.state = .up_to_date; + try nativeClick(&app, click_x, click_y); + try std.testing.expect(onWindowMessage(&app, app.window.hwnd, c.WM_COMMAND, uia_banner_command, 0, &result)); + try std.testing.expectEqual(@as(usize, 2), OfferProbe.count); + offers += 1; + }; + try std.testing.expectEqual(@as(usize, 14), offers); + try std.testing.expectEqual(@as(usize, 2), collapsed); +} + +test "every scrolling sidebar element advertises scroll-into-view and the fixed footer and header do not" { + var scrolling: usize = 0; + var fixed: usize = 0; + for ([_]u32{ 96, 144 }) |dpi| for ([_]i32{ 820, 500 }) |logical_height| { + const case = FooterLayoutCase{ .dpi = dpi, .width = physicalCoordinate(960, dpi), .height = physicalCoordinate(logical_height, dpi) }; + var app = try footerLayoutApp(case, footer_layout_controls[0], true, true); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const content_bottom = app.sidebarContentBottom(client.bottom); + for ([_]i32{ 0, 120, 1_000_000 }) |requested| { + app.sidebar_scroll = requested; + app.clampSidebarScroll(); + var sink: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&sink, client); + for (0..sink.count) |index| { + const name = sink.name(index); + const is_fixed = std.mem.startsWith(u8, name, "sidebar-update-banner:") or std.mem.startsWith(u8, name, "sidebar-error-footer:"); + try std.testing.expectEqual(!is_fixed, sink.scrolls[index]); + if (is_fixed) fixed += 1 else scrolling += 1; + } + const state = sink.scroll_state orelse return error.SidebarScrollStateNotPublished; + try std.testing.expectEqual(app.sidebar_scroll, state.offset); + try std.testing.expectEqual(Sidebar.maxScroll(&app.model, app.currentWorktreeInspection(), content_bottom, &app.sidebar_state), state.max_offset); + try std.testing.expectEqual(content_bottom - Sidebar.content_top, state.viewport); + try std.testing.expect(state.max_offset > 0); + } + }; + try std.testing.expect(scrolling >= 400); + try std.testing.expect(fixed >= 20); +} + +fn sidebarScrollMessage(app: *App, operation: usize, argument: isize) !bool { + var result: c.LRESULT = 0; + try std.testing.expect(onWindowMessage(app, app.window.hwnd, Accessibility.wm_sidebar_scroll, operation, argument, &result)); + return result == 1; +} + +test "UIA scroll-into-view brings every sidebar element fully into the scroll region from any offset, 96 and 144 DPI, 820 to 300" { + var revealed: usize = 0; + for ([_]u32{ 96, 144 }) |dpi| for ([_]i32{ 820, 500, 400, 300 }) |logical_height| for ([_]bool{ false, true }) |update| { + const case = FooterLayoutCase{ .dpi = dpi, .width = physicalCoordinate(960, dpi), .height = physicalCoordinate(logical_height, dpi) }; + var app = try footerLayoutApp(case, footer_layout_controls[0], update, false); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const region_top = Sidebar.content_top; + const region_bottom = app.sidebarContentBottom(client.bottom); + try std.testing.expect(region_bottom - region_top >= Sidebar.min_content_region); + + // Every scrolling element's whole extent, from a client tall enough that nothing is clipped. + app.sidebar_scroll = 0; + var baseline: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&baseline, .{ .left = 0, .top = 0, .right = client.right, .bottom = client.bottom + 100_000 }); + try std.testing.expect(baseline.count >= 60); + + for ([_]i32{ 0, 1_000_000 }) |start| for (0..baseline.count) |index| { + if (!baseline.scrolls[index]) continue; + const whole = baseline.rects[index]; + const height = logicalCoordinate(whole.bottom, app.dpi) - logicalCoordinate(whole.top, app.dpi); + const identity = baseline.name(index); + // A truncated identity would hash differently from the one the app matches on. + try std.testing.expect(identity.len < baseline.names[index].len); + app.sidebar_scroll = start; + app.clampSidebarScroll(); + const payload = Accessibility.worktreeIdentityPayload(identity); + try std.testing.expect(try sidebarScrollMessage(&app, Accessibility.SidebarScrollOperation.into_view, @intCast(payload))); + + var after: SidebarUiaSink = .{}; + app.syncAccessibilityTo(&after, client); + const published = for (0..after.count) |after_index| { + if (std.mem.eql(u8, after.name(after_index), identity)) break after.rects[after_index]; + } else return error.SidebarElementNotPublishedAfterScrollIntoView; + const top = logicalCoordinate(published.top, app.dpi); + const bottom = logicalCoordinate(published.bottom, app.dpi); + if (top < region_top or bottom > region_bottom or bottom - top != @min(height, region_bottom - region_top)) { + std.debug.print("{s} published {d}..{d} (whole height {d}) in region {d}..{d} after scroll-into-view from {d} dpi {d} height {d}\n", .{ identity, top, bottom, height, region_top, region_bottom, start, dpi, logical_height }); + return error.SidebarElementNotFullyRevealed; + } + revealed += 1; + }; + }; + try std.testing.expect(revealed >= 1000); +} + +test "UIA scroll requests move the one sidebar offset and refuse what they cannot do" { + const operations = Accessibility.SidebarScrollOperation; + var app = try footerLayoutApp(footer_layout_cases[0], footer_layout_controls[0], true, false); + defer deinitFooterLayoutApp(&app); + try loadManySidebarLoops(&app, 40); + const client = logicalClientRect(app.window.hwnd, app.dpi); + const content_bottom = app.sidebarContentBottom(client.bottom); + const viewport = content_bottom - Sidebar.content_top; + const maximum = Sidebar.maxScroll(&app.model, app.currentWorktreeInspection(), content_bottom, &app.sidebar_state); + try std.testing.expect(maximum > viewport * 2); + + app.sidebar_scroll = 0; + try std.testing.expect(try sidebarScrollMessage(&app, operations.large_increment, 0)); + try std.testing.expectEqual(viewport, app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.small_increment, 0)); + try std.testing.expectEqual(viewport + 24, app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.small_decrement, 0)); + try std.testing.expect(try sidebarScrollMessage(&app, operations.large_decrement, 0)); + try std.testing.expectEqual(@as(i32, 0), app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.large_decrement, 0)); + try std.testing.expectEqual(@as(i32, 0), app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.set_percent, 10_000)); + try std.testing.expectEqual(maximum, app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.large_increment, 0)); + try std.testing.expectEqual(maximum, app.sidebar_scroll); + try std.testing.expect(try sidebarScrollMessage(&app, operations.set_percent, 5_000)); + try std.testing.expectEqual(@divTrunc(maximum + 1, 2), app.sidebar_scroll); + + // Refusals leave the offset alone. + const before = app.sidebar_scroll; + try std.testing.expect(!try sidebarScrollMessage(&app, operations.set_percent, 10_001)); + try std.testing.expect(!try sidebarScrollMessage(&app, operations.set_percent, -1)); + try std.testing.expect(!try sidebarScrollMessage(&app, 2, 0)); + try std.testing.expect(!try sidebarScrollMessage(&app, operations.into_view, @intCast(Accessibility.worktreeIdentityPayload("sidebar-update-banner:update")))); + try std.testing.expect(!try sidebarScrollMessage(&app, operations.into_view, @intCast(Accessibility.worktreeIdentityPayload("header-jump:jump")))); + try std.testing.expect(!try sidebarScrollMessage(&app, operations.into_view, @intCast(Accessibility.worktreeIdentityPayload("loop:not-a-row")))); + try std.testing.expectEqual(before, app.sidebar_scroll); +} + test "update available banner is an invokable UIA button whose bounds are the drawn rect" { var published: usize = 0; for (footer_layout_cases) |case| for (footer_layout_controls) |controls| for ([_]bool{ false, true }) |ingress| for ([_]bool{ false, true }) |update| { @@ -11344,28 +12065,30 @@ test "update available banner is an invokable UIA button whose bounds are the dr defer deinitFooterLayoutApp(&app); const client = logicalClientRect(app.window.hwnd, app.dpi); const sidebar_bottom = GraphCanvas.sidebarBottom(client.bottom, app.workspace_controls, app.surface); + const footer = Sidebar.effectiveFooter(sidebar_bottom, update, ingress); for ([_]i32{ 0, 1_000_000 }) |requested_scroll| { app.sidebar_scroll = requested_scroll; app.clampSidebarScroll(); var sink: SidebarUiaSink = .{}; app.syncAccessibilityTo(&sink, client); const found = sink.indexOfName("sidebar-update-banner:update"); - if (!update) { + // A banner that is absent, or collapsed on a viewport too short to also keep two rows, is not published. + if (!footer.update) { try std.testing.expect(found == null); continue; } const index = found orelse return error.UpdateBannerNotPublished; - const expected = (AccessibilityBounds{ .logical = Sidebar.updateBannerRect(sidebar_bottom, ingress) }).physicalRect(app.dpi); + const expected = (AccessibilityBounds{ .logical = Sidebar.updateBannerRect(sidebar_bottom, footer.ingress) }).physicalRect(app.dpi); try std.testing.expectEqualDeep(expected, sink.rects[index]); try std.testing.expectEqualStrings("Update available", sink.label(index)); try std.testing.expect(sink.invokable[index]); published += 1; } }; - try std.testing.expect(published >= 32); + try std.testing.expect(published >= 28); - // Invoking it routes to the same update offer a click opens; with no update available the - // offer is a no-op, so this does not raise the modal dialog. + // Invoking it with no update available is a no-op; the real invoke-versus-click equivalence + // is covered by "banner click and the UIA dynamic invoke command open the same update offer". var app = try footerLayoutApp(footer_layout_cases[0], footer_layout_controls[0], false, false); defer deinitFooterLayoutApp(&app); try std.testing.expect(app.applyUiaDynamicInvoke(Accessibility.worktreeIdentityPayload("sidebar-update-banner:update"))); diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index 2b0a7f00..d3ebef58 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -182,8 +182,9 @@ pub fn draw( fill(hdc, sidebar, Tokens.workspace_rail); drawText(hdc, allocator, "GRAPH", 18, Tokens.header_height + 20, 16, 0x00FFFFFF); drawText(hdc, allocator, "Projects", 18, Tokens.header_height + 54, 14, 0x00B8B8B8); - const has_update = update_version.len != 0; - const has_error = ingress_error.len != 0; + const footer = effectiveFooter(viewport_bottom, update_version.len != 0, ingress_error.len != 0); + const has_update = footer.update; + const has_error = footer.ingress; const saved_clip = c.SaveDC(hdc); _ = c.IntersectClipRect( hdc, @@ -286,7 +287,7 @@ pub fn draw( for (model.attention_entries.items[0..@min(model.attention_entries.items.len, 4)], 0..) |entry, index| { drawText(hdc, allocator, entry.node.title, 24, attention_y, 11, 0x00E6E6E6); drawText(hdc, allocator, attentionReason(entry.node), 24, attention_y + 15, 9, stateColor(entry.node.state)); - const stop_bounds = needsYouStopBounds(model, inspection, state, 0, index); + const stop_bounds = needsYouStopBounds(model, inspection, state, scroll_offset, index); fill(hdc, stop_bounds, 0x00353224); drawTextRect(hdc, allocator, "Stop", stop_bounds, 9, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); attention_y += 34; @@ -295,7 +296,7 @@ pub fn draw( for (model.attention.items[0..@min(model.attention.items.len, 4)], 0..) |node, index| { drawText(hdc, allocator, node.title, 24, attention_y, 11, 0x00E6E6E6); drawText(hdc, allocator, attentionReason(node), 24, attention_y + 15, 9, stateColor(node.state)); - const stop_bounds = needsYouStopBounds(model, inspection, state, 0, index); + const stop_bounds = needsYouStopBounds(model, inspection, state, scroll_offset, index); fill(hdc, stop_bounds, 0x00353224); drawTextRect(hdc, allocator, "Stop", stop_bounds, 9, 0x00FFCD7A, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); attention_y += 34; @@ -306,11 +307,11 @@ pub fn draw( const attention_rows = @min(model.attentionCount(), 4); const activity_y = section_y + 30 + (@as(i32, @intCast(attention_rows)) * 34) + 18; drawText(hdc, allocator, "Activity", 18, activity_y, 11, 0x00B8B8B8); - const filter_bounds = activityFilterBounds(model, inspection, state, 0); + const filter_bounds = activityFilterBounds(model, inspection, state, scroll_offset); fill(hdc, filter_bounds, if (state.activity_attention_only) 0x00302B1D else 0x0026262B); drawTextRect(hdc, allocator, "Attention only", filter_bounds, 9, if (state.activity_attention_only) 0x00FFCD7A else 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); - const left_bounds = activityControlBounds(model, inspection, state, 0, .left); - const right_bounds = activityControlBounds(model, inspection, state, 0, .right); + const left_bounds = activityControlBounds(model, inspection, state, scroll_offset, .left); + const right_bounds = activityControlBounds(model, inspection, state, scroll_offset, .right); fill(hdc, left_bounds, 0x0026262B); fill(hdc, right_bounds, 0x0026262B); drawTextRect(hdc, allocator, "<", left_bounds, 10, 0x00B8B8B8, c.DT_CENTER | c.DT_SINGLELINE | c.DT_VCENTER); @@ -319,7 +320,7 @@ pub fn draw( for (0..viewport.visible_count) |visible_index| { const activity_index = activityEventAtVisible(model, state, visible_index) orelse break; const event = model.activity.items[activity_index]; - const card = activityCardBounds(model, inspection, state, 0, visible_index); + const card = activityCardBounds(model, inspection, state, scroll_offset, visible_index); const stamp = std.fmt.allocPrint(allocator, "{d}m", .{@max(0, @divTrunc(std.time.timestamp() - event.timestamp, 60))}) catch null; defer if (stamp) |value| allocator.free(value); fill(hdc, card, 0x0026262B); @@ -329,13 +330,13 @@ pub fn draw( } } if (saved_clip != 0) _ = c.RestoreDC(hdc, saved_clip); - if (ingress_error.len != 0 and errorFooterVisible(viewport_bottom)) { + if (has_error and errorFooterVisible(viewport_bottom)) { const bounds = errorFooterRect(viewport_bottom); fill(hdc, bounds, 0x00242448); drawTextRect(hdc, allocator, ingress_error, errorFooterTextRect(viewport_bottom), 10, 0x006060FF, c.DT_LEFT | c.DT_WORDBREAK | c.DT_NOPREFIX); } - if (update_version.len != 0) { - const bounds = updateBannerRect(viewport_bottom, ingress_error.len != 0); + if (has_update) { + const bounds = updateBannerRect(viewport_bottom, has_error); fill(hdc, bounds, 0x00352B1C); drawText(hdc, allocator, "v", bounds.left + 10, bounds.top + 10, 14, 0x00FF840A); drawText(hdc, allocator, "Update available", bounds.left + 30, bounds.top + 7, 12, 0x00F0F0F0); @@ -347,7 +348,7 @@ pub fn draw( hdc, allocator, status, - statusTextRect(viewport_bottom, update_version.len != 0, ingress_error.len != 0), + statusTextRect(viewport_bottom, has_update, has_error), 11, 0x00909090, c.DT_LEFT | c.DT_SINGLELINE | c.DT_END_ELLIPSIS | c.DT_NOPREFIX, @@ -393,6 +394,33 @@ pub fn footerReserve(has_update: bool, has_error: bool) i32 { (if (has_update) footer_update_extent else 0); } +/// Scroll region (two rows) the sidebar keeps before it gives footer space back to content. +pub const min_content_region: i32 = 52; + +pub const Footer = struct { + /// The update banner is shown above the status line. + update: bool, + /// The inline ingress error footer is shown above the update banner. + ingress: bool, +}; + +/// The footer parts that fit while still leaving `min_content_region` of scrolling content. On a +/// short viewport the error footer collapses first, then the update banner; the status line is +/// the footer's floor. The same result drives drawing, hit testing, and UIA, so a collapsed +/// part is neither painted nor published nor clickable. +pub fn effectiveFooter(viewport_bottom: i32, has_update: bool, has_error: bool) Footer { + var footer = Footer{ .update = has_update, .ingress = has_error }; + if (footerLeavesRegion(viewport_bottom, footer)) return footer; + footer.ingress = false; + if (footerLeavesRegion(viewport_bottom, footer)) return footer; + footer.update = false; + return footer; +} + +fn footerLeavesRegion(viewport_bottom: i32, footer: Footer) bool { + return viewport_bottom - footerReserve(footer.update, footer.ingress) - content_top >= min_content_region; +} + /// Bottom edge of the scrollable sidebar content; rows, labels, hit targets, and UIA /// bounds all end here so they cannot sit under the status line, banner, or error footer. pub fn contentViewportBottom(viewport_bottom: i32, has_update: bool, has_error: bool) i32 { @@ -439,8 +467,9 @@ pub fn updateBannerRect(viewport_bottom: i32, has_error: bool) c.RECT { } pub fn updateBannerAt(x: i32, y: i32, viewport_bottom: i32, available: bool, has_error: bool) bool { - if (!available) return false; - const bounds = updateBannerRect(viewport_bottom, has_error); + const footer = effectiveFooter(viewport_bottom, available, has_error); + if (!footer.update) return false; + const bounds = updateBannerRect(viewport_bottom, footer.ingress); return x >= bounds.left and x < bounds.right and y >= bounds.top and y < bounds.bottom; } @@ -1034,7 +1063,7 @@ pub fn activityControlBounds( control: ActivityDirection, ) c.RECT { const top = activityHeaderTop(model, inspection, state, scroll_offset); - const left: i32 = if (control == .left) 184 else 208; + const left: i32 = if (control == .left) 172 else 196; return rect(left, top, left + 22, top + 22); } @@ -1868,6 +1897,33 @@ test "update banner is a bounded footer action" { try std.testing.expect(updateBannerRect(700, true).bottom < errorFooterRect(700).top); } +test "footer parts collapse error footer first, then the update banner, to keep two rows" { + // Room for everything: nothing collapses. + try std.testing.expectEqual(Footer{ .update = true, .ingress = true }, effectiveFooter(700, true, true)); + try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(700, false, false)); + try std.testing.expectEqual(Footer{ .update = true, .ingress = false }, effectiveFooter(700, true, false)); + // The collapse points are exact: the stack fits when viewport - reserve - content_top >= min. + const status_only = content_top + min_content_region + footerReserve(false, false); + const with_update = content_top + min_content_region + footerReserve(true, false); + const with_both = content_top + min_content_region + footerReserve(true, true); + try std.testing.expectEqual(Footer{ .update = true, .ingress = true }, effectiveFooter(with_both, true, true)); + try std.testing.expectEqual(Footer{ .update = true, .ingress = false }, effectiveFooter(with_both - 1, true, true)); + try std.testing.expectEqual(Footer{ .update = true, .ingress = false }, effectiveFooter(with_update, true, true)); + try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(with_update - 1, true, true)); + try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(status_only, true, true)); + // An error footer collapses without an update banner too, and nothing is ever added. + try std.testing.expectEqual(Footer{ .update = false, .ingress = true }, effectiveFooter(content_top + min_content_region + footerReserve(false, true), false, true)); + try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(content_top + min_content_region + footerReserve(false, true) - 1, false, true)); + try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(10, false, false)); + // The collapsed footer is the one the content region, status line, and banner hit test use. + for ([_]i32{ status_only, with_update - 1, with_update, with_both - 1, with_both }) |viewport| { + const footer = effectiveFooter(viewport, true, true); + try std.testing.expect(viewport - footerReserve(footer.update, footer.ingress) - content_top >= min_content_region or !footer.update and !footer.ingress); + const banner = updateBannerRect(viewport, footer.ingress); + try std.testing.expectEqual(footer.update, updateBannerAt(banner.left, banner.top, viewport, true, true)); + } +} + test "error footer exposes an inset wrapping rect" { const footer = errorFooterRect(700); const text = errorFooterTextRect(700); diff --git a/investigation/ui-parity-matrix.md b/investigation/ui-parity-matrix.md index c642e8a1..da10c141 100644 --- a/investigation/ui-parity-matrix.md +++ b/investigation/ui-parity-matrix.md @@ -127,7 +127,7 @@ native keyboard/accelerator/window proof; the workspace row remains Partial. | Loop context menu | Open, composite actions, rename, stop, delete | Sidebar and canvas loop rows share stable-ID Open, Rename, Stop, and Delete actions. Composite cards expose Open Group, Pilot Once, and Arm Schedule; the drilled-in canvas addresses mutations through the parent composite. The live UIA gate now opens and reads all three `wm_uia_context_menu` loop variants: target 3 (a plain wired loop) asserts Open/Rename/Stop/Delete are present and that every composite-only and unwired-only command is absent; target 6 (a composite, not-yet-piloted loop) asserts Open Group/Pilot Once/Arm Schedule are present with Arm Schedule rendered `MF_GRAYED` (not piloted), and that unwired-only commands are absent; target 7 (an unwired loop node, added live via the sidebar-reorder fixture mutation) asserts Wire it up/Mark as entry are present and composite-only commands are absent. As with the project context menu, item identity/text/enabled state are read through `MN_GETHMENU` and the Win32 menu API against the HMENU the shell handed to `TrackPopupMenu` — live evidence of what the shell actually renders, not UIA-tree evidence | Validated | | Recent projects | Reachable from Add Folder menu | Recent and currently-open projects are now exposed as distinct sidebar rows, with unopened recents remaining under the LOCAL/REMOTE sections while open workspaces use separate `open-project` identities. The deterministic UIA gate verifies the split presentation and section behavior. The live gate now also walks the Recent Folders submenu reachable from Add Folder end-to-end: it sends a real `WM_INITMENUPOPUP` (the message `App.zig` uses to refresh `recent_folders` from the live model before a popup shows, so the read reflects the fixture's recent projects rather than pre-fixture placeholder state), reads the submenu's live items in fixture order with their stable `recent_folder_command_base`-derived command IDs, and invokes the second entry through the real `WM_COMMAND` route, confirming the shell routes it without crashing | Validated | | Add Folder menu | Open Folder, Clone, Add Remote, recents | File now groups Open Folder, Clone Repository, Add Remote Repository, and Add Codespace under Add Folder and adds a dedicated Recent Folders submenu with its own command range; that submenu is now located rather than positionally indexed, so a new ingress entry can no longer silently retarget the rebuild. Focused MainWindow coverage validates the native menu structure and recent-folder command wiring. The live UIA gate now reads the Add Folder submenu directly off the live `HMENU` (`GetMenu`/`GetSubMenu`, not `TrackPopupMenu`, since this is the persistent menu bar) and asserts all four action labels plus the Recent Folders submenu; see the Recent projects row above for the live Recent Folders walkthrough this shares | Validated | -| Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. The banner now also has its own UIA element (`sidebar-update-banner-*`, name "Update available", invokable button, bounds equal to the drawn rect, opening the same update offer as a click); App tests cover that bounds/name/invoke routing, but the live gate has not been changed to use it, so the next sentence still describes the gate's pixel-click path. The live UIA gate now drives this through the real click path rather than the `GRAPHCODE_UIA_SHOW_UPDATE` bypass: the gate computes the banner's live pixel geometry from the shell's real client height (matching the same viewport-bottom formula the paint code uses) and posts a genuine synthetic `WM_LBUTTONDOWN`/`WM_LBUTTONUP` at that point, then verifies the real `GraphCode Update Available` dialog opens with the offered `GraphCode 9.9.9-test` version text, dismisses it via the live Later command, and confirms the shell survives. **In-app install remains deliberately Blocked** — the offer still hands installation off to the verified release page, and this row's evidence does not claim otherwise | Validated | +| Sidebar update banner | Available version and click-to-install action | A persistent footer banner now shows the retained offered version and reopens the native update offer when clicked. The banner now also has its own UIA element (`sidebar-update-banner-*`, name "Update available", invokable button, bounds equal to the drawn rect, opening the same update offer as a click); App tests cover that bounds/name/invoke routing, but the live gate has not been changed to use it, so the next sentence still describes the gate's pixel-click path. On a viewport too short to leave two rows of sidebar content beside it, the banner (like the inline error footer, which collapses first) is not painted, published, or clickable; only the status line remains. A native provider test shows COM `IInvokeProvider::Invoke` posts exactly the dynamic-invoke command for the banner identity, and an App test shows that command and a banner click open the same offer (version and release URL); these are two linked tests, not one test spanning COM to the dialog, and the real dialog is still only opened by the live gate's click path. The live UIA gate now drives this through the real click path rather than the `GRAPHCODE_UIA_SHOW_UPDATE` bypass: the gate computes the banner's live pixel geometry from the shell's real client height (matching the same viewport-bottom formula the paint code uses) and posts a genuine synthetic `WM_LBUTTONDOWN`/`WM_LBUTTONUP` at that point, then verifies the real `GraphCode Update Available` dialog opens with the offered `GraphCode 9.9.9-test` version text, dismisses it via the live Later command, and confirms the shell survives. **In-app install remains deliberately Blocked** — the offer still hands installation off to the verified release page, and this row's evidence does not claim otherwise | Validated | | Sidebar error footer | Persistent, scoped project-ingress error | Folder, clone, remote, and daemon-open failures now persist in a dedicated red sidebar footer independently of transient status. Successful project ingress clears it, wrapped layout preserves long messages, and the deterministic UIA gate verifies the dedicated footer identity plus multi-line bounds below the update offer. | Validated | | Needs-you section | Navigable list with reason/project and Stop action | Up to four entries now expose selection, explicit reason copy, stable UIA identities, click/UIA navigation, and a dedicated Stop action. Focused routing coverage plus the deterministic UIA gate verify Stop targets the populated entry's real project path and loop ID. | Validated | | Activity strip | Optional bottom strip, summary, attention-only filter, horizontally scrolling actionable events | Activity events retain project/node identity and timestamps, render timestamped cards, expose stable UIA rows plus scroll controls, and now keep a real horizontal viewport with an attention-only filter. Focused Sidebar coverage and the deterministic UIA gate verify scroll-state changes, attention-only filtering, and card navigation into the selected loop workspace. | Validated | @@ -242,7 +242,7 @@ case proves ownership only. All existing Partial rows remain Partial. | 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. The Workspace menu's New, Rename, and Delete lifecycle commands and dynamic workspace-switch rows are now part of that native provider tree instead of existing only in `Accessibility.zig`; the Zig contract was reduced to the native fixed table, and a pinned-Zig executable test now fails when any contract id lacks an exact native fixed-table id. The live gate now also traverses the Workspace menu in RawView and ControlView, checks its fixed lifecycle names and InvokePattern exposure, and verifies at least one `workspace-switch-*` row under the same parent. `TerminalSurface.zig` retains reported selection and cell metrics, but callback metadata does not prove applied terminal selection. Exact-pin Winghostty `f5abc059` source already creates an embedded child-HWND Text/Text2 provider and routes `WM_GETOBJECT` to it; the earlier missing-provider premise was inaccurate. GraphCode now feeds that provider owned UTF-8 from its unchanged rendered-cell grid with independent UTF-16 length/cursor offsets, rather than a rolling raw VT byte tail. Focused producer tests exercise overwritten text, chunk splits, reset/rollover, Unicode scalar representation, bounds, allocation/lifetime and injected publication failures without native APIs. This is fixed current-grid content, not transcript/scrollback or full Unicode terminal rendering. Render/text publication remains best-effort: failures are reported and the provider may retain its last successful, no-longer-current snapshot. Native terminal text results, applied selection, visible caret/geometry, range/HRESULT conformance, retained-generation behavior, atomicity, daemon-to-model UIA integration, and remaining dialogs still need separate evidence or implementation; keyboard discovery and HelpText residuals are not closed | Partial | +| UI Automation tree | Names, roles, selection, invoke/toggle, focus, live status for every visible surface | The synchronized live C++ provider exposes stable project rows, loop rows, project/overview/Quick Chat cards, worktree rows, destinations, canvas primary action, zoom controls, policy actions, focus, selection-change events, and status. The live gate uses explicitly in-process deterministic fixtures to validate populated RawView/ControlView navigation, real bounds, observable Quick Chat/workspace invocation effects, tagged-command isolation, identity-preserving reorder/removal, events, concurrency, and teardown. The Workspace menu's New, Rename, and Delete lifecycle commands and dynamic workspace-switch rows are now part of that native provider tree instead of existing only in `Accessibility.zig`; the Zig contract was reduced to the native fixed table, and a pinned-Zig executable test now fails when any contract id lacks an exact native fixed-table id. The live gate now also traverses the Workspace menu in RawView and ControlView, checks its fixed lifecycle names and InvokePattern exposure, and verifies at least one `workspace-switch-*` row under the same parent. `TerminalSurface.zig` retains reported selection and cell metrics, but callback metadata does not prove applied terminal selection. Exact-pin Winghostty `f5abc059` source already creates an embedded child-HWND Text/Text2 provider and routes `WM_GETOBJECT` to it; the earlier missing-provider premise was inaccurate. GraphCode now feeds that provider owned UTF-8 from its unchanged rendered-cell grid with independent UTF-16 length/cursor offsets, rather than a rolling raw VT byte tail. Focused producer tests exercise overwritten text, chunk splits, reset/rollover, Unicode scalar representation, bounds, allocation/lifetime and injected publication failures without native APIs. This is fixed current-grid content, not transcript/scrollback or full Unicode terminal rendering. Render/text publication remains best-effort: failures are reported and the provider may retain its last successful, no-longer-current snapshot. Native terminal text results, applied selection, visible caret/geometry, range/HRESULT conformance, retained-generation behavior, atomicity, daemon-to-model UIA integration, and remaining dialogs still need separate evidence or implementation. The sidebar lists now also expose `IScrollProvider` (vertical only, one shared offset) and each scrolling sidebar element `IScrollItemProvider`, and the root's coordinate hit-test resolves the row, destination, banner, or header under a point (banner above header above scrolled rows) instead of returning the focused element; native provider tests and App tests cover those contracts, but no screen reader, Narrator, or live UIA client has been run against them; keyboard discovery and HelpText residuals are not closed | Partial | | Reproducible DPI/geometry regression coverage | Control metrics for GraphCode-owned chrome scale correctly and predictably across 100/125/150/200% DPI | `Tools/windows/visual-baseline.ps1` previously only checked that each DPI variant's `scale`/`viewport` were present and positive. It now reimplements `Dpi.zig`'s exact `scale()` rounding formula, self-checked against `Dpi.zig`'s own fixed-point unit-test cases, reads the real base pixel values straight out of `DesignTokens.zig` (not a copy baked into the manifest), and asserts the scaled geometry for `sidebar_width`, `tab_bar_height`, `pane_header_height`, and `loop_bar_height` at the real Windows per-monitor DPI values (96/120/144/192) is monotonic and matches the 96-DPI base exactly at 100%. Every `regionGeometry` entry is required to target a `deterministicScreenshotRegions` (GraphCode-owned) region, never a Winghostty-owned one, keeping third-party terminal pixels structurally out of scope. **This check performs static manifest/geometry metadata validation, not rendered-output comparison: it never launches the app, captures a window, or rasterizes a bitmap.** It re-derives expected numeric geometry from source-of-truth code and checks the manifest against that math, which is materially stronger than the prior presence-only checks and does catch real drift, but it is not a screenshot diff and should not be read as one; this repo/CI has no deterministic way to rasterize a live Win32 window. Manually re-verified that corrupting either a DPI value or a `DesignTokens.zig` constant makes the script fail | Validated | | Keyboard discovery | Every shortcut represented by a menu item or visible hint where practical | The Loop, View, and File menus label Show in Graph (Ctrl+Shift+G), Reconnect (Ctrl+R), and Project Worktree Policy (Ctrl+Shift+P); Help > Keyboard Shortcuts lists remaining useful mapped routes (Ctrl+M, context-sensitive Ctrl+E, Ctrl+Up/Down, worktree Up/Down, terminal focus 1/2, Ctrl+Shift+X, Ctrl+Shift+V) and toolbar/palette navigation. Context menus retain their applicable F2, Delete, Ctrl+S, Ctrl+N, and Quick Chat labels. No gesture hints are advertised. Canvas/pointer discovery remains incomplete: canvas click-to-select/open, blank-canvas drag-to-pan, node drag-to-move, connector drag-to-create-edge, wheel/pinch zoom, right-click context-menu access, and sidebar root-loop drag-to-reorder still lack visible gesture hints. Closing the canvas gesture gap requires rendering in the canvas-owning `GraphCanvas.zig`; sidebar reorder guidance belongs to excluded `Sidebar.zig`. Both are outside this PR's scope. The hidden-window regression inspects the production HMENU labels and verifies keyboard Help content has no gesture rows. RED: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include" --test-filter 'main and help menus expose missing shortcuts without canvas gesture claims'` -> failed because 10 keyboard-only rows were expected but the Help menu contained 18 rows, including gesture claims. GREEN: same focused pinned-Zig command after the change -> 1 test passed. REGRESSION: `& $env:GRAPHCODE_ZIG0152 test src\MainWindow.zig -target x86_64-windows-msvc -lc -luser32 -lgdi32 -ladvapi32 "-I$env:GRAPHCODE_WINGHOSTTY_ROOT\include"` -> all 48 MainWindow and imported InputRouter tests passed; the 18-entry accelerator assertion and original 15-binding order remain unchanged. Ctrl+P duplicates Ctrl+J; Ctrl+Shift+I duplicates Ctrl+Shift+W. The earlier claim that Ctrl+Shift+C is a binding conflict was wrong: a source audit of the accelerator table, `pretranslateKey`, `onShellKey`, `fallbackKeyAction` and `InputRouter.keyAction` found no Ctrl+Shift+C accelerator and no dispatch collision. The chord deliberately copies while a terminal is focused and clones otherwise, and Clone stays reachable from a terminal through File > Add Folder. The real defect was discovery text, now fixed: the File menu reads `Clone Repository...\tCtrl+Shift+C outside terminal` and Help > Keyboard Shortcuts carries a greyed `Copy terminal text\tCtrl+Shift+C` entry immediately before Paste. A MainWindow test asserts a positive accelerator count, zero Ctrl+Shift+C accelerators, `keyAction('C', ctrl, shift) == clone_repository`, both exact labels, and that the Copy entry is informational with command ID 0; MainWindow passed 50/50 and InputRouter 9/9, and the hosted integration job 109828242393 ran the live Add Folder menu check against the qualified label. These are menu/source and live-menu tests, not physical keyboard, touch-device, or macOS runtime evidence, and the canvas and sidebar gesture hints above are still absent; the row remains Partial. Terminal-focused keys now follow one rule: Ctrl+D, W, S, T, N, `[` and `]` go to the shell instead of the matching menu accelerators, which stay available elsewhere and have terminal-safe alternatives (Ctrl+Shift+T new tab, Ctrl+Shift+N new loop, Alt+Shift+D split right, Ctrl+Shift+W close tab, Ctrl+Shift+[ / ] pane focus) that the Terminal and Loop menus now label; Help > Keyboard Shortcuts states the rule. F6 and F10 follow the same rule: while a terminal has focus they are encoded to it (`ESC[17~`, `ESC[21~`; Shift+F10 stays the terminal context menu) instead of entering the window toolbar or the native menu bar, and the shell keeps Ctrl+Shift+F6 (window toolbar) and Ctrl+Shift+F10 (menu bar) for those, labelled in View > Focus Window Toolbar and Help > Keyboard Shortcuts. Evidence is in-process: routing and dispatch unit tests, a hidden real-window fixture that checks a terminal-owned F10 is neither buffered nor passed to default menu processing, and a production-dispatch test that F1-F12 each enqueue their encoder sequence once. Known limitation, Ctrl+S: plain Ctrl+S is still the raw 0x13 byte. In a program that reads with line input on (for example pwsh `[Console]::ReadKey`) conhost's pause-output handling swallows Ctrl+S and the next key, while Ctrl+T alone works; a win32-input-mode workaround was rejected because an isolated zmx 785b3fd matrix showed it breaks Git MSYS vim (Enter no longer honored) and makes cmd.exe insert a literal ^S. That evidence is not a Dev Box walkthrough, and Ctrl+S is not claimed to work. Tests assert only that Ctrl+S/T/D/W/N add no shell-encoded bytes. The App `live terminal keyboard` key table does drive synthesized WM_KEYDOWN through the production dispatch into a real Winghostty surface (F6, Shift+F6, F10 in both key-down families, Ctrl+F10, F11 and F12 reach the input queue; Ctrl+Shift+F10 posts SC_KEYMENU), but no physical keyboard, real conhost, or Dev Box walkthrough ran, and menu mnemonics (Alt, Alt+letter) are terminal input while a terminal has focus | Partial | | IME/dead keys/layouts | Native composition in forms and terminal | Fixed a real terminal input gap: Winghostty reports preedit and committed result text through `on_ime_update`, but `TerminalSurface.onImeUpdate` discarded both; it now ignores preedit and enqueues only committed UTF-8 through the same bounded terminal input queue as ordinary text. A focused executable test invokes that production callback and proves uncommitted text is absent while committed Japanese text reaches the correct surface queue. `MainWindow.Window.dispatchMessage` executable tests create a real Unicode `EDIT` control and observe `WM_IME_STARTCOMPOSITION` / `WM_IME_COMPOSITION` / `WM_IME_ENDCOMPOSITION` at its native window procedure, then activate US-International and French layouts and exercise the production `TranslateMessage`/`DispatchMessageW` path with physical scan codes, observing a real dead-key `WM_DEADCHAR` followed by `é` and French scan-code mapping to `a`; unavailable layouts are explicitly logged and skipped rather than silently weakened. These are hidden-window Win32 runtime tests, not a foreground UIA walkthrough or validation with a live IME candidate window, and terminal preedit rendering remains provider-owned, so the row remains Partial. A native-linked test now also drives US-International dead keys (`'` then `e`/`a` committing `é`/`á`) and German AltGr+Q (`@`) through the production dispatch into a real Winghostty terminal surface and observes the bytes queued for the shell, so the shell's own key encoding cannot swallow or double them; it is in-process evidence, not a physical keyboard or live IME walkthrough | Partial | From f9aa25b514d810de6dc560818b7d076c43f601d6 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 10 Oct 2026 00:00:48 -0700 Subject: [PATCH 2/3] Say what the sidebar minimum region guarantees; hit-test from a shifted origin The minimum is a 52 px scroll region, not two published elements at every offset: rename the tests and docs to say so. Add a physical-pixel, non-zero-origin ElementProviderFromPoint check to the native provider test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- .../AccessibilityProvider.Native.Tests.cpp | 19 ++++++++++++++++++- graphcode-windows/README.md | 6 ++++-- graphcode-windows/src/App.zig | 4 ++-- graphcode-windows/src/Sidebar.zig | 4 ++-- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 3e552769..6f6b6d47 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -188,6 +188,23 @@ static void nativeHitTestChecks(IRawElementProviderSimple *provider, HWND hwnd) "a point over the reported canvas resolves to the canvas element"); if (canvas) canvas->Release(); expectHit(root, origin, 100, 210, L"Visible row", "rows still win outside the canvas"); + + // A 144-DPI shell publishes physical pixels (1.5x its logical rects); the point is a physical + // screen coordinate, so the window's non-zero screen origin is the only conversion. + const char *scaled_identities[] = {"loop:scaled"}; + const char *scaled_names[] = {"Scaled row"}; + const int scaled_parents[] = {2}; + const int scaled_none[] = {0}; + const int scaled_actions[] = {1}; + const int scaled_bounds[] = {18, 300, 348, 339}; + check(update(provider, "Scaled", scaled_identities, scaled_names, scaled_parents, scaled_none, + scaled_none, scaled_actions, scaled_bounds, 1, 0, 1, 0, 0, 0, 0) == S_OK, + "scaled publication succeeds"); + check(origin.x != 0 && origin.y != 0, "the hit-test window has a non-zero screen origin"); + expectHit(root, origin, 18, 300, L"Scaled row", "a physical-pixel row is hit at its top-left corner from a shifted origin"); + expectHit(root, origin, 347, 338, L"Scaled row", "a physical-pixel row is hit at its last pixel from a shifted origin"); + expectHit(root, origin, 348, 338, nullptr, "a physical-pixel row's right edge is exclusive from a shifted origin"); + expectHit(root, origin, 100, 339, nullptr, "a physical-pixel row's bottom edge is exclusive from a shifted origin"); root->Release(); } @@ -504,7 +521,7 @@ static void nativeCommandAndScrollChecks() { int main() { HWND hwnd = CreateWindowW(L"STATIC", L"UIA native test", WS_OVERLAPPED, - 0, 0, 100, 100, nullptr, nullptr, nullptr, nullptr); + 137, 211, 300, 300, nullptr, nullptr, nullptr, nullptr); check(hwnd != nullptr, "native window exists"); if (!hwnd) return 1; nativeCommandAndScrollChecks(); diff --git a/graphcode-windows/README.md b/graphcode-windows/README.md index 80cd96fa..4585d5d7 100644 --- a/graphcode-windows/README.md +++ b/graphcode-windows/README.md @@ -343,8 +343,10 @@ with it (rows, controls, the Graph and Quick Chats destinations) exposes footer do not scroll and expose no scroll item. The root's coordinate hit-test returns the element under the point (banner above header above scrolled rows). -Short windows: the sidebar keeps at least two rows (`Sidebar.min_content_region`) -of scroll region. If the footer would leave less, the error footer collapses +Short windows: the sidebar keeps a 52 px scroll region (`Sidebar.min_content_region`, +room for two 24 px row slots; the tests assert the region height and at least one +published element, not two elements at every offset, and a 34 px Needs-you or +Activity card can fill it). If the footer would leave less, the error footer collapses first, then the update banner; the status line is the floor. A collapsed part is not painted, published, or clickable. The window's minimum height is the smallest client height at which the status line, Activity strip, and open Workspace panel diff --git a/graphcode-windows/src/App.zig b/graphcode-windows/src/App.zig index d2baf4c6..2ed80a9a 100644 --- a/graphcode-windows/src/App.zig +++ b/graphcode-windows/src/App.zig @@ -11763,7 +11763,7 @@ test "scrolled Needs you and Activity controls are painted where UIA publishes t try std.testing.expect(clicked_stops >= 10); } -test "sidebar footer collapses the error footer then the banner to keep two rows, and a collapsed part is not painted, published or clickable" { +test "sidebar footer collapses the error footer then the banner to keep a 52 px scroll region, and a collapsed part is not painted, published or clickable" { const banner_fill: u32 = 0x00352B1C; const error_fill: u32 = 0x00242448; var collapsed_error: usize = 0; @@ -11821,7 +11821,7 @@ test "sidebar footer collapses the error footer then the banner to keep two rows try std.testing.expect(reachable >= 48); } -test "the minimum window height keeps a two-row sidebar scroll region with the panel, strip, banner and error footer" { +test "the minimum window height keeps a 52 px sidebar scroll region and at least one published row with the panel, strip, banner and error footer" { for ([_]u32{ 96, 144 }) |dpi| { var app = try overviewTestApp(dpi); defer deinitOverviewTestApp(&app); diff --git a/graphcode-windows/src/Sidebar.zig b/graphcode-windows/src/Sidebar.zig index d3ebef58..ba60cae5 100644 --- a/graphcode-windows/src/Sidebar.zig +++ b/graphcode-windows/src/Sidebar.zig @@ -394,7 +394,7 @@ pub fn footerReserve(has_update: bool, has_error: bool) i32 { (if (has_update) footer_update_extent else 0); } -/// Scroll region (two rows) the sidebar keeps before it gives footer space back to content. +/// Scroll region (52 px, room for two 24 px row slots) the sidebar keeps before it gives footer space back to content. It is a region height, not a guarantee of two published elements at every offset. pub const min_content_region: i32 = 52; pub const Footer = struct { @@ -1897,7 +1897,7 @@ test "update banner is a bounded footer action" { try std.testing.expect(updateBannerRect(700, true).bottom < errorFooterRect(700).top); } -test "footer parts collapse error footer first, then the update banner, to keep two rows" { +test "footer parts collapse error footer first, then the update banner, to keep a 52 px scroll region" { // Room for everything: nothing collapses. try std.testing.expectEqual(Footer{ .update = true, .ingress = true }, effectiveFooter(700, true, true)); try std.testing.expectEqual(Footer{ .update = false, .ingress = false }, effectiveFooter(700, false, false)); From 9915c548412044caa2bb7959ae4373d96ebd31e4 Mon Sep 17 00:00:00 2001 From: Colin Neilens Date: Sat, 10 Oct 2026 00:06:03 -0700 Subject: [PATCH 3/3] Fix hit-test edge expectation: the canvas lies under the row's exclusive edge Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Signed-off-by: Colin Neilens --- Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp index 6f6b6d47..6b3f8dd7 100644 --- a/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp +++ b/Tools/windows/Tests/AccessibilityProvider.Native.Tests.cpp @@ -203,7 +203,8 @@ static void nativeHitTestChecks(IRawElementProviderSimple *provider, HWND hwnd) check(origin.x != 0 && origin.y != 0, "the hit-test window has a non-zero screen origin"); expectHit(root, origin, 18, 300, L"Scaled row", "a physical-pixel row is hit at its top-left corner from a shifted origin"); expectHit(root, origin, 347, 338, L"Scaled row", "a physical-pixel row is hit at its last pixel from a shifted origin"); - expectHit(root, origin, 348, 338, nullptr, "a physical-pixel row's right edge is exclusive from a shifted origin"); + // x 348 is inside the canvas reported above (left 240), so the exclusive row edge falls through to it. + expectHit(root, origin, 348, 338, L"Graph", "a physical-pixel row's right edge is exclusive from a shifted origin"); expectHit(root, origin, 100, 339, nullptr, "a physical-pixel row's bottom edge is exclusive from a shifted origin"); root->Release(); }