Echo typed characters in Wine edit fields

This commit is contained in:
deedra committed 2026-10-12 15:31:00 -07:00
1 parent d9aa40e16c
commit a002c4cc1e
7 files changed
+154 -22

No files matched your search

+15 -8
View File
@@ -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 use visible sibling labels immediately to their left, even when the labels
were created after the fields. Protected fields report their label and were created after the fields. Protected fields report their label and
password role without forwarding their contents. password role without forwarding their contents.
It reports the current line on focus and menu return, characters It reports accepted typed characters, the current line on focus and menu return,
and words on horizontal navigation, lines on vertical/page/document-boundary characters and words on horizontal navigation, lines on vertical/page/document-boundary
navigation, changed selections, and Backspace/Delete/Cut feedback. Password navigation, changed selections, and Backspace/Delete/Cut feedback. Password
contents are excluded, and read-only edits do not report unsuccessful deletions. 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 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 Backspace reports only the removed text, such as `t`. Delete and Cut retain
their deletion feedback, such as `t deleted`. 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 Editor actions use `PresentAccessibleEvent` with event type `text`; actual focus
uses `focus`. Cthulhu-Go must include the corresponding editor-event support so uses `focus`. Cthulhu-Go must include the corresponding editor-event support so
rapid actions and repeated characters bypass focus coalescing. Selection rapid actions and repeated characters bypass focus coalescing. Selection
offsets and the action kind travel in the otherwise non-spoken `states` field offsets and the action kind (8 for typed insertion) travel in the otherwise
for debug diagnostics. Standard edits use UTF-16 offsets; Scintilla marks its 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 encoded byte offsets with `offset_unit=byte`. Older readers that only accept
focus/value/selection events still receive focus and menus, but need updating focus/value/selection events still receive focus and menus, but need updating
for editor actions. 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 These hooks provide editing feedback. Independent review-cursor commands use
the optional flat-review protocol described below; Say All and native braille the optional flat-review protocol described below; Say All and native braille
routing remain unsupported. Typing echo routing remain unsupported. Word echo and typing echo in Scintilla
remains the reader's responsibility. RichEdit and 32-bit Scintilla hooks are not remain outside this helper's current support. RichEdit and 32-bit Scintilla hooks
covered. Reports are limited to 1023 UTF-16 units; documents over one million are not covered. Reports are limited to 1023 UTF-16 units; documents over one million
units use line feedback without full selection/deletion snapshots. units retain typing and line feedback without full selection/deletion snapshots.
Scintilla reads at most 4096 encoded bytes per report, then limits output to Scintilla reads at most 4096 encoded bytes per report, then limits output to
1023 UTF-16 units without splitting surrogate pairs. Wrapped Scintilla lines 1023 UTF-16 units without splitting surrogate pairs. Wrapped Scintilla lines
report the underlying document line. report the underlying document line.
+36
View File
@@ -14,6 +14,8 @@ typedef struct EditState {
WCHAR *text; WCHAR *text;
DWORD length; DWORD length;
BOOL busy, known, destroyed; BOOL busy, known, destroyed;
WCHAR pending_high;
DWORD pending_caret;
} EditState; } EditState;
static void selection(HWND window, DWORD *start, DWORD *end) { 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_PASTE || message == WM_CUT || message == WM_CLEAR ||
message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL || message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL ||
message == WM_SETTEXT || message == EM_SETHANDLE || message == EM_FMTLINES; 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 requested_focus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE);
BOOL focus = message == WM_SETFOCUS || requested_focus; BOOL focus = message == WM_SETFOCUS || requested_focus;
BOOL selected = message == EM_SETSEL; 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); return DefSubclassProc(window, message, wp, lp);
state->busy = TRUE; 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; DWORD old_start = 0, old_end = 0;
selection(window, &old_start, &old_end); selection(window, &old_start, &old_end);
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0; 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 kind = message == WM_CHAR && wp == VK_BACK
? WINE_ACCESS_TEXT_BACKSPACED : WINE_ACCESS_TEXT_DELETED; ? WINE_ACCESS_TEXT_BACKSPACED : WINE_ACCESS_TEXT_DELETED;
report = TRUE; 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); if (report) notify_text(window, kind, text, start, end);
state->busy = FALSE; state->busy = FALSE;
+1
View File
@@ -17,6 +17,7 @@
#define WINE_ACCESS_TEXT_READ 5 #define WINE_ACCESS_TEXT_READ 5
#define WINE_ACCESS_TEXT_PASSWORD 6 #define WINE_ACCESS_TEXT_PASSWORD 6
#define WINE_ACCESS_TEXT_BACKSPACED 7 #define WINE_ACCESS_TEXT_BACKSPACED 7
#define WINE_ACCESS_TEXT_INSERTED 8
#define WINE_ACCESS_TEXT_SCINTILLA 1 #define WINE_ACCESS_TEXT_SCINTILLA 1
#define WINE_ACCESS_TEXT_READONLY 2 #define WINE_ACCESS_TEXT_READONLY 2
+17 -11
View File
@@ -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 python3 tests/menu_integration.py --build-dir build --fixture notepad
``` ```
The standard Edit fixture checks focus, characters/words/lines, incremental The standard Edit fixture checks focus, typing echo, characters/words/lines,
selection and unselection, Backspace/Delete/Clear, read-only controls, password incremental selection and unselection, Backspace/Delete/Clear, read-only controls, password
exclusion, surrogate pairs, rapid repeated letters, menu return at the caret, exclusion, surrogate pairs, rapid repeated letters, menu return at the caret,
short replacements, CRLF deletion, long-line truncation, Undo snapshots, and short replacements, CRLF deletion, long-line truncation, Undo snapshots, and
Ctrl+End line reading, and Up/Down navigation in a read-only multiline output 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 control. Typing checks include repeated letters, spaces, Enter/Tab, accented
variants attach after the control has focus or 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 restart the helper without closing it. Every text action must have an established
focus source, matching the Go reader's filtering contract. focus source, matching the Go reader's filtering contract.
The Notepad fixture launches Wine's actual `notepad.exe` and uses `SendInput` 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 for typing, arrows, Ctrl+Right, Shift selection, Delete, and Alt/Escape menu return.
checks resulting document text as well as recorded accessibility output. These 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 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 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 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. prefix. Tab and Shift+Tab between Email, Password, Sign in and Work Offline.
Expect labels and edit/password roles; navigate and select ordinary email Expect labels and edit/password roles; navigate and select ordinary email
text, and confirm password contents stay excluded. Recheck native Notepad, text, and confirm password contents stay excluded. Recheck native Notepad,
menus, the installer list and switching to Linux applications. Typing echo menus, the installer list and switching to Linux applications. Standard Edit
and routable Wine review remain separate unfinished features. typing echo is now forwarded through editor events; routable Wine review
remains unfinished.
## Live Notepad acceptance ## 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 rebuilds Cthulhu and its braille helper and opens the test terminal; it does
not build wine-access. not build wine-access.
3. Run `wine notepad` in the intended Wine prefix. Expect edit/blank focus 3. Run `wine notepad` in the intended Wine prefix. Expect edit/blank focus
feedback. Type `hello beta`, Enter, then `second line`. Typing echo should feedback. Type `hello beta`, Enter, then `second line`. Expect each typed
follow the reader's configured settings. 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, 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 then `e`, `l`, `l`. Both repeated letters should produce feedback. Ctrl+Right
should read `beta`; Down should read `second line`. should read `beta`; Down should read `second line`.
+63
View File
@@ -1,5 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later // SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h> #include <windows.h>
#include <stdlib.h>
#include <stdio.h>
#ifdef WINE_ACCESS_WINFORMS_FIXTURE #ifdef WINE_ACCESS_WINFORMS_FIXTURE
static const WCHAR *editClass = L"WindowsForms10.EDIT.app.0.fixture_r7_ad1"; 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); key(VK_DELETE, FALSE, FALSE);
if (GetWindowTextLengthW(editor) != 31) ExitProcess(2); if (GetWindowTextLengthW(editor) != 31) ExitProcess(2);
break; 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: default:
DestroyWindow(window); break; DestroyWindow(window); break;
} }
+7 -2
View File
@@ -251,8 +251,10 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
"N unselected", "N deleted"): "N unselected", "N deleted"):
assert expected in spoken, f"missing Notepad feedback: {expected!r}; {text_events!r}" assert expected in spoken, f"missing Notepad feedback: {expected!r}; {text_events!r}"
assert spoken.count("l") == 2, "Notepad lost repeated characters" 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" 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) sys.exit(0)
if args.fixture == "scintilla": if args.fixture == "scintilla":
textEvents = [e for e in events if e[0].endswith(":text")] 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"): 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 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" 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": if args.fixture == "winforms":
assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta" assert any(e[2] == "Email" and e[3] == "edit" and e[4] == "alpha beta"
for e in text_events), "managed email label/focus missing" for e in text_events), "managed email label/focus missing"
assert any(e[2] == "Password" and e[3] == "edit" and e[6] == "password" 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" 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" 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) 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]]
for event in menus: for event in menus:
+15 -1
View File
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: LGPL-2.1-or-later // SPDX-License-Identifier: LGPL-2.1-or-later
// Drive Wine's actual Notepad; the standard-control fixture covers edge cases. // Drive Wine's actual Notepad; the standard-control fixture covers edge cases.
#include <windows.h> #include <windows.h>
#include <stdio.h>
static DWORD notepad_pid; static DWORD notepad_pid;
static HWND notepad; static HWND notepad;
@@ -61,6 +62,18 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
if (notepad) { if (notepad) {
HWND edit = FindWindowExW(notepad, NULL, L"Edit", NULL); HWND edit = FindWindowExW(notepad, NULL, L"Edit", NULL);
SetForegroundWindow(notepad); 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, WM_SETTEXT, 0, (LPARAM)L"hello beta\r\nNotepad second line");
SendMessageW(edit, EM_SETSEL, 0, 0); SendMessageW(edit, EM_SETSEL, 0, 0);
Sleep(400); Sleep(400);
@@ -79,12 +92,13 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
WCHAR text[80] = {0}; WCHAR text[80] = {0};
SendMessageW(edit, WM_GETTEXT, 80, (LPARAM)text); SendMessageW(edit, WM_GETTEXT, 80, (LPARAM)text);
if (!lstrcmpW(text, L"hello beta\r\notepad second line")) result = 0; 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); PostMessageW(notepad, WM_CLOSE, 0, 0);
for (int i = 0; i < 100 && !saveDialog; ++i) { for (int i = 0; i < 100 && !saveDialog; ++i) {
EnumWindows(find_save_dialog, 0); EnumWindows(find_save_dialog, 0);
Sleep(50); Sleep(50);
} }
if (!saveDialog) result = 2; if (!saveDialog) { fprintf(stderr, "Notepad save dialog missing\n"); result = 2; }
else { else {
SetForegroundWindow(saveDialog); SetForegroundWindow(saveDialog);
Sleep(400); Sleep(400);