/* * 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; import java.lang.reflect.Array; /** * 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) { this(offset, offset, singleLine, oldTransformationMethod); } private InsertModeTransformationMethod(int start, int end, boolean singleLine, @NonNull TransformationMethod oldTransformationMethod) { mStart = start; mEnd = end; mSingleLine = singleLine; mOldTransformationMethod = oldTransformationMethod; } /** * Create a new {@code InsertModeTransformation} with the given new inner * {@code oldTransformationMethod} and the {@code singleLine} value. The returned * {@link InsertModeTransformationMethod} will keep the highlight range. * * @param oldTransformationMethod the updated inner transformation method at the * {@link android.widget.TextView}. * @param singleLine the updated singleLine value. * @return the new {@link InsertModeTransformationMethod} with the updated * {@code oldTransformationMethod} and {@code singleLine} value. */ public InsertModeTransformationMethod update(TransformationMethod oldTransformationMethod, boolean singleLine) { return new InsertModeTransformationMethod(mStart, mEnd, singleLine, 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[] getSpans(int start, int end, Class type) { if (end < start) { return ArrayUtils.emptyArray(type); } T[] spansOriginal = null; if (mSpannedOriginal != null) { final int originalStart = transformedToOriginal(start, OffsetMapping.MAP_STRATEGY_CURSOR); final int originalEnd = transformedToOriginal(end, OffsetMapping.MAP_STRATEGY_CURSOR); // We can't simply call SpannedString.getSpans(originalStart, originalEnd) here. // When start == end SpannedString.getSpans returns spans whose spanEnd == start. // For example, // text: abcd span: [1, 3) // getSpan(3, 3) will return the span [1, 3) but getSpan(3, 4) returns no span. // // This creates some special cases when originalStart == originalEnd. // For example: // original text: abcd span1: [1, 3) span2: [3, 4) span3: [3, 3) // transformed text: abc\n\nd span1: [1, 3) span2: [5, 6) span3: [3, 3) // Case 1: // When start = 3 and end = 4, transformedText#getSpan(3, 4) should return span3. // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3) // returns span1, span2 and span3. // // Case 2: // When start == end == 4, transformedText#getSpan(4, 4) should return nothing. // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3) // return span1, span2 and span3. // // Case 3: // When start == end == 5, transformedText#getSpan(5, 5) should return span2. // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3) // return span1, span2 and span3. // // To handle the issue, we need to filter out the invalid spans. spansOriginal = mSpannedOriginal.getSpans(originalStart, originalEnd, type); spansOriginal = ArrayUtils.filter(spansOriginal, size -> (T[]) Array.newInstance(type, size), span -> intersect(getSpanStart(span), getSpanEnd(span), start, end)); } T[] spansPlaceholder = null; if (mSpannedPlaceholder != null && intersect(start, end, mEnd, mEnd + mPlaceholder.length())) { int placeholderStart = Math.max(start - mEnd, 0); int placeholderEnd = Math.min(end - mEnd, mPlaceholder.length()); spansPlaceholder = mSpannedPlaceholder.getSpans(placeholderStart, placeholderEnd, type); } // 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) { // When originalSpanStart == originalSpanEnd == mEnd, the span should be // considered "before" the placeholder text. So we return the originalSpanStart. if (index < mEnd || (index == mEnd && mSpannedOriginal.getSpanEnd(tag) == index)) { 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; } }