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

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,15 +1,18 @@
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.SystemClock
8
10
  import android.text.Annotation
9
11
  import android.text.Editable
10
12
  import android.text.InputType
11
13
  import android.text.Layout
12
14
  import android.text.Spanned
15
+ import android.text.SpannableStringBuilder
13
16
  import android.text.StaticLayout
14
17
  import android.text.TextPaint
15
18
  import android.text.TextWatcher
@@ -80,6 +83,17 @@ class EditorEditText @JvmOverloads constructor(
80
83
  val label: String
81
84
  )
82
85
 
86
+ data class CommandPreparation(
87
+ val ready: Boolean,
88
+ val updateJSON: String?
89
+ )
90
+
91
+ private data class HardwareKeyEventSignature(
92
+ val keyCode: Int,
93
+ val downTime: Long,
94
+ val repeatCount: Int
95
+ )
96
+
83
97
  data class LinkHit(
84
98
  val href: String,
85
99
  val text: String
@@ -112,7 +126,21 @@ class EditorEditText @JvmOverloads constructor(
112
126
  val scalarFrom: Int,
113
127
  val scalarTo: Int,
114
128
  val replacementText: String,
115
- val resultingText: String
129
+ val resultingText: String,
130
+ val selectionScalarAnchor: Int?,
131
+ val selectionScalarHead: Int?
132
+ )
133
+
134
+ private data class NativeTextMutationAfterBlurWindow(
135
+ val editorId: Long,
136
+ val authorizedTextRevision: Long,
137
+ val deadlineMs: Long,
138
+ var didAdoptMutation: Boolean = false
139
+ )
140
+
141
+ private data class NativeTextMutationAdoptionSuppression(
142
+ val editorId: Long,
143
+ val authorizedTextRevision: Long
116
144
  )
117
145
 
118
146
  /**
@@ -137,6 +165,13 @@ class EditorEditText @JvmOverloads constructor(
137
165
  * focus, text selection, and copy capability.
138
166
  */
139
167
  var isEditable: Boolean = true
168
+ set(value) {
169
+ if (field == value) return
170
+ if (!value) {
171
+ discardTransientNativeInputForReadOnly()
172
+ }
173
+ field = value
174
+ }
140
175
 
141
176
  /**
142
177
  * Guard flag to prevent re-entrant input interception while we're
@@ -192,12 +227,25 @@ class EditorEditText @JvmOverloads constructor(
192
227
  var reconciliationCount: Int = 0
193
228
  private set
194
229
 
195
- private var lastHandledHardwareKeyCode: Int? = null
196
- private var lastHandledHardwareKeyDownTime: Long? = null
230
+ private var lastHandledHardwareKeySignature: HardwareKeyEventSignature? = null
231
+ private var recentHandledHardwareKeyDownSignature: HardwareKeyEventSignature? = null
232
+ private var recentHandledHardwareKeyDownUptimeMs: Long = 0L
233
+ private var activeInputConnection: EditorInputConnection? = null
234
+ private var inputConnectionGeneration: Long = 0L
235
+ private var composingText: String? = null
236
+ private var composingReplacementStartUtf16: Int? = null
237
+ private var composingReplacementEndUtf16: Int? = null
238
+ private var composingReplacementAuthorizedTextRevision: Long? = null
239
+ private var didInvalidateCompositionReplacementRange = false
240
+ private var nativeTextMutationAfterBlurWindow: NativeTextMutationAfterBlurWindow? = null
241
+ private var nativeTextMutationAdoptionSuppression: NativeTextMutationAdoptionSuppression? = null
242
+ private var lastAuthorizedTextRevision: Long = 0L
243
+ private var lastAuthorizedRenderedText: CharSequence? = null
197
244
  private var explicitSelectedImageRange: ImageSelectionRange? = null
198
245
  private var lastRenderAppliedPatchForTesting: Boolean = false
199
246
  internal var captureApplyUpdateTraceForTesting: Boolean = false
200
247
  private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
248
+ private val imeTraceForTesting = java.util.ArrayDeque<String>()
201
249
  private var currentRenderBlocksJson: org.json.JSONArray? = null
202
250
  private var renderAppearanceRevision: Long = 1L
203
251
  private var lastAppliedRenderAppearanceRevision: Long = 0L
@@ -205,10 +253,32 @@ class EditorEditText @JvmOverloads constructor(
205
253
  internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
206
254
  internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
207
255
  internal var onReplaceTextInRustForTesting: ((Int, Int, String) -> Unit)? = null
256
+ internal var onSetSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
257
+ internal var onDeleteAndSplitScalarInRustForTesting: ((Int, Int) -> Unit)? = null
258
+ internal var onInsertContentHtmlInRustForTesting: ((String) -> Unit)? = null
259
+ internal var onInsertContentJsonAtSelectionScalarForTesting: ((Int, Int, String) -> Unit)? = null
260
+ internal var blockExternalEditorUpdatePreparationForTesting = false
261
+ internal var blockExternalEditorCommandPreparationForTesting = false
208
262
 
209
263
  fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
210
264
  fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
211
265
 
266
+ internal fun recordImeTraceForTesting(event: String, details: String = "") {
267
+ if (imeTraceForTesting.size >= IME_TRACE_LIMIT_FOR_TESTING) {
268
+ imeTraceForTesting.removeFirst()
269
+ }
270
+ imeTraceForTesting.addLast(
271
+ if (details.isEmpty()) event else "$event:$details"
272
+ )
273
+ }
274
+
275
+ internal fun clearImeTraceForTesting() {
276
+ imeTraceForTesting.clear()
277
+ }
278
+
279
+ internal fun imeTraceSnapshotForTesting(): List<String> =
280
+ imeTraceForTesting.toList()
281
+
212
282
  init {
213
283
  // Configure for rich text editing.
214
284
  inputType = resolvedInputType()
@@ -283,22 +353,23 @@ class EditorEditText @JvmOverloads constructor(
283
353
 
284
354
  val currentStart = selectionStart
285
355
  val currentEnd = selectionEnd
356
+ val authorizedSelection = authorizedSelectionForTransientInputRestore(
357
+ currentStart,
358
+ currentEnd
359
+ )
360
+ discardTransientInputAndRestoreAuthorizedTextForEditor()
286
361
  setRawInputType(nextInputType)
287
362
 
288
363
  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)
364
+ if (editable != null && authorizedSelection != null) {
365
+ setSelection(
366
+ authorizedSelection.first.coerceIn(0, editable.length),
367
+ authorizedSelection.second.coerceIn(0, editable.length)
368
+ )
297
369
  }
298
370
 
299
371
  if (hasFocus()) {
300
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
301
- imm?.restartInput(this)
372
+ restartInputForEditor()
302
373
  }
303
374
  }
304
375
 
@@ -346,16 +417,72 @@ class EditorEditText @JvmOverloads constructor(
346
417
  */
347
418
  override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
348
419
  val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
349
- return EditorInputConnection(this, baseConnection)
420
+ val generation = nextInputConnectionGenerationForEditor()
421
+ return EditorInputConnection(this, baseConnection, editorId, generation).also {
422
+ activeInputConnection = it
423
+ }
350
424
  }
351
425
 
352
426
  override fun dispatchKeyEvent(event: KeyEvent): Boolean {
427
+ if (!isEditable && isReadOnlyTextMutationKeyEvent(event)) {
428
+ return true
429
+ }
430
+ if (handleCompositionKeyEvent(event) { super.dispatchKeyEvent(event) }) {
431
+ return true
432
+ }
353
433
  if (handleHardwareKeyEvent(event)) {
354
434
  return true
355
435
  }
436
+ if (handlePrintableHardwareKeyEvent(event) { super.dispatchKeyEvent(event) }) {
437
+ return true
438
+ }
356
439
  return super.dispatchKeyEvent(event)
357
440
  }
358
441
 
