diff --git a/src/cthulhu/ax_component.py b/src/cthulhu/ax_component.py index c26bf10..17bdea6 100644 --- a/src/cthulhu/ax_component.py +++ b/src/cthulhu/ax_component.py @@ -145,7 +145,7 @@ class AXComponent: return not(rect.width or rect.height) @staticmethod - def has_no_size_or_invalid_rect(obj: Atspi.Accessible) -> bool: + def has_no_size_or_invalid_rect(obj: Atspi.Accessible, clear_cache: bool = True) -> bool: """Returns True if the rect associated with obj is sizeless or invalid.""" rect = AXComponent.get_rect(obj) @@ -158,7 +158,8 @@ class AXComponent: if (rect.width < -1 or rect.height < -1): tokens = ["WARNING: ", obj, "has a broken rect:", rect] debug.print_tokens(debug.LEVEL_INFO, tokens, True) - AXObject.clear_cache(obj) + if clear_cache: + AXObject.clear_cache(obj) rect = AXComponent.get_rect(obj) if (rect.width < -1 or rect.height < -1): msg = "AXComponent: Clearing cache did not fix the rect" diff --git a/src/cthulhu/ax_utilities.py b/src/cthulhu/ax_utilities.py index 9d53eb7..77498e4 100644 --- a/src/cthulhu/ax_utilities.py +++ b/src/cthulhu/ax_utilities.py @@ -109,13 +109,14 @@ class AXUtilities: AXTable.clear_cache_now(reason) @staticmethod - def can_be_active_window(window: Atspi.Accessible) -> bool: + def can_be_active_window(window: Atspi.Accessible, clear_cache: bool = True) -> bool: """Returns True if window can be the active window based on its state.""" if window is None: return False - AXObject.clear_cache(window, False, "Checking if window can be the active window") + if clear_cache: + AXObject.clear_cache(window, False, "Checking if window can be the active window") app = AXUtilitiesApplication.get_application(window) tokens = ["AXUtilities:", window, "from", app] @@ -808,11 +809,13 @@ class AXUtilities: @staticmethod def is_on_screen( obj: Atspi.Accessible, - bounding_box: Optional[Atspi.Rect] = None + bounding_box: Optional[Atspi.Rect] = None, + clear_cache: bool = True ) -> bool: """Returns true if obj should be treated as being on screen.""" - AXObject.clear_cache(obj, False, "Updating to check if object is on screen.") + if clear_cache: + AXObject.clear_cache(obj, False, "Updating to check if object is on screen.") tokens = ["AXUtilities: Checking if", obj, "is showing and visible...."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) @@ -833,7 +836,7 @@ class AXUtilities: tokens = ["AXUtilities:", obj, "is not hidden. Checking size and rect..."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) - if AXComponent.has_no_size_or_invalid_rect(obj): + if AXComponent.has_no_size_or_invalid_rect(obj, clear_cache=clear_cache): tokens = ["AXUtilities: Rect of", obj, "is unhelpful. Treating as on screen."] debug.print_tokens(debug.LEVEL_INFO, tokens, True) return True @@ -972,6 +975,8 @@ class AXUtilities: debug.print_tokens(debug.LEVEL_INFO, tokens, True) return result +# Dynamically expose helper methods for compatibility with older callers. +# Keep side effects explicit in the underlying helpers (e.g., clear_cache flags). for method_name, method in inspect.getmembers(AXUtilitiesApplication, predicate=inspect.isfunction): setattr(AXUtilities, method_name, method) diff --git a/src/cthulhu/event_manager.py b/src/cthulhu/event_manager.py index 9637298..3afb875 100644 --- a/src/cthulhu/event_manager.py +++ b/src/cthulhu/event_manager.py @@ -109,7 +109,7 @@ class EventManager: return False window = cthulhu_state.activeWindow - if not AXUtilities.can_be_active_window(window): + if not AXUtilities.can_be_active_window(window, clear_cache=True): window = AXUtilities.find_active_window() if window is not None: tokens = ["EVENT MANAGER: Setting initial active window to", window] @@ -237,40 +237,44 @@ class EventManager: tokens = ["EVENT MANAGER:", event.type, "from", app] debug.printTokens(debug.LEVEL_INFO, tokens, True) + def _log_ignore(reason, message): + msg = f"EVENT MANAGER: Ignoring ({reason}) - {message}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + def _log_allow(reason, message): + msg = f"EVENT MANAGER: Not ignoring ({reason}) - {message}" + debug.printMessage(debug.LEVEL_INFO, msg, True) + + def _ignore_with_reason(reason, message): + _log_ignore(reason, message) + return True + + def _allow_with_reason(reason, message): + _log_allow(reason, message) + return False + if self._eventsSuspended: tokens = ["EVENT MANAGER: Suspended events:", ', '.join(self._suspendableEvents)] debug.printTokens(debug.LEVEL_INFO, tokens, True) if not self._active: - msg = 'EVENT MANAGER: Ignoring because event manager is not active' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("inactive", "event manager is not active") if list(filter(event.type.startswith, self._ignoredEvents)): - msg = 'EVENT MANAGER: Ignoring because event type is ignored' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("type-ignored", "event type is ignored") if AXObject.get_name(app) == 'gnome-shell': if event.type.startswith('object:children-changed:remove'): - msg = 'EVENT MANAGER: Ignoring event based on type and app' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("gnome-shell", "children-changed:remove") if event.type.startswith('window'): - msg = 'EVENT MANAGER: Not ignoring because event type is never ignored' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False + return _allow_with_reason("window-event", "event type is never ignored") if event.type.startswith('mouse:button'): - msg = 'EVENT MANAGER: Not ignoring because event type is never ignored' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False + return _allow_with_reason("mouse-event", "event type is never ignored") if self._isDuplicateEvent(event): - msg = 'EVENT MANAGER: Ignoring duplicate event' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("duplicate", "duplicate event") # Thunderbird spams us with these when a message list thread is expanded or collapsed. if event.type.endswith('system') \ @@ -278,40 +282,29 @@ class EventManager: if AXUtilities.is_table_related(event.source) \ or AXUtilities.is_tree_related(event.source) \ or AXUtilities.is_section(event.source): - msg = 'EVENT MANAGER: Ignoring system event based on role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("thunderbird-system", "system event based on role") if self._inDeluge() and self._ignoreDuringDeluge(event): - msg = 'EVENT MANAGER: Ignoring event type due to deluge' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("deluge", "event type during deluge") script = cthulhu_state.activeScript if event.type.startswith('object:children-changed') \ or event.type.startswith('object:state-changed:sensitive'): if not script: - msg = 'EVENT MANAGER: Ignoring because there is no active script' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("no-active-script", "no active script") if script.app != app: # Allow Steam notifications from inactive apps. if self._isSteamApp(app) and self._isSteamNotificationEvent(event): - msg = 'EVENT MANAGER: Allowing Steam notification from inactive app' - debug.printMessage(debug.LEVEL_INFO, msg, True) + _log_allow("steam-notification", "inactive app notification") else: - msg = 'EVENT MANAGER: Ignoring because event is not from active app' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("inactive-app", "event not from active app") if event.type.startswith('object:text-changed') \ and self.EMBEDDED_OBJECT_CHARACTER in event.any_data \ and not event.any_data.replace(self.EMBEDDED_OBJECT_CHARACTER, ""): # We should also get children-changed events telling us the same thing. # Getting a bunch of both can result in a flood that grinds us to a halt. - msg = 'EVENT MANAGER: Ignoring because changed text is only embedded objects' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("embedded-only", "changed text only embedded objects") # TODO - JD: For now we won't ask for the name. Simply asking for the name should # not break anything, and should be a reliable way to quickly identify defunct @@ -321,14 +314,10 @@ class EventManager: #name = Atspi.Accessible.get_name(event.source) if AXUtilities.has_no_state(event.source): - msg = 'EVENT MANAGER: Ignoring event due to empty state set' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("empty-state", "empty state set") if AXUtilities.is_defunct(event.source): - msg = 'EVENT MANAGER: Ignoring event from defunct source' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("defunct-source", "defunct source") role = AXObject.get_role(event.source) if event.type.startswith('object:property-change:accessible-name'): @@ -344,28 +333,20 @@ class EventManager: Atspi.Role.IMAGE, # Thunderbird spam Atspi.Role.MENU, Atspi.Role.MENU_ITEM]: - msg = 'EVENT MANAGER: Ignoring event type due to role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("name-change-role", "role filtered") # TeamTalk5 is notoriously spammy here, and name change events on widgets are # typically only presented if they are focused. if not AXUtilities.is_focused(event.source) \ and role in [Atspi.Role.PUSH_BUTTON, Atspi.Role.CHECK_BOX, Atspi.Role.RADIO_BUTTON]: - msg = 'EVENT MANAGER: Ignoring event type due to role and state' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("name-change-unfocused", "role and state") elif event.type.startswith('object:property-change:accessible-value'): if role == Atspi.Role.SPLIT_PANE and not AXUtilities.is_focused(event.source): - msg = 'EVENT MANAGER: Ignoring event type due to role and state' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("value-change-unfocused", "role and state") elif event.type.startswith('object:text-changed:insert') and event.detail2 > 1000 \ and role in [Atspi.Role.TEXT, Atspi.Role.STATIC]: - msg = 'EVENT MANAGER: Ignoring because inserted text has more than 1000 chars' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("text-insert-large", "inserted text > 1000 chars") elif event.type.startswith('object:state-changed:sensitive'): if role in [Atspi.Role.MENU_ITEM, Atspi.Role.MENU, @@ -373,14 +354,10 @@ class EventManager: Atspi.Role.PANEL, Atspi.Role.CHECK_MENU_ITEM, Atspi.Role.RADIO_MENU_ITEM]: - msg = 'EVENT MANAGER: Ignoring event type due to role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("sensitive-role", "role filtered") elif event.type.startswith('object:state-changed:selected'): if not event.detail1 and role in [Atspi.Role.PUSH_BUTTON]: - msg = 'EVENT MANAGER: Ignoring event type due to role and detail1' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("selected-button-false", "role and detail1") elif event.type.startswith('object:state-changed:showing'): if role not in [Atspi.Role.ALERT, Atspi.Role.ANIMATION, @@ -390,39 +367,27 @@ class EventManager: Atspi.Role.DIALOG, Atspi.Role.STATUS_BAR, Atspi.Role.TOOL_TIP]: - msg = 'EVENT MANAGER: Ignoring event type due to role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("showing-role", "role filtered") elif event.type.startswith('object:text-caret-moved'): if role in [Atspi.Role.LABEL] and not AXUtilities.is_focused(event.source): - msg = 'EVENT MANAGER: Ignoring event type due to role and state' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("caret-unfocused-label", "role and state") elif event.type.startswith('object:selection-changed'): if event.source in self._parentsOfDefunctDescendants: - msg = 'EVENT MANAGER: Ignoring event from parent of defunct descendants' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("defunct-descendant-parent", "parent of defunct descendants") if AXObject.is_dead(event.source): - msg = 'EVENT MANAGER: Ignoring event from dead source' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("dead-source", "dead source") if event.type.startswith('object:children-changed') \ or event.type.startswith('object:active-descendant-changed'): if role in [Atspi.Role.MENU, Atspi.Role.LAYERED_PANE, Atspi.Role.MENU_ITEM]: - msg = 'EVENT MANAGER: Ignoring event type due to role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("children-role", "role filtered") if event.any_data is None: - msg = 'EVENT_MANAGER: Ignoring due to lack of event.any_data' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("missing-any-data", "no event.any_data") if event.type.endswith('remove'): if event.any_data == cthulhu_state.locusOfFocus: msg = 'EVENT MANAGER: Locus of focus is being destroyed' @@ -439,8 +404,7 @@ class EventManager: defunct = AXObject.is_dead(event.any_data) or AXUtilities.is_defunct(event.any_data) if defunct: - msg = 'EVENT MANAGER: Ignoring event for potentially-defunct child/descendant' - debug.printMessage(debug.LEVEL_INFO, msg, True) + _log_ignore("defunct-child", "potentially defunct child/descendant") if AXUtilities.manages_descendants(event.source) \ and event.source not in self._parentsOfDefunctDescendants: self._parentsOfDefunctDescendants.append(event.source) @@ -455,21 +419,15 @@ class EventManager: # reason for ignoring it here rather than quickly processing it is the # potential for event floods like we're seeing from matrix.org. if AXUtilities.is_image(event.any_data): - msg = 'EVENT MANAGER: Ignoring event type due to role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("child-image", "role filtered") # In normal apps we would have caught this from the parent role. # But gnome-shell has panel parents adding/removing menu items. if event.type.startswith('object:children-changed'): if AXUtilities.is_menu_item(event.any_data): - msg = 'EVENT MANAGER: Ignoring event type due to child role' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return True + return _ignore_with_reason("child-menu-item", "child role filtered") - msg = 'EVENT MANAGER: Not ignoring due to lack of cause' - debug.printMessage(debug.LEVEL_INFO, msg, True) - return False + return _allow_with_reason("no-cause", "no ignore condition met") def _addToQueue(self, event, asyncMode): debugging = debug.debugEventQueue diff --git a/src/cthulhu/flat_review.py b/src/cthulhu/flat_review.py index effc082..8cefcc3 100644 --- a/src/cthulhu/flat_review.py +++ b/src/cthulhu/flat_review.py @@ -111,24 +111,29 @@ class Word: if attr != "chars": return super().__getattribute__(attr) + chars = [] + for i, char in enumerate(self.string): + start = i + self.startOffset + extents = self._getCharExtents(start) + chars.append(Char(self, i, start, char, *extents)) + + return chars + + def _getCharExtents(self, start): # TODO - JD: For now, don't fake character and word extents. # The main goal is to improve reviewability. extents = self.x, self.y, self.width, self.height - chars = [] - for i, char in enumerate(self.string): - start = i + self.startOffset - if AXObject.supports_text(self.zone.accessible): - try: - rect = Atspi.Text.get_range_extents( - self.zone.accessible, start, start + 1, Atspi.CoordType.SCREEN) - extents = rect.x, rect.y, rect.width, rect.height - except Exception as error: - tokens = ["FLAT REVIEW: Exception in getRangeExtents:", error] - debug.printTokens(debug.LEVEL_INFO, tokens, True) - chars.append(Char(self, i, start, char, *extents)) + if AXObject.supports_text(self.zone.accessible): + try: + rect = Atspi.Text.get_range_extents( + self.zone.accessible, start, start + 1, Atspi.CoordType.SCREEN) + extents = rect.x, rect.y, rect.width, rect.height + except Exception as error: + tokens = ["FLAT REVIEW: Exception in getRangeExtents:", error] + debug.printTokens(debug.LEVEL_INFO, tokens, True) - return chars + return extents def getRelativeOffset(self, offset): """Returns the char offset with respect to this word or -1.""" @@ -143,6 +148,16 @@ class Zone: """Represents text that is a portion of a single horizontal line.""" WORDS_RE = re.compile(r"(\S+\s*)", re.UNICODE) + TEXT_ROLES = ( + Atspi.Role.LABEL, + Atspi.Role.MENU, + Atspi.Role.MENU_ITEM, + Atspi.Role.CHECK_MENU_ITEM, + Atspi.Role.RADIO_MENU_ITEM, + Atspi.Role.PAGE_TAB, + Atspi.Role.PUSH_BUTTON, + Atspi.Role.TABLE_CELL, + ) def __init__(self, accessible, string, x, y, width, height, role=None): """Creates a new Zone. @@ -180,10 +195,7 @@ class Zone: if not self._shouldFakeText(): return self._words - # TODO - JD: For now, don't fake character and word extents. - # The main goal is to improve reviewability. - extents = self.x, self.y, self.width, self.height - + extents = self._getFakeTextExtents() words = [] for i, word in enumerate(re.finditer(self.WORDS_RE, self._string)): words.append(Word(self, i, word.start(), word.group(), *extents)) @@ -194,20 +206,16 @@ class Zone: def _shouldFakeText(self): """Returns True if we should try to fake the text interface""" - textRoles = [Atspi.Role.LABEL, - Atspi.Role.MENU, - Atspi.Role.MENU_ITEM, - Atspi.Role.CHECK_MENU_ITEM, - Atspi.Role.RADIO_MENU_ITEM, - Atspi.Role.PAGE_TAB, - Atspi.Role.PUSH_BUTTON, - Atspi.Role.TABLE_CELL] - - if self.role in textRoles: + if self.role in self.TEXT_ROLES: return True return False + def _getFakeTextExtents(self): + # TODO - JD: For now, don't fake character and word extents. + # The main goal is to improve reviewability. + return self.x, self.y, self.width, self.height + def _extentsAreOnSameLine(self, zone, pixelDelta=5): """Returns True if this Zone is physically on the same line as zone.""" @@ -291,7 +299,7 @@ class TextZone(Zone): string = AXText.get_substring(self.accessible, self.startOffset, self.endOffset) words = [] for i, word in enumerate(re.finditer(self.WORDS_RE, string)): - start, end = map(lambda x: x + self.startOffset, word.span()) + start, end = (pos + self.startOffset for pos in word.span()) try: rect = Atspi.Text.get_range_extents(self.accessible, start, end, Atspi.CoordType.SCREEN) extents = rect.x, rect.y, rect.width, rect.height @@ -338,6 +346,9 @@ class StateZone(Zone): else: generator = cthulhu_state.activeScript.brailleGenerator + return self._getStateString(generator) + + def _getStateString(self, generator): result = generator.getStateIndicator(self.accessible, role=self.role) if result: return result[0] @@ -362,21 +373,33 @@ class ValueZone(Zone): else: generator = cthulhu_state.activeScript.brailleGenerator - result = "" + return self._getValueString(generator) + def _getValueString(self, generator): # TODO - JD: This cobbling together beats what we had, but the # generators should also be doing the assembly. rolename = generator.getLocalizedRoleName(self.accessible) value = generator.getValue(self.accessible) if rolename and value: - result = f"{rolename} {value[0]}" + return f"{rolename} {value[0]}" - return result + return "" class Line: """A Line is a single line across a window and is composed of Zones.""" + TEXT_BRAILLE_ROLES = ( + Atspi.Role.TEXT, + Atspi.Role.PASSWORD_TEXT, + Atspi.Role.TERMINAL, + ) + TEXT_BRAILLE_FALLBACK_ROLES = ( + Atspi.Role.PARAGRAPH, + Atspi.Role.HEADING, + Atspi.Role.LINK, + ) + def __init__(self, index, zones): @@ -395,16 +418,16 @@ class Line: return " ".join([zone.string for zone in self.zones]) if attr == "x": - return min([zone.x for zone in self.zones]) + return min(zone.x for zone in self.zones) if attr == "y": - return min([zone.y for zone in self.zones]) + return min(zone.y for zone in self.zones) if attr == "width": - return sum([zone.width for zone in self.zones]) + return sum(zone.width for zone in self.zones) if attr == "height": - return max([zone.height for zone in self.zones]) + return max(zone.height for zone in self.zones) return super().__getattribute__(attr) @@ -420,19 +443,13 @@ class Line: # The 'isinstance(zone, TextZone)' test is a sanity check # to handle problems with Java text. See Bug 435553. if isinstance(zone, TextZone) and \ - ((AXObject.get_role(zone.accessible) in \ - (Atspi.Role.TEXT, - Atspi.Role.PASSWORD_TEXT, - Atspi.Role.TERMINAL)) or \ + ((AXObject.get_role(zone.accessible) in self.TEXT_BRAILLE_ROLES) or \ # [[[TODO: Eitan - HACK: # This is just to get FF3 cursor key routing support. # We really should not be determining all this stuff here, # it should be in the scripts. # Same applies to roles above.]]] - (AXObject.get_role(zone.accessible) in \ - (Atspi.Role.PARAGRAPH, - Atspi.Role.HEADING, - Atspi.Role.LINK))): + (AXObject.get_role(zone.accessible) in self.TEXT_BRAILLE_FALLBACK_ROLES)): region = braille.ReviewText(zone.accessible, zone.string, zone.startOffset, @@ -485,6 +502,17 @@ class Context: WRAP_TOP_BOTTOM = 1 << 1 WRAP_ALL = (WRAP_LINE | WRAP_TOP_BOTTOM) + CONTAINER_ROLES = (Atspi.Role.MENU,) + VALUE_ZONE_ROLES = (Atspi.Role.SCROLL_BAR, Atspi.Role.SLIDER, Atspi.Role.PROGRESS_BAR) + REDUNDANT_NAME_ROLES = (Atspi.Role.TABLE_ROW,) + USELESS_NAME_ROLES = (Atspi.Role.TABLE_CELL, Atspi.Role.LABEL) + STATE_ZONE_ROLES = ( + Atspi.Role.CHECK_BOX, + Atspi.Role.CHECK_MENU_ITEM, + Atspi.Role.RADIO_BUTTON, + Atspi.Role.RADIO_MENU_ITEM, + ) + def __init__(self, script, root=None): """Create a new Context for script.""" @@ -518,10 +546,8 @@ class Context: tokens = ["ERROR: Exception getting extents of", self.topLevel] debug.printTokens(debug.LEVEL_INFO, tokens, True) - containerRoles = [Atspi.Role.MENU] - def isContainer(x): - return AXObject.get_role(x) in containerRoles + return AXObject.get_role(x) in self.CONTAINER_ROLES container = AXObject.find_ancestor(self.focusObj, isContainer) if not container and isContainer(self.focusObj): @@ -567,8 +593,9 @@ class Context: cliprect = self._ensureRect(cliprect) zones = [] - substrings = [(*m.span(), m.group(0)) for m in re.finditer(r"[^\ufffc]+", string)] - substrings = list(map(lambda x: (x[0] + startOffset, x[1] + startOffset, x[2]), substrings)) + substrings = [(*m.span(), m.group(0)) for m in EMBEDDED_OBJECT_RE.finditer(string)] + substrings = [(start + startOffset, end + startOffset, text) + for (start, end, text) in substrings] for (start, end, substring) in substrings: try: rect = Atspi.Text.get_range_extents(accessible, start, end, Atspi.CoordType.SCREEN) @@ -581,23 +608,6 @@ class Context: return zones - def _getLines(self, accessible, startOffset, endOffset): - # TODO - JD: Move this into the script utilities so we can better handle - # app and toolkit quirks and also reuse this (e.g. for SayAll). - if not AXObject.supports_text(accessible): - return [] - - lines = [] - offset = startOffset - maxOffset = min(endOffset, AXText.get_character_count(accessible)) - while offset < maxOffset: - line, start, end = AXText.get_line_at_offset(accessible, offset) - if line and (line, start, end) not in lines: - lines.append((line, start, end)) - offset = max(end, offset + 1) - - return lines - def getZonesFromText(self, accessible, cliprect): """Gets a list of Zones from an object that implements the AccessibleText specialization. @@ -613,15 +623,9 @@ class Context: if not self.script.utilities.hasPresentableText(accessible): return [] - zones = [] - - # TODO - JD: This is here temporarily whilst I sort out the rest - # of the text-related mess. - if AXObject.supports_editable_text(accessible) \ - and AXUtilities.is_single_line(accessible): - rect = AXComponent.get_rect(accessible) - return [TextZone(accessible, 0, AXText.get_substring(accessible, 0, -1), - rect.x, rect.y, rect.width, rect.height)] + zones = self._getSingleLineEditableZones(accessible) + if zones: + return zones upperMax = lowerMax = AXText.get_character_count(accessible) upperMid = lowerMid = int(upperMax / 2) @@ -657,7 +661,7 @@ class Context: msg = "FLAT REVIEW: Getting lines for %s offsets %i-%i" % (accessible, upperMin, lowerMax) debug.printMessage(debug.LEVEL_INFO, msg, True) - lines = self._getLines(accessible, upperMin, lowerMax) + lines = self.script.utilities.getLinesForRange(accessible, upperMin, lowerMax) tokens = ["FLAT REVIEW:", len(lines), "lines found for", accessible] debug.printTokens(debug.LEVEL_INFO, tokens, True) @@ -666,6 +670,17 @@ class Context: return zones + def _getSingleLineEditableZones(self, accessible): + # TODO - JD: This is here temporarily whilst I sort out the rest + # of the text-related mess. + if not (AXObject.supports_editable_text(accessible) + and AXUtilities.is_single_line(accessible)): + return [] + + rect = AXComponent.get_rect(accessible) + return [TextZone(accessible, 0, AXText.get_substring(accessible, 0, -1), + rect.x, rect.y, rect.width, rect.height)] + def _insertStateZone(self, zones, accessible, extents): """If the accessible presents non-textual state, such as a checkbox or radio button, insert a StateZone representing @@ -687,10 +702,7 @@ class Context: and self.script.utilities.hasMeaningfulToggleAction(accessible): role = Atspi.Role.CHECK_BOX - if role not in [Atspi.Role.CHECK_BOX, - Atspi.Role.CHECK_MENU_ITEM, - Atspi.Role.RADIO_BUTTON, - Atspi.Role.RADIO_MENU_ITEM]: + if role not in self.STATE_ZONE_ROLES: return zone = None @@ -733,18 +745,14 @@ class Context: return [] zones = self.getZonesFromText(accessible, cliprect) - if not zones and role in [Atspi.Role.SCROLL_BAR, - Atspi.Role.SLIDER, - Atspi.Role.PROGRESS_BAR]: + if not zones and role in self.VALUE_ZONE_ROLES: zones.append(ValueZone(accessible, *extents)) elif not zones: string = "" - redundant = [Atspi.Role.TABLE_ROW] - if role not in redundant: + if role not in self.REDUNDANT_NAME_ROLES: string = self.script.speechGenerator.getName(accessible, inFlatReview=True) - useless = [Atspi.Role.TABLE_CELL, Atspi.Role.LABEL] - if not string and role not in useless: + if not string and role not in self.USELESS_NAME_ROLES: string = self.script.speechGenerator.getRoleName(accessible) if string: zones.append(Zone(accessible, string, *extents)) @@ -1467,3 +1475,4 @@ class Context: raise Exception("Invalid type: %d" % flatReviewType) return moved +EMBEDDED_OBJECT_RE = re.compile(r"[^\ufffc]+") diff --git a/src/cthulhu/input_event_manager.py b/src/cthulhu/input_event_manager.py index e83d36c..d680677 100644 --- a/src/cthulhu/input_event_manager.py +++ b/src/cthulhu/input_event_manager.py @@ -332,7 +332,7 @@ class InputEventManager: manager = focus_manager.get_manager() if pressed: window = manager.get_active_window() - if not AXUtilities.can_be_active_window(window): + if not AXUtilities.can_be_active_window(window, clear_cache=True): new_window = AXUtilities.find_active_window() if new_window is not None: window = new_window diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 62f8e91..931342b 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -101,6 +101,22 @@ class Utilities: SUBSCRIPT_DIGITS = \ ['\u2080', '\u2081', '\u2082', '\u2083', '\u2084', '\u2085', '\u2086', '\u2087', '\u2088', '\u2089'] + MENU_ROLES_IN_OPEN_MENU = { + Atspi.Role.MENU, + Atspi.Role.MENU_ITEM, + Atspi.Role.CHECK_MENU_ITEM, + Atspi.Role.RADIO_MENU_ITEM, + Atspi.Role.SEPARATOR, + } + ZOMBIE_TOP_LEVEL_ROLES = { + Atspi.Role.APPLICATION, + Atspi.Role.ALERT, + Atspi.Role.DIALOG, + Atspi.Role.LABEL, # For Unity Panel Service bug + Atspi.Role.PAGE, # For Evince bug + Atspi.Role.WINDOW, + Atspi.Role.FRAME, + } flags = re.UNICODE WORDS_RE = re.compile(r"(\W+)", flags) @@ -3687,6 +3703,21 @@ class Utilities: debug.printException(debug.LEVEL_WARNING) return "" + def getLinesForRange(self, obj, startOffset, endOffset): + if not AXObject.supports_text(obj): + return [] + + lines = [] + offset = startOffset + maxOffset = min(endOffset, AXText.get_character_count(obj)) + while offset < maxOffset: + line, start, end = AXText.get_line_at_offset(obj, offset) + if line and (line, start, end) not in lines: + lines.append((line, start, end)) + offset = max(end, offset + 1) + + return lines + def getLineContentsAtOffset(self, obj, offset, layoutMode=True, useCache=True): return [] @@ -4573,30 +4604,23 @@ class Utilities: # seems to be present in multiple toolkits, so it's either being # inherited (e.g. from Gtk in Firefox Chrome, LO, Eclipse) or it # may be an AT-SPI2 bug. For now, handling it here. - menuRoles = [Atspi.Role.MENU, - Atspi.Role.MENU_ITEM, - Atspi.Role.CHECK_MENU_ITEM, - Atspi.Role.RADIO_MENU_ITEM, - Atspi.Role.SEPARATOR] - if AXObject.get_role(obj) in menuRoles and self.isInOpenMenuBarMenu(obj): + if self._is_open_menu_bar_menu_role(obj): tokens = ["HACK: Treating", obj, "as showing and visible"] debug.printTokens(debug.LEVEL_INFO, tokens, True) return True return False + def _is_open_menu_bar_menu_role(self, obj): + if AXObject.get_role(obj) not in self.MENU_ROLES_IN_OPEN_MENU: + return False + return self.isInOpenMenuBarMenu(obj) + def isZombie(self, obj): index = AXObject.get_index_in_parent(obj) - topLevelRoles = [Atspi.Role.APPLICATION, - Atspi.Role.ALERT, - Atspi.Role.DIALOG, - Atspi.Role.LABEL, # For Unity Panel Service bug - Atspi.Role.PAGE, # For Evince bug - Atspi.Role.WINDOW, - Atspi.Role.FRAME] role = AXObject.get_role(obj) tokens = ["SCRIPT UTILITIES: ", obj, "is zombie:"] - if index == -1 and role not in topLevelRoles: + if index == -1 and role not in self.ZOMBIE_TOP_LEVEL_ROLES: tokens.append("index is -1") debug.printTokens(debug.LEVEL_INFO, tokens, True) return True