[orca] Refactor text event handling and present more feedback in text content



commit 672c58ed7abd4b75b047206bce4997a1c137d463
Author: Joanmarie Diggs <jdiggs igalia com>
Date:   Sat Feb 13 11:22:06 2016 -0500

    Refactor text event handling and present more feedback in text content
    
    * Present copy, cut, paste, undo, redo, selection deletion, and
      selection restoration
    * Improve presentation of selected content in Gecko
    * Stop checking for text selection in places where it doesn't make
      sense (duh).
    
    Please note, this is the initial commit of these changes. There are
    still some issues with LibreOffice and Gecko.

 src/orca/formatting.py                             |   12 +-
 src/orca/messages.py                               |   65 ++
 src/orca/script_utilities.py                       |  682 ++++++++++++++++++--
 .../scripts/apps/gnome-shell/script_utilities.py   |   20 +
 src/orca/scripts/apps/soffice/script.py            |   21 +
 src/orca/scripts/apps/soffice/script_utilities.py  |   73 +--
 src/orca/scripts/default.py                        |  594 +++++------------
 src/orca/scripts/web/script.py                     |   30 +-
 src/orca/scripts/web/script_utilities.py           |   50 ++-
 test/keystrokes/firefox/selection_textarea.params  |    1 +
 test/keystrokes/firefox/selection_textarea.py      |  162 +++++
 test/keystrokes/firefox/selection_wiki.params      |    1 +
 test/keystrokes/firefox/selection_wiki.py          |  354 ++++++++++
 .../gtk3-demo/role_text_multiline_selection.py     |  135 ++++
 14 files changed, 1647 insertions(+), 553 deletions(-)
---
diff --git a/src/orca/formatting.py b/src/orca/formatting.py
index c40d0b4..8ff8da3 100644
--- a/src/orca/formatting.py
+++ b/src/orca/formatting.py
@@ -102,7 +102,7 @@ formatting = {
             },
         'default': {
             'focused': '[]',
-            'unfocused': 'labelOrName + allTextSelection + roleName + availability + ' + MNEMONIC + ' + 
accelerator + childWidget',
+            'unfocused': 'labelOrName + roleName + availability + ' + MNEMONIC + ' + accelerator + 
childWidget',
             'basicWhereAmI': 'labelOrName + roleName',
             'detailedWhereAmI' : 'pageSummary'
             },
