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

Commit 8eb57870 authored by Haoyu Zhang's avatar Haoyu Zhang
Browse files

Introduce InsertModeTransformationMethod

Bug: 242089987
Test: atest InsertionModeTransformationMethodTest
Change-Id: Id0afe2281632356551e3067ffdc1cc004c1d7278
parent aa8a8dff
Loading
Loading
Loading
Loading
+440 −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.IntRange;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.text.Editable;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.Spanned;
import android.text.TextUtils;
import android.text.TextWatcher;
import android.text.style.ReplacementSpan;
import android.util.DisplayMetrics;
import android.util.MathUtils;
import android.util.TypedValue;
import android.view.View;

import com.android.internal.util.ArrayUtils;
import com.android.internal.util.Preconditions;

/**
 * The transformation method used by handwriting insert mode.
 * This transformation will insert a placeholder string to the original text at the given
 * offset. And it also provides a highlight range for the newly inserted text and the placeholder
 * text.
 *
 * For example,
 *   original text: "Hello world"
 *   insert mode is started at index:  5,
 *   placeholder text: "\n\n"
 * The transformed text will be: "Hello\n\n world", and the highlight range will be [5, 7)
 * including the inserted placeholder text.
 *
 * If " abc" is inserted to the original text at index 5,
 *   the new original text: "Hello abc world"
 *   the new transformed text: "hello abc\n\n world", and the highlight range will be [5, 11).
 * @hide
 */
public class InsertModeTransformationMethod implements TransformationMethod, TextWatcher {
    /** The start offset of the highlight range in the original text, inclusive. */
    private int mStart;
    /**
     * The end offset of the highlight range in the original text, exclusive. The placeholder text
     * is also inserted at this index.
     */
    private int mEnd;
    /** The transformation method that's already set on the {@link android.widget.TextView}. */
    private final TransformationMethod mOldTransformationMethod;
    /** Whether the {@link android.widget.TextView} is single-lined. */
    private final boolean mSingleLine;

    /**
     * @param offset the original offset to start the insert mode. It must be in the range from 0
     *               to the length of the transformed text.
     * @param singleLine whether the text is single line.
     * @param oldTransformationMethod the old transformation method at the
     * {@link android.widget.TextView}. If it's not null, this {@link TransformationMethod} will
     * first call {@link TransformationMethod#getTransformation(CharSequence, View)} on the old one,
     * and then do the transformation for the insert mode.
     *
     */
    public InsertModeTransformationMethod(@IntRange(from = 0) int offset, boolean singleLine,
            @NonNull TransformationMethod oldTransformationMethod) {
        mStart = offset;
        mEnd = offset;
        mSingleLine = singleLine;
        mOldTransformationMethod = oldTransformationMethod;
    }

    public TransformationMethod getOldTransformationMethod() {
        return mOldTransformationMethod;
    }

    private CharSequence getPlaceholderText(View view) {
        if (!mSingleLine) {
            return  "\n\n";
        }
        final SpannableString singleLinePlaceholder = new SpannableString("\uFFFD");
        final DisplayMetrics displayMetrics = view.getResources().getDisplayMetrics();
        final int widthPx = (int) Math.ceil(
                TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 108, displayMetrics));

        singleLinePlaceholder.setSpan(new SingleLinePlaceholderSpan(widthPx), 0, 1,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
        return singleLinePlaceholder;
    }

    @Override
    public CharSequence getTransformation(CharSequence source, View view) {
        final CharSequence charSequence;
        if (mOldTransformationMethod != null) {
            charSequence = mOldTransformationMethod.getTransformation(source, view);
            if (source instanceof Spannable) {
                final Spannable spannable = (Spannable) source;
                spannable.setSpan(mOldTransformationMethod, 0, spannable.length(),
                        Spanned.SPAN_INCLUSIVE_INCLUSIVE);
            }
        } else {
            charSequence = source;
        }

        final CharSequence placeholderText = getPlaceholderText(view);
        return new TransformedText(charSequence, placeholderText);
    }

