diff --git a/src/cthulhu/script_utilities.py b/src/cthulhu/script_utilities.py index 68708a7..fce7fe6 100644 --- a/src/cthulhu/script_utilities.py +++ b/src/cthulhu/script_utilities.py @@ -2490,6 +2490,39 @@ class Utilities: return child + def findChildAtOffset(self, obj, offset): + """Attempts to correct for off-by-one brokenness in hypertext implementations. + + We're seeing off-by-one errors in (at least) Chromium where the text + at a given offset is an embedded object character, but retrieving the + child at that offset returns None. + + This method handles this condition by checking for the child at the + previous and next offsets. If there's a child, and its offset in parent + matches the desired offset, return that child. + """ + + if child := self.getChildAtOffset(obj, offset): + return child + + if child_before := self.getChildAtOffset(obj, offset - 1): + offset_in_parent = self.characterOffsetInParent(child_before) + if offset_in_parent == offset: + tokens = [f"SCRIPT UTILITIES: Corrected child at offset {offset} in", obj, "is", + child_before, f"at offset {offset - 1}"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return child_before + + if child_after := self.getChildAtOffset(obj, offset + 1): + offset_in_parent = self.characterOffsetInParent(child_after) + if offset_in_parent == offset: + tokens = [f"SCRIPT UTILITIES: Corrected child at offset {offset} in", obj, "is", + child_after, f"at offset {offset + 1}"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return child_after + + return None + def characterOffsetInParent(self, obj): """Returns the character offset of the embedded object character for this object in its parent's accessible text. @@ -2582,7 +2615,7 @@ class Utilities: toBuild = list(string) for i, char in enumerate(toBuild): if char == self.EMBEDDED_OBJECT_CHARACTER: - child = self.getChildAtOffset(obj, i + startOffset) + child = self.findChildAtOffset(obj, i + startOffset) result = self.expandEOCs(child) if child and AXObject.get_role(child) in blockRoles: result += " " @@ -5440,14 +5473,14 @@ class Utilities: if oldString.endswith(self.EMBEDDED_OBJECT_CHARACTER) and oldEnd == changeStart: # There's a possibility that we have a link spanning multiple lines. If so, # we want to present the continuation that just became selected. - child = self.getChildAtOffset(obj, oldEnd - 1) + child = self.findChildAtOffset(obj, oldEnd - 1) self.handleTextSelectionChange(child, False) else: changes.append([changeStart, changeEnd, messages.TEXT_UNSELECTED]) if newString.endswith(self.EMBEDDED_OBJECT_CHARACTER): # There's a possibility that we have a link spanning multiple lines. If so, # we want to present the continuation that just became unselected. - child = self.getChildAtOffset(obj, newEnd - 1) + child = self.findChildAtOffset(obj, newEnd - 1) self.handleTextSelectionChange(child, False) speakMessage = speakMessage and not _settingsManager.getSetting('onlySpeakDisplayedText') @@ -5463,7 +5496,7 @@ class Utilities: self._script.speakMessage(message, interrupt=False) if endsWithChild: - child = self.getChildAtOffset(obj, end) + child = self.findChildAtOffset(obj, end) self.handleTextSelectionChange(child, speakMessage) return True diff --git a/src/cthulhu/scripts/default.py b/src/cthulhu/scripts/default.py index 41dc98c..86b3192 100644 --- a/src/cthulhu/scripts/default.py +++ b/src/cthulhu/scripts/default.py @@ -44,6 +44,7 @@ import time import cthulhu.braille as braille import cthulhu.cmdnames as cmdnames +import cthulhu.dbus_service as dbus_service import cthulhu.debug as debug import cthulhu.find as find import cthulhu.flat_review as flat_review @@ -128,6 +129,17 @@ class Script(script.Script): Atspi.Accessible.set_cache_mask( app, Atspi.Cache.DEFAULT ^ Atspi.Cache.NAME ^ Atspi.Cache.DESCRIPTION) + # Register D-Bus commands if available + try: + controller = dbus_service.get_remote_controller() + if controller: + tokens = ["DEFAULT: Registering D-Bus commands for default script"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + controller.register_decorated_module("DefaultScript", self) + except Exception as error: + tokens = ["DEFAULT: Exception registering D-Bus commands:", error] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + def setupInputEventHandlers(self): """Defines InputEventHandler fields for this script that can be called by the key and braille bindings.""" @@ -1082,7 +1094,9 @@ class Script(script.Script): for character in itemString: self.speakCharacter(character) - def sayAll(self, inputEvent, obj=None, offset=None): + @dbus_service.command + def sayAll(self, inputEvent, obj=None, offset=None, notify_user=True): + """Speaks the entire document or text, starting from the current position.""" obj = obj or cthulhu_state.locusOfFocus if not obj or AXObject.is_dead(obj): self.presentMessage(messages.LOCATION_NOT_FOUND_FULL) @@ -2798,6 +2812,17 @@ class Script(script.Script): try: [lineString, startOffset, endOffset] = text.getTextAtOffset( fixedTargetOffset, Atspi.TextBoundaryType.LINE_START) + + # Chrome fix: Handle case where get_line_at_offset returns the line + # after the offset, which seems to happen when the character at offset + # is an embedded object at a line boundary. + if 0 <= fixedTargetOffset < startOffset: + backup_offset = fixedTargetOffset - 1 + tokens = [f"DEFAULT: Start offset {startOffset} is greater than target offset {fixedTargetOffset}. Trying with offset {backup_offset}"] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + [lineString, startOffset, endOffset] = text.getTextAtOffset( + backup_offset, Atspi.TextBoundaryType.LINE_START) + except Exception: return ["", 0, 0] diff --git a/src/cthulhu/scripts/web/script_utilities.py b/src/cthulhu/scripts/web/script_utilities.py index 0796011..795bf77 100644 --- a/src/cthulhu/scripts/web/script_utilities.py +++ b/src/cthulhu/scripts/web/script_utilities.py @@ -870,7 +870,7 @@ class Utilities(script_utilities.Utilities): if not self.isTextBlockElement(obj): return -1 - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if child and not self.isTextBlockElement(child): matches = [x for x in contents if x[0] == child] if len(matches) == 1: @@ -1344,14 +1344,14 @@ class Utilities(script_utilities.Utilities): if not string: return [[obj, start, end, string]] - stringOffset = offset - start + stringOffset = max(0, offset - start) try: char = string[stringOffset] except Exception: pass else: if char == self.EMBEDDED_OBJECT_CHARACTER: - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if child: return self._getContentsForObj(child, 0, boundary) @@ -1699,7 +1699,7 @@ class Utilities(script_utilities.Utilities): offset = max(0, offset) if (AXUtilities.is_tool_bar(obj) or AXUtilities.is_menu_bar(obj)) \ and not self._treatObjectAsWhole(obj): - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if child: obj = child offset = 0 @@ -2362,7 +2362,7 @@ class Utilities(script_utilities.Utilities): string, start, end = super().textAtPoint(obj, x, y, coordType, boundary) if string == self.EMBEDDED_OBJECT_CHARACTER: - child = self.getChildAtOffset(obj, start) + child = self.findChildAtOffset(obj, start) if child: return self.textAtPoint(child, x, y, coordType, boundary) @@ -4804,7 +4804,7 @@ class Utilities(script_utilities.Utilities): obj = None else: contextObj, contextOffset = obj, offset - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if child: obj = child else: @@ -5177,7 +5177,7 @@ class Utilities(script_utilities.Utilities): debug.printMessage(debug.LEVEL_INFO, msg, True) return obj, offset - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if not child: msg = "WEB: Child at offset is null. Returning context unchanged." debug.printMessage(debug.LEVEL_INFO, msg, True) @@ -5188,7 +5188,7 @@ class Utilities(script_utilities.Utilities): tokens = ["WEB: Child", child, "of", obj, "at offset", offset, "cannot be context."] debug.printTokens(debug.LEVEL_INFO, tokens, True) offset += 1 - child = self.getChildAtOffset(obj, offset) + child = self.findChildAtOffset(obj, offset) if self.isListItemMarker(child): tokens = ["WEB: First caret context is next offset in", obj, ":", @@ -5233,11 +5233,15 @@ class Utilities(script_utilities.Utilities): if text: allText = text.getText(0, -1) for i in range(offset + 1, len(allText)): - child = self.getChildAtOffset(obj, i) + child = self.findChildAtOffset(obj, i) if child and allText[i] != self.EMBEDDED_OBJECT_CHARACTER: tokens = ["ERROR: Child", child, "found at offset with char '", allText[i].replace("\n", "\\n"), "'"] debug.printTokens(debug.LEVEL_INFO, tokens, True) + if offset == self.characterOffsetInParent(child): + tokens = ["WEB: Handling error by returning", obj, i] + debug.printTokens(debug.LEVEL_INFO, tokens, True) + return obj, i if self._canHaveCaretContext(child): if self._treatObjectAsWhole(child, -1): return child, 0 @@ -5305,7 +5309,7 @@ class Utilities(script_utilities.Utilities): if offset == -1 or offset > len(allText): offset = len(allText) for i in range(offset - 1, -1, -1): - child = self.getChildAtOffset(obj, i) + child = self.findChildAtOffset(obj, i) if child and allText[i] != self.EMBEDDED_OBJECT_CHARACTER: tokens = ["ERROR: Child", child, "found at offset with char '", allText[i].replace("\n", "\\n"), "'"]