Notepad++ should work now.
This commit is contained in:
1 parent
cbce83581a
commit
df7879acb5
12 files changed
+523
-18
No files matched your search
+3
-2
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
|
||||
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')
|
||||
|
||||
+11
-3
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
|
||||
+5
-2
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user