Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit c3d7b4e7 authored by Haoyu Zhang's avatar Haoyu Zhang
Browse files

Introduce OffsetMapping for TextView TransformationMethod

This CL supports OffsetMapping internally for the TextView's
TransformationMethod. It make it possible for TransformationMethod to
alter the original text's length. It'll be mainly used for the insertion
mode feature requested by stylus handwriting.

This CL checked all index access to Layout from Editor, TextView and
MovementMethod. And make sure they are aware of the text transformation.

When there isn't a TransformationMethod or the TransformationMethod
returns a text that's not an instance of OffsetMapping, the existing
behavior is not impacted.

The following classes will support a transformed text with OffsetMapping:
- InsertionPointCursorController, SelectionModifierCursorController
- InsertionHandleView, SelectionHandleView, HandleView
- PinnedPopupWindow, EasyEditPopupWindow, SuggestionsPopupWindow
- SelectionActionModeHelper

The following parts don't support OffsetMapping, but instead will
direclty return false/null or become no-op when OffsetMapping is used:
- CursorAnchorInfoNotifier, CursorAnchorInfo
- TextBoundsInfo
- TextView HandwritingGesture
- Selection move methods
- LinkMovementMethod
- BaseKeyListener

Bug: 242089987
Test: atest DynamicLayoutTransformedTextTest
Test: manually test
Change-Id: I2ad2c2148353e69214efd9aae04a69a6367d0981
parent f31bd18f
Loading
Loading
Loading
Loading
+45 −6
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ import android.compat.annotation.UnsupportedAppUsage;
import android.graphics.Paint;
import android.graphics.Rect;
import android.os.Build;
import android.text.method.OffsetMapping;
import android.text.style.ReplacementSpan;
import android.text.style.UpdateLayout;
import android.text.style.WrapTogetherSpan;
@@ -1095,10 +1096,27 @@ public class DynamicLayout extends Layout {
        }

        public void beforeTextChanged(CharSequence s, int where, int before, int after) {
            // Intentionally empty
            final DynamicLayout dynamicLayout = mLayout.get();
            if (dynamicLayout != null && dynamicLayout.mDisplay instanceof OffsetMapping) {
                final OffsetMapping transformedText = (OffsetMapping) dynamicLayout.mDisplay;
                if (mTransformedTextUpdate == null) {
                    mTransformedTextUpdate = new OffsetMapping.TextUpdate(where, before, after);
                } else {
                    mTransformedTextUpdate.where = where;
                    mTransformedTextUpdate.before = before;
                    mTransformedTextUpdate.after = after;
                }
                transformedText.originalToTransformed(mTransformedTextUpdate);
            }
        }

        public void onTextChanged(CharSequence s, int where, int before, int after) {
            final DynamicLayout dynamicLayout = mLayout.get();
            if (dynamicLayout != null && dynamicLayout.mDisplay instanceof OffsetMapping) {
                where = mTransformedTextUpdate.where;
                before = mTransformedTextUpdate.before;
                after = mTransformedTextUpdate.after;
            }
            reflow(s, where, before, after);
        }

@@ -1106,14 +1124,34 @@ public class DynamicLayout extends Layout {
            // Intentionally empty
        }

        public void onSpanAdded(Spannable s, Object o, int start, int end) {
            if (o instanceof UpdateLayout)
        /**
         * Reflow the {@link DynamicLayout} at the given range from {@code start} to the
         * {@code end}.
         * If the display text in this {@link DynamicLayout} is a {@link OffsetMapping} instance
         * (which means it's also a transformed text), it will transform the given range first and
         * then reflow.
         */
        private void transformAndReflow(Spannable s, int start, int end) {
            final DynamicLayout dynamicLayout = mLayout.get();
            if (dynamicLayout != null && dynamicLayout.mDisplay instanceof OffsetMapping) {
                final OffsetMapping transformedText = (OffsetMapping) dynamicLayout.mDisplay;
                start = transformedText.originalToTransformed(start,
                        OffsetMapping.MAP_STRATEGY_CHARACTER);
                end = transformedText.originalToTransformed(end,
                        OffsetMapping.MAP_STRATEGY_CHARACTER);
            }
            reflow(s, start, end - start, end - start);
        }

        public void onSpanAdded(Spannable s, Object o, int start, int end) {
            if (o instanceof UpdateLayout) {
                transformAndReflow(s, start, end);
            }
        }

        public void onSpanRemoved(Spannable s, Object o, int start, int end) {
            if (o instanceof UpdateLayout)
                reflow(s, start, end - start, end - start);
                transformAndReflow(s, start, end);
        }

        public void onSpanChanged(Spannable s, Object o, int start, int end, int nstart, int nend) {
@@ -1123,12 +1161,13 @@ public class DynamicLayout extends Layout {
                    // instead of causing an exception
                    start = 0;
                }
                reflow(s, start, end - start, end - start);
                reflow(s, nstart, nend - nstart, nend - nstart);
                transformAndReflow(s, start, end);
                transformAndReflow(s, nstart, nend);
            }
        }

        private WeakReference<DynamicLayout> mLayout;
        private OffsetMapping.TextUpdate mTransformedTextUpdate;
    }

    @Override
