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