From 5f78168c572faa846d9820a4e4769e8b093b1b84 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 10 Oct 2026 15:30:23 -0400 Subject: [PATCH] More accessibility fixes using foobar2000 as a test. --- CMakeLists.txt | 2 +- README.md | 9 +++ control_class.h | 3 + main.cpp | 75 ++++++++++++++++++--- native_items.cpp | 136 ++++++++++++++++++++++++++++++++++++++ native_items.h | 23 +++++++ tests/README.md | 31 +++++++++ tests/controls_fixture.c | 112 +++++++++++++++++++++++++++++++ tests/menu_integration.py | 30 ++++++++- 9 files changed, 407 insertions(+), 14 deletions(-) create mode 100644 native_items.cpp create mode 100644 native_items.h create mode 100644 tests/controls_fixture.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 4af5004..8b733e4 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -32,7 +32,7 @@ add_custom_command( DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/nvdaController.idl" VERBATIM) -add_executable(cthulhu-wine-access main.cpp list_view.cpp "${generated_server}") +add_executable(cthulhu-wine-access main.cpp list_view.cpp native_items.cpp "${generated_server}") add_dependencies(cthulhu-wine-access wine-access-menu) target_compile_features(cthulhu-wine-access PRIVATE cxx_std_20) target_include_directories(cthulhu-wine-access PRIVATE "${generated_dir}") diff --git a/README.md b/README.md index 86f34ab..a5d32f9 100644 --- a/README.md +++ b/README.md @@ -139,6 +139,15 @@ when Wine omits selection events. Up to eight columns and 1023 UTF-16 units per column are read. This does not add 32-bit menu or editor hooks, column navigation, or routable object review. +Native tab controls report their focused caption and position; tree controls +report the selected node and its expansion state. A bounded focus poll follows +navigation when Wine omits selection events. These queries work with 32-bit +and 64-bit controls, including standard controls registered with `ATL:` names. +Named generic MSAA clients no longer hide known native button/combo-box roles +and values. Edit and combo-box labels can be visible siblings to the left, +even when the labels were created after the fields. Application-owned MSAA +lists retain their own names, roles and item data. + This is editing feedback, not a full text-review provider: Say All, review-cursor commands and braille routing into Wine text are not implemented. Typing echo remains the reader's responsibility. RichEdit and 32-bit editor hooks are not diff --git a/control_class.h b/control_class.h index b4ad31c..e15e469 100644 --- a/control_class.h +++ b/control_class.h @@ -7,6 +7,9 @@ // not edits merely because their name contains EDIT somewhere. static inline BOOL wine_access_class_matches(const WCHAR *className, const WCHAR *nativeName) { if (lstrcmpiW(className, nativeName) == 0) return TRUE; + if (lstrlenW(className) > 4 && + CompareStringOrdinal(className, 4, L"ATL:", 4, TRUE) == CSTR_EQUAL && + lstrcmpiW(className + 4, nativeName) == 0) return TRUE; WCHAR expected[128] = L"WindowsForms10."; if (lstrlenW(nativeName) > 80) return FALSE; lstrcatW(expected, nativeName); diff --git a/main.cpp b/main.cpp index 14fd956..d9440f8 100644 --- a/main.cpp +++ b/main.cpp @@ -6,6 +6,7 @@ #include "msaa_states.h" #include "list_view.h" #include "control_class.h" +#include "native_items.h" #include #include @@ -37,6 +38,8 @@ HWND lastListControl = nullptr; int lastListIndex = -1; int lastListCount = 0; bool lastListSelected = false; +HWND lastNativeControl = nullptr; +NativeItemKey lastNativeItem; // Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit // wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons. @@ -163,11 +166,16 @@ void find_control_label(HWND window, wchar_t *text, int capacity) { HWND previous = window; wchar_t className[64] = {}; text[0] = L'\0'; - // Managed controls need not be created in label/control order. Prefer a + const bool knownClass = GetClassNameW(window, className, std::size(className)); + const bool managedEdit = knownClass && lstrcmpiW(className, L"Edit") != 0 && + wine_access_class_matches(className, L"Edit"); + // Controls need not be created in label/control order. Prefer a // visible static label immediately to the left on the same row, among // siblings only. Keep the existing native-control ordering below. - if (GetClassNameW(window, className, std::size(className)) && - lstrcmpiW(className, L"Edit") != 0 && wine_access_class_matches(className, L"Edit")) { + if (knownClass && + (wine_access_class_matches(className, L"Edit") || + wine_access_class_matches(className, L"ComboBox") || + wine_access_class_matches(className, L"ComboBoxEx32"))) { RECT controlRect{}; if (GetWindowRect(window, &controlRect)) { HWND label = nullptr; @@ -188,9 +196,11 @@ void find_control_label(HWND window, wchar_t *text, int capacity) { } if (label && get_control_text(label, text, capacity)) { tidy_text(text, true); + return; } } - return; + // Retain ordered labels for controls without a same-row sibling. + if (managedEdit) return; } for (int hop = 0; hop < 3; ++hop) { previous = GetWindow(previous, GW_HWNDPREV); @@ -317,6 +327,18 @@ ControlDescription describe_standard_control(HWND window) { return control; } + if (is_native_item_control(window)) { + const auto item = read_native_item(window); + control.name = item.text; + control.role = item.role; + control.states = item.states; + control.position = item.position; + control.count = item.count; + lastNativeControl = window; + lastNativeItem = item.key; + return control; + } + if (wine_access_class_matches(className, L"ListBox") || wine_access_class_matches(className, L"ComboLBox")) { find_control_label(window, label, std::size(label)); @@ -384,10 +406,28 @@ bool present_standard_focus(HWND window) { control, window_title(window)); } -void poll_list_focus(bool refresh = false) { +void poll_control_focus(bool refresh = false) { GUITHREADINFO info{}; info.cbSize = sizeof(info); HWND foreground = GetForegroundWindow(); + if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && + !(info.flags & GUI_INMENUMODE) && is_native_item_control(info.hwndFocus)) { + lastListControl = nullptr; + lastListIndex = -1; + lastListCount = 0; + lastListSelected = false; + const auto key = native_item_key(info.hwndFocus); + if (info.hwndFocus != lastNativeControl) { + present_standard_focus(info.hwndFocus); + } else if (refresh || !(key == lastNativeItem)) { + const auto control = describe_standard_control(info.hwndFocus); + present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", + control, window_title(info.hwndFocus)); + } + return; + } + lastNativeControl = nullptr; + lastNativeItem = {}; if (foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && !(info.flags & GUI_INMENUMODE) && is_list_view(info.hwndFocus)) { int count = 0; @@ -587,9 +627,9 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, return; // Wine's MSAA provider exposes native list views as named clients, without // their rows. Use the standard-control query even when that name is present. - if (is_list_view(window)) { + if (is_list_view(window) || is_native_item_control(window)) { if (event == EVENT_OBJECT_FOCUS) present_standard_focus(window); - else poll_list_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); + else poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); return; } IAccessible *accessible = nullptr; @@ -628,6 +668,8 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, VariantClear(&state); LONG childCount = 0; accessible->get_accChildCount(&childCount); + const bool nativeClient = child.vt == VT_I4 && child.lVal == CHILDID_SELF && + (roleText.empty() || roleId == ROLE_SYSTEM_CLIENT); accessible->Release(); VariantClear(&child); @@ -667,6 +709,19 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, control.states = stateText; control.description = description; control.count = static_cast(childCount); + // Wine can return named generic clients even for standard buttons and + // combo boxes. Their names alone must not hide the native role/value. + // Application-owned list items and other meaningful MSAA roles stay intact. + if (nativeClient) { + const auto native = describe_standard_control(window); + if (!native.role.empty()) { + control = native; + if (control.name.empty()) control.name = name; + if (control.states.empty()) control.states = stateText; + if (control.description.empty()) control.description = description; + if (password) control.value.clear(); + } + } present_accessible_event(sourceId, eventType, control, windowTitle); } @@ -865,13 +920,13 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) { static_cast(GetLastError())); } MSG message{}; - // Native list views also omit row-selection WinEvents in Wine. Observe the - // focused row with scalar queries; retrieve its text only when it changes. + // Native lists, tabs and trees can omit selection WinEvents in Wine. Observe + // the focused item with scalar queries; retrieve text only when it changes. const UINT_PTR listTimer = dialogReaderEnabled ? SetTimer(nullptr, 0, 100, nullptr) : 0; while (GetMessageW(&message, nullptr, 0, 0) > 0) { if (listTimer && message.message == WM_TIMER && message.hwnd == nullptr && message.wParam == listTimer) { - poll_list_focus(); + poll_control_focus(); continue; } TranslateMessage(&message); diff --git a/native_items.cpp b/native_items.cpp new file mode 100644 index 0000000..9cb0ff1 --- /dev/null +++ b/native_items.cpp @@ -0,0 +1,136 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "native_items.h" +#include "control_class.h" +#include +#include +#include +#include + +namespace { +bool query(HWND window, UINT message, WPARAM wp, LPARAM lp, DWORD_PTR *result) { + return SendMessageTimeoutW(window, message, wp, lp, SMTO_ABORTIFHUNG, + 100, result) != 0; +} +bool is_tab(HWND window) { + wchar_t className[128] = {}; + return GetClassNameW(window, className, std::size(className)) && + wine_access_class_matches(className, L"SysTabControl32"); +} +struct Tab32 { uint32_t mask, state, stateMask, text; int32_t capacity; }; +struct Tab64 { uint32_t mask, state, stateMask, padding; uint64_t text; int32_t capacity; }; +struct Tree32 { + uint32_t mask, item, state, stateMask, text; + int32_t capacity, image, selectedImage, children; +}; +struct Tree64 { + uint32_t mask, padding; + uint64_t item; + uint32_t state, stateMask; + uint64_t text; + int32_t capacity, image, selectedImage, children; +}; +static_assert(offsetof(Tab32, text) == 12 && offsetof(Tab64, text) == 16); +static_assert(offsetof(Tree32, text) == 16 && offsetof(Tree64, text) == 24); +constexpr size_t textOffset = 128; +constexpr int textCapacity = 1024; +constexpr size_t bufferSize = textOffset + textCapacity * sizeof(wchar_t); +} + +bool is_native_item_control(HWND window) { + wchar_t className[128] = {}; + return GetClassNameW(window, className, std::size(className)) && + (wine_access_class_matches(className, L"SysTabControl32") || + wine_access_class_matches(className, L"SysTreeView32")); +} + +NativeItemKey native_item_key(HWND window) { + NativeItemKey key; + DWORD_PTR count = 0, item = 0, state = 0; + if (is_tab(window)) { + if (!query(window, TCM_GETITEMCOUNT, 0, 0, &count) || count == 0) return key; + // Keyboard focus can move before selection in button-style tab controls. + if (!query(window, TCM_GETCURFOCUS, 0, 0, &item) || + static_cast(item) < 0) + if (!query(window, TCM_GETCURSEL, 0, 0, &item)) return key; + if (item >= count) return key; + key.item = item + 1; // Zero means no item for both control types. + DWORD_PTR selected = 0; + if (query(window, TCM_GETCURSEL, 0, 0, &selected) && selected == item) + key.state = TVIS_SELECTED; + } else { + if (!query(window, TVM_GETCOUNT, 0, 0, &count) || + !query(window, TVM_GETNEXTITEM, TVGN_CARET, 0, &item)) return key; + key.item = item; + if (item && query(window, TVM_GETITEMSTATE, item, + TVIS_SELECTED | TVIS_EXPANDED, &state)) key.state = state; + } + key.count = static_cast(count); + return key; +} + +NativeItem read_native_item(HWND window) { + NativeItem item; + const bool tab = is_tab(window); + item.role = tab ? "page tab" : "tree"; + const auto key = native_item_key(window); + item.key = key; + if (!key.item) return item; + DWORD processId = 0; + GetWindowThreadProcessId(window, &processId); + HANDLE process = OpenProcess(PROCESS_VM_OPERATION | PROCESS_VM_READ | + PROCESS_VM_WRITE | PROCESS_QUERY_LIMITED_INFORMATION, + FALSE, processId); + if (!process) return item; + BOOL wow64 = FALSE; + if (!IsWow64Process(process, &wow64)) { CloseHandle(process); return item; } + void *remote = VirtualAllocEx(process, nullptr, bufferSize, + MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (!remote || (wow64 && reinterpret_cast(remote) > UINT32_MAX)) { + if (remote) VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return item; + } + BYTE buffer[bufferSize] = {}; + const auto address = reinterpret_cast(remote) + textOffset; + if (tab && wow64) { + const Tab32 data{TCIF_TEXT, 0, 0, static_cast(address), textCapacity}; + memcpy(buffer, &data, sizeof(data)); + } else if (tab) { + const Tab64 data{TCIF_TEXT, 0, 0, 0, address, textCapacity}; + memcpy(buffer, &data, sizeof(data)); + } else if (wow64) { + const Tree32 data{TVIF_TEXT | TVIF_CHILDREN, static_cast(key.item), + 0, 0, static_cast(address), textCapacity, 0, 0, 0}; + memcpy(buffer, &data, sizeof(data)); + } else { + const Tree64 data{TVIF_TEXT | TVIF_CHILDREN, 0, key.item, 0, 0, + address, textCapacity, 0, 0, 0}; + memcpy(buffer, &data, sizeof(data)); + } + DWORD_PTR result = 0; + if (WriteProcessMemory(process, remote, buffer, sizeof(buffer), nullptr) && + query(window, tab ? TCM_GETITEMW : TVM_GETITEMW, tab ? key.item - 1 : 0, + reinterpret_cast(remote), &result) && result && + ReadProcessMemory(process, remote, buffer, sizeof(buffer), nullptr)) { + wchar_t text[textCapacity] = {}; + memcpy(text, buffer + textOffset, sizeof(text)); + text[textCapacity - 1] = L'\0'; + char utf8[textCapacity * 4] = {}; + if (WideCharToMultiByte(CP_UTF8, 0, text, -1, utf8, sizeof(utf8), nullptr, nullptr)) + item.text = utf8; + if (key.state & TVIS_SELECTED) item.states = "selected"; + if (tab) { item.position = static_cast(key.item); item.count = key.count; } + else { + item.role = "tree item"; + int32_t children = 0; + memcpy(&children, buffer + (wow64 ? offsetof(Tree32, children) : offsetof(Tree64, children)), sizeof(children)); + if (children != 0) { + if (!item.states.empty()) item.states += ", "; + item.states += key.state & TVIS_EXPANDED ? "expanded" : "collapsed"; + } + } + } + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + CloseHandle(process); + return item; +} diff --git a/native_items.h b/native_items.h new file mode 100644 index 0000000..5293bd9 --- /dev/null +++ b/native_items.h @@ -0,0 +1,23 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include +#include + +struct NativeItemKey { + uintptr_t item = 0; + int count = 0; + DWORD state = 0; + bool operator==(const NativeItemKey &) const = default; +}; +struct NativeItem { + NativeItemKey key; + std::string text; + std::string role; + std::string states; + int position = 0; + int count = 0; +}; +bool is_native_item_control(HWND window); +NativeItemKey native_item_key(HWND window); +NativeItem read_native_item(HWND window); diff --git a/tests/README.md b/tests/README.md index 6ab462b..530009d 100644 --- a/tests/README.md +++ b/tests/README.md @@ -134,6 +134,37 @@ Check arrows and Tab/Shift+Tab back to Install/Cancel. Also check a multirow native list, normal menus, editor navigation, switching to a native Linux application, and reader shutdown. Verify speech and physical braille separately. +## Native tabs, trees and named clients + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture controls +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture controls --32-bit +``` + +These private fixtures exercise native and `ATL:SysTabControl32` tab captions, +positions and arrow navigation, tree selection/expansion/navigation and focus +return, named native buttons exposed as generic MSAA clients, and combo boxes +with labels created after their fields. The 64-bit fixture also checks the +editor hook's late native label. Empty tabs/trees must drop their previous +caption. The existing menu, editor, Notepad, list and managed-field fixtures +remain regression checks; an application-owned MSAA provider in the menu +fixture must retain its role instead of being replaced by native queries. + +Read-only inspection of foobar2000 v2.26.1 with the new native query code +retrieved `All Music`, `Album List`, and `Default Playlist` from the open +application. This identifies data missed by the old helper; it does not prove +live speech, physical braille, or complete foobar2000 support. Its custom +playlist MSAA provider is separate from native tabs and trees. + +After rebuilding/installing all three helper files, restart the reader and +reopen foobar2000 after saving work. Tab and Shift+Tab through the album tree, +View combo box, Filter edit, album/property tabs and playlist tabs. Expect +captions plus their roles; arrows should read the new selected tab/tree node, +with expanded/collapsed states where appropriate. Check native buttons, +combo-box value changes, custom playlist item reading, Alt menus and Escape +return, ordinary Notepad editing, password exclusion, and switching back to a +native Linux application. Verify speech and physical braille separately. + ## Windows Forms login fields ```sh diff --git a/tests/controls_fixture.c b/tests/controls_fixture.c new file mode 100644 index 0000000..ae60e53 --- /dev/null +++ b/tests/controls_fixture.c @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#define UNICODE +#define _UNICODE +#include +#include + +static HWND tabs, atlTabs, tree, combo, edit, button; +static HTREEITEM root; +static int step; + +static void focus_control(HWND window) { + SetFocus(window); + NotifyWinEvent(EVENT_OBJECT_FOCUS, window, OBJID_CLIENT, CHILDID_SELF); +} + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_TIMER) { + switch (++step) { + case 1: SendMessageW(tabs, WM_KEYDOWN, VK_RIGHT, 1); break; + case 2: focus_control(button); break; + case 3: focus_control(atlTabs); break; + case 4: SendMessageW(atlTabs, WM_KEYDOWN, VK_RIGHT, 1); break; + case 5: focus_control(tree); break; + case 6: SendMessageW(tree, TVM_EXPAND, TVE_EXPAND, (LPARAM)root); break; + case 7: SendMessageW(tree, WM_KEYDOWN, VK_DOWN, 1); break; + case 8: focus_control(button); break; + case 9: focus_control(tree); break; + case 10: focus_control(combo); break; + case 11: + SendMessageW(combo, CB_SETCURSEL, 1, 0); + NotifyWinEvent(EVENT_OBJECT_VALUECHANGE, combo, OBJID_CLIENT, CHILDID_SELF); + break; + case 12: focus_control(edit); break; + case 13: focus_control(atlTabs); break; + case 14: SendMessageW(atlTabs, TCM_DELETEALLITEMS, 0, 0); break; + case 15: focus_control(tree); break; + case 16: SendMessageW(tree, TVM_DELETEITEM, 0, (LPARAM)TVI_ROOT); break; + default: DestroyWindow(window); break; + } + return 0; + } + if (message == WM_DESTROY) { PostQuitMessage(0); return 0; } + return DefWindowProcW(window, message, wp, lp); +} + +int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { + (void)previous; (void)args; (void)show; + INITCOMMONCONTROLSEX controls = {sizeof(controls), ICC_TAB_CLASSES | ICC_TREEVIEW_CLASSES}; + InitCommonControlsEx(&controls); + WNDCLASSW cls = {0}; + cls.hInstance = instance; + cls.lpszClassName = L"WineAccessControlsFixture"; + cls.lpfnWndProc = window_proc; + RegisterClassW(&cls); + HWND window = CreateWindowW(cls.lpszClassName, L"Wine controls fixture", WS_OVERLAPPEDWINDOW, + 120, 120, 700, 440, NULL, NULL, instance, NULL); + WNDCLASSEXW tabClass = {0}; + tabClass.cbSize = sizeof(tabClass); + if (!GetClassInfoExW(NULL, WC_TABCONTROLW, &tabClass)) return 1; + tabClass.hInstance = instance; + tabClass.lpszClassName = L"ATL:SysTabControl32"; + if (!RegisterClassExW(&tabClass)) return 2; + tabs = CreateWindowW(WC_TABCONTROLW, L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 10, 300, 80, window, NULL, instance, NULL); + atlTabs = CreateWindowW(tabClass.lpszClassName, L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 330, 10, 300, 80, window, NULL, instance, NULL); + TCITEMW tab = {0}; + tab.mask = TCIF_TEXT; + tab.pszText = L"Albums & artists β"; + SendMessageW(tabs, TCM_INSERTITEMW, 0, (LPARAM)&tab); + tab.pszText = L"Details"; + SendMessageW(tabs, TCM_INSERTITEMW, 1, (LPARAM)&tab); + tab.pszText = L"Default Playlist"; + SendMessageW(atlTabs, TCM_INSERTITEMW, 0, (LPARAM)&tab); + tab.pszText = L"Second Playlist"; + SendMessageW(atlTabs, TCM_INSERTITEMW, 1, (LPARAM)&tab); + tree = CreateWindowW(WC_TREEVIEWW, L"Tree1", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 110, 300, 170, window, NULL, instance, NULL); + TVINSERTSTRUCTW node = {0}; + node.hParent = TVI_ROOT; + node.hInsertAfter = TVI_LAST; + node.item.mask = TVIF_TEXT; + node.item.pszText = L"All music"; + root = (HTREEITEM)SendMessageW(tree, TVM_INSERTITEMW, 0, (LPARAM)&node); + node.hParent = root; + node.item.pszText = L"Artist & album β"; + SendMessageW(tree, TVM_INSERTITEMW, 0, (LPARAM)&node); + SendMessageW(tree, TVM_SELECTITEM, TVGN_CARET, (LPARAM)root); + button = CreateWindowW(L"Button", L"Named native button", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 310, 250, 30, window, NULL, instance, NULL); + combo = CreateWindowW(L"ComboBox", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | CBS_DROPDOWNLIST, + 390, 110, 200, 150, window, NULL, instance, NULL); + SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"by artist"); + SendMessageW(combo, CB_ADDSTRING, 0, (LPARAM)L"by year"); + SendMessageW(combo, CB_SETCURSEL, 0, 0); + edit = CreateWindowW(L"Edit", L"search", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 390, 170, 200, 25, window, NULL, instance, NULL); + // Labels are deliberately created after their fields, like foobar2000. + CreateWindowW(L"Static", L"View:", WS_CHILD | WS_VISIBLE, + 330, 110, 50, 25, window, NULL, instance, NULL); + CreateWindowW(L"Static", L"Filter:", WS_CHILD | WS_VISIBLE, + 330, 170, 50, 25, window, NULL, instance, NULL); + ShowWindow(window, SW_SHOW); + SetForegroundWindow(window); + focus_control(tabs); + SetTimer(window, 1, 400, NULL); + MSG message; + while (GetMessageW(&message, NULL, 0, 0) > 0) { + TranslateMessage(&message); DispatchMessageW(&message); + } + return 0; +} diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 7c15f10..2f7f02f 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -16,13 +16,13 @@ from gi.repository import Gio, GLib parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") -parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "winforms"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "winforms", "controls"), default="menu") parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--late-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true") args = parser.parse_args() -if args.is32Bit and args.fixture != "list": - parser.error("32-bit checks currently require --fixture list") +if args.is32Bit and args.fixture not in ("list", "controls"): + parser.error("32-bit checks currently require --fixture list or controls") if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms"): parser.error("helper lifecycle checks require --fixture edit or --fixture winforms") build = Path(args.build_dir).resolve() @@ -111,6 +111,30 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: focused = event[0] elif event[1] == "text": assert event[0] == focused, f"text sent without establishing focus: {event!r}" + if args.fixture == "controls": + for event in events: + print(event[1:9]) + for caption in ("Albums & artists β", "Details", "Default Playlist", "Second Playlist"): + assert any(e[2] == caption and e[3] == "page tab" and e[7] in (1, 2) + and e[8] == 2 for e in events), f"native/ATL tab missing: {caption}" + assert any(e[2] == "All music" and e[3] == "tree item" and "collapsed" in e[5] + for e in events), "collapsed tree selection missing" + assert any(e[2] == "All music" and "expanded" in e[5] for e in events), "tree expansion missing" + assert any(e[2] == "Artist & album β" and e[3] == "tree item" for e in events), "tree navigation missing" + assert any(e[1] == "focus" and e[2] == "Named native button" and e[3] == "button" + for e in events), "named generic MSAA client hid button role" + for value in ("by artist", "by year"): + assert any(e[2] == "View" and e[3] == "combo box" and e[4] == value + for e in events), f"combo label/role/value missing: {value}" + if not args.is32Bit: + assert any(e[2] == "Filter" and e[3] == "edit" and e[4] == "search" + for e in events), "late native edit label missing" + assert any(e[2] == "" and e[3] == "page tab" and e[7:9] == (0, 0) + for e in events), "empty tabs retained stale caption" + assert any(e[2] == "" and e[3] == "tree" for e in events), "empty tree retained stale item" + assert not any(e[2] == "Tree1" for e in events), "generic tree name leaked" + print("PASS: native/ATL tabs, tree selection/states/return, named button, combo and edit labels, empty controls") + sys.exit(0) if args.fixture == "list": listEvents = [e for e in events if e[3] == "list"] for event in listEvents: