From 6e049e35e2be4cee07d1f5b7e7ede06edc1cc984 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Wed, 8 Apr 2026 06:41:24 -0400 Subject: [PATCH] Attempt to speed up Steam even more. Sound for link added. --- sounds/meson.build | 2 + src/cthulhu/plugin_system_manager.py | 35 +++- src/cthulhu/script_utilities.py | 58 ++++++- src/cthulhu/scripts/default.py | 3 +- src/cthulhu/scripts/web/speech_generator.py | 2 +- src/cthulhu/sound_theme_manager.py | 18 +++ src/cthulhu/speech_generator.py | 15 +- tests/test_link_sound_regressions.py | 151 ++++++++++++++++++ .../test_plugin_system_manager_regressions.py | 48 +++++- tests/test_script_utilities_regressions.py | 28 ++++ 10 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 tests/test_link_sound_regressions.py create mode 100644 tests/test_script_utilities_regressions.py diff --git a/sounds/meson.build b/sounds/meson.build index 0d1b8b0..4681e33 100644 --- a/sounds/meson.build +++ b/sounds/meson.build @@ -10,6 +10,8 @@ default_theme_sounds = files( 'default/checkbox_unchecked.wav', 'default/combobox.wav', 'default/focus_mode.wav', + 'default/link.wav', + 'default/link_visited.wav', 'default/radiobutton_checked.wav', 'default/radiobutton_unchecked.wav', 'default/start.wav', diff --git a/src/cthulhu/plugin_system_manager.py b/src/cthulhu/plugin_system_manager.py index 478670b..0b46c88 100644 --- a/src/cthulhu/plugin_system_manager.py +++ b/src/cthulhu/plugin_system_manager.py @@ -914,6 +914,37 @@ class PluginSystemManager: inactive_plugins = [p.get_module_name() for p in self.plugins if not p.loaded] logger.info(f"Inactive plugins after sync: {inactive_plugins}") + @staticmethod + def _lifecycle_accepts_plugin_instance(method: Any) -> bool: + """Return True if the bound lifecycle method expects a plugin argument.""" + + try: + parameters = list(inspect.signature(method).parameters.values()) + except (TypeError, ValueError): + return True + + if any(parameter.kind == inspect.Parameter.VAR_POSITIONAL for parameter in parameters): + return True + + positional_parameters = [ + parameter for parameter in parameters + if parameter.kind in ( + inspect.Parameter.POSITIONAL_ONLY, + inspect.Parameter.POSITIONAL_OR_KEYWORD, + ) + ] + return bool(positional_parameters) + + def _callPluginLifecycle(self, pluginInstance: Any, methodName: str) -> None: + """Call a plugin lifecycle method without replaying it through pluggy.""" + + method = getattr(pluginInstance, methodName) + if self._lifecycle_accepts_plugin_instance(method): + method(pluginInstance) + return + + method() + def loadPlugin(self, pluginInfo): """Load a plugin.""" module_name = pluginInfo.get_module_name() @@ -998,7 +1029,7 @@ class PluginSystemManager: logger.info(f"Activating plugin: {module_name}") # Lifecycle is per-plugin. Broadcasting through pluggy replays # activate() on every previously-registered plugin. - plugin_instance.activate(plugin_instance) + self._callPluginLifecycle(plugin_instance, "activate") except Exception as e: logger.error(f"Error activating plugin {module_name}: {e}") import traceback @@ -1039,7 +1070,7 @@ class PluginSystemManager: try: # Mirror targeted activation and only deactivate the plugin # instance being unloaded. - plugin_instance.deactivate(plugin_instance) + self._callPluginLifecycle(plugin_instance, "deactivate") except Exception as e: logger.error(f"Error deactivating plugin {module_name}: {e}") diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 90e86e7..42b3992 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -41,7 +41,6 @@ import gi import locale import math import re -import subprocess import time from difflib import SequenceMatcher from typing import Any, Callable, Generator, Optional, TYPE_CHECKING @@ -200,7 +199,8 @@ class Utilities: return "" try: - cmdline = subprocess.getoutput(f"cat /proc/{pid}/cmdline") + with open(f"/proc/{pid}/cmdline", "rb") as cmdline_file: + cmdline = cmdline_file.read().decode("utf-8", errors="replace") except Exception: return "" @@ -211,7 +211,9 @@ class Utilities: return False app = AXObject.get_application(window) - tokens = ["SCRIPT UTILITIES: Looking at", window, "from", app, self._getAppCommandLine(app)] + tokens = ["SCRIPT UTILITIES: Looking at", window, "from", app] + if debug.debugLevel <= debug.LEVEL_INFO: + tokens.append(self._getAppCommandLine(app)) debug.printTokens(debug.LEVEL_INFO, tokens, True) if clearCache: @@ -3082,6 +3084,56 @@ class Utilities: return "".join(adjustedLine) + def getLinkIndicatorPresentation(self, obj: Atspi.Accessible, line: Any, startOffset: Any): + """Return the spoken text and any link icons for a line fragment.""" + + spoken = self.adjustForLinks(obj, line, startOffset) + roleSoundPresentation = cthulhu.cthulhuApp.settingsManager.getSetting('roleSoundPresentation') + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY: + return spoken, [] + + if not cthulhu.cthulhuApp.settingsManager.getSetting('enableSound'): + return spoken, [] + + if not AXObject.supports_hypertext(obj): + return spoken, [] + + from . import sound_theme_manager + + manager = sound_theme_manager.getManager() + if manager is None: + return spoken, [] + + endOffset = startOffset + len(line) + icons = [] + missingIcon = False + for link in AXHypertext.get_all_links(obj): + start_index = AXHypertext.get_link_start_offset(link) + end_index = AXHypertext.get_link_end_offset(link) + if start_index < 0 or end_index < 0: + continue + + if startOffset < end_index <= endOffset: + pass + elif startOffset <= start_index < endOffset: + pass + else: + continue + + icon = manager.getLinkSoundIcon(visited=AXUtilities.is_visited(link)) + if icon: + icons.append(icon) + else: + missingIcon = True + + if not icons: + return spoken, [] + + if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY and not missingIcon: + return line, icons + + return spoken, icons + def _processMultiCaseString(self, string: Any) -> Any: return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', string) diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 9ef5d85..8bf289e 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -2402,7 +2402,7 @@ class Script(script.Script): voice = self.speechGenerator.voice( obj=obj, string=string, language=language, dialect=dialect) - string = self.utilities.adjustForLinks(obj, string, start) + string, linkIcons = self.utilities.getLinkIndicatorPresentation(obj, string, start) string = self.utilities.adjustForRepeats(string) if self.utilities.shouldVerbalizeAllPunctuation(obj): string = self.utilities.verbalizeAllPunctuation(string) @@ -2414,6 +2414,7 @@ class Script(script.Script): result = [string] result.extend(voice) + result.extend(linkIcons) utterance.append(result) speech.speak(utterance) else: diff --git a/src/cthulhu/scripts/web/speech_generator.py b/src/cthulhu/scripts/web/speech_generator.py index 8048c4b..c715cf2 100644 --- a/src/cthulhu/scripts/web/speech_generator.py +++ b/src/cthulhu/scripts/web/speech_generator.py @@ -570,7 +570,7 @@ class SpeechGenerator(speech_generator.SpeechGenerator): roleSoundIcon = None if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \ and soundEnabled: - roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) + roleSoundIcon = self._getRoleSoundIcon(obj, role) if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ and soundEnabled: # Stateful controls present their role via state sounds; suppress diff --git a/src/cthulhu/sound_theme_manager.py b/src/cthulhu/sound_theme_manager.py index c66efd3..f7987c7 100644 --- a/src/cthulhu/sound_theme_manager.py +++ b/src/cthulhu/sound_theme_manager.py @@ -303,6 +303,24 @@ class SoundThemeManager: return None + def getLinkSoundIcon(self, visited=False, themeName=None): + """Return an Icon for a plain or visited link sound from the current theme.""" + + themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default' + if themeName == THEME_NONE: + return None + + candidates = ["link"] + if visited: + candidates.insert(0, "link_visited") + + for candidate in candidates: + soundPath = self.getSoundPath(themeName, candidate) + if soundPath: + return Icon(os.path.dirname(soundPath), os.path.basename(soundPath)) + + return None + def getRoleStateSoundIcon(self, role, stateKey, themeName=None): """Return an Icon for the role/state sound from the current theme, if any.""" themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default' diff --git a/src/cthulhu/speech_generator.py b/src/cthulhu/speech_generator.py index 7885c7d..a28889c 100644 --- a/src/cthulhu/speech_generator.py +++ b/src/cthulhu/speech_generator.py @@ -118,6 +118,16 @@ class SpeechGenerator(generator.Generator): def __init__(self, script): generator.Generator.__init__(self, script, "speech") + def _getRoleSoundIcon(self, obj, role): + """Return the themed sound icon for obj's role, if any.""" + + if role == Atspi.Role.LINK or AXUtilities.is_link(obj): + return sound_theme_manager.getManager().getLinkSoundIcon( + visited=AXUtilities.is_visited(obj) + ) + + return sound_theme_manager.getManager().getRoleSoundIcon(role) + def _addGlobals(self, globalsDict): """Other things to make available from the formatting string. """ @@ -676,7 +686,7 @@ class SpeechGenerator(generator.Generator): roleSoundIcon = None if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \ and soundEnabled: - roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) + roleSoundIcon = self._getRoleSoundIcon(obj, role) if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ and soundEnabled: # Stateful controls present their role via state sounds; suppress @@ -1442,9 +1452,10 @@ class SpeechGenerator(generator.Generator): args.pop("string") voice = self.voice(string=string, obj=obj, **args) - string = self._script.utilities.adjustForLinks(obj, string, start) + string, linkIcons = self._script.utilities.getLinkIndicatorPresentation(obj, string, start) rv = [self._script.utilities.adjustForRepeats(string)] rv.extend(voice) + rv.extend(linkIcons) # TODO - JD: speech.speak() has a bug which causes a list of utterances to # be presented before a string+voice pair that comes first. Until we can diff --git a/tests/test_link_sound_regressions.py b/tests/test_link_sound_regressions.py new file mode 100644 index 0000000..12958d0 --- /dev/null +++ b/tests/test_link_sound_regressions.py @@ -0,0 +1,151 @@ +import sys +import types +import unittest +from pathlib import Path +from unittest import mock +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +sound_stub = types.ModuleType("cthulhu.sound") +sound_stub.getPlayer = mock.Mock(return_value=None) +sys.modules.setdefault("cthulhu.sound", sound_stub) + +speech_stub = types.ModuleType("cthulhu.speech") +speech_stub.speak = mock.Mock() +sys.modules.setdefault("cthulhu.speech", speech_stub) + +from cthulhu import settings +from cthulhu.sound_theme_manager import SoundThemeManager +from cthulhu.speech_generator import SpeechGenerator +from cthulhu.script_utilities import Utilities + + +class LinkSoundThemeRegressionTests(unittest.TestCase): + def test_default_theme_install_manifest_includes_link_sounds(self): + mesonPath = Path(__file__).resolve().parents[1] / "sounds" / "meson.build" + mesonText = mesonPath.read_text(encoding="utf-8") + + self.assertIn("'default/link.wav'", mesonText) + self.assertIn("'default/link_visited.wav'", mesonText) + + def _create_manager(self): + app = mock.Mock() + settingsManager = mock.Mock() + settingsManager.getSetting.return_value = "default" + app.getSettingsManager.return_value = settingsManager + return SoundThemeManager(app) + + def test_visited_link_prefers_visited_asset(self): + manager = self._create_manager() + + with mock.patch.object( + manager, + "getSoundPath", + side_effect=["/tmp/link_visited.wav", "/tmp/link.wav"], + ): + icon = manager.getLinkSoundIcon(visited=True) + + self.assertTrue(icon.path.endswith("link_visited.wav")) + + def test_visited_link_falls_back_to_plain_link_asset(self): + manager = self._create_manager() + + with mock.patch.object( + manager, + "getSoundPath", + side_effect=[None, "/tmp/link.wav"], + ): + icon = manager.getLinkSoundIcon(visited=True) + + self.assertTrue(icon.path.endswith("link.wav")) + + +class SpeechGeneratorLinkSoundRegressionTests(unittest.TestCase): + def test_role_sound_icon_uses_visited_link_asset_for_visited_links(self): + generator = object.__new__(SpeechGenerator) + generator._script = mock.Mock() + generator._script.utilities = mock.Mock() + + icon = SimpleNamespace(path="/tmp/link_visited.wav") + + with ( + mock.patch("cthulhu.speech_generator.AXUtilities.is_link", return_value=True), + mock.patch("cthulhu.speech_generator.AXUtilities.is_visited", return_value=True), + mock.patch("cthulhu.speech_generator.sound_theme_manager.getManager") as get_manager, + ): + get_manager.return_value.getLinkSoundIcon.return_value = icon + result = generator._getRoleSoundIcon(mock.Mock(), "link-role") + + self.assertEqual(result, icon) + get_manager.return_value.getLinkSoundIcon.assert_called_once_with(visited=True) + + +class LinkIndicatorPresentationRegressionTests(unittest.TestCase): + def test_sound_only_inline_link_marker_uses_icon_instead_of_word(self): + utility = object.__new__(Utilities) + utility.adjustForLinks = mock.Mock(return_value="Docs link") + + icon = SimpleNamespace(path="/tmp/link.wav") + link = mock.Mock() + + def get_setting(name): + values = { + "roleSoundPresentation": settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY, + "enableSound": True, + } + return values.get(name) + + fakeApp = mock.Mock() + fakeApp.settingsManager.getSetting.side_effect = get_setting + + with ( + mock.patch("cthulhu.script_utilities.AXObject.supports_hypertext", return_value=True), + mock.patch("cthulhu.script_utilities.AXHypertext.get_all_links", return_value=[link]), + mock.patch("cthulhu.script_utilities.AXHypertext.get_link_start_offset", return_value=0), + mock.patch("cthulhu.script_utilities.AXHypertext.get_link_end_offset", return_value=4), + mock.patch("cthulhu.script_utilities.AXUtilities.is_visited", return_value=False), + mock.patch("cthulhu.sound_theme_manager.getManager") as get_manager, + mock.patch("cthulhu.script_utilities.cthulhu.cthulhuApp", fakeApp), + ): + get_manager.return_value.getLinkSoundIcon.return_value = icon + spoken, icons = utility.getLinkIndicatorPresentation(mock.Mock(), "Docs", 0) + + self.assertEqual(spoken, "Docs") + self.assertEqual(icons, [icon]) + + def test_sound_and_speech_inline_link_marker_keeps_word_and_adds_icon(self): + utility = object.__new__(Utilities) + utility.adjustForLinks = mock.Mock(return_value="Docs link") + + icon = SimpleNamespace(path="/tmp/link_visited.wav") + link = mock.Mock() + + def get_setting(name): + values = { + "roleSoundPresentation": settings.ROLE_SOUND_PRESENTATION_SOUND_AND_SPEECH, + "enableSound": True, + } + return values.get(name) + + fakeApp = mock.Mock() + fakeApp.settingsManager.getSetting.side_effect = get_setting + + with ( + mock.patch("cthulhu.script_utilities.AXObject.supports_hypertext", return_value=True), + mock.patch("cthulhu.script_utilities.AXHypertext.get_all_links", return_value=[link]), + mock.patch("cthulhu.script_utilities.AXHypertext.get_link_start_offset", return_value=0), + mock.patch("cthulhu.script_utilities.AXHypertext.get_link_end_offset", return_value=4), + mock.patch("cthulhu.script_utilities.AXUtilities.is_visited", return_value=True), + mock.patch("cthulhu.sound_theme_manager.getManager") as get_manager, + mock.patch("cthulhu.script_utilities.cthulhu.cthulhuApp", fakeApp), + ): + get_manager.return_value.getLinkSoundIcon.return_value = icon + spoken, icons = utility.getLinkIndicatorPresentation(mock.Mock(), "Docs", 0) + + self.assertEqual(spoken, "Docs link") + self.assertEqual(icons, [icon]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_plugin_system_manager_regressions.py b/tests/test_plugin_system_manager_regressions.py index f14db33..554ee67 100644 --- a/tests/test_plugin_system_manager_regressions.py +++ b/tests/test_plugin_system_manager_regressions.py @@ -34,13 +34,37 @@ class {class_name}(Plugin): self.deactivation_count += 1 """ +PLUGIN_TEMPLATE_PLAIN_SIGNATURE = """ +from cthulhu.plugin import Plugin, cthulhu_hookimpl + + +class {class_name}(Plugin): + def __init__(self): + super().__init__() + self.activation_count = 0 + self.deactivation_count = 0 + + @cthulhu_hookimpl + def activate(self): + self.activation_count += 1 + + @cthulhu_hookimpl + def deactivate(self): + self.deactivation_count += 1 +""" + class PluginSystemManagerRegressionTests(unittest.TestCase): - def _create_plugin_info(self, root_dir: str, module_name: str) -> PluginInfo: + def _create_plugin_info( + self, + root_dir: str, + module_name: str, + template: str = PLUGIN_TEMPLATE, + ) -> PluginInfo: plugin_dir = Path(root_dir) / module_name plugin_dir.mkdir() (plugin_dir / "plugin.py").write_text( - textwrap.dedent(PLUGIN_TEMPLATE.format(class_name=module_name)), + textwrap.dedent(template.format(class_name=module_name)), encoding="utf-8", ) return PluginInfo( @@ -93,6 +117,26 @@ class PluginSystemManagerRegressionTests(unittest.TestCase): self.assertEqual(first_plugin.instance.deactivation_count, 0) self.assertEqual(second_instance.deactivation_count, 1) + @mock.patch("cthulhu.plugin_system_manager.dbus_service.get_remote_controller") + def test_loading_plugin_with_plain_activate_signature_succeeds(self, remote_controller): + remote_controller.return_value = mock.Mock() + + with tempfile.TemporaryDirectory() as temp_dir: + manager = self._create_manager() + plugin_info = self._create_plugin_info( + temp_dir, + "PlainSignaturePlugin", + template=PLUGIN_TEMPLATE_PLAIN_SIGNATURE, + ) + + self.assertTrue(manager.loadPlugin(plugin_info)) + instance = plugin_info.instance + self.assertEqual(instance.activation_count, 1) + + self.assertTrue(manager.unloadPlugin(plugin_info)) + self.assertEqual(instance.deactivation_count, 1) + self.assertEqual(plugin_info.instance, None) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_script_utilities_regressions.py b/tests/test_script_utilities_regressions.py new file mode 100644 index 0000000..6f62e19 --- /dev/null +++ b/tests/test_script_utilities_regressions.py @@ -0,0 +1,28 @@ +import sys +import unittest +from unittest import mock +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +from cthulhu import debug +from cthulhu.script_utilities import Utilities + + +class ScriptUtilitiesRegressionTests(unittest.TestCase): + @mock.patch("cthulhu.script_utilities.AXObject.get_application") + def test_can_be_active_window_skips_cmdline_lookup_without_info_logging(self, get_application): + utility = object.__new__(Utilities) + utility._getAppCommandLine = mock.Mock(side_effect=AssertionError("cmdline lookup should be skipped")) + utility._isActiveAndShowingAndNotIconified = mock.Mock(return_value=True) + + window = object() + app = object() + get_application.return_value = app + + with mock.patch.object(debug, "debugLevel", debug.LEVEL_SEVERE): + self.assertTrue(utility.canBeActiveWindow(window)) + + +if __name__ == "__main__": + unittest.main()