From df7879acb52e20a215c06c356baa95e73d2c4974 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 10 Oct 2026 22:39:24 -0400 Subject: [PATCH] Notepad++ should work now. --- CMakeLists.txt | 5 +- README.md | 20 +- .../ArchLinux/wine-access-git/PKGBUILD | 2 +- edit_hook.c | 14 +- edit_hook.h | 4 + main.cpp | 10 +- menu_bridge.h | 7 +- scintilla_hook.c | 232 ++++++++++++++++++ scintilla_messages.h | 30 +++ tests/README.md | 27 ++ tests/menu_integration.py | 50 +++- tests/scintilla_fixture.c | 140 +++++++++++ 12 files changed, 523 insertions(+), 18 deletions(-) create mode 100644 scintilla_hook.c create mode 100644 scintilla_messages.h create mode 100644 tests/scintilla_fixture.c diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b733e4..8f3324e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -15,8 +15,9 @@ set(menu_hook "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu.dll") add_custom_command(OUTPUT "${menu_hook}" COMMAND "${MINGW_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" - "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 - DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h control_class.h + "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" + "${CMAKE_CURRENT_SOURCE_DIR}/scintilla_hook.c" -luser32 -lcomctl32 -lgdi32 + DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h control_class.h scintilla_hook.c scintilla_messages.h VERBATIM) add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") diff --git a/README.md b/README.md index 9e39adc..fe8a900 100644 --- a/README.md +++ b/README.md @@ -118,15 +118,26 @@ 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 an already-focused editor and resume after the helper restarts. +It also subclasses 64-bit `Scintilla` controls, including Notepad++'s document +area. Up/Down and page navigation report the current document line; +Left/Right report characters, and Ctrl+Left/Right report words. Focus and menu +return read the caret's line. Ordinary selections, Backspace/Delete/Ctrl+X, +Unicode characters, undo and read-only documents are supported. Text is read +inside the application through Scintilla's native API, using its encoding and +byte positions; no full document snapshot is required. Rectangular/multiple +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`. 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. UTF-16 selection +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. Older readers that only accept focus/value/selection -events still receive focus and menus, but need updating for editor actions. +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. MSAA `name` and `state` events report changes to controls. The `states` field contains stable comma-separated state labels decoded from the MSAA bitmask; @@ -161,6 +172,9 @@ commands and braille routing into Wine text are not implemented. Typing echo remains the reader's responsibility. RichEdit and 32-bit editor 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. +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. Keep all three installed files together. The native hook currently targets 64-bit applications; 32-bit menu coverage and custom menus that do not send diff --git a/distro-packages/ArchLinux/wine-access-git/PKGBUILD b/distro-packages/ArchLinux/wine-access-git/PKGBUILD index 0d37b05..d36f82c 100644 --- a/distro-packages/ArchLinux/wine-access-git/PKGBUILD +++ b/distro-packages/ArchLinux/wine-access-git/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: Storm Dragon pkgname=wine-access-git -pkgver=r9.g39b7488 +pkgver=r17.gcbce835 pkgrel=1 pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' arch=('x86_64') diff --git a/edit_hook.c b/edit_hook.c index e9b2fdf..3386b24 100644 --- a/edit_hook.c +++ b/edit_hook.c @@ -53,7 +53,8 @@ static BOOL word_space(WCHAR ch) { return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; } -static void notify_text(HWND window, UINT kind, const WCHAR *text, DWORD start, DWORD end) { +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) @@ -62,6 +63,7 @@ static void notify_text(HWND window, UINT kind, const WCHAR *text, DWORD start, 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}; @@ -70,6 +72,10 @@ static void notify_text(HWND window, UINT kind, const WCHAR *text, DWORD start, 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; @@ -187,8 +193,9 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l BOOL wine_access_watch_edit(HWND window) { WCHAR class_name[64]; - if (!window || !GetClassNameW(window, class_name, 64) || - !wine_access_class_matches(class_name, L"Edit")) return FALSE; + if (!window || !GetClassNameW(window, class_name, 64)) return FALSE; + if (!lstrcmpiW(class_name, L"Scintilla")) return wine_access_watch_scintilla(window); + 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; @@ -214,6 +221,7 @@ __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); + wine_access_scintilla_input(focus, (const MSG *)lp); } return CallNextHookEx(NULL, code, wp, lp); } diff --git a/edit_hook.h b/edit_hook.h index 6435c27..ed72510 100644 --- a/edit_hook.h +++ b/edit_hook.h @@ -4,3 +4,7 @@ #define EDIT_STATE_PROPERTY L"CthulhuWineEditState" #define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus" BOOL wine_access_watch_edit(HWND window); +BOOL wine_access_watch_scintilla(HWND window); +void wine_access_scintilla_input(HWND window, const MSG *message); +void wine_access_notify_text(HWND window, UINT kind, const WCHAR *text, + ULONGLONG start, ULONGLONG end, UINT flags); diff --git a/main.cpp b/main.cpp index f9dd6af..bc9863e 100644 --- a/main.cpp +++ b/main.cpp @@ -512,7 +512,10 @@ void present_text_event(const WineAccessTextEvent &event) { if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || !GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window) return; - if ((GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) && + const bool scintilla = event.flags & WINE_ACCESS_TEXT_SCINTILLA; + const bool readOnly = scintilla ? (event.flags & WINE_ACCESS_TEXT_READONLY) != 0 + : (GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY) != 0; + if (!scintilla && (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) && event.kind != WINE_ACCESS_TEXT_PASSWORD) return; std::string text = to_utf8(event.text); @@ -527,7 +530,7 @@ void present_text_event(const WineAccessTextEvent &event) { wchar_t label[256] = {}; find_control_label(window, label, std::size(label)); control.name = to_utf8(label); - control.role = GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY ? "read only edit" : "edit"; + control.role = readOnly ? "read only edit" : "edit"; control.value = text.empty() ? "blank" : text; } else { if (text == " ") text = "space"; @@ -552,12 +555,13 @@ void present_text_event(const WineAccessTextEvent &event) { // Re-establish focus before its first action without re-reading the document. if (!focus && lastTextControl != window) { ControlDescription focused; - focused.role = GetWindowLongPtrW(window, GWL_STYLE) & ES_READONLY ? "read only edit" : "edit"; + focused.role = readOnly ? "read only edit" : "edit"; if (!present_accessible_event(source, "focus", focused, title)) return; } if (event.kind != WINE_ACCESS_TEXT_PASSWORD) control.states = "start=" + std::to_string(event.start) + " end=" + std::to_string(event.end) + " kind=" + std::to_string(event.kind); + if (scintilla) control.states += " offset_unit=byte"; // Text commands are ordered actions, not transient focus notifications. if (present_accessible_event(source, focus ? "focus" : "text", control, title)) lastTextControl = window; diff --git a/menu_bridge.h b/menu_bridge.h index bf903d1..dcbcbd2 100644 --- a/menu_bridge.h +++ b/menu_bridge.h @@ -17,13 +17,16 @@ #define WINE_ACCESS_TEXT_READ 5 #define WINE_ACCESS_TEXT_PASSWORD 6 #define WINE_ACCESS_TEXT_BACKSPACED 7 +#define WINE_ACCESS_TEXT_SCINTILLA 1 +#define WINE_ACCESS_TEXT_READONLY 2 typedef struct WineAccessTextEvent { ULONGLONG owner; ULONGLONG control; UINT kind; - DWORD start; - DWORD end; + UINT flags; + ULONGLONG start; + ULONGLONG end; WCHAR text[1024]; WCHAR title[256]; } WineAccessTextEvent; diff --git a/scintilla_hook.c b/scintilla_hook.c new file mode 100644 index 0000000..cdc71cd --- /dev/null +++ b/scintilla_hook.c @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#include "edit_hook.h" +#include "menu_bridge.h" +#include "scintilla_messages.h" +#include +#include + +#define SCINTILLA_SUBCLASS_ID 0x57415331 +#define MAX_RANGE_BYTES 4096 +#define SCINTILLA_CUT_MESSAGE L"CthulhuWineScintillaCut" +#define SCINTILLA_STATE_PROPERTY L"CthulhuWineScintillaState" + +typedef struct ScintillaState { + BOOL busy, destroyed; + BOOL pendingCut; + ptrdiff_t cutLength; + WCHAR cutText[1024]; +} ScintillaState; + +static LRESULT query(HWND window, UINT message, WPARAM parameter) { + return SendMessageW(window, message, parameter, 0); +} + +static void selection(HWND window, ptrdiff_t *start, ptrdiff_t *end) { + ptrdiff_t caret = query(window, SCI_GETCURRENTPOS, 0); + ptrdiff_t anchor = query(window, SCI_GETANCHOR, 0); + *start = caret < anchor ? caret : anchor; + *end = caret > anchor ? caret : anchor; +} + +static BOOL simple_selection(HWND window) { + return query(window, SCI_GETSELECTIONS, 0) == 1 && + !query(window, SCI_SELECTIONISRECTANGLE, 0); +} + +static void read_range(HWND window, ptrdiff_t start, ptrdiff_t end, WCHAR *output) { + output[0] = 0; + if (start < 0 || end <= start) return; + if (end - start > MAX_RANGE_BYTES) { + // Stop on a character boundary, including UTF-8 and DBCS characters. + end = query(window, SCI_POSITIONBEFORE, start + MAX_RANGE_BYTES + 1); + } + if (end <= start || end - start > MAX_RANGE_BYTES) return; + char bytes[MAX_RANGE_BYTES + 1] = {0}; + ScintillaTextRange range = {{start, end}, bytes}; + LRESULT copied = SendMessageW(window, SCI_GETTEXTRANGEFULL, 0, (LPARAM)&range); + if (copied <= 0 || copied > MAX_RANGE_BYTES) return; + UINT codePage = query(window, SCI_GETCODEPAGE, 0); + if (!codePage) { + // Single-byte documents use the default style's Windows character set. + DWORD charset = query(window, SCI_STYLEGETCHARACTERSET, 32); + if (charset == DEFAULT_CHARSET) codePage = GetACP(); + else { + CHARSETINFO info; + if (!TranslateCharsetInfo((DWORD *)(UINT_PTR)charset, &info, TCI_SRCCHARSET)) return; + codePage = info.ciACP; + } + } + WCHAR converted[MAX_RANGE_BYTES + 1]; + int units = MultiByteToWideChar(codePage, 0, bytes, (int)copied, + converted, MAX_RANGE_BYTES); + if (units > 1023) units = 1023; + if (units && converted[units - 1] >= 0xd800 && converted[units - 1] <= 0xdbff) --units; + if (units > 0) CopyMemory(output, converted, units * sizeof(WCHAR)); + output[units] = 0; +} + +static void current_line(HWND window, WCHAR *output) { + ptrdiff_t caret = query(window, SCI_GETCURRENTPOS, 0); + LRESULT line = query(window, SCI_LINEFROMPOSITION, caret); + read_range(window, query(window, SCI_POSITIONFROMLINE, line), + query(window, SCI_GETLINEENDPOSITION, line), output); +} + +static LRESULT CALLBACK scintilla_proc(HWND window, UINT message, WPARAM wp, LPARAM lp, + UINT_PTR id, DWORD_PTR reference) { + ScintillaState *state = (ScintillaState *)reference; + if (message == WM_NCDESTROY) { + RemoveWindowSubclass(window, scintilla_proc, id); + RemovePropW(window, EDIT_STATE_PROPERTY); + RemovePropW(window, SCINTILLA_STATE_PROPERTY); + state->destroyed = TRUE; + if (!state->busy) free(state); + return DefSubclassProc(window, message, wp, lp); + } + if (state->busy) return DefSubclassProc(window, message, wp, lp); + if (message == RegisterWindowMessageW(SCINTILLA_CUT_MESSAGE)) { + if (state->pendingCut) { + state->pendingCut = FALSE; + if (query(window, SCI_GETLENGTH, 0) < state->cutLength) { + ptrdiff_t start, end; + selection(window, &start, &end); + wine_access_notify_text(window, WINE_ACCESS_TEXT_DELETED, state->cutText, + start, end, WINE_ACCESS_TEXT_SCINTILLA); + } + } + return 0; + } + 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 requestedFocus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE); + BOOL focus = message == WM_SETFOCUS || requestedFocus; + BOOL selected = message == SCI_SETSEL; + BOOL control = (GetKeyState(VK_CONTROL) & 0x8000) != 0; + BOOL backspace = message == WM_KEYDOWN && wp == VK_BACK; + BOOL deleting = backspace || (message == WM_KEYDOWN && wp == VK_DELETE) || + (message == WM_KEYDOWN && control && wp == 'X') || + message == WM_CUT || message == WM_CLEAR || message == SCI_CUT || message == SCI_CLEAR; + if (!navigation && !focus && !selected && !deleting) + return DefSubclassProc(window, message, wp, lp); + + state->busy = TRUE; + ptrdiff_t oldStart, oldEnd; + selection(window, &oldStart, &oldEnd); + ptrdiff_t oldLength = query(window, SCI_GETLENGTH, 0); + WCHAR removed[1024] = {0}; + if (deleting && !query(window, SCI_GETREADONLY, 0) && simple_selection(window)) { + ptrdiff_t first = oldStart, last = oldEnd; + // Ctrl+Backspace/Delete can remove a word; this hook only predicts plain + // character deletion. Selected ranges and cut are always explicit. + if (first == last && !control) { + if (backspace && first) first = query(window, SCI_POSITIONBEFORE, first); + else if (message == WM_KEYDOWN && wp == VK_DELETE && last < oldLength) + last = query(window, SCI_POSITIONAFTER, last); + // Treat CRLF as one line break for deletion feedback. + if (backspace && first && query(window, SCI_GETCHARAT, first) == '\n' && + query(window, SCI_GETCHARAT, first - 1) == '\r') --first; + if (!backspace && last < oldLength && query(window, SCI_GETCHARAT, last - 1) == '\r' && + query(window, SCI_GETCHARAT, last) == '\n') ++last; + } + read_range(window, first, last, removed); + } + // Selection feedback reads the old text before the application can change it. + WCHAR unselected[1024] = {0}; + if ((navigation || selected) && oldStart != oldEnd && simple_selection(window)) + read_range(window, oldStart, oldEnd, unselected); + LRESULT result = requestedFocus ? 0 : DefSubclassProc(window, message, wp, lp); + if (state->destroyed) { free(state); return result; } + + ptrdiff_t start, end; + selection(window, &start, &end); + WCHAR text[1024] = {0}; + UINT kind = WINE_ACCESS_TEXT_READ; + BOOL report = FALSE; + if (focus) { + current_line(window, text); + kind = WINE_ACCESS_TEXT_FOCUS; + report = TRUE; + } else if ((navigation || selected) && (start != oldStart || end != oldEnd)) { + if (start != end && simple_selection(window)) { + ptrdiff_t first = start, last = end; + kind = WINE_ACCESS_TEXT_SELECTED; + if (oldStart != oldEnd && start == oldStart) { + first = end < oldEnd ? end : oldEnd; + last = end > oldEnd ? end : oldEnd; + if (end < oldEnd) kind = WINE_ACCESS_TEXT_UNSELECTED; + } else if (oldStart != oldEnd && end == oldEnd) { + first = start < oldStart ? start : oldStart; + last = start > oldStart ? start : oldStart; + if (start > oldStart) kind = WINE_ACCESS_TEXT_UNSELECTED; + } + read_range(window, first, last, text); + } else if (unselected[0]) { + lstrcpynW(text, unselected, 1024); + 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) && control))) { + current_line(window, text); + } else { + ptrdiff_t caret = query(window, SCI_GETCURRENTPOS, 0); + ptrdiff_t last = query(window, SCI_POSITIONAFTER, caret); + if (control && navigation) { + last = SendMessageW(window, SCI_WORDENDPOSITION, caret, FALSE); + // Windows word navigation includes trailing spaces. + ptrdiff_t limit = query(window, SCI_GETLENGTH, 0); + while (last < limit && last - caret < MAX_RANGE_BYTES && + query(window, SCI_GETCHARAT, last) == ' ') ++last; + } + read_range(window, caret, last, text); + } + report = TRUE; + } else if (deleting && removed[0] && query(window, SCI_GETLENGTH, 0) < oldLength) { + lstrcpynW(text, removed, 1024); + kind = backspace ? WINE_ACCESS_TEXT_BACKSPACED : WINE_ACCESS_TEXT_DELETED; + report = TRUE; + } + if (report) { + UINT flags = WINE_ACCESS_TEXT_SCINTILLA; + if (query(window, SCI_GETREADONLY, 0)) flags |= WINE_ACCESS_TEXT_READONLY; + wine_access_notify_text(window, kind, text, start, end, flags); + if (deleting) state->pendingCut = FALSE; + } + state->busy = FALSE; + return result; +} + +BOOL wine_access_watch_scintilla(HWND window) { + if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE; + ScintillaState *state = calloc(1, sizeof(*state)); + if (!state) return FALSE; + if (!SetWindowSubclass(window, scintilla_proc, SCINTILLA_SUBCLASS_ID, (DWORD_PTR)state)) { + free(state); return FALSE; + } + if (!SetPropW(window, EDIT_STATE_PROPERTY, state) || + !SetPropW(window, SCINTILLA_STATE_PROPERTY, state)) { + RemovePropW(window, EDIT_STATE_PROPERTY); + RemoveWindowSubclass(window, scintilla_proc, SCINTILLA_SUBCLASS_ID); + free(state); return FALSE; + } + HMODULE module; + GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN, + (LPCWSTR)(UINT_PTR)scintilla_proc, &module); + return TRUE; +} + +void wine_access_scintilla_input(HWND window, const MSG *message) { + if (message->message != WM_KEYDOWN || message->wParam != 'X' || + !(GetKeyState(VK_CONTROL) & 0x8000)) return; + ScintillaState *state = (ScintillaState *)GetPropW(window, SCINTILLA_STATE_PROPERTY); + if (!state || state->busy || query(window, SCI_GETREADONLY, 0) || !simple_selection(window)) return; + ptrdiff_t start, end; + selection(window, &start, &end); + if (start == end) return; + // Notepad++ translates Ctrl+X before dispatch and calls Scintilla's direct + // function, bypassing the subclass. Capture only the selected bounded range + // before translation, then check the result on the application's queue. + state->cutLength = query(window, SCI_GETLENGTH, 0); + read_range(window, start, end, state->cutText); + state->pendingCut = state->cutText[0] && + PostMessageW(window, RegisterWindowMessageW(SCINTILLA_CUT_MESSAGE), 0, 0); +} diff --git a/scintilla_messages.h b/scintilla_messages.h new file mode 100644 index 0000000..12aa49b --- /dev/null +++ b/scintilla_messages.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +#pragma once +#include + +// Public Scintilla API: https://www.scintilla.org/ScintillaDoc.html +// Positions are encoded byte offsets, not UTF-16 offsets. +#define SCI_GETLENGTH 2006 +#define SCI_GETCHARAT 2007 +#define SCI_GETCURRENTPOS 2008 +#define SCI_GETANCHOR 2009 +#define SCI_GETTEXTRANGEFULL 2039 +#define SCI_GETLINEENDPOSITION 2136 +#define SCI_GETCODEPAGE 2137 +#define SCI_GETREADONLY 2140 +#define SCI_SETSEL 2160 +#define SCI_LINEFROMPOSITION 2166 +#define SCI_POSITIONFROMLINE 2167 +#define SCI_CUT 2177 +#define SCI_CLEAR 2180 +#define SCI_WORDENDPOSITION 2267 +#define SCI_SELECTIONISRECTANGLE 2372 +#define SCI_POSITIONBEFORE 2417 +#define SCI_POSITIONAFTER 2418 +#define SCI_STYLEGETCHARACTERSET 2490 +#define SCI_GETSELECTIONS 2570 + +typedef struct ScintillaTextRange { + struct { ptrdiff_t start, end; } range; + char *text; +} ScintillaTextRange; diff --git a/tests/README.md b/tests/README.md index 791421c..b8ba0ed 100644 --- a/tests/README.md +++ b/tests/README.md @@ -108,6 +108,33 @@ reader's `docs/testing/wine-accessibility.md` for current evidence and live acceptance gates. Run Xvfb fixtures sequentially; concurrent automatic display allocation can collide. +## Notepad++ / Scintilla checks + +Use a locally available 64-bit Notepad++ executable: + +```sh +xvfb-run -a python3 tests/menu_integration.py --build-dir build --fixture scintilla --notepadpp-exe /path/to/notepad++.exe +``` + +The runner copies the executable and model XML files into a temporary directory, +uses local configuration, disables plugins/session restoration, and opens a +disposable UTF-8 document in its private Wine prefix. The user's running +Notepad++ and documents are untouched. The fixture uses actual `SendInput` +arrows, Ctrl+Right, Shift selection, Delete, Backspace, undo and menu return; +checks Ctrl+X, multibyte accented characters, emoji, a document over one million +bytes and truncation at a surrogate pair; and verifies document length +after a read-only deletion attempt. Add `--late-helper` or `--restart-helper` +to check attachment and reader restart while the editor stays open. + +After installing, restart Cthulhu and close/reopen Notepad++ so its process +loads the new DLL. In a disposable document with several lines, accented +characters and emoji, check Up/Down line reading and Left/Right character +reading in both directions. Check Ctrl+Left/Right words, Shift selection and +unselection, Backspace/Delete, undo, Alt/Escape return and switching document +tabs. Repeat ordinary Wine Notepad editing, menu navigation, and password +fields in other applications for regressions. Check speech and physical +braille separately. These recorder tests do not establish either live output. + ## List-view checks ```sh diff --git a/tests/menu_integration.py b/tests/menu_integration.py index d61b80e..39f47ed 100644 --- a/tests/menu_integration.py +++ b/tests/menu_integration.py @@ -7,6 +7,7 @@ import argparse import os from pathlib import Path import subprocess +import shutil import sys import tempfile import time @@ -16,20 +17,24 @@ from gi.repository import Gio, GLib parser = argparse.ArgumentParser() parser.add_argument("--build-dir", default="build") parser.add_argument("--session", action="store_true") -parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "winforms", "controls"), default="menu") +parser.add_argument("--fixture", choices=("menu", "edit", "notepad", "list", "winforms", "controls", "scintilla"), default="menu") +parser.add_argument("--notepadpp-exe", type=Path) parser.add_argument("--32-bit", dest="is32Bit", action="store_true") parser.add_argument("--late-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true") args = parser.parse_args() if args.is32Bit and args.fixture not in ("list", "controls"): parser.error("32-bit checks currently require --fixture list or controls") -if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms"): - parser.error("helper lifecycle checks require --fixture edit or --fixture winforms") +if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"): + parser.error("helper lifecycle checks require --fixture edit, winforms or scintilla") +if args.fixture == "scintilla" and (not args.notepadpp_exe or not args.notepadpp_exe.is_file()): + parser.error("--fixture scintilla requires --notepadpp-exe pointing to a 64-bit Notepad++ executable") build = Path(args.build_dir).resolve() if not args.session: sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, __file__, "--session", "--build-dir", str(build), "--fixture", args.fixture, + *(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []), *(["--32-bit"] if args.is32Bit else []), *(["--late-helper"] if args.late_helper else []), *(["--restart-helper"] if args.restart_helper else [])])) @@ -59,6 +64,21 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1") ready = Path(temporary) / "ready" env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\") + if args.fixture == "scintilla": + # Copy the application to isolate its configuration as well as its + # document. Never open or edit files in the user's running instance. + appDir = Path(temporary) / "notepadpp" + appDir.mkdir() + for name in (args.notepadpp_exe.name, "langs.model.xml", "stylers.model.xml"): + source = args.notepadpp_exe.parent / name + if source.is_file(): + shutil.copy2(source, appDir / name) + (appDir / "doLocalConf.xml").touch() + document = Path(temporary) / "editor.txt" + document.write_bytes(("hello beta\r\nScintilla second line\r\ncafΓ© πŸ˜€ tail\r\n" + + "x" * 1100000 + "\r\n" + "x" * 1022 + "πŸ˜€tail").encode("utf-8")) + env["WINE_ACCESS_NOTEPADPP_EXE"] = "Z:" + str(appDir / args.notepadpp_exe.name).replace("/", "\\") + env["WINE_ACCESS_SCINTILLA_DOCUMENT"] = "Z:" + str(document).replace("/", "\\") fixture = Path(temporary) / "menu-fixture.exe" compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc" sourceName = "edit" if args.fixture == "winforms" else args.fixture @@ -72,7 +92,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45) if args.late_helper: app = subprocess.Popen(["wine", str(fixture)], env=env) - deadline = time.monotonic() + 5 + deadline = time.monotonic() + 10 while not ready.exists() and app.poll() is None and time.monotonic() < deadline: time.sleep(0.01) assert ready.exists(), "editor did not become ready before helper attachment" @@ -173,6 +193,28 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary: 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") sys.exit(0) + if args.fixture == "scintilla": + textEvents = [e for e in events if e[0].endswith(":text")] + for event in textEvents: + print(event[1:7]) + spoken = [e[2] for e in textEvents if e[1] == "text"] + for expected in ("e", "l", "beta", "hello ", "Scintilla second line", "hello beta", "S selected", + "c selected", "c unselected", "S unselected", "S deleted", "c", + "Scintilla selected", "Scintilla deleted", "cafΓ© πŸ˜€ tail", "Γ©", "πŸ˜€", + "πŸ˜€ selected", "πŸ˜€ unselected", "πŸ˜€ deleted"): + assert expected in spoken, f"missing Scintilla feedback: {expected!r}; {textEvents!r}" + assert spoken.count("l") == 2, "Scintilla lost repeated characters" + assert any(e[1] == "focus" and e[4] == "intilla second line" for e in textEvents), "menu return lost Scintilla caret line" + assert any(e[3] == "read only edit" for e in textEvents), "Scintilla read-only role missing" + assert any(e[2] == "c" and "kind=7" in e[5] for e in textEvents), "Scintilla Backspace suffix wrong" + assert any(e[2] == "πŸ˜€" and "kind=7" in e[5] for e in textEvents), "multibyte Backspace missing" + assert any(e[2] == "x" * 1022 for e in textEvents), "Scintilla truncation split a surrogate pair" + assert any(e[2] == "x" * 1023 for e in textEvents), "large Scintilla document line missing" + focusEvents = [e for e in textEvents if e[1] == "focus"] + assert [e[4] for e in focusEvents[-2:]] == ["hello beta", "hello beta"], "single-byte Scintilla charset reading failed" + assert all("offset_unit=byte" in e[5] for e in textEvents if e[1] == "text"), "Scintilla offsets mislabeled" + print("PASS: actual Notepad++ arrows, words, Unicode, selection, deletion, undo, menu return and read-only document") + sys.exit(0) if args.fixture in ("edit", "winforms"): text_events = [e for e in events if e[0].endswith(":text")] for event in text_events: diff --git a/tests/scintilla_fixture.c b/tests/scintilla_fixture.c new file mode 100644 index 0000000..5b7897e --- /dev/null +++ b/tests/scintilla_fixture.c @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: LGPL-2.1-or-later +// Drive an actual Notepad++ instance in the runner's disposable Wine prefix. +#include +#include "../scintilla_messages.h" + +static DWORD editorPid; +static HWND editor, owner; + +static BOOL CALLBACK find_editor(HWND window, LPARAM unused) { + (void)unused; + WCHAR name[64]; + if (IsWindowVisible(window) && GetClassNameW(window, name, 64) && + !lstrcmpW(name, L"Scintilla")) { + editor = window; + return FALSE; + } + return TRUE; +} + +static BOOL CALLBACK find_owner(HWND window, LPARAM unused) { + (void)unused; + DWORD pid; + GetWindowThreadProcessId(window, &pid); + if (pid == editorPid && IsWindowVisible(window)) { + EnumChildWindows(window, find_editor, 0); + if (editor) { owner = window; return FALSE; } + } + return TRUE; +} + +static void key(WORD vk, BOOL control, BOOL shift) { + INPUT input[6] = {0}; + UINT count = 0; + if (control) { input[count].ki.wVk = VK_CONTROL; ++count; } + if (shift) { input[count].ki.wVk = VK_SHIFT; ++count; } + input[count].ki.wVk = vk; ++count; + input[count].ki.wVk = vk; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count; + if (shift) { input[count].ki.wVk = VK_SHIFT; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count; } + if (control) { input[count].ki.wVk = VK_CONTROL; input[count].ki.dwFlags = KEYEVENTF_KEYUP; ++count; } + for (UINT i = 0; i < count; ++i) input[i].type = INPUT_KEYBOARD; + if (SendInput(count, input, sizeof(INPUT)) != count) ExitProcess(3); + Sleep(200); +} + +static void move_to(ptrdiff_t position) { + SendMessageW(editor, SCI_SETSEL, position, position); + Sleep(100); +} + +int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) { + (void)instance; (void)previous; (void)args; (void)show; + WCHAR executable[2048], document[2048], command[4600]; + if (!GetEnvironmentVariableW(L"WINE_ACCESS_NOTEPADPP_EXE", executable, 2048) || + !GetEnvironmentVariableW(L"WINE_ACCESS_SCINTILLA_DOCUMENT", document, 2048)) return 2; + wsprintfW(command, L"\"%s\" -multiInst -nosession -noPlugin \"%s\"", executable, document); + STARTUPINFOW startup = {0}; + PROCESS_INFORMATION process = {0}; + startup.cb = sizeof(startup); + if (!CreateProcessW(NULL, command, NULL, NULL, FALSE, 0, NULL, NULL, &startup, &process)) return 2; + editorPid = process.dwProcessId; + for (int i = 0; i < 100 && !editor; ++i) { EnumWindows(find_owner, 0); Sleep(50); } + if (!editor || SendMessageW(editor, SCI_GETLENGTH, 0, 0) < 40) return 2; + ptrdiff_t originalLength = SendMessageW(editor, SCI_GETLENGTH, 0, 0); + SetForegroundWindow(owner); + key(VK_HOME, TRUE, FALSE); + WCHAR ready[1024]; + if (GetEnvironmentVariableW(L"WINE_ACCESS_FIXTURE_READY", ready, 1024)) { + HANDLE file = CreateFileW(ready, GENERIC_WRITE, FILE_SHARE_READ, NULL, CREATE_ALWAYS, 0, NULL); + if (file == INVALID_HANDLE_VALUE) return 2; + CloseHandle(file); + } + Sleep(1500); + key(VK_RIGHT, FALSE, FALSE); // e + key(VK_RIGHT, FALSE, FALSE); // l + key(VK_RIGHT, FALSE, FALSE); // l + key(VK_RIGHT, TRUE, FALSE); // beta + key(VK_LEFT, TRUE, FALSE); // hello plus trailing space + key(VK_RIGHT, TRUE, FALSE); + key(VK_DOWN, FALSE, FALSE); // Scintilla second line + key(VK_UP, FALSE, FALSE); // hello beta + key(VK_DOWN, FALSE, FALSE); + key(VK_HOME, FALSE, FALSE); + key(VK_RIGHT, FALSE, TRUE); // S selected + key(VK_RIGHT, FALSE, TRUE); // c selected + key(VK_LEFT, FALSE, TRUE); // c unselected + key(VK_LEFT, FALSE, FALSE); // S unselected + key(VK_DELETE, FALSE, FALSE);// S deleted + key(VK_RIGHT, FALSE, FALSE); + key(VK_BACK, FALSE, FALSE); // c (without deleted suffix) + key(VK_MENU, FALSE, FALSE); + key(VK_ESCAPE, FALSE, FALSE); + key(VK_ESCAPE, FALSE, FALSE);// return at "intilla second line" + key('Z', TRUE, FALSE); // undo c + key('Z', TRUE, FALSE); // undo S + key(VK_HOME, FALSE, FALSE); + key(VK_RIGHT, FALSE, FALSE);// c from restored document + SendMessageW(editor, SCI_SETSEL, 12, 21); // Scintilla selected + Sleep(100); + key('X', TRUE, FALSE); // Scintilla deleted + key('Z', TRUE, FALSE); + key(VK_HOME, FALSE, FALSE); + key(VK_DOWN, FALSE, FALSE); // cafΓ© πŸ˜€ tail + ptrdiff_t lineStart = SendMessageW(editor, SCI_POSITIONFROMLINE, 2, 0); + move_to(lineStart + 2); + key(VK_RIGHT, FALSE, FALSE);// Γ© (two UTF-8 bytes) + move_to(lineStart + 6); + key(VK_LEFT, FALSE, FALSE); // space + key(VK_RIGHT, FALSE, FALSE);// πŸ˜€ (four UTF-8 bytes) + key(VK_RIGHT, FALSE, TRUE); // πŸ˜€ selected + key(VK_LEFT, FALSE, TRUE); // πŸ˜€ unselected + key(VK_DELETE, FALSE, FALSE);// πŸ˜€ deleted + key('Z', TRUE, FALSE); + move_to(lineStart + 10); + key(VK_BACK, FALSE, FALSE); // πŸ˜€ + key('Z', TRUE, FALSE); + SendMessageW(editor, 2171 /* SCI_SETREADONLY */, TRUE, 0); + SendMessageW(editor, RegisterWindowMessageW(L"CthulhuWineEditFocus"), 0, 0); + key(VK_DELETE, FALSE, FALSE);// read-only: must leave document unchanged + if (SendMessageW(editor, SCI_GETLENGTH, 0, 0) != originalLength) return 4; + SendMessageW(editor, 2171 /* SCI_SETREADONLY */, FALSE, 0); + move_to(SendMessageW(editor, SCI_POSITIONFROMLINE, 3, 0)); + key(VK_END, TRUE, FALSE); // truncate before a surrogate pair in the last line + key(VK_UP, FALSE, FALSE);// very large document line, bounded range read + move_to(0); + LRESULT codePage = SendMessageW(editor, SCI_GETCODEPAGE, 0, 0); + SendMessageW(editor, 2037 /* SCI_SETCODEPAGE */, 0, 0); + SendMessageW(editor, 2066 /* SCI_STYLESETCHARACTERSET */, 32, DEFAULT_CHARSET); + SendMessageW(editor, RegisterWindowMessageW(L"CthulhuWineEditFocus"), 0, 0); + Sleep(100); + SendMessageW(editor, 2066 /* SCI_STYLESETCHARACTERSET */, 32, ANSI_CHARSET); + SendMessageW(editor, RegisterWindowMessageW(L"CthulhuWineEditFocus"), 0, 0); + Sleep(100); + SendMessageW(editor, 2037 /* SCI_SETCODEPAGE */, codePage, 0); + // Mark this disposable document clean to close without a save prompt. + SendMessageW(editor, 2014 /* SCI_SETSAVEPOINT */, 0, 0); + PostMessageW(owner, WM_CLOSE, 0, 0); + int result = WaitForSingleObject(process.hProcess, 3000) == WAIT_OBJECT_0 ? 0 : 2; + CloseHandle(process.hThread); CloseHandle(process.hProcess); + return result; +}