diff options
| -rw-r--r-- | core/java/android/widget/Editor.java | 166 | ||||
| -rw-r--r-- | core/java/android/widget/TextView.java | 174 | ||||
| -rw-r--r-- | core/tests/coretests/src/android/widget/EditTextCursorAnchorInfoTest.java | 576 |
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; + } +} |