[orca] Fix issues in Mouse Review



commit c1b2a06828ce28ff06204b478e0fe99a4a6260c1
Author: Joanmarie Diggs <jdiggs igalia com>
Date:   Mon Aug 29 19:46:31 2016 -0400

    Fix issues in Mouse Review
    
    * Dig deeper to find object under pointer for page tab list descendants
    * Fix issue causing Orca to say "blank" in text with embedded objects
    * Add word support to all text objects; not just editable text objects
    * Work around Gtk+ 3 exposing incorrect text range extents for entries
    * Add logic to handle windows whose accessible name doesn't match the
      displayed name
    * Add an announcement so that when the user toggles Mouse Review some
      confirmation is provided
    
    Also create some utility methods which can be shared between Mouse Review
    and Flat Review.
    
    Note: There are still known issues with Mouse Review, in particular
    presentation of window manager objects. This is a work in progress.

 src/orca/flat_review.py                            |   17 +-
 src/orca/messages.py                               |   10 +
 src/orca/mouse_review.py                           |  510 ++++++++++----------
 src/orca/orca.py                                   |   13 -
 src/orca/script_utilities.py                       |  266 +++++------
 src/orca/scripts/apps/soffice/script.py            |    6 +-
 src/orca/scripts/default.py                        |   32 +-
 .../scripts/toolkits/Gecko/script_utilities.py     |   12 +
 src/orca/scripts/toolkits/gtk/script_utilities.py  |   45 ++
 src/orca/scripts/web/script.py                     |    3 +-
 src/orca/scripts/web/script_utilities.py           |   28 ++
 src/orca/settings.py                               |    2 -
 12 files changed, 479 insertions(+), 465 deletions(-)
---
diff --git a/src/orca/flat_review.py b/src/orca/flat_review.py
index 04558c7..3f779c7 100644
--- a/src/orca/flat_review.py
+++ b/src/orca/flat_review.py
@@ -703,18 +703,11 @@ class Context:
                 substringEndOffset   = substringStartOffset
                 unicodeStartOffset   = i + 1
             else:
-                [x, y, width, height] = text.getRangeExtents(
-                        substringStartOffset, substringEndOffset, 0)
-                if self.script.utilities.containsRegion(
-                        x, y, width, height,
-                        cliprect.x, cliprect.y,
-                        cliprect.width, cliprect.height):
-
+                extents = text.getRangeExtents(
+                    substringStartOffset, substringEndOffset, 0)
+                if self.script.utilities.containsRegion(extents, cliprect):
                     anyVisible = True
-
-                    clipping = self.clip(x, y, width, height,
-                                         cliprect.x, cliprect.y,
-                                         cliprect.width, cliprect.height)
+                    clipping = self.clip(*extents, *cliprect)
 
                     # [[[TODO: WDW - HACK it would be nice to clip the
                     # the text by what is really showing on the screen,
@@ -972,7 +965,7 @@ class Context:
         except:
             return []
 
-        if not self.script.utilities.containsRegion(*extents, *cliprect):
+        if not self.script.utilities.containsRegion(extents, cliprect):
             return []
 
         try:
diff --git a/src/orca/messages.py b/src/orca/messages.py
index b375c7b..00fbb4b 100644
--- a/src/orca/messages.py
+++ b/src/orca/messages.py
@@ -1333,6 +1333,16 @@ MODE_BROWSE_IS_STICKY = _("Browse mode is sticky.")
 # mouse. If this command fails, Orca will present this message.
 MOUSE_OVER_NOT_FOUND = _("Mouse over object not found.")
 
+# Translators: Orca has a feature to speak the item under the pointer. This feaure,
+# known as mouse review, can be enabled and disabled via command. The following is
+# the message which Orca will present when mouse review is toggled off via command.
+MOUSE_REVIEW_DISABLED = _("Mouse review disabled.")
+
+# Translators: Orca has a feature to speak the item under the pointer. This feaure,
+# known as mouse review, can be enabled and disabled via command. The following is
+# the message which Orca will present when mouse review is toggled on via command.
+MOUSE_REVIEW_ENABLED = _("Mouse review enabled.")
+
 # Translators: Orca has a command that presents a list of structural navigation
 # objects in a dialog box so that users can navigate more quickly than they
 # could with native keyboard navigation. This is a message that will be
diff --git a/src/orca/mouse_review.py b/src/orca/mouse_review.py
index f99165d..45b0bdf 100644
--- a/src/orca/mouse_review.py
+++ b/src/orca/mouse_review.py
@@ -1,6 +1,7 @@
 # Mouse reviewer for Orca
 #
 # Copyright 2008 Eitan Isaacson
+# Copyright 2016 Igalia, S.L.
 #
 # This library is free software; you can redistribute it and/or
 # modify it under the terms of the GNU Lesser General Public
@@ -22,332 +23,317 @@
 __id__        = "$Id$"
 __version__   = "$Revision$"
 __date__      = "$Date$"
-__copyright__ = "Copyright (c) 2008 Eitan Isaacson"
+__copyright__ = "Copyright (c) 2008 Eitan Isaacson" \
+                "Copyright (c) 2016 Igalia, S.L."
 __license__   = "LGPL"
 
 import gi
+import math
+import pyatspi
+import time
 
-from . import debug
-
+from gi.repository import Gdk
 try:
     gi.require_version("Wnck", "3.0")
     from gi.repository import Wnck
     _mouseReviewCapable = True
 except:
-    debug.println(debug.LEVEL_WARNING, \
-                  "Python module wnck not found, mouse review not available.")
     _mouseReviewCapable = False
 
-import pyatspi
-from gi.repository import Gdk
-from gi.repository import GLib
-
+from . import debug
 from . import event_manager
+from . import messages
+from . import orca_state
 from . import script_manager
+from . import settings_manager
 from . import speech
