diff --git a/src/cthulhu/ax_utilities_collection.py b/src/cthulhu/ax_utilities_collection.py index 9a356cf..f4e2c8d 100644 --- a/src/cthulhu/ax_utilities_collection.py +++ b/src/cthulhu/ax_utilities_collection.py @@ -100,6 +100,10 @@ class AXUtilitiesCollection: return [] role_list = list(role_list) + if AXUtilitiesCollection._should_avoid_collection_for_roles(root, role_list): + return AXUtilitiesCollection._find_all_with_role_fallback( + root, role_list, role_match_type, pred) + tokens = ["AXUtilitiesCollection:", inspect.currentframe(), "Root:", root, role_match_type, "of:", role_list] debug.print_tokens(debug.LEVEL_INFO, tokens, True) @@ -111,6 +115,87 @@ class AXUtilitiesCollection: return matches + @staticmethod + def _find_all_with_role_fallback( + root: Atspi.Accessible, + role_list: list[Atspi.Role], + role_match_type: Atspi.CollectionMatchType, + pred: Callable[[Atspi.Accessible], bool] | None = None + ) -> list[Atspi.Accessible]: + def matchesLandmarkRole(acc, role): + if role == Atspi.Role.LANDMARK: + return True + if AXUtilitiesRole.is_landmark_banner(acc): + return True + if AXUtilitiesRole.is_landmark_complementary(acc): + return True + if AXUtilitiesRole.is_landmark_contentinfo(acc): + return True + if AXUtilitiesRole.is_landmark_form(acc): + return True + if AXUtilitiesRole.is_landmark_main(acc): + return True + if AXUtilitiesRole.is_landmark_navigation(acc): + return True + if AXUtilitiesRole.is_landmark_region(acc): + return True + if AXUtilitiesRole.is_landmark_search(acc): + return True + return AXUtilitiesRole.is_dpub(acc) + + def role_matches(acc): + role = AXObject.get_role(acc) + matched = role in role_list + if not matched and Atspi.Role.LANDMARK in role_list: + matched = matchesLandmarkRole(acc, role) + if role_match_type == Atspi.CollectionMatchType.NONE: + matched = not matched + if not matched: + return False + if pred is None: + return True + return pred(acc) + + return AXObject.find_all_descendants(root, role_matches) + + @staticmethod + def _should_avoid_collection_for_roles( + root: Atspi.Accessible, + role_list: list[Atspi.Role] + ) -> bool: + if Atspi.Role.LANDMARK not in role_list: + return False + + toolkit_name = AXObject.get_toolkit_name(root) + if toolkit_name in {"chromium", "chrome"}: + msg = ( + "AXUtilitiesCollection: Avoiding collection interface for landmarks " + f"in Chromium-family app (toolkit={toolkit_name})." + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + + app = AXObject.get_application(root) + raw_name = AXObject.get_name(app) or "" + app_name = raw_name.lower() + chromium_apps = { + "brave", + "chrome", + "chromium", + "edge", + "opera", + "vivaldi", + } + if not any(name in app_name for name in chromium_apps): + return False + + msg = ( + "AXUtilitiesCollection: Avoiding collection interface for landmarks " + f"in Chromium-family app ({raw_name or 'unknown app'})." + ) + debug.print_message(debug.LEVEL_INFO, msg, True) + return True + @staticmethod def find_all_with_interfaces( root: Atspi.Accessible, diff --git a/src/cthulhu/structural_navigation.py b/src/cthulhu/structural_navigation.py index 3abe229..eeb009b 100644 --- a/src/cthulhu/structural_navigation.py +++ b/src/cthulhu/structural_navigation.py @@ -806,14 +806,26 @@ class StructuralNavigation: self.clearCache() self._inModalDialog = inModalDialog + def filterZombies(matchList): + if not matchList: + return [] + return [match for match in matchList if not self._script.utilities.isZombie(match)] + document = self._script.utilities.documentFrame() cache = self._objectCache.get(hash(document), {}) key = f"{structuralNavigationObject.objType}:{arg}" matches = cache.get(key, []) if matches: - tokens = ["STRUCTURAL NAVIGATION: Returning", len(matches), "matches from cache"] + matches = filterZombies(matches) + if matches: + tokens = ["STRUCTURAL NAVIGATION: Returning", len(matches), "matches from cache"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return matches.copy() + + tokens = ["STRUCTURAL NAVIGATION: Cached matches are zombies; refreshing"] debug.printTokens(debug.LEVEL_INFO, tokens, True) - return matches.copy() + cache.pop(key, None) + self._objectCache[hash(document)] = cache if structuralNavigationObject.getter: matches = structuralNavigationObject.getter(document, arg) @@ -834,6 +846,7 @@ class StructuralNavigation: "objects outside of modal dialog", modalDialog] debug.printTokens(debug.LEVEL_INFO, tokens, True) + matches = filterZombies(matches) rv = matches.copy() cache[key] = matches self._objectCache[hash(document)] = cache @@ -899,6 +912,8 @@ class StructuralNavigation: def _isValidMatch(obj): if AXObject.is_dead(obj): return False + if self._script.utilities.isZombie(obj): + return False if self._script.utilities.isHidden(obj) or self._script.utilities.isEmpty(obj): return False if structuralNavigationObject.predicate is None: