summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--core/java/android/widget/Editor.java166
-rw-r--r--core/java/android/widget/TextView.java174
-rw-r--r--core/tests/coretests/src/android/widget/EditTextCursorAnchorInfoTest.java576
3 files changed, 759 insertions, 157 deletions
diff --git a/core/java/android/widget/Editor.java b/core/java/android/widget/Editor.java
index 136846a7bf61..3f452f8ca2f9 100644
--- a/core/java/android/widget/Editor.java
+++ b/core/java/android/widget/Editor.java
@@ -124,13 +124,11 @@ import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.view.animation.LinearInterpolator;
import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.CursorAnchorInfo;
-import android.view.inputmethod.EditorBoundsInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
-import android.view.inputmethod.TextAppearanceInfo;
import android.view.textclassifier.TextClassification;
import android.view.textclassifier.TextClassificationManager;
import android.widget.AdapterView.OnItemClickListener;
@@ -4667,7 +4665,7 @@ public class Editor {
* {@link InputMethodManager#isWatchingCursor(View)} returns false.
*/
private final class CursorAnchorInfoNotifier implements TextViewPositionListener {
- final CursorAnchorInfo.Builder mSelectionInfoBuilder = new CursorAnchorInfo.Builder();
+ final CursorAnchorInfo.Builder mCursorAnchorInfoBuilder = new CursorAnchorInfo.Builder();
final Matrix mViewToScreenMatrix = new Matrix();
@Override
@@ -4687,165 +4685,21 @@ public class Editor {
// Skip if the IME has not requested the cursor/anchor position.
final int knownCursorAnchorInfoModes =
InputConnection.CURSOR_UPDATE_IMMEDIATE | InputConnection.CURSOR_UPDATE_MONITOR;
- if ((mInputMethodState.mUpdateCursorAnchorInfoMode & knownCursorAnchorInfoModes) == 0) {
+ if ((ims.mUpdateCursorAnchorInfoMode & knownCursorAnchorInfoModes) == 0) {
return;
}
- Layout layout = mTextView.getLayout();
- if (layout == null) {
- return;
- }
- final int filter = mInputMethodState.mUpdateCursorAnchorInfoFilter;
- boolean includeEditorBounds =
- (filter & InputConnection.CURSOR_UPDATE_FILTER_EDITOR_BOUNDS) != 0;
- boolean includeCharacterBounds =
- (filter & InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS) != 0;
- boolean includeInsertionMarker =
- (filter & InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER) != 0;
- boolean includeVisibleLineBounds =
- (filter & InputConnection.CURSOR_UPDATE_FILTER_VISIBLE_LINE_BOUNDS) != 0;
- boolean includeTextAppearance =
- (filter & InputConnection.CURSOR_UPDATE_FILTER_TEXT_APPEARANCE) != 0;
- boolean includeAll =
- (!includeEditorBounds && !includeCharacterBounds && !includeInsertionMarker
- && !includeVisibleLineBounds && !includeTextAppearance);
-
- includeEditorBounds |= includeAll;
- includeCharacterBounds |= includeAll;
- includeInsertionMarker |= includeAll;
- includeVisibleLineBounds |= includeAll;
- includeTextAppearance |= includeAll;
-
- final CursorAnchorInfo.Builder builder = mSelectionInfoBuilder;
- builder.reset();
-
- final int selectionStart = mTextView.getSelectionStart();
- builder.setSelectionRange(selectionStart, mTextView.getSelectionEnd());
-
- // Construct transformation matrix from view local coordinates to screen coordinates.
- mViewToScreenMatrix.reset();
- mTextView.transformMatrixToGlobal(mViewToScreenMatrix);
- builder.setMatrix(mViewToScreenMatrix);
-
- if (includeEditorBounds) {
- final RectF editorBounds = new RectF();
- editorBounds.set(0 /* left */, 0 /* top */,
- mTextView.getWidth(), mTextView.getHeight());
- final RectF handwritingBounds = new RectF(
- -mTextView.getHandwritingBoundsOffsetLeft(),
- -mTextView.getHandwritingBoundsOffsetTop(),
- mTextView.getWidth() + mTextView.getHandwritingBoundsOffsetRight(),
- mTextView.getHeight() + mTextView.getHandwritingBoundsOffsetBottom());
- EditorBoundsInfo.Builder boundsBuilder = new EditorBoundsInfo.Builder();
- EditorBoundsInfo editorBoundsInfo = boundsBuilder.setEditorBounds(editorBounds)
- .setHandwritingBounds(handwritingBounds).build();
- builder.setEditorBoundsInfo(editorBoundsInfo);
- }
-
- if (includeCharacterBounds || includeInsertionMarker || includeVisibleLineBounds) {
- final float viewportToContentHorizontalOffset =
- mTextView.viewportToContentHorizontalOffset();
- final float viewportToContentVerticalOffset =
- mTextView.viewportToContentVerticalOffset();
- final boolean isTextTransformed = (mTextView.getTransformationMethod() != null
- && mTextView.getTransformed() instanceof OffsetMapping);
- if (includeCharacterBounds && !isTextTransformed) {
- final CharSequence text = mTextView.getText();
- if (text instanceof Spannable) {
- final Spannable sp = (Spannable) text;
- int composingTextStart = EditableInputConnection.getComposingSpanStart(sp);
- int composingTextEnd = EditableInputConnection.getComposingSpanEnd(sp);
- if (composingTextEnd < composingTextStart) {
- final int temp = composingTextEnd;
- composingTextEnd = composingTextStart;
- composingTextStart = temp;
- }
- final boolean hasComposingText =
- (0 <= composingTextStart) && (composingTextStart
- < composingTextEnd);
- if (hasComposingText) {
- final CharSequence composingText = text.subSequence(composingTextStart,
- composingTextEnd);
- builder.setComposingText(composingTextStart, composingText);
- mTextView.populateCharacterBounds(builder, composingTextStart,
- composingTextEnd, viewportToContentHorizontalOffset,
- viewportToContentVerticalOffset);
- }
- }
- }
- if (includeInsertionMarker) {
- // Treat selectionStart as the insertion point.
- if (0 <= selectionStart) {
- final int offsetTransformed = mTextView.originalToTransformed(
- selectionStart, OffsetMapping.MAP_STRATEGY_CURSOR);
- final int line = layout.getLineForOffset(offsetTransformed);
- final float insertionMarkerX =
- layout.getPrimaryHorizontal(offsetTransformed)
- + viewportToContentHorizontalOffset;
- final float insertionMarkerTop = layout.getLineTop(line)
- + viewportToContentVerticalOffset;
- final float insertionMarkerBaseline = layout.getLineBaseline(line)
- + viewportToContentVerticalOffset;
- final float insertionMarkerBottom =
- layout.getLineBottom(line, /* includeLineSpacing= */ false)
- + viewportToContentVerticalOffset;
- final boolean isTopVisible = mTextView
- .isPositionVisible(insertionMarkerX, insertionMarkerTop);
- final boolean isBottomVisible = mTextView
- .isPositionVisible(insertionMarkerX, insertionMarkerBottom);
- int insertionMarkerFlags = 0;
- if (isTopVisible || isBottomVisible) {
- insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
- }
- if (!isTopVisible || !isBottomVisible) {
- insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
- }
- if (layout.isRtlCharAt(offsetTransformed)) {
- insertionMarkerFlags |= CursorAnchorInfo.FLAG_IS_RTL;
- }
- builder.setInsertionMarkerLocation(insertionMarkerX, insertionMarkerTop,
- insertionMarkerBaseline, insertionMarkerBottom,
- insertionMarkerFlags);
- }
- }
+ final CursorAnchorInfo cursorAnchorInfo =
+ mTextView.getCursorAnchorInfo(ims.mUpdateCursorAnchorInfoFilter,
+ mCursorAnchorInfoBuilder, mViewToScreenMatrix);
- if (includeVisibleLineBounds) {
- final Rect visibleRect = new Rect();
- if (mTextView.getContentVisibleRect(visibleRect)) {
- // Subtract the viewportToContentVerticalOffset to convert the view
- // coordinates to layout coordinates.
- final float visibleTop =
- visibleRect.top - viewportToContentVerticalOffset;
- final float visibleBottom =
- visibleRect.bottom - viewportToContentVerticalOffset;
- final int firstLine =
- layout.getLineForVertical((int) Math.floor(visibleTop));
- final int lastLine =
- layout.getLineForVertical((int) Math.ceil(visibleBottom));
-
- for (int line = firstLine; line <= lastLine; ++line) {
- final float left = layout.getLineLeft(line)
- + viewportToContentHorizontalOffset;
- final float top = layout.getLineTop(line)
- + viewportToContentVerticalOffset;
- final float right = layout.getLineRight(line)
- + viewportToContentHorizontalOffset;
- final float bottom = layout.getLineBottom(line, false)
- + viewportToContentVerticalOffset;
- builder.addVisibleLineBounds(left, top, right, bottom);
- }
- }
- }
- }
+ if (cursorAnchorInfo != null) {
+ imm.updateCursorAnchorInfo(mTextView, cursorAnchorInfo);
- if (includeTextAppearance) {
- builder.setTextAppearanceInfo(TextAppearanceInfo.createFromTextView(mTextView));
+ // Drop the immediate flag if any.
+ mInputMethodState.mUpdateCursorAnchorInfoMode &=
+ ~InputConnection.CURSOR_UPDATE_IMMEDIATE;
}
- imm.updateCursorAnchorInfo(mTextView, builder.build());
-
- // Drop the immediate flag if any.
- mInputMethodState.mUpdateCursorAnchorInfoMode &=
- ~InputConnection.CURSOR_UPDATE_IMMEDIATE;
}
}
diff --git a/core/java/android/widget/TextView.java b/core/java/android/widget/TextView.java
index 77df1f1ab2d1..626df4857c13 100644
--- a/core/java/android/widget/TextView.java
+++ b/core/java/android/widget/TextView.java
@@ -196,6 +196,7 @@ import android.view.inputmethod.CorrectionInfo;
import android.view.inputmethod.CursorAnchorInfo;
import android.view.inputmethod.DeleteGesture;
import android.view.inputmethod.DeleteRangeGesture;
+import android.view.inputmethod.EditorBoundsInfo;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.ExtractedText;
import android.view.inputmethod.ExtractedTextRequest;
@@ -209,6 +210,7 @@ import android.view.inputmethod.PreviewableHandwritingGesture;
import android.view.inputmethod.RemoveSpaceGesture;
import android.view.inputmethod.SelectGesture;
import android.view.inputmethod.SelectRangeGesture;
+import android.view.inputmethod.TextAppearanceInfo;
import android.view.inputmethod.TextBoundsInfo;
import android.view.inspector.InspectableProperty;
import android.view.inspector.InspectableProperty.EnumEntry;
@@ -13658,7 +13660,7 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
* @return true if at least part of the text content is visible; false if the text content is
* completely clipped or translated out of the visible area.
*/
- boolean getContentVisibleRect(Rect rect) {
+ private boolean getContentVisibleRect(Rect rect) {
if (!getLocalVisibleRect(rect)) {
return false;
}
@@ -13744,6 +13746,176 @@ public class TextView extends View implements ViewTreeObserver.OnPreDrawListener
}
/**
+ * Compute {@link CursorAnchorInfo} from this {@link TextView}.
+ *
+ * @param filter the {@link CursorAnchorInfo} update filter which specified the needed
+ * information from IME.
+ * @param cursorAnchorInfoBuilder a cached {@link CursorAnchorInfo.Builder} object used to build
+ * the result {@link CursorAnchorInfo}.
+ * @param viewToScreenMatrix a cached {@link Matrix} object used to compute the view to screen
+ * matrix.
+ * @return the result {@link CursorAnchorInfo} to be passed to IME.
+ * @hide
+ */
+ @VisibleForTesting
+ @Nullable
+ public CursorAnchorInfo getCursorAnchorInfo(@InputConnection.CursorUpdateFilter int filter,
+ @NonNull CursorAnchorInfo.Builder cursorAnchorInfoBuilder,
+ @NonNull Matrix viewToScreenMatrix) {
+ Layout layout = getLayout();
+ if (layout == null) {
+ return null;
+ }
+ boolean includeEditorBounds =
+ (filter & InputConnection.CURSOR_UPDATE_FILTER_EDITOR_BOUNDS) != 0;
+ boolean includeCharacterBounds =
+ (filter & InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS) != 0;
+ boolean includeInsertionMarker =
+ (filter & InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER) != 0;
+ boolean includeVisibleLineBounds =
+ (filter & InputConnection.CURSOR_UPDATE_FILTER_VISIBLE_LINE_BOUNDS) != 0;
+ boolean includeTextAppearance =
+ (filter & InputConnection.CURSOR_UPDATE_FILTER_TEXT_APPEARANCE) != 0;
+ boolean includeAll =
+ (!includeEditorBounds && !includeCharacterBounds && !includeInsertionMarker
+ && !includeVisibleLineBounds && !includeTextAppearance);
+
+ includeEditorBounds |= includeAll;
+ includeCharacterBounds |= includeAll;
+ includeInsertionMarker |= includeAll;
+ includeVisibleLineBounds |= includeAll;
+ includeTextAppearance |= includeAll;
+
+ final CursorAnchorInfo.Builder builder = cursorAnchorInfoBuilder;
+ builder.reset();
+
+ final int selectionStart = getSelectionStart();
+ builder.setSelectionRange(selectionStart, getSelectionEnd());
+
+ // Construct transformation matrix from view local coordinates to screen coordinates.
+ viewToScreenMatrix.reset();
+ transformMatrixToGlobal(viewToScreenMatrix);
+ builder.setMatrix(viewToScreenMatrix);
+
+ if (includeEditorBounds) {
+ final RectF editorBounds = new RectF();
+ editorBounds.set(0 /* left */, 0 /* top */,
+ getWidth(), getHeight());
+ final RectF handwritingBounds = new RectF(
+ -getHandwritingBoundsOffsetLeft(),
+ -getHandwritingBoundsOffsetTop(),
+ getWidth() + getHandwritingBoundsOffsetRight(),
+ getHeight() + getHandwritingBoundsOffsetBottom());
+ EditorBoundsInfo.Builder boundsBuilder = new EditorBoundsInfo.Builder();
+ EditorBoundsInfo editorBoundsInfo = boundsBuilder.setEditorBounds(editorBounds)
+ .setHandwritingBounds(handwritingBounds).build();
+ builder.setEditorBoundsInfo(editorBoundsInfo);
+ }
+
+ if (includeCharacterBounds || includeInsertionMarker || includeVisibleLineBounds) {
+ final float viewportToContentHorizontalOffset =
+ viewportToContentHorizontalOffset();
+ final float viewportToContentVerticalOffset =
+ viewportToContentVerticalOffset();
+ final boolean isTextTransformed = (getTransformationMethod() != null
+ && getTransformed() instanceof OffsetMapping);
+ if (includeCharacterBounds && !isTextTransformed) {
+ final CharSequence text = getText();
+ if (text instanceof Spannable) {
+ final Spannable sp = (Spannable) text;
+ int composingTextStart = EditableInputConnection.getComposingSpanStart(sp);
+ int composingTextEnd = EditableInputConnection.getComposingSpanEnd(sp);
+ if (composingTextEnd < composingTextStart) {
+ final int temp = composingTextEnd;
+ composingTextEnd = composingTextStart;
+ composingTextStart = temp;
+ }
+ final boolean hasComposingText =
+ (0 <= composingTextStart) && (composingTextStart
+ < composingTextEnd);
+ if (hasComposingText) {
+ final CharSequence composingText = text.subSequence(composingTextStart,
+ composingTextEnd);
+ builder.setComposingText(composingTextStart, composingText);
+ populateCharacterBounds(builder, composingTextStart,
+ composingTextEnd, viewportToContentHorizontalOffset,
+ viewportToContentVerticalOffset);
+ }
+ }
+ }
+
+ if (includeInsertionMarker) {
+ // Treat selectionStart as the insertion point.
+ if (0 <= selectionStart) {
+ final int offsetTransformed = originalToTransformed(
+ selectionStart, OffsetMapping.MAP_STRATEGY_CURSOR);
+ final int line = layout.getLineForOffset(offsetTransformed);
+ final float insertionMarkerX =
+ layout.getPrimaryHorizontal(offsetTransformed)
+ + viewportToContentHorizontalOffset;
+ final float insertionMarkerTop = layout.getLineTop(line)
+ + viewportToContentVerticalOffset;
+ final float insertionMarkerBaseline = layout.getLineBaseline(line)
+ + viewportToContentVerticalOffset;
+ final float insertionMarkerBottom =
+ layout.getLineBottom(line, /* includeLineSpacing= */ false)
+ + viewportToContentVerticalOffset;
+ final boolean isTopVisible =
+ isPositionVisible(insertionMarkerX, insertionMarkerTop);
+ final boolean isBottomVisible =
+ isPositionVisible(insertionMarkerX, insertionMarkerBottom);
+ int insertionMarkerFlags = 0;
+ if (isTopVisible || isBottomVisible) {
+ insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_VISIBLE_REGION;
+ }
+ if (!isTopVisible || !isBottomVisible) {
+ insertionMarkerFlags |= CursorAnchorInfo.FLAG_HAS_INVISIBLE_REGION;
+ }
+ if (layout.isRtlCharAt(offsetTransformed)) {
+ insertionMarkerFlags |= CursorAnchorInfo.FLAG_IS_RTL;
+ }
+ builder.setInsertionMarkerLocation(insertionMarkerX, insertionMarkerTop,
+ insertionMarkerBaseline, insertionMarkerBottom,
+ insertionMarkerFlags);
+ }
+ }
+
+ if (includeVisibleLineBounds) {
+ final Rect visibleRect = new Rect();
+ if (getContentVisibleRect(visibleRect)) {
+ // Subtract the viewportToContentVerticalOffset to convert the view
+ // coordinates to layout coordinates.
+ final float visibleTop =
+ visibleRect.top - viewportToContentVerticalOffset;
+ final float visibleBottom =
+ visibleRect.bottom - viewportToContentVerticalOffset;
+ final int firstLine =
+ layout.getLineForVertical((int) Math.floor(visibleTop));
+ final int lastLine =
+ layout.getLineForVertical((int) Math.ceil(visibleBottom));
+
+ for (int line = firstLine; line <= lastLine; ++line) {
+ final float left = layout.getLineLeft(line)
+ + viewportToContentHorizontalOffset;
+ final float top = layout.getLineTop(line)
+ + viewportToContentVerticalOffset;
+ final float right = layout.getLineRight(line)
+ + viewportToContentHorizontalOffset;
+ final float bottom = layout.getLineBottom(line, false)
+ + viewportToContentVerticalOffset;
+ builder.addVisibleLineBounds(left, top, right, bottom);
+ }
+ }
+ }
+ }
+
+ if (includeTextAppearance) {
+ builder.setTextAppearanceInfo(TextAppearanceInfo.createFromTextView(this));
+ }
+ return builder.build();
+ }
+
+ /**
* Creates the {@link TextBoundsInfo} for the text lines that intersects with the {@code rectF}.
* @hide
*/
diff --git a/core/tests/coretests/src/android/widget/EditTextCursorAnchorInfoTest.java b/core/tests/coretests/src/android/widget/EditTextCursorAnchorInfoTest.java
new file mode 100644
index 000000000000..1a019878f67f
--- /dev/null
+++ b/core/tests/coretests/src/android/widget/EditTextCursorAnchorInfoTest.java
@@ -0,0 +1,576 @@
+/*
+ * Copyright (C) 2023 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 com.google.common.truth.Truth.assertThat;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.graphics.Matrix;
+import android.graphics.Rect;
+import android.graphics.RectF;
+import android.graphics.Typeface;
+import android.graphics.drawable.Drawable;
+import android.graphics.drawable.ShapeDrawable;
+import android.util.TypedValue;
+import android.view.Gravity;
+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 com.google.common.collect.ImmutableList;
+
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RunWith(AndroidJUnit4.class)
+public class EditTextCursorAnchorInfoTest {
+ private static final CursorAnchorInfo.Builder sCursorAnchorInfoBuilder =
+ new CursorAnchorInfo.Builder();
+ private static final Matrix sMatrix = new Matrix();
+ private static final int[] sLocationOnScreen = new int[2];
+ private static Typeface sTypeface;
+ private static final float TEXT_SIZE = 1f;
+ // The line height of the test font font is 1.2 * textSize.
+ private static final int LINE_HEIGHT = 12;
+ private static final CharSequence DEFAULT_TEXT = "X\nXX\nXXX\nXXXX\nXXXXX";
+ private static final ImmutableList<RectF> DEFAULT_LINE_BOUNDS = ImmutableList.of(
+ new RectF(0f, 0f, 10f, LINE_HEIGHT),
+ new RectF(0f, LINE_HEIGHT, 20f, 2 * LINE_HEIGHT),
+ new RectF(0f, 2 * LINE_HEIGHT, 30f, 3 * LINE_HEIGHT),
+ new RectF(0f, 3 * LINE_HEIGHT, 40f, 4 * LINE_HEIGHT),
+ new RectF(0f, 4 * LINE_HEIGHT, 50f, 5 * LINE_HEIGHT));
+
+ @Rule
+ public ActivityTestRule<TextViewActivity> mActivityRule = new ActivityTestRule<>(
+ TextViewActivity.class);
+ private Activity mActivity;
+ private TextView mEditText;
+
+ @BeforeClass
+ public static void setupClass() {
+ Instrumentation instrumentation = 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
+ sTypeface = Typeface.createFromAsset(instrumentation.getTargetContext().getAssets(),
+ "fonts/StaticLayoutLineBreakingTestFont.ttf");
+ }
+
+ @Before
+ public void setup() {
+ mActivity = mActivityRule.getActivity();
+ }
+
+ @Test
+ public void testMatrix() {
+ setupEditText("", /* height= */ 100);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ Matrix actualMatrix = cursorAnchorInfo.getMatrix();
+ Matrix expectedMatrix = new Matrix();
+ expectedMatrix.setTranslate(sLocationOnScreen[0], sLocationOnScreen[1]);
+
+ assertThat(actualMatrix).isEqualTo(expectedMatrix);
+ }
+
+ @Test
+ public void testMatrix_withTranslation() {
+ float translationX = 10f;
+ float translationY = 20f;
+ createEditText("");
+ mEditText.setTranslationX(translationX);
+ mEditText.setTranslationY(translationY);
+ measureEditText(100);
+
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ Matrix actualMatrix = cursorAnchorInfo.getMatrix();
+ Matrix expectedMatrix = new Matrix();
+ expectedMatrix.setTranslate(sLocationOnScreen[0] + translationX,
+ sLocationOnScreen[1] + translationY);
+
+ assertThat(actualMatrix).isEqualTo(expectedMatrix);
+ }
+
+ @Test
+ public void testVisibleLineBounds_allVisible() {
+ setupEditText(DEFAULT_TEXT, /* height= */ 100);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ assertThat(lineBounds).isEqualTo(DEFAULT_LINE_BOUNDS);
+ }
+
+ @Test
+ public void testVisibleLineBounds_allVisible_withLineSpacing() {
+ float lineSpacing = 10f;
+ setupEditText("X\nXX\nXXX", /* height= */ 100, lineSpacing,
+ /* lineMultiplier=*/ 1f);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ assertThat(lineBounds.size()).isEqualTo(3);
+ assertThat(lineBounds.get(0)).isEqualTo(new RectF(0f, 0f, 10f, LINE_HEIGHT));
+
+ float line1Top = LINE_HEIGHT + lineSpacing;
+ float line1Bottom = line1Top + LINE_HEIGHT;
+ assertThat(lineBounds.get(1)).isEqualTo(new RectF(0f, line1Top, 20f, line1Bottom));
+
+ float line2Top = 2 * (LINE_HEIGHT + lineSpacing);
+ float line2Bottom = line2Top + LINE_HEIGHT;
+ assertThat(lineBounds.get(2)).isEqualTo(new RectF(0f, line2Top, 30f, line2Bottom));
+ }
+
+ @Test
+ public void testVisibleLineBounds_allVisible_withLineMultiplier() {
+ float lineMultiplier = 2f;
+ setupEditText("X\nXX\nXXX", /* height= */ 100, /* lineSpacing= */ 0f,
+ /* lineMultiplier=*/ lineMultiplier);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ assertThat(lineBounds.size()).isEqualTo(3);
+ assertThat(lineBounds.get(0)).isEqualTo(new RectF(0f, 0f, 10f, LINE_HEIGHT));
+
+ float line1Top = LINE_HEIGHT * lineMultiplier;
+ float line1Bottom = line1Top + LINE_HEIGHT;
+ assertThat(lineBounds.get(1)).isEqualTo(new RectF(0f, line1Top, 20f, line1Bottom));
+
+ float line2Top = 2 * LINE_HEIGHT * lineMultiplier;
+ float line2Bottom = line2Top + LINE_HEIGHT;
+ assertThat(lineBounds.get(2)).isEqualTo(new RectF(0f, line2Top, 30f, line2Bottom));
+ }
+
+ @Test
+ public void testVisibleLineBounds_cutBottomLines() {
+ // Line top is inclusive and line bottom is exclusive. And if the visible area's
+ // bottom equals to the line top, this line is still visible. So the line height is
+ // 3 * LINE_HEIGHT - 1 to avoid including the line 3.
+ setupEditText(DEFAULT_TEXT, /* height= */ 3 * LINE_HEIGHT - 1);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ assertThat(lineBounds).isEqualTo(DEFAULT_LINE_BOUNDS.subList(0, 3));
+ }
+
+ @Test
+ public void testVisibleLineBounds_scrolled_cutTopLines() {
+ // First 2 lines are cut.
+ int scrollY = 2 * LINE_HEIGHT;
+ setupEditText(/* height= */ 3 * LINE_HEIGHT,
+ /* scrollY= */ scrollY);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 2, 5);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0, -scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_scrolled_cutTopAndBottomLines() {
+ // Line top is inclusive and line bottom is exclusive. And if the visible area's
+ // bottom equals to the line top, this line is still visible. So the line height is
+ // 2 * LINE_HEIGHT - 1 which only shows 2 lines.
+ int scrollY = 2 * LINE_HEIGHT;
+ setupEditText(/* height= */ 2 * LINE_HEIGHT - 1,
+ /* scrollY= */ scrollY);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 2, 4);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0, -scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_scrolled_partiallyVisibleLines() {
+ // The first 2 lines are completely cut, line 2 and 3 are partially visible.
+ int scrollY = 2 * LINE_HEIGHT + LINE_HEIGHT / 2;
+ setupEditText(/* height= */ LINE_HEIGHT,
+ /* scrollY= */ scrollY);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 2, 4);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, -scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withCompoundDrawable_allVisible() {
+ int topDrawableHeight = LINE_HEIGHT;
+ Drawable topDrawable = createDrawable(topDrawableHeight);
+ Drawable bottomDrawable = createDrawable(2 * LINE_HEIGHT);
+ setupEditText(/* height= */ 100,
+ /* scrollY= */ 0, topDrawable, bottomDrawable);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = copy(DEFAULT_LINE_BOUNDS);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topDrawableHeight));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withCompoundDrawable_cutBottomLines() {
+ // The view's totally height is 5 * LINE_HEIGHT, and drawables take 3 * LINE_HEIGHT.
+ // Only first 2 lines are visible.
+ int topDrawableHeight = LINE_HEIGHT;
+ Drawable topDrawable = createDrawable(topDrawableHeight);
+ Drawable bottomDrawable = createDrawable(2 * LINE_HEIGHT + 1);
+ setupEditText(/* height= */ 5 * LINE_HEIGHT,
+ /* scrollY= */ 0, topDrawable, bottomDrawable);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 0, 2);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topDrawableHeight));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withCompoundDrawable_scrolled() {
+ // The view's totally height is 5 * LINE_HEIGHT, and drawables take 3 * LINE_HEIGHT.
+ // So 2 lines are visible. Because the view is scrolled vertically by LINE_HEIGHT,
+ // the line 1 and 2 are visible.
+ int topDrawableHeight = LINE_HEIGHT;
+ Drawable topDrawable = createDrawable(topDrawableHeight);
+ Drawable bottomDrawable = createDrawable(2 * LINE_HEIGHT + 1);
+ int scrollY = LINE_HEIGHT;
+ setupEditText(/* height= */ 5 * LINE_HEIGHT, scrollY,
+ topDrawable, bottomDrawable);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 1, 3);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topDrawableHeight - scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withCompoundDrawable_partiallyVisible() {
+ // The view's totally height is 5 * LINE_HEIGHT, and drawables take 3 * LINE_HEIGHT.
+ // And because the view is scrolled vertically by 0.5 * LINE_HEIGHT,
+ // the line 0, 1 and 2 are visible.
+ int topDrawableHeight = LINE_HEIGHT;
+ Drawable topDrawable = createDrawable(topDrawableHeight);
+ Drawable bottomDrawable = createDrawable(2 * LINE_HEIGHT + 1);
+ int scrollY = LINE_HEIGHT / 2;
+ setupEditText(/* height= */ 5 * LINE_HEIGHT, scrollY,
+ topDrawable, bottomDrawable);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 0, 3);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topDrawableHeight - scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withPaddings_allVisible() {
+ int topPadding = LINE_HEIGHT;
+ int bottomPadding = LINE_HEIGHT;
+ setupEditText(/* height= */ 100, /* scrollY= */ 0, topPadding, bottomPadding);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = copy(DEFAULT_LINE_BOUNDS);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topPadding));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withPaddings_cutBottomLines() {
+ // The view's totally height is 5 * LINE_HEIGHT, and paddings take 3 * LINE_HEIGHT.
+ // So 2 lines are visible.
+ int topPadding = LINE_HEIGHT;
+ int bottomPadding = 2 * LINE_HEIGHT + 1;
+ setupEditText(/* height= */ 5 * LINE_HEIGHT, /* scrollY= */ 0, topPadding, bottomPadding);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 0, 2);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topPadding));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withPaddings_scrolled() {
+ // The view's totally height is 5 * LINE_HEIGHT, and paddings take 3 * LINE_HEIGHT.
+ // So 2 lines are visible. Because the view is scrolled vertically by LINE_HEIGHT,
+ // the line 1 and 2 are visible.
+ int topPadding = LINE_HEIGHT;
+ int bottomPadding = 2 * LINE_HEIGHT + 1;
+ int scrollY = LINE_HEIGHT;
+ setupEditText(/* height= */ 5 * LINE_HEIGHT, scrollY,
+ topPadding, bottomPadding);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 1, 3);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topPadding - scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_withPadding_partiallyVisible() {
+ // The view's totally height is 5 * LINE_HEIGHT, and paddings take 3 * LINE_HEIGHT.
+ // And because the view is scrolled vertically by 0.5 * LINE_HEIGHT, the line 0, 1 and 2
+ // are visible.
+ int topPadding = LINE_HEIGHT;
+ int bottomPadding = 2 * LINE_HEIGHT + 1;
+ int scrollY = LINE_HEIGHT / 2;
+ setupEditText(/* height= */ 5 * LINE_HEIGHT, scrollY,
+ topPadding, bottomPadding);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 0, 3);
+ expectedLineBounds.forEach(rectF -> rectF.offset(0f, topPadding - scrollY));
+
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_clippedTop() {
+ // The first line is clipped off.
+ setupVerticalClippedEditText(LINE_HEIGHT, 5 * LINE_HEIGHT);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 1, 5);
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_clippedBottom() {
+ // The last line is clipped off.
+ setupVerticalClippedEditText(0, 4 * LINE_HEIGHT - 1);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 0, 4);
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ @Test
+ public void testVisibleLineBounds_clippedTopAndBottom() {
+ // The first and last line are clipped off.
+ setupVerticalClippedEditText(LINE_HEIGHT, 4 * LINE_HEIGHT - 1);
+ CursorAnchorInfo cursorAnchorInfo =
+ mEditText.getCursorAnchorInfo(0, sCursorAnchorInfoBuilder, sMatrix);
+
+ List<RectF> lineBounds = cursorAnchorInfo.getVisibleLineBounds();
+
+ List<RectF> expectedLineBounds = subList(DEFAULT_LINE_BOUNDS, 1, 4);
+ assertThat(lineBounds).isEqualTo(expectedLineBounds);
+ }
+
+ private List<RectF> copy(List<RectF> rectFList) {
+ List<RectF> result = new ArrayList<>();
+ for (RectF rectF : rectFList) {
+ result.add(new RectF(rectF));
+ }
+ return result;
+ }
+ private List<RectF> subList(List<RectF> rectFList, int start, int end) {
+ List<RectF> result = new ArrayList<>();
+ for (int index = start; index < end; ++index) {
+ result.add(new RectF(rectFList.get(index)));
+ }
+ return result;
+ }
+
+ private void setupVerticalClippedEditText(int visibleTop, int visibleBottom) {
+ ScrollView scrollView = new ScrollView(mActivity);
+ mEditText = new EditText(mActivity);
+ mEditText.setTypeface(sTypeface);
+ mEditText.setText(DEFAULT_TEXT);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PX, TEXT_SIZE);
+
+ mEditText.setPadding(0, 0, 0, 0);
+ mEditText.setCompoundDrawables(null, null, null, null);
+ mEditText.setCompoundDrawablePadding(0);
+
+ mEditText.scrollTo(0, 0);
+ mEditText.setLineSpacing(0f, 1f);
+
+ // Place the text layout top to the view's top.
+ mEditText.setGravity(Gravity.TOP);
+ int width = 1000;
+ int height = visibleBottom - visibleTop;
+
+ scrollView.addView(mEditText, new FrameLayout.LayoutParams(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(5 * LINE_HEIGHT, View.MeasureSpec.EXACTLY)));
+ scrollView.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ scrollView.layout(0, 0, width, height);
+
+ scrollView.scrollTo(0, visibleTop);
+ }
+
+ private void setupEditText(CharSequence text, int height) {
+ createEditText(text);
+ measureEditText(height);
+ }
+
+ private void setupEditText(CharSequence text, int height, float lineSpacing,
+ float lineMultiplier) {
+ createEditText(text);
+ mEditText.setLineSpacing(lineSpacing, lineMultiplier);
+ measureEditText(height);
+ }
+
+ private void setupEditText(int height, int scrollY) {
+ createEditText();
+ mEditText.scrollTo(0, scrollY);
+ measureEditText(height);
+ }
+
+ private void setupEditText(int height, int scrollY, Drawable drawableTop,
+ Drawable drawableBottom) {
+ createEditText();
+ mEditText.scrollTo(0, scrollY);
+ mEditText.setCompoundDrawables(null, drawableTop, null, drawableBottom);
+ measureEditText(height);
+ }
+
+ private void setupEditText(int height, int scrollY, int paddingTop,
+ int paddingBottom) {
+ createEditText();
+ mEditText.scrollTo(0, scrollY);
+ mEditText.setPadding(0, paddingTop, 0, paddingBottom);
+ measureEditText(height);
+ }
+
+ private void createEditText() {
+ createEditText(DEFAULT_TEXT);
+ }
+
+ private void createEditText(CharSequence text) {
+ mEditText = new EditText(mActivity);
+ mEditText.setTypeface(sTypeface);
+ mEditText.setText(text);
+ mEditText.setTextSize(TypedValue.COMPLEX_UNIT_PX, TEXT_SIZE);
+
+ mEditText.setPadding(0, 0, 0, 0);
+ mEditText.setCompoundDrawables(null, null, null, null);
+ mEditText.setCompoundDrawablePadding(0);
+
+ mEditText.scrollTo(0, 0);
+ mEditText.setLineSpacing(0f, 1f);
+
+ // Place the text layout top to the view's top.
+ mEditText.setGravity(Gravity.TOP);
+ }
+
+ private void measureEditText(int height) {
+ // width equals to 1000 is enough to avoid line break for all test cases.
+ measureEditText(1000, height);
+ }
+
+ private void measureEditText(int width, int height) {
+ mEditText.measure(
+ View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ mEditText.layout(0, 0, width, height);
+
+ mEditText.getLocationOnScreen(sLocationOnScreen);
+ }
+
+ private Drawable createDrawable(int height) {
+ // width is not important for this drawable, make it 1 pixel.
+ return createDrawable(1, height);
+ }
+
+ private Drawable createDrawable(int width, int height) {
+ ShapeDrawable drawable = new ShapeDrawable();
+ drawable.setBounds(new Rect(0, 0, width, height));
+ return drawable;
+ }
+}