@apollohg/react-native-prose-editor 0.5.17 → 0.5.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,16 +6,28 @@ import android.content.Context
6
6
  import android.graphics.Typeface
7
7
  import android.graphics.Rect
8
8
  import android.graphics.RectF
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.Looper
9
12
  import android.os.SystemClock
13
+ import android.provider.Settings
10
14
  import android.text.Annotation
11
15
  import android.text.Editable
12
16
  import android.text.InputType
13
17
  import android.text.Layout
18
+ import android.text.Selection
14
19
  import android.text.Spanned
15
20
  import android.text.SpannableStringBuilder
16
21
  import android.text.StaticLayout
17
22
  import android.text.TextPaint
18
23
  import android.text.TextWatcher
24
+ import android.text.style.AbsoluteSizeSpan
25
+ import android.text.style.BackgroundColorSpan
26
+ import android.text.style.ForegroundColorSpan
27
+ import android.text.style.StrikethroughSpan
28
+ import android.text.style.StyleSpan
29
+ import android.text.style.TypefaceSpan
30
+ import android.text.style.UnderlineSpan
19
31
  import android.util.AttributeSet
20
32
  import android.util.Log
21
33
  import android.util.TypedValue
@@ -73,6 +85,15 @@ class EditorEditText @JvmOverloads constructor(
73
85
  val totalNanos: Long
74
86
  )
75
87
 