442
+ internal fun handleCompositionKeyEvent(event: KeyEvent, applyBaseEvent: () -> Boolean): Boolean {
443
+ val inputConnection = activeInputConnection ?: return false
444
+ if (!inputConnection.hasPendingComposition()) return false
445
+ if (!isCompositionKeyCode(event.keyCode)) return false
446
+ if (event.action == KeyEvent.ACTION_DOWN) {
447
+ val signature = hardwareKeyEventSignature(event)
448
+ if (
449
+ lastHandledHardwareKeySignature == signature ||
450
+ didRecentlyHandleHardwareKeyDown(signature)
451
+ ) {
452
+ return true
453
+ }
454
+ markHandledHardwareKeyDown(signature)
455
+ runWithTransientInputMutationGuard {
456
+ when (event.keyCode) {
457
+ KeyEvent.KEYCODE_DEL,
458
+ KeyEvent.KEYCODE_FORWARD_DEL -> inputConnection.deleteTransientTextForHardwareKeyEvent(event)
459
+ else -> applyBaseEvent()
460
+ }
461
+ }
462
+ inputConnection.refreshComposingTextFromEditableForEditor()
463
+ return true
464
+ }
465
+ if (event.action == KeyEvent.ACTION_UP) {
466
+ if (lastHandledHardwareKeySignature?.let {
467
+ it.keyCode == event.keyCode && it.downTime == event.downTime
468
+ } == true) {
469
+ lastHandledHardwareKeySignature = null
470
+ }
471
+ return true
472
+ }
473
+ return false
474
+ }
475
+
476
+ private fun isCompositionKeyCode(keyCode: Int): Boolean =
477
+ when (keyCode) {
478
+ KeyEvent.KEYCODE_DEL,
479
+ KeyEvent.KEYCODE_FORWARD_DEL,
480
+ KeyEvent.KEYCODE_ENTER,
481
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
482
+ KeyEvent.KEYCODE_TAB -> true
483
+ else -> false
484
+ }
485
+
359
486
  override fun onDraw(canvas: android.graphics.Canvas) {
360
487
  super.onDraw(canvas)
361
488
 
@@ -492,7 +619,15 @@ class EditorEditText @JvmOverloads constructor(
492
619
  * @param id The editor ID from `editor_create()`.
493
620
  * @param initialHTML Optional HTML to set as initial content.
494
621
  */
495
- fun bindEditor(id: Long, initialHTML: String? = null) {
622
+ fun bindEditor(id: Long, initialHTML: String? = null, notifyListener: Boolean = true) {
623
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
624
+ discardTransientNativeInputForEditorRebind()
625
+ editorId = 0L
626
+ return
627
+ }
628
+ if (editorId != id) {
629
+ discardTransientNativeInputForEditorRebind()
630
+ }
496
631
  editorId = id
497
632
 
498
633
  if (!initialHTML.isNullOrEmpty()) {
@@ -502,7 +637,7 @@ class EditorEditText @JvmOverloads constructor(
502
637
  } else {
503
638
  // Pull current state from Rust (content may already be loaded via bridge).
504
639
  val stateJSON = editorGetCurrentState(editorId.toULong())
505
- applyUpdateJSON(stateJSON)
640
+ applyUpdateJSON(stateJSON, notifyListener = notifyListener)
506
641
  }
507
642
  }
508
643
 
@@ -510,6 +645,9 @@ class EditorEditText @JvmOverloads constructor(
510
645
  * Unbind from the current editor instance.
511
646
  */
512
647
  fun unbindEditor() {
648
+ if (editorId != 0L) {
649
+ discardTransientNativeInputForEditorRebind()
650
+ }
513
651
  editorId = 0
514
652
  }
515
653
 
@@ -530,7 +668,7 @@ class EditorEditText @JvmOverloads constructor(
530
668
  renderAppearanceRevision += 1L
531
669
  setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
532
670
  applyContentInsets(theme?.contentInsets)
533
- if (editorId != 0L) {
671
+ if (hasLiveEditor()) {
534
672
  val previousScrollX = scrollX
535
673
  val previousScrollY = scrollY
536
674
  val stateJSON = editorGetCurrentState(editorId.toULong())
@@ -685,17 +823,18 @@ class EditorEditText @JvmOverloads constructor(
685
823
  * Called by [EditorInputConnection.commitText]. Routes the text through
686
824
  * the Rust editor instead of directly inserting into the EditText.
687
825
  */
688
- fun handleTextCommit(text: String) {
826
+ fun handleTextCommit(text: String, newCursorPosition: Int = 1) {
689
827
  if (!isEditable) return
690
828
  if (isApplyingRustState) return
829
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
691
830
  if (editorId == 0L) {
692
831
  // No Rust editor bound — fall through to direct editing (dev mode).
693
832
  val editable = this.text ?: return
694
- val start = selectionStart
695
- val end = selectionEnd
833
+ val (start, end) = selectionRange
696
834
  editable.replace(start, end, text)
697
835
  return
698
836
  }
837
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
699
838
 
700
839
  // Handle Enter/Return as a block split operation.
701
840
  if (text == "\n") {
@@ -704,18 +843,19 @@ class EditorEditText @JvmOverloads constructor(
704
843
  }
705
844
 
706
845
  val currentText = this.text?.toString() ?: ""
707
- val start = selectionStart
708
- val end = selectionEnd
709
-
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)
718
- }
846
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
847
+ insertPlainTextRangeInRust(
848
+ scalarStart,
849
+ scalarEnd,
850
+ text,
851
+ requestedCursorScalar = requestedCursorScalar(
852
+ scalarStart,
853
+ scalarEnd,
854
+ currentText,
855
+ text,
856
+ newCursorPosition
857
+ )
858
+ )
719
859
  }
720
860
 
721
861
  internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
@@ -728,31 +868,497 @@ class EditorEditText @JvmOverloads constructor(
728
868
  }
729
869
  }
730
870
 
731
- fun handleCompositionCommit(text: String, replacementStartUtf16: Int, replacementEndUtf16: Int) {
871
+ internal fun authorizedUtf16Range(start: Int, end: Int): Pair<Int, Int> {
872
+ if (start == end) {
873
+ val snapped = PositionBridge.snapToScalarBoundary(
874
+ start,
875
+ lastAuthorizedText,
876
+ biasForward = true
877
+ )
878
+ return snapped to snapped
879
+ }
880
+ return PositionBridge.snapRangeToScalarBoundaries(start, end, lastAuthorizedText)
881
+ }
882
+
883
+ internal fun isCurrentTextAuthorizedForEditor(): Boolean =
884
+ (text?.toString() ?: "") == lastAuthorizedText
885
+
886
+ internal fun captureCompositionReplacementRangeIfNeeded() {
887
+ if (didInvalidateCompositionReplacementRange) return
888
+ if (compositionReplacementRange() != null) return
889
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
890
+ setCompositionReplacementRange(start, end)
891
+ }
892
+
893
+ internal fun setCompositionReplacementRange(start: Int, end: Int) {
894
+ if (didInvalidateCompositionReplacementRange) return
895
+ val replacementRange = authorizedUtf16Range(start, end)
896
+ composingReplacementStartUtf16 = replacementRange.first
897
+ composingReplacementEndUtf16 = replacementRange.second
898
+ composingReplacementAuthorizedTextRevision = lastAuthorizedTextRevision
899
+ didInvalidateCompositionReplacementRange = false
900
+ }
901
+
902
+ internal fun compositionReplacementRange(): Pair<Int, Int>? {
903
+ val start = composingReplacementStartUtf16 ?: return null
904
+ val end = composingReplacementEndUtf16 ?: return null
905
+ if (composingReplacementAuthorizedTextRevision != lastAuthorizedTextRevision) {
906
+ clearCompositionTrackingForEditor()
907
+ didInvalidateCompositionReplacementRange = true
908
+ return null
909
+ }
910
+ return start to end
911
+ }
912
+
913
+ private fun authorizedSelectionForTransientInputRestore(
914
+ currentStart: Int,
915
+ currentEnd: Int
916
+ ): Pair<Int, Int>? {
917
+ compositionReplacementRange()?.let { return it }
918
+ return if (
919
+ currentStart >= 0 &&
920
+ currentEnd >= 0 &&
921
+ currentStart <= lastAuthorizedText.length &&
922
+ currentEnd <= lastAuthorizedText.length
923
+ ) {
924
+ currentStart to currentEnd
925
+ } else {
926
+ null
927
+ }
928
+ }
929
+
930
+ internal fun consumeInvalidatedCompositionReplacementRangeForEditor(): Boolean {
931
+ val invalidated = didInvalidateCompositionReplacementRange
932
+ didInvalidateCompositionReplacementRange = false
933
+ return invalidated
934
+ }
935
+
936
+ internal fun hasInvalidatedCompositionReplacementRangeForEditor(): Boolean =
937
+ didInvalidateCompositionReplacementRange
938
+
939
+ internal fun setComposingTextForEditor(text: String?) {
940
+ composingText = text
941
+ }
942
+
943
+ internal fun composingTextForEditor(): String? = composingText
944
+
945
+ internal fun composingTextFromVisibleReplacementForEditor(): String? {
946
+ val (start, end) = compositionReplacementRange() ?: return null
947
+ val authorizedText = lastAuthorizedText
948
+ val currentText = text?.toString() ?: return null
949
+ if (start < 0 || end < start || end > authorizedText.length) return null
950
+
951
+ val authorizedPrefix = authorizedText.substring(0, start)
952
+ val authorizedSuffix = authorizedText.substring(end)
953
+ if (!currentText.startsWith(authorizedPrefix)) return null
954
+ if (!currentText.endsWith(authorizedSuffix)) return null
955
+
956
+ val replacementEnd = currentText.length - authorizedSuffix.length
957
+ if (replacementEnd < authorizedPrefix.length) return null
958
+ return currentText.substring(authorizedPrefix.length, replacementEnd)
959
+ }
960
+
961
+ internal fun clearCompositionTrackingForEditor() {
962
+ composingText = null
963
+ composingReplacementStartUtf16 = null
964
+ composingReplacementEndUtf16 = null
965
+ composingReplacementAuthorizedTextRevision = null
966
+ }
967
+
968
+ private fun hasCompositionTrackingForEditor(): Boolean =
969
+ composingText != null ||
970
+ composingReplacementStartUtf16 != null ||
971
+ composingReplacementEndUtf16 != null ||
972
+ composingReplacementAuthorizedTextRevision != null
973
+
974
+ private fun retireInputConnectionForEditor() {
975
+ activeInputConnection?.clearCompositionTrackingForEditor()
976
+ invalidateInputConnectionsForEditor()
977
+ clearCompositionTrackingForEditor()
978
+ clearCompositionInvalidationForEditor()
979
+ clearNativeComposingSpans()
980
+ }
981
+
982
+ internal fun isEditorDestroyedForInput(): Boolean =
983
+ editorId != 0L && NativeEditorViewRegistry.isDestroyed(editorId)
984
+
985
+ private fun hasLiveEditor(): Boolean =
986
+ editorId != 0L && !isEditorDestroyedForInput()
987
+
988
+ private fun discardTransientInputForDestroyedEditorIfNeeded(): Boolean {
989
+ if (!isEditorDestroyedForInput()) return false
990
+ retireInputConnectionForEditor()
991
+ clearNativeTextMutationAfterBlurWindow()
992
+ clearNativeTextMutationAdoptionSuppression()
993
+ return true
994
+ }
995
+
996
+ private fun discardTransientInputAndRestoreAuthorizedTextForEditor() {
997
+ retireInputConnectionForEditor()
998
+ clearNativeTextMutationAfterBlurWindow()
999
+ restoreAuthorizedTextSnapshotForEditor()
1000
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1001
+ }
1002
+
1003
+ private fun restoreAuthorizedTextSnapshotForEditor() {
1004
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1005
+ val authorizedSnapshot = lastAuthorizedRenderedText ?: lastAuthorizedText
1006
+ val wasApplyingRustState = isApplyingRustState
1007
+ isApplyingRustState = true
1008
+ beginBatchEdit()
1009
+ try {
1010
+ setText(authorizedSnapshot)
1011
+ } finally {
1012
+ endBatchEdit()
1013
+ isApplyingRustState = wasApplyingRustState
1014
+ }
1015
+ }
1016
+
1017
+ private fun restartInputAfterCompositionInvalidationIfNeeded(shouldRestart: Boolean) {
1018
+ if (!shouldRestart) return
1019
+ restartInputForEditorIfFocused()
1020
+ }
1021
+
1022
+ private fun restartInputForEditorIfFocused() {
1023
+ if (!hasFocus()) return
1024
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1025
+ imm?.restartInput(this)
1026
+ }
1027
+
1028
+ private fun restartInputForEditor() {
1029
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1030
+ imm?.restartInput(this)
1031
+ }
1032
+
1033
+ private fun clearCompositionInvalidationForEditor() {
1034
+ didInvalidateCompositionReplacementRange = false
1035
+ }
1036
+
1037
+ private fun nextInputConnectionGenerationForEditor(): Long {
1038
+ inputConnectionGeneration += 1L
1039
+ return inputConnectionGeneration
1040
+ }
1041
+
1042
+ internal fun isInputConnectionCurrentForEditor(
1043
+ boundEditorId: Long,
1044
+ boundGeneration: Long
1045
+ ): Boolean =
1046
+ editorId == boundEditorId &&
1047
+ inputConnectionGeneration == boundGeneration &&
1048
+ !isEditorDestroyedForInput()
1049
+
1050
+ private fun invalidateInputConnectionsForEditor() {
1051
+ inputConnectionGeneration += 1L
1052
+ activeInputConnection = null
1053
+ }
1054
+
1055
+ private fun clearNativeComposingSpans() {
1056
+ val editable = text ?: return
1057
+ BaseInputConnection.removeComposingSpans(editable)
1058
+ }
1059
+
1060
+ internal fun restoreAuthorizedTextIfNeeded() {
1061
+ if (!hasLiveEditor()) return
1062
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1063
+ recordImeTraceForTesting(
1064
+ "restoreAuthorizedText",
1065
+ "authorizedLength=${lastAuthorizedText.length}"
1066
+ )
1067
+ val stateJSON = editorGetCurrentState(editorId.toULong())
1068
+ applyUpdateJSON(stateJSON)
1069
+ }
1070
+
1071
+ fun discardTransientNativeInputForEditorRebind() {
1072
+ retireInputConnectionForEditor()
1073
+ nativeTextMutationAfterBlurWindow = null
1074
+ clearNativeTextMutationAdoptionSuppression()
1075
+ clearImeTraceForTesting()
1076
+ }
1077
+
1078
+ internal fun discardTransientNativeInputForExternalRecovery() {
1079
+ retireInputConnectionForEditor()
1080
+ nativeTextMutationAfterBlurWindow = null
1081
+ restoreAuthorizedTextIfNeeded()
1082
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1083
+ }
1084
+
1085
+ private fun discardTransientNativeInputForReadOnly() {
1086
+ discardTransientNativeInputForExternalRecovery()
1087
+ }
1088
+
1089
+ fun prepareForExternalEditorUpdate(): Boolean {
1090
+ if (blockExternalEditorUpdatePreparationForTesting) return false
1091
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
1092
+ val inputConnection = activeInputConnection
1093
+ if (inputConnection?.flushPendingCompositionForExternalMutation() == false) {
1094
+ return false
1095
+ }
1096
+ return drainNativeTextMutationIfNeeded(allowAfterBlur = true)
1097
+ }
1098
+
1099
+ fun prepareForExternalEditorCommand(): CommandPreparation {
1100
+ if (blockExternalEditorCommandPreparationForTesting) {
1101
+ return CommandPreparation(ready = false, updateJSON = null)
1102
+ }
1103
+ val previousAuthorizedText = lastAuthorizedText
1104
+ if (!prepareForExternalEditorUpdate()) {
1105
+ return CommandPreparation(ready = false, updateJSON = null)
1106
+ }
1107
+ if (!hasLiveEditor() || lastAuthorizedText == previousAuthorizedText) {
1108
+ return CommandPreparation(ready = true, updateJSON = null)
1109
+ }
1110
+ return CommandPreparation(
1111
+ ready = true,
1112
+ updateJSON = editorGetCurrentState(editorId.toULong())
1113
+ )
1114
+ }
1115
+
1116
+ fun handleCompositionCommit(
1117
+ text: String,
1118
+ replacementStartUtf16: Int,
1119
+ replacementEndUtf16: Int,
1120
+ newCursorPosition: Int = 1
1121
+ ) {
732
1122
  if (!isEditable) return
733
1123
  if (isApplyingRustState) return
734
- if (editorId == 0L) return
1124
+ if (!hasLiveEditor()) return
1125
+
1126
+ val authorizedText = lastAuthorizedText
1127
+ val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1128
+ replacementStartUtf16,
1129
+ replacementEndUtf16,
1130
+ authorizedText
1131
+ )
1132
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
1133
+ val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
1134
+
1135
+ if (
1136
+ startUtf16 <= endUtf16 &&
1137
+ endUtf16 <= authorizedText.length &&
1138
+ authorizedText.substring(startUtf16, endUtf16) == text
1139
+ ) {
1140
+ val requestedCursor = requestedCursorScalar(
1141
+ scalarStart,
1142
+ scalarEnd,
1143
+ authorizedText,
1144
+ text,
1145
+ newCursorPosition
1146
+ ) ?: scalarEnd
1147
+ restoreAuthorizedTextIfNeeded()
1148
+ applyRequestedCursorScalar(requestedCursor)
1149
+ return
1150
+ }
735
1151
 
736
1152
  if (text == "\n") {
737
- handleReturnKey()
1153
+ if (scalarStart != scalarEnd) {
1154
+ deleteAndSplitInRust(scalarStart, scalarEnd)
1155
+ } else {
1156
+ splitBlockInRust(scalarStart)
1157
+ }
738
1158
  return
739
1159
  }
740
1160
 
1161
+ insertPlainTextRangeInRust(
1162
+ scalarStart,
1163
+ scalarEnd,
1164
+ text,
1165
+ requestedCursorScalar = requestedCursorScalar(
1166
+ scalarStart,
1167
+ scalarEnd,
1168
+ authorizedText,
1169
+ text,
1170
+ newCursorPosition
1171
+ )
1172
+ )
1173
+ }
1174
+
1175
+ fun handleCorrectionCommit(
1176
+ offsetUtf16: Int,
1177
+ oldText: String,
1178
+ newText: String
1179
+ ): Boolean {
1180
+ if (!isEditable) return true
1181
+ if (isApplyingRustState) return true
1182
+ if (!hasLiveEditor()) return false
1183
+
741
1184
  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)
1185
+ if (offsetUtf16 < 0) {
1186
+ recordImeTraceForTesting(
1187
+ "correctionExplicitNoop",
1188
+ "reason=invalidOffset offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1189
+ )
1190
+ return false
1191
+ }
1192
+ val endUtf16 = offsetUtf16 + oldText.length
1193
+ if (endUtf16 < offsetUtf16 || endUtf16 > authorizedText.length) {
1194
+ recordImeTraceForTesting(
1195
+ "correctionExplicitNoop",
1196
+ "reason=outOfBounds offset=$offsetUtf16 oldLength=${oldText.length} authorizedLength=${authorizedText.length}"
1197
+ )
1198
+ return false
1199
+ }
1200
+ if (authorizedText.substring(offsetUtf16, endUtf16) != oldText) {
1201
+ recordImeTraceForTesting(
1202
+ "correctionExplicitNoop",
1203
+ "reason=staleText offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1204
+ )
1205
+ return false
1206
+ }
1207
+
1208
+ val (startUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1209
+ offsetUtf16,
1210
+ endUtf16,
1211
+ authorizedText
1212
+ )
1213
+ if (
1214
+ startUtf16 != offsetUtf16 ||
1215
+ snappedEndUtf16 != endUtf16 ||
1216
+ startUtf16 > snappedEndUtf16
1217
+ ) {
1218
+ recordImeTraceForTesting(
1219
+ "correctionExplicitNoop",
1220
+ "reason=unsnappedScalarBoundary range=$offsetUtf16..$endUtf16 snapped=$startUtf16..$snappedEndUtf16"
1221
+ )
1222
+ return false
1223
+ }
1224
+
746
1225
  val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
747
- val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
1226
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1227
+ recordImeTraceForTesting(
1228
+ "correctionExplicitApply",
1229
+ "range=$scalarStart..$scalarEnd newLength=${newText.length}"
1230
+ )
1231
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1232
+ return true
1233
+ }
748
1234
 
749
- if (scalarStart != scalarEnd) {
750
- replaceTextRangeInRust(scalarStart, scalarEnd, text)
751
- } else {
752
- insertTextInRust(text, scalarStart)
1235
+ fun handleMissingOldTextCorrectionCommit(
1236
+ offsetUtf16: Int,
1237
+ newText: String
1238
+ ): Boolean {
1239
+ if (!isEditable) return true
1240
+ if (isApplyingRustState) return true
1241
+ if (!hasLiveEditor()) return false
1242
+
1243
+ val authorizedText = lastAuthorizedText
1244
+ val tokenRange = missingOldTextCorrectionTokenRange(authorizedText, offsetUtf16)
1245
+ ?: run {
1246
+ recordImeTraceForTesting(
1247
+ "correctionInferredNoop",
1248
+ "reason=noToken offset=$offsetUtf16 newLength=${newText.length}"
1249
+ )
1250
+ return false
1251
+ }
1252
+ val (startUtf16, endUtf16) = tokenRange
1253
+
1254
+ val (snappedStartUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1255
+ startUtf16,
1256
+ endUtf16,
1257
+ authorizedText
1258
+ )
1259
+ if (snappedStartUtf16 >= snappedEndUtf16) {
1260
+ recordImeTraceForTesting(
1261
+ "correctionInferredNoop",
1262
+ "reason=emptySnappedRange token=$startUtf16..$endUtf16 snapped=$snappedStartUtf16..$snappedEndUtf16"
1263
+ )
1264
+ return false
1265
+ }
1266
+
1267
+ val scalarStart = PositionBridge.utf16ToScalar(snappedStartUtf16, authorizedText)
1268
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1269
+ recordImeTraceForTesting(
1270
+ "correctionInferredApply",
1271
+ "range=$scalarStart..$scalarEnd utf16=$snappedStartUtf16..$snappedEndUtf16 newLength=${newText.length}"
1272
+ )
1273
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1274
+ return true
1275
+ }
1276
+
1277
+ private fun missingOldTextCorrectionTokenRange(
1278
+ text: String,
1279
+ offsetUtf16: Int
1280
+ ): Pair<Int, Int>? {
1281
+ if (offsetUtf16 < 0 || offsetUtf16 >= text.length) return null
1282
+
1283
+ val tokenOffset = PositionBridge.snapToScalarBoundary(
1284
+ offsetUtf16,
1285
+ text,
1286
+ biasForward = false
1287
+ )
1288
+ if (tokenOffset < 0 || tokenOffset >= text.length) return null
1289
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, tokenOffset)) return null
1290
+
1291
+ var startUtf16 = tokenOffset
1292
+ while (startUtf16 > 0) {
1293
+ val previousUtf16 = Character.offsetByCodePoints(text, startUtf16, -1)
1294
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, previousUtf16)) break
1295
+ startUtf16 = previousUtf16
1296
+ }
1297
+
1298
+ var endUtf16 = tokenOffset + Character.charCount(Character.codePointAt(text, tokenOffset))
1299
+ while (endUtf16 < text.length) {
1300
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, endUtf16)) break
1301
+ endUtf16 += Character.charCount(Character.codePointAt(text, endUtf16))
1302
+ }
1303
+
1304
+ return if (startUtf16 < endUtf16) startUtf16 to endUtf16 else null
1305
+ }
1306
+
1307
+ private fun isMissingOldTextCorrectionTokenCodePointAt(text: String, utf16Offset: Int): Boolean {
1308
+ if (utf16Offset < 0 || utf16Offset >= text.length) return false
1309
+ val codePoint = Character.codePointAt(text, utf16Offset)
1310
+ if (isMissingOldTextCorrectionCoreTokenCodePoint(codePoint)) return true
1311
+ if (!isMissingOldTextCorrectionJoinerCodePoint(codePoint)) return false
1312
+
1313
+ val previousCodePoint = previousCodePointBefore(text, utf16Offset) ?: return false
1314
+ val nextUtf16Offset = utf16Offset + Character.charCount(codePoint)
1315
+ val nextCodePoint = nextCodePointAt(text, nextUtf16Offset) ?: return false
1316
+ return isMissingOldTextCorrectionCoreTokenCodePoint(previousCodePoint) &&
1317
+ isMissingOldTextCorrectionCoreTokenCodePoint(nextCodePoint)
1318
+ }
1319
+
1320
+ private fun isMissingOldTextCorrectionCoreTokenCodePoint(codePoint: Int): Boolean {
1321
+ if (Character.isLetterOrDigit(codePoint)) return true
1322
+ return when (Character.getType(codePoint)) {
1323
+ Character.NON_SPACING_MARK.toInt(),
1324
+ Character.COMBINING_SPACING_MARK.toInt(),
1325
+ Character.ENCLOSING_MARK.toInt(),
1326
+ Character.CONNECTOR_PUNCTUATION.toInt(),
1327
+ Character.MATH_SYMBOL.toInt(),
1328
+ Character.CURRENCY_SYMBOL.toInt(),
1329
+ Character.MODIFIER_SYMBOL.toInt(),
1330
+ Character.OTHER_SYMBOL.toInt(),
1331
+ Character.SURROGATE.toInt() -> true
1332
+ else -> false
753
1333
  }
754
1334
  }
755
1335
 
1336
+ private fun isMissingOldTextCorrectionJoinerCodePoint(codePoint: Int): Boolean =
1337
+ codePoint == '\''.code ||
1338
+ codePoint == 0x2018 ||
1339
+ codePoint == 0x2019 ||
1340
+ codePoint == 0x201B ||
1341
+ codePoint == 0xFF07 ||
1342
+ codePoint == '-'.code ||
1343
+ codePoint == 0x2010 ||
1344
+ codePoint == 0x2011 ||
1345
+ codePoint == 0x2012 ||
1346
+ codePoint == 0x2013 ||
1347
+ codePoint == 0x2014 ||
1348
+ codePoint == 0x2212 ||
1349
+ codePoint == 0x200D
1350
+
1351
+ private fun previousCodePointBefore(text: String, utf16Offset: Int): Int? {
1352
+ if (utf16Offset <= 0 || utf16Offset > text.length) return null
1353
+ val previousUtf16 = Character.offsetByCodePoints(text, utf16Offset, -1)
1354
+ return Character.codePointAt(text, previousUtf16)
1355
+ }
1356
+
1357
+ private fun nextCodePointAt(text: String, utf16Offset: Int): Int? {
1358
+ if (utf16Offset < 0 || utf16Offset >= text.length) return null
1359
+ return Character.codePointAt(text, utf16Offset)
1360
+ }
1361
+
756
1362
  // ── Input Handling: Deletion ────────────────────────────────────────
757
1363
 
758
1364
  /**
@@ -766,17 +1372,32 @@ class EditorEditText @JvmOverloads constructor(
766
1372
  fun handleDelete(beforeLength: Int, afterLength: Int) {
767
1373
  if (!isEditable) return
768
1374
  if (isApplyingRustState) return
1375
+ val selectionRange = normalizedUtf16SelectionRange()
769
1376
  if (editorId == 0L) {
770
1377
  // Dev mode: direct editing.
771
1378
  val editable = this.text ?: return
772
- val cursor = selectionStart
773
- val delStart = maxOf(0, cursor - beforeLength)
774
- val delEnd = minOf(editable.length, cursor + afterLength)
1379
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1380
+ val delStart: Int
1381
+ val delEnd: Int
1382
+ if (selectionStart != selectionEnd) {
1383
+ delStart = selectionStart
1384
+ delEnd = selectionEnd
1385
+ } else {
1386
+ delStart = maxOf(0, selectionStart - beforeLength.coerceAtLeast(0))
1387
+ delEnd = minOf(editable.length, selectionStart + afterLength.coerceAtLeast(0))
1388
+ }
775
1389
  editable.delete(delStart, delEnd)
776
1390
  return
777
1391
  }
1392
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
778
1393
 
779
1394
  val currentText = text?.toString() ?: ""
1395
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1396
+ if (selectionStart != selectionEnd) {
1397
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1398
+ deleteRangeInRust(scalarStart, scalarEnd)
1399
+ return
1400
+ }
780
1401
  val cursor = selectionStart
781
1402
  if (beforeLength > 0 &&
782
1403
  afterLength == 0 &&
@@ -787,8 +1408,13 @@ class EditorEditText @JvmOverloads constructor(
787
1408
  deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
788
1409
  return
789
1410
  }
790
- val delStart = maxOf(0, cursor - beforeLength)
791
- val delEnd = minOf(currentText.length, cursor + afterLength)
1411
+ val rawDelStart = maxOf(0, cursor - beforeLength.coerceAtLeast(0))
1412
+ val rawDelEnd = minOf(currentText.length, cursor + afterLength.coerceAtLeast(0))
1413
+ val (delStart, delEnd) = PositionBridge.snapRangeToScalarBoundaries(
1414
+ rawDelStart,
1415
+ rawDelEnd,
1416
+ currentText
1417
+ )
792
1418
 
793
1419
  val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
794
1420
  val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
@@ -809,11 +1435,11 @@ class EditorEditText @JvmOverloads constructor(
809
1435
  fun handleBackspace() {
810
1436
  if (!isEditable) return
811
1437
  if (isApplyingRustState) return
1438
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
812
1439
  if (editorId == 0L) {
813
1440
  // Dev mode: direct editing.
814
1441
  val editable = this.text ?: return
815
- val start = selectionStart
816
- val end = selectionEnd
1442
+ val (start, end) = selectionRange
817
1443
  if (start != end) {
818
1444
  editable.delete(start, end)
819
1445
  } else if (start > 0) {
@@ -824,15 +1450,14 @@ class EditorEditText @JvmOverloads constructor(
824
1450
  }
825
1451
  return
826
1452
  }
1453
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
827
1454
 
828
1455
  val currentText = text?.toString() ?: ""
829
- val start = selectionStart
830
- val end = selectionEnd
1456
+ val (start, end) = selectionRange
831
1457
 
832
1458
  if (start != end) {
833
1459
  // Range selection: delete the range.
834
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
835
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
1460
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
836
1461
  deleteRangeInRust(scalarStart, scalarEnd)
837
1462
  } else if (start > 0) {
838
1463
  if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
@@ -859,6 +1484,57 @@ class EditorEditText @JvmOverloads constructor(
859
1484
  }
860
1485
  }
861
1486
 
1487
+ fun handleForwardDelete() {
1488
+ if (!isEditable) return
1489
+ if (isApplyingRustState) return
1490
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
1491
+ if (editorId == 0L) {
1492
+ val editable = this.text ?: return
1493
+ val (start, end) = selectionRange
1494
+ if (start != end) {
1495
+ editable.delete(start, end)
1496
+ } else if (start < editable.length) {
1497
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
1498
+ breakIter.setText(editable.toString())
1499
+ val nextBoundary = breakIter.following(start)
1500
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
1501
+ editable.length
1502
+ } else {
1503
+ nextBoundary
1504
+ }
1505
+ editable.delete(start, nextUtf16.coerceIn(start, editable.length))
1506
+ }
1507
+ return
1508
+ }
1509
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1510
+
1511
+ val currentText = text?.toString() ?: ""
1512
+ val (start, end) = selectionRange
1513
+ if (start != end) {
1514
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1515
+ deleteRangeInRust(scalarStart, scalarEnd)
1516
+ } else if (start < currentText.length) {
1517
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
1518
+ breakIter.setText(currentText)
1519
+ val nextBoundary = breakIter.following(start)
1520
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
1521
+ currentText.length
1522
+ } else {
1523
+ nextBoundary
1524
+ }
1525
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
1526
+ start,
1527
+ nextUtf16.coerceIn(start, currentText.length),
1528
+ currentText
1529
+ )
1530
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
1531
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
1532
+ if (scalarStart < scalarEnd) {
1533
+ deleteRangeInRust(scalarStart, scalarEnd)
1534
+ }
1535
+ }
1536
+ }
1537
+
862
1538
  // ── Input Handling: Return Key ──────────────────────────────────────
863
1539
 
864
1540
  /**
@@ -869,8 +1545,7 @@ class EditorEditText @JvmOverloads constructor(
869
1545
  if (isApplyingRustState) return
870
1546
 
871
1547
  val currentText = text?.toString() ?: ""
872
- val start = selectionStart
873
- val end = selectionEnd
1548
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
874
1549
 
875
1550
  if (editorId == 0L) {
876
1551
  // Dev mode: insert newline directly.
@@ -878,15 +1553,12 @@ class EditorEditText @JvmOverloads constructor(
878
1553
  editable.replace(start, end, "\n")
879
1554
  return
880
1555
  }
1556
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
881
1557
 
882
1558
  if (start != end) {
883
1559
  // 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)
1560
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1561
+ deleteAndSplitInRust(scalarStart, scalarEnd)
890
1562
  } else {
891
1563
  val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
892
1564
  splitBlockInRust(scalarPos)
@@ -907,6 +1579,7 @@ class EditorEditText @JvmOverloads constructor(
907
1579
  editable.replace(start, end, "\n")
908
1580
  return
909
1581
  }
1582
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
910
1583
 
911
1584
  val selection = currentScalarSelection() ?: return
912
1585
  val updateJSON = editorInsertNodeAtSelectionScalar(
@@ -924,7 +1597,7 @@ class EditorEditText @JvmOverloads constructor(
924
1597
  fun handleTab(shiftPressed: Boolean): Boolean {
925
1598
  if (!isEditable) return false
926
1599
  if (isApplyingRustState) return false
927
- if (editorId == 0L) return false
1600
+ if (!hasLiveEditor()) return false
928
1601
  if (!isSelectionInsideList()) return false
929
1602
  val selection = currentScalarSelection() ?: return false
930
1603
 
@@ -952,6 +1625,10 @@ class EditorEditText @JvmOverloads constructor(
952
1625
  handleBackspace()
953
1626
  true
954
1627
  }
1628
+ KeyEvent.KEYCODE_FORWARD_DEL -> {
1629
+ handleForwardDelete()
1630
+ true
1631
+ }
955
1632
  KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
956
1633
  if (shiftPressed) {
957
1634
  handleHardBreak()
@@ -965,28 +1642,55 @@ class EditorEditText @JvmOverloads constructor(
965
1642
  }
966
1643
  }
967
1644
 
1645
+ private fun isSupportedHardwareMutationKey(keyCode: Int): Boolean =
1646
+ when (keyCode) {
1647
+ KeyEvent.KEYCODE_DEL,
1648
+ KeyEvent.KEYCODE_FORWARD_DEL,
1649
+ KeyEvent.KEYCODE_ENTER,
1650
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
1651
+ KeyEvent.KEYCODE_TAB -> true
1652
+ else -> false
1653
+ }
1654
+
1655
+ internal fun isReadOnlyTextMutationKeyEvent(event: KeyEvent): Boolean {
1656
+ if (isSupportedHardwareMutationKey(event.keyCode) ||
1657
+ event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL
1658
+ ) {
1659
+ return true
1660
+ }
1661
+ if (event.keyCode == KeyEvent.KEYCODE_INSERT && event.isShiftPressed) {
1662
+ return true
1663
+ }
1664
+ if (event.isCtrlPressed || event.isMetaPressed) {
1665
+ return when (event.keyCode) {
1666
+ KeyEvent.KEYCODE_V,
1667
+ KeyEvent.KEYCODE_X,
1668
+ KeyEvent.KEYCODE_Z,
1669
+ KeyEvent.KEYCODE_Y -> true
1670
+ else -> false
1671
+ }
1672
+ }
1673
+ if (!keyEventCharacters(event).isNullOrEmpty()) return true
1674
+ return event.unicodeChar != 0
1675
+ }
1676
+
968
1677
  fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
969
1678
  if (event == null || !isEditable || isApplyingRustState) return false
970
1679
 
971
1680
  return when (event.action) {
972
1681
  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
1682
+ if (!isSupportedHardwareMutationKey(event.keyCode)) return false
981
1683
 
982
- if (lastHandledHardwareKeyCode == event.keyCode &&
983
- lastHandledHardwareKeyDownTime == event.downTime) {
1684
+ val signature = hardwareKeyEventSignature(event)
1685
+ if (
1686
+ lastHandledHardwareKeySignature == signature ||
1687
+ didRecentlyHandleHardwareKeyDown(signature)
1688
+ ) {
984
1689
  return true
985
1690
  }
986
1691
 
987
1692
  if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
988
- lastHandledHardwareKeyCode = event.keyCode
989
- lastHandledHardwareKeyDownTime = event.downTime
1693
+ markHandledHardwareKeyDown(signature)
990
1694
  true
991
1695
  } else {
992
1696
  false
@@ -994,10 +1698,10 @@ class EditorEditText @JvmOverloads constructor(
994
1698
  }
995
1699
 
996
1700
  KeyEvent.ACTION_UP -> {
997
- if (lastHandledHardwareKeyCode == event.keyCode &&
998
- lastHandledHardwareKeyDownTime == event.downTime) {
999
- lastHandledHardwareKeyCode = null
1000
- lastHandledHardwareKeyDownTime = null
1701
+ if (lastHandledHardwareKeySignature?.let {
1702
+ it.keyCode == event.keyCode && it.downTime == event.downTime
1703
+ } == true) {
1704
+ lastHandledHardwareKeySignature = null
1001
1705
  true
1002
1706
  } else {
1003
1707
  false
@@ -1008,8 +1712,119 @@ class EditorEditText @JvmOverloads constructor(
1008
1712
  }
1009
1713
  }
1010
1714
 
1715
+ internal fun handlePrintableHardwareKeyEvent(
1716
+ event: KeyEvent,
1717
+ applyBaseEvent: () -> Boolean
1718
+ ): Boolean {
1719
+ if (!isEditable || isApplyingRustState || !isPrintableHardwareMutationKey(event)) {
1720
+ return false
1721
+ }
1722
+ val signature = hardwareKeyEventSignature(event)
1723
+ return when (event.action) {
1724
+ KeyEvent.ACTION_DOWN -> {
1725
+ if (
1726
+ lastHandledHardwareKeySignature == signature ||
1727
+ didRecentlyHandleHardwareKeyDown(signature)
1728
+ ) {
1729
+ true
1730
+ } else {
1731
+ val inputConnection = activeInputConnection?.takeIf {
1732
+ it.hasPendingComposition()
1733
+ }
1734
+ if (inputConnection != null) {
1735
+ var didMutate = false
1736
+ runWithTransientInputMutationGuard {
1737
+ didMutate = insertTransientHardwareText(keyEventText(event))
1738
+ didMutate
1739
+ }
1740
+ if (!didMutate) return false
1741
+ inputConnection.refreshComposingTextFromEditableForEditor()
1742
+ } else {
1743
+ applyBaseEvent()
1744
+ }
1745
+ markHandledHardwareKeyDown(signature)
1746
+ true
1747
+ }
1748
+ }
1749
+ KeyEvent.ACTION_UP -> {
1750
+ if (lastHandledHardwareKeySignature?.let {
1751
+ it.keyCode == event.keyCode && it.downTime == event.downTime
1752
+ } == true) {
1753
+ lastHandledHardwareKeySignature = null
1754
+ }
1755
+ false
1756
+ }
1757
+ else -> false
1758
+ }
1759
+ }
1760
+
1761
+ private fun isPrintableHardwareMutationKey(event: KeyEvent): Boolean {
1762
+ if (isSupportedHardwareMutationKey(event.keyCode)) return false
1763
+ if (event.isCtrlPressed || event.isMetaPressed) return false
1764
+ return !keyEventText(event).isNullOrEmpty()
1765
+ }
1766
+
1767
+ private fun hardwareKeyEventSignature(event: KeyEvent): HardwareKeyEventSignature =
1768
+ HardwareKeyEventSignature(
1769
+ keyCode = event.keyCode,
1770
+ downTime = event.downTime,
1771
+ repeatCount = event.repeatCount
1772
+ )
1773
+
1774
+ @Suppress("DEPRECATION")
1775
+ private fun keyEventCharacters(event: KeyEvent): String? = event.characters
1776
+
1777
+ private fun keyEventText(event: KeyEvent): String? {
1778
+ val characters = keyEventCharacters(event)
1779
+ if (!characters.isNullOrEmpty()) return characters
1780
+ val unicodeChar = event.unicodeChar
1781
+ if (unicodeChar == 0) return null
1782
+ return runCatching {
1783
+ String(Character.toChars(unicodeChar))
1784
+ }.getOrNull()
1785
+ }
1786
+
1787
+ private fun insertTransientHardwareText(insertedText: String?): Boolean {
1788
+ if (insertedText.isNullOrEmpty()) return false
1789
+ val editable = text ?: return false
1790
+ val currentText = editable.toString()
1791
+ val rawStart = selectionStart
1792
+ val rawEnd = selectionEnd
1793
+ if (rawStart < 0 || rawEnd < 0) return false
1794
+ val start = rawStart.coerceIn(0, editable.length)
1795
+ val end = rawEnd.coerceIn(0, editable.length)
1796
+ val normalizedStart = minOf(start, end)
1797
+ val normalizedEnd = maxOf(start, end)
1798
+ val (replaceStart, replaceEnd) = PositionBridge.snapRangeToScalarBoundaries(
1799
+ normalizedStart,
1800
+ normalizedEnd,
1801
+ currentText
1802
+ )
1803
+ editable.replace(replaceStart, replaceEnd, insertedText)
1804
+ val cursor = (replaceStart + insertedText.length).coerceIn(0, editable.length)
1805
+ setSelection(cursor)
1806
+ return true
1807
+ }
1808
+
1809
+ private fun markHandledHardwareKeyDown(signature: HardwareKeyEventSignature) {
1810
+ lastHandledHardwareKeySignature = signature
1811
+ recentHandledHardwareKeyDownSignature = signature
1812
+ recentHandledHardwareKeyDownUptimeMs = SystemClock.uptimeMillis()
1813
+ }
1814
+
1815
+ private fun didRecentlyHandleHardwareKeyDown(signature: HardwareKeyEventSignature): Boolean {
1816
+ val recentSignature = recentHandledHardwareKeyDownSignature ?: return false
1817
+ val elapsedMs = SystemClock.uptimeMillis() - recentHandledHardwareKeyDownUptimeMs
1818
+ if (elapsedMs > RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS) {
1819
+ recentHandledHardwareKeyDownSignature = null
1820
+ recentHandledHardwareKeyDownUptimeMs = 0L
1821
+ return false
1822
+ }
1823
+ return recentSignature == signature
1824
+ }
1825
+
1011
1826
  fun performToolbarToggleMark(markName: String) {
1012
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1827
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1013
1828
  val selection = currentScalarSelection() ?: return
1014
1829
  val updateJSON = editorToggleMarkAtSelectionScalar(
1015
1830
  editorId.toULong(),
@@ -1021,7 +1836,7 @@ class EditorEditText @JvmOverloads constructor(
1021
1836
  }
1022
1837
 
1023
1838
  fun performToolbarToggleList(listType: String, isActive: Boolean) {
1024
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1839
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1025
1840
  val selection = currentScalarSelection() ?: return
1026
1841
  val updateJSON = if (isActive) {
1027
1842
  editorUnwrapFromListAtSelectionScalar(
@@ -1041,7 +1856,7 @@ class EditorEditText @JvmOverloads constructor(
1041
1856
  }
1042
1857
 
1043
1858
  fun performToolbarToggleBlockquote() {
1044
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1859
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1045
1860
  val selection = currentScalarSelection() ?: return
1046
1861
  val updateJSON = editorToggleBlockquoteAtSelectionScalar(
1047
1862
  editorId.toULong(),
@@ -1052,7 +1867,7 @@ class EditorEditText @JvmOverloads constructor(
1052
1867
  }
1053
1868
 
1054
1869
  fun performToolbarToggleHeading(level: Int) {
1055
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1870
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1056
1871
  if (level !in 1..6) return
1057
1872
  val selection = currentScalarSelection() ?: return
1058
1873
  val updateJSON = editorToggleHeadingAtSelectionScalar(
@@ -1065,7 +1880,7 @@ class EditorEditText @JvmOverloads constructor(
1065
1880
  }
1066
1881
 
1067
1882
  fun performToolbarIndentListItem() {
1068
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1883
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1069
1884
  val selection = currentScalarSelection() ?: return
1070
1885
  val updateJSON = editorIndentListItemAtSelectionScalar(
1071
1886
  editorId.toULong(),
@@ -1076,7 +1891,7 @@ class EditorEditText @JvmOverloads constructor(
1076
1891
  }
1077
1892
 
1078
1893
  fun performToolbarOutdentListItem() {
1079
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1894
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1080
1895
  val selection = currentScalarSelection() ?: return
1081
1896
  val updateJSON = editorOutdentListItemAtSelectionScalar(
1082
1897
  editorId.toULong(),
@@ -1087,7 +1902,7 @@ class EditorEditText @JvmOverloads constructor(
1087
1902
  }
1088
1903
 
1089
1904
  fun performToolbarInsertNode(nodeType: String) {
1090
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1905
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1091
1906
  val selection = currentScalarSelection() ?: return
1092
1907
  val updateJSON = editorInsertNodeAtSelectionScalar(
1093
1908
  editorId.toULong(),
@@ -1099,12 +1914,12 @@ class EditorEditText @JvmOverloads constructor(
1099
1914
  }
1100
1915
 
1101
1916
  fun performToolbarUndo() {
1102
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1917
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1103
1918
  applyUpdateJSON(editorUndo(editorId.toULong()))
1104
1919
  }
1105
1920
 
1106
1921
  fun performToolbarRedo() {
1107
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1922
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1108
1923
  applyUpdateJSON(editorRedo(editorId.toULong()))
1109
1924
  }
1110
1925
 
@@ -1117,32 +1932,52 @@ class EditorEditText @JvmOverloads constructor(
1117
1932
  * falling back to plain text.
1118
1933
  */
1119
1934
  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()
1935
+ if (!isEditable && isMutatingContextMenuItem(id)) return true
1936
+ if (id == android.R.id.cut) {
1937
+ handleCut()
1938
+ return true
1939
+ }
1940
+ if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
1941
+ handlePaste(plainTextOnly = id == android.R.id.pasteAsPlainText)
1123
1942
  return true
1124
1943
  }
