Improve selection handle behavior for bidi text.

A point on a direction boundary can be mapped to two offset and
one offset on a direction boundary can be mapped to two points.
Previously, paragraph's primary direction is always used for deciding
offset and coordinates; thus, handle movement around a direction
boundary is often nonintuitive.

With this CL:
1. For selection end handle, direction of character at offset - 1 is
used for deciding handle shape and position.
2. For getting offset from coordinates, previous offset is used to
minimize the offset delta and primary .
3. For getting coordinates form offset, new logic chooses primary or
secondary horizontal coordinate depending on the current run
direction and paragraph direction.
4. When a handle passes another one because it passes a direction
boundary, new logic keeps the handle at the run boundary instead of
minimizing the selection.

Bug: 19199637
Bug: 21480356
Bug: 21649994


Change-Id: I2a7e87ad08416f4bd01a5f68e006240f77d9036b
diff --git a/core/java/android/text/Layout.java b/core/java/android/text/Layout.java
index 692d848..0bd5071 100644
--- a/core/java/android/text/Layout.java
+++ b/core/java/android/text/Layout.java
@@ -799,6 +799,31 @@
         return false;
     }
 
+    /**
+     * Returns the range of the run that the character at offset belongs to.
+     * @param offset the offset
+     * @return The range of the run
+     * @hide
+     */
+    public long getRunRange(int offset) {
+        int line = getLineForOffset(offset);
+        Directions dirs = getLineDirections(line);
+        if (dirs == DIRS_ALL_LEFT_TO_RIGHT || dirs == DIRS_ALL_RIGHT_TO_LEFT) {
+            return TextUtils.packRangeInLong(0, getLineEnd(line));
+        }
+        int[] runs = dirs.mDirections;
+        int lineStart = getLineStart(line);
+        for (int i = 0; i < runs.length; i += 2) {
+            int start = lineStart + runs[i];
+            int limit = start + (runs[i+1] & RUN_LENGTH_MASK);
+            if (offset >= start && offset < limit) {
+                return TextUtils.packRangeInLong(start, limit);
+            }
+        }
+        // Should happen only if the offset is "out of bounds"
+        return TextUtils.packRangeInLong(0, getLineEnd(line));
+    }
+
     private boolean primaryIsTrailingPrevious(int offset) {
         int line = getLineForOffset(offset);
         int lineStart = getLineStart(line);
@@ -886,6 +911,10 @@
         return getHorizontal(offset, !trailing, clamped);
     }
 
+    private float getHorizontal(int offset, boolean primary) {
+        return primary ? getPrimaryHorizontal(offset) : getSecondaryHorizontal(offset);
+    }
+
     private float getHorizontal(int offset, boolean trailing, boolean clamped) {
         int line = getLineForOffset(offset);
 
@@ -1114,6 +1143,20 @@
      * closest to the specified horizontal position.
      */
     public int getOffsetForHorizontal(int line, float horiz) {
+        return getOffsetForHorizontal(line, horiz, true);
+    }
+
+    /**
+     * Get the character offset on the specified line whose position is
+     * closest to the specified horizontal position.
+     *
+     * @param line the line used to find the closest offset
+     * @param horiz the horizontal position used to find the closest offset
+     * @param primary whether to use the primary position or secondary position to find the offset
+     *
+     * @hide
+     */
+    public int getOffsetForHorizontal(int line, float horiz, boolean primary) {
         // TODO: use Paint.getOffsetForAdvance to avoid binary search
         final int lineEndOffset = getLineEnd(line);
         final int lineStartOffset = getLineStart(line);
@@ -1133,7 +1176,7 @@
                     !isRtlCharAt(lineEndOffset - 1)) + lineStartOffset;
         }
         int best = lineStartOffset;
