Refine Wine dialog event presentation

This commit is contained in:
Storm Dragon
2026-07-17 19:51:33 -04:00
parent 1fd9acd94b
commit f6b7f2ad71
5 changed files with 351 additions and 13 deletions
+7
View File
@@ -304,6 +304,13 @@ class Backend:
def _migrateSettings(self, settingsDict): def _migrateSettings(self, settingsDict):
"""Migrate old setting names to new ones.""" """Migrate old setting names to new ones."""
migrationKey = 'wineAccessibilityPluginMigrated'
activePlugins = settingsDict.get('activePlugins')
if isinstance(activePlugins, list) and migrationKey not in settingsDict:
if 'WineAccessibility' not in activePlugins:
activePlugins.append('WineAccessibility')
settingsDict[migrationKey] = True
# Migration: enableSpeechIndentation -> enableIndentation # Migration: enableSpeechIndentation -> enableIndentation
if 'enableSpeechIndentation' in settingsDict and 'enableIndentation' not in settingsDict: if 'enableSpeechIndentation' in settingsDict and 'enableIndentation' not in settingsDict:
settingsDict['enableIndentation'] = settingsDict['enableSpeechIndentation'] settingsDict['enableIndentation'] = settingsDict['enableSpeechIndentation']
+85 -5
View File
@@ -25,6 +25,7 @@ import contextlib
import enum import enum
import inspect import inspect
import os import os
import time
import types import types
import typing import typing
import xml.etree.ElementTree as ET import xml.etree.ElementTree as ET
@@ -641,6 +642,8 @@ class _InterfaceBuilder:
class CthulhuDBusServiceInterface(Publishable): class CthulhuDBusServiceInterface(Publishable):
"""Internal D-Bus service object that handles D-Bus specifics.""" """Internal D-Bus service object that handles D-Bus specifics."""
_WINE_FOCUS_DEBOUNCE_MS = 50
def for_publication(self): def for_publication(self):
"""Returns the D-Bus interface XML for publication.""" """Returns the D-Bus interface XML for publication."""
@@ -744,15 +747,92 @@ class CthulhuDBusServiceInterface(Publishable):
) -> bool: ) -> bool:
"""Presents a normalized accessibility event received from Wine.""" """Presents a normalized accessibility event received from Wine."""
del sourceId, eventType, states del states
eventType = eventType.casefold()
debug.print_message(
debug.LEVEL_INFO,
(
"WINE ACCESS EVENT: "
f"type={eventType} source={sourceId} name={name!r} "
f"role={role!r} value={value!r} window={windowTitle!r}"
),
True,
)
if eventType == "focus":
self._wineAccessibleFocusSource = sourceId
else:
if eventType not in {"value", "selection"}:
return False
focusedSource = getattr(self, "_wineAccessibleFocusSource", None)
focusedWindow = getattr(self, "_wineAccessibleWindowTitle", None)
pendingFocus = getattr(self, "_wineAccessiblePendingFocus", None)
if focusedWindow is None and pendingFocus is not None:
focusedWindow = pendingFocus[2]
if sourceId != focusedSource:
if eventType != "selection":
return False
if not windowTitle or windowTitle != focusedWindow:
return False
if role.casefold() == "client":
role = ""
parts = [part for part in (name, role, value, description) if part] parts = [part for part in (name, role, value, description) if part]
if position > 0 and count > 0: if position > 0 and count > 0:
parts.append(f"{position} of {count}") parts.append(f"{position} of {count}")
if windowTitle and windowTitle not in parts: if not parts and not (eventType == "focus" and windowTitle):
parts.append(windowTitle)
if not parts:
return False return False
return self.PresentMessage(", ".join(parts))
message = ", ".join(parts)
now = time.monotonic()
signature = (sourceId, message or windowTitle)
lastSignature = getattr(self, "_wineAccessibleLastSignature", None)
lastTime = getattr(self, "_wineAccessibleLastTime", 0.0)
if signature == lastSignature and now - lastTime < 0.1:
if eventType == "focus":
self._wineAccessiblePendingFocus = None
return False
if eventType == "focus":
self._wineAccessiblePendingFocus = (signature, parts, windowTitle)
if not getattr(self, "_wineAccessibleFocusTimeoutId", 0):
self._wineAccessibleFocusTimeoutId = GLib.timeout_add(
self._WINE_FOCUS_DEBOUNCE_MS,
self._present_pending_accessible_focus,
)
return True
pending = getattr(self, "_wineAccessiblePendingFocus", None)
if pending is not None and (
pending[0][0] == sourceId or (windowTitle and pending[2] == windowTitle)
):
self._wineAccessiblePendingFocus = (signature, parts, pending[2])
return True
self._wineAccessibleLastSignature = signature
self._wineAccessibleLastTime = now
return self.PresentMessage(message)
def _present_pending_accessible_focus(self) -> bool:
"""Presents the final focus event after Wine's transient focus burst."""
pending = getattr(self, "_wineAccessiblePendingFocus", None)
self._wineAccessiblePendingFocus = None
self._wineAccessibleFocusTimeoutId = 0
if pending is None:
return False
signature, parts, windowTitle = pending
self._wineAccessibleLastSignature = signature
self._wineAccessibleLastTime = time.monotonic()
parts = list(parts)
if windowTitle:
previousTitle = getattr(self, "_wineAccessibleWindowTitle", None)
self._wineAccessibleWindowTitle = windowTitle
if windowTitle != previousTitle and windowTitle not in parts:
parts.append(windowTitle)
if parts:
self.PresentMessage(", ".join(parts))
return False
def GetVersion(self) -> str: # pylint: disable=invalid-name def GetVersion(self) -> str: # pylint: disable=invalid-name
"""Returns Cthulhu's version and revision if available.""" """Returns Cthulhu's version and revision if available."""
@@ -156,8 +156,206 @@ class NativeRemoteControllerTest(unittest.TestCase):
) )
self.assertTrue(result) self.assertTrue(result)
present_message.assert_not_called()
interface._present_pending_accessible_focus()
present_message.assert_called_once_with("Open, button, 2 of 4, Settings") present_message.assert_called_once_with("Open, button, 2 of 4, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_ignores_changes_from_unfocused_objects(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0, "Settings"
)
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"control-2", "value", "Cancel", "button", "", "", "", 0, 0, "Settings"
)
self.assertFalse(result)
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_presents_focused_value_changes_without_repeating_title(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Version", "combo box", "Windows 10", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"control-1", "value", "Version", "combo box", "Windows 11", "", "", 0, 0,
"Wine configuration"
)
self.assertTrue(result)
present_message.assert_called_once_with("Version, combo box, Windows 11")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_suppresses_immediate_duplicates(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
first = interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings"
)
interface._present_pending_accessible_focus()
second = interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "button", "", "", "", 0, 0, "Settings"
)
self.assertTrue(first)
self.assertFalse(second)
present_message.assert_called_once_with("OK, button, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_burst_presents_only_final_control(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Add application", "button", "", "", "", 0, 0,
"Wine configuration"
)
interface.PresentAccessibleEvent(
"control-2", "focus", "OK", "button", "", "", "", 0, 0,
"Wine configuration"
)
interface.PresentAccessibleEvent(
"control-1", "focus", "Add application", "button", "", "", "", 0, 0,
"Wine configuration"
)
present_message.assert_not_called()
interface._present_pending_accessible_focus()
present_message.assert_called_once_with(
"Add application, button, Wine configuration"
)
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_duplicate_clears_stale_pending_focus(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0,
"Settings"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
interface.PresentAccessibleEvent(
"control-2", "focus", "Cancel", "button", "", "", "", 0, 0,
"Settings"
)
duplicate = interface.PresentAccessibleEvent(
"control-1", "focus", "Open", "button", "", "", "", 0, 0,
"Settings"
)
interface._present_pending_accessible_focus()
self.assertFalse(duplicate)
self.assertEqual(interface._wineAccessibleFocusSource, "control-1")
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_focus_presents_new_window_title_without_control_fields(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
result = interface.PresentAccessibleEvent(
"window", "focus", "", "", "", "", "", 0, 0, "Wine configuration"
)
interface._present_pending_accessible_focus()
self.assertTrue(result)
present_message.assert_called_once_with("Wine configuration")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_omits_generic_client_role(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "OK", "client", "", "", "", 0, 0, "Settings"
)
interface._present_pending_accessible_focus()
present_message.assert_called_once_with("OK, Settings")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_ignores_name_and_state_noise_on_focused_object(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"control-1", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
name_result = interface.PresentAccessibleEvent(
"control-1", "name", "OK", "client", "", "", "", 0, 0,
"Wine configuration"
)
state_result = interface.PresentAccessibleEvent(
"control-1", "state", "OK", "client", "", "focused", "", 0, 0,
"Wine configuration"
)
self.assertFalse(name_result)
self.assertFalse(state_result)
present_message.assert_not_called()
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_accepts_selection_from_focused_combo_child(
self, present_message
):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"combo", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"combo-item-2", "selection", "Windows 8.1", "client", "", "", "", 0, 0,
"Wine configuration"
)
self.assertTrue(result)
present_message.assert_called_once_with("Windows 8.1")
@mock.patch.object(dbus_service.CthulhuDBusServiceInterface, "PresentMessage")
def test_accessible_event_rejects_selection_from_other_window(self, present_message):
present_message.return_value = True
interface = dbus_service.CthulhuDBusServiceInterface()
interface.PresentAccessibleEvent(
"combo", "focus", "Windows 10", "client", "", "", "", 0, 0,
"Wine configuration"
)
interface._present_pending_accessible_focus()
present_message.reset_mock()
result = interface.PresentAccessibleEvent(
"other-item", "selection", "Unrelated", "client", "", "", "", 0, 0,
"Other window"
)
self.assertFalse(result)
present_message.assert_not_called()
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -64,7 +64,11 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP, settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP,
) )
self.assertEqual(general["cthulhuModifierKeys"], settings.DESKTOP_MODIFIER_KEYS) self.assertEqual(general["cthulhuModifierKeys"], settings.DESKTOP_MODIFIER_KEYS)
self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"]) self.assertEqual(
general["activePlugins"],
["Clipboard", "OCR", "WineAccessibility"],
)
self.assertTrue(general["wineAccessibilityPluginMigrated"])
self.assertEqual(general["aiProvider"], settings.AI_PROVIDER_OLLAMA) self.assertEqual(general["aiProvider"], settings.AI_PROVIDER_OLLAMA)
self.assertFalse(general["aiAssistantEnabled"]) self.assertFalse(general["aiAssistantEnabled"])
self.assertEqual(general["ocrLanguageCode"], "eng") self.assertEqual(general["ocrLanguageCode"], "eng")
@@ -82,10 +86,35 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
savedSettings = settingsPath.read_text(encoding="utf-8") savedSettings = settingsPath.read_text(encoding="utf-8")
self.assertIn('profile = ["Default", "default"]', savedSettings) self.assertIn('profile = ["Default", "default"]', savedSettings)
self.assertIn('activePlugins = ["Clipboard", "OCR"]', savedSettings) self.assertIn(
'activePlugins = ["Clipboard", "OCR", "WineAccessibility"]',
savedSettings,
)
self.assertIn("wineAccessibilityPluginMigrated = true", savedSettings)
self.assertNotIn("format-version = 2", savedSettings) self.assertNotIn("format-version = 2", savedSettings)
self.assertNotIn("[profiles.default.metadata]", savedSettings) self.assertNotIn("[profiles.default.metadata]", savedSettings)
def test_wine_accessibility_migration_respects_later_plugin_disable(self):
migratedSettings = """[general]
startingProfile = ["Default", "default"]
[profiles.default]
profile = ["Default", "default"]
activePlugins = ["Clipboard", "OCR"]
wineAccessibilityPluginMigrated = true
"""
with tempfile.TemporaryDirectory() as tempDir:
Path(tempDir, "user-settings.toml").write_text(
migratedSettings,
encoding="utf-8",
)
backend = Backend(tempDir)
general = backend.getGeneral("default")
self.assertEqual(general["activePlugins"], ["Clipboard", "OCR"])
def test_legacy_profile_keybindings_are_preserved(self): def test_legacy_profile_keybindings_are_preserved(self):
legacySettings = LEGACY_SETTINGS.replace( legacySettings = LEGACY_SETTINGS.replace(
'desktop-modifier-keys = ["Insert", "KP_Insert"]', 'desktop-modifier-keys = ["Insert", "KP_Insert"]',
+30 -6
View File
@@ -7,6 +7,7 @@
#include <rpc.h> #include <rpc.h>
#include <windows.h> #include <windows.h>
#include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <cwchar> #include <cwchar>
#include <string> #include <string>
@@ -133,11 +134,33 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
wchar_t title[512] = {}; wchar_t title[512] = {};
GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512); GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512);
const std::string windowTitle = to_utf8(title); const std::string windowTitle = to_utf8(title);
const char *eventType = event == EVENT_OBJECT_FOCUS ? "focus" : "change"; const std::string sourceId =
std::to_string(reinterpret_cast<std::uintptr_t>(window)) + ":" +
std::to_string(objectId) + ":" + std::to_string(childId);
const char *eventType = nullptr;
switch (event) {
case EVENT_OBJECT_FOCUS:
eventType = "focus";
break;
case EVENT_OBJECT_NAMECHANGE:
eventType = "name";
break;
case EVENT_OBJECT_VALUECHANGE:
eventType = "value";
break;
case EVENT_OBJECT_SELECTION:
eventType = "selection";
break;
case EVENT_OBJECT_STATECHANGE:
eventType = "state";
break;
default:
return;
}
call_boolean("PresentAccessibleEvent", call_boolean("PresentAccessibleEvent",
g_variant_new("(sssssssiis)", "msaa", eventType, name.c_str(), g_variant_new("(sssssssiis)", sourceId.c_str(), eventType,
roleText.c_str(), value.c_str(), stateText.c_str(), name.c_str(), roleText.c_str(), value.c_str(),
description.c_str(), 0, stateText.c_str(), description.c_str(), 0,
static_cast<int>(childCount), static_cast<int>(childCount),
windowTitle.c_str())); windowTitle.c_str()));
} }
@@ -253,8 +276,9 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
windowClass.hInstance = instance; windowClass.hInstance = instance;
windowClass.lpszClassName = L"wxWindowClassNR"; windowClass.lpszClassName = L"wxWindowClassNR";
RegisterClassW(&windowClass); RegisterClassW(&windowClass);
window = CreateWindowW(windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0, window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
nullptr, nullptr, instance, nullptr); windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0,
0, nullptr, nullptr, instance, nullptr);
} }
if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) { if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) {
g_object_unref(connection); g_object_unref(connection);