Finish up edit field accessibility.

This commit is contained in:
ak47 committed 2026-10-10 00:18:22 -04:00
1 parent 6f6d4040c4
commit c5657ee5d4
9 files changed
+359 -37

No files matched your search

+3 -1
View File
@@ -45,8 +45,10 @@ target_compile_options(cthulhu-wine-access PRIVATE
# Winegcc emits a launcher and a Winelib sidecar. CMake's normal target # Winegcc emits a launcher and a Winelib sidecar. CMake's normal target
# installation looks for the Linux target name, so install both emitted # installation looks for the Linux target name, so install both emitted
# artifacts explicitly and keep them together for Wine to resolve. # artifacts explicitly and keep them together for Wine to resolve.
install(FILES install(PROGRAMS
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe" "${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe"
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
install(FILES
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so" "${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so"
"${menu_hook}" "${menu_hook}"
DESTINATION "${WINE_ACCESS_INSTALL_DIR}") DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
+28
View File
@@ -87,6 +87,27 @@ repeated selections, and restores standard-control focus reporting when menus
close. The hook applies to the helper's Wine desktop and prefix and is removed close. The hook applies to the helper's Wine desktop and prefix and is removed
on normal shutdown. It is disabled by `--no-dialog-reader`. on normal shutdown. It is disabled by `--no-dialog-reader`.
The same DLL subclasses standard `Edit` controls, including Wine Notepad's
document area. It reports the current line on focus and menu return, characters
and words on horizontal navigation, lines on vertical/page/document-boundary
navigation, changed selections, and Backspace/Delete/Cut feedback. Password
contents are excluded, and read-only edits do not report unsuccessful deletions.
Undo refreshes the text snapshot used by subsequent navigation. It can attach to
an already-focused editor and resume after the helper restarts.
Editor actions use `PresentAccessibleEvent` with event type `text`; actual focus
uses `focus`. Cthulhu-Go must include the corresponding editor-event support so
rapid actions and repeated characters bypass focus coalescing. UTF-16 selection
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.
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
covered. Reports are limited to 1023 UTF-16 units; documents over one million
units use line feedback without full selection/deletion snapshots.
Keep all three installed files together. The native hook currently targets Keep all three installed files together. The native hook currently targets
64-bit applications; 32-bit menu coverage and custom menus that do not send 64-bit applications; 32-bit menu coverage and custom menus that do not send
`WM_MENUSELECT` are not established. `WM_MENUSELECT` are not established.
@@ -98,6 +119,8 @@ available (`python-gobject` on Arch Linux):
```sh ```sh
python3 tests/menu_integration.py --build-dir build python3 tests/menu_integration.py --build-dir build
python3 tests/menu_integration.py --build-dir build --fixture edit
python3 tests/menu_integration.py --build-dir build --fixture notepad
``` ```
The test opens a short-lived native menu window in a temporary Wine prefix, The test opens a short-lived native menu window in a temporary Wine prefix,
@@ -106,6 +129,11 @@ context menus, shortcuts, item states, and duplicate suppression. It does not
send speech or braille to the running reader. Live listening with Cthulhu is a send speech or braille to the running reader. Live listening with Cthulhu is a
separate acceptance check. separate acceptance check.
See [the test guide](tests/README.md) for helper lifecycle checks and the live
Notepad speech/braille procedure. After updating the DLL, close and reopen the
Wine application: existing control subclasses keep their DLL loaded until the
application exits.
## License ## License
The helper source is available under the terms of LGPL-2.1-or-later. See The helper source is available under the terms of LGPL-2.1-or-later. See
+31 -19
View File
@@ -5,14 +5,13 @@
#include <stdlib.h> #include <stdlib.h>
#define EDIT_SUBCLASS_ID 0x57415431 #define EDIT_SUBCLASS_ID 0x57415431
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
// Bound memory and work inside applications. Navigation reuses the snapshot; // Bound memory and work inside applications. Navigation reuses the snapshot;
// only text changes refresh it. Large documents retain line/character reading. // only text changes refresh it. Large documents retain line reading.
#define MAX_SNAPSHOT_UNITS (1024 * 1024) #define MAX_SNAPSHOT_UNITS (1024 * 1024)
typedef struct EditState { typedef struct EditState {
WCHAR *text; WCHAR *text;
DWORD length, start, end; DWORD length;
BOOL busy, known, destroyed; BOOL busy, known, destroyed;
} EditState; } EditState;
@@ -45,6 +44,7 @@ static void current_line(HWND window, DWORD caret, WCHAR *output) {
output[0] = 1023; output[0] = 1023;
LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output); LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output);
if (copied < 0 || copied > 1023) copied = 0; if (copied < 0 || copied > 1023) copied = 0;
if (copied && output[copied - 1] >= 0xd800 && output[copied - 1] <= 0xdbff) --copied;
output[copied] = 0; output[copied] = 0;
} }
@@ -85,8 +85,10 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
wp == VK_HOME || wp == VK_END || wp == VK_PRIOR || wp == VK_NEXT); wp == VK_HOME || wp == VK_END || wp == VK_PRIOR || wp == VK_NEXT);
BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) || BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) ||
message == WM_PASTE || message == WM_CUT || message == WM_CLEAR || message == WM_PASTE || message == WM_CUT || message == WM_CLEAR ||
message == WM_UNDO || message == EM_REPLACESEL || message == WM_SETTEXT; message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL ||
BOOL focus = message == WM_SETFOCUS; message == WM_SETTEXT || message == EM_SETHANDLE || message == EM_FMTLINES;
BOOL requested_focus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE);
BOOL focus = message == WM_SETFOCUS || requested_focus;
BOOL selected = message == EM_SETSEL; BOOL selected = message == EM_SETSEL;
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR) if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
return DefSubclassProc(window, message, wp, lp); return DefSubclassProc(window, message, wp, lp);
@@ -97,20 +99,22 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0; BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
if (!password && (!state->known || focus)) snapshot(window, state); if (!password && (!state->known || focus)) snapshot(window, state);
WCHAR removed[1024] = {0}; WCHAR removed[1024] = {0};
if (!password && state->text && changing) { if (!password && state->text && changing && old_start <= state->length && old_end <= state->length) {
DWORD first = old_start, last = old_end; DWORD first = old_start, last = old_end;
if (first == last) { if (first == last) {
if (message == WM_CHAR && wp == VK_BACK && first) { if (message == WM_CHAR && wp == VK_BACK && first) {
--first; --first;
if (first && state->text[first] >= 0xdc00 && state->text[first] <= 0xdfff) --first; if (first && state->text[first] >= 0xdc00 && state->text[first] <= 0xdfff) --first;
else if (first && state->text[first] == '\n' && state->text[first - 1] == '\r') --first;
} else if (message == WM_KEYDOWN && wp == VK_DELETE && last < state->length) { } else if (message == WM_KEYDOWN && wp == VK_DELETE && last < state->length) {
++last; ++last;
if (last < state->length && state->text[last - 1] >= 0xd800 && state->text[last - 1] <= 0xdbff) ++last; if (last < state->length && state->text[last - 1] >= 0xd800 && state->text[last - 1] <= 0xdbff) ++last;
else if (last < state->length && state->text[last - 1] == '\r' && state->text[last] == '\n') ++last;
} }
} }
if (last <= state->length) copy_range(removed, state->text, first, last); if (last <= state->length) copy_range(removed, state->text, first, last);
} }
LRESULT result = DefSubclassProc(window, message, wp, lp); LRESULT result = requested_focus ? 0 : DefSubclassProc(window, message, wp, lp);
if (state->destroyed) { if (state->destroyed) {
free(state->text); free(state); free(state->text); free(state);
return result; return result;
@@ -138,11 +142,11 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
// Speak the changed portion when extending or shrinking a selection. // Speak the changed portion when extending or shrinking a selection.
DWORD first = start, last = end; DWORD first = start, last = end;
kind = WINE_ACCESS_TEXT_SELECTED; kind = WINE_ACCESS_TEXT_SELECTED;
if (old_start != old_end && start == old_start) { if (old_start != old_end && old_end <= state->length && start == old_start) {
first = end < old_end ? end : old_end; first = end < old_end ? end : old_end;
last = end > old_end ? end : old_end; last = end > old_end ? end : old_end;
if (end < old_end) kind = WINE_ACCESS_TEXT_UNSELECTED; if (end < old_end) kind = WINE_ACCESS_TEXT_UNSELECTED;
} else if (old_start != old_end && end == old_end) { } else if (old_start != old_end && old_end <= state->length && end == old_end) {
first = start < old_start ? start : old_start; first = start < old_start ? start : old_start;
last = start > old_start ? start : old_start; last = start > old_start ? start : old_start;
if (start > old_start) kind = WINE_ACCESS_TEXT_UNSELECTED; if (start > old_start) kind = WINE_ACCESS_TEXT_UNSELECTED;
@@ -151,7 +155,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
} else if (old_start != old_end && state->text && old_end <= state->length) { } else if (old_start != old_end && state->text && old_end <= state->length) {
copy_range(text, state->text, old_start, old_end); copy_range(text, state->text, old_start, old_end);
kind = WINE_ACCESS_TEXT_UNSELECTED; kind = WINE_ACCESS_TEXT_UNSELECTED;
} else if (navigation && (wp == VK_UP || wp == VK_DOWN || wp == VK_PRIOR || wp == VK_NEXT)) { } else if (navigation && (wp == VK_UP || wp == VK_DOWN || wp == VK_PRIOR || wp == VK_NEXT ||
((wp == VK_HOME || wp == VK_END) && (GetKeyState(VK_CONTROL) & 0x8000)))) {
current_line(window, start, text); current_line(window, start, text);
} else if (state->text && start < state->length) { } else if (state->text && start < state->length) {
DWORD last = start + 1; DWORD last = start + 1;
@@ -165,39 +170,46 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
current_line(window, start, text); current_line(window, start, text);
} }
report = TRUE; report = TRUE;
} else if (changing && removed[0] && old_length > state->length) { } else if (changing && removed[0] && old_length > state->length &&
(message == WM_CLEAR || message == WM_CUT ||
(message == WM_CHAR && wp == VK_BACK) ||
(message == WM_KEYDOWN && wp == VK_DELETE))) {
lstrcpynW(text, removed, 1024); lstrcpynW(text, removed, 1024);
kind = WINE_ACCESS_TEXT_DELETED; report = TRUE; kind = WINE_ACCESS_TEXT_DELETED; report = TRUE;
} }
if (report) notify_text(window, kind, text, start, end); if (report) notify_text(window, kind, text, start, end);
state->start = start; state->end = end;
state->busy = FALSE; state->busy = FALSE;
return result; return result;
} }
void wine_access_watch_edit(HWND window) { BOOL wine_access_watch_edit(HWND window) {
WCHAR class_name[64]; WCHAR class_name[64];
if (!window || !GetClassNameW(window, class_name, 64) || lstrcmpW(class_name, L"Edit")) return; if (!window || !GetClassNameW(window, class_name, 64) || lstrcmpW(class_name, L"Edit")) return FALSE;
// Wine's comctl32 v5 exports GetWindowSubclass only by ordinal. Our own // Wine's comctl32 v5 exports GetWindowSubclass only by ordinal. Our own
// property also keeps attachment independent of the application's manifest. // property also keeps attachment independent of the application's manifest.
if (GetPropW(window, EDIT_STATE_PROPERTY)) return; if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE;
EditState *state = calloc(1, sizeof(*state)); EditState *state = calloc(1, sizeof(*state));
if (!state) return; if (!state) return FALSE;
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) { if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
free(state); return; free(state); return FALSE;
} }
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) { if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID); RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
free(state); return; free(state); return FALSE;
} }
// Subclass callbacks can outlive the reader. Keep their module mapped until // Subclass callbacks can outlive the reader. Keep their module mapped until
// the application exits; each control frees its state on WM_NCDESTROY. // the application exits; each control frees its state on WM_NCDESTROY.
HMODULE module; HMODULE module;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
(LPCWSTR)(UINT_PTR)edit_proc, &module); (LPCWSTR)(UINT_PTR)edit_proc, &module);
return TRUE;
} }
__declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) { __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) {
if (code >= 0 && wp == PM_REMOVE) wine_access_watch_edit(GetFocus()); if (code >= 0 && wp == PM_REMOVE) {
HWND focus = GetFocus();
if (wine_access_watch_edit(focus))
SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
}
return CallNextHookEx(NULL, code, wp, lp); return CallNextHookEx(NULL, code, wp, lp);
} }
+3 -1
View File
@@ -1,4 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-or-later // SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once #pragma once
#include <windows.h> #include <windows.h>
void wine_access_watch_edit(HWND window); #define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
#define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus"
BOOL wine_access_watch_edit(HWND window);
+35 -8
View File
@@ -2,6 +2,7 @@
#include "nvdaController.h" #include "nvdaController.h"
#include "menu_bridge.h" #include "menu_bridge.h"
#include "edit_hook.h"
#include <commctrl.h> #include <commctrl.h>
#include <gio/gio.h> #include <gio/gio.h>
@@ -28,6 +29,7 @@ std::deque<WineAccessTextEvent> textEvents;
bool editReaderActive = false; bool editReaderActive = false;
std::string lastMenuEvent; std::string lastMenuEvent;
ULONGLONG lastMenuOwner = 0; ULONGLONG lastMenuOwner = 0;
HWND lastTextControl = nullptr;
// Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit // 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. // wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons.
@@ -183,6 +185,9 @@ bool present_accessible_event(const std::string &sourceId,
const char *eventType, const char *eventType,
const ControlDescription &control, const ControlDescription &control,
const std::string &windowTitle) { const std::string &windowTitle) {
// Readers can update their focus owner even when they suppress its output.
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
lastTextControl = nullptr;
return call_boolean("PresentAccessibleEvent", return call_boolean("PresentAccessibleEvent",
g_variant_new( g_variant_new(
"(sssssssiis)", sourceId.c_str(), eventType, "(sssssssiis)", sourceId.c_str(), eventType,
@@ -199,6 +204,12 @@ ControlDescription describe_standard_control(HWND window) {
wchar_t label[256] = {}; wchar_t label[256] = {};
if (!GetClassNameW(window, className, std::size(className))) if (!GetClassNameW(window, className, std::size(className)))
return control; return control;
const LONG_PTR windowStyle = GetWindowLongPtrW(window, GWL_STYLE);
const bool isEdit = lstrcmpW(className, L"Edit") == 0 ||
lstrcmpW(className, L"RICHEDIT50W") == 0 ||
lstrcmpW(className, L"RichEdit20W") == 0 ||
lstrcmpW(className, L"RichEdit20A") == 0;
if (!isEdit || !(windowStyle & ES_PASSWORD))
get_control_text(window, text, std::size(text)); get_control_text(window, text, std::size(text));
if (lstrcmpW(className, L"Button") == 0) { if (lstrcmpW(className, L"Button") == 0) {
@@ -233,10 +244,6 @@ ControlDescription describe_standard_control(HWND window) {
return control; return control;
} }
const bool isEdit = lstrcmpW(className, L"Edit") == 0 ||
lstrcmpW(className, L"RICHEDIT50W") == 0 ||
lstrcmpW(className, L"RichEdit20W") == 0 ||
lstrcmpW(className, L"RichEdit20A") == 0;
if (isEdit) { if (isEdit) {
find_control_label(window, label, std::size(label)); find_control_label(window, label, std::size(label));
control.name = to_utf8(label); control.name = to_utf8(label);
@@ -313,6 +320,10 @@ ControlDescription describe_standard_control(HWND window) {
} }
bool present_standard_focus(HWND window) { bool present_standard_focus(HWND window) {
// Read through the subclass so returning from a menu reports the caret's
// line, not the beginning of the document. Never block on the callback.
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
return PostMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
const ControlDescription control = describe_standard_control(window); const ControlDescription control = describe_standard_control(window);
if (control.name.empty() && control.role.empty() && control.value.empty() && if (control.name.empty() && control.role.empty() && control.value.empty() &&
control.description.empty()) control.description.empty())
@@ -370,6 +381,9 @@ void present_text_event(const WineAccessTextEvent &event) {
if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) ||
!GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window) !GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window)
return; return;
if ((GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) &&
event.kind != WINE_ACCESS_TEXT_PASSWORD)
return;
std::string text = to_utf8(event.text); std::string text = to_utf8(event.text);
ControlDescription control; ControlDescription control;
if (event.kind == WINE_ACCESS_TEXT_PASSWORD) { if (event.kind == WINE_ACCESS_TEXT_PASSWORD) {
@@ -397,7 +411,22 @@ void present_text_event(const WineAccessTextEvent &event) {
control.name = text.empty() ? "blank" : text; control.name = text.empty() ? "blank" : text;
} }
} }
present_accessible_event(source_id(window, "text"), "focus", control, to_utf8(event.title)); const bool focus = event.kind == WINE_ACCESS_TEXT_FOCUS || event.kind == WINE_ACCESS_TEXT_PASSWORD;
const auto source = source_id(window, "text");
const auto title = to_utf8(event.title);
// A reader restart can encounter an editor whose subclass already exists.
// Re-establish focus before its first action without re-reading the document.
if (!focus && lastTextControl != window) {
ControlDescription focused;
focused.role = GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY ? "read only edit" : "edit";
if (!present_accessible_event(source, "focus", focused, title)) return;
}
if (event.kind != WINE_ACCESS_TEXT_PASSWORD)
control.states = "start=" + std::to_string(event.start) + " end=" +
std::to_string(event.end) + " kind=" + std::to_string(event.kind);
// Text commands are ordered actions, not transient focus notifications.
if (present_accessible_event(source, focus ? "focus" : "text", control, title))
lastTextControl = window;
} }
LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
@@ -471,9 +500,7 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION && event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
event != EVENT_OBJECT_STATECHANGE) event != EVENT_OBJECT_STATECHANGE)
return; return;
wchar_t className[64] = {}; if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
if (editReaderActive && GetClassNameW(window, className, std::size(className)) &&
lstrcmpW(className, L"Edit") == 0)
return; return;
IAccessible *accessible = nullptr; IAccessible *accessible = nullptr;
VARIANT child{}; VARIANT child{};
+63 -1
View File
@@ -1,4 +1,4 @@
# Menu bridge integration # Wine control integration
Build the project, then run `python3 tests/menu_integration.py --build-dir build` Build the project, then run `python3 tests/menu_integration.py --build-dir build`
from the repository root in a graphical session. Dependencies are Wine, from the repository root in a graphical session. Dependencies are Wine,
@@ -31,3 +31,65 @@ live listening or physical braille acceptance.
32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full 32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full
object review are outside the current implementation. object review are outside the current implementation.
## Editor and actual Notepad checks
Run these against the built helper, optionally prefixed with `xvfb-run -a` for
a private X display:
```sh
python3 tests/menu_integration.py --build-dir build --fixture edit
python3 tests/menu_integration.py --build-dir build --fixture edit --late-helper
python3 tests/menu_integration.py --build-dir build --fixture edit --restart-helper
python3 tests/menu_integration.py --build-dir build --fixture notepad
```
The standard Edit fixture checks focus, characters/words/lines, incremental
selection and unselection, Backspace/Delete/Clear, read-only controls, password
exclusion, surrogate pairs, rapid repeated letters, menu return at the caret,
short replacements, CRLF deletion, long-line truncation, Undo snapshots, and
Ctrl+End line reading. Lifecycle variants attach after the control has focus or
restart the helper without closing it. Every text action must have an established
focus source, matching the Go reader's filtering contract.
The Notepad fixture launches Wine's actual `notepad.exe` and uses `SendInput`
for arrows, Ctrl+Right, Shift selection, Delete, and Alt/Escape menu return. It
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.
## Live Notepad acceptance
Install all three helper files together and use the matching Cthulhu-Go source.
Close existing Notepad windows after saving any work so the updated hook can load.
1. Quit Cthulhu with Cthulhu+Q.
2. Press Ctrl+Escape, then Shift+G. These are separate key presses. Shift+G
rebuilds Cthulhu and its braille helper and opens the test terminal; it does
not build wine-access.
3. Run `wine notepad` in the intended Wine prefix. Expect edit/blank focus
feedback. Type `hello beta`, Enter, then `second line`. Typing echo should
follow the reader's configured settings.
4. Press Ctrl+Home, then Right three times. Expect the first line at Ctrl+Home,
then `e`, `l`, `l`. Both repeated letters should produce feedback. Ctrl+Right
should read `beta`; Down should read `second line`.
5. Press Home, Shift+Right twice, then Shift+Left twice. Expect `s selected`,
`e selected`, `e unselected`, `s unselected`. Focus stays in the edit area.
Delete should say `s deleted`. Right followed by Backspace should say
`e deleted`; the second line is now `cond line`.
6. Press Alt to enter the menus and Escape to return (Escape again if needed).
Expect menu labels while navigating and `edit, cond line` on return, not
the start of the document. Arrows must continue to reach Notepad.
7. Use Undo, then navigate the restored text. Expect feedback to match the
document. Switch to another application and back; verify focus and keyboard
reading resume normally. Close Notepad without saving this test document.
Connected braille should show the corresponding feedback messages. This does
not establish a routable editor buffer. Report the step, exact speech and
braille, focused control, and any key that behaved differently. For unexpected
results inspect `~/devel/cthulhu-go.log`; debug `Wine editor feedback` records
include source IDs, native UTF-16 selection ranges, action kinds and output text.
+70 -3
View File
@@ -17,6 +17,7 @@ static void key(UINT key, BOOL control, BOOL shift) {
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) { static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (message == WM_TIMER) { if (message == WM_TIMER) {
if (step == 0) SetTimer(window, 1, 350, NULL);
switch (++step) { switch (++step) {
case 1: key(VK_RIGHT, FALSE, FALSE); break; case 1: key(VK_RIGHT, FALSE, FALSE); break;
case 2: key(VK_RIGHT, TRUE, FALSE); break; case 2: key(VK_RIGHT, TRUE, FALSE); break;
@@ -47,8 +48,63 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
SetFocus(password); SetFocus(editor); SetFocus(password); SetFocus(editor);
break; break;
case 19: key(VK_DELETE, FALSE, FALSE); break; case 19: key(VK_DELETE, FALSE, FALSE); break;
default: case 20:
if (GetWindowTextLengthW(editor) != 7) ExitProcess(2); if (GetWindowTextLengthW(editor) != 7) ExitProcess(2);
SendMessageW(editor, EM_SETREADONLY, FALSE, 0);
SetWindowTextW(editor, L"hello\r\nmenu return line");
SendMessageW(editor, EM_SETSEL, 0, 0);
break;
case 21:
key(VK_RIGHT, FALSE, FALSE);
key(VK_RIGHT, FALSE, FALSE);
key(VK_RIGHT, FALSE, FALSE);
break;
case 22:
SendMessageW(editor, EM_SETSEL, 7, 7);
SendMessageW(window, WM_MENUSELECT, MAKEWPARAM(1, 0), (LPARAM)GetMenu(window));
break;
case 23: SendMessageW(window, WM_EXITMENULOOP, 0, 0); break;
case 24:
// Replacement with shorter text is not a Delete/Backspace command.
SendMessageW(editor, EM_SETSEL, 0, 5);
SendMessageW(editor, EM_REPLACESEL, TRUE, (LPARAM)L"hi");
break;
case 25:
SetWindowTextW(editor, L"first\r\nsecond");
SendMessageW(editor, EM_SETSEL, 7, 7);
SendMessageW(editor, WM_CHAR, VK_BACK, 1);
break;
case 26:
SetWindowTextW(editor, L"first\r\nsecond");
SendMessageW(editor, EM_SETSEL, 5, 5);
key(VK_DELETE, FALSE, FALSE);
break;
case 27:
// A surrogate at the 1023-unit line boundary must not discard the line.
{
WCHAR line[1027];
for (int i = 0; i < 1022; ++i) line[i] = L'x';
line[1022] = 0xd83d; line[1023] = 0xde00;
line[1024] = L'y'; line[1025] = 0;
SetWindowTextW(editor, line);
SendMessageW(editor, EM_SETSEL, 0, 0);
SetFocus(password); SetFocus(editor);
}
break;
case 28:
SetWindowTextW(editor, L"undo restored");
SendMessageW(editor, EM_SETSEL, 0, 4);
SendMessageW(editor, EM_REPLACESEL, TRUE, (LPARAM)L"x");
SendMessageW(editor, EM_UNDO, 0, 0);
SendMessageW(editor, EM_SETSEL, 1, 1);
break;
case 29:
key(VK_RIGHT, FALSE, FALSE); // d, not the space in the old snapshot
break;
case 30:
key(VK_END, TRUE, FALSE); // whole restored line
break;
default:
DestroyWindow(window); break; DestroyWindow(window); break;
} }
return 0; return 0;
@@ -65,14 +121,25 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW, HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW,
120, 120, 500, 300, NULL, NULL, instance, NULL); 120, 120, 500, 300, NULL, NULL, instance, NULL);
editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line", editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line",
WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL, WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL,
10, 10, 450, 150, window, NULL, instance, NULL); 10, 10, 450, 150, window, NULL, instance, NULL);
password = CreateWindowW(L"Edit", L"private-password-fixture", password = CreateWindowW(L"Edit", L"private-password-fixture",
WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30, WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30,
window, NULL, instance, NULL); window, NULL, instance, NULL);
HMENU menu = CreateMenu();
AppendMenuW(menu, MF_STRING, 1, L"Test menu");
SetMenu(window, menu);
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor); ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
SendMessageW(editor, EM_SETSEL, 0, 0); SendMessageW(editor, EM_SETSEL, 0, 0);
SetTimer(window, 1, 350, NULL); WCHAR ready[1024];
if (GetEnvironmentVariableW(L"WINE_ACCESS_FIXTURE_READY", ready, 1024)) {
HANDLE file = CreateFileW(ready, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, 0, NULL);
if (file == INVALID_HANDLE_VALUE) return 2;
CloseHandle(file);
}
// Leave time to attach/restart a helper while this already-focused window
// keeps pumping messages. Subsequent actions use the ordinary short timer.
SetTimer(window, 1, 1500, NULL);
MSG message; MSG message;
while (GetMessageW(&message, NULL, 0, 0) > 0) { while (GetMessageW(&message, NULL, 0, 0) > 0) {
TranslateMessage(&message); DispatchMessageW(&message); TranslateMessage(&message); DispatchMessageW(&message);
+50 -4
View File
@@ -1,4 +1,4 @@
"""Exercise a real Wine menu hook against a private D-Bus recording service. """Exercise Wine menus/editors against a private D-Bus recording service.
Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to
the running reader. The fixture window closes automatically. the running reader. The fixture window closes automatically.
@@ -16,13 +16,19 @@ from gi.repository import Gio, GLib
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build") parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true") parser.add_argument("--session", action="store_true")
parser.add_argument("--fixture", choices=("menu", "edit"), default="menu") parser.add_argument("--fixture", choices=("menu", "edit", "notepad"), default="menu")
parser.add_argument("--late-helper", action="store_true")
parser.add_argument("--restart-helper", action="store_true")
args = parser.parse_args() args = parser.parse_args()
if (args.late_helper or args.restart_helper) and args.fixture != "edit":
parser.error("helper lifecycle checks require --fixture edit")
build = Path(args.build_dir).resolve() build = Path(args.build_dir).resolve()
if not args.session: if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build), __file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture])) "--fixture", args.fixture,
*(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_helper else [])]))
interface = "org.stormux.Cthulhu1.Service" interface = "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service" object_path = "/org/stormux/Cthulhu1/Service"
@@ -47,6 +53,8 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
env = os.environ.copy() env = os.environ.copy()
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all", env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all",
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
ready = Path(temporary) / "ready"
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\")
fixture = Path(temporary) / "menu-fixture.exe" fixture = Path(temporary) / "menu-fixture.exe"
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows", subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
"-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")), "-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")),
@@ -55,6 +63,12 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
app = None app = None
try: try:
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
if args.late_helper:
app = subprocess.Popen(["wine", str(fixture)], env=env)
deadline = time.monotonic() + 5
while not ready.exists() and app.poll() is None and time.monotonic() < deadline:
time.sleep(0.01)
assert ready.exists(), "editor did not become ready before helper attachment"
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env) helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
# Allow the helper to register its bridge before opening the fixture. # Allow the helper to register its bridge before opening the fixture.
deadline = time.monotonic() + 1 deadline = time.monotonic() + 1
@@ -63,17 +77,43 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
pass pass
time.sleep(0.01) time.sleep(0.01)
assert helper.poll() is None, "helper exited before fixture startup" assert helper.poll() is None, "helper exited before fixture startup"
if app is None:
app = subprocess.Popen(["wine", str(fixture)], env=env) app = subprocess.Popen(["wine", str(fixture)], env=env)
deadline = time.monotonic() + 15 restarted = False
deadline = time.monotonic() + 25
while app.poll() is None and time.monotonic() < deadline: while app.poll() is None and time.monotonic() < deadline:
while GLib.MainContext.default().iteration(False): while GLib.MainContext.default().iteration(False):
pass pass
if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events):
helper.terminate()
helper.wait(timeout=5)
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
restarted = True
time.sleep(0.01) time.sleep(0.01)
assert app.poll() == 0, "fixture failed or timed out" assert app.poll() == 0, "fixture failed or timed out"
assert helper.poll() is None, "helper exited during the fixture"
if args.restart_helper:
assert restarted, "fixture never triggered a helper restart"
for _ in range(20): for _ in range(20):
while GLib.MainContext.default().iteration(False): while GLib.MainContext.default().iteration(False):
pass pass
time.sleep(0.01) time.sleep(0.01)
focused = None
for event in events:
if event[1] == "focus":
focused = event[0]
elif event[1] == "text":
assert event[0] == focused, f"text sent without establishing focus: {event!r}"
if args.fixture == "notepad":
text_events = [e for e in events if e[0].endswith(":text")]
spoken = [e[2] for e in text_events]
for expected in ("e", "l", "beta", "Notepad second line", "N selected",
"N unselected", "N deleted"):
assert expected in spoken, f"missing Notepad feedback: {expected!r}; {text_events!r}"
assert spoken.count("l") == 2, "Notepad lost repeated characters"
assert any(e[3] == "edit" and e[4] == "otepad second line" for e in text_events), "Notepad menu return missing"
print("PASS: actual Wine Notepad keyboard characters, words, lines, selection, deletion, and menu return")
sys.exit(0)
if args.fixture == "edit": if args.fixture == "edit":
text_events = [e for e in events if e[0].endswith(":text")] text_events = [e for e in events if e[0].endswith(":text")]
for event in text_events: for event in text_events:
@@ -88,6 +128,12 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
assert any(e[6] == "password" for e in text_events), "password role missing" assert any(e[6] == "password" for e in text_events), "password role missing"
assert not any("private-password-fixture" in str(e) for e in events), "password leaked" assert not any("private-password-fixture" in str(e) for e in events), "password leaked"
assert "😀 deleted" not in spoken, "read-only control reported deletion" assert "😀 deleted" not in spoken, "read-only control reported deletion"
assert "hello deleted" not in spoken, "replacement misreported as deletion"
assert sum(e[1] == "text" and e[2] == "l" for e in text_events) == 3, "rapid repeated letters lost"
assert any(e[3] == "edit" and e[4] == "menu return line" for e in text_events), "menu return lost caret line"
assert spoken.count("new line deleted") == 2, "CRLF deletion feedback missing"
assert any(e[4] == "x" * 1022 for e in text_events), "truncated surrogate discarded line"
assert "d" in spoken and "undo restored" in spoken, "Undo left stale text snapshot"
print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls") print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls")
sys.exit(0) sys.exit(0)
menus = [event for event in events if ":menu:" in event[0]] menus = [event for event in events if ":menu:" in event[0]]
+76
View File
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
// Drive Wine's actual Notepad; the standard-control fixture covers edge cases.
#include <windows.h>
static DWORD notepad_pid;
static HWND notepad;
static BOOL CALLBACK find_notepad(HWND window, LPARAM unused) {
(void)unused;
DWORD pid;
GetWindowThreadProcessId(window, &pid);
if (pid == notepad_pid && IsWindowVisible(window) &&
FindWindowExW(window, NULL, L"Edit", NULL)) {
notepad = window;
return FALSE;
}
return TRUE;
}
static void key(WORD vk, BOOL control, BOOL shift) {
INPUT input[6] = {0};
UINT count = 0;
if (control) { input[count].ki.wVk = VK_CONTROL; ++count; }
if (shift) { input[count].ki.wVk = VK_SHIFT; ++count; }
input[count].ki.wVk = vk; ++count;
input[count].ki.wVk = vk; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count;
if (shift) { input[count].ki.wVk = VK_SHIFT; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count; }
if (control) { input[count].ki.wVk = VK_CONTROL; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count; }
for (UINT i = 0; i < count; ++i) input[i].type = INPUT_KEYBOARD;
if (SendInput(count, input, sizeof(INPUT)) != count) ExitProcess(3);
Sleep(200);
}
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
(void)instance; (void)previous; (void)args; (void)show;
STARTUPINFOW startup = {0};
PROCESS_INFORMATION process = {0};
startup.cb = sizeof(startup);
WCHAR command[] = L"notepad.exe";
if (!CreateProcessW(NULL, command, NULL, NULL, FALSE, 0, NULL, NULL, &startup, &process)) return 2;
notepad_pid = process.dwProcessId;
for (int i = 0; i < 100 && !notepad; ++i) {
EnumWindows(find_notepad, 0);
Sleep(50);
}
int result = 2;
if (notepad) {
HWND edit = FindWindowExW(notepad, NULL, L"Edit", NULL);
SetForegroundWindow(notepad);
SendMessageW(edit, WM_SETTEXT, 0, (LPARAM)L"hello beta\r\nNotepad second line");
SendMessageW(edit, EM_SETSEL, 0, 0);
Sleep(400);
key(VK_RIGHT, FALSE, FALSE); // e
key(VK_RIGHT, FALSE, FALSE); // l
key(VK_RIGHT, FALSE, FALSE); // l
key(VK_RIGHT, TRUE, FALSE); // beta
key(VK_DOWN, FALSE, FALSE); // second line
key(VK_HOME, FALSE, FALSE);
key(VK_RIGHT, FALSE, TRUE); // N selected
key(VK_LEFT, FALSE, TRUE); // N unselected
key(VK_DELETE, FALSE, FALSE);// N deleted
key(VK_MENU, FALSE, FALSE);
key(VK_ESCAPE, FALSE, FALSE);
key(VK_ESCAPE, FALSE, FALSE);
WCHAR text[80] = {0};
SendMessageW(edit, WM_GETTEXT, 80, (LPARAM)text);
if (!lstrcmpW(text, L"hello beta\r\notepad second line")) result = 0;
// Suppress the save prompt only in this test-owned, unsaved document.
SendMessageW(edit, EM_SETMODIFY, FALSE, 0);
PostMessageW(notepad, WM_CLOSE, 0, 0);
if (WaitForSingleObject(process.hProcess, 3000) != WAIT_OBJECT_0) result = 2;
}
CloseHandle(process.hThread);
CloseHandle(process.hProcess);
return result;
}