    @Override
    public void onFocusChanged(View view, CharSequence sourceText, boolean focused, int direction,
            Rect previouslyFocusedRect) {
        if (mOldTransformationMethod != null) {
            mOldTransformationMethod.onFocusChanged(view, sourceText, focused, direction,
                    previouslyFocusedRect);
        }
    }

    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) { }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
        // The text change is after the offset where placeholder is inserted, return.
        if (start > mEnd) return;
        final int diff = count - before;

        // Note: If start == mStart and before == 0, the change is also considered after the
        // highlight start. It won't modify the mStart in this case.
        if (start < mStart) {
            if (start + before <= mStart) {
                // The text change is before the highlight start, move the highlight start.
                mStart += diff;
            } else {
                // The text change covers the highlight start. Extend the highlight start to the
                // change start. This should be a rare case.
                mStart = start;
            }
        }

        if (start + before <= mEnd) {
            // The text change is before the highlight end, move the highlight end.
            mEnd += diff;
        } else if (start < mEnd) {
            // The text change covers the highlight end. Extend the highlight end to the
            // change end. This should be a rare case.
            mEnd = start + count;
        }
    }

    @Override
    public void afterTextChanged(Editable s) { }

    /**
     * The transformed text returned by the {@link InsertModeTransformationMethod}.
     */
    public class TransformedText implements OffsetMapping, Spanned {
        private final CharSequence mOriginal;
        private final CharSequence mPlaceholder;
        private final Spanned mSpannedOriginal;
        private final Spanned mSpannedPlaceholder;

        TransformedText(CharSequence original, CharSequence placeholder) {
            mOriginal = original;
            if (original instanceof Spanned) {
                mSpannedOriginal = (Spanned) original;
            } else {
                mSpannedOriginal = null;
            }
            mPlaceholder = placeholder;
            if (placeholder instanceof Spanned) {
                mSpannedPlaceholder = (Spanned) placeholder;
            } else {
                mSpannedPlaceholder = null;
            }
        }

        @Override
        public int originalToTransformed(int offset, int strategy) {
            if (offset < 0) return offset;
            Preconditions.checkArgumentInRange(offset, 0, mOriginal.length(), "offset");
            if (offset == mEnd && strategy == OffsetMapping.MAP_STRATEGY_CURSOR) {
                // The offset equals to mEnd. For a cursor position it's considered before the
                // inserted placeholder text.
                return offset;
            }
            if (offset < mEnd) {
                return offset;
            }
            return offset + mPlaceholder.length();
        }

        @Override
        public int transformedToOriginal(int offset, int strategy) {
            if (offset < 0) return offset;
            Preconditions.checkArgumentInRange(offset, 0, length(), "offset");

            // The placeholder text is inserted at mEnd. Because the offset is smaller than
            // mEnd, we can directly return it.
            if (offset < mEnd) return offset;
            if (offset < mEnd + mPlaceholder.length()) {
                return mEnd;
            }
            return offset - mPlaceholder.length();
        }

        @Override
        public void originalToTransformed(TextUpdate textUpdate) {
            if (textUpdate.where > mEnd) {
                textUpdate.where += mPlaceholder.length();
            } else if (textUpdate.where + textUpdate.before > mEnd) {
                // The update also covers the placeholder string.
                textUpdate.before += mPlaceholder.length();
                textUpdate.after += mPlaceholder.length();
            }
        }

        @Override
        public int length() {
            return mOriginal.length() + mPlaceholder.length();
        }

        @Override
        public char charAt(int index) {
            Preconditions.checkArgumentInRange(index, 0, length() - 1, "index");
            if (index < mEnd) {
                return mOriginal.charAt(index);
            }
            if (index < mEnd + mPlaceholder.length()) {
                return mPlaceholder.charAt(index - mEnd);
            }
            return mOriginal.charAt(index - mPlaceholder.length());
        }

        @Override
        public CharSequence subSequence(int start, int end) {
            if (end < start || start < 0 || end > length()) {
                throw new IndexOutOfBoundsException();
            }
            if (start == end) {
                return "";
            }

            final int placeholderLength = mPlaceholder.length();

            final int seg1Start = Math.min(start, mEnd);
            final int seg1End = Math.min(end, mEnd);

            final int seg2Start = MathUtils.constrain(start - mEnd, 0, placeholderLength);
            final int seg2End = MathUtils.constrain(end - mEnd, 0, placeholderLength);

            final int seg3Start = Math.max(start - placeholderLength, mEnd);
            final int seg3End = Math.max(end - placeholderLength, mEnd);

            return TextUtils.concat(
                    mOriginal.subSequence(seg1Start, seg1End),
                    mPlaceholder.subSequence(seg2Start, seg2End),
                    mOriginal.subSequence(seg3Start, seg3End));
        }

        @Override
        public String toString() {
            return String.valueOf(mOriginal.subSequence(0, mEnd))
                    + mPlaceholder
                    + mOriginal.subSequence(mEnd, mOriginal.length());
        }

        @Override
        @SuppressWarnings("unchecked")
        public <T> T[] getSpans(int start, int end, Class<T> type) {
            if (end < start) {
                return ArrayUtils.emptyArray(type);
            }

            final T[] spansOriginal;
            if (mSpannedOriginal != null) {
                final int originalStart =
                        transformedToOriginal(start, OffsetMapping.MAP_STRATEGY_CURSOR);
                final int originalEnd =
                        transformedToOriginal(end, OffsetMapping.MAP_STRATEGY_CURSOR);
                spansOriginal = mSpannedOriginal.getSpans(originalStart, originalEnd, type);
            } else {
                spansOriginal = null;
            }

            final T[] spansPlaceholder;
            if (mSpannedPlaceholder != null
                    && intersect(start, end, mEnd, mEnd + mPlaceholder.length())) {
                final int placeholderStart = Math.max(start - mEnd, 0);
                final int placeholderEnd = Math.min(end - mEnd, mPlaceholder.length());
                spansPlaceholder =
                        mSpannedPlaceholder.getSpans(placeholderStart, placeholderEnd, type);
            } else {
                spansPlaceholder = null;
            }

            // TODO: sort the spans based on their priority.
            return ArrayUtils.concat(type, spansOriginal, spansPlaceholder);
        }

        @Override
        public int getSpanStart(Object tag) {
            if (mSpannedOriginal != null) {
                final int index = mSpannedOriginal.getSpanStart(tag);
                if (index >= 0) {
                    if (index < mEnd) {
                        return index;
                    }
                    return index + mPlaceholder.length();
                }
            }

            // The span is not on original text, try find it on the placeholder.
            if (mSpannedPlaceholder != null) {
                final int index = mSpannedPlaceholder.getSpanStart(tag);
                if (index >= 0) {
                    // Find the span on placeholder, transform it and return.
                    return index + mEnd;
                }
            }
            return -1;
        }

        @Override
        public int getSpanEnd(Object tag) {
            if (mSpannedOriginal != null) {
                final int index = mSpannedOriginal.getSpanEnd(tag);
                if (index >= 0) {
                    if (index <= mEnd) {
                        return index;
                    }
                    return index + mPlaceholder.length();
                }
            }

            // The span is not on original text, try find it on the placeholder.
            if (mSpannedPlaceholder != null) {
                final int index = mSpannedPlaceholder.getSpanEnd(tag);
                if (index >= 0) {
                    // Find the span on placeholder, transform it and return.
                    return index + mEnd;
                }
            }
            return -1;
        }

        @Override
        public int getSpanFlags(Object tag) {
            if (mSpannedOriginal != null) {
                final int flags = mSpannedOriginal.getSpanFlags(tag);
                if (flags != 0) {
                    return flags;
                }
            }
            if (mSpannedPlaceholder != null) {
                return mSpannedPlaceholder.getSpanFlags(tag);
            }
            return 0;
        }

        @Override
        public int nextSpanTransition(int start, int limit, Class type) {
            if (limit <= start) return limit;
            final Object[] spans = getSpans(start, limit, type);
            for (int i = 0; i < spans.length; ++i) {
                int spanStart = getSpanStart(spans[i]);
                int spanEnd = getSpanEnd(spans[i]);
                if (start < spanStart && spanStart < limit) {
                    limit = spanStart;
                }
                if (start < spanEnd && spanEnd < limit) {
                    limit = spanEnd;
                }
            }
            return limit;
        }

        /**
         * Return the start index of the highlight range for the insert mode, inclusive.
         */
        public int getHighlightStart() {
            return mStart;
        }

        /**
         * Return the end index of the highlight range for the insert mode, exclusive.
         */
        public int getHighlightEnd() {
            return mEnd + mPlaceholder.length();
        }
    }

    /**
     * The placeholder span used for single line
     */
    public static class SingleLinePlaceholderSpan extends ReplacementSpan {
        private final int mWidth;
        SingleLinePlaceholderSpan(int width) {
            mWidth = width;
        }
        @Override
        public int getSize(@NonNull Paint paint, CharSequence text, int start, int end,
                @Nullable Paint.FontMetricsInt fm) {
            return mWidth;
        }

        @Override
        public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x,
                int top, int y, int bottom, @NonNull Paint paint) { }
    }

    /**
     * Return true if the given two ranges intersects. This logic is the same one used in
     * {@link Spanned} to determine whether a span range intersect with the query range.
     */
    private static boolean intersect(int s1, int e1, int s2, int e2) {
        if (s1 > e2) return false;
        if (e1 < s2) return false;
        if (s1 != e1 && s2 != e2) {
            if (s1 == e2) return false;
            if (e1 == s2) return false;
        }
        return true;
    }
}
+796 −0

File added.

Preview size limit exceeded, changes collapsed.