Work started on editor support.

This commit is contained in:
Storm Dragon committed 2026-10-09 15:57:45 -04:00
1 parent 0992ffee0d
commit 6f6d4040c4
9 files changed
+401 -5

No files matched your search

+2 -2
View File
@@ -15,8 +15,8 @@ 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"
-luser32
DEPENDS menu_hook.c menu_bridge.h
"${CMAKE_CURRENT_SOURCE_DIR}/edit_hook.c" -luser32 -lcomctl32
DEPENDS menu_hook.c menu_bridge.h edit_hook.c edit_hook.h
VERBATIM)
add_custom_target(wine-access-menu ALL DEPENDS "${menu_hook}")
@@ -1,6 +1,6 @@
# Maintainer: Storm Dragon <storm_dragon@stormux.org>
pkgname=wine-access-git
pkgver=r3.gf365af3
pkgver=r6.g0992ffe
pkgrel=1
pkgdesc='Wine accessibility helper with MSAA, NVDA Controller, and Tolk compatibility for Cthulhu'
arch=('x86_64')
+203
View File
@@ -0,0 +1,203 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "edit_hook.h"
#include "menu_bridge.h"
#include <commctrl.h>
#include <stdlib.h>
#define EDIT_SUBCLASS_ID 0x57415431
#define EDIT_STATE_PROPERTY L"CthulhuWineEditState"
// Bound memory and work inside applications. Navigation reuses the snapshot;
// only text changes refresh it. Large documents retain line/character reading.
#define MAX_SNAPSHOT_UNITS (1024 * 1024)
typedef struct EditState {
WCHAR *text;
DWORD length, start, end;
BOOL busy, known, destroyed;
} EditState;
static void selection(HWND window, DWORD *start, DWORD *end) {
SendMessageW(window, EM_GETSEL, (WPARAM)start, (LPARAM)end);
}
static void snapshot(HWND window, EditState *state) {
free(state->text);
state->text = NULL;
state->length = GetWindowTextLengthW(window);
if (state->length <= MAX_SNAPSHOT_UNITS) {
state->text = calloc((size_t)state->length + 1, sizeof(WCHAR));
if (state->text)
state->length = GetWindowTextW(window, state->text, state->length + 1);
}
}
static void copy_range(WCHAR *output, const WCHAR *text, DWORD start, DWORD end) {
UINT used = 0;
while (start < end && used < 1023) output[used++] = text[start++];
// Never leave half a UTF-16 surrogate pair at the truncation boundary.
if (used && output[used - 1] >= 0xd800 && output[used - 1] <= 0xdbff) --used;
output[used] = 0;
}
static void current_line(HWND window, DWORD caret, WCHAR *output) {
LRESULT line = SendMessageW(window, EM_LINEFROMCHAR, caret, 0);
// EM_GETLINE expects the buffer capacity in its first WORD.
output[0] = 1023;
LRESULT copied = SendMessageW(window, EM_GETLINE, line, (LPARAM)output);
if (copied < 0 || copied > 1023) copied = 0;
output[copied] = 0;
}
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) {
HWND owner = GetAncestor(window, GA_ROOT);
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
if (!bridge || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) || GetFocus() != window)
return;
WineAccessTextEvent event = {0};
event.owner = (ULONGLONG)(UINT_PTR)owner;
event.control = (ULONGLONG)(UINT_PTR)window;
event.kind = kind; event.start = start; event.end = end;
lstrcpynW(event.text, text, 1024);
GetWindowTextW(owner, event.title, 256);
COPYDATASTRUCT data = {WINE_ACCESS_TEXT_TAG, sizeof(event), &event};
DWORD_PTR result;
SendMessageTimeoutW(bridge, WM_COPYDATA, (WPARAM)window, (LPARAM)&data,
SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result);
}
static LRESULT CALLBACK edit_proc(HWND window, UINT message, WPARAM wp, LPARAM lp,
UINT_PTR id, DWORD_PTR reference) {
EditState *state = (EditState *)reference;
if (message == WM_NCDESTROY) {
RemoveWindowSubclass(window, edit_proc, id);
RemovePropW(window, EDIT_STATE_PROPERTY);
state->destroyed = TRUE;
if (!state->busy) { free(state->text); free(state); }
return DefSubclassProc(window, message, wp, lp);
}
if (state->busy) return DefSubclassProc(window, message, wp, lp);
BOOL navigation = message == WM_KEYDOWN &&
(wp == VK_LEFT || wp == VK_RIGHT || wp == VK_UP || wp == VK_DOWN ||
wp == VK_HOME || wp == VK_END || wp == VK_PRIOR || wp == VK_NEXT);
BOOL changing = message == WM_CHAR || (message == WM_KEYDOWN && wp == VK_DELETE) ||
message == WM_PASTE || message == WM_CUT || message == WM_CLEAR ||
message == WM_UNDO || message == EM_REPLACESEL || message == WM_SETTEXT;
BOOL focus = message == WM_SETFOCUS;
BOOL selected = message == EM_SETSEL;
if (!navigation && !changing && !focus && !selected && message != EM_SETPASSWORDCHAR)
return DefSubclassProc(window, message, wp, lp);
state->busy = TRUE;
DWORD old_start = 0, old_end = 0;
selection(window, &old_start, &old_end);
BOOL password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
if (!password && (!state->known || focus)) snapshot(window, state);
WCHAR removed[1024] = {0};
if (!password && state->text && changing) {
DWORD first = old_start, last = old_end;
if (first == last) {
if (message == WM_CHAR && wp == VK_BACK && first) {
--first;
if (first && state->text[first] >= 0xdc00 && state->text[first] <= 0xdfff) --first;
} else if (message == WM_KEYDOWN && wp == VK_DELETE && last < state->length) {
++last;
if (last < state->length && state->text[last - 1] >= 0xd800 && state->text[last - 1] <= 0xdbff) ++last;
}
}
if (last <= state->length) copy_range(removed, state->text, first, last);
}
LRESULT result = DefSubclassProc(window, message, wp, lp);
if (state->destroyed) {
free(state->text); free(state);
return result;
}
DWORD start = 0, end = 0;
selection(window, &start, &end);
password = (GetWindowLongPtrW(window, GWL_STYLE) & ES_PASSWORD) != 0;
if (password) {
free(state->text); state->text = NULL; state->known = FALSE;
if (focus) notify_text(window, WINE_ACCESS_TEXT_PASSWORD, L"", start, end);
state->busy = FALSE;
return result;
}
DWORD old_length = state->length;
if (changing || message == EM_SETPASSWORDCHAR) snapshot(window, state);
state->known = TRUE;
WCHAR text[1024] = {0};
UINT kind = WINE_ACCESS_TEXT_READ;
BOOL report = FALSE;
if (focus) {
current_line(window, start, text);
kind = WINE_ACCESS_TEXT_FOCUS; report = TRUE;
} else if ((navigation || selected) && (start != old_start || end != old_end)) {
if (start != end && state->text && end <= state->length) {
// Speak the changed portion when extending or shrinking a selection.
DWORD first = start, last = end;
kind = WINE_ACCESS_TEXT_SELECTED;
if (old_start != old_end && start == old_start) {
first = end < old_end ? end : old_end;
last = end > old_end ? end : old_end;
if (end < old_end) kind = WINE_ACCESS_TEXT_UNSELECTED;
} else if (old_start != old_end && end == old_end) {
first = start < old_start ? start : old_start;
last = start > old_start ? start : old_start;
if (start > old_start) kind = WINE_ACCESS_TEXT_UNSELECTED;
}
copy_range(text, state->text, first, last);
} else if (old_start != old_end && state->text && old_end <= state->length) {
copy_range(text, state->text, old_start, old_end);
kind = WINE_ACCESS_TEXT_UNSELECTED;
} else if (navigation && (wp == VK_UP || wp == VK_DOWN || wp == VK_PRIOR || wp == VK_NEXT)) {
current_line(window, start, text);
} else if (state->text && start < state->length) {
DWORD last = start + 1;
if (GetKeyState(VK_CONTROL) & 0x8000) {
while (last < state->length && !word_space(state->text[last])) ++last;
} else if (state->text[start] >= 0xd800 && state->text[start] <= 0xdbff && last < state->length) {
++last;
}
copy_range(text, state->text, start, last);
} else if (!state->text) {
current_line(window, start, text);
}
report = TRUE;
} else if (changing && removed[0] && old_length > state->length) {
lstrcpynW(text, removed, 1024);
kind = WINE_ACCESS_TEXT_DELETED; report = TRUE;
}
if (report) notify_text(window, kind, text, start, end);
state->start = start; state->end = end;
state->busy = FALSE;
return result;
}
void wine_access_watch_edit(HWND window) {
WCHAR class_name[64];
if (!window || !GetClassNameW(window, class_name, 64) || lstrcmpW(class_name, L"Edit")) return;
// 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;
EditState *state = calloc(1, sizeof(*state));
if (!state) return;
if (!SetWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID, (DWORD_PTR)state)) {
free(state); return;
}
if (!SetPropW(window, EDIT_STATE_PROPERTY, state)) {
RemoveWindowSubclass(window, edit_proc, EDIT_SUBCLASS_ID);
free(state); return;
}
// Subclass callbacks can outlive the reader. Keep their module mapped until
// the application exits; each control frees its state on WM_NCDESTROY.
HMODULE module;
GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_PIN,
(LPCWSTR)(UINT_PTR)edit_proc, &module);
}
__declspec(dllexport) LRESULT CALLBACK WineAccessInputHook(int code, WPARAM wp, LPARAM lp) {
if (code >= 0 && wp == PM_REMOVE) wine_access_watch_edit(GetFocus());
return CallNextHookEx(NULL, code, wp, lp);
}
+4
View File
@@ -0,0 +1,4 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#pragma once
#include <windows.h>
void wine_access_watch_edit(HWND window);
+71
View File
@@ -24,6 +24,8 @@ constexpr auto INTERFACE_NAME = "org.stormux.Cthulhu1.Service";
constexpr int DBUS_TIMEOUT_MS = 500;
GDBusConnection *connection = nullptr;
std::deque<WineAccessMenuEvent> menuEvents;
std::deque<WineAccessTextEvent> textEvents;
bool editReaderActive = false;
std::string lastMenuEvent;
ULONGLONG lastMenuOwner = 0;
@@ -360,9 +362,59 @@ void present_menu_event(const WineAccessMenuEvent &event) {
}
}
void present_text_event(const WineAccessTextEvent &event) {
HWND owner = reinterpret_cast<HWND>(static_cast<UINT_PTR>(event.owner));
HWND window = reinterpret_cast<HWND>(static_cast<UINT_PTR>(event.control));
GUITHREADINFO info{};
info.cbSize = sizeof(info);
if (owner == nullptr || owner != GetAncestor(GetForegroundWindow(), GA_ROOT) ||
!GetGUIThreadInfo(GetWindowThreadProcessId(owner, nullptr), &info) || info.hwndFocus != window)
return;
std::string text = to_utf8(event.text);
ControlDescription control;
if (event.kind == WINE_ACCESS_TEXT_PASSWORD) {
control.role = "edit";
control.description = "password";
} else if (event.kind == WINE_ACCESS_TEXT_FOCUS) {
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.value = text.empty() ? "blank" : text;
} else {
if (text == " ") text = "space";
else if (text == "\t") text = "tab";
else if (text == "\r" || text == "\n" || text == "\r\n") text = "new line";
const char *format = nullptr;
if (event.kind == WINE_ACCESS_TEXT_SELECTED) format = "%s selected";
else if (event.kind == WINE_ACCESS_TEXT_UNSELECTED) format = "%s unselected";
else if (event.kind == WINE_ACCESS_TEXT_DELETED) format = "%s deleted";
if (format != nullptr) {
gchar *message = g_strdup_printf(format, text.c_str());
control.name = message;
g_free(message);
} else {
control.name = text.empty() ? "blank" : text;
}
}
present_accessible_event(source_id(window, "text"), "focus", control, to_utf8(event.title));
}
LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (message == WM_COPYDATA && lp != 0) {
const auto *data = reinterpret_cast<const COPYDATASTRUCT *>(lp);
if (data->dwData == WINE_ACCESS_TEXT_TAG && data->cbData == sizeof(WineAccessTextEvent) &&
data->lpData != nullptr && textEvents.size() < 32) {
auto event = *static_cast<const WineAccessTextEvent *>(data->lpData);
event.text[std::size(event.text) - 1] = L'\0';
event.title[std::size(event.title) - 1] = L'\0';
textEvents.push_back(event);
if (!PostMessageW(window, WINE_ACCESS_MENU_MESSAGE + 1, 0, 0)) {
textEvents.pop_back();
return FALSE;
}
return TRUE;
}
if (data->dwData != WINE_ACCESS_MENU_TAG || data->cbData != sizeof(WineAccessMenuEvent) ||
data->lpData == nullptr || menuEvents.size() >= 32)
return FALSE;
@@ -385,6 +437,14 @@ LRESULT CALLBACK menu_bridge_proc(HWND window, UINT message, WPARAM wp, LPARAM l
}
return 0;
}
if (message == WINE_ACCESS_MENU_MESSAGE + 1) {
if (!textEvents.empty()) {
const auto event = textEvents.front();
textEvents.pop_front();
present_text_event(event);
}
return 0;
}
return DefWindowProcW(window, message, wp, lp);
}
@@ -411,6 +471,10 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
event != EVENT_OBJECT_VALUECHANGE && event != EVENT_OBJECT_SELECTION &&
event != EVENT_OBJECT_STATECHANGE)
return;
wchar_t className[64] = {};
if (editReaderActive && GetClassNameW(window, className, std::size(className)) &&
lstrcmpW(className, L"Edit") == 0)
return;
IAccessible *accessible = nullptr;
VARIANT child{};
VariantInit(&child);
@@ -639,6 +703,7 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
HWINEVENTHOOK hook = nullptr;
HHOOK menuHook = nullptr;
HHOOK inputHook = nullptr;
HMODULE menuModule = nullptr;
HWND menuBridge = nullptr;
if (dialogReaderEnabled) {
@@ -656,6 +721,10 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
const auto callback = std::bit_cast<HOOKPROC>(GetProcAddress(menuModule, "WineAccessMenuHook"));
if (callback != nullptr)
menuHook = SetWindowsHookExW(WH_CALLWNDPROC, callback, menuModule, 0);
const auto inputCallback = std::bit_cast<HOOKPROC>(GetProcAddress(menuModule, "WineAccessInputHook"));
if (inputCallback != nullptr)
inputHook = SetWindowsHookExW(WH_GETMESSAGE, inputCallback, menuModule, 0);
editReaderActive = menuHook != nullptr && inputHook != nullptr;
}
if (menuHook == nullptr)
std::fprintf(stderr, "wine-access: native menu hook unavailable (error %lu)\n",
@@ -670,6 +739,8 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
UnhookWinEvent(hook);
if (menuHook != nullptr)
UnhookWindowsHookEx(menuHook);
if (inputHook != nullptr)
UnhookWindowsHookEx(inputHook);
if (menuBridge != nullptr)
DestroyWindow(menuBridge);
if (menuModule != nullptr)
+17
View File
@@ -9,6 +9,23 @@
#define WINE_ACCESS_MENU_SUBMENU 2
#define WINE_ACCESS_MENU_CHECKED 4
#define WINE_ACCESS_MENU_DISABLED 8
#define WINE_ACCESS_TEXT_TAG 0x57415431
#define WINE_ACCESS_TEXT_FOCUS 1
#define WINE_ACCESS_TEXT_SELECTED 2
#define WINE_ACCESS_TEXT_UNSELECTED 3
#define WINE_ACCESS_TEXT_DELETED 4
#define WINE_ACCESS_TEXT_READ 5
#define WINE_ACCESS_TEXT_PASSWORD 6
typedef struct WineAccessTextEvent {
ULONGLONG owner;
ULONGLONG control;
UINT kind;
DWORD start;
DWORD end;
WCHAR text[1024];
WCHAR title[256];
} WineAccessTextEvent;
typedef struct WineAccessMenuEvent {
ULONGLONG owner;
+2
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include "menu_bridge.h"
#include "edit_hook.h"
static void menu_text(WCHAR *output, UINT capacity, const WCHAR *input) {
UINT used = 0;
@@ -19,6 +20,7 @@ __declspec(dllexport) LRESULT CALLBACK WineAccessMenuHook(int code, WPARAM wp,
LPARAM lp) {
if (code >= 0) {
const CWPSTRUCT *message = (const CWPSTRUCT *)lp;
if (message->message == WM_SETFOCUS) wine_access_watch_edit(message->hwnd);
if (message->message == WM_MENUSELECT || message->message == WM_EXITMENULOOP) {
HWND bridge = FindWindowW(WINE_ACCESS_MENU_CLASS, NULL);
HWND owner = GetAncestor(message->hwnd, GA_ROOT);
+81
View File
@@ -0,0 +1,81 @@
// SPDX-License-Identifier: LGPL-2.1-or-later
#include <windows.h>
static HWND editor, password;
static int step;
static void key(UINT key, BOOL control, BOOL shift) {
BYTE saved[256], state[256];
GetKeyboardState(saved);
CopyMemory(state, saved, sizeof(state));
state[VK_CONTROL] = control ? 0x80 : 0;
state[VK_SHIFT] = shift ? 0x80 : 0;
SetKeyboardState(state);
SendMessageW(editor, WM_KEYDOWN, key, 1);
SetKeyboardState(saved);
}
static LRESULT CALLBACK window_proc(HWND window, UINT message, WPARAM wp, LPARAM lp) {
if (message == WM_TIMER) {
switch (++step) {
case 1: key(VK_RIGHT, FALSE, FALSE); break;
case 2: key(VK_RIGHT, TRUE, FALSE); break;
case 3: key(VK_DOWN, FALSE, FALSE); break;
case 4: key(VK_HOME, FALSE, FALSE); break;
case 5: key(VK_RIGHT, FALSE, TRUE); break;
case 6: key(VK_RIGHT, FALSE, TRUE); break;
case 7: key(VK_LEFT, FALSE, TRUE); break;
case 8: key(VK_LEFT, FALSE, FALSE); break;
case 9: key(VK_DELETE, FALSE, FALSE); break;
case 10:
key(VK_RIGHT, FALSE, FALSE);
SendMessageW(editor, WM_CHAR, VK_BACK, 1);
break;
case 11: SendMessageW(editor, EM_SETSEL, 0, 5); break;
case 12: SendMessageW(editor, WM_CLEAR, 0, 0); break;
case 13: SetFocus(password); break;
case 14: SendMessageW(password, WM_KEYDOWN, VK_LEFT, 1); break;
case 15: SendMessageW(password, WM_CHAR, VK_BACK, 1); break;
case 16:
SetFocus(editor);
SetWindowTextW(editor, L"\U0001f600 cafe");
SendMessageW(editor, EM_SETSEL, 2, 2);
break;
case 17: key(VK_HOME, FALSE, FALSE); break;
case 18:
SendMessageW(editor, EM_SETREADONLY, TRUE, 0);
SetFocus(password); SetFocus(editor);
break;
case 19: key(VK_DELETE, FALSE, FALSE); break;
default:
if (GetWindowTextLengthW(editor) != 7) ExitProcess(2);
DestroyWindow(window); break;
}
return 0;
}
if (message == WM_DESTROY) { PostQuitMessage(0); return 0; }
return DefWindowProcW(window, message, wp, lp);
}
int WINAPI WinMain(HINSTANCE instance, HINSTANCE previous, LPSTR args, int show) {
(void)previous; (void)args; (void)show;
WNDCLASSW cls = {0};
cls.hInstance = instance; cls.lpszClassName = L"WineAccessEditFixture";
cls.lpfnWndProc = window_proc; RegisterClassW(&cls);
HWND window = CreateWindowW(cls.lpszClassName, L"Wine editor fixture", WS_OVERLAPPEDWINDOW,
120, 120, 500, 300, NULL, NULL, instance, NULL);
editor = CreateWindowW(L"Edit", L"alpha beta\r\nsecond line",
WS_CHILD | WS_VISIBLE | WS_TABSTOP | ES_MULTILINE | ES_AUTOVSCROLL,
10, 10, 450, 150, window, NULL, instance, NULL);
password = CreateWindowW(L"Edit", L"private-password-fixture",
WS_CHILD | WS_VISIBLE | ES_PASSWORD, 10, 180, 300, 30,
window, NULL, instance, NULL);
ShowWindow(window, SW_SHOW); SetForegroundWindow(window); SetFocus(editor);
SendMessageW(editor, EM_SETSEL, 0, 0);
SetTimer(window, 1, 350, NULL);
MSG message;
while (GetMessageW(&message, NULL, 0, 0) > 0) {
TranslateMessage(&message); DispatchMessageW(&message);
}
return 0;
}
+20 -2
View File
@@ -16,11 +16,13 @@ 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"), default="menu")
args = parser.parse_args()
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)]))
__file__, "--session", "--build-dir", str(build),
"--fixture", args.fixture]))
interface = "org.stormux.Cthulhu1.Service"
object_path = "/org/stormux/Cthulhu1/Service"
@@ -47,7 +49,7 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
WINEDLLOVERRIDES="mscoree,mshtml=", CTHULHU_WINE_ACCESS_MANAGED="1")
fixture = Path(temporary) / "menu-fixture.exe"
subprocess.run(["x86_64-w64-mingw32-gcc", "-Wall", "-Wextra", "-mwindows",
"-o", str(fixture), str(Path(__file__).with_name("menu_fixture.c")),
"-o", str(fixture), str(Path(__file__).with_name(args.fixture + "_fixture.c")),
"-luser32"], check=True)
helper = None
app = None
@@ -72,6 +74,22 @@ with tempfile.TemporaryDirectory(prefix="wine-access-menus-") as temporary:
while GLib.MainContext.default().iteration(False):
pass
time.sleep(0.01)
if args.fixture == "edit":
text_events = [e for e in events if e[0].endswith(":text")]
for event in text_events:
print(event[2:7])
spoken = [e[2] for e in text_events]
for expected in ("l", "beta", "second line", "s selected", "e selected",
"e unselected", "s unselected", "s deleted", "e deleted",
"alpha selected", "alpha deleted", "😀"):
assert expected in spoken, f"missing editor feedback: {expected!r}"
assert any(e[3] == "edit" and e[4] == "alpha beta" for e in text_events), "initial line missing"
assert any(e[3] == "read only edit" for e in text_events), "read-only focus 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 "😀 deleted" not in spoken, "read-only control reported deletion"
print("PASS: editor focus, characters, words, lines, selection, deletion, Unicode, passwords, and read-only controls")
sys.exit(0)
menus = [event for event in events if ":menu:" in event[0]]
for event in menus:
print(event[2:7])