@@ -177,7 +177,7 @@ formatting = {
             },
         pyatspi.ROLE_FRAME: {
             'focused': 'labelOrName + roleName',
-            'unfocused': 'labelOrName + allTextSelection + roleName + unfocusedDialogCount + availability'
+            'unfocused': 'labelOrName + roleName + unfocusedDialogCount + availability'
             },
         pyatspi.ROLE_HEADER: {
             'unfocused': '(displayedText or name) + roleName',
@@ -206,8 +206,8 @@ formatting = {
             'basicWhereAmI': 'labelAndName + allTextSelection + roleName'
             },
         pyatspi.ROLE_LAYERED_PANE: {
-            'focused': 'labelAndName + allTextSelection + roleName + availability + noShowingChildren',
-            'unfocused': 'labelAndName + allTextSelection + roleName + availability + noShowingChildren',
+            'focused': 'labelAndName + roleName + availability + noShowingChildren',
+            'unfocused': 'labelAndName + roleName + availability + noShowingChildren',
             'basicWhereAmI': 'labelAndName + pause + roleName + pause + selectedItemCount + pause',
             'detailedWhereAmI': 'labelAndName + pause + roleName + pause + selectedItemCount + pause + 
selectedItems + pause'
             },
@@ -270,7 +270,7 @@ formatting = {
         },
         pyatspi.ROLE_MENU: {
             'focused': 'labelOrName + roleName',
-            'unfocused': 'labelOrName + allTextSelection + roleName + availability + ' + MNEMONIC + ' + 
accelerator + pause + positionInList',
+            'unfocused': 'labelOrName + roleName + availability + ' + MNEMONIC + ' + accelerator + pause + 
positionInList',
             'basicWhereAmI': '(ancestors or parentRoleName) + pause + labelOrName + roleName + pause + 
positionInList + ' + MNEMONIC
             },
         pyatspi.ROLE_MENU_ITEM: {
@@ -398,7 +398,7 @@ formatting = {
             },
         pyatspi.ROLE_TEAROFF_MENU_ITEM: {
             'focused': '[]',
-            'unfocused': 'labelOrName + allTextSelection + roleName + availability '
+            'unfocused': 'labelOrName + roleName + availability '
             },
         pyatspi.ROLE_TERMINAL: {
             'focused': 'terminal',
diff --git a/src/orca/messages.py b/src/orca/messages.py
index 794cb63..5e5f409 100644
--- a/src/orca/messages.py
+++ b/src/orca/messages.py
@@ -266,6 +266,46 @@ CLI_GUI_SETUP = _("Set up user preferences (GUI version)")
 # from the command line and the help text is displayed.
 CLI_EPILOG = _("Report bugs to orca-list gnome org ")
 
+# Translators: Orca normal speaks the text which was just deleted from a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been deleted from a
+# document matches the clipboard contents, Orca will indicate that fact
+# instead of presenting the full string which was just deleted. This message
+# is the full/verbose indication.
+CLIPBOARD_CUT_FULL = _("Cut selection to clipboard.")
+
+# Translators: Orca normal speaks the text which was just deleted from a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been deleted from a
+# document matches the clipboard contents, Orca will indicate that fact
+# instead of presenting the full string which was just deleted. This message
+# is the brief indication.
+CLIPBOARD_CUT_BRIEF = C_("clipboard", "cut")
+
+# Translators: This message is the detailed message presented when the contents
+# of the clipboard have changed and match the current selection.
+CLIPBOARD_COPIED_FULL = _("Copied selection to clipboard.")
+
+# Translators: This message is the brief message presented when the contents
+# of the clipboard have changed and match the current selection.
+CLIPBOARD_COPIED_BRIEF = C_("clipboard", "copied")
+
+# Translators: Orca normal speaks the text which was just inserted into a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been inserted into a
+# document matches the clipboard contents, Orca will indicate that fact
+# instead of presenting the full string which was just inserted. This message
+# is the full/verbose indication.
+CLIPBOARD_PASTED_FULL = _("Pasted contents from clipboard.")
+
+# Translators: Orca normal speaks the text which was just inserted into a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been inserted into a
+# document matches the clipboard contents, Orca will indicate that fact
+# instead of presenting the full string which was just inserted. This message
+# is the brief indication.
+CLIPBOARD_PASTED_BRIEF = C_("clipboard", "pasted")
+
 # Translators: In chat applications, it is often possible to see that a "buddy"
 # is typing currently (e.g. via a keyboard icon or status text). Some users like
 # to have this typing status announced by Orca; others find that announcement
@@ -1659,6 +1699,23 @@ SETTINGS_RELOADED = _("Screen reader settings reloaded.")
 # selected. The string substitution is for the selected text.
 SELECTED_TEXT_IS = _("Selected text is: %s")
 
+# Translators: Orca normal speaks the text which was just deleted from a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been deleted from a
+# document matches the previously-selected contents, Orca will indicate that
+# fact instead of presenting the full string which was just deleted.
+SELECTION_DELETED = _("Selection deleted.")
+
+# Translators: Orca normal speaks the text which was just inserted into a
+# document via command. Depending on the circumstances, that might be a
+# large string. Therefore, if the text which has just been inserted into a
+# document is also already selected, it is likely that the insertion is
+# due to having been restored (e.g. the user selected text, deleted it,
+# and then pressed Ctrl+Z to undo that deletion). In this instance, Orca
+# will indicate the restoration rather than presenting the full string
+# which was just inserted.
+SELECTION_RESTORED = _("Selection restored.")
+
 # Translators: This message is presented to the user when speech synthesis
 # has been temporarily turned off.
 SPEECH_DISABLED = _("Speech disabled.")
@@ -1864,6 +1921,14 @@ TIME_FORMAT_24_HM_WITH_WORDS = _("%H hours and %M minutes.")
 # user.  The value is the unicode number value of this character in hex.
 UNICODE = _("Unicode %s")
 
+# Translators: This string is presented when an application's undo command is
+# used in a document resulting in a change to that document's contents.
+UNDO = C_("command", "undo")
+
+# Translators: This string is presented when an application's redo command is
+# used in a document resulting in a change to that document's contents.
+REDO = C_("command", "redo")
+
 # Translators: This message presents the Orca version number.
 VERSION = _("Screen reader version %s.") % version
 
diff --git a/src/orca/script_utilities.py b/src/orca/script_utilities.py
index dc07d91..71e8985 100644
--- a/src/orca/script_utilities.py
+++ b/src/orca/script_utilities.py
@@ -34,6 +34,8 @@ import math
 import pyatspi
 import re
 import time
+from gi.repository import Gdk
+from gi.repository import Gtk
 
 from . import chnames
 from . import colornames
@@ -1613,6 +1615,9 @@ class Utilities:
         manages its descendants.
         """
 
+        if self.isDead(obj):
+            return None
+
         try:
             return self._script.\
                 generatorCache[self.REAL_ACTIVE_DESCENDANT][obj]
@@ -2028,6 +2033,56 @@ class Utilities:
                 endOffset = offset
             text.setSelection(0, startOffset, endOffset)
 
+    def findPreviousObject(self, obj):
+        """Finds the object before this one."""
+
+        if not obj:
+            return None
+
+        for relation in obj.getRelationSet():
+            if relation.getRelationType() == pyatspi.RELATION_FLOWS_FROM:
+                return relation.getTarget(0)
+
+        index = obj.getIndexInParent() - 1
+        if not (0 <= index < obj.parent.childCount - 1):
+            obj = obj.parent
+            index = obj.getIndexInParent() - 1
+
+        try:
+            prevObj = obj.parent[index]
+        except:
+            prevObj = None
+
+        if prevObj == obj:
+            prevObj = None
+
+        return prevObj
+
+    def findNextObject(self, obj):
+        """Finds the object after this one."""
+
+        if not obj:
+            return None
+
+        for relation in obj.getRelationSet():
+            if relation.getRelationType() == pyatspi.RELATION_FLOWS_TO:
+                return relation.getTarget(0)
+
+        index = obj.getIndexInParent() + 1
+        if not (0 < index < obj.parent.childCount):
+            obj = obj.parent
+            index = obj.getIndexInParent() + 1
+
+        try:
+            nextObj = obj.parent[index]
+        except:
+            nextObj = None
+
+        if nextObj == obj:
+            nextObj = None
+
+        return nextObj
+
     def allSelectedText(self, obj):
         """Get all the text applicable text selections for the given object.
         including any previous or next text objects that also have
@@ -2040,58 +2095,27 @@ class Utilities:
         offsets within the text for the given object.
         """
 
-        textContents = ""
-        startOffset = 0
-        endOffset = 0
-        text = obj.queryText()
-        if text.getNSelections() > 0:
-            [textContents, startOffset, endOffset] = self.selectedText(obj)
+        textContents, startOffset, endOffset = self.selectedText(obj)
 
-        current = obj
-        morePossibleSelections = True
-        while morePossibleSelections:
-            morePossibleSelections = False
-            for relation in current.getRelationSet():
-                if relation.getRelationType() == pyatspi.RELATION_FLOWS_FROM:
-                    prevObj = relation.getTarget(0)
-                    prevObjText = prevObj.queryText()
-                    if prevObjText.getNSelections() > 0:
-                        [newTextContents, start, end] = \
-                            self.selectedText(prevObj)
-                        textContents = newTextContents + " " + textContents
-                        current = prevObj
-                        morePossibleSelections = True
-                    else:
-                        displayedText = prevObjText.getText(0,
-                            prevObjText.characterCount)
-                        if len(displayedText) == 0:
-                            current = prevObj
-                            morePossibleSelections = True
+        prevObj = self.findPreviousObject(obj)
+        while prevObj:
+            if self.queryNonEmptyText(prevObj):
+                selection, start, end = self.selectedText(prevObj)
+                if not selection:
                     break
-
-        current = obj
-        morePossibleSelections = True
-        while morePossibleSelections:
-            morePossibleSelections = False
-            for relation in current.getRelationSet():
-                if relation.getRelationType() == pyatspi.RELATION_FLOWS_TO:
-                    nextObj = relation.getTarget(0)
-                    nextObjText = nextObj.queryText()
-                    if nextObjText.getNSelections() > 0:
-                        [newTextContents, start, end] = \
-                            self.selectedText(nextObj)
-                        textContents += " " + newTextContents
-                        current = nextObj
-                        morePossibleSelections = True
-                    else:
-                        displayedText = nextObjText.getText(0,
-                            nextObjText.characterCount)
-                        if len(displayedText) == 0:
-                            current = nextObj
-                            morePossibleSelections = True
+                textContents = "%s %s" % (selection, textContents)
+            prevObj = self.findPreviousObject(prevObj)
+
+        nextObj = self.findNextObject(obj)
+        while nextObj:
+            if self.queryNonEmptyText(nextObj):
+                selection, start, end = self.selectedText(nextObj)
+                if not selection:
                     break
+                textContents = "%s %s" % (textContents, selection)
+            nextObj = self.findNextObject(nextObj)
 
-        return [textContents, startOffset, endOffset]
+        return textContents, startOffset, endOffset
 
     @staticmethod
     def allTextSelections(obj):
@@ -2238,6 +2262,15 @@ class Utilities:
 
         return False
 
+    def getCharacterAtOffset(self, obj, offset=None):
+        text = self.queryNonEmptyText(obj)
+        if text:
+            if offset is None:
+                offset = text.caretOffset
+            return text.getText(offset, offset + 1)
+
+        return ""
+
     def queryNonEmptyText(self, obj):
         """Get the text interface associated with an object, if it is
         non-empty.
@@ -2256,8 +2289,7 @@ class Utilities:
 
         return None
 
-    @staticmethod
-    def selectedText(obj):
+    def selectedText(self, obj):
         """Get the text selection for the given object.
 
         Arguments:
@@ -2269,11 +2301,18 @@ class Utilities:
 
         textContents = ""
         startOffset = endOffset = 0
-        textObj = obj.queryText()
-        nSelections = textObj.getNSelections()
+        try:
+            textObj = obj.queryText()
+        except:
+            nSelections = 0
+        else:
+            nSelections = textObj.getNSelections()
+
         for i in range(0, nSelections):
             [startOffset, endOffset] = textObj.getSelection(i)
-            selectedText = textObj.getText(startOffset, endOffset)
+            if startOffset == endOffset:
+                continue
+            selectedText = self.expandEOCs(obj, startOffset, endOffset)
             if i > 0:
                 textContents += " "
             textContents += selectedText
@@ -3122,8 +3161,18 @@ class Utilities:
         try:
             attrs = dict([attr.split(':', 1) for attr in obj.getAttributes()])
         except:
+            msg = "ERROR: Exception getting attributes for %s" % obj
+            debug.println(debug.LEVEL_INFO, msg, True)
             return 0
-        return int(attrs.get('level', '0'))
+
+        try:
+            value = int(attrs.get('level', '0'))
+        except ValueError:
+            msg = "ERROR: Exception getting value for %s (%s)" % (obj, attrs)
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return 0
+
+        return value
 
     def hasMeaningfulToggleAction(self, obj):
         try:
@@ -3426,13 +3475,532 @@ class Utilities:
 
         # TODO: JD - this doesn't yet handle the case of multiple non-contiguous
         # selections in a single accessible object.
+        start, end, string = 0, 0, ''
         if text:
             start, end = text.getSelection(0)
-            string = text.getText(start, end)
-        else:
-            start, end, string = 0, 0, ''
+            if start != end:
+                string = text.getText(start, end)
+                while string.endswith(self.EMBEDDED_OBJECT_CHARACTER):
+                    end -= 1
+                    string = string[:-1]
 
         msg = "INFO: New selection for %s is '%s' (%i, %i)" % (obj, string, start, end)
         debug.println(debug.LEVEL_INFO, msg, True)
         textSelections[hash(obj)] = start, end, string
         self._script.pointOfReference['textSelections'] = textSelections
+
+    @staticmethod
+    def onClipboardContentsChanged(*args):
+        script = orca_state.activeScript
+        if not script:
+            return
+
+        script.onClipboardContentsChanged(*args)
+
+    def connectToClipboard(self):
+        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
+        clipboard.connect('owner-change', self.onClipboardContentsChanged)
+
+    def getClipboardContents(self):
+        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
+        return clipboard.wait_for_text()
+
+    def setClipboardText(self, text):
+        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
+        clipboard.set_text(text, len(text))
+
+    def appendTextToClipboard(self, text):
+        clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
+        clipboard.request_text(self._appendTextToClipboardCallback, text)
+
+    def _appendTextToClipboardCallback(self, clipboard, text, newText):
+        text = text.rstrip("\n")
+        text = "%s\n%s" % (text, newText)
+        clipboard.set_text(text, len(text))
+
+    def lastInputEventWasCommand(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        return mods & keybindings.COMMAND_MODIFIER_MASK
+
+    def lastInputEventWasCharNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not mods:
+            return keyString in ["Left", "Right"]
+
+        return False
+
+    def lastInputEventWasWordNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.CTRL_MODIFIER_MASK:
+            return keyString in ["Left", "Right"]
+
+        return False
+
+    def lastInputEventWasLineNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not mods:
+            return keyString in ["Up", "Down"]
+
+        return False
+
+    def lastInputEventWasLineBoundaryNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not mods:
+            return keyString in ["Home", "End"]
+
+        return False
+
+    def lastInputEventWasPageNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not mods:
+            return keyString in ["Page_Up", "Page_Down"]
+
+        return False
+
+    def lastInputEventWasFileBoundaryNav(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.CTRL_MODIFIER_MASK:
+            return keyString in ["Home", "End"]
+
+        return False
+
+    def lastInputEventWasCaretNavWithSelection(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.SHIFT_MODIFIER_MASK:
+            return keyString in ["Home", "End", "Up", "Down", "Left", "Right"]
+
+        return False
+
+    def lastInputEventWasUndo(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'z':
+            return not (mods & keybindings.SHIFT_MODIFIER_MASK)
+
+        return False
+
+    def lastInputEventWasRedo(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'z':
+            return mods & keybindings.SHIFT_MODIFIER_MASK
+
+        return False
+
+    def lastInputEventWasCut(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        return mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'x'
+
+    def lastInputEventWasCopy(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        return mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'c'
+
+    def lastInputEventWasPaste(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        return mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'v'
+
+    def lastInputEventWasDelete(self):
+        keyString, mods = self.lastKeyAndModifiers()
+        if not mods and keyString == "Delete":
+            return True
+
+        return mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == "d"
+
+    def lastInputEventWasPrimaryMouseClick(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "1" and event.pressed
+
+        return False
+
+    def lastInputEventWasMiddleMouseClick(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "2" and event.pressed
+
+        return False
+
+    def lastInputEventWasSecondaryMouseClick(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "3" and event.pressed
+
+        return False
+
+    def lastInputEventWasPrimaryMouseRelease(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "1" and not event.pressed
+
+        return False
+
+    def lastInputEventWasMiddleMouseRelease(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "2" and not event.pressed
+
+        return False
+
+    def lastInputEventWasSecondaryMouseRelease(self):
+        event = orca_state.lastInputEvent
+        if isinstance(event, input_event.MouseButtonEvent):
+            return event.button == "3" and not event.pressed
+
+        return False
+
+    def treatEventAsTerminalCommand(self, event):
+        try:
+            role = event.source.getRole()
+        except:
+            msg = "ERROR: Exception getting role of %s" % event.source
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        if role != pyatspi.ROLE_TERMINAL:
+            return False
+
+        if self.lastInputEventWasCommand():
+            return True
+
+        if event.type.startswith("object:text-changed:insert") and event.any_data.strip():
+            keyString, mods = self.lastKeyAndModifiers()
+            if keyString in ["Return", "Tab", "space", " "]:
+                return True
+
+        return False
+
+    def isPresentableTextChangedEventForLocusOfFocus(self, event):
+        if not event.type.startswith("object:text-changed:") \
+           and not event.type.startswith("object:text-attributes-changed"):
+            return False
+
+        try:
+            role = event.source.getRole()
+            state = event.source.getState()
+        except:
+            msg = "ERROR: Exception getting role and state of %s" % event.source
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        ignoreRoles = [pyatspi.ROLE_LABEL,
+                       pyatspi.ROLE_MENU,
+                       pyatspi.ROLE_MENU_ITEM,
+                       pyatspi.ROLE_SLIDER,
+                       pyatspi.ROLE_SPIN_BUTTON]
+        if role in ignoreRoles:
+            msg = "INFO: Event is not being presented due to role"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        if role == pyatspi.ROLE_TABLE_CELL \
+           and not state.contains(pyatspi.STATE_FOCUSED) \
+           and not state.contains(pyatspi.STATE_SELECTED):
+            msg = "INFO: Event is not being presented due to role and states"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        if role == pyatspi.ROLE_PASSWORD_TEXT and state.contains(pyatspi.STATE_FOCUSED):
+            return True
+
+        if orca_state.locusOfFocus in [event.source, event.source.parent]:
+            return True
+
+        if self.isDead(orca_state.locusOfFocus):
+            return True
+
+        msg = "INFO: Event is not being presented due to lack of cause"
+        debug.println(debug.LEVEL_INFO, msg, True)
+        return False
+
+    def isBackSpaceCommandTextDeletionEvent(self, event):
+        if not event.type.startswith("object:text-changed:delete"):
+            return False
+
+        keyString, mods = self.lastKeyAndModifiers()
+        if keyString == "BackSpace":
+            return True
+
+        return False
+
+    def isDeleteCommandTextDeletionEvent(self, event):
+        if not event.type.startswith("object:text-changed:delete"):
+            return False
+
+        return self.lastInputEventWasDelete()
+
+    def isUndoCommandTextDeletionEvent(self, event):
+        if not event.type.startswith("object:text-changed:delete"):
+            return False
+
+        if not self.lastInputEventWasUndo():
+            return False
+
+        start, end, string = self.getCachedTextSelection(event.source)
+        return not string
+
+    def isSelectedTextDeletionEvent(self, event):
+        if not event.type.startswith("object:text-changed:delete"):
+            return False
+
+        if self.lastInputEventWasPaste():
+            return False
+
+        start, end, string = self.getCachedTextSelection(event.source)
+        return string and string.strip() == event.any_data.strip()
+
+    def isSelectedTextInsertionEvent(self, event):
+        if not event.type.startswith("object:text-changed:insert"):
+            return False
+
+        self.updateCachedTextSelection(event.source)
+        start, end, string = self.getCachedTextSelection(event.source)
+        return string and string == event.any_data and start == event.detail1
+
+    def isSelectedTextRestoredEvent(self, event):
+        if not self.lastInputEventWasUndo():
+            return False
+
+        if self.isSelectedTextInsertionEvent(event):
+            return True
+
+        return False
+
+    def isMiddleMouseButtonTextInsertionEvent(self, event):
+        if not event.type.startswith("object:text-changed:insert"):
+            return False
+
+        return self.lastInputEventWasMiddleMouseClick()
+
+    def isEchoableTextInsertionEvent(self, event):
+        if not event.type.startswith("object:text-changed:insert"):
+            return False
+
+        try:
+            role = event.source.getRole()
+        except:
+            msg = "ERROR: Exception getting role of %s" % event.source
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        if role == pyatspi.ROLE_PASSWORD_TEXT:
+            return _settingsManager.getSetting("enableKeyEcho")
+
+        if len(event.any_data.strip()) == 1:
+            return _settingsManager.getSetting("enableEchoByCharacter")
+
+        return False
+
+    def isClipboardTextChangedEvent(self, event):
+        if not event.type.startswith("object:text-changed"):
+            return False
+
+        if not self.lastInputEventWasCommand() or self.lastInputEventWasUndo():
+            return False
+
+        if "delete" in event.type and self.lastInputEventWasPaste():
+            return False
+
+        try:
+            state = event.source.getState()
+        except:
+            msg = "ERROR: Exception getting state of %s" % event.source
+            debug.println(debug.LEVEL_INFO, msg, True)
+        else:
+            if not state.contains(pyatspi.STATE_EDITABLE):
+                return False
+
+        contents = self.getClipboardContents()
+        if event.any_data == contents:
+            return True
+
+        # HACK: If the application treats each paragraph as a separate object,
+        # we'll get individual events for each paragraph rather than a single
+        # event whose any_data matches the clipboard contents.
+        if "\n" in contents and event.any_data in contents:
+            return True
+
+        return False
+
+    def objectContentsAreInClipboard(self, obj=None):
+        obj = obj or orca_state.locusOfFocus
+        if not obj or self.isDead(obj):
+            return False
+
+        contents = self.getClipboardContents()
+        if not contents:
+            return False
+
+        string, start, end = self.selectedText(obj)
+        if string and string in contents:
+            return True
+
+        obj = self.realActiveDescendant(obj) or obj
+        if self.isDead(obj):
+            return False
+
+        return obj and obj.name in contents
+
+    def clearCachedCommandState(self):
+        self._script.pointOfReference['undo'] = False
+        self._script.pointOfReference['redo'] = False
+        self._script.pointOfReference['paste'] = False
+
+    def handleUndoTextEvent(self, event):
+        if self.lastInputEventWasUndo():
+            if not self._script.pointOfReference.get('undo'):
+                self._script.presentMessage(messages.UNDO)
+                self._script.pointOfReference['undo'] = True
+            self.updateCachedTextSelection(event.source)
+            return True
+
+        if self.lastInputEventWasRedo():
+            if not self._script.pointOfReference.get('redo'):
+                self._script.presentMessage(messages.REDO)
+                self._script.pointOfReference['redo'] = True
+            self.updateCachedTextSelection(event.source)
+            return True
+
+        return False
+
+    def handleUndoLocusOfFocusChange(self):
+        if self.lastInputEventWasUndo():
+            if not self._script.pointOfReference.get('undo'):
+                self._script.presentMessage(messages.UNDO)
+                self._script.pointOfReference['undo'] = True
+            return True
+
+        if self.lastInputEventWasRedo():
+            if not self._script.pointOfReference.get('redo'):
+                self._script.presentMessage(messages.REDO)
+                self._script.pointOfReference['redo'] = True
+            return True
+
+        return False
+
+    def handlePasteLocusOfFocusChange(self):
+        if self.lastInputEventWasPaste():
+            if not self._script.pointOfReference.get('paste'):
+                self._script.presentMessage(
+                    messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF)
+                self._script.pointOfReference['paste'] = True
+            return True
+
+        return False
+
+    def presentFocusChangeReason(self):
+        if self.handleUndoLocusOfFocusChange():
+            return True
+        if self.handlePasteLocusOfFocusChange():
+            return True
+        return False
+
+    def handleTextSelectionChange(self, obj):
+        # Note: This guesswork to figure out what actually changed with respect
+        # to text selection will get eliminated once the new text-selection API
+        # is added to ATK and implemented by the toolkits. (BGO 638378)
+
+        oldStart, oldEnd, oldString = self.getCachedTextSelection(obj)
+        self.updateCachedTextSelection(obj)
+        newStart, newEnd, newString = self.getCachedTextSelection(obj)
+
+        # TODO - JD: This may be (now or soon) obsolete.
+        if self._script.pointOfReference.get('lastAutoComplete') == hash(obj):
+            return False
+
+        if self._speakTextSelectionState(len(newString)):
+            return True
+
+        changes = []
+        oldChars = set(range(oldStart, oldEnd))
+        newChars = set(range(newStart, newEnd))
+        if not oldChars.union(newChars):
+            return False
+
+        if oldChars and newChars and not oldChars.intersection(newChars):
+            # A simultaneous unselection and selection centered at one offset.
+            changes.append([oldStart, oldEnd, messages.TEXT_UNSELECTED])
+            changes.append([newStart, newEnd, messages.TEXT_SELECTED])
+        else:
+            change = sorted(oldChars.symmetric_difference(newChars))
+            if not change:
+                return False
+
+            changeStart, changeEnd = change[0], change[-1] + 1
+            if oldChars < newChars:
+                changes.append([changeStart, changeEnd, messages.TEXT_SELECTED])
+            else:
+                changes.append([changeStart, changeEnd, messages.TEXT_UNSELECTED])
+
+        speakMessage = not _settingsManager.getSetting('onlySpeakDisplayedText')
+        for start, end, message in changes:
+            self._script.sayPhrase(obj, start, end)
+            if speakMessage:
+                self._script.speakMessage(message, interrupt=False)
+
+        return True
+
+    def _getCtrlShiftSelectionsStrings(self):
+        """Hacky and to-be-obsoleted method."""
+        return [messages.PARAGRAPH_SELECTED_DOWN,
+                messages.PARAGRAPH_UNSELECTED_DOWN,
+                messages.PARAGRAPH_SELECTED_UP,
+                messages.PARAGRAPH_UNSELECTED_UP]
+
+    def _speakTextSelectionState(self, nSelections):
+        """Hacky and to-be-obsoleted method."""
+
+        if _settingsManager.getSetting('onlySpeakDisplayedText'):
+            return False
+
+        eventStr, mods = self.lastKeyAndModifiers()
+        isControlKey = mods & keybindings.CTRL_MODIFIER_MASK
+        isShiftKey = mods & keybindings.SHIFT_MODIFIER_MASK
+        selectedText = nSelections > 0
+
+        line = None
+        if (eventStr == "Page_Down") and isShiftKey and isControlKey:
+            line = messages.LINE_SELECTED_RIGHT
+        elif (eventStr == "Page_Up") and isShiftKey and isControlKey:
+            line = messages.LINE_SELECTED_LEFT
+        elif (eventStr == "Page_Down") and isShiftKey and not isControlKey:
+            if selectedText:
+                line = messages.PAGE_SELECTED_DOWN
+            else:
+                line = messages.PAGE_UNSELECTED_DOWN
+        elif (eventStr == "Page_Up") and isShiftKey and not isControlKey:
+            if selectedText:
+                line = messages.PAGE_SELECTED_UP
+            else:
+                line = messages.PAGE_UNSELECTED_UP
+        elif (eventStr == "Down") and isShiftKey and isControlKey:
+            strings = self._getCtrlShiftSelectionsStrings()
+            if selectedText:
+                line = strings[0]
+            else:
+                line = strings[1]
+        elif (eventStr == "Up") and isShiftKey and isControlKey:
+            strings = self._getCtrlShiftSelectionsStrings()
+            if selectedText:
+                line = strings[2]
+            else:
+                line = strings[3]
+        elif (eventStr == "Home") and isShiftKey and isControlKey:
+            if selectedText:
+                line = messages.DOCUMENT_SELECTED_UP
+            else:
+                line = messages.DOCUMENT_UNSELECTED_UP
+        elif (eventStr == "End") and isShiftKey and isControlKey:
+            if selectedText:
+                line = messages.DOCUMENT_SELECTED_DOWN
+            else:
+                line = messages.DOCUMENT_SELECTED_UP
+        elif (eventStr == "A") and isControlKey and selectedText:
+            if not self._script.pointOfReference.get('entireDocumentSelected'):
+                self._script.pointOfReference['entireDocumentSelected'] = True
+                line = messages.DOCUMENT_SELECTED_ALL
+            else:
+                return True
+
+        if line:
+            self._script.speakMessage(line)
+            return True
+
+        return False
diff --git a/src/orca/scripts/apps/gnome-shell/script_utilities.py 
b/src/orca/scripts/apps/gnome-shell/script_utilities.py
index e088fb3..97da615 100644
--- a/src/orca/scripts/apps/gnome-shell/script_utilities.py
+++ b/src/orca/scripts/apps/gnome-shell/script_utilities.py
@@ -26,8 +26,11 @@ __copyright__ = "Copyright (c) 2014 Igalia, S.L."
 __license__   = "LGPL"
 
 import pyatspi
+
+import orca.debug as debug
 import orca.script_utilities as script_utilities
 
+
 class Utilities(script_utilities.Utilities):
 
     def __init__(self, script):
@@ -49,3 +52,20 @@ class Utilities(script_utilities.Utilities):
                 children.append(selection.getSelectedChild(x))
 
         return children
+
+    def selectedText(self, obj):
+        string, start, end = super().selectedText(obj)
+        if -1 not in [start, end]:
+            return string, start, end
+
+        msg = "GNOME SHELL: Bogus selection range (%i, %i) for %s" % (start, end, obj)
+        debug.println(debug.LEVEL_INFO, msg, True)
+
+        text = self.queryNonEmptyText(obj)
+        if text.getNSelections() > 0:
+            string = text.getText(0, -1)
+            start, end = 0, len(string)
+            msg = "HACK: Returning '%s' (%i, %i) for %s" % (string, start, end, obj)
+            debug.println(debug.LEVEL_INFO, msg, True)
+
+        return string, start, end
diff --git a/src/orca/scripts/apps/soffice/script.py b/src/orca/scripts/apps/soffice/script.py
index cc6a75f..482f2bb 100644
--- a/src/orca/scripts/apps/soffice/script.py
+++ b/src/orca/scripts/apps/soffice/script.py
@@ -849,6 +849,27 @@ class Script(default.Script):
         # We're seeing a crazy ton of these emitted bogusly.
         pass
 
+    def onSelectedChanged(self, event):
+        """Callback for object:state-changed:selected accessibility events."""
+
+        full, brief = "", ""
+        if self.utilities.isSelectedTextDeletionEvent(event):
+            msg = "SOFFICE: Change is believed to be due to deleting selected text"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            full = messages.SELECTION_DELETED
+        elif self.utilities.isSelectedTextRestoredEvent(event):
+            msg = "SOFFICE: Selection is believed to be due to restoring selected text"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            if self.utilities.handleUndoTextEvent(event):
+                full = messages.SELECTION_RESTORED
+
+        if full or brief:
+            self.presentMessage(full, brief)
+            self.utilities.updateCachedTextSelection(event.source)
+            return
+
+        super().onSelectedChanged(event)
+
     def getTextLineAtCaret(self, obj, offset=None, startOffset=None, endOffset=None):
         """To-be-removed. Returns the string, caretOffset, startOffset."""
 
diff --git a/src/orca/scripts/apps/soffice/script_utilities.py 
b/src/orca/scripts/apps/soffice/script_utilities.py
index 02bf1a7..a0b51b9 100644
--- a/src/orca/scripts/apps/soffice/script_utilities.py
+++ b/src/orca/scripts/apps/soffice/script_utilities.py
@@ -31,6 +31,7 @@ __license__   = "LGPL"
 import pyatspi
 
 import orca.debug as debug
+import orca.keybindings as keybindings
 import orca.orca_state as orca_state
 import orca.script_utilities as script_utilities
 
@@ -419,50 +420,6 @@ class Utilities(script_utilities.Utilities):
 
         return parent
 
-    def findPreviousObject(self, obj):
-        """Finds the object before this one."""
-
-        if not obj:
-            return None
-
-        for relation in obj.getRelationSet():
-            if relation.getRelationType() == pyatspi.RELATION_FLOWS_FROM:
-                return relation.getTarget(0)
-
-        index = obj.getIndexInParent() - 1
-        if not (0 <= index < obj.parent.childCount - 1):
-            obj = obj.parent
-            index = obj.getIndexInParent() - 1
-
-        try:
-            prevObj = obj.parent[index]
-        except:
-            prevObj = obj
-
-        return prevObj
-
-    def findNextObject(self, obj):
-        """Finds the object after this one."""
-
-        if not obj:
-            return None
-
-        for relation in obj.getRelationSet():
-            if relation.getRelationType() == pyatspi.RELATION_FLOWS_TO:
-                return relation.getTarget(0)
-
-        index = obj.getIndexInParent() + 1
-        if not (0 < index < obj.parent.childCount):
-            obj = obj.parent
-            index = obj.getIndexInParent() + 1
-
-        try:
-            nextObj = obj.parent[index]
-        except:
-            nextObj = None
-
-        return nextObj
-
     @staticmethod
     def _flowsFromOrToSelection(obj):
         try:
@@ -483,6 +440,18 @@ class Utilities(script_utilities.Utilities):
 
         return False
 
+    def objectContentsAreInClipboard(self, obj=None):
+        obj = obj or orca_state.locusOfFocus
+        if not obj:
+            return False
+
+        if self.isSpreadSheetCell(obj):
+            contents = self.getClipboardContents()
+            string = self.displayedText(obj) or "\n"
+            return string in contents
+
+        return super().objectContentsAreInClipboard(obj)
+
     #########################################################################
     #                                                                       #
     # Impress-Specific Utilities                                            #
@@ -622,6 +591,22 @@ class Utilities(script_utilities.Utilities):
 
         return False
 
+    def isSelectedTextDeletionEvent(self, event):
+        if event.type.startswith("object:state-changed:selected") and not event.detail1:
+            return self.isDead(orca_state.locusOfFocus) and self.lastInputEventWasDelete()
+
+        return super().isSelectedTextDeletionEvent(event)
+
+    def lastInputEventWasRedo(self):
+        if super().lastInputEventWasRedo():
+            return True
+
+        keyString, mods = self.lastKeyAndModifiers()
+        if mods & keybindings.COMMAND_MODIFIER_MASK and keyString.lower() == 'y':
+            return not (mods & keybindings.SHIFT_MODIFIER_MASK)
+
+        return False
+
     def selectedChildren(self, obj):
         if not obj:
             return []
diff --git a/src/orca/scripts/default.py b/src/orca/scripts/default.py
index f2caf10..13df17b 100644
--- a/src/orca/scripts/default.py
+++ b/src/orca/scripts/default.py
@@ -32,8 +32,6 @@ __license__   = "LGPL"
 
 import time
 
-from gi.repository import Gtk, Gdk
-
 import pyatspi
 import orca.braille as braille
 import orca.chnames as chnames
@@ -714,16 +712,9 @@ class Script(script.Script):
         self._sayAllIsInterrupted = False
         self.pointOfReference = {}
 
-    def processKeyboardEvent(self, keyboardEvent):
-        """Processes the given keyboard event. It uses the super
-        class equivalent to do most of the work. The only thing done here
-        is to detect when the user is trying to get out of learn mode.
-
-        Arguments:
-        - keyboardEvent: an instance of input_event.KeyboardEvent
-        """
-
-        return script.Script.processKeyboardEvent(self, keyboardEvent)
+    def registerEventListeners(self):
+        super().registerEventListeners()
+        self.utilities.connectToClipboard()
 
     def _saveFocusedObjectInfo(self, obj):
         """Saves some basic information about obj. Note that this method is
@@ -797,6 +788,8 @@ class Script(script.Script):
         - newLocusOfFocus: Accessible that is the new locus of focus
         """
 
+        self.utilities.presentFocusChangeReason()
+
         if not newLocusOfFocus:
             orca_state.noFocusTimeStamp = time.time()
             return
@@ -1197,9 +1190,7 @@ class Script(script.Script):
             self.utilities.adjustTextSelection(obj, caretOffset)
             texti = obj.queryText()
             startOffset, endOffset = texti.getSelection(0)
-            string = texti.getText(startOffset, endOffset)
-            clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
-            clipboard.set_text(string, len(string))
+            self.utilities.setClipboardText(texti.getText(startOffset, endOffset))
 
         return True
 
@@ -1719,34 +1710,19 @@ class Script(script.Script):
         them in the clipboard."""
 
         if self.flatReviewContext:
-            clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
-            clipboard.set_text(
-                self.currentReviewContents, len(self.currentReviewContents))
+            self.utilities.setClipboardText(self.currentReviewContents)
             self.presentMessage(messages.FLAT_REVIEW_COPIED)
         else:
             self.presentMessage(messages.FLAT_REVIEW_NOT_IN)
 
         return True
 
-    def _appendToClipboard(self, clipboard, text, newText):
-        """Appends newText to text and places the results in the 
-        clipboard."""
-
-        text = text.rstrip("\n")
-        text = "%s\n%s" % (text, newText)
-        if clipboard:
-            clipboard.set_text(text, len(text))
-
-        return True
-
     def flatReviewAppend(self, inputEvent):
         """Appends the contents of the item under flat review to
         the clipboard."""
 
         if self.flatReviewContext:
-            clipboard = Gtk.Clipboard.get(Gdk.Atom.intern("CLIPBOARD", False))
-            clipboard.request_text(
-                self._appendToClipboard, self.currentReviewContents)
+            self.utilities.appendTextToClipboard(self.currentReviewContents)
             self.presentMessage(messages.FLAT_REVIEW_APPENDED)
         else:
             self.presentMessage(messages.FLAT_REVIEW_NOT_IN)
@@ -2227,7 +2203,12 @@ class Script(script.Script):
         if text.getNSelections():
             msg = "DEFAULT: Event source has text selections"
             debug.println(debug.LEVEL_INFO, msg, True)
+            self.utilities.handleTextSelectionChange(event.source)
             return
+        else:
+            start, end, string = self.utilities.getCachedTextSelection(obj)
+            if string and self.utilities.handleTextSelectionChange(obj):
+                return
 
         msg = "DEFAULT: Presenting text at new caret position"
         debug.println(debug.LEVEL_INFO, msg, True)
@@ -2288,38 +2269,13 @@ class Script(script.Script):
         self.pointOfReference['indeterminateChange'] = hash(obj), event.detail1
 
     def onMouseButton(self, event):
-        """Called whenever the user presses or releases a mouse button.
-
-        Arguments:
-        - event: the Event
-        """
+        """Callback for mouse:button events."""
 
         mouseEvent = input_event.MouseButtonEvent(event)
         orca_state.lastInputEvent = mouseEvent
 
         if mouseEvent.pressed:
             speech.stop()
-            return
-
-        # If we've received a mouse button released event, then check if
-        # there are and text selections for the locus of focus and speak
-        # them.
-        #
-        obj = orca_state.locusOfFocus
-        try:
-            text = obj.queryText()
-        except:
-            return
-
-        self.updateBraille(orca_state.locusOfFocus)
-        textContents = self.utilities.allSelectedText(obj)[0]
-        if not textContents:
-            return
-
-        utterances = []
-        utterances.append(textContents)
-        utterances.append(messages.TEXT_SELECTED)
-        speech.speak(utterances)
 
     def onNameChanged(self, event):
         """Callback for object:property-change:accessible-name events."""
@@ -2409,9 +2365,13 @@ class Script(script.Script):
         """Callback for object:selection-changed accessibility events."""
 
         obj = event.source
-        state = obj.getState()
-        if state.contains(pyatspi.STATE_MANAGES_DESCENDANTS):
-            return
+
+        if self.utilities.handlePasteLocusOfFocusChange():
+            orca.setLocusOfFocus(event, event.source, False)
+        else:
+            state = obj.getState()
+            if state.contains(pyatspi.STATE_MANAGES_DESCENDANTS):
+                return
 
         # TODO - JD: We need to give more thought to where we look to this
         # event and where we prefer object:state-changed:selected.
@@ -2511,30 +2471,18 @@ class Script(script.Script):
                 return
 
     def onTextAttributesChanged(self, event):
-        """Called when an object's text attributes change. Right now this
-        method is only to handle the presentation of spelling errors on
-        the fly. Also note that right now, the Gecko toolkit is the only
-        one to present this information to us.
+        """Callback for object:text-attributes-changed accessibility events."""
 
-        Arguments:
-        - event: the Event
-        """
+        if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event):
+            return
 
-        if _settingsManager.getSetting('speakMisspelledIndicator') \
-           and self.utilities.isSameObject(
-                event.source, orca_state.locusOfFocus):
-            try:
-                text = event.source.queryText()
-            except:
-                return
+        text = self.utilities.queryNonEmptyText(event.source)
+        if not text:
+            msg = "DEFAULT: Querying non-empty text returned None"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return
 
-            # If the misspelled word indicator has just appeared, it's
-            # because the user typed a word boundary or navigated out
-            # of the word. We don't want to have to store a full set of
-            # each object's text attributes to compare, therefore, we'll
-            # check the previous word (most likely case) and the next
-            # word with respect to the current position.
-            #
+        if _settingsManager.getSetting('speakMisspelledIndicator'):
             offset = text.caretOffset
             if not text.getText(offset, offset+1).isalnum():
                 offset -= 1
@@ -2543,267 +2491,133 @@ class Script(script.Script):
                 self.speakMessage(messages.MISSPELLED)
 
     def onTextDeleted(self, event):
-        """Called whenever text is deleted from an object.
-
-        Arguments:
-        - event: the Event
-        """
+        """Callback for object:text-changed:delete accessibility events."""
 
-        role = event.source.getRole()
-        state = event.source.getState()
-        if role == pyatspi.ROLE_PASSWORD_TEXT and state.contains(pyatspi.STATE_FOCUSED):
-            orca.setLocusOfFocus(event, event.source, False)
-
-        # Ignore text deletions from non-focused objects, unless the
-        # currently focused object is the parent of the object from which
-        # text was deleted
-        #
-        if (event.source != orca_state.locusOfFocus) \
-            and (event.source.parent != orca_state.locusOfFocus):
+        if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event):
             return
 
-        # We'll also ignore sliders because we get their output via
-        # their values changing.
-        #
-        if role == pyatspi.ROLE_SLIDER:
-            return
-
-        # [[[NOTE: WDW - if we handle events synchronously, we'll
-        # be looking at the text object *before* the text was
-        # actually removed from the object.  If we handle events
-        # asynchronously, we'll be looking at the text object
-        # *after* the text was removed.  The importance of knowing
-        # this is that the output will differ depending upon how
-        # orca.settings.asyncMode has been set.  For example, the
-        # regression tests run in synchronous mode, so the output
-        # they see will not be the same as what the user normally
-        # experiences.]]]
+        self.utilities.handleUndoTextEvent(event)
 
+        orca.setLocusOfFocus(event, event.source, False)
         self.updateBraille(event.source)
 
-        # The any_data member of the event object has the deleted text in
-        # it - If the last key pressed was a backspace or delete key,
-        # speak the deleted text.  [[[TODO: WDW - again, need to think
-        # about the ramifications of this when it comes to editors such
-        # as vi or emacs.
-        #
-        keyString, mods = self.utilities.lastKeyAndModifiers()
-        if not keyString:
-            return
-
-        text = event.source.queryText()
-        if keyString == "BackSpace":
-            # Speak the character that has just been deleted.
-            #
-            character = event.any_data
-
-        elif keyString == "Delete" \
-             or (keyString == "D" and mods & keybindings.CTRL_MODIFIER_MASK):
-            # Speak the character to the right of the caret after
-            # the current right character has been deleted.
-            #
-            offset = text.caretOffset
-            [character, startOffset, endOffset] = \
-                text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_CHAR)
+        full, brief = "", ""
+        if self.utilities.isClipboardTextChangedEvent(event):
+            msg = "DEFAULT: Deletion is believed to be due to clipboard cut"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            full, brief = messages.CLIPBOARD_CUT_FULL, messages.CLIPBOARD_CUT_BRIEF
+        elif self.utilities.isSelectedTextDeletionEvent(event):
+            msg = "DEFAULT: Deletion is believed to be due to deleting selected text"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            full = messages.SELECTION_DELETED
 
