Finish up edit field accessibility.
This commit is contained in:
1 parent
6f6d4040c4
commit
c5657ee5d4
9 files changed
+359
-37
No files matched your search
+3
-1
@@ -45,8 +45,10 @@ target_compile_options(cthulhu-wine-access PRIVATE
|
|||||||
# Winegcc emits a launcher and a Winelib sidecar. CMake's normal target
|
# Winegcc emits a launcher and a Winelib sidecar. CMake's normal target
|
||||||
# installation looks for the Linux target name, so install both emitted
|
# installation looks for the Linux target name, so install both emitted
|
||||||
# artifacts explicitly and keep them together for Wine to resolve.
|
# artifacts explicitly and keep them together for Wine to resolve.
|
||||||
install(FILES
|
install(PROGRAMS
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe"
|
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe"
|
||||||
|
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
||||||
|
install(FILES
|
||||||
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so"
|
"${CMAKE_CURRENT_BINARY_DIR}/cthulhu-wine-access.exe.so"
|
||||||
"${menu_hook}"
|
"${menu_hook}"
|
||||||
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
DESTINATION "${WINE_ACCESS_INSTALL_DIR}")
|
||||||
@@ -87,6 +87,27 @@ repeated selections, and restores standard-control focus reporting when menus
|
|||||||
close. The hook applies to the helper's Wine desktop and prefix and is removed
|
close. The hook applies to the helper's Wine desktop and prefix and is removed
|
||||||
on normal shutdown. It is disabled by `--no-dialog-reader`.
|
on normal shutdown. It is disabled by `--no-dialog-reader`.
|
||||||
|
|
||||||
|
The same DLL subclasses standard `Edit` controls, including Wine Notepad's
|
||||||
|
document area. It reports the current line on focus and menu return, characters
|
||||||
|
and words on horizontal navigation, lines on vertical/page/document-boundary
|
||||||
|
navigation, changed selections, and Backspace/Delete/Cut feedback. Password
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
This is editing feedback, not a full text-review provider: Say All, review-cursor
|
||||||
|
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.
|
||||||
|
|
||||||
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
|
||||||
`WM_MENUSELECT` are not established.
|
`WM_MENUSELECT` are not established.
|
||||||
@@ -98,6 +119,8 @@ available (`python-gobject` on Arch Linux):
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
python3 tests/menu_integration.py --build-dir build
|
python3 tests/menu_integration.py --build-dir build
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture edit
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture notepad
|
||||||
```
|
```
|
||||||
|
|
||||||
The test opens a short-lived native menu window in a temporary Wine prefix,
|
The test opens a short-lived native menu window in a temporary Wine prefix,
|
||||||
@@ -106,6 +129,11 @@ context menus, shortcuts, item states, and duplicate suppression. It does not
|
|||||||
send speech or braille to the running reader. Live listening with Cthulhu is a
|
send speech or braille to the running reader. Live listening with Cthulhu is a
|
||||||
separate acceptance check.
|
separate acceptance check.
|
||||||
|
|
||||||
|
See [the test guide](tests/README.md) for helper lifecycle checks and the live
|
||||||
|
Notepad speech/braille procedure. After updating the DLL, close and reopen the
|
||||||
|
Wine application: existing control subclasses keep their DLL loaded until the
|
||||||
|
application exits.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
The helper source is available under the terms of LGPL-2.1-or-later. See
|
The helper source is available under the terms of LGPL-2.1-or-later. See
|
||||||
|
|||||||
+31
-19
@@ -5,14 +5,13 @@
|
|||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
#define EDIT_SUBCLASS_ID 0x57415431
|
#define EDIT_SUBCLASS_ID 0x57415431
|
||||||
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
|
|
||||||
// Bound memory and work inside applications. Navigation reuses the snapshot;
|
// Bound memory and work inside applications. Navigation reuses the snapshot;
|
||||||
// only text changes refresh it. Large documents retain line/character reading.
|
// only text changes refresh it. Large documents retain line reading.
|
||||||
#define MAX_SNAPSHOT_UNITS (1024 * 1024)
|
#define MAX_SNAPSHOT_UNITS (1024 * 1024)
|
||||||
|
|
||||||
typedef struct EditState {
|
typedef struct EditState {
|
||||||
WCHAR *text;
|
WCHAR *text;
|
||||||
DWORD length, start, end;
|
DWORD length;
|
||||||
BOOL busy, known, destroyed;
|
BOOL busy, known, destroyed;
|
||||||
} EditState;
|
} EditState;
|
||||||
|
|
||||||
@@ -45,6 +44,7 @@ static void current_line(HWND window, DWORD caret, WCHAR *output) {
|
|||||||
output[0] = 1023;
|
output[0] = 1023;
|
||||||
LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output);
|
LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output);
|
||||||
if (copied < 0 || copied > 1023) copied = 0;
|
if (copied < 0 || copied > 1023) copied = 0;
|
||||||
|
if (copied && output[copied - 1] >= 0xd800 && output[copied - 1] <= 0xdbff) --copied;
|
||||||
output[copied] = 0;
|
output[copied] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +85,10 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
|||||||
wp == VK_HOME || wp == VK_END || wp == VK_PRIOR || wp == VK_NEXT);
|
wp == VK_HOME || wp == VK_END || wp == VK_PRIOR || wp == VK_NEXT);
|
||||||
BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) ||
|
BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) ||
|
||||||
message == WM_PASTE || message == WM_CUT || message == WM_CLEAR ||
|
message == WM_PASTE || message == WM_CUT || message == WM_CLEAR ||
|
||||||
message == WM_UNDO || message == EM_REPLACESEL || message == WM_SETTEXT;
|
message == WM_UNDO || message == EM_UNDO || message == EM_REPLACESEL ||
|
||||||
BOOL focus = message == WM_SETFOCUS;
|
message == WM_SETTEXT || message == EM_SETHANDLE || message == EM_FMTLINES;
|
||||||
|
BOOL requested_focus = message == RegisterWindowMessageW(EDIT_FOCUS_MESSAGE);
|
||||||
|
BOOL focus = message == WM_SETFOCUS || requested_focus;
|
||||||
BOOL selected = message == EM_SETSEL;
|
BOOL selected = message == EM_SETSEL;
|
||||||
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
|
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
|
||||||
return DefSubclassProc(window, message, wp, lp);
|
return DefSubclassProc(window, message, wp, lp);
|
||||||
@@ -97,20 +99,22 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
|||||||
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
|
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
|
||||||
if (!password && (!state->known || focus)) snapshot(window, state);
|
if (!password && (!state->known || focus)) snapshot(window, state);
|
||||||
WCHAR removed[1024] = {0};
|
WCHAR removed[1024] = {0};
|
||||||
if (!password && state->text && changing) {
|
if (!password && state->text && changing && old_start <= state->length && old_end <= state->length) {
|
||||||
DWORD first = old_start, last = old_end;
|
DWORD first = old_start, last = old_end;
|
||||||
if (first == last) {
|
if (first == last) {
|
||||||
if (message == WM_CHAR && wp == VK_BACK && first) {
|
if (message == WM_CHAR && wp == VK_BACK && first) {
|
||||||
--first;
|
--first;
|
||||||
if (first && state->text[first] >= 0xdc00 && state->text[first] <= 0xdfff) --first;
|
if (first && state->text[first] >= 0xdc00 && state->text[first] <= 0xdfff) --first;
|
||||||
|
else if (first && state->text[first] == '\n' && state->text[first - 1] == '\r') --first;
|
||||||
} else if (message == WM_KEYDOWN && wp == VK_DELETE && last < state->length) {
|
} else if (message == WM_KEYDOWN && wp == VK_DELETE && last < state->length) {
|
||||||
++last;
|
++last;
|
||||||
if (last < state->length && state->text[last - 1] >= 0xd800 && state->text[last - 1] <= 0xdbff) ++last;
|
if (last < state->length && state->text[last - 1] >= 0xd800 && state->text[last - 1] <= 0xdbff) ++last;
|
||||||
|
else if (last < state->length && state->text[last - 1] == '\r' && state->text[last] == '\n') ++last;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (last <= state->length) copy_range(removed, state->text, first, last);
|
if (last <= state->length) copy_range(removed, state->text, first, last);
|
||||||
}
|
}
|
||||||
LRESULT result = DefSubclassProc(window, message, wp, lp);
|
LRESULT result = requested_focus ? 0 : DefSubclassProc(window, message, wp, lp);
|
||||||
if (state->destroyed) {
|
if (state->destroyed) {
|
||||||
free(state->text); free(state);
|
free(state->text); free(state);
|
||||||
return result;
|
return result;
|
||||||
@@ -138,11 +142,11 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
|||||||
// Speak the changed portion when extending or shrinking a selection.
|
// Speak the changed portion when extending or shrinking a selection.
|
||||||
DWORD first = start, last = end;
|
DWORD first = start, last = end;
|
||||||
kind = WINE_ACCESS_TEXT_SELECTED;
|
kind = WINE_ACCESS_TEXT_SELECTED;
|
||||||
if (old_start != old_end && start == old_start) {
|
if (old_start != old_end && old_end <= state->length && start == old_start) {
|
||||||
first = end < old_end ? end : old_end;
|
first = end < old_end ? end : old_end;
|
||||||
last = end > old_end ? end : old_end;
|
last = end > old_end ? end : old_end;
|
||||||
if (end < old_end) kind = WINE_ACCESS_TEXT_UNSELECTED;
|
if (end < old_end) kind = WINE_ACCESS_TEXT_UNSELECTED;
|
||||||
} else if (old_start != old_end && end == old_end) {
|
} else if (old_start != old_end && old_end <= state->length && end == old_end) {
|
||||||
first = start < old_start ? start : old_start;
|
first = start < old_start ? start : old_start;
|
||||||
last = start > old_start ? start : old_start;
|
last = start > old_start ? start : old_start;
|
||||||
if (start > old_start) kind = WINE_ACCESS_TEXT_UNSELECTED;
|
if (start > old_start) kind = WINE_ACCESS_TEXT_UNSELECTED;
|
||||||
@@ -151,7 +155,8 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
|||||||
} else if (old_start != old_end && state->text && old_end <= state->length) {
|
} else if (old_start != old_end && state->text && old_end <= state->length) {
|
||||||
copy_range(text, state->text, old_start, old_end);
|
copy_range(text, state->text, old_start, old_end);
|
||||||
kind = WINE_ACCESS_TEXT_UNSELECTED;
|
kind = WINE_ACCESS_TEXT_UNSELECTED;
|
||||||
} else if (navigation && (wp == VK_UP || wp == VK_DOWN || wp == VK_PRIOR || wp == VK_NEXT)) {
|
} else if (navigation && (wp == VK_UP || wp == VK_DOWN || wp == VK_PRIOR || wp == VK_NEXT ||
|
||||||
|
((wp == VK_HOME || wp == VK_END) && (GetKeyState(VK_CONTROL) & 0x8000)))) {
|
||||||
current_line(window, start, text);
|
current_line(window, start, text);
|
||||||
} else if (state->text && start < state->length) {
|
} else if (state->text && start < state->length) {
|
||||||
DWORD last = start + 1;
|
DWORD last = start + 1;
|
||||||
@@ -165,39 +170,46 @@ static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM l
|
|||||||
current_line(window, start, text);
|
current_line(window, start, text);
|
||||||
}
|
}
|
||||||
report = TRUE;
|
report = TRUE;
|
||||||
} else if (changing && removed[0] && old_length > state->length) {
|
} else if (changing && removed[0] && old_length > state->length &&
|
||||||
|
(message == WM_CLEAR || message == WM_CUT ||
|
||||||
|
(message == WM_CHAR && wp == VK_BACK) ||
|
||||||
|
(message == WM_KEYDOWN && wp == VK_DELETE))) {
|
||||||
lstrcpynW(text, removed, 1024);
|
lstrcpynW(text, removed, 1024);
|
||||||
kind = WINE_ACCESS_TEXT_DELETED; report = TRUE;
|
kind = WINE_ACCESS_TEXT_DELETED; report = TRUE;
|
||||||
}
|
}
|
||||||
if (report) notify_text(window, kind, text, start, end);
|
if (report) notify_text(window, kind, text, start, end);
|
||||||
state->start = start; state->end = end;
|
|
||||||
state->busy = FALSE;
|
state->busy = FALSE;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
void 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) || lstrcmpW(class_name, L"Edit")) return;
|
if (!window || !GetClassNameW(window, class_name, 64) || lstrcmpW(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;
|
if (GetPropW(window, EDIT_STATE_PROPERTY)) return FALSE;
|
||||||
EditState *state = calloc(1, sizeof(*state));
|
EditState *state = calloc(1, sizeof(*state));
|
||||||
if (!state) return;
|
if (!state) return FALSE;
|
||||||
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
|
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
|
||||||
free(state); return;
|
free(state); return FALSE;
|
||||||
}
|
}
|
||||||
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
|
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
|
||||||
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
|
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
|
||||||
free(state); return;
|
free(state); return FALSE;
|
||||||
}
|
}
|
||||||
// Subclass callbacks can outlive the reader. Keep their module mapped until
|
// Subclass callbacks can outlive the reader. Keep their module mapped until
|
||||||
// the application exits; each control frees its state on WM_NCDESTROY.
|
// the application exits; each control frees its state on WM_NCDESTROY.
|
||||||
HMODULE module;
|
HMODULE module;
|
||||||
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
|
||||||
(LPCWSTR)(UINT_PTR)edit_proc, &module);
|
(LPCWSTR)(UINT_PTR)edit_proc, &module);
|
||||||
|
return TRUE;
|
||||||
}
|
}
|
||||||
|
|
||||||
__declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) {
|
__declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) {
|
||||||
if (code >= 0 && wp == PM_REMOVE) wine_access_watch_edit(GetFocus());
|
if (code >= 0 && wp == PM_REMOVE) {
|
||||||
|
HWND focus = GetFocus();
|
||||||
|
if (wine_access_watch_edit(focus))
|
||||||
|
SendMessageW(focus, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
|
||||||
|
}
|
||||||
return CallNextHookEx(NULL, code, wp, lp);
|
return CallNextHookEx(NULL, code, wp, lp);
|
||||||
}
|
}
|
||||||
+3
-1
@@ -1,4 +1,6 @@
|
|||||||
// SPDX-License-Identifier: LGPL-2.1-or-later
|
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
void wine_access_watch_edit(HWND window);
|
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
|
||||||
|
#define EDIT_FOCUS_MESSAGE L"CthulhuWineEditFocus"
|
||||||
|
BOOL wine_access_watch_edit(HWND window);
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "nvdaController.h"
|
#include "nvdaController.h"
|
||||||
#include "menu_bridge.h"
|
#include "menu_bridge.h"
|
||||||
|
#include "edit_hook.h"
|
||||||
|
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <gio/gio.h>
|
#include <gio/gio.h>
|
||||||
@@ -28,6 +29,7 @@ std::deque<WineAccessTextEvent> textEvents;
|
|||||||
bool editReaderActive = false;
|
bool editReaderActive = false;
|
||||||
std::string lastMenuEvent;
|
std::string lastMenuEvent;
|
||||||
ULONGLONG lastMenuOwner = 0;
|
ULONGLONG lastMenuOwner = 0;
|
||||||
|
HWND lastTextControl = nullptr;
|
||||||
|
|
||||||
// Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit
|
// Winegcc uses 16-bit wchar_t; Linux libc's wcs* functions expect 32-bit
|
||||||
// wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons.
|
// wchar_t. Use Wine's Unicode APIs or explicit code-unit comparisons.
|
||||||
@@ -183,6 +185,9 @@ bool present_accessible_event(const std::string &sourceId,
|
|||||||
const char *eventType,
|
const char *eventType,
|
||||||
const ControlDescription &control,
|
const ControlDescription &control,
|
||||||
const std::string &windowTitle) {
|
const std::string &windowTitle) {
|
||||||
|
// Readers can update their focus owner even when they suppress its output.
|
||||||
|
if (std::string(eventType) == "focus" && sourceId != source_id(lastTextControl, "text"))
|
||||||
|
lastTextControl = nullptr;
|
||||||
return call_boolean("PresentAccessibleEvent",
|
return call_boolean("PresentAccessibleEvent",
|
||||||
g_variant_new(
|
g_variant_new(
|
||||||
"(sssssssiis)", sourceId.c_str(), eventType,
|
"(sssssssiis)", sourceId.c_str(), eventType,
|
||||||
@@ -199,6 +204,12 @@ ControlDescription describe_standard_control(HWND window) {
|
|||||||
wchar_t label[256] = {};
|
wchar_t label[256] = {};
|
||||||
if (!GetClassNameW(window, className, std::size(className)))
|
if (!GetClassNameW(window, className, std::size(className)))
|
||||||
return control;
|
return control;
|
||||||
|
const LONG_PTR windowStyle = GetWindowLongPtrW(window, GWL_STYLE);
|
||||||
|
const bool isEdit = lstrcmpW(className, L"Edit") == 0 ||
|
||||||
|
lstrcmpW(className, L"RICHEDIT50W") == 0 ||
|
||||||
|
lstrcmpW(className, L"RichEdit20W") == 0 ||
|
||||||
|
lstrcmpW(className, L"RichEdit20A") == 0;
|
||||||
|
if (!isEdit || !(windowStyle & ES_PASSWORD))
|
||||||
get_control_text(window, text, std::size(text));
|
get_control_text(window, text, std::size(text));
|
||||||
|
|
||||||
if (lstrcmpW(className, L"Button") == 0) {
|
if (lstrcmpW(className, L"Button") == 0) {
|
||||||
@@ -233,10 +244,6 @@ ControlDescription describe_standard_control(HWND window) {
|
|||||||
return control;
|
return control;
|
||||||
}
|
}
|
||||||
|
|
||||||
const bool isEdit = lstrcmpW(className, L"Edit") == 0 ||
|
|
||||||
lstrcmpW(className, L"RICHEDIT50W") == 0 ||
|
|
||||||
lstrcmpW(className, L"RichEdit20W") == 0 ||
|
|
||||||
lstrcmpW(className, L"RichEdit20A") == 0;
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
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);
|
||||||
@@ -313,6 +320,10 @@ ControlDescription describe_standard_control(HWND window) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool present_standard_focus(HWND window) {
|
bool present_standard_focus(HWND window) {
|
||||||
|
// Read through the subclass so returning from a menu reports the caret's
|
||||||
|
// line, not the beginning of the document. Never block on the callback.
|
||||||
|
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
|
||||||
|
return PostMessageW(window, RegisterWindowMessageW(EDIT_FOCUS_MESSAGE), 0, 0);
|
||||||
const ControlDescription control = describe_standard_control(window);
|
const ControlDescription control = describe_standard_control(window);
|
||||||
if (control.name.empty() && control.role.empty() && control.value.empty() &&
|
if (control.name.empty() && control.role.empty() && control.value.empty() &&
|
||||||
control.description.empty())
|
control.description.empty())
|
||||||
@@ -370,6 +381,9 @@ 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) &&
|
||||||
|
event.kind != WINE_ACCESS_TEXT_PASSWORD)
|
||||||
|
return;
|
||||||
std::string text = to_utf8(event.text);
|
std::string text = to_utf8(event.text);
|
||||||
ControlDescription control;
|
ControlDescription control;
|
||||||
if (event.kind == WINE_ACCESS_TEXT_PASSWORD) {
|
if (event.kind == WINE_ACCESS_TEXT_PASSWORD) {
|
||||||
@@ -397,7 +411,22 @@ void present_text_event(const WineAccessTextEvent &event) {
|
|||||||
control.name = text.empty() ? "blank" : text;
|
control.name = text.empty() ? "blank" : text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
present_accessible_event(source_id(window, "text"), "focus", control, to_utf8(event.title));
|
const bool focus = event.kind == WINE_ACCESS_TEXT_FOCUS || event.kind == WINE_ACCESS_TEXT_PASSWORD;
|
||||||
|
const auto source = source_id(window, "text");
|
||||||
|
const auto title = to_utf8(event.title);
|
||||||
|
// A reader restart can encounter an editor whose subclass already exists.
|
||||||
|
// 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";
|
||||||
|
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);
|
||||||
|
// Text commands are ordered actions, not transient focus notifications.
|
||||||
|
if (present_accessible_event(source, focus ? "focus" : "text", control, title))
|
||||||
|
lastTextControl = window;
|
||||||
}
|
}
|
||||||
|
|
||||||
LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||||
@@ -471,9 +500,7 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
|||||||
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
|
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
|
||||||
event != EVENT_OBJECT_STATECHANGE)
|
event != EVENT_OBJECT_STATECHANGE)
|
||||||
return;
|
return;
|
||||||
wchar_t className[64] = {};
|
if (editReaderActive && GetPropW(window, EDIT_STATE_PROPERTY))
|
||||||
if (editReaderActive && GetClassNameW(window, className, std::size(className)) &&
|
|
||||||
lstrcmpW(className, L"Edit") == 0)
|
|
||||||
return;
|
return;
|
||||||
IAccessible *accessible = nullptr;
|
IAccessible *accessible = nullptr;
|
||||||
VARIANT child{};
|
VARIANT child{};
|
||||||
|
|||||||
+63
-1
@@ -1,4 +1,4 @@
|
|||||||
# Menu bridge integration
|
# Wine control integration
|
||||||
|
|
||||||
Build the project, then run `python3 tests/menu_integration.py --build-dir build`
|
Build the project, then run `python3 tests/menu_integration.py --build-dir build`
|
||||||
from the repository root in a graphical session. Dependencies are Wine,
|
from the repository root in a graphical session. Dependencies are Wine,
|
||||||
@@ -31,3 +31,65 @@ live listening or physical braille acceptance.
|
|||||||
|
|
||||||
32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full
|
32-bit hook support, entirely custom menus without `WM_MENUSELECT`, and full
|
||||||
object review are outside the current implementation.
|
object review are outside the current implementation.
|
||||||
|
|
||||||
|
## Editor and actual Notepad checks
|
||||||
|
|
||||||
|
Run these against the built helper, optionally prefixed with `xvfb-run -a` for
|
||||||
|
a private X display:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture edit
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture edit --late-helper
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture edit --restart-helper
|
||||||
|
python3 tests/menu_integration.py --build-dir build --fixture notepad
|
||||||
|
```
|
||||||
|
|
||||||
|
The standard Edit fixture checks focus, characters/words/lines, incremental
|
||||||
|
selection and unselection, Backspace/Delete/Clear, read-only controls, password
|
||||||
|
exclusion, surrogate pairs, rapid repeated letters, menu return at the caret,
|
||||||
|
short replacements, CRLF deletion, long-line truncation, Undo snapshots, and
|
||||||
|
Ctrl+End line reading. Lifecycle variants attach after the control has focus or
|
||||||
|
restart the helper without closing it. Every text action must have an established
|
||||||
|
focus source, matching the Go reader's filtering contract.
|
||||||
|
|
||||||
|
The Notepad fixture launches Wine's actual `notepad.exe` and uses `SendInput`
|
||||||
|
for arrows, Ctrl+Right, Shift selection, Delete, and Alt/Escape menu return. It
|
||||||
|
checks resulting document text as well as recorded accessibility output. These
|
||||||
|
tests use a recording D-Bus service; they do not run the full reader or prove
|
||||||
|
audible speech, typing echo, physical keys, or a physical braille display.
|
||||||
|
|
||||||
|
On 2026-10-10, the build, isolated editor/Notepad/menu checks, staged launcher
|
||||||
|
permissions, and Cthulhu-Go app/remote/wine race tests passed. See the sibling
|
||||||
|
reader's `docs/testing/wine-accessibility.md` for the matching Go behavior.
|
||||||
|
|
||||||
|
## Live Notepad acceptance
|
||||||
|
|
||||||
|
Install all three helper files together and use the matching Cthulhu-Go source.
|
||||||
|
Close existing Notepad windows after saving any work so the updated hook can load.
|
||||||
|
|
||||||
|
1. Quit Cthulhu with Cthulhu+Q.
|
||||||
|
2. Press Ctrl+Escape, then Shift+G. These are separate key presses. Shift+G
|
||||||
|
rebuilds Cthulhu and its braille helper and opens the test terminal; it does
|
||||||
|
not build wine-access.
|
||||||
|
3. Run `wine notepad` in the intended Wine prefix. Expect edit/blank focus
|
||||||
|
feedback. Type `hello beta`, Enter, then `second line`. Typing echo should
|
||||||
|
follow the reader's configured settings.
|
||||||
|
4. Press Ctrl+Home, then Right three times. Expect the first line at Ctrl+Home,
|
||||||
|
then `e`, `l`, `l`. Both repeated letters should produce feedback. Ctrl+Right
|
||||||
|
should read `beta`; Down should read `second line`.
|
||||||
|
5. Press Home, Shift+Right twice, then Shift+Left twice. Expect `s selected`,
|
||||||
|
`e selected`, `e unselected`, `s unselected`. Focus stays in the edit area.
|
||||||
|
Delete should say `s deleted`. Right followed by Backspace should say
|
||||||
|
`e deleted`; the second line is now `cond line`.
|
||||||
|
6. Press Alt to enter the menus and Escape to return (Escape again if needed).
|
||||||
|
Expect menu labels while navigating and `edit, cond line` on return, not
|
||||||
|
the start of the document. Arrows must continue to reach Notepad.
|
||||||
|
7. Use Undo, then navigate the restored text. Expect feedback to match the
|
||||||
|
document. Switch to another application and back; verify focus and keyboard
|
||||||
|
reading resume normally. Close Notepad without saving this test document.
|
||||||
|
|
||||||
|
Connected braille should show the corresponding feedback messages. This does
|
||||||
|
not establish a routable editor buffer. Report the step, exact speech and
|
||||||
|
braille, focused control, and any key that behaved differently. For unexpected
|
||||||
|
results inspect `~/devel/cthulhu-go.log`; debug `Wine editor feedback` records
|
||||||
|
include source IDs, native UTF-16 selection ranges, action kinds and output text.
|
||||||
+70
-3
@@ -17,6 +17,7 @@ static void key(UINT key, BOOL control, BOOL shift) {
|
|||||||
|
|
||||||
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
|
||||||
if (message == WM_TIMER) {
|
if (message == WM_TIMER) {
|
||||||
|
if (step == 0) SetTimer(window, 1, 350, NULL);
|
||||||
switch (++step) {
|
switch (++step) {
|
||||||
case 1: key(VK_RIGHT, FALSE, FALSE); break;
|
case 1: key(VK_RIGHT, FALSE, FALSE); break;
|
||||||
case 2: key(VK_RIGHT, TRUE, FALSE); break;
|
case 2: key(VK_RIGHT, TRUE, FALSE); break;
|
||||||
@@ -47,8 +48,63 @@ static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM
|
|||||||
SetFocus(password); SetFocus(editor);
|
SetFocus(password); SetFocus(editor);
|
||||||
break;
|
break;
|
||||||
case 19: key(VK_DELETE, FALSE, FALSE); break;
|
case 19: key(VK_DELETE, FALSE, FALSE); break;
|
||||||
default:
|
case 20:
|
||||||
if (GetWindowTextLengthW(editor) != 7) ExitProcess(2);
|
if (GetWindowTextLengthW(editor) != 7) ExitProcess(2);
|
||||||
|
SendMessageW(editor, EM_SETREADONLY, FALSE, 0);
|
||||||
|
SetWindowTextW(editor, L"hello\r\nmenu return line");
|
||||||
|
SendMessageW(editor, EM_SETSEL, 0, 0);
|
||||||
|
break;
|
||||||
|
case 21:
|
||||||
|
key(VK_RIGHT, FALSE, FALSE);
|
||||||
|
key(VK_RIGHT, FALSE, FALSE);
|
||||||
|
key(VK_RIGHT, FALSE, FALSE);
|
||||||
|
break;
|
||||||
|
case 22:
|
||||||
|
SendMessageW(editor, EM_SETSEL, 7, 7);
|
||||||
|
SendMessageW(window, WM_MENUSELECT, MAKEWPARAM(1, 0), (LPARAM)GetMenu(window));
|
||||||
|
break;
|
||||||
|
case 23: SendMessageW(window, WM_EXITMENULOOP, 0, 0); break;
|
||||||
|
case 24:
|
||||||
|
// Replacement with shorter text is not a Delete/Backspace command.
|
||||||
|
SendMessageW(editor, EM_SETSEL, 0, 5);
|
||||||
|
SendMessageW(editor, EM_REPLACESEL, TRUE, (LPARAM)L"hi");
|
||||||
|
break;
|
||||||
|
case 25:
|
||||||
|
SetWindowTextW(editor, L"first\r\nsecond");
|
||||||
|
SendMessageW(editor, EM_SETSEL, 7, 7);
|
||||||
|
SendMessageW(editor, WM_CHAR, VK_BACK, 1);
|
||||||
|
break;
|
||||||
|
case 26:
|
||||||
|
SetWindowTextW(editor, L"first\r\nsecond");
|
||||||
|
SendMessageW(editor, EM_SETSEL, 5, 5);
|
||||||
|
key(VK_DELETE, FALSE, FALSE);
|
||||||
|
break;
|
||||||
|
case 27:
|
||||||
|
// A surrogate at the 1023-unit line boundary must not discard the line.
|
||||||
|
{
|
||||||
|
WCHAR line[1027];
|
||||||
|
for (int i = 0; i < 1022; ++i) line[i] = L'x';
|
||||||
|
line[1022] = 0xd83d; line[1023] = 0xde00;
|
||||||
|
line[1024] = L'y'; line[1025] = 0;
|
||||||
|
SetWindowTextW(editor, line);
|
||||||
|
SendMessageW(editor, EM_SETSEL, 0, 0);
|
||||||
|
SetFocus(password); SetFocus(editor);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 28:
|
||||||
|
SetWindowTextW(editor, L"undo restored");
|
||||||
|
SendMessageW(editor, EM_SETSEL, 0, 4);
|
||||||
|
SendMessageW(editor, EM_REPLACESEL, TRUE, (LPARAM)L"x");
|
||||||
|
SendMessageW(editor, EM_UNDO, 0, 0);
|
||||||
|
SendMessageW(editor, EM_SETSEL, 1, 1);
|
||||||
|
break;
|
||||||
|
case 29:
|
||||||
|
key(VK_RIGHT, FALSE, FALSE); // d, not the space in the old snapshot
|
||||||
|
break;
|
||||||
|
case 30:
|
||||||
|
key(VK_END, TRUE, FALSE); // whole restored line
|
||||||
|
break;
|
||||||
|
default:
|
||||||
DestroyWindow(window); break;
|
DestroyWindow(window); break;
|
||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
@@ -65,14 +121,25 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show)
|
|||||||
HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW,
|
HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW,
|
||||||
120, 120, 500, 300, NULL, NULL, instance, NULL);
|
120, 120, 500, 300, NULL, NULL, instance, NULL);
|
||||||
editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line",
|
editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line",
|
||||||
WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL,
|
WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL | ES_AUTOHSCROLL,
|
||||||
10, 10, 450, 150, window, NULL, instance, NULL);
|
10, 10, 450, 150, window, NULL, instance, NULL);
|
||||||
password = CreateWindowW(L"Edit", L"private-password-fixture",
|
password = CreateWindowW(L"Edit", L"private-password-fixture",
|
||||||
WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30,
|
WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30,
|
||||||
window, NULL, instance, NULL);
|
window, NULL, instance, NULL);
|
||||||
|
HMENU menu = CreateMenu();
|
||||||
|
AppendMenuW(menu, MF_STRING, 1, L"Test menu");
|
||||||
|
SetMenu(window, menu);
|
||||||
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
|
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
|
||||||
SendMessageW(editor, EM_SETSEL, 0, 0);
|
SendMessageW(editor, EM_SETSEL, 0, 0);
|
||||||
SetTimer(window, 1, 350, NULL);
|
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);
|
||||||
|
}
|
||||||
|
// Leave time to attach/restart a helper while this already-focused window
|
||||||
|
// keeps pumping messages. Subsequent actions use the ordinary short timer.
|
||||||
|
SetTimer(window, 1, 1500, NULL);
|
||||||
MSG message;
|
MSG message;
|
||||||
while (GetMessageW(&message, NULL, 0, 0) > 0) {
|
while (GetMessageW(&message, NULL, 0, 0) > 0) {
|
||||||
TranslateMessage(&message); DispatchMessageW(&message);
|
TranslateMessage(&message); DispatchMessageW(&message);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Exercise a real Wine menu hook against a private D-Bus recording service.
|
"""Exercise Wine menus/editors against a private D-Bus recording service.
|
||||||
|
|
||||||
Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to
|
Requires a graphical session, PyGObject, Wine, and MinGW. No speech is sent to
|
||||||
the running reader. The fixture window closes automatically.
|
the running reader. The fixture window closes automatically.
|
||||||
@@ -16,13 +16,19 @@ 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"), default="menu")
|
parser.add_argument("--fixture", choices=("menu", "edit", "notepad"), default="menu")
|
||||||
|
parser.add_argument("--late-helper", action="store_true")
|
||||||
|
parser.add_argument("--restart-helper", action="store_true")
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
if (args.late_helper or args.restart_helper) and args.fixture != "edit":
|
||||||
|
parser.error("helper lifecycle checks require --fixture edit")
|
||||||
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,
|
||||||
|
*(["--late-helper"] if args.late_helper else []),
|
||||||
|
*(["--restart-helper"] if args.restart_helper else [])]))
|
||||||
|
|
||||||
interface = "org.stormux.Cthulhu1.Service"
|
interface = "org.stormux.Cthulhu1.Service"
|
||||||
object_path = "/org/stormux/Cthulhu1/Service"
|
object_path = "/org/stormux/Cthulhu1/Service"
|
||||||
@@ -47,6 +53,8 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
|||||||
env = os.environ.copy()
|
env = os.environ.copy()
|
||||||
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all",
|
env.update(WINEPREFIX=str(Path(temporary) / "prefix"), WINEDEBUG="-all",
|
||||||
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
|
||||||
|
ready = Path(temporary) / "ready"
|
||||||
|
env["WINE_ACCESS_FIXTURE_READY"] = "Z:" + str(ready).replace("/", "\\")
|
||||||
fixture = Path(temporary) / "menu-fixture.exe"
|
fixture = Path(temporary) / "menu-fixture.exe"
|
||||||
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
|
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
|
||||||
"-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")),
|
"-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")),
|
||||||
@@ -55,6 +63,12 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
|||||||
app = None
|
app = None
|
||||||
try:
|
try:
|
||||||
subprocess.run(["wineboot", "-u"], env=env, check=True, timeout=45)
|
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
|
||||||
|
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"
|
||||||
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
|
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
|
||||||
# Allow the helper to register its bridge before opening the fixture.
|
# Allow the helper to register its bridge before opening the fixture.
|
||||||
deadline = time.monotonic() + 1
|
deadline = time.monotonic() + 1
|
||||||
@@ -63,17 +77,43 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
|||||||
pass
|
pass
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
assert helper.poll() is None, "helper exited before fixture startup"
|
assert helper.poll() is None, "helper exited before fixture startup"
|
||||||
|
if app is None:
|
||||||
app = subprocess.Popen(["wine", str(fixture)], env=env)
|
app = subprocess.Popen(["wine", str(fixture)], env=env)
|
||||||
deadline = time.monotonic() + 15
|
restarted = False
|
||||||
|
deadline = time.monotonic() + 25
|
||||||
while app.poll() is None and time.monotonic() < deadline:
|
while app.poll() is None and time.monotonic() < deadline:
|
||||||
while GLib.MainContext.default().iteration(False):
|
while GLib.MainContext.default().iteration(False):
|
||||||
pass
|
pass
|
||||||
|
if args.restart_helper and not restarted and any(e[0].endswith(":text") for e in events):
|
||||||
|
helper.terminate()
|
||||||
|
helper.wait(timeout=5)
|
||||||
|
helper = subprocess.Popen([str(build / "cthulhu-wine-access.exe"), "--no-controller"], env=env)
|
||||||
|
restarted = True
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
assert app.poll() == 0, "fixture failed or timed out"
|
assert app.poll() == 0, "fixture failed or timed out"
|
||||||
|
assert helper.poll() is None, "helper exited during the fixture"
|
||||||
|
if args.restart_helper:
|
||||||
|
assert restarted, "fixture never triggered a helper restart"
|
||||||
for _ in range(20):
|
for _ in range(20):
|
||||||
while GLib.MainContext.default().iteration(False):
|
while GLib.MainContext.default().iteration(False):
|
||||||
pass
|
pass
|
||||||
time.sleep(0.01)
|
time.sleep(0.01)
|
||||||
|
focused = None
|
||||||
|
for event in events:
|
||||||
|
if event[1] == "focus":
|
||||||
|
focused = event[0]
|
||||||
|
elif event[1] == "text":
|
||||||
|
assert event[0] == focused, f"text sent without establishing focus: {event!r}"
|
||||||
|
if args.fixture == "notepad":
|
||||||
|
text_events = [e for e in events if e[0].endswith(":text")]
|
||||||
|
spoken = [e[2] for e in text_events]
|
||||||
|
for expected in ("e", "l", "beta", "Notepad second line", "N selected",
|
||||||
|
"N unselected", "N deleted"):
|
||||||
|
assert expected in spoken, f"missing Notepad feedback: {expected!r}; {text_events!r}"
|
||||||
|
assert spoken.count("l") == 2, "Notepad lost repeated characters"
|
||||||
|
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 == "edit":
|
if args.fixture == "edit":
|
||||||
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:
|
||||||
@@ -88,6 +128,12 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
|
|||||||
assert any(e[6] == "password" for e in text_events), "password role missing"
|
assert any(e[6] == "password" for e in text_events), "password role missing"
|
||||||
assert not any("private-password-fixture" in str(e) for e in events), "password leaked"
|
assert not any("private-password-fixture" in str(e) for e in events), "password leaked"
|
||||||
assert "😀 deleted" not in spoken, "read-only control reported deletion"
|
assert "😀 deleted" not in spoken, "read-only control reported deletion"
|
||||||
|
assert "hello deleted" not in spoken, "replacement misreported as deletion"
|
||||||
|
assert sum(e[1] == "text" and e[2] == "l" for e in text_events) == 3, "rapid repeated letters lost"
|
||||||
|
assert any(e[3] == "edit" and e[4] == "menu return line" for e in text_events), "menu return lost caret line"
|
||||||
|
assert spoken.count("new line deleted") == 2, "CRLF deletion feedback missing"
|
||||||
|
assert any(e[4] == "x" * 1022 for e in text_events), "truncated surrogate discarded line"
|
||||||
|
assert "d" in spoken and "undo restored" in spoken, "Undo left stale text snapshot"
|
||||||
print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls")
|
print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls")
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
menus = [event for event in events if ":menu:" in event[0]]
|
menus = [event for event in events if ":menu:" in event[0]]
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
// SPDX-License-Identifier: LGPL-2.1-or-later
|
||||||
|
// Drive Wine's actual Notepad; the standard-control fixture covers edge cases.
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
static DWORD notepad_pid;
|
||||||
|
static HWND notepad;
|
||||||
|
|
||||||
|
static BOOL CALLBACK find_notepad(HWND window, LPARAM unused) {
|
||||||
|
(void)unused;
|
||||||
|
DWORD pid;
|
||||||
|
GetWindowThreadProcessId(window, &pid);
|
||||||
|
if (pid == notepad_pid && IsWindowVisible(window) &&
|
||||||
|
FindWindowExW(window, NULL, L"Edit", NULL)) {
|
||||||
|
notepad = 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
|
||||||
|
(void)instance; (void)previous; (void)args; (void)show;
|
||||||
|
STARTUPINFOW startup = {0};
|
||||||
|
PROCESS_INFORMATION process = {0};
|
||||||
|
startup.cb = sizeof(startup);
|
||||||
|
WCHAR command[] = L"notepad.exe";
|
||||||
|
if (!CreateProcessW(NULL, command, NULL, NULL, FALSE, 0, NULL, NULL, &startup, &process)) return 2;
|
||||||
|
notepad_pid = process.dwProcessId;
|
||||||
|
for (int i = 0; i < 100 && !notepad; ++i) {
|
||||||
|
EnumWindows(find_notepad, 0);
|
||||||
|
Sleep(50);
|
||||||
|
}
|
||||||
|
int result = 2;
|
||||||
|
if (notepad) {
|
||||||
|
HWND edit = FindWindowExW(notepad, NULL, L"Edit", NULL);
|
||||||
|
SetForegroundWindow(notepad);
|
||||||
|
SendMessageW(edit, WM_SETTEXT, 0, (LPARAM)L"hello beta\r\nNotepad second line");
|
||||||
|
SendMessageW(edit, EM_SETSEL, 0, 0);
|
||||||
|
Sleep(400);
|
||||||
|
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_DOWN, FALSE, FALSE); // second line
|
||||||
|
key(VK_HOME, FALSE, FALSE);
|
||||||
|
key(VK_RIGHT, FALSE, TRUE); // N selected
|
||||||
|
key(VK_LEFT, FALSE, TRUE); // N unselected
|
||||||
|
key(VK_DELETE, FALSE, FALSE);// N deleted
|
||||||
|
key(VK_MENU, FALSE, FALSE);
|
||||||
|
key(VK_ESCAPE, FALSE, FALSE);
|
||||||
|
key(VK_ESCAPE, FALSE, FALSE);
|
||||||
|
WCHAR text[80] = {0};
|
||||||
|
SendMessageW(edit, WM_GETTEXT, 80, (LPARAM)text);
|
||||||
|
if (!lstrcmpW(text, L"hello beta\r\notepad second line")) result = 0;
|
||||||
|
// Suppress the save prompt only in this test-owned, unsaved document.
|
||||||
|
SendMessageW(edit, EM_SETMODIFY, FALSE, 0);
|
||||||
|
PostMessageW(notepad, WM_CLOSE, 0, 0);
|
||||||
|
if (WaitForSingleObject(process.hProcess, 3000) != WAIT_OBJECT_0) result = 2;
|
||||||
|
}
|
||||||
|
CloseHandle(process.hThread);
|
||||||
|
CloseHandle(process.hProcess);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user