From a750761f613800e4e9be3067a77fc9a2f6574010 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 4 Apr 2026 20:22:54 -0400 Subject: [PATCH] Steam UI improvements. --- src/cthulhu/input_event.py | 4 +- .../scripts/apps/steamwebhelper/meson.build | 1 + .../scripts/apps/steamwebhelper/script.py | 83 +++++++-- .../apps/steamwebhelper/script_utilities.py | 162 ++++++++++++++++++ src/cthulhu/scripts/web/script_utilities.py | 6 +- tests/test_input_event_regressions.py | 65 +++++++ tests/test_steam_notification_regressions.py | 4 +- tests/test_steam_selection_regressions.py | 161 +++++++++++++++++ tests/test_web_router_regressions.py | 40 +++++ 9 files changed, 506 insertions(+), 20 deletions(-) create mode 100644 src/cthulhu/scripts/apps/steamwebhelper/script_utilities.py create mode 100644 tests/test_input_event_regressions.py create mode 100644 tests/test_steam_selection_regressions.py diff --git a/src/cthulhu/input_event.py b/src/cthulhu/input_event.py index a49aab8..3ee95e9 100644 --- a/src/cthulhu/input_event.py +++ b/src/cthulhu/input_event.py @@ -870,6 +870,8 @@ class KeyboardEvent(InputEvent): return True, 'Cthulhu modifier' if not self._handler: + if scriptConsumes: + return True, 'Script consumed without handler' return False, 'No handler' return scriptConsumes, 'Script indication' @@ -1044,7 +1046,7 @@ class KeyboardEvent(InputEvent): return False, 'Should not consume' if not (self._consumer or self._handler): - return False, 'No consumer or handler' + return True, 'Consumed during shouldConsume' if self._consumer or self._handler.function: GLib.timeout_add(1, self._consume) diff --git a/src/cthulhu/scripts/apps/steamwebhelper/meson.build b/src/cthulhu/scripts/apps/steamwebhelper/meson.build index 2ae9c2d..17c4477 100644 --- a/src/cthulhu/scripts/apps/steamwebhelper/meson.build +++ b/src/cthulhu/scripts/apps/steamwebhelper/meson.build @@ -1,6 +1,7 @@ steamwebhelper_python_sources = files([ '__init__.py', 'script.py', + 'script_utilities.py', ]) python3.install_sources( diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script.py b/src/cthulhu/scripts/apps/steamwebhelper/script.py index ed8468d..09123b2 100644 --- a/src/cthulhu/scripts/apps/steamwebhelper/script.py +++ b/src/cthulhu/scripts/apps/steamwebhelper/script.py @@ -42,6 +42,7 @@ 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 +from .script_utilities import Utilities settingsManager = settings_manager.getManager() @@ -88,6 +89,13 @@ class Script(Chromium.Script): re.IGNORECASE ) + def shouldConsumeKeyboardEvent(self, keyboardEvent, handler) -> bool: + consumes = super().shouldConsumeKeyboardEvent(keyboardEvent, handler) + if consumes: + return True + + return self._trySteamButtonActivation(keyboardEvent) + def onShowingChanged(self, event): """Callback for object:state-changed:showing accessibility events.""" @@ -99,6 +107,9 @@ class Script(Chromium.Script): # Fall through to Chromium/web handling super().onShowingChanged(event) + def getUtilities(self): + return Utilities(self) + def onChildrenAdded(self, event): """Callback for object:children-changed:add accessibility events.""" @@ -154,6 +165,40 @@ class Script(Chromium.Script): self._logSteamNavigationEvent("active-descendant-changed", event) return super().onActiveDescendantChanged(event) + def _trySteamButtonActivation(self, keyboardEvent) -> bool: + if keyboardEvent.event_string not in ["Return", "KP_Enter"]: + return False + + if not keyboardEvent.is_pressed_key(): + return False + + if getattr(keyboardEvent, "modifiers", 0): + return False + + obj = self._getClickableActivationTarget() + if not obj or not self.utilities.inDocumentContent(obj): + return False + + if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)): + return False + + if not any( + AXObject.has_action(obj, actionName) + for actionName in ["press", "click", "click-ancestor", "activate", "open", "jump"] + ): + return False + + if self._performClickableAction(obj): + return True + + from cthulhu import ax_event_synthesizer + result = ax_event_synthesizer.AXEventSynthesizer.click_object(obj) + if result: + self._restoreFocusAfterClick(obj) + return True + + return False + def _isSteamNotification(self, obj): """Detect if object is a Steam notification. @@ -253,29 +298,37 @@ class Script(Chromium.Script): return f"string('{text}')" if anyData is None: return "None" + if isinstance(anyData, (bool, int, float)): + return repr(anyData) return self._describeSteamObject(anyData) def _describeSteamObject(self, obj): if obj is None: return "None" - name = AXObject.get_name(obj) or "" - description = AXObject.get_description(obj) or "" - roleName = AXObject.get_role_name(obj) or "" - text = self.utilities.displayedText(obj) or "" - path = AXObject.get_path(obj) + try: + name = AXObject.get_name(obj) or "" + description = AXObject.get_description(obj) or "" + roleName = AXObject.get_role_name(obj) or "" + text = self.utilities.displayedText(obj) or "" + path = AXObject.get_path(obj) - name = self._normalizeSteamNotificationText(name) - description = self._normalizeSteamNotificationText(description) - text = self._normalizeSteamNotificationText(text) + name = self._normalizeSteamNotificationText(name) + description = self._normalizeSteamNotificationText(description) + text = self._normalizeSteamNotificationText(text) - return ( - f"role='{roleName}' " - f"name='{name}' " - f"description='{description}' " - f"text='{text}' " - f"path={path}" - ) + return ( + f"role='{roleName}' " + f"name='{name}' " + f"description='{description}' " + f"text='{text}' " + f"path={path}" + ) + except Exception as error: + return ( + f"uninspectable(type={type(obj).__name__}, " + f"value={obj!r}, error={error})" + ) def _presentSteamLiveRegionText(self, event): if not isinstance(event.any_data, str): diff --git a/src/cthulhu/scripts/apps/steamwebhelper/script_utilities.py b/src/cthulhu/scripts/apps/steamwebhelper/script_utilities.py new file mode 100644 index 0000000..b2959f3 --- /dev/null +++ b/src/cthulhu/scripts/apps/steamwebhelper/script_utilities.py @@ -0,0 +1,162 @@ +#!/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 + +"""Steam-specific utility helpers.""" + +from __future__ import annotations + +from typing import Optional + +from cthulhu import debug +from cthulhu.ax_object import AXObject +from cthulhu.ax_text import AXText +from cthulhu.ax_utilities import AXUtilities +from cthulhu.scripts.toolkits.Chromium.script_utilities import Utilities as ChromiumUtilities + + +class Utilities(ChromiumUtilities): + def __init__(self, script) -> None: + super().__init__(script) + self._steamInferredButtonLabels: dict[int, str] = {} + + def clearCachedObjects(self) -> None: + super().clearCachedObjects() + self._steamInferredButtonLabels = {} + + def displayedLabel(self, obj): + label = super().displayedLabel(obj) + if label or not self._shouldInferSteamButtonLabel(obj): + return label + + inferredLabel = self._getSteamInferredButtonLabel(obj) + if inferredLabel: + self._displayedLabelText[hash(obj)] = inferredLabel + return inferredLabel + + def displayedText(self, obj): + text = super().displayedText(obj) + if text or not self._shouldInferSteamButtonLabel(obj): + return text + + inferredLabel = self._getSteamInferredButtonLabel(obj) + if inferredLabel: + cache = self._script.generatorCache.setdefault(self.DISPLAYED_TEXT, {}) + cache[obj] = inferredLabel + return inferredLabel + + def _shouldInferSteamButtonLabel(self, obj) -> bool: + if not (obj and self.inDocumentContent(obj)): + return False + + if AXObject.get_name(obj): + return False + + if not (AXUtilities.is_button(obj) or AXUtilities.is_push_button(obj)): + return False + + className = AXObject.get_attribute(obj, "class") or "" + return "FriendsListTab" in className or "AddFriendButton" in className + + def _getSteamInferredButtonLabel(self, obj) -> str: + cached = self._steamInferredButtonLabels.get(hash(obj)) + if cached is not None: + return cached + + className = AXObject.get_attribute(obj, "class") or "" + inferredLabel = self._getSteamButtonLabelFromClass(className) + if not inferredLabel: + inferredLabel = self._getSteamNearbyButtonLabel(obj) + + inferredLabel = inferredLabel or "" + self._steamInferredButtonLabels[hash(obj)] = inferredLabel + if inferredLabel: + tokens = ["STEAM: Inferred label for", obj, ":", inferredLabel] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + + return inferredLabel + + @staticmethod + def _getSteamButtonLabelFromClass(className: str) -> str: + if "AddFriendButton" in className: + return "Add Friend" + return "" + + def _getSteamNearbyButtonLabel(self, obj) -> str: + parent = AXObject.get_parent(obj) + if parent is None: + return "" + + siblingLabel = self._getSteamLabelFromChildren(parent, ignore=obj) + if siblingLabel: + return siblingLabel + + parentLabel = self._getSteamReadableText(parent) + if self._isUsefulSteamLabel(parentLabel): + return parentLabel + + grandParent = AXObject.get_parent(parent) + if grandParent is None: + return "" + + return self._getSteamLabelFromChildren(grandParent, ignore=parent) + + def _getSteamLabelFromChildren(self, obj, ignore=None) -> str: + for child in AXObject.iter_children(obj): + if child == ignore: + continue + if AXUtilities.is_button(child) or AXUtilities.is_push_button(child): + continue + + label = self._getSteamReadableText(child) + if self._isUsefulSteamLabel(label): + return label + + return "" + + def _getSteamReadableText(self, obj) -> str: + if obj is None: + return "" + + name = self._normalizeSteamLabelText(AXObject.get_name(obj) or "") + if self._isUsefulSteamLabel(name): + return name + + if not AXObject.supports_text(obj): + return "" + + text = AXText.get_all_text(obj) or "" + text = text.replace(self.EMBEDDED_OBJECT_CHARACTER, " ") + text = self._normalizeSteamLabelText(text) + if self._isUsefulSteamLabel(text): + return text + + return "" + + @staticmethod + def _normalizeSteamLabelText(text: str) -> str: + return " ".join(text.split()) + + @staticmethod + def _isUsefulSteamLabel(text: Optional[str]) -> bool: + if not text: + return False + + return not text.isdigit() and text.casefold() != "unlabeled image" diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 2537a9b..6fd2c2e 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -5093,7 +5093,9 @@ class Utilities(script_utilities.Utilities): obj, offset = None, -1 notify = True - lastWasUp = input_event_manager.get_manager().last_event_was_up() + manager = input_event_manager.get_manager() + lastWasUp = manager.last_event_was_up() + lastWasDown = manager.last_event_was_down() childCount = AXObject.get_child_count(event.source) if lastWasUp: if event.detail1 >= childCount: @@ -5112,7 +5114,7 @@ class Utilities(script_utilities.Utilities): debug.printTokens(debug.LEVEL_INFO, tokens, True) obj, offset = self.previousContext(prevObj, -1) - elif keyString == "Down": + elif lastWasDown: if event.detail1 == 0: msg = "WEB: First child removed. Getting new location from start of parent." debug.printMessage(debug.LEVEL_INFO, msg, True) diff --git a/tests/test_input_event_regressions.py b/tests/test_input_event_regressions.py new file mode 100644 index 0000000..9309966 --- /dev/null +++ b/tests/test_input_event_regressions.py @@ -0,0 +1,65 @@ +import sys +import unittest +from pathlib import Path +from unittest import mock + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +soundGeneratorModule = sys.modules.get("cthulhu.sound_generator") +if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"): + class _StubSoundGenerator: + pass + + soundGeneratorModule.SoundGenerator = _StubSoundGenerator + +from gi.repository import Gdk + +from cthulhu import input_event + + +class KeyboardEventConsumptionTests(unittest.TestCase): + def test_script_consumed_key_without_handler_is_treated_as_consumed(self): + testScript = mock.Mock() + testScript.app = None + testScript.keyBindings.getInputHandler.return_value = None + testScript.shouldConsumeKeyboardEvent.return_value = True + testScript.learnModePresenter.is_active.return_value = False + testScript.presentKeyboardEvent.return_value = False + + keyboardEvent = input_event.KeyboardEvent( + True, + 36, + Gdk.KEY_Return, + 0, + "Return", + ) + keyboardEvent.set_script(testScript) + keyboardEvent.set_object(None) + keyboardEvent.set_window(None) + + with ( + mock.patch("cthulhu.input_event.cthulhu_state.capturingKeys", False), + mock.patch("cthulhu.input_event.cthulhu_state.bypassNextCommand", False), + ): + keyboardEvent._finalize_initialization() + self.assertTrue(keyboardEvent._should_consume) + self.assertEqual( + keyboardEvent._consume_reason, + "Script consumed without handler", + ) + + didConsume, resultReason = keyboardEvent._process() + + self.assertTrue(didConsume) + self.assertEqual(resultReason, "Consumed during shouldConsume") + testScript.presentationInterrupt.assert_called_once() + testScript.presentKeyboardEvent.assert_called_once_with(keyboardEvent) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_steam_notification_regressions.py b/tests/test_steam_notification_regressions.py index 79691bb..b732aeb 100644 --- a/tests/test_steam_notification_regressions.py +++ b/tests/test_steam_notification_regressions.py @@ -70,13 +70,13 @@ class SteamNotificationQueueTests(unittest.TestCase): testScript._presentSteamNotificationTextNow.assert_not_called() self.assertEqual( testScript._steamPendingNotification["text"], - "username Playing: Game Title", + "username Playing: Borderlands 2", ) testScript._flushSteamPendingNotification(fromTimer=True) testScript._presentSteamNotificationTextNow.assert_called_once_with( - "username Playing: Game Title", + "username Playing: Borderlands 2", notification, ) diff --git a/tests/test_steam_selection_regressions.py b/tests/test_steam_selection_regressions.py new file mode 100644 index 0000000..7728ea5 --- /dev/null +++ b/tests/test_steam_selection_regressions.py @@ -0,0 +1,161 @@ +import sys +import unittest +from pathlib import Path +from unittest import mock + +import gi + +gi.require_version("Gdk", "3.0") +gi.require_version("Gtk", "3.0") + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src")) + +soundGeneratorModule = sys.modules.get("cthulhu.sound_generator") +if soundGeneratorModule is not None and not hasattr(soundGeneratorModule, "SoundGenerator"): + class _StubSoundGenerator: + pass + + soundGeneratorModule.SoundGenerator = _StubSoundGenerator + +from cthulhu.scripts.apps.steamwebhelper import script as steam_script +from cthulhu.scripts.apps.steamwebhelper import script_utilities as steam_script_utilities + + +class SteamSelectionChangedTests(unittest.TestCase): + def test_selection_changed_tolerates_scalar_any_data(self): + testScript = steam_script.Script.__new__(steam_script.Script) + source = object() + event = mock.Mock(source=source, any_data=0) + chromiumCalls = [] + + def displayedText(obj): + if obj is source: + return "" + raise TypeError("argument self: Expected Atspi.Accessible, but got int") + + def get_name(obj): + if obj is source: + return "Notifications" + raise TypeError("argument self: Expected Atspi.Accessible, but got int") + + testScript.utilities = mock.Mock() + testScript.utilities.displayedText.side_effect = displayedText + + def chromiumOnSelectionChanged(self, selectionEvent): + chromiumCalls.append((self, selectionEvent)) + return True + + with ( + mock.patch.object(steam_script.AXObject, "get_name", side_effect=get_name), + mock.patch.object(steam_script.AXObject, "get_description", return_value=""), + mock.patch.object(steam_script.AXObject, "get_role_name", return_value="page tab list"), + mock.patch.object(steam_script.AXObject, "get_path", return_value=[1, 2, 3]), + mock.patch.object( + steam_script.Chromium.Script, + "onSelectionChanged", + new=chromiumOnSelectionChanged, + ), + ): + self.assertTrue(testScript.onSelectionChanged(event)) + + self.assertEqual(chromiumCalls, [(testScript, event)]) + + +class SteamReturnActivationTests(unittest.TestCase): + def test_return_activates_focused_steam_button(self): + testScript = steam_script.Script.__new__(steam_script.Script) + button = object() + keyboardEvent = mock.Mock(event_string="Return", modifiers=0) + keyboardEvent.is_pressed_key.return_value = True + + testScript.utilities = mock.Mock() + testScript.utilities.inDocumentContent.return_value = True + testScript.inFocusMode = mock.Mock(return_value=True) + testScript.presentMessage = mock.Mock() + testScript._presentDelayedMessage = mock.Mock() + testScript._restoreFocusAfterClick = mock.Mock() + + def has_action(obj, action_name): + return obj is button and action_name == "press" + + with ( + mock.patch.object(steam_script.cthulhu_state, "locusOfFocus", button), + mock.patch.object(steam_script.AXUtilities, "is_entry", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_text", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_password_text", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_combo_box", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_button", return_value=True), + mock.patch.object(steam_script.AXUtilities, "is_push_button", return_value=False), + mock.patch.object(steam_script.AXUtilities, "is_link", return_value=False), + mock.patch.object(steam_script.AXObject, "has_action", side_effect=has_action), + mock.patch.object(steam_script.Script, "_performClickableAction", return_value=True) as performAction, + ): + self.assertTrue(testScript.shouldConsumeKeyboardEvent(keyboardEvent, None)) + + performAction.assert_called_once_with(button) + + +class SteamLabelRecoveryTests(unittest.TestCase): + def test_displayed_label_recovers_friends_list_tab_text_from_parent_context(self): + testScript = mock.Mock(generatorCache={}) + utilities = steam_script_utilities.Utilities(testScript) + button = object() + parent = object() + textSibling = object() + + utilities.inDocumentContent = mock.Mock(return_value=True) + + def get_attribute(obj, name): + if obj is button and name == "class": + return "FriendsListTab Active Panel Focusable gpfocus" + return None + + def get_name(obj): + if obj is textSibling: + return "Friends" + return "" + + def iter_children(obj, pred=None): + children = [textSibling, button] + if obj is not parent: + children = [] + if pred is not None: + children = [child for child in children if pred(child)] + return iter(children) + + with ( + mock.patch.object(steam_script_utilities.ChromiumUtilities, "displayedLabel", return_value=""), + mock.patch.object(steam_script_utilities.AXObject, "get_parent", side_effect=lambda obj: parent if obj is button else None), + mock.patch.object(steam_script_utilities.AXObject, "get_attribute", side_effect=get_attribute), + mock.patch.object(steam_script_utilities.AXObject, "get_name", side_effect=get_name), + mock.patch.object(steam_script_utilities.AXObject, "supports_text", return_value=False), + mock.patch.object(steam_script_utilities.AXObject, "iter_children", side_effect=iter_children), + mock.patch.object(steam_script_utilities.AXUtilities, "is_button", side_effect=lambda obj: obj is button), + mock.patch.object(steam_script_utilities.AXUtilities, "is_push_button", return_value=False), + ): + self.assertEqual(utilities.displayedLabel(button), "Friends") + + def test_displayed_label_maps_add_friend_button_class_to_fallback_name(self): + testScript = mock.Mock(generatorCache={}) + utilities = steam_script_utilities.Utilities(testScript) + button = object() + + utilities.inDocumentContent = mock.Mock(return_value=True) + + def get_attribute(obj, name): + if obj is button and name == "class": + return "friendListButton AddFriendButton Panel Focusable" + return None + + with ( + mock.patch.object(steam_script_utilities.ChromiumUtilities, "displayedLabel", return_value=""), + mock.patch.object(steam_script_utilities.AXObject, "get_attribute", side_effect=get_attribute), + mock.patch.object(steam_script_utilities.AXObject, "get_name", return_value=""), + mock.patch.object(steam_script_utilities.AXUtilities, "is_button", side_effect=lambda obj: obj is button), + mock.patch.object(steam_script_utilities.AXUtilities, "is_push_button", return_value=False), + ): + self.assertEqual(utilities.displayedLabel(button), "Add Friend") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_web_router_regressions.py b/tests/test_web_router_regressions.py index 31c1782..b06c549 100644 --- a/tests/test_web_router_regressions.py +++ b/tests/test_web_router_regressions.py @@ -108,5 +108,45 @@ class WebHiddenPopupTests(unittest.TestCase): self.assertFalse(utilities._canHaveCaretContext(hiddenObject)) +class WebRemovedChildRegressionTests(unittest.TestCase): + def test_removed_child_recovery_does_not_crash_when_last_key_is_not_up_or_down(self): + utilities = script_utilities.Utilities.__new__(script_utilities.Utilities) + removedChild = object() + locusOfFocus = object() + source = object() + recoveredObject = object() + event = mock.Mock(any_data=removedChild, source=source, detail1=0) + + utilities._script = mock.Mock(pointOfReference={"names": {}}) + utilities._handleEventForRemovedListBoxChild = mock.Mock(return_value=False) + utilities.isSameObject = mock.Mock(return_value=False) + utilities.searchForCaretContext = mock.Mock(return_value=(recoveredObject, 0)) + utilities.setCaretContext = mock.Mock() + + manager = mock.Mock() + manager.last_event_was_up.return_value = False + manager.last_event_was_down.return_value = False + + def find_ancestor(obj, predicate): + if obj is locusOfFocus and predicate(removedChild): + return removedChild + return None + + with ( + mock.patch.object(script_utilities.cthulhu_state, "locusOfFocus", locusOfFocus), + mock.patch.object(script_utilities.input_event_manager, "get_manager", return_value=manager), + mock.patch.object(script_utilities.AXObject, "find_ancestor", side_effect=find_ancestor), + mock.patch.object(script_utilities.AXObject, "get_child_count", return_value=0), + mock.patch.object(script_utilities.AXObject, "clear_cache"), + mock.patch.object(script_utilities.AXObject, "is_dead", return_value=False), + mock.patch.object(script_utilities.AXUtilities, "get_focused_object", return_value=None), + mock.patch.object(script_utilities.cthulhu, "setLocusOfFocus") as setLocusOfFocus, + ): + self.assertTrue(utilities.handleEventForRemovedChild(event)) + + setLocusOfFocus.assert_called_once_with(event, recoveredObject, False) + utilities.setCaretContext.assert_called_once_with(recoveredObject, 0) + + if __name__ == "__main__": unittest.main()