-        else:
+        if full or brief:
+            self.presentMessage(full, brief)
+            self.utilities.updateCachedTextSelection(event.source)
             return
 
-        if len(character) == 1:
-            self.speakCharacter(character)
+        string = event.any_data
+        if self.utilities.isDeleteCommandTextDeletionEvent(event):
+            msg = "DEFAULT: Deletion is believed to be due to Delete command"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            string = self.utilities.getCharacterAtOffset(event.source)
+        elif self.utilities.isBackSpaceCommandTextDeletionEvent(event):
+            msg = "DEFAULT: Deletion is believed to be due to BackSpace command"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        else:
+            msg = "INFO: Event is not being presented due to lack of cause"
+            debug.println(debug.LEVEL_INFO, msg, True)
             return
 
-        if self.utilities.linkIndex(event.source, text.caretOffset) >= 0:
-            voice = self.voices[settings.HYPERLINK_VOICE]
-        elif character.isupper():
-            voice = self.voices[settings.UPPERCASE_VOICE]
+        if len(string) == 1:
+            self.speakCharacter(string)
+        elif string.isupper():
+            speech.speak(string, self.voices[settings.UPPERCASE_VOICE])
         else:
-            voice = self.voices[settings.DEFAULT_VOICE]
-
-        # We won't interrupt what else might be being spoken
-        # right now because it is typically something else
-        # related to this event.
-        #
-        speech.speak(character, voice, False)
+            speech.speak(string, self.voices[settings.DEFAULT_VOICE])
 
     def onTextInserted(self, event):
