[orca] Improve presentation of native-app navigation by word



commit 157be2aaecc7d12a1d0979cd5784c2fa16cc1bf4
Author: Joanmarie Diggs <jdiggs igalia com>
Date:   Thu Oct 29 17:06:55 2020 +0100

    Improve presentation of native-app navigation by word
    
    Orca was depending on implementations providing AtkText/AtspiText
    implementations whose word boundary/granularity reflected what Orca
    should present when native-app navigation by word was used. While most
    implementation do provide that for simple cases, we see all sorts of
    discrepancies when punctuation, math symbols, and the like are involved.
    In those cases, merely presenting what we get for the word at offset
    can cause Orca to double-present some things and fail to present others.
    
    This commit attempts to improve things by presenting the newly-traversed
    string (rather than the reported word at offset) during repeated native
    word navigation. Doing so should ensure that everything gets presented
    exactly once, regardless of how the native application word navigation
    moves the user. It also attempts to identify suspect native-navigation
    word boundaries based on the text at offset to handle instances where
    a word-navigation command was used after a non-word-navigation command.

 src/orca/script_utilities.py                      | 103 ++++++++++++++++++++++
 src/orca/scripts/apps/soffice/script_utilities.py |   3 +
 src/orca/scripts/default.py                       |  85 ++++++++----------
 src/orca/scripts/toolkits/WebKitGtk/script.py     |   8 ++
 src/orca/scripts/web/script.py                    |   4 +
 5 files changed, 157 insertions(+), 46 deletions(-)
---
diff --git a/src/orca/script_utilities.py b/src/orca/script_utilities.py
index 76b6e8c52..bc27bb5f4 100644
--- a/src/orca/script_utilities.py
+++ b/src/orca/script_utilities.py
@@ -4492,6 +4492,95 @@ class Utilities:
         chunks = list(filter(lambda x: x.strip(), string.split("\n\n")))
         return len(chunks) > 1
 
+    def getWordAtOffsetAdjustedForNavigation(self, obj, offset=None):
+        try:
+            text = obj.queryText()
+            if offset is None:
+                offset = text.caretOffset
+        except:
+            return "", 0, 0
+
+        word, start, end = self.getWordAtOffset(obj, offset)
+        prevObj, prevOffset = self._script.pointOfReference.get("penultimateCursorPosition", (None, -1))
+        if prevObj != obj:
+            return word, start, end
+
+        # If we're in an ongoing series of native navigation-by-word commands, just present the
+        # newly-traversed string.
+        prevWord, prevStart, prevEnd = self.getWordAtOffset(prevObj, prevOffset)
+        if self._script.pointOfReference.get("lastTextUnitSpoken") == "word":
+            if self.lastInputEventWasPrevWordNav():
+                start = offset
+                end = prevOffset
+            elif self.lastInputEventWasNextWordNav():
+                start = prevOffset
+                end = offset
+
+            word = text.getText(start, end)
+            msg = "INFO: Adjusted word at offset %i for ongoing word nav is '%s' (%i-%i)" \
+                % (offset, word.replace("\n", "\\n"), start, end)
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return word, start, end
+
+        # Otherwise, attempt some smarts so that the user winds up with the same presentation
+        # they would get were this an ongoing series of native navigation-by-word commands.
+        if self.lastInputEventWasPrevWordNav():
+            # If we moved left via native nav, this should be the start of a native-navigation
+            # word boundary, regardless of what ATK/AT-SPI2 tells us.
+            start = offset
+
+            # The ATK/AT-SPI2 word typically ends in a space; if the ending is neither a space,
+            # nor an alphanumeric character, then suspect that character is a navigation boundary
+            # where we would have landed before via the native previous word command.
+            if not (word[-1].isspace() or word[-1].isalnum()):
+                end -= 1
+
+        elif self.lastInputEventWasNextWordNav():
+            # If we moved right via native nav, this should be the end of a native-navigation
+            # word boundary, regardless of what ATK/AT-SPI2 tells us.
+            end = offset
+
+            # This suggests we just moved to the end of the previous word.
+            if word != prevWord and prevStart < offset <= prevEnd:
+                start = prevStart
+
+            # If the character to the left of our present position is neither a space, nor
+            # an alphanumeric character, then suspect that character is a navigation boundary
+            # where we would have landed before via the native next word command.
+            lastChar = text.getText(offset - 1, offset)
+            if not (lastChar.isspace() or lastChar.isalnum()):
+                start = offset - 1
+
+        word = text.getText(start, end)
+
+        # We only want to present the newline character when we cross a boundary moving from one
+        # word to another. If we're in the same word, strip it out.
+        if "\n" in word and word == prevWord:
+            if word.startswith("\n"):
+                start += 1
+            elif word.endswith("\n"):
+                end -= 1
+            word = text.getText(start, end)
+
+        word = text.getText(start, end)
+        msg = "INFO: Adjusted word at offset %i for new word nav is '%s' (%i-%i)" \
+            % (offset, word.replace("\n", "\\n"), start, end)
+        debug.println(debug.LEVEL_INFO, msg, True)
+        return word, start, end
+
+    def getWordAtOffset(self, obj, offset=None):
+        try:
+            text = obj.queryText()
+            if offset is None:
+                offset = text.caretOffset
+        except:
+            return "", 0, 0
+
+        word, start, end = text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_WORD_START)
+        msg = "INFO: Word at %i is '%s' (%i-%i)" % (offset, word.replace("\n", "\\n"), start, end)
+        debug.println(debug.LEVEL_INFO, msg, True)
+        return word, start, end
+
     def textAtPoint(self, obj, x, y, coordType=None, boundary=None):
         text = self.queryNonEmptyText(obj)
         if not text:
