TextView implementation of select and delete gestures

Bug: 240435922
Test: atest android.text.LayoutGetRangeForRectTest
Test: atest android.widget.cts.TextViewHandwritingGestureTest
Change-Id: I6ceaa4555b82f1c2650d2db3bf39674641d53b8f
This commit is contained in:
Justin Ghan
2022-08-10 17:38:11 -07:00
parent 60e04f428b
commit 8b5d4bd91e
7 changed files with 1177 additions and 24 deletions

View File

@@ -0,0 +1,78 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.text;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.graphics.Paint;
/**
* Implementation of {@code SegmentIterator} using grapheme clusters as the text segment. Whitespace
* characters are included as segments.
*
* @hide
*/
public class GraphemeClusterSegmentIterator extends SegmentIterator {
private final CharSequence mText;
private final TextPaint mTextPaint;
public GraphemeClusterSegmentIterator(
@NonNull CharSequence text, @NonNull TextPaint textPaint) {
mText = text;
mTextPaint = textPaint;
}
@Override
public int previousStartBoundary(@IntRange(from = 0) int offset) {
int boundary = mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, offset, Paint.CURSOR_BEFORE);
return boundary == -1 ? DONE : boundary;
}
@Override
public int previousEndBoundary(@IntRange(from = 0) int offset) {
int boundary = mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, offset, Paint.CURSOR_BEFORE);
// Check that there is another cursor position before, otherwise this is not a valid
// end boundary.
if (mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, boundary, Paint.CURSOR_BEFORE) == -1) {
return DONE;
}
return boundary == -1 ? DONE : boundary;
}
@Override
public int nextStartBoundary(@IntRange(from = 0) int offset) {
int boundary = mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, offset, Paint.CURSOR_AFTER);
// Check that there is another cursor position after, otherwise this is not a valid
// start boundary.
if (mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, boundary, Paint.CURSOR_AFTER) == -1) {
return DONE;
}
return boundary == -1 ? DONE : boundary;
}
@Override
public int nextEndBoundary(@IntRange(from = 0) int offset) {
int boundary = mTextPaint.getTextRunCursor(
mText, 0, mText.length(), false, offset, Paint.CURSOR_AFTER);
return boundary == -1 ? DONE : boundary;
}
}

View File