-from . import braille
-from . import settings
 
 _eventManager = event_manager.getManager()
 _scriptManager = script_manager.getManager()
+_settingsManager = settings_manager.getManager()
 
-class BoundingBox:
-    """A bounding box, currently it is used to test if a given point is
-    inside the bounds of the box.
-    """
+class _StringContext:
+    """The textual information associated with an _ItemContext."""
 
-    def __init__(self, x, y, width, height):
-        """Initialize a bounding box.
+    def __init__(self, obj, script=None, string="", start=0, end=0):
+        """Initialize the _StringContext.
 
         Arguments:
-        - x: Left border of box.
-        - y: Top border of box.
-        - width: Width of box.
-        - height: Height of box.
+        - string: The human-consumable string
+        - obj: The accessible object associated with this string
+        - start: The start offset with respect to entire text, if one exists
+        - end: The end offset with respect to the entire text, if one exists
+        - script: The script associated with the accessible object
         """
-        self.x, self.y, self.width, self.height = x, y, width, height
 
-    def isInBox(self, x, y):
-        """Test if a given point is inside a box.
+        self._obj = hash(obj)
+        self._script = script
+        self._string = string
+        self._start = start
+        self._end = end
 
-        Arguments:
-        - x: X coordinate.
-        - y: Y coordinate.
+    def __eq__(self, other):
+        return other is not None \
+            and self._obj == other._obj \
+            and self._string == other._string \
+            and self._start == other._start \
+            and self._end == other._end
 
-        Returns True if point is inside box.
-        """
-        return (self.x <= x <= self.x + self.width) and \
-            (self.y <= y <= self.y + self.height)
+    def present(self):
+        """Presents this context to the user."""
 
-class _WordContext:
-    """A word on which the mouse id hovering above. This class should have
-    enough info to make it unique, so we know when we have left the word.
-    """
-    def __init__(self, word, acc, start, end):
-        """Initialize a word context.
+        if not (self._script and self._string):
+            return False
+
+        voice = self._script.speechGenerator.voice(string=self._string)
+        string = self._script.utilities.adjustForRepeats(self._string)
+        self._script.speakMessage(string, voice=voice, interrupt=False)
+        self._script.displayBrailleMessage(self._string, -1)
+        return True
 
-        Arguments:
-        - word: The string of the word we are on.
-        - acc: The accessible object that contains the word.
-        - start: The start offset of the word in the text.
-        - end: The end offset of the word in the text.
-        """
-        self.word = word
-        self.acc = acc
-        self.start = start
-        self.end = end
-
-    def __cmp__(self, other):
-        """Compare two word contexts, if they refer to the same word, return 0.
-        Otherwise return 1
-        """
-        if other is None:
-            return 1
-        return int(not(self.word == other.word and self.acc == other.acc and
-                       self.start == other.start and self.end == other.end))
 
 class _ItemContext:
-    """An _ItemContext holds all the information of the item we are currently
-    hovering above. If the accessible supports word speaking, we also store
-    a word context here.
-    """
-    def __init__(self, x=0, y=0, acc=None, frame=None, app=None, script=None):
-        """Initialize an _ItemContext with all the information we have.
+    """Holds all the information of the item at a specified point."""
+
+    def __init__(self, x=0, y=0, obj=None, frame=None, script=None):
+        """Initialize the _ItemContext.
 
         Arguments:
-        - x: The X coordinate of the pointer.
-        - y: The Y coordinate of the pointer.
-        - acc: The end-node accessible at that coordinate.
-        - frame: The top-level frame below the pointer.
-        - app: The application the pointer is hovering above.
-        - script: The script for the context's application.
+        - x: The X coordinate
+        - y: The Y coordinate
+        - obj: The accessible object of interest at that coordinate
+        - frame: The containing accessible object (often a top-level window)
+        - script: The script associated with the accessible object
         """
-        self.acc = acc
-        self.frame = frame
-        self.app = app
-        self.script = script
-        self.word_ctx = self._getWordContext(x, y)
 
-    def _getWordContext(self, x, y):
-        """If the context's accessible supports it, retrieve the word we are
-        currently hovering above.
+        self._x = x
+        self._y = y
+        self._obj = obj
+        self._frame = frame
+        self._script = script
+        self._string = self._getStringContext()
 
-        Arguments:
-        - x: The X coordinate of the pointer.
-        - y: The Y coordinate of the pointer.
+    def __eq__(self, other):
+        return other is not None \
+            and self._frame == other._frame \
+            and self._obj == other._obj \
+            and self._string == other._string
+
+    def _getStringContext(self):
+        """Returns the _StringContext associated with the specified point."""
+
+        if not (self._script and self._obj):
+            return _StringContext(self._obj)
+
+        interfaces = pyatspi.listInterfaces(self._obj)
+        if "Text" not in interfaces:
+            return _StringContext(self._obj, self._script)
+
+        state = self._obj.getState()
+        if not state.contains(pyatspi.STATE_SELECTABLE):
+            boundary = pyatspi.TEXT_BOUNDARY_WORD_START
+        else:
+            boundary = pyatspi.TEXT_BOUNDARY_LINE_START
+
+        string, start, end = self._script.utilities.textAtPoint(
+            self._obj, self._x, self._y, boundary=boundary)
+        if not string and self._script.utilities.isTextArea(self._obj):
+            string = self._script.speechGenerator.getRoleName(self._obj)
+
+        return _StringContext(self._obj, self._script, string, start, end)
+
+    def present(self, prior):
+        """Presents this context to the user."""
+
+        if self == prior:
+            return False
+
+        interrupt = self._obj and self._obj != prior._obj \
+            or math.sqrt((self._x - prior._x)**2 + (self._y - prior._y)**2) > 25
+
+        if interrupt:
+            self._script.presentationInterrupt()
+
+        if self._frame and self._frame != prior._frame:
+            self._script.presentObject(self._frame, alreadyFocused=True)
+
+        if self._string != prior._string and self._string.present():
+            return True
+
+        if self._obj and self._obj != prior._obj:
+            self._script.presentObject(self._obj)
+
+        return True
 
-        Returns a _WordContext of the current word, or None.
-        """
-        if not self.script or not self.script.speakWordUnderMouse(self.acc):
-            return None
-        word, start, end = self.script.utilities.wordAtCoords(self.acc, x, y)
-        return _WordContext(word, self.acc, start, end)
 
 class MouseReviewer:
