diff --git a/README.md b/README.md index fe8a900..a49e38e 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,13 @@ 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. +Delphi VCL `TListView`, `TButton`, `TBitBtn`, and `TStaticText` subclasses +use these same native queries. List views with standard checkboxes report +checked/not-checked states and observe Space-key toggles even without a +selection event. This covers GoldWave 5.70's Setup list and standard buttons; +arbitrary custom Delphi controls and 32-bit menu/editor hooks remain outside +this support. + Control values and window titles retain literal ampersands. Button and static label captions decode keyboard mnemonics and escaped `&&`; static labels with `SS_NOPREFIX` retain their literal text. diff --git a/control_class.h b/control_class.h index e15e469..934af05 100644 --- a/control_class.h +++ b/control_class.h @@ -7,6 +7,16 @@ // 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; + // Delphi VCL registers native subclasses under its own class names. + // Keep aliases explicit: an arbitrary T-prefixed custom control need not + // implement the corresponding Windows control messages. + if (lstrcmpiW(nativeName, L"SysListView32") == 0 && + lstrcmpiW(className, L"TListView") == 0) return TRUE; + if (lstrcmpiW(nativeName, L"Button") == 0 && + (lstrcmpiW(className, L"TButton") == 0 || + lstrcmpiW(className, L"TBitBtn") == 0)) return TRUE; + if (lstrcmpiW(nativeName, L"Static") == 0 && + lstrcmpiW(className, L"TStaticText") == 0) return TRUE; if (lstrlenW(className) > 4 && CompareStringOrdinal(className, 4, L"ATL:", 4, TRUE) == CSTR_EQUAL && lstrcmpiW(className + 4, nativeName) == 0) return TRUE; diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index d36f82c..247cd48 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=r17.gcbce835 +pkgver=r18.gdf7879a pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' arch=('x86_64') diff --git a/list_view.cpp b/list_view.cpp index 55f217d..e8ba593 100644 --- a/list_view.cpp +++ b/list_view.cpp @@ -65,6 +65,7 @@ ListViewRow read_list_view(HWND window) { DWORD_PTR state = 0; if (query(window, LVM_GETITEMSTATE, row.index, LVIS_SELECTED, &state)) row.selected = state & LVIS_SELECTED; + row.checked = list_view_checked(window, row.index); native_control::RemoteBuffer remote(window, bufferSize); if (!remote) return row; const bool wow64 = remote.is32Bit(); @@ -104,3 +105,15 @@ ListViewRow read_list_view(HWND window) { } return row; } + +int list_view_checked(HWND window, int index) { + DWORD_PTR style = 0, state = 0; + if (index < 0 || + !query(window, LVM_GETEXTENDEDLISTVIEWSTYLE, 0, 0, &style) || + !(style & LVS_EX_CHECKBOXES) || + !query(window, LVM_GETITEMSTATE, index, LVIS_STATEIMAGEMASK, &state)) + return -1; + const DWORD_PTR image = (state & LVIS_STATEIMAGEMASK) >> 12; + // Other state images are application-defined; do not invent a check state. + return image == 1 ? 0 : image == 2 ? 1 : -1; +} diff --git a/list_view.h b/list_view.h index 4c91b86..a3ed630 100644 --- a/list_view.h +++ b/list_view.h @@ -8,8 +8,10 @@ struct ListViewRow { int index = -1; int count = 0; bool selected = false; + int checked = -1; // -1: no standard checkbox, 0: unchecked, 1: checked. }; bool is_list_view(HWND window); ListViewRow read_list_view(HWND window); int list_view_index(HWND window, int *count); +int list_view_checked(HWND window, int index); diff --git a/main.cpp b/main.cpp index bc9863e..db588ba 100644 --- a/main.cpp +++ b/main.cpp @@ -38,6 +38,7 @@ HWND lastListControl = nullptr; int lastListIndex = -1; int lastListCount = 0; bool lastListSelected = false; +int lastListChecked = -1; HWND lastNativeControl = nullptr; NativeItemKey lastNativeItem; @@ -332,10 +333,15 @@ ControlDescription describe_standard_control(HWND window) { if (!row.text.empty()) control.position = row.index + 1; control.count = row.count; if (row.selected) control.states = "selected"; + if (row.checked >= 0) { + if (!control.states.empty()) control.states += ", "; + control.states += row.checked ? "checked" : "not checked"; + } lastListControl = window; lastListIndex = row.index; lastListCount = row.count; lastListSelected = row.selected; + lastListChecked = row.checked; return control; } @@ -427,6 +433,7 @@ void poll_control_focus(bool refresh = false) { lastListIndex = -1; lastListCount = 0; lastListSelected = false; + lastListChecked = -1; const auto key = native_item_key(info.hwndFocus); if (info.hwndFocus != lastNativeControl) { present_standard_focus(info.hwndFocus); @@ -448,9 +455,11 @@ void poll_control_focus(bool refresh = false) { SendMessageTimeoutW(info.hwndFocus, LVM_GETITEMSTATE, index, LVIS_SELECTED, SMTO_ABORTIFHUNG, 100, &state); const bool selected = state & LVIS_SELECTED; + const int checked = list_view_checked(info.hwndFocus, index); if (info.hwndFocus != lastListControl) { present_standard_focus(info.hwndFocus); - } else if (refresh || index != lastListIndex || count != lastListCount || selected != lastListSelected) { + } else if (refresh || index != lastListIndex || count != lastListCount || + selected != lastListSelected || checked != lastListChecked) { const auto control = describe_standard_control(info.hwndFocus); present_accessible_event(source_id(info.hwndFocus, "standard"), "selection", control, window_title(info.hwndFocus)); @@ -461,6 +470,7 @@ void poll_control_focus(bool refresh = false) { lastListIndex = -1; lastListCount = 0; lastListSelected = false; + lastListChecked = -1; } void present_menu_event(const WineAccessMenuEvent &event) { diff --git a/tests/README.md b/tests/README.md index b8ba0ed..e96592b 100644 --- a/tests/README.md +++ b/tests/README.md @@ -161,6 +161,36 @@ 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. +## Delphi Setup lists and checkboxes + +The October 10 GoldWave 5.70 Setup inspection found a 32-bit `TListView` +with 14 checkbox rows, plus `TButton` and `TBitBtn` controls. Wine exposed +them as generic MSAA clients; the list client's name/value were empty. Focus +events were present for the buttons and on return to the list. The native +messages already exposed the option captions and checkbox state, but the +helper did not recognize these VCL class names or read list checkbox states. + +The explicit VCL aliases and native checkbox query fix those shared gaps. +The updated query code read the actual open Setup's first option, `Disable +effect plug-ins`, `not checked`, position 1 of 14, and the Backup/Restore/OK/ +Cancel button roles without changing settings or sending speech. A later +live registry query also confirmed `StartupOptions=0x3008` and Setup showed +`Force screen reader mode` checked; the earlier immediate persistence query +had still seen `0x1008`. + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture delphi --32-bit +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture delphi +``` + +Both fixtures passed with native controls registered under VCL names. They +check initial rows/labels, Down navigation, Space check/uncheck without +synthetic accessibility events, button roles, return to the checked row, +and empty-list state reset. The existing 32-bit plain-list regression also +passed. These are private D-Bus recordings; audible GoldWave speech and +physical braille remain a live acceptance check after installing the helper +and restarting Cthulhu. The fix does not add 32-bit menu or editor hooks. + ## Native tabs, trees and named clients ```sh diff --git a/tests/list_fixture.c b/tests/list_fixture.c index 0865650..1ecc442 100644 --- a/tests/list_fixture.c +++ b/tests/list_fixture.c @@ -5,6 +5,9 @@ #include static HWND list, button; +#ifdef WINE_ACCESS_DELPHI_FIXTURE +static HWND okButton; +#endif static int step; static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { @@ -15,18 +18,29 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM SendMessageW(list, WM_KEYDOWN, VK_DOWN, 1); break; case 3: +#ifdef WINE_ACCESS_DELPHI_FIXTURE + SendMessageW(list, WM_KEYDOWN, VK_SPACE, 1); +#else SendMessageW(list, WM_KEYDOWN, VK_UP, 1); +#endif break; case 4: SetFocus(button); break; case 5: SetFocus(list); break; case 6: { +#ifdef WINE_ACCESS_DELPHI_FIXTURE + SendMessageW(list, WM_KEYDOWN, VK_SPACE, 1); +#else LVITEMW item = {0}; item.pszText = L"Ready"; SendMessageW(list, LVM_SETITEMTEXTW, 0, (LPARAM)&item); NotifyWinEvent(EVENT_OBJECT_VALUECHANGE, list, OBJID_CLIENT, CHILDID_SELF); +#endif break; } case 7: SendMessageW(list, LVM_DELETEALLITEMS, 0, 0); break; +#ifdef WINE_ACCESS_DELPHI_FIXTURE + case 8: SetFocus(okButton); break; +#endif default: DestroyWindow(window); } return 0; @@ -46,12 +60,32 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) RegisterClassW(&cls); HWND window = CreateWindowW(cls.lpszClassName, L"Wine list fixture", WS_OVERLAPPEDWINDOW, 120, 120, 600, 300, NULL, NULL, instance, NULL); - button = CreateWindowW(L"Button", L"Return to control", WS_CHILD | WS_VISIBLE | WS_TABSTOP, +#ifdef WINE_ACCESS_DELPHI_FIXTURE + const WCHAR *nativeNames[] = {WC_LISTVIEWW, L"Button", L"Button", L"Static"}; + const WCHAR *delphiNames[] = {L"TListView", L"TBitBtn", L"TButton", L"TStaticText"}; + for (int i = 0; i < 4; ++i) { + WNDCLASSEXW subclass = {0}; + subclass.cbSize = sizeof(subclass); + if (!GetClassInfoExW(NULL, nativeNames[i], &subclass)) return 1; + subclass.hInstance = instance; + subclass.lpszClassName = delphiNames[i]; + if (!RegisterClassExW(&subclass)) return 2; + } + const WCHAR *buttonClass = L"TBitBtn", *listClass = L"TListView", *labelClass = L"TStaticText"; + okButton = CreateWindowW(L"TButton", L"&OK", WS_CHILD | WS_VISIBLE | WS_TABSTOP, + 240, 240, 120, 30, window, NULL, instance, NULL); +#else + const WCHAR *buttonClass = L"Button", *listClass = WC_LISTVIEWW, *labelClass = L"Static"; +#endif + button = CreateWindowW(buttonClass, L"Return to control", WS_CHILD | WS_VISIBLE | WS_TABSTOP, 10, 240, 200, 30, window, NULL, instance, NULL); - CreateWindowW(L"Static", L"Required components", WS_CHILD | WS_VISIBLE, + CreateWindowW(labelClass, L"Required components", WS_CHILD | WS_VISIBLE, 10, 10, 550, 30, window, NULL, instance, NULL); - list = CreateWindowW(WC_LISTVIEWW, L"List1", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT, + list = CreateWindowW(listClass, L"List1", WS_CHILD | WS_VISIBLE | WS_TABSTOP | LVS_REPORT, 10, 50, 550, 180, window, NULL, instance, NULL); +#ifdef WINE_ACCESS_DELPHI_FIXTURE + SendMessageW(list, LVM_SETEXTENDEDLISTVIEWSTYLE, LVS_EX_CHECKBOXES, LVS_EX_CHECKBOXES); +#endif LVCOLUMNW column = {0}; column.mask = LVCF_TEXT | LVCF_WIDTH; column.cx = 150; diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 39f47ed..11cfb72 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -17,14 +17,14 @@ 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", "controls", "scintilla"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "delphi", "winforms", "controls", "scintilla"), default="menu") parser.add_argument("--notepadpp-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 ("list", "controls"): - parser.error("32-bit checks currently require --fixture list or controls") +if args.is32Bit and args.fixture not in ("list", "delphi", "controls"): + parser.error("32-bit checks currently require --fixture list, 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()): @@ -82,8 +82,11 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: fixture = Path(temporary) / "menu-fixture.exe" compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc" sourceName = "edit" if args.fixture == "winforms" else args.fixture + if args.fixture == "delphi": + sourceName = "list" subprocess.run([compiler, "-Wall", "-Wextra", "-mwindows", *(["-DWINE_ACCESS_WINFORMS_FIXTURE"] if args.fixture == "winforms" else []), + *(["-DWINE_ACCESS_DELPHI_FIXTURE"] if args.fixture == "delphi" else []), "-o", str(fixture), str(Path(__file__).with_name(sourceName + "_fixture.c")), "-luser32", "-lcomctl32", "-loleacc", "-lole32"], check=True) helper = None @@ -160,6 +163,23 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: 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 == "delphi": + for event in events: + print(event[1:9]) + assert any(e[1] == "focus" and e[2] == "Required components" and + e[3] == "list" and e[4] == "Pending, Runtime & tools β" and + e[5] == "not checked" and e[7:9] == (1, 2) for e in events), "Delphi initial row/label/state missing" + rows = [e for e in events if e[3] == "list" and e[4] == "Installed, Second component"] + assert any(e[1] == "selection" and e[5] == "selected, not checked" for e in rows), "Delphi Down missing" + assert any(e[1] == "selection" and e[5] == "selected, checked" for e in rows), "Space checkbox change missing" + assert any(e[1] == "focus" and e[5] == "selected, checked" for e in rows), "Delphi return lost checkbox" + assert sum(e[1] == "selection" and e[5] == "selected, not checked" for e in rows) >= 2, "Space uncheck missing" + for caption in ("Return to control", "OK"): + assert any(e[1] == "focus" and e[2] == caption and e[3] == "button" for e in events), f"Delphi button missing: {caption}" + assert any(e[3] == "list" and e[4] == "" and e[5] == "" and e[7:9] == (0, 0) for e in events), "empty Delphi list retained state" + 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 == "list": listEvents = [e for e in events if e[3] == "list"] for event in listEvents: