1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.text.method;
18 
19 import android.annotation.IntRange;
20 import android.annotation.NonNull;
21 import android.annotation.Nullable;
22 import android.graphics.Canvas;
23 import android.graphics.Paint;
24 import android.graphics.Rect;
25 import android.text.Editable;
26 import android.text.Spannable;
27 import android.text.SpannableString;
28 import android.text.Spanned;
29 import android.text.TextUtils;
30 import android.text.TextWatcher;
31 import android.text.style.ReplacementSpan;
32 import android.util.DisplayMetrics;
33 import android.util.MathUtils;
34 import android.util.TypedValue;
35 import android.view.View;
36 
37 import com.android.internal.util.ArrayUtils;
38 import com.android.internal.util.Preconditions;
39 
40 import java.lang.reflect.Array;
41 
42 /**
43  * The transformation method used by handwriting insert mode.
44  * This transformation will insert a placeholder string to the original text at the given
45  * offset. And it also provides a highlight range for the newly inserted text and the placeholder
46  * text.
47  *
48  * For example,
49  *   original text: "Hello world"
50  *   insert mode is started at index:  5,
51  *   placeholder text: "\n\n"
52  * The transformed text will be: "Hello\n\n world", and the highlight range will be [5, 7)
53  * including the inserted placeholder text.
54  *
55  * If " abc" is inserted to the original text at index 5,
56  *   the new original text: "Hello abc world"
57  *   the new transformed text: "hello abc\n\n world", and the highlight range will be [5, 11).
58  * @hide
59  */
60 public class InsertModeTransformationMethod implements TransformationMethod, TextWatcher {
61     /** The start offset of the highlight range in the original text, inclusive. */
62     private int mStart;
63     /**
64      * The end offset of the highlight range in the original text, exclusive. The placeholder text
65      * is also inserted at this index.
66      */
67     private int mEnd;
68     /** The transformation method that's already set on the {@link android.widget.TextView}. */
69     private final TransformationMethod mOldTransformationMethod;
70     /** Whether the {@link android.widget.TextView} is single-lined. */
71     private final boolean mSingleLine;
72 
73     /**
74      * @param offset the original offset to start the insert mode. It must be in the range from 0
75      *               to the length of the transformed text.
76      * @param singleLine whether the text is single line.
77      * @param oldTransformationMethod the old transformation method at the
78      * {@link android.widget.TextView}. If it's not null, this {@link TransformationMethod} will
79      * first call {@link TransformationMethod#getTransformation(CharSequence, View)} on the old one,
80      * and then do the transformation for the insert mode.
81      *
82      */
InsertModeTransformationMethod(@ntRangefrom = 0) int offset, boolean singleLine, @NonNull TransformationMethod oldTransformationMethod)83     public InsertModeTransformationMethod(@IntRange(from = 0) int offset, boolean singleLine,
84             @NonNull TransformationMethod oldTransformationMethod) {
85         this(offset, offset, singleLine, oldTransformationMethod);
86     }
87 
InsertModeTransformationMethod(int start, int end, boolean singleLine, @NonNull TransformationMethod oldTransformationMethod)88     private InsertModeTransformationMethod(int start, int end, boolean singleLine,
89             @NonNull TransformationMethod oldTransformationMethod) {
90         mStart = start;
91         mEnd = end;
92         mSingleLine = singleLine;
93         mOldTransformationMethod = oldTransformationMethod;
94     }
95 
96     /**
97      * Create a new {@code InsertModeTransformation} with the given new inner
98      * {@code oldTransformationMethod} and the {@code singleLine} value. The returned
99      * {@link InsertModeTransformationMethod} will keep the highlight range.
100      *
101      * @param oldTransformationMethod the updated inner transformation method at the
102      * {@link android.widget.TextView}.
103      * @param singleLine the updated singleLine value.
104      * @return the new {@link InsertModeTransformationMethod} with the updated
105      * {@code oldTransformationMethod} and {@code singleLine} value.
106      */
update(TransformationMethod oldTransformationMethod, boolean singleLine)107     public InsertModeTransformationMethod update(TransformationMethod oldTransformationMethod,
108             boolean singleLine) {
109         return new InsertModeTransformationMethod(mStart, mEnd, singleLine,
110                 oldTransformationMethod);
111     }
112 
getOldTransformationMethod()113     public TransformationMethod getOldTransformationMethod() {
114         return mOldTransformationMethod;
115     }
116 
getPlaceholderText(View view)117     private CharSequence getPlaceholderText(View view) {
118         if (!mSingleLine) {
119             return  "\n\n";
120         }
121         final SpannableString singleLinePlaceholder = new SpannableString("\uFFFD");
122         final DisplayMetrics displayMetrics = view.getResources().getDisplayMetrics();
123         final int widthPx = (int) Math.ceil(
124                 TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 108, displayMetrics));
125 
126         singleLinePlaceholder.setSpan(new SingleLinePlaceholderSpan(widthPx), 0, 1,
127                 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
128         return singleLinePlaceholder;
129     }
130 
131     @Override
getTransformation(CharSequence source, View view)132     public CharSequence getTransformation(CharSequence source, View view) {
133         final CharSequence charSequence;
134         if (mOldTransformationMethod != null) {
135             charSequence = mOldTransformationMethod.getTransformation(source, view);
136             if (source instanceof Spannable) {
137                 final Spannable spannable = (Spannable) source;
138                 spannable.setSpan(mOldTransformationMethod, 0, spannable.length(),
139                         Spanned.SPAN_INCLUSIVE_INCLUSIVE);
140             }
141         } else {
142             charSequence = source;
143         }
144 
145         final CharSequence placeholderText = getPlaceholderText(view);
146         return new TransformedText(charSequence, placeholderText);
147     }
148 
149     @Override
onFocusChanged(View view, CharSequence sourceText, boolean focused, int direction, Rect previouslyFocusedRect)150     public void onFocusChanged(View view, CharSequence sourceText, boolean focused, int direction,
151             Rect previouslyFocusedRect) {
152         if (mOldTransformationMethod != null) {
153             mOldTransformationMethod.onFocusChanged(view, sourceText, focused, direction,
154                     previouslyFocusedRect);
155         }
156     }
157 
158     @Override
beforeTextChanged(CharSequence s, int start, int count, int after)159     public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
160 
161     @Override
onTextChanged(CharSequence s, int start, int before, int count)162     public void onTextChanged(CharSequence s, int start, int before, int count) {
163         // The text change is after the offset where placeholder is inserted, return.
164         if (start > mEnd) return;
165         final int diff = count - before;
166 
167         // Note: If start == mStart and before == 0, the change is also considered after the
168         // highlight start. It won't modify the mStart in this case.
169         if (start < mStart) {
170             if (start + before <= mStart) {
171                 // The text change is before the highlight start, move the highlight start.
172                 mStart += diff;
173             } else {
174                 // The text change covers the highlight start. Extend the highlight start to the
175                 // change start. This should be a rare case.
176                 mStart = start;
177             }
178         }
179 
180         if (start + before <= mEnd) {
181             // The text change is before the highlight end, move the highlight end.
182             mEnd += diff;
183         } else if (start < mEnd) {
184             // The text change covers the highlight end. Extend the highlight end to the
185             // change end. This should be a rare case.
186             mEnd = start + count;
187         }
188     }
189 
190     @Override
afterTextChanged(Editable s)191     public void afterTextChanged(Editable s) { }
192 
193     /**
194      * The transformed text returned by the {@link InsertModeTransformationMethod}.
195      */
196     public class TransformedText implements OffsetMapping, Spanned {
197         private final CharSequence mOriginal;
198         private final CharSequence mPlaceholder;
199         private final Spanned mSpannedOriginal;
200         private final Spanned mSpannedPlaceholder;
201 
TransformedText(CharSequence original, CharSequence placeholder)202         TransformedText(CharSequence original, CharSequence placeholder) {
203             mOriginal = original;
204             if (original instanceof Spanned) {
205                 mSpannedOriginal = (Spanned) original;
206             } else {
207                 mSpannedOriginal = null;
208             }
209             mPlaceholder = placeholder;
210             if (placeholder instanceof Spanned) {
211                 mSpannedPlaceholder = (Spanned) placeholder;
212             } else {
213                 mSpannedPlaceholder = null;
214             }
215         }
216 
217         @Override
originalToTransformed(int offset, int strategy)218         public int originalToTransformed(int offset, int strategy) {
219             if (offset < 0) return offset;
220             Preconditions.checkArgumentInRange(offset, 0, mOriginal.length(), "offset");
221             if (offset == mEnd && strategy == OffsetMapping.MAP_STRATEGY_CURSOR) {
222                 // The offset equals to mEnd. For a cursor position it's considered before the
223                 // inserted placeholder text.
224                 return offset;
225             }
226             if (offset < mEnd) {
227                 return offset;
228             }
229             return offset + mPlaceholder.length();
230         }
231 
232         @Override
transformedToOriginal(int offset, int strategy)233         public int transformedToOriginal(int offset, int strategy) {
234             if (offset < 0) return offset;
235             Preconditions.checkArgumentInRange(offset, 0, length(), "offset");
236 
237             // The placeholder text is inserted at mEnd. Because the offset is smaller than
238             // mEnd, we can directly return it.
239             if (offset < mEnd) return offset;
240             if (offset < mEnd + mPlaceholder.length()) {
241                 return mEnd;
242             }
243             return offset - mPlaceholder.length();
244         }
245 
246         @Override
originalToTransformed(TextUpdate textUpdate)247         public void originalToTransformed(TextUpdate textUpdate) {
248             if (textUpdate.where > mEnd) {
249                 textUpdate.where += mPlaceholder.length();
250             } else if (textUpdate.where + textUpdate.before > mEnd) {
251                 // The update also covers the placeholder string.
252                 textUpdate.before += mPlaceholder.length();
253                 textUpdate.after += mPlaceholder.length();
254             }
255         }
256 
257         @Override
length()258         public int length() {
259             return mOriginal.length() + mPlaceholder.length();
260         }
261 
262         @Override
charAt(int index)263         public char charAt(int index) {
264             Preconditions.checkArgumentInRange(index, 0, length() - 1, "index");
265             if (index < mEnd) {
266                 return mOriginal.charAt(index);
267             }
268             if (index < mEnd + mPlaceholder.length()) {
269                 return mPlaceholder.charAt(index - mEnd);
270             }
271             return mOriginal.charAt(index - mPlaceholder.length());
272         }
273 
274         @Override
subSequence(int start, int end)275         public CharSequence subSequence(int start, int end) {
276             if (end < start || start < 0 || end > length()) {
277                 throw new IndexOutOfBoundsException();
278             }
279             if (start == end) {
280                 return "";
281             }
282 
283             final int placeholderLength = mPlaceholder.length();
284 
285             final int seg1Start = Math.min(start, mEnd);
286             final int seg1End = Math.min(end, mEnd);
287 
288             final int seg2Start = MathUtils.constrain(start - mEnd, 0, placeholderLength);
289             final int seg2End = MathUtils.constrain(end - mEnd, 0, placeholderLength);
290 
291             final int seg3Start = Math.max(start - placeholderLength, mEnd);
292             final int seg3End = Math.max(end - placeholderLength, mEnd);
293 
294             return TextUtils.concat(
295                     mOriginal.subSequence(seg1Start, seg1End),
296                     mPlaceholder.subSequence(seg2Start, seg2End),
297                     mOriginal.subSequence(seg3Start, seg3End));
298         }
299 
300         @Override
toString()301         public String toString() {
302             return String.valueOf(mOriginal.subSequence(0, mEnd))
303                     + mPlaceholder
304                     + mOriginal.subSequence(mEnd, mOriginal.length());
305         }
306 
307         @Override
308         @SuppressWarnings("unchecked")
getSpans(int start, int end, Class<T> type)309         public <T> T[] getSpans(int start, int end, Class<T> type) {
310             if (end < start) {
311                 return ArrayUtils.emptyArray(type);
312             }
313 
314             T[] spansOriginal = null;
315             if (mSpannedOriginal != null) {
316                 final int originalStart =
317                         transformedToOriginal(start, OffsetMapping.MAP_STRATEGY_CURSOR);
318                 final int originalEnd =
319                         transformedToOriginal(end, OffsetMapping.MAP_STRATEGY_CURSOR);
320                 // We can't simply call SpannedString.getSpans(originalStart, originalEnd) here.
321                 // When start == end SpannedString.getSpans returns spans whose spanEnd == start.
322                 // For example,
323                 //   text: abcd  span: [1, 3)
324                 // getSpan(3, 3) will return the span [1, 3) but getSpan(3, 4) returns no span.
325                 //
326                 // This creates some special cases when originalStart == originalEnd.
327                 // For example:
328                 //   original text: abcd    span1: [1, 3) span2: [3, 4) span3: [3, 3)
329                 //   transformed text: abc\n\nd    span1: [1, 3) span2: [5, 6) span3: [3, 3)
330                 // Case 1:
331                 // When start = 3 and end = 4, transformedText#getSpan(3, 4) should return span3.
332                 // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3)
333                 // returns span1, span2 and span3.
334                 //
335                 // Case 2:
336                 // When start == end == 4, transformedText#getSpan(4, 4) should return nothing.
337                 // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3)
338                 // return span1, span2 and span3.
339                 //
340                 // Case 3:
341                 // When start == end == 5, transformedText#getSpan(5, 5) should return span2.
342                 // However, because originalStart == originalEnd == 3, originalText#getSpan(3, 3)
343                 // return span1,  span2 and span3.
344                 //
345                 // To handle the issue, we need to filter out the invalid spans.
346                 spansOriginal = mSpannedOriginal.getSpans(originalStart, originalEnd, type);
347                 spansOriginal = ArrayUtils.filter(spansOriginal,
348                         size -> (T[]) Array.newInstance(type, size),
349                         span -> intersect(getSpanStart(span), getSpanEnd(span), start, end));
350             }
351 
352             T[] spansPlaceholder = null;
353             if (mSpannedPlaceholder != null
354                     && intersect(start, end, mEnd, mEnd + mPlaceholder.length())) {
355                 int placeholderStart = Math.max(start - mEnd, 0);
356                 int placeholderEnd = Math.min(end - mEnd, mPlaceholder.length());
357                 spansPlaceholder =
358                         mSpannedPlaceholder.getSpans(placeholderStart, placeholderEnd, type);
359             }
360 
361             // TODO: sort the spans based on their priority.
362             return ArrayUtils.concat(type, spansOriginal, spansPlaceholder);
363         }
364 
365         @Override
getSpanStart(Object tag)366         public int getSpanStart(Object tag) {
367             if (mSpannedOriginal != null) {
368                 final int index = mSpannedOriginal.getSpanStart(tag);
369                 if (index >= 0) {
370                     // When originalSpanStart == originalSpanEnd == mEnd, the span should be
371                     // considered "before" the placeholder text. So we return the originalSpanStart.
372                     if (index < mEnd
373                             || (index == mEnd && mSpannedOriginal.getSpanEnd(tag) == index)) {
374                         return index;
375                     }
376                     return index + mPlaceholder.length();
377                 }
378             }
379 
380             // The span is not on original text, try find it on the placeholder.
381             if (mSpannedPlaceholder != null) {
382                 final int index = mSpannedPlaceholder.getSpanStart(tag);
383                 if (index >= 0) {
384                     // Find the span on placeholder, transform it and return.
385                     return index + mEnd;
386                 }
387             }
388             return -1;
389         }
390 
391         @Override
getSpanEnd(Object tag)392         public int getSpanEnd(Object tag) {
393             if (mSpannedOriginal != null) {
394                 final int index = mSpannedOriginal.getSpanEnd(tag);
395                 if (index >= 0) {
396                     if (index <= mEnd) {
397                         return index;
398                     }
399                     return index + mPlaceholder.length();
400                 }
401             }
402 
403             // The span is not on original text, try find it on the placeholder.
404             if (mSpannedPlaceholder != null) {
405                 final int index = mSpannedPlaceholder.getSpanEnd(tag);
406                 if (index >= 0) {
407                     // Find the span on placeholder, transform it and return.
408                     return index + mEnd;
409                 }
410             }
411             return -1;
412         }
413 
414         @Override
getSpanFlags(Object tag)415         public int getSpanFlags(Object tag) {
416             if (mSpannedOriginal != null) {
417                 final int flags = mSpannedOriginal.getSpanFlags(tag);
418                 if (flags != 0) {
419                     return flags;
420                 }
421             }
422             if (mSpannedPlaceholder != null) {
423                 return mSpannedPlaceholder.getSpanFlags(tag);
424             }
425             return 0;
426         }
427 
428         @Override
nextSpanTransition(int start, int limit, Class type)429         public int nextSpanTransition(int start, int limit, Class type) {
430             if (limit <= start) return limit;
431             final Object[] spans = getSpans(start, limit, type);
432             for (int i = 0; i < spans.length; ++i) {
433                 int spanStart = getSpanStart(spans[i]);
434                 int spanEnd = getSpanEnd(spans[i]);
435                 if (start < spanStart && spanStart < limit) {
436                     limit = spanStart;
437                 }
438                 if (start < spanEnd && spanEnd < limit) {
439                     limit = spanEnd;
440                 }
441             }
442             return limit;
443         }
444 
445         /**
446          * Return the start index of the highlight range for the insert mode, inclusive.
447          */
getHighlightStart()448         public int getHighlightStart() {
449             return mStart;
450         }
451 
452         /**
453          * Return the end index of the highlight range for the insert mode, exclusive.
454          */
getHighlightEnd()455         public int getHighlightEnd() {
456             return mEnd + mPlaceholder.length();
457         }
458     }
459 
460     /**
461      * The placeholder span used for single line
462      */
463     public static class SingleLinePlaceholderSpan extends ReplacementSpan {
464         private final int mWidth;
SingleLinePlaceholderSpan(int width)465         SingleLinePlaceholderSpan(int width) {
466             mWidth = width;
467         }
468         @Override
getSize(@onNull Paint paint, CharSequence text, int start, int end, @Nullable Paint.FontMetricsInt fm)469         public int getSize(@NonNull Paint paint, CharSequence text, int start, int end,
470                 @Nullable Paint.FontMetricsInt fm) {
471             return mWidth;
472         }
473 
474         @Override
draw(@onNull Canvas canvas, CharSequence text, int start, int end, float x, int top, int y, int bottom, @NonNull Paint paint)475         public void draw(@NonNull Canvas canvas, CharSequence text, int start, int end, float x,
476                 int top, int y, int bottom, @NonNull Paint paint) { }
477     }
478 
479     /**
480      * Return true if the given two ranges intersects. This logic is the same one used in
481      * {@link Spanned} to determine whether a span range intersect with the query range.
482      */
intersect(int s1, int e1, int s2, int e2)483     private static boolean intersect(int s1, int e1, int s2, int e2) {
484         if (s1 > e2) return false;
485         if (e1 < s2) return false;
486         if (s1 != e1 && s2 != e2) {
487             if (s1 == e2) return false;
488             if (e1 == s2) return false;
489         }
490         return true;
491     }
492 }
493