-    """Main class for the mouse-review feature.
-    """
+    """Main class for the mouse-review feature."""
+
     def __init__(self):
-        """Initalize a mouse reviewer class.
-        """
+        self._active = _settingsManager.getSetting("enableMouseReview")
+        self._currentMouseOver = _ItemContext()
+        self._pointer = None
+        self._windows = []
+        self._handlerIds = {}
+
         if not _mouseReviewCapable:
+            msg = "MOUSE REVIEW ERROR: Wnck is not available"
+            debug.println(debug.LEVEL_INFO, msg, True)
             return
 
-        # Need to do this and allow the main loop to cycle once to get any info
-        # IMPORTANT: This causes orca to segfault upon launch in Wayland.
-        # wnck_screen = Wnck.Screen.get_default()
-        self.active = False
-        self._currentMouseOver = _ItemContext()
-        self._oldMouseOver = _ItemContext()
-        self._lastReportedCoord = None
+        if not self._active:
+            return
 
-    def toggle(self, on=None):
-        """Toggle mouse reviewing on or off.
+        self.activate()
+
+    def _get_listeners(self):
+        """Returns the accessible-event listeners for mouse review."""
+
+        return {"mouse:abs": self._listener}
+
+    def activate(self):
+        """Activates mouse review."""
+
+        display = Gdk.Display.get_default()
+        seat = Gdk.Display.get_default_seat(display)
+        self._pointer = seat.get_pointer()
+
+        _eventManager.registerModuleListeners(self._get_listeners())
+        screen = Wnck.Screen.get_default()
+        i = screen.connect("window-stacking-changed", self._on_stacking_changed)
+        self._handlerIds[i] = screen
+
+    def deactivate(self):
+        """Deactivates mouse review."""
+
+        _eventManager.deregisterModuleListeners(self._get_listeners())
+        for key, value in self._handlerIds.items():
+            value.disconnect(key)
+        self._handlerIds = {}
+
+    def toggle(self, script=None, event=None):
+        """Toggle mouse reviewing on or off."""
 
-        Arguments:
-        - on: If set to True or False, explicitly toggles reviewing on or off.
-        """
         if not _mouseReviewCapable:
             return
 
-        if on is None:
-            on = not self.active
-        if on and not self.active:
-            _eventManager.registerModuleListeners(
-                {"mouse:abs":self._onMouseMoved})
-        elif not on and self.active:
-            _eventManager.deregisterModuleListeners(
-                {"mouse:abs":self._onMouseMoved})
-        self.active = on
-
-    def _onMouseMoved(self, event):
-        """Callback for "mouse:abs" AT-SPI event. We will check after the dwell
-        delay if the mouse moved away, if it didn't we will review the
-        component under it.
+        self._active = not self._active
+        _settingsManager.setSetting("enableMouseReview", self._active)
 
-        Arguments:
-        - event: The event we recieved.
-        """
-        if settings.mouseDwellDelay:
-            GLib.timeout_add(settings.mouseDwellDelay,
-                             self._mouseDwellTimeout,
-                             event.detail1,
-                             event.detail2)
+        if not self._active:
+            self.deactivate()
+            msg = messages.MOUSE_REVIEW_DISABLED
         else:
-            self._mouseDwellTimeout(event.detail1, event.detail2)
+            self.activate()
+            msg = messages.MOUSE_REVIEW_ENABLED
 
-    def _mouseDwellTimeout(self, prev_x, prev_y):
-        """Dwell timout callback. If we are still dwelling, review the
-        component.
+        if orca_state.activeScript:
+            orca_state.activeScript.presentMessage(msg)
 
-        Arguments:
-        - prev_x: Previous X coordinate of mouse pointer.
-        - prev_y: Previous Y coordinate of mouse pointer.
-        """
-        display = Gdk.Display.get_default()
-        screen, x, y, flags =  display.get_pointer()
-        if abs(prev_x - x) <= settings.mouseDwellMaxDrift \
-           and abs(prev_y - y) <= settings.mouseDwellMaxDrift \
-           and not (x, y) == self._lastReportedCoord:
-            self._lastReportedCoord = (x, y)
-            self._reportUnderMouse(x, y)
-        return False
+    def _on_stacking_changed(self, screen):
+        """Callback for Wnck's window-stacking-changed signal."""
 
-    def _reportUnderMouse(self, x, y):
-        """Report the element under the given coordinates:
+        stacked = screen.get_windows_stacked()
+        stacked.reverse()
+        self._windows = stacked
 
-        Arguments:
-        - x: X coordinate.
-        - y: Y coordinate.
-        """
-        current_element = self._getContextUnderMouse(x, y)
-        if not current_element:
-            return
+    def _contains_point(self, obj, x, y, coordType=None):
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
 
-        self._currentMouseOver, self._oldMouseOver = \
-            current_element, self._currentMouseOver
+        try:
+            return obj.queryComponent().contains(x, y, coordType)
+        except:
+            return False
 
-        output_obj = []
+    def _has_bounds(self, obj, bounds, coordType=None):
+        """Returns True if the bounding box of obj is bounds."""
 
