@apollohg/react-native-prose-editor 0.5.16 → 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.
@@ -1,18 +1,33 @@
1
1
  package com.apollohg.editor
2
2
 
3
+ import android.content.ClipData
3
4
  import android.content.ClipboardManager
4
5
  import android.content.Context
5
6
  import android.graphics.Typeface
6
7
  import android.graphics.Rect
7
8
  import android.graphics.RectF
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.os.SystemClock
13
+ import android.provider.Settings
8
14
  import android.text.Annotation
9
15
  import android.text.Editable
10
16
  import android.text.InputType
11
17
  import android.text.Layout
18
+ import android.text.Selection
12
19
  import android.text.Spanned
20
+ import android.text.SpannableStringBuilder
13
21
  import android.text.StaticLayout
14
22
  import android.text.TextPaint
15
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
16
31
  import android.util.AttributeSet
17
32
  import android.util.Log
18
33
  import android.util.TypedValue
@@ -70,6 +85,15 @@ class EditorEditText @JvmOverloads constructor(
70
85
  val totalNanos: Long
71
86
  )
72
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
+
73
97
  data class SelectedImageGeometry(
74
98
  val docPos: Int,
75
99
  val rect: RectF
@@ -80,6 +104,17 @@ class EditorEditText @JvmOverloads constructor(
80
104
  val label: String
81
105
  )
82
106
 
107
+ data class CommandPreparation(
108
+ val ready: Boolean,
109
+ val updateJSON: String?
110
+ )
111
+
112
+ private data class HardwareKeyEventSignature(
113
+ val keyCode: Int,
114
+ val downTime: Long,
115
+ val repeatCount: Int
116
+ )
117
+
83
118
  data class LinkHit(
84
119
  val href: String,
85
120
  val text: String
@@ -112,9 +147,43 @@ class EditorEditText @JvmOverloads constructor(
112
147
  val scalarFrom: Int,
113
148
  val scalarTo: Int,
114
149
  val replacementText: String,
115
- val resultingText: String
150
+ val resultingText: String,
151
+ val replacementStartUtf16: Int,
152
+ val replacementEndUtf16: Int,
153
+ val selectionScalarAnchor: Int?,
154
+ val selectionScalarHead: Int?
155
+ )
156
+
157
+ private data class NativeTextMutationAfterBlurWindow(
158
+ val editorId: Long,
159
+ val authorizedTextRevision: Long,
160
+ val deadlineMs: Long,
161
+ var didAdoptMutation: Boolean = false
116
162
  )
117
163
 
164
+ private data class NativeTextMutationAdoptionSuppression(
165
+ val editorId: Long,
166
+ val authorizedTextRevision: Long
167
+ )
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
+
118
187
  /**
119
188
  * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
120
189
  */
@@ -137,6 +206,13 @@ class EditorEditText @JvmOverloads constructor(
137
206
  * focus, text selection, and copy capability.
138
207
  */
139
208
  var isEditable: Boolean = true
209
+ set(value) {
210
+ if (field == value) return
211
+ if (!value) {
212
+ discardTransientNativeInputForReadOnly()
213
+ }
214
+ field = value
215
+ }
140
216
 
141
217
  /**
142
218
  * Guard flag to prevent re-entrant input interception while we're
@@ -192,22 +268,110 @@ class EditorEditText @JvmOverloads constructor(
192
268
  var reconciliationCount: Int = 0
193
269
  private set
194
270
 
195
- private var lastHandledHardwareKeyCode: Int? = null
196
- private var lastHandledHardwareKeyDownTime: Long? = null
271
+ private var lastHandledHardwareKeySignature: HardwareKeyEventSignature? = null
272
+ private var recentHandledHardwareKeyDownSignature: HardwareKeyEventSignature? = null
273
+ private var recentHandledHardwareKeyDownUptimeMs: Long = 0L
274
+ private var activeInputConnection: EditorInputConnection? = null
275
+ private var inputConnectionGeneration: Long = 0L
276
+ private var composingText: String? = null
277
+ private var composingReplacementStartUtf16: Int? = null
278
+ private var composingReplacementEndUtf16: Int? = null
279
+ private var composingReplacementAuthorizedTextRevision: Long? = null
280
+ private var didInvalidateCompositionReplacementRange = false
281
+ private var nativeTextMutationAfterBlurWindow: NativeTextMutationAfterBlurWindow? = null
282
+ private var nativeTextMutationAdoptionSuppression: NativeTextMutationAdoptionSuppression? = null
283
+ private var lastAuthorizedTextRevision: Long = 0L
284
+ private var lastAuthorizedRenderedText: CharSequence? = null
197
285
  private var explicitSelectedImageRange: ImageSelectionRange? = null
198
286
  private var lastRenderAppliedPatchForTesting: Boolean = false
199
287
  internal var captureApplyUpdateTraceForTesting: Boolean = false
200
288
  private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
289
+ private val imeTraceForTesting = java.util.ArrayDeque<String>()
290
+ private var imeTraceSequence: Long = 0L
291
+ private var lastImeTraceUptimeMs: Long = 0L
201
292
  private var currentRenderBlocksJson: org.json.JSONArray? = null
202
293
  private var renderAppearanceRevision: Long = 1L
203
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
204
301
  internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
205
302
  internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
206
303
  internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
207
304
  internal var onReplaceTextInRustForTesting: ((Int, Int, String) -> Unit)? = null
305
+ internal var onSetSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
306
+ internal var onDeleteAndSplitScalarInRustForTesting: ((Int, Int) -> Unit)? = null
307
+ internal var onInsertContentHtmlInRustForTesting: ((String) -> Unit)? = null
308
+ internal var onInsertContentJsonAtSelectionScalarForTesting: ((Int, Int, String) -> Unit)? = null
309
+ internal var blockExternalEditorUpdatePreparationForTesting = false
310
+ internal var blockExternalEditorCommandPreparationForTesting = false
208
311
 
209
312
  fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
210
313
  fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
314
+ internal fun hasDeferredRustUpdateApplicationForTesting(): Boolean = deferredRustUpdateJSON != null
315
+
316
+ internal fun applyRustUpdateJSONForTesting(updateJSON: String) {
317
+ applyRustUpdateJSON(updateJSON)
318
+ }
319
+
320
+ internal fun recordImeTraceForTesting(event: String, details: String = "") {
321
+ if (imeTraceForTesting.size >= IME_TRACE_LIMIT_FOR_TESTING) {
322
+ imeTraceForTesting.removeFirst()
323
+ }
324
+ imeTraceForTesting.addLast(
325
+ if (details.isEmpty()) event else "$event:$details"
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
+ }
363
+ }
364
+
365
+ internal fun clearImeTraceForTesting() {
366
+ imeTraceForTesting.clear()
367
+ imeTraceSequence = 0L
368
+ lastImeTraceUptimeMs = 0L
369
+ }
370
+
371
+ internal fun imeTraceSnapshotForTesting(): List<String> =
372
+ imeTraceForTesting.toList()
373
+
374
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
211
375
 
212
376
  init {
213
377
  // Configure for rich text editing.
@@ -283,22 +447,23 @@ class EditorEditText @JvmOverloads constructor(
283
447
 
284
448
  val currentStart = selectionStart
285
449
  val currentEnd = selectionEnd
450
+ val authorizedSelection = authorizedSelectionForTransientInputRestore(
451
+ currentStart,
452
+ currentEnd
453
+ )
454
+ discardTransientInputAndRestoreAuthorizedTextForEditor()
286
455
  setRawInputType(nextInputType)
287
456
 
288
457
  val editable = text
289
- if (
290
- editable != null &&
291
- currentStart >= 0 &&
292
- currentEnd >= 0 &&
293
- currentStart <= editable.length &&
294
- currentEnd <= editable.length
295
- ) {
296
- setSelection(currentStart, currentEnd)
458
+ if (editable != null && authorizedSelection != null) {
459
+ setSelection(
460
+ authorizedSelection.first.coerceIn(0, editable.length),
461
+ authorizedSelection.second.coerceIn(0, editable.length)
462
+ )
297
463
  }
298
464
 
299
465
  if (hasFocus()) {
300
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
301
- imm?.restartInput(this)
466
+ restartInputForEditor()
302
467
  }
303
468
  }
304
469
 
@@ -346,16 +511,124 @@ class EditorEditText @JvmOverloads constructor(
346
511
  */
347
512
  override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
348
513
  val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
349
- return EditorInputConnection(this, baseConnection)
514
+ val originalInitialCapsMode = outAttrs.initialCapsMode
515
+ outAttrs.initialCapsMode = cursorCapsModeForEditor(
516
+ reqModes = outAttrs.inputType,
517
+ baseCapsMode = outAttrs.initialCapsMode
518
+ )
519
+ val initialSurroundingText = applyInitialSurroundingTextForIme(outAttrs)
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
+ )
529
+ return EditorInputConnection(this, baseConnection, editorId, generation).also {
530
+ activeInputConnection = it
531
+ }
532
+ }
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()
350
570
  }
351
571
 
352
572
  override fun dispatchKeyEvent(event: KeyEvent): Boolean {
573
+ if (!isEditable && isReadOnlyTextMutationKeyEvent(event)) {
574
+ return true
575
+ }
576
+ if (handleCompositionKeyEvent(event) { super.dispatchKeyEvent(event) }) {
577
+ return true
578
+ }
353
579
  if (handleHardwareKeyEvent(event)) {
354
580
  return true
355
581
  }
582
+ if (handlePrintableHardwareKeyEvent(event) { super.dispatchKeyEvent(event) }) {
583
+ return true
584
+ }
356
585
  return super.dispatchKeyEvent(event)
357
586
  }
358
587
 
