From e7422024fed80a30107b47feee0b17ad6af6b7da Mon Sep 17 00:00:00 2001 From: Haoyu Zhang Date: Tue, 19 Jul 2022 09:56:12 -0700 Subject: [PATCH] Introduce Layout#fillCharacterBounds Introduce Layout#fillCharacterBounds to compute character bounds faster. This change makes TextView#populateCharacter 5 times faster. After: populateCharacterBounds[mTextLength (100))]_min (ns): 52361 populateCharacterBounds[mTextLength (300))]_min (ns): 148941 populateCharacterBounds[mTextLength (1,000))]_min (ns): 463237 populateCharacterBounds[mTextLength (3,000))]_min (ns): 1388081 populateCharacterBounds[mTextLength (10,000))]_min (ns): 1165867 Before: populateCharacterBounds[mTextLength (100))]_min (ns): 204065 populateCharacterBounds[mTextLength (300))]_min (ns): 653159 populateCharacterBounds[mTextLength (1,000))]_min (ns): 2184327 populateCharacterBounds[mTextLength (3,000))]_min (ns): 6770318 populateCharacterBounds[mTextLength (10,000))]_min (ns): 22777530 Bug: 233922052 Test: atest android.text.TextViewPopulateCharacterBoundsTest Test: atest android.text.TextViewCursorAnchorInfoPerfTest Change-Id: I68a47cf3099b89e0a10604b7706fa7a3dea66d32 --- core/api/current.txt | 1 + core/java/android/text/Layout.java | 96 ++++ core/java/android/widget/TextView.java | 82 ++-- .../StaticLayoutLineBreakingTestFont.ttf | Bin 888 -> 904 bytes .../StaticLayoutLineBreakingTestFont.ttx | 1 + .../TextViewPopulateCharacterBoundsTest.java | 422 ++++++++++++++++++ 6 files changed, 548 insertions(+), 54 deletions(-) create mode 100644 core/tests/coretests/src/android/widget/TextViewPopulateCharacterBoundsTest.java diff --git a/core/api/current.txt b/core/api/current.txt index 1e1a4a6f5506c..d7c4bcdfeed8b 100644 --- a/core/api/current.txt +++ b/core/api/current.txt @@ -45090,6 +45090,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 36ed024d10a3a2e210741f86f34f1cc8e7ae44dc..dfb1f0badf83ae71b8de62b5db6909f8f7d00dc6 100644 GIT binary patch delta 183 zcmeyt*1_(=z{tSBz{SwOz|5fFAFOYb!I5swz`&>h6irCZO)Ov#R#pP?*8urD(sL@) zfHV*=RR9UbjMT&wVLq8F6J3lMQzll(Th;(Y-xTB*m;B$sV8p<{oB`x3fNcQUVx%t6 z70++;6=+imP@Lh)M+XiNeV=W9&cq8d8M!8BF@}nQECK;uAm)N%#>pobmrqt;WZ9h0 H#K{N%A^jxS delta 167 zcmeBR|H0h6irCZO)Ov#Rt7Q|*8qt%={c2Y zKpF^`azJE8YGMkv7HiZ*7h}eVi52peAkjAk`NbvwcQ6<+Ffc~|`3hhgfVMauc3_X^ wxA_XRDFvka%0~wd5Pe;#{nNw?GZ_^oXEBCyE&vGu0ng+sjLSFIGjTEk0NXw!FaQ7m 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); + } + +}