@@ -19,11 +19,13 @@ package android.text;
import android.annotation.IntDef;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.compat.annotation.UnsupportedAppUsage;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.Rect;
import android.graphics.RectF;
import android.graphics.text.LineBreaker;
import android.os.Build;
import android.text.method.TextKeyListener;
@@ -710,8 +712,7 @@ public abstract class Layout {
}
/**
* Return the start position of the line, given the left and right bounds
* of the margins.
* Return the start position of the line, given the left and right bounds of the margins.
*
* @param line the line index
* @param left the left bounds (0, or leading margin if ltr para)
@@ -1312,6 +1313,38 @@ public abstract class Layout {
return horizontal;
}
private void fillHorizontalBoundsForLine(int line, float[] horizontalBounds) {
final int lineStart = getLineStart(line);
final int lineEnd = getLineEnd(line);
final int lineLength = lineEnd - lineStart;
final int dir = getParagraphDirection(line);
final Directions directions = getLineDirections(line);
final boolean hasTab = getLineContainsTab(line);
TabStops tabStops = null;
if (hasTab && mText instanceof Spanned) {
// Just checking this line should be good enough, tabs should be
// consistent across all lines in a paragraph.
TabStopSpan[] tabs =
getParagraphSpans((Spanned) mText, lineStart, lineEnd, TabStopSpan.class);
if (tabs.length > 0) {
tabStops = new TabStops(TAB_INCREMENT, tabs); // XXX should reuse
}
}
final TextLine tl = TextLine.obtain();
tl.set(mPaint, mText, lineStart, lineEnd, dir, directions, hasTab, tabStops,
getEllipsisStart(line), getEllipsisStart(line) + getEllipsisCount(line),
isFallbackLineSpacingEnabled());
if (horizontalBounds == null || horizontalBounds.length < 2 * lineLength) {
horizontalBounds = new float[2 * lineLength];
}
tl.measureAllBounds(horizontalBounds, null);
TextLine.recycle(tl);
}
/**
* Return the characters' bounds in the given range. The {@code bounds} array will be filled
* starting from {@code boundsStart} (inclusive). The coordinates are in local text layout.
@@ -1358,32 +1391,11 @@ public abstract class Layout {
final int lineStart = getLineStart(line);
final int lineEnd = getLineEnd(line);
final int lineLength = lineEnd - lineStart;
final int dir = getParagraphDirection(line);
final boolean hasTab = getLineContainsTab(line);
final Directions directions = getLineDirections(line);
TabStops tabStops = null;
if (hasTab && mText instanceof Spanned) {
// Just checking this line should be good enough, tabs should be
// consistent across all lines in a paragraph.
TabStopSpan[] tabs = getParagraphSpans((Spanned) mText, lineStart, lineEnd,
TabStopSpan.class);
if (tabs.length > 0) {
tabStops = new TabStops(TAB_INCREMENT, tabs); // XXX should reuse
}
}
final TextLine tl = TextLine.obtain();
tl.set(mPaint, mText, lineStart, lineEnd, dir, directions, hasTab, tabStops,
getEllipsisStart(line), getEllipsisStart(line) + getEllipsisCount(line),
isFallbackLineSpacingEnabled());
if (horizontalBounds == null || horizontalBounds.length < 2 * lineLength) {
horizontalBounds = new float[2 * lineLength];
}
fillHorizontalBoundsForLine(line, horizontalBounds);
tl.measureAllBounds(horizontalBounds, null);
TextLine.recycle(tl);
final int lineLeft = getParagraphLeft(line);
final int lineRight = getParagraphRight(line);
final int lineStartPos = getLineStartPos(line, lineLeft, lineRight);
@@ -1801,6 +1813,380 @@ public abstract class Layout {
}
}
/**
* Finds the range of text which is inside the specified rectangle area. The start of the range
* is the start of the first text segment inside the area, and the end of the range is the end
* of the last text segment inside the area.
*
* <p>A text segment is considered to be inside the area if the center of its bounds is inside
* the area. If a text segment spans multiple lines or multiple directional runs (e.g. a
* hyphenated word), the text segment is divided into pieces at the line and run breaks, then
* the text segment is considered to be inside the area if any of its pieces have their center
* inside the area.
*
* <p>The returned range may also include text segments which are not inside the specified area,
* if those text segments are in between text segments which are inside the area. For example,
* the returned range may be "segment1 segment2 segment3" if "segment1" and "segment3" are
* inside the area and "segment2" is not.
*
* @param area area for which the text range will be found
* @param segmentIterator iterator for determining the ranges of text to be considered as a text
* segment
* @return int array of size 2 containing the start (inclusive) and end (exclusive) character
* offsets of the range, or null if there are no text segments inside the area
* @hide
*/
@Nullable
public int[] getRangeForRect(@NonNull RectF area, @NonNull SegmentIterator segmentIterator) {
// Find the first line whose vertical center is below the top of the area.
int startLine = getLineForVertical((int) area.top);
int startLineTop = getLineTop(startLine);
int startLineBottom = getLineBottomWithoutSpacing(startLine);
if (area.top > (startLineTop + startLineBottom) / 2f) {
startLine++;
if (startLine >= getLineCount()) {
// The top of the area is below the vertical center of the last line, so the area
// does not contain any text.
return null;
}
}
// Find the last line whose vertical center is above the bottom of the area.
int endLine = getLineForVertical((int) area.bottom);
int endLineTop = getLineTop(endLine);
int endLineBottom = getLineBottomWithoutSpacing(endLine);
if (area.bottom < (endLineTop + endLineBottom) / 2f) {
endLine--;
}
if (endLine < startLine) {
// There are no lines with vertical centers between the top and bottom of the area, so
// the area does not contain any text.
return null;
}
int start = getStartOrEndOffsetForHorizontalInterval(
startLine, area.left, area.right, segmentIterator, /* getStart= */ true);
// If the area does not contain any text on this line, keep trying subsequent lines until
// the end line is reached.
while (start == -1 && startLine < endLine) {
startLine++;
start = getStartOrEndOffsetForHorizontalInterval(
startLine, area.left, area.right, segmentIterator, /* getStart= */ true);
}
if (start == -1) {
// All lines were checked, the area does not contain any text.
return null;
}
int end = getStartOrEndOffsetForHorizontalInterval(
endLine, area.left, area.right, segmentIterator, /* getStart= */ false);
// If the area does not contain any text on this line, keep trying previous lines until
// the start line is reached.
while (end == -1 && startLine < endLine) {
endLine--;
end = getStartOrEndOffsetForHorizontalInterval(
endLine, area.left, area.right, segmentIterator, /* getStart= */ false);
}
if (end == -1) {
// All lines were checked, the area does not contain any text.
return null;
}
// If a text segment spans multiple lines or multiple directional runs (e.g. a hyphenated
// word), then getStartOrEndOffsetForHorizontalInterval() can return an offset in the middle
// of a text segment. Adjust the range to include the rest of any partial text segments. If
// start is already the start boundary of a text segment, then this is a no-op.
start = segmentIterator.previousStartBoundary(start + 1);
end = segmentIterator.nextEndBoundary(end - 1);
return new int[] {start, end};
}
/**
* Finds the start character offset of the first text segment inside a horizontal interval
* within a line, or the end character offset of the last text segment inside the horizontal
* interval.
*
* @param line index of the line to search
* @param left left bound of the horizontal interval
* @param right right bound of the horizontal interval
* @param segmentIterator iterator for determining the ranges of text to be considered as a text
* segment
* @param getStart true to find the start of the first text segment inside the horizontal
* interval, false to find the end of the last text segment
* @return the start character offset of the first text segment inside the horizontal interval,
* or the end character offset of the last text segment inside the horizontal interval.
*/
private int getStartOrEndOffsetForHorizontalInterval(
@IntRange(from = 0) int line, float left, float right,
@NonNull SegmentIterator segmentIterator, boolean getStart) {
int lineStartOffset = getLineStart(line);
int lineEndOffset = getLineEnd(line);
if (lineStartOffset == lineEndOffset) {
return -1;
}
float[] horizontalBounds = new float[2 * (lineEndOffset - lineStartOffset)];
fillHorizontalBoundsForLine(line, horizontalBounds);
int lineStartPos = getLineStartPos(line, getParagraphLeft(line), getParagraphRight(line));
// Loop through the runs forwards or backwards depending on getStart value.
Layout.Directions directions = getLineDirections(line);
int runIndex = getStart ? 0 : directions.getRunCount() - 1;
while ((getStart && runIndex < directions.getRunCount()) || (!getStart && runIndex >= 0)) {
// runStartOffset and runEndOffset are offset indices within the line.
int runStartOffset = directions.getRunStart(runIndex);
int runEndOffset = Math.min(
runStartOffset + directions.getRunLength(runIndex),
lineEndOffset - lineStartOffset);
boolean isRtl = directions.isRunRtl(runIndex);
float runLeft = lineStartPos
+ (isRtl
? horizontalBounds[2 * (runEndOffset - 1)]
: horizontalBounds[2 * runStartOffset]);
float runRight = lineStartPos
+ (isRtl
? horizontalBounds[2 * runStartOffset + 1]
: horizontalBounds[2 * (runEndOffset - 1) + 1]);
int result =
getStart
? getStartOffsetForHorizontalIntervalWithinRun(
left, right, lineStartOffset, lineStartPos, horizontalBounds,
runStartOffset, runEndOffset, runLeft, runRight, isRtl,
segmentIterator)
: getEndOffsetForHorizontalIntervalWithinRun(
left, right, lineStartOffset, lineStartPos, horizontalBounds,
runStartOffset, runEndOffset, runLeft, runRight, isRtl,
segmentIterator);
if (result >= 0) {
return result;
}
runIndex += getStart ? 1 : -1;
}
return -1;
}
/**
* Finds the start character offset of the first text segment inside a horizontal interval
* within a directional run.
*
* @param left left bound of the horizontal interval
* @param right right bound of the horizontal interval
* @param lineStartOffset start character offset of the line containing this run
* @param lineStartPos start position of the line containing this run
* @param horizontalBounds array containing the signed horizontal bounds of the characters in
* the line. The left and right bounds of the character at offset i are stored at index (2 *
* i) and index (2 * i + 1). Bounds are relative to {@code lineStartPos}.
* @param runStartOffset start offset of the run relative to {@code lineStartOffset}
* @param runEndOffset end offset of the run relative to {@code lineStartOffset}
* @param runLeft left bound of the run
* @param runRight right bound of the run
* @param isRtl whether the run is right-to-left
* @param segmentIterator iterator for determining the ranges of text to be considered as a text
* segment
* @return the start character offset of the first text segment inside the horizontal interval
*/
private static int getStartOffsetForHorizontalIntervalWithinRun(
float left, float right,
@IntRange(from = 0) int lineStartOffset,
@IntRange(from = 0) int lineStartPos,
@NonNull float[] horizontalBounds,
@IntRange(from = 0) int runStartOffset, @IntRange(from = 0) int runEndOffset,
float runLeft, float runRight,
boolean isRtl,
@NonNull SegmentIterator segmentIterator) {
if (runRight < left || runLeft > right) {
// The run does not overlap the interval.
return -1;
}
// Find the first character in the run whose bounds overlap with the interval.
// firstCharOffset is an offset index within the line.
int firstCharOffset;
if ((!isRtl && left <= runLeft) || (isRtl && right >= runRight)) {
firstCharOffset = runStartOffset;
} else {
int low = runStartOffset;
int high = runEndOffset;
int guess;
while (high - low > 1) {
guess = (high + low) / 2;
// Left edge of the character at guess
float pos = lineStartPos + horizontalBounds[2 * guess];
if ((!isRtl && pos > left) || (isRtl && pos < right)) {
high = guess;
} else {
low = guess;
}
}
// The interval bound is between the left edge of the character at low and the left edge
// of the character at high. For LTR text, this is within the character at low. For RTL
// text, this is within the character at high.
firstCharOffset = isRtl ? high : low;
}
// Find the first text segment containing this character (or, if no text segment contains
// this character, the first text segment after this character). All previous text segments
// in this run are to the left (for LTR) of the interval.
segmentIterator.setRunLimits(
lineStartOffset + runStartOffset, lineStartOffset + runEndOffset);
int segmentEndOffset =
segmentIterator.nextEndBoundaryOrRunEnd(lineStartOffset + firstCharOffset);
if (segmentEndOffset == SegmentIterator.DONE) {
// There are no text segments containing or after firstCharOffset, so no text segments
// in this run overlap the interval.
return -1;
}
int segmentStartOffset = segmentIterator.previousStartBoundaryOrRunStart(segmentEndOffset);
float segmentCenter = lineStartPos
+ (horizontalBounds[2 * (segmentStartOffset - lineStartOffset)]
+ horizontalBounds[2 * (segmentEndOffset - lineStartOffset) - 1]) / 2;
if ((!isRtl && segmentCenter > right) || (isRtl && segmentCenter < left)) {
// The entire interval is to the left (for LTR) of the text segment's center. So the
// interval does not contain any text segments within this run.
return -1;
}
if ((!isRtl && segmentCenter >= left) || (isRtl && segmentCenter <= right)) {
// The center is within the interval, so return the start offset of this text segment.
return segmentStartOffset;
}
// If first text segment's center is not within the interval, try the next text segment.
segmentStartOffset = segmentIterator.nextStartBoundaryWithinRunLimits(segmentStartOffset);
if (segmentStartOffset == SegmentIterator.DONE
|| segmentStartOffset == lineStartOffset + runEndOffset) {
// No more text segments within this run.
return -1;
}
segmentEndOffset = segmentIterator.nextEndBoundaryOrRunEnd(segmentStartOffset);
segmentCenter = lineStartPos
+ (horizontalBounds[2 * (segmentStartOffset - lineStartOffset)]
+ horizontalBounds[2 * (segmentEndOffset - lineStartOffset) - 1]) / 2;
// We already know that segmentCenter >= left (for LTR) since the previous word contains the
// left point.
if ((!isRtl && segmentCenter <= right) || (isRtl && segmentCenter >= left)) {
return segmentStartOffset;
}
// If the second text segment is also not within the interval, then this means that the
// interval is between the centers of the first and second text segments, so it does not
// contain any text segments on this line.
return -1;
}
/**
* Finds the end character offset of the last text segment inside a horizontal interval within a
* directional run.
*
* @param left left bound of the horizontal interval
* @param right right bound of the horizontal interval
* @param lineStartOffset start character offset of the line containing this run
* @param lineStartPos start position of the line containing this run
* @param horizontalBounds array containing the signed horizontal bounds of the characters in
* the line. The left and right bounds of the character at offset i are stored at index (2 *
* i) and index (2 * i + 1). Bounds are relative to {@code lineStartPos}.
* @param runStartOffset start offset of the run relative to {@code lineStartOffset}
* @param runEndOffset end offset of the run relative to {@code lineStartOffset}
* @param runLeft left bound of the run
* @param runRight right bound of the run
* @param isRtl whether the run is right-to-left
* @param segmentIterator iterator for determining the ranges of text to be considered as a text
* segment
* @return the end character offset of the last text segment inside the horizontal interval
*/
private static int getEndOffsetForHorizontalIntervalWithinRun(
float left, float right,
@IntRange(from = 0) int lineStartOffset,
@IntRange(from = 0) int lineStartPos,
@NonNull float[] horizontalBounds,
@IntRange(from = 0) int runStartOffset, @IntRange(from = 0) int runEndOffset,
float runLeft, float runRight,
boolean isRtl,
@NonNull SegmentIterator segmentIterator) {
if (runRight < left || runLeft > right) {
// The run does not overlap the interval.
return -1;
}
// Find the last character in the run whose bounds overlap with the interval.
// firstCharOffset is an offset index within the line.
int lastCharOffset;
if ((!isRtl && right >= runRight) || (isRtl && left <= runLeft)) {
lastCharOffset = runEndOffset - 1;
} else {
int low = runStartOffset;
int high = runEndOffset;
int guess;
while (high - low > 1) {
guess = (high + low) / 2;
// Left edge of the character at guess
float pos = lineStartPos + horizontalBounds[2 * guess];
if ((!isRtl && pos > right) || (isRtl && pos < left)) {
high = guess;
} else {
low = guess;
}
}
// The interval bound is between the left edge of the character at low and the left edge
// of the character at high. For LTR text, this is within the character at low. For RTL
// text, this is within the character at high.
lastCharOffset = isRtl ? high : low;
}
// Find the last text segment containing this character (or, if no text segment
// contains this character, the first text segment before this character). All
// following text segments in this run are to the right (for LTR) of the interval.
segmentIterator.setRunLimits(
lineStartOffset + runStartOffset, lineStartOffset + runEndOffset);
// + 1 to allow segmentStartOffset = lineStartOffset + lastCharOffset
int segmentStartOffset =
segmentIterator.previousStartBoundaryOrRunStart(
lineStartOffset + lastCharOffset + 1);
if (segmentStartOffset == SegmentIterator.DONE) {
// There are no text segments containing or before lastCharOffset, so no text segments
// in this run overlap the interval.
return -1;
}
int segmentEndOffset = segmentIterator.nextEndBoundaryOrRunEnd(segmentStartOffset);
float segmentCenter = lineStartPos
+ (horizontalBounds[2 * (segmentStartOffset - lineStartOffset)]
+ horizontalBounds[2 * (segmentEndOffset - lineStartOffset) - 1]) / 2;
if ((!isRtl && segmentCenter < left) || (isRtl && segmentCenter > right)) {
// The entire interval is to the right (for LTR) of the text segment's center. So the
// interval does not contain any text segments within this run.
return -1;
}
if ((!isRtl && segmentCenter <= right) || (isRtl && segmentCenter >= left)) {
// The center is within the interval, so return the end offset of this text segment.
return segmentEndOffset;
}
// If first text segment's center is not within the interval, try the next text segment.
segmentEndOffset = segmentIterator.previousEndBoundaryWithinRunLimits(segmentEndOffset);
if (segmentEndOffset == SegmentIterator.DONE
|| segmentEndOffset == lineStartOffset + runStartOffset) {
// No more text segments within this run.
return -1;
}
segmentStartOffset = segmentIterator.previousStartBoundaryOrRunStart(segmentEndOffset);
segmentCenter = lineStartPos
+ (horizontalBounds[2 * (segmentStartOffset - lineStartOffset)]
+ horizontalBounds[2 * (segmentEndOffset - lineStartOffset) - 1]) / 2;
// We already know that segmentCenter <= right (for LTR) since the following word
// contains the right point.
if ((!isRtl && segmentCenter >= left) || (isRtl && segmentCenter <= right)) {
return segmentEndOffset;
}
// If the second text segment is also not within the interval, then this means that the
// interval is between the centers of the first and second text segments, so it does not
// contain any text segments on this line.
return -1;
}
/**
* Return the text offset after the last character on the specified line.
*/

