// SPDX-License-Identifier: LGPL-2.1-or-later #include "edit_hook.h" #include "menu_bridge.h" #include "control_class.h" #include #include #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); }