diff --git a/README.md b/README.md index 95faf18..70ee6ee 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,6 @@ toolkit, OpenOffice/LibreOffice, Gecko, WebKitGtk, and KDE Qt toolkit. * **pluggy** - Plugin and hook calling mechanisms for Python * **python-dasbus** - D-Bus remote control interface (optional) -* **libpeas** - Plugin loader library ### Audio and Speech diff --git a/distro-packages/Slint/README b/distro-packages/Slint/README index 811924e..1825ead 100644 --- a/distro-packages/Slint/README +++ b/distro-packages/Slint/README @@ -19,7 +19,6 @@ This package requires the following packages, all available from SlackBuilds.org - gst-plugins-good - gtk3 - liblouis -- libpeas - libwnck3 - python3-atspi - python3-cairo diff --git a/distro-packages/Slint/cthulhu-info b/distro-packages/Slint/cthulhu-info index 2cef0ec..7f30753 100644 --- a/distro-packages/Slint/cthulhu-info +++ b/distro-packages/Slint/cthulhu-info @@ -5,6 +5,6 @@ DOWNLOAD="https://git.stormux.org/storm/cthulhu.git" MD5SUM="SKIP" DOWNLOAD_x86_64="" MD5SUM_x86_64="" -REQUIRES="at-spi2-core brltty gobject-introspection gsettings-desktop-schemas gstreamer gst-plugins-base gst-plugins-good gtk3 liblouis libpeas libwnck3 python3-atspi python3-cairo python3-gobject python3-setproctitle speech-dispatcher" +REQUIRES="at-spi2-core brltty gobject-introspection gsettings-desktop-schemas gstreamer gst-plugins-base gst-plugins-good gtk3 liblouis libwnck3 python3-atspi python3-cairo python3-gobject python3-setproctitle speech-dispatcher" MAINTAINER="Storm Dragon" EMAIL="storm_dragon@stormux.org" diff --git a/meson.build b/meson.build index f4987e2..23ff936 100644 --- a/meson.build +++ b/meson.build @@ -89,14 +89,6 @@ else summary += {'sound support': 'no (missing gstreamer)'} endif -# Check for libpeas -libpeas_dep = dependency('libpeas-1.0', required: false) -if libpeas_dep.found() - summary += {'plugin loading': 'yes (found libpeas)'} -else - summary += {'plugin loading': 'no (missing libpeas)'} -endif - # Integration with session startup i18n.merge_file( input: 'cthulhu-autostart.desktop.in', diff --git a/sounds/meson.build b/sounds/meson.build index 8b8419a..0d1b8b0 100644 --- a/sounds/meson.build +++ b/sounds/meson.build @@ -1,7 +1,26 @@ # Install sound theme files # Themes are installed to: {datadir}/cthulhu/sounds/{theme_name}/ -install_subdir( - 'default', - install_dir: get_option('datadir') / 'cthulhu' / 'sounds' +# Use explicit install_data for better compatibility across Meson versions +default_theme_sounds = files( + 'default/browse_mode.wav', + 'default/button.wav', + 'default/checkbox_checked.wav', + 'default/checkbox_mixed.wav', + 'default/checkbox_unchecked.wav', + 'default/combobox.wav', + 'default/focus_mode.wav', + 'default/radiobutton_checked.wav', + 'default/radiobutton_unchecked.wav', + 'default/start.wav', + 'default/stop.wav', + 'default/switch_off.wav', + 'default/switch_on.wav', + 'default/togglebutton_checked.wav', + 'default/togglebutton_unchecked.wav', +) + +install_data( + default_theme_sounds, + install_dir: get_option('datadir') / 'cthulhu' / 'sounds' / 'default' ) diff --git a/src/cthulhu/cthulhu-setup.ui b/src/cthulhu/cthulhu-setup.ui index 34abfcd..f69e8c3 100644 --- a/src/cthulhu/cthulhu-setup.ui +++ b/src/cthulhu/cthulhu-setup.ui @@ -3776,6 +3776,17 @@ 8 + + + True + False + AI Assistant + + + 8 + False + + True @@ -3951,17 +3962,6 @@ 9 - - - True - False - AI Assistant - - - 8 - False - - True diff --git a/src/cthulhu/cthulhu_gui_prefs.py b/src/cthulhu/cthulhu_gui_prefs.py index f2dec26..09814ef 100644 --- a/src/cthulhu/cthulhu_gui_prefs.py +++ b/src/cthulhu/cthulhu_gui_prefs.py @@ -3088,9 +3088,26 @@ class CthulhuSetupGUI(cthulhu_gtkbuilder.GtkBuilderWrapper): input_event_manager.get_manager().unmap_all_modifiers() except Exception: pass + self._unmapCthulhuModifiersForCapture() editable.connect('key-press-event', self.kbKeyPressed) return + def _unmapCthulhuModifiersForCapture(self): + """Unmap Cthulhu modifier keys so they can be captured as bindings.""" + device = cthulhu_state.device + if device is None: + return + + for modifierKey in settings.cthulhuModifierKeys: + keycode = keybindings.getKeycode(modifierKey) + if keycode == 0 and modifierKey == "Shift_Lock": + keycode = keybindings.getKeycode("Caps_Lock") + if keycode: + try: + device.unmap_modifier(keycode) + except Exception: + pass + def editingCanceledKey(self, editable): """Stops user input of a Key for a selected key binding""" diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 35fc16a..f9a6624 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -60,6 +60,7 @@ class EventManager: self._active = False self._enqueueCount = 0 self._dequeueCount = 0 + self._cmdlineCache = {} self._eventQueue = queue.Queue(0) self._gidleId = 0 self._gidleLock = threading.Lock() @@ -175,6 +176,43 @@ class EventManager: return False + def _getAppCmdline(self, app): + pid = AXObject.get_process_id(app) + if pid == -1: + return "" + + cmdline = self._cmdlineCache.get(pid) + if cmdline is None: + cmdline = debug.getCmdline(pid) + self._cmdlineCache[pid] = cmdline + return cmdline + + def _isSteamApp(self, app): + name = AXObject.get_name(app) + if not name: + nameLower = "" + else: + nameLower = name.lower() + + if "steamwebhelper" in nameLower or nameLower in ("steam", "steam web helper"): + return True + + cmdline = self._getAppCmdline(app) + return "steamwebhelper" in cmdline + + def _isSteamNotificationEvent(self, event): + for obj in (event.any_data, event.source): + if not isinstance(obj, Atspi.Accessible): + continue + if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj): + return True + liveAttr = AXObject.get_attribute(obj, 'live') + containerLive = AXObject.get_attribute(obj, 'container-live') + if liveAttr in ('assertive', 'polite') or containerLive in ('assertive', 'polite'): + return True + + return False + def _ignore(self, event): """Returns True if this event should be ignored.""" @@ -241,9 +279,14 @@ class EventManager: debug.printMessage(debug.LEVEL_INFO, msg, True) return True if script.app != app: - msg = 'EVENT MANAGER: Ignoring because event is not from active app' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + # Allow Steam notifications from inactive apps. + if self._isSteamApp(app) and self._isSteamNotificationEvent(event): + msg = 'EVENT MANAGER: Allowing Steam notification from inactive app' + debug.printMessage(debug.LEVEL_INFO, msg, True) + else: + msg = 'EVENT MANAGER: Ignoring because event is not from active app' + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True if event.type.startswith('object:text-changed') \ and self.EMBEDDED_OBJECT_CHARACTER in event.any_data \ diff --git a/src/cthulhu/flat_review_presenter.py b/src/cthulhu/flat_review_presenter.py index ea29ac1..e1b3338 100644 --- a/src/cthulhu/flat_review_presenter.py +++ b/src/cthulhu/flat_review_presenter.py @@ -479,23 +479,23 @@ class FlatReviewPresenter: bindings.add( keybindings.KeyBinding( - "", + "f", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, self._handlers.get("showContentsHandler"))) bindings.add( keybindings.KeyBinding( - "", + "c", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_CTRL_MODIFIER_MASK, self._handlers.get("flatReviewCopyHandler"))) bindings.add( keybindings.KeyBinding( - "", + "c", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, self._handlers.get("flatReviewAppendHandler"))) bindings.add( @@ -685,23 +685,23 @@ class FlatReviewPresenter: bindings.add( keybindings.KeyBinding( - "", + "f", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, self._handlers.get("showContentsHandler"))) bindings.add( keybindings.KeyBinding( - "", + "c", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_CTRL_MODIFIER_MASK, self._handlers.get("flatReviewCopyHandler"))) bindings.add( keybindings.KeyBinding( - "", + "c", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_CTRL_MODIFIER_MASK | keybindings.SHIFT_MODIFIER_MASK, self._handlers.get("flatReviewAppendHandler"))) bindings.add( diff --git a/src/cthulhu/notification_presenter.py b/src/cthulhu/notification_presenter.py index af6e614..2e8e22f 100644 --- a/src/cthulhu/notification_presenter.py +++ b/src/cthulhu/notification_presenter.py @@ -126,9 +126,9 @@ class NotificationPresenter: bindings.add( keybindings.KeyBinding( - "", + "n", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_MODIFIER_MASK, self._handlers.get("present_last_notification"))) bindings.add( @@ -147,9 +147,9 @@ class NotificationPresenter: bindings.add( keybindings.KeyBinding( - "", + "n", keybindings.defaultModifierMask, - keybindings.NO_MODIFIER_MASK, + keybindings.CTHULHU_CTRL_MODIFIER_MASK, self._handlers.get("show_notification_list"))) return bindings diff --git a/src/cthulhu/script_manager.py b/src/cthulhu/script_manager.py index f21abd3..47e9602 100644 --- a/src/cthulhu/script_manager.py +++ b/src/cthulhu/script_manager.py @@ -113,6 +113,12 @@ class ScriptManager: debug.printMessage(debug.LEVEL_INFO, msg, True) return None + pid = AXObject.get_process_id(app) + if pid != -1: + cmdline = debug.getCmdline(pid) + if "steamwebhelper" in cmdline: + return "steamwebhelper" + altNames = list(self._appNames.keys()) if name.endswith(".py") or name.endswith(".bin"): name = name.split('.')[0] diff --git a/src/cthulhu/scripts/apps/__init__.py b/src/cthulhu/scripts/apps/__init__.py index 1d73da5..8333088 100644 --- a/src/cthulhu/scripts/apps/__init__.py +++ b/src/cthulhu/scripts/apps/__init__.py @@ -40,5 +40,6 @@ __all__ = ['Banshee', 'soffice', 'SeaMonkey', 'smuxi-frontend-gnome', + 'steamwebhelper', 'Thunderbird', 'xfwm4'] diff --git a/src/cthulhu/scripts/apps/meson.build b/src/cthulhu/scripts/apps/meson.build index a9ecfe6..91991cd 100644 --- a/src/cthulhu/scripts/apps/meson.build +++ b/src/cthulhu/scripts/apps/meson.build @@ -25,4 +25,5 @@ subdir('notify-osd') subdir('pidgin') subdir('soffice') subdir('smuxi-frontend-gnome') +subdir('steamwebhelper') subdir('xfwm4') \ No newline at end of file diff --git a/src/cthulhu/scripts/apps/steamwebhelper/__init__.py b/src/cthulhu/scripts/apps/steamwebhelper/__init__.py new file mode 100644 index 0000000..585c964 --- /dev/null +++ b/src/cthulhu/scripts/apps/steamwebhelper/__init__.py @@ -0,0 +1 @@ +from .script import Script diff --git a/src/cthulhu/scripts/apps/steamwebhelper/meson.build b/src/cthulhu/scripts/apps/steamwebhelper/meson.build new file mode 100644 index 0000000..2ae9c2d --- /dev/null +++ b/src/cthulhu/scripts/apps/steamwebhelper/meson.build @@ -0,0 +1,9 @@ +steamwebhelper_python_sources = files([ + '__init__.py', + 'script.py', +]) + +python3.install_sources( + steamwebhelper_python_sources, + subdir: 'cthulhu/scripts/apps/steamwebhelper' +) diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script.py b/src/cthulhu/scripts/apps/steamwebhelper/script.py new file mode 100644 index 0000000..b5314d8 --- /dev/null +++ b/src/cthulhu/scripts/apps/steamwebhelper/script.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +# +# Copyright (c) 2024 Stormux +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the +# Free Software Foundation, Inc., Franklin Street, Fifth Floor, +# Boston MA 02110-1301 USA. +# +# Cthulhu project: https://git.stormux.org/storm/cthulhu + +"""Custom script for Steam Web Helper (Steam client UI).""" + +__id__ = "$Id$" +__version__ = "$Revision$" +__date__ = "$Date$" +__copyright__ = "Copyright (c) 2024 Stormux" +__license__ = "LGPL" + +import time + +from cthulhu import debug +from cthulhu import settings +from cthulhu import cthulhu_state +from cthulhu import settings_manager +from cthulhu import structural_navigation +from cthulhu.ax_object import AXObject +from cthulhu.ax_utilities import AXUtilities +from cthulhu.ax_utilities_relation import AXUtilitiesRelation +from cthulhu.ax_utilities_role import AXUtilitiesRole +from cthulhu.scripts.toolkits import Chromium + +settingsManager = settings_manager.getManager() + + +class SteamStructuralNavigation(structural_navigation.StructuralNavigation): + def _headingGetter(self, document, arg=None): + def isUsefulHeading(candidate): + if not self._script.utilities.inDocumentContent(candidate): + return False + + text = self._script.utilities.displayedText(candidate) or AXObject.get_name(candidate) + if not text: + return False + + return not text.strip().isdigit() + + if arg is None: + return super()._headingGetter(document, arg) + + headings = super()._headingGetter(document, None) + headings = [heading for heading in headings if isUsefulHeading(heading)] + return [heading for heading in headings if self._script.utilities.headingLevel(heading) == arg] + + +class Script(Chromium.Script): + """Script for Steam Web Helper with background notification support.""" + + def __init__(self, app): + """Creates a new script for the Steam Web Helper application. + + Arguments: + - app: the application to create a script for. + """ + super().__init__(app) + # CRITICAL: Enable background event processing for notifications + # This allows Steam notifications to be spoken even when Steam + # is not the focused application. + self.presentIfInactive = True + self._lastSteamNotification = ("", 0.0) + + def onShowingChanged(self, event): + """Callback for object:state-changed:showing accessibility events.""" + + # Check if this is a Steam notification/alert becoming visible + if event.detail1 and self._isSteamNotification(event.source): + self._presentSteamNotification(event.source) + return + + # Fall through to Chromium/web handling + super().onShowingChanged(event) + + def onChildrenAdded(self, event): + """Callback for object:children-changed:add accessibility events.""" + + # Check if a notification element was added + if self._isSteamNotification(event.any_data): + self._presentSteamNotification(event.any_data) + return + + super().onChildrenAdded(event) + + def getStructuralNavigation(self): + types = self.getEnabledStructuralNavigationTypes() + enable = settingsManager.getSetting('structuralNavigationEnabled') + return SteamStructuralNavigation(self, types, enable) + + def onFocusedChanged(self, event): + """Callback for object:state-changed:focused accessibility events.""" + + if event.detail1 and AXUtilities.is_document_web(event.source): + prevDocument = self.utilities.getDocumentForObject(cthulhu_state.locusOfFocus) + if prevDocument and AXUtilities.is_embedded(prevDocument) \ + and self.utilities.inDocumentContent(cthulhu_state.locusOfFocus): + msg = "STEAM: Ignoring focus event for top-level document while in embedded content" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + if event.detail1 \ + and AXUtilitiesRole.is_button(event.source) \ + and cthulhu_state.locusOfFocus in AXUtilitiesRelation.get_is_labelled_by(event.source): + msg = "STEAM: Ignoring focus event for labelled button" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + return super().onFocusedChanged(event) + + def onTextInserted(self, event): + """Callback for object:text-changed:insert accessibility events.""" + + if self._presentSteamLiveRegionText(event): + return + + super().onTextInserted(event) + + def _isSteamNotification(self, obj): + """Detect if object is a Steam notification. + + Arguments: + - obj: the accessible object to check + + Returns True if obj appears to be a notification element. + """ + if obj is None: + return False + + # Check for NOTIFICATION or ALERT roles + if AXUtilities.is_notification(obj) or AXUtilities.is_alert(obj): + msg = "STEAM: Found notification/alert role" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + # Check for live region with assertive/polite politeness + # (Steam may use ARIA live regions for toasts) + liveAttr = AXObject.get_attribute(obj, 'live') + containerLive = AXObject.get_attribute(obj, 'container-live') + if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']: + politeness = liveAttr or containerLive + msg = f"STEAM: Found live region with politeness: {politeness}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + def isNotificationCandidate(candidate): + if AXUtilities.is_notification(candidate) or AXUtilities.is_alert(candidate): + return True + candidateLive = AXObject.get_attribute(candidate, 'live') + candidateContainerLive = AXObject.get_attribute(candidate, 'container-live') + return candidateLive in ['assertive', 'polite'] or candidateContainerLive in ['assertive', 'polite'] + + ancestor = AXObject.find_ancestor(obj, isNotificationCandidate) + if ancestor: + msg = "STEAM: Found notification ancestor" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return True + + return False + + def _presentSteamNotification(self, obj): + """Speak and save the notification. + + Arguments: + - obj: the notification accessible object + """ + text = self._getNotificationText(obj) + if not text: + msg = "STEAM: Notification had no text content" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + self._presentSteamNotificationText(text, obj) + + def _presentSteamLiveRegionText(self, event): + if not isinstance(event.any_data, str): + return False + + text = event.any_data.strip() + if not text: + return False + + if not self._isSteamLiveRegion(event.source): + return False + + msg = f"STEAM: Live region text inserted: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + self._presentSteamNotificationText(text, event.source) + return True + + def _isSteamLiveRegion(self, obj): + if not obj: + return False + + liveAttr = AXObject.get_attribute(obj, 'live') + containerLive = AXObject.get_attribute(obj, 'container-live') + if liveAttr in ['assertive', 'polite'] or containerLive in ['assertive', 'polite']: + return True + + def isLiveRegion(candidate): + candidateLive = AXObject.get_attribute(candidate, 'live') + candidateContainerLive = AXObject.get_attribute(candidate, 'container-live') + return candidateLive in ['assertive', 'polite'] \ + or candidateContainerLive in ['assertive', 'polite'] + + return AXObject.find_ancestor(obj, isLiveRegion) is not None + + def _presentSteamNotificationText(self, text, obj): + if self._isDuplicateSteamNotification(text): + msg = "STEAM: Suppressing duplicate notification" + debug.printMessage(debug.LEVEL_INFO, msg, True) + return + + notificationMsg = f"Steam: {text}" + + # Speak the notification + voice = self.speechGenerator.voice(obj=obj, string=notificationMsg) + self.speakMessage(notificationMsg, voice=voice) + + # Display on braille + self.displayBrailleMessage(notificationMsg, flashTime=settings.brailleFlashTime) + + # Save to notification history + self.notificationPresenter.save_notification(notificationMsg) + + msg = f"STEAM: Presented notification: {text}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + def _isDuplicateSteamNotification(self, text): + lastText, lastTime = self._lastSteamNotification + now = time.monotonic() + if text == lastText and (now - lastTime) < 1.0: + return True + + self._lastSteamNotification = (text, now) + return False + + def _getNotificationText(self, obj): + """Extract text from notification element. + + Arguments: + - obj: the notification accessible object + + Returns the text content of the notification, or empty string if none found. + """ + def getText(candidate): + name = AXObject.get_name(candidate) + if name: + return name + return self.utilities.displayedText(candidate) + + # Try getting name directly + text = getText(obj) + if text: + return text + + # Try getting all text from labels/text elements + texts = [] + for label in AXUtilities.find_all_labels(obj): + text = getText(label) + if text: + texts.append(text) + + if texts: + return ' '.join(texts) + + def isNamedButton(candidate): + if not AXUtilitiesRole.is_button(candidate): + return False + return bool(getText(candidate)) + + descendantButton = AXObject.find_descendant(obj, isNamedButton) + if descendantButton: + return getText(descendantButton) + + ancestorButton = AXObject.find_ancestor(obj, isNamedButton) + if ancestorButton: + return getText(ancestorButton) + + def hasReadableText(candidate): + return bool(getText(candidate)) + + descendantText = AXObject.find_descendant(obj, hasReadableText) + if descendantText: + return getText(descendantText) + + # Fall back to displayed text of the object itself + return "" diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 3ab47ae..8826ed4 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -330,6 +330,11 @@ class Utilities(script_utilities.Utilities): if len(documents) == 1: document = documents[0] + if focusDoc and focusDoc != document and documentHasUri(focusDoc): + tokens = ["WEB: Single showing document differs; using focus-based document:", + focusDoc] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return focusDoc if documentHasUri(document): return document diff --git a/src/cthulhu/sound_theme_manager.py b/src/cthulhu/sound_theme_manager.py index 0759cc1..912f0f5 100644 --- a/src/cthulhu/sound_theme_manager.py +++ b/src/cthulhu/sound_theme_manager.py @@ -99,14 +99,59 @@ class SoundThemeManager: self._systemSoundsDir = None self._userSoundsDir = None + def _deriveDatadirFromModulePath(self): + """Derive the datadir from where this module is actually installed. + + If module is at /usr/local/lib/python3.x/site-packages/cthulhu/, + then datadir should be /usr/local/share. + """ + try: + modulePath = os.path.dirname(os.path.abspath(__file__)) + # Walk up looking for lib/python* pattern + currentPath = modulePath + for _ in range(10): # Limit iterations to avoid infinite loop + parent = os.path.dirname(currentPath) + if parent == currentPath: + break + dirName = os.path.basename(currentPath) + # Check if we're in a lib directory + if dirName == 'lib' or dirName.startswith('lib64'): + # Parent of lib is the prefix + prefix = parent + datadir = os.path.join(prefix, 'share') + if os.path.isdir(datadir): + return datadir + currentPath = parent + except Exception: + pass + return None + def getSystemSoundsDir(self): - """Get system sounds directory from platform settings.""" + """Get system sounds directory from platform settings. + + First tries cthulhu_platform.datadir, then derives from module location. + """ if self._systemSoundsDir is None: + datadir = None + + # First try the configured datadir from cthulhu_platform try: from . import cthulhu_platform - datadir = getattr(cthulhu_platform, 'datadir', '/usr/share') + datadir = getattr(cthulhu_platform, 'datadir', None) except ImportError: + pass + + # If datadir points to user home, it's likely wrong - try to derive it + if datadir and (datadir.startswith(os.path.expanduser('~')) or + '/home/' in datadir): + derivedDatadir = self._deriveDatadirFromModulePath() + if derivedDatadir: + datadir = derivedDatadir + + # Fallback to /usr/share if nothing else works + if not datadir: datadir = '/usr/share' + self._systemSoundsDir = os.path.join(datadir, 'cthulhu', 'sounds') return self._systemSoundsDir @@ -123,10 +168,20 @@ class SoundThemeManager: Returns list of theme names (folder names) from both system and user dirs. User themes with same name as system themes will override system themes. + Also checks standard fallback paths. """ themes = set() - for baseDir in [self.getSystemSoundsDir(), self.getUserSoundsDir()]: + # Check configured paths plus standard fallback locations + # /usr/share is standard, /usr/local/share is non-standard + searchDirs = [ + self.getUserSoundsDir(), + self.getSystemSoundsDir(), + '/usr/share/cthulhu/sounds', + '/usr/local/share/cthulhu/sounds', + ] + + for baseDir in searchDirs: if os.path.isdir(baseDir): try: for entry in os.listdir(baseDir): @@ -143,15 +198,30 @@ class SoundThemeManager: """Get the path to a theme directory. User themes take precedence over system themes. + Checks multiple standard locations as fallbacks. """ + # Check user directory first userPath = os.path.join(self.getUserSoundsDir(), themeName) if os.path.isdir(userPath): return userPath + # Check the configured system directory systemPath = os.path.join(self.getSystemSoundsDir(), themeName) if os.path.isdir(systemPath): return systemPath + # Fallback: check standard system paths in case cthulhu_platform.datadir + # doesn't match where sounds are actually installed + # Check /usr/share first (standard), then /usr/local/share (non-standard) + fallbackPaths = [ + '/usr/share/cthulhu/sounds', + '/usr/local/share/cthulhu/sounds', + ] + for fallbackDir in fallbackPaths: + fallbackPath = os.path.join(fallbackDir, themeName) + if os.path.isdir(fallbackPath): + return fallbackPath + return None def getSoundPath(self, themeName, soundName): diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index eeb009b..e48a812 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -37,6 +37,7 @@ gi.require_version("Atspi", "2.0") from gi.repository import Atspi from . import cmdnames +from . import cthulhu from . import dbus_service from . import debug from . import guilabels @@ -1175,23 +1176,38 @@ class StructuralNavigation: objPath = AXObject.get_path(obj) objRole = AXObject.get_role(obj) if objRole == Atspi.Role.INVALID: - return obj, characterOffset + return None, characterOffset self._script.utilities.setCaretPosition(obj, characterOffset) AXObject.clear_cache(obj) - if not AXUtilities.is_defunct(obj): + if not AXUtilities.is_defunct(obj) and not self._script.utilities.isZombie(obj): return obj, characterOffset tokens = ["STRUCTURAL NAVIGATION:", obj, "became defunct after setting caret position"] debug.printTokens(debug.LEVEL_INFO, tokens, True) replicant = self._script.utilities.get_objectFromPath(objPath) - if replicant and AXObject.get_role(replicant) == objRole: + if replicant and AXObject.get_role(replicant) == objRole \ + and not self._script.utilities.isZombie(replicant): tokens = ["STRUCTURAL NAVIGATION: Updating obj to replicant", replicant] debug.printTokens(debug.LEVEL_INFO, tokens, True) obj = replicant + return obj, characterOffset - return obj, characterOffset + document = getattr(self._script.utilities, "documentFrame", None) + searchForCaretContext = getattr(self._script.utilities, "searchForCaretContext", None) + if document and searchForCaretContext: + documentFrame = document() + if documentFrame and not self._script.utilities.isZombie(documentFrame): + fallbackObj, fallbackOffset = searchForCaretContext(documentFrame) + if fallbackObj and not self._script.utilities.isZombie(fallbackObj): + tokens = ["STRUCTURAL NAVIGATION: Recovering focus to", fallbackObj] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + cthulhu.setLocusOfFocus(None, fallbackObj, notifyScript=False) + self._script.utilities.setCaretContext( + fallbackObj, fallbackOffset, documentFrame) + + return None, characterOffset def _presentLine(self, obj, offset): """Presents the first line of the object to the user.