View File

@@ -0,0 +1,111 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.text;
import android.annotation.IntRange;
/**
* Finds text segment boundaries within text. Subclasses can implement different types of text
* segments. Grapheme clusters and words are examples of possible text segments.
*
* <p>Granular units may not overlap, so every character belongs to at most one text segment. A
* character may belong to no text segments.
*
* <p>For example, a word level text segment iterator may subdivide the text "Hello, World!" into
* four text segments: "Hello", ",", "World", "!". The space character does not belong to any text
* segments.
*
* @hide
*/
public abstract class SegmentIterator {
public static final int DONE = -1;
private int mRunStartOffset;
private int mRunEndOffset;
/**
* Returns the character offset of the previous text segment start boundary before the specified
* character offset, or {@code DONE} if there are none.
*/
public abstract int previousStartBoundary(@IntRange(from = 0) int offset);
/**
* Returns the character offset of the previous text segment end boundary before the specified
* character offset, or {@code DONE} if there are none.
*/
public abstract int previousEndBoundary(@IntRange(from = 0) int offset);
/**
* Returns the character offset of the next text segment start boundary after the specified
* character offset, or {@code DONE} if there are none.
*/
public abstract int nextStartBoundary(@IntRange(from = 0) int offset);
/**
* Returns the character offset of the next text segment end boundary after the specified
* character offset, or {@code DONE} if there are none.
*/
public abstract int nextEndBoundary(@IntRange(from = 0) int offset);
/**
* Sets the start and end of a run which can be used to constrain the scope of the iterator's
* search.
*
* @hide
*/
void setRunLimits(
@IntRange(from = 0) int runStartOffset, @IntRange(from = 0) int runEndOffset) {
mRunStartOffset = runStartOffset;
mRunEndOffset = runEndOffset;
}
/** @hide */
int previousStartBoundaryOrRunStart(@IntRange(from = 0) int offset) {
int start = previousStartBoundary(offset);
if (start == DONE) {
return DONE;
}
return Math.max(start, mRunStartOffset);
}
/** @hide */
int previousEndBoundaryWithinRunLimits(@IntRange(from = 0) int offset) {
int end = previousEndBoundary(offset);
if (end <= mRunStartOffset) {
return DONE;
}
return end;
}
/** @hide */
int nextStartBoundaryWithinRunLimits(@IntRange(from = 0) int offset) {
int start = nextStartBoundary(offset);
if (start >= mRunEndOffset) {
return DONE;
}
return start;
}
/** @hide */
int nextEndBoundaryOrRunEnd(@IntRange(from = 0) int offset) {
int end = nextEndBoundary(offset);
if (end == DONE) {
return DONE;
}
return Math.min(end, mRunEndOffset);
}
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.text;
import android.annotation.IntRange;
import android.annotation.NonNull;
import android.icu.text.BreakIterator;
import android.text.method.WordIterator;
/**
* Implementation of {@code SegmentIterator} using words as the text segment. Word boundaries are
* found using {@code WordIterator}. Whitespace characters are excluded, so they are not included in
* any text segments.
*
* @hide
*/
public class WordSegmentIterator extends SegmentIterator {
private final CharSequence mText;
private final WordIterator mWordIterator;
public WordSegmentIterator(@NonNull CharSequence text, @NonNull WordIterator wordIterator) {
mText = text;
mWordIterator = wordIterator;
}
@Override
public int previousStartBoundary(@IntRange(from = 0) int offset) {
int boundary = offset;
do {
boundary = mWordIterator.prevBoundary(boundary);
if (boundary == BreakIterator.DONE) {
return DONE;
}
} while (Character.isWhitespace(mText.charAt(boundary)));
return boundary;
}
@Override
public int previousEndBoundary(@IntRange(from = 0) int offset) {
int boundary = offset;
do {
boundary = mWordIterator.prevBoundary(boundary);
if (boundary == BreakIterator.DONE || boundary == 0) {
return DONE;
}
} while (Character.isWhitespace(mText.charAt(boundary - 1)));
return boundary;
}
@Override
public int nextStartBoundary(@IntRange(from = 0) int offset) {
int boundary = offset;
do {
boundary = mWordIterator.nextBoundary(boundary);
if (boundary == BreakIterator.DONE || boundary == mText.length()) {
return DONE;
}
} while (Character.isWhitespace(mText.charAt(boundary)));
return boundary;
}
@Override
public int nextEndBoundary(@IntRange(from = 0) int offset) {
int boundary = offset;
do {
boundary = mWordIterator.nextBoundary(boundary);
if (boundary == BreakIterator.DONE) {
return DONE;
}
} while (Character.isWhitespace(mText.charAt(boundary - 1)));
return boundary;
}
}

View File

@@ -98,12 +98,14 @@ import android.text.BoringLayout;
import android.text.DynamicLayout;
import android.text.Editable;
import android.text.GetChars;
import android.text.GraphemeClusterSegmentIterator;
import android.text.GraphicsOperations;
import android.text.InputFilter;
import android.text.InputType;
import android.text.Layout;
import android.text.ParcelableSpan;
import android.text.PrecomputedText;
import android.text.SegmentIterator;
import android.text.Selection;
import android.text.SpanWatcher;
import android.text.Spannable;
@@ -117,6 +119,7 @@ import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.TextWatcher;
import android.text.WordSegmentIterator;
import android.text.method.AllCapsTransformationMethod;
import android.text.method.ArrowKeyMovementMethod;
import android.text.method.DateKeyListener;
@@ -183,11 +186,14 @@ import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.DeleteGesture;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.HandwritingGesture;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
import android.view.inputmethod.SelectGesture;
import android.view.inspector.InspectableProperty;
import android.view.inspector.InspectableProperty.EnumEntry;
import android.view.inspector.InspectableProperty.FlagEntry;
@@ -9282,6 +9288,58 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
return false;
}
/** @hide */
public int performHandwritingSelectGesture(@NonNull SelectGesture gesture) {
int[] range = getRangeForRect(gesture.getSelectionArea(), gesture.getGranularity());
if (range == null) {
return handleGestureFailure(gesture);
}
Selection.setSelection(getEditableText(), range[0], range[1]);
mEditor.startSelectionActionModeAsync(/* adjustSelection= */ false);
return 0;
}
/** @hide */
public int performHandwritingDeleteGesture(@NonNull DeleteGesture gesture) {
int[] range = getRangeForRect(gesture.getDeletionArea(), gesture.getGranularity());
if (range == null) {
return handleGestureFailure(gesture);
}
getEditableText().delete(range[0], range[1]);
Selection.setSelection(getEditableText(), range[0]);
// TODO: Delete extra spaces.
return 0;
}
private int handleGestureFailure(HandwritingGesture gesture) {
if (!TextUtils.isEmpty(gesture.getFallbackText())) {
getEditableText()
.replace(getSelectionStart(), getSelectionEnd(), gesture.getFallbackText());
}
return 0;
}
@Nullable
private int[] getRangeForRect(@NonNull RectF area, int granularity) {
// The coordinates provided are screen coordinates - transform to content coordinates.
int[] screenToViewport = getLocationOnScreen();
area = new RectF(area);
area.offset(
-(screenToViewport[0] + viewportToContentHorizontalOffset()),
-(screenToViewport[1] + viewportToContentVerticalOffset()));
SegmentIterator segmentIterator;
if (granularity == HandwritingGesture.GRANULARITY_WORD) {
WordIterator wordIterator = getWordIterator();
wordIterator.setCharSequence(mText, 0, mText.length());
segmentIterator = new WordSegmentIterator(mText, wordIterator);
} else {
segmentIterator = new GraphemeClusterSegmentIterator(mText, mTextPaint);
}
return mLayout.getRangeForRect(area, segmentIterator);
}
/** @hide */
@VisibleForTesting
@UnsupportedAppUsage

