Attempt to speed up Steam even more. Sound for link added.

This commit is contained in:
Storm Dragon
2026-04-08 06:41:24 -04:00
parent ee4292564a
commit 6e049e35e2
10 changed files with 349 additions and 11 deletions
+2
View File
@@ -10,6 +10,8 @@ default_theme_sounds = files(
'default/checkbox_unchecked.wav', 'default/checkbox_unchecked.wav',
'default/combobox.wav', 'default/combobox.wav',
'default/focus_mode.wav', 'default/focus_mode.wav',
'default/link.wav',
'default/link_visited.wav',
'default/radiobutton_checked.wav', 'default/radiobutton_checked.wav',
'default/radiobutton_unchecked.wav', 'default/radiobutton_unchecked.wav',
'default/start.wav', 'default/start.wav',
+33 -2
View File
@@ -914,6 +914,37 @@ class PluginSystemManager:
inactive_plugins = [p.get_module_name() for p in self.plugins if not p.loaded] inactive_plugins = [p.get_module_name() for p in self.plugins if not p.loaded]
logger.info(f"Inactive plugins after sync: {inactive_plugins}") 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): def loadPlugin(self, pluginInfo):
"""Load a plugin.""" """Load a plugin."""
module_name = pluginInfo.get_module_name() module_name = pluginInfo.get_module_name()
@@ -998,7 +1029,7 @@ class PluginSystemManager:
logger.info(f"Activating plugin: {module_name}") logger.info(f"Activating plugin: {module_name}")
# Lifecycle is per-plugin. Broadcasting through pluggy replays # Lifecycle is per-plugin. Broadcasting through pluggy replays
# activate() on every previously-registered plugin. # activate() on every previously-registered plugin.
plugin_instance.activate(plugin_instance) self._callPluginLifecycle(plugin_instance, "activate")
except Exception as e: except Exception as e:
logger.error(f"Error activating plugin {module_name}: {e}") logger.error(f"Error activating plugin {module_name}: {e}")
import traceback import traceback
@@ -1039,7 +1070,7 @@ class PluginSystemManager:
try: try:
# Mirror targeted activation and only deactivate the plugin # Mirror targeted activation and only deactivate the plugin
# instance being unloaded. # instance being unloaded.
plugin_instance.deactivate(plugin_instance) self._callPluginLifecycle(plugin_instance, "deactivate")
except Exception as e: except Exception as e:
logger.error(f"Error deactivating plugin {module_name}: {e}") logger.error(f"Error deactivating plugin {module_name}: {e}")
+55 -3
View File
@@ -41,7 +41,6 @@ import gi
import locale import locale
import math import math
import re import re
import subprocess
import time import time
from difflib import SequenceMatcher from difflib import SequenceMatcher
from typing import Any, Callable, Generator, Optional, TYPE_CHECKING from typing import Any, Callable, Generator, Optional, TYPE_CHECKING
@@ -200,7 +199,8 @@ class Utilities:
return "" return ""
try: 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: except Exception:
return "" return ""
@@ -211,7 +211,9 @@ class Utilities:
return False return False
app = AXObject.get_application(window) 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) debug.printTokens(debug.LEVEL_INFO, tokens, True)
if clearCache: if clearCache:
@@ -3082,6 +3084,56 @@ class Utilities:
return "".join(adjustedLine) 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: def _processMultiCaseString(self, string: Any) -> Any:
return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', string) return re.sub(r'(?<=[a-z])(?=[A-Z])', ' ', string)
+2 -1
View File
@@ -2402,7 +2402,7 @@ class Script(script.Script):
voice = self.speechGenerator.voice( voice = self.speechGenerator.voice(
obj=obj, string=string, language=language, dialect=dialect) 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) string = self.utilities.adjustForRepeats(string)
if self.utilities.shouldVerbalizeAllPunctuation(obj): if self.utilities.shouldVerbalizeAllPunctuation(obj):
string = self.utilities.verbalizeAllPunctuation(string) string = self.utilities.verbalizeAllPunctuation(string)
@@ -2414,6 +2414,7 @@ class Script(script.Script):
result = [string] result = [string]
result.extend(voice) result.extend(voice)
result.extend(linkIcons)
utterance.append(result) utterance.append(result)
speech.speak(utterance) speech.speak(utterance)
else: else:
+1 -1
View File
@@ -570,7 +570,7 @@ class SpeechGenerator(speech_generator.SpeechGenerator):
roleSoundIcon = None roleSoundIcon = None
if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \ if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \
and soundEnabled: and soundEnabled:
roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) roleSoundIcon = self._getRoleSoundIcon(obj, role)
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \
and soundEnabled: and soundEnabled:
# Stateful controls present their role via state sounds; suppress # Stateful controls present their role via state sounds; suppress
+18
View File
@@ -303,6 +303,24 @@ class SoundThemeManager:
return None 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): def getRoleStateSoundIcon(self, role, stateKey, themeName=None):
"""Return an Icon for the role/state sound from the current theme, if any.""" """Return an Icon for the role/state sound from the current theme, if any."""
themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default' themeName = themeName or self.app.getSettingsManager().getSetting('soundTheme') or 'default'
+13 -2
View File
@@ -118,6 +118,16 @@ class SpeechGenerator(generator.Generator):
def __init__(self, script): def __init__(self, script):
generator.Generator.__init__(self, script, "speech") 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): def _addGlobals(self, globalsDict):
"""Other things to make available from the formatting string. """Other things to make available from the formatting string.
""" """
@@ -676,7 +686,7 @@ class SpeechGenerator(generator.Generator):
roleSoundIcon = None roleSoundIcon = None
if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \ if roleSoundPresentation != settings.ROLE_SOUND_PRESENTATION_SPEECH_ONLY \
and soundEnabled: and soundEnabled:
roleSoundIcon = sound_theme_manager.getManager().getRoleSoundIcon(role) roleSoundIcon = self._getRoleSoundIcon(obj, role)
if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \ if roleSoundPresentation == settings.ROLE_SOUND_PRESENTATION_SOUND_ONLY \
and soundEnabled: and soundEnabled:
# Stateful controls present their role via state sounds; suppress # Stateful controls present their role via state sounds; suppress
@@ -1442,9 +1452,10 @@ class SpeechGenerator(generator.Generator):
args.pop("string") args.pop("string")
voice = self.voice(string=string, obj=obj, **args) 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 = [self._script.utilities.adjustForRepeats(string)]
rv.extend(voice) rv.extend(voice)
rv.extend(linkIcons)
# TODO - JD: speech.speak() has a bug which causes a list of utterances to # 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 # be presented before a string+voice pair that comes first. Until we can
+151
View File
@@ -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()
@@ -34,13 +34,37 @@ class {class_name}(Plugin):
self.deactivation_count += 1 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): 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 = Path(root_dir) / module_name
plugin_dir.mkdir() plugin_dir.mkdir()
(plugin_dir / "plugin.py").write_text( (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", encoding="utf-8",
) )
return PluginInfo( return PluginInfo(
@@ -93,6 +117,26 @@ class PluginSystemManagerRegressionTests(unittest.TestCase):
self.assertEqual(first_plugin.instance.deactivation_count, 0) self.assertEqual(first_plugin.instance.deactivation_count, 0)
self.assertEqual(second_instance.deactivation_count, 1) 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__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -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()