@apollohg/react-native-prose-editor 0.5.15 → 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
@@ -18,6 +21,7 @@ import android.util.Log
18
21
  import android.util.TypedValue
19
22
  import android.view.KeyEvent
20
23
  import android.view.MotionEvent
24
+ import android.view.inputmethod.BaseInputConnection
21
25
  import android.view.inputmethod.EditorInfo
22
26
  import android.view.inputmethod.InputConnection
23
27
  import android.view.inputmethod.InputMethodManager
@@ -79,6 +83,17 @@ class EditorEditText @JvmOverloads constructor(
79
83
  val label: String
80
84
  )
81
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
+
82
97
  data class LinkHit(
83
98
  val href: String,
84
99
  val text: String
@@ -107,6 +122,27 @@ class EditorEditText @JvmOverloads constructor(
107
122
  val end: Int
108
123
  )
109
124
 
125
+ private data class NativeTextMutation(
126
+ val scalarFrom: Int,
127
+ val scalarTo: Int,
128
+ val replacementText: 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
144
+ )
145
+
110
146
  /**
111
147
  * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
112
148
  */
@@ -129,6 +165,13 @@ class EditorEditText @JvmOverloads constructor(
129
165
  * focus, text selection, and copy capability.
130
166
  */
131
167
  var isEditable: Boolean = true
168
+ set(value) {
169
+ if (field == value) return
170
+ if (!value) {
171
+ discardTransientNativeInputForReadOnly()
172
+ }
173
+ field = value
174
+ }
132
175
 
133
176
  /**
134
177
  * Guard flag to prevent re-entrant input interception while we're
@@ -184,12 +227,25 @@ class EditorEditText @JvmOverloads constructor(
184
227
  var reconciliationCount: Int = 0
185
228
  private set
186
229
 
187
- private var lastHandledHardwareKeyCode: Int? = null
188
- 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
189
244
  private var explicitSelectedImageRange: ImageSelectionRange? = null
190
245
  private var lastRenderAppliedPatchForTesting: Boolean = false
191
246
  internal var captureApplyUpdateTraceForTesting: Boolean = false
192
247
  private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
248
+ private val imeTraceForTesting = java.util.ArrayDeque<String>()
193
249
  private var currentRenderBlocksJson: org.json.JSONArray? = null
194
250
  private var renderAppearanceRevision: Long = 1L
195
251
  private var lastAppliedRenderAppearanceRevision: Long = 0L
@@ -197,10 +253,32 @@ class EditorEditText @JvmOverloads constructor(
197
253
  internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
198
254
  internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
199
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
200
262
 
201
263
  fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
202
264
  fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
203
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
+
204
282
  init {
205
283
  // Configure for rich text editing.
206
284
  inputType = resolvedInputType()
@@ -275,22 +353,23 @@ class EditorEditText @JvmOverloads constructor(
275
353
 
276
354
  val currentStart = selectionStart
277
355
  val currentEnd = selectionEnd
356
+ val authorizedSelection = authorizedSelectionForTransientInputRestore(
357
+ currentStart,
358
+ currentEnd
359
+ )
360
+ discardTransientInputAndRestoreAuthorizedTextForEditor()
278
361
  setRawInputType(nextInputType)
279
362
 
280
363
  val editable = text
281
- if (
282
- editable != null &&
283
- currentStart >= 0 &&
284
- currentEnd >= 0 &&
285
- currentStart <= editable.length &&
286
- currentEnd <= editable.length
287
- ) {
288
- 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
+ )
289
369
  }
290
370
 
291
371
  if (hasFocus()) {
292
- val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
293
- imm?.restartInput(this)
372
+ restartInputForEditor()
294
373
  }
295
374
  }
296
375
 
@@ -338,16 +417,72 @@ class EditorEditText @JvmOverloads constructor(
338
417
  */
339
418
  override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
340
419
  val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
341
- return EditorInputConnection(this, baseConnection)
420
+ val generation = nextInputConnectionGenerationForEditor()
421
+ return EditorInputConnection(this, baseConnection, editorId, generation).also {
422
+ activeInputConnection = it
423
+ }
342
424
  }
343
425
 
344
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
+ }
345
433
  if (handleHardwareKeyEvent(event)) {
346
434
  return true
347
435
  }
436
+ if (handlePrintableHardwareKeyEvent(event) { super.dispatchKeyEvent(event) }) {
437
+ return true
438
+ }
348
439
  return super.dispatchKeyEvent(event)
349
440
  }
