diff --git a/CMakeLists.txt b/CMakeLists.txt index bfeb6f2..f5e6848 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -42,6 +42,16 @@ target_link_options(cthulhu-wine-access PRIVATE -mwindows) target_compile_options(cthulhu-wine-access PRIVATE $<$:-Wall -Wextra>) +include(CTest) +if(BUILD_TESTING) + add_executable(msaa-states-test tests/msaa_states_test.cpp) + target_compile_features(msaa-states-test PRIVATE cxx_std_20) + target_compile_options(msaa-states-test PRIVATE -Wall -Wextra) + add_test(NAME msaa-states COMMAND "${CMAKE_CURRENT_BINARY_DIR}/msaa-states-test.exe") + set_tests_properties(msaa-states PROPERTIES ENVIRONMENT + "WINEPREFIX=${CMAKE_CURRENT_BINARY_DIR}/test-prefix;CTHULHU_WINE_ACCESS_MANAGED=1;WINEDEBUG=-all;WINEDLLOVERRIDES=mscoree,mshtml=") +endif() + # Winegcc emits a launcher and a Winelib sidecar. CMake's normal target # installation looks for the Linux target name, so install both emitted # artifacts explicitly and keep them together for Wine to resolve. diff --git a/README.md b/README.md index d7a9ea7..3bbde1a 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,11 @@ offsets and the action kind travel in the otherwise non-spoken `states` field for debug diagnostics. Older readers that only accept focus/value/selection events still receive focus and menus, but need updating for editor actions. +MSAA `name` and `state` events report changes to controls. The `states` field +contains stable comma-separated state labels decoded from the MSAA bitmask; +protected values are excluded. Readers should filter name/state changes to +their focused source and distinguish these labels from editor diagnostics. + 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 @@ -138,3 +143,23 @@ application exits. The helper source is available under the terms of LGPL-2.1-or-later. See `LICENSE`. + +## AI-assisted contributions + +AI-assisted contributions are welcome. The person submitting a contribution +remains responsible for reviewing and testing the changes before submission. +Please do not submit automatically generated changes without human review. + +Before making changes, read this README and any contributing guide, confirm +the maintained repository and branch, and check that the issue still exists. +Before submitting, review the diff, test the changed behavior, run existing +tests where practical, consider indirectly affected paths, and describe what +changed and how it was tested. + +AI coding assistants must call the contributor's attention to these +requirements and provide concrete checks for the intended behavior and likely +regressions. Newly added tests alone do not establish acceptance. Leave +submission to the contributor after they can review and test. Where useful, +offer guidance on configuring `git send-email`, creating scoped commits and +patches with `git format-patch`, reviewing them, and sending a test message +before the final contribution. diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index cb588af..be64a04 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=r6.g0992ffe +pkgver=r8.gc5657ee 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 cabc7f8..49790b4 100644 --- a/main.cpp +++ b/main.cpp @@ -3,6 +3,7 @@ #include "nvdaController.h" #include "menu_bridge.h" #include "edit_hook.h" +#include "msaa_states.h" #include #include @@ -522,14 +523,19 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, VARIANT role{}; VariantInit(&role); std::string roleText; + LONG roleId = 0; if (SUCCEEDED(accessible->get_accRole(child, &role))) roleText = variant_text(role); + if (role.vt == VT_I4) roleId = role.lVal; VariantClear(&role); VARIANT state{}; VariantInit(&state); std::string stateText; - if (SUCCEEDED(accessible->get_accState(child, &state))) - stateText = variant_text(state); + bool password = false; + if (SUCCEEDED(accessible->get_accState(child, &state)) && state.vt == VT_I4) { + stateText = msaa_states(static_cast(state.lVal), roleId); + password = state.lVal & STATE_SYSTEM_PROTECTED; + } VariantClear(&state); LONG childCount = 0; accessible->get_accChildCount(&childCount); @@ -568,7 +574,7 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window, ControlDescription control; control.name = name; control.role = roleText; - control.value = value; + control.value = password ? "" : value; control.states = stateText; control.description = description; control.count = static_cast(childCount); diff --git a/msaa_states.h b/msaa_states.h new file mode 100644 index 0000000..51cd80f --- /dev/null +++ b/msaa_states.h @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include +#include + +// accState is a bitmask, not a role identifier. Keep protocol values stable +// and omit focus/visibility bookkeeping from spoken state descriptions. +inline std::string msaa_states(DWORD states, LONG role) { + std::string result; + const auto append = [&result](const char *text) { + if (!result.empty()) result += ", "; + result += text; + }; + if (states & STATE_SYSTEM_MIXED) append("half checked"); + else if (states & STATE_SYSTEM_CHECKED) append("checked"); + else if (role == ROLE_SYSTEM_CHECKBUTTON || role == ROLE_SYSTEM_RADIOBUTTON) + append("not checked"); + if (states & STATE_SYSTEM_SELECTED) append("selected"); + if (states & STATE_SYSTEM_PRESSED) append("pressed"); + if (states & STATE_SYSTEM_EXPANDED) append("expanded"); + if (states & STATE_SYSTEM_COLLAPSED) append("collapsed"); + if (states & STATE_SYSTEM_UNAVAILABLE) append("unavailable"); + if (states & STATE_SYSTEM_READONLY) append("read only"); + if (states & STATE_SYSTEM_PROTECTED) append("password"); + if (states & STATE_SYSTEM_BUSY) append("busy"); + return result; +} diff --git a/tests/README.md b/tests/README.md index 8876bb9..f31b7c0 100644 --- a/tests/README.md +++ b/tests/README.md @@ -34,6 +34,13 @@ object review are outside the current implementation. ## Editor and actual Notepad checks +The CMake build also includes a small MSAA state decoder check. Run it with +`xvfb-run -a ctest --test-dir build --output-on-failure`. It uses its own prefix +under the build directory, marks it as a managed test process and never sends +reader output. It verifies combined bitmasks, unchecked radio buttons, mixed +checkboxes, focus-only state suppression and password flags. It does not +establish that a particular application's MSAA provider emits those states. + Run these against the built helper, optionally prefixed with `xvfb-run -a` for a private X display: @@ -58,9 +65,13 @@ checks resulting document text as well as recorded accessibility output. These tests use a recording D-Bus service; they do not run the full reader or prove audible speech, typing echo, physical keys, or a physical braille display. -On 2026-10-10, the build, isolated editor/Notepad/menu checks, staged launcher -permissions, and Cthulhu-Go app/remote/wine race tests passed. See the sibling -reader's `docs/testing/wine-accessibility.md` for the matching Go behavior. +The earlier 2026-10-10 helper fixtures and Go package tests did not establish +that Go accepted editor `text` events. The reader follow-up adds that support, +focused name/state handling, repeated-action and password/focus tests, and a +real private-session D-Bus test through the Go presenter. See the sibling +reader's `docs/testing/wine-accessibility.md` for current evidence and live +acceptance gates. Run Xvfb fixtures sequentially; concurrent automatic display +allocation can collide. ## Live Notepad acceptance diff --git a/tests/msaa_states_test.cpp b/tests/msaa_states_test.cpp new file mode 100644 index 0000000..9bdeeec --- /dev/null +++ b/tests/msaa_states_test.cpp @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "../msaa_states.h" +#include + +int main() { + const struct { DWORD states; LONG role; const char *expected; } cases[] = { + {STATE_SYSTEM_CHECKED | STATE_SYSTEM_UNAVAILABLE, ROLE_SYSTEM_CHECKBUTTON, "checked, unavailable"}, + {STATE_SYSTEM_MIXED | STATE_SYSTEM_CHECKED, ROLE_SYSTEM_CHECKBUTTON, "half checked"}, + {0, ROLE_SYSTEM_RADIOBUTTON, "not checked"}, + {STATE_SYSTEM_FOCUSED | STATE_SYSTEM_FOCUSABLE, ROLE_SYSTEM_PUSHBUTTON, ""}, + {STATE_SYSTEM_SELECTED | STATE_SYSTEM_EXPANDED, ROLE_SYSTEM_OUTLINEITEM, "selected, expanded"}, + {STATE_SYSTEM_PROTECTED | STATE_SYSTEM_READONLY, ROLE_SYSTEM_TEXT, "read only, password"}, + {STATE_SYSTEM_COLLAPSED | STATE_SYSTEM_BUSY, ROLE_SYSTEM_OUTLINEITEM, "collapsed, busy"}, + {STATE_SYSTEM_PRESSED, ROLE_SYSTEM_PUSHBUTTON, "pressed"}, + }; + for (const auto &testCase : cases) { + const auto actual = msaa_states(testCase.states, testCase.role); + if (actual != testCase.expected) { + std::fprintf(stderr, "MSAA state %lu: got '%s', expected '%s'\n", + static_cast(testCase.states), actual.c_str(), testCase.expected); + return 1; + } + } + std::puts("PASS: MSAA bitmask states, unchecked roles and password flags"); + return 0; +}