From 0acba6a733eb168d20530a0121c559d484fe4487 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Fri, 15 May 2026 15:48:52 -0400 Subject: [PATCH] Backported Chrome omnibox fix from Orca. --- src/cthulhu/ax_object.py | 32 +++++++++- src/cthulhu/focus_manager.py | 7 +++ tests/test_chromium_omnibox_regressions.py | 68 ++++++++++++++++++++++ 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/test_chromium_omnibox_regressions.py diff --git a/src/cthulhu/ax_object.py b/src/cthulhu/ax_object.py index 00137a6..3614941 100644 --- a/src/cthulhu/ax_object.py +++ b/src/cthulhu/ax_object.py @@ -148,14 +148,42 @@ class AXObject: if not toolkit_name.startswith("qt"): return False + if not AXObject._can_reach_application(obj): + tokens = ["AXObject:", obj, "has broken ancestry. See qt bug 130116."] + debug.print_tokens(debug.LEVEL_INFO, tokens, True) + return True + + return False + + @staticmethod + def _can_reach_application(obj: Atspi.Accessible) -> bool: + """Returns True if obj's ancestry reaches the application.""" + reached_app = False parent = AXObject.get_parent(obj) while parent and not reached_app: reached_app = AXObject.get_role(parent) == Atspi.Role.APPLICATION parent = AXObject.get_parent(parent) - if not reached_app: - tokens = ["AXObject:", obj, "has broken ancestry. See qt bug 130116."] + return reached_app + + @staticmethod + def has_broken_popup_ancestry(obj: Atspi.Accessible) -> bool: + """Returns True if obj is a popup item whose ancestry is broken.""" + + if obj is None or AXObject.is_dead(obj): + return False + + # Chromium Omnibox popups can lose their path back to the frame after + # the popup is closed and reopened. + if not AXObject.get_toolkit_name(obj).startswith("chromium"): + return False + + if AXObject.get_role(obj) != Atspi.Role.LIST_ITEM: + return False + + if not AXObject._can_reach_application(obj): + tokens = ["AXObject:", obj, "has broken popup ancestry."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True diff --git a/src/cthulhu/focus_manager.py b/src/cthulhu/focus_manager.py index 3625db2..8391150 100644 --- a/src/cthulhu/focus_manager.py +++ b/src/cthulhu/focus_manager.py @@ -348,6 +348,13 @@ class FocusManager: tokens.extend(["in", app]) _log_tokens(tokens) + if frame is None and AXObject.has_broken_popup_ancestry(self._focus): + _log_tokens( + ["Not clearing active window; focus", self._focus, "is in popup with broken ancestry"], + "broken-popup-ancestry", + ) + return + if frame == self._window: _log("Setting active window to existing active window", "no-change") elif frame is None: diff --git a/tests/test_chromium_omnibox_regressions.py b/tests/test_chromium_omnibox_regressions.py new file mode 100644 index 0000000..81d38f7 --- /dev/null +++ b/tests/test_chromium_omnibox_regressions.py @@ -0,0 +1,68 @@ +import unittest +from unittest import mock + +import gi +gi.require_version("Atspi", "2.0") +from gi.repository import Atspi + +from cthulhu import ax_object +from cthulhu import cthulhu_state +from cthulhu import focus_manager + + +class ChromiumOmniboxRegressionTests(unittest.TestCase): + def tearDown(self): + cthulhu_state.activeWindow = None + cthulhu_state.locusOfFocus = None + + def test_detects_chromium_popup_item_with_broken_ancestry(self): + popupItem = object() + + with ( + mock.patch.object(ax_object.AXObject, "is_dead", return_value=False), + mock.patch.object(ax_object.AXObject, "get_toolkit_name", return_value="chromium"), + mock.patch.object(ax_object.AXObject, "get_role", return_value=Atspi.Role.LIST_ITEM), + mock.patch.object(ax_object.AXObject, "get_parent", return_value=None), + mock.patch.object(ax_object.debug, "print_tokens"), + ): + self.assertTrue(ax_object.AXObject.has_broken_popup_ancestry(popupItem)) + + def test_ignores_chromium_popup_item_with_valid_ancestry(self): + popupItem = object() + parent = object() + + def get_role(obj): + if obj is popupItem: + return Atspi.Role.LIST_ITEM + return Atspi.Role.APPLICATION + + with ( + mock.patch.object(ax_object.AXObject, "is_dead", return_value=False), + mock.patch.object(ax_object.AXObject, "get_toolkit_name", return_value="chromium"), + mock.patch.object(ax_object.AXObject, "get_role", side_effect=get_role), + mock.patch.object(ax_object.AXObject, "get_parent", side_effect=[parent, None]), + ): + self.assertFalse(ax_object.AXObject.has_broken_popup_ancestry(popupItem)) + + def test_preserves_active_window_when_chromium_popup_ancestry_is_broken(self): + activeWindow = object() + popupItem = object() + cthulhu_state.activeWindow = activeWindow + cthulhu_state.locusOfFocus = popupItem + + controller = mock.Mock() + app = mock.Mock() + manager = None + with ( + mock.patch.object(focus_manager.dbus_service, "get_remote_controller", return_value=controller), + mock.patch.object(focus_manager.AXObject, "has_broken_popup_ancestry", return_value=True), + ): + manager = focus_manager.FocusManager(app) + manager.set_active_window(None) + + self.assertIs(manager.get_active_window(), activeWindow) + self.assertIs(cthulhu_state.activeWindow, activeWindow) + + +if __name__ == "__main__": + unittest.main()