1125
1944
  return super.onTextContextMenuItem(id)
1126
1945
  }
1127
1946
 
1947
+ private fun isMutatingContextMenuItem(id: Int): Boolean =
1948
+ id == android.R.id.paste ||
1949
+ id == android.R.id.pasteAsPlainText ||
1950
+ id == android.R.id.cut
1951
+
1128
1952
  /**
1129
- * Block accessibility-initiated text mutations (paste, set text) when not editable.
1953
+ * Block accessibility-initiated text mutations (paste, cut, set text) when not editable.
1130
1954
  * Selection and copy actions remain available.
1131
1955
  */
1132
1956
  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)) {
1957
+ if (!isEditable && (
1958
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT ||
1959
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE ||
1960
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_CUT
1961
+ )
1962
+ ) {
1135
1963
  return false
1136
1964
  }
1965
+ if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT) {
1966
+ return handleAccessibilitySetText(arguments)
1967
+ }
1137
1968
  return super.performAccessibilityAction(action, arguments)
1138
1969
  }
1139
1970
 
1140
- private fun handlePaste() {
1971
+ private fun handlePaste(plainTextOnly: Boolean) {
1141
1972
  if (editorId == 0L) {
1142
1973
  // Dev mode: default paste behavior.
1143
- super.onTextContextMenuItem(android.R.id.paste)
1974
+ super.onTextContextMenuItem(
1975
+ if (plainTextOnly) android.R.id.pasteAsPlainText else android.R.id.paste
1976
+ )
1144
1977
  return
1145
1978
  }
1979
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1980
+ if (!prepareForExternalEditorUpdate()) return
1146
1981
 
1147
1982
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
1148
1983
  ?: return
@@ -1153,18 +1988,69 @@ class EditorEditText @JvmOverloads constructor(
1153
1988
 
1154
1989
  // Try HTML first for rich paste.
1155
1990
  val htmlText = item.htmlText
1156
- if (htmlText != null) {
1991
+ if (!plainTextOnly && htmlText != null) {
1157
1992
  pasteHTML(htmlText)
1158
1993
  return
1159
1994
  }
1160
1995
 
1161
1996
  // Fallback to plain text.
1162
- val plainText = item.text?.toString()
1997
+ val plainText = item.text?.toString() ?: item.coerceToText(context)?.toString()
1163
1998
  if (plainText != null) {
1164
1999
  pastePlainText(plainText)
1165
2000
  }
1166
2001
  }
1167
2002
 
2003
+ private fun handleCut() {
2004
+ if (editorId == 0L) {
2005
+ super.onTextContextMenuItem(android.R.id.cut)
2006
+ return
2007
+ }
2008
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2009
+ if (!prepareForExternalEditorUpdate()) return
2010
+
2011
+ val currentText = text?.toString() ?: return
2012
+ val (selectionStart, selectionEnd) = normalizedUtf16SelectionRange(currentText) ?: return
2013
+ if (selectionStart == selectionEnd) return
2014
+
2015
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
2016
+ selectionStart,
2017
+ selectionEnd,
2018
+ currentText
2019
+ )
2020
+ if (utf16Start >= utf16End) return
2021
+
2022
+ val selectedText = currentText.substring(utf16Start, utf16End)
2023
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
2024
+ clipboard?.setPrimaryClip(ClipData.newPlainText(null, selectedText))
2025
+
2026
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
2027
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
2028
+ deleteRangeInRust(scalarStart, scalarEnd)
2029
+ }
2030
+
2031
+ private fun handleAccessibilitySetText(arguments: android.os.Bundle?): Boolean {
2032
+ val replacement = arguments
2033
+ ?.getCharSequence(
2034
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
2035
+ )
2036
+ ?.toString()
2037
+ ?: return false
2038
+ if (editorId == 0L) {
2039
+ return super.performAccessibilityAction(
2040
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT,
2041
+ arguments
2042
+ )
2043
+ }
2044
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
2045
+ if (!prepareForExternalEditorUpdate()) return false
2046
+
2047
+ val currentText = text?.toString() ?: return false
2048
+ val scalarStart = 0
2049
+ val scalarEnd = currentText.codePointCount(0, currentText.length)
2050
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, replacement)
2051
+ return true
2052
+ }
2053
+
1168
2054
  // ── Selection Change ────────────────────────────────────────────────
1169
2055
 
1170
2056
  /**
@@ -1183,23 +2069,35 @@ class EditorEditText @JvmOverloads constructor(
1183
2069
  ensureSelectionVisible()
1184
2070
  onSelectionOrContentMayChange?.invoke()
1185
2071
 
1186
- if (editorId == 0L) return
2072
+ syncCurrentSelectionToRust()
2073
+ }
2074
+
2075
+ private fun syncCurrentSelectionToRust() {
2076
+ if (!hasLiveEditor()) return
1187
2077
 
1188
2078
  val currentText = text?.toString() ?: ""
1189
2079
  if (currentText != lastAuthorizedText) return
1190
- val scalarAnchor = PositionBridge.utf16ToScalar(selStart, currentText)
1191
- val scalarHead = PositionBridge.utf16ToScalar(selEnd, currentText)
1192
-
1193
- // Sync selection to Rust (converts scalar→doc internally).
1194
- editorSetSelectionScalar(
1195
- editorId.toULong(),
1196
- scalarAnchor.toUInt(),
1197
- scalarHead.toUInt()
1198
- )
2080
+ val (scalarAnchor, scalarHead) = rawScalarSelection(currentText) ?: return
2081
+
2082
+ val selectionHook = onSetSelectionScalarInRustForTesting
2083
+ val docAnchor: Int
2084
+ val docHead: Int
2085
+ if (selectionHook != null) {
2086
+ selectionHook(scalarAnchor, scalarHead)
2087
+ docAnchor = scalarAnchor
2088
+ docHead = scalarHead
2089
+ } else {
2090
+ // Sync selection to Rust (converts scalar→doc internally).
2091
+ editorSetSelectionScalar(
2092
+ editorId.toULong(),
2093
+ scalarAnchor.toUInt(),
2094
+ scalarHead.toUInt()
2095
+ )
1199
2096
 
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()
2097
+ // Emit doc positions (not scalar offsets) to match the Selection contract.
2098
+ docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
2099
+ docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
2100
+ }
1203
2101
  editorListener?.onSelectionChanged(docAnchor, docHead)
1204
2102
  }
1205
2103
 
@@ -1209,6 +2107,7 @@ class EditorEditText @JvmOverloads constructor(
1209
2107
  * Insert text at a scalar position via the Rust editor.
1210
2108
  */
1211
2109
  private fun insertTextInRust(text: String, atScalarPos: Int) {
2110
+ if (!hasLiveEditor()) return
1212
2111
  onInsertTextInRustForTesting?.let { callback ->
1213
2112
  callback(text, atScalarPos)
1214
2113
  return
@@ -1218,6 +2117,7 @@ class EditorEditText @JvmOverloads constructor(
1218
2117
  }
1219
2118
 
1220
2119
  private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
2120
+ if (!hasLiveEditor()) return
1221
2121
  onReplaceTextInRustForTesting?.let { callback ->
1222
2122
  callback(scalarFrom, scalarTo, text)
1223
2123
  return
@@ -1231,6 +2131,109 @@ class EditorEditText @JvmOverloads constructor(
1231
2131
  applyUpdateJSON(updateJSON)
1232
2132
  }
1233
2133
 
2134
+ private fun insertPlainTextRangeInRust(
2135
+ scalarFrom: Int,
2136
+ scalarTo: Int,
2137
+ text: String,
2138
+ requestedCursorScalar: Int? = null
2139
+ ) {
2140
+ if (!hasLiveEditor()) return
2141
+ if (text.isEmpty()) {
2142
+ if (scalarFrom != scalarTo) {
2143
+ deleteRangeInRust(scalarFrom, scalarTo)
2144
+ }
2145
+ applyRequestedCursorScalar(requestedCursorScalar)
2146
+ return
2147
+ }
2148
+ if (text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
2149
+ val docJson = plainTextDocumentFragmentJson(text)
2150
+ onInsertContentJsonAtSelectionScalarForTesting?.let { callback ->
2151
+ callback(scalarFrom, scalarTo, docJson)
2152
+ applyRequestedCursorScalar(requestedCursorScalar)
2153
+ return
2154
+ }
2155
+ val updateJSON = editorInsertContentJsonAtSelectionScalar(
2156
+ editorId.toULong(),
2157
+ scalarFrom.toUInt(),
2158
+ scalarTo.toUInt(),
2159
+ docJson
2160
+ )
2161
+ applyUpdateJSON(updateJSON)
2162
+ applyRequestedCursorScalar(requestedCursorScalar)
2163
+ return
2164
+ }
2165
+
2166
+ if (scalarFrom != scalarTo) {
2167
+ replaceTextRangeInRust(scalarFrom, scalarTo, text)
2168
+ } else {
2169
+ insertTextInRust(text, scalarFrom)
2170
+ }
2171
+ applyRequestedCursorScalar(requestedCursorScalar)
2172
+ }
2173
+
2174
+ private fun requestedCursorScalar(
2175
+ scalarFrom: Int,
2176
+ scalarTo: Int,
2177
+ currentText: String,
2178
+ insertedText: String,
2179
+ newCursorPosition: Int
2180
+ ): Int? {
2181
+ if (newCursorPosition == 1) return null
2182
+ val insertedScalarLength = insertedText.codePointCount(0, insertedText.length)
2183
+ val currentScalarLength = currentText.codePointCount(0, currentText.length)
2184
+ val nextScalarLength =
2185
+ (currentScalarLength - (scalarTo - scalarFrom) + insertedScalarLength).coerceAtLeast(0)
2186
+ val requested = if (newCursorPosition > 0) {
2187
+ scalarFrom + insertedScalarLength + newCursorPosition - 1
2188
+ } else {
2189
+ scalarFrom + newCursorPosition
2190
+ }
2191
+ return requested.coerceIn(0, nextScalarLength)
2192
+ }
2193
+
2194
+ private fun applyRequestedCursorScalar(requestedCursorScalar: Int?) {
2195
+ val requested = requestedCursorScalar ?: return
2196
+ if (!hasLiveEditor()) return
2197
+ val currentText = text?.toString().orEmpty()
2198
+ val safeScalar = requested.coerceAtLeast(0)
2199
+ onSetSelectionScalarInRustForTesting?.let { callback ->
2200
+ callback(safeScalar, safeScalar)
2201
+ } ?: editorSetSelectionScalar(
2202
+ editorId.toULong(),
2203
+ safeScalar.toUInt(),
2204
+ safeScalar.toUInt()
2205
+ )
2206
+ val localScalar = safeScalar.coerceIn(0, currentText.codePointCount(0, currentText.length))
2207
+ val safeUtf16 = PositionBridge.scalarToUtf16(localScalar, currentText)
2208
+ .coerceIn(0, currentText.length)
2209
+ if (selectionStart != safeUtf16 || selectionEnd != safeUtf16) {
2210
+ setSelection(safeUtf16, safeUtf16)
2211
+ }
2212
+ }
2213
+
2214
+ private fun plainTextDocumentFragmentJson(text: String): String {
2215
+ val normalizedText = text.replace("\r\n", "\n").replace('\r', '\n')
2216
+ val content = org.json.JSONArray()
2217
+ for (line in normalizedText.split('\n')) {
2218
+ val paragraph = org.json.JSONObject().put("type", "paragraph")
2219
+ if (line.isNotEmpty()) {
2220
+ paragraph.put(
2221
+ "content",
2222
+ org.json.JSONArray().put(
2223
+ org.json.JSONObject()
2224
+ .put("type", "text")
2225
+ .put("text", line)
2226
+ )
2227
+ )
2228
+ }
2229
+ content.put(paragraph)
2230
+ }
2231
+ return org.json.JSONObject()
2232
+ .put("type", "doc")
2233
+ .put("content", content)
2234
+ .toString()
2235
+ }
2236
+
1234
2237
  private fun nativeTextMutationFromAuthorizedDiff(currentText: String): NativeTextMutation? {
1235
2238
  val authorizedText = lastAuthorizedText
1236
2239
  if (currentText == authorizedText) return null
@@ -1243,6 +2246,10 @@ class EditorEditText @JvmOverloads constructor(
1243
2246
  ) {
1244
2247
  prefix++
1245
2248
  }
2249
+ prefix = minOf(
2250
+ PositionBridge.snapToScalarBoundary(prefix, authorizedText, biasForward = false),
2251
+ PositionBridge.snapToScalarBoundary(prefix, currentText, biasForward = false)
2252
+ )
1246
2253
 
1247
2254
  var authorizedEnd = authorizedText.length
1248
2255
  var currentEnd = currentText.length
@@ -1254,41 +2261,190 @@ class EditorEditText @JvmOverloads constructor(
1254
2261
  authorizedEnd--
1255
2262
  currentEnd--
1256
2263
  }
2264
+ authorizedEnd = PositionBridge.snapToScalarBoundary(
2265
+ authorizedEnd,
2266
+ authorizedText,
2267
+ biasForward = true
2268
+ )
2269
+ currentEnd = PositionBridge.snapToScalarBoundary(
2270
+ currentEnd,
2271
+ currentText,
2272
+ biasForward = true
2273
+ )
1257
2274
 
1258
2275
  val replacementText = currentText.substring(prefix, currentEnd)
2276
+ val rawSelectionStart = selectionStart
2277
+ val rawSelectionEnd = selectionEnd
2278
+ val selectionAnchorUtf16 = rawSelectionStart
2279
+ .takeIf { it >= 0 }
2280
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
2281
+ val selectionHeadUtf16 = rawSelectionEnd
2282
+ .takeIf { it >= 0 }
2283
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
1259
2284
  return NativeTextMutation(
1260
2285
  scalarFrom = PositionBridge.utf16ToScalar(prefix, authorizedText),
1261
2286
  scalarTo = PositionBridge.utf16ToScalar(authorizedEnd, authorizedText),
1262
2287
  replacementText = replacementText,
1263
- resultingText = currentText
2288
+ resultingText = currentText,
2289
+ selectionScalarAnchor = selectionAnchorUtf16?.let {
2290
+ PositionBridge.utf16ToScalar(it, currentText)
2291
+ },
2292
+ selectionScalarHead = selectionHeadUtf16?.let {
2293
+ PositionBridge.utf16ToScalar(it, currentText)
2294
+ }
2295
+ )
2296
+ }
2297
+
2298
+ private fun shouldAdoptNativeTextMutation(
2299
+ mutation: NativeTextMutation,
2300
+ allowAfterBlur: Boolean = false
2301
+ ): Boolean {
2302
+ if (!isEditable) return false
2303
+ if (isNativeTextMutationAdoptionSuppressedForCurrentRevision()) return false
2304
+ if (!hasFocus()) {
2305
+ return allowAfterBlur &&
2306
+ canAdoptNativeTextMutationAfterBlur() &&
2307
+ shouldAdoptFinalNativeTextMutation(mutation)
2308
+ }
2309
+ return shouldAdoptFinalNativeTextMutation(mutation)
2310
+ }
2311
+
2312
+ private fun shouldAdoptFinalNativeTextMutation(mutation: NativeTextMutation): Boolean {
2313
+ if (composingTextForEditor() != null) return false
2314
+ val trackedRange = compositionReplacementRange() ?: return true
2315
+ val authorizedText = lastAuthorizedText
2316
+ val trackedStart = PositionBridge.utf16ToScalar(trackedRange.first, authorizedText)
2317
+ val trackedEnd = PositionBridge.utf16ToScalar(trackedRange.second, authorizedText)
2318
+ if (trackedStart == trackedEnd) {
2319
+ return mutation.scalarFrom == trackedStart &&
2320
+ mutation.scalarTo == trackedStart &&
2321
+ mutation.replacementText.isNotEmpty()
2322
+ }
2323
+ if (mutation.scalarFrom == mutation.scalarTo) {
2324
+ return mutation.replacementText.isNotEmpty() &&
2325
+ mutation.scalarFrom >= trackedStart &&
2326
+ mutation.scalarFrom <= trackedEnd
2327
+ }
2328
+ return mutation.scalarFrom < trackedEnd && mutation.scalarTo > trackedStart
2329
+ }
2330
+
2331
+ private fun drainNativeTextMutationIfNeeded(allowAfterBlur: Boolean): Boolean {
2332
+ if (editorId == 0L) return true
2333
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
2334
+ val editable = text
2335
+ val currentText = editable?.toString() ?: ""
2336
+ if (currentText == lastAuthorizedText) return true
2337
+
2338
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
2339
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur)) {
2340
+ commitNativeTextMutation(mutation)
2341
+ return true
2342
+ }
2343
+ recordImeTraceForTesting(
2344
+ "nativeMutationNoop",
2345
+ "reason=${if (mutation == null) "noDiffRange" else "notAdoptable"} allowAfterBlur=$allowAfterBlur currentLength=${currentText.length} authorizedLength=${lastAuthorizedText.length}"
2346
+ )
2347
+ return false
2348
+ }
2349
+
2350
+ private fun beginNativeTextMutationAfterBlurWindow() {
2351
+ if (!hasLiveEditor()) {
2352
+ clearNativeTextMutationAfterBlurWindow()
2353
+ return
2354
+ }
2355
+ nativeTextMutationAfterBlurWindow = NativeTextMutationAfterBlurWindow(
2356
+ editorId = editorId,
2357
+ authorizedTextRevision = lastAuthorizedTextRevision,
2358
+ deadlineMs = SystemClock.uptimeMillis() + NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS
1264
2359
  )
1265
2360
  }
1266
2361
 
1267
- private fun shouldAdoptNativeTextMutation(editable: Editable?): Boolean {
1268
- if (!isEditable || !hasFocus()) return false
1269
- if (editable == null) return true
2362
+ private fun clearNativeTextMutationAfterBlurWindow() {
2363
+ nativeTextMutationAfterBlurWindow = null
2364
+ }
2365
+
2366
+ private fun suppressNativeTextMutationAdoptionForCurrentRevision() {
2367
+ if (!hasLiveEditor()) {
2368
+ clearNativeTextMutationAdoptionSuppression()
2369
+ return
2370
+ }
2371
+ nativeTextMutationAdoptionSuppression = NativeTextMutationAdoptionSuppression(
2372
+ editorId = editorId,
2373
+ authorizedTextRevision = lastAuthorizedTextRevision
2374
+ )
2375
+ }
1270
2376
 
1271
- val composingStart = BaseInputConnection.getComposingSpanStart(editable)
1272
- val composingEnd = BaseInputConnection.getComposingSpanEnd(editable)
1273
- return composingStart < 0 || composingEnd < 0 || composingStart == composingEnd
2377
+ private fun clearNativeTextMutationAdoptionSuppression() {
2378
+ nativeTextMutationAdoptionSuppression = null
2379
+ }
2380
+
2381
+ private fun isNativeTextMutationAdoptionSuppressedForCurrentRevision(): Boolean {
2382
+ val suppression = nativeTextMutationAdoptionSuppression ?: return false
2383
+ if (
2384
+ suppression.editorId != editorId ||
2385
+ suppression.authorizedTextRevision != lastAuthorizedTextRevision
2386
+ ) {
2387
+ nativeTextMutationAdoptionSuppression = null
2388
+ return false
2389
+ }
2390
+ return true
2391
+ }
2392
+
2393
+ private fun canAdoptNativeTextMutationAfterBlur(): Boolean {
2394
+ val window = nativeTextMutationAfterBlurWindow ?: return false
2395
+ val now = SystemClock.uptimeMillis()
2396
+ if (now > window.deadlineMs ||
2397
+ window.editorId != editorId ||
2398
+ window.authorizedTextRevision != lastAuthorizedTextRevision ||
2399
+ window.didAdoptMutation
2400
+ ) {
2401
+ nativeTextMutationAfterBlurWindow = null
2402
+ return false
2403
+ }
2404
+ return true
1274
2405
  }
1275
2406
 
1276
2407
  private fun commitNativeTextMutation(mutation: NativeTextMutation) {
1277
- if ((text?.toString() ?: "") != mutation.resultingText) return
2408
+ if (!hasLiveEditor()) return
2409
+ if ((text?.toString() ?: "") != mutation.resultingText) {
2410
+ recordImeTraceForTesting(
2411
+ "nativeMutationNoop",
2412
+ "reason=staleResult range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length}"
2413
+ )
2414
+ return
2415
+ }
2416
+ val shouldRestartInput = hasFocus()
2417
+ retireInputConnectionForEditor()
2418
+ nativeTextMutationAfterBlurWindow?.didAdoptMutation = true
2419
+ clearNativeTextMutationAfterBlurWindow()
1278
2420
 
1279
- if (mutation.scalarFrom == mutation.scalarTo) {
1280
- if (mutation.replacementText.isNotEmpty()) {
1281
- insertTextInRust(mutation.replacementText, mutation.scalarFrom)
1282
- }
1283
- } else if (mutation.replacementText.isEmpty()) {
2421
+ recordImeTraceForTesting(
2422
+ "nativeMutationApply",
2423
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput"
2424
+ )
2425
+ if (mutation.replacementText.isEmpty()) {
1284
2426
  deleteRangeInRust(mutation.scalarFrom, mutation.scalarTo)
1285
2427
  } else {
1286
- replaceTextRangeInRust(
2428
+ insertPlainTextRangeInRust(
1287
2429
  mutation.scalarFrom,
1288
2430
  mutation.scalarTo,
1289
2431
  mutation.replacementText
1290
2432
  )
1291
2433
  }
2434
+ restoreSelectionAfterNativeTextMutation(mutation)
2435
+ if (shouldRestartInput) {
2436
+ restartInputForEditor()
2437
+ }
2438
+ }
2439
+
2440
+ private fun restoreSelectionAfterNativeTextMutation(mutation: NativeTextMutation) {
2441
+ val selectionScalarAnchor = mutation.selectionScalarAnchor ?: return
2442
+ val selectionScalarHead = mutation.selectionScalarHead ?: return
2443
+ val currentText = text?.toString() ?: return
2444
+ val anchorUtf16 = PositionBridge.scalarToUtf16(selectionScalarAnchor, currentText)
2445
+ val headUtf16 = PositionBridge.scalarToUtf16(selectionScalarHead, currentText)
2446
+ val length = currentText.length
2447
+ setSelection(anchorUtf16.coerceIn(0, length), headUtf16.coerceIn(0, length))
1292
2448
  }
1293
2449
 
1294
2450
  /**
@@ -1298,6 +2454,7 @@ class EditorEditText @JvmOverloads constructor(
1298
2454
  * @param scalarTo End scalar offset (exclusive).
1299
2455
  */
1300
2456
  private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
2457
+ if (!hasLiveEditor()) return
1301
2458
  if (scalarFrom >= scalarTo) return
1302
2459
  onDeleteRangeInRustForTesting?.let { callback ->
1303
2460
  callback(scalarFrom, scalarTo)
@@ -1308,6 +2465,7 @@ class EditorEditText @JvmOverloads constructor(
1308
2465
  }
1309
2466
 
1310
2467
  private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
2468
+ if (!hasLiveEditor()) return
1311
2469
  onDeleteBackwardAtSelectionScalarInRustForTesting?.let { callback ->
1312
2470
  callback(scalarAnchor, scalarHead)
1313
2471
  return
@@ -1324,16 +2482,84 @@ class EditorEditText @JvmOverloads constructor(
1324
2482
  * Split a block at a scalar position via the Rust editor.
1325
2483
  */
1326
2484
  private fun splitBlockInRust(atScalarPos: Int) {
2485
+ if (!hasLiveEditor()) return
1327
2486
  val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
1328
2487
  applyUpdateJSON(updateJSON)
1329
2488
  }
1330
2489
 
1331
- private fun currentScalarSelection(): Pair<Int, Int>? {
2490
+ private fun deleteAndSplitInRust(scalarFrom: Int, scalarTo: Int) {
2491
+ if (!hasLiveEditor()) return
2492
+ onDeleteAndSplitScalarInRustForTesting?.let { callback ->
2493
+ callback(scalarFrom, scalarTo)
2494
+ return
2495
+ }
2496
+ val updateJSON = editorDeleteAndSplitScalar(
2497
+ editorId.toULong(),
2498
+ scalarFrom.toUInt(),
2499
+ scalarTo.toUInt()
2500
+ )
2501
+ applyUpdateJSON(updateJSON)
2502
+ }
2503
+
2504
+ internal fun currentScalarSelection(): Pair<Int, Int>? {
2505
+ val currentText = text?.toString() ?: return null
2506
+ return normalizedScalarSelectionRange(currentText)
2507
+ }
2508
+
2509
+ private fun normalizedUtf16SelectionRange(currentText: String): Pair<Int, Int>? {
2510
+ val start = selectionStart
2511
+ val end = selectionEnd
2512
+ if (start < 0 || end < 0) return null
2513
+ val clampedStart = start.coerceIn(0, currentText.length)
2514
+ val clampedEnd = end.coerceIn(0, currentText.length)
2515
+ return minOf(clampedStart, clampedEnd) to maxOf(clampedStart, clampedEnd)
2516
+ }
2517
+
2518
+ private fun normalizedUtf16SelectionRange(): Pair<Int, Int>? {
1332
2519
  val currentText = text?.toString() ?: return null
1333
- return Pair(
1334
- PositionBridge.utf16ToScalar(selectionStart, currentText),
1335
- PositionBridge.utf16ToScalar(selectionEnd, currentText)
2520
+ return normalizedUtf16SelectionRange(currentText)
2521
+ }
2522
+
2523
+ private fun normalizedScalarSelectionRange(currentText: String): Pair<Int, Int>? {
2524
+ val (start, end) = normalizedUtf16SelectionRange(currentText) ?: return null
2525
+ val (snappedStart, snappedEnd) = if (start == end) {
2526
+ val snapped = PositionBridge.snapToScalarBoundary(
2527
+ start,
2528
+ currentText,
2529
+ biasForward = true
2530
+ )
2531
+ snapped to snapped
2532
+ } else {
2533
+ PositionBridge.snapRangeToScalarBoundaries(start, end, currentText)
2534
+ }
2535
+ return PositionBridge.utf16ToScalar(snappedStart, currentText) to
2536
+ PositionBridge.utf16ToScalar(snappedEnd, currentText)
2537
+ }
2538
+
2539
+ private fun rawScalarSelection(currentText: String): Pair<Int, Int>? {
2540
+ val anchor = selectionStart
2541
+ val head = selectionEnd
2542
+ if (anchor < 0 || head < 0) return null
2543
+ val clampedAnchor = anchor.coerceIn(0, currentText.length)
2544
+ val clampedHead = head.coerceIn(0, currentText.length)
2545
+ if (clampedAnchor == clampedHead) {
2546
+ val snapped = PositionBridge.snapToScalarBoundary(
2547
+ clampedAnchor,
2548
+ currentText,
2549
+ biasForward = true
2550
+ )
2551
+ val scalar = PositionBridge.utf16ToScalar(snapped, currentText)
2552
+ return scalar to scalar
2553
+ }
2554
+ val (rangeStart, rangeEnd) = PositionBridge.snapRangeToScalarBoundaries(
2555
+ minOf(clampedAnchor, clampedHead),
2556
+ maxOf(clampedAnchor, clampedHead),
2557
+ currentText
1336
2558
  )
2559
+ val snappedAnchor = if (clampedAnchor < clampedHead) rangeStart else rangeEnd
2560
+ val snappedHead = if (clampedAnchor < clampedHead) rangeEnd else rangeStart
2561
+ return PositionBridge.utf16ToScalar(snappedAnchor, currentText) to
2562
+ PositionBridge.utf16ToScalar(snappedHead, currentText)
1337
2563
  }
1338
2564
 
1339
2565
  fun selectedImageGeometry(): SelectedImageGeometry? {
@@ -1352,7 +2578,7 @@ class EditorEditText @JvmOverloads constructor(
1352
2578
  val textLayout = layout ?: return null
1353
2579
  val currentText = text?.toString() ?: return null
1354
2580
  val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
1355
- val docPos = if (editorId != 0L) {
2581
+ val docPos = if (hasLiveEditor()) {
1356
2582
  editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
1357
2583
  } else {
1358
2584
  0
@@ -1366,7 +2592,7 @@ class EditorEditText @JvmOverloads constructor(
1366
2592
  }
1367
2593
 
1368
2594
  fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
1369
- if (editorId == 0L) return
2595
+ if (!hasLiveEditor()) return
1370
2596
  val density = resources.displayMetrics.density
1371
2597
  val widthDp = maxOf(48, (widthPx / density).roundToInt())
1372
2598
  val heightDp = maxOf(48, (heightPx / density).roundToInt())
@@ -1380,7 +2606,7 @@ class EditorEditText @JvmOverloads constructor(
1380
2606
  }
1381
2607
 
1382
2608
  private fun isSelectionInsideList(): Boolean {
1383
- if (editorId == 0L) return false
2609
+ if (!hasLiveEditor()) return false
1384
2610
 
1385
2611
  return try {
1386
2612
  val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
@@ -1396,6 +2622,12 @@ class EditorEditText @JvmOverloads constructor(
1396
2622
  * Paste HTML content through Rust.
1397
2623
  */
1398
2624
  private fun pasteHTML(html: String) {
2625
+ if (!hasLiveEditor()) return
2626
+ syncCurrentSelectionToRust()
2627
+ onInsertContentHtmlInRustForTesting?.let { callback ->
2628
+ callback(html)
2629
+ return
2630
+ }
1399
2631
  val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
1400
2632
  applyUpdateJSON(updateJSON)
1401
2633
  }
@@ -1404,9 +2636,8 @@ class EditorEditText @JvmOverloads constructor(
1404
2636
  * Paste plain text through Rust.
1405
2637
  */
1406
2638
  private fun pastePlainText(text: String) {
1407
- val currentText = this.text?.toString() ?: ""
1408
- val scalarPos = PositionBridge.utf16ToScalar(selectionStart, currentText)
1409
- insertTextInRust(text, scalarPos)
2639
+ val (scalarStart, scalarEnd) = currentScalarSelection() ?: return
2640
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, text)
1410
2641
  }
1411
2642
 
1412
2643
  // ── Applying Rust State ─────────────────────────────────────────────
@@ -1477,6 +2708,8 @@ class EditorEditText @JvmOverloads constructor(
1477
2708
  replaceRange: RenderReplaceRange? = null,
1478
2709
  usedPatch: Boolean
1479
2710
  ) {
2711
+ val hadCompositionTracking = hasCompositionTrackingForEditor()
2712
+ var shouldRestartInput = false
1480
2713
  isApplyingRustState = true
1481
2714
  beginBatchEdit()
1482
2715
  try {
@@ -1486,11 +2719,22 @@ class EditorEditText @JvmOverloads constructor(
1486
2719
  setText(spannable)
1487
2720
  }
1488
2721
  lastAuthorizedText = text?.toString().orEmpty()
2722
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
2723
+ lastAuthorizedTextRevision += 1L
2724
+ clearNativeTextMutationAdoptionSuppression()
2725
+ if (hadCompositionTracking) {
2726
+ retireInputConnectionForEditor()
2727
+ shouldRestartInput = true
2728
+ } else {
2729
+ clearCompositionTrackingForEditor()
2730
+ }
1489
2731
  lastRenderAppliedPatchForTesting = usedPatch
2732
+ clearNativeTextMutationAfterBlurWindow()
1490
2733
  } finally {
1491
2734
  endBatchEdit()
1492
2735
  isApplyingRustState = false
1493
2736
  }
2737
+ restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
1494
2738
  }
1495
2739
 
1496
2740
  private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
@@ -1691,6 +2935,8 @@ class EditorEditText @JvmOverloads constructor(
1691
2935
  if (shouldSkipRender) {
1692
2936
  lastRenderAppliedPatchForTesting = false
1693
2937
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
2938
+ clearNativeTextMutationAdoptionSuppression()
2939
+ clearNativeTextMutationAfterBlurWindow()
1694
2940
  buildRenderNanos = 0L
1695
2941
  applyRenderNanos = 0L
1696
2942
  } else {
@@ -1911,7 +3157,10 @@ class EditorEditText @JvmOverloads constructor(
1911
3157
 
1912
3158
  override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
1913
3159
  super.onFocusChanged(focused, direction, previouslyFocusedRect)
1914
- if (!focused) {
3160
+ if (focused) {
3161
+ clearNativeTextMutationAfterBlurWindow()
3162
+ } else {
3163
+ beginNativeTextMutationAfterBlurWindow()
1915
3164
  clearExplicitSelectedImageRange()
1916
3165
  }
1917
3166
  }
@@ -2018,6 +3267,7 @@ class EditorEditText @JvmOverloads constructor(
2018
3267
  */
2019
3268
  private fun applySelectionFromJSON(selection: org.json.JSONObject) {
2020
3269
  val type = selection.optString("type", "") ?: return
3270
+ if (isEditorDestroyedForInput()) return
2021
3271
 
2022
3272
  isApplyingRustState = true
2023
3273
  try {
@@ -2029,12 +3279,12 @@ class EditorEditText @JvmOverloads constructor(
2029
3279
  // Convert doc positions to scalar offsets.
2030
3280
  val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
2031
3281
  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)
3282
+ val anchorUtf16 = PositionBridge.scalarToUtf16(scalarAnchor, currentText)
3283
+ val headUtf16 = PositionBridge.scalarToUtf16(scalarHead, currentText)
2034
3284
  val len = text?.length ?: 0
2035
3285
  setSelection(
2036
- startUtf16.coerceIn(0, len),
2037
- endUtf16.coerceIn(0, len)
3286
+ anchorUtf16.coerceIn(0, len),
3287
+ headUtf16.coerceIn(0, len)
2038
3288
  )
2039
3289
  }
2040
3290
  "node" -> {
@@ -2081,13 +3331,13 @@ class EditorEditText @JvmOverloads constructor(
2081
3331
 
2082
3332
  override fun afterTextChanged(s: Editable?) {
2083
3333
  if (isApplyingRustState) return
2084
- if (editorId == 0L) return
3334
+ if (!hasLiveEditor()) return
2085
3335
 
2086
3336
  val currentText = s?.toString() ?: ""
2087
3337
  if (currentText == lastAuthorizedText) return
2088
3338
 
2089
3339
  val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
2090
- if (mutation != null && shouldAdoptNativeTextMutation(s)) {
3340
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur = true)) {
2091
3341
  commitNativeTextMutation(mutation)
2092
3342
  return
2093
3343
  }
@@ -2113,6 +3363,9 @@ class EditorEditText @JvmOverloads constructor(
2113
3363
  private const val DEFAULT_AUTO_CORRECT = true
2114
3364
  private const val DEFAULT_KEYBOARD_TYPE = "default"
2115
3365
  private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
3366
+ private const val IME_TRACE_LIMIT_FOR_TESTING = 80
3367
+ private const val NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS = 750L
3368
+ private const val RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS = 750L
2116
3369
  private const val LOG_TAG = "NativeEditor"
2117
3370
  }
2118
3371
  }