diff --git a/core/java/android/text/GraphemeClusterSegmentIterator.java b/core/java/android/text/GraphemeClusterSegmentIterator.java new file mode 100644 index 0000000000000..e3976a7eab35e --- /dev/null +++ b/core/java/android/text/GraphemeClusterSegmentIterator.java @@ -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; + } +} diff --git a/core/java/android/text/Layout.java b/core/java/android/text/Layout.java index 4efc8381a112b..b5f7c545aa072 100644 --- a/core/java/android/text/Layout.java +++ b/core/java/android/text/Layout.java @@ -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. + * + *
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. + * + *
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. */ diff --git a/core/java/android/text/SegmentIterator.java b/core/java/android/text/SegmentIterator.java new file mode 100644 index 0000000000000..00522e542eecc --- /dev/null +++ b/core/java/android/text/SegmentIterator.java @@ -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. + * + *
Granular units may not overlap, so every character belongs to at most one text segment. A + * character may belong to no text segments. + * + *
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); + } +} diff --git a/core/java/android/text/WordSegmentIterator.java b/core/java/android/text/WordSegmentIterator.java new file mode 100644 index 0000000000000..11153192b2867 --- /dev/null +++ b/core/java/android/text/WordSegmentIterator.java @@ -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; + } +} diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java index 6a572c530a1ea..8419d8adb3296 100644 --- a/core/java/android/widget/TextView.java +++ b/core/java/android/widget/TextView.java @@ -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 diff --git a/core/java/com/android/internal/inputmethod/EditableInputConnection.java b/core/java/com/android/internal/inputmethod/EditableInputConnection.java index be7501fea7d08..5393db8335cfa 100644 --- a/core/java/com/android/internal/inputmethod/EditableInputConnection.java +++ b/core/java/com/android/internal/inputmethod/EditableInputConnection.java @@ -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); diff --git a/core/tests/coretests/src/android/text/LayoutGetRangeForRectTest.java b/core/tests/coretests/src/android/text/LayoutGetRangeForRectTest.java new file mode 100644 index 0000000000000..32fdb5e850bd1 --- /dev/null +++ b/core/tests/coretests/src/android/text/LayoutGetRangeForRectTest.java @@ -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(); + } +}