diff --git a/AGENTS.md b/AGENTS.md
index 79b236e..01712c9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,16 @@ This repository is a screen reader. Prioritize accessibility, correctness, and s
- `meson compile -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
- **When modifying existing code:** follow the surrounding code’s conventions.
- **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)
- 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.
+
+## 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.
diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui
index 86ffe6d..002ade0 100644
--- a/src/cthulhu/cthulhu-setup.ui
+++ b/src/cthulhu/cthulhu-setup.ui
@@ -571,7 +571,7 @@
True
timeFormatCombo
-
+
@@ -588,7 +588,7 @@
True
dateFormatCombo
-
+
diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py
index 55b90fb..97eb24f 100644
--- a/src/cthulhu/cthulhu_gui_prefs.py
+++ b/src/cthulhu/cthulhu_gui_prefs.py
@@ -71,6 +71,7 @@ from . import text_attribute_names
from . import sound_theme_manager
from . import script_manager
from .ax_object import AXObject
+from .ax_utilities import AXUtilities
_settingsManager = None # Removed - use cthulhu.cthulhuApp.settingsManager
@@ -202,6 +203,7 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
self.profilesCombo = None
self.profilesComboModel = None
self.startingProfileCombo = None
+ self._initialFocusSyncAttempts = 0
self._capturedKey = []
self.script = None
@@ -1506,6 +1508,16 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
#
if not serverInfo:
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
i = 0
@@ -1720,6 +1732,118 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper):
}
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):
"""Populate the echo family combobox from the provided families list."""
@@ -1873,6 +1997,23 @@ print(json.dumps(result))
self._setupEchoSpeechFamilies()
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
for i, server in enumerate(self.echoSpeechServersChoices):
info = server.getInfo()
@@ -3148,6 +3289,68 @@ print(json.dumps(result))
cthulhuSetupWindow.set_title(title)
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):
"""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.speechSystemsChoice.__name__
- if self.speechServersChoice:
+ speechServerChoice = self._getSpeechServerChoiceForSave()
+ if speechServerChoice:
self.prefsDict["speechServerInfo"] = \
- self.speechServersChoice.getInfo()
+ speechServerChoice.getInfo()
if self.defaultVoice is not None:
self.prefsDict["voices"] = {
@@ -4684,8 +4888,9 @@ print(json.dumps(result))
self.echoVoice['established'] = True
self.prefsDict["echoVoice"] = acss.ACSS(self.echoVoice)
- if self.echoSpeechServersChoice:
- self.prefsDict["echoSpeechServerInfo"] = self.echoSpeechServersChoice.getInfo()
+ echoSpeechServerChoice = self._getEchoSpeechServerChoiceForSave()
+ if echoSpeechServerChoice:
+ self.prefsDict["echoSpeechServerInfo"] = echoSpeechServerChoice.getInfo()
else:
self.prefsDict["echoSpeechServerInfo"] = None
diff --git a/src/cthulhu/cthulhu_state.py b/src/cthulhu/cthulhu_state.py
index 8cbb238..586e36d 100644
--- a/src/cthulhu/cthulhu_state.py
+++ b/src/cthulhu/cthulhu_state.py
@@ -47,6 +47,11 @@ __license__ = "LGPL"
#
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.
#
activeWindow: Optional[Any] = None # Actually: Optional[Atspi.Accessible]
diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py
index 5f5c1da..8dc37b7 100644
--- a/src/cthulhu/event_manager.py
+++ b/src/cthulhu/event_manager.py
@@ -70,7 +70,9 @@ class EventManager:
self._dequeueCount: int = 0
self._cmdlineCache: Dict[int, str] = {}
self._eventQueue: queue.Queue[Any] = queue.Queue(0)
+ self._prioritizedEvent: Optional[Atspi.Event] = None
self._gidleId: int = 0
+ self._prioritizedIdleId: int = 0
self._gidleLock: threading.Lock = threading.Lock()
self._gilSleepTime: float = 0.00001
self._synchronousToolkits: List[str] = ['VCL']
@@ -284,6 +286,12 @@ class EventManager:
if self._isDuplicateEvent(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.
if event.type.endswith('system') \
and AXObject.get_name(app).lower().startswith('thunderbird'):
@@ -596,6 +604,11 @@ class EventManager:
self._enqueueCount -= 1
return
+ if isObjectEvent and self._prioritizeSelfHostedFocusedEvent(e):
+ if debug.debugEventQueue:
+ self._enqueueCount -= 1
+ return
+
self._queuePrintln(e)
if self._inFlood() and self._prioritizeDuringFlood(e):
@@ -628,6 +641,73 @@ class EventManager:
if debug.debugEventQueue:
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:
if cthulhu_state.locusOfFocus or cthulhu_state.activeWindow or cthulhu_state.activeScript:
return False
@@ -645,6 +725,60 @@ class EventManager:
defaultScript.idleMessage()
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:
"""Handles all events destined for scripts. Called by the GTK
idle thread."""
@@ -657,7 +791,13 @@ class EventManager:
self._dequeueCount += 1
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)
inputEvents = (input_event.KeyboardEvent, input_event.BrailleEvent)
if isinstance(event, inputEvents):
diff --git a/src/cthulhu/focus_manager.py b/src/cthulhu/focus_manager.py
index b81473f..3625db2 100644
--- a/src/cthulhu/focus_manager.py
+++ b/src/cthulhu/focus_manager.py
@@ -116,6 +116,13 @@ class FocusManager:
_log_tokens(["Focused object in", self._window, "is", 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:
"""Returns True if we have no knowledge about what is focused."""
@@ -350,18 +357,39 @@ class FocusManager:
self._window = frame
cthulhu_state.activeWindow = frame
+ contextObject = self._focus
if set_window_as_focus:
self.set_locus_of_focus(None, self._window, notify_script)
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)
- # 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
- if not AXObject.has_broken_ancestry(self._focus):
- self.set_locus_of_focus(None, self._window, notify_script=True)
+ if self.active_window_is_cthulhu():
+ _log_tokens(
+ ["Skipping focused-object lookup and script activation for self-hosted window", self._window],
+ "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)
- self.app.scriptManager.activate_script_for_context(app, self._focus, "focus: active-window")
+ if contextObject is None:
+ 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
def toggle_presentation_mode(
diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py
index 9959329..e816655 100644
--- a/src/cthulhu/input_event_manager.py
+++ b/src/cthulhu/input_event_manager.py
@@ -306,6 +306,10 @@ class InputEventManager:
return False
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:
window = manager.get_active_window()
if not AXUtilities.can_be_active_window(window, clear_cache=True):
@@ -322,7 +326,7 @@ class InputEventManager:
window = None
manager.set_active_window(None, notify_script=True)
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())
elif self.last_event_was_keyboard():
assert isinstance(self._last_input_event, input_event.KeyboardEvent)
@@ -331,7 +335,7 @@ class InputEventManager:
event.set_script(self._last_input_event.get_script())
else:
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._finalize_initialization()
diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py
index b8d4b6b..ba5c895 100644
--- a/src/cthulhu/scripts/default.py
+++ b/src/cthulhu/scripts/default.py
@@ -1925,6 +1925,14 @@ class Script(script.Script):
self.pointOfReference = {}
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):
msg = "DEFAULT: Ignoring event. Likely from key grab."
debug.printMessage(debug.LEVEL_INFO, msg, True)
diff --git a/src/cthulhu/speech.py b/src/cthulhu/speech.py
index 282f6bf..7d18353 100644
--- a/src/cthulhu/speech.py
+++ b/src/cthulhu/speech.py
@@ -82,6 +82,108 @@ def _isSpeechDispatcherFactory(moduleName: Optional[str]) -> bool:
return False
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:
if not moduleName:
@@ -125,9 +227,24 @@ def _refreshEchoSpeechServer() -> None:
return
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(
settings.speechServerFactory,
- settings.echoSpeechServerInfo
+ resolvedInfo
)
except Exception:
debug.printException(debug.LEVEL_INFO)