Merge "Introduce InsertModeTransformationMethod"

This commit is contained in:
Haoyu Zhang
2023-01-19 00:00:50 +00:00
committed by Android (Google) Code Review
2 changed files with 1236 additions and 0 deletions

View File

@@ -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> 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;
}
}

View File

@@ -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 <T> void assertNextSpanTransition(Spanned spanned, int[] transitions,
Class<T> 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 { }
}