-        """Called whenever text is inserted into an object.
+        """Callback for object:text-changed:insert accessibility events."""
 
-        Arguments:
-        - event: the Event
-        """
-
-        role = event.source.getRole()
-        state = event.source.getState()
-        if role == pyatspi.ROLE_PASSWORD_TEXT and state.contains(pyatspi.STATE_FOCUSED):
-            orca.setLocusOfFocus(event, event.source, False)
-
-        # Ignore text insertions from non-focused objects, unless the
-        # currently focused object is the parent of the object from which
-        # text was inserted.
-        #
-        if (event.source != orca_state.locusOfFocus) \
-            and (event.source.parent != orca_state.locusOfFocus):
-            return
-
-        ignoreRoles = [pyatspi.ROLE_LABEL,
-                       pyatspi.ROLE_MENU,
-                       pyatspi.ROLE_MENU_ITEM,
-                       pyatspi.ROLE_SLIDER,
-                       pyatspi.ROLE_SPIN_BUTTON]
-        if role in ignoreRoles:
+        if not self.utilities.isPresentableTextChangedEventForLocusOfFocus(event):
             return
 
-        if role == pyatspi.ROLE_TABLE_CELL \
-           and not state.contains(pyatspi.STATE_FOCUSED) \
-           and not state.contains(pyatspi.STATE_SELECTED):
-            return
+        self.utilities.handleUndoTextEvent(event)
 
