diff --git a/README.md b/README.md index 393280c..50c07f6 100644 --- a/README.md +++ b/README.md @@ -236,6 +236,11 @@ when Wine omits selection events. Up to eight columns and 1023 UTF-16 units per column are read. These queries do not add column navigation or routable object review. +Standard `ListBox` controls report the selected item's text and position when +Up/Down changes the selection, including AudioQuake's map chooser. The same +foreground poll detects changes when Wine omits selection events, with one +source identity for focus and selection. Background lists remain silent. + 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 diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index dbf3781..da03277 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=wine-access-git -pkgver=r22.gf40e471 +pkgver=r24.g0f59809 pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' arch=('x86_64') diff --git a/main.cpp b/main.cpp index 5b649fc..51981f7 100644 --- a/main.cpp +++ b/main.cpp @@ -43,11 +43,21 @@ int lastListIndex = -1; int lastListCount = 0; bool lastListSelected = false; int lastListChecked = -1; +HWND lastListBoxControl = nullptr; +int lastListBoxIndex = -1; +int lastListBoxCount = 0; HWND lastNativeControl = nullptr; NativeItemKey lastNativeItem; HWND lastComboControl = nullptr; std::string lastComboValue; +bool is_list_box(HWND window) { + wchar_t className[128] = {}; + return GetClassNameW(window, className, std::size(className)) && + (wine_access_class_matches(className, L"ListBox") || + wine_access_class_matches(className, L"ComboLBox")); +} + bool is_combo_box(HWND window) { wchar_t className[128] = {}; return GetClassNameW(window, className, std::size(className)) && @@ -418,6 +428,9 @@ ControlDescription describe_standard_control(HWND window) { } if (static_cast(count) >= 0) control.count = static_cast(count); + lastListBoxControl = window; + lastListBoxIndex = static_cast(selected); + lastListBoxCount = control.count; return control; } @@ -462,6 +475,12 @@ void poll_control_focus(bool refresh = false) { const bool focused = foreground && GetGUIThreadInfo(GetWindowThreadProcessId(foreground, nullptr), &info) && !(info.flags & GUI_INMENUMODE); + const bool focusedListBox = focused && is_list_box(info.hwndFocus); + if (!focusedListBox) { + lastListBoxControl = nullptr; + lastListBoxIndex = -1; + lastListBoxCount = 0; + } if (!focused || !is_combo_box(info.hwndFocus)) { lastComboControl = nullptr; lastComboValue.clear(); @@ -508,6 +527,25 @@ void poll_control_focus(bool refresh = false) { lastListCount = 0; lastListSelected = false; lastListChecked = -1; + if (focusedListBox) { + DWORD_PTR selected = static_cast(-1); + DWORD_PTR count = 0; + if (!SendMessageTimeoutW(info.hwndFocus, LB_GETCURSEL, 0, 0, + SMTO_ABORTIFHUNG, 100, &selected) || + !SendMessageTimeoutW(info.hwndFocus, LB_GETCOUNT, 0, 0, + SMTO_ABORTIFHUNG, 100, &count) || + static_cast(count) < 0) + return; + if (info.hwndFocus != lastListBoxControl) { + present_standard_focus(info.hwndFocus); + } else if (refresh || static_cast(selected) != lastListBoxIndex || + static_cast(count) != lastListBoxCount) { + const auto control = describe_standard_control(info.hwndFocus); + present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", + control, window_title(info.hwndFocus)); + } + return; + } if (focused && is_combo_box(info.hwndFocus)) { if (info.hwndFocus != lastComboControl) { present_standard_focus(info.hwndFocus); @@ -741,6 +779,13 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, } return; } + // List boxes can omit selection WinEvents just like list views. Route all + // their events through the foreground poll, keeping one source and avoiding + // announcements of background selection changes. + if (is_list_box(window)) { + poll_control_focus(event == EVENT_OBJECT_NAMECHANGE || event == EVENT_OBJECT_VALUECHANGE); + 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) || is_native_item_control(window)) { diff --git a/tests/README.md b/tests/README.md index 6b2d877..f4de28e 100644 --- a/tests/README.md +++ b/tests/README.md @@ -31,6 +31,23 @@ characters, selections, deletion, protected fields and keypad review with live speech and braille. When an Orca integration exists, repeat those workflows with `--reader orca`, including return to Linux controls and reader restart. +## List-box navigation + +For list-box navigation, run these checks sequentially: + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture listbox +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture listbox --32-bit +``` + +They cover native Up/Down navigation, Unicode text, positions, focus return, +duplicate suppression, background changes and an emptied list. After installing +the helper and restarting the reader, open AudioQuake's LDL tutorial/example map +chooser and use Up/Down without leaving the list. Check that speech and braille +follow each map and position, then Tab to OK/Cancel and Shift+Tab back. Recheck +combo-box arrows, ordinary list views, tabs/trees and return from Wine menus. +Recorded events alone do not establish audible speech or physical braille. + ## Virtual desktop focus recovery `xvfb-run -a ctest --test-dir build -R foreground-focus --output-on-failure` diff --git a/tests/listbox_fixture.c b/tests/listbox_fixture.c new file mode 100644 index 0000000..bad348e --- /dev/null +++ b/tests/listbox_fixture.c @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include + +static HWND list, button; +static int step; + +static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { + if (message == WM_TIMER) { + switch (++step) { + case 1: case 2: SendMessageW(list, WM_KEYDOWN, VK_DOWN, 1); break; + case 3: SendMessageW(list, WM_KEYDOWN, VK_UP, 1); break; + case 4: SetFocus(button); break; + case 5: + // A background change must not be announced or steal focus. + SendMessageW(list, LB_INSERTSTRING, 0, (LPARAM)L"Background-only map"); + SendMessageW(list, LB_SETCURSEL, 0, 0); + NotifyWinEvent(EVENT_OBJECT_VALUECHANGE, list, OBJID_CLIENT, CHILDID_SELF); + break; + case 6: + if (GetFocus() != button) return 0; + SendMessageW(list, LB_DELETESTRING, 0, 0); + SendMessageW(list, LB_SETCURSEL, 0, 0); + SetFocus(list); + break; + case 7: SendMessageW(list, LB_RESETCONTENT, 0, 0); break; + default: DestroyWindow(window); + } + 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; + WNDCLASSW cls = {0}; + cls.hInstance = instance; + cls.lpszClassName = L"WineAccessListBoxFixture"; + cls.lpfnWndProc = window_proc; + RegisterClassW(&cls); + HWND window = CreateWindowW(cls.lpszClassName, L"Select map", WS_OVERLAPPEDWINDOW, + 120, 120, 600, 300, NULL, NULL, instance, NULL); + CreateWindowW(L"Static", L"Tutorial maps", WS_CHILD | WS_VISIBLE, + 10, 10, 550, 30, window, NULL, instance, NULL); + list = CreateWindowW(L"ListBox", L"", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LBS_NOTIFY, + 10, 50, 550, 160, window, NULL, instance, NULL); + button = CreateWindowW(L"Button", L"OK", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 10, 220, 100, 30, window, NULL, instance, NULL); + SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"First map"); + SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"Second map β"); + SendMessageW(list, LB_ADDSTRING, 0, (LPARAM)L"Third map"); + SendMessageW(list, LB_SETCURSEL, 0, 0); + ShowWindow(window, SW_SHOW); + SetForegroundWindow(window); + SetFocus(list); + SetTimer(window, 1, 500, 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 e94b264..deeb804 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -18,15 +18,15 @@ parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") parser.add_argument("--reader", choices=("cthulhu", "orca")) -parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "listbox", "delphi", "winforms", "controls", "scintilla", "goldwave"), default="menu") parser.add_argument("--notepadpp-exe", type=Path) parser.add_argument("--goldwave-exe", type=Path) 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 not in ("menu", "edit", "winforms", "list", "delphi", "controls"): - parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, delphi or controls") +if args.is32Bit and args.fixture not in ("menu", "edit", "winforms", "list", "listbox", "delphi", "controls"): + parser.error("32-bit checks currently require --fixture menu, edit, winforms, list, listbox, delphi or controls") if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"): parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla") if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()): @@ -203,6 +203,24 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: assert not any(e[2] == "List1" for e in events), "Delphi generic client leaked" print("PASS: Delphi list labels, rows, keyboard navigation, checkbox toggles, return, buttons and empty state") sys.exit(0) + if args.fixture == "listbox": + listEvents = [e for e in events if e[3] == "list"] + for event in listEvents: + print(event[1:9]) + assert any(e[1] == "focus" and e[2] == "Tutorial maps" and + e[4] == "First map" and e[7:9] == (1, 3) + for e in listEvents), "initial map or label missing" + selections = [(e[4], e[7], e[8]) for e in listEvents if e[1] == "selection"] + assert selections == [("Second map β", 2, 3), ("Third map", 3, 3), + ("Second map β", 2, 3), ("", 0, 0)], \ + f"map navigation/empty state missing or duplicated: {selections}" + assert sum(e[1] == "focus" and e[4] == "First map" + for e in listEvents) == 2, "return to map list missing or duplicated" + assert any(e[1] == "focus" and e[2] == "OK" for e in events), "button focus missing" + assert not any("Background-only" in str(e) for e in events), "background map leaked" + assert len({e[0] for e in listEvents}) == 1, "map events changed source identity" + print("PASS: list-box arrows, Unicode, positions, focus return, background exclusion and empty state") + sys.exit(0) if args.fixture == "list": listEvents = [e for e in events if e[3] == "list"] for event in listEvents: