Files
deedra b808c6686a Read command-history recalls in Wine edit fields
MUSHclient consumes queued Up/Down keys in PreTranslateMessage before the
Edit control receives WM_KEYDOWN. It recalls commands with SetWindowText
followed by SetSel at the end of the replacement. The editor subclass only
refreshed its snapshot for WM_SETTEXT, so the recalled command was silent
or followed by a stray character or blank from the caret update. Recalling
equal-length commands also leaves the final caret position unchanged.

Observe Up/Down for the focused standard Edit control in WH_GETMESSAGE,
excluding Ctrl-modified navigation. Associate the next successful
WM_SETTEXT with that retrieved key and announce the bounded recalled text
as an existing ordered text-read action. Report empty input as blank.
Consume the pending recall after the replacement and suppress the ensuing
EM_SETSEL feedback so each recalled command is announced once.

Reset recall tracking on subsequent retrieved messages, actual focus and
ordinary dispatched navigation. Preserve snapshot refreshes and normal
Left/Right reading after recalls. Keep password and foreground/focus
exclusion in place, leave unrelated programmatic text updates silent, and
check the control class before accessing the shared attachment property
so Scintilla's different state structure is untouched. Both native hook
architectures receive the fix through the existing build.

Extend the Edit fixture with application-side pretranslation of queued
history arrows. Assert the exact ordered feedback for Up/Down, equal-length
entries, rapid recalls, empty input and horizontal navigation afterward.
Require silence for unchanged history boundaries, unrelated programmatic
updates, protected fields and independently unprotected background fields.
Document history behavior and live MUSHclient acceptance steps.

Validation used a private Xorg dummy display, disposable Wine prefixes and
recording D-Bus services, with GUI checks run sequentially:

- cmake --build build --parallel
- ctest --test-dir build --output-on-failure: all four checks passed
- python3 tests/menu_integration.py --build-dir build --fixture edit
- The Edit check with --32-bit, --32-bit --late-helper, and
  --32-bit --restart-helper
- 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 menu
  --32-bit
- git diff --check

The new 32-bit history regression failed against the original hook,
recording only blank actions, and passed with the fix. Final 32-bit,
64-bit and Windows Forms editor checks passed, including existing typing,
selection, deletion, Unicode, password and read-only assertions.

Built and installed the helper into the existing user-local installation,
backed up the previous five files and verified the installed artifacts.
The user subsequently confirmed that the installed fix works in MUSHclient.
Physical braille acceptance was not separately established.
2026-10-14 15:46:26 -07:00

297 lines
13 KiB
C