+        orca.setLocusOfFocus(event, event.source, False)
         self.updateBraille(event.source)
 
-        # If the last input event was a keyboard event, check to see if
-        # the text for this event matches what the user typed. If it does,
-        # then don't speak it.
-        #
-        # Note that the text widgets sometimes compress their events,
-        # thus we might get a longer string from a single text inserted
-        # event, while we also get individual keyboard events for the
-        # characters used to type the string.  This is ugly.  We attempt
-        # to handle it here by only echoing text if we think it was the
-        # result of a command (e.g., a paste operation).
-        #
-        # Note that we have to special case the space character as it
-        # comes across as "space" in the keyboard event and " " in the
-        # text event.
-        #
-        string = event.any_data
-        speakThis = False
-        wasCommand = False
-        wasAutoComplete = False
-        if isinstance(orca_state.lastInputEvent, input_event.MouseButtonEvent):
-            speakThis = orca_state.lastInputEvent.button == "2"
-        else:
-            keyString, mods = self.utilities.lastKeyAndModifiers()
-            wasCommand = mods & keybindings.COMMAND_MODIFIER_MASK
-            if not wasCommand and keyString in ["Return", "Tab", "space"] \
-               and role == pyatspi.ROLE_TERMINAL \
-               and event.any_data.strip():
-                wasCommand = True
-            try:
-                selections = event.source.queryText().getNSelections()
-            except:
-                selections = 0
+        full, brief = "", ""
+        if self.utilities.isClipboardTextChangedEvent(event):
+            msg = "DEFAULT: Insertion is believed to be due to clipboard paste"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            full, brief = messages.CLIPBOARD_PASTED_FULL, messages.CLIPBOARD_PASTED_BRIEF
+        elif self.utilities.isSelectedTextRestoredEvent(event):
+            msg = "DEFAULT: Insertion is believed to be due to restoring selected text"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            full = messages.SELECTION_RESTORED
 
-            if selections:
-                wasAutoComplete = role in [pyatspi.ROLE_TEXT, pyatspi.ROLE_ENTRY]
+        if full or brief:
+            self.presentMessage(full, brief)
+            self.utilities.updateCachedTextSelection(event.source)
+            return
 
-            if (string == " " and keyString == "space") or string == keyString:
-                pass
-            elif wasCommand or wasAutoComplete:
-                speakThis = True
-            elif role == pyatspi.ROLE_PASSWORD_TEXT \
-                 and _settingsManager.getSetting('enableKeyEcho'):
-                # Echoing "star" is preferable to echoing the descriptive
-                # name of the bullet that has appeared (e.g. "black circle")
-                #
-                string = "*"
-                speakThis = True
+        speakString = True
+        string = event.any_data
 
-        # Auto-completed, auto-corrected, auto-inserted, etc.
-        #
-        speakThis = speakThis or self.utilities.isAutoTextEvent(event)
+        if self.utilities.lastInputEventWasCommand():
+            msg = "DEFAULT: Insertion is believed to be due to command"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        elif self.utilities.treatEventAsTerminalCommand(event):
+            msg = "DEFAULT: Insertion is believed to be due to terminal command"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        elif self.utilities.isMiddleMouseButtonTextInsertionEvent(event):
+            msg = "DEFAULT: Insertion is believed to be due to middle mouse button"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        elif self.utilities.isEchoableTextInsertionEvent(event):
+            msg = "DEFAULT: Insertion is believed to be echoable"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        elif self.utilities.isAutoTextEvent(event):
+            msg = "DEFAULT: Insertion is believed to be auto text event"
+            debug.println(debug.LEVEL_INFO, msg, True)
+        elif self.utilities.isSelectedTextInsertionEvent(event):
+            msg = "DEFAULT: Insertion is also selected"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            # TODO - JD: This may be (now or soon) obsolete.
+            self.pointOfReference['lastAutoComplete'] = hash(event.source)
+        else:
+            msg = "DEFAULT: Not speaking inserted string due to lack of cause"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            speakString = False
 
-        # We might need to echo this if it is a single character.
-        #
-        speakThis = speakThis \
-                    or (_settingsManager.getSetting('enableEchoByCharacter') \
-                        and string \
-                        and role != pyatspi.ROLE_PASSWORD_TEXT \
-                        and len(string.strip()) == 1)
-
-        if speakThis:
-            if string.isupper():
-                speech.speak(string, self.voices[settings.UPPERCASE_VOICE])
-            elif not string.isalnum():
+        if speakString:
+            if len(string) == 1:
                 self.speakCharacter(string)
+            elif string.isupper():
+                speech.speak(string, self.voices[settings.UPPERCASE_VOICE])
             else:
-                speech.speak(string)
-
-        if wasCommand:
-            return
-
-        if wasAutoComplete:
-            self.pointOfReference['lastAutoComplete'] = hash(event.source)
-
-        try:
-            text = event.source.queryText()
-        except NotImplementedError:
-            return
+                speech.speak(string, self.voices[settings.DEFAULT_VOICE])
 
-        offset = text.caretOffset - 1
-        previousOffset = offset - 1
-        if (offset < 0 or previousOffset < 0):
+        if len(string) != 1:
             return
 
-        [currentChar, startOffset, endOffset] = \
-            text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_CHAR)
-        [previousChar, startOffset, endOffset] = \
-            text.getTextAtOffset(previousOffset, pyatspi.TEXT_BOUNDARY_CHAR)
-
         if _settingsManager.getSetting('enableEchoBySentence') \
-           and self.utilities.isSentenceDelimiter(currentChar, previousChar):
-            self.echoPreviousSentence(event.source)
+           and self.echoPreviousSentence(event.source):
+            return
 
-        elif _settingsManager.getSetting('enableEchoByWord') \
-             and self.utilities.isWordDelimiter(currentChar):
+        if _settingsManager.getSetting('enableEchoByWord'):
             self.echoPreviousWord(event.source)
 
     def onTextSelectionChanged(self, event):
         """Callback for object:text-selection-changed accessibility events."""
 
         obj = event.source
-        self.updateBraille(obj)
-
-        # Note: This guesswork to figure out what actually changed with respect
-        # to text selection will get eliminated once the new text-selection API
-        # is added to ATK and implemented by the toolkits. (BGO 638378)
-
-        oldStart, oldEnd, oldString = self.utilities.getCachedTextSelection(obj)
-        self.utilities.updateCachedTextSelection(obj)
-        newStart, newEnd, newString = self.utilities.getCachedTextSelection(obj)
-
-        if self.pointOfReference.get('lastAutoComplete') == hash(obj):
-            return
-
-        if self._speakTextSelectionState(len(newString)):
-            return
-
-        changes = []
-        oldChars = set(range(oldStart, oldEnd))
-        newChars = set(range(newStart, newEnd))
-        if not oldChars.union(newChars):
+        if self.utilities.handleUndoTextEvent(event):
+            self.updateBraille(obj)
             return
 
-        if oldChars and newChars and not oldChars.intersection(newChars):
-            # A simultaneous unselection and selection centered at one offset.
-            changes.append([oldStart, oldEnd, messages.TEXT_UNSELECTED])
-            changes.append([newStart, newEnd, messages.TEXT_SELECTED])
-        else:
-            change = sorted(oldChars.symmetric_difference(newChars))
-            if not change:
-                return
-
-            changeStart, changeEnd = change[0], change[-1] + 1
-            if oldChars < newChars:
-                changes.append([changeStart, changeEnd, messages.TEXT_SELECTED])
-            else:
-                changes.append([changeStart, changeEnd, messages.TEXT_UNSELECTED])
-
-        speakMessage = not _settingsManager.getSetting('onlySpeakDisplayedText')
-        for start, end, message in changes:
-            self.sayPhrase(obj, start, end)
-            if speakMessage:
-                self.speakMessage(message, interrupt=False)
+        self.utilities.handleTextSelectionChange(obj)
+        self.updateBraille(obj)
 
     def onColumnReordered(self, event):
         """Called whenever the columns in a table are reordered.
@@ -2964,6 +2778,19 @@ class Script(script.Script):
         # disable learn mode
         orca_state.learnModeEnabled = False
 
+    def onClipboardContentsChanged(self, *args):
+        if not self.utilities.objectContentsAreInClipboard():
+            return
+
+        if not self.utilities.lastInputEventWasCut():
+            self.presentMessage(messages.CLIPBOARD_COPIED_FULL, messages.CLIPBOARD_COPIED_BRIEF)
+            return
+
+        if self.utilities.isTextArea(orca_state.locusOfFocus):
+            return
+
+        self.presentMessage(messages.CLIPBOARD_CUT_FULL, messages.CLIPBOARD_CUT_BRIEF)
+
     ########################################################################
     #                                                                      #
     # Methods for presenting content                                       #
@@ -2971,64 +2798,54 @@ class Script(script.Script):
     ########################################################################
 
     def _presentTextAtNewCaretPosition(self, event, otherObj=None):
-        """Updates braille and outputs speech for the event.source or the
-        otherObj."""
-
         obj = otherObj or event.source
-        text = obj.queryText()
-
         self.updateBrailleForNewCaretPosition(obj)
         if self._inSayAll:
             return
 
-        if not orca_state.lastInputEvent:
+        if self.utilities.lastInputEventWasLineNav():
+            msg = "DEFAULT: Presenting result of line nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            self.sayLine(obj)
             return
 
-        if isinstance(orca_state.lastInputEvent, input_event.MouseButtonEvent):
-            if not orca_state.lastInputEvent.pressed:
-                self.sayLine(obj)
+        if self.utilities.lastInputEventWasWordNav():
+            msg = "DEFAULT: Presenting result of word nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            self.sayWord(obj)
             return
 
-        # Guess why the caret moved and say something appropriate.
-        # [[[TODO: WDW - this motion assumes traditional GUI
-        # navigation gestures.  In an editor such as vi, line up and
-        # down is done via other actions such as "i" or "j".  We may
-        # need to think about this a little harder.]]]
-        #
-        keyString, mods = self.utilities.lastKeyAndModifiers()
-        if not keyString:
+        if self.utilities.lastInputEventWasCharNav():
+            msg = "DEFAULT: Presenting result of char nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            self.sayCharacter(obj)
             return
 
-        isControlKey = mods & keybindings.CTRL_MODIFIER_MASK
-
-        if keyString in ["Up", "Down"]:
+        if self.utilities.lastInputEventWasPageNav():
+            msg = "DEFAULT: Presenting result of page nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
             self.sayLine(obj)
+            return
 
-        elif keyString in ["Left", "Right"]:
-            if isControlKey:
-                self.sayWord(obj)
-            else:
-                self.sayCharacter(obj)
-
-        elif keyString == "Page_Up":
-            # TODO - JD: Why is Control special here?
-            # If the user has typed Control-Page_Up, then we
-            # speak the character to the right of the current text cursor
-            # position otherwise we speak the current line.
-            #
-            if isControlKey:
-                self.sayCharacter(obj)
-            else:
-                self.sayLine(obj)
+        if self.utilities.lastInputEventWasLineBoundaryNav():
+            msg = "DEFAULT: Presenting result of line boundary nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
+            self.sayCharacter(obj)
+            return
 
-        elif keyString == "Page_Down":
+        if self.utilities.lastInputEventWasFileBoundaryNav():
+            msg = "DEFAULT: Presenting result of file boundary nav"
+            debug.println(debug.LEVEL_INFO, msg, True)
             self.sayLine(obj)
+            return
 
-        elif keyString in ["Home", "End"]:
-            if isControlKey:
+        if self.utilities.lastInputEventWasPrimaryMouseRelease():
+            start, end, string = self.utilities.getCachedTextSelection(event.source)
+            if not string:
+                msg = "DEFAULT: Presenting result of primary mouse button release"
+                debug.println(debug.LEVEL_INFO, msg, True)
                 self.sayLine(obj)
-            else:
-                self.sayCharacter(obj)
+                return
 
     def _rewindSayAll(self, context, minCharCount=10):
         if not _settingsManager.getSetting('rewindAndFastForwardInSayAll'):
@@ -3140,19 +2957,19 @@ class Script(script.Script):
         try:
             text = obj.queryText()
         except NotImplementedError:
-            return
+            return False
 
         offset = text.caretOffset - 1
         previousOffset = text.caretOffset - 2
         if (offset < 0 or previousOffset < 0):
-            return
+            return False
 
         [currentChar, startOffset, endOffset] = \
             text.getTextAtOffset(offset, pyatspi.TEXT_BOUNDARY_CHAR)
         [previousChar, startOffset, endOffset] = \
             text.getTextAtOffset(previousOffset, pyatspi.TEXT_BOUNDARY_CHAR)
         if not self.utilities.isSentenceDelimiter(currentChar, previousChar):
-            return
+            return False
 
         # OK - we seem to be cool so far.  So...starting with what
         # should be the last character in the sentence (caretOffset - 2),
@@ -3184,7 +3001,7 @@ class Script(script.Script):
         # for that, too.
         #
         if sentenceStartOffset == sentenceEndOffset:
-            return
+            return False
         else:
             sentence = self.utilities.substring(obj, sentenceStartOffset + 1,
                                          sentenceEndOffset + 1)
@@ -3198,6 +3015,7 @@ class Script(script.Script):
 
         sentence = self.utilities.adjustForRepeats(sentence)
         speech.speak(sentence, voice)
+        return True
 
     def echoPreviousWord(self, obj, offset=None):
         """Speaks the word prior to the caret, as long as there is
