Speculative fix on preferences weirdness.
This commit is contained in:
1 parent
c7b8e4a30d
commit
a7cd1d033a
9 files changed
+538
-16
No files matched your search
@@ -14,6 +14,16 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
|
|||||||
- `meson compile -C _build`
|
- `meson compile -C _build`
|
||||||
- `meson install -C _build`
|
- `meson install -C _build`
|
||||||
|
|
||||||
|
## Runtime target and testing rules
|
||||||
|
- Make source changes in this repo, not in `~/.local/lib/python*/site-packages/cthulhu/`, unless the user explicitly asks for an installed-package hotfix.
|
||||||
|
- If you confirm the active import comes from `~/.local/...`, that does **not** mean you should edit there. It means you should update the repo and then run `./build-local.sh` to replace the installed copy for testing.
|
||||||
|
- Default test/apply workflow for local Cthulhu fixes:
|
||||||
|
- edit repo files
|
||||||
|
- run `./build-local.sh`
|
||||||
|
- reproduce/test against the refreshed `~/.local` install
|
||||||
|
- If repo and installed behavior differ, prefer rebuilding with `./build-local.sh` over patching the installed package directly.
|
||||||
|
- Treat direct edits under `~/.local/.../cthulhu/` as an exception path that requires explicit user approval.
|
||||||
|
|
||||||
## Coding guidelines
|
## Coding guidelines
|
||||||
- **When modifying existing code:** follow the surrounding code’s conventions.
|
- **When modifying existing code:** follow the surrounding code’s conventions.
|
||||||
- **When writing new code from scratch:** prefer
|
- **When writing new code from scratch:** prefer
|
||||||
@@ -55,3 +65,8 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
|
|||||||
## Meson install reminder (important)
|
## Meson install reminder (important)
|
||||||
- If you add new Python modules under `src/cthulhu/`, update `src/cthulhu/meson.build` so they get installed (otherwise imports can fail after install).
|
- If you add new Python modules under `src/cthulhu/`, update `src/cthulhu/meson.build` so they get installed (otherwise imports can fail after install).
|
||||||
- If you add a new plugin directory, update `src/cthulhu/plugins/meson.build` and add a `meson.build` in the plugin directory.
|
- If you add a new plugin directory, update `src/cthulhu/plugins/meson.build` and add a `meson.build` in the plugin directory.
|
||||||
|
|
||||||
|
## Common Cthulhu agent mistakes
|
||||||
|
- Checking the import origin, seeing `~/.local/...`, and then editing the installed package instead of the repo.
|
||||||
|
- Forgetting that `./build-local.sh` is the normal way to apply repo changes into the installed copy for testing.
|
||||||
|
- Making repo fixes and then diagnosing the old installed copy without rebuilding.
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
<property name="use_underline">True</property>
|
<property name="use_underline">True</property>
|
||||||
<property name="mnemonic_widget">timeFormatCombo</property>
|
<property name="mnemonic_widget">timeFormatCombo</property>
|
||||||
<accessibility>
|
<accessibility>
|
||||||
<relation type="label-for" target="availableProfilesComboBox1"/>
|
<relation type="label-for" target="timeFormatCombo"/>
|
||||||
</accessibility>
|
</accessibility>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
@@ -588,7 +588,7 @@
|
|||||||
<property name="use_underline">True</property>
|
<property name="use_underline">True</property>
|
||||||
<property name="mnemonic_widget">dateFormatCombo</property>
|
<property name="mnemonic_widget">dateFormatCombo</property>
|
||||||
<accessibility>
|
<accessibility>
|
||||||
<relation type="label-for" target="availableProfilesComboBox2"/>
|
<relation type="label-for" target="dateFormatCombo"/>
|
||||||
</accessibility>
|
</accessibility>
|
||||||
</object>
|
</object>
|
||||||
<packing>
|
<packing>
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from . import text_attribute_names
|
|||||||
from . import sound_theme_manager
|
from . import sound_theme_manager
|
||||||
from . import script_manager
|
from . import script_manager
|
||||||
from .ax_object import AXObject
|
from .ax_object import AXObject
|
||||||
|
from .ax_utilities import AXUtilities
|
||||||
|
|
||||||
_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager
|
_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager
|
||||||
|
|
||||||
@@ -202,6 +203,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
|||||||
self.profilesCombo = None
|
self.profilesCombo = None
|
||||||
self.profilesComboModel = None
|
self.profilesComboModel = None
|
||||||
self.startingProfileCombo = None
|
self.startingProfileCombo = None
|
||||||
|
self._initialFocusSyncAttempts = 0
|
||||||
self._capturedKey = []
|
self._capturedKey = []
|
||||||
self.script = None
|
self.script = None
|
||||||
|
|
||||||
@@ -1506,6 +1508,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
|||||||
#
|
#
|
||||||
if not serverInfo:
|
if not serverInfo:
|
||||||
serverInfo = speech.getInfo()
|
serverInfo = speech.getInfo()
|
||||||
|
if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default':
|
||||||
|
defaultFamily = None
|
||||||
|
voices = self.prefsDict.get("voices", {})
|
||||||
|
defaultVoice = voices.get(settings.DEFAULT_VOICE) if voices else None
|
||||||
|
if defaultVoice:
|
||||||
|
defaultFamily = acss.ACSS(defaultVoice).get(acss.ACSS.FAMILY)
|
||||||
|
|
||||||
|
resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily)
|
||||||
|
if resolved is not None:
|
||||||
|
serverInfo = resolved.getInfo()
|
||||||
|
|
||||||
valueSet = False
|
valueSet = False
|
||||||
i = 0
|
i = 0
|
||||||
@@ -1720,6 +1732,118 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
|
|||||||
}
|
}
|
||||||
self.echoVoice['established'] = True
|
self.echoVoice['established'] = True
|
||||||
|
|
||||||
|
def _resolveSpeechDispatcherServerForFamily(self, family):
|
||||||
|
"""Returns a concrete Speech Dispatcher server matching the voice family."""
|
||||||
|
|
||||||
|
if not family or not self.speechServersChoices:
|
||||||
|
return None
|
||||||
|
|
||||||
|
name = family.get(speechserver.VoiceFamily.NAME)
|
||||||
|
language = family.get(speechserver.VoiceFamily.LANG)
|
||||||
|
dialect = family.get(speechserver.VoiceFamily.DIALECT)
|
||||||
|
variant = family.get(speechserver.VoiceFamily.VARIANT)
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for server in self.speechServersChoices:
|
||||||
|
info = server.getInfo()
|
||||||
|
if not info or len(info) < 2 or info[1] == 'default':
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
families = server.getVoiceFamilies()
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_FINEST)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for candidate in families:
|
||||||
|
if candidate.get(speechserver.VoiceFamily.NAME) != name:
|
||||||
|
continue
|
||||||
|
if candidate.get(speechserver.VoiceFamily.LANG) != language:
|
||||||
|
continue
|
||||||
|
if candidate.get(speechserver.VoiceFamily.DIALECT) != dialect:
|
||||||
|
continue
|
||||||
|
if candidate.get(speechserver.VoiceFamily.VARIANT) != variant:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tokens = [
|
||||||
|
"PREFERENCES DIALOG: Resolved voice family",
|
||||||
|
name,
|
||||||
|
"to speech server",
|
||||||
|
info,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return server
|
||||||
|
|
||||||
|
tokens = [
|
||||||
|
"PREFERENCES DIALOG: Could not resolve speech server for family",
|
||||||
|
family,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _getSpeechServerChoiceForSave(self):
|
||||||
|
"""Returns the server choice that should be persisted for speech settings."""
|
||||||
|
|
||||||
|
server = self.speechServersChoice
|
||||||
|
if not server or self.speechSystemsChoice != self._getSpeechDispatcherFactory():
|
||||||
|
return server
|
||||||
|
|
||||||
|
info = server.getInfo()
|
||||||
|
if not info or len(info) < 2 or info[1] != 'default':
|
||||||
|
return server
|
||||||
|
|
||||||
|
defaultFamily = None
|
||||||
|
if self.defaultVoice is not None:
|
||||||
|
defaultFamily = self.defaultVoice.get(acss.ACSS.FAMILY)
|
||||||
|
|
||||||
|
resolved = self._resolveSpeechDispatcherServerForFamily(defaultFamily)
|
||||||
|
return resolved or server
|
||||||
|
|
||||||
|
def _getEchoSpeechServerFamilyForSave(self):
|
||||||
|
"""Returns the most specific echo family to use for server resolution."""
|
||||||
|
|
||||||
|
family = None
|
||||||
|
if self.echoVoice is not None:
|
||||||
|
family = self.echoVoice.get(acss.ACSS.FAMILY)
|
||||||
|
|
||||||
|
if family:
|
||||||
|
return family
|
||||||
|
|
||||||
|
if self.defaultVoice is not None:
|
||||||
|
return self.defaultVoice.get(acss.ACSS.FAMILY)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _getEchoSpeechServerChoiceForSave(self):
|
||||||
|
"""Returns the echo speech server choice that should be persisted."""
|
||||||
|
|
||||||
|
server = self.echoSpeechServersChoice
|
||||||
|
if server is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
info = server.getInfo()
|
||||||
|
if not info or len(info) < 2 or info[1] != 'default':
|
||||||
|
return server
|
||||||
|
|
||||||
|
family = self._getEchoSpeechServerFamilyForSave()
|
||||||
|
resolved = self._resolveSpeechDispatcherServerForFamily(family)
|
||||||
|
if resolved is not None:
|
||||||
|
return resolved
|
||||||
|
|
||||||
|
speechServer = self._getSpeechServerChoiceForSave()
|
||||||
|
if speechServer is not None:
|
||||||
|
speechInfo = speechServer.getInfo()
|
||||||
|
if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default':
|
||||||
|
tokens = [
|
||||||
|
"PREFERENCES DIALOG: Falling back to main speech server for echo",
|
||||||
|
speechInfo,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return speechServer
|
||||||
|
|
||||||
|
return server
|
||||||
|
|
||||||
def _populateEchoSpeechFamilies(self, families):
|
def _populateEchoSpeechFamilies(self, families):
|
||||||
"""Populate the echo family combobox from the provided families list."""
|
"""Populate the echo family combobox from the provided families list."""
|
||||||
|
|
||||||
@@ -1873,6 +1997,23 @@ print(json.dumps(result))
|
|||||||
self._setupEchoSpeechFamilies()
|
self._setupEchoSpeechFamilies()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if serverInfo and len(serverInfo) >= 2 and serverInfo[1] == 'default':
|
||||||
|
family = self._getEchoSpeechServerFamilyForSave()
|
||||||
|
resolved = self._resolveSpeechDispatcherServerForFamily(family)
|
||||||
|
if resolved is None:
|
||||||
|
speechServer = self._getSpeechServerChoiceForSave()
|
||||||
|
if speechServer is not None:
|
||||||
|
speechInfo = speechServer.getInfo()
|
||||||
|
if speechInfo and len(speechInfo) >= 2 and speechInfo[1] != 'default':
|
||||||
|
resolved = speechServer
|
||||||
|
tokens = [
|
||||||
|
"PREFERENCES DIALOG: Reusing main speech server for echo",
|
||||||
|
speechInfo,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
if resolved is not None:
|
||||||
|
serverInfo = resolved.getInfo()
|
||||||
|
|
||||||
valueSet = False
|
valueSet = False
|
||||||
for i, server in enumerate(self.echoSpeechServersChoices):
|
for i, server in enumerate(self.echoSpeechServersChoices):
|
||||||
info = server.getInfo()
|
info = server.getInfo()
|
||||||
@@ -3148,6 +3289,68 @@ print(json.dumps(result))
|
|||||||
cthulhuSetupWindow.set_title(title)
|
cthulhuSetupWindow.set_title(title)
|
||||||
|
|
||||||
cthulhuSetupWindow.show()
|
cthulhuSetupWindow.show()
|
||||||
|
self._initialFocusSyncAttempts = 0
|
||||||
|
GLib.idle_add(self._set_initial_window_state)
|
||||||
|
GLib.idle_add(self._set_initial_gtk_focus)
|
||||||
|
GLib.timeout_add(50, self._set_initial_window_state)
|
||||||
|
|
||||||
|
def _set_initial_gtk_focus(self):
|
||||||
|
"""Give GTK focus to a real preferences control as soon as the dialog appears."""
|
||||||
|
|
||||||
|
candidateIds = [
|
||||||
|
"generalDesktopButton",
|
||||||
|
"generalLaptopButton",
|
||||||
|
"availableProfilesComboBox1",
|
||||||
|
"speechSupportCheckButton",
|
||||||
|
"notebook",
|
||||||
|
]
|
||||||
|
cthulhuSetupWindow = self.get_widget("cthulhuSetupWindow")
|
||||||
|
for widgetId in candidateIds:
|
||||||
|
widget = self.get_widget(widgetId)
|
||||||
|
if not widget.get_visible() or not widget.get_sensitive():
|
||||||
|
continue
|
||||||
|
if not widget.get_can_focus():
|
||||||
|
continue
|
||||||
|
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
f"PREFERENCES DIALOG: Setting initial GTK focus to {widgetId}",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
cthulhuSetupWindow.set_focus(widget)
|
||||||
|
widget.grab_focus()
|
||||||
|
return False
|
||||||
|
|
||||||
|
debug.printMessage(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
"PREFERENCES DIALOG: No focusable initial GTK widget found",
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _set_initial_window_state(self):
|
||||||
|
"""Sync Cthulhu's active window to preferences without forcing dialog focus."""
|
||||||
|
|
||||||
|
self._initialFocusSyncAttempts += 1
|
||||||
|
activeWindow = AXUtilities.find_active_window()
|
||||||
|
if activeWindow is None:
|
||||||
|
return self._initialFocusSyncAttempts < 5
|
||||||
|
|
||||||
|
app = AXObject.get_application(activeWindow)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
if appName != "cthulhu":
|
||||||
|
return self._initialFocusSyncAttempts < 5
|
||||||
|
|
||||||
|
debug.printTokens(
|
||||||
|
debug.LEVEL_INFO,
|
||||||
|
["PREFERENCES DIALOG: Syncing active window to", activeWindow],
|
||||||
|
True,
|
||||||
|
)
|
||||||
|
cthulhu.setActiveWindow(activeWindow, notifyScript=False)
|
||||||
|
|
||||||
|
tokens = ["PREFERENCES DIALOG: Synced initial window state to", activeWindow]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return False
|
||||||
|
|
||||||
def _initComboBox(self, combobox):
|
def _initComboBox(self, combobox):
|
||||||
"""Initialize the given combo box to take a list of int/str pairs.
|
"""Initialize the given combo box to take a list of int/str pairs.
|
||||||
@@ -4648,9 +4851,10 @@ print(json.dumps(result))
|
|||||||
self.prefsDict["speechServerFactory"] = \
|
self.prefsDict["speechServerFactory"] = \
|
||||||
self.speechSystemsChoice.__name__
|
self.speechSystemsChoice.__name__
|
||||||
|
|
||||||
if self.speechServersChoice:
|
speechServerChoice = self._getSpeechServerChoiceForSave()
|
||||||
|
if speechServerChoice:
|
||||||
self.prefsDict["speechServerInfo"] = \
|
self.prefsDict["speechServerInfo"] = \
|
||||||
self.speechServersChoice.getInfo()
|
speechServerChoice.getInfo()
|
||||||
|
|
||||||
if self.defaultVoice is not None:
|
if self.defaultVoice is not None:
|
||||||
self.prefsDict["voices"] = {
|
self.prefsDict["voices"] = {
|
||||||
@@ -4684,8 +4888,9 @@ print(json.dumps(result))
|
|||||||
self.echoVoice['established'] = True
|
self.echoVoice['established'] = True
|
||||||
self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice)
|
self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice)
|
||||||
|
|
||||||
if self.echoSpeechServersChoice:
|
echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave()
|
||||||
self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo()
|
if echoSpeechServerChoice:
|
||||||
|
self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo()
|
||||||
else:
|
else:
|
||||||
self.prefsDict["echoSpeechServerInfo"] = None
|
self.prefsDict["echoSpeechServerInfo"] = None
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ __license__ = "LGPL"
|
|||||||
#
|
#
|
||||||
locusOfFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
|
locusOfFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
|
||||||
|
|
||||||
|
# A pending focused object from a Cthulhu-owned window that should be used
|
||||||
|
# for keyboard-event context until the queued focus event is processed.
|
||||||
|
#
|
||||||
|
pendingSelfHostedFocus: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
|
||||||
|
|
||||||
# The currently active window.
|
# The currently active window.
|
||||||
#
|
#
|
||||||
activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
|
activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
|
||||||
|
|||||||
@@ -70,7 +70,9 @@ class EventManager:
|
|||||||
self._dequeueCount: int = 0
|
self._dequeueCount: int = 0
|
||||||
self._cmdlineCache: Dict[int, str] = {}
|
self._cmdlineCache: Dict[int, str] = {}
|
||||||
self._eventQueue: queue.Queue[Any] = queue.Queue(0)
|
self._eventQueue: queue.Queue[Any] = queue.Queue(0)
|
||||||
|
self._prioritizedEvent: Optional[Atspi.Event] = None
|
||||||
self._gidleId: int = 0
|
self._gidleId: int = 0
|
||||||
|
self._prioritizedIdleId: int = 0
|
||||||
self._gidleLock: threading.Lock = threading.Lock()
|
self._gidleLock: threading.Lock = threading.Lock()
|
||||||
self._gilSleepTime: float = 0.00001
|
self._gilSleepTime: float = 0.00001
|
||||||
self._synchronousToolkits: List[str] = ['VCL']
|
self._synchronousToolkits: List[str] = ['VCL']
|
||||||
@@ -284,6 +286,12 @@ class EventManager:
|
|||||||
if self._isDuplicateEvent(event):
|
if self._isDuplicateEvent(event):
|
||||||
return _ignore_with_reason("duplicate", "duplicate event")
|
return _ignore_with_reason("duplicate", "duplicate event")
|
||||||
|
|
||||||
|
if self._isSelfHostedFocusClearedEvent(event):
|
||||||
|
return _ignore_with_reason("self-hosted-focus-cleared", "self-hosted focused=false event")
|
||||||
|
|
||||||
|
if self._isRedundantSelfHostedPropertyEvent(event):
|
||||||
|
return _ignore_with_reason("self-hosted-redundant-property", "self-hosted redundant property event")
|
||||||
|
|
||||||
# Thunderbird spams us with these when a message list thread is expanded or collapsed.
|
# Thunderbird spams us with these when a message list thread is expanded or collapsed.
|
||||||
if event.type.endswith('system') \
|
if event.type.endswith('system') \
|
||||||
and AXObject.get_name(app).lower().startswith('thunderbird'):
|
and AXObject.get_name(app).lower().startswith('thunderbird'):
|
||||||
@@ -596,6 +604,11 @@ class EventManager:
|
|||||||
self._enqueueCount -= 1
|
self._enqueueCount -= 1
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e):
|
||||||
|
if debug.debugEventQueue:
|
||||||
|
self._enqueueCount -= 1
|
||||||
|
return
|
||||||
|
|
||||||
self._queuePrintln(e)
|
self._queuePrintln(e)
|
||||||
|
|
||||||
if self._inFlood() and self._prioritizeDuringFlood(e):
|
if self._inFlood() and self._prioritizeDuringFlood(e):
|
||||||
@@ -628,6 +641,73 @@ class EventManager:
|
|||||||
if debug.debugEventQueue:
|
if debug.debugEventQueue:
|
||||||
self._enqueueCount -= 1
|
self._enqueueCount -= 1
|
||||||
|
|
||||||
|
def _isSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
|
||||||
|
"""Returns True if the event is a newly-focused event from Cthulhu."""
|
||||||
|
|
||||||
|
if not event.type.startswith("object:state-changed:focused") or not event.detail1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
app = AXObject.get_application(event.source)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
return appName == "cthulhu"
|
||||||
|
|
||||||
|
def _isSelfHostedFocusClearedEvent(self, event: Atspi.Event) -> bool:
|
||||||
|
"""Returns True if the event is a focus-lost event from Cthulhu."""
|
||||||
|
|
||||||
|
if not event.type.startswith("object:state-changed:focused") or event.detail1:
|
||||||
|
return False
|
||||||
|
|
||||||
|
app = AXObject.get_application(event.source)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
return appName == "cthulhu"
|
||||||
|
|
||||||
|
def _isRedundantSelfHostedPropertyEvent(self, event: Atspi.Event) -> bool:
|
||||||
|
"""Returns True if the event is redundant prefs startup noise from Cthulhu."""
|
||||||
|
|
||||||
|
app = AXObject.get_application(event.source)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
if appName != "cthulhu":
|
||||||
|
return False
|
||||||
|
|
||||||
|
if event.type.startswith("object:property-change:accessible-name"):
|
||||||
|
return AXUtilities.is_combo_box(event.source) or AXUtilities.is_table_cell(event.source)
|
||||||
|
|
||||||
|
if event.type.startswith("object:property-change:accessible-value"):
|
||||||
|
return AXObject.get_role(event.source) == Atspi.Role.SLIDER \
|
||||||
|
and event.source != cthulhu_state.locusOfFocus
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _prioritizeSelfHostedFocusedEvent(self, event: Atspi.Event) -> bool:
|
||||||
|
"""Schedules the latest focused-child event from Cthulhu ahead of the normal queue."""
|
||||||
|
|
||||||
|
if not self._isSelfHostedFocusedEvent(event):
|
||||||
|
return False
|
||||||
|
|
||||||
|
tokens = ["EVENT MANAGER: Prioritizing self-hosted focused event for", event.source]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
|
||||||
|
script = self._get_scriptForEvent(event)
|
||||||
|
if script is not None:
|
||||||
|
script.eventCache[event.type] = (event, time.time())
|
||||||
|
|
||||||
|
self._gidleLock.acquire()
|
||||||
|
try:
|
||||||
|
replaced = self._prioritizedEvent is not None
|
||||||
|
self._prioritizedEvent = event
|
||||||
|
cthulhu_state.pendingSelfHostedFocus = event.source
|
||||||
|
if not self._prioritizedIdleId:
|
||||||
|
self._prioritizedIdleId = GLib.idle_add(
|
||||||
|
self._dequeuePrioritizedEvent,
|
||||||
|
priority=GLib.PRIORITY_HIGH_IDLE,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
self._gidleLock.release()
|
||||||
|
|
||||||
|
msg = f"EVENT MANAGER: Prioritized self-hosted focused event. Replaced pending event: {replaced}"
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|
||||||
|
return True
|
||||||
def _isNoFocus(self) -> bool:
|
def _isNoFocus(self) -> bool:
|
||||||
if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
|
if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
|
||||||
return False
|
return False
|
||||||
@@ -645,6 +725,60 @@ class EventManager:
|
|||||||
defaultScript.idleMessage()
|
defaultScript.idleMessage()
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _dequeuePrioritizedEvent(self) -> bool:
|
||||||
|
"""Handles prioritized focused events from Cthulhu-owned windows."""
|
||||||
|
|
||||||
|
self._gidleLock.acquire()
|
||||||
|
try:
|
||||||
|
event = self._prioritizedEvent
|
||||||
|
self._prioritizedEvent = None
|
||||||
|
self._prioritizedIdleId = 0
|
||||||
|
if event is not None and cthulhu_state.pendingSelfHostedFocus == event.source:
|
||||||
|
cthulhu_state.pendingSelfHostedFocus = None
|
||||||
|
finally:
|
||||||
|
self._gidleLock.release()
|
||||||
|
|
||||||
|
if event is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
tokens = ["EVENT MANAGER: Dequeued prioritized event", event]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
|
||||||
|
debugging = not debug.eventDebugFilter or debug.eventDebugFilter.match(event.type)
|
||||||
|
if debugging:
|
||||||
|
startTime = time.time()
|
||||||
|
msg = (
|
||||||
|
f"\nvvvvv PROCESS OBJECT EVENT {event.type} "
|
||||||
|
f"(prioritized) vvvvv"
|
||||||
|
)
|
||||||
|
debug.printMessage(debug.eventDebugLevel, msg, False)
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._processObjectEvent(event)
|
||||||
|
if self._didSuspendEventsFor(event):
|
||||||
|
self._unsuspendEvents(event)
|
||||||
|
elif self._eventsSuspended and self._shouldUnsuspendEventsFor(event):
|
||||||
|
self._unsuspendEvents(event, force=True)
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_SEVERE)
|
||||||
|
|
||||||
|
if debugging:
|
||||||
|
msg = (
|
||||||
|
f"TOTAL PROCESSING TIME: {time.time() - startTime:.4f}"
|
||||||
|
f"\n^^^^^ PROCESS OBJECT EVENT {event.type} ^^^^^\n"
|
||||||
|
)
|
||||||
|
debug.printMessage(debug.eventDebugLevel, msg, False)
|
||||||
|
|
||||||
|
self._gidleLock.acquire()
|
||||||
|
try:
|
||||||
|
hasMore = self._prioritizedEvent is not None
|
||||||
|
if not hasMore and self._eventQueue.qsize() and not self._gidleId:
|
||||||
|
self._gidleId = GLib.idle_add(self._dequeue)
|
||||||
|
finally:
|
||||||
|
self._gidleLock.release()
|
||||||
|
|
||||||
|
return hasMore
|
||||||
|
|
||||||
def _dequeue(self) -> bool:
|
def _dequeue(self) -> bool:
|
||||||
"""Handles all events destined for scripts. Called by the GTK
|
"""Handles all events destined for scripts. Called by the GTK
|
||||||
idle thread."""
|
idle thread."""
|
||||||
@@ -657,7 +791,13 @@ class EventManager:
|
|||||||
self._dequeueCount += 1
|
self._dequeueCount += 1
|
||||||
|
|
||||||
try:
|
try:
|
||||||
event = self._eventQueue.get_nowait()
|
fromPriority = False
|
||||||
|
self._gidleLock.acquire()
|
||||||
|
try:
|
||||||
|
event = self._eventQueue.get_nowait()
|
||||||
|
finally:
|
||||||
|
self._gidleLock.release()
|
||||||
|
|
||||||
self._queuePrintln(event, isEnqueue=False)
|
self._queuePrintln(event, isEnqueue=False)
|
||||||
inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
|
inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
|
||||||
if isinstance(event, inputEvents):
|
if isinstance(event, inputEvents):
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ class FocusManager:
|
|||||||
_log_tokens(["Focused object in", self._window, "is", result])
|
_log_tokens(["Focused object in", self._window, "is", result])
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
def active_window_is_cthulhu(self) -> bool:
|
||||||
|
"""Returns True if the active window belongs to Cthulhu itself."""
|
||||||
|
|
||||||
|
app = AXObject.get_application(self._window)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
return appName == "cthulhu"
|
||||||
|
|
||||||
def focus_and_window_are_unknown(self) -> bool:
|
def focus_and_window_are_unknown(self) -> bool:
|
||||||
"""Returns True if we have no knowledge about what is focused."""
|
"""Returns True if we have no knowledge about what is focused."""
|
||||||
|
|
||||||
@@ -350,18 +357,39 @@ class FocusManager:
|
|||||||
self._window = frame
|
self._window = frame
|
||||||
cthulhu_state.activeWindow = frame
|
cthulhu_state.activeWindow = frame
|
||||||
|
|
||||||
|
contextObject = self._focus
|
||||||
if set_window_as_focus:
|
if set_window_as_focus:
|
||||||
self.set_locus_of_focus(None, self._window, notify_script)
|
self.set_locus_of_focus(None, self._window, notify_script)
|
||||||
elif not (self.focus_is_active_window() or self.focus_is_in_active_window()):
|
elif not (self.focus_is_active_window() or self.focus_is_in_active_window()):
|
||||||
_log_tokens(["Focus", self._focus, "is not in", self._window], stack=True)
|
_log_tokens(["Focus", self._focus, "is not in", self._window], stack=True)
|
||||||
|
|
||||||
# Don't update the focus to the active window if we can't get to the active window
|
if self.active_window_is_cthulhu():
|
||||||
# from the focused object. https://bugreports.qt.io/browse/QTBUG-130116
|
_log_tokens(
|
||||||
if not AXObject.has_broken_ancestry(self._focus):
|
["Skipping focused-object lookup and script activation for self-hosted window", self._window],
|
||||||
self.set_locus_of_focus(None, self._window, notify_script=True)
|
"self-window",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
focusedObject = self.find_focused_object()
|
||||||
|
if focusedObject is not None and focusedObject != self._window:
|
||||||
|
_log_tokens(["Using focused object", focusedObject, "from active window", self._window])
|
||||||
|
self.set_locus_of_focus(None, focusedObject, notify_script=True)
|
||||||
|
contextObject = focusedObject
|
||||||
|
elif self._focus is None:
|
||||||
|
_log_tokens(["No previous focus. Falling back to active window", self._window])
|
||||||
|
self.set_locus_of_focus(None, self._window, notify_script=True)
|
||||||
|
contextObject = self._window
|
||||||
|
# Don't update the focus to the active window if we can't get to the active window
|
||||||
|
# from the focused object. https://bugreports.qt.io/browse/QTBUG-130116
|
||||||
|
elif not AXObject.has_broken_ancestry(self._focus):
|
||||||
|
self.set_locus_of_focus(None, self._window, notify_script=True)
|
||||||
|
contextObject = self._window
|
||||||
|
|
||||||
app = _get_ax_utilities().get_application(self._focus)
|
if contextObject is None:
|
||||||
self.app.scriptManager.activate_script_for_context(app, self._focus, "focus: active-window")
|
contextObject = self._window
|
||||||
|
|
||||||
|
app = _get_ax_utilities().get_application(contextObject)
|
||||||
|
self.app.scriptManager.activate_script_for_context(app, contextObject, "focus: active-window")
|
||||||
|
|
||||||
@dbus_service.command
|
@dbus_service.command
|
||||||
def toggle_presentation_mode(
|
def toggle_presentation_mode(
|
||||||
|
|||||||
@@ -306,6 +306,10 @@ class InputEventManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
manager = focus_manager.get_manager()
|
manager = focus_manager.get_manager()
|
||||||
|
pendingFocus = cthulhu_state.pendingSelfHostedFocus
|
||||||
|
if pendingFocus is not None:
|
||||||
|
tokens = ["INPUT EVENT MANAGER: Using pending self-hosted focus for keyboard event:", pendingFocus]
|
||||||
|
debug.print_tokens(debug.LEVEL_INFO, tokens, True)
|
||||||
if pressed:
|
if pressed:
|
||||||
window = manager.get_active_window()
|
window = manager.get_active_window()
|
||||||
if not AXUtilities.can_be_active_window(window, clear_cache=True):
|
if not AXUtilities.can_be_active_window(window, clear_cache=True):
|
||||||
@@ -322,7 +326,7 @@ class InputEventManager:
|
|||||||
window = None
|
window = None
|
||||||
manager.set_active_window(None, notify_script=True)
|
manager.set_active_window(None, notify_script=True)
|
||||||
event.set_window(window)
|
event.set_window(window)
|
||||||
event.set_object(manager.get_locus_of_focus())
|
event.set_object(pendingFocus or manager.get_locus_of_focus())
|
||||||
event.set_script(script_manager.get_manager().get_active_script())
|
event.set_script(script_manager.get_manager().get_active_script())
|
||||||
elif self.last_event_was_keyboard():
|
elif self.last_event_was_keyboard():
|
||||||
assert isinstance(self._last_input_event, input_event.KeyboardEvent)
|
assert isinstance(self._last_input_event, input_event.KeyboardEvent)
|
||||||
@@ -331,7 +335,7 @@ class InputEventManager:
|
|||||||
event.set_script(self._last_input_event.get_script())
|
event.set_script(self._last_input_event.get_script())
|
||||||
else:
|
else:
|
||||||
event.set_window(manager.get_active_window())
|
event.set_window(manager.get_active_window())
|
||||||
event.set_object(manager.get_locus_of_focus())
|
event.set_object(pendingFocus or manager.get_locus_of_focus())
|
||||||
event.set_script(script_manager.get_manager().get_active_script())
|
event.set_script(script_manager.get_manager().get_active_script())
|
||||||
|
|
||||||
event._finalize_initialization()
|
event._finalize_initialization()
|
||||||
|
|||||||
@@ -1925,6 +1925,14 @@ class Script(script.Script):
|
|||||||
self.pointOfReference = {}
|
self.pointOfReference = {}
|
||||||
|
|
||||||
cthulhu.setActiveWindow(window)
|
cthulhu.setActiveWindow(window)
|
||||||
|
|
||||||
|
app = AXObject.get_application(window)
|
||||||
|
appName = (AXObject.get_name(app) or "").lower()
|
||||||
|
if appName == "cthulhu":
|
||||||
|
msg = "DEFAULT: Self-hosted window activated. Waiting for focused child event."
|
||||||
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
return
|
||||||
|
|
||||||
if self.utilities.isKeyGrabEvent(event):
|
if self.utilities.isKeyGrabEvent(event):
|
||||||
msg = "DEFAULT: Ignoring event. Likely from key grab."
|
msg = "DEFAULT: Ignoring event. Likely from key grab."
|
||||||
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
debug.printMessage(debug.LEVEL_INFO, msg, True)
|
||||||
|
|||||||
+118
-1
@@ -82,6 +82,108 @@ def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool:
|
|||||||
return False
|
return False
|
||||||
return moduleName.split(".")[-1] == "speechdispatcherfactory"
|
return moduleName.split(".")[-1] == "speechdispatcherfactory"
|
||||||
|
|
||||||
|
def _matchesVoiceFamily(candidateFamily: Any, targetFamily: Any) -> bool:
|
||||||
|
if not candidateFamily or not targetFamily:
|
||||||
|
return False
|
||||||
|
|
||||||
|
for key in (VoiceFamily.NAME, VoiceFamily.LANG, VoiceFamily.DIALECT, VoiceFamily.VARIANT):
|
||||||
|
if candidateFamily.get(key) != targetFamily.get(key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _resolveSpeechDispatcherServerInfo(
|
||||||
|
moduleName: Optional[str],
|
||||||
|
speechServerInfo: Optional[Any],
|
||||||
|
voice: Optional[Any] = None,
|
||||||
|
fallbackServerInfo: Optional[Any] = None,
|
||||||
|
) -> Optional[Any]:
|
||||||
|
|
||||||
|
if not _isSpeechDispatcherFactory(moduleName):
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
|
if speechServerInfo and len(speechServerInfo) >= 2 and speechServerInfo[1] != "default":
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
|
if fallbackServerInfo and len(fallbackServerInfo) >= 2 and fallbackServerInfo[1] != "default":
|
||||||
|
tokens = [
|
||||||
|
"SPEECH: Resolving Speech Dispatcher server via fallback server info:",
|
||||||
|
fallbackServerInfo,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return fallbackServerInfo
|
||||||
|
|
||||||
|
family = None
|
||||||
|
if voice:
|
||||||
|
try:
|
||||||
|
family = ACSS(voice).get(ACSS.FAMILY)
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
|
||||||
|
if not family:
|
||||||
|
try:
|
||||||
|
family = ACSS(settings.voices.get(settings.DEFAULT_VOICE, {})).get(ACSS.FAMILY)
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
family = None
|
||||||
|
|
||||||
|
if not family:
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
|
factory = None
|
||||||
|
try:
|
||||||
|
factory = importlib.import_module(f"cthulhu.{moduleName}")
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
factory = importlib.import_module(moduleName)
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
|
||||||
|
if factory is None:
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
|
try:
|
||||||
|
servers = factory.SpeechServer.getSpeechServers() # type: ignore[attr-defined]
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
|
for server in servers:
|
||||||
|
try:
|
||||||
|
info = server.getInfo()
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not info or len(info) < 2 or info[1] == "default":
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
families = server.getVoiceFamilies() or []
|
||||||
|
except Exception:
|
||||||
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
continue
|
||||||
|
|
||||||
|
for candidate in families:
|
||||||
|
if not _matchesVoiceFamily(candidate, family):
|
||||||
|
continue
|
||||||
|
|
||||||
|
tokens = [
|
||||||
|
"SPEECH: Resolved Speech Dispatcher server info",
|
||||||
|
info,
|
||||||
|
"for voice family",
|
||||||
|
family,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return info
|
||||||
|
|
||||||
|
tokens = [
|
||||||
|
"SPEECH: Could not resolve Speech Dispatcher server info for voice family",
|
||||||
|
family,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
return speechServerInfo
|
||||||
|
|
||||||
def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer:
|
def _initSpeechServer(moduleName: Optional[str], speechServerInfo: Optional[Any]) -> SpeechServer:
|
||||||
|
|
||||||
if not moduleName:
|
if not moduleName:
|
||||||
@@ -125,9 +227,24 @@ def _refreshEchoSpeechServer() -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
resolvedInfo = _resolveSpeechDispatcherServerInfo(
|
||||||
|
settings.speechServerFactory,
|
||||||
|
settings.echoSpeechServerInfo,
|
||||||
|
settings.echoVoice,
|
||||||
|
settings.speechServerInfo,
|
||||||
|
)
|
||||||
|
if resolvedInfo != settings.echoSpeechServerInfo:
|
||||||
|
tokens = [
|
||||||
|
"SPEECH: Updating echo speech server info from",
|
||||||
|
settings.echoSpeechServerInfo,
|
||||||
|
"to",
|
||||||
|
resolvedInfo,
|
||||||
|
]
|
||||||
|
debug.printTokens(debug.LEVEL_INFO, tokens, True)
|
||||||
|
settings.echoSpeechServerInfo = resolvedInfo
|
||||||
_echoSpeechserver = _initSpeechServer(
|
_echoSpeechserver = _initSpeechServer(
|
||||||
settings.speechServerFactory,
|
settings.speechServerFactory,
|
||||||
settings.echoSpeechServerInfo
|
resolvedInfo
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
debug.printException(debug.LEVEL_INFO)
|
debug.printException(debug.LEVEL_INFO)
|
||||||
|
|||||||
Reference in new issue
Block a user