-        if current_element.acc.getRole() in (pyatspi.ROLE_MENU_ITEM,
-                                             pyatspi.ROLE_COMBO_BOX) and \
-                current_element.acc.getState().contains(
-                    pyatspi.STATE_SELECTED):
-            # If it is selected, we are probably doing that by hovering over it
-            # Orca will report this in any case.
-            return
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
 
-        if self._currentMouseOver.frame != self._oldMouseOver.frame and \
-                settings.mouseDwellDelay == 0:
-            output_obj.append(self._currentMouseOver.frame)
+        try:
+            extents = obj.queryComponent().getExtents(coordType)
+        except:
+            return False
 
-        if self._currentMouseOver.acc != self._oldMouseOver.acc \
-                or (settings.mouseDwellDelay > 0 and \
-                        not self._currentMouseOver.word_ctx):
-            output_obj.append(self._currentMouseOver.acc)
+        return list(extents) == list(bounds)
 
-        if self._currentMouseOver.word_ctx:
-            if self._currentMouseOver.word_ctx != self._oldMouseOver.word_ctx:
-                output_obj.append(self._currentMouseOver.word_ctx.word)
+    def _accessible_window_at_point(self, pX, pY):
+        """Returns the accessible window at the specified coordinates."""
 
-        self._outputElements(output_obj)
-        return False
+        window = None
+        for w in self._windows:
+            x, y, width, height = w.get_geometry()
+            if x <= pX <= x + width and y <= pY <= y + height:
+                window = w
+                break
 
-    def _outputElements(self, output_obj):
-        """Output the given elements.
-        TODO: Now we are mainly using WhereAmI, we might need to find out a
-        better, less verbose output method.
+        if not window:
+            return None
 
-        Arguments:
-        - output_obj: A list of objects to output, could be accessibles and
-        text.
-        """
-        if output_obj:
-            speech.stop()
-        for obj in output_obj:
-            if obj is None:
-                continue
-            if isinstance(obj, str):
-                speech.speak(obj)
-                # TODO: There is probably something more useful that we could
-                # display.
-                braille.displayMessage(obj)
-            else:
-                speech.speak(
-                    self._currentMouseOver.script.speechGenerator.\
-                        generateSpeech(obj))
-                self._currentMouseOver.script.updateBraille(obj)
-
-    def _getZOrder(self, frame_name):
-        """Determine the stack position of a given window.
+        app = None
+        pid = window.get_application().get_pid()
+        for a in pyatspi.Registry.getDesktop(0):
+            if a.get_process_id() == pid:
+                app = a
+                break
 
-        Arguments:
-        - frame_name: The name of the window.
+        if not app:
+            return None
 
-        Returns position of given window in window-managers stack.
-        """
-        # This is neccesary because z-order is still broken in AT-SPI.
-        wnck_screen = Wnck.Screen.get_default()
-        window_order = \
-            [w.get_name() for w in wnck_screen.get_windows_stacked()]
-        return window_order.index(frame_name)
+        candidates = [o for o in app if self._contains_point(o, pX, pY)]
+        if len(candidates) == 1:
+            return candidates[0]
 
-    def _getContextUnderMouse(self, x, y):
-        """Get the context under the mouse.
+        name = window.get_name()
+        matches = [o for o in candidates if o.name == name]
+        if len(matches) == 1:
+            return matches[0]
 
-        Arguments:
-        - x: X coordinate.
-        - y: Y coordinate.
+        bbox = window.get_client_window_geometry()
+        matches = [o for o in candidates if self._has_bounds(o, bbox)]
+        if len(matches) == 1:
+            return matches[0]
+
+        return None
+
+    def _on_mouse_moved(self, event):
+        """Callback for mouse:abs events."""
+
+        screen, pX, pY = self._pointer.get_position()
+        window = self._accessible_window_at_point(pX, pY)
+        msg = "MOUSE REVIEW: Window at (%i, %i) is %s" % (pX, pY, window)
+        debug.println(debug.LEVEL_INFO, msg, True)
+        if not window:
+            return
+
+        script = orca_state.activeScript
+        if not script:
+            return
+
+        obj = script.utilities.descendantAtPoint(window, pX, pY)
+        msg = "MOUSE REVIEW: Object at (%i, %i) is %s" % (pX, pY, obj)
+        debug.println(debug.LEVEL_INFO, msg, True)
+
+        script = _scriptManager.getScript(window.getApplication(), obj)
+        new = _ItemContext(pX, pY, obj, window, script)
+        new.present(self._currentMouseOver)
+        self._currentMouseOver = new
+
+    def _listener(self, event):
+        """Generic listener, mainly to output debugging info."""
+
+        startTime = time.time()
+        msg = "\nvvvvv PROCESS OBJECT EVENT %s vvvvv" % event.type
+        debug.println(debug.LEVEL_INFO, msg, False)
+
+        if event.type.startswith("mouse:abs"):
+            self._on_mouse_moved(event)
+
+        msg = "TOTAL PROCESSING TIME: %.4f\n" % (time.time() - startTime)
+        msg += "^^^^^ PROCESS OBJECT EVENT %s ^^^^^\n" % event.type
+        debug.println(debug.LEVEL_INFO, msg, False)
 
-        Returns _ItemContext of the component under the mouse.
-        """
 
