From a002c4cc1e77e82ff9902b1ae1f0f593d2b5b5c6 Mon Sep 17 00:00:00 2001 From: deedra waters Date: Mon, 12 Oct 2026 15:31:00 -0700 Subject: [PATCH] Echo typed characters in Wine edit fields --- README.md | 23 +++++++++----- edit_hook.c | 36 ++++++++++++++++++++++ menu_bridge.h | 1 + tests/README.md | 28 ++++++++++------- tests/edit_fixture.c | 63 +++++++++++++++++++++++++++++++++++++++ tests/menu_integration.py | 9 ++++-- tests/notepad_fixture.c | 16 +++++++++- 7 files changed, 154 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 50c07f6..2c19fbc 100644 --- a/README.md +++ b/README.md @@ -193,8 +193,8 @@ document area, MUSHclient's command field and read-only output notepad, and use visible sibling labels immediately to their left, even when the labels were created after the fields. Protected fields report their label and password role without forwarding their contents. -It reports the current line on focus and menu return, characters -and words on horizontal navigation, lines on vertical/page/document-boundary +It reports accepted typed characters, 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 @@ -212,11 +212,18 @@ selection feedback and Ctrl+Backspace/Delete feedback are not implemented. Backspace reports only the removed text, such as `t`. Delete and Cut retain their deletion feedback, such as `t deleted`. +Standard Edit typing sends each accepted character as an ordered editor action, +including repeated letters, spaces, tabs, line breaks and Unicode surrogate +pairs. Typing over selected text also reports the inserted character. Password +fields, background controls and rejected input remain silent. Paste and +programmatic text changes do not generate character echo. The reader presents +these through the same feedback path as caret navigation and Backspace. + 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. Selection -offsets and the action kind travel in the otherwise non-spoken `states` field -for debug diagnostics. Standard edits use UTF-16 offsets; Scintilla marks its +offsets and the action kind (8 for typed insertion) travel in the otherwise +non-spoken `states` field for debug diagnostics. Standard edits use UTF-16 offsets; Scintilla marks its encoded byte offsets with `offset_unit=byte`. Older readers that only accept focus/value/selection events still receive focus and menus, but need updating for editor actions. @@ -262,10 +269,10 @@ label captions decode keyboard mnemonics and escaped `&&`; static labels with These hooks provide editing feedback. Independent review-cursor commands use the optional flat-review protocol described below; Say All and native braille -routing remain unsupported. Typing echo -remains the reader's responsibility. RichEdit and 32-bit Scintilla 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. +routing remain unsupported. Word echo and typing echo in Scintilla +remain outside this helper's current support. RichEdit and 32-bit Scintilla hooks +are not covered. Reports are limited to 1023 UTF-16 units; documents over one million +units retain typing and line feedback without full selection/deletion snapshots. Scintilla reads at most 4096 encoded bytes per report, then limits output to 1023 UTF-16 units without splitting surrogate pairs. Wrapped Scintilla lines report the underlying document line. diff --git a/edit_hook.c b/edit_hook.c index 63a4b21..36fb1a6 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -14,6 +14,8 @@ typedef struct EditState { WCHAR *text; DWORD length; BOOL busy, known, destroyed; + WCHAR pending_high; + DWORD pending_caret; } EditState; static void selection(HWND window, DWORD *start, DWORD *end) { @@ -94,6 +96,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l message == WM_PASTE || message == WM_CUT || message == WM_CLEAR || message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL || message == WM_SETTEXT || message == EM_SETHANDLE || message == EM_FMTLINES; + BOOL typing = message == WM_CHAR && + ((wp >= 0x20 && wp <= 0xffff && wp != 0x7f) || wp == VK_RETURN || wp == VK_TAB); BOOL requested_focus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE); BOOL focus = message == WM_SETFOCUS || requested_focus; BOOL selected = message == EM_SETSEL; @@ -101,6 +105,9 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l return DefSubclassProc(window, message, wp, lp); state->busy = TRUE; + WCHAR pending_high = state->pending_high; + DWORD pending_caret = state->pending_caret; + state->pending_high = 0; DWORD old_start = 0, old_end = 0; selection(window, &old_start, &old_end); BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0; @@ -185,6 +192,35 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l kind = message == WM_CHAR && wp == VK_BACK ? WINE_ACCESS_TEXT_BACKSPACED : WINE_ACCESS_TEXT_DELETED; report = TRUE; + } else if (typing && !(GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY) && + start == end && start > old_start && old_start <= old_end && + old_end <= old_length && state->length > old_length - (old_end - old_start)) { + // Echo accepted input, including replacements that shorten a selection. + // WM_CHAR delivers supplementary characters as two UTF-16 messages. + if (wp >= 0xd800 && wp <= 0xdbff) { + state->pending_high = (WCHAR)wp; + state->pending_caret = start; + } else if (wp < 0xdc00 || wp > 0xdfff || + (pending_high && pending_caret == old_start && old_start == old_end)) { + DWORD first = old_start; + BOOL paired = wp >= 0xdc00 && wp <= 0xdfff; + if (paired) --first; + if (state->text && start <= state->length) { + copy_range(text, state->text, first, start); + } else { + // Large documents have no full snapshot. The accepted WM_CHAR still + // supplies bounded typing feedback without copying the document. + UINT used = 0; + if (paired) text[used++] = pending_high; + text[used++] = (WCHAR)wp; + text[used] = 0; + LONG_PTR style = GetWindowLongPtrW(window, GWL_STYLE); + if (style & ES_UPPERCASE) CharUpperBuffW(text, used); + else if (style & ES_LOWERCASE) CharLowerBuffW(text, used); + } + kind = WINE_ACCESS_TEXT_INSERTED; + report = text[0] != 0; + } } if (report) notify_text(window, kind, text, start, end); state->busy = FALSE; diff --git a/menu_bridge.h b/menu_bridge.h index dcbcbd2..a34c230 100644 --- a/menu_bridge.h +++ b/menu_bridge.h @@ -17,6 +17,7 @@ #define WINE_ACCESS_TEXT_READ 5 #define WINE_ACCESS_TEXT_PASSWORD 6 #define WINE_ACCESS_TEXT_BACKSPACED 7 +#define WINE_ACCESS_TEXT_INSERTED 8 #define WINE_ACCESS_TEXT_SCINTILLA 1 #define WINE_ACCESS_TEXT_READONLY 2 diff --git a/tests/README.md b/tests/README.md index f4de28e..5f7d82c 100644 --- a/tests/README.md +++ b/tests/README.md @@ -193,21 +193,24 @@ python3 tests/menu_integration.py --build-dir build --fixture winforms --32-bit 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 +The standard Edit fixture checks focus, typing echo, 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, and Up/Down navigation in a read-only multiline output -control. Both 32-bit and 64-bit fixtures use the same assertions. Lifecycle -variants attach after the control has focus or +control. Typing checks include repeated letters, spaces, Enter/Tab, accented +characters, surrogate pairs, selection replacement, documents beyond the full +snapshot limit, and silence for password/background/read-only fields and +input rejected at the text limit. Both 32-bit and 64-bit fixtures use the same +assertions. 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 +for typing, 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. +audible typing echo, physical keys, or a physical braille display. For live MUSHclient acceptance, install the helper and reopen MUSHclient so the updated 32-bit DLL can load. In the command field, check Left/Right and @@ -408,8 +411,9 @@ helper and the application load the new DLL together. Reopen BARD in its own prefix. Tab and Shift+Tab between Email, Password, Sign in and Work Offline. Expect labels and edit/password roles; navigate and select ordinary email text, and confirm password contents stay excluded. Recheck native Notepad, -menus, the installer list and switching to Linux applications. Typing echo -and routable Wine review remain separate unfinished features. +menus, the installer list and switching to Linux applications. Standard Edit +typing echo is now forwarded through editor events; routable Wine review +remains unfinished. ## Live Notepad acceptance @@ -421,8 +425,10 @@ Close existing Notepad windows after saving any work so the updated hook can loa 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. + feedback. Type `hello beta`, Enter, then `second line`. Expect each typed + character, including both `l` characters, spaces and the line break. Standard + Edit typing uses the reader's editor feedback path. Check typing over selected + text too, and confirm password fields never announce their typed contents. 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`. diff --git a/tests/edit_fixture.c b/tests/edit_fixture.c index a170ae5..50e4a8a 100644 --- a/tests/edit_fixture.c +++ b/tests/edit_fixture.c @@ -1,5 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later #include +#include +#include #ifdef WINE_ACCESS_WINFORMS_FIXTURE static const WCHAR *editClass = L"WindowsForms10.EDIT.app.0.fixture_r7_ad1"; @@ -131,6 +133,67 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM key(VK_DELETE, FALSE, FALSE); if (GetWindowTextLengthW(editor) != 31) ExitProcess(2); break; + case 35: + SendMessageW(editor, WM_CHAR, L'!', 1); + if (GetWindowTextLengthW(editor) != 31) ExitProcess(2); + break; + case 36: + SendMessageW(editor, EM_SETREADONLY, FALSE, 0); + SetWindowTextW(editor, L""); + SendMessageW(editor, WM_CHAR, L'z', 1); + SendMessageW(editor, WM_CHAR, L'z', 1); + SendMessageW(editor, WM_CHAR, L' ', 1); + SendMessageW(editor, WM_CHAR, 0x00e9, 1); + SendMessageW(editor, WM_CHAR, 0xd83d, 1); + SendMessageW(editor, WM_CHAR, 0xde00, 1); + SendMessageW(editor, WM_CHAR, VK_RETURN, 1); + SendMessageW(editor, WM_CHAR, VK_TAB, 1); + { + WCHAR text[32]; + GetWindowTextW(editor, text, 32); + if (lstrcmpW(text, L"zz \u00e9\U0001f600\r\n\t")) ExitProcess(2); + } + break; + case 37: + // Replacing selected text is still typing, even when length shrinks. + SendMessageW(editor, EM_SETSEL, 0, 4); + SendMessageW(editor, WM_CHAR, L'q', 1); + break; + case 38: + SetFocus(password); + SendMessageW(password, WM_CHAR, L'@', 1); + // Background changes must not produce echo for the focused password. + SendMessageW(editor, WM_CHAR, L'~', 1); + break; + case 39: + SetFocus(editor); + { + DWORD length = 1024 * 1024 + 1; + WCHAR *large = calloc(length + 1, sizeof(WCHAR)); + if (!large) ExitProcess(2); + for (DWORD i = 0; i < length; ++i) large[i] = L'x'; + SendMessageW(editor, EM_SETLIMITTEXT, length + 3, 0); + SetWindowTextW(editor, large); + free(large); + SendMessageW(editor, EM_SETSEL, length, length); + SendMessageW(editor, WM_CHAR, L'L', 1); + SendMessageW(editor, WM_CHAR, 0xd83d, 1); + SendMessageW(editor, WM_CHAR, 0xde00, 1); + if (GetWindowTextLengthW(editor) != (int)length + 3) { + fprintf(stderr, "large typing: length=%d expected=%lu limit=%ld\n", + GetWindowTextLengthW(editor), (unsigned long)length + 3, + (long)SendMessageW(editor, EM_GETLIMITTEXT, 0, 0)); + ExitProcess(2); + } + } + break; + case 40: + SetWindowTextW(editor, L"full"); + SendMessageW(editor, EM_SETSEL, 4, 4); + SendMessageW(editor, EM_SETLIMITTEXT, 4, 0); + SendMessageW(editor, WM_CHAR, L'%', 1); + if (GetWindowTextLengthW(editor) != 4) ExitProcess(2); + break; default: DestroyWindow(window); break; } diff --git a/tests/menu_integration.py b/tests/menu_integration.py index deeb804..51f35ff 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -251,8 +251,10 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: "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" + typed = [e[2] for e in text_events if e[1] == "text" and "kind=8" in e[5]] + assert typed == ["z", "z", "space", "new line"], f"Notepad typing echo missing or incorrect: {typed!r}" 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") + print("PASS: actual Wine Notepad typing echo, keyboard characters, words, lines, selection, deletion, and menu return") sys.exit(0) if args.fixture == "scintilla": textEvents = [e for e in events if e[0].endswith(":text")] @@ -302,13 +304,16 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: for line in ("readonly first", "readonly second"): assert any(e[1] == "text" and e[2] == line for e in text_events), f"read-only arrow feedback missing: {line}" assert "r deleted" not in spoken, "read-only document reported an unsuccessful deletion" + typed = [e[2] for e in text_events if e[1] == "text" and "kind=8" in e[5]] + assert typed == ["z", "z", "space", "\u00e9", "\U0001f600", "new line", "tab", "q", "L", "\U0001f600"], f"typing echo missing or incorrect: {typed!r}" + assert not any(e[1] == "text" and e[2] in ("!", "@", "~", "%") for e in events), "rejected, password or background typing leaked" if args.fixture == "winforms": assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta" for e in text_events), "managed email label/focus missing" assert any(e[2] == "Password" and e[3] == "edit" and e[6] == "password" and e[4] == "" for e in text_events), "managed password label/protection missing" assert not any(e[2] == "Unrelated" for e in text_events), "unrelated label associated with field" - print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls") + print("PASS: editor focus, typing echo, 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]] for event in menus: diff --git a/tests/notepad_fixture.c b/tests/notepad_fixture.c index d74ce0f..205a41e 100644 --- a/tests/notepad_fixture.c +++ b/tests/notepad_fixture.c @@ -1,6 +1,7 @@ // SPDX-License-Identifier: LGPL-2.1-or-later // Drive Wine's actual Notepad; the standard-control fixture covers edge cases. #include +#include static DWORD notepad_pid; static HWND notepad; @@ -61,6 +62,18 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) if (notepad) { HWND edit = FindWindowExW(notepad, NULL, L"Edit", NULL); SetForegroundWindow(notepad); + Sleep(400); + key('Z', FALSE, FALSE); + key('Z', FALSE, FALSE); + key(VK_SPACE, FALSE, FALSE); + key(VK_RETURN, FALSE, FALSE); + WCHAR typed[16] = {0}; + SendMessageW(edit, WM_GETTEXT, 16, (LPARAM)typed); + if (lstrcmpW(typed, L"zz \r\n")) { + fprintf(stderr, "Notepad typing did not reach its edit: units=%d first=%04x foreground=%p expected=%p\n", + lstrlenW(typed), (unsigned)typed[0], (void *)GetForegroundWindow(), (void *)notepad); + ExitProcess(2); + } SendMessageW(edit, WM_SETTEXT, 0, (LPARAM)L"hello beta\r\nNotepad second line"); SendMessageW(edit, EM_SETSEL, 0, 0); Sleep(400); @@ -79,12 +92,13 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) WCHAR text[80] = {0}; SendMessageW(edit, WM_GETTEXT, 80, (LPARAM)text); if (!lstrcmpW(text, L"hello beta\r\notepad second line")) result = 0; + else fprintf(stderr, "Notepad final document mismatch: units=%d\n", lstrlenW(text)); PostMessageW(notepad, WM_CLOSE, 0, 0); for (int i = 0; i < 100 && !saveDialog; ++i) { EnumWindows(find_save_dialog, 0); Sleep(50); } - if (!saveDialog) result = 2; + if (!saveDialog) { fprintf(stderr, "Notepad save dialog missing\n"); result = 2; } else { SetForegroundWindow(saveDialog); Sleep(400);