@@ -5081,6 +5170,20 @@ class Utilities:
 
         return mods & keybindings.CTRL_MODIFIER_MASK
 
+    def lastInputEventWasPrevWordNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not keyString == "Left":
+            return False
+
+        return mods & keybindings.CTRL_MODIFIER_MASK
+
+    def lastInputEventWasNextWordNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not keyString == "Right":
+            return False
+
+        return mods & keybindings.CTRL_MODIFIER_MASK
+
     def lastInputEventWasLineNav(self):
         keyString, mods = self.lastKeyAndModifiers()
         if not keyString in ["Up", "Down"]:
diff --git a/src/orca/scripts/apps/soffice/script_utilities.py 
b/src/orca/scripts/apps/soffice/script_utilities.py
index d9559ed20..901f58895 100644
--- a/src/orca/scripts/apps/soffice/script_utilities.py
+++ b/src/orca/scripts/apps/soffice/script_utilities.py
@@ -740,6 +740,9 @@ class Utilities(script_utilities.Utilities):
 
         return obj, 0
 
+    def getWordAtOffsetAdjustedForNavigation(self, obj, offset=None):
+        return self.getWordAtOffset(obj, offset)
+
     def shouldReadFullRow(self, obj):
         if self._script._lastCommandWasStructNav:
             return False
diff --git a/src/orca/scripts/default.py b/src/orca/scripts/default.py
index 5d7a8d5a4..ab38a33d9 100644
--- a/src/orca/scripts/default.py
+++ b/src/orca/scripts/default.py
@@ -30,6 +30,7 @@ __copyright__ = "Copyright (c) 2004-2009 Sun Microsystems Inc." \
                 "Copyright (c) 2010 Joanmarie Diggs"
 __license__   = "LGPL"
 
+import re
 import time
 
 import pyatspi
@@ -116,7 +117,6 @@ class Script(script.Script):
         #
         self.currentReviewContents = ""
 
-        self._lastWord = ""
         self._lastWordCheckedForSpelling = ""
 
         self._inSayAll = False
@@ -3321,6 +3321,8 @@ class Script(script.Script):
             self.speakMisspelledIndicator(obj, offset)
             self.speakCharacter(character)
 
