diff --git a/core/java/android/text/method/InsertModeTransformationMethod.java b/core/java/android/text/method/InsertModeTransformationMethod.java new file mode 100644 index 0000000000000..fbdaa3d735f4b --- /dev/null +++ b/core/java/android/text/method/InsertModeTransformationMethod.java @@ -0,0 +1,440 @@ +/* + * 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[] getSpans(int start, int end, Class 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; + } +} diff --git a/core/tests/coretests/src/android/text/method/InsertModeTransformationMethodTest.java b/core/tests/coretests/src/android/text/method/InsertModeTransformationMethodTest.java new file mode 100644 index 0000000000000..7706d9aebdcce --- /dev/null +++ b/core/tests/coretests/src/android/text/method/InsertModeTransformationMethodTest.java @@ -0,0 +1,796 @@ +/* + * 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 static com.google.common.truth.Truth.assertThat; + +import android.content.Context; +import android.platform.test.annotations.Presubmit; +import android.text.SpannableString; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ReplacementSpan; +import android.view.View; + +import androidx.test.InstrumentationRegistry; +import androidx.test.filters.SmallTest; +import androidx.test.runner.AndroidJUnit4; + + +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +@Presubmit +@SmallTest +@RunWith(AndroidJUnit4.class) +public class InsertModeTransformationMethodTest { + private static View sView; + private static final String TEXT = "abc def"; + + @BeforeClass + public static void setupClass() { + final Context context = InstrumentationRegistry.getTargetContext(); + sView = new View(context); + } + + @Test + public void transformedText_charAt() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, false, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final CharSequence expected = + TEXT.substring(0, offset) + "\n\n" + TEXT.substring(offset); + + assertCharSequence(transformedText, expected); + } + } + + @Test + public void transformedText_charAt_singleLine() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, true, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final CharSequence expected = + TEXT.substring(0, offset) + "\uFFFD" + TEXT.substring(offset); + + assertCharSequence(transformedText, expected); + } + } + + @Test + public void transformedText_charAt_editing() { + transformedText_charAt_editing(false, "\n\n"); + } + + @Test + public void transformedText_charAt_singleLine_editing() { + transformedText_charAt_editing(true, "\uFFFD"); + } + + public void transformedText_charAt_editing(boolean singleLine, String placeholder) { + final SpannableStringBuilder text = new SpannableStringBuilder(TEXT); + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, singleLine, null); + final CharSequence transformedText = transformationMethod.getTransformation(text, sView); + // TransformationMethod is set on the original text as a TextWatcher in the TextView. + text.setSpan(transformationMethod, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + assertCharSequence(transformedText, "abc" + placeholder + " def"); + + // original text is "abcxx def" after insertion. + text.insert(3, "xx"); + assertCharSequence(transformedText, "abcxx" + placeholder + " def"); + + // original text is "abcxx vvdef" after insertion. + text.insert(6, "vv"); + assertCharSequence(transformedText, "abcxx" + placeholder + " vvdef"); + + // original text is "abc vvdef" after deletion. + text.delete(3, 5); + assertCharSequence(transformedText, "abc" + placeholder + " vvdef"); + + // original text is "abc def" after deletion. + text.delete(4, 6); + assertCharSequence(transformedText, "abc" + placeholder + " def"); + + // original text is "abdef" after deletion. + // the placeholder is now inserted at index 2, since the deletion covers the index 3. + text.delete(2, 4); + assertCharSequence(transformedText, "ab" + placeholder + "def"); + + // original text is "axxdef" after replace. + text.replace(1, 2, "xx"); + assertCharSequence(transformedText, "axx" + placeholder + "def"); + + // original text is "axxvvf" after replace. + text.replace(3, 5, "vv"); + assertCharSequence(transformedText, "axx" + placeholder + "vvf"); + + // original text is "abc def" after replace. + // the placeholder is inserted at index 6 after the insertion, since the replacement covers + // the index 3. + text.replace(1, 5, "bc de"); + assertCharSequence(transformedText, "abc de" + placeholder + "f"); + } + + @Test + public void transformedText_subSequence() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, false, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final CharSequence expected = + TEXT.substring(0, offset) + "\n\n" + TEXT.substring(offset); + + for (int start = 0; start < transformedText.length(); ++start) { + for (int end = start; end <= transformedText.length(); ++end) { + assertCharSequence(transformedText.subSequence(start, end), + expected.subSequence(start, end)); + } + } + } + } + + @Test + public void transformedText_subSequence_singleLine() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, true, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final CharSequence expected = + TEXT.substring(0, offset) + "\uFFFD" + TEXT.substring(offset); + + for (int start = 0; start < transformedText.length(); ++start) { + for (int end = start; end <= transformedText.length(); ++end) { + assertCharSequence(transformedText.subSequence(start, end), + expected.subSequence(start, end)); + } + } + } + } + + @Test + public void transformedText_toString() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, false, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final String expected = + TEXT.substring(0, offset) + "\n\n" + TEXT.substring(offset); + + assertThat(transformedText.toString()).isEqualTo(expected); + } + } + + @Test + public void transformedText_toString_singleLine() { + for (int offset = 0; offset < TEXT.length(); ++offset) { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(offset, true, null); + final CharSequence transformedText = + transformationMethod.getTransformation(TEXT, sView); + final String expected = + TEXT.substring(0, offset) + "\uFFFD" + TEXT.substring(offset); + + assertThat(transformedText.toString()).isEqualTo(expected); + } + } + + + @Test + public void transformedText_getSpans() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // In the transformedText "abc\n\n def", the new ranges of the spans are: + // span1: [0, 3) + // span2: [2, 6) + // span3: [6, 7) + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, false, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + + // only span1 is in the range of [0, 2). + final TestSpan[] spans0to2 = transformedText.getSpans(0, 2, TestSpan.class); + assertThat(spans0to2.length).isEqualTo(1); + assertThat(spans0to2[0]).isEqualTo(span1); + + // span1 and span2 are in the range of [1, 6). + final TestSpan[] spans1to6 = transformedText.getSpans(1, 6, TestSpan.class); + assertThat(spans1to6.length).isEqualTo(2); + assertThat(spans1to6[0]).isEqualTo(span1); + assertThat(spans1to6[1]).isEqualTo(span2); + + // only span2 is in the range of [4, 6). + final TestSpan[] spans4to6 = transformedText.getSpans(4, 6, TestSpan.class); + assertThat(spans4to6.length).isEqualTo(1); + assertThat(spans4to6[0]).isEqualTo(span2); + + // span2 and span3 are in the range of [4, 7). + final TestSpan[] spans4to7 = transformedText.getSpans(4, 7, TestSpan.class); + assertThat(spans4to7.length).isEqualTo(2); + assertThat(spans4to7[0]).isEqualTo(span2); + assertThat(spans4to7[1]).isEqualTo(span3); + + // only span3 is in the range of [6, 7). + final TestSpan[] spans6to7 = transformedText.getSpans(6, 7, TestSpan.class); + assertThat(spans6to7.length).isEqualTo(1); + assertThat(spans6to7[0]).isEqualTo(span3); + + // there is no span in the range of [7, 9). + final TestSpan[] spans7to9 = transformedText.getSpans(7, 9, TestSpan.class); + assertThat(spans7to9.length).isEqualTo(0); + } + + @Test + public void transformedText_getSpans_singleLine() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // In the transformedText, the new ranges of the spans are: + // span1: [0, 3) + // span2: [2, 5) + // span3: [5, 6) + // There should also be a ReplacementSpan in the range [3, 4). + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, true, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + + // only span1 is in the range of [0, 2). + final TestSpan[] spans0to2 = transformedText.getSpans(0, 2, TestSpan.class); + assertThat(spans0to2.length).isEqualTo(1); + assertThat(spans0to2[0]).isEqualTo(span1); + + // span1 and span2 are in the range of [1, 5). + final TestSpan[] spans1to4 = transformedText.getSpans(1, 4, TestSpan.class); + assertThat(spans1to4.length).isEqualTo(2); + assertThat(spans1to4[0]).isEqualTo(span1); + assertThat(spans1to4[1]).isEqualTo(span2); + + // only span2 is in the range of [3, 5). + final TestSpan[] spans3to5 = transformedText.getSpans(3, 5, TestSpan.class); + assertThat(spans3to5.length).isEqualTo(1); + assertThat(spans3to5[0]).isEqualTo(span2); + + // span2 and span3 are in the range of [3, 6). + final TestSpan[] spans3to6 = transformedText.getSpans(3, 6, TestSpan.class); + assertThat(spans3to6.length).isEqualTo(2); + assertThat(spans3to6[0]).isEqualTo(span2); + assertThat(spans3to6[1]).isEqualTo(span3); + + // only span3 is in the range of [5, 6). + final TestSpan[] spans5to6 = transformedText.getSpans(5, 6, TestSpan.class); + assertThat(spans5to6.length).isEqualTo(1); + assertThat(spans5to6[0]).isEqualTo(span3); + + // there is no span in the range of [6, 8) + final TestSpan[] spans6to8 = transformedText.getSpans(6, 8, TestSpan.class); + assertThat(spans6to8.length).isEqualTo(0); + + // When it's singleLine, there should be a ReplacementSpan in the range [3, 4) + final ReplacementSpan[] replacementSpans3to4 = + transformedText.getSpans(3, 4, ReplacementSpan.class); + assertThat(replacementSpans3to4.length).isEqualTo(1); + + final ReplacementSpan[] replacementSpans0to3 = + transformedText.getSpans(0, 3, ReplacementSpan.class); + assertThat(replacementSpans0to3.length).isEqualTo(0); + + final ReplacementSpan[] replacementSpans4to8 = + transformedText.getSpans(4, 8, ReplacementSpan.class); + assertThat(replacementSpans4to8.length).isEqualTo(0); + } + + @Test + public void transformedText_getSpanStartAndEnd() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // In the transformedText, the new ranges of the spans are: + // span1: [0, 3) + // span2: [2, 6) + // span3: [6, 7) + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, false, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + + assertThat(transformedText.getSpanStart(span1)).isEqualTo(0); + assertThat(transformedText.getSpanEnd(span1)).isEqualTo(3); + + assertThat(transformedText.getSpanStart(span2)).isEqualTo(2); + assertThat(transformedText.getSpanEnd(span2)).isEqualTo(6); + + assertThat(transformedText.getSpanStart(span3)).isEqualTo(6); + assertThat(transformedText.getSpanEnd(span3)).isEqualTo(7); + } + + @Test + public void transformedText_getSpanStartAndEnd_singleLine() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + + // In the transformedText, the new ranges of the spans are: + // span1: [0, 3) + // span2: [2, 5) + // span3: [5, 6) + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, true, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + + assertThat(transformedText.getSpanStart(span1)).isEqualTo(0); + assertThat(transformedText.getSpanEnd(span1)).isEqualTo(3); + + assertThat(transformedText.getSpanStart(span2)).isEqualTo(2); + assertThat(transformedText.getSpanEnd(span2)).isEqualTo(5); + + assertThat(transformedText.getSpanStart(span3)).isEqualTo(5); + assertThat(transformedText.getSpanEnd(span3)).isEqualTo(6); + + final ReplacementSpan[] replacementSpans = + transformedText.getSpans(0, 8, ReplacementSpan.class); + assertThat(transformedText.getSpanStart(replacementSpans[0])).isEqualTo(3); + assertThat(transformedText.getSpanEnd(replacementSpans[0])).isEqualTo(4); + } + + @Test + public void transformedText_getSpanFlag() { + transformedText_getSpanFlag(false); + } + + @Test + public void transformedText_getSpanFlag_singleLine() { + transformedText_getSpanFlag(true); + } + + public void transformedText_getSpanFlag(boolean singleLine) { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + text.setSpan(span1, 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 2, 4, Spanned.SPAN_EXCLUSIVE_INCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, singleLine, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + + assertThat(transformedText.getSpanFlags(span1)).isEqualTo(Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + assertThat(transformedText.getSpanFlags(span2)).isEqualTo(Spanned.SPAN_EXCLUSIVE_INCLUSIVE); + assertThat(transformedText.getSpanFlags(span3)).isEqualTo(Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + } + + @Test + public void transformedText_nextSpanTransition() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 1, 4, Spanned.SPAN_EXCLUSIVE_INCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + // In the transformedText, the new ranges of the spans are: + // span1: [0, 3) + // span2: [1, 6) + // span3: [6, 7) + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, false, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + final int[] expectedTransitions = new int[] { 0, 1, 3, 6, 7 }; + assertNextSpanTransition(transformedText, expectedTransitions, TestSpan.class); + } + + @Test + public void transformedText_nextSpanTransition_singleLine() { + final SpannableString text = new SpannableString(TEXT); + final TestSpan span1 = new TestSpan(); + final TestSpan span2 = new TestSpan(); + final TestSpan span3 = new TestSpan(); + + + text.setSpan(span1, 0, 3, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + text.setSpan(span2, 1, 4, Spanned.SPAN_EXCLUSIVE_INCLUSIVE); + text.setSpan(span3, 4, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + // In the transformedText, the new ranges of the spans are: + // span1: [0, 3) + // span2: [1, 5) + // span3: [5, 6) + // there is also a ReplacementSpan at range [3, 4) + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, true, null); + final Spanned transformedText = + (Spanned) transformationMethod.getTransformation(text, sView); + final int[] expectedTransitions = new int[] { 0, 1, 3, 4, 5, 6 }; + assertNextSpanTransition(transformedText, expectedTransitions, Object.class); + } + + @Test + public void transformedText_originalToTransformed() { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(2, false, null); + final OffsetMapping transformedText = + (OffsetMapping) transformationMethod.getTransformation(TEXT, sView); + + // "abc def" is transformed to "ab\n\nc def". + final int[] mappedCharacterOffsets = new int[] { 0, 1, 4, 5, 6, 7, 8 }; + assertOriginalToTransformed(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCharacterOffsets); + + // "abc def" is transformed to "ab\n\nc def". + // the cursor before 'c' is mapped to index position before "\n\n". + final int[] mappedCursorOffsets = new int[] { 0, 1, 2, 5, 6, 7, 8 }; + assertOriginalToTransformed(transformedText, OffsetMapping.MAP_STRATEGY_CURSOR, + mappedCursorOffsets); + } + + @Test + public void transformedText_originalToTransformed_singleLine() { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(2, true, null); + final OffsetMapping transformedText = + (OffsetMapping) transformationMethod.getTransformation(TEXT, sView); + + // "abc def" is transformed to "ab\uFFFDc def". + final int[] mappedCharacterOffsets = new int[] { 0, 1, 3, 4, 5, 6, 7 }; + assertOriginalToTransformed(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCharacterOffsets); + + // "abc def" is transformed to "ab\uFFFDc def". + // the cursor before 'c' is mapped to index position before "\uFFFD". + final int[] mappedCursorOffsets = new int[] { 0, 1, 3, 4, 5, 6, 7 }; + assertOriginalToTransformed(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCursorOffsets); + } + + @Test + public void transformedText_transformedToOriginal() { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(2, false, null); + final OffsetMapping transformedText = + (OffsetMapping) transformationMethod.getTransformation(TEXT, sView); + + // "abc def" is transformed to "ab\n\nc def". + // the two '\n' characters have no corresponding character; map them to 'c'. + final int[] mappedCharacterOffsets = new int[] { 0, 1, 2, 2, 2, 3, 4, 5, 6 }; + assertTransformedToOriginal(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCharacterOffsets); + + // offset 2 and 3 (cursor positions before the two '\n' characters) are mapped to index 2 + // (cursor position before 'c' in the original text) + final int[] mappedCursorOffsets = new int[] { 0, 1, 2, 2, 2, 3, 4, 5, 6 }; + assertTransformedToOriginal(transformedText, OffsetMapping.MAP_STRATEGY_CURSOR, + mappedCursorOffsets); + } + + @Test + public void transformedText_transformedToOriginal_singleLine() { + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(2, true, null); + final OffsetMapping transformedText = + (OffsetMapping) transformationMethod.getTransformation(TEXT, sView); + + // "abc def" is transformed to "ab\uFFFDc def". + // '\uFFFD' has no corresponding character; map it to 'c'. + final int[] mappedCharacterOffsets = new int[] { 0, 1, 2, 2, 3, 4, 5, 6 }; + assertTransformedToOriginal(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCharacterOffsets); + + // offset 2 (cursor positions before '\uFFFD') is mapped to index 2 (cursor position before + // 'c' in the original text) + final int[] mappedCursorOffsets = new int[] { 0, 1, 2, 2, 3, 4, 5, 6 }; + assertTransformedToOriginal(transformedText, OffsetMapping.MAP_STRATEGY_CHARACTER, + mappedCursorOffsets); + } + + @Test + public void transformedText_getHighlightStartAndEnd_insertion() { + transformedText_getHighlightStartAndEnd_insertion(false, "\n\n"); + } + + @Test + public void transformedText_getHighlightStartAndEnd_insertion_singleLine() { + transformedText_getHighlightStartAndEnd_insertion(true, "\uFDDD"); + } + + public void transformedText_getHighlightStartAndEnd_insertion(boolean singleLine, + String placeholder) { + final SpannableStringBuilder text = new SpannableStringBuilder(TEXT); + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, singleLine, null); + final InsertModeTransformationMethod.TransformedText transformedText = + (InsertModeTransformationMethod.TransformedText) transformationMethod + .getTransformation(text, sView); + // TransformationMethod is set on the original text as a TextWatcher in the TextView. + text.setSpan(transformationMethod, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + // note: the placeholder text is also highlighted. + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(3 + placeholder.length()); + + // original text is "abcxx def" after insertion. + // the placeholder is now inserted at index 5. + // the highlight start is still 3. + // the highlight end now is 5 + placeholder.length(), including the newly inserted text. + text.insert(3, "xx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(5 + placeholder.length()); + + // original text is "abcxxvv def" after insertion. + // the placeholder is now inserted at index 7. + // the highlight start is still 3. + // the highlight end now is 7 + placeholder.length(), including the newly inserted text. + text.insert(5, "vv"); + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(7 + placeholder.length()); + + // original text is "abzzcxxvv def" after insertion. + // the placeholder is now inserted at index 9. + // the highlight start is 5, since the insertion happens before the highlight range. + // the highlight end now is 9 + placeholder.length(). + text.insert(2, "zz"); + assertThat(transformedText.getHighlightStart()).isEqualTo(5); + assertThat(transformedText.getHighlightEnd()).isEqualTo(9 + placeholder.length()); + + // original text is "abzzcxxvv iidef" after insertion. + // the placeholder is still inserted at index 9. + // the highlight start is still 5, since the insertion happens after the highlight range. + // the highlight end now is 9 + placeholder.length(). + text.insert(10, "ii"); + assertThat(transformedText.getHighlightStart()).isEqualTo(5); + assertThat(transformedText.getHighlightEnd()).isEqualTo(9 + placeholder.length()); + + } + + @Test + public void transformedText_getHighlightStartAndEnd_deletion() { + transformedText_getHighlightStartAndEnd_deletion(false, "\n\n"); + } + + @Test + public void transformedText_getHighlightStartAndEnd_insertion_deletion() { + transformedText_getHighlightStartAndEnd_deletion(true, "\uFDDD"); + } + + public void transformedText_getHighlightStartAndEnd_deletion(boolean singleLine, + String placeholder) { + final SpannableStringBuilder text = new SpannableStringBuilder(TEXT); + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, singleLine, null); + final InsertModeTransformationMethod.TransformedText transformedText = + (InsertModeTransformationMethod.TransformedText) transformationMethod + .getTransformation(text, sView); + // TransformationMethod is set on the original text as a TextWatcher in the TextView. + text.setSpan(transformationMethod, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + // note: the placeholder text is also highlighted. + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(3 + placeholder.length()); + + // original text is "abcxxxxxx def" after insertion. + // the placeholder is now inserted at index 9. + // the highlight start is still 3. + // the highlight end now is 9 + placeholder.length(). + text.insert(3, "xxxxxx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(9 + placeholder.length()); + + // original text is "abxxxxxx def" after deletion. + // the placeholder is now inserted at index 6. + // the highlight start is 2, since the deletion happens before the highlight range. + // the highlight end now is 8 + placeholder.length(). + text.delete(2, 3); + assertThat(transformedText.getHighlightStart()).isEqualTo(2); + assertThat(transformedText.getHighlightEnd()).isEqualTo(8 + placeholder.length()); + + // original text is "abxxx def" after deletion. + // the placeholder is now inserted at index 5. + // the highlight start is still 2, since the deletion happens in the highlight range. + // the highlight end now is 5 + placeholder.length(). + text.delete(2, 5); + assertThat(transformedText.getHighlightStart()).isEqualTo(2); + assertThat(transformedText.getHighlightEnd()).isEqualTo(5 + placeholder.length()); + + // original text is "abxxx d" after deletion. + // the placeholder is now inserted at index 5. + // the highlight start is still 2, since the deletion happens after the highlight range. + // the highlight end now is still 5 + placeholder.length(). + text.delete(7, 9); + assertThat(transformedText.getHighlightStart()).isEqualTo(2); + assertThat(transformedText.getHighlightEnd()).isEqualTo(5 + placeholder.length()); + + // original text is "af" after deletion. + // the placeholder is now inserted at index 1. + // the highlight start is 1, since the deletion covers highlight range. + // the highlight end is 1 + placeholder.length(). + text.delete(1, 5); + assertThat(transformedText.getHighlightStart()).isEqualTo(1); + assertThat(transformedText.getHighlightEnd()).isEqualTo(1 + placeholder.length()); + } + + + @Test + public void transformedText_getHighlightStartAndEnd_replace() { + transformedText_getHighlightStartAndEnd_replace(false, "\n\n"); + } + + @Test + public void transformedText_getHighlightStartAndEnd_insertion__replace() { + transformedText_getHighlightStartAndEnd_replace(true, "\uFDDD"); + } + + public void transformedText_getHighlightStartAndEnd_replace(boolean singleLine, + String placeholder) { + final SpannableStringBuilder text = new SpannableStringBuilder(TEXT); + final InsertModeTransformationMethod transformationMethod = + new InsertModeTransformationMethod(3, singleLine, null); + final InsertModeTransformationMethod.TransformedText transformedText = + (InsertModeTransformationMethod.TransformedText) transformationMethod + .getTransformation(text, sView); + // TransformationMethod is set on the original text as a TextWatcher in the TextView. + text.setSpan(transformationMethod, 0, text.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE); + + // note: the placeholder text is also highlighted. + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(3 + placeholder.length()); + + // original text is "abcxxxxxx def" after insertion. + // the placeholder is now inserted at index 9. + // the highlight start is still 3. + // the highlight end now is 9 + placeholder.length(). + text.insert(3, "xxxxxx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(3); + assertThat(transformedText.getHighlightEnd()).isEqualTo(9 + placeholder.length()); + + // original text is "abvvxxxxxx def" after replace. + // the replacement happens before the highlight range; highlight range is offset by 1 + // the placeholder is now inserted at index 10, + // the highlight start is 4. + // the highlight end is 10 + placeholder.length(). + text.replace(2, 3, "vv"); + assertThat(transformedText.getHighlightStart()).isEqualTo(4); + assertThat(transformedText.getHighlightEnd()).isEqualTo(10 + placeholder.length()); + + // original text is "abvvxxx def" after replace. + // the replacement happens in the highlight range; highlight end is offset by -3 + // the placeholder is now inserted at index 7, + // the highlight start is still 4. + // the highlight end is 7 + placeholder.length(). + text.replace(5, 9, "x"); + assertThat(transformedText.getHighlightStart()).isEqualTo(4); + assertThat(transformedText.getHighlightEnd()).isEqualTo(7 + placeholder.length()); + + // original text is "abvvxxxvvv" after replace. + // the replacement happens after the highlight range; highlight is not changed + // the placeholder is now inserted at index 7, + // the highlight start is still 4. + // the highlight end is 7 + placeholder.length(). + text.replace(7, 11, "vvv"); + assertThat(transformedText.getHighlightStart()).isEqualTo(4); + assertThat(transformedText.getHighlightEnd()).isEqualTo(7 + placeholder.length()); + + // original text is "abxxxxvvv" after replace. + // the replacement happens covers the highlight start; highlight start extends to the + // replacement start; highlight end is offset by -1 + // the placeholder is now inserted at index 6, + // the highlight start is 2. + // the highlight end is 6 + placeholder.length(). + text.replace(2, 5, "xx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(2); + assertThat(transformedText.getHighlightEnd()).isEqualTo(6 + placeholder.length()); + + // original text is "abxxxxxvv" after replace. + // the replacement happens covers the highlight end; highlight end extends to the + // replacement end; highlight start stays the same + // the placeholder is now inserted at index 7, + // the highlight start is 2. + // the highlight end is 7 + placeholder.length(). + text.replace(5, 7, "xx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(2); + assertThat(transformedText.getHighlightEnd()).isEqualTo(7 + placeholder.length()); + + // original text is "axxv" after replace. + // the replacement happens covers the highlight range; highlight start is set to the + // replacement start; highlight end is set to the replacement end + // the placeholder is now inserted at index 3, + // the highlight start is 1. + // the highlight end is 3 + placeholder.length(). + text.replace(1, 8, "xx"); + assertThat(transformedText.getHighlightStart()).isEqualTo(1); + assertThat(transformedText.getHighlightEnd()).isEqualTo(3 + placeholder.length()); + } + + private static void assertNextSpanTransition(Spanned spanned, int[] transitions, + Class type) { + int currentTransition = 0; + for (int transition : transitions) { + assertThat(currentTransition).isEqualTo(transition); + currentTransition = + spanned.nextSpanTransition(currentTransition, spanned.length(), type); + } + + // Make sure there is no transition after the currentTransition. + assertThat(currentTransition).isEqualTo(spanned.length()); + } + + private static void assertCharSequence(CharSequence actual, CharSequence expected) { + assertThat(actual.length()).isEqualTo(expected.length()); + for (int index = 0; index < actual.length(); ++index) { + assertThat(actual.charAt(index)).isEqualTo(expected.charAt(index)); + } + } + + private static void assertOriginalToTransformed(OffsetMapping transformedText, int strategy, + int[] expected) { + for (int offset = 0; offset < expected.length; ++offset) { + assertThat(transformedText.originalToTransformed(offset, strategy)) + .isEqualTo(expected[offset]); + } + } + + private static void assertTransformedToOriginal(OffsetMapping transformedText, int strategy, + int[] expected) { + for (int offset = 0; offset < expected.length; ++offset) { + assertThat(transformedText.transformedToOriginal(offset, strategy)) + .isEqualTo(expected[offset]); + } + } + + private static class TestSpan { } +}