@@ -3219,7 +3037,7 @@ class Script(script.Script):
         try:
             text = obj.queryText()
         except NotImplementedError:
-            return
+            return False
 
         if not offset:
             if text.caretOffset == -1:
@@ -3228,14 +3046,14 @@ class Script(script.Script):
                 offset = text.caretOffset - 1
 
         if (offset < 0):
-            return
+            return False
 
         [char, startOffset, endOffset] = \
             text.getTextAtOffset( \
                 offset,
                 pyatspi.TEXT_BOUNDARY_CHAR)
         if not self.utilities.isWordDelimiter(char):
-            return
+            return False
 
         # OK - we seem to be cool so far.  So...starting with what
         # should be the last character in the word (caretOffset - 2),
@@ -3265,7 +3083,7 @@ class Script(script.Script):
         # for that, too.
         #
         if wordStartOffset == wordEndOffset:
-            return
+            return False
         else:
             word = self.utilities.\
                 substring(obj, wordStartOffset + 1, wordEndOffset + 1)
@@ -3279,6 +3097,7 @@ class Script(script.Script):
 
         word = self.utilities.adjustForRepeats(word)
         speech.speak(word, voice)
+        return True
 
     def presentToolTip(self, obj):
         """
@@ -3799,74 +3618,6 @@ class Script(script.Script):
 
         self.pointOfReference["lastCursorPosition"] = [obj, caretOffset]
 
-    def _getCtrlShiftSelectionsStrings(self):
-        return [messages.PARAGRAPH_SELECTED_DOWN,
-                messages.PARAGRAPH_UNSELECTED_DOWN,
-                messages.PARAGRAPH_SELECTED_UP,
-                messages.PARAGRAPH_UNSELECTED_UP]
-
-    def _speakTextSelectionState(self, nSelections):
-        """Hacky method to speak special cases without any valid sanity
-        checking. It is not long for this world. Do not call it."""
-
-        if _settingsManager.getSetting('onlySpeakDisplayedText'):
-            return False
-
-        eventStr, mods = self.utilities.lastKeyAndModifiers()
-        isControlKey = mods & keybindings.CTRL_MODIFIER_MASK
-        isShiftKey = mods & keybindings.SHIFT_MODIFIER_MASK
-        selectedText = nSelections > 0
-
-        line = None
-        if (eventStr == "Page_Down") and isShiftKey and isControlKey:
-            line = messages.LINE_SELECTED_RIGHT
-        elif (eventStr == "Page_Up") and isShiftKey and isControlKey:
-            line = messages.LINE_SELECTED_LEFT
-        elif (eventStr == "Page_Down") and isShiftKey and not isControlKey:
-            if selectedText:
-                line = messages.PAGE_SELECTED_DOWN
-            else:
-                line = messages.PAGE_UNSELECTED_DOWN
-        elif (eventStr == "Page_Up") and isShiftKey and not isControlKey:
-            if selectedText:
-                line = messages.PAGE_SELECTED_UP
-            else:
-                line = messages.PAGE_UNSELECTED_UP
-        elif (eventStr == "Down") and isShiftKey and isControlKey:
-            strings = self._getCtrlShiftSelectionsStrings()
-            if selectedText:
-                line = strings[0]
-            else:
-                line = strings[1]
-        elif (eventStr == "Up") and isShiftKey and isControlKey:
-            strings = self._getCtrlShiftSelectionsStrings()
-            if selectedText:
-                line = strings[2]
-            else:
-                line = strings[3]
-        elif (eventStr == "Home") and isShiftKey and isControlKey:
-            if selectedText:
-                line = messages.DOCUMENT_SELECTED_UP
-            else:
-                line = messages.DOCUMENT_UNSELECTED_UP
-        elif (eventStr == "End") and isShiftKey and isControlKey:
-            if selectedText:
-                line = messages.DOCUMENT_SELECTED_DOWN
-            else:
-                line = messages.DOCUMENT_SELECTED_UP
-        elif (eventStr == "A") and isControlKey and selectedText:
-            if not self.pointOfReference.get('entireDocumentSelected'):
-                self.pointOfReference['entireDocumentSelected'] = True
-                line = messages.DOCUMENT_SELECTED_ALL
-            else:
-                return True
-
-        if line:
-            speech.speak(line, None, False)
-            return True
-
-        return False
-
     def systemBeep(self):
         """Rings the system bell. This is really a hack. Ideally, we want
         a method that will present an earcon (any sound designated for the
@@ -3940,6 +3691,7 @@ class Script(script.Script):
 
         if not event.isPressedKey():
             self._sayAllIsInterrupted = False
+            self.utilities.clearCachedCommandState()
 
         if not orca_state.learnModeEnabled:
             if event.shouldEcho == False or event.isOrcaModified():
diff --git a/src/orca/scripts/web/script.py b/src/orca/scripts/web/script.py
index e95cda4..658c849 100644
--- a/src/orca/scripts/web/script.py
+++ b/src/orca/scripts/web/script.py
@@ -656,12 +656,6 @@ class Script(default.Script):
         orca.setLocusOfFocus(None, context.obj, notifyScript=False)
         self.utilities.setCaretContext(context.obj, context.currentOffset)
 
-    def _getCtrlShiftSelectionsStrings(self):
-        return [messages.LINE_SELECTED_DOWN,
-                messages.LINE_UNSELECTED_DOWN,
-                messages.LINE_SELECTED_UP,
-                messages.LINE_UNSELECTED_UP]
-
     def inFocusMode(self):
         """ Returns True if we're in focus mode."""
 
@@ -755,8 +749,17 @@ class Script(default.Script):
             debug.println(debug.LEVEL_INFO, "BRAILLE: disabled", True)
             return
 
-        if not (self._lastCommandWasCaretNav or self._lastCommandWasStructNav) \
-           or self._inFocusMode or not self.utilities.inDocumentContent():
+        if self._inFocusMode or not self.utilities.inDocumentContent():
+            msg = "WEB: updating braille for non-browse-mode object %s" % obj
+            debug.println(debug.LEVEL_INFO, msg, True)
+            super().updateBraille(obj, **args)
+            return
+
+        if not self._lastCommandWasCaretNav \
+           and not self._lastCommandWasStructNav \
+           and not self.utilities.lastInputEventWasCaretNavWithSelection():
+            msg = "WEB: updating braille for unhandled navigation type %s" % obj
+            debug.println(debug.LEVEL_INFO, msg, True)
             super().updateBraille(obj, **args)
             return
 
@@ -1737,14 +1740,9 @@ class Script(default.Script):
             return True
 
         char = text.getText(event.detail1, event.detail1+1)
-        if char == self.EMBEDDED_OBJECT_CHARACTER:
-            msg = "WEB: Ignoring: Event offset is at embedded object"
-            debug.println(debug.LEVEL_INFO, msg, True)
-            return True
-
-        obj, offset = self.utilities.getCaretContext()
-        if obj and obj.parent and event.source in [obj.parent, obj.parent.parent]:
-            msg = "WEB: Ignoring: Source is context ancestor"
+        if char == self.EMBEDDED_OBJECT_CHARACTER \
+           and not self.utilities.lastInputEventWasCaretNavWithSelection():
+            msg = "WEB: Ignoring: Not selecting and event offset is at embedded object"
             debug.println(debug.LEVEL_INFO, msg, True)
             return True
 
diff --git a/src/orca/scripts/web/script_utilities.py b/src/orca/scripts/web/script_utilities.py
index 54c19e7..22dcff0 100644
--- a/src/orca/scripts/web/script_utilities.py
+++ b/src/orca/scripts/web/script_utilities.py
@@ -611,7 +611,7 @@ class Utilities(script_utilities.Utilities):
 
     def expandEOCs(self, obj, startOffset=0, endOffset=-1):
         if not self.inDocumentContent(obj):
-            return ""
+            return super().expandEOCs(obj, startOffset, endOffset)
 
         text = self.queryNonEmptyText(obj)
         if not text:
@@ -1018,13 +1018,6 @@ class Utilities(script_utilities.Utilities):
 
         return objects
 
-    def getCharacterAtOffset(self, obj, offset):
-        text = self.queryNonEmptyText(obj)
-        if text:
-            return text.getText(offset, offset + 1)
-
-        return ""
-
     def getCharacterContentsAtOffset(self, obj, offset, useCache=True):
         if not obj:
             return []
@@ -1347,6 +1340,38 @@ class Utilities(script_utilities.Utilities):
 
         return contents
 
+    def hasPresentableText(self, obj):
+        text = self.queryNonEmptyText(obj)
+        if not text:
+            return False
+
+        return bool(re.search("\w", text.getText(0, -1)))
+
+    def updateCachedTextSelection(self, obj):
+        if not self.inDocumentContent(obj):
+            super().updateCachedTextSelection(obj)
+            return
+
+        if self.hasPresentableText(obj):
+            super().updateCachedTextSelection(obj)
+
+    def handleTextSelectionChange(self, obj):
+        if not self.inDocumentContent(obj):
+            return super().handleTextSelectionChange(obj)
+
+        if self.hasPresentableText(obj) and super().handleTextSelectionChange(obj):
+            return True
+
+        handled = False
+        descendants = pyatspi.findAllDescendants(obj, lambda x: self.hasPresentableText)
+        for descendant in descendants:
+            if handled:
+                super().updateCachedTextSelection(descendant)
+            else:
+                handled = handled or super().handleTextSelectionChange(descendant)
+
+        return handled
+
     def inTopLevelWebApp(self, obj=None):
         if not obj:
             obj = orca_state.locusOfFocus
@@ -2429,7 +2454,7 @@ class Utilities(script_utilities.Utilities):
 
         if isinstance(orca_state.lastInputEvent, input_event.KeyboardEvent):
             inputEvent = orca_state.lastNonModifierKeyEvent
-            return inputEvent and inputEvent.isPrintableKey()
+            return inputEvent and inputEvent.isPrintableKey() and not inputEvent.modifiers
 
         return False
 
@@ -2862,3 +2887,10 @@ class Utilities(script_utilities.Utilities):
                     uvlinks += 1
 
         return [headings, forms, tables, vlinks, uvlinks, percentRead]
+
+    def _getCtrlShiftSelectionsStrings(self):
+        """Hacky and to-be-obsoleted method."""
+        return [messages.LINE_SELECTED_DOWN,
+                messages.LINE_UNSELECTED_DOWN,
+                messages.LINE_SELECTED_UP,
+                messages.LINE_UNSELECTED_UP]
diff --git a/test/keystrokes/firefox/selection_textarea.params 
b/test/keystrokes/firefox/selection_textarea.params
new file mode 100644
index 0000000..c571fac
--- /dev/null
+++ b/test/keystrokes/firefox/selection_textarea.params
@@ -0,0 +1 @@
+PARAMS=$TEST_DIR/../../html/textarea.html
diff --git a/test/keystrokes/firefox/selection_textarea.py b/test/keystrokes/firefox/selection_textarea.py
new file mode 100644
index 0000000..6afbc0f
--- /dev/null
+++ b/test/keystrokes/firefox/selection_textarea.py
@@ -0,0 +1,162 @@
+#!/usr/bin/python
+
+from macaroon.playback import *
+import utils
+
+sequence = MacroSequence()
+
+#sequence.append(WaitForDocLoad())
+sequence.append(PauseAction(5000))
+
+# Work around some new quirk in Gecko that causes this test to fail if
+# run via the test harness rather than manually.
+sequence.append(KeyComboAction("<Control>r"))
+
+sequence.append(KeyComboAction("Tab"))
+sequence.append(TypeAction("This is a test."))
+sequence.append(KeyComboAction("Return"))
+sequence.append(TypeAction("So is this line."))
+sequence.append(KeyComboAction("Return"))
+sequence.append(KeyComboAction("<Control>Home"))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>a"))
+sequence.append(utils.AssertPresentationAction(
+    "1. Select all'",
+    ["BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "SPEECH OUTPUT: 'entire document selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Delete"))
+sequence.append(utils.AssertPresentationAction(
+    "2. Delete the selection",
+    ["BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "BRAILLE LINE:  'Selection deleted.'",
+     "     VISIBLE:  'Selection deleted.', cursor=0",
+     "SPEECH OUTPUT: 'Selection deleted.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>z"))
+sequence.append(utils.AssertPresentationAction(
+    "3. Undo",
+    ["BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "BRAILLE LINE:  'undo'",
+     "     VISIBLE:  'undo', cursor=0",
+     "BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "BRAILLE LINE:  'Selection restored.'",
+     "     VISIBLE:  'Selection restored.', cursor=0",
+     "BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "SPEECH OUTPUT: 'undo' voice=system",
+     "SPEECH OUTPUT: 'Selection restored.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Up"))
+sequence.append(utils.AssertPresentationAction(
+    "4. Up'",
+    ["BRAILLE LINE:  'Label This is a test. $l'",
+     "     VISIBLE:  'Label This is a test. $l', cursor=7",
+     "BRAILLE LINE:  'Label This is a test. $l'",
+     "     VISIBLE:  'Label This is a test. $l', cursor=7",
+     "SPEECH OUTPUT: 'This is a test.",
+     "So is this line.",
+     "'",
+     "SPEECH OUTPUT: 'unselected' voice=system",
+     "SPEECH OUTPUT: 'This is a test.'"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Up"))
+sequence.append(KeyComboAction("<Shift><Control>Right"))
+sequence.append(KeyComboAction("<Shift><Control>Right"))
+sequence.append(utils.AssertPresentationAction(
+    "5. Press Up and select two words'",
+    ["BRAILLE LINE:  'Label This is a test. $l'",
+     "     VISIBLE:  'Label This is a test. $l', cursor=11",
+     "BRAILLE LINE:  'Label This is a test. $l'",
+     "     VISIBLE:  'Label This is a test. $l', cursor=14",
+     "SPEECH OUTPUT: 'This'",
+     "SPEECH OUTPUT: 'selected' voice=system",
+     "SPEECH OUTPUT: ' is'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>c"))
+sequence.append(utils.AssertPresentationAction(
+    "6. Copy the selection",
+    ["BRAILLE LINE:  'Copied selection to clipboard.'",
+     "     VISIBLE:  'Copied selection to clipboard.', cursor=0",
+     "SPEECH OUTPUT: 'Copied selection to clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>End"))
+sequence.append(utils.AssertPresentationAction(
+    "7. End of file",
+    ["BRAILLE LINE:  'Label This is a test. $l'",
+     "     VISIBLE:  'Label This is a test. $l', cursor=14",
+     "SPEECH OUTPUT: 'This is'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Return"))
+sequence.append(utils.AssertPresentationAction(
+    "8. Newline",
+    ["BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>v"))
+sequence.append(utils.AssertPresentationAction(
+    "9. Paste",
+    ["BRAILLE LINE:  'Label This is $l'",
+     "     VISIBLE:  'Label This is $l', cursor=14",
+     "BRAILLE LINE:  'Pasted contents from clipboard.'",
+     "     VISIBLE:  'Pasted contents from clipboard.', cursor=0",
+     "BRAILLE LINE:  'Label This is $l'",
+     "     VISIBLE:  'Label This is $l', cursor=14",
+     "SPEECH OUTPUT: 'Pasted contents from clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "10. Select Up",
+    ["BRAILLE LINE:  'Label  $l'",
+     "     VISIBLE:  'Label  $l', cursor=7",
+     "SPEECH OUTPUT: '",
+     "This is'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "11. Select Up",
+    ["BRAILLE LINE:  'Label So is this line. $l'",
+     "     VISIBLE:  'Label So is this line. $l', cursor=14",
+     "SPEECH OUTPUT: 'his line.",
+     "'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>x"))
+sequence.append(utils.AssertPresentationAction(
+    "12. Cut the selection",
+    ["BRAILLE LINE:  'Label So is t $l'",
+     "     VISIBLE:  'Label So is t $l', cursor=14",
+     "BRAILLE LINE:  'Cut selection to clipboard.'",
+     "     VISIBLE:  'Cut selection to clipboard.', cursor=0",
+     "SPEECH OUTPUT: 'Cut selection to clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>x"))
+sequence.append(utils.AssertPresentationAction(
+    "13. Cut with nothing selected",
+    ["BRAILLE LINE:  'Label So is t $l'",
+     "     VISIBLE:  'Label So is t $l', cursor=14"]))
+
+sequence.append(utils.AssertionSummaryAction())
+sequence.start()
diff --git a/test/keystrokes/firefox/selection_wiki.params b/test/keystrokes/firefox/selection_wiki.params
new file mode 100644
index 0000000..0dafc37
--- /dev/null
+++ b/test/keystrokes/firefox/selection_wiki.params
@@ -0,0 +1 @@
+PARAMS=$TEST_DIR/../../html/orca-wiki.html
diff --git a/test/keystrokes/firefox/selection_wiki.py b/test/keystrokes/firefox/selection_wiki.py
new file mode 100644
index 0000000..149009d
--- /dev/null
+++ b/test/keystrokes/firefox/selection_wiki.py
@@ -0,0 +1,354 @@
+#!/usr/bin/python
+
+from macaroon.playback import *
+import utils
+
+sequence = MacroSequence()
+
+#sequence.append(WaitForDocLoad())
+sequence.append(PauseAction(5000))
+
+# Work around some new quirk in Gecko that causes this test to fail if
+# run via the test harness rather than manually.
+sequence.append(KeyComboAction("<Control>r"))
+
+sequence.append(KeyComboAction("<Control>Home"))
+sequence.append(KeyComboAction("h"))
+sequence.append(KeyComboAction("h"))
+sequence.append(KeyComboAction("h"))
+sequence.append(PauseAction(5000))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "1. Shift Down",
+    ["BRAILLE LINE:  'About h1'",
+     "     VISIBLE:  'About h1', cursor=1",
+     "SPEECH OUTPUT: 'About'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "2. Shift Down",
+    ["BRAILLE LINE:  'Orca is a free, open source, flexible, extensible, and'",
+     "     VISIBLE:  'rce, flexible, extensible, and', cursor=32",
+     "SPEECH OUTPUT: 'Orca is a free, open source, flexible, extensible, and '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "3. Shift Down",
+    ["BRAILLE LINE:  'powerful assistive technology for people with visual'",
+     "     VISIBLE:  'hnology for people with visual', cursor=32",
+     "SPEECH OUTPUT: 'powerful assistive technology for people with visual '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "4. Shift Down",
+    ["BRAILLE LINE:  'impairments. Using various combinations of speech'",
+     "     VISIBLE:  'various combinations of speech', cursor=32",
+     "SPEECH OUTPUT: 'impairments. Using various combinations of speech '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "5. Shift Down",
+    ["BRAILLE LINE:  'synthesis, braille, and magnification, Orca helps provide'",
+     "     VISIBLE:  'nification, Orca helps provide', cursor=32",
+     "SPEECH OUTPUT: 'synthesis, braille, and magnification, Orca helps provide '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "6. Shift Down",
+    ["BRAILLE LINE:  'access to applications and toolkits that support the AT-SPI'",
+     "     VISIBLE:  'olkits that support the AT-SPI', cursor=32",
+     "SPEECH OUTPUT: 'access to applications and toolkits that support the AT-SPI '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "7. Shift Down",
+    ["BRAILLE LINE:  '(e.g., the GNOME desktop). The development of Orca has'",
+     "     VISIBLE:  '). The development of Orca has', cursor=32",
+     "SPEECH OUTPUT: '(e.g., the GNOME desktop). The development of Orca has '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "8. Shift Down",
+    ["BRAILLE LINE:  'been led by the Accessibility Program Office of Sun'",
+     "     VISIBLE:  'been led by the Accessibility Pr', cursor=17",
+     "SPEECH OUTPUT: 'been led by the '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "9. Shift Down",
+    ["BRAILLE LINE:  'Microsystems, Inc. with contributions from many'",
+     "     VISIBLE:  'Microsystems, Inc. with contribu', cursor=1",
+     "SPEECH OUTPUT: 'Accessibility Program Office of Sun Microsystems, Inc.  with'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "10. Shift Down",
+    ["BRAILLE LINE:  'community members.'",
+     "     VISIBLE:  'community members.', cursor=1",
+     "SPEECH OUTPUT: 'contributions from many community members .'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "11. Shift Down",
+    ["BRAILLE LINE:  'The complete list of work to do, including bugs and feature requests, along with 
known'",
+     "     VISIBLE:  'ure requests, along with known', cursor=32",
+     "SPEECH OUTPUT: 'The complete list of work to do, including bugs and feature requests, along with known 
'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "12. Shift Down",
+    ["BRAILLE LINE:  'problems in other components, is maintained in Bugzilla \(please see our notes on how 
we'",
+     "     VISIBLE:  'r components, is maintained in B', cursor=32",
+     "SPEECH OUTPUT: 'problems in other components, is maintained in Bugzilla  \(please see our'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "13. Shift Down",
+    ["BRAILLE LINE:  'use Bugzilla).'",
+     "     VISIBLE:  'use Bugzilla).', cursor=1",
+     "SPEECH OUTPUT: 'notes on how we use Bugzilla ).'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "14. Shift Down",
+    ["BRAILLE LINE:  'Please join and participate on the Orca mailing list (archives): it's a helpful, kind, 
and'",
+     "     VISIBLE:  'se join and participate on the O', cursor=32",
+     "SPEECH OUTPUT: 'Please join and participate on the Orca mailing list  (archives ): it's a helpful, 
kind, and'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "15. Shift Down",
+    ["BRAILLE LINE:  'productive environment composed of users and developers.'",
+     "     VISIBLE:  'productive environment composed ', cursor=0",
+     "SPEECH OUTPUT: 'productive environment composed of users and developers. '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "16. Shift Down",
+    ["BRAILLE LINE:  'Audio Guides h1'",
+     "     VISIBLE:  'Audio Guides h1', cursor=1",
+     "SPEECH OUTPUT: 'Audio Guides'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "17. Shift Down",
+    ["BRAILLE LINE:  'Darragh Ó Héiligh has created several audio guides for Orca. This is a fantastic'",
+     "     VISIBLE:  'Darragh Ó Héiligh has created se', cursor=1",
+     "SPEECH OUTPUT: 'Darragh Ó Héiligh  has created several audio guides for Orca. This is a fantastic'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "18. Shift Down",
+    ["BRAILLE LINE:  'contribution (THANKS!)!!! The audio guides can be found at 
http://www.digitaldarragh.com'",
+     "     VISIBLE:  'e audio guides can be found at h', cursor=32",
+     "SPEECH OUTPUT: 'contribution (THANKS!)!!! The audio guides can be found at '",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "19. Shift Down",
+    ["BRAILLE LINE:  '/linuxat.asp and include the following:'",
+     "     VISIBLE:  '/linuxat.asp and include the fol', cursor=1",
+     "SPEECH OUTPUT: 'http://www.digitaldarragh.com/linuxat.asp  and include the following:'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "20. Shift Down",
+    ["BRAILLE LINE:  '• Walk through of the installation of Ubuntu 7.4. Very helpful tutorial'",
+     "     VISIBLE:  'Walk through of the installation', cursor=1",
+     "SPEECH OUTPUT: 'Walk through of the installation of Ubuntu 7.4. Very helpful tutorial'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "21. Shift Down",
+    ["BRAILLE LINE:  '• Review of Fedora 7 and the Orca screen reader for the Gnome graphical desktop'",
+     "     VISIBLE:  'Review of Fedora 7 and the Orca ', cursor=1",
+     "SPEECH OUTPUT: 'Review of Fedora 7 and the Orca screen reader for the Gnome graphical desktop'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "22. Shift Down",
+    ["BRAILLE LINE:  '• Guide to installing the latest versions of Firefox and Orca'",
+     "     VISIBLE:  'Guide to installing the latest v', cursor=1",
+     "SPEECH OUTPUT: 'Guide to installing the latest versions of Firefox and Orca'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "23. Shift Down",
+    ["BRAILLE LINE:  'Download/Installation h1'",
+     "     VISIBLE:  'Download/Installation h1', cursor=1",
+     "SPEECH OUTPUT: 'Download/Installation'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "24. Shift Down",
+    ["BRAILLE LINE:  'As of GNOME 2.16, Orca is a part of the GNOME platform. As a result, Orca is already'",
+     "     VISIBLE:  '. As a result, Orca is already', cursor=32",
+     "SPEECH OUTPUT: 'As of GNOME 2.16, Orca is a part of the GNOME platform. As a result, Orca is already 
'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Down"))
+sequence.append(utils.AssertPresentationAction(
+    "25. Shift Down",
+    ["BRAILLE LINE:  'provided by default on a number of operating system distributions, including Open 
Solaris'",
+     "     VISIBLE:  'ystem distributions, including O', cursor=32",
+     "SPEECH OUTPUT: 'provided by default on a number of operating system distributions, including Open 
Solaris'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "26. Shift Up",
+    ["BRAILLE LINE:  'and Ubuntu.'",
+     "     VISIBLE:  'and Ubuntu.', cursor=0",
+     "SPEECH OUTPUT: 'provided by default on a number of operating system distributions, including Open 
Solaris'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "27. Shift Up",
+    ["BRAILLE LINE:  'provided by default on a number of operating system distributions, including Open 
Solaris'",
+     "     VISIBLE:  'provided by default on a number ', cursor=0",
+     "SPEECH OUTPUT: 'As of GNOME 2.16, Orca is a part of the GNOME platform. As a result, Orca is already 
'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "28. Shift Up",
+    ["BRAILLE LINE:  'As of GNOME 2.16, Orca is a part of the GNOME platform. As a result, Orca is already'",
+     "     VISIBLE:  'As of GNOME 2.16, Orca is a part', cursor=1",
+     "SPEECH OUTPUT: 'Download/Installation'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "29. Shift Up",
+    ["BRAILLE LINE:  'Download/Installation h1'",
+     "     VISIBLE:  'Download/Installation h1', cursor=1",
+     "SPEECH OUTPUT: 'Guide to installing the latest versions of Firefox and Orca'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "30. Shift Up",
+    ["BRAILLE LINE:  '• Guide to installing the latest versions of Firefox and Orca'",
+     "     VISIBLE:  'Guide to installing the latest v', cursor=1",
+     "SPEECH OUTPUT: 'Review of Fedora 7 and the Orca screen reader for the Gnome graphical desktop'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "31. Shift Up",
+    ["BRAILLE LINE:  '• Review of Fedora 7 and the Orca screen reader for the Gnome graphical desktop'",
+     "     VISIBLE:  'Review of Fedora 7 and the Orca ', cursor=1",
+     "SPEECH OUTPUT: 'Walk through of the installation of Ubuntu 7.4. Very helpful tutorial'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "32. Shift Up",
+    ["BRAILLE LINE:  '• Walk through of the installation of Ubuntu 7.4. Very helpful tutorial'",
+     "     VISIBLE:  'Walk through of the installation', cursor=1",
+     "SPEECH OUTPUT: 'http://www.digitaldarragh.com/linuxat.asp  and include the following:'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "33. Shift Up",
+    ["BRAILLE LINE:  '/linuxat.asp and include the following:'",
+     "     VISIBLE:  '/linuxat.asp and include the fol', cursor=1",
+     "SPEECH OUTPUT: 'contribution (THANKS!)!!! The audio guides can be found at '",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "34. Shift Up",
+    ["BRAILLE LINE:  'contribution (THANKS!)!!! The audio guides can be found at 
http://www.digitaldarragh.com'",
+     "     VISIBLE:  'contribution (THANKS!)!!! The au', cursor=0",
+     "SPEECH OUTPUT: 'Darragh Ó Héiligh  has created several audio guides for Orca. This is a fantastic'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "35. Shift Up",
+    ["BRAILLE LINE:  'Darragh Ó Héiligh has created several audio guides for Orca. This is a fantastic'",
+     "     VISIBLE:  'Darragh Ó Héiligh has created se', cursor=1",
+     "SPEECH OUTPUT: 'Audio Guides'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "36. Shift Up",
+    ["BRAILLE LINE:  'Audio Guides h1'",
+     "     VISIBLE:  'Audio Guides h1', cursor=1",
+     "SPEECH OUTPUT: 'productive environment composed of users and developers. '",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>c"))
+sequence.append(utils.AssertPresentationAction(
+    "37. Control c",
+    ["BRAILLE LINE:  'Copied selection to clipboard.'",
+     "     VISIBLE:  'Copied selection to clipboard.', cursor=0",
+     "SPEECH OUTPUT: 'Copied selection to clipboard.' voice=system"]))
+
+sequence.append(utils.AssertionSummaryAction())
+sequence.start()
diff --git a/test/keystrokes/gtk3-demo/role_text_multiline_selection.py 
b/test/keystrokes/gtk3-demo/role_text_multiline_selection.py
new file mode 100644
index 0000000..c2c4938
--- /dev/null
+++ b/test/keystrokes/gtk3-demo/role_text_multiline_selection.py
@@ -0,0 +1,135 @@
+#!/usr/bin/python
+
+"""Test of multiline editable text."""
+
+from macaroon.playback import *
+import utils
+
+sequence = MacroSequence()
+
+sequence.append(KeyComboAction("End"))
+sequence.append(KeyComboAction("Up"))
+sequence.append(KeyComboAction("Up"))
+sequence.append(KeyComboAction("<Shift><Control>Right"))
+sequence.append(KeyComboAction("Down"))
+sequence.append(KeyComboAction("Return"))
+sequence.append(PauseAction(3000))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>a"))
+sequence.append(utils.AssertPresentationAction(
+    "1. Select all'",
+    ["BRAILLE LINE:  'gtk3-demo application Hypertext frame Some text to show that simple hypertext can 
easily be realized  $l'",
+     "     VISIBLE:  'Some text to show that simple hy', cursor=1",
+     "SPEECH OUTPUT: 'entire document selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Delete"))
+sequence.append(utils.AssertPresentationAction(
+    "2. Delete the selection",
+    ["BRAILLE LINE:  'gtk3-demo application Hypertext frame  $l'",
+     "     VISIBLE:  ' $l', cursor=1",
+     "BRAILLE LINE:  'Selection deleted.'",
+     "     VISIBLE:  'Selection deleted.', cursor=0",
+     "SPEECH OUTPUT: 'Selection deleted.' voice=system"]))
+
+sequence.append(TypeAction("This is a test."))
+sequence.append(KeyComboAction("Return"))
+sequence.append(TypeAction("This is another test."))
+sequence.append(KeyComboAction("Return"))
+sequence.append(KeyComboAction("<Control>Home"))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift><Control>Right"))
+sequence.append(KeyComboAction("<Shift><Control>Right"))
+sequence.append(utils.AssertPresentationAction(
+    "3. Select two words'",
+    ["BRAILLE LINE:  'gtk3-demo application Hypertext frame This is a test. $l'",
+     "     VISIBLE:  'This is a test. $l', cursor=5",
+     "BRAILLE LINE:  'gtk3-demo application Hypertext frame This is a test. $l'",
+     "     VISIBLE:  'This is a test. $l', cursor=8",
+     "SPEECH OUTPUT: 'This'",
+     "SPEECH OUTPUT: 'selected' voice=system",
+     "SPEECH OUTPUT: ' is'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>c"))
+sequence.append(utils.AssertPresentationAction(
+    "4. Copy the selection",
+    ["BRAILLE LINE:  'Copied selection to clipboard.'",
+     "     VISIBLE:  'Copied selection to clipboard.', cursor=0",
+     "SPEECH OUTPUT: 'Copied selection to clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>End"))
+sequence.append(utils.AssertPresentationAction(
+    "5. End of file",
+    ["BRAILLE LINE:  'gtk3-demo application Hypertext frame This is a test. $l'",
+     "     VISIBLE:  'This is a test. $l', cursor=8",
+     "BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1",
+     "SPEECH OUTPUT: 'This is'",
+     "SPEECH OUTPUT: 'unselected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("Return"))
+sequence.append(utils.AssertPresentationAction(
+    "6. Newline",
+    ["BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1",
+     "BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>v"))
+sequence.append(utils.AssertPresentationAction(
+    "7. Paste",
+    ["BRAILLE LINE:  'This is $l'",
+     "     VISIBLE:  'This is $l', cursor=8",
+     "BRAILLE LINE:  'Pasted contents from clipboard.'",
+     "     VISIBLE:  'Pasted contents from clipboard.', cursor=0",
+     "BRAILLE LINE:  'This is $l'",
+     "     VISIBLE:  'This is $l', cursor=8",
+     "SPEECH OUTPUT: 'Pasted contents from clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "8. Select Up",
+    ["BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1",
+     "SPEECH OUTPUT: '",
+     "This is'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Shift>Up"))
+sequence.append(utils.AssertPresentationAction(
+    "9. Select Up",
+    ["BRAILLE LINE:  'This is another test. $l'",
+     "     VISIBLE:  'This is another test. $l', cursor=1",
+     "SPEECH OUTPUT: 'This is another test.",
+     "'",
+     "SPEECH OUTPUT: 'selected' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>x"))
+sequence.append(utils.AssertPresentationAction(
+    "10. Cut the selection",
+    ["BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1",
+     "BRAILLE LINE:  'Cut selection to clipboard.'",
+     "     VISIBLE:  'Cut selection to clipboard.', cursor=0",
+     "SPEECH OUTPUT: 'Cut selection to clipboard.' voice=system"]))
+
+sequence.append(utils.StartRecordingAction())
+sequence.append(KeyComboAction("<Control>x"))
+sequence.append(utils.AssertPresentationAction(
+    "11. Cut with nothing selected",
+    ["BRAILLE LINE:  ' $l'",
+     "     VISIBLE:  ' $l', cursor=1"]))
+
+sequence.append(KeyComboAction("<Alt>F4"))
+sequence.append(utils.AssertionSummaryAction())
+sequence.start()


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