Notepad++ should work now.

This commit is contained in:
Storm Dragon committed 2026-10-10 22:39:24 -04:00
1 parent cbce83581a
commit df7879acb5
12 files changed
+523 -18

No files matched your search

+3 -2
View File
@@ -15,8 +15,9 @@ set(menu_hook "${CMAKE_CURRENT_BINARY_DIR}/wine-access-menu.dll")
add_custom_command(OUTPUT "${menu_hook}" add_custom_command(OUTPUT "${menu_hook}"
COMMAND "${MINGW_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared COMMAND "${MINGW_C_COMPILER}" -std=c11 -O2 -Wall -Wextra -shared
-static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c" -static-libgcc -o "${menu_hook}" "${CMAKE_CURRENT_SOURCE_DIR}/menu_hook.c"
"${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32 "${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c"
DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h control_class.h "${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) VERBATIM)
add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}") add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}")
+17 -3
View File
@@ -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 Undo refreshes the text snapshot used by subsequent navigation. It can attach to
an already-focused editor and resume after the helper restarts. 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 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`.
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. 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 offsets and the action kind travel in the otherwise non-spoken `states` field
for debug diagnostics. Older readers that only accept focus/value/selection for debug diagnostics. Standard edits use UTF-16 offsets; Scintilla marks its
events still receive focus and menus, but need updating for editor actions. 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 MSAA `name` and `state` events report changes to controls. The `states` field
contains stable comma-separated state labels decoded from the MSAA bitmask; 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 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 covered. Reports are limited to 1023 UTF-16 units; documents over one million
units use line feedback without full selection/deletion snapshots. 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 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 64-bit applications; 32-bit menu coverage and custom menus that do not send
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org> # Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=wine-access-git pkgname=wine-access-git
pkgver=r9.g39b7488 pkgver=r17.gcbce835
pkgrel=1 pkgrel=1
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu' pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu'
arch=('x86_64') arch=('x86_64')
+11 -3
View File
@@ -53,7 +53,8 @@ static BOOL word_space(WCHAR ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'; 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 owner = GetAncestor(window, GA_ROOT);
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL); HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
if (!bridge || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || GetFocus() != window) 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.owner = (ULONGLONG)(UINT_PTR)owner;
event.control = (ULONGLONG)(UINT_PTR)window; event.control = (ULONGLONG)(UINT_PTR)window;
event.kind = kind; event.start = start; event.end = end; event.kind = kind; event.start = start; event.end = end;
event.flags = flags;
lstrcpynW(event.text, text, 1024); lstrcpynW(event.text, text, 1024);
GetWindowTextW(owner, event.title, 256); GetWindowTextW(owner, event.title, 256);
COPYDATASTRUCT data = {WINE_ACCESS_TEXT_TAG, sizeof(event), &event}; 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); 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, static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR reference) { UINT_PTR id, DWORD_PTR reference) {
EditState *state = (EditState *)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) { BOOL wine_access_watch_edit(HWND window) {
WCHAR class_name[64]; WCHAR class_name[64];
if (!window || !GetClassNameW(window, class_name, 64) || if (!window || !GetClassNameW(window, class_name, 64)) return FALSE;
!wine_access_class_matches(class_name, L"Edit")) 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 // Wine's comctl32 v5 exports GetWindowSubclass only by ordinal. Our own
// property also keeps attachment independent of the application's manifest. // property also keeps attachment independent of the application's manifest.
if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE; if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE;
@@ -214,6 +221,7 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp,
HWND focus = GetFocus(); HWND focus = GetFocus();
if (wine_access_watch_edit(focus)) if (wine_access_watch_edit(focus))
SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0); SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
wine_access_scintilla_input(focus, (const MSG *)lp);
} }
return CallNextHookEx(NULL, code, wp, lp); return CallNextHookEx(NULL, code, wp, lp);
} }
+4
View File
@@ -4,3 +4,7 @@
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState" #define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
#define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus" #define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus"
BOOL wine_access_watch_edit(HWND window); 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);
+7 -3
View File
@@ -512,7 +512,10 @@ void present_text_event(const WineAccessTextEvent &event) {
if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) ||
!GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window) !GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window)
return; 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) event.kind != WINE_ACCESS_TEXT_PASSWORD)
return; return;
std::string text = to_utf8(event.text); std::string text = to_utf8(event.text);
@@ -527,7 +530,7 @@ void present_text_event(const WineAccessTextEvent &event) {
wchar_t label[256] = {}; wchar_t label[256] = {};
find_control_label(window, label, std::size(label)); find_control_label(window, label, std::size(label));
control.name = to_utf8(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; control.value = text.empty() ? "blank" : text;
} else { } else {
if (text == " ") text = "space"; 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. // Re-establish focus before its first action without re-reading the document.
if (!focus && lastTextControl != window) { if (!focus && lastTextControl != window) {
ControlDescription focused; 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 (!present_accessible_event(source, "focus", focused, title)) return;
} }
if (event.kind != WINE_ACCESS_TEXT_PASSWORD) if (event.kind != WINE_ACCESS_TEXT_PASSWORD)
control.states = "start=" + std::to_string(event.start) + " end=" + control.states = "start=" + std::to_string(event.start) + " end=" +
std::to_string(event.end) + " kind=" + std::to_string(event.kind); 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. // Text commands are ordered actions, not transient focus notifications.
if (present_accessible_event(source, focus ? "focus" : "text", control, title)) if (present_accessible_event(source, focus ? "focus" : "text", control, title))
lastTextControl = window; lastTextControl = window;
+5 -2
View File
@@ -17,13 +17,16 @@
#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_SCINTILLA 1
#define WINE_ACCESS_TEXT_READONLY 2
typedef struct WineAccessTextEvent { typedef struct WineAccessTextEvent {
ULONGLONG owner; ULONGLONG owner;
ULONGLONG control; ULONGLONG control;
UINT kind; UINT kind;
DWORD start; UINT flags;
DWORD end; ULONGLONG start;
ULONGLONG end;
WCHAR text[1024]; WCHAR text[1024];
WCHAR title[256]; WCHAR title[256];
} WineAccessTextEvent; } WineAccessTextEvent;
+232
View File
@@ -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 <commctrl.h>
#include <stdlib.h>
#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);
}
+30
View File
@@ -0,0 +1,30 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <stddef.h>
// 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;
+27
View File
@@ -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 acceptance gates. Run Xvfb fixtures sequentially; concurrent automatic display
allocation can collide. 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 ## List-view checks
```sh ```sh
+46 -4
View File
@@ -7,6 +7,7 @@ import argparse
import os import os
from pathlib import Path from pathlib import Path
import subprocess import subprocess
import shutil
import sys import sys
import tempfile import tempfile
import time import time
@@ -16,20 +17,24 @@ from gi.repository import Gio, GLib
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--build-dir", default="build") parser.add_argument("--build-dir", default="build")
parser.add_argument("--session", action="store_true") 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("--32-bit", dest="is32Bit", action="store_true")
parser.add_argument("--late-helper", action="store_true") parser.add_argument("--late-helper", action="store_true")
parser.add_argument("--restart-helper", action="store_true") parser.add_argument("--restart-helper", action="store_true")
args = parser.parse_args() args = parser.parse_args()
if args.is32Bit and args.fixture not in ("list", "controls"): if args.is32Bit and args.fixture not in ("list", "controls"):
parser.error("32-bit checks currently require --fixture list or 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"): if (args.late_helper or args.restart_helper) and args.fixture not in ("edit", "winforms", "scintilla"):
parser.error("helper lifecycle checks require --fixture edit or --fixture winforms") 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() build = Path(args.build_dir).resolve()
if not args.session: if not args.session:
sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable, sys.exit(subprocess.call(["dbus-run-session", "--", sys.executable,
__file__, "--session", "--build-dir", str(build), __file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture, "--fixture", args.fixture,
*(["--notepadpp-exe", str(args.notepadpp_exe.resolve())] if args.notepadpp_exe else []),
*(["--32-bit"] if args.is32Bit else []), *(["--32-bit"] if args.is32Bit else []),
*(["--late-helper"] if args.late_helper else []), *(["--late-helper"] if args.late_helper else []),
*(["--restart-helper"] if args.restart_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") WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
ready = Path(temporary) / "ready" ready = Path(temporary) / "ready"
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\") 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" fixture = Path(temporary) / "menu-fixture.exe"
compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc" compiler = "i686-w64-mingw32-gcc" if args.is32Bit else "x86_64-w64-mingw32-gcc"
sourceName = "edit" if args.fixture == "winforms" else args.fixture 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) subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
if args.late_helper: if args.late_helper:
app = subprocess.Popen(["wine", str(fixture)], env=env) 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: while not ready.exists() and app.poll() is None and time.monotonic() < deadline:
time.sleep(0.01) time.sleep(0.01)
assert ready.exists(), "editor did not become ready before helper attachment" 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" 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 keyboard characters, words, lines, selection, deletion, and menu return")
sys.exit(0) 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"): if args.fixture in ("edit", "winforms"):
text_events = [e for e in events if e[0].endswith(":text")] text_events = [e for e in events if e[0].endswith(":text")]
for event in text_events: for event in text_events:
+140
View File
@@ -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 <windows.h>
#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;
}