Speculative fix on preferences weirdness.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -571,7 +571,7 @@
|
||||
<property name="use_underline">True</property>
|
||||
<property name="mnemonic_widget">timeFormatCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="availableProfilesComboBox1"/>
|
||||
<relation type="label-for" target="timeFormatCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
@@ -588,7 +588,7 @@
|
||||
<property name="use_underline">True</property>
|
||||
<property name="mnemonic_widget">dateFormatCombo</property>
|
||||
<accessibility>
|
||||
<relation type="label-for" target="availableProfilesComboBox2"/>
|
||||
<relation type="label-for" target="dateFormatCombo"/>
|
||||
</accessibility>
|
||||
</object>
|
||||
<packing>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
+118
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user