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/combobox.wav',
'default/focus_mode.wav',
'default/link.wav',
'default/link_visited.wav',
'default/radiobutton_checked.wav',
'default/radiobutton_unchecked.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]
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}")
+55 -3
View File
@@ -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)
+2 -1
View File
@@ -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:
+1 -1
View File
@@ -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
+18
View File
@@ -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'
+13 -2
View File
@@ -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
+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
"""
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()
@@ -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()