Attempt to speed up Steam even more. Sound for link added.
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user