+30 −0
Original line number Diff line number Diff line
@@ -68,6 +68,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean left(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendLeft(buffer, layout);
@@ -78,6 +81,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean right(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendRight(buffer, layout);
@@ -88,6 +94,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean up(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendUp(buffer, layout);
@@ -98,6 +107,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean down(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendDown(buffer, layout);
@@ -108,6 +120,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean pageUp(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        final boolean selecting = isSelecting(buffer);
        final int targetY = getCurrentLineTop(buffer, layout) - getPageHeight(widget);
@@ -132,6 +147,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean pageDown(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        final boolean selecting = isSelecting(buffer);
        final int targetY = getCurrentLineTop(buffer, layout) + getPageHeight(widget);
@@ -176,6 +194,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean lineStart(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendToLeftEdge(buffer, layout);
@@ -186,6 +207,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    protected boolean lineEnd(TextView widget, Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendToRightEdge(buffer, layout);
@@ -224,6 +248,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    public boolean previousParagraph(@NonNull TextView widget, @NonNull Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendToParagraphStart(buffer);
@@ -234,6 +261,9 @@ public class ArrowKeyMovementMethod extends BaseMovementMethod implements Moveme

    @Override
    public boolean nextParagraph(@NonNull TextView widget, @NonNull  Spannable buffer) {
        if (widget.isOffsetMappingAvailable()) {
            return false;
        }
        final Layout layout = widget.getLayout();
        if (isSelecting(buffer)) {
            return Selection.extendToParagraphEnd(buffer);
+3 −2
Original line number Diff line number Diff line
@@ -440,8 +440,9 @@ public abstract class BaseKeyListener extends MetaKeyKeyListener

    private boolean deleteLine(View view, Editable content) {
        if (view instanceof TextView) {
            final Layout layout = ((TextView) view).getLayout();
            if (layout != null) {
            final TextView textView = (TextView) view;
            final Layout layout = textView.getLayout();
            if (layout != null && !textView.isOffsetMappingAvailable()) {
                final int line = layout.getLineForOffset(Selection.getSelectionStart(content));
                final int start = layout.getLineStart(line);
                final int end = layout.getLineEnd(line);
+4 −0
Original line number Diff line number Diff line
@@ -100,6 +100,10 @@ public class LinkMovementMethod extends ScrollingMovementMethod {

    private boolean action(int what, TextView widget, Spannable buffer) {
        Layout layout = widget.getLayout();
        if (widget.isOffsetMappingAvailable()) {
            // The text in the layout is transformed and has OffsetMapping, don't do anything.
            return false;
        }

        int padding = widget.getTotalPaddingTop() +
                      widget.getTotalPaddingBottom();
+170 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2022 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package android.text.method;

import android.annotation.IntDef;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

/**
 * The interface for the index mapping information of a transformed text returned by
 * {@link TransformationMethod}. This class is mainly used to support the
 * {@link TransformationMethod} that alters the text length.
 * @hide
 */
public interface OffsetMapping {
    /**
     * The mapping strategy for a character offset.
     *
     * @see #originalToTransformed(int, int)
     * @see #transformedToOriginal(int, int)
     */
    int MAP_STRATEGY_CHARACTER = 0;

    /**
     * The mapping strategy for a cursor position.
     *
     * @see #originalToTransformed(int, int)
     * @see #transformedToOriginal(int, int)
     */
    int MAP_STRATEGY_CURSOR = 1;

    @IntDef(prefix = { "MAP_STRATEGY" }, value = {
            MAP_STRATEGY_CHARACTER,
            MAP_STRATEGY_CURSOR
    })
    @Retention(RetentionPolicy.SOURCE)
    @interface MapStrategy {}

    /**
     * Map an offset at original text to the offset at transformed text. <br/>
     *
     * This function must be a monotonically non-decreasing function. In other words, if the offset
     * advances at the original text, the offset at the transformed text must advance or stay there.
     * <br/>
     *
     * Depending on the mapping strategy, a same offset can be mapped differently. For example,
     * <pre>
     * Original:       ABCDE
     * Transformed:    ABCXDE ('X' is introduced due to the transformation.)
     * </pre>
     * Let's check the offset 3, which is the offset of the character 'D'.
     * If we want to map the character offset 3, it should be mapped to index 4.
     * If we want to map the cursor offset 3 (the offset of the character before which the cursor is
     * placed), it's unclear if the mapped cursor is before 'X' or after 'X'.
     * This depends on how the transformed text reacts an insertion at offset 3 in the original
     * text. Assume character 'V' is insert at offset 3 in the original text, and the original text
     * become "ABCVDE". The transformed text can be:
     * <pre>
     * 1) "ABCVXDE"
     * or
     * 2) "ABCXVDE"
     * </pre>
     * In the first case, the offset 3 should be mapped to 3 (before 'X'). And in the second case,
     * the offset should be mapped to 4 (after 'X').<br/>
     *
     * In some cases, a character offset at the original text doesn't have a proper corresponding
     * offset at the transformed text. For example:
     * <pre>
     * Original:    ABCDE
     * Transformed: ABDE ('C' is deleted due to the transformation.)
     * </pre>
     * The character offset 2 can be mapped either to offset 2 or 3, but neither is meaningful. For
     * convenience, it MUST map to the next offset (offset 3 in this case), or the
     * transformedText.length() if there is no valid character to map.
     * This is mandatory when the map strategy is {@link #MAP_STRATEGY_CHARACTER}, but doesn't
     * apply for other map strategies.
     *
     * @param offset the offset at the original text. It's normally equal to or less than the
     *               originalText.length(). When {@link #MAP_STRATEGY_CHARACTER} is passed, it must
     *               be less than originalText.length(). For convenience, it's also allowed to be
     *               negative, which represents an invalid offset. When the given offset is
     *               negative, this method should return it as it is.
     * @param strategy the mapping strategy. Depending on its value, the same offset can be mapped
     *                 differently.
     * @return the mapped offset at the transformed text, must be equal to or less than the
     * transformedText.length().
     *
     * @see #transformedToOriginal(int, int)
     */
    int originalToTransformed(int offset, @MapStrategy int strategy);

    /**
     * Map an offset at transformed text to the offset at original text. This is the reverse method
     * of {@link #originalToTransformed(int, int)}. <br/>
     * This function must be a monotonically non-decreasing function. In other words, if the offset
     * advances at the original text, the offset at the transformed text must advance or stay there.
     * <br/>
     * Similar to the {@link #originalToTransformed(int, int)} if a character offset doesn't have a
     * corresponding offset at the transformed text, it MUST return the value as the previous
     * offset. This is mandatory when the map strategy is {@link #MAP_STRATEGY_CHARACTER},
     * but doesn't apply for other map strategies.
     *
     * @param offset the offset at the transformed text. It's normally equal to or less than the
     *               transformedText.length(). When {@link #MAP_STRATEGY_CHARACTER} is passed, it
     *               must be less than transformedText.length(). For convenience, it's also allowed
     *               to be negative, which represents an invalid offset. When the given offset is
     *               negative, this method should return it as it is.
     * @param strategy the mapping strategy. Depending on its value, the same offset can be mapped
     *                 differently.
     * @return the mapped offset at the original text, must be equal to or less than the
     * original.length().
     *
     * @see #originalToTransformed(int, int)
     */
    int transformedToOriginal(int offset, @MapStrategy int strategy);

    /**
     * Map a text update in the original text to an update the transformed text.
     * This method used to determine how the transformed text is updated in response to an
     * update in the original text. It is always called before the original text being changed.
     *
     * The main usage of this method is to update text layout incrementally. So it should report
     * the range where text needs to be laid out again.
     *
     * @param textUpdate the {@link TextUpdate} object containing the text  update information on
     *                  the original text. The transformed text update information will also be
     *                   stored at this object.
     */
    void originalToTransformed(TextUpdate textUpdate);

    /**
     * The class that stores the text update information that from index <code>where</code>,
     * <code>after</code> characters will replace the old text that has length <code>before</code>.
     */
    class TextUpdate {
        /** The start index of the text update range, inclusive */
        public int where;
        /** The length of the replaced old text. */
        public int before;
        /** The length of the new text that replaces the old text. */
        public int after;

        /**
         * Creates a {@link TextUpdate} object.
         * @param where the start index of the text update range.
         * @param before the length of the replaced old text.
         * @param after the length of the new text that replaces the old text.
         */
        public TextUpdate(int where, int before, int after) {
            this.where = where;
            this.before = before;
            this.after = after;
        }
    }
}
Loading