diff options
| author | 2023-02-08 20:04:55 +0000 | |
|---|---|---|
| committer | 2023-02-08 20:04:55 +0000 | |
| commit | abdb05e9cb40860a04d78fc24e46feaa564a4fde (patch) | |
| tree | 42cba7cd9ceda55ac24aa00a8214d9429f328734 | |
| parent | c081eeb7a260e6036e1bbd6e2cd5e2306f87721e (diff) | |
| parent | c82a496b95ae37ed481c8a4018c0dd1b869f3a13 (diff) | |
Merge "Refactor CursorAnchorInfoNotifier"
| -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; +    } +} |