-        # Inspect accessible under mouse
-        desktop = pyatspi.Registry.getDesktop(0)
-        top_window = [None, -1]
-        for app in desktop:
-            if not app:
-                continue
-            script = _scriptManager.getScript(app)
-            if not script:
-                continue
-            for frame in app:
-                if not frame:
-                    continue
-                acc = script.utilities.componentAtDesktopCoords(frame, x, y)
-                if acc:
-                    try:
-                        z_order = self._getZOrder(frame.name)
-                    except ValueError:
-                        # It's possibly a popup menu, so it would not be in
-                        # our frame name list.
-                        # And if it is, it is probably the top-most
-                        # component.
-                        try:
-                            if acc.queryComponent().getLayer() == \
-                                    pyatspi.LAYER_POPUP:
-                                return _ItemContext(x, y, acc, frame,
-                                                    app, script)
-                        except:
-                            pass
-                    else:
-                        if z_order > top_window[-1]:
-                            top_window = \
-                                [_ItemContext(x, y, acc, frame, app, script),
-                                 z_order]
-        return top_window[0]
-
-# Initialize a singleton reviewer.
-if Gdk.Display.get_default():
-    mouse_reviewer = MouseReviewer()
-else:
-    raise RuntimeError('Cannot initialize mouse review, no display')
-
-def toggle(script=None, event=None):
-    """
-    Toggle the reviewer on or off.
-
-    Arguments:
-    - script: Given script if this was called as a keybinding callback.
-    - event: Given event if this was called as a keybinding callback.
-    """
-    mouse_reviewer.toggle()
+reviewer = MouseReviewer()
diff --git a/src/orca/orca.py b/src/orca/orca.py
index 58c0527..c2a2a30 100644
--- a/src/orca/orca.py
+++ b/src/orca/orca.py
@@ -85,12 +85,6 @@ _scriptManager = script_manager.getManager()
 _settingsManager = settings_manager.getManager()
 _logger = logger.getLogger()
 
-try:
-    # If we don't have an active desktop, we will get a RuntimeError.
-    from . import mouse_review
-except RuntimeError:
-    pass
-
 def onEnabledChanged(gsetting, key):
     try:
         enabled = gsetting.get_boolean(key)
@@ -386,13 +380,6 @@ def loadUserSettings(script=None, inputEvent=None, skipReloadMessage=False):
     if _settingsManager.getSetting('enableSound'):
         player.init()
 
-    # I'm not sure where else this should go. But it doesn't really look
-    # right here.
-    try:
-        mouse_review.mouse_reviewer.toggle(on=settings.enableMouseReview)
-    except NameError:
-        pass
-
     global _orcaModifiers
     custom = [k for k in settings.orcaModifierKeys if k not in _orcaModifiers]
     _orcaModifiers += custom
diff --git a/src/orca/script_utilities.py b/src/orca/script_utilities.py
index b269ec0..076ffd2 100644
--- a/src/orca/script_utilities.py
+++ b/src/orca/script_utilities.py
@@ -29,6 +29,7 @@ __copyright__ = "Copyright (c) 2010 Joanmarie Diggs."
 __license__   = "LGPL"
 
 import functools
+import gi
 import locale
 import math
 import pyatspi
@@ -45,7 +46,6 @@ from . import keybindings
 from . import input_event
 from . import mathsymbols
 from . import messages
-from . import mouse_review
 from . import orca
 from . import orca_state
 from . import object_properties
@@ -351,40 +351,6 @@ class Utilities:
         debug.println(debug.LEVEL_INFO, msg, True)
         return commonAncestor
 
