diff --git a/README.md b/README.md index abac7ce..8687462 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,10 @@ 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. +Command-history recalls in MUSHclient report the recalled command, or `blank` +when returning to empty input. The input hook observes Up/Down before the +application consumes them, so recalls also work when the caret position stays +the same. Unrelated programmatic text changes remain silent. Undo refreshes the text snapshot used by subsequent navigation. It can attach to an already-focused editor and resume after the helper restarts. diff --git a/edit_hook.c b/edit_hook.c index e8aa24f..6a935ae 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -15,6 +15,7 @@ typedef struct EditState { WCHAR *text; DWORD length; BOOL busy, known, destroyed; + BOOL history_key, history_replaced; WCHAR pending_high; DWORD pending_caret; } EditState; @@ -102,6 +103,10 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l BOOL requested_focus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE); BOOL focus = message == WM_SETFOCUS || requested_focus; BOOL selected = message == EM_SETSEL; + // A dispatched arrow belongs to ordinary caret navigation. Applications + // such as MUSHclient consume history arrows before DispatchMessage instead. + if (navigation || focus) state->history_key = state->history_replaced = FALSE; + BOOL history_replacement = message == WM_SETTEXT && state->history_key; if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR) return DefSubclassProc(window, message, wp, lp); @@ -152,7 +157,17 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l if (focus) { current_line(window, start, text); kind = WINE_ACCESS_TEXT_FOCUS; report = TRUE; - } else if ((navigation || selected) && (start != old_start || end != old_end)) { + } else if (history_replacement && result) { + // Pretranslated Up/Down recalls arrive as SetWindowText followed by + // SetSel, without any WM_KEYDOWN reaching this procedure. Read the whole + // recalled command once, including blank and equal-length replacements. + if (state->text) copy_range(text, state->text, 0, state->length); + else current_line(window, start, text); + state->history_key = FALSE; + state->history_replaced = TRUE; + report = TRUE; + } else if ((navigation || (selected && !state->history_replaced)) && + (start != old_start || end != old_end)) { if (start != end && state->text && end <= state->length) { // Speak the changed portion when extending or shrinking a selection. DWORD first = start, last = end; @@ -262,6 +277,19 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, HWND focus = GetFocus(); if (wine_access_watch_edit(focus)) SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); + EditState *state = (EditState *)GetPropW(focus, EDIT_STATE_PROPERTY); + WCHAR class_name[64]; + // Scintilla uses the same attachment property with a different state. + if (state && GetClassNameW(focus, class_name, 64) && + wine_access_class_matches(class_name, L"Edit") && !state->busy) { + const MSG *message = (const MSG *)lp; + // Restrict replacement feedback to this retrieved input message. + // Timers, unrelated programmatic changes and background edits stay silent. + state->history_key = message->hwnd == focus && message->message == WM_KEYDOWN && + (message->wParam == VK_UP || message->wParam == VK_DOWN) && + !(GetKeyState(VK_CONTROL) & 0x8000); + state->history_replaced = FALSE; + } #ifdef _WIN64 wine_access_scintilla_input(focus, (const MSG *)lp); #endif diff --git a/tests/README.md b/tests/README.md index 1def745..116ed84 100644 --- a/tests/README.md +++ b/tests/README.md @@ -304,9 +304,13 @@ Ctrl+End line reading, and Up/Down navigation in a read-only multiline output 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 +input rejected at the text limit. Command-history checks consume queued Up/Down +before dispatch, then replace the text and move the caret as MUSHclient does. +They require ordered, single announcements for equal-length and rapid recalls +and empty input, with no password, background or unrelated programmatic output. +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` @@ -315,13 +319,16 @@ It checks resulting document text as well as recorded accessibility output. Thes tests use a recording D-Bus service; they do not run the full reader or prove 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 -Ctrl+Left/Right feedback without submitting a command. Use Ctrl+Tab to reach -the read-only output notepad supplied by the Aardwolf review-buffer plugin; -check Up/Down line reading and Left/Right characters there, then return to -the command field. Check plugin menus and dialogs still work, and repeat after -restarting the reader. These hooks provide caret feedback; independent review +For live MUSHclient acceptance, install the helper, restart Cthulhu and reopen +MUSHclient so the updated 32-bit DLL can load. In the command field, check Left/Right and +Ctrl+Left/Right feedback without submitting a command. Use Up/Down to recall +existing command history. Check each recalled command is spoken once, +including equal-length entries and `blank` when returning to empty input. +Do not submit recalled commands just to test speech. +Use Ctrl+Tab to reach the read-only output notepad supplied by the Aardwolf +review-buffer plugin; check Up/Down line reading and Left/Right characters +there, then return to the command field. Check plugin menus and dialogs still +work, and repeat after restarting the reader. These hooks provide caret feedback; independent review commands use the optional flat-review protocol described above. Native braille routing into Wine text remains unsupported. diff --git a/tests/edit_fixture.c b/tests/edit_fixture.c index 50e4a8a..95efa9d 100644 --- a/tests/edit_fixture.c +++ b/tests/edit_fixture.c @@ -18,8 +18,24 @@ static void register_control(HINSTANCE instance, const WCHAR *nativeName, const static const WCHAR *editClass = L"Edit"; #endif -static HWND editor, password; +static HWND editor, password, history; static int step; +static int history_position; + +// Model MUSHclient's PreTranslateMessage: consume queued arrows before the +// Edit procedure, then use SetWindowText and SetSel to recall a command. +static BOOL history_input(const MSG *message) { + if (message->hwnd != history || message->message != WM_KEYDOWN || + (message->wParam != VK_UP && message->wParam != VK_DOWN)) return FALSE; + if (message->wParam == VK_UP && history_position < 2) ++history_position; + else if (message->wParam == VK_DOWN && history_position > 0) --history_position; + else return TRUE; + const WCHAR *commands[] = {L"", L"history north", L"history south"}; + SetWindowTextW(history, commands[history_position]); + int length = GetWindowTextLengthW(history); + SendMessageW(history, EM_SETSEL, length, length); + return TRUE; +} static void key(UINT key, BOOL control, BOOL shift) { BYTE saved[256], state[256]; @@ -194,6 +210,43 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM SendMessageW(editor, WM_CHAR, L'%', 1); if (GetWindowTextLengthW(editor) != 4) ExitProcess(2); break; + case 41: + // Keep this field away from the managed-label regression's static labels. + history = CreateWindowW(editClass, L"", WS_CHILD | WS_VISIBLE | ES_AUTOHSCROLL, + 100, 150, 300, 25, window, NULL, GetModuleHandleW(NULL), NULL); + SetFocus(history); + break; + case 42: PostMessageW(history, WM_KEYDOWN, VK_UP, 1); break; + case 43: PostMessageW(history, WM_KEYDOWN, VK_UP, 1); break; + case 44: PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); break; + case 45: PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); break; + case 46: + // Rapid recalls must stay ordered, including equal-length commands. + PostMessageW(history, WM_KEYDOWN, VK_UP, 1); + PostMessageW(history, WM_KEYDOWN, VK_UP, 1); + PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); + break; + case 47: + // Ordinary navigation after a recall must use the refreshed snapshot. + PostMessageW(history, WM_KEYDOWN, VK_LEFT, 1); + PostMessageW(history, WM_KEYDOWN, VK_RIGHT, 1); + break; + case 48: + SetWindowTextW(history, L"programmatic history sentinel"); + PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); + break; + case 49: + PostMessageW(history, WM_KEYDOWN, VK_DOWN, 1); // no history change + break; + case 50: + SendMessageW(history, EM_SETPASSWORDCHAR, L'*', 0); + PostMessageW(history, WM_KEYDOWN, VK_UP, 1); + break; + case 51: + SetFocus(password); + SendMessageW(history, EM_SETPASSWORDCHAR, 0, 0); + PostMessageW(history, WM_KEYDOWN, VK_UP, 1); // background recall + break; default: DestroyWindow(window); break; } @@ -245,6 +298,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) SetTimer(window, 1, 1500, NULL); MSG message; while (GetMessageW(&message, NULL, 0, 0) > 0) { + if (history_input(&message)) continue; TranslateMessage(&message); DispatchMessageW(&message); } return 0; diff --git a/tests/menu_integration.py b/tests/menu_integration.py index 5fc9a47..a6f757f 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -312,6 +312,13 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: 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" + history_focus = next(e[0] for e in text_events + if e[1] == "focus" and e[4] == "blank") + history_actions = [e[2] for e in text_events + if e[0] == history_focus and e[1] == "text"] + assert history_actions == ["history north", "history south", "history north", "blank", + "history north", "history south", "history north", "h", "blank", "blank"], \ + f"command history missing, reordered, duplicated or leaked: {history_actions!r}" 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"