88
+ internal data class ImeInitialSurroundingText(
89
+ val text: String,
90
+ val selectionStart: Int,
91
+ val selectionEnd: Int,
92
+ val originalSelectionStart: Int,
93
+ val originalSelectionEnd: Int,
94
+ val removedPlaceholderCount: Int
95
+ )
96
+
76
97
  data class SelectedImageGeometry(
77
98
  val docPos: Int,
78
99
  val rect: RectF
@@ -127,6 +148,8 @@ class EditorEditText @JvmOverloads constructor(
127
148
  val scalarTo: Int,
128
149
  val replacementText: String,
129
150
  val resultingText: String,
151
+ val replacementStartUtf16: Int,
152
+ val replacementEndUtf16: Int,
130
153
  val selectionScalarAnchor: Int?,
131
154
  val selectionScalarHead: Int?
132
155
  )
@@ -143,6 +166,24 @@ class EditorEditText @JvmOverloads constructor(
143
166
  val authorizedTextRevision: Long
144
167
  )
145
168
 
169
+ private interface TransientComposingTextStyleSpan
170
+
171
+ private class TransientComposingSizeSpan(sizePx: Int) :
172
+ AbsoluteSizeSpan(sizePx, false),
173
+ TransientComposingTextStyleSpan
174
+
175
+ private class TransientComposingColorSpan(color: Int) :
176
+ ForegroundColorSpan(color),
177
+ TransientComposingTextStyleSpan
178
+
179
+ private class TransientComposingTypefaceSpan(family: String) :
180
+ TypefaceSpan(family),
181
+ TransientComposingTextStyleSpan
182
+
183
+ private class TransientComposingStyleSpan(style: Int) :
184
+ StyleSpan(style),
185
+ TransientComposingTextStyleSpan
186
+
146
187
  /**
147
188
  * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
148
189
  */
@@ -246,9 +287,17 @@ class EditorEditText @JvmOverloads constructor(
246
287
  internal var captureApplyUpdateTraceForTesting: Boolean = false
247
288
  private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
248
289
  private val imeTraceForTesting = java.util.ArrayDeque<String>()
290
+ private var imeTraceSequence: Long = 0L
291
+ private var lastImeTraceUptimeMs: Long = 0L
249
292
  private var currentRenderBlocksJson: org.json.JSONArray? = null
250
293
  private var renderAppearanceRevision: Long = 1L
251
294
  private var lastAppliedRenderAppearanceRevision: Long = 0L
295
+ private var pendingOptimisticRenderText: String? = null
296
+ private var deferredRustUpdateApplicationDepth: Int = 0
297
+ private var deferredRustUpdateJSON: String? = null
298
+ private var deferredRustUpdateGeneration: Long = 0L
299
+ private var lineBoundaryInputRefreshGeneration: Long = 0L
300
+ private var restartInputSelectionUpdateGeneration: Long = 0L
252
301
  internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
253
302
  internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
254
303
  internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
@@ -262,6 +311,11 @@ class EditorEditText @JvmOverloads constructor(
262
311
 
263
312
  fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
264
313
  fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
314
+ internal fun hasDeferredRustUpdateApplicationForTesting(): Boolean = deferredRustUpdateJSON != null
315
+
316
+ internal fun applyRustUpdateJSONForTesting(updateJSON: String) {
317
+ applyRustUpdateJSON(updateJSON)
318
+ }
265
319
 
266
320
  internal fun recordImeTraceForTesting(event: String, details: String = "") {
267
321
  if (imeTraceForTesting.size >= IME_TRACE_LIMIT_FOR_TESTING) {
@@ -270,15 +324,55 @@ class EditorEditText @JvmOverloads constructor(
270
324
  imeTraceForTesting.addLast(
271
325
  if (details.isEmpty()) event else "$event:$details"
272
326
  )
327
+ if (Log.isLoggable(IME_TRACE_LOG_TAG, Log.VERBOSE)) {
328
+ val now = SystemClock.uptimeMillis()
329
+ val deltaMs = if (lastImeTraceUptimeMs == 0L) 0L else now - lastImeTraceUptimeMs
330
+ lastImeTraceUptimeMs = now
331
+ imeTraceSequence += 1L
332
+ val textLength = text?.length ?: -1
333
+ val selection = "${selectionStart}..${selectionEnd}"
334
+ val composingRange = "${composingReplacementStartUtf16 ?: -1}.." +
335
+ "${composingReplacementEndUtf16 ?: -1}"
336
+ val composingRevisionMatches =
337
+ composingReplacementAuthorizedTextRevision == lastAuthorizedTextRevision
338
+ val message = buildString {
339
+ append("#").append(imeTraceSequence)
340
+ append(" +").append(deltaMs).append("ms ")
341
+ append(event)
342
+ if (details.isNotEmpty()) {
343
+ append(" ").append(details)
344
+ }
345
+ append(" editor=").append(editorId)
346
+ append(" gen=").append(inputConnectionGeneration)
347
+ append(" activeIc=").append(activeInputConnection != null)
348
+ append(" focus=").append(hasFocus())
349
+ append(" applying=").append(isApplyingRustState)
350
+ append(" editable=").append(isEditable)
351
+ append(" textLen=").append(textLength)
352
+ append(" authLen=").append(lastAuthorizedText.length)
353
+ append(" sel=").append(selection)
354
+ append(" composingTextLen=").append(composingText?.length ?: -1)
355
+ append(" composingRange=").append(composingRange)
356
+ append(" composingRevOk=").append(composingRevisionMatches)
357
+ append(" invalidComp=").append(didInvalidateCompositionReplacementRange)
358
+ append(" deferredRustUpdate=").append(deferredRustUpdateJSON != null)
359
+ append(" scroll=").append(scrollX).append(",").append(scrollY)
360
+ }
361
+ Log.v(IME_TRACE_LOG_TAG, message)
362
+ }
273
363
  }
274
364
 
275
365
  internal fun clearImeTraceForTesting() {
276
366
  imeTraceForTesting.clear()
367
+ imeTraceSequence = 0L
368
+ lastImeTraceUptimeMs = 0L
277
369
  }
278
370
 
279
371
  internal fun imeTraceSnapshotForTesting(): List<String> =
280
372
  imeTraceForTesting.toList()
281
373
 
374
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
375
+
282
376
  init {
283
377
  // Configure for rich text editing.
284
378
  inputType = resolvedInputType()
@@ -417,12 +511,64 @@ class EditorEditText @JvmOverloads constructor(
417
511
  */
418
512
  override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
419
513
  val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
514
+ val originalInitialCapsMode = outAttrs.initialCapsMode
515
+ outAttrs.initialCapsMode = cursorCapsModeForEditor(
516
+ reqModes = outAttrs.inputType,
517
+ baseCapsMode = outAttrs.initialCapsMode
518
+ )
519
+ val initialSurroundingText = applyInitialSurroundingTextForIme(outAttrs)
420
520
  val generation = nextInputConnectionGenerationForEditor()
521
+ recordImeTraceForTesting(
522
+ "createInputConnection",
523
+ "boundEditor=$editorId boundGen=$generation inputType=$inputType initialCaps=$originalInitialCapsMode->${outAttrs.initialCapsMode} " +
524
+ "imeContextPlaceholdersRemoved=${initialSurroundingText?.removedPlaceholderCount ?: 0} " +
525
+ "imeContextSel=${initialSurroundingText?.selectionStart ?: outAttrs.initialSelStart}..${initialSurroundingText?.selectionEnd ?: outAttrs.initialSelEnd} " +
526
+ "imeContextRawSel=${initialSurroundingText?.originalSelectionStart ?: selectionStart}..${initialSurroundingText?.originalSelectionEnd ?: selectionEnd} " +
527
+ "imeContextBeforeTail=\"${initialSurroundingText?.textBeforeSelectionTailForImeLog() ?: ""}\""
528
+ )
421
529
  return EditorInputConnection(this, baseConnection, editorId, generation).also {
422
530
  activeInputConnection = it
423
531
  }
424
532
  }
425
533
 
534
+ private fun applyInitialSurroundingTextForIme(outAttrs: EditorInfo): ImeInitialSurroundingText? {
535
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
536
+ val initialText = initialSurroundingTextForImeForEditor() ?: return null
537
+
538
+ outAttrs.initialSelStart = initialText.selectionStart
539
+ outAttrs.initialSelEnd = initialText.selectionEnd
540
+ outAttrs.setInitialSurroundingText(initialText.text)
541
+ return initialText
542
+ }
543
+
544
+ private fun ImeInitialSurroundingText.textBeforeSelectionTailForImeLog(limit: Int = 24): String {
545
+ val end = selectionStart.coerceIn(0, text.length)
546
+ val start = maxOf(0, end - limit)
547
+ return text.substring(start, end).toImeTraceSnippet()
548
+ }
549
+
550
+ private fun String.toImeTraceSnippet(): String {
551
+ val builder = StringBuilder(length)
552
+ forEach { ch ->
553
+ when (ch) {
554
+ '\n' -> builder.append("\\n")
555
+ '\r' -> builder.append("\\r")
556
+ '\t' -> builder.append("\\t")
557
+ '\\' -> builder.append("\\\\")
558
+ '"' -> builder.append("\\\"")
559
+ else -> {
560
+ if (ch.code < 0x20 || ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]) {
561
+ builder.append("\\u")
562
+ builder.append(ch.code.toString(16).padStart(4, '0'))
563
+ } else {
564
+ builder.append(ch)
565
+ }
566
+ }
567
+ }
568
+ }
569
+ return builder.toString()
570
+ }
571
+
426
572
  override fun dispatchKeyEvent(event: KeyEvent): Boolean {
427
573
  if (!isEditable && isReadOnlyTextMutationKeyEvent(event)) {
428
574
  return true
@@ -824,40 +970,203 @@ class EditorEditText @JvmOverloads constructor(
824
970
  * the Rust editor instead of directly inserting into the EditText.
825
971
  */
826
972
  fun handleTextCommit(text: String, newCursorPosition: Int = 1) {
827
- if (!isEditable) return
828
- if (isApplyingRustState) return
829
- val selectionRange = normalizedUtf16SelectionRange() ?: return
973
+ val startedAt = System.nanoTime()
974
+ if (!isEditable) {
975
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=notEditable textLength=${text.length}")
976
+ return
977
+ }
978
+ if (isApplyingRustState) {
979
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=applyingRust textLength=${text.length}")
980
+ return
981
+ }
982
+ val selectionRange = normalizedUtf16SelectionRange()
983
+ if (selectionRange == null) {
984
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=noSelection textLength=${text.length}")
985
+ return
986
+ }
830
987
  if (editorId == 0L) {
831
988
  // No Rust editor bound — fall through to direct editing (dev mode).
832
989
  val editable = this.text ?: return
833
990
  val (start, end) = selectionRange
834
991
  editable.replace(start, end, text)
992
+ recordImeTraceForTesting(
993
+ "handleTextCommitDirect",
994
+ "textLength=${text.length} utf16Sel=$start..$end totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
995
+ )
996
+ return
997
+ }
998
+ if (discardTransientInputForDestroyedEditorIfNeeded()) {
999
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=destroyedEditor textLength=${text.length}")
835
1000
  return
836
1001
  }
837
- if (discardTransientInputForDestroyedEditorIfNeeded()) return
838
1002
 
839
1003
  // Handle Enter/Return as a block split operation.
840
1004
  if (text == "\n") {
1005
+ recordImeTraceForTesting(
1006
+ "handleTextCommit",
1007
+ "route=return utf16Sel=${selectionRange.first}..${selectionRange.second}"
1008
+ )
841
1009
  handleReturnKey()
1010
+ recordImeTraceForTesting(
1011
+ "handleTextCommitDone",
1012
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1013
+ )
842
1014
  return
843
1015
  }
844
1016
 
845
1017
  val currentText = this.text?.toString() ?: ""
846
- val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
847
- insertPlainTextRangeInRust(
1018
+ val scalarSelectionRange = normalizedScalarSelectionRange(currentText)
1019
+ if (scalarSelectionRange == null) {
1020
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=noScalarSelection textLength=${text.length}")
1021
+ return
1022
+ }
1023
+ val (scalarStart, scalarEnd) = scalarSelectionRange
1024
+ val requestedCursor = requestedCursorScalar(
848
1025
  scalarStart,
849
1026
  scalarEnd,
1027
+ currentText,
850
1028
  text,
851
- requestedCursorScalar = requestedCursorScalar(
852
- scalarStart,
853
- scalarEnd,
854
- currentText,
855
- text,
856
- newCursorPosition
1029
+ newCursorPosition
1030
+ )
1031
+ recordImeTraceForTesting(
1032
+ "handleTextCommit",
1033
+ "textLength=${text.length} cursor=$newCursorPosition utf16Sel=${selectionRange.first}..${selectionRange.second} scalarSel=$scalarStart..$scalarEnd requestedCursor=$requestedCursor"
1034
+ )
1035
+ val didApplyOptimisticVisibleText = applyOptimisticPlainTextCommitIfPossible(
1036
+ startUtf16 = selectionRange.first,
1037
+ endUtf16 = selectionRange.second,
1038
+ committedText = text,
1039
+ newCursorPosition = newCursorPosition
1040
+ )
1041
+ if (didApplyOptimisticVisibleText) {
1042
+ recordImeTraceForTesting(
1043
+ "optimisticVisibleTextCommit",
1044
+ "textLength=${text.length} utf16Sel=${selectionRange.first}..${selectionRange.second}"
857
1045
  )
1046
+ }
1047
+ insertPlainTextRangeInRust(
1048
+ scalarStart,
1049
+ scalarEnd,
1050
+ text,
1051
+ requestedCursorScalar = requestedCursor
1052
+ )
1053
+ recordImeTraceForTesting(
1054
+ "handleTextCommitDone",
1055
+ "textLength=${text.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
858
1056
  )
859
1057
  }
860
1058
 
1059
+ private data class OptimisticInlineSpan(
1060
+ val span: Any,
1061
+ val flags: Int
1062
+ )
1063
+
1064
+ private fun applyOptimisticPlainTextCommitIfPossible(
1065
+ startUtf16: Int,
1066
+ endUtf16: Int,
1067
+ committedText: String,
1068
+ newCursorPosition: Int
1069
+ ): Boolean {
1070
+ if (newCursorPosition != 1) return false
1071
+ if (startUtf16 != endUtf16) return false
1072
+ if (committedText.isEmpty()) return false
1073
+ if (committedText.codePointCount(0, committedText.length) != 1) return false
1074
+ if (committedText.indexOf('\n') >= 0 || committedText.indexOf('\r') >= 0) return false
1075
+ if (hasCompositionTrackingForEditor()) return false
1076
+ val editable = text ?: return false
1077
+ val currentText = editable.toString()
1078
+ if (currentText != lastAuthorizedText) return false
1079
+ if (startUtf16 < 0 || endUtf16 < startUtf16 || endUtf16 > editable.length) return false
1080
+ val spanned = editable as? Spanned
1081
+ if (spanned != null && spannedRangeContainsImageSpan(spanned, startUtf16, endUtf16)) return false
1082
+
1083
+ val inlineSpans = spanned?.let {
1084
+ optimisticInlineSpansForInsertion(it, startUtf16)
1085
+ }.orEmpty()
1086
+ var didApply = false
1087
+ runWithTransientInputMutationGuard {
1088
+ editable.replace(startUtf16, endUtf16, committedText)
1089
+ val insertedEnd = startUtf16 + committedText.length
1090
+ applyOptimisticInlineSpans(editable, startUtf16, insertedEnd, inlineSpans)
1091
+ Selection.setSelection(editable, insertedEnd, insertedEnd)
1092
+ didApply = true
1093
+ true
1094
+ }
1095
+ if (didApply) {
1096
+ pendingOptimisticRenderText = editable.toString()
1097
+ }
1098
+ return didApply
1099
+ }
1100
+
1101
+ private fun optimisticInlineSpansForInsertion(
1102
+ spanned: Spanned,
1103
+ insertionStart: Int
1104
+ ): List<OptimisticInlineSpan> {
1105
+ if (spanned.isEmpty()) return emptyList()
1106
+ val sourceIndex = when {
1107
+ insertionStart > 0 -> insertionStart - 1
1108
+ insertionStart < spanned.length -> insertionStart
1109
+ else -> return emptyList()
1110
+ }
1111
+ val queryStart = sourceIndex.coerceIn(0, spanned.length - 1)
1112
+ val queryEnd = (queryStart + 1).coerceAtMost(spanned.length)
1113
+ val spans = mutableListOf<OptimisticInlineSpan>()
1114
+ spanned.getSpans(queryStart, queryEnd, Any::class.java).forEach { span ->
1115
+ if (spanned.getSpanStart(span) > queryStart || spanned.getSpanEnd(span) <= queryStart) {
1116
+ return@forEach
1117
+ }
1118
+ cloneOptimisticInlineSpan(span)?.let { clone ->
1119
+ spans.add(OptimisticInlineSpan(clone, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE))
1120
+ }
1121
+ }
1122
+ return spans
1123
+ }
1124
+
1125
+ private fun cloneOptimisticInlineSpan(span: Any): Any? =
1126
+ when (span) {
1127
+ is ForegroundColorSpan -> ForegroundColorSpan(span.foregroundColor)
1128
+ is BackgroundColorSpan -> BackgroundColorSpan(span.backgroundColor)
1129
+ is AbsoluteSizeSpan -> AbsoluteSizeSpan(span.size, span.dip)
1130
+ is StyleSpan -> StyleSpan(span.style)
1131
+ is UnderlineSpan -> UnderlineSpan()
1132
+ is StrikethroughSpan -> StrikethroughSpan()
1133
+ else -> null
1134
+ }
1135
+
1136
+ private fun applyOptimisticInlineSpans(
1137
+ editable: Editable,
1138
+ start: Int,
1139
+ end: Int,
1140
+ inlineSpans: List<OptimisticInlineSpan>
1141
+ ) {
1142
+ if (start >= end || end > editable.length) return
1143
+ var hasColor = false
1144
+ var hasSize = false
1145
+ inlineSpans.forEach { spec ->
1146
+ hasColor = hasColor || spec.span is ForegroundColorSpan
1147
+ hasSize = hasSize || spec.span is AbsoluteSizeSpan
1148
+ editable.setSpan(spec.span, start, end, spec.flags)
1149
+ }
1150
+ val textStyle = theme?.effectiveTextStyle("paragraph")
1151
+ if (!hasColor) {
1152
+ editable.setSpan(
1153
+ ForegroundColorSpan(textStyle?.color ?: baseTextColor),
1154
+ start,
1155
+ end,
1156
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1157
+ )
1158
+ }
1159
+ if (!hasSize) {
1160
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
1161
+ editable.setSpan(
1162
+ AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
1163
+ start,
1164
+ end,
1165
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1166
+ )
1167
+ }
1168
+ }
1169
+
861
1170
  internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
862
1171
  val wasApplyingRustState = isApplyingRustState
863
1172
  isApplyingRustState = true
@@ -942,6 +1251,83 @@ class EditorEditText @JvmOverloads constructor(
942
1251
 
943
1252
  internal fun composingTextForEditor(): String? = composingText
944
1253
 
1254
+ internal fun samsungSentenceCapsComposingTextForEditor(composingText: String?): String? {
1255
+ if (composingText.isNullOrEmpty()) return composingText
1256
+ if (!isSamsungKeyboardActiveForEditor()) return composingText
1257
+ if ((inputType and InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) != InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) {
1258
+ return composingText
1259
+ }
1260
+ val (replacementStart, replacementEnd) = compositionReplacementRange() ?: return composingText
1261
+ if (replacementStart != replacementEnd) return composingText
1262
+ if (!isRenderedLineStartForSentenceCaps(lastAuthorizedText, replacementStart)) {
1263
+ return composingText
1264
+ }
1265
+
1266
+ val firstCodePoint = Character.codePointAt(composingText, 0)
1267
+ if (!Character.isLowerCase(firstCodePoint)) return composingText
1268
+ val adjusted = buildString(composingText.length) {
1269
+ appendCodePoint(Character.toTitleCase(firstCodePoint))
1270
+ append(composingText.substring(Character.charCount(firstCodePoint)))
1271
+ }
1272
+ recordImeTraceForTesting(
1273
+ "samsungSentenceCapsFallback",
1274
+ "range=$replacementStart..$replacementEnd textLength=${composingText.length}"
1275
+ )
1276
+ return adjusted
1277
+ }
1278
+
1279
+ internal fun applyTransientComposingTextStyleForEditor() {
1280
+ val editable = text ?: return
1281
+ removeTransientComposingTextStyleSpans(editable)
1282
+
1283
+ val start = BaseInputConnection.getComposingSpanStart(editable)
1284
+ val end = BaseInputConnection.getComposingSpanEnd(editable)
1285
+ if (start < 0 || end < 0 || start >= end || end > editable.length) return
1286
+
1287
+ val textStyle = theme?.effectiveTextStyle("paragraph")
1288
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
1289
+ val resolvedTextColor = textStyle?.color ?: baseTextColor
1290
+
1291
+ editable.setSpan(
1292
+ TransientComposingSizeSpan(resolvedTextSize.toInt()),
1293
+ start,
1294
+ end,
1295
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1296
+ )
1297
+ editable.setSpan(
1298
+ TransientComposingColorSpan(resolvedTextColor),
1299
+ start,
1300
+ end,
1301
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1302
+ )
1303
+
1304
+ val typefaceStyle = textStyle?.typefaceStyle() ?: Typeface.NORMAL
1305
+ if (typefaceStyle != Typeface.NORMAL) {
1306
+ editable.setSpan(
1307
+ TransientComposingStyleSpan(typefaceStyle),
1308
+ start,
1309
+ end,
1310
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1311
+ )
1312
+ }
1313
+
1314
+ val fontFamily = textStyle?.fontFamily?.takeIf { it.isNotBlank() }
1315
+ if (fontFamily != null) {
1316
+ editable.setSpan(
1317
+ TransientComposingTypefaceSpan(fontFamily),
1318
+ start,
1319
+ end,
1320
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1321
+ )
1322
+ }
1323
+ }
1324
+
1325
+ private fun removeTransientComposingTextStyleSpans(editable: Editable) {
1326
+ editable
1327
+ .getSpans(0, editable.length, TransientComposingTextStyleSpan::class.java)
1328
+ .forEach(editable::removeSpan)
1329
+ }
1330
+
945
1331
  internal fun composingTextFromVisibleReplacementForEditor(): String? {
946
1332
  val (start, end) = compositionReplacementRange() ?: return null
947
1333
  val authorizedText = lastAuthorizedText
@@ -972,6 +1358,7 @@ class EditorEditText @JvmOverloads constructor(
972
1358
  composingReplacementAuthorizedTextRevision != null
973
1359
 
974
1360
  private fun retireInputConnectionForEditor() {
1361
+ recordImeTraceForTesting("retireInputConnection")
975
1362
  activeInputConnection?.clearCompositionTrackingForEditor()
976
1363
  invalidateInputConnectionsForEditor()
977
1364
  clearCompositionTrackingForEditor()
@@ -1016,18 +1403,63 @@ class EditorEditText @JvmOverloads constructor(
1016
1403
 
1017
1404
  private fun restartInputAfterCompositionInvalidationIfNeeded(shouldRestart: Boolean) {
1018
1405
  if (!shouldRestart) return
1019
- restartInputForEditorIfFocused()
1406
+ restartInputForEditorIfFocused("focused")
1020
1407
  }
1021
1408
 
1022
- private fun restartInputForEditorIfFocused() {
1409
+ private fun restartInputForEditorIfFocused(source: String) {
1023
1410
  if (!hasFocus()) return
1024
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1025
- imm?.restartInput(this)
1411
+ restartInputForEditor(source)
1026
1412
  }
1027
1413
 
1028
- private fun restartInputForEditor() {
1414
+ private fun restartInputForEditor(source: String = "explicit") {
1415
+ recordImeTraceForTesting("restartInput", "source=$source")
1029
1416
  val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1030
1417
  imm?.restartInput(this)
1418
+ scheduleSelectionUpdateAfterRestartInput(source)
1419
+ }
1420
+
1421
+ private fun scheduleSelectionUpdateAfterRestartInput(source: String) {
1422
+ val generation = ++restartInputSelectionUpdateGeneration
1423
+ post {
1424
+ if (generation != restartInputSelectionUpdateGeneration) return@post
1425
+ if (!hasFocus()) return@post
1426
+ val start = selectionStart
1427
+ val end = selectionEnd
1428
+ if (start < 0 || end < 0) {
1429
+ recordImeTraceForTesting(
1430
+ "updateSelectionAfterRestartSkipped",
1431
+ "source=$source reason=selection start=$start end=$end"
1432
+ )
1433
+ return@post
1434
+ }
1435
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1436
+ imm?.updateSelection(this, start, end, -1, -1)
1437
+ recordImeTraceForTesting(
1438
+ "updateSelectionAfterRestart",
1439
+ "source=$source sel=$start..$end"
1440
+ )
1441
+ }
1442
+ }
1443
+
1444
+ private fun scheduleLineBoundaryInputRefreshForEditor(source: String) {
1445
+ if (!hasFocus()) return
1446
+ val generation = ++lineBoundaryInputRefreshGeneration
1447
+ recordImeTraceForTesting(
1448
+ "lineBoundaryInputRefreshScheduled",
1449
+ "source=$source generation=$generation"
1450
+ )
1451
+ post {
1452
+ if (generation != lineBoundaryInputRefreshGeneration) return@post
1453
+ if (!hasFocus()) return@post
1454
+ if (!isCursorAtRenderedLineStartForSentenceCaps()) {
1455
+ recordImeTraceForTesting(
1456
+ "lineBoundaryInputRefreshSkipped",
1457
+ "source=$source reason=cursor"
1458
+ )
1459
+ return@post
1460
+ }
1461
+ restartInputForEditor("lineBoundary:$source")
1462
+ }
1031
1463
  }
1032
1464
 
1033
1465
  private fun clearCompositionInvalidationForEditor() {
@@ -1035,7 +1467,6 @@ class EditorEditText @JvmOverloads constructor(
1035
1467
  }
1036
1468
 
1037
1469
  private fun nextInputConnectionGenerationForEditor(): Long {
1038
- inputConnectionGeneration += 1L
1039
1470
  return inputConnectionGeneration
1040
1471
  }
1041
1472
 
@@ -1049,12 +1480,14 @@ class EditorEditText @JvmOverloads constructor(
1049
1480
 
1050
1481
  private fun invalidateInputConnectionsForEditor() {
1051
1482
  inputConnectionGeneration += 1L
1483
+ recordImeTraceForTesting("invalidateInputConnections", "nextGen=$inputConnectionGeneration")
1052
1484
  activeInputConnection = null
1053
1485
  }
1054
1486
 
1055
1487
  private fun clearNativeComposingSpans() {
1056
1488
  val editable = text ?: return
1057
1489
  BaseInputConnection.removeComposingSpans(editable)
1490
+ removeTransientComposingTextStyleSpans(editable)
1058
1491
  }
1059
1492
 
1060
1493
  internal fun restoreAuthorizedTextIfNeeded() {
@@ -1119,9 +1552,19 @@ class EditorEditText @JvmOverloads constructor(
1119
1552
  replacementEndUtf16: Int,
1120
1553
  newCursorPosition: Int = 1
1121
1554
  ) {
1122
- if (!isEditable) return
1123
- if (isApplyingRustState) return
1124
- if (!hasLiveEditor()) return
1555
+ val startedAt = System.nanoTime()
1556
+ if (!isEditable) {
1557
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=notEditable textLength=${text.length}")
1558
+ return
1559
+ }
1560
+ if (isApplyingRustState) {
1561
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=applyingRust textLength=${text.length}")
1562
+ return
1563
+ }
1564
+ if (!hasLiveEditor()) {
1565
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=noLiveEditor textLength=${text.length}")
1566
+ return
1567
+ }
1125
1568
 
1126
1569
  val authorizedText = lastAuthorizedText
1127
1570
  val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
@@ -1144,31 +1587,52 @@ class EditorEditText @JvmOverloads constructor(
1144
1587
  text,
1145
1588
  newCursorPosition
1146
1589
  ) ?: scalarEnd
1590
+ recordImeTraceForTesting(
1591
+ "handleCompositionCommitNoop",
1592
+ "reason=alreadyAuthorized textLength=${text.length} requestedCursor=$requestedCursor range=$startUtf16..$endUtf16"
1593
+ )
1147
1594
  restoreAuthorizedTextIfNeeded()
1148
1595
  applyRequestedCursorScalar(requestedCursor)
1149
1596
  return
1150
1597
  }
1151
1598
 
1152
1599
  if (text == "\n") {
1600
+ recordImeTraceForTesting(
1601
+ "handleCompositionCommit",
1602
+ "route=return textLength=${text.length} utf16Range=$startUtf16..$endUtf16 scalarRange=$scalarStart..$scalarEnd"
1603
+ )
1153
1604
  if (scalarStart != scalarEnd) {
1154
1605
  deleteAndSplitInRust(scalarStart, scalarEnd)
1155
1606
  } else {
1156
1607
  splitBlockInRust(scalarStart)
1157
1608
  }
1609
+ recordImeTraceForTesting(
1610
+ "handleCompositionCommitDone",
1611
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1612
+ )
1158
1613
  return
1159
1614
  }
1160
1615
 
1616
+ val requestedCursor = requestedCursorScalar(
1617
+ scalarStart,
1618
+ scalarEnd,
1619
+ authorizedText,
1620
+ text,
1621
+ newCursorPosition
1622
+ )
1623
+ recordImeTraceForTesting(
1624
+ "handleCompositionCommit",
1625
+ "textLength=${text.length} cursor=$newCursorPosition utf16Range=$startUtf16..$endUtf16 scalarRange=$scalarStart..$scalarEnd requestedCursor=$requestedCursor"
1626
+ )
1161
1627
  insertPlainTextRangeInRust(
1162
1628
  scalarStart,
1163
1629
  scalarEnd,
1164
1630
  text,
1165
- requestedCursorScalar = requestedCursorScalar(
1166
- scalarStart,
1167
- scalarEnd,
1168
- authorizedText,
1169
- text,
1170
- newCursorPosition
1171
- )
1631
+ requestedCursorScalar = requestedCursor
1632
+ )
1633
+ recordImeTraceForTesting(
1634
+ "handleCompositionCommitDone",
1635
+ "textLength=${text.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1172
1636
  )
1173
1637
  }
1174
1638
 
@@ -2103,6 +2567,210 @@ class EditorEditText @JvmOverloads constructor(
2103
2567
 
2104
2568
  // ── Rust Integration ────────────────────────────────────────────────
2105
2569
 
2570
+ // Samsung Keyboard may call finishComposingText() and then commitText(" ")
2571
+ // for one space tap. Defer the render from finishComposingText() by one
2572
+ // loop so setText() does not restart input before the pending space arrives.
2573
+ internal fun runWithDeferredRustUpdateApplication(block: () -> Unit) {
2574
+ recordImeTraceForTesting(
2575
+ "deferRustUpdateBegin",
2576
+ "depth=$deferredRustUpdateApplicationDepth pending=${deferredRustUpdateJSON != null}"
2577
+ )
2578
+ deferredRustUpdateApplicationDepth += 1
2579
+ try {
2580
+ block()
2581
+ } finally {
2582
+ deferredRustUpdateApplicationDepth -= 1
2583
+ recordImeTraceForTesting(
2584
+ "deferRustUpdateEnd",
2585
+ "depth=$deferredRustUpdateApplicationDepth pending=${deferredRustUpdateJSON != null}"
2586
+ )
2587
+ if (deferredRustUpdateApplicationDepth == 0) {
2588
+ scheduleDeferredRustUpdateApplication()
2589
+ }
2590
+ }
2591
+ }
2592
+
2593
+ private fun applyRustUpdateJSON(updateJSON: String) {
2594
+ if (deferredRustUpdateApplicationDepth > 0) {
2595
+ deferredRustUpdateJSON = updateJSON
2596
+ recordImeTraceForTesting(
2597
+ "rustUpdateDeferred",
2598
+ "jsonLength=${updateJSON.length} depth=$deferredRustUpdateApplicationDepth"
2599
+ )
2600
+ authorizeCurrentVisibleTextForDeferredRustUpdate()
2601
+ return
2602
+ }
2603
+ cancelDeferredRustUpdateApplication()
2604
+ recordImeTraceForTesting(
2605
+ "rustUpdateApply",
2606
+ "mode=immediate jsonLength=${updateJSON.length}"
2607
+ )
2608
+ applyUpdateJSON(updateJSON)
2609
+ }
2610
+
2611
+ private fun authorizeCurrentVisibleTextForDeferredRustUpdate() {
2612
+ lastAuthorizedText = text?.toString().orEmpty()
2613
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
2614
+ lastAuthorizedTextRevision += 1L
2615
+ clearNativeTextMutationAdoptionSuppression()
2616
+ clearNativeTextMutationAfterBlurWindow()
2617
+ }
2618
+
2619
+ internal fun authorizeCurrentVisibleTextForPendingImeOperationForEditor() {
2620
+ pendingOptimisticRenderText = null
2621
+ authorizeCurrentVisibleTextForDeferredRustUpdate()
2622
+ recordImeTraceForTesting(
2623
+ "authorizePendingImeVisibleText",
2624
+ "textLength=${lastAuthorizedText.length}"
2625
+ )
2626
+ }
2627
+
2628
+ internal fun deleteScalarRangeForPendingImeOperationForEditor(scalarFrom: Int, scalarTo: Int) {
2629
+ deleteRangeInRust(scalarFrom, scalarTo)
2630
+ }
2631
+
2632
+ internal fun applyVisibleCompositionCommitForPendingImeOperationForEditor(
2633
+ committedText: String,
2634
+ replacementStartUtf16: Int,
2635
+ replacementEndUtf16: Int,
2636
+ newCursorPosition: Int
2637
+ ): Boolean {
2638
+ val editable = text ?: return false
2639
+ val currentText = editable.toString()
2640
+ val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
2641
+ replacementStartUtf16,
2642
+ replacementEndUtf16,
2643
+ currentText
2644
+ )
2645
+ if (startUtf16 > endUtf16 || endUtf16 > editable.length) return false
2646
+ var didApply = false
2647
+ runWithTransientInputMutationGuard {
2648
+ editable.replace(startUtf16, endUtf16, committedText)
2649
+ val insertedEnd = startUtf16 + committedText.length
2650
+ val requestedCursor = when {
2651
+ newCursorPosition > 0 -> insertedEnd + newCursorPosition - 1
2652
+ newCursorPosition < 0 -> startUtf16 + newCursorPosition
2653
+ else -> insertedEnd
2654
+ }.coerceIn(0, editable.length)
2655
+ Selection.setSelection(editable, requestedCursor, requestedCursor)
2656
+ didApply = true
2657
+ true
2658
+ }
2659
+ if (didApply) {
2660
+ pendingOptimisticRenderText = null
2661
+ }
2662
+ return didApply
2663
+ }
2664
+
2665
+ internal fun commitAlreadyVisibleCompositionMutationForPendingImeOperationForEditor(
2666
+ committedText: String,
2667
+ newCursorPosition: Int
2668
+ ): Boolean {
2669
+ if (committedText.isEmpty()) return false
2670
+ val currentText = text?.toString() ?: return false
2671
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText) ?: return false
2672
+ val tokenRange = committedTokenRangeAroundMutation(
2673
+ currentText,
2674
+ mutation.replacementStartUtf16,
2675
+ mutation.replacementEndUtf16
2676
+ ) ?: run {
2677
+ recordImeTraceForTesting(
2678
+ "alreadyVisibleCompositionNoop",
2679
+ "reason=noToken committedLength=${committedText.length} visibleRange=${mutation.replacementStartUtf16}..${mutation.replacementEndUtf16}"
2680
+ )
2681
+ return false
2682
+ }
2683
+ val visibleToken = currentText.substring(tokenRange.first, tokenRange.second)
2684
+ if (visibleToken != committedText) {
2685
+ recordImeTraceForTesting(
2686
+ "alreadyVisibleCompositionNoop",
2687
+ "reason=tokenMismatch committedLength=${committedText.length} tokenLength=${visibleToken.length} visibleRange=${mutation.replacementStartUtf16}..${mutation.replacementEndUtf16}"
2688
+ )
2689
+ return false
2690
+ }
2691
+
2692
+ val authorizedText = lastAuthorizedText
2693
+ val requestedCursor = requestedCursorScalar(
2694
+ mutation.scalarFrom,
2695
+ mutation.scalarTo,
2696
+ authorizedText,
2697
+ mutation.replacementText,
2698
+ newCursorPosition
2699
+ )
2700
+ recordImeTraceForTesting(
2701
+ "alreadyVisibleCompositionApply",
2702
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} committedLength=${committedText.length} requestedCursor=$requestedCursor"
2703
+ )
2704
+ pendingOptimisticRenderText = null
2705
+ insertPlainTextRangeInRust(
2706
+ mutation.scalarFrom,
2707
+ mutation.scalarTo,
2708
+ mutation.replacementText,
2709
+ requestedCursorScalar = requestedCursor
2710
+ )
2711
+ return true
2712
+ }
2713
+
2714
+ private fun committedTokenRangeAroundMutation(
2715
+ currentText: String,
2716
+ replacementStartUtf16: Int,
2717
+ replacementEndUtf16: Int
2718
+ ): Pair<Int, Int>? {
2719
+ if (currentText.isEmpty()) return null
2720
+ val start = replacementStartUtf16.coerceIn(0, currentText.length)
2721
+ val end = replacementEndUtf16.coerceIn(start, currentText.length)
2722
+ val probe = when {
2723
+ start < end -> start
2724
+ start < currentText.length -> start
2725
+ start > 0 -> Character.offsetByCodePoints(currentText, start, -1)
2726
+ else -> return null
2727
+ }
2728
+ val tokenRange = missingOldTextCorrectionTokenRange(currentText, probe) ?: return null
2729
+ return if (start < end) {
2730
+ tokenRange.takeIf { it.first <= start && it.second >= end }
2731
+ } else {
2732
+ tokenRange.takeIf { start >= it.first && start <= it.second }
2733
+ }
2734
+ }
2735
+
2736
+ private fun scheduleDeferredRustUpdateApplication() {
2737
+ val pendingUpdateJSON = deferredRustUpdateJSON ?: return
2738
+ val generation = ++deferredRustUpdateGeneration
2739
+ recordImeTraceForTesting(
2740
+ "rustUpdateDeferredScheduled",
2741
+ "generation=$generation jsonLength=${pendingUpdateJSON.length}"
2742
+ )
2743
+ Handler(Looper.getMainLooper()).post {
2744
+ if (generation != deferredRustUpdateGeneration) {
2745
+ recordImeTraceForTesting(
2746
+ "rustUpdateDeferredSkip",
2747
+ "reason=generation generation=$generation current=$deferredRustUpdateGeneration"
2748
+ )
2749
+ return@post
2750
+ }
2751
+ if (deferredRustUpdateJSON != pendingUpdateJSON) {
2752
+ recordImeTraceForTesting("rustUpdateDeferredSkip", "reason=replaced generation=$generation")
2753
+ return@post
2754
+ }
2755
+ deferredRustUpdateJSON = null
2756
+ recordImeTraceForTesting(
2757
+ "rustUpdateApply",
2758
+ "mode=deferred generation=$generation jsonLength=${pendingUpdateJSON.length}"
2759
+ )
2760
+ applyUpdateJSON(pendingUpdateJSON)
2761
+ }
2762
+ }
2763
+
2764
+ private fun cancelDeferredRustUpdateApplication() {
2765
+ if (deferredRustUpdateJSON == null) return
2766
+ recordImeTraceForTesting(
2767
+ "rustUpdateDeferredCancel",
2768
+ "generation=$deferredRustUpdateGeneration"
2769
+ )
2770
+ deferredRustUpdateJSON = null
2771
+ deferredRustUpdateGeneration += 1L
2772
+ }
2773
+
2106
2774
  /**
2107
2775
  * Insert text at a scalar position via the Rust editor.
2108
2776
  */
@@ -2112,8 +2780,13 @@ class EditorEditText @JvmOverloads constructor(
2112
2780
  callback(text, atScalarPos)
2113
2781
  return
2114
2782
  }
2783
+ val startedAt = System.nanoTime()
2115
2784
  val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
2116
- applyUpdateJSON(updateJSON)
2785
+ recordImeTraceForTesting(
2786
+ "rustInsertText",
2787
+ "at=$atScalarPos textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2788
+ )
2789
+ applyRustUpdateJSON(updateJSON)
2117
2790
  }
2118
2791
 
2119
2792
  private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
@@ -2122,13 +2795,18 @@ class EditorEditText @JvmOverloads constructor(
2122
2795
  callback(scalarFrom, scalarTo, text)
2123
2796
  return
2124
2797
  }
2798
+ val startedAt = System.nanoTime()
2125
2799
  val updateJSON = editorReplaceTextScalar(
2126
2800
  editorId.toULong(),
2127
2801
  scalarFrom.toUInt(),
2128
2802
  scalarTo.toUInt(),
2129
2803
  text
2130
2804
  )
2131
- applyUpdateJSON(updateJSON)
2805
+ recordImeTraceForTesting(
2806
+ "rustReplaceText",
2807
+ "range=$scalarFrom..$scalarTo textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2808
+ )
2809
+ applyRustUpdateJSON(updateJSON)
2132
2810
  }
2133
2811
 
2134
2812
  private fun insertPlainTextRangeInRust(
@@ -2138,6 +2816,10 @@ class EditorEditText @JvmOverloads constructor(
2138
2816
  requestedCursorScalar: Int? = null
2139
2817
  ) {
2140
2818
  if (!hasLiveEditor()) return
2819
+ recordImeTraceForTesting(
2820
+ "rustPlainTextRoute",
2821
+ "range=$scalarFrom..$scalarTo textLength=${text.length} requestedCursor=$requestedCursorScalar"
2822
+ )
2141
2823
  if (text.isEmpty()) {
2142
2824
  if (scalarFrom != scalarTo) {
2143
2825
  deleteRangeInRust(scalarFrom, scalarTo)
@@ -2152,13 +2834,18 @@ class EditorEditText @JvmOverloads constructor(
2152
2834
  applyRequestedCursorScalar(requestedCursorScalar)
2153
2835
  return
2154
2836
  }
2837
+ val startedAt = System.nanoTime()
2155
2838
  val updateJSON = editorInsertContentJsonAtSelectionScalar(
2156
2839
  editorId.toULong(),
2157
2840
  scalarFrom.toUInt(),
2158
2841
  scalarTo.toUInt(),
2159
2842
  docJson
2160
2843
  )
2161
- applyUpdateJSON(updateJSON)
2844
+ recordImeTraceForTesting(
2845
+ "rustInsertContentJson",
2846
+ "range=$scalarFrom..$scalarTo textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2847
+ )
2848
+ applyRustUpdateJSON(updateJSON)
2162
2849
  applyRequestedCursorScalar(requestedCursorScalar)
2163
2850
  return
2164
2851
  }
@@ -2286,6 +2973,8 @@ class EditorEditText @JvmOverloads constructor(
2286
2973
  scalarTo = PositionBridge.utf16ToScalar(authorizedEnd, authorizedText),
2287
2974
  replacementText = replacementText,
2288
2975
  resultingText = currentText,
2976
+ replacementStartUtf16 = prefix,
2977
+ replacementEndUtf16 = currentEnd,
2289
2978
  selectionScalarAnchor = selectionAnchorUtf16?.let {
2290
2979
  PositionBridge.utf16ToScalar(it, currentText)
2291
2980
  },
@@ -2406,6 +3095,7 @@ class EditorEditText @JvmOverloads constructor(
2406
3095
 
2407
3096
  private fun commitNativeTextMutation(mutation: NativeTextMutation) {
2408
3097
  if (!hasLiveEditor()) return
3098
+ val startedAt = System.nanoTime()
2409
3099
  if ((text?.toString() ?: "") != mutation.resultingText) {
2410
3100
  recordImeTraceForTesting(
2411
3101
  "nativeMutationNoop",
@@ -2435,6 +3125,10 @@ class EditorEditText @JvmOverloads constructor(
2435
3125
  if (shouldRestartInput) {
2436
3126
  restartInputForEditor()
2437
3127
  }
3128
+ recordImeTraceForTesting(
3129
+ "nativeMutationApplyDone",
3130
+ "totalUs=${nanosToMicros(System.nanoTime() - startedAt)} restartInput=$shouldRestartInput"
3131
+ )
2438
3132
  }
2439
3133
 
2440
3134
  private fun restoreSelectionAfterNativeTextMutation(mutation: NativeTextMutation) {
@@ -2460,8 +3154,13 @@ class EditorEditText @JvmOverloads constructor(
2460
3154
  callback(scalarFrom, scalarTo)
2461
3155
  return
2462
3156
  }
3157
+ val startedAt = System.nanoTime()
2463
3158
  val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
2464
- applyUpdateJSON(updateJSON)
3159
+ recordImeTraceForTesting(
3160
+ "rustDeleteRange",
3161
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3162
+ )
3163
+ applyRustUpdateJSON(updateJSON)
2465
3164
  }
2466
3165
 
2467
3166
  private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
@@ -2470,12 +3169,17 @@ class EditorEditText @JvmOverloads constructor(
2470
3169
  callback(scalarAnchor, scalarHead)
2471
3170
  return
2472
3171
  }
3172
+ val startedAt = System.nanoTime()
2473
3173
  val updateJSON = editorDeleteBackwardAtSelectionScalar(
2474
3174
  editorId.toULong(),
2475
3175
  scalarAnchor.toUInt(),
2476
3176
  scalarHead.toUInt()
2477
3177
  )
2478
- applyUpdateJSON(updateJSON)
3178
+ recordImeTraceForTesting(
3179
+ "rustDeleteBackward",
3180
+ "selection=$scalarAnchor..$scalarHead rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3181
+ )
3182
+ applyRustUpdateJSON(updateJSON)
2479
3183
  }
2480
3184
 
2481
3185
  /**
@@ -2483,8 +3187,14 @@ class EditorEditText @JvmOverloads constructor(
2483
3187
  */
2484
3188
  private fun splitBlockInRust(atScalarPos: Int) {
2485
3189
  if (!hasLiveEditor()) return
3190
+ val startedAt = System.nanoTime()
2486
3191
  val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
2487
- applyUpdateJSON(updateJSON)
3192
+ recordImeTraceForTesting(
3193
+ "rustSplitBlock",
3194
+ "at=$atScalarPos rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3195
+ )
3196
+ applyRustUpdateJSON(updateJSON)
3197
+ scheduleLineBoundaryInputRefreshForEditor("splitBlock")
2488
3198
  }
2489
3199
 
2490
3200
  private fun deleteAndSplitInRust(scalarFrom: Int, scalarTo: Int) {
@@ -2493,12 +3203,18 @@ class EditorEditText @JvmOverloads constructor(
2493
3203
  callback(scalarFrom, scalarTo)
2494
3204
  return
2495
3205
  }
3206
+ val startedAt = System.nanoTime()
2496
3207
  val updateJSON = editorDeleteAndSplitScalar(
2497
3208
  editorId.toULong(),
2498
3209
  scalarFrom.toUInt(),
2499
3210
  scalarTo.toUInt()
2500
3211
  )
2501
- applyUpdateJSON(updateJSON)
3212
+ recordImeTraceForTesting(
3213
+ "rustDeleteAndSplit",
3214
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3215
+ )
3216
+ applyRustUpdateJSON(updateJSON)
3217
+ scheduleLineBoundaryInputRefreshForEditor("deleteAndSplit")
2502
3218
  }
2503
3219
 
2504
3220
  internal fun currentScalarSelection(): Pair<Int, Int>? {
@@ -2506,6 +3222,143 @@ class EditorEditText @JvmOverloads constructor(
2506
3222
  return normalizedScalarSelectionRange(currentText)
2507
3223
  }
2508
3224
 
3225
+ internal fun cursorCapsModeForEditor(reqModes: Int, baseCapsMode: Int): Int {
3226
+ val sentenceCapsMode = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
3227
+ if ((reqModes and sentenceCapsMode) != sentenceCapsMode) return baseCapsMode
3228
+ if ((baseCapsMode and sentenceCapsMode) == sentenceCapsMode) return baseCapsMode
3229
+ if (!isCursorAtRenderedLineStartForSentenceCaps()) return baseCapsMode
3230
+ return baseCapsMode or sentenceCapsMode
3231
+ }
3232
+
3233
+ internal fun textBeforeCursorForImeContextForEditor(n: Int, flags: Int): CharSequence? {
3234
+ if (n <= 0) return ""
3235
+ val content = text ?: return null
3236
+ val start = selectionStart
3237
+ val end = selectionEnd
3238
+ if (start < 0 || end < 0) return null
3239
+ val cursor = minOf(start, end).coerceIn(0, content.length)
3240
+ var effectiveCursor = cursor
3241
+ while (
3242
+ effectiveCursor > 0 &&
3243
+ content[effectiveCursor - 1] == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3244
+ ) {
3245
+ effectiveCursor -= 1
3246
+ }
3247
+ val contextStart = maxOf(0, effectiveCursor - n)
3248
+ val context = content.subSequence(contextStart, effectiveCursor)
3249
+ return if ((flags and InputConnection.GET_TEXT_WITH_STYLES) != 0) {
3250
+ context
3251
+ } else {
3252
+ context.toString()
3253
+ }
3254
+ }
3255
+
3256
+ internal fun initialSurroundingTextForImeForEditor(): ImeInitialSurroundingText? {
3257
+ val rawText = text?.toString() ?: return null
3258
+ val placeholder = LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3259
+ if (rawText.indexOf(placeholder) < 0) return null
3260
+ val start = selectionStart
3261
+ val end = selectionEnd
3262
+ if (start < 0 || end < 0) return null
3263
+ val rawSelectionStart = start.coerceIn(0, rawText.length)
3264
+ val rawSelectionEnd = end.coerceIn(0, rawText.length)
3265
+
3266
+ val sanitized = StringBuilder(rawText.length)
3267
+ var removedCount = 0
3268
+ var removedBeforeSelectionStart = 0
3269
+ var removedBeforeSelectionEnd = 0
3270
+ rawText.forEachIndexed { index, ch ->
3271
+ if (ch == placeholder) {
3272
+ removedCount += 1
3273
+ if (index < rawSelectionStart) removedBeforeSelectionStart += 1
3274
+ if (index < rawSelectionEnd) removedBeforeSelectionEnd += 1
3275
+ } else {
3276
+ sanitized.append(ch)
3277
+ }
3278
+ }
3279
+
3280
+ return ImeInitialSurroundingText(
3281
+ text = sanitized.toString(),
3282
+ selectionStart = rawSelectionStart - removedBeforeSelectionStart,
3283
+ selectionEnd = rawSelectionEnd - removedBeforeSelectionEnd,
3284
+ originalSelectionStart = rawSelectionStart,
3285
+ originalSelectionEnd = rawSelectionEnd,
3286
+ removedPlaceholderCount = removedCount
3287
+ )
3288
+ }
3289
+
3290
+ private fun isCursorAtRenderedLineStartForSentenceCaps(): Boolean {
3291
+ val currentText = text?.toString() ?: return false
3292
+ val start = selectionStart
3293
+ val end = selectionEnd
3294
+ if (start < 0 || end < 0 || start != end) return false
3295
+
3296
+ val cursor = end.coerceIn(0, currentText.length)
3297
+ return isRenderedLineStartForSentenceCaps(currentText, cursor)
3298
+ }
3299
+
3300
+ private fun isRenderedLineStartForSentenceCaps(text: String, cursor: Int): Boolean {
3301
+ val cursor = cursor.coerceIn(0, text.length)
3302
+ if (cursor == 0) return true
3303
+
3304
+ val lineStart = lastRenderedLineBreakBefore(text, cursor) + 1
3305
+ var index = lineStart
3306
+ while (index < cursor && isIgnoredSentenceCapsLinePrefix(text[index])) {
3307
+ index += 1
3308
+ }
3309
+ if (index == cursor) return true
3310
+
3311
+ val markerEnd = renderedListMarkerEnd(text, index, cursor) ?: return false
3312
+ index = markerEnd
3313
+ while (index < cursor && isIgnoredSentenceCapsLinePrefix(text[index])) {
3314
+ index += 1
3315
+ }
3316
+ return index == cursor
3317
+ }
3318
+
3319
+ private fun isSamsungKeyboardActiveForEditor(): Boolean {
3320
+ val inputMethodId = Settings.Secure.getString(
3321
+ context.contentResolver,
3322
+ Settings.Secure.DEFAULT_INPUT_METHOD
3323
+ ) ?: return false
3324
+ return inputMethodId.contains("samsung", ignoreCase = true) ||
3325
+ inputMethodId.contains("honeyboard", ignoreCase = true)
3326
+ }
3327
+
3328
+ private fun lastRenderedLineBreakBefore(text: String, cursor: Int): Int {
3329
+ var index = cursor.coerceAtMost(text.length) - 1
3330
+ while (index >= 0) {
3331
+ when (text[index]) {
3332
+ '\n', '\r' -> return index
3333
+ }
3334
+ index -= 1
3335
+ }
3336
+ return -1
3337
+ }
3338
+
3339
+ private fun isIgnoredSentenceCapsLinePrefix(ch: Char): Boolean =
3340
+ ch == ' ' ||
3341
+ ch == '\t' ||
3342
+ ch == '\u00A0' ||
3343
+ ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3344
+
3345
+ private fun renderedListMarkerEnd(text: String, start: Int, endExclusive: Int): Int? {
3346
+ if (start >= endExclusive) return null
3347
+ if (text[start] == LayoutConstants.UNORDERED_LIST_BULLET[0]) {
3348
+ return start + 1
3349
+ }
3350
+
3351
+ var index = start
3352
+ while (index < endExclusive && text[index].isDigit()) {
3353
+ index += 1
3354
+ }
3355
+ if (index == start || index >= endExclusive) return null
3356
+ return when (text[index]) {
3357
+ '.', ')' -> index + 1
3358
+ else -> null
3359
+ }
3360
+ }
3361
+
2509
3362
  private fun normalizedUtf16SelectionRange(currentText: String): Pair<Int, Int>? {
2510
3363
  val start = selectionStart
2511
3364
  val end = selectionEnd
@@ -2708,8 +3561,12 @@ class EditorEditText @JvmOverloads constructor(
2708
3561
  replaceRange: RenderReplaceRange? = null,
2709
3562
  usedPatch: Boolean
2710
3563
  ) {
3564
+ val startedAt = System.nanoTime()
3565
+ val previousScrollX = scrollX
3566
+ val previousScrollY = scrollY
2711
3567
  val hadCompositionTracking = hasCompositionTrackingForEditor()
2712
3568
  var shouldRestartInput = false
3569
+ val mode = if (replaceRange != null) "replace" else "setText"
2713
3570
  isApplyingRustState = true
2714
3571
  beginBatchEdit()
2715
3572
  try {
@@ -2734,9 +3591,30 @@ class EditorEditText @JvmOverloads constructor(
2734
3591
  endBatchEdit()
2735
3592
  isApplyingRustState = false
2736
3593
  }
3594
+ recordImeTraceForTesting(
3595
+ "applyRenderedSpannable",
3596
+ "mode=$mode usedPatch=$usedPatch incomingLength=${spannable.length} replace=${replaceRange?.start}..${replaceRange?.endExclusive} hadComposition=$hadCompositionTracking restartInput=$shouldRestartInput applyUs=${nanosToMicros(System.nanoTime() - startedAt)} scroll=$previousScrollX,$previousScrollY->$scrollX,$scrollY layout=${layout != null}"
3597
+ )
2737
3598
  restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
2738
3599
  }
2739
3600
 
3601
+ private fun authorizeVisibleTextForMatchedOptimisticRender(spannable: CharSequence) {
3602
+ val startedAt = System.nanoTime()
3603
+ val visibleText = text?.toString().orEmpty()
3604
+ lastAuthorizedText = visibleText
3605
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
3606
+ ?: SpannableStringBuilder(spannable)
3607
+ lastAuthorizedTextRevision += 1L
3608
+ clearNativeTextMutationAdoptionSuppression()
3609
+ clearCompositionTrackingForEditor()
3610
+ lastRenderAppliedPatchForTesting = false
3611
+ clearNativeTextMutationAfterBlurWindow()
3612
+ recordImeTraceForTesting(
3613
+ "reuseOptimisticVisibleTextRender",
3614
+ "textLength=${visibleText.length} applyUs=${nanosToMicros(System.nanoTime() - startedAt)}"
3615
+ )
3616
+ }
3617
+
2740
3618
  private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
2741
3619
  RenderBridge.buildSpannableFromBlocks(
2742
3620
  patch.renderBlocks,
@@ -2906,9 +3784,14 @@ class EditorEditText @JvmOverloads constructor(
2906
3784
  val parseStartedAt = totalStartedAt
2907
3785
  val update = try {
2908
3786
  org.json.JSONObject(updateJSON)
2909
- } catch (_: Exception) {
3787
+ } catch (error: Exception) {
3788
+ recordImeTraceForTesting(
3789
+ "applyUpdateJSONNoop",
3790
+ "reason=parseError jsonLength=${updateJSON.length} error=${error.javaClass.simpleName}"
3791
+ )
2910
3792
  return
2911
3793
  }
3794
+ cancelDeferredRustUpdateApplication()
2912
3795
  val parseNanos = System.nanoTime() - parseStartedAt
2913
3796
 
2914
3797
  val resolveRenderBlocksStartedAt = System.nanoTime()
@@ -2933,6 +3816,7 @@ class EditorEditText @JvmOverloads constructor(
2933
3816
  val buildRenderNanos: Long
2934
3817
  val applyRenderNanos: Long
2935
3818
  if (shouldSkipRender) {
3819
+ pendingOptimisticRenderText = null
2936
3820
  lastRenderAppliedPatchForTesting = false
2937
3821
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
2938
3822
  clearNativeTextMutationAdoptionSuppression()
@@ -2964,12 +3848,27 @@ class EditorEditText @JvmOverloads constructor(
2964
3848
  this
2965
3849
  )
2966
3850
  } else {
3851
+ recordImeTraceForTesting(
3852
+ "applyUpdateJSONNoop",
3853
+ "reason=noRenderPayload jsonLength=${updateJSON.length}"
3854
+ )
2967
3855
  return
2968
3856
  }
2969
3857
  buildRenderNanos = System.nanoTime() - buildStartedAt
2970
3858
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
2971
3859
  val applyStartedAt = System.nanoTime()
2972
- applyRenderedSpannable(fullSpannable, usedPatch = false)
3860
+ val optimisticText = pendingOptimisticRenderText
3861
+ val canReuseOptimisticVisibleText =
3862
+ optimisticText != null &&
3863
+ text?.toString() == optimisticText &&
3864
+ fullSpannable.toString() == optimisticText &&
3865
+ !spannedContainsImageSpan(fullSpannable)
3866
+ if (canReuseOptimisticVisibleText) {
3867
+ authorizeVisibleTextForMatchedOptimisticRender(fullSpannable)
3868
+ } else {
3869
+ applyRenderedSpannable(fullSpannable, usedPatch = false)
3870
+ }
3871
+ pendingOptimisticRenderText = null
2973
3872
  applyRenderNanos = System.nanoTime() - applyStartedAt
2974
3873
  lastAppliedRenderAppearanceRevision = renderAppearanceRevision
2975
3874
  }
@@ -2994,6 +3893,12 @@ class EditorEditText @JvmOverloads constructor(
2994
3893
  }
2995
3894
  val postApplyNanos = System.nanoTime() - postApplyStartedAt
2996
3895
 
3896
+ val totalNanos = System.nanoTime() - totalStartedAt
3897
+ recordImeTraceForTesting(
3898
+ "applyUpdateJSON",
3899
+ "notify=$notifyListener skippedRender=$shouldSkipRender attemptedPatch=${renderPatch != null} jsonLength=${updateJSON.length} parseUs=${nanosToMicros(parseNanos)} resolveUs=${nanosToMicros(resolveRenderBlocksNanos)} buildUs=${nanosToMicros(buildRenderNanos)} applyUs=${nanosToMicros(applyRenderNanos)} selectionUs=${nanosToMicros(selectionNanos)} postUs=${nanosToMicros(postApplyNanos)} totalUs=${nanosToMicros(totalNanos)}"
3900
+ )
3901
+
2997
3902
  if (captureApplyUpdateTraceForTesting) {
2998
3903
  lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
2999
3904
  attemptedPatch = renderPatch != null,
@@ -3006,7 +3911,7 @@ class EditorEditText @JvmOverloads constructor(
3006
3911
  applyRenderNanos = applyRenderNanos,
3007
3912
  selectionNanos = selectionNanos,
3008
3913
  postApplyNanos = postApplyNanos,
3009
- totalNanos = System.nanoTime() - totalStartedAt
3914
+ totalNanos = totalNanos
3010
3915
  )
3011
3916
  }
3012
3917
  }
@@ -3020,6 +3925,7 @@ class EditorEditText @JvmOverloads constructor(
3020
3925
  * @param renderJSON The JSON array string of render elements.
3021
3926
  */
3022
3927
  fun applyRenderJSON(renderJSON: String) {
3928
+ val startedAt = System.nanoTime()
3023
3929
  val spannable = RenderBridge.buildSpannable(
3024
3930
  renderJSON,
3025
3931
  baseFontSize,
@@ -3034,6 +3940,7 @@ class EditorEditText @JvmOverloads constructor(
3034
3940
 
3035
3941
  explicitSelectedImageRange = null
3036
3942
  currentRenderBlocksJson = null
3943
+ pendingOptimisticRenderText = null
3037
3944
  applyRenderedSpannable(spannable, usedPatch = false)
3038
3945
  onSelectionOrContentMayChange?.invoke()
3039
3946
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
@@ -3041,6 +3948,10 @@ class EditorEditText @JvmOverloads constructor(
3041
3948
  } else {
3042
3949
  preserveScrollPosition(previousScrollX, previousScrollY)
3043
3950
  }
3951
+ recordImeTraceForTesting(
3952
+ "applyRenderJSON",
3953
+ "jsonLength=${renderJSON.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
3954
+ )
3044
3955
  }
3045
3956
 
3046
3957
  private fun textOffsetHitAt(x: Float, y: Float): Pair<Spanned, Int>? {
@@ -3364,6 +4275,7 @@ class EditorEditText @JvmOverloads constructor(
3364
4275
  private const val DEFAULT_KEYBOARD_TYPE = "default"
3365
4276
  private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
3366
4277
  private const val IME_TRACE_LIMIT_FOR_TESTING = 80
4278
+ private const val IME_TRACE_LOG_TAG = "NativeEditorIme"
3367
4279
  private const val NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS = 750L
3368
4280
  private const val RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS = 750L
3369
4281
  private const val LOG_TAG = "NativeEditor"