// SPDX-License-Identifier: LGPL-2.1-or-later
#include "edit_hook.h"
#include "menu_bridge.h"
#include "control_class.h"
#include <commctrl.h>
#include <stdlib.h>
#define EDIT_SUBCLASS_ID 0x57415431
// Bound memory and work inside applications. Navigation reuses the snapshot;
// only text changes refresh it. Large documents retain line reading.
#define MAX_SNAPSHOT_UNITS (1024 * 1024)
typedef struct EditState {
WCHAR *text;
DWORD length;
BOOL busy, known, destroyed;
BOOL history_key, history_replaced;
WCHAR pending_high;
DWORD pending_caret;
} EditState;
static void selection(HWND window, DWORD *start, DWORD *end) {
SendMessageW(window, EM_GETSEL, (WPARAM)start, (LPARAM)end);
}
static void snapshot(HWND window, EditState *state) {
free(state->text);
state->text = NULL;
state->length = GetWindowTextLengthW(window);
if (state->length <= MAX_SNAPSHOT_UNITS) {
state->text = calloc((size_t)state->length + 1, sizeof(WCHAR));
if (state->text)
state->length = GetWindowTextW(window, state->text, state->length + 1);
}
}
static void copy_range(WCHAR *output, const WCHAR *text, DWORD start, DWORD end) {
UINT used = 0;
while (start < end && used < 1023) output[used++] = text[start++];
// Never leave half a UTF-16 surrogate pair at the truncation boundary.
if (used && output[used - 1] >= 0xd800 && output[used - 1] <= 0xdbff) --used;
output[used] = 0;
}
static void current_line(HWND window, DWORD caret, WCHAR *output) {
LRESULT line = SendMessageW(window, EM_LINEFROMCHAR, caret, 0);
// EM_GETLINE expects the buffer capacity in its first WORD.
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;
}
static BOOL word_space(WCHAR ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
}
void wine_access_notify_text(HWND window, UINT kind, const WCHAR *text,
ULONGLONG start, ULONGLONG end, UINT flags) {
HWND owner = GetAncestor(window, GA_ROOT);
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
if (!bridge || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || GetFocus() != window)
return;
WineAccessTextEvent event = {0};
event.owner = (ULONGLONG)(UINT_PTR)owner;
event.control = (ULONGLONG)(UINT_PTR)window;
event.kind = kind; event.start = start; event.end = end;
event.flags = flags;
lstrcpynW(event.text, text, 1024);
GetWindowTextW(owner, event.title, 256);
COPYDATASTRUCT data = {WINE_ACCESS_TEXT_TAG, sizeof(event), &event};
DWORD_PTR result;
SendMessageTimeoutW(bridge, WM_COPYDATA, (WPARAM)window, (LPARAM)&data,
SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result);
}
static void notify_text(HWND window, UINT kind, const WCHAR *text, DWORD start, DWORD end) {
wine_access_notify_text(window, kind, text, start, end, 0);
}
static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR reference) {
EditState *state = (EditState *)reference;
if (message == WM_NCDESTROY) {
RemoveWindowSubclass(window, edit_proc, id);
RemovePropW(window, EDIT_STATE_PROPERTY);
state->destroyed = TRUE;
if (!state->busy) { free(state->text); free(state); }
return DefSubclassProc(window, message, wp, lp);
}
if (state->busy) return DefSubclassProc(window, message, wp, lp);
BOOL navigation = message == WM_KEYDOWN &&
(wp == VK_LEFT || wp == VK_RIGHT || wp == VK_UP || wp == VK_DOWN ||
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_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;
// 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);
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;
if (!password && (!state->known || focus)) snapshot(window, state);
WCHAR removed[1024] = {0};
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 = requested_focus ? 0 : DefSubclassProc(window, message, wp, lp);
if (state->destroyed) {
free(state->text); free(state);
return result;
}
DWORD start = 0, end = 0;
selection(window, &start, &end);
password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
if (password) {
free(state->text); state->text = NULL; state->known = FALSE;
if (focus) notify_text(window, WINE_ACCESS_TEXT_PASSWORD, L"", start, end);
state->busy = FALSE;
return result;
}
DWORD old_length = state->length;
if (changing || message == EM_SETPASSWORDCHAR) snapshot(window, state);
state->known = TRUE;
WCHAR text[1024] = {0};
UINT kind = WINE_ACCESS_TEXT_READ;
BOOL report = FALSE;
if (focus) {
current_line(window, start, text);
kind = WINE_ACCESS_TEXT_FOCUS; report = TRUE;
} 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;
kind = WINE_ACCESS_TEXT_SELECTED;
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 && 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;
}
copy_range(text, state->text, first, last);
} 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 ||
((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;
if (GetKeyState(VK_CONTROL) & 0x8000) {
while (last < state->length && !word_space(state->text[last])) ++last;
} else if (state->text[start] >= 0xd800 && state->text[start] <= 0xdbff && last < state->length) {
++last;
}
copy_range(text, state->text, start, last);
} else if (!state->text) {
current_line(window, start, text);
}
report = TRUE;
} 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 = 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;
return result;
}
BOOL wine_access_watch_edit(HWND window) {
WCHAR class_name[64];
if (!window || !GetClassNameW(window, class_name, 64)) return FALSE;
#ifdef _WIN64
// Scintilla's separate encoded-position reader remains 64-bit.
if (!lstrcmpiW(class_name, L"Scintilla")) return wine_access_watch_scintilla(window);
#endif
if (!wine_access_class_matches(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 FALSE;
EditState *state = calloc(1, sizeof(*state));
if (!state) return FALSE;
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
free(state); return FALSE;
}
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
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) {
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
}
return CallNextHookEx(NULL, code, wp, lp);
}