-    def componentAtDesktopCoords(self, parent, x, y):
-        """Get the descendant component at the given desktop coordinates.
-
-        Arguments:
-
-        - parent: The parent component we are searching below.
-        - x: X coordinate.
-        - y: Y coordinate.
-
-        Returns end-node that contains the given coordinates, or None.
-        """
-        acc = self.popupItemAtDesktopCoords(x, y)
-        if acc:
-            return acc
-
-        container = parent
-        while True:
-            try:
-                ci = container.queryComponent()
-            except:
-                return None
-            else:
-                inner_container = container
-            container =  ci.getAccessibleAtPoint(x, y, pyatspi.DESKTOP_COORDS)
-            if not container or container.queryComponent() == ci:
-                break
-            if inner_container.getRole() == pyatspi.ROLE_PAGE_TAB_LIST:
-                return container
-
-        if inner_container == parent:
-            return None
-        else:
-            return inner_container
-
     def defaultButton(self, obj):
         """Returns the default button in the dialog which contains obj.
 
@@ -1582,38 +1548,6 @@ class Utilities:
         self._script.generatorCache[self.NODE_LEVEL][obj] = len(nodes) - 1
         return self._script.generatorCache[self.NODE_LEVEL][obj]
 
-    def popupItemAtDesktopCoords(self, x, y):
-        """Since pop-up items often don't contain nested components, we need
-        a way to efficiently determine if the cursor is over a menu item.
-
-        Arguments:
-        - x: X coordinate.
-        - y: Y coordinate.
-
-        Returns a menu item the mouse is over, or None.
-        """
-
-        suspect_children = []
-        if self._script.lastSelectedMenu:
-            try:
-                si = self._script.lastSelectedMenu.querySelection()
-            except NotImplementedError:
-                return None
-
-            if si.nSelectedChildren > 0:
-                suspect_children = [si.getSelectedChild(0)]
-            else:
-                suspect_children = self._script.lastSelectedMenu
-            for child in suspect_children:
-                try:
-                    ci = child.queryComponent()
-                except NotImplementedError:
-                    continue
-
-                if ci.contains(x, y, pyatspi.DESKTOP_COORDS) \
-                   and ci.getLayer() == pyatspi.LAYER_POPUP:
-                    return child
-
     def pursueForFlatReview(self, obj):
         """Determines if we should look any further at the object
         for flat review."""
@@ -1775,11 +1709,7 @@ class Utilities:
                     if stateset.contains(pyatspi.STATE_SHOWING) \
                        and (extents.x >= 0) and (extents.y >= 0) \
                        and (extents.width > 0) and (extents.height > 0) \
-                       and self.containsRegion(
-                            extents.x, extents.y,
-                            extents.width, extents.height,
-                            parentExtents.x, parentExtents.y,
-                            parentExtents.width, parentExtents.height):
+                       and self.containsRegion(extents, parentExtents):
                         descendants.append(header)
 
         # This algorithm goes left to right, top to bottom while attempting
@@ -2005,7 +1935,7 @@ class Utilities:
             return False
 
         try:
-            extents = obj.queryComponent().getExtents(0)
+            extents = obj.queryComponent().getExtents(pyatspi.DESKTOP_COORDS)
         except:
             msg = "ERROR: Exception getting extents for %s" % obj
             debug.println(debug.LEVEL_INFO, msg, True)
@@ -2649,60 +2579,6 @@ class Utilities:
 
         return False
 
-    def wordAtCoords(self, acc, x, y):
-        """Get the word at the given coords in the accessible.
-
-        Arguments:
-        - acc: Accessible that supports the Text interface.
-        - x: X coordinate.
-        - y: Y coordinate.
-
-        Returns a tuple containing the word, start offset, and end offset.
-        """
-
-        try:
-            ti = acc.queryText()
-        except NotImplementedError:
-            return '', 0, 0
-
-        text_contents = ti.getText(0, ti.characterCount)
-        line_offsets = []
-        start_offset = 0
-        while True:
-            try:
-                end_offset = text_contents.index('\n', start_offset)
-            except ValueError:
-                line_offsets.append((start_offset, len(text_contents)))
-                break
-            line_offsets.append((start_offset, end_offset))
-            start_offset = end_offset + 1
-        for start, end in line_offsets:
-            bx, by, bw, bh = \
-                ti.getRangeExtents(start, end, pyatspi.DESKTOP_COORDS)
-            bb = mouse_review.BoundingBox(bx, by, bw, bh)
-            if bb.isInBox(x, y):
-                start_offset = 0
-                word_offsets = []
-                while True:
-                    try:
-                        end_offset = \
-                            text_contents[start:end].index(' ', start_offset)
-                    except ValueError:
-                        word_offsets.append((start_offset,
-                                             len(text_contents[start:end])))
-                        break
-                    word_offsets.append((start_offset, end_offset))
-                    start_offset = end_offset + 1
-                for a, b in word_offsets:
-                    bx, by, bw, bh = \
-                        ti.getRangeExtents(start+a, start+b,
-                                           pyatspi.DESKTOP_COORDS)
-                    bb = mouse_review.BoundingBox(bx, by, bw, bh)
-                    if bb.isInBox(x, y):
-                        return text_contents[start+a:start+b], start+a, start+b
-
-        return '', 0, 0
-
     #########################################################################
     #                                                                       #
     # Miscellaneous Utilities                                               #
@@ -3009,28 +2885,44 @@ class Utilities:
                or character in '!*+,-./:;<=>?@[\]^_{|}' \
                or character == self._script.NO_BREAK_SPACE_CHARACTER
 
-    @staticmethod
-    def containsRegion(ax, ay, awidth, aheight, bx, by, bwidth, bheight):
-        """Returns true if any portion of region 'a' is in region 'b'"""
+    def intersectingRegion(self, obj1, obj2, coordType=None):
+        """Returns the extents of the intersection of obj1 and obj2."""
 
-        highestBottom = min(ay + aheight, by + bheight)
-        lowestTop = max(ay, by)
-        leftMostRightEdge = min(ax + awidth, bx + bwidth)
-        rightMostLeftEdge = max(ax, bx)
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
 
-        if lowestTop <= highestBottom \
-           and rightMostLeftEdge <= leftMostRightEdge:
-            return True
-        elif aheight == 0:
-            if awidth == 0:
-                return lowestTop == highestBottom \
-                       and leftMostRightEdge == rightMostLeftEdge
-            else:
-                return leftMostRightEdge <= rightMostLeftEdge
-        elif awidth == 0:
-            return lowestTop <= highestBottom
+        try:
+            extents1 = obj1.queryComponent().getExtents(coordType)
+            extents2 = obj2.queryComponent().getExtents(coordType)
+        except:
+            return 0, 0, 0, 0
 
-        return False
+        return self.intersection(extents1, extents2)
+
+    def intersection(self, extents1, extents2):
+        x1, y1, width1, height1 = extents1
+        x2, y2, width2, height2 = extents2
+
+        xPoints1 = range(x1, x1 + width1 + 1)
+        xPoints2 = range(x2, x2 + width2 + 1)
+        xIntersection = sorted(set(xPoints1).intersection(set(xPoints2)))
+
+        yPoints1 = range(y1, y1 + height1 + 1)
+        yPoints2 = range(y2, y2 + height2 + 1)
+        yIntersection = sorted(set(yPoints1).intersection(set(yPoints2)))
+
+        if not (xIntersection and yIntersection):
+            return 0, 0, 0, 0
+
+        x = xIntersection[0]
+        y = yIntersection[0]
+        width = xIntersection[-1] - x
+        height = yIntersection[-1] - y
+
+        return x, y, width, height
+
+    def containsRegion(self, extents1, extents2):
+        return self.intersection(extents1, extents2) != (0, 0, 0, 0)
 
     @staticmethod
     def _allNamesForKeyCode(keycode):
@@ -3538,6 +3430,88 @@ class Utilities:
 
         return table.nRows, table.nColumns
 
+    def containsPoint(self, obj, x, y, coordType):
+        try:
+            component = obj.queryComponent()
+        except:
+            return False
+
+        return component.contains(x, y, coordType)
+
+    def _boundsIncludeChildren(self, obj):
+        if not obj:
+            return False
+
+        roles = [pyatspi.ROLE_MENU,
+                 pyatspi.ROLE_PAGE_TAB]
+
+        return obj.getRole() not in roles
+
+    def _treatAsLeafNode(self, obj):
+        if not obj:
+            return False
+
+        if not obj.childCount:
+            return True
+
+        state = obj.getState()
+        if state.contains(pyatspi.STATE_EXPANDABLE):
+            return not state.contains(pyatspi.STATE_EXPANDED)
+
+        roles = [pyatspi.ROLE_COMBO_BOX,
+                 pyatspi.ROLE_PUSH_BUTTON]
+
+        return obj.getRole() in roles
+
+    def descendantAtPoint(self, root, x, y, coordType=None):
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
+
+        if self.containsPoint(root, x, y, coordType):
+            if self._treatAsLeafNode(root) or not self._boundsIncludeChildren(root):
+                return root
+        elif self._treatAsLeafNode(root) or self._boundsIncludeChildren(root):
+            return None
+
+        for child in root:
+            obj = self.descendantAtPoint(child, x, y, coordType)
+            if obj:
+                return obj
+
+        return None
+
+    def _adjustPointForObj(self, obj, x, y, coordType):
+        return x, y
+
+    def textAtPoint(self, obj, x, y, coordType=None, boundary=None):
+        text = self.queryNonEmptyText(obj)
+        if not text:
+            return "", 0, 0
+
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
+
+        if boundary is None:
+            boundary = pyatspi.TEXT_BOUNDARY_LINE_START
+
+        x, y = self._adjustPointForObj(obj, x, y, coordType)
+        offset = text.getOffsetAtPoint(x, y, coordType)
+        if not 0 <= offset < text.characterCount:
+            return "", 0, 0
+
+        string, start, end = text.getTextAtOffset(offset, boundary)
+        if not string:
+            return "", start, end
+
+        if boundary == pyatspi.TEXT_BOUNDARY_WORD_START and not string.strip():
+            return "", 0, 0
+
+        extents = text.getRangeExtents(start, end, coordType)
+        if not self.containsRegion(extents, (x, y, 1, 1)):
+            return "", 0, 0
+
+        return string, start, end
+
     def _getTableRowRange(self, obj):
         rowCount, columnCount = self.rowAndColumnCount(obj)
         startIndex, endIndex = 0, columnCount
diff --git a/src/orca/scripts/apps/soffice/script.py b/src/orca/scripts/apps/soffice/script.py
index 8eb1540..e41a3c8 100644
--- a/src/orca/scripts/apps/soffice/script.py
+++ b/src/orca/scripts/apps/soffice/script.py
@@ -298,12 +298,12 @@ class Script(default.Script):
 
         super().doWhereAmI(inputEvent, basicOnly)
 
-    def presentObject(self, obj, offset=0):
+    def presentObject(self, obj, **args):
         if not self._lastCommandWasStructNav:
-            super().presentObject(obj, offset)
+            super().presentObject(obj, **args)
             return
 
-        utterances = self.speechGenerator.generateSpeech(obj)
+        utterances = self.speechGenerator.generateSpeech(obj, **args)
         speech.speak(utterances)
 
     def panBrailleLeft(self, inputEvent=None, panAmount=0):
diff --git a/src/orca/scripts/default.py b/src/orca/scripts/default.py
index 6f44852..82513c2 100644
--- a/src/orca/scripts/default.py
+++ b/src/orca/scripts/default.py
@@ -96,8 +96,6 @@ class Script(script.Script):
         self.digits = '0123456789'
         self.whitespace = ' \t\n\r\v\f'
 
-        self.lastSelectedMenu = None
-
         # A dictionary of non-standardly-named text attributes and their
         # Atk equivalents.
         #
@@ -500,7 +498,7 @@ class Script(script.Script):
 
         self.inputEventHandlers["toggleMouseReviewHandler"] = \
             input_event.InputEventHandler(
-                mouse_review.toggle,
+                mouse_review.reviewer.toggle,
                 cmdnames.MOUSE_REVIEW_TOGGLE)
 
         self.inputEventHandlers["presentTimeHandler"] = \
@@ -2389,14 +2387,6 @@ class Script(script.Script):
         if keyString == "space":
             return
  
-        # Save the event source, if it is a menu or combo box. It will be
-        # useful for optimizing componentAtDesktopCoords in the case that
-        # the pointer is hovering over a menu item. The alternative is to
-        # traverse the application's tree looking for potential moused-over
-        # menu items.
-        if obj.getRole() in (pyatspi.ROLE_COMBO_BOX, pyatspi.ROLE_MENU):
-            self.lastSelectedMenu = obj
-
         selectedChildren = self.utilities.selectedChildren(obj)
         for child in selectedChildren:
             if pyatspi.findAncestor(orca_state.locusOfFocus, lambda x: x == child):
@@ -3274,10 +3264,11 @@ class Script(script.Script):
         self._lastWord = word
         speech.speak(word, voice)
 
-    def presentObject(self, obj, offset=0):
-        self.updateBraille(obj)
-        utterances = self.speechGenerator.generateSpeech(obj)
-        speech.speak(utterances)
+    def presentObject(self, obj, **args):
+        interrupt = args.get("interrupt", False)
+        self.updateBraille(obj, **args)
+        utterances = self.speechGenerator.generateSpeech(obj, **args)
+        speech.speak(utterances, interrupt=interrupt)
 
     def stopSpeechOnActiveDescendantChanged(self, event):
         """Whether or not speech should be stopped prior to setting the