350
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
+
351
486
  override fun onDraw(canvas: android.graphics.Canvas) {
352
487
  super.onDraw(canvas)
353
488
 
@@ -484,7 +619,15 @@ class EditorEditText @JvmOverloads constructor(
484
619
  * @param id The editor ID from `editor_create()`.
485
620
  * @param initialHTML Optional HTML to set as initial content.
486
621
  */
487
- 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
+ }
488
631
  editorId = id
489
632
 
490
633
  if (!initialHTML.isNullOrEmpty()) {
@@ -494,7 +637,7 @@ class EditorEditText @JvmOverloads constructor(
494
637
  } else {
495
638
  // Pull current state from Rust (content may already be loaded via bridge).
496
639
  val stateJSON = editorGetCurrentState(editorId.toULong())
497
- applyUpdateJSON(stateJSON)
640
+ applyUpdateJSON(stateJSON, notifyListener = notifyListener)
498
641
  }
499
642
  }
500
643
 
@@ -502,6 +645,9 @@ class EditorEditText @JvmOverloads constructor(
502
645
  * Unbind from the current editor instance.
503
646
  */
504
647
  fun unbindEditor() {
648
+ if (editorId != 0L) {
649
+ discardTransientNativeInputForEditorRebind()
650
+ }
505
651
  editorId = 0
506
652
  }
507
653
 
@@ -522,7 +668,7 @@ class EditorEditText @JvmOverloads constructor(
522
668
  renderAppearanceRevision += 1L
523
669
  setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
524
670
  applyContentInsets(theme?.contentInsets)
525
- if (editorId != 0L) {
671
+ if (hasLiveEditor()) {
526
672
  val previousScrollX = scrollX
527
673
  val previousScrollY = scrollY
528
674
  val stateJSON = editorGetCurrentState(editorId.toULong())
@@ -677,17 +823,18 @@ class EditorEditText @JvmOverloads constructor(
677
823
  * Called by [EditorInputConnection.commitText]. Routes the text through
678
824
  * the Rust editor instead of directly inserting into the EditText.
679
825
  */
680
- fun handleTextCommit(text: String) {
826
+ fun handleTextCommit(text: String, newCursorPosition: Int = 1) {
681
827
  if (!isEditable) return
682
828
  if (isApplyingRustState) return
829
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
683
830
  if (editorId == 0L) {
684
831
  // No Rust editor bound — fall through to direct editing (dev mode).
685
832
  val editable = this.text ?: return
686
- val start = selectionStart
687
- val end = selectionEnd
833
+ val (start, end) = selectionRange
688
834
  editable.replace(start, end, text)
689
835
  return
690
836
  }
837
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
691
838
 
692
839
  // Handle Enter/Return as a block split operation.
693
840
  if (text == "\n") {
@@ -696,18 +843,19 @@ class EditorEditText @JvmOverloads constructor(
696
843
  }
697
844
 
698
845
  val currentText = this.text?.toString() ?: ""
699
- val start = selectionStart
700
- val end = selectionEnd
701
-
702
- if (start != end) {
703
- // Range selection: atomic replace via Rust.
704
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
705
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
706
- replaceTextRangeInRust(scalarStart, scalarEnd, text)
707
- } else {
708
- val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
709
- insertTextInRust(text, scalarPos)
710
- }
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
+ )
711
859
  }
712
860
 
713
861
  internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
@@ -720,31 +868,497 @@ class EditorEditText @JvmOverloads constructor(
720
868
  }
721
869
  }
722
870
 
723
- 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
+ ) {
724
1122
  if (!isEditable) return
725
1123
  if (isApplyingRustState) return
726
- 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
+ }
727
1151
 
728
1152
  if (text == "\n") {
729
- handleReturnKey()
1153
+ if (scalarStart != scalarEnd) {
1154
+ deleteAndSplitInRust(scalarStart, scalarEnd)
1155
+ } else {
1156
+ splitBlockInRust(scalarStart)
1157
+ }
730
1158
  return
731
1159
  }
732
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
+
733
1184
  val authorizedText = lastAuthorizedText
734
- val startUtf16 = minOf(replacementStartUtf16, replacementEndUtf16)
735
- .coerceIn(0, authorizedText.length)
736
- val endUtf16 = maxOf(replacementStartUtf16, replacementEndUtf16)
737
- .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
+
738
1225
  val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
739
- 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
+ }
740
1234
 
741
- if (scalarStart != scalarEnd) {
742
- replaceTextRangeInRust(scalarStart, scalarEnd, text)
743
- } else {
744
- 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
745
1333
  }
746
1334
  }
747
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
+
748
1362
  // ── Input Handling: Deletion ────────────────────────────────────────
749
1363
 
750
1364
  /**
@@ -758,17 +1372,32 @@ class EditorEditText @JvmOverloads constructor(
758
1372
  fun handleDelete(beforeLength: Int, afterLength: Int) {
759
1373
  if (!isEditable) return
760
1374
  if (isApplyingRustState) return
1375
+ val selectionRange = normalizedUtf16SelectionRange()
761
1376
  if (editorId == 0L) {
762
1377
  // Dev mode: direct editing.
763
1378
  val editable = this.text ?: return
764
- val cursor = selectionStart
765
- val delStart = maxOf(0, cursor - beforeLength)
766
- 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
+ }
767
1389
  editable.delete(delStart, delEnd)
768
1390
  return
769
1391
  }
1392
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
770
1393
 
771
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
+ }
772
1401
  val cursor = selectionStart
773
1402
  if (beforeLength > 0 &&
774
1403
  afterLength == 0 &&
@@ -779,8 +1408,13 @@ class EditorEditText @JvmOverloads constructor(
779
1408
  deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
780
1409
  return
781
1410
  }
782
- val delStart = maxOf(0, cursor - beforeLength)
783
- 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
+ )
784
1418
 
785
1419
  val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
786
1420
  val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
@@ -801,11 +1435,11 @@ class EditorEditText @JvmOverloads constructor(
801
1435
  fun handleBackspace() {
802
1436
  if (!isEditable) return
803
1437
  if (isApplyingRustState) return
1438
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
804
1439
  if (editorId == 0L) {
805
1440
  // Dev mode: direct editing.
806
1441
  val editable = this.text ?: return
807
- val start = selectionStart
808
- val end = selectionEnd
1442
+ val (start, end) = selectionRange
809
1443
  if (start != end) {
810
1444
  editable.delete(start, end)
811
1445
  } else if (start > 0) {
@@ -816,15 +1450,14 @@ class EditorEditText @JvmOverloads constructor(
816
1450
  }
817
1451
  return
818
1452
  }
1453
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
819
1454
 
820
1455
  val currentText = text?.toString() ?: ""
821
- val start = selectionStart
822
- val end = selectionEnd
1456
+ val (start, end) = selectionRange
823
1457
 
824
1458
  if (start != end) {
825
1459
  // Range selection: delete the range.
826
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
827
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
1460
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
828
1461
  deleteRangeInRust(scalarStart, scalarEnd)
829
1462
  } else if (start > 0) {
830
1463
  if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
@@ -851,6 +1484,57 @@ class EditorEditText @JvmOverloads constructor(
851
1484
  }
852
1485
  }
853
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
+
854
1538
  // ── Input Handling: Return Key ──────────────────────────────────────
855
1539
 
856
1540
  /**
@@ -861,8 +1545,7 @@ class EditorEditText @JvmOverloads constructor(
861
1545
  if (isApplyingRustState) return
862
1546
 
863
1547
  val currentText = text?.toString() ?: ""
864
- val start = selectionStart
865
- val end = selectionEnd
1548
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
866
1549
 
867
1550
  if (editorId == 0L) {
868
1551
  // Dev mode: insert newline directly.
@@ -870,15 +1553,12 @@ class EditorEditText @JvmOverloads constructor(
870
1553
  editable.replace(start, end, "\n")
871
1554
  return
872
1555
  }
1556
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
873
1557
 
874
1558
  if (start != end) {
875
1559
  // Range selection: atomic delete-and-split via Rust.
876
- val scalarStart = PositionBridge.utf16ToScalar(start, currentText)
877
- val scalarEnd = PositionBridge.utf16ToScalar(end, currentText)
878
- val updateJSON = editorDeleteAndSplitScalar(
879
- editorId.toULong(), scalarStart.toUInt(), scalarEnd.toUInt()
880
- )
881
- applyUpdateJSON(updateJSON)
1560
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1561
+ deleteAndSplitInRust(scalarStart, scalarEnd)
882
1562
  } else {
883
1563
  val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
884
1564
  splitBlockInRust(scalarPos)
@@ -899,6 +1579,7 @@ class EditorEditText @JvmOverloads constructor(
899
1579
  editable.replace(start, end, "\n")
900
1580
  return
901
1581
  }
1582
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
902
1583
 
903
1584
  val selection = currentScalarSelection() ?: return
904
1585
  val updateJSON = editorInsertNodeAtSelectionScalar(
@@ -916,7 +1597,7 @@ class EditorEditText @JvmOverloads constructor(
916
1597
  fun handleTab(shiftPressed: Boolean): Boolean {
917
1598
  if (!isEditable) return false
918
1599
  if (isApplyingRustState) return false
919
- if (editorId == 0L) return false
1600
+ if (!hasLiveEditor()) return false
920
1601
  if (!isSelectionInsideList()) return false
921
1602
  val selection = currentScalarSelection() ?: return false
922
1603
 
@@ -944,6 +1625,10 @@ class EditorEditText @JvmOverloads constructor(
944
1625
  handleBackspace()
945
1626
  true
946
1627
  }
1628
+ KeyEvent.KEYCODE_FORWARD_DEL -> {
1629
+ handleForwardDelete()
1630
+ true
1631
+ }
947
1632
  KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
948
1633
  if (shiftPressed) {
949
1634
  handleHardBreak()
@@ -957,28 +1642,55 @@ class EditorEditText @JvmOverloads constructor(
957
1642
  }
958
1643
  }
959
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
+
960
1677
  fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
961
1678
  if (event == null || !isEditable || isApplyingRustState) return false
962
1679
 
963
1680
  return when (event.action) {
964
1681
  KeyEvent.ACTION_DOWN -> {
965
- val supported = when (event.keyCode) {
966
- KeyEvent.KEYCODE_DEL,
967
- KeyEvent.KEYCODE_ENTER,
968
- KeyEvent.KEYCODE_NUMPAD_ENTER,
969
- KeyEvent.KEYCODE_TAB -> true
970
- else -> false
971
- }
972
- if (!supported) return false
1682
+ if (!isSupportedHardwareMutationKey(event.keyCode)) return false
973
1683
 
974
- if (lastHandledHardwareKeyCode == event.keyCode &&
975
- lastHandledHardwareKeyDownTime == event.downTime) {
1684
+ val signature = hardwareKeyEventSignature(event)
1685
+ if (
1686
+ lastHandledHardwareKeySignature == signature ||
1687
+ didRecentlyHandleHardwareKeyDown(signature)
1688
+ ) {
976
1689
  return true
977
1690
  }
978
1691
 
979
1692
  if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
980
- lastHandledHardwareKeyCode = event.keyCode
981
- lastHandledHardwareKeyDownTime = event.downTime
1693
+ markHandledHardwareKeyDown(signature)
982
1694
  true
983
1695
  } else {
984
1696
  false
@@ -986,10 +1698,10 @@ class EditorEditText @JvmOverloads constructor(
986
1698
  }
987
1699
 
988
1700
  KeyEvent.ACTION_UP -> {
989
- if (lastHandledHardwareKeyCode == event.keyCode &&
990
- lastHandledHardwareKeyDownTime == event.downTime) {
991
- lastHandledHardwareKeyCode = null
992
- lastHandledHardwareKeyDownTime = null
1701
+ if (lastHandledHardwareKeySignature?.let {
1702
+ it.keyCode == event.keyCode && it.downTime == event.downTime
1703
+ } == true) {
1704
+ lastHandledHardwareKeySignature = null
993
1705
  true
994
1706
  } else {
995
1707
  false
@@ -1000,8 +1712,119 @@ class EditorEditText @JvmOverloads constructor(
1000
1712
  }
1001
1713
  }
1002
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
+
1003
1826
  fun performToolbarToggleMark(markName: String) {
1004
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1827
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1005
1828
  val selection = currentScalarSelection() ?: return
1006
1829
  val updateJSON = editorToggleMarkAtSelectionScalar(
1007
1830
  editorId.toULong(),
@@ -1013,7 +1836,7 @@ class EditorEditText @JvmOverloads constructor(
1013
1836
  }
1014
1837
 
1015
1838
  fun performToolbarToggleList(listType: String, isActive: Boolean) {
1016
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1839
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1017
1840
  val selection = currentScalarSelection() ?: return
1018
1841
  val updateJSON = if (isActive) {
1019
1842
  editorUnwrapFromListAtSelectionScalar(
@@ -1033,7 +1856,7 @@ class EditorEditText @JvmOverloads constructor(
1033
1856
  }
1034
1857
 
1035
1858
  fun performToolbarToggleBlockquote() {
1036
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1859
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1037
1860
  val selection = currentScalarSelection() ?: return
1038
1861
  val updateJSON = editorToggleBlockquoteAtSelectionScalar(
1039
1862
  editorId.toULong(),
@@ -1044,7 +1867,7 @@ class EditorEditText @JvmOverloads constructor(
1044
1867
  }
1045
1868
 
1046
1869
  fun performToolbarToggleHeading(level: Int) {
1047
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1870
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1048
1871
  if (level !in 1..6) return
1049
1872
  val selection = currentScalarSelection() ?: return
1050
1873
  val updateJSON = editorToggleHeadingAtSelectionScalar(
@@ -1057,7 +1880,7 @@ class EditorEditText @JvmOverloads constructor(
1057
1880
  }
1058
1881
 
1059
1882
  fun performToolbarIndentListItem() {
1060
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1883
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1061
1884
  val selection = currentScalarSelection() ?: return
1062
1885
  val updateJSON = editorIndentListItemAtSelectionScalar(
1063
1886
  editorId.toULong(),
@@ -1068,7 +1891,7 @@ class EditorEditText @JvmOverloads constructor(
1068
1891
  }
1069
1892
 
1070
1893
  fun performToolbarOutdentListItem() {
1071
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1894
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1072
1895
  val selection = currentScalarSelection() ?: return
1073
1896
  val updateJSON = editorOutdentListItemAtSelectionScalar(
1074
1897
  editorId.toULong(),
@@ -1079,7 +1902,7 @@ class EditorEditText @JvmOverloads constructor(
1079
1902
  }
1080
1903
 
1081
1904
  fun performToolbarInsertNode(nodeType: String) {
1082
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1905
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1083
1906
  val selection = currentScalarSelection() ?: return
1084
1907
  val updateJSON = editorInsertNodeAtSelectionScalar(
1085
1908
  editorId.toULong(),
@@ -1091,12 +1914,12 @@ class EditorEditText @JvmOverloads constructor(
1091
1914
  }
1092
1915
 
1093
1916
  fun performToolbarUndo() {
1094
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1917
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1095
1918
  applyUpdateJSON(editorUndo(editorId.toULong()))
1096
1919
  }
1097
1920
 
1098
1921
  fun performToolbarRedo() {
1099
- if (!isEditable || isApplyingRustState || editorId == 0L) return
1922
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
1100
1923
  applyUpdateJSON(editorRedo(editorId.toULong()))
1101
1924
  }
1102
1925
 
@@ -1109,32 +1932,52 @@ class EditorEditText @JvmOverloads constructor(
1109
1932
  * falling back to plain text.
1110
1933
  */
1111
1934
  override fun onTextContextMenuItem(id: Int): Boolean {
1112
- if (!isEditable && id == android.R.id.paste) return true
1113
- if (id == android.R.id.paste) {
1114
- 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)
1115
1942
  return true
1116
1943
  }
1117
1944
  return super.onTextContextMenuItem(id)
1118
1945
  }
1119
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
+
1120
1952
  /**
1121
- * Block accessibility-initiated text mutations (paste, set text) when not editable.
1953
+ * Block accessibility-initiated text mutations (paste, cut, set text) when not editable.
1122
1954
  * Selection and copy actions remain available.
1123
1955
  */
1124
1956
  override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
1125
- if (!isEditable && (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT
1126
- || 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
+ ) {
1127
1963
  return false
1128
1964
  }
1965
+ if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT) {
1966
+ return handleAccessibilitySetText(arguments)
1967
+ }
1129
1968
  return super.performAccessibilityAction(action, arguments)
1130
1969
  }
1131
1970
 
1132
- private fun handlePaste() {
1971
+ private fun handlePaste(plainTextOnly: Boolean) {
1133
1972
  if (editorId == 0L) {
1134
1973
  // Dev mode: default paste behavior.
1135
- super.onTextContextMenuItem(android.R.id.paste)
1974
+ super.onTextContextMenuItem(
1975
+ if (plainTextOnly) android.R.id.pasteAsPlainText else android.R.id.paste
1976
+ )
1136
1977
  return
1137
1978
  }
1979
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1980
+ if (!prepareForExternalEditorUpdate()) return
1138
1981
 
1139
1982
  val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
1140
1983
  ?: return
@@ -1145,18 +1988,69 @@ class EditorEditText @JvmOverloads constructor(
1145
1988
 
1146
1989
  // Try HTML first for rich paste.
1147
1990
  val htmlText = item.htmlText
1148
- if (htmlText != null) {
1991
+ if (!plainTextOnly && htmlText != null) {
1149
1992
  pasteHTML(htmlText)
1150
1993
  return
1151
1994
  }
1152
1995
 
1153
1996
  // Fallback to plain text.
1154
- val plainText = item.text?.toString()
1997
+ val plainText = item.text?.toString() ?: item.coerceToText(context)?.toString()
1155
1998
  if (plainText != null) {
1156
1999
  pastePlainText(plainText)
1157
2000
  }
1158
2001
  }
1159
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
+
1160
2054
  // ── Selection Change ────────────────────────────────────────────────
1161
2055
 
1162
2056
  /**
@@ -1175,23 +2069,35 @@ class EditorEditText @JvmOverloads constructor(
1175
2069
  ensureSelectionVisible()
1176
2070
  onSelectionOrContentMayChange?.invoke()
1177
2071
 
1178
- if (editorId == 0L) return
2072
+ syncCurrentSelectionToRust()
2073
+ }
2074
+
2075
+ private fun syncCurrentSelectionToRust() {
2076
+ if (!hasLiveEditor()) return
1179
2077
 
1180
2078
  val currentText = text?.toString() ?: ""
1181
2079
  if (currentText != lastAuthorizedText) return
1182
- val scalarAnchor = PositionBridge.utf16ToScalar(selStart, currentText)
1183
- val scalarHead = PositionBridge.utf16ToScalar(selEnd, currentText)
1184
-
1185
- // Sync selection to Rust (converts scalar→doc internally).
1186
- editorSetSelectionScalar(
1187
- editorId.toULong(),
1188
- scalarAnchor.toUInt(),
1189
- scalarHead.toUInt()
1190
- )
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
+ )
1191
2096
 
1192
- // Emit doc positions (not scalar offsets) to match the Selection contract.
1193
- val docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
1194
- 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
+ }
1195
2101
  editorListener?.onSelectionChanged(docAnchor, docHead)
1196
2102
  }
1197
2103
 
@@ -1201,6 +2107,7 @@ class EditorEditText @JvmOverloads constructor(
1201
2107
  * Insert text at a scalar position via the Rust editor.
1202
2108
  */
1203
2109
  private fun insertTextInRust(text: String, atScalarPos: Int) {
2110
+ if (!hasLiveEditor()) return
1204
2111
  onInsertTextInRustForTesting?.let { callback ->
1205
2112
  callback(text, atScalarPos)
1206
2113
  return
@@ -1210,6 +2117,7 @@ class EditorEditText @JvmOverloads constructor(
1210
2117
  }
1211
2118
 
1212
2119
  private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
2120
+ if (!hasLiveEditor()) return
1213
2121
  onReplaceTextInRustForTesting?.let { callback ->
1214
2122
  callback(scalarFrom, scalarTo, text)
1215
2123
  return
@@ -1223,6 +2131,322 @@ class EditorEditText @JvmOverloads constructor(
1223
2131
  applyUpdateJSON(updateJSON)
1224
2132
  }
1225
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
+
2237
+ private fun nativeTextMutationFromAuthorizedDiff(currentText: String): NativeTextMutation? {
2238
+ val authorizedText = lastAuthorizedText
2239
+ if (currentText == authorizedText) return null
2240
+
2241
+ var prefix = 0
2242
+ val sharedLength = minOf(authorizedText.length, currentText.length)
2243
+ while (
2244
+ prefix < sharedLength &&
2245
+ authorizedText[prefix] == currentText[prefix]
2246
+ ) {
2247
+ prefix++
2248
+ }
2249
+ prefix = minOf(
2250
+ PositionBridge.snapToScalarBoundary(prefix, authorizedText, biasForward = false),
2251
+ PositionBridge.snapToScalarBoundary(prefix, currentText, biasForward = false)
2252
+ )
2253
+
2254
+ var authorizedEnd = authorizedText.length
2255
+ var currentEnd = currentText.length
2256
+ while (
2257
+ authorizedEnd > prefix &&
2258
+ currentEnd > prefix &&
2259
+ authorizedText[authorizedEnd - 1] == currentText[currentEnd - 1]
2260
+ ) {
2261
+ authorizedEnd--
2262
+ currentEnd--
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
+ )
2274
+
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) }
2284
+ return NativeTextMutation(
2285
+ scalarFrom = PositionBridge.utf16ToScalar(prefix, authorizedText),
2286
+ scalarTo = PositionBridge.utf16ToScalar(authorizedEnd, authorizedText),
2287
+ replacementText = replacementText,
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
2359
+ )
2360
+ }
2361
+
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
+ }
2376
+
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
2405
+ }
2406
+
2407
+ private fun commitNativeTextMutation(mutation: NativeTextMutation) {
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()
2420
+
2421
+ recordImeTraceForTesting(
2422
+ "nativeMutationApply",
2423
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput"
2424
+ )
2425
+ if (mutation.replacementText.isEmpty()) {
2426
+ deleteRangeInRust(mutation.scalarFrom, mutation.scalarTo)
2427
+ } else {
2428
+ insertPlainTextRangeInRust(
2429
+ mutation.scalarFrom,
2430
+ mutation.scalarTo,
2431
+ mutation.replacementText
2432
+ )
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))
2448
+ }
2449
+
1226
2450
  /**
1227
2451
  * Delete a scalar range via the Rust editor.
1228
2452
  *
@@ -1230,6 +2454,7 @@ class EditorEditText @JvmOverloads constructor(
1230
2454
  * @param scalarTo End scalar offset (exclusive).
1231
2455
  */
1232
2456
  private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
2457
+ if (!hasLiveEditor()) return
1233
2458
  if (scalarFrom >= scalarTo) return
1234
2459
  onDeleteRangeInRustForTesting?.let { callback ->
1235
2460
  callback(scalarFrom, scalarTo)
@@ -1240,6 +2465,7 @@ class EditorEditText @JvmOverloads constructor(
1240
2465
  }
1241
2466
 
1242
2467
  private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
2468
+ if (!hasLiveEditor()) return
1243
2469
  onDeleteBackwardAtSelectionScalarInRustForTesting?.let { callback ->
1244
2470
  callback(scalarAnchor, scalarHead)
1245
2471
  return
@@ -1256,16 +2482,84 @@ class EditorEditText @JvmOverloads constructor(
1256
2482
  * Split a block at a scalar position via the Rust editor.
1257
2483
  */
1258
2484
  private fun splitBlockInRust(atScalarPos: Int) {
2485
+ if (!hasLiveEditor()) return
1259
2486
  val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
1260
2487
  applyUpdateJSON(updateJSON)
1261
2488
  }
1262
2489
 
1263
- 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>? {
1264
2505
  val currentText = text?.toString() ?: return null
1265
- return Pair(
1266
- PositionBridge.utf16ToScalar(selectionStart, currentText),
1267
- PositionBridge.utf16ToScalar(selectionEnd, currentText)
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>? {
2519
+ val currentText = text?.toString() ?: return null
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
1268
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)
1269
2563
  }
1270
2564
 
1271
2565
  fun selectedImageGeometry(): SelectedImageGeometry? {
@@ -1284,7 +2578,7 @@ class EditorEditText @JvmOverloads constructor(
1284
2578
  val textLayout = layout ?: return null
1285
2579
  val currentText = text?.toString() ?: return null
1286
2580
  val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
1287
- val docPos = if (editorId != 0L) {
2581
+ val docPos = if (hasLiveEditor()) {
1288
2582
  editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
1289
2583
  } else {
1290
2584
  0
@@ -1298,7 +2592,7 @@ class EditorEditText @JvmOverloads constructor(
1298
2592
  }
1299
2593
 
1300
2594
  fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
1301
- if (editorId == 0L) return
2595
+ if (!hasLiveEditor()) return
1302
2596
  val density = resources.displayMetrics.density
1303
2597
  val widthDp = maxOf(48, (widthPx / density).roundToInt())
1304
2598
  val heightDp = maxOf(48, (heightPx / density).roundToInt())
@@ -1312,7 +2606,7 @@ class EditorEditText @JvmOverloads constructor(
1312
2606
  }
1313
2607
 
1314
2608
  private fun isSelectionInsideList(): Boolean {
1315
- if (editorId == 0L) return false
2609
+ if (!hasLiveEditor()) return false
1316
2610
 
1317
2611
  return try {
1318
2612
  val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
@@ -1328,6 +2622,12 @@ class EditorEditText @JvmOverloads constructor(
1328
2622
  * Paste HTML content through Rust.
1329
2623
  */
1330
2624
  private fun pasteHTML(html: String) {
2625
+ if (!hasLiveEditor()) return
2626
+ syncCurrentSelectionToRust()
2627
+ onInsertContentHtmlInRustForTesting?.let { callback ->
2628
+ callback(html)
2629
+ return
2630
+ }
1331
2631
  val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
1332
2632
  applyUpdateJSON(updateJSON)
1333
2633
  }
@@ -1336,9 +2636,8 @@ class EditorEditText @JvmOverloads constructor(
1336
2636
  * Paste plain text through Rust.
1337
2637
  */
1338
2638
  private fun pastePlainText(text: String) {
1339
- val currentText = this.text?.toString() ?: ""
1340
- val scalarPos = PositionBridge.utf16ToScalar(selectionStart, currentText)
1341
- insertTextInRust(text, scalarPos)
2639
+ val (scalarStart, scalarEnd) = currentScalarSelection() ?: return
2640
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, text)
1342
2641
  }
1343
2642
 
1344
2643
  // ── Applying Rust State ─────────────────────────────────────────────
@@ -1409,6 +2708,8 @@ class EditorEditText @JvmOverloads constructor(
1409
2708
  replaceRange: RenderReplaceRange? = null,
1410
2709
  usedPatch: Boolean
1411
2710
  ) {
2711
+ val hadCompositionTracking = hasCompositionTrackingForEditor()
2712
+ var shouldRestartInput = false
1412
2713
  isApplyingRustState = true
1413
2714
  beginBatchEdit()
1414
2715
  try {
@@ -1418,11 +2719,22 @@ class EditorEditText @JvmOverloads constructor(
1418
2719
  setText(spannable)
1419
2720
  }
1420
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
+ }
1421
2731
  lastRenderAppliedPatchForTesting = usedPatch
2732
+ clearNativeTextMutationAfterBlurWindow()
1422
2733
  } finally {
1423
2734
  endBatchEdit()
1424
2735
  isApplyingRustState = false
1425
2736
  }
2737
+ restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
1426
2738
  }
1427
2739
 
1428
2740
  private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
@@ -1623,6 +2935,8 @@ class EditorEditText @JvmOverloads constructor(
1623
2935
  if (shouldSkipRender) {
1624
2936
  lastRenderAppliedPatchForTesting = false
1625
2937
  currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
2938
+ clearNativeTextMutationAdoptionSuppression()
2939
+ clearNativeTextMutationAfterBlurWindow()
1626
2940
  buildRenderNanos = 0L
1627
2941
  applyRenderNanos = 0L
1628
2942
  } else {
@@ -1843,7 +3157,10 @@ class EditorEditText @JvmOverloads constructor(
1843
3157
 
1844
3158
  override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
1845
3159
  super.onFocusChanged(focused, direction, previouslyFocusedRect)
1846
- if (!focused) {
3160
+ if (focused) {
3161
+ clearNativeTextMutationAfterBlurWindow()
3162
+ } else {
3163
+ beginNativeTextMutationAfterBlurWindow()
1847
3164
  clearExplicitSelectedImageRange()
1848
3165
  }
1849
3166
  }
@@ -1950,6 +3267,7 @@ class EditorEditText @JvmOverloads constructor(
1950
3267
  */
1951
3268
  private fun applySelectionFromJSON(selection: org.json.JSONObject) {
1952
3269
  val type = selection.optString("type", "") ?: return
3270
+ if (isEditorDestroyedForInput()) return
1953
3271
 
1954
3272
  isApplyingRustState = true
1955
3273
  try {
@@ -1961,12 +3279,12 @@ class EditorEditText @JvmOverloads constructor(
1961
3279
  // Convert doc positions to scalar offsets.
1962
3280
  val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
1963
3281
  val scalarHead = editorDocToScalar(editorId.toULong(), docHead.toUInt()).toInt()
1964
- val startUtf16 = PositionBridge.scalarToUtf16(minOf(scalarAnchor, scalarHead), currentText)
1965
- val endUtf16 = PositionBridge.scalarToUtf16(maxOf(scalarAnchor, scalarHead), currentText)
3282
+ val anchorUtf16 = PositionBridge.scalarToUtf16(scalarAnchor, currentText)
3283
+ val headUtf16 = PositionBridge.scalarToUtf16(scalarHead, currentText)
1966
3284
  val len = text?.length ?: 0
1967
3285
  setSelection(
1968
- startUtf16.coerceIn(0, len),
1969
- endUtf16.coerceIn(0, len)
3286
+ anchorUtf16.coerceIn(0, len),
3287
+ headUtf16.coerceIn(0, len)
1970
3288
  )
1971
3289
  }
1972
3290
  "node" -> {
@@ -2013,11 +3331,17 @@ class EditorEditText @JvmOverloads constructor(
2013
3331
 
2014
3332
  override fun afterTextChanged(s: Editable?) {
2015
3333
  if (isApplyingRustState) return
2016
- if (editorId == 0L) return
3334
+ if (!hasLiveEditor()) return
2017
3335
 
2018
3336
  val currentText = s?.toString() ?: ""
2019
3337
  if (currentText == lastAuthorizedText) return
2020
3338
 
3339
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
3340
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur = true)) {
3341
+ commitNativeTextMutation(mutation)
3342
+ return
3343
+ }
3344
+
2021
3345
  // Text has diverged from Rust's authorized state.
2022
3346
  reconciliationCount++
2023
3347
  Log.w(
@@ -2039,6 +3363,9 @@ class EditorEditText @JvmOverloads constructor(
2039
3363
  private const val DEFAULT_AUTO_CORRECT = true
2040
3364
  private const val DEFAULT_KEYBOARD_TYPE = "default"
2041
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
2042
3369
  private const val LOG_TAG = "NativeEditor"
2043
3370
  }
2044
3371
  }