+        self.pointOfReference["lastTextUnitSpoken"] = "char"
+
     def sayLine(self, obj):
         """Speaks the line of an AccessibleText object that contains the
         caret, unless the line is empty in which case it's ignored.
@@ -3353,6 +3355,8 @@ class Script(script.Script):
             #
             self.sayCharacter(obj)
 
+        self.pointOfReference["lastTextUnitSpoken"] = "line"
+
     def sayPhrase(self, obj, startOffset, endOffset):
         """Speaks the text of an Accessible object between the start and
         end offsets, unless the phrase is empty in which case it's ignored.
@@ -3386,57 +3390,44 @@ class Script(script.Script):
         else:
             self.speakCharacter(phrase)
 
-    def sayWord(self, obj):
-        """Speaks the word at the caret.
-
-        Arguments:
-        - obj: an Accessible object that implements the AccessibleText
-               interface
-        """
+        self.pointOfReference["lastTextUnitSpoken"] = "phrase"
 
-        text = obj.queryText()
-        offset = text.caretOffset
-        lastKey, mods = self.utilities.lastKeyAndModifiers()
-        lastWord = self._lastWord
-
-        [word, startOffset, endOffset] = \
-            text.getTextAtOffset(offset,
-                                 pyatspi.TEXT_BOUNDARY_WORD_START)
-
-        msg = "DEFAULT: Word at offset %i is '%s' (%i-%i)" % (offset, word, startOffset, endOffset)
-        debug.println(debug.LEVEL_INFO, msg, True)
+    def sayWord(self, obj):
+        """Speaks the word at the caret, taking into account the previous caret position."""
 
-        if not word:
+        try:
+            text = obj.queryText()
+            offset = text.caretOffset
+        except:
             self.sayCharacter(obj)
             return
 
-        # Speak a newline if a control-right-arrow or control-left-arrow
-        # was used to cross a line boundary. Handling is different for
-        # the two keys since control-right-arrow places the cursor after
-        # the last character in a word, but control-left-arrow places
-        # the cursor at the beginning of a word.
-        #
-        if lastKey == "Right" and len(lastWord) > 0:
-            lastChar = lastWord[len(lastWord) - 1]
-            if lastChar == "\n" and lastWord != word:
-                self.speakCharacter("\n")
-
-        if lastKey == "Left" and len(word) > 0:
-            lastChar = word[len(word) - 1]
-            if lastChar == "\n" and lastWord != word:
-                self.speakCharacter("\n")
-
-        orca.emitRegionChanged(obj, startOffset, endOffset, orca.CARET_TRACKING)
-
-        self.speakMisspelledIndicator(obj, startOffset)
-        voice = self.speechGenerator.voice(string=word)
-        word = self.utilities.adjustForRepeats(word)
-
-        msg = "DEFAULT: Word adjusted for repeats: '%s'" % word
+        word, startOffset, endOffset = self.utilities.getWordAtOffsetAdjustedForNavigation(obj, offset)
+
+        # Announce when we cross a hard line boundary.
+        if "\n" in word:
+            self.speakCharacter("\n")
+            if word.startswith("\n"):
+                startOffset += 1
+            elif word.endswith("\n"):
+                endOffset -= 1
+            word = text.getText(startOffset, endOffset)
+
+        # sayPhrase is useful because it handles punctuation verbalization, but we don't want
+        # to trigger its whitespace presentation.
+        matches = list(re.finditer(r"\S+", word))
+        if matches:
+            startOffset += matches[0].start()
+            endOffset -= len(word) - matches[-1].end()
+            word = text.getText(startOffset, endOffset)
+
+        msg = "DEFAULT: Final word at offset %i is '%s' (%i-%i)" \
+            % (offset, word.replace("\n", "\\n"), startOffset, endOffset)
         debug.println(debug.LEVEL_INFO, msg, True)
 