@@ -3613,17 +3604,6 @@ class Script(script.Script):
 
         print("\a")
 
-    def speakWordUnderMouse(self, acc):
-        """Determine if the speak-word-under-mouse capability applies to
-        the given accessible.
-
-        Arguments:
-        - acc: Accessible to test.
-
-        Returns True if this accessible should provide the single word.
-        """
-        return acc and acc.getState().contains(pyatspi.STATE_EDITABLE)
-
     def speakMisspelledIndicator(self, obj, offset):
         """Speaks an announcement indicating that a given word is misspelled.
 
diff --git a/src/orca/scripts/toolkits/Gecko/script_utilities.py 
b/src/orca/scripts/toolkits/Gecko/script_utilities.py
index 982a9c8..11f1db8 100644
--- a/src/orca/scripts/toolkits/Gecko/script_utilities.py
+++ b/src/orca/scripts/toolkits/Gecko/script_utilities.py
@@ -45,6 +45,18 @@ class Utilities(web.Utilities):
     def _attemptBrokenTextRecovery(self):
         return True
 
+    def containsPoint(self, obj, x, y, coordType):
+        if not super().containsPoint(obj, x, y, coordType):
+            return False
+
+        roles = [pyatspi.ROLE_MENU, pyatspi.ROLE_TOOL_TIP]
+        if obj.getRole() in roles and self.topLevelObject(obj) == obj.parent:
+            msg = "GECKO: %s is suspected to be off screen object" % obj
+            debug.println(debug.LEVEL_INFO, msg, True)
+            return False
+
+        return True
+
     def nodeLevel(self, obj):
         """Determines the level of at which this object is at by using
         the object attribute 'level'.  To be consistent with the default
