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