Finish up edit field accessibility.
This commit is contained in:
1 parent
6f6d4040c4
commit
c5657ee5d4
9 files changed
+359
-37
No files matched your search
+3
-1
@@ -45,8 +45,10 @@ target_compile_options(cthulhu-wine-access PRIVATE
|
||||
# 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.
|
||||
install(FILES
|
||||
install(PROGRAMS
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe"
|
||||
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
||||
install(FILES
|
||||
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so"
|
||||
"${menu_hook}"
|
||||
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
||||
@@ -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
|
||||
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
|
||||
64-bit applications; 32-bit menu coverage and custom menus that do not send
|
||||
`WM_MENUSELECT` are not established.
|
||||
@@ -98,6 +119,8 @@ available (`python-gobject` on Arch Linux):
|
||||
|
||||
```sh
|
||||
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,
|
||||
@@ -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
|
||||
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
|
||||
|
||||
The helper source is available under the terms of LGPL-2.1-or-later. See
|
||||
|
||||
+31
-19
@@ -5,14 +5,13 @@
|
||||
#include <stdlib.h>
|
||||
|
||||
#define EDIT_SUBCLASS_ID 0x57415431
|
||||
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
|
||||
// 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)
|
||||
|
||||
typedef struct EditState {
|
||||
WCHAR *text;
|
||||
DWORD length, start, end;
|
||||
DWORD length;
|
||||
BOOL busy, known, destroyed;
|
||||
} EditState;
|
||||
|
||||
@@ -45,6 +44,7 @@ static void current_line(HWND window, DWORD caret, WCHAR *output) {
|
||||
output[0] = 1023;
|
||||
LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output);
|
||||
if (copied < 0 || copied > 1023) copied = 0;
|
||||
if (copied && output[copied - 1] >= 0xd800 && output[copied - 1] <= 0xdbff) --copied;
|
||||
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);
|
||||
BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) ||
|
||||
message == WM_PASTE || message == WM_CUT || message == WM_CLEAR ||
|
||||
message == WM_UNDO || message == EM_REPLACESEL || message == WM_SETTEXT;
|
||||
BOOL focus = message == WM_SETFOCUS;
|
||||
message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL ||
|
||||
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;
|
||||
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
|
||||
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;
|
||||
if (!password && (!state->known || focus)) snapshot(window, state);
|
||||
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;
|
||||
if (first == last) {
|
||||
if (message == WM_CHAR && wp == VK_BACK && first) {
|
||||
--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) {
|
||||
++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);
|
||||
}
|
||||
LRESULT result = DefSubclassProc(window, message, wp, lp);
|
||||
LRESULT result = requested_focus ? 0 : DefSubclassProc(window, message, wp, lp);
|
||||
if (state->destroyed) {
|
||||
free(state->text); free(state);
|
||||
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.
|
||||
DWORD first = start, last = end;
|
||||
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;
|
||||
last = end > old_end ? end : old_end;
|
||||
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;
|
||||
last = start > old_start ? start : old_start;
|
||||
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) {
|
||||
copy_range(text, state->text, old_start, old_end);
|
||||
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);
|
||||
} else if (state->text && start < state->length) {
|
||||
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);
|
||||
}
|
||||
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);
|
||||
kind = WINE_ACCESS_TEXT_DELETED; report = TRUE;
|
||||
}
|
||||
if (report) notify_text(window, kind, text, start, end);
|
||||
state->start = start; state->end = end;
|
||||
state->busy = FALSE;
|
||||
return result;
|
||||
}
|
||||
|
||||
void wine_access_watch_edit(HWND window) {
|
||||
BOOL wine_access_watch_edit(HWND window) {
|
||||
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
|
||||
// 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));
|
||||
if (!state) return;
|
||||
if (!state) return FALSE;
|
||||
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)) {
|
||||
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
|
||||
// the application exits; each control frees its state on WM_NCDESTROY.
|
||||
HMODULE module;
|
||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||
(LPCWSTR)(UINT_PTR)edit_proc, &module);
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
__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);
|
||||
}
|
||||
+3
-1
@@ -1,4 +1,6 @@
|
||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||
#pragma once
|
||||
#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);
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "nvdaController.h"
|
||||
#include "menu_bridge.h"
|
||||
#include "edit_hook.h"
|
||||
|
||||
#include <commctrl.h>
|
||||
#include <gio/gio.h>
|
||||
@@ -28,6 +29,7 @@ std::deque<WineAccessTextEvent> textEvents;
|
||||
bool editReaderActive = false;
|
||||
std::string lastMenuEvent;
|
||||
ULONGLONG lastMenuOwner = 0;
|
||||
HWND lastTextControl = nullptr;
|
||||
|
||||
// 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.
|
||||
@@ -183,6 +185,9 @@ bool present_accessible_event(const std::string &sourceId,
|
||||
const char *eventType,
|
||||
const ControlDescription &control,
|
||||
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",
|
||||
g_variant_new(
|
||||
"(sssssssiis)", sourceId.c_str(), eventType,
|
||||
@@ -199,6 +204,12 @@ ControlDescription describe_standard_control(HWND window) {
|
||||
wchar_t label[256] = {};
|
||||
if (!GetClassNameW(window, className, std::size(className)))
|
||||
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));
|
||||
|
||||
if (lstrcmpW(className, L"Button") == 0) {
|
||||
@@ -233,10 +244,6 @@ ControlDescription describe_standard_control(HWND window) {
|
||||
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) {
|
||||
find_control_label(window, label, std::size(label));
|
||||
control.name = to_utf8(label);
|
||||
@@ -313,6 +320,10 @@ ControlDescription describe_standard_control(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);
|
||||
if (control.name.empty() && control.role.empty() && control.value.empty() &&
|
||||
control.description.empty())
|
||||
@@ -370,6 +381,9 @@ void present_text_event(const WineAccessTextEvent &event) {
|
||||
if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) ||
|
||||
!GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window)
|
||||
return;
|
||||
if ((GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) &&
|
||||
event.kind != WINE_ACCESS_TEXT_PASSWORD)
|
||||
return;
|
||||
std::string text = to_utf8(event.text);
|
||||
ControlDescription control;
|
||||
if (event.kind == WINE_ACCESS_TEXT_PASSWORD) {
|
||||
@@ -397,7 +411,22 @@ void present_text_event(const WineAccessTextEvent &event) {
|
||||
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) {
|
||||
@@ -471,9 +500,7 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
|
||||
event != EVENT_OBJECT_STATECHANGE)
|
||||
return;
|
||||
wchar_t className[64] = {};
|
||||
if (editReaderActive && GetClassNameW(window, className, std::size(className)) &&
|
||||
lstrcmpW(className, L"Edit") == 0)
|
||||
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
|
||||
return;
|
||||
IAccessible *accessible = nullptr;
|
||||
VARIANT child{};
|
||||
|
||||
+63
-1
@@ -1,4 +1,4 @@
|
||||
# Menu bridge integration
|
||||
# Wine control integration
|
||||
|
||||
Build the project, then run `python3 tests/menu_integration.py --build-dir build`
|
||||
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
|
||||
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
@@ -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) {
|
||||
if (message == WM_TIMER) {
|
||||
if (step == 0) SetTimer(window, 1, 350, NULL);
|
||||
switch (++step) {
|
||||
case 1: key(VK_RIGHT, FALSE, 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);
|
||||
break;
|
||||
case 19: key(VK_DELETE, FALSE, FALSE); break;
|
||||
default:
|
||||
case 20:
|
||||
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;
|
||||
}
|
||||
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,
|
||||
120, 120, 500, 300, NULL, NULL, instance, NULL);
|
||||
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);
|
||||
password = CreateWindowW(L"Edit", L"private-password-fixture",
|
||||
WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30,
|
||||
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);
|
||||
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;
|
||||
while (GetMessageW(&message, NULL, 0, 0) > 0) {
|
||||
TranslateMessage(&message); DispatchMessageW(&message);
|
||||
|
||||
@@ -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
|
||||
the running reader. The fixture window closes automatically.
|
||||
@@ -16,13 +16,19 @@ 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"), 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()
|
||||
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()
|
||||
if not args.session:
|
||||
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
|
||||
__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"
|
||||
object_path = "/org/stormux/Cthulhu1/Service"
|
||||
@@ -47,6 +53,8 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
env = os.environ.copy()
|
||||
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all",
|
||||
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"
|
||||
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
|
||||
"-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
|
||||
try:
|
||||
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)
|
||||
# Allow the helper to register its bridge before opening the fixture.
|
||||
deadline = time.monotonic() + 1
|
||||
@@ -63,17 +77,43 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
||||
pass
|
||||
time.sleep(0.01)
|
||||
assert helper.poll() is None, "helper exited before fixture startup"
|
||||
if app is None:
|
||||
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 GLib.MainContext.default().iteration(False):
|
||||
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)
|
||||
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):
|
||||
while GLib.MainContext.default().iteration(False):
|
||||
pass
|
||||
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":
|
||||
text_events = [e for e in events if e[0].endswith(":text")]
|
||||
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 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 "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")
|
||||
sys.exit(0)
|
||||
menus = [event for event in events if ":menu:" in event[0]]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user