View File

@@ -22,6 +22,9 @@ import static android.view.inputmethod.InputConnectionProto.SELECTED_TEXT;
import static android.view.inputmethod.InputConnectionProto.SELECTED_TEXT_END;
import static android.view.inputmethod.InputConnectionProto.SELECTED_TEXT_START;
import android.annotation.CallbackExecutor;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.os.Bundle;
import android.text.Editable;
import android.text.Selection;
@@ -31,12 +34,18 @@ import android.util.proto.ProtoOutputStream;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.CompletionInfo;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.DeleteGesture;
import android.view.inputmethod.DumpableInputConnection;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.HandwritingGesture;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.SelectGesture;
import android.widget.TextView;
import java.util.concurrent.Executor;
import java.util.function.IntConsumer;
/**
* Base class for an editable InputConnection instance. This is created by {@link TextView} or
* {@link android.widget.EditText}.
@@ -256,6 +265,23 @@ public final class EditableInputConnection extends BaseInputConnection
return true;
}
@Override
public void performHandwritingGesture(
@NonNull HandwritingGesture gesture, @Nullable @CallbackExecutor Executor executor,
@Nullable IntConsumer consumer) {
int result;
if (gesture instanceof SelectGesture) {
result = mTextView.performHandwritingSelectGesture((SelectGesture) gesture);
} else if (gesture instanceof DeleteGesture) {
result = mTextView.performHandwritingDeleteGesture((DeleteGesture) gesture);
} else {
result = 0;
}
if (executor != null && consumer != null) {
executor.execute(() -> consumer.accept(result));
}
}
@Override
public void dumpDebug(ProtoOutputStream proto, long fieldId) {
final long token = proto.start(fieldId);

View File

@@ -0,0 +1,407 @@
/*
* Copyright (C) 2022 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.text;
import static com.google.common.truth.Truth.assertThat;
import android.graphics.RectF;
import android.graphics.Typeface;
import android.text.method.WordIterator;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class LayoutGetRangeForRectTest {
private static final int WIDTH = 200;
private static final int HEIGHT = 1000;
private static final String DEFAULT_TEXT = ""
// Line 0 (offset 0 to 18)
// - Word 0 (offset 0 to 4) has bounds [0, 40], center 20
// - Word 1 (offset 5 to 11) has bounds [50, 110], center 80
// - Word 2 (offset 12 to 17) has bounds [120, 170], center 145
+ "XXXX XXXXXX XXXXX "
// Line 1 (offset 18 to 36)
// - Word 3 (offset 18 to 23) has bounds [0, 50], center 25
// - Word 4 (offset 24 to 26, RTL) has bounds [100, 110], center 105
// - Word 5 (offset 27 to 29, RTL) has bounds [80, 90], center 85
// - Word 6 start part (offset 30 to 32, RTL) has bounds [60, 70], center 65
// - Word 6 end part (offset 32 to 35) has bounds [110, 140], center 125
+ "XXXXX \u05D1\u05D1 \u05D1\u05D1 \u05D1\u05D1XXX\n"
// Line 2 (offset 36 to 38)
// - Word 7 start part (offset 36 to 38) has bounds [0, 150], center 75
// Line 3 (offset 38 to 40)
// - Word 7 middle part (offset 38 to 40) has bounds [0, 150], center 75
// Line 4 (offset 40 to 46)
// - Word 7 end part (offset 40 to 41) has bounds [0, 100], center 50
// - Word 8 (offset 42 to 44) has bounds [110, 130], center 120
+ "CLCLC XX \n";
private Layout mLayout;
private float[] mLineCenters;
private GraphemeClusterSegmentIterator mGraphemeClusterSegmentIterator;
private WordSegmentIterator mWordSegmentIterator;
@Before
public void setup() {
// The test font includes the following characters:
// U+0020 ( ): 10em
// U+002E (.): 10em
// U+0049 (I): 1em
// U+0056 (V): 5em
// U+0058 (X): 10em
// U+004C (L): 50em
// U+0043 (C): 100em
// U+005F (_): 0em
// U+05D0 : 1em // HEBREW LETTER ALEF
// U+05D1 : 5em // HEBREW LETTER BET
// U+FFFD (invalid surrogate will be replaced to this): 7em
// U+10331 (\uD800\uDF31): 10em
// Undefined : 0.5em
TextPaint textPaint = new TextPaint();
textPaint.setTypeface(
Typeface.createFromAsset(
InstrumentationRegistry.getInstrumentation().getTargetContext().getAssets(),
"fonts/StaticLayoutLineBreakingTestFont.ttf"));
// Make 1 em equal to 1 pixel.
textPaint.setTextSize(1.0f);
mLayout = StaticLayout.Builder.obtain(
DEFAULT_TEXT, 0, DEFAULT_TEXT.length(), textPaint, WIDTH).build();
mLineCenters = new float[mLayout.getLineCount()];
for (int i = 0; i < mLayout.getLineCount(); ++i) {
mLineCenters[i] = (mLayout.getLineTop(i) + mLayout.getLineBottomWithoutSpacing(i)) / 2f;
}
mGraphemeClusterSegmentIterator =
new GraphemeClusterSegmentIterator(DEFAULT_TEXT, textPaint);
WordIterator wordIterator = new WordIterator();
wordIterator.setCharSequence(DEFAULT_TEXT, 0, DEFAULT_TEXT.length());
mWordSegmentIterator = new WordSegmentIterator(DEFAULT_TEXT, wordIterator);
}
@Test
public void getRangeForRect_character() {
// Character 1 on line 0 has center 15.
// Character 2 on line 0 has center 25.
RectF area = new RectF(14f, mLineCenters[0] - 1f, 26f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(1, 3).inOrder();
}
@Test
public void getRangeForRect_character_partialCharacterButNotCenter() {
// Character 0 on line 0 has center 5.
// Character 1 on line 0 has center 15.
// Character 2 on line 0 has center 25.
// Character 3 on line 0 has center 35.
RectF area = new RectF(6f, mLineCenters[0] - 1f, 34f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
// Area partially overlaps characters 0 and 3 but does not contain their centers.
assertThat(range).asList().containsExactly(1, 3).inOrder();
}
@Test
public void getRangeForRect_character_rtl() {
// Character 25 on line 1 has center 102.5.
// Character 26 on line 1 has center 95.
RectF area = new RectF(94f, mLineCenters[1] - 1f, 103f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(25, 27).inOrder();
}
@Test
public void getRangeForRect_character_ltrAndRtl() {
// Character 22 on line 1 has center 45.
// The end of the RTL run (offset 24 to 32) on line 1 is at 60.
RectF area = new RectF(44f, mLineCenters[1] - 1f, 93f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(22, 32).inOrder();
}
@Test
public void getRangeForRect_character_rtlAndLtr() {
// The start of the RTL run (offset 24 to 32) on line 1 is at 110.
// Character 33 on line 1 has center 125.
RectF area = new RectF(93f, mLineCenters[1] - 1f, 131f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(24, 34).inOrder();
}
@Test
public void getRangeForRect_character_betweenCharacters_shouldFallback() {
// Character 1 on line 0 has center 15.
// Character 2 on line 0 has center 25.
RectF area = new RectF(16f, mLineCenters[0] - 1f, 24f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).isNull();
}
@Test
public void getRangeForRect_character_betweenLines_shouldFallback() {
// Area top is below the center of line 0.
// Area bottom is above the center of line 1.
RectF area = new RectF(0f, mLineCenters[0] + 1f, WIDTH, mLineCenters[1] - 1f);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
// Area partially covers two lines but does not contain the center of any characters.
assertThat(range).isNull();
}
@Test
public void getRangeForRect_character_multiLine() {
// Character 9 on line 0 has center 95.
// Character 42 on line 4 has center 115.
RectF area = new RectF(93f, 0, 118f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(9, 43).inOrder();
}
@Test
public void getRangeForRect_character_multiLine_betweenCharactersOnSomeLines() {
// Character 6 on line 0 has center 65.
// Character 7 on line 0 has center 75.
// Character 30 on line 1 has center 67.5.
// Character 36 on line 2 has center 50.
// Character 37 on line 2 has center 125.
// Character 38 on line 3 has center 50.
// Character 39 on line 3 has center 125.
// Character 40 on line 4 has center 50.
// Character 41 on line 4 has center 105.
RectF area = new RectF(66f, 0, 69f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
// Area crosses all lines but does not contain the center of any characters on lines 0, 2,
// 3, or 4. So the only included character is character 30 on line 1.
assertThat(range).asList().containsExactly(30, 31).inOrder();
}
@Test
public void getRangeForRect_character_multiLine_betweenCharactersOnAllLines_shouldFallback() {
// Character 6 on line 0 has center 65.
// Character 7 on line 0 has center 75.
// Character 30 on line 1 has center 67.5.
// Character 31 on line 1 has center 62.5.
// Character 36 on line 2 has center 50.
// Character 37 on line 2 has center 125.
// Character 38 on line 3 has center 50.
// Character 39 on line 3 has center 125.
// Character 40 on line 4 has center 50.
// Character 41 on line 4 has center 105.
RectF area = new RectF(66f, 0, 67f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
// Area crosses all lines but does not contain the center of any characters.
assertThat(range).isNull();
}
@Test
public void getRangeForRect_character_all() {
// Entire area, should include all text.
RectF area = new RectF(0f, 0f, WIDTH, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mGraphemeClusterSegmentIterator);
assertThat(range).asList().containsExactly(0, DEFAULT_TEXT.length()).inOrder();
}
@Test
public void getRangeForRect_word() {
// Word 1 (offset 5 to 11) on line 0 has center 80.
RectF area = new RectF(79f, mLineCenters[0] - 1f, 81f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).asList().containsExactly(5, 11).inOrder();
}
@Test
public void getRangeForRect_word_partialWordButNotCenter() {
// Word 0 (offset 0 to 4) on line 0 has center 20.
// Word 1 (offset 5 to 11) on line 0 has center 80.
// Word 2 (offset 12 to 17) on line 0 center 145
RectF area = new RectF(21f, mLineCenters[0] - 1f, 144f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Area partially overlaps words 0 and 2 but does not contain their centers, so only word 1
// is included. Whitespace between words is not included.
assertThat(range).asList().containsExactly(5, 11).inOrder();
}
@Test
public void getRangeForRect_word_rtl() {
// Word 4 (offset 24 to 26, RTL) on line 1 center 105
RectF area = new RectF(88f, mLineCenters[1] - 1f, 119f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).asList().containsExactly(24, 26).inOrder();
}
@Test
public void getRangeForRect_word_ltrAndRtl() {
// Word 3 (offset 18 to 23) on line 1 has center 25
// The end of the RTL run (offset 24 to 32) on line 1 is at 60.
RectF area = new RectF(24f, mLineCenters[1] - 1f, 93f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Selects all of word 6, not just the first RTL part.
assertThat(range).asList().containsExactly(18, 35).inOrder();
}
@Test
public void getRangeForRect_word_rtlAndLtr() {
// The start of the RTL run (offset 24 to 32) on line 1 is at 110.
// End part of word 6 (offset 32 to 35) on line 1 has center 125.
RectF area = new RectF(93f, mLineCenters[1] - 1f, 174f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).asList().containsExactly(24, 35).inOrder();
}
@Test
public void getRangeForRect_word_betweenWords_shouldFallback() {
// Word 1 on line 0 has center 80.
// Word 2 on line 0 has center 145.
RectF area = new RectF(81f, mLineCenters[0] - 1f, 144f, mLineCenters[0] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).isNull();
}
@Test
public void getRangeForRect_word_betweenLines_shouldFallback() {
// Area top is below the center of line 0.
// Area bottom is above the center of line 1.
RectF area = new RectF(0f, mLineCenters[0] + 1f, WIDTH, mLineCenters[1] - 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Area partially covers two lines but does not contain the center of any words.
assertThat(range).isNull();
}
@Test
public void getRangeForRect_word_multiLine() {
// Word 1 (offset 5 to 11) on line 0 has center 80.
// End part of word 7 (offset 40 to 41) on line 4 has center 50.
RectF area = new RectF(42f, 0, 91f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).asList().containsExactly(5, 41).inOrder();
}
@Test
public void getRangeForRect_word_multiLine_betweenWordsOnSomeLines() {
// Word 1 on line 0 has center 80.
// Word 2 on line 0 has center 145.
// Word 5 (offset 27 to 29) on line 1 has center 85.
// Word 7 on line 2 has center 50.
// Word 37 on line 2 has center 125.
// Word 38 on line 3 has center 50.
// Word 39 on line 3 has center 125.
// Word 40 on line 4 has center 50.
// Word 41 on line 4 has center 105.
RectF area = new RectF(84f, 0, 86f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Area crosses all lines but does not contain the center of any words on lines 0, 2, 3, or
// 4. So the only included word is word 5 on line 1.
assertThat(range).asList().containsExactly(27, 29).inOrder();
}
@Test
public void getRangeForRect_word_multiLine_betweenCharactersOnAllLines_shouldFallback() {
// Word 1 on line 0 has center 80.
// Word 2 on line 0 has center 145.
// Word 4 on line 1 has center 105.
// Word 5 on line 1 has center 85.
// Word 7 on line 2 has center 50.
// Word 37 on line 2 has center 125.
// Word 38 on line 3 has center 50.
// Word 39 on line 3 has center 125.
// Word 40 on line 4 has center 50.
// Word 41 on line 4 has center 105.
RectF area = new RectF(86f, 0, 89f, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Area crosses all lines but does not contain the center of any words.
assertThat(range).isNull();
}
@Test
public void getRangeForRect_word_wordSpansMultipleLines_firstPartInsideArea() {
// Word 5 (offset 27 to 29) on line 1 has center 85.
// First part of word 7 (offset 36 to 38) on line 2 has center 75.
RectF area = new RectF(74f, mLineCenters[1] - 1f, 86f, mLineCenters[2] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Selects all of word 7, not just the first part on line 2.
assertThat(range).asList().containsExactly(27, 41).inOrder();
}
@Test
public void getRangeForRect_word_wordSpansMultipleLines_middlePartInsideArea() {
// Middle part of word 7 (offset 38 to 40) on line 2 has center 75.
RectF area = new RectF(74f, mLineCenters[3] - 1f, 75f, mLineCenters[3] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Selects all of word 7, not just the middle part on line 3.
assertThat(range).asList().containsExactly(36, 41).inOrder();
}
@Test
public void getRangeForRect_word_wordSpansMultipleLines_endPartInsideArea() {
// End part of word 7 (offset 40 to 41) on line 4 has center 50.
// Word 8 (offset 42 to 44) on line 4 has center 120
RectF area = new RectF(49f, mLineCenters[4] - 1f, 121f, mLineCenters[4] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Selects all of word 7, not just the middle part on line 3.
assertThat(range).asList().containsExactly(36, 44).inOrder();
}
@Test
public void getRangeForRect_word_wordSpansMultipleRuns_firstPartInsideArea() {
// Word 5 (offset 27 to 29) on line 1 has center 85.
// First part of word 6 (offset 30 to 32) on line 1 has center 65.
RectF area = new RectF(64f, mLineCenters[1] - 1f, 86f, mLineCenters[1] + 1f);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
// Selects all of word 6, not just the first RTL part.
assertThat(range).asList().containsExactly(27, 35).inOrder();
}
@Test
public void getRangeForRect_word_all() {
// Entire area, should include all text except the last two whitespace characters.
RectF area = new RectF(0f, 0f, WIDTH, HEIGHT);
int[] range = mLayout.getRangeForRect(area, mWordSegmentIterator);
assertThat(range).asList().containsExactly(0, DEFAULT_TEXT.length() - 2).inOrder();
}
}