588
+ internal fun handleCompositionKeyEvent(event: KeyEvent, applyBaseEvent: () -> Boolean): Boolean {
589
+ val inputConnection = activeInputConnection ?: return false
590
+ if (!inputConnection.hasPendingComposition()) return false
591
+ if (!isCompositionKeyCode(event.keyCode)) return false
592
+ if (event.action == KeyEvent.ACTION_DOWN) {
593
+ val signature = hardwareKeyEventSignature(event)
594
+ if (
595
+ lastHandledHardwareKeySignature == signature ||
596
+ didRecentlyHandleHardwareKeyDown(signature)
597
+ ) {
598
+ return true
599
+ }
600
+ markHandledHardwareKeyDown(signature)
601
+ runWithTransientInputMutationGuard {
602
+ when (event.keyCode) {
603
+ KeyEvent.KEYCODE_DEL,
604
+ KeyEvent.KEYCODE_FORWARD_DEL -> inputConnection.deleteTransientTextForHardwareKeyEvent(event)
605
+ else -> applyBaseEvent()
606
+ }
607
+ }
608
+ inputConnection.refreshComposingTextFromEditableForEditor()
609
+ return true
610
+ }
611
+ if (event.action == KeyEvent.ACTION_UP) {
612
+ if (lastHandledHardwareKeySignature?.let {
613
+ it.keyCode == event.keyCode && it.downTime == event.downTime
614
+ } == true) {
615
+ lastHandledHardwareKeySignature = null
616
+ }
617
+ return true
618
+ }
619
+ return false
620
+ }
621
+
622
+ private fun isCompositionKeyCode(keyCode: Int): Boolean =
623
+ when (keyCode) {
624
+ KeyEvent.KEYCODE_DEL,
625
+ KeyEvent.KEYCODE_FORWARD_DEL,
626
+ KeyEvent.KEYCODE_ENTER,
627
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
628
+ KeyEvent.KEYCODE_TAB -> true
629
+ else -> false
630
+ }
631
+
359
632
  override fun onDraw(canvas: android.graphics.Canvas) {
360
633
  super.onDraw(canvas)
361
634
 
@@ -492,7 +765,15 @@ class EditorEditText @JvmOverloads constructor(
492
765
  * @param id The editor ID from `editor_create()`.
493
766
  * @param initialHTML Optional HTML to set as initial content.
494
767
  */
495
- fun bindEditor(id: Long, initialHTML: String? = null) {
768
+ fun bindEditor(id: Long, initialHTML: String? = null, notifyListener: Boolean = true) {
769
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
770
+ discardTransientNativeInputForEditorRebind()
771
+ editorId = 0L
772
+ return
773
+ }
774
+ if (editorId != id) {
775
+ discardTransientNativeInputForEditorRebind()
776
+ }
496
777
  editorId = id
497
778
 
498
779
  if (!initialHTML.isNullOrEmpty()) {
@@ -502,7 +783,7 @@ class EditorEditText @JvmOverloads constructor(
502
783
  } else {
503
784
  // Pull current state from Rust (content may already be loaded via bridge).
504
785
  val stateJSON = editorGetCurrentState(editorId.toULong())
505
- applyUpdateJSON(stateJSON)
786
+ applyUpdateJSON(stateJSON, notifyListener = notifyListener)
506
787
  }
507
788
  }
508
789
 
@@ -510,6 +791,9 @@ class EditorEditText @JvmOverloads constructor(
510
791
  * Unbind from the current editor instance.
511
792
  */
512
793
  fun unbindEditor() {
794
+ if (editorId != 0L) {
795
+ discardTransientNativeInputForEditorRebind()
796
+ }
513
797
  editorId = 0
514
798
  }
515
799
 
@@ -530,7 +814,7 @@ class EditorEditText @JvmOverloads constructor(
530
814
  renderAppearanceRevision += 1L
531
815
  setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
532
816
  applyContentInsets(theme?.contentInsets)
533
- if (editorId != 0L) {
817
+ if (hasLiveEditor()) {
534
818
  val previousScrollX = scrollX
535
819
  val previousScrollY = scrollY
536
820
  val stateJSON = editorGetCurrentState(editorId.toULong())
@@ -685,36 +969,201 @@ class EditorEditText @JvmOverloads constructor(
685
969
  * Called by [EditorInputConnection.commitText]. Routes the text through
686
970
  * the Rust editor instead of directly inserting into the EditText.
687
971
  */
688
- fun handleTextCommit(text: String) {
689
- if (!isEditable) return
690
- if (isApplyingRustState) return
972
+ fun handleTextCommit(text: String, newCursorPosition: Int = 1) {
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
+ }
691
987
  if (editorId == 0L) {
692
988
  // No Rust editor bound — fall through to direct editing (dev mode).
693
989
  val editable = this.text ?: return
694
- val start = selectionStart
695
- val end = selectionEnd
990
+ val (start, end) = selectionRange
696
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}")
697
1000
  return
698
1001
  }
699
1002
 
700
1003
  // Handle Enter/Return as a block split operation.
701
1004
  if (text == "\n") {
1005
+ recordImeTraceForTesting(
1006
+ "handleTextCommit",
1007
+ "route=return utf16Sel=${selectionRange.first}..${selectionRange.second}"
1008
+ )
702
1009
  handleReturnKey()
1010
+ recordImeTraceForTesting(
1011
+ "handleTextCommitDone",
1012
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1013
+ )
703
1014
  return
704
1015
  }
705
1016
 
706
1017
  val currentText = this.text?.toString() ?: ""
707
- val start = selectionStart
708
- val end = selectionEnd
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(
1025
+ scalarStart,
1026
+ scalarEnd,
1027
+ currentText,
1028
+ text,
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}"
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)}"
1056
+ )
1057
+ }
709
1058
 
710
- if (start != end) {
711
- // Range selection: atomic replace via Rust.
712
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
713
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
714
- replaceTextRangeInRust(scalarStart, scalarEnd, text)
715
- } else {
716
- val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
717
- insertTextInRust(text, scalarPos)
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
+ )
718
1167
  }
719
1168
  }
720
1169
 
@@ -728,165 +1177,852 @@ class EditorEditText @JvmOverloads constructor(
728
1177
  }
729
1178
  }
730
1179
 
731
- fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
732
- if (!isEditable) return
733
- if (isApplyingRustState) return
734
- if (editorId == 0L) return
735
-
736
- if (text == "\n") {
737
- handleReturnKey()
738
- return
1180
+ internal fun authorizedUtf16Range(start: Int, end: Int): Pair<Int, Int> {
1181
+ if (start == end) {
1182
+ val snapped = PositionBridge.snapToScalarBoundary(
1183
+ start,
1184
+ lastAuthorizedText,
1185
+ biasForward = true
1186
+ )
1187
+ return snapped to snapped
739
1188
  }
1189
+ return PositionBridge.snapRangeToScalarBoundaries(start, end, lastAuthorizedText)
1190
+ }
740
1191
 
741
- val authorizedText = lastAuthorizedText
742
- val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
743
- .coerceIn(0, authorizedText.length)
744
- val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
745
- .coerceIn(0, authorizedText.length)
746
- val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
747
- val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
1192
+ internal fun isCurrentTextAuthorizedForEditor(): Boolean =
1193
+ (text?.toString() ?: "") == lastAuthorizedText
748
1194
 
749
- if (scalarStart != scalarEnd) {
750
- replaceTextRangeInRust(scalarStart, scalarEnd, text)
751
- } else {
752
- insertTextInRust(text, scalarStart)
753
- }
1195
+ internal fun captureCompositionReplacementRangeIfNeeded() {
1196
+ if (didInvalidateCompositionReplacementRange) return
1197
+ if (compositionReplacementRange() != null) return
1198
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
1199
+ setCompositionReplacementRange(start, end)
754
1200
  }
755
1201
 
756
- // ── Input Handling: Deletion ────────────────────────────────────────
1202
+ internal fun setCompositionReplacementRange(start: Int, end: Int) {
1203
+ if (didInvalidateCompositionReplacementRange) return
1204
+ val replacementRange = authorizedUtf16Range(start, end)
1205
+ composingReplacementStartUtf16 = replacementRange.first
1206
+ composingReplacementEndUtf16 = replacementRange.second
1207
+ composingReplacementAuthorizedTextRevision = lastAuthorizedTextRevision
1208
+ didInvalidateCompositionReplacementRange = false
1209
+ }
757
1210
 
758
- /**
759
- * Handle surrounding text deletion from the IME.
760
- *
761
- * Called by [EditorInputConnection.deleteSurroundingText].
762
- *
763
- * @param beforeLength Number of UTF-16 code units to delete before the cursor.
764
- * @param afterLength Number of UTF-16 code units to delete after the cursor.
765
- */
766
- fun handleDelete(beforeLength: Int, afterLength: Int) {
767
- if (!isEditable) return
768
- if (isApplyingRustState) return
769
- if (editorId == 0L) {
770
- // Dev mode: direct editing.
771
- val editable = this.text ?: return
772
- val cursor = selectionStart
773
- val delStart = maxOf(0, cursor - beforeLength)
774
- val delEnd = minOf(editable.length, cursor + afterLength)
775
- editable.delete(delStart, delEnd)
776
- return
1211
+ internal fun compositionReplacementRange(): Pair<Int, Int>? {
1212
+ val start = composingReplacementStartUtf16 ?: return null
1213
+ val end = composingReplacementEndUtf16 ?: return null
1214
+ if (composingReplacementAuthorizedTextRevision != lastAuthorizedTextRevision) {
1215
+ clearCompositionTrackingForEditor()
1216
+ didInvalidateCompositionReplacementRange = true
1217
+ return null
777
1218
  }
1219
+ return start to end
1220
+ }
778
1221
 
779
- val currentText = text?.toString() ?: ""
780
- val cursor = selectionStart
781
- if (beforeLength > 0 &&
782
- afterLength == 0 &&
783
- cursor > 0 &&
784
- currentText.getOrNull(cursor - 1) == EMPTY_BLOCK_PLACEHOLDER
1222
+ private fun authorizedSelectionForTransientInputRestore(
1223
+ currentStart: Int,
1224
+ currentEnd: Int
1225
+ ): Pair<Int, Int>? {
1226
+ compositionReplacementRange()?.let { return it }
1227
+ return if (
1228
+ currentStart >= 0 &&
1229
+ currentEnd >= 0 &&
1230
+ currentStart <= lastAuthorizedText.length &&
1231
+ currentEnd <= lastAuthorizedText.length
785
1232
  ) {
786
- val scalarCursor = PositionBridge.utf16ToScalar(cursor, currentText)
787
- deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
788
- return
1233
+ currentStart to currentEnd
1234
+ } else {
1235
+ null
789
1236
  }
790
- val delStart = maxOf(0, cursor - beforeLength)
791
- val delEnd = minOf(currentText.length, cursor + afterLength)
792
-
793
- val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
794
- val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
1237
+ }
795
1238
 
796
- if (scalarStart < scalarEnd) {
797
- deleteRangeInRust(scalarStart, scalarEnd)
798
- } else if (beforeLength > 0 && afterLength == 0) {
799
- deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
800
- }
1239
+ internal fun consumeInvalidatedCompositionReplacementRangeForEditor(): Boolean {
1240
+ val invalidated = didInvalidateCompositionReplacementRange
1241
+ didInvalidateCompositionReplacementRange = false
1242
+ return invalidated
801
1243
  }
802
1244
 
803
- /**
804
- * Handle backspace key press (hardware keyboard or key event).
805
- *
806
- * If there's a range selection, deletes the range. Otherwise deletes
807
- * the grapheme cluster before the cursor.
808
- */
809
- fun handleBackspace() {
810
- if (!isEditable) return
811
- if (isApplyingRustState) return
812
- if (editorId == 0L) {
813
- // Dev mode: direct editing.
814
- val editable = this.text ?: return
815
- val start = selectionStart
816
- val end = selectionEnd
817
- if (start != end) {
818
- editable.delete(start, end)
819
- } else if (start > 0) {
820
- // Delete one grapheme cluster backward.
821
- val prevBoundary = PositionBridge.snapToGraphemeBoundary(start - 1, text?.toString() ?: "")
822
- val adjustedPrev = if (prevBoundary >= start) maxOf(0, start - 1) else prevBoundary
823
- editable.delete(adjustedPrev, start)
824
- }
825
- return
826
- }
1245
+ internal fun hasInvalidatedCompositionReplacementRangeForEditor(): Boolean =
1246
+ didInvalidateCompositionReplacementRange
827
1247
 
828
- val currentText = text?.toString() ?: ""
829
- val start = selectionStart
830
- val end = selectionEnd
1248
+ internal fun setComposingTextForEditor(text: String?) {
1249
+ composingText = text
1250
+ }
831
1251
 
832
- if (start != end) {
833
- // Range selection: delete the range.
834
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
835
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
836
- deleteRangeInRust(scalarStart, scalarEnd)
837
- } else if (start > 0) {
838
- if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
839
- val scalarCursor = PositionBridge.utf16ToScalar(start, currentText)
840
- deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
841
- return
842
- }
843
- // Cursor: delete one grapheme cluster backward.
844
- // Find the previous grapheme boundary by snapping (start - 1).
845
- val breakIter = java.text.BreakIterator.getCharacterInstance()
846
- breakIter.setText(currentText)
847
- val prevBoundary = breakIter.preceding(start)
848
- val prevUtf16 = if (prevBoundary == java.text.BreakIterator.DONE) 0 else prevBoundary
1252
+ internal fun composingTextForEditor(): String? = composingText
849
1253
 
850
- val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
851
- val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
852
- if (scalarStart < scalarEnd) {
853
- deleteRangeInRust(scalarStart, scalarEnd)
854
- } else {
855
- deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
856
- }
857
- } else {
858
- deleteBackwardAtSelectionScalarInRust(0, 0)
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
859
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
860
1277
  }
861
1278
 
862
- // ── Input Handling: Return Key ──────────────────────────────────────
1279
+ internal fun applyTransientComposingTextStyleForEditor() {
1280
+ val editable = text ?: return
1281
+ removeTransientComposingTextStyleSpans(editable)
863
1282
 
864
- /**
865
- * Handle return/enter key as a block split operation.
866
- */
867
- fun handleReturnKey() {
868
- if (!isEditable) return
869
- if (isApplyingRustState) return
1283
+ val start = BaseInputConnection.getComposingSpanStart(editable)
1284
+ val end = BaseInputConnection.getComposingSpanEnd(editable)
1285
+ if (start < 0 || end < 0 || start >= end || end > editable.length) return
870
1286
 
871
- val currentText = text?.toString() ?: ""
872
- val start = selectionStart
873
- val end = selectionEnd
1287
+ val textStyle = theme?.effectiveTextStyle("paragraph")
1288
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
1289
+ val resolvedTextColor = textStyle?.color ?: baseTextColor
874
1290
 
875
- if (editorId == 0L) {
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
+
1331
+ internal fun composingTextFromVisibleReplacementForEditor(): String? {
1332
+ val (start, end) = compositionReplacementRange() ?: return null
1333
+ val authorizedText = lastAuthorizedText
1334
+ val currentText = text?.toString() ?: return null
1335
+ if (start < 0 || end < start || end > authorizedText.length) return null
1336
+
1337
+ val authorizedPrefix = authorizedText.substring(0, start)
1338
+ val authorizedSuffix = authorizedText.substring(end)
1339
+ if (!currentText.startsWith(authorizedPrefix)) return null
1340
+ if (!currentText.endsWith(authorizedSuffix)) return null
1341
+
1342
+ val replacementEnd = currentText.length - authorizedSuffix.length
1343
+ if (replacementEnd < authorizedPrefix.length) return null
1344
+ return currentText.substring(authorizedPrefix.length, replacementEnd)
1345
+ }
1346
+
1347
+ internal fun clearCompositionTrackingForEditor() {
1348
+ composingText = null
1349
+ composingReplacementStartUtf16 = null
1350
+ composingReplacementEndUtf16 = null
1351
+ composingReplacementAuthorizedTextRevision = null
1352
+ }
1353
+
1354
+ private fun hasCompositionTrackingForEditor(): Boolean =
1355
+ composingText != null ||
1356
+ composingReplacementStartUtf16 != null ||
1357
+ composingReplacementEndUtf16 != null ||
1358
+ composingReplacementAuthorizedTextRevision != null
1359
+
1360
+ private fun retireInputConnectionForEditor() {
1361
+ recordImeTraceForTesting("retireInputConnection")
1362
+ activeInputConnection?.clearCompositionTrackingForEditor()
1363
+ invalidateInputConnectionsForEditor()
1364
+ clearCompositionTrackingForEditor()
1365
+ clearCompositionInvalidationForEditor()
1366
+ clearNativeComposingSpans()
1367
+ }
1368
+
1369
+ internal fun isEditorDestroyedForInput(): Boolean =
1370
+ editorId != 0L && NativeEditorViewRegistry.isDestroyed(editorId)
1371
+
1372
+ private fun hasLiveEditor(): Boolean =
1373
+ editorId != 0L && !isEditorDestroyedForInput()
1374
+
1375
+ private fun discardTransientInputForDestroyedEditorIfNeeded(): Boolean {
1376
+ if (!isEditorDestroyedForInput()) return false
1377
+ retireInputConnectionForEditor()
1378
+ clearNativeTextMutationAfterBlurWindow()
1379
+ clearNativeTextMutationAdoptionSuppression()
1380
+ return true
1381
+ }
1382
+
1383
+ private fun discardTransientInputAndRestoreAuthorizedTextForEditor() {
1384
+ retireInputConnectionForEditor()
1385
+ clearNativeTextMutationAfterBlurWindow()
1386
+ restoreAuthorizedTextSnapshotForEditor()
1387
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1388
+ }
1389
+
1390
+ private fun restoreAuthorizedTextSnapshotForEditor() {
1391
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1392
+ val authorizedSnapshot = lastAuthorizedRenderedText ?: lastAuthorizedText
1393
+ val wasApplyingRustState = isApplyingRustState
1394
+ isApplyingRustState = true
1395
+ beginBatchEdit()
1396
+ try {
1397
+ setText(authorizedSnapshot)
1398
+ } finally {
1399
+ endBatchEdit()
1400
+ isApplyingRustState = wasApplyingRustState
1401
+ }
1402
+ }
1403
+
1404
+ private fun restartInputAfterCompositionInvalidationIfNeeded(shouldRestart: Boolean) {
1405
+ if (!shouldRestart) return
1406
+ restartInputForEditorIfFocused("focused")
1407
+ }
1408
+
1409
+ private fun restartInputForEditorIfFocused(source: String) {
1410
+ if (!hasFocus()) return
1411
+ restartInputForEditor(source)
1412
+ }
1413
+
1414
+ private fun restartInputForEditor(source: String = "explicit") {
1415
+ recordImeTraceForTesting("restartInput", "source=$source")
1416
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
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
+ }
1463
+ }
1464
+
1465
+ private fun clearCompositionInvalidationForEditor() {
1466
+ didInvalidateCompositionReplacementRange = false
1467
+ }
1468
+
1469
+ private fun nextInputConnectionGenerationForEditor(): Long {
1470
+ return inputConnectionGeneration
1471
+ }
1472
+
1473
+ internal fun isInputConnectionCurrentForEditor(
1474
+ boundEditorId: Long,
1475
+ boundGeneration: Long
1476
+ ): Boolean =
1477
+ editorId == boundEditorId &&
1478
+ inputConnectionGeneration == boundGeneration &&
1479
+ !isEditorDestroyedForInput()
1480
+
1481
+ private fun invalidateInputConnectionsForEditor() {
1482
+ inputConnectionGeneration += 1L
1483
+ recordImeTraceForTesting("invalidateInputConnections", "nextGen=$inputConnectionGeneration")
1484
+ activeInputConnection = null
1485
+ }
1486
+
1487
+ private fun clearNativeComposingSpans() {
1488
+ val editable = text ?: return
1489
+ BaseInputConnection.removeComposingSpans(editable)
1490
+ removeTransientComposingTextStyleSpans(editable)
1491
+ }
1492
+
1493
+ internal fun restoreAuthorizedTextIfNeeded() {
1494
+ if (!hasLiveEditor()) return
1495
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1496
+ recordImeTraceForTesting(
1497
+ "restoreAuthorizedText",
1498
+ "authorizedLength=${lastAuthorizedText.length}"
1499
+ )
1500
+ val stateJSON = editorGetCurrentState(editorId.toULong())
1501
+ applyUpdateJSON(stateJSON)
1502
+ }
1503
+
1504
+ fun discardTransientNativeInputForEditorRebind() {
1505
+ retireInputConnectionForEditor()
1506
+ nativeTextMutationAfterBlurWindow = null
1507
+ clearNativeTextMutationAdoptionSuppression()
1508
+ clearImeTraceForTesting()
1509
+ }
1510
+
1511
+ internal fun discardTransientNativeInputForExternalRecovery() {
1512
+ retireInputConnectionForEditor()
1513
+ nativeTextMutationAfterBlurWindow = null
1514
+ restoreAuthorizedTextIfNeeded()
1515
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1516
+ }
1517
+
1518
+ private fun discardTransientNativeInputForReadOnly() {
1519
+ discardTransientNativeInputForExternalRecovery()
1520
+ }
1521
+
1522
+ fun prepareForExternalEditorUpdate(): Boolean {
1523
+ if (blockExternalEditorUpdatePreparationForTesting) return false
1524
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
1525
+ val inputConnection = activeInputConnection
1526
+ if (inputConnection?.flushPendingCompositionForExternalMutation() == false) {
1527
+ return false
1528
+ }
1529
+ return drainNativeTextMutationIfNeeded(allowAfterBlur = true)
1530
+ }
1531
+
1532
+ fun prepareForExternalEditorCommand(): CommandPreparation {
1533
+ if (blockExternalEditorCommandPreparationForTesting) {
1534
+ return CommandPreparation(ready = false, updateJSON = null)
1535
+ }
1536
+ val previousAuthorizedText = lastAuthorizedText
1537
+ if (!prepareForExternalEditorUpdate()) {
1538
+ return CommandPreparation(ready = false, updateJSON = null)
1539
+ }
1540
+ if (!hasLiveEditor() || lastAuthorizedText == previousAuthorizedText) {
1541
+ return CommandPreparation(ready = true, updateJSON = null)
1542
+ }
1543
+ return CommandPreparation(
1544
+ ready = true,
1545
+ updateJSON = editorGetCurrentState(editorId.toULong())
1546
+ )
1547
+ }
1548
+
1549
+ fun handleCompositionCommit(
1550
+ text: String,
1551
+ replacementStartUtf16: Int,
1552
+ replacementEndUtf16: Int,
1553
+ newCursorPosition: Int = 1
1554
+ ) {
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
+ }
1568
+
1569
+ val authorizedText = lastAuthorizedText
1570
+ val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1571
+ replacementStartUtf16,
1572
+ replacementEndUtf16,
1573
+ authorizedText
1574
+ )
1575
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
1576
+ val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
1577
+
1578
+ if (
1579
+ startUtf16 <= endUtf16 &&
1580
+ endUtf16 <= authorizedText.length &&
1581
+ authorizedText.substring(startUtf16, endUtf16) == text
1582
+ ) {
1583
+ val requestedCursor = requestedCursorScalar(
1584
+ scalarStart,
1585
+ scalarEnd,
1586
+ authorizedText,
1587
+ text,
1588
+ newCursorPosition
1589
+ ) ?: scalarEnd
1590
+ recordImeTraceForTesting(
1591
+ "handleCompositionCommitNoop",
1592
+ "reason=alreadyAuthorized textLength=${text.length} requestedCursor=$requestedCursor range=$startUtf16..$endUtf16"
1593
+ )
1594
+ restoreAuthorizedTextIfNeeded()
1595
+ applyRequestedCursorScalar(requestedCursor)
1596
+ return
1597
+ }
1598
+
1599
+ if (text == "\n") {
1600
+ recordImeTraceForTesting(
1601
+ "handleCompositionCommit",
1602
+ "route=return textLength=${text.length} utf16Range=$startUtf16..$endUtf16 scalarRange=$scalarStart..$scalarEnd"
1603
+ )
1604
+ if (scalarStart != scalarEnd) {
1605
+ deleteAndSplitInRust(scalarStart, scalarEnd)
1606
+ } else {
1607
+ splitBlockInRust(scalarStart)
1608
+ }
1609
+ recordImeTraceForTesting(
1610
+ "handleCompositionCommitDone",
1611
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1612
+ )
1613
+ return
1614
+ }
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
+ )
1627
+ insertPlainTextRangeInRust(
1628
+ scalarStart,
1629
+ scalarEnd,
1630
+ text,
1631
+ requestedCursorScalar = requestedCursor
1632
+ )
1633
+ recordImeTraceForTesting(
1634
+ "handleCompositionCommitDone",
1635
+ "textLength=${text.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1636
+ )
1637
+ }
1638
+
1639
+ fun handleCorrectionCommit(
1640
+ offsetUtf16: Int,
1641
+ oldText: String,
1642
+ newText: String
1643
+ ): Boolean {
1644
+ if (!isEditable) return true
1645
+ if (isApplyingRustState) return true
1646
+ if (!hasLiveEditor()) return false
1647
+
1648
+ val authorizedText = lastAuthorizedText
1649
+ if (offsetUtf16 < 0) {
1650
+ recordImeTraceForTesting(
1651
+ "correctionExplicitNoop",
1652
+ "reason=invalidOffset offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1653
+ )
1654
+ return false
1655
+ }
1656
+ val endUtf16 = offsetUtf16 + oldText.length
1657
+ if (endUtf16 < offsetUtf16 || endUtf16 > authorizedText.length) {
1658
+ recordImeTraceForTesting(
1659
+ "correctionExplicitNoop",
1660
+ "reason=outOfBounds offset=$offsetUtf16 oldLength=${oldText.length} authorizedLength=${authorizedText.length}"
1661
+ )
1662
+ return false
1663
+ }
1664
+ if (authorizedText.substring(offsetUtf16, endUtf16) != oldText) {
1665
+ recordImeTraceForTesting(
1666
+ "correctionExplicitNoop",
1667
+ "reason=staleText offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1668
+ )
1669
+ return false
1670
+ }
1671
+
1672
+ val (startUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1673
+ offsetUtf16,
1674
+ endUtf16,
1675
+ authorizedText
1676
+ )
1677
+ if (
1678
+ startUtf16 != offsetUtf16 ||
1679
+ snappedEndUtf16 != endUtf16 ||
1680
+ startUtf16 > snappedEndUtf16
1681
+ ) {
1682
+ recordImeTraceForTesting(
1683
+ "correctionExplicitNoop",
1684
+ "reason=unsnappedScalarBoundary range=$offsetUtf16..$endUtf16 snapped=$startUtf16..$snappedEndUtf16"
1685
+ )
1686
+ return false
1687
+ }
1688
+
1689
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
1690
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1691
+ recordImeTraceForTesting(
1692
+ "correctionExplicitApply",
1693
+ "range=$scalarStart..$scalarEnd newLength=${newText.length}"
1694
+ )
1695
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1696
+ return true
1697
+ }
1698
+
1699
+ fun handleMissingOldTextCorrectionCommit(
1700
+ offsetUtf16: Int,
1701
+ newText: String
1702
+ ): Boolean {
1703
+ if (!isEditable) return true
1704
+ if (isApplyingRustState) return true
1705
+ if (!hasLiveEditor()) return false
1706
+
1707
+ val authorizedText = lastAuthorizedText
1708
+ val tokenRange = missingOldTextCorrectionTokenRange(authorizedText, offsetUtf16)
1709
+ ?: run {
1710
+ recordImeTraceForTesting(
1711
+ "correctionInferredNoop",
1712
+ "reason=noToken offset=$offsetUtf16 newLength=${newText.length}"
1713
+ )
1714
+ return false
1715
+ }
1716
+ val (startUtf16, endUtf16) = tokenRange
1717
+
1718
+ val (snappedStartUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1719
+ startUtf16,
1720
+ endUtf16,
1721
+ authorizedText
1722
+ )
1723
+ if (snappedStartUtf16 >= snappedEndUtf16) {
1724
+ recordImeTraceForTesting(
1725
+ "correctionInferredNoop",
1726
+ "reason=emptySnappedRange token=$startUtf16..$endUtf16 snapped=$snappedStartUtf16..$snappedEndUtf16"
1727
+ )
1728
+ return false
1729
+ }
1730
+
1731
+ val scalarStart = PositionBridge.utf16ToScalar(snappedStartUtf16, authorizedText)
1732
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1733
+ recordImeTraceForTesting(
1734
+ "correctionInferredApply",
1735
+ "range=$scalarStart..$scalarEnd utf16=$snappedStartUtf16..$snappedEndUtf16 newLength=${newText.length}"
1736
+ )
1737
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1738
+ return true
1739
+ }
1740
+
1741
+ private fun missingOldTextCorrectionTokenRange(
1742
+ text: String,
1743
+ offsetUtf16: Int
1744
+ ): Pair<Int, Int>? {
1745
+ if (offsetUtf16 < 0 || offsetUtf16 >= text.length) return null
1746
+
1747
+ val tokenOffset = PositionBridge.snapToScalarBoundary(
1748
+ offsetUtf16,
1749
+ text,
1750
+ biasForward = false
1751
+ )
1752
+ if (tokenOffset < 0 || tokenOffset >= text.length) return null
1753
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, tokenOffset)) return null
1754
+
1755
+ var startUtf16 = tokenOffset
1756
+ while (startUtf16 > 0) {
1757
+ val previousUtf16 = Character.offsetByCodePoints(text, startUtf16, -1)
1758
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, previousUtf16)) break
1759
+ startUtf16 = previousUtf16
1760
+ }
1761
+
1762
+ var endUtf16 = tokenOffset + Character.charCount(Character.codePointAt(text, tokenOffset))
1763
+ while (endUtf16 < text.length) {
1764
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, endUtf16)) break
1765
+ endUtf16 += Character.charCount(Character.codePointAt(text, endUtf16))
1766
+ }
1767
+
1768
+ return if (startUtf16 < endUtf16) startUtf16 to endUtf16 else null
1769
+ }
1770
+
1771
+ private fun isMissingOldTextCorrectionTokenCodePointAt(text: String, utf16Offset: Int): Boolean {
1772
+ if (utf16Offset < 0 || utf16Offset >= text.length) return false
1773
+ val codePoint = Character.codePointAt(text, utf16Offset)
1774
+ if (isMissingOldTextCorrectionCoreTokenCodePoint(codePoint)) return true
1775
+ if (!isMissingOldTextCorrectionJoinerCodePoint(codePoint)) return false
1776
+
1777
+ val previousCodePoint = previousCodePointBefore(text, utf16Offset) ?: return false
1778
+ val nextUtf16Offset = utf16Offset + Character.charCount(codePoint)
1779
+ val nextCodePoint = nextCodePointAt(text, nextUtf16Offset) ?: return false
1780
+ return isMissingOldTextCorrectionCoreTokenCodePoint(previousCodePoint) &&
1781
+ isMissingOldTextCorrectionCoreTokenCodePoint(nextCodePoint)
1782
+ }
1783
+
1784
+ private fun isMissingOldTextCorrectionCoreTokenCodePoint(codePoint: Int): Boolean {
1785
+ if (Character.isLetterOrDigit(codePoint)) return true
1786
+ return when (Character.getType(codePoint)) {
1787
+ Character.NON_SPACING_MARK.toInt(),
1788
+ Character.COMBINING_SPACING_MARK.toInt(),
1789
+ Character.ENCLOSING_MARK.toInt(),
1790
+ Character.CONNECTOR_PUNCTUATION.toInt(),
1791
+ Character.MATH_SYMBOL.toInt(),
1792
+ Character.CURRENCY_SYMBOL.toInt(),
1793
+ Character.MODIFIER_SYMBOL.toInt(),
1794
+ Character.OTHER_SYMBOL.toInt(),
1795
+ Character.SURROGATE.toInt() -> true
1796
+ else -> false
1797
+ }
1798
+ }
1799
+
1800
+ private fun isMissingOldTextCorrectionJoinerCodePoint(codePoint: Int): Boolean =
1801
+ codePoint == '\''.code ||
1802
+ codePoint == 0x2018 ||
1803
+ codePoint == 0x2019 ||
1804
+ codePoint == 0x201B ||
1805
+ codePoint == 0xFF07 ||
1806
+ codePoint == '-'.code ||
1807
+ codePoint == 0x2010 ||
1808
+ codePoint == 0x2011 ||
1809
+ codePoint == 0x2012 ||
1810
+ codePoint == 0x2013 ||
1811
+ codePoint == 0x2014 ||
1812
+ codePoint == 0x2212 ||
1813
+ codePoint == 0x200D
1814
+
1815
+ private fun previousCodePointBefore(text: String, utf16Offset: Int): Int? {
1816
+ if (utf16Offset <= 0 || utf16Offset > text.length) return null
1817
+ val previousUtf16 = Character.offsetByCodePoints(text, utf16Offset, -1)
1818
+ return Character.codePointAt(text, previousUtf16)
1819
+ }
1820
+
1821
+ private fun nextCodePointAt(text: String, utf16Offset: Int): Int? {
1822
+ if (utf16Offset < 0 || utf16Offset >= text.length) return null
1823
+ return Character.codePointAt(text, utf16Offset)
1824
+ }
1825
+
1826
+ // ── Input Handling: Deletion ────────────────────────────────────────
1827
+
1828
+ /**
1829
+ * Handle surrounding text deletion from the IME.
1830
+ *
1831
+ * Called by [EditorInputConnection.deleteSurroundingText].
1832
+ *
1833
+ * @param beforeLength Number of UTF-16 code units to delete before the cursor.
1834
+ * @param afterLength Number of UTF-16 code units to delete after the cursor.
1835
+ */
1836
+ fun handleDelete(beforeLength: Int, afterLength: Int) {
1837
+ if (!isEditable) return
1838
+ if (isApplyingRustState) return
1839
+ val selectionRange = normalizedUtf16SelectionRange()
1840
+ if (editorId == 0L) {
1841
+ // Dev mode: direct editing.
1842
+ val editable = this.text ?: return
1843
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1844
+ val delStart: Int
1845
+ val delEnd: Int
1846
+ if (selectionStart != selectionEnd) {
1847
+ delStart = selectionStart
1848
+ delEnd = selectionEnd
1849
+ } else {
1850
+ delStart = maxOf(0, selectionStart - beforeLength.coerceAtLeast(0))
1851
+ delEnd = minOf(editable.length, selectionStart + afterLength.coerceAtLeast(0))
1852
+ }
1853
+ editable.delete(delStart, delEnd)
1854
+ return
1855
+ }
1856
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1857
+
1858
+ val currentText = text?.toString() ?: ""
1859
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1860
+ if (selectionStart != selectionEnd) {
1861
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1862
+ deleteRangeInRust(scalarStart, scalarEnd)
1863
+ return
1864
+ }
1865
+ val cursor = selectionStart
1866
+ if (beforeLength > 0 &&
1867
+ afterLength == 0 &&
1868
+ cursor > 0 &&
1869
+ currentText.getOrNull(cursor - 1) == EMPTY_BLOCK_PLACEHOLDER
1870
+ ) {
1871
+ val scalarCursor = PositionBridge.utf16ToScalar(cursor, currentText)
1872
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
1873
+ return
1874
+ }
1875
+ val rawDelStart = maxOf(0, cursor - beforeLength.coerceAtLeast(0))
1876
+ val rawDelEnd = minOf(currentText.length, cursor + afterLength.coerceAtLeast(0))
1877
+ val (delStart, delEnd) = PositionBridge.snapRangeToScalarBoundaries(
1878
+ rawDelStart,
1879
+ rawDelEnd,
1880
+ currentText
1881
+ )
1882
+
1883
+ val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
1884
+ val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
1885
+
1886
+ if (scalarStart < scalarEnd) {
1887
+ deleteRangeInRust(scalarStart, scalarEnd)
1888
+ } else if (beforeLength > 0 && afterLength == 0) {
1889
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
1890
+ }
1891
+ }
1892
+
1893
+ /**
1894
+ * Handle backspace key press (hardware keyboard or key event).
1895
+ *
1896
+ * If there's a range selection, deletes the range. Otherwise deletes
1897
+ * the grapheme cluster before the cursor.
1898
+ */
1899
+ fun handleBackspace() {
1900
+ if (!isEditable) return
1901
+ if (isApplyingRustState) return
1902
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
1903
+ if (editorId == 0L) {
1904
+ // Dev mode: direct editing.
1905
+ val editable = this.text ?: return
1906
+ val (start, end) = selectionRange
1907
+ if (start != end) {
1908
+ editable.delete(start, end)
1909
+ } else if (start > 0) {
1910
+ // Delete one grapheme cluster backward.
1911
+ val prevBoundary = PositionBridge.snapToGraphemeBoundary(start - 1, text?.toString() ?: "")
1912
+ val adjustedPrev = if (prevBoundary >= start) maxOf(0, start - 1) else prevBoundary
1913
+ editable.delete(adjustedPrev, start)
1914
+ }
1915
+ return
1916
+ }
1917
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1918
+
1919
+ val currentText = text?.toString() ?: ""
1920
+ val (start, end) = selectionRange
1921
+
1922
+ if (start != end) {
1923
+ // Range selection: delete the range.
1924
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1925
+ deleteRangeInRust(scalarStart, scalarEnd)
1926
+ } else if (start > 0) {
1927
+ if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
1928
+ val scalarCursor = PositionBridge.utf16ToScalar(start, currentText)
1929
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
1930
+ return
1931
+ }
1932
+ // Cursor: delete one grapheme cluster backward.
1933
+ // Find the previous grapheme boundary by snapping (start - 1).
1934
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
1935
+ breakIter.setText(currentText)
1936
+ val prevBoundary = breakIter.preceding(start)
1937
+ val prevUtf16 = if (prevBoundary == java.text.BreakIterator.DONE) 0 else prevBoundary
1938
+
1939
+ val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
1940
+ val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
1941
+ if (scalarStart < scalarEnd) {
1942
+ deleteRangeInRust(scalarStart, scalarEnd)
1943
+ } else {
1944
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
1945
+ }
1946
+ } else {
1947
+ deleteBackwardAtSelectionScalarInRust(0, 0)
1948
+ }
1949
+ }
1950
+
1951
+ fun handleForwardDelete() {
1952
+ if (!isEditable) return
1953
+ if (isApplyingRustState) return
1954
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
1955
+ if (editorId == 0L) {
1956
+ val editable = this.text ?: return
1957
+ val (start, end) = selectionRange
1958
+ if (start != end) {
1959
+ editable.delete(start, end)
1960
+ } else if (start < editable.length) {
1961
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
1962
+ breakIter.setText(editable.toString())
1963
+ val nextBoundary = breakIter.following(start)
1964
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
1965
+ editable.length
1966
+ } else {
1967
+ nextBoundary
1968
+ }
1969
+ editable.delete(start, nextUtf16.coerceIn(start, editable.length))
1970
+ }
1971
+ return
1972
+ }
1973
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1974
+
1975
+ val currentText = text?.toString() ?: ""
1976
+ val (start, end) = selectionRange
1977
+ if (start != end) {
1978
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1979
+ deleteRangeInRust(scalarStart, scalarEnd)
1980
+ } else if (start < currentText.length) {
1981
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
1982
+ breakIter.setText(currentText)
1983
+ val nextBoundary = breakIter.following(start)
1984
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
1985
+ currentText.length
1986
+ } else {
1987
+ nextBoundary
1988
+ }
1989
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
1990
+ start,
1991
+ nextUtf16.coerceIn(start, currentText.length),
1992
+ currentText
1993
+ )
1994
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
1995
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
1996
+ if (scalarStart < scalarEnd) {
1997
+ deleteRangeInRust(scalarStart, scalarEnd)
1998
+ }
1999
+ }
2000
+ }
2001
+
2002
+ // ── Input Handling: Return Key ──────────────────────────────────────
2003
+
2004
+ /**
2005
+ * Handle return/enter key as a block split operation.
2006
+ */
2007
+ fun handleReturnKey() {
2008
+ if (!isEditable) return
2009
+ if (isApplyingRustState) return
2010
+
2011
+ val currentText = text?.toString() ?: ""
2012
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
2013
+
2014
+ if (editorId == 0L) {
876
2015
  // Dev mode: insert newline directly.
877
2016
  val editable = this.text ?: return
878
2017
  editable.replace(start, end, "\n")
879
2018
  return
880
2019
  }
2020
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
881
2021
 
882
2022
  if (start != end) {
883
2023
  // Range selection: atomic delete-and-split via Rust.
884
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
885
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
886
- val updateJSON = editorDeleteAndSplitScalar(
887
- editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt()
888
- )
889
- applyUpdateJSON(updateJSON)
2024
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
2025
+ deleteAndSplitInRust(scalarStart, scalarEnd)
890
2026
  } else {
891
2027
  val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
892
2028
  splitBlockInRust(scalarPos)
@@ -907,6 +2043,7 @@ class EditorEditText @JvmOverloads constructor(
907
2043
  editable.replace(start, end, "\n")
908
2044
  return
909
2045
  }
2046
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
910
2047
 
911
2048
  val selection = currentScalarSelection() ?: return
912
2049
  val updateJSON = editorInsertNodeAtSelectionScalar(
@@ -924,7 +2061,7 @@ class EditorEditText @JvmOverloads constructor(
924
2061
  fun handleTab(shiftPressed: Boolean): Boolean {
925
2062
  if (!isEditable) return false
926
2063
  if (isApplyingRustState) return false
927
- if (editorId == 0L) return false
2064
+ if (!hasLiveEditor()) return false
928
2065
  if (!isSelectionInsideList()) return false
929
2066
  val selection = currentScalarSelection() ?: return false
930
2067
 
@@ -952,6 +2089,10 @@ class EditorEditText @JvmOverloads constructor(
952
2089
  handleBackspace()
953
2090
  true
954
2091
  }
2092
+ KeyEvent.KEYCODE_FORWARD_DEL -> {
2093
+ handleForwardDelete()
2094
+ true
2095
+ }
955
2096
  KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
956
2097
  if (shiftPressed) {
957
2098
  handleHardBreak()
@@ -965,28 +2106,55 @@ class EditorEditText @JvmOverloads constructor(
965
2106
  }
966
2107
  }
967
2108
 
2109
+ private fun isSupportedHardwareMutationKey(keyCode: Int): Boolean =
2110
+ when (keyCode) {
2111
+ KeyEvent.KEYCODE_DEL,
2112
+ KeyEvent.KEYCODE_FORWARD_DEL,
2113
+ KeyEvent.KEYCODE_ENTER,
2114
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
2115
+ KeyEvent.KEYCODE_TAB -> true
2116
+ else -> false
2117
+ }
2118
+
2119
+ internal fun isReadOnlyTextMutationKeyEvent(event: KeyEvent): Boolean {
2120
+ if (isSupportedHardwareMutationKey(event.keyCode) ||
2121
+ event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL
2122
+ ) {
2123
+ return true
2124
+ }
2125
+ if (event.keyCode == KeyEvent.KEYCODE_INSERT && event.isShiftPressed) {
2126
+ return true
2127
+ }
2128
+ if (event.isCtrlPressed || event.isMetaPressed) {
2129
+ return when (event.keyCode) {
2130
+ KeyEvent.KEYCODE_V,
2131
+ KeyEvent.KEYCODE_X,
2132
+ KeyEvent.KEYCODE_Z,
2133
+ KeyEvent.KEYCODE_Y -> true
2134
+ else -> false
2135
+ }
2136
+ }
2137
+ if (!keyEventCharacters(event).isNullOrEmpty()) return true
2138
+ return event.unicodeChar != 0
2139
+ }
2140
+
968
2141
  fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
969
2142
  if (event == null || !isEditable || isApplyingRustState) return false
970
2143
 
971
2144
  return when (event.action) {
972
2145
  KeyEvent.ACTION_DOWN -> {
973
- val supported = when (event.keyCode) {
974
- KeyEvent.KEYCODE_DEL,
975
- KeyEvent.KEYCODE_ENTER,
976
- KeyEvent.KEYCODE_NUMPAD_ENTER,
977
- KeyEvent.KEYCODE_TAB -> true
978
- else -> false
979
- }
980
- if (!supported) return false
2146
+ if (!isSupportedHardwareMutationKey(event.keyCode)) return false
981
2147
 
982
- if (lastHandledHardwareKeyCode == event.keyCode &&
983
- lastHandledHardwareKeyDownTime == event.downTime) {
2148
+ val signature = hardwareKeyEventSignature(event)
2149
+ if (
2150
+ lastHandledHardwareKeySignature == signature ||
2151
+ didRecentlyHandleHardwareKeyDown(signature)
2152
+ ) {
984
2153
  return true
985
2154
  }
986
2155
 
987
2156
  if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
988
- lastHandledHardwareKeyCode = event.keyCode
989
- lastHandledHardwareKeyDownTime = event.downTime
2157
+ markHandledHardwareKeyDown(signature)
990
2158
  true
991
2159
  } else {
992
2160
  false
@@ -994,10 +2162,10 @@ class EditorEditText @JvmOverloads constructor(
994
2162
  }
995
2163
 
996
2164
  KeyEvent.ACTION_UP -> {
997
- if (lastHandledHardwareKeyCode == event.keyCode &&
998
- lastHandledHardwareKeyDownTime == event.downTime) {
999
- lastHandledHardwareKeyCode = null
1000
- lastHandledHardwareKeyDownTime = null
2165
+ if (lastHandledHardwareKeySignature?.let {
2166
+ it.keyCode == event.keyCode && it.downTime == event.downTime
2167
+ } == true) {
2168
+ lastHandledHardwareKeySignature = null
1001
2169
  true
1002
2170
  } else {
1003
2171
  false
@@ -1008,8 +2176,119 @@ class EditorEditText @JvmOverloads constructor(
1008
2176
  }
1009
2177
  }
1010
2178
 
2179
+ internal fun handlePrintableHardwareKeyEvent(
2180
+ event: KeyEvent,
2181
+ applyBaseEvent: () -> Boolean
2182
+ ): Boolean {
2183
+ if (!isEditable || isApplyingRustState || !isPrintableHardwareMutationKey(event)) {
2184
+ return false
2185
+ }
2186
+ val signature = hardwareKeyEventSignature(event)
2187
+ return when (event.action) {
2188
+ KeyEvent.ACTION_DOWN -> {
2189
+ if (
2190
+ lastHandledHardwareKeySignature == signature ||
2191
+ didRecentlyHandleHardwareKeyDown(signature)
2192
+ ) {
2193
+ true
2194
+ } else {
2195
+ val inputConnection = activeInputConnection?.takeIf {
2196
+ it.hasPendingComposition()
2197
+ }
2198
+ if (inputConnection != null) {
2199
+ var didMutate = false
2200
+ runWithTransientInputMutationGuard {
2201
+ didMutate = insertTransientHardwareText(keyEventText(event))
2202
+ didMutate
2203
+ }
2204
+ if (!didMutate) return false
2205
+ inputConnection.refreshComposingTextFromEditableForEditor()
2206
+ } else {
2207
+ applyBaseEvent()
2208
+ }
2209
+ markHandledHardwareKeyDown(signature)
2210
+ true
2211
+ }
2212
+ }
2213
+ KeyEvent.ACTION_UP -> {
2214
+ if (lastHandledHardwareKeySignature?.let {
2215
+ it.keyCode == event.keyCode && it.downTime == event.downTime
2216
+ } == true) {
2217
+ lastHandledHardwareKeySignature = null
2218
+ }
2219
+ false
2220
+ }
2221
+ else -> false
2222
+ }
2223
+ }
2224
+
2225
+ private fun isPrintableHardwareMutationKey(event: KeyEvent): Boolean {
2226
+ if (isSupportedHardwareMutationKey(event.keyCode)) return false
2227
+ if (event.isCtrlPressed || event.isMetaPressed) return false
2228
+ return !keyEventText(event).isNullOrEmpty()
2229
+ }
2230
+
2231
+ private fun hardwareKeyEventSignature(event: KeyEvent): HardwareKeyEventSignature =
2232
+ HardwareKeyEventSignature(
2233
+ keyCode = event.keyCode,
2234
+ downTime = event.downTime,
2235
+ repeatCount = event.repeatCount
2236
+ )
2237
+
2238
+ @Suppress("DEPRECATION")
2239
+ private fun keyEventCharacters(event: KeyEvent): String? = event.characters
2240
+
2241
+ private fun keyEventText(event: KeyEvent): String? {
2242
+ val characters = keyEventCharacters(event)
2243
+ if (!characters.isNullOrEmpty()) return characters
2244
+ val unicodeChar = event.unicodeChar
2245
+ if (unicodeChar == 0) return null
2246
+ return runCatching {
2247
+ String(Character.toChars(unicodeChar))
2248
+ }.getOrNull()
2249
+ }
2250
+
2251
+ private fun insertTransientHardwareText(insertedText: String?): Boolean {
2252
+ if (insertedText.isNullOrEmpty()) return false
2253
+ val editable = text ?: return false
2254
+ val currentText = editable.toString()
2255
+ val rawStart = selectionStart
2256
+ val rawEnd = selectionEnd
2257
+ if (rawStart < 0 || rawEnd < 0) return false
2258
+ val start = rawStart.coerceIn(0, editable.length)
2259
+ val end = rawEnd.coerceIn(0, editable.length)
2260
+ val normalizedStart = minOf(start, end)
2261
+ val normalizedEnd = maxOf(start, end)
2262
+ val (replaceStart, replaceEnd) = PositionBridge.snapRangeToScalarBoundaries(
2263
+ normalizedStart,
2264
+ normalizedEnd,
2265
+ currentText
2266
+ )
2267
+ editable.replace(replaceStart, replaceEnd, insertedText)
2268
+ val cursor = (replaceStart + insertedText.length).coerceIn(0, editable.length)
2269
+ setSelection(cursor)
2270
+ return true
2271
+ }
2272
+
2273
+ private fun markHandledHardwareKeyDown(signature: HardwareKeyEventSignature) {
2274
+ lastHandledHardwareKeySignature = signature
2275
+ recentHandledHardwareKeyDownSignature = signature
2276
+ recentHandledHardwareKeyDownUptimeMs = SystemClock.uptimeMillis()
2277
+ }
2278
+
2279
+ private fun didRecentlyHandleHardwareKeyDown(signature: HardwareKeyEventSignature): Boolean {
2280
+ val recentSignature = recentHandledHardwareKeyDownSignature ?: return false
2281
+ val elapsedMs = SystemClock.uptimeMillis() - recentHandledHardwareKeyDownUptimeMs
2282
+ if (elapsedMs > RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS) {
2283
+ recentHandledHardwareKeyDownSignature = null
2284
+ recentHandledHardwareKeyDownUptimeMs = 0L
2285
+ return false
2286
+ }
2287
+ return recentSignature == signature
2288
+ }
2289
+
1011
2290
  fun performToolbarToggleMark(markName: String) {
1012
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2291
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1013
2292
  val selection = currentScalarSelection() ?: return
1014
2293
  val updateJSON = editorToggleMarkAtSelectionScalar(
1015
2294
  editorId.toULong(),
@@ -1021,7 +2300,7 @@ class EditorEditText @JvmOverloads constructor(
1021
2300
  }
1022
2301
 
1023
2302
  fun performToolbarToggleList(listType: String, isActive: Boolean) {
1024
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2303
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1025
2304
  val selection = currentScalarSelection() ?: return
1026
2305
  val updateJSON = if (isActive) {
1027
2306
  editorUnwrapFromListAtSelectionScalar(
@@ -1041,7 +2320,7 @@ class EditorEditText @JvmOverloads constructor(
1041
2320
  }
1042
2321
 
1043
2322
  fun performToolbarToggleBlockquote() {
1044
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2323
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1045
2324
  val selection = currentScalarSelection() ?: return
1046
2325
  val updateJSON = editorToggleBlockquoteAtSelectionScalar(
1047
2326
  editorId.toULong(),
@@ -1052,7 +2331,7 @@ class EditorEditText @JvmOverloads constructor(
1052
2331
  }
1053
2332
 
1054
2333
  fun performToolbarToggleHeading(level: Int) {
1055
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2334
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1056
2335
  if (level !in 1..6) return
1057
2336
  val selection = currentScalarSelection() ?: return
1058
2337
  val updateJSON = editorToggleHeadingAtSelectionScalar(
@@ -1065,7 +2344,7 @@ class EditorEditText @JvmOverloads constructor(
1065
2344
  }
1066
2345
 
1067
2346
  fun performToolbarIndentListItem() {
1068
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2347
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1069
2348
  val selection = currentScalarSelection() ?: return
1070
2349
  val updateJSON = editorIndentListItemAtSelectionScalar(
1071
2350
  editorId.toULong(),
@@ -1076,7 +2355,7 @@ class EditorEditText @JvmOverloads constructor(
1076
2355
  }
1077
2356
 
1078
2357
  fun performToolbarOutdentListItem() {
1079
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2358
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1080
2359
  val selection = currentScalarSelection() ?: return
1081
2360
  val updateJSON = editorOutdentListItemAtSelectionScalar(
1082
2361
  editorId.toULong(),
@@ -1087,7 +2366,7 @@ class EditorEditText @JvmOverloads constructor(
1087
2366
  }
1088
2367
 
1089
2368
  fun performToolbarInsertNode(nodeType: String) {
1090
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2369
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1091
2370
  val selection = currentScalarSelection() ?: return
1092
2371
  val updateJSON = editorInsertNodeAtSelectionScalar(
1093
2372
  editorId.toULong(),
@@ -1099,12 +2378,12 @@ class EditorEditText @JvmOverloads constructor(
1099
2378
  }
1100
2379
 
1101
2380
  fun performToolbarUndo() {
1102
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2381
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1103
2382
  applyUpdateJSON(editorUndo(editorId.toULong()))
1104
2383
  }
1105
2384
 
1106
2385
  fun performToolbarRedo() {
1107
- if (!isEditable || isApplyingRustState || editorId == 0L) return
2386
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1108
2387
  applyUpdateJSON(editorRedo(editorId.toULong()))
1109
2388
  }
1110
2389
 
@@ -1117,32 +2396,52 @@ class EditorEditText @JvmOverloads constructor(
1117
2396
  * falling back to plain text.
1118
2397
  */
1119
2398
  override fun onTextContextMenuItem(id: Int): Boolean {
1120
- if (!isEditable && id == android.R.id.paste) return true
1121
- if (id == android.R.id.paste) {
1122
- handlePaste()
2399
+ if (!isEditable && isMutatingContextMenuItem(id)) return true
2400
+ if (id == android.R.id.cut) {
2401
+ handleCut()
2402
+ return true
2403
+ }
2404
+ if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
2405
+ handlePaste(plainTextOnly = id == android.R.id.pasteAsPlainText)
1123
2406
  return true
1124
2407
  }
1125
2408
  return super.onTextContextMenuItem(id)
1126
2409
  }
1127
2410
 
2411
+ private fun isMutatingContextMenuItem(id: Int): Boolean =
2412
+ id == android.R.id.paste ||
2413
+ id == android.R.id.pasteAsPlainText ||
2414
+ id == android.R.id.cut
2415
+
1128
2416
  /**
1129
- * Block accessibility-initiated text mutations (paste, set text) when not editable.
2417
+ * Block accessibility-initiated text mutations (paste, cut, set text) when not editable.
1130
2418
  * Selection and copy actions remain available.
1131
2419
  */
1132
2420
  override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
1133
- if (!isEditable && (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT
1134
- || action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE)) {
2421
+ if (!isEditable && (
2422
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT ||
2423
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE ||
2424
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_CUT
2425
+ )
2426
+ ) {
1135
2427
  return false
1136
2428
  }
2429
+ if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT) {
2430
+ return handleAccessibilitySetText(arguments)
2431
+ }
1137
2432
  return super.performAccessibilityAction(action, arguments)
1138
2433
  }
1139
2434
 
1140
- private fun handlePaste() {
2435
+ private fun handlePaste(plainTextOnly: Boolean) {
1141
2436
  if (editorId == 0L) {
1142
2437
  // Dev mode: default paste behavior.
1143
- super.onTextContextMenuItem(android.R.id.paste)
2438
+ super.onTextContextMenuItem(
2439
+ if (plainTextOnly) android.R.id.pasteAsPlainText else android.R.id.paste
2440
+ )
1144
2441
  return
1145
2442
  }
2443
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2444
+ if (!prepareForExternalEditorUpdate()) return
1146
2445
 
1147
2446
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
1148
2447
  ?: return
@@ -1151,84 +2450,475 @@ class EditorEditText @JvmOverloads constructor(
1151
2450
 
1152
2451
  val item = clip.getItemAt(0)
1153
2452
 
1154
- // Try HTML first for rich paste.
1155
- val htmlText = item.htmlText
1156
- if (htmlText != null) {
1157
- pasteHTML(htmlText)
2453
+ // Try HTML first for rich paste.
2454
+ val htmlText = item.htmlText
2455
+ if (!plainTextOnly && htmlText != null) {
2456
+ pasteHTML(htmlText)
2457
+ return
2458
+ }
2459
+
2460
+ // Fallback to plain text.
2461
+ val plainText = item.text?.toString() ?: item.coerceToText(context)?.toString()
2462
+ if (plainText != null) {
2463
+ pastePlainText(plainText)
2464
+ }
2465
+ }
2466
+
2467
+ private fun handleCut() {
2468
+ if (editorId == 0L) {
2469
+ super.onTextContextMenuItem(android.R.id.cut)
2470
+ return
2471
+ }
2472
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2473
+ if (!prepareForExternalEditorUpdate()) return
2474
+
2475
+ val currentText = text?.toString() ?: return
2476
+ val (selectionStart, selectionEnd) = normalizedUtf16SelectionRange(currentText) ?: return
2477
+ if (selectionStart == selectionEnd) return
2478
+
2479
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
2480
+ selectionStart,
2481
+ selectionEnd,
2482
+ currentText
2483
+ )
2484
+ if (utf16Start >= utf16End) return
2485
+
2486
+ val selectedText = currentText.substring(utf16Start, utf16End)
2487
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
2488
+ clipboard?.setPrimaryClip(ClipData.newPlainText(null, selectedText))
2489
+
2490
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
2491
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
2492
+ deleteRangeInRust(scalarStart, scalarEnd)
2493
+ }
2494
+
2495
+ private fun handleAccessibilitySetText(arguments: android.os.Bundle?): Boolean {
2496
+ val replacement = arguments
2497
+ ?.getCharSequence(
2498
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
2499
+ )
2500
+ ?.toString()
2501
+ ?: return false
2502
+ if (editorId == 0L) {
2503
+ return super.performAccessibilityAction(
2504
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT,
2505
+ arguments
2506
+ )
2507
+ }
2508
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
2509
+ if (!prepareForExternalEditorUpdate()) return false
2510
+
2511
+ val currentText = text?.toString() ?: return false
2512
+ val scalarStart = 0
2513
+ val scalarEnd = currentText.codePointCount(0, currentText.length)
2514
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, replacement)
2515
+ return true
2516
+ }
2517
+
2518
+ // ── Selection Change ────────────────────────────────────────────────
2519
+
2520
+ /**
2521
+ * Override to notify the listener when selection changes.
2522
+ *
2523
+ * Converts the EditText selection to scalar offsets and notifies both
2524
+ * the listener and the Rust editor.
2525
+ */
2526
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
2527
+ super.onSelectionChanged(selStart, selEnd)
2528
+ if (isApplyingRustState) return
2529
+ val spannable = text as? Spanned
2530
+ if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
2531
+ explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
2532
+ }
2533
+ ensureSelectionVisible()
2534
+ onSelectionOrContentMayChange?.invoke()
2535
+
2536
+ syncCurrentSelectionToRust()
2537
+ }
2538
+
2539
+ private fun syncCurrentSelectionToRust() {
2540
+ if (!hasLiveEditor()) return
2541
+
2542
+ val currentText = text?.toString() ?: ""
2543
+ if (currentText != lastAuthorizedText) return
2544
+ val (scalarAnchor, scalarHead) = rawScalarSelection(currentText) ?: return
2545
+
2546
+ val selectionHook = onSetSelectionScalarInRustForTesting
2547
+ val docAnchor: Int
2548
+ val docHead: Int
2549
+ if (selectionHook != null) {
2550
+ selectionHook(scalarAnchor, scalarHead)
2551
+ docAnchor = scalarAnchor
2552
+ docHead = scalarHead
2553
+ } else {
2554
+ // Sync selection to Rust (converts scalar→doc internally).
2555
+ editorSetSelectionScalar(
2556
+ editorId.toULong(),
2557
+ scalarAnchor.toUInt(),
2558
+ scalarHead.toUInt()
2559
+ )
2560
+
2561
+ // Emit doc positions (not scalar offsets) to match the Selection contract.
2562
+ docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
2563
+ docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
2564
+ }
2565
+ editorListener?.onSelectionChanged(docAnchor, docHead)
2566
+ }
2567
+
2568
+ // ── Rust Integration ────────────────────────────────────────────────
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()
1158
2601
  return
1159
2602
  }
2603
+ cancelDeferredRustUpdateApplication()
2604
+ recordImeTraceForTesting(
2605
+ "rustUpdateApply",
2606
+ "mode=immediate jsonLength=${updateJSON.length}"
2607
+ )
2608
+ applyUpdateJSON(updateJSON)
2609
+ }
1160
2610
 
1161
- // Fallback to plain text.
1162
- val plainText = item.text?.toString()
1163
- if (plainText != null) {
1164
- pastePlainText(plainText)
1165
- }
2611
+ private fun authorizeCurrentVisibleTextForDeferredRustUpdate() {
2612
+ lastAuthorizedText = text?.toString().orEmpty()
2613
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
2614
+ lastAuthorizedTextRevision += 1L
2615
+ clearNativeTextMutationAdoptionSuppression()
2616
+ clearNativeTextMutationAfterBlurWindow()
1166
2617
  }
1167
2618
 
1168
- // ── Selection Change ────────────────────────────────────────────────
2619
+ internal fun authorizeCurrentVisibleTextForPendingImeOperationForEditor() {
2620
+ pendingOptimisticRenderText = null
2621
+ authorizeCurrentVisibleTextForDeferredRustUpdate()
2622
+ recordImeTraceForTesting(
2623
+ "authorizePendingImeVisibleText",
2624
+ "textLength=${lastAuthorizedText.length}"
2625
+ )
2626
+ }
1169
2627
 
1170
- /**
1171
- * Override to notify the listener when selection changes.
1172
- *
1173
- * Converts the EditText selection to scalar offsets and notifies both
1174
- * the listener and the Rust editor.
1175
- */
1176
- override fun onSelectionChanged(selStart: Int, selEnd: Int) {
1177
- super.onSelectionChanged(selStart, selEnd)
1178
- if (isApplyingRustState) return
1179
- val spannable = text as? Spanned
1180
- if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
1181
- explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
1182
- }
1183
- ensureSelectionVisible()
1184
- onSelectionOrContentMayChange?.invoke()
2628
+ internal fun deleteScalarRangeForPendingImeOperationForEditor(scalarFrom: Int, scalarTo: Int) {
2629
+ deleteRangeInRust(scalarFrom, scalarTo)
2630
+ }
1185
2631
 
1186
- if (editorId == 0L) return
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
+ }
1187
2664
 
1188
- val currentText = text?.toString() ?: ""
1189
- if (currentText != lastAuthorizedText) return
1190
- val scalarAnchor = PositionBridge.utf16ToScalar(selStart, currentText)
1191
- val scalarHead = PositionBridge.utf16ToScalar(selEnd, currentText)
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
+ }
1192
2691
 
1193
- // Sync selection to Rust (converts scalar→doc internally).
1194
- editorSetSelectionScalar(
1195
- editorId.toULong(),
1196
- scalarAnchor.toUInt(),
1197
- scalarHead.toUInt()
2692
+ val authorizedText = lastAuthorizedText
2693
+ val requestedCursor = requestedCursorScalar(
2694
+ mutation.scalarFrom,
2695
+ mutation.scalarTo,
2696
+ authorizedText,
2697
+ mutation.replacementText,
2698
+ newCursorPosition
1198
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
+ }
1199
2713
 
1200
- // Emit doc positions (not scalar offsets) to match the Selection contract.
1201
- val docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
1202
- val docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
1203
- editorListener?.onSelectionChanged(docAnchor, docHead)
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
+ }
1204
2734
  }
1205
2735
 
1206
- // ── Rust Integration ────────────────────────────────────────────────
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
+ }
1207
2773
 
1208
2774
  /**
1209
2775
  * Insert text at a scalar position via the Rust editor.
1210
2776
  */
1211
2777
  private fun insertTextInRust(text: String, atScalarPos: Int) {
2778
+ if (!hasLiveEditor()) return
1212
2779
  onInsertTextInRustForTesting?.let { callback ->
1213
2780
  callback(text, atScalarPos)
1214
2781
  return
1215
2782
  }
2783
+ val startedAt = System.nanoTime()
1216
2784
  val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
1217
- applyUpdateJSON(updateJSON)
2785
+ recordImeTraceForTesting(
2786
+ "rustInsertText",
2787
+ "at=$atScalarPos textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2788
+ )
2789
+ applyRustUpdateJSON(updateJSON)
1218
2790
  }
1219
2791
 
1220
2792
  private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
2793
+ if (!hasLiveEditor()) return
1221
2794
  onReplaceTextInRustForTesting?.let { callback ->
1222
2795
  callback(scalarFrom, scalarTo, text)
1223
2796
  return
1224
2797
  }
2798
+ val startedAt = System.nanoTime()
1225
2799
  val updateJSON = editorReplaceTextScalar(
1226
2800
  editorId.toULong(),
1227
2801
  scalarFrom.toUInt(),
1228
2802
  scalarTo.toUInt(),
1229
2803
  text
1230
2804
  )
1231
- 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)
2810
+ }
2811
+
2812
+ private fun insertPlainTextRangeInRust(
2813
+ scalarFrom: Int,
2814
+ scalarTo: Int,
2815
+ text: String,
2816
+ requestedCursorScalar: Int? = null
2817
+ ) {
2818
+ if (!hasLiveEditor()) return
2819
+ recordImeTraceForTesting(
2820
+ "rustPlainTextRoute",
2821
+ "range=$scalarFrom..$scalarTo textLength=${text.length} requestedCursor=$requestedCursorScalar"
2822
+ )
2823
+ if (text.isEmpty()) {
2824
+ if (scalarFrom != scalarTo) {
2825
+ deleteRangeInRust(scalarFrom, scalarTo)
2826
+ }
2827
+ applyRequestedCursorScalar(requestedCursorScalar)
2828
+ return
2829
+ }
2830
+ if (text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
2831
+ val docJson = plainTextDocumentFragmentJson(text)
2832
+ onInsertContentJsonAtSelectionScalarForTesting?.let { callback ->
2833
+ callback(scalarFrom, scalarTo, docJson)
2834
+ applyRequestedCursorScalar(requestedCursorScalar)
2835
+ return
2836
+ }
2837
+ val startedAt = System.nanoTime()
2838
+ val updateJSON = editorInsertContentJsonAtSelectionScalar(
2839
+ editorId.toULong(),
2840
+ scalarFrom.toUInt(),
2841
+ scalarTo.toUInt(),
2842
+ docJson
2843
+ )
2844
+ recordImeTraceForTesting(
2845
+ "rustInsertContentJson",
2846
+ "range=$scalarFrom..$scalarTo textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2847
+ )
2848
+ applyRustUpdateJSON(updateJSON)
2849
+ applyRequestedCursorScalar(requestedCursorScalar)
2850
+ return
2851
+ }
2852
+
2853
+ if (scalarFrom != scalarTo) {
2854
+ replaceTextRangeInRust(scalarFrom, scalarTo, text)
2855
+ } else {
2856
+ insertTextInRust(text, scalarFrom)
2857
+ }
2858
+ applyRequestedCursorScalar(requestedCursorScalar)
2859
+ }
2860
+
2861
+ private fun requestedCursorScalar(
2862
+ scalarFrom: Int,
2863
+ scalarTo: Int,
2864
+ currentText: String,
2865
+ insertedText: String,
2866
+ newCursorPosition: Int
2867
+ ): Int? {
2868
+ if (newCursorPosition == 1) return null
2869
+ val insertedScalarLength = insertedText.codePointCount(0, insertedText.length)
2870
+ val currentScalarLength = currentText.codePointCount(0, currentText.length)
2871
+ val nextScalarLength =
2872
+ (currentScalarLength - (scalarTo - scalarFrom) + insertedScalarLength).coerceAtLeast(0)
2873
+ val requested = if (newCursorPosition > 0) {
2874
+ scalarFrom + insertedScalarLength + newCursorPosition - 1
2875
+ } else {
2876
+ scalarFrom + newCursorPosition
2877
+ }
2878
+ return requested.coerceIn(0, nextScalarLength)
2879
+ }
2880
+
2881
+ private fun applyRequestedCursorScalar(requestedCursorScalar: Int?) {
2882
+ val requested = requestedCursorScalar ?: return
2883
+ if (!hasLiveEditor()) return
2884
+ val currentText = text?.toString().orEmpty()
2885
+ val safeScalar = requested.coerceAtLeast(0)
2886
+ onSetSelectionScalarInRustForTesting?.let { callback ->
2887
+ callback(safeScalar, safeScalar)
2888
+ } ?: editorSetSelectionScalar(
2889
+ editorId.toULong(),
2890
+ safeScalar.toUInt(),
2891
+ safeScalar.toUInt()
2892
+ )
2893
+ val localScalar = safeScalar.coerceIn(0, currentText.codePointCount(0, currentText.length))
2894
+ val safeUtf16 = PositionBridge.scalarToUtf16(localScalar, currentText)
2895
+ .coerceIn(0, currentText.length)
2896
+ if (selectionStart != safeUtf16 || selectionEnd != safeUtf16) {
2897
+ setSelection(safeUtf16, safeUtf16)
2898
+ }
2899
+ }
2900
+
2901
+ private fun plainTextDocumentFragmentJson(text: String): String {
2902
+ val normalizedText = text.replace("\r\n", "\n").replace('\r', '\n')
2903
+ val content = org.json.JSONArray()
2904
+ for (line in normalizedText.split('\n')) {
2905
+ val paragraph = org.json.JSONObject().put("type", "paragraph")
2906
+ if (line.isNotEmpty()) {
2907
+ paragraph.put(
2908
+ "content",
2909
+ org.json.JSONArray().put(
2910
+ org.json.JSONObject()
2911
+ .put("type", "text")
2912
+ .put("text", line)
2913
+ )
2914
+ )
2915
+ }
2916
+ content.put(paragraph)
2917
+ }
2918
+ return org.json.JSONObject()
2919
+ .put("type", "doc")
2920
+ .put("content", content)
2921
+ .toString()
1232
2922
  }
1233
2923
 
1234
2924
  private fun nativeTextMutationFromAuthorizedDiff(currentText: String): NativeTextMutation? {
@@ -1243,6 +2933,10 @@ class EditorEditText @JvmOverloads constructor(
1243
2933
  ) {
1244
2934
  prefix++
1245
2935
  }
2936
+ prefix = minOf(
2937
+ PositionBridge.snapToScalarBoundary(prefix, authorizedText, biasForward = false),
2938
+ PositionBridge.snapToScalarBoundary(prefix, currentText, biasForward = false)
2939
+ )
1246
2940
 
1247
2941
  var authorizedEnd = authorizedText.length
1248
2942
  var currentEnd = currentText.length
@@ -1254,41 +2948,197 @@ class EditorEditText @JvmOverloads constructor(
1254
2948
  authorizedEnd--
1255
2949
  currentEnd--
1256
2950
  }
2951
+ authorizedEnd = PositionBridge.snapToScalarBoundary(
2952
+ authorizedEnd,
2953
+ authorizedText,
2954
+ biasForward = true
2955
+ )
2956
+ currentEnd = PositionBridge.snapToScalarBoundary(
2957
+ currentEnd,
2958
+ currentText,
2959
+ biasForward = true
2960
+ )
1257
2961
 
1258
2962
  val replacementText = currentText.substring(prefix, currentEnd)
2963
+ val rawSelectionStart = selectionStart
2964
+ val rawSelectionEnd = selectionEnd
2965
+ val selectionAnchorUtf16 = rawSelectionStart
2966
+ .takeIf { it >= 0 }
2967
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
2968
+ val selectionHeadUtf16 = rawSelectionEnd
2969
+ .takeIf { it >= 0 }
2970
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
1259
2971
  return NativeTextMutation(
1260
2972
  scalarFrom = PositionBridge.utf16ToScalar(prefix, authorizedText),
1261
2973
  scalarTo = PositionBridge.utf16ToScalar(authorizedEnd, authorizedText),
1262
2974
  replacementText = replacementText,
1263
- resultingText = currentText
2975
+ resultingText = currentText,
2976
+ replacementStartUtf16 = prefix,
2977
+ replacementEndUtf16 = currentEnd,
2978
+ selectionScalarAnchor = selectionAnchorUtf16?.let {
2979
+ PositionBridge.utf16ToScalar(it, currentText)
2980
+ },
2981
+ selectionScalarHead = selectionHeadUtf16?.let {
2982
+ PositionBridge.utf16ToScalar(it, currentText)
2983
+ }
1264
2984
  )
1265
2985
  }
1266
2986
 
1267
- private fun shouldAdoptNativeTextMutation(editable: Editable?): Boolean {
1268
- if (!isEditable || !hasFocus()) return false
1269
- if (editable == null) return true
2987
+ private fun shouldAdoptNativeTextMutation(
2988
+ mutation: NativeTextMutation,
2989
+ allowAfterBlur: Boolean = false
2990
+ ): Boolean {
2991
+ if (!isEditable) return false
2992
+ if (isNativeTextMutationAdoptionSuppressedForCurrentRevision()) return false
2993
+ if (!hasFocus()) {
2994
+ return allowAfterBlur &&
2995
+ canAdoptNativeTextMutationAfterBlur() &&
2996
+ shouldAdoptFinalNativeTextMutation(mutation)
2997
+ }
2998
+ return shouldAdoptFinalNativeTextMutation(mutation)
2999
+ }
1270
3000
 
1271
- val composingStart = BaseInputConnection.getComposingSpanStart(editable)
1272
- val composingEnd = BaseInputConnection.getComposingSpanEnd(editable)
1273
- return composingStart < 0 || composingEnd < 0 || composingStart == composingEnd
3001
+ private fun shouldAdoptFinalNativeTextMutation(mutation: NativeTextMutation): Boolean {
3002
+ if (composingTextForEditor() != null) return false
3003
+ val trackedRange = compositionReplacementRange() ?: return true
3004
+ val authorizedText = lastAuthorizedText
3005
+ val trackedStart = PositionBridge.utf16ToScalar(trackedRange.first, authorizedText)
3006
+ val trackedEnd = PositionBridge.utf16ToScalar(trackedRange.second, authorizedText)
3007
+ if (trackedStart == trackedEnd) {
3008
+ return mutation.scalarFrom == trackedStart &&
3009
+ mutation.scalarTo == trackedStart &&
3010
+ mutation.replacementText.isNotEmpty()
3011
+ }
3012
+ if (mutation.scalarFrom == mutation.scalarTo) {
3013
+ return mutation.replacementText.isNotEmpty() &&
3014
+ mutation.scalarFrom >= trackedStart &&
3015
+ mutation.scalarFrom <= trackedEnd
3016
+ }
3017
+ return mutation.scalarFrom < trackedEnd && mutation.scalarTo > trackedStart
1274
3018
  }
1275
3019
 
1276
- private fun commitNativeTextMutation(mutation: NativeTextMutation) {
1277
- if ((text?.toString() ?: "") != mutation.resultingText) return
3020
+ private fun drainNativeTextMutationIfNeeded(allowAfterBlur: Boolean): Boolean {
3021
+ if (editorId == 0L) return true
3022
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
3023
+ val editable = text
3024
+ val currentText = editable?.toString() ?: ""
3025
+ if (currentText == lastAuthorizedText) return true
1278
3026
 
1279
- if (mutation.scalarFrom == mutation.scalarTo) {
1280
- if (mutation.replacementText.isNotEmpty()) {
1281
- insertTextInRust(mutation.replacementText, mutation.scalarFrom)
1282
- }
1283
- } else if (mutation.replacementText.isEmpty()) {
3027
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
3028
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur)) {
3029
+ commitNativeTextMutation(mutation)
3030
+ return true
3031
+ }
3032
+ recordImeTraceForTesting(
3033
+ "nativeMutationNoop",
3034
+ "reason=${if (mutation == null) "noDiffRange" else "notAdoptable"} allowAfterBlur=$allowAfterBlur currentLength=${currentText.length} authorizedLength=${lastAuthorizedText.length}"
3035
+ )
3036
+ return false
3037
+ }
3038
+
3039
+ private fun beginNativeTextMutationAfterBlurWindow() {
3040
+ if (!hasLiveEditor()) {
3041
+ clearNativeTextMutationAfterBlurWindow()
3042
+ return
3043
+ }
3044
+ nativeTextMutationAfterBlurWindow = NativeTextMutationAfterBlurWindow(
3045
+ editorId = editorId,
3046
+ authorizedTextRevision = lastAuthorizedTextRevision,
3047
+ deadlineMs = SystemClock.uptimeMillis() + NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS
3048
+ )
3049
+ }
3050
+
3051
+ private fun clearNativeTextMutationAfterBlurWindow() {
3052
+ nativeTextMutationAfterBlurWindow = null
3053
+ }
3054
+
3055
+ private fun suppressNativeTextMutationAdoptionForCurrentRevision() {
3056
+ if (!hasLiveEditor()) {
3057
+ clearNativeTextMutationAdoptionSuppression()
3058
+ return
3059
+ }
3060
+ nativeTextMutationAdoptionSuppression = NativeTextMutationAdoptionSuppression(
3061
+ editorId = editorId,
3062
+ authorizedTextRevision = lastAuthorizedTextRevision
3063
+ )
3064
+ }
3065
+
3066
+ private fun clearNativeTextMutationAdoptionSuppression() {
3067
+ nativeTextMutationAdoptionSuppression = null
3068
+ }
3069
+
3070
+ private fun isNativeTextMutationAdoptionSuppressedForCurrentRevision(): Boolean {
3071
+ val suppression = nativeTextMutationAdoptionSuppression ?: return false
3072
+ if (
3073
+ suppression.editorId != editorId ||
3074
+ suppression.authorizedTextRevision != lastAuthorizedTextRevision
3075
+ ) {
3076
+ nativeTextMutationAdoptionSuppression = null
3077
+ return false
3078
+ }
3079
+ return true
3080
+ }
3081
+
3082
+ private fun canAdoptNativeTextMutationAfterBlur(): Boolean {
3083
+ val window = nativeTextMutationAfterBlurWindow ?: return false
3084
+ val now = SystemClock.uptimeMillis()
3085
+ if (now > window.deadlineMs ||
3086
+ window.editorId != editorId ||
3087
+ window.authorizedTextRevision != lastAuthorizedTextRevision ||
3088
+ window.didAdoptMutation
3089
+ ) {
3090
+ nativeTextMutationAfterBlurWindow = null
3091
+ return false
3092
+ }
3093
+ return true
3094
+ }
3095
+
3096
+ private fun commitNativeTextMutation(mutation: NativeTextMutation) {
3097
+ if (!hasLiveEditor()) return
3098
+ val startedAt = System.nanoTime()
3099
+ if ((text?.toString() ?: "") != mutation.resultingText) {
3100
+ recordImeTraceForTesting(
3101
+ "nativeMutationNoop",
3102
+ "reason=staleResult range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length}"
3103
+ )
3104
+ return
3105
+ }
3106
+ val shouldRestartInput = hasFocus()
3107
+ retireInputConnectionForEditor()
3108
+ nativeTextMutationAfterBlurWindow?.didAdoptMutation = true
3109
+ clearNativeTextMutationAfterBlurWindow()
3110
+
3111
+ recordImeTraceForTesting(
3112
+ "nativeMutationApply",
3113
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput"
3114
+ )
3115
+ if (mutation.replacementText.isEmpty()) {
1284
3116
  deleteRangeInRust(mutation.scalarFrom, mutation.scalarTo)
1285
3117
  } else {
1286
- replaceTextRangeInRust(
3118
+ insertPlainTextRangeInRust(
1287
3119
  mutation.scalarFrom,
1288
3120
  mutation.scalarTo,
1289
3121
  mutation.replacementText
1290
3122
  )
1291
3123
  }
3124
+ restoreSelectionAfterNativeTextMutation(mutation)
3125
+ if (shouldRestartInput) {
3126
+ restartInputForEditor()
3127
+ }
3128
+ recordImeTraceForTesting(
3129
+ "nativeMutationApplyDone",
3130
+ "totalUs=${nanosToMicros(System.nanoTime() - startedAt)} restartInput=$shouldRestartInput"
3131
+ )
3132
+ }
3133
+
3134
+ private fun restoreSelectionAfterNativeTextMutation(mutation: NativeTextMutation) {
3135
+ val selectionScalarAnchor = mutation.selectionScalarAnchor ?: return
3136
+ val selectionScalarHead = mutation.selectionScalarHead ?: return
3137
+ val currentText = text?.toString() ?: return
3138
+ val anchorUtf16 = PositionBridge.scalarToUtf16(selectionScalarAnchor, currentText)
3139
+ val headUtf16 = PositionBridge.scalarToUtf16(selectionScalarHead, currentText)
3140
+ val length = currentText.length
3141
+ setSelection(anchorUtf16.coerceIn(0, length), headUtf16.coerceIn(0, length))
1292
3142
  }
1293
3143
 
1294
3144
  /**
@@ -1298,42 +3148,271 @@ class EditorEditText @JvmOverloads constructor(
1298
3148
  * @param scalarTo End scalar offset (exclusive).
1299
3149
  */
1300
3150
  private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
3151
+ if (!hasLiveEditor()) return
1301
3152
  if (scalarFrom >= scalarTo) return
1302
3153
  onDeleteRangeInRustForTesting?.let { callback ->
1303
3154
  callback(scalarFrom, scalarTo)
1304
3155
  return
1305
3156
  }
3157
+ val startedAt = System.nanoTime()
1306
3158
  val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
1307
- applyUpdateJSON(updateJSON)
3159
+ recordImeTraceForTesting(
3160
+ "rustDeleteRange",
3161
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3162
+ )
3163
+ applyRustUpdateJSON(updateJSON)
1308
3164
  }
1309
3165
 
1310
3166
  private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
3167
+ if (!hasLiveEditor()) return
1311
3168
  onDeleteBackwardAtSelectionScalarInRustForTesting?.let { callback ->
1312
3169
  callback(scalarAnchor, scalarHead)
1313
3170
  return
1314
3171
  }
3172
+ val startedAt = System.nanoTime()
1315
3173
  val updateJSON = editorDeleteBackwardAtSelectionScalar(
1316
3174
  editorId.toULong(),
1317
3175
  scalarAnchor.toUInt(),
1318
3176
  scalarHead.toUInt()
1319
3177
  )
1320
- applyUpdateJSON(updateJSON)
3178
+ recordImeTraceForTesting(
3179
+ "rustDeleteBackward",
3180
+ "selection=$scalarAnchor..$scalarHead rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3181
+ )
3182
+ applyRustUpdateJSON(updateJSON)
1321
3183
  }
1322
3184
 
1323
3185
  /**
1324
3186
  * Split a block at a scalar position via the Rust editor.
1325
3187
  */
1326
3188
  private fun splitBlockInRust(atScalarPos: Int) {
3189
+ if (!hasLiveEditor()) return
3190
+ val startedAt = System.nanoTime()
1327
3191
  val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
1328
- applyUpdateJSON(updateJSON)
3192
+ recordImeTraceForTesting(
3193
+ "rustSplitBlock",
3194
+ "at=$atScalarPos rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3195
+ )
3196
+ applyRustUpdateJSON(updateJSON)
3197
+ scheduleLineBoundaryInputRefreshForEditor("splitBlock")
3198
+ }
3199
+
3200
+ private fun deleteAndSplitInRust(scalarFrom: Int, scalarTo: Int) {
3201
+ if (!hasLiveEditor()) return
3202
+ onDeleteAndSplitScalarInRustForTesting?.let { callback ->
3203
+ callback(scalarFrom, scalarTo)
3204
+ return
3205
+ }
3206
+ val startedAt = System.nanoTime()
3207
+ val updateJSON = editorDeleteAndSplitScalar(
3208
+ editorId.toULong(),
3209
+ scalarFrom.toUInt(),
3210
+ scalarTo.toUInt()
3211
+ )
3212
+ recordImeTraceForTesting(
3213
+ "rustDeleteAndSplit",
3214
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3215
+ )
3216
+ applyRustUpdateJSON(updateJSON)
3217
+ scheduleLineBoundaryInputRefreshForEditor("deleteAndSplit")
3218
+ }
3219
+
3220
+ internal fun currentScalarSelection(): Pair<Int, Int>? {
3221
+ val currentText = text?.toString() ?: return null
3222
+ return normalizedScalarSelectionRange(currentText)
3223
+ }
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
+
3362
+ private fun normalizedUtf16SelectionRange(currentText: String): Pair<Int, Int>? {
3363
+ val start = selectionStart
3364
+ val end = selectionEnd
3365
+ if (start < 0 || end < 0) return null
3366
+ val clampedStart = start.coerceIn(0, currentText.length)
3367
+ val clampedEnd = end.coerceIn(0, currentText.length)
3368
+ return minOf(clampedStart, clampedEnd) to maxOf(clampedStart, clampedEnd)
1329
3369
  }
1330
3370
 
1331
- private fun currentScalarSelection(): Pair<Int, Int>? {
3371
+ private fun normalizedUtf16SelectionRange(): Pair<Int, Int>? {
1332
3372
  val currentText = text?.toString() ?: return null
1333
- return Pair(
1334
- PositionBridge.utf16ToScalar(selectionStart, currentText),
1335
- PositionBridge.utf16ToScalar(selectionEnd, currentText)
3373
+ return normalizedUtf16SelectionRange(currentText)
3374
+ }
3375
+
3376
+ private fun normalizedScalarSelectionRange(currentText: String): Pair<Int, Int>? {
3377
+ val (start, end) = normalizedUtf16SelectionRange(currentText) ?: return null
3378
+ val (snappedStart, snappedEnd) = if (start == end) {
3379
+ val snapped = PositionBridge.snapToScalarBoundary(
3380
+ start,
3381
+ currentText,
3382
+ biasForward = true
3383
+ )
3384
+ snapped to snapped
3385
+ } else {
3386
+ PositionBridge.snapRangeToScalarBoundaries(start, end, currentText)
3387
+ }
3388
+ return PositionBridge.utf16ToScalar(snappedStart, currentText) to
3389
+ PositionBridge.utf16ToScalar(snappedEnd, currentText)
3390
+ }
3391
+
3392
+ private fun rawScalarSelection(currentText: String): Pair<Int, Int>? {
3393
+ val anchor = selectionStart
3394
+ val head = selectionEnd
3395
+ if (anchor < 0 || head < 0) return null
3396
+ val clampedAnchor = anchor.coerceIn(0, currentText.length)
3397
+ val clampedHead = head.coerceIn(0, currentText.length)
3398
+ if (clampedAnchor == clampedHead) {
3399
+ val snapped = PositionBridge.snapToScalarBoundary(
3400
+ clampedAnchor,
3401
+ currentText,
3402
+ biasForward = true
3403
+ )
3404
+ val scalar = PositionBridge.utf16ToScalar(snapped, currentText)
3405
+ return scalar to scalar
3406
+ }
3407
+ val (rangeStart, rangeEnd) = PositionBridge.snapRangeToScalarBoundaries(
3408
+ minOf(clampedAnchor, clampedHead),
3409
+ maxOf(clampedAnchor, clampedHead),
3410
+ currentText
1336
3411
  )
3412
+ val snappedAnchor = if (clampedAnchor < clampedHead) rangeStart else rangeEnd
3413
+ val snappedHead = if (clampedAnchor < clampedHead) rangeEnd else rangeStart
3414
+ return PositionBridge.utf16ToScalar(snappedAnchor, currentText) to
3415
+ PositionBridge.utf16ToScalar(snappedHead, currentText)
1337
3416
  }
1338
3417
 
1339
3418
  fun selectedImageGeometry(): SelectedImageGeometry? {
@@ -1352,7 +3431,7 @@ class EditorEditText @JvmOverloads constructor(
1352
3431
  val textLayout = layout ?: return null
1353
3432
  val currentText = text?.toString() ?: return null
1354
3433
  val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
1355
- val docPos = if (editorId != 0L) {
3434
+ val docPos = if (hasLiveEditor()) {
1356
3435
  editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
1357
3436
  } else {
1358
3437
  0
@@ -1366,7 +3445,7 @@ class EditorEditText @JvmOverloads constructor(
1366
3445
  }
1367
3446
 
1368
3447
  fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
1369
- if (editorId == 0L) return
3448
+ if (!hasLiveEditor()) return
1370
3449
  val density = resources.displayMetrics.density
1371
3450
  val widthDp = maxOf(48, (widthPx / density).roundToInt())
1372
3451
  val heightDp = maxOf(48, (heightPx / density).roundToInt())
@@ -1380,7 +3459,7 @@ class EditorEditText @JvmOverloads constructor(
1380
3459
  }
1381
3460
 
1382
3461
  private fun isSelectionInsideList(): Boolean {
1383
- if (editorId == 0L) return false
3462
+ if (!hasLiveEditor()) return false
1384
3463
 
1385
3464
  return try {
1386
3465
  val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
@@ -1396,6 +3475,12 @@ class EditorEditText @JvmOverloads constructor(
1396
3475
  * Paste HTML content through Rust.
1397
3476
  */
1398
3477
  private fun pasteHTML(html: String) {
3478
+ if (!hasLiveEditor()) return
3479
+ syncCurrentSelectionToRust()
3480
+ onInsertContentHtmlInRustForTesting?.let { callback ->
3481
+ callback(html)
3482
+ return
3483
+ }
1399
3484
  val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
1400
3485
  applyUpdateJSON(updateJSON)
1401
3486
  }
@@ -1404,9 +3489,8 @@ class EditorEditText @JvmOverloads constructor(
1404
3489
  * Paste plain text through Rust.
1405
3490
  */
1406
3491
  private fun pastePlainText(text: String) {
1407
- val currentText = this.text?.toString() ?: ""
1408
- val scalarPos = PositionBridge.utf16ToScalar(selectionStart, currentText)
1409
- insertTextInRust(text, scalarPos)
3492
+ val (scalarStart, scalarEnd) = currentScalarSelection() ?: return
3493
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, text)
1410
3494
  }
1411
3495
 
1412
3496
  // ── Applying Rust State ─────────────────────────────────────────────
@@ -1477,6 +3561,12 @@ class EditorEditText @JvmOverloads constructor(
1477
3561
  replaceRange: RenderReplaceRange? = null,
1478
3562
  usedPatch: Boolean
1479
3563
  ) {
3564
+ val startedAt = System.nanoTime()
3565
+ val previousScrollX = scrollX
3566
+ val previousScrollY = scrollY
3567
+ val hadCompositionTracking = hasCompositionTrackingForEditor()
3568
+ var shouldRestartInput = false
3569
+ val mode = if (replaceRange != null) "replace" else "setText"
1480
3570
  isApplyingRustState = true
1481
3571
  beginBatchEdit()
1482
3572
  try {
@@ -1486,11 +3576,43 @@ class EditorEditText @JvmOverloads constructor(
1486
3576
  setText(spannable)
1487
3577
  }
1488
3578
  lastAuthorizedText = text?.toString().orEmpty()
3579
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
3580
+ lastAuthorizedTextRevision += 1L
3581
+ clearNativeTextMutationAdoptionSuppression()
3582
+ if (hadCompositionTracking) {
3583
+ retireInputConnectionForEditor()
3584
+ shouldRestartInput = true
3585
+ } else {
3586
+ clearCompositionTrackingForEditor()
3587
+ }
1489
3588
  lastRenderAppliedPatchForTesting = usedPatch
3589
+ clearNativeTextMutationAfterBlurWindow()
1490
3590
  } finally {
1491
3591
  endBatchEdit()
1492
3592
  isApplyingRustState = false
1493
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
+ )
3598
+ restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
3599
+ }
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
+ )
1494
3616
  }
1495
3617
 
1496
3618
  private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
@@ -1662,9 +3784,14 @@ class EditorEditText @JvmOverloads constructor(
1662
3784
  val parseStartedAt = totalStartedAt
1663
3785
  val update = try {
1664
3786
  org.json.JSONObject(updateJSON)
1665
- } catch (_: Exception) {
3787
+ } catch (error: Exception) {
3788
+ recordImeTraceForTesting(
3789
+ "applyUpdateJSONNoop",
3790
+ "reason=parseError jsonLength=${updateJSON.length} error=${error.javaClass.simpleName}"
3791
+ )
1666
3792
  return
1667
3793
  }
3794
+ cancelDeferredRustUpdateApplication()
1668
3795
  val parseNanos = System.nanoTime() - parseStartedAt
1669
3796
 
1670
3797
  val resolveRenderBlocksStartedAt = System.nanoTime()
@@ -1689,8 +3816,11 @@ class EditorEditText @JvmOverloads constructor(
1689
3816
  val buildRenderNanos: Long
1690
3817
  val applyRenderNanos: Long
1691
3818
  if (shouldSkipRender) {
3819
+ pendingOptimisticRenderText = null
1692
3820
  lastRenderAppliedPatchForTesting = false
1693
3821
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
3822
+ clearNativeTextMutationAdoptionSuppression()
3823
+ clearNativeTextMutationAfterBlurWindow()
1694
3824
  buildRenderNanos = 0L
1695
3825
  applyRenderNanos = 0L
1696
3826
  } else {
@@ -1718,12 +3848,27 @@ class EditorEditText @JvmOverloads constructor(
1718
3848
  this
1719
3849
  )
1720
3850
  } else {
3851
+ recordImeTraceForTesting(
3852
+ "applyUpdateJSONNoop",
3853
+ "reason=noRenderPayload jsonLength=${updateJSON.length}"
3854
+ )
1721
3855
  return
1722
3856
  }
1723
3857
  buildRenderNanos = System.nanoTime() - buildStartedAt
1724
3858
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
1725
3859
  val applyStartedAt = System.nanoTime()
1726
- 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
1727
3872
  applyRenderNanos = System.nanoTime() - applyStartedAt
1728
3873
  lastAppliedRenderAppearanceRevision = renderAppearanceRevision
1729
3874
  }
@@ -1748,6 +3893,12 @@ class EditorEditText @JvmOverloads constructor(
1748
3893
  }
1749
3894
  val postApplyNanos = System.nanoTime() - postApplyStartedAt
1750
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
+
1751
3902
  if (captureApplyUpdateTraceForTesting) {
1752
3903
  lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
1753
3904
  attemptedPatch = renderPatch != null,
@@ -1760,7 +3911,7 @@ class EditorEditText @JvmOverloads constructor(
1760
3911
  applyRenderNanos = applyRenderNanos,
1761
3912
  selectionNanos = selectionNanos,
1762
3913
  postApplyNanos = postApplyNanos,
1763
- totalNanos = System.nanoTime() - totalStartedAt
3914
+ totalNanos = totalNanos
1764
3915
  )
1765
3916
  }
1766
3917
  }
@@ -1774,6 +3925,7 @@ class EditorEditText @JvmOverloads constructor(
1774
3925
  * @param renderJSON The JSON array string of render elements.
1775
3926
  */
1776
3927
  fun applyRenderJSON(renderJSON: String) {
3928
+ val startedAt = System.nanoTime()
1777
3929
  val spannable = RenderBridge.buildSpannable(
1778
3930
  renderJSON,
1779
3931
  baseFontSize,
@@ -1788,6 +3940,7 @@ class EditorEditText @JvmOverloads constructor(
1788
3940
 
1789
3941
  explicitSelectedImageRange = null
1790
3942
  currentRenderBlocksJson = null
3943
+ pendingOptimisticRenderText = null
1791
3944
  applyRenderedSpannable(spannable, usedPatch = false)
1792
3945
  onSelectionOrContentMayChange?.invoke()
1793
3946
  if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
@@ -1795,6 +3948,10 @@ class EditorEditText @JvmOverloads constructor(
1795
3948
  } else {
1796
3949
  preserveScrollPosition(previousScrollX, previousScrollY)
1797
3950
  }
3951
+ recordImeTraceForTesting(
3952
+ "applyRenderJSON",
3953
+ "jsonLength=${renderJSON.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
3954
+ )
1798
3955
  }
1799
3956
 
1800
3957
  private fun textOffsetHitAt(x: Float, y: Float): Pair<Spanned, Int>? {
@@ -1911,7 +4068,10 @@ class EditorEditText @JvmOverloads constructor(
1911
4068
 
1912
4069
  override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
1913
4070
  super.onFocusChanged(focused, direction, previouslyFocusedRect)
1914
- if (!focused) {
4071
+ if (focused) {
4072
+ clearNativeTextMutationAfterBlurWindow()
4073
+ } else {
4074
+ beginNativeTextMutationAfterBlurWindow()
1915
4075
  clearExplicitSelectedImageRange()
1916
4076
  }
1917
4077
  }
@@ -2018,6 +4178,7 @@ class EditorEditText @JvmOverloads constructor(
2018
4178
  */
2019
4179
  private fun applySelectionFromJSON(selection: org.json.JSONObject) {
2020
4180
  val type = selection.optString("type", "") ?: return
4181
+ if (isEditorDestroyedForInput()) return
2021
4182
 
2022
4183
  isApplyingRustState = true
2023
4184
  try {
@@ -2029,12 +4190,12 @@ class EditorEditText @JvmOverloads constructor(
2029
4190
  // Convert doc positions to scalar offsets.
2030
4191
  val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
2031
4192
  val scalarHead = editorDocToScalar(editorId.toULong(), docHead.toUInt()).toInt()
2032
- val startUtf16 = PositionBridge.scalarToUtf16(minOf(scalarAnchor, scalarHead), currentText)
2033
- val endUtf16 = PositionBridge.scalarToUtf16(maxOf(scalarAnchor, scalarHead), currentText)
4193
+ val anchorUtf16 = PositionBridge.scalarToUtf16(scalarAnchor, currentText)
4194
+ val headUtf16 = PositionBridge.scalarToUtf16(scalarHead, currentText)
2034
4195
  val len = text?.length ?: 0
2035
4196
  setSelection(
2036
- startUtf16.coerceIn(0, len),
2037
- endUtf16.coerceIn(0, len)
4197
+ anchorUtf16.coerceIn(0, len),
4198
+ headUtf16.coerceIn(0, len)
2038
4199
  )
2039
4200
  }
2040
4201
  "node" -> {
@@ -2081,13 +4242,13 @@ class EditorEditText @JvmOverloads constructor(
2081
4242
 
2082
4243
  override fun afterTextChanged(s: Editable?) {
2083
4244
  if (isApplyingRustState) return
2084
- if (editorId == 0L) return
4245
+ if (!hasLiveEditor()) return
2085
4246
 
2086
4247
  val currentText = s?.toString() ?: ""
2087
4248
  if (currentText == lastAuthorizedText) return
2088
4249
 
2089
4250
  val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
2090
- if (mutation != null && shouldAdoptNativeTextMutation(s)) {
4251
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur = true)) {
2091
4252
  commitNativeTextMutation(mutation)
2092
4253
  return
2093
4254
  }
@@ -2113,6 +4274,10 @@ class EditorEditText @JvmOverloads constructor(
2113
4274
  private const val DEFAULT_AUTO_CORRECT = true
2114
4275
  private const val DEFAULT_KEYBOARD_TYPE = "default"
2115
4276
  private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
4277
+ private const val IME_TRACE_LIMIT_FOR_TESTING = 80
4278
+ private const val IME_TRACE_LOG_TAG = "NativeEditorIme"
4279
+ private const val NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS = 750L
4280
+ private const val RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS = 750L
2116
4281
  private const val LOG_TAG = "NativeEditor"
2117
4282
  }
2118
4283
  }