-        self._lastWord = word
-        speech.speak(word, voice)
+        self.speakMisspelledIndicator(obj, startOffset)
+        self.sayPhrase(obj, startOffset, endOffset)
+        self.pointOfReference["lastTextUnitSpoken"] = "word"
 
     def presentObject(self, obj, **args):
         interrupt = args.get("interrupt", False)
@@ -3814,7 +3805,9 @@ class Script(script.Script):
         - caretOffset: the cursor position within this object
         """
 
-        self.pointOfReference["lastCursorPosition"] = [obj, caretOffset]
+        prevObj, prevOffset = self.pointOfReference.get("lastCursorPosition", (None, -1))
+        self.pointOfReference["penultimateCursorPosition"] = prevObj, prevOffset
+        self.pointOfReference["lastCursorPosition"] = obj, caretOffset
 
     def systemBeep(self):
         """Rings the system bell. This is really a hack. Ideally, we want
diff --git a/src/orca/scripts/toolkits/WebKitGtk/script.py b/src/orca/scripts/toolkits/WebKitGtk/script.py
index 460e193a2..a3a123763 100644
--- a/src/orca/scripts/toolkits/WebKitGtk/script.py
+++ b/src/orca/scripts/toolkits/WebKitGtk/script.py
@@ -293,6 +293,8 @@ class Script(default.Script):
             else:
                 speech.speak(self.speechGenerator.generateSpeech(obj))
 
+        self.pointOfReference["lastTextUnitSpoken"] = "char"
+
     def sayWord(self, obj):
         """Speaks the word at the caret.
 
@@ -309,6 +311,8 @@ class Script(default.Script):
         for (obj, start, end, string) in objects:
             self.sayPhrase(obj, start, end)
 
+        self.pointOfReference["lastTextUnitSpoken"] = "word"
+
     def sayLine(self, obj):
         """Speaks the line at the caret.
 
@@ -334,6 +338,8 @@ class Script(default.Script):
             if obj.getRole() in rolesToSpeak:
                 speech.speak(self.speechGenerator.getRoleName(obj))
 
+        self.pointOfReference["lastTextUnitSpoken"] = "line"
+
     def sayPhrase(self, obj, startOffset, endOffset):
         """Speaks the text of an Accessible object between the given offsets.
 
@@ -360,6 +366,8 @@ class Script(default.Script):
             #
             self.sayCharacter(obj)
 
+        self.pointOfReference["lastTextUnitSpoken"] = "phrase"
+
     def skipObjectEvent(self, event):
         """Gives us, and scripts, the ability to decide an event isn't
         worth taking the time to process under the current circumstances.
diff --git a/src/orca/scripts/web/script.py b/src/orca/scripts/web/script.py
index b994a900d..9d856bd9e 100644
--- a/src/orca/scripts/web/script.py
+++ b/src/orca/scripts/web/script.py
@@ -902,6 +902,8 @@ class Script(default.Script):
         else:
             self.speakContents(contents)
 
+        self.pointOfReference["lastTextUnitSpoken"] = "char"
+
     def sayWord(self, obj):
         """Speaks the word at the current caret position."""
 
@@ -919,6 +921,7 @@ class Script(default.Script):
         textObj, startOffset, endOffset, word = wordContents[0]
         self.speakMisspelledIndicator(textObj, startOffset)
         self.speakContents(wordContents)
+        self.pointOfReference["lastTextUnitSpoken"] = "word"
 
     def sayLine(self, obj):
         """Speaks the line at the current caret position."""
@@ -935,6 +938,7 @@ class Script(default.Script):
         obj, offset = self.utilities.getCaretContext(documentFrame=None)
         contents = self.utilities.getLineContentsAtOffset(obj, offset, useCache=not isEditable)
         self.speakContents(contents, priorObj=priorObj)
+        self.pointOfReference["lastTextUnitSpoken"] = "line"
 
     def presentObject(self, obj, **args):
         if not self.utilities.inDocumentContent(obj):


[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]