diff --git a/src/orca/scripts/toolkits/gtk/script_utilities.py 
b/src/orca/scripts/toolkits/gtk/script_utilities.py
index 27eb79b..0e90997 100644
--- a/src/orca/scripts/toolkits/gtk/script_utilities.py
+++ b/src/orca/scripts/toolkits/gtk/script_utilities.py
@@ -170,3 +170,48 @@ class Utilities(script_utilities.Utilities):
             return True
 
         return False
+
+    def _adjustPointForObj(self, obj, x, y, coordType):
+        try:
+            singleLine = obj.getState().contains(pyatspi.STATE_SINGLE_LINE)
+        except:
+            singleLine = False
+
+        if not singleLine or "EditableText" not in pyatspi.listInterfaces(obj):
+            return x, y
+
+        text = self.queryNonEmptyText(obj)
+        if not text:
+            return x, y
+
+        objBox = obj.queryComponent().getExtents(coordType)
+        stringBox = text.getRangeExtents(0, text.characterCount, coordType)
+        if self.intersection(objBox, stringBox) != (0, 0, 0, 0):
+            return x, y
+
+        msg = "ERROR: text bounds %s not in obj bounds %s" % (stringBox, objBox)
+        debug.println(debug.LEVEL_INFO, msg, True)
+
+        # This is where the string starts; not the widget.
+        boxX, boxY = stringBox[0], stringBox[1]
+
+        # Window Coordinates should be relative to the window; not the widget.
+        # But broken interface is broken, and this appears to be what is being
+        # exposed. And we need this information to get the widget's x and y.
+        charExtents = text.getCharacterExtents(0, pyatspi.WINDOW_COORDS)
+        if 0 < charExtents[0] < charExtents[2]:
+            boxX -= charExtents[0]
+        if 0 < charExtents[1] < charExtents[3]:
+            boxY -= charExtents[1]
+
+        # The point relative to the widget:
+        relX = x - objBox[0]
+        relY = y - objBox[1]
+
+        # The point relative to our adjusted bounding box:
+        newX = boxX + relX
+        newY = boxY + relY
+
+        msg = "INFO: Adjusted (%i, %i) to (%i, %i)" % (x, y, newX, newY)
+        debug.println(debug.LEVEL_INFO, msg, True)
+        return newX, newY
diff --git a/src/orca/scripts/web/script.py b/src/orca/scripts/web/script.py
index 266c877..3c02f5c 100644
--- a/src/orca/scripts/web/script.py
+++ b/src/orca/scripts/web/script.py
@@ -773,7 +773,8 @@ class Script(default.Script):
         obj, offset = self.utilities.getCaretContext(documentFrame=None)
         self.speakContents(self.utilities.getLineContentsAtOffset(obj, offset))
 
-    def presentObject(self, obj, offset=0):
+    def presentObject(self, obj, **args):
+        offset = args.get("offset", 0)
         contents = self.utilities.getObjectContentsAtOffset(obj, offset)
         self.displayContents(contents)
         self.speakContents(contents)
diff --git a/src/orca/scripts/web/script_utilities.py b/src/orca/scripts/web/script_utilities.py
index 3bb1c38..c450e8f 100644
--- a/src/orca/scripts/web/script_utilities.py
+++ b/src/orca/scripts/web/script_utilities.py
@@ -1561,6 +1561,34 @@ class Utilities(script_utilities.Utilities):
         self._isTextBlockElement[hash(obj)] = rv
         return rv
 
+    def _treatAsLeafNode(self, obj):
+        if super()._treatAsLeafNode(obj):
+            return True
+
+        if not self.isTextBlockElement(obj):
+            return False
+
+        for child in obj:
+            if self.isTextBlockElement(child):
+                return False
+
+        return True
+
+    def textAtPoint(self, obj, x, y, coordType=None, boundary=None):
+        if coordType is None:
+            coordType = pyatspi.DESKTOP_COORDS
+
+        if boundary is None:
+            boundary = pyatspi.TEXT_BOUNDARY_LINE_START
+
+        string, start, end = super().textAtPoint(obj, x, y, coordType, boundary)
+        if string == self.EMBEDDED_OBJECT_CHARACTER:
+            child = self.getChildAtOffset(obj, start)
+            if child:
+                return self.textAtPoint(child, x, y, coordType, boundary)
+
+        return string, start, end
+
     def treatAsDiv(self, obj):
         if not (obj and self.inDocumentContent(obj)):
             return False
diff --git a/src/orca/settings.py b/src/orca/settings.py
index d7b6816..38b3ac0 100644
--- a/src/orca/settings.py
+++ b/src/orca/settings.py
@@ -282,8 +282,6 @@ presentLockingKeys           = None
 
 # Mouse review
 enableMouseReview          = False
-mouseDwellDelay            = 0
-mouseDwellMaxDrift         = 3
 
 # Progressbars
 speakProgressBarUpdates    = True



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