Compare commits
6 Commits
8ff74bb83a
...
e3e58adfbe
| Author | SHA1 | Date | |
|---|---|---|---|
| e3e58adfbe | |||
| 5b446000b8 | |||
| 733f5eee69 | |||
| 921ffc4145 | |||
| afdd812f2f | |||
| e2cbcb0ac4 |
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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',
|
||||
|
||||
+22
-3
@@ -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'
|
||||
)
|
||||
|
||||
@@ -3776,6 +3776,17 @@
|
||||
<property name="position">8</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="tab">
|
||||
<object class="GtkLabel" id="aiTabLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">AI Assistant</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="position">8</property>
|
||||
<property name="tab_fill">False</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child>
|
||||
<object class="GtkGrid" id="ocrGrid">
|
||||
<property name="visible">True</property>
|
||||
@@ -3951,17 +3962,6 @@
|
||||
<property name="position">9</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="tab">
|
||||
<object class="GtkLabel" id="aiTabLabel">
|
||||
<property name="visible">True</property>
|
||||
<property name="can_focus">False</property>
|
||||
<property name="label" translatable="yes">AI Assistant</property>
|
||||
</object>
|
||||
<packing>
|
||||
<property name="position">8</property>
|
||||
<property name="tab_fill">False</property>
|
||||
</packing>
|
||||
</child>
|
||||
<child type="tab">
|
||||
<object class="GtkLabel" id="ocrTabLabel">
|
||||
<property name="visible">True</property>
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -40,5 +40,6 @@ __all__ = ['Banshee',
|
||||
'soffice',
|
||||
'SeaMonkey',
|
||||
'smuxi-frontend-gnome',
|
||||
'steamwebhelper',
|
||||
'Thunderbird',
|
||||
'xfwm4']
|
||||
|
||||
@@ -25,4 +25,5 @@ subdir('notify-osd')
|
||||
subdir('pidgin')
|
||||
subdir('soffice')
|
||||
subdir('smuxi-frontend-gnome')
|
||||
subdir('steamwebhelper')
|
||||
subdir('xfwm4')
|
||||
@@ -0,0 +1 @@
|
||||
from .script import Script
|
||||
@@ -0,0 +1,9 @@
|
||||
steamwebhelper_python_sources = files([
|
||||
'__init__.py',
|
||||
'script.py',
|
||||
])
|
||||
|
||||
python3.install_sources(
|
||||
steamwebhelper_python_sources,
|
||||
subdir: 'cthulhu/scripts/apps/steamwebhelper'
|
||||
)
|
||||
@@ -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 ""
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user