-        float bestdist = Math.abs(getPrimaryHorizontal(best) - horiz);
+        float bestdist = Math.abs(getHorizontal(best, primary) - horiz);
 
         for (int i = 0; i < dirs.mDirections.length; i += 2) {
             int here = lineStartOffset + dirs.mDirections[i];
@@ -1149,7 +1192,7 @@
                 guess = (high + low) / 2;
                 int adguess = getOffsetAtStartOf(guess);
 
-                if (getPrimaryHorizontal(adguess) * swap >= horiz * swap)
+                if (getHorizontal(adguess, primary) * swap >= horiz * swap)
                     high = guess;
                 else
                     low = guess;
@@ -1162,9 +1205,9 @@
                 int aft = tl.getOffsetToLeftRightOf(low - lineStartOffset, isRtl) + lineStartOffset;
                 low = tl.getOffsetToLeftRightOf(aft - lineStartOffset, !isRtl) + lineStartOffset;
                 if (low >= here && low < there) {
-                    float dist = Math.abs(getPrimaryHorizontal(low) - horiz);
+                    float dist = Math.abs(getHorizontal(low, primary) - horiz);
                     if (aft < there) {
-                        float other = Math.abs(getPrimaryHorizontal(aft) - horiz);
+                        float other = Math.abs(getHorizontal(aft, primary) - horiz);
 
                         if (other < dist) {
                             dist = other;
@@ -1179,7 +1222,7 @@
                 }
             }
 
-            float dist = Math.abs(getPrimaryHorizontal(here) - horiz);
+            float dist = Math.abs(getHorizontal(here, primary) - horiz);
 
             if (dist < bestdist) {
                 bestdist = dist;
@@ -1187,7 +1230,7 @@
             }
         }
 
-        float dist = Math.abs(getPrimaryHorizontal(max) - horiz);
+        float dist = Math.abs(getHorizontal(max, primary) - horiz);
 
         if (dist <= bestdist) {
             bestdist = dist;
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index 942cbcb..59d857c 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -4034,14 +4034,17 @@
                 // Don't update drawable during dragging.
                 return;
             }
+            final Layout layout = mTextView.getLayout();
+            if (layout == null) {
+                return;
+            }
             final int offset = getCurrentCursorOffset();
-            final boolean isRtlCharAtOffset = mTextView.getLayout().isRtlCharAt(offset);
+            final boolean isRtlCharAtOffset = isAtRtlRun(layout, offset);
             final Drawable oldDrawable = mDrawable;
             mDrawable = isRtlCharAtOffset ? mDrawableRtl : mDrawableLtr;
             mHotspotX = getHotspotX(mDrawable, isRtlCharAtOffset);
             mHorizontalGravity = getHorizontalGravity(isRtlCharAtOffset);
-            final Layout layout = mTextView.getLayout();
-            if (layout != null && oldDrawable != mDrawable && isShowing()) {
+            if (oldDrawable != mDrawable && isShowing()) {
                 // Update popup window position.
                 mPositionX = getCursorHorizontalPosition(layout, offset) - mHotspotX -
                         getHorizontalOffset() + getCursorOffset();
@@ -4154,6 +4157,19 @@
 
         public abstract void updatePosition(float x, float y);
 
+        protected boolean isAtRtlRun(@NonNull Layout layout, int offset) {
+            return layout.isRtlCharAt(offset);
+        }
+
+        @VisibleForTesting
+        public float getHorizontal(@NonNull Layout layout, int offset) {
+            return layout.getPrimaryHorizontal(offset);
+        }
+
+        protected int getOffsetAtCoordinate(@NonNull Layout layout, int line, float x) {
+            return mTextView.getOffsetAtCoordinate(line, x);
+        }
+
         protected void positionAtCursorOffset(int offset, boolean parentScrolled) {
             // A HandleView relies on the layout, which may be nulled by external methods
             Layout layout = mTextView.getLayout();
@@ -4194,7 +4210,7 @@
          * @return The clamped horizontal position for the cursor.
          */
         int getCursorHorizontalPosition(Layout layout, int offset) {
-            return (int) (layout.getPrimaryHorizontal(offset) - 0.5f);
+            return (int) (getHorizontal(layout, offset) - 0.5f);
         }
 
         public void updatePosition(int parentPositionX, int parentPositionY,
@@ -4427,7 +4443,7 @@
         int getCursorHorizontalPosition(Layout layout, int offset) {
             final Drawable drawable = mCursorCount > 0 ? mCursorDrawable[0] : null;
             if (drawable != null) {
-                final float horizontal = layout.getPrimaryHorizontal(offset);
+                final float horizontal = getHorizontal(layout, offset);
                 return clampHorizontalPosition(drawable, horizontal) + mTempRect.left;
             }
             return super.getCursorHorizontalPosition(layout, offset);
@@ -4499,10 +4515,10 @@
                     mPreviousLineTouched = mTextView.getLineAtCoordinate(y);
                 }
                 int currLine = getCurrentLineAdjustedForSlop(layout, mPreviousLineTouched, y);
-                offset = mTextView.getOffsetAtCoordinate(currLine, x);
+                offset = getOffsetAtCoordinate(layout, currLine, x);
                 mPreviousLineTouched = currLine;
             } else {
-                offset = mTextView.getOffsetForPosition(x, y);
+                offset = -1;
             }
             positionAtCursorOffset(offset, false);
             if (mTextActionMode != null) {
@@ -4612,14 +4628,14 @@
             final int anotherHandleOffset =
                     isStartHandle() ? mTextView.getSelectionEnd() : mTextView.getSelectionStart();
             int currLine = getCurrentLineAdjustedForSlop(layout, mPreviousLineTouched, y);
-            int initialOffset = mTextView.getOffsetAtCoordinate(currLine, x);
+            int initialOffset = getOffsetAtCoordinate(layout, currLine, x);
 
             if (isStartHandle() && initialOffset >= anotherHandleOffset
                     || !isStartHandle() && initialOffset <= anotherHandleOffset) {
                 // Handles have crossed, bound it to the first selected line and
                 // adjust by word / char as normal.
                 currLine = layout.getLineForOffset(anotherHandleOffset);
-                initialOffset = mTextView.getOffsetAtCoordinate(currLine, x);
+                initialOffset = getOffsetAtCoordinate(layout, currLine, x);
             }
 
             int offset = initialOffset;
@@ -4631,8 +4647,8 @@
             }
 
             final int currentOffset = getCurrentCursorOffset();
-            final boolean rtlAtCurrentOffset = layout.isRtlCharAt(currentOffset);
-            final boolean atRtl = layout.isRtlCharAt(offset);
+            final boolean rtlAtCurrentOffset = isAtRtlRun(layout, currentOffset);
+            final boolean atRtl = isAtRtlRun(layout, offset);
             final boolean isLvlBoundary = layout.isLevelBoundary(offset);
 
             // We can't determine if the user is expanding or shrinking the selection if they're
@@ -4689,14 +4705,15 @@
 
             if (isExpanding) {
                 // User is increasing the selection.
-                final boolean snapToWord = !mInWord
-                        || (isStartHandle() ? currLine < mPrevLine : currLine > mPrevLine);
+                int wordBoundary = isStartHandle() ? wordStart : wordEnd;
+                final boolean snapToWord = (!mInWord
+                        || (isStartHandle() ? currLine < mPrevLine : currLine > mPrevLine))
+                                && atRtl == isAtRtlRun(layout, wordBoundary);
                 if (snapToWord) {
                     // Sometimes words can be broken across lines (Chinese, hyphenation).
                     // We still snap to the word boundary but we only use the letters on the
                     // current line to determine if the user is far enough into the word to snap.
-                    int wordBoundary = isStartHandle() ? wordStart : wordEnd;
-                    if (layout != null && layout.getLineForOffset(wordBoundary) != currLine) {
+                    if (layout.getLineForOffset(wordBoundary) != currLine) {
                         wordBoundary = isStartHandle() ?
                                 layout.getLineStart(currLine) : layout.getLineEnd(currLine);
                     }
@@ -4717,9 +4734,9 @@
                         offset = mPreviousOffset;
                     }
                 }
-                if (layout != null && (isStartHandle() && offset < initialOffset)
+                if ((isStartHandle() && offset < initialOffset)
                         || (!isStartHandle() && offset > initialOffset)) {
-                    final float adjustedX = layout.getPrimaryHorizontal(offset);
+                    final float adjustedX = getHorizontal(layout, offset);
                     mTouchWordDelta =
                             mTextView.convertToLocalHorizontalCoordinate(x) - adjustedX;
                 } else {
@@ -4728,7 +4745,7 @@
                 positionCursor = true;
             } else {
                 final int adjustedOffset =
-                        mTextView.getOffsetAtCoordinate(currLine, x - mTouchWordDelta);
+                        getOffsetAtCoordinate(layout, currLine, x - mTouchWordDelta);
                 final boolean shrinking = isStartHandle()
                         ? adjustedOffset > mPreviousOffset || currLine > mPrevLine
                         : adjustedOffset < mPreviousOffset || currLine < mPrevLine;
@@ -4737,9 +4754,9 @@
                     if (currLine != mPrevLine) {
                         // We're on a different line, so we'll snap to word boundaries.
                         offset = isStartHandle() ? wordStart : wordEnd;
-                        if (layout != null && (isStartHandle() && offset < initialOffset)
+                        if ((isStartHandle() && offset < initialOffset)
                                 || (!isStartHandle() && offset > initialOffset)) {
-                            final float adjustedX = layout.getPrimaryHorizontal(offset);
+                            final float adjustedX = getHorizontal(layout, offset);
                             mTouchWordDelta =
                                     mTextView.convertToLocalHorizontalCoordinate(x) - adjustedX;
                         } else {
@@ -4754,7 +4771,7 @@
                     // Handle has jumped to the word boundary, and the user is moving
                     // their finger towards the handle, the delta should be updated.
                     mTouchWordDelta = mTextView.convertToLocalHorizontalCoordinate(x) -
-                            layout.getPrimaryHorizontal(mPreviousOffset);
+                            getHorizontal(layout, mPreviousOffset);
                 }
             }
 
@@ -4792,9 +4809,32 @@
                     isStartHandle() ? mTextView.getSelectionEnd() : mTextView.getSelectionStart();
             if ((isStartHandle() && offset >= anotherHandleOffset)
                     || (!isStartHandle() && offset <= anotherHandleOffset)) {
+                mTouchWordDelta = 0.0f;
+                final Layout layout = mTextView.getLayout();
+                if (layout != null && offset != anotherHandleOffset) {
+                    final float horiz = getHorizontal(layout, offset);
+                    final float anotherHandleHoriz = getHorizontal(layout, anotherHandleOffset,
+                            !isStartHandle());
+                    final float currentHoriz = getHorizontal(layout, mPreviousOffset);
+                    if (currentHoriz < anotherHandleHoriz && horiz < anotherHandleHoriz
+                            || currentHoriz > anotherHandleHoriz && horiz > anotherHandleHoriz) {
+                        // This handle passes another one as it crossed a direction boundary.
+                        // Don't minimize the selection, but keep the handle at the run boundary.
+                        final int currentOffset = getCurrentCursorOffset();
+                        final int offsetToGetRunRange = isStartHandle() ?
+                                currentOffset : Math.max(currentOffset - 1, 0);
+                        final long range = layout.getRunRange(offsetToGetRunRange);
+                        if (isStartHandle()) {
+                            offset = TextUtils.unpackRangeStartFromLong(range);
+                        } else {
+                            offset = TextUtils.unpackRangeEndFromLong(range);
+                        }
+                        positionAtCursorOffset(offset, false);
+                        return;
+                    }
+                }
                 // Handles can not cross and selection is at least one character.
                 offset = getNextCursorOffset(anotherHandleOffset, !isStartHandle());
-                mTouchWordDelta = 0.0f;
             }
             positionAtCursorOffset(offset, false);
         }
@@ -4812,6 +4852,49 @@
             }
             return nearEdge;
         }
+
+        @Override
+        protected boolean isAtRtlRun(@NonNull Layout layout, int offset) {
+            final int offsetToCheck = isStartHandle() ? offset : Math.max(offset - 1, 0);
+            return layout.isRtlCharAt(offsetToCheck);
+        }
+
+        @Override
+        public float getHorizontal(@NonNull Layout layout, int offset) {
+            return getHorizontal(layout, offset, isStartHandle());
+        }
+
+        private float getHorizontal(@NonNull Layout layout, int offset, boolean startHandle) {
+            final int line = layout.getLineForOffset(offset);
+            final int offsetToCheck = startHandle ? offset : Math.max(offset - 1, 0);
+            final boolean isRtlChar = layout.isRtlCharAt(offsetToCheck);
+            final boolean isRtlParagraph = layout.getParagraphDirection(line) == -1;
+            return (isRtlChar == isRtlParagraph) ?
+                    layout.getPrimaryHorizontal(offset) : layout.getSecondaryHorizontal(offset);
+        }
+
+        @Override
+        protected int getOffsetAtCoordinate(@NonNull Layout layout, int line, float x) {
+            final int primaryOffset = layout.getOffsetForHorizontal(line, x, true);
+            if (!layout.isLevelBoundary(primaryOffset)) {
+                return primaryOffset;
+            }
+            final int secondaryOffset = layout.getOffsetForHorizontal(line, x, false);
+            final int currentOffset = getCurrentCursorOffset();
+            final int primaryDiff = Math.abs(primaryOffset - currentOffset);
+            final int secondaryDiff = Math.abs(secondaryOffset - currentOffset);
+            if (primaryDiff < secondaryDiff) {
+                return primaryOffset;
+            } else if (primaryDiff > secondaryDiff) {
+                return secondaryOffset;
+            } else {
+                final int offsetToCheck = isStartHandle() ?
+                        currentOffset : Math.max(currentOffset - 1, 0);
+                final boolean isRtlChar = layout.isRtlCharAt(offsetToCheck);
+                final boolean isRtlParagraph = layout.getParagraphDirection(line) == -1;
+                return isRtlChar == isRtlParagraph ? primaryOffset : secondaryOffset;
+            }
+        }
     }
 
     private int getCurrentLineAdjustedForSlop(Layout layout, int prevLine, float y) {
diff --git a/core/tests/coretests/src/android/widget/TextViewActivityTest.java b/core/tests/coretests/src/android/widget/TextViewActivityTest.java
index 91d57e7..f779d6c 100644
--- a/core/tests/coretests/src/android/widget/TextViewActivityTest.java
+++ b/core/tests/coretests/src/android/widget/TextViewActivityTest.java
@@ -385,6 +385,51 @@
     }
 
     @SmallTest
+    public void testSelectionHandles_bidi() throws Exception {
+        final String text = "abc \u0621\u0622\u0623 def";
+        onView(withId(R.id.textview)).perform(click());
+        onView(withId(R.id.textview)).perform(replaceText(text));
+
+        assertNoSelectionHandles();
+
+        onView(withId(R.id.textview)).perform(doubleClickOnTextAtIndex(text.indexOf('\u0622')));
+
+        onHandleView(com.android.internal.R.id.selection_start_handle)
+                .check(matches(isDisplayed()));
+        onHandleView(com.android.internal.R.id.selection_end_handle)
+                .check(matches(isDisplayed()));
+
+        onView(withId(R.id.textview)).check(hasSelection("\u0621\u0622\u0623"));
+
+        final TextView textView = (TextView) getActivity().findViewById(R.id.textview);
+        onHandleView(com.android.internal.R.id.selection_start_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_START, text.indexOf('f')));
+        onView(withId(R.id.textview)).check(hasSelection("\u0621\u0622\u0623"));
+
+        onHandleView(com.android.internal.R.id.selection_end_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_END, text.indexOf('a')));
+        onView(withId(R.id.textview)).check(hasSelection("\u0621\u0622\u0623"));
+
+        onHandleView(com.android.internal.R.id.selection_start_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_START, text.indexOf('\u0623') + 1,
+                        false));
+        onView(withId(R.id.textview)).check(hasSelection("\u0623"));
+
+        onHandleView(com.android.internal.R.id.selection_start_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_START, text.indexOf('\u0621'),
+                        false));
+        onView(withId(R.id.textview)).check(hasSelection("\u0621\u0622\u0623"));
+
+        onHandleView(com.android.internal.R.id.selection_start_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_START, text.indexOf('a')));
+        onView(withId(R.id.textview)).check(hasSelection("abc \u0621\u0622\u0623"));
+
+        onHandleView(com.android.internal.R.id.selection_end_handle)
+                .perform(dragHandle(textView, Handle.SELECTION_END, text.length()));
+        onView(withId(R.id.textview)).check(hasSelection("abc \u0621\u0622\u0623 def"));
+    }
+
+    @SmallTest
     public void testSelectionHandles_multiLine() throws Exception {
         final String text = "abcd\n" + "efg\n" + "hijk\n" + "lmn\n" + "opqr";
         onView(withId(R.id.textview)).perform(click());
diff --git a/core/tests/coretests/src/android/widget/espresso/TextViewActions.java b/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
index 1dd6e17..4cecb65 100644
--- a/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
+++ b/core/tests/coretests/src/android/widget/espresso/TextViewActions.java
@@ -17,6 +17,10 @@
 package android.widget.espresso;
 
 import static android.support.test.espresso.action.ViewActions.actionWithAssertions;
+
+import org.hamcrest.core.Is;
+import org.hamcrest.core.IsInstanceOf;
+
 import android.graphics.Rect;
 import android.support.test.espresso.PerformException;
 import android.support.test.espresso.ViewAction;
@@ -29,6 +33,7 @@
 import android.view.MotionEvent;
 import android.view.View;
 import android.widget.Editor;
+import android.widget.Editor.HandleView;
 import android.widget.TextView;
 
 /**
@@ -311,18 +316,87 @@
      * @param endIndex The index of the TextView's text to end the drag at
      */
     public static ViewAction dragHandle(TextView textView, Handle handleType, int endIndex) {
-        final int currentOffset = handleType == Handle.SELECTION_START ?
-                textView.getSelectionStart() : textView.getSelectionEnd();
+        return dragHandle(textView, handleType, endIndex, true);
+    }
+
+    /**
+     * Returns an action that tap then drags on the handle from the current position to endIndex on
+     * the TextView.<br>
+     * <br>
+     * View constraints:
+     * <ul>
+     * <li>must be a TextView's drag-handle displayed on screen
+     * <ul>
+     *
+     * @param textView TextView the handle is on
+     * @param handleType Type of the handle
+     * @param endIndex The index of the TextView's text to end the drag at
+     * @param primary whether to use primary direction to get coordinate form index when endIndex is
+     * at a direction boundary.
+     */
+    public static ViewAction dragHandle(TextView textView, Handle handleType, int endIndex,
+            boolean primary) {
         return actionWithAssertions(
                 new DragAction(
                         DragAction.Drag.TAP,
-                        new HandleCoordinates(textView, handleType, currentOffset),
-                        new HandleCoordinates(textView, handleType, endIndex),
+                        new CurrentHandleCoordinates(textView),
+                        new HandleCoordinates(textView, handleType, endIndex, primary),
                         Press.FINGER,
                         Editor.HandleView.class));
     }
 
     /**
+     * A provider of the x, y coordinates of the handle dragging point.
+     */
+    private static final class CurrentHandleCoordinates implements CoordinatesProvider {
+        // Must be larger than Editor#LINE_SLOP_MULTIPLIER_FOR_HANDLEVIEWS.
+        private final TextView mTextView;
+        private final String mActionDescription;
+
+
+        public CurrentHandleCoordinates(TextView textView) {
+            mTextView = textView;
+            mActionDescription = "Could not locate handle.";
+        }
+
+        @Override
+        public float[] calculateCoordinates(View view) {
+            try {
+                return locateHandle(view);
+            } catch (StringIndexOutOfBoundsException e) {
+                throw new PerformException.Builder()
+                        .withActionDescription(mActionDescription)
+                        .withViewDescription(HumanReadables.describe(view))
+                        .withCause(e)
+                        .build();
+            }
+        }
+
+        private float[] locateHandle(View view) {
+            final Rect bounds = new Rect();
+            view.getBoundsOnScreen(bounds);
+            final Rect visibleDisplayBounds = new Rect();
+            mTextView.getWindowVisibleDisplayFrame(visibleDisplayBounds);
+            visibleDisplayBounds.right -= 1;
+            visibleDisplayBounds.bottom -= 1;
+            if (!visibleDisplayBounds.intersect(bounds)) {
+                throw new PerformException.Builder()
+                        .withActionDescription(mActionDescription
+                                + " The handle is entirely out of the visible display frame of"
+                                + "the TextView's window.")
+                        .withViewDescription(HumanReadables.describe(view))
+                        .build();
+            }
+            final float dragPointX = Math.max(Math.min(bounds.centerX(),
+                    visibleDisplayBounds.right), visibleDisplayBounds.left);
+            final float verticalOffset = bounds.height() * 0.7f;
+            final float dragPointY = Math.max(Math.min(bounds.top + verticalOffset,
+                    visibleDisplayBounds.bottom), visibleDisplayBounds.top);
+            return new float[] {dragPointX, dragPointY};
+        }
+    }
+
+    /**
      * A provider of the x, y coordinates of the handle that points the specified text index in a
      * text view.
      */
@@ -332,14 +406,17 @@
         private final TextView mTextView;
         private final Handle mHandleType;
         private final int mIndex;
+        private final boolean mPrimary;
         private final String mActionDescription;
 
-        public HandleCoordinates(TextView textView, Handle handleType, int index) {
+        public HandleCoordinates(TextView textView, Handle handleType, int index, boolean primary) {
             mTextView = textView;
             mHandleType = handleType;
             mIndex = index;
+            mPrimary = primary;
             mActionDescription = "Could not locate " + handleType.toString()
-                    + " handle that points text index: " + index;
+                    + " handle that points text index: " + index
+                    + " (" + (primary ? "primary" : "secondary" ) + ")";
         }
 
         @Override
@@ -356,17 +433,26 @@
         }
 
         private float[] locateHandlePointsTextIndex(View view) {
+            if (!(view instanceof HandleView)) {
+                throw new PerformException.Builder()
+                        .withActionDescription(mActionDescription + " The view is not a HandleView")
+                        .withViewDescription(HumanReadables.describe(view))
+                        .build();
+            }
+            final HandleView handleView = (HandleView) view;
             final int currentOffset = mHandleType == Handle.SELECTION_START ?
                     mTextView.getSelectionStart() : mTextView.getSelectionEnd();
 
             final Layout layout = mTextView.getLayout();
+
             final int currentLine = layout.getLineForOffset(currentOffset);
             final int targetLine = layout.getLineForOffset(mIndex);
-
+            final float currentX = handleView.getHorizontal(layout, currentOffset);
+            final float currentY = layout.getLineTop(currentLine);
             final float[] currentCoordinates =
-                    (new TextCoordinates(currentOffset)).calculateCoordinates(mTextView);
+                    TextCoordinates.convertToScreenCoordinates(mTextView, currentX, currentY);
             final float[] targetCoordinates =
-                    (new TextCoordinates(mIndex)).calculateCoordinates(mTextView);
+                    (new TextCoordinates(mIndex, mPrimary)).calculateCoordinates(mTextView);
             final Rect bounds = new Rect();
             view.getBoundsOnScreen(bounds);
             final Rect visibleDisplayBounds = new Rect();
@@ -403,17 +489,24 @@
     private static final class TextCoordinates implements CoordinatesProvider {
 
         private final int mIndex;
+        private final boolean mPrimary;
         private final String mActionDescription;
 
         public TextCoordinates(int index) {
+            this(index, true);
+        }
+
+        public TextCoordinates(int index, boolean primary) {
             mIndex = index;
-            mActionDescription = "Could not locate text at index: " + mIndex;
+            mPrimary = primary;
+            mActionDescription = "Could not locate text at index: " + mIndex
+                    + " (" + (primary ? "primary" : "secondary" ) + ")";
         }
 
         @Override
         public float[] calculateCoordinates(View view) {
             try {
-                return locateTextAtIndex((TextView) view, mIndex);
+                return locateTextAtIndex((TextView) view, mIndex, mPrimary);
             } catch (ClassCastException e) {
                 throw new PerformException.Builder()
                         .withActionDescription(mActionDescription)
@@ -432,19 +525,30 @@
         /**
          * @throws StringIndexOutOfBoundsException
          */
-        private float[] locateTextAtIndex(TextView textView, int index) {
+        private float[] locateTextAtIndex(TextView textView, int index, boolean primary) {
             if (index < 0 || index > textView.getText().length()) {
                 throw new StringIndexOutOfBoundsException(index);
             }
-            final int[] xy = new int[2];
-            textView.getLocationOnScreen(xy);
             final Layout layout = textView.getLayout();
             final int line = layout.getLineForOffset(index);
-            final float x = textView.getTotalPaddingLeft() - textView.getScrollX()
-                    + layout.getPrimaryHorizontal(index);
-            final float y = textView.getTotalPaddingTop() - textView.getScrollY()
-                    + layout.getLineTop(line);
-            return new float[]{x + xy[0], y + xy[1]};
+            return convertToScreenCoordinates(textView,
+                    (primary ? layout.getPrimaryHorizontal(index)
+                            : layout.getSecondaryHorizontal(index)),
+                    layout.getLineTop(line));
+        }
+
+        /**
+         * Convert TextView's local coordinates to on screen coordinates.
+         * @param textView the TextView
+         * @param x local horizontal coordinate
+         * @param y local vertical coordinate
+         * @return
+         */
+        public static float[] convertToScreenCoordinates(TextView textView, float x, float y) {
+            final int[] xy = new int[2];
+            textView.getLocationOnScreen(xy);
+            return new float[]{ x + textView.getTotalPaddingLeft() - textView.getScrollX() + xy[0],
+                    y + textView.getTotalPaddingTop() - textView.getScrollY() + xy[1] };
         }
     }
 }