Refine Wine dialog event presentation
This commit is contained in:
@@ -304,6 +304,13 @@ class Backend:
|
||||
|
||||
def _migrateSettings(self, settingsDict):
|
||||
"""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
|
||||
if 'enableSpeechIndentation' in settingsDict and 'enableIndentation' not in settingsDict:
|
||||
settingsDict['enableIndentation'] = settingsDict['enableSpeechIndentation']
|
||||
|
||||
@@ -25,6 +25,7 @@ import contextlib
|
||||
import enum
|
||||
import inspect
|
||||
import os
|
||||
import time
|
||||
import types
|
||||
import typing
|
||||
import xml.etree.ElementTree as ET
|
||||
@@ -641,6 +642,8 @@ class _InterfaceBuilder:
|
||||
class CthulhuDBusServiceInterface(Publishable):
|
||||
"""Internal D-Bus service object that handles D-Bus specifics."""
|
||||
|
||||
_WINE_FOCUS_DEBOUNCE_MS = 50
|
||||
|
||||
def for_publication(self):
|
||||
"""Returns the D-Bus interface XML for publication."""
|
||||
|
||||
@@ -744,15 +747,92 @@ class CthulhuDBusServiceInterface(Publishable):
|
||||
) -> bool:
|
||||
"""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]
|
||||
if position > 0 and count > 0:
|
||||
parts.append(f"{position} of {count}")
|
||||
if windowTitle and windowTitle not in parts:
|
||||
if not parts and not (eventType == "focus" and windowTitle):
|
||||
return False
|
||||
|
||||
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 not parts:
|
||||
if parts:
|
||||
self.PresentMessage(", ".join(parts))
|
||||
return False
|
||||
return self.PresentMessage(", ".join(parts))
|
||||
|
||||
def GetVersion(self) -> str: # pylint: disable=invalid-name
|
||||
"""Returns Cthulhu's version and revision if available."""
|
||||
|
||||
@@ -156,8 +156,206 @@ class NativeRemoteControllerTest(unittest.TestCase):
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -64,7 +64,11 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
|
||||
settings.GENERAL_KEYBOARD_LAYOUT_DESKTOP,
|
||||
)
|
||||
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.assertFalse(general["aiAssistantEnabled"])
|
||||
self.assertEqual(general["ocrLanguageCode"], "eng")
|
||||
@@ -82,10 +86,35 @@ class LegacyTomlSchemaMigrationTests(unittest.TestCase):
|
||||
savedSettings = settingsPath.read_text(encoding="utf-8")
|
||||
|
||||
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("[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):
|
||||
legacySettings = LEGACY_SETTINGS.replace(
|
||||
'desktop-modifier-keys = ["Insert", "KP_Insert"]',
|
||||
|
||||
+30
-6
@@ -7,6 +7,7 @@
|
||||
#include <rpc.h>
|
||||
#include <windows.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdlib>
|
||||
#include <cwchar>
|
||||
#include <string>
|
||||
@@ -133,11 +134,33 @@ void CALLBACK winevent_callback(HWINEVENTHOOK, DWORD event, HWND window,
|
||||
wchar_t title[512] = {};
|
||||
GetWindowTextW(GetAncestor(window, GA_ROOT), title, 512);
|
||||
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",
|
||||
g_variant_new("(sssssssiis)", "msaa", eventType, name.c_str(),
|
||||
roleText.c_str(), value.c_str(), stateText.c_str(),
|
||||
description.c_str(), 0,
|
||||
g_variant_new("(sssssssiis)", sourceId.c_str(), eventType,
|
||||
name.c_str(), roleText.c_str(), value.c_str(),
|
||||
stateText.c_str(), description.c_str(), 0,
|
||||
static_cast<int>(childCount),
|
||||
windowTitle.c_str()));
|
||||
}
|
||||
@@ -253,8 +276,9 @@ int WINAPI WinMain(HINSTANCE instance, HINSTANCE, char *, int) {
|
||||
windowClass.hInstance = instance;
|
||||
windowClass.lpszClassName = L"wxWindowClassNR";
|
||||
RegisterClassW(&windowClass);
|
||||
window = CreateWindowW(windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0, 0,
|
||||
nullptr, nullptr, instance, nullptr);
|
||||
window = CreateWindowExW(WS_EX_NOACTIVATE | WS_EX_TOOLWINDOW,
|
||||
windowClass.lpszClassName, L"NVDA", 0, 0, 0, 0,
|
||||
0, nullptr, nullptr, instance, nullptr);
|
||||
}
|
||||
if ((controllerEnabled && (window == nullptr || !register_rpc_server()))) {
|
||||
g_object_unref(connection);
|
||||
|
||||
Reference in New Issue
Block a user