diff --git a/core/api/current.txt b/core/api/current.txt
index bd2a438162352..af187922d2755 100644
--- a/core/api/current.txt
+++ b/core/api/current.txt
@@ -45091,6 +45091,7 @@ package android.text {
ctor protected Layout(CharSequence, android.text.TextPaint, int, android.text.Layout.Alignment, float, float);
method public void draw(android.graphics.Canvas);
method public void draw(android.graphics.Canvas, android.graphics.Path, android.graphics.Paint, int);
+ method public void fillCharacterBounds(@IntRange(from=0) int, @IntRange(from=0) int, @NonNull float[], @IntRange(from=0) int);
method public final android.text.Layout.Alignment getAlignment();
method public abstract int getBottomPadding();
method public void getCursorPath(int, android.graphics.Path, CharSequence);
diff --git a/core/java/android/text/Layout.java b/core/java/android/text/Layout.java
index 95adb7765f1ec..4efc8381a112b 100644
--- a/core/java/android/text/Layout.java
+++ b/core/java/android/text/Layout.java
@@ -18,6 +18,7 @@ package android.text;
import android.annotation.IntDef;
import android.annotation.IntRange;
+import android.annotation.NonNull;
import android.compat.annotation.UnsupportedAppUsage;
import android.graphics.Canvas;
import android.graphics.Paint;
@@ -1311,6 +1312,101 @@ public abstract class Layout {
return horizontal;
}
+ /**
+ * 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.
+ *
+ * @param start the start index to compute the character bounds, inclusive.
+ * @param end the end index to compute the character bounds, exclusive.
+ * @param bounds the array to fill in the character bounds. The array is divided into segments
+ * of four where each index in that segment represents left, top, right and
+ * bottom of the character.
+ * @param boundsStart the inclusive start index in the array to start filling in the values
+ * from.
+ *
+ * @throws IndexOutOfBoundsException if the range defined by {@code start} and {@code end}
+ * exceeds the range of the text, or {@code bounds} doesn't have enough space to store the
+ * result.
+ * @throws IllegalArgumentException if {@code bounds} is null.
+ */
+ public void fillCharacterBounds(@IntRange(from = 0) int start, @IntRange(from = 0) int end,
+ @NonNull float[] bounds, @IntRange(from = 0) int boundsStart) {
+ if (start < 0 || end < start || end > mText.length()) {
+ throw new IndexOutOfBoundsException("given range: " + start + ", " + end + " is "
+ + "out of the text range: 0, " + mText.length());
+ }
+
+ if (bounds == null) {
+ throw new IllegalArgumentException("bounds can't be null.");
+ }
+
+ final int neededLength = 4 * (end - start);
+ if (neededLength > bounds.length - boundsStart) {
+ throw new IndexOutOfBoundsException("bounds doesn't have enough space to store the "
+ + "result, needed: " + neededLength + " had: "
+ + (bounds.length - boundsStart));
+ }
+
+ if (start == end) {
+ return;
+ }
+
+ final int startLine = getLineForOffset(start);
+ final int endLine = getLineForOffset(end - 1);
+ float[] horizontalBounds = null;
+ for (int line = startLine; line <= endLine; ++line) {
+ 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];
+ }
+
+ tl.measureAllBounds(horizontalBounds, null);
+ TextLine.recycle(tl);
+ final int lineLeft = getParagraphLeft(line);
+ final int lineRight = getParagraphRight(line);
+ final int lineStartPos = getLineStartPos(line, lineLeft, lineRight);
+
+ final int lineTop = getLineTop(line);
+ final int lineBottom = getLineBottom(line);
+
+ final int startIndex = Math.max(start, lineStart);
+ final int endIndex = Math.min(end, lineEnd);
+ for (int index = startIndex; index < endIndex; ++index) {
+ final int offset = index - lineStart;
+ final float left = horizontalBounds[offset * 2] + lineStartPos;
+ final float right = horizontalBounds[offset * 2 + 1] + lineStartPos;
+
+ final int boundsIndex = boundsStart + 4 * (index - start);
+ bounds[boundsIndex] = left;
+ bounds[boundsIndex + 1] = lineTop;
+ bounds[boundsIndex + 2] = right;
+ bounds[boundsIndex + 3] = lineBottom;
+ }
+ }
+ }
+
/**
* Get the leftmost position that should be exposed for horizontal
* scrolling on the specified line.
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index b7bb09153fccf..fe0cbcb4eb7e2 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -12530,64 +12530,38 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
public void populateCharacterBounds(CursorAnchorInfo.Builder builder,
int startIndex, int endIndex, float viewportToContentHorizontalOffset,
float viewportToContentVerticalOffset) {
- final int minLine = mLayout.getLineForOffset(startIndex);
- final int maxLine = mLayout.getLineForOffset(endIndex - 1);
final Rect rect = new Rect();
getLocalVisibleRect(rect);
final RectF visibleRect = new RectF(rect);
- for (int line = minLine; line <= maxLine; ++line) {
- final int lineStart = mLayout.getLineStart(line);
- final int lineEnd = mLayout.getLineEnd(line);
- final int offsetStart = Math.max(lineStart, startIndex);
- final int offsetEnd = Math.min(lineEnd, endIndex);
- final boolean ltrLine =
- mLayout.getParagraphDirection(line) == Layout.DIR_LEFT_TO_RIGHT;
- final float[] widths = new float[offsetEnd - offsetStart];
- mLayout.getPaint().getTextWidths(mTransformed, offsetStart, offsetEnd, widths);
- final float top = mLayout.getLineTop(line);
- final float bottom = mLayout.getLineBottom(line);
- for (int offset = offsetStart; offset < offsetEnd; ++offset) {
- final float charWidth = widths[offset - offsetStart];
- final boolean isRtl = mLayout.isRtlCharAt(offset);
- // TODO: This doesn't work perfectly for text with custom styles and
- // TAB chars.
- final float left;
- if (ltrLine) {
- if (isRtl) {
- left = mLayout.getSecondaryHorizontal(offset) - charWidth;
- } else {
- left = mLayout.getPrimaryHorizontal(offset);
- }
- } else {
- if (!isRtl) {
- left = mLayout.getSecondaryHorizontal(offset);
- } else {
- left = mLayout.getPrimaryHorizontal(offset) - charWidth;
- }
- }
- final float right = left + charWidth;
- // TODO: Check top-right and bottom-left as well.
- final float localLeft = left + viewportToContentHorizontalOffset;
- final float localRight = right + viewportToContentHorizontalOffset;
- final float localTop = top + viewportToContentVerticalOffset;
- final float localBottom = bottom + viewportToContentVerticalOffset;
- final boolean isTopLeftVisible = visibleRect.contains(localLeft, localTop);
- final boolean isBottomRightVisible =
- visibleRect.contains(localRight, localBottom);
- int characterBoundsFlags = 0;
- if (isTopLeftVisible || isBottomRightVisible) {
- characterBoundsFlags |= FLAG_HAS_VISIBLE_REGION;
- }
- if (!isTopLeftVisible || !isBottomRightVisible) {
- characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
- }
- if (isRtl) {
- characterBoundsFlags |= CursorAnchorInfo.FLAG_IS_RTL;
- }
- // Here offset is the index in Java chars.
- builder.addCharacterBounds(offset, localLeft, localTop, localRight,
- localBottom, characterBoundsFlags);
+
+ final float[] characterBounds = new float[4 * (endIndex - startIndex)];
+ mLayout.fillCharacterBounds(startIndex, endIndex, characterBounds, 0);
+ final int limit = endIndex - startIndex;
+ for (int offset = 0; offset < limit; ++offset) {
+ final float left =
+ characterBounds[offset * 4] + viewportToContentHorizontalOffset;
+ final float top =
+ characterBounds[offset * 4 + 1] + viewportToContentVerticalOffset;
+ final float right =
+ characterBounds[offset * 4 + 2] + viewportToContentHorizontalOffset;
+ final float bottom =
+ characterBounds[offset * 4 + 3] + viewportToContentVerticalOffset;
+
+ final boolean hasVisibleRegion = visibleRect.intersects(left, top, right, bottom);
+ final boolean hasInVisibleRegion = !visibleRect.contains(left, top, right, bottom);
+ int characterBoundsFlags = 0;
+ if (hasVisibleRegion) {
+ characterBoundsFlags |= FLAG_HAS_VISIBLE_REGION;
}
+ if (hasInVisibleRegion) {
+ characterBoundsFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
+ }
+
+ if (mLayout.isRtlCharAt(offset)) {
+ characterBoundsFlags |= CursorAnchorInfo.FLAG_IS_RTL;
+ }
+ builder.addCharacterBounds(offset + startIndex, left, top, right, bottom,
+ characterBoundsFlags);
}
}
diff --git a/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttf b/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttf
index 36ed024d10a3a..dfb1f0badf83a 100644
Binary files a/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttf and b/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttf differ
diff --git a/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttx b/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttx
index feefed384c492..622b86159d998 100644
--- a/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttx
+++ b/core/tests/coretests/assets/fonts/StaticLayoutLineBreakingTestFont.ttx
@@ -144,6 +144,7 @@
+
diff --git a/core/tests/coretests/src/android/widget/TextViewPopulateCharacterBoundsTest.java b/core/tests/coretests/src/android/widget/TextViewPopulateCharacterBoundsTest.java
new file mode 100644
index 0000000000000..d31a6a95746a7
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/TextViewPopulateCharacterBoundsTest.java
@@ -0,0 +1,422 @@
+/*
+ * 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.widget;
+
+import static android.view.inputmethod.CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
+import static android.view.inputmethod.CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
+import static android.view.inputmethod.CursorAnchorInfo.FLAG_IS_RTL;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.graphics.Matrix;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.text.Layout;
+import android.util.TypedValue;
+import android.view.View;
+import android.view.inputmethod.CursorAnchorInfo;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import androidx.test.rule.ActivityTestRule;
+
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class TextViewPopulateCharacterBoundsTest {
+ @Rule
+ public ActivityTestRule mActivityRule = new ActivityTestRule<>(
+ TextViewActivity.class);
+ private Activity mActivity;
+ private Instrumentation mInstrumentation;
+ private Typeface mTypeface;
+ @Before
+ public void setup() {
+ mActivity = mActivityRule.getActivity();
+ mInstrumentation = InstrumentationRegistry.getInstrumentation();
+
+ // The test font has following coverage and width.
+ // U+0020: 10em
+ // U+002E (.): 10em
+ // U+0043 (C): 100em
+ // U+0049 (I): 1em
+ // U+004C (L): 50em
+ // U+0056 (V): 5em
+ // U+0058 (X): 10em
+ // 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
+ mTypeface = Typeface.createFromAsset(mInstrumentation.getTargetContext().getAssets(),
+ "fonts/StaticLayoutLineBreakingTestFont.ttf");
+ }
+
+ private TextView createTextView(String text, float textSize, int width, int height) {
+ final TextView textView = new TextView(mActivity);
+ textView.setTypeface(mTypeface);
+
+ textView.setText(text);
+ // Make 1 em equal to 10 pixels.
+ textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize);
+ textView.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ textView.layout(0, 0, width, height);
+ return textView;
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_LTR() {
+ final String text = "IIVX";
+ final TextView textView = createTextView(text, 10.0f, 200, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, layout.getLineTop(0), 10.0f, layout.getLineBottom(0)),
+ new RectF(10.0f, layout.getLineTop(0), 20.0f, layout.getLineBottom(0)),
+ new RectF(20.0f, layout.getLineTop(0), 70.0f, layout.getLineBottom(0)),
+ new RectF(70.0f, layout.getLineTop(0), 170.0f, layout.getLineBottom(0))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_LTR_multiline() {
+ final String text = "IVVI";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, layout.getLineTop(0), 10.0f, layout.getLineBottom(0)),
+ new RectF(10.0f, layout.getLineTop(0), 60.0f, layout.getLineBottom(0)),
+ // The second line.
+ new RectF(0.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1)),
+ new RectF(50.0f, layout.getLineTop(1), 60.0f, layout.getLineBottom(1))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_LTR_newline() {
+ final String text = "IV\nVI";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, layout.getLineTop(0), 10.0f, layout.getLineBottom(0)),
+ new RectF(10.0f, layout.getLineTop(0), 60.0f, layout.getLineBottom(0)),
+ // Newline belongs to the first line, and it has 0 width in the font.
+ new RectF(60.0f, layout.getLineTop(0), 60.0f, layout.getLineBottom(0)),
+ // The second line.
+ new RectF(0.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1)),
+ new RectF(50.0f, layout.getLineTop(1), 60.0f, layout.getLineBottom(1))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_RTL() {
+ final String text = "\u05D0\u05D0\u05D1\u05D1";
+ final TextView textView = createTextView(text, 10.0f, 200, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(190.0f, layout.getLineTop(0), 200.0f, layout.getLineBottom(0)),
+ new RectF(180.0f, layout.getLineTop(0), 190.0f, layout.getLineBottom(0)),
+ new RectF(130.0f, layout.getLineTop(0), 180.0f, layout.getLineBottom(0)),
+ new RectF(80.0f, layout.getLineTop(0), 130.0f, layout.getLineBottom(0))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_RTL_multiline() {
+ final String text = "\u05D0\u05D1\u05D1\u05D0";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(90.0f, layout.getLineTop(0), 100.0f, layout.getLineBottom(0)),
+ new RectF(40.0f, layout.getLineTop(0), 90.0f, layout.getLineBottom(0)),
+ // The second line
+ new RectF(50.0f, layout.getLineTop(1), 100.0f, layout.getLineBottom(1)),
+ new RectF(40.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_RTL_newline() {
+ final String text = "\u05D0\u05D1\n\u05D1\u05D0";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(90.0f, layout.getLineTop(0), 100.0f, layout.getLineBottom(0)),
+ new RectF(40.0f, layout.getLineTop(0), 90.0f, layout.getLineBottom(0)),
+ // Newline belongs to the first line, and it has 0 width in the font.
+ new RectF(40.0f, layout.getLineTop(0), 40.0f, layout.getLineBottom(0)),
+ // The second line
+ new RectF(50.0f, layout.getLineTop(1), 100.0f, layout.getLineBottom(1)),
+ new RectF(40.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ // Newline is in an RTL run.
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_BiDi() {
+ final String text = "IV\u05D0\u05D1IV";
+ final TextView textView = createTextView(text, 10.0f, 200, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, layout.getLineTop(0), 10.0f, layout.getLineBottom(0)),
+ new RectF(10.0f, layout.getLineTop(0), 60.0f, layout.getLineBottom(0)),
+ new RectF(110.0f, layout.getLineTop(0), 120.0f, layout.getLineBottom(0)),
+ new RectF(60.0f, layout.getLineTop(0), 110.0f, layout.getLineBottom(0)),
+ new RectF(120.0f, layout.getLineTop(0), 130.0f, layout.getLineBottom(0)),
+ new RectF(130.0f, layout.getLineTop(0), 180.0f, layout.getLineBottom(0))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_BiDi_multiline() {
+ final String text = "IV\u05D0\u05D1IV";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final Layout layout = textView.getLayout();
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, layout.getLineTop(0), 10.0f, layout.getLineBottom(0)),
+ new RectF(10.0f, layout.getLineTop(0), 60.0f, layout.getLineBottom(0)),
+ new RectF(60.0f, layout.getLineTop(0), 70.0f, layout.getLineBottom(0)),
+ // The second line.
+ new RectF(0.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1)),
+ new RectF(50.0f, layout.getLineTop(1), 60.0f, layout.getLineBottom(1)),
+ // The third line
+ new RectF(0.0f, layout.getLineTop(2), 50.0f, layout.getLineBottom(2))
+ };
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION | FLAG_IS_RTL,
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_charactersWithInvisibleRegion() {
+ final String text = "IVVI";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+ final Layout layout = textView.getLayout();
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ final int verticalOffset = -50;
+ // Make viewToContentVerticalOffset -50px to simulate the case where TextView is scrolled.
+ textView.populateCharacterBounds(builder, 0, text.length(), 0, verticalOffset);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ final float firstLineTop = layout.getLineTop(0) + verticalOffset;
+ final float firstLineBottom = layout.getLineBottom(0) + verticalOffset;
+
+ final float secondLineTop = layout.getLineTop(1) + verticalOffset;
+ final float secondLineBottom = layout.getLineBottom(1) + verticalOffset;
+ final RectF[] expectedCharacterBounds = new RectF[] {
+ new RectF(0.0f, firstLineTop, 10.0f, firstLineBottom),
+ new RectF(10.0f, firstLineTop, 60.0f, firstLineBottom),
+ new RectF(0.0f, secondLineTop, 50.0f, secondLineBottom),
+ new RectF(50.0f, secondLineTop, 60.0f, secondLineBottom)
+ };
+
+ assertCharacterBounds(expectedCharacterBounds, cursorAnchorInfo);
+
+ final int[] expectedCharacterBoundsFlags = new int[] {
+ FLAG_HAS_VISIBLE_REGION | FLAG_HAS_INVISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION | FLAG_HAS_INVISIBLE_REGION,
+ // The second line is visible.
+ FLAG_HAS_VISIBLE_REGION,
+ FLAG_HAS_VISIBLE_REGION
+ };
+ assertCharacterBoundsFlags(expectedCharacterBoundsFlags, cursorAnchorInfo);
+ }
+
+ @Test
+ public void testPopulateCharacterBounds_withinRange() {
+ final String text = "IVVI";
+ final TextView textView = createTextView(text, 10.0f, 100, 1000);
+ final Layout layout = textView.getLayout();
+
+ final CursorAnchorInfo.Builder builder = new CursorAnchorInfo.Builder();
+ builder.setMatrix(Matrix.IDENTITY_MATRIX);
+ // Only query for character bounds within the range [2, 4).
+ textView.populateCharacterBounds(builder, 2, 4, 0, 0);
+
+ final CursorAnchorInfo cursorAnchorInfo = builder.build();
+
+ assertThat(cursorAnchorInfo.getCharacterBounds(2)).isEqualTo(
+ new RectF(0.0f, layout.getLineTop(1), 50.0f, layout.getLineBottom(1)));
+ assertThat(cursorAnchorInfo.getCharacterBounds(3)).isEqualTo(
+ new RectF(50.0f, layout.getLineTop(1), 60.0f, layout.getLineBottom(1)));
+
+ assertThat(cursorAnchorInfo.getCharacterBoundsFlags(2)).isEqualTo(FLAG_HAS_VISIBLE_REGION);
+ assertThat(cursorAnchorInfo.getCharacterBoundsFlags(3)).isEqualTo(FLAG_HAS_VISIBLE_REGION);
+ }
+
+ private static void assertCharacterBounds(RectF[] expected,
+ CursorAnchorInfo cursorAnchorInfo) {
+ final RectF[] characterBounds = new RectF[expected.length];
+ for (int i = 0; i < expected.length; ++i) {
+ characterBounds[i] = cursorAnchorInfo.getCharacterBounds(i);
+ }
+ assertArrayEquals(expected, characterBounds);
+ }
+
+ private static void assertCharacterBoundsFlags(int[] expected,
+ CursorAnchorInfo cursorAnchorInfo) {
+ final int[] characterBoundsFlags = new int[expected.length];
+ for (int i = 0; i < expected.length; ++i) {
+ characterBoundsFlags[i] = cursorAnchorInfo.getCharacterBoundsFlags(i);
+ }
+ assertArrayEquals(expected, characterBoundsFlags);
+ }
+
+}