@openeditor/react-native-prose-editor 0.0.2

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.
Files changed (91) hide show
  1. package/LICENSE +160 -0
  2. package/README.md +180 -0
  3. package/android/build.gradle +69 -0
  4. package/android/consumer-rules.pro +18 -0
  5. package/android/src/androidTest/AndroidManifest.xml +8 -0
  6. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +53 -0
  7. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +241 -0
  8. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +338 -0
  9. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +789 -0
  10. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +350 -0
  11. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +5 -0
  12. package/android/src/main/assets/editor-icons/MaterialIcons.json +2236 -0
  13. package/android/src/main/assets/editor-icons/MaterialIcons.ttf +0 -0
  14. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +50 -0
  15. package/android/src/main/java/com/apollohg/editor/EditorAddons.kt +135 -0
  16. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +4527 -0
  17. package/android/src/main/java/com/apollohg/editor/EditorHeightBehavior.kt +14 -0
  18. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +999 -0
  19. package/android/src/main/java/com/apollohg/editor/EditorTheme.kt +474 -0
  20. package/android/src/main/java/com/apollohg/editor/ImageResizeOverlayView.kt +199 -0
  21. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +3137 -0
  22. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +561 -0
  23. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +315 -0
  24. package/android/src/main/java/com/apollohg/editor/NativeToolbar.kt +1413 -0
  25. package/android/src/main/java/com/apollohg/editor/PositionBridge.kt +133 -0
  26. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +315 -0
  27. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +2172 -0
  28. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +399 -0
  29. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +482 -0
  30. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +137 -0
  31. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +321 -0
  32. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +4202 -0
  33. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +2581 -0
  34. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +27 -0
  35. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +197 -0
  36. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +84 -0
  37. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +544 -0
  38. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +461 -0
  39. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +2054 -0
  40. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +1367 -0
  41. package/app.plugin.js +62 -0
  42. package/dist/EditorTheme.d.ts +113 -0
  43. package/dist/EditorTheme.js +29 -0
  44. package/dist/EditorToolbar.d.ts +191 -0
  45. package/dist/EditorToolbar.js +1095 -0
  46. package/dist/NativeEditorBridge.d.ts +325 -0
  47. package/dist/NativeEditorBridge.js +933 -0
  48. package/dist/NativeProseViewer.d.ts +53 -0
  49. package/dist/NativeProseViewer.js +480 -0
  50. package/dist/NativeRichTextEditor.d.ts +183 -0
  51. package/dist/NativeRichTextEditor.js +2574 -0
  52. package/dist/YjsCollaboration.d.ts +90 -0
  53. package/dist/YjsCollaboration.js +743 -0
  54. package/dist/addons.d.ts +100 -0
  55. package/dist/addons.js +82 -0
  56. package/dist/heightCache.d.ts +6 -0
  57. package/dist/heightCache.js +45 -0
  58. package/dist/index.d.ts +10 -0
  59. package/dist/index.js +30 -0
  60. package/dist/schemas.d.ts +37 -0
  61. package/dist/schemas.js +263 -0
  62. package/dist/useNativeEditor.d.ts +42 -0
  63. package/dist/useNativeEditor.js +124 -0
  64. package/expo-module.config.json +9 -0
  65. package/ios/EditorAddons.swift +327 -0
  66. package/ios/EditorCore.xcframework/Info.plist +44 -0
  67. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  68. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  69. package/ios/EditorLayoutManager.swift +774 -0
  70. package/ios/EditorTheme.swift +515 -0
  71. package/ios/Generated_editor_core.swift +1513 -0
  72. package/ios/NativeEditorExpoView.swift +3593 -0
  73. package/ios/NativeEditorModule.swift +624 -0
  74. package/ios/NativeProseViewerExpoView.swift +276 -0
  75. package/ios/PositionBridge.swift +558 -0
  76. package/ios/ReactNativeProseEditor.podspec +49 -0
  77. package/ios/RenderBridge.swift +1708 -0
  78. package/ios/RichTextEditorView.swift +6150 -0
  79. package/ios/Tests/NativePerformanceTests.swift +687 -0
  80. package/ios/Tests/PositionBridgeTests.swift +706 -0
  81. package/ios/Tests/RenderBridgeTests.swift +2236 -0
  82. package/ios/Tests/RichTextEditorViewTests.swift +6548 -0
  83. package/ios/editor_coreFFI/editor_coreFFI.h +1289 -0
  84. package/ios/editor_coreFFI/module.modulemap +7 -0
  85. package/ios/editor_coreFFI.h +904 -0
  86. package/ios/editor_coreFFI.modulemap +7 -0
  87. package/package.json +74 -0
  88. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  89. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  90. package/rust/android/x86_64/libeditor_core.so +0 -0
  91. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2563 -0
@@ -0,0 +1,4527 @@
1
+ package com.openeditor.editor
2
+
3
+ import android.content.ClipData
4
+ import android.content.ClipboardManager
5
+ import android.content.Context
6
+ import android.graphics.Typeface
7
+ import android.graphics.Rect
8
+ import android.graphics.RectF
9
+ import android.os.Build
10
+ import android.os.Handler
11
+ import android.os.Looper
12
+ import android.os.SystemClock
13
+ import android.provider.Settings
14
+ import android.text.Annotation
15
+ import android.text.Editable
16
+ import android.text.InputType
17
+ import android.text.Layout
18
+ import android.text.Selection
19
+ import android.text.Spanned
20
+ import android.text.SpannableStringBuilder
21
+ import android.text.StaticLayout
22
+ import android.text.TextPaint
23
+ import android.text.TextWatcher
24
+ import android.text.style.AbsoluteSizeSpan
25
+ import android.text.style.BackgroundColorSpan
26
+ import android.text.style.ForegroundColorSpan
27
+ import android.text.style.StrikethroughSpan
28
+ import android.text.style.StyleSpan
29
+ import android.text.style.TypefaceSpan
30
+ import android.text.style.UnderlineSpan
31
+ import android.util.AttributeSet
32
+ import android.util.Log
33
+ import android.util.TypedValue
34
+ import android.view.KeyEvent
35
+ import android.view.MotionEvent
36
+ import android.view.inputmethod.BaseInputConnection
37
+ import android.view.inputmethod.EditorInfo
38
+ import android.view.inputmethod.InputConnection
39
+ import android.view.inputmethod.InputMethodManager
40
+ import androidx.appcompat.widget.AppCompatEditText
41
+ import kotlin.math.roundToInt
42
+ import uniffi.editor_core.* // UniFFI-generated bindings
43
+
44
+ /**
45
+ * Custom [AppCompatEditText] subclass that intercepts all text input and routes it
46
+ * through the Rust editor-core engine via UniFFI bindings.
47
+ *
48
+ * Instead of letting Android's EditText internal text storage handle insertions
49
+ * and deletions, this class captures the user's intent (typing, deleting,
50
+ * pasting, autocorrect) and sends it to the Rust editor. The Rust editor
51
+ * returns render elements, which are converted to [android.text.SpannableStringBuilder]
52
+ * via [RenderBridge] and applied back to the EditText.
53
+ *
54
+ * This is the "input interception" pattern: the EditText is effectively
55
+ * a rendering surface, not a text editing engine.
56
+ *
57
+ * ## Composition Handling
58
+ *
59
+ * For CJK input methods, swipe keyboards, and some autocorrect flows, composing
60
+ * text is rendered transiently by the base [InputConnection]. The final commit
61
+ * is routed through Rust against the original authorized selection.
62
+ *
63
+ * ## Thread Safety
64
+ *
65
+ * All EditText methods are called on the main thread. The UniFFI calls
66
+ * (`editor_insert_text`, `editor_delete_range`, etc.) are synchronous and
67
+ * fast enough for main-thread use.
68
+ */
69
+ class EditorEditText @JvmOverloads constructor(
70
+ context: Context,
71
+ attrs: AttributeSet? = null,
72
+ defStyleAttr: Int = android.R.attr.editTextStyle
73
+ ) : AppCompatEditText(context, attrs, defStyleAttr) {
74
+ data class ApplyUpdateTrace(
75
+ val attemptedPatch: Boolean,
76
+ val usedPatch: Boolean,
77
+ val skippedRender: Boolean,
78
+ val parseNanos: Long,
79
+ val resolveRenderBlocksNanos: Long,
80
+ val patchEligibilityNanos: Long,
81
+ val buildRenderNanos: Long,
82
+ val applyRenderNanos: Long,
83
+ val selectionNanos: Long,
84
+ val postApplyNanos: Long,
85
+ val totalNanos: Long
86
+ )
87
+
88
+ internal data class ImeInitialSurroundingText(
89
+ val text: String,
90
+ val selectionStart: Int,
91
+ val selectionEnd: Int,
92
+ val originalSelectionStart: Int,
93
+ val originalSelectionEnd: Int,
94
+ val removedPlaceholderCount: Int
95
+ )
96
+
97
+ data class SelectedImageGeometry(
98
+ val docPos: Int,
99
+ val rect: RectF
100
+ )
101
+
102
+ data class MentionHit(
103
+ val docPos: Int,
104
+ val label: String
105
+ )
106
+
107
+ data class CommandPreparation(
108
+ val ready: Boolean,
109
+ val updateJSON: String?
110
+ )
111
+
112
+ private data class HardwareKeyEventSignature(
113
+ val keyCode: Int,
114
+ val downTime: Long,
115
+ val repeatCount: Int
116
+ )
117
+
118
+ data class LinkHit(
119
+ val href: String,
120
+ val text: String
121
+ )
122
+
123
+ private data class ParsedRenderPatch(
124
+ val startIndex: Int,
125
+ val deleteCount: Int,
126
+ val renderBlocks: org.json.JSONArray
127
+ )
128
+
129
+ private data class RenderReplaceRange(
130
+ val start: Int,
131
+ val endExclusive: Int
132
+ )
133
+
134
+ private data class PatchApplyTrace(
135
+ val applied: Boolean,
136
+ val eligibilityNanos: Long,
137
+ val buildRenderNanos: Long,
138
+ val applyRenderNanos: Long
139
+ )
140
+
141
+ private data class ImageSelectionRange(
142
+ val start: Int,
143
+ val end: Int
144
+ )
145
+
146
+ private data class NativeTextMutation(
147
+ val scalarFrom: Int,
148
+ val scalarTo: Int,
149
+ val replacementText: String,
150
+ val resultingText: String,
151
+ val replacementStartUtf16: Int,
152
+ val replacementEndUtf16: Int,
153
+ val selectionScalarAnchor: Int?,
154
+ val selectionScalarHead: Int?
155
+ )
156
+
157
+ private data class NativeTextMutationAfterBlurWindow(
158
+ val editorId: Long,
159
+ val authorizedTextRevision: Long,
160
+ val deadlineMs: Long,
161
+ var didAdoptMutation: Boolean = false
162
+ )
163
+
164
+ private data class NativeTextMutationAdoptionSuppression(
165
+ val editorId: Long,
166
+ val authorizedTextRevision: Long
167
+ )
168
+
169
+ private interface TransientComposingTextStyleSpan
170
+
171
+ private class TransientComposingSizeSpan(sizePx: Int) :
172
+ AbsoluteSizeSpan(sizePx, false),
173
+ TransientComposingTextStyleSpan
174
+
175
+ private class TransientComposingColorSpan(color: Int) :
176
+ ForegroundColorSpan(color),
177
+ TransientComposingTextStyleSpan
178
+
179
+ private class TransientComposingTypefaceSpan(family: String) :
180
+ TypefaceSpan(family),
181
+ TransientComposingTextStyleSpan
182
+
183
+ private class TransientComposingStyleSpan(style: Int) :
184
+ StyleSpan(style),
185
+ TransientComposingTextStyleSpan
186
+
187
+ /**
188
+ * Listener interface for editor events, parallel to iOS's EditorTextViewDelegate.
189
+ */
190
+ interface EditorListener {
191
+ /** Called when the editor's selection changes (anchor and head as scalar offsets). */
192
+ fun onSelectionChanged(anchor: Int, head: Int)
193
+
194
+ /** Called when the editor content is updated after a Rust operation. */
195
+ fun onEditorUpdate(updateJSON: String)
196
+ }
197
+
198
+ /** The Rust editor instance ID (from editor_create / editor_create_with_max_length). */
199
+ var editorId: Long = 0
200
+
201
+ /**
202
+ * Controls whether user input is accepted.
203
+ *
204
+ * When false, all user-input mutation entry points (typing, deletion,
205
+ * paste, composition) are blocked. Unlike [isEnabled], this preserves
206
+ * focus, text selection, and copy capability.
207
+ */
208
+ var isEditable: Boolean = true
209
+ set(value) {
210
+ if (field == value) return
211
+ if (!value) {
212
+ discardTransientNativeInputForReadOnly()
213
+ }
214
+ field = value
215
+ if (value) {
216
+ restartInputForEditorIfFocused("editable")
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Guard flag to prevent re-entrant input interception while we're
222
+ * applying state from Rust (calling [setText] or modifying text storage).
223
+ */
224
+ var isApplyingRustState = false
225
+
226
+ /** Listener for editor events. */
227
+ var editorListener: EditorListener? = null
228
+ var onSelectionOrContentMayChange: (() -> Unit)? = null
229
+
230
+ /** The base font size in pixels used for unstyled text. */
231
+ private var baseFontSize: Float = textSize
232
+
233
+ /** The base text color as an ARGB int. */
234
+ private var baseTextColor: Int = currentTextColor
235
+
236
+ /** The base background color before theme overrides. */
237
+ private var baseBackgroundColor: Int = android.graphics.Color.WHITE
238
+
239
+ /** Optional render theme supplied by React. */
240
+ var theme: EditorTheme? = null
241
+ private set
242
+
243
+ var placeholderText: String = ""
244
+ set(value) {
245
+ if (field == value) return
246
+ field = value
247
+ requestLayout()
248
+ invalidate()
249
+ }
250
+
251
+ var heightBehavior: EditorHeightBehavior = EditorHeightBehavior.FIXED
252
+ private set
253
+ private var imageResizingEnabled = true
254
+ private var nativeAutoCapitalize = DEFAULT_AUTO_CAPITALIZE
255
+ private var nativeAutoCorrect = DEFAULT_AUTO_CORRECT
256
+ private var nativeKeyboardType = DEFAULT_KEYBOARD_TYPE
257
+
258
+ private var contentInsets: EditorContentInsets? = null
259
+ private var viewportBottomInsetPx: Int = 0
260
+
261
+ /**
262
+ * The plain text from the last Rust-authorized render.
263
+ * Used by [ReconciliationWatcher] to detect unauthorized divergence.
264
+ */
265
+ private var lastAuthorizedText: String = ""
266
+
267
+ /**
268
+ * Number of reconciliation events triggered during this EditText's lifetime.
269
+ * Useful for monitoring and kill-condition analysis.
270
+ */
271
+ var reconciliationCount: Int = 0
272
+ private set
273
+
274
+ private var lastHandledHardwareKeySignature: HardwareKeyEventSignature? = null
275
+ private var recentHandledHardwareKeyDownSignature: HardwareKeyEventSignature? = null
276
+ private var recentHandledHardwareKeyDownUptimeMs: Long = 0L
277
+ private var activeInputConnection: EditorInputConnection? = null
278
+ private var inputConnectionGeneration: Long = 0L
279
+ private var composingText: String? = null
280
+ private var composingReplacementStartUtf16: Int? = null
281
+ private var composingReplacementEndUtf16: Int? = null
282
+ private var composingReplacementAuthorizedTextRevision: Long? = null
283
+ private var didInvalidateCompositionReplacementRange = false
284
+ private var nativeTextMutationAfterBlurWindow: NativeTextMutationAfterBlurWindow? = null
285
+ private var nativeTextMutationAdoptionSuppression: NativeTextMutationAdoptionSuppression? = null
286
+ private var lastAuthorizedTextRevision: Long = 0L
287
+ private var lastAuthorizedRenderedText: CharSequence? = null
288
+ private var explicitSelectedImageRange: ImageSelectionRange? = null
289
+ private var lastRenderAppliedPatchForTesting: Boolean = false
290
+ internal var captureApplyUpdateTraceForTesting: Boolean = false
291
+ private var lastApplyUpdateTraceForTesting: ApplyUpdateTrace? = null
292
+ private val imeTraceForTesting = java.util.ArrayDeque<String>()
293
+ private var imeTraceSequence: Long = 0L
294
+ private var lastImeTraceUptimeMs: Long = 0L
295
+ private var currentRenderBlocksJson: org.json.JSONArray? = null
296
+ private var renderAppearanceRevision: Long = 1L
297
+ private var lastAppliedRenderAppearanceRevision: Long = 0L
298
+ private var pendingOptimisticRenderText: String? = null
299
+ private var deferredRustUpdateApplicationDepth: Int = 0
300
+ private var deferredRustUpdateJSON: String? = null
301
+ private var deferredRustUpdateGeneration: Long = 0L
302
+ private var lineBoundaryInputRefreshGeneration: Long = 0L
303
+ private var restartInputSelectionUpdateGeneration: Long = 0L
304
+ internal var onDeleteRangeInRustForTesting: ((Int, Int) -> Unit)? = null
305
+ internal var onDeleteBackwardAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
306
+ internal var onToggleTaskItemCheckedAtSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
307
+ internal var onInsertTextInRustForTesting: ((String, Int) -> Unit)? = null
308
+ internal var onReplaceTextInRustForTesting: ((Int, Int, String) -> Unit)? = null
309
+ internal var onSetSelectionScalarInRustForTesting: ((Int, Int) -> Unit)? = null
310
+ internal var onDeleteAndSplitScalarInRustForTesting: ((Int, Int) -> Unit)? = null
311
+ internal var onInsertContentHtmlInRustForTesting: ((String) -> Unit)? = null
312
+ internal var onInsertContentJsonAtSelectionScalarForTesting: ((Int, Int, String) -> Unit)? = null
313
+ internal var blockExternalEditorUpdatePreparationForTesting = false
314
+ internal var blockExternalEditorCommandPreparationForTesting = false
315
+
316
+ fun lastRenderAppliedPatch(): Boolean = lastRenderAppliedPatchForTesting
317
+ fun lastApplyUpdateTrace(): ApplyUpdateTrace? = lastApplyUpdateTraceForTesting
318
+ internal fun hasDeferredRustUpdateApplicationForTesting(): Boolean = deferredRustUpdateJSON != null
319
+
320
+ internal fun applyRustUpdateJSONForTesting(updateJSON: String) {
321
+ applyRustUpdateJSON(updateJSON)
322
+ }
323
+
324
+ internal fun recordImeTraceForTesting(event: String, details: String = "") {
325
+ if (imeTraceForTesting.size >= IME_TRACE_LIMIT_FOR_TESTING) {
326
+ imeTraceForTesting.removeFirst()
327
+ }
328
+ imeTraceForTesting.addLast(
329
+ if (details.isEmpty()) event else "$event:$details"
330
+ )
331
+ if (Log.isLoggable(IME_TRACE_LOG_TAG, Log.VERBOSE)) {
332
+ val now = SystemClock.uptimeMillis()
333
+ val deltaMs = if (lastImeTraceUptimeMs == 0L) 0L else now - lastImeTraceUptimeMs
334
+ lastImeTraceUptimeMs = now
335
+ imeTraceSequence += 1L
336
+ val textLength = text?.length ?: -1
337
+ val selection = "${selectionStart}..${selectionEnd}"
338
+ val composingRange = "${composingReplacementStartUtf16 ?: -1}.." +
339
+ "${composingReplacementEndUtf16 ?: -1}"
340
+ val composingRevisionMatches =
341
+ composingReplacementAuthorizedTextRevision == lastAuthorizedTextRevision
342
+ val message = buildString {
343
+ append("#").append(imeTraceSequence)
344
+ append(" +").append(deltaMs).append("ms ")
345
+ append(event)
346
+ if (details.isNotEmpty()) {
347
+ append(" ").append(details)
348
+ }
349
+ append(" editor=").append(editorId)
350
+ append(" gen=").append(inputConnectionGeneration)
351
+ append(" activeIc=").append(activeInputConnection != null)
352
+ append(" focus=").append(hasFocus())
353
+ append(" applying=").append(isApplyingRustState)
354
+ append(" editable=").append(isEditable)
355
+ append(" textLen=").append(textLength)
356
+ append(" authLen=").append(lastAuthorizedText.length)
357
+ append(" sel=").append(selection)
358
+ append(" composingTextLen=").append(composingText?.length ?: -1)
359
+ append(" composingRange=").append(composingRange)
360
+ append(" composingRevOk=").append(composingRevisionMatches)
361
+ append(" invalidComp=").append(didInvalidateCompositionReplacementRange)
362
+ append(" deferredRustUpdate=").append(deferredRustUpdateJSON != null)
363
+ append(" scroll=").append(scrollX).append(",").append(scrollY)
364
+ }
365
+ Log.v(IME_TRACE_LOG_TAG, message)
366
+ }
367
+ }
368
+
369
+ internal fun clearImeTraceForTesting() {
370
+ imeTraceForTesting.clear()
371
+ imeTraceSequence = 0L
372
+ lastImeTraceUptimeMs = 0L
373
+ }
374
+
375
+ internal fun imeTraceSnapshotForTesting(): List<String> =
376
+ imeTraceForTesting.toList()
377
+
378
+ private fun nanosToMicros(nanos: Long): Long = nanos / 1_000L
379
+
380
+ init {
381
+ // Configure for rich text editing.
382
+ inputType = resolvedInputType()
383
+
384
+ // Disable built-in spell checking to avoid conflicts with Rust state.
385
+ // The Rust editor is the source of truth for text content.
386
+ isSaveEnabled = false
387
+
388
+ // Watch for unauthorized text mutations (IME, accessibility, etc.)
389
+ // and reconcile back to Rust's authoritative state.
390
+ addTextChangedListener(ReconciliationWatcher())
391
+ baseBackgroundColor = android.graphics.Color.WHITE
392
+ isVerticalScrollBarEnabled = true
393
+ overScrollMode = OVER_SCROLL_IF_CONTENT_SCROLLS
394
+
395
+ // Pin content to top-start to prevent theme-dependent vertical centering.
396
+ gravity = android.view.Gravity.TOP or android.view.Gravity.START
397
+
398
+ // Strip the default EditText theme drawable which carries implicit padding.
399
+ // Background color is applied in setBaseStyle() / applyTheme().
400
+ background = null
401
+ linksClickable = false
402
+
403
+ // Suppress the platform caret and draw our own. Android's Editor anchors
404
+ // the native caret to getLineBottom(line), which a ParagraphSpacerSpan
405
+ // inflates — stretching the caret into the inter-block gap. Our caret is
406
+ // clipped to the glyph height via [CaretGeometry]. See [drawCustomCaret].
407
+ isCursorVisible = false
408
+
409
+ updateEffectivePadding()
410
+ }
411
+
412
+ // ── Custom caret ────────────────────────────────────────────────────────
413
+
414
+ private var caretBlinkVisible = true
415
+ private val caretPaint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG)
416
+ private val caretWidthPx: Float by lazy { maxOf(MIN_CARET_WIDTH_PX, resources.displayMetrics.density) }
417
+ private val caretColor: Int by lazy { resolveCaretColor() }
418
+ private val caretBlinkRunnable = object : Runnable {
419
+ override fun run() {
420
+ caretBlinkVisible = !caretBlinkVisible
421
+ invalidate()
422
+ postDelayed(this, CARET_BLINK_INTERVAL_MS)
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Reset the caret to solid-on and (re)schedule the blink. Called whenever the
428
+ * caret could appear or move (focus, window focus, selection change) so it
429
+ * behaves like the platform caret: solid immediately after a move, then blinks.
430
+ */
431
+ private fun restartCaretBlink() {
432
+ removeCallbacks(caretBlinkRunnable)
433
+ caretBlinkVisible = true
434
+ if (CaretGeometry.shouldRender(isFocused, hasWindowFocus(), selectionStart, selectionEnd)) {
435
+ postDelayed(caretBlinkRunnable, CARET_BLINK_INTERVAL_MS)
436
+ }
437
+ invalidate()
438
+ }
439
+
440
+ private fun stopCaretBlink() {
441
+ removeCallbacks(caretBlinkRunnable)
442
+ caretBlinkVisible = false
443
+ invalidate()
444
+ }
445
+
446
+ private fun drawCustomCaret(canvas: android.graphics.Canvas) {
447
+ if (!caretBlinkVisible) return
448
+ if (!CaretGeometry.shouldRender(isFocused, hasWindowFocus(), selectionStart, selectionEnd)) return
449
+ val rect = customCaretDrawRect() ?: return
450
+ caretPaint.color = caretColor
451
+ canvas.drawRect(rect.left, rect.top, rect.right, rect.bottom, caretPaint)
452
+ }
453
+
454
+ /**
455
+ * The caret rectangle in canvas (content) coordinates — clipped to the glyph
456
+ * height via [CaretGeometry]. No scroll offset is applied: at onDraw time the
457
+ * framework canvas is already in scrolled content space, exactly like the text
458
+ * Layout it paints. (This differs from [caretRect], which reports view-relative
459
+ * coordinates to JS.)
460
+ */
461
+ internal fun customCaretDrawRect(): RectF? {
462
+ val textLayout = layout ?: return null
463
+ val offset = selectionEnd.coerceIn(0, textLayout.text.length)
464
+ val bounds = CaretGeometry.verticalBounds(textLayout, offset, paint)
465
+ val left = totalPaddingLeft + textLayout.getPrimaryHorizontal(offset)
466
+ return RectF(left, totalPaddingTop + bounds.top, left + caretWidthPx, totalPaddingTop + bounds.bottom)
467
+ }
468
+
469
+ /**
470
+ * The native caret is tinted by the theme's `colorControlActivated`; resolve
471
+ * the same value so the replacement keeps the platform appearance, falling
472
+ * back to the text color when the attribute is not a color.
473
+ */
474
+ private fun resolveCaretColor(): Int {
475
+ val resolved = TypedValue()
476
+ val found = context.theme.resolveAttribute(
477
+ android.R.attr.colorControlActivated,
478
+ resolved,
479
+ true
480
+ )
481
+ val isColor = resolved.type >= TypedValue.TYPE_FIRST_COLOR_INT &&
482
+ resolved.type <= TypedValue.TYPE_LAST_COLOR_INT
483
+ return if (found && isColor) resolved.data else currentTextColor
484
+ }
485
+
486
+ fun setAutoCapitalize(autoCapitalize: String?) {
487
+ val next = when (autoCapitalize) {
488
+ "none",
489
+ "sentences",
490
+ "words",
491
+ "characters" -> autoCapitalize
492
+ else -> DEFAULT_AUTO_CAPITALIZE
493
+ }
494
+ if (nativeAutoCapitalize == next) return
495
+ nativeAutoCapitalize = next
496
+ applyInputTraits()
497
+ }
498
+
499
+ fun setAutoCorrect(autoCorrect: Boolean?) {
500
+ val next = autoCorrect ?: DEFAULT_AUTO_CORRECT
501
+ if (nativeAutoCorrect == next) return
502
+ nativeAutoCorrect = next
503
+ applyInputTraits()
504
+ }
505
+
506
+ fun setKeyboardType(keyboardType: String?) {
507
+ val next = when (keyboardType) {
508
+ "default",
509
+ "email-address",
510
+ "numeric",
511
+ "phone-pad",
512
+ "ascii-capable",
513
+ "numbers-and-punctuation",
514
+ "url",
515
+ "number-pad",
516
+ "name-phone-pad",
517
+ "decimal-pad",
518
+ "twitter",
519
+ "web-search",
520
+ "visible-password",
521
+ "ascii-capable-number-pad" -> keyboardType
522
+ else -> DEFAULT_KEYBOARD_TYPE
523
+ }
524
+ if (nativeKeyboardType == next) return
525
+ nativeKeyboardType = next
526
+ applyInputTraits()
527
+ }
528
+
529
+ private fun applyInputTraits() {
530
+ val nextInputType = resolvedInputType()
531
+ if (inputType == nextInputType) return
532
+
533
+ val currentStart = selectionStart
534
+ val currentEnd = selectionEnd
535
+ val authorizedSelection = authorizedSelectionForTransientInputRestore(
536
+ currentStart,
537
+ currentEnd
538
+ )
539
+ discardTransientInputAndRestoreAuthorizedTextForEditor()
540
+ setRawInputType(nextInputType)
541
+
542
+ val editable = text
543
+ if (editable != null && authorizedSelection != null) {
544
+ setSelection(
545
+ authorizedSelection.first.coerceIn(0, editable.length),
546
+ authorizedSelection.second.coerceIn(0, editable.length)
547
+ )
548
+ }
549
+
550
+ if (hasFocus()) {
551
+ restartInputForEditor()
552
+ }
553
+ }
554
+
555
+ private fun resolvedInputType(): Int {
556
+ var nextInputType = when (nativeKeyboardType) {
557
+ "email-address" -> InputType.TYPE_CLASS_TEXT or
558
+ InputType.TYPE_TEXT_VARIATION_EMAIL_ADDRESS
559
+ "url" -> InputType.TYPE_CLASS_TEXT or
560
+ InputType.TYPE_TEXT_VARIATION_URI
561
+ "phone-pad" -> InputType.TYPE_CLASS_PHONE
562
+ "number-pad" -> InputType.TYPE_CLASS_NUMBER
563
+ "decimal-pad" -> InputType.TYPE_CLASS_NUMBER or
564
+ InputType.TYPE_NUMBER_FLAG_DECIMAL
565
+ "numeric" -> InputType.TYPE_CLASS_NUMBER or
566
+ InputType.TYPE_NUMBER_FLAG_DECIMAL or
567
+ InputType.TYPE_NUMBER_FLAG_SIGNED
568
+ "visible-password" -> InputType.TYPE_CLASS_TEXT or
569
+ InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD
570
+ else -> InputType.TYPE_CLASS_TEXT
571
+ }
572
+
573
+ if ((nextInputType and InputType.TYPE_MASK_CLASS) == InputType.TYPE_CLASS_TEXT) {
574
+ nextInputType = nextInputType or InputType.TYPE_TEXT_FLAG_MULTI_LINE
575
+ nextInputType = nextInputType or when (nativeAutoCapitalize) {
576
+ "none" -> 0
577
+ "words" -> InputType.TYPE_TEXT_FLAG_CAP_WORDS
578
+ "characters" -> InputType.TYPE_TEXT_FLAG_CAP_CHARACTERS
579
+ else -> InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
580
+ }
581
+ nextInputType = nextInputType or if (nativeAutoCorrect) {
582
+ InputType.TYPE_TEXT_FLAG_AUTO_CORRECT
583
+ } else {
584
+ InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
585
+ }
586
+ }
587
+
588
+ return nextInputType
589
+ }
590
+
591
+ // ── InputConnection Override ────────────────────────────────────────
592
+
593
+ /**
594
+ * Create a custom [EditorInputConnection] that intercepts all input
595
+ * from the soft keyboard.
596
+ */
597
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? {
598
+ val baseConnection = super.onCreateInputConnection(outAttrs) ?: return null
599
+ val originalInitialCapsMode = outAttrs.initialCapsMode
600
+ outAttrs.initialCapsMode = cursorCapsModeForEditor(
601
+ reqModes = outAttrs.inputType,
602
+ baseCapsMode = outAttrs.initialCapsMode
603
+ )
604
+ val initialSurroundingText = applyInitialSurroundingTextForIme(outAttrs)
605
+ val generation = nextInputConnectionGenerationForEditor()
606
+ recordImeTraceForTesting(
607
+ "createInputConnection",
608
+ "boundEditor=$editorId boundGen=$generation inputType=$inputType initialCaps=$originalInitialCapsMode->${outAttrs.initialCapsMode} " +
609
+ "imeContextPlaceholdersRemoved=${initialSurroundingText?.removedPlaceholderCount ?: 0} " +
610
+ "imeContextSel=${initialSurroundingText?.selectionStart ?: outAttrs.initialSelStart}..${initialSurroundingText?.selectionEnd ?: outAttrs.initialSelEnd} " +
611
+ "imeContextRawSel=${initialSurroundingText?.originalSelectionStart ?: selectionStart}..${initialSurroundingText?.originalSelectionEnd ?: selectionEnd} " +
612
+ "imeContextBeforeTail=\"${initialSurroundingText?.textBeforeSelectionTailForImeLog() ?: ""}\""
613
+ )
614
+ return EditorInputConnection(this, baseConnection, editorId, generation).also {
615
+ activeInputConnection = it
616
+ }
617
+ }
618
+
619
+ private fun applyInitialSurroundingTextForIme(outAttrs: EditorInfo): ImeInitialSurroundingText? {
620
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return null
621
+ val initialText = initialSurroundingTextForImeForEditor() ?: return null
622
+
623
+ outAttrs.initialSelStart = initialText.selectionStart
624
+ outAttrs.initialSelEnd = initialText.selectionEnd
625
+ outAttrs.setInitialSurroundingText(initialText.text)
626
+ return initialText
627
+ }
628
+
629
+ private fun ImeInitialSurroundingText.textBeforeSelectionTailForImeLog(limit: Int = 24): String {
630
+ val end = selectionStart.coerceIn(0, text.length)
631
+ val start = maxOf(0, end - limit)
632
+ return text.substring(start, end).toImeTraceSnippet()
633
+ }
634
+
635
+ private fun String.toImeTraceSnippet(): String {
636
+ val builder = StringBuilder(length)
637
+ forEach { ch ->
638
+ when (ch) {
639
+ '\n' -> builder.append("\\n")
640
+ '\r' -> builder.append("\\r")
641
+ '\t' -> builder.append("\\t")
642
+ '\\' -> builder.append("\\\\")
643
+ '"' -> builder.append("\\\"")
644
+ else -> {
645
+ if (ch.code < 0x20 || ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]) {
646
+ builder.append("\\u")
647
+ builder.append(ch.code.toString(16).padStart(4, '0'))
648
+ } else {
649
+ builder.append(ch)
650
+ }
651
+ }
652
+ }
653
+ }
654
+ return builder.toString()
655
+ }
656
+
657
+ override fun dispatchKeyEvent(event: KeyEvent): Boolean {
658
+ if (!isEditable && isReadOnlyTextMutationKeyEvent(event)) {
659
+ return true
660
+ }
661
+ if (handleCompositionKeyEvent(event) { super.dispatchKeyEvent(event) }) {
662
+ return true
663
+ }
664
+ if (handleHardwareKeyEvent(event)) {
665
+ return true
666
+ }
667
+ if (handlePrintableHardwareKeyEvent(event) { super.dispatchKeyEvent(event) }) {
668
+ return true
669
+ }
670
+ return super.dispatchKeyEvent(event)
671
+ }
672
+
673
+ internal fun handleCompositionKeyEvent(event: KeyEvent, applyBaseEvent: () -> Boolean): Boolean {
674
+ val inputConnection = activeInputConnection ?: return false
675
+ if (!inputConnection.hasPendingComposition()) return false
676
+ if (!isCompositionKeyCode(event.keyCode)) return false
677
+ if (event.action == KeyEvent.ACTION_DOWN) {
678
+ val signature = hardwareKeyEventSignature(event)
679
+ if (
680
+ lastHandledHardwareKeySignature == signature ||
681
+ didRecentlyHandleHardwareKeyDown(signature)
682
+ ) {
683
+ return true
684
+ }
685
+ markHandledHardwareKeyDown(signature)
686
+ runWithTransientInputMutationGuard {
687
+ when (event.keyCode) {
688
+ KeyEvent.KEYCODE_DEL,
689
+ KeyEvent.KEYCODE_FORWARD_DEL -> inputConnection.deleteTransientTextForHardwareKeyEvent(event)
690
+ else -> applyBaseEvent()
691
+ }
692
+ }
693
+ inputConnection.refreshComposingTextFromEditableForEditor()
694
+ return true
695
+ }
696
+ if (event.action == KeyEvent.ACTION_UP) {
697
+ if (lastHandledHardwareKeySignature?.let {
698
+ it.keyCode == event.keyCode && it.downTime == event.downTime
699
+ } == true) {
700
+ lastHandledHardwareKeySignature = null
701
+ }
702
+ return true
703
+ }
704
+ return false
705
+ }
706
+
707
+ private fun isCompositionKeyCode(keyCode: Int): Boolean =
708
+ when (keyCode) {
709
+ KeyEvent.KEYCODE_DEL,
710
+ KeyEvent.KEYCODE_FORWARD_DEL,
711
+ KeyEvent.KEYCODE_ENTER,
712
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
713
+ KeyEvent.KEYCODE_TAB -> true
714
+ else -> false
715
+ }
716
+
717
+ override fun onDraw(canvas: android.graphics.Canvas) {
718
+ super.onDraw(canvas)
719
+ drawCustomCaret(canvas)
720
+
721
+ val placeholderLayout =
722
+ buildPlaceholderLayout(width - compoundPaddingLeft - compoundPaddingRight) ?: return
723
+
724
+ val previousColor = paint.color
725
+ val saveCount = canvas.save()
726
+ canvas.translate(compoundPaddingLeft.toFloat(), extendedPaddingTop.toFloat())
727
+ placeholderLayout.draw(canvas)
728
+ canvas.restoreToCount(saveCount)
729
+ paint.color = previousColor
730
+ }
731
+
732
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
733
+ super.onMeasure(widthMeasureSpec, heightMeasureSpec)
734
+
735
+ val placeholderHeight = resolvePlaceholderHeightForMeasuredWidth(measuredWidth) ?: return
736
+ val desiredHeight = maxOf(measuredHeight, placeholderHeight)
737
+ val resolvedHeight = when (MeasureSpec.getMode(heightMeasureSpec)) {
738
+ MeasureSpec.EXACTLY -> measuredHeight
739
+ MeasureSpec.AT_MOST -> desiredHeight.coerceAtMost(MeasureSpec.getSize(heightMeasureSpec))
740
+ else -> desiredHeight
741
+ }
742
+
743
+ if (resolvedHeight != measuredHeight) {
744
+ setMeasuredDimension(measuredWidth, resolvedHeight)
745
+ }
746
+ }
747
+
748
+ override fun onTouchEvent(event: MotionEvent): Boolean {
749
+ if (event.actionMasked == MotionEvent.ACTION_DOWN && imageSpanHitAt(event.x, event.y) == null) {
750
+ clearExplicitSelectedImageRange()
751
+ }
752
+ if (handleTaskListMarkerTap(event)) {
753
+ return true
754
+ }
755
+ if (handleImageTap(event)) {
756
+ return true
757
+ }
758
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
759
+ val canScroll = canScrollVertically(-1) || canScrollVertically(1)
760
+ if (canScroll) {
761
+ when (event.actionMasked) {
762
+ MotionEvent.ACTION_DOWN,
763
+ MotionEvent.ACTION_MOVE -> parent?.requestDisallowInterceptTouchEvent(true)
764
+ MotionEvent.ACTION_UP,
765
+ MotionEvent.ACTION_CANCEL -> parent?.requestDisallowInterceptTouchEvent(false)
766
+ }
767
+ }
768
+ }
769
+ return super.onTouchEvent(event)
770
+ }
771
+
772
+ override fun performClick(): Boolean {
773
+ return super.performClick()
774
+ }
775
+
776
+ private fun isRenderedContentEmpty(content: CharSequence? = text): Boolean {
777
+ val renderedContent = content ?: return true
778
+ if (renderedContent.isEmpty()) return true
779
+
780
+ for (index in 0 until renderedContent.length) {
781
+ when (renderedContent[index]) {
782
+ EMPTY_BLOCK_PLACEHOLDER, '\n', '\r' -> continue
783
+ else -> return false
784
+ }
785
+ }
786
+
787
+ return true
788
+ }
789
+
790
+ private fun shouldDisplayPlaceholder(): Boolean {
791
+ return placeholderText.isNotEmpty() && isRenderedContentEmpty()
792
+ }
793
+
794
+ fun shouldDisplayPlaceholderForTesting(): Boolean = shouldDisplayPlaceholder()
795
+
796
+ private fun buildPlaceholderLayout(availableWidth: Int): StaticLayout? {
797
+ if (!shouldDisplayPlaceholder()) return null
798
+ if (availableWidth <= 0) return null
799
+
800
+ val placeholderPaint = resolvedPlaceholderPaint()
801
+ return StaticLayout.Builder
802
+ .obtain(
803
+ placeholderText,
804
+ 0,
805
+ placeholderText.length,
806
+ placeholderPaint,
807
+ availableWidth
808
+ )
809
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
810
+ .setIncludePad(includeFontPadding)
811
+ .build()
812
+ }
813
+
814
+ private fun resolvedPlaceholderPaint(): TextPaint {
815
+ val textStyle = theme?.effectiveTextStyle("paragraph")
816
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
817
+ val resolvedTypeface = resolvePlaceholderTypeface(textStyle)
818
+
819
+ return TextPaint(paint).apply {
820
+ color = theme?.placeholderColor ?: currentHintTextColor
821
+ textSize = resolvedTextSize
822
+ typeface = resolvedTypeface
823
+ }
824
+ }
825
+
826
+ private fun resolvePlaceholderTypeface(textStyle: EditorTextStyle?): Typeface {
827
+ val baseTypeface = typeface ?: Typeface.DEFAULT
828
+ val requestedStyle = textStyle?.typefaceStyle() ?: Typeface.NORMAL
829
+ val family = textStyle?.fontFamily?.takeIf { it.isNotBlank() }
830
+
831
+ return when {
832
+ family != null -> Typeface.create(family, requestedStyle)
833
+ requestedStyle != Typeface.NORMAL -> Typeface.create(baseTypeface, requestedStyle)
834
+ else -> baseTypeface
835
+ }
836
+ }
837
+
838
+ private fun resolvePlaceholderHeightForMeasuredWidth(widthPx: Int): Int? {
839
+ val availableWidth = (widthPx - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
840
+ return resolvePlaceholderHeightForAvailableWidth(availableWidth)
841
+ }
842
+
843
+ private fun resolvePlaceholderHeightForAvailableWidth(availableWidth: Int): Int? {
844
+ val placeholderLayout = buildPlaceholderLayout(availableWidth) ?: return null
845
+ val placeholderHeight = placeholderLayout.height.takeIf { it > 0 } ?: lineHeight
846
+ return placeholderHeight + compoundPaddingTop + compoundPaddingBottom
847
+ }
848
+
849
+ // ── Editor Binding ──────────────────────────────────────────────────
850
+
851
+ /**
852
+ * Bind this EditText to a Rust editor instance and optionally apply initial content.
853
+ *
854
+ * @param id The editor ID from `editor_create()`.
855
+ * @param initialHTML Optional HTML to set as initial content.
856
+ */
857
+ fun bindEditor(id: Long, initialHTML: String? = null, notifyListener: Boolean = true) {
858
+ if (id != 0L && NativeEditorViewRegistry.isDestroyed(id)) {
859
+ discardTransientNativeInputForEditorRebind()
860
+ editorId = 0L
861
+ return
862
+ }
863
+ if (editorId != id) {
864
+ discardTransientNativeInputForEditorRebind()
865
+ }
866
+ editorId = id
867
+
868
+ if (!initialHTML.isNullOrEmpty()) {
869
+ editorSetHtml(editorId.toULong(), initialHTML)
870
+ val stateJSON = editorGetCurrentState(editorId.toULong())
871
+ applyUpdateJSON(stateJSON, notifyListener = false)
872
+ } else {
873
+ // Pull current state from Rust (content may already be loaded via bridge).
874
+ val stateJSON = editorGetCurrentState(editorId.toULong())
875
+ applyUpdateJSON(stateJSON, notifyListener = notifyListener)
876
+ }
877
+ }
878
+
879
+ /**
880
+ * Unbind from the current editor instance.
881
+ */
882
+ fun unbindEditor() {
883
+ if (editorId != 0L) {
884
+ discardTransientNativeInputForEditorRebind()
885
+ }
886
+ editorId = 0
887
+ }
888
+
889
+ fun setBaseStyle(fontSizePx: Float, textColor: Int, backgroundColor: Int) {
890
+ if (baseFontSize != fontSizePx || baseTextColor != textColor) {
891
+ renderAppearanceRevision += 1L
892
+ }
893
+ baseFontSize = fontSizePx
894
+ baseTextColor = textColor
895
+ baseBackgroundColor = backgroundColor
896
+ setTextSize(TypedValue.COMPLEX_UNIT_PX, fontSizePx)
897
+ setTextColor(textColor)
898
+ setBackgroundColor(theme?.backgroundColor ?: backgroundColor)
899
+ }
900
+
901
+ fun applyTheme(theme: EditorTheme?) {
902
+ this.theme = theme
903
+ renderAppearanceRevision += 1L
904
+ setBackgroundColor(theme?.backgroundColor ?: baseBackgroundColor)
905
+ applyContentInsets(theme?.contentInsets)
906
+ if (hasLiveEditor()) {
907
+ val previousScrollX = scrollX
908
+ val previousScrollY = scrollY
909
+ val stateJSON = editorGetCurrentState(editorId.toULong())
910
+ applyUpdateJSON(stateJSON, notifyListener = false)
911
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
912
+ preserveScrollPosition(previousScrollX, previousScrollY)
913
+ } else {
914
+ requestLayout()
915
+ }
916
+ }
917
+ }
918
+
919
+ fun setHeightBehavior(heightBehavior: EditorHeightBehavior) {
920
+ if (this.heightBehavior == heightBehavior) return
921
+ this.heightBehavior = heightBehavior
922
+ isVerticalScrollBarEnabled = heightBehavior == EditorHeightBehavior.FIXED
923
+ overScrollMode = if (heightBehavior == EditorHeightBehavior.FIXED) {
924
+ OVER_SCROLL_IF_CONTENT_SCROLLS
925
+ } else {
926
+ OVER_SCROLL_NEVER
927
+ }
928
+ updateEffectivePadding()
929
+ ensureSelectionVisible()
930
+ requestLayout()
931
+ }
932
+
933
+ private fun applyContentInsets(contentInsets: EditorContentInsets?) {
934
+ this.contentInsets = contentInsets
935
+ updateEffectivePadding()
936
+ }
937
+
938
+ fun setViewportBottomInsetPx(bottomInsetPx: Int) {
939
+ val clampedInset = bottomInsetPx.coerceAtLeast(0)
940
+ if (viewportBottomInsetPx == clampedInset) return
941
+ viewportBottomInsetPx = clampedInset
942
+ updateEffectivePadding()
943
+ ensureSelectionVisible()
944
+ }
945
+
946
+ private fun updateEffectivePadding() {
947
+ val density = resources.displayMetrics.density
948
+ val left = ((contentInsets?.left ?: 0f) * density).toInt()
949
+ val top = ((contentInsets?.top ?: 0f) * density).toInt()
950
+ val right = ((contentInsets?.right ?: 0f) * density).toInt()
951
+ val bottom = ((contentInsets?.bottom ?: 0f) * density).toInt()
952
+
953
+ if (heightBehavior == EditorHeightBehavior.FIXED) {
954
+ setPadding(left, 0, right, 0)
955
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
956
+ } else {
957
+ setPadding(left, top, right, bottom)
958
+ setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
959
+ }
960
+ }
961
+
962
+ fun setImageResizingEnabled(enabled: Boolean) {
963
+ if (imageResizingEnabled == enabled) return
964
+ imageResizingEnabled = enabled
965
+ if (!enabled) {
966
+ clearExplicitSelectedImageRange()
967
+ } else {
968
+ onSelectionOrContentMayChange?.invoke()
969
+ }
970
+ }
971
+
972
+ fun resolveAutoGrowHeight(): Int {
973
+ val availableWidth = (measuredWidth - compoundPaddingLeft - compoundPaddingRight).coerceAtLeast(0)
974
+ val placeholderHeight = resolvePlaceholderHeightForAvailableWidth(availableWidth)
975
+ val laidOutTextHeight = if (isLaidOut) layout?.height else null
976
+ if (laidOutTextHeight != null && laidOutTextHeight > 0) {
977
+ return maxOf(
978
+ laidOutTextHeight + compoundPaddingTop + compoundPaddingBottom,
979
+ placeholderHeight ?: 0
980
+ )
981
+ }
982
+
983
+ val currentText = text
984
+ if (availableWidth > 0 && currentText != null) {
985
+ val staticLayout = StaticLayout.Builder
986
+ .obtain(currentText, 0, currentText.length, paint, availableWidth)
987
+ .setAlignment(Layout.Alignment.ALIGN_NORMAL)
988
+ .setIncludePad(includeFontPadding)
989
+ .build()
990
+ val textHeight = staticLayout.height.takeIf { it > 0 } ?: lineHeight
991
+ return maxOf(
992
+ textHeight + compoundPaddingTop + compoundPaddingBottom,
993
+ placeholderHeight ?: 0
994
+ )
995
+ }
996
+
997
+ val minimumHeight = suggestedMinimumHeight.coerceAtLeast(minHeight)
998
+ return maxOf(
999
+ placeholderHeight ?: 0,
1000
+ (lineHeight + compoundPaddingTop + compoundPaddingBottom).coerceAtLeast(minimumHeight)
1001
+ )
1002
+ }
1003
+
1004
+ private fun preserveScrollPosition(previousScrollX: Int, previousScrollY: Int) {
1005
+ val restore = {
1006
+ val maxScrollX = maxOf(0, computeHorizontalScrollRange() - width)
1007
+ val maxScrollY = maxOf(0, computeVerticalScrollRange() - height)
1008
+ scrollTo(
1009
+ previousScrollX.coerceIn(0, maxScrollX),
1010
+ previousScrollY.coerceIn(0, maxScrollY)
1011
+ )
1012
+ }
1013
+
1014
+ restore()
1015
+ post { restore() }
1016
+ }
1017
+
1018
+ private fun ensureSelectionVisible() {
1019
+ if (heightBehavior != EditorHeightBehavior.FIXED) return
1020
+ if (!isLaidOut || width <= 0 || height <= 0) return
1021
+ val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return
1022
+
1023
+ post {
1024
+ if (!isLaidOut || layout == null) return@post
1025
+ bringPointIntoView(selectionOffset)
1026
+
1027
+ val textLayout = layout ?: return@post
1028
+ val clampedOffset = selectionOffset.coerceAtMost(textLayout.text.length)
1029
+ val line = textLayout.getLineForOffset(clampedOffset)
1030
+ val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset).toInt()
1031
+ val rect = Rect(
1032
+ caretLeft + totalPaddingLeft,
1033
+ textLayout.getLineTop(line) + totalPaddingTop,
1034
+ caretLeft + totalPaddingLeft + 1,
1035
+ textLayout.getLineBottom(line) + totalPaddingTop
1036
+ )
1037
+ requestRectangleOnScreen(rect)
1038
+ }
1039
+ }
1040
+
1041
+ internal fun caretRect(): RectF? {
1042
+ val textLayout = layout ?: return null
1043
+ val selectionOffset = selectionEnd.takeIf { it >= 0 } ?: return null
1044
+ val clampedOffset = selectionOffset.coerceIn(0, textLayout.text.length)
1045
+ val caretLeft = textLayout.getPrimaryHorizontal(clampedOffset)
1046
+ // Clip the caret to the rendered glyph height so a ParagraphSpacerSpan's
1047
+ // inflated descent does not stretch it into the inter-block gap.
1048
+ val bounds = CaretGeometry.verticalBounds(textLayout, clampedOffset, paint)
1049
+ val left = totalPaddingLeft + caretLeft - scrollX
1050
+ val top = totalPaddingTop + bounds.top - scrollY
1051
+ val bottom = totalPaddingTop + bounds.bottom - scrollY
1052
+ return RectF(left, top, left + 1f, bottom)
1053
+ }
1054
+
1055
+ // ── Input Handling: Text Commit ─────────────────────────────────────
1056
+
1057
+ /**
1058
+ * Handle committed text from the IME (typed characters, autocomplete).
1059
+ *
1060
+ * Called by [EditorInputConnection.commitText]. Routes the text through
1061
+ * the Rust editor instead of directly inserting into the EditText.
1062
+ */
1063
+ fun handleTextCommit(text: String, newCursorPosition: Int = 1) {
1064
+ val startedAt = System.nanoTime()
1065
+ if (!isEditable) {
1066
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=notEditable textLength=${text.length}")
1067
+ return
1068
+ }
1069
+ if (isApplyingRustState) {
1070
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=applyingRust textLength=${text.length}")
1071
+ return
1072
+ }
1073
+ val selectionRange = normalizedUtf16SelectionRange()
1074
+ if (selectionRange == null) {
1075
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=noSelection textLength=${text.length}")
1076
+ return
1077
+ }
1078
+ if (editorId == 0L) {
1079
+ // No Rust editor bound — fall through to direct editing (dev mode).
1080
+ val editable = this.text ?: return
1081
+ val (start, end) = selectionRange
1082
+ editable.replace(start, end, text)
1083
+ recordImeTraceForTesting(
1084
+ "handleTextCommitDirect",
1085
+ "textLength=${text.length} utf16Sel=$start..$end totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1086
+ )
1087
+ return
1088
+ }
1089
+ if (discardTransientInputForDestroyedEditorIfNeeded()) {
1090
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=destroyedEditor textLength=${text.length}")
1091
+ return
1092
+ }
1093
+
1094
+ // Handle Enter/Return as a block split operation.
1095
+ if (text == "\n") {
1096
+ recordImeTraceForTesting(
1097
+ "handleTextCommit",
1098
+ "route=return utf16Sel=${selectionRange.first}..${selectionRange.second}"
1099
+ )
1100
+ handleReturnKey()
1101
+ recordImeTraceForTesting(
1102
+ "handleTextCommitDone",
1103
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1104
+ )
1105
+ return
1106
+ }
1107
+
1108
+ val currentText = this.text?.toString() ?: ""
1109
+ val scalarSelectionRange = normalizedScalarSelectionRange(currentText)
1110
+ if (scalarSelectionRange == null) {
1111
+ recordImeTraceForTesting("handleTextCommitNoop", "reason=noScalarSelection textLength=${text.length}")
1112
+ return
1113
+ }
1114
+ val (scalarStart, scalarEnd) = scalarSelectionRange
1115
+ val requestedCursor = requestedCursorScalar(
1116
+ scalarStart,
1117
+ scalarEnd,
1118
+ currentText,
1119
+ text,
1120
+ newCursorPosition
1121
+ )
1122
+ recordImeTraceForTesting(
1123
+ "handleTextCommit",
1124
+ "textLength=${text.length} cursor=$newCursorPosition utf16Sel=${selectionRange.first}..${selectionRange.second} scalarSel=$scalarStart..$scalarEnd requestedCursor=$requestedCursor"
1125
+ )
1126
+ val didApplyOptimisticVisibleText = applyOptimisticPlainTextCommitIfPossible(
1127
+ startUtf16 = selectionRange.first,
1128
+ endUtf16 = selectionRange.second,
1129
+ committedText = text,
1130
+ newCursorPosition = newCursorPosition
1131
+ )
1132
+ if (didApplyOptimisticVisibleText) {
1133
+ recordImeTraceForTesting(
1134
+ "optimisticVisibleTextCommit",
1135
+ "textLength=${text.length} utf16Sel=${selectionRange.first}..${selectionRange.second}"
1136
+ )
1137
+ }
1138
+ insertPlainTextRangeInRust(
1139
+ scalarStart,
1140
+ scalarEnd,
1141
+ text,
1142
+ requestedCursorScalar = requestedCursor
1143
+ )
1144
+ recordImeTraceForTesting(
1145
+ "handleTextCommitDone",
1146
+ "textLength=${text.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1147
+ )
1148
+ }
1149
+
1150
+ private data class OptimisticInlineSpan(
1151
+ val span: Any,
1152
+ val flags: Int
1153
+ )
1154
+
1155
+ private fun applyOptimisticPlainTextCommitIfPossible(
1156
+ startUtf16: Int,
1157
+ endUtf16: Int,
1158
+ committedText: String,
1159
+ newCursorPosition: Int
1160
+ ): Boolean {
1161
+ if (newCursorPosition != 1) return false
1162
+ if (startUtf16 != endUtf16) return false
1163
+ if (committedText.isEmpty()) return false
1164
+ if (committedText.codePointCount(0, committedText.length) != 1) return false
1165
+ if (committedText.indexOf('\n') >= 0 || committedText.indexOf('\r') >= 0) return false
1166
+ if (hasCompositionTrackingForEditor()) return false
1167
+ val editable = text ?: return false
1168
+ val currentText = editable.toString()
1169
+ if (currentText != lastAuthorizedText) return false
1170
+ if (startUtf16 < 0 || endUtf16 < startUtf16 || endUtf16 > editable.length) return false
1171
+ val spanned = editable as? Spanned
1172
+ if (spanned != null && spannedRangeContainsImageSpan(spanned, startUtf16, endUtf16)) return false
1173
+
1174
+ val inlineSpans = spanned?.let {
1175
+ optimisticInlineSpansForInsertion(it, startUtf16)
1176
+ }.orEmpty()
1177
+ var didApply = false
1178
+ runWithTransientInputMutationGuard {
1179
+ editable.replace(startUtf16, endUtf16, committedText)
1180
+ val insertedEnd = startUtf16 + committedText.length
1181
+ applyOptimisticInlineSpans(editable, startUtf16, insertedEnd, inlineSpans)
1182
+ Selection.setSelection(editable, insertedEnd, insertedEnd)
1183
+ didApply = true
1184
+ true
1185
+ }
1186
+ if (didApply) {
1187
+ pendingOptimisticRenderText = editable.toString()
1188
+ }
1189
+ return didApply
1190
+ }
1191
+
1192
+ private fun optimisticInlineSpansForInsertion(
1193
+ spanned: Spanned,
1194
+ insertionStart: Int
1195
+ ): List<OptimisticInlineSpan> {
1196
+ if (spanned.isEmpty()) return emptyList()
1197
+ val sourceIndex = when {
1198
+ insertionStart > 0 -> insertionStart - 1
1199
+ insertionStart < spanned.length -> insertionStart
1200
+ else -> return emptyList()
1201
+ }
1202
+ val queryStart = sourceIndex.coerceIn(0, spanned.length - 1)
1203
+ val queryEnd = (queryStart + 1).coerceAtMost(spanned.length)
1204
+ val spans = mutableListOf<OptimisticInlineSpan>()
1205
+ spanned.getSpans(queryStart, queryEnd, Any::class.java).forEach { span ->
1206
+ if (spanned.getSpanStart(span) > queryStart || spanned.getSpanEnd(span) <= queryStart) {
1207
+ return@forEach
1208
+ }
1209
+ cloneOptimisticInlineSpan(span)?.let { clone ->
1210
+ spans.add(OptimisticInlineSpan(clone, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE))
1211
+ }
1212
+ }
1213
+ return spans
1214
+ }
1215
+
1216
+ private fun cloneOptimisticInlineSpan(span: Any): Any? =
1217
+ when (span) {
1218
+ is ForegroundColorSpan -> ForegroundColorSpan(span.foregroundColor)
1219
+ is BackgroundColorSpan -> BackgroundColorSpan(span.backgroundColor)
1220
+ is AbsoluteSizeSpan -> AbsoluteSizeSpan(span.size, span.dip)
1221
+ is StyleSpan -> StyleSpan(span.style)
1222
+ is UnderlineSpan -> UnderlineSpan()
1223
+ is StrikethroughSpan -> StrikethroughSpan()
1224
+ else -> null
1225
+ }
1226
+
1227
+ private fun applyOptimisticInlineSpans(
1228
+ editable: Editable,
1229
+ start: Int,
1230
+ end: Int,
1231
+ inlineSpans: List<OptimisticInlineSpan>
1232
+ ) {
1233
+ if (start >= end || end > editable.length) return
1234
+ var hasColor = false
1235
+ var hasSize = false
1236
+ inlineSpans.forEach { spec ->
1237
+ hasColor = hasColor || spec.span is ForegroundColorSpan
1238
+ hasSize = hasSize || spec.span is AbsoluteSizeSpan
1239
+ editable.setSpan(spec.span, start, end, spec.flags)
1240
+ }
1241
+ val textStyle = theme?.effectiveTextStyle("paragraph")
1242
+ if (!hasColor) {
1243
+ editable.setSpan(
1244
+ ForegroundColorSpan(textStyle?.color ?: baseTextColor),
1245
+ start,
1246
+ end,
1247
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1248
+ )
1249
+ }
1250
+ if (!hasSize) {
1251
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
1252
+ editable.setSpan(
1253
+ AbsoluteSizeSpan(resolvedTextSize.toInt(), false),
1254
+ start,
1255
+ end,
1256
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1257
+ )
1258
+ }
1259
+ }
1260
+
1261
+ internal fun runWithTransientInputMutationGuard(block: () -> Boolean): Boolean {
1262
+ val wasApplyingRustState = isApplyingRustState
1263
+ isApplyingRustState = true
1264
+ return try {
1265
+ block()
1266
+ } finally {
1267
+ isApplyingRustState = wasApplyingRustState
1268
+ }
1269
+ }
1270
+
1271
+ internal fun authorizedUtf16Range(start: Int, end: Int): Pair<Int, Int> {
1272
+ if (start == end) {
1273
+ val snapped = PositionBridge.snapToScalarBoundary(
1274
+ start,
1275
+ lastAuthorizedText,
1276
+ biasForward = true
1277
+ )
1278
+ return snapped to snapped
1279
+ }
1280
+ return PositionBridge.snapRangeToScalarBoundaries(start, end, lastAuthorizedText)
1281
+ }
1282
+
1283
+ internal fun isCurrentTextAuthorizedForEditor(): Boolean =
1284
+ (text?.toString() ?: "") == lastAuthorizedText
1285
+
1286
+ internal fun captureCompositionReplacementRangeIfNeeded() {
1287
+ if (didInvalidateCompositionReplacementRange) return
1288
+ if (compositionReplacementRange() != null) return
1289
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
1290
+ setCompositionReplacementRange(start, end)
1291
+ }
1292
+
1293
+ internal fun setCompositionReplacementRange(start: Int, end: Int) {
1294
+ if (didInvalidateCompositionReplacementRange) return
1295
+ val replacementRange = authorizedUtf16Range(start, end)
1296
+ composingReplacementStartUtf16 = replacementRange.first
1297
+ composingReplacementEndUtf16 = replacementRange.second
1298
+ composingReplacementAuthorizedTextRevision = lastAuthorizedTextRevision
1299
+ didInvalidateCompositionReplacementRange = false
1300
+ }
1301
+
1302
+ internal fun compositionReplacementRange(): Pair<Int, Int>? {
1303
+ val start = composingReplacementStartUtf16 ?: return null
1304
+ val end = composingReplacementEndUtf16 ?: return null
1305
+ if (composingReplacementAuthorizedTextRevision != lastAuthorizedTextRevision) {
1306
+ clearCompositionTrackingForEditor()
1307
+ didInvalidateCompositionReplacementRange = true
1308
+ return null
1309
+ }
1310
+ return start to end
1311
+ }
1312
+
1313
+ private fun authorizedSelectionForTransientInputRestore(
1314
+ currentStart: Int,
1315
+ currentEnd: Int
1316
+ ): Pair<Int, Int>? {
1317
+ compositionReplacementRange()?.let { return it }
1318
+ return if (
1319
+ currentStart >= 0 &&
1320
+ currentEnd >= 0 &&
1321
+ currentStart <= lastAuthorizedText.length &&
1322
+ currentEnd <= lastAuthorizedText.length
1323
+ ) {
1324
+ currentStart to currentEnd
1325
+ } else {
1326
+ null
1327
+ }
1328
+ }
1329
+
1330
+ internal fun consumeInvalidatedCompositionReplacementRangeForEditor(): Boolean {
1331
+ val invalidated = didInvalidateCompositionReplacementRange
1332
+ didInvalidateCompositionReplacementRange = false
1333
+ return invalidated
1334
+ }
1335
+
1336
+ internal fun hasInvalidatedCompositionReplacementRangeForEditor(): Boolean =
1337
+ didInvalidateCompositionReplacementRange
1338
+
1339
+ internal fun setComposingTextForEditor(text: String?) {
1340
+ composingText = text
1341
+ }
1342
+
1343
+ internal fun composingTextForEditor(): String? = composingText
1344
+
1345
+ internal fun samsungSentenceCapsComposingTextForEditor(composingText: String?): String? {
1346
+ if (composingText.isNullOrEmpty()) return composingText
1347
+ if (!isSamsungKeyboardActiveForEditor()) return composingText
1348
+ if ((inputType and InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) != InputType.TYPE_TEXT_FLAG_CAP_SENTENCES) {
1349
+ return composingText
1350
+ }
1351
+ val (replacementStart, replacementEnd) = compositionReplacementRange() ?: return composingText
1352
+ if (replacementStart != replacementEnd) return composingText
1353
+ if (!isRenderedLineStartForSentenceCaps(lastAuthorizedText, replacementStart)) {
1354
+ return composingText
1355
+ }
1356
+
1357
+ val firstCodePoint = Character.codePointAt(composingText, 0)
1358
+ if (!Character.isLowerCase(firstCodePoint)) return composingText
1359
+ val adjusted = buildString(composingText.length) {
1360
+ appendCodePoint(Character.toTitleCase(firstCodePoint))
1361
+ append(composingText.substring(Character.charCount(firstCodePoint)))
1362
+ }
1363
+ recordImeTraceForTesting(
1364
+ "samsungSentenceCapsFallback",
1365
+ "range=$replacementStart..$replacementEnd textLength=${composingText.length}"
1366
+ )
1367
+ return adjusted
1368
+ }
1369
+
1370
+ internal fun applyTransientComposingTextStyleForEditor() {
1371
+ val editable = text ?: return
1372
+ removeTransientComposingTextStyleSpans(editable)
1373
+
1374
+ val start = BaseInputConnection.getComposingSpanStart(editable)
1375
+ val end = BaseInputConnection.getComposingSpanEnd(editable)
1376
+ if (start < 0 || end < 0 || start >= end || end > editable.length) return
1377
+
1378
+ val textStyle = theme?.effectiveTextStyle("paragraph")
1379
+ val resolvedTextSize = textStyle?.fontSize?.times(resources.displayMetrics.density) ?: baseFontSize
1380
+ val resolvedTextColor = textStyle?.color ?: baseTextColor
1381
+
1382
+ editable.setSpan(
1383
+ TransientComposingSizeSpan(resolvedTextSize.toInt()),
1384
+ start,
1385
+ end,
1386
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1387
+ )
1388
+ editable.setSpan(
1389
+ TransientComposingColorSpan(resolvedTextColor),
1390
+ start,
1391
+ end,
1392
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1393
+ )
1394
+
1395
+ val typefaceStyle = textStyle?.typefaceStyle() ?: Typeface.NORMAL
1396
+ if (typefaceStyle != Typeface.NORMAL) {
1397
+ editable.setSpan(
1398
+ TransientComposingStyleSpan(typefaceStyle),
1399
+ start,
1400
+ end,
1401
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1402
+ )
1403
+ }
1404
+
1405
+ val fontFamily = textStyle?.fontFamily?.takeIf { it.isNotBlank() }
1406
+ if (fontFamily != null) {
1407
+ editable.setSpan(
1408
+ TransientComposingTypefaceSpan(fontFamily),
1409
+ start,
1410
+ end,
1411
+ Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
1412
+ )
1413
+ }
1414
+ }
1415
+
1416
+ private fun removeTransientComposingTextStyleSpans(editable: Editable) {
1417
+ editable
1418
+ .getSpans(0, editable.length, TransientComposingTextStyleSpan::class.java)
1419
+ .forEach(editable::removeSpan)
1420
+ }
1421
+
1422
+ internal fun composingTextFromVisibleReplacementForEditor(): String? {
1423
+ val (start, end) = compositionReplacementRange() ?: return null
1424
+ val authorizedText = lastAuthorizedText
1425
+ val currentText = text?.toString() ?: return null
1426
+ if (start < 0 || end < start || end > authorizedText.length) return null
1427
+
1428
+ val authorizedPrefix = authorizedText.substring(0, start)
1429
+ val authorizedSuffix = authorizedText.substring(end)
1430
+ if (!currentText.startsWith(authorizedPrefix)) return null
1431
+ if (!currentText.endsWith(authorizedSuffix)) return null
1432
+
1433
+ val replacementEnd = currentText.length - authorizedSuffix.length
1434
+ if (replacementEnd < authorizedPrefix.length) return null
1435
+ return currentText.substring(authorizedPrefix.length, replacementEnd)
1436
+ }
1437
+
1438
+ internal fun clearCompositionTrackingForEditor() {
1439
+ composingText = null
1440
+ composingReplacementStartUtf16 = null
1441
+ composingReplacementEndUtf16 = null
1442
+ composingReplacementAuthorizedTextRevision = null
1443
+ }
1444
+
1445
+ private fun hasCompositionTrackingForEditor(): Boolean =
1446
+ composingText != null ||
1447
+ composingReplacementStartUtf16 != null ||
1448
+ composingReplacementEndUtf16 != null ||
1449
+ composingReplacementAuthorizedTextRevision != null
1450
+
1451
+ private fun retireInputConnectionForEditor() {
1452
+ recordImeTraceForTesting("retireInputConnection")
1453
+ activeInputConnection?.clearCompositionTrackingForEditor()
1454
+ invalidateInputConnectionsForEditor()
1455
+ clearCompositionTrackingForEditor()
1456
+ clearCompositionInvalidationForEditor()
1457
+ clearNativeComposingSpans()
1458
+ }
1459
+
1460
+ internal fun isEditorDestroyedForInput(): Boolean =
1461
+ editorId != 0L && NativeEditorViewRegistry.isDestroyed(editorId)
1462
+
1463
+ private fun hasLiveEditor(): Boolean =
1464
+ editorId != 0L && !isEditorDestroyedForInput()
1465
+
1466
+ private fun discardTransientInputForDestroyedEditorIfNeeded(): Boolean {
1467
+ if (!isEditorDestroyedForInput()) return false
1468
+ retireInputConnectionForEditor()
1469
+ clearNativeTextMutationAfterBlurWindow()
1470
+ clearNativeTextMutationAdoptionSuppression()
1471
+ return true
1472
+ }
1473
+
1474
+ private fun discardTransientInputAndRestoreAuthorizedTextForEditor() {
1475
+ retireInputConnectionForEditor()
1476
+ clearNativeTextMutationAfterBlurWindow()
1477
+ restoreAuthorizedTextSnapshotForEditor()
1478
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1479
+ }
1480
+
1481
+ private fun restoreAuthorizedTextSnapshotForEditor() {
1482
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1483
+ val authorizedSnapshot = lastAuthorizedRenderedText ?: lastAuthorizedText
1484
+ val wasApplyingRustState = isApplyingRustState
1485
+ isApplyingRustState = true
1486
+ beginBatchEdit()
1487
+ try {
1488
+ setText(authorizedSnapshot)
1489
+ } finally {
1490
+ endBatchEdit()
1491
+ isApplyingRustState = wasApplyingRustState
1492
+ }
1493
+ }
1494
+
1495
+ private fun restartInputAfterCompositionInvalidationIfNeeded(shouldRestart: Boolean) {
1496
+ if (!shouldRestart) return
1497
+ restartInputForEditorIfFocused("focused")
1498
+ }
1499
+
1500
+ private fun restartInputForEditorIfFocused(source: String) {
1501
+ if (!hasFocus()) return
1502
+ restartInputForEditor(source)
1503
+ }
1504
+
1505
+ private fun restartInputForEditor(source: String = "explicit") {
1506
+ recordImeTraceForTesting("restartInput", "source=$source")
1507
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1508
+ imm?.restartInput(this)
1509
+ scheduleSelectionUpdateAfterRestartInput(source)
1510
+ }
1511
+
1512
+ private fun scheduleSelectionUpdateAfterRestartInput(source: String) {
1513
+ val generation = ++restartInputSelectionUpdateGeneration
1514
+ post {
1515
+ if (generation != restartInputSelectionUpdateGeneration) return@post
1516
+ if (!hasFocus()) return@post
1517
+ val start = selectionStart
1518
+ val end = selectionEnd
1519
+ if (start < 0 || end < 0) {
1520
+ recordImeTraceForTesting(
1521
+ "updateSelectionAfterRestartSkipped",
1522
+ "source=$source reason=selection start=$start end=$end"
1523
+ )
1524
+ return@post
1525
+ }
1526
+ val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
1527
+ imm?.updateSelection(this, start, end, -1, -1)
1528
+ recordImeTraceForTesting(
1529
+ "updateSelectionAfterRestart",
1530
+ "source=$source sel=$start..$end"
1531
+ )
1532
+ }
1533
+ }
1534
+
1535
+ private fun scheduleLineBoundaryInputRefreshForEditor(source: String) {
1536
+ if (!hasFocus()) return
1537
+ val generation = ++lineBoundaryInputRefreshGeneration
1538
+ recordImeTraceForTesting(
1539
+ "lineBoundaryInputRefreshScheduled",
1540
+ "source=$source generation=$generation"
1541
+ )
1542
+ post {
1543
+ if (generation != lineBoundaryInputRefreshGeneration) return@post
1544
+ if (!hasFocus()) return@post
1545
+ if (!isCursorAtRenderedLineStartForSentenceCaps()) {
1546
+ recordImeTraceForTesting(
1547
+ "lineBoundaryInputRefreshSkipped",
1548
+ "source=$source reason=cursor"
1549
+ )
1550
+ return@post
1551
+ }
1552
+ restartInputForEditor("lineBoundary:$source")
1553
+ }
1554
+ }
1555
+
1556
+ private fun clearCompositionInvalidationForEditor() {
1557
+ didInvalidateCompositionReplacementRange = false
1558
+ }
1559
+
1560
+ private fun nextInputConnectionGenerationForEditor(): Long {
1561
+ return inputConnectionGeneration
1562
+ }
1563
+
1564
+ internal fun isInputConnectionCurrentForEditor(
1565
+ boundEditorId: Long,
1566
+ boundGeneration: Long
1567
+ ): Boolean =
1568
+ editorId == boundEditorId &&
1569
+ inputConnectionGeneration == boundGeneration &&
1570
+ !isEditorDestroyedForInput()
1571
+
1572
+ private fun invalidateInputConnectionsForEditor() {
1573
+ inputConnectionGeneration += 1L
1574
+ recordImeTraceForTesting("invalidateInputConnections", "nextGen=$inputConnectionGeneration")
1575
+ activeInputConnection = null
1576
+ }
1577
+
1578
+ private fun clearNativeComposingSpans() {
1579
+ val editable = text ?: return
1580
+ BaseInputConnection.removeComposingSpans(editable)
1581
+ removeTransientComposingTextStyleSpans(editable)
1582
+ }
1583
+
1584
+ internal fun restoreAuthorizedTextIfNeeded() {
1585
+ if (!hasLiveEditor()) return
1586
+ if ((text?.toString() ?: "") == lastAuthorizedText) return
1587
+ recordImeTraceForTesting(
1588
+ "restoreAuthorizedText",
1589
+ "authorizedLength=${lastAuthorizedText.length}"
1590
+ )
1591
+ val stateJSON = editorGetCurrentState(editorId.toULong())
1592
+ applyUpdateJSON(stateJSON)
1593
+ }
1594
+
1595
+ fun discardTransientNativeInputForEditorRebind() {
1596
+ retireInputConnectionForEditor()
1597
+ nativeTextMutationAfterBlurWindow = null
1598
+ clearNativeTextMutationAdoptionSuppression()
1599
+ clearImeTraceForTesting()
1600
+ }
1601
+
1602
+ internal fun discardTransientNativeInputForExternalRecovery() {
1603
+ retireInputConnectionForEditor()
1604
+ nativeTextMutationAfterBlurWindow = null
1605
+ restoreAuthorizedTextIfNeeded()
1606
+ suppressNativeTextMutationAdoptionForCurrentRevision()
1607
+ }
1608
+
1609
+ private fun discardTransientNativeInputForReadOnly() {
1610
+ discardTransientNativeInputForExternalRecovery()
1611
+ }
1612
+
1613
+ fun prepareForExternalEditorUpdate(): Boolean {
1614
+ if (blockExternalEditorUpdatePreparationForTesting) return false
1615
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
1616
+ val inputConnection = activeInputConnection
1617
+ if (inputConnection?.flushPendingCompositionForExternalMutation() == false) {
1618
+ return false
1619
+ }
1620
+ return drainNativeTextMutationIfNeeded(
1621
+ allowAfterBlur = true,
1622
+ preserveInputConnectionForExternalUpdate = true
1623
+ )
1624
+ }
1625
+
1626
+ fun prepareForExternalEditorCommand(): CommandPreparation {
1627
+ if (blockExternalEditorCommandPreparationForTesting) {
1628
+ return CommandPreparation(ready = false, updateJSON = null)
1629
+ }
1630
+ val previousAuthorizedText = lastAuthorizedText
1631
+ if (!prepareForExternalEditorUpdate()) {
1632
+ return CommandPreparation(ready = false, updateJSON = null)
1633
+ }
1634
+ if (!hasLiveEditor() || lastAuthorizedText == previousAuthorizedText) {
1635
+ return CommandPreparation(ready = true, updateJSON = null)
1636
+ }
1637
+ return CommandPreparation(
1638
+ ready = true,
1639
+ updateJSON = editorGetCurrentState(editorId.toULong())
1640
+ )
1641
+ }
1642
+
1643
+ fun handleCompositionCommit(
1644
+ text: String,
1645
+ replacementStartUtf16: Int,
1646
+ replacementEndUtf16: Int,
1647
+ newCursorPosition: Int = 1
1648
+ ) {
1649
+ val startedAt = System.nanoTime()
1650
+ if (!isEditable) {
1651
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=notEditable textLength=${text.length}")
1652
+ return
1653
+ }
1654
+ if (isApplyingRustState) {
1655
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=applyingRust textLength=${text.length}")
1656
+ return
1657
+ }
1658
+ if (!hasLiveEditor()) {
1659
+ recordImeTraceForTesting("handleCompositionCommitNoop", "reason=noLiveEditor textLength=${text.length}")
1660
+ return
1661
+ }
1662
+
1663
+ val authorizedText = lastAuthorizedText
1664
+ val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1665
+ replacementStartUtf16,
1666
+ replacementEndUtf16,
1667
+ authorizedText
1668
+ )
1669
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
1670
+ val scalarEnd = PositionBridge.utf16ToScalar(endUtf16, authorizedText)
1671
+
1672
+ if (
1673
+ startUtf16 <= endUtf16 &&
1674
+ endUtf16 <= authorizedText.length &&
1675
+ authorizedText.substring(startUtf16, endUtf16) == text
1676
+ ) {
1677
+ val requestedCursor = requestedCursorScalar(
1678
+ scalarStart,
1679
+ scalarEnd,
1680
+ authorizedText,
1681
+ text,
1682
+ newCursorPosition
1683
+ ) ?: scalarEnd
1684
+ recordImeTraceForTesting(
1685
+ "handleCompositionCommitNoop",
1686
+ "reason=alreadyAuthorized textLength=${text.length} requestedCursor=$requestedCursor range=$startUtf16..$endUtf16"
1687
+ )
1688
+ restoreAuthorizedTextIfNeeded()
1689
+ applyRequestedCursorScalar(requestedCursor)
1690
+ return
1691
+ }
1692
+
1693
+ if (text == "\n") {
1694
+ recordImeTraceForTesting(
1695
+ "handleCompositionCommit",
1696
+ "route=return textLength=${text.length} utf16Range=$startUtf16..$endUtf16 scalarRange=$scalarStart..$scalarEnd"
1697
+ )
1698
+ if (scalarStart != scalarEnd) {
1699
+ deleteAndSplitInRust(scalarStart, scalarEnd)
1700
+ } else {
1701
+ splitBlockInRust(scalarStart)
1702
+ }
1703
+ recordImeTraceForTesting(
1704
+ "handleCompositionCommitDone",
1705
+ "route=return totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1706
+ )
1707
+ return
1708
+ }
1709
+
1710
+ val requestedCursor = requestedCursorScalar(
1711
+ scalarStart,
1712
+ scalarEnd,
1713
+ authorizedText,
1714
+ text,
1715
+ newCursorPosition
1716
+ )
1717
+ recordImeTraceForTesting(
1718
+ "handleCompositionCommit",
1719
+ "textLength=${text.length} cursor=$newCursorPosition utf16Range=$startUtf16..$endUtf16 scalarRange=$scalarStart..$scalarEnd requestedCursor=$requestedCursor"
1720
+ )
1721
+ insertPlainTextRangeInRust(
1722
+ scalarStart,
1723
+ scalarEnd,
1724
+ text,
1725
+ requestedCursorScalar = requestedCursor
1726
+ )
1727
+ recordImeTraceForTesting(
1728
+ "handleCompositionCommitDone",
1729
+ "textLength=${text.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
1730
+ )
1731
+ }
1732
+
1733
+ fun handleCorrectionCommit(
1734
+ offsetUtf16: Int,
1735
+ oldText: String,
1736
+ newText: String
1737
+ ): Boolean {
1738
+ if (!isEditable) return true
1739
+ if (isApplyingRustState) return true
1740
+ if (!hasLiveEditor()) return false
1741
+
1742
+ val authorizedText = lastAuthorizedText
1743
+ if (offsetUtf16 < 0) {
1744
+ recordImeTraceForTesting(
1745
+ "correctionExplicitNoop",
1746
+ "reason=invalidOffset offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1747
+ )
1748
+ return false
1749
+ }
1750
+ val endUtf16 = offsetUtf16 + oldText.length
1751
+ if (endUtf16 < offsetUtf16 || endUtf16 > authorizedText.length) {
1752
+ recordImeTraceForTesting(
1753
+ "correctionExplicitNoop",
1754
+ "reason=outOfBounds offset=$offsetUtf16 oldLength=${oldText.length} authorizedLength=${authorizedText.length}"
1755
+ )
1756
+ return false
1757
+ }
1758
+ if (authorizedText.substring(offsetUtf16, endUtf16) != oldText) {
1759
+ recordImeTraceForTesting(
1760
+ "correctionExplicitNoop",
1761
+ "reason=staleText offset=$offsetUtf16 oldLength=${oldText.length} newLength=${newText.length}"
1762
+ )
1763
+ return false
1764
+ }
1765
+
1766
+ val (startUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1767
+ offsetUtf16,
1768
+ endUtf16,
1769
+ authorizedText
1770
+ )
1771
+ if (
1772
+ startUtf16 != offsetUtf16 ||
1773
+ snappedEndUtf16 != endUtf16 ||
1774
+ startUtf16 > snappedEndUtf16
1775
+ ) {
1776
+ recordImeTraceForTesting(
1777
+ "correctionExplicitNoop",
1778
+ "reason=unsnappedScalarBoundary range=$offsetUtf16..$endUtf16 snapped=$startUtf16..$snappedEndUtf16"
1779
+ )
1780
+ return false
1781
+ }
1782
+
1783
+ val scalarStart = PositionBridge.utf16ToScalar(startUtf16, authorizedText)
1784
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1785
+ recordImeTraceForTesting(
1786
+ "correctionExplicitApply",
1787
+ "range=$scalarStart..$scalarEnd newLength=${newText.length}"
1788
+ )
1789
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1790
+ return true
1791
+ }
1792
+
1793
+ fun handleMissingOldTextCorrectionCommit(
1794
+ offsetUtf16: Int,
1795
+ newText: String
1796
+ ): Boolean {
1797
+ if (!isEditable) return true
1798
+ if (isApplyingRustState) return true
1799
+ if (!hasLiveEditor()) return false
1800
+
1801
+ val authorizedText = lastAuthorizedText
1802
+ val tokenRange = missingOldTextCorrectionTokenRange(authorizedText, offsetUtf16)
1803
+ ?: run {
1804
+ recordImeTraceForTesting(
1805
+ "correctionInferredNoop",
1806
+ "reason=noToken offset=$offsetUtf16 newLength=${newText.length}"
1807
+ )
1808
+ return false
1809
+ }
1810
+ val (startUtf16, endUtf16) = tokenRange
1811
+
1812
+ val (snappedStartUtf16, snappedEndUtf16) = PositionBridge.snapRangeToScalarBoundaries(
1813
+ startUtf16,
1814
+ endUtf16,
1815
+ authorizedText
1816
+ )
1817
+ if (snappedStartUtf16 >= snappedEndUtf16) {
1818
+ recordImeTraceForTesting(
1819
+ "correctionInferredNoop",
1820
+ "reason=emptySnappedRange token=$startUtf16..$endUtf16 snapped=$snappedStartUtf16..$snappedEndUtf16"
1821
+ )
1822
+ return false
1823
+ }
1824
+
1825
+ val scalarStart = PositionBridge.utf16ToScalar(snappedStartUtf16, authorizedText)
1826
+ val scalarEnd = PositionBridge.utf16ToScalar(snappedEndUtf16, authorizedText)
1827
+ recordImeTraceForTesting(
1828
+ "correctionInferredApply",
1829
+ "range=$scalarStart..$scalarEnd utf16=$snappedStartUtf16..$snappedEndUtf16 newLength=${newText.length}"
1830
+ )
1831
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, newText)
1832
+ return true
1833
+ }
1834
+
1835
+ private fun missingOldTextCorrectionTokenRange(
1836
+ text: String,
1837
+ offsetUtf16: Int
1838
+ ): Pair<Int, Int>? {
1839
+ if (offsetUtf16 < 0 || offsetUtf16 >= text.length) return null
1840
+
1841
+ val tokenOffset = PositionBridge.snapToScalarBoundary(
1842
+ offsetUtf16,
1843
+ text,
1844
+ biasForward = false
1845
+ )
1846
+ if (tokenOffset < 0 || tokenOffset >= text.length) return null
1847
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, tokenOffset)) return null
1848
+
1849
+ var startUtf16 = tokenOffset
1850
+ while (startUtf16 > 0) {
1851
+ val previousUtf16 = Character.offsetByCodePoints(text, startUtf16, -1)
1852
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, previousUtf16)) break
1853
+ startUtf16 = previousUtf16
1854
+ }
1855
+
1856
+ var endUtf16 = tokenOffset + Character.charCount(Character.codePointAt(text, tokenOffset))
1857
+ while (endUtf16 < text.length) {
1858
+ if (!isMissingOldTextCorrectionTokenCodePointAt(text, endUtf16)) break
1859
+ endUtf16 += Character.charCount(Character.codePointAt(text, endUtf16))
1860
+ }
1861
+
1862
+ return if (startUtf16 < endUtf16) startUtf16 to endUtf16 else null
1863
+ }
1864
+
1865
+ private fun isMissingOldTextCorrectionTokenCodePointAt(text: String, utf16Offset: Int): Boolean {
1866
+ if (utf16Offset < 0 || utf16Offset >= text.length) return false
1867
+ val codePoint = Character.codePointAt(text, utf16Offset)
1868
+ if (isMissingOldTextCorrectionCoreTokenCodePoint(codePoint)) return true
1869
+ if (!isMissingOldTextCorrectionJoinerCodePoint(codePoint)) return false
1870
+
1871
+ val previousCodePoint = previousCodePointBefore(text, utf16Offset) ?: return false
1872
+ val nextUtf16Offset = utf16Offset + Character.charCount(codePoint)
1873
+ val nextCodePoint = nextCodePointAt(text, nextUtf16Offset) ?: return false
1874
+ return isMissingOldTextCorrectionCoreTokenCodePoint(previousCodePoint) &&
1875
+ isMissingOldTextCorrectionCoreTokenCodePoint(nextCodePoint)
1876
+ }
1877
+
1878
+ private fun isMissingOldTextCorrectionCoreTokenCodePoint(codePoint: Int): Boolean {
1879
+ if (Character.isLetterOrDigit(codePoint)) return true
1880
+ return when (Character.getType(codePoint)) {
1881
+ Character.NON_SPACING_MARK.toInt(),
1882
+ Character.COMBINING_SPACING_MARK.toInt(),
1883
+ Character.ENCLOSING_MARK.toInt(),
1884
+ Character.CONNECTOR_PUNCTUATION.toInt(),
1885
+ Character.MATH_SYMBOL.toInt(),
1886
+ Character.CURRENCY_SYMBOL.toInt(),
1887
+ Character.MODIFIER_SYMBOL.toInt(),
1888
+ Character.OTHER_SYMBOL.toInt(),
1889
+ Character.SURROGATE.toInt() -> true
1890
+ else -> false
1891
+ }
1892
+ }
1893
+
1894
+ private fun isMissingOldTextCorrectionJoinerCodePoint(codePoint: Int): Boolean =
1895
+ codePoint == '\''.code ||
1896
+ codePoint == 0x2018 ||
1897
+ codePoint == 0x2019 ||
1898
+ codePoint == 0x201B ||
1899
+ codePoint == 0xFF07 ||
1900
+ codePoint == '-'.code ||
1901
+ codePoint == 0x2010 ||
1902
+ codePoint == 0x2011 ||
1903
+ codePoint == 0x2012 ||
1904
+ codePoint == 0x2013 ||
1905
+ codePoint == 0x2014 ||
1906
+ codePoint == 0x2212 ||
1907
+ codePoint == 0x200D
1908
+
1909
+ private fun previousCodePointBefore(text: String, utf16Offset: Int): Int? {
1910
+ if (utf16Offset <= 0 || utf16Offset > text.length) return null
1911
+ val previousUtf16 = Character.offsetByCodePoints(text, utf16Offset, -1)
1912
+ return Character.codePointAt(text, previousUtf16)
1913
+ }
1914
+
1915
+ private fun nextCodePointAt(text: String, utf16Offset: Int): Int? {
1916
+ if (utf16Offset < 0 || utf16Offset >= text.length) return null
1917
+ return Character.codePointAt(text, utf16Offset)
1918
+ }
1919
+
1920
+ // ── Input Handling: Deletion ────────────────────────────────────────
1921
+
1922
+ /**
1923
+ * Handle surrounding text deletion from the IME.
1924
+ *
1925
+ * Called by [EditorInputConnection.deleteSurroundingText].
1926
+ *
1927
+ * @param beforeLength Number of UTF-16 code units to delete before the cursor.
1928
+ * @param afterLength Number of UTF-16 code units to delete after the cursor.
1929
+ */
1930
+ fun handleDelete(beforeLength: Int, afterLength: Int) {
1931
+ if (!isEditable) return
1932
+ if (isApplyingRustState) return
1933
+ val selectionRange = normalizedUtf16SelectionRange()
1934
+ if (editorId == 0L) {
1935
+ // Dev mode: direct editing.
1936
+ val editable = this.text ?: return
1937
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1938
+ val delStart: Int
1939
+ val delEnd: Int
1940
+ if (selectionStart != selectionEnd) {
1941
+ delStart = selectionStart
1942
+ delEnd = selectionEnd
1943
+ } else {
1944
+ delStart = maxOf(0, selectionStart - beforeLength.coerceAtLeast(0))
1945
+ delEnd = minOf(editable.length, selectionStart + afterLength.coerceAtLeast(0))
1946
+ }
1947
+ editable.delete(delStart, delEnd)
1948
+ return
1949
+ }
1950
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
1951
+
1952
+ val currentText = text?.toString() ?: ""
1953
+ val (selectionStart, selectionEnd) = selectionRange ?: return
1954
+ if (selectionStart != selectionEnd) {
1955
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
1956
+ deleteRangeInRust(scalarStart, scalarEnd)
1957
+ return
1958
+ }
1959
+ val cursor = selectionStart
1960
+ if (beforeLength > 0 &&
1961
+ afterLength == 0 &&
1962
+ cursor > 0 &&
1963
+ currentText.getOrNull(cursor - 1) == EMPTY_BLOCK_PLACEHOLDER
1964
+ ) {
1965
+ val scalarCursor = PositionBridge.utf16ToScalar(cursor, currentText)
1966
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
1967
+ return
1968
+ }
1969
+ val rawDelStart = maxOf(0, cursor - beforeLength.coerceAtLeast(0))
1970
+ val rawDelEnd = minOf(currentText.length, cursor + afterLength.coerceAtLeast(0))
1971
+ val (delStart, delEnd) = PositionBridge.snapRangeToScalarBoundaries(
1972
+ rawDelStart,
1973
+ rawDelEnd,
1974
+ currentText
1975
+ )
1976
+
1977
+ val scalarStart = PositionBridge.utf16ToScalar(delStart, currentText)
1978
+ val scalarEnd = PositionBridge.utf16ToScalar(delEnd, currentText)
1979
+
1980
+ if (scalarStart < scalarEnd) {
1981
+ deleteRangeInRust(scalarStart, scalarEnd)
1982
+ } else if (beforeLength > 0 && afterLength == 0) {
1983
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
1984
+ }
1985
+ }
1986
+
1987
+ /**
1988
+ * Handle backspace key press (hardware keyboard or key event).
1989
+ *
1990
+ * If there's a range selection, deletes the range. Otherwise deletes
1991
+ * the grapheme cluster before the cursor.
1992
+ */
1993
+ fun handleBackspace() {
1994
+ if (!isEditable) return
1995
+ if (isApplyingRustState) return
1996
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
1997
+ if (editorId == 0L) {
1998
+ // Dev mode: direct editing.
1999
+ val editable = this.text ?: return
2000
+ val (start, end) = selectionRange
2001
+ if (start != end) {
2002
+ editable.delete(start, end)
2003
+ } else if (start > 0) {
2004
+ // Delete one grapheme cluster backward.
2005
+ val prevBoundary = PositionBridge.snapToGraphemeBoundary(start - 1, text?.toString() ?: "")
2006
+ val adjustedPrev = if (prevBoundary >= start) maxOf(0, start - 1) else prevBoundary
2007
+ editable.delete(adjustedPrev, start)
2008
+ }
2009
+ return
2010
+ }
2011
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2012
+
2013
+ val currentText = text?.toString() ?: ""
2014
+ val (start, end) = selectionRange
2015
+
2016
+ if (start != end) {
2017
+ // Range selection: delete the range.
2018
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
2019
+ deleteRangeInRust(scalarStart, scalarEnd)
2020
+ } else if (start > 0) {
2021
+ if (currentText.getOrNull(start - 1) == EMPTY_BLOCK_PLACEHOLDER) {
2022
+ val scalarCursor = PositionBridge.utf16ToScalar(start, currentText)
2023
+ deleteBackwardAtSelectionScalarInRust(scalarCursor, scalarCursor)
2024
+ return
2025
+ }
2026
+ // Cursor: delete one grapheme cluster backward.
2027
+ // Find the previous grapheme boundary by snapping (start - 1).
2028
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
2029
+ breakIter.setText(currentText)
2030
+ val prevBoundary = breakIter.preceding(start)
2031
+ val prevUtf16 = if (prevBoundary == java.text.BreakIterator.DONE) 0 else prevBoundary
2032
+
2033
+ val scalarStart = PositionBridge.utf16ToScalar(prevUtf16, currentText)
2034
+ val scalarEnd = PositionBridge.utf16ToScalar(start, currentText)
2035
+ if (scalarStart < scalarEnd) {
2036
+ deleteRangeInRust(scalarStart, scalarEnd)
2037
+ } else {
2038
+ deleteBackwardAtSelectionScalarInRust(scalarEnd, scalarEnd)
2039
+ }
2040
+ } else {
2041
+ deleteBackwardAtSelectionScalarInRust(0, 0)
2042
+ }
2043
+ }
2044
+
2045
+ fun handleForwardDelete() {
2046
+ if (!isEditable) return
2047
+ if (isApplyingRustState) return
2048
+ val selectionRange = normalizedUtf16SelectionRange() ?: return
2049
+ if (editorId == 0L) {
2050
+ val editable = this.text ?: return
2051
+ val (start, end) = selectionRange
2052
+ if (start != end) {
2053
+ editable.delete(start, end)
2054
+ } else if (start < editable.length) {
2055
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
2056
+ breakIter.setText(editable.toString())
2057
+ val nextBoundary = breakIter.following(start)
2058
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
2059
+ editable.length
2060
+ } else {
2061
+ nextBoundary
2062
+ }
2063
+ editable.delete(start, nextUtf16.coerceIn(start, editable.length))
2064
+ }
2065
+ return
2066
+ }
2067
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2068
+
2069
+ val currentText = text?.toString() ?: ""
2070
+ val (start, end) = selectionRange
2071
+ if (start != end) {
2072
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
2073
+ deleteRangeInRust(scalarStart, scalarEnd)
2074
+ } else if (start < currentText.length) {
2075
+ val breakIter = java.text.BreakIterator.getCharacterInstance()
2076
+ breakIter.setText(currentText)
2077
+ val nextBoundary = breakIter.following(start)
2078
+ val nextUtf16 = if (nextBoundary == java.text.BreakIterator.DONE) {
2079
+ currentText.length
2080
+ } else {
2081
+ nextBoundary
2082
+ }
2083
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
2084
+ start,
2085
+ nextUtf16.coerceIn(start, currentText.length),
2086
+ currentText
2087
+ )
2088
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
2089
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
2090
+ if (scalarStart < scalarEnd) {
2091
+ deleteRangeInRust(scalarStart, scalarEnd)
2092
+ }
2093
+ }
2094
+ }
2095
+
2096
+ // ── Input Handling: Return Key ──────────────────────────────────────
2097
+
2098
+ /**
2099
+ * Handle return/enter key as a block split operation.
2100
+ */
2101
+ fun handleReturnKey() {
2102
+ if (!isEditable) return
2103
+ if (isApplyingRustState) return
2104
+
2105
+ val currentText = text?.toString() ?: ""
2106
+ val (start, end) = normalizedUtf16SelectionRange() ?: return
2107
+
2108
+ if (editorId == 0L) {
2109
+ // Dev mode: insert newline directly.
2110
+ val editable = this.text ?: return
2111
+ editable.replace(start, end, "\n")
2112
+ return
2113
+ }
2114
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2115
+
2116
+ if (start != end) {
2117
+ // Range selection: atomic delete-and-split via Rust.
2118
+ val (scalarStart, scalarEnd) = normalizedScalarSelectionRange(currentText) ?: return
2119
+ deleteAndSplitInRust(scalarStart, scalarEnd)
2120
+ } else {
2121
+ val scalarPos = PositionBridge.utf16ToScalar(start, currentText)
2122
+ splitBlockInRust(scalarPos)
2123
+ }
2124
+ }
2125
+
2126
+ /**
2127
+ * Handle Shift+Enter as an inline hard break insertion.
2128
+ */
2129
+ fun handleHardBreak() {
2130
+ if (!isEditable) return
2131
+ if (isApplyingRustState) return
2132
+
2133
+ if (editorId == 0L) {
2134
+ val editable = this.text ?: return
2135
+ val start = selectionStart
2136
+ val end = selectionEnd
2137
+ editable.replace(start, end, "\n")
2138
+ return
2139
+ }
2140
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2141
+
2142
+ val selection = currentScalarSelection() ?: return
2143
+ val updateJSON = editorInsertNodeAtSelectionScalar(
2144
+ editorId.toULong(),
2145
+ selection.first.toUInt(),
2146
+ selection.second.toUInt(),
2147
+ "hardBreak"
2148
+ )
2149
+ applyUpdateJSON(updateJSON)
2150
+ }
2151
+
2152
+ /**
2153
+ * Handle hardware Tab / Shift+Tab as list indent / outdent when the caret is in a list.
2154
+ */
2155
+ fun handleTab(shiftPressed: Boolean): Boolean {
2156
+ if (!isEditable) return false
2157
+ if (isApplyingRustState) return false
2158
+ if (!hasLiveEditor()) return false
2159
+ if (!isSelectionInsideList()) return false
2160
+ val selection = currentScalarSelection() ?: return false
2161
+
2162
+ val updateJSON = if (shiftPressed) {
2163
+ editorOutdentListItemAtSelectionScalar(
2164
+ editorId.toULong(),
2165
+ selection.first.toUInt(),
2166
+ selection.second.toUInt()
2167
+ )
2168
+ } else {
2169
+ editorIndentListItemAtSelectionScalar(
2170
+ editorId.toULong(),
2171
+ selection.first.toUInt(),
2172
+ selection.second.toUInt()
2173
+ )
2174
+ }
2175
+ applyUpdateJSON(updateJSON)
2176
+ return true
2177
+ }
2178
+
2179
+ fun handleHardwareKeyDown(keyCode: Int, shiftPressed: Boolean): Boolean {
2180
+ if (!isEditable || isApplyingRustState) return false
2181
+ return when (keyCode) {
2182
+ KeyEvent.KEYCODE_DEL -> {
2183
+ handleBackspace()
2184
+ true
2185
+ }
2186
+ KeyEvent.KEYCODE_FORWARD_DEL -> {
2187
+ handleForwardDelete()
2188
+ true
2189
+ }
2190
+ KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> {
2191
+ if (shiftPressed) {
2192
+ handleHardBreak()
2193
+ } else {
2194
+ handleReturnKey()
2195
+ }
2196
+ true
2197
+ }
2198
+ KeyEvent.KEYCODE_TAB -> handleTab(shiftPressed)
2199
+ else -> false
2200
+ }
2201
+ }
2202
+
2203
+ private fun isSupportedHardwareMutationKey(keyCode: Int): Boolean =
2204
+ when (keyCode) {
2205
+ KeyEvent.KEYCODE_DEL,
2206
+ KeyEvent.KEYCODE_FORWARD_DEL,
2207
+ KeyEvent.KEYCODE_ENTER,
2208
+ KeyEvent.KEYCODE_NUMPAD_ENTER,
2209
+ KeyEvent.KEYCODE_TAB -> true
2210
+ else -> false
2211
+ }
2212
+
2213
+ internal fun isReadOnlyTextMutationKeyEvent(event: KeyEvent): Boolean {
2214
+ if (isSupportedHardwareMutationKey(event.keyCode) ||
2215
+ event.keyCode == KeyEvent.KEYCODE_FORWARD_DEL
2216
+ ) {
2217
+ return true
2218
+ }
2219
+ if (event.keyCode == KeyEvent.KEYCODE_INSERT && event.isShiftPressed) {
2220
+ return true
2221
+ }
2222
+ if (event.isCtrlPressed || event.isMetaPressed) {
2223
+ return when (event.keyCode) {
2224
+ KeyEvent.KEYCODE_V,
2225
+ KeyEvent.KEYCODE_X,
2226
+ KeyEvent.KEYCODE_Z,
2227
+ KeyEvent.KEYCODE_Y -> true
2228
+ else -> false
2229
+ }
2230
+ }
2231
+ if (!keyEventCharacters(event).isNullOrEmpty()) return true
2232
+ return event.unicodeChar != 0
2233
+ }
2234
+
2235
+ fun handleHardwareKeyEvent(event: KeyEvent?): Boolean {
2236
+ if (event == null || !isEditable || isApplyingRustState) return false
2237
+
2238
+ return when (event.action) {
2239
+ KeyEvent.ACTION_DOWN -> {
2240
+ if (!isSupportedHardwareMutationKey(event.keyCode)) return false
2241
+
2242
+ val signature = hardwareKeyEventSignature(event)
2243
+ if (
2244
+ lastHandledHardwareKeySignature == signature ||
2245
+ didRecentlyHandleHardwareKeyDown(signature)
2246
+ ) {
2247
+ return true
2248
+ }
2249
+
2250
+ if (handleHardwareKeyDown(event.keyCode, event.isShiftPressed)) {
2251
+ markHandledHardwareKeyDown(signature)
2252
+ true
2253
+ } else {
2254
+ false
2255
+ }
2256
+ }
2257
+
2258
+ KeyEvent.ACTION_UP -> {
2259
+ if (lastHandledHardwareKeySignature?.let {
2260
+ it.keyCode == event.keyCode && it.downTime == event.downTime
2261
+ } == true) {
2262
+ lastHandledHardwareKeySignature = null
2263
+ true
2264
+ } else {
2265
+ false
2266
+ }
2267
+ }
2268
+
2269
+ else -> false
2270
+ }
2271
+ }
2272
+
2273
+ internal fun handlePrintableHardwareKeyEvent(
2274
+ event: KeyEvent,
2275
+ applyBaseEvent: () -> Boolean
2276
+ ): Boolean {
2277
+ if (!isEditable || isApplyingRustState || !isPrintableHardwareMutationKey(event)) {
2278
+ return false
2279
+ }
2280
+ val signature = hardwareKeyEventSignature(event)
2281
+ return when (event.action) {
2282
+ KeyEvent.ACTION_DOWN -> {
2283
+ if (
2284
+ lastHandledHardwareKeySignature == signature ||
2285
+ didRecentlyHandleHardwareKeyDown(signature)
2286
+ ) {
2287
+ true
2288
+ } else {
2289
+ val inputConnection = activeInputConnection?.takeIf {
2290
+ it.hasPendingComposition()
2291
+ }
2292
+ if (inputConnection != null) {
2293
+ var didMutate = false
2294
+ runWithTransientInputMutationGuard {
2295
+ didMutate = insertTransientHardwareText(keyEventText(event))
2296
+ didMutate
2297
+ }
2298
+ if (!didMutate) return false
2299
+ inputConnection.refreshComposingTextFromEditableForEditor()
2300
+ } else {
2301
+ applyBaseEvent()
2302
+ }
2303
+ markHandledHardwareKeyDown(signature)
2304
+ true
2305
+ }
2306
+ }
2307
+ KeyEvent.ACTION_UP -> {
2308
+ if (lastHandledHardwareKeySignature?.let {
2309
+ it.keyCode == event.keyCode && it.downTime == event.downTime
2310
+ } == true) {
2311
+ lastHandledHardwareKeySignature = null
2312
+ }
2313
+ false
2314
+ }
2315
+ else -> false
2316
+ }
2317
+ }
2318
+
2319
+ private fun isPrintableHardwareMutationKey(event: KeyEvent): Boolean {
2320
+ if (isSupportedHardwareMutationKey(event.keyCode)) return false
2321
+ if (event.isCtrlPressed || event.isMetaPressed) return false
2322
+ return !keyEventText(event).isNullOrEmpty()
2323
+ }
2324
+
2325
+ private fun hardwareKeyEventSignature(event: KeyEvent): HardwareKeyEventSignature =
2326
+ HardwareKeyEventSignature(
2327
+ keyCode = event.keyCode,
2328
+ downTime = event.downTime,
2329
+ repeatCount = event.repeatCount
2330
+ )
2331
+
2332
+ @Suppress("DEPRECATION")
2333
+ private fun keyEventCharacters(event: KeyEvent): String? = event.characters
2334
+
2335
+ private fun keyEventText(event: KeyEvent): String? {
2336
+ val characters = keyEventCharacters(event)
2337
+ if (!characters.isNullOrEmpty()) return characters
2338
+ val unicodeChar = event.unicodeChar
2339
+ if (unicodeChar == 0) return null
2340
+ return runCatching {
2341
+ String(Character.toChars(unicodeChar))
2342
+ }.getOrNull()
2343
+ }
2344
+
2345
+ private fun insertTransientHardwareText(insertedText: String?): Boolean {
2346
+ if (insertedText.isNullOrEmpty()) return false
2347
+ val editable = text ?: return false
2348
+ val currentText = editable.toString()
2349
+ val rawStart = selectionStart
2350
+ val rawEnd = selectionEnd
2351
+ if (rawStart < 0 || rawEnd < 0) return false
2352
+ val start = rawStart.coerceIn(0, editable.length)
2353
+ val end = rawEnd.coerceIn(0, editable.length)
2354
+ val normalizedStart = minOf(start, end)
2355
+ val normalizedEnd = maxOf(start, end)
2356
+ val (replaceStart, replaceEnd) = PositionBridge.snapRangeToScalarBoundaries(
2357
+ normalizedStart,
2358
+ normalizedEnd,
2359
+ currentText
2360
+ )
2361
+ editable.replace(replaceStart, replaceEnd, insertedText)
2362
+ val cursor = (replaceStart + insertedText.length).coerceIn(0, editable.length)
2363
+ setSelection(cursor)
2364
+ return true
2365
+ }
2366
+
2367
+ private fun markHandledHardwareKeyDown(signature: HardwareKeyEventSignature) {
2368
+ lastHandledHardwareKeySignature = signature
2369
+ recentHandledHardwareKeyDownSignature = signature
2370
+ recentHandledHardwareKeyDownUptimeMs = SystemClock.uptimeMillis()
2371
+ }
2372
+
2373
+ private fun didRecentlyHandleHardwareKeyDown(signature: HardwareKeyEventSignature): Boolean {
2374
+ val recentSignature = recentHandledHardwareKeyDownSignature ?: return false
2375
+ val elapsedMs = SystemClock.uptimeMillis() - recentHandledHardwareKeyDownUptimeMs
2376
+ if (elapsedMs > RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS) {
2377
+ recentHandledHardwareKeyDownSignature = null
2378
+ recentHandledHardwareKeyDownUptimeMs = 0L
2379
+ return false
2380
+ }
2381
+ return recentSignature == signature
2382
+ }
2383
+
2384
+ fun performToolbarToggleMark(markName: String) {
2385
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2386
+ val selection = currentScalarSelection() ?: return
2387
+ val updateJSON = editorToggleMarkAtSelectionScalar(
2388
+ editorId.toULong(),
2389
+ selection.first.toUInt(),
2390
+ selection.second.toUInt(),
2391
+ markName
2392
+ )
2393
+ applyUpdateJSON(updateJSON)
2394
+ }
2395
+
2396
+ fun performToolbarToggleList(listType: String, isActive: Boolean) {
2397
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2398
+ val selection = currentScalarSelection() ?: return
2399
+ val updateJSON = if (isActive) {
2400
+ editorUnwrapFromListAtSelectionScalar(
2401
+ editorId.toULong(),
2402
+ selection.first.toUInt(),
2403
+ selection.second.toUInt()
2404
+ )
2405
+ } else {
2406
+ editorWrapInListAtSelectionScalar(
2407
+ editorId.toULong(),
2408
+ selection.first.toUInt(),
2409
+ selection.second.toUInt(),
2410
+ listType
2411
+ )
2412
+ }
2413
+ applyUpdateJSON(updateJSON)
2414
+ }
2415
+
2416
+ fun performToolbarToggleBlockquote() {
2417
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2418
+ val selection = currentScalarSelection() ?: return
2419
+ val updateJSON = editorToggleBlockquoteAtSelectionScalar(
2420
+ editorId.toULong(),
2421
+ selection.first.toUInt(),
2422
+ selection.second.toUInt()
2423
+ )
2424
+ applyUpdateJSON(updateJSON)
2425
+ }
2426
+
2427
+ fun performToolbarToggleHeading(level: Int) {
2428
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2429
+ if (level !in 1..6) return
2430
+ val selection = currentScalarSelection() ?: return
2431
+ val updateJSON = editorToggleHeadingAtSelectionScalar(
2432
+ editorId.toULong(),
2433
+ selection.first.toUInt(),
2434
+ selection.second.toUInt(),
2435
+ level.toUByte()
2436
+ )
2437
+ applyUpdateJSON(updateJSON)
2438
+ }
2439
+
2440
+ fun performToolbarIndentListItem() {
2441
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2442
+ val selection = currentScalarSelection() ?: return
2443
+ val updateJSON = editorIndentListItemAtSelectionScalar(
2444
+ editorId.toULong(),
2445
+ selection.first.toUInt(),
2446
+ selection.second.toUInt()
2447
+ )
2448
+ applyUpdateJSON(updateJSON)
2449
+ }
2450
+
2451
+ fun performToolbarOutdentListItem() {
2452
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2453
+ val selection = currentScalarSelection() ?: return
2454
+ val updateJSON = editorOutdentListItemAtSelectionScalar(
2455
+ editorId.toULong(),
2456
+ selection.first.toUInt(),
2457
+ selection.second.toUInt()
2458
+ )
2459
+ applyUpdateJSON(updateJSON)
2460
+ }
2461
+
2462
+ fun performToolbarInsertNode(nodeType: String) {
2463
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2464
+ val selection = currentScalarSelection() ?: return
2465
+ val updateJSON = editorInsertNodeAtSelectionScalar(
2466
+ editorId.toULong(),
2467
+ selection.first.toUInt(),
2468
+ selection.second.toUInt(),
2469
+ nodeType
2470
+ )
2471
+ applyUpdateJSON(updateJSON)
2472
+ }
2473
+
2474
+ fun performToolbarUndo() {
2475
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2476
+ applyUpdateJSON(editorUndo(editorId.toULong()))
2477
+ }
2478
+
2479
+ fun performToolbarRedo() {
2480
+ if (!isEditable || isApplyingRustState || !hasLiveEditor()) return
2481
+ applyUpdateJSON(editorRedo(editorId.toULong()))
2482
+ }
2483
+
2484
+ // ── Input Handling: Paste ────────────────────────────────────────────
2485
+
2486
+ /**
2487
+ * Intercept paste operations to route content through Rust.
2488
+ *
2489
+ * Attempts to extract HTML from the clipboard first (for rich text paste),
2490
+ * falling back to plain text.
2491
+ */
2492
+ override fun onTextContextMenuItem(id: Int): Boolean {
2493
+ if (!isEditable && isMutatingContextMenuItem(id)) return true
2494
+ if (id == android.R.id.cut) {
2495
+ handleCut()
2496
+ return true
2497
+ }
2498
+ if (id == android.R.id.paste || id == android.R.id.pasteAsPlainText) {
2499
+ handlePaste(plainTextOnly = id == android.R.id.pasteAsPlainText)
2500
+ return true
2501
+ }
2502
+ return super.onTextContextMenuItem(id)
2503
+ }
2504
+
2505
+ private fun isMutatingContextMenuItem(id: Int): Boolean =
2506
+ id == android.R.id.paste ||
2507
+ id == android.R.id.pasteAsPlainText ||
2508
+ id == android.R.id.cut
2509
+
2510
+ /**
2511
+ * Block accessibility-initiated text mutations (paste, cut, set text) when not editable.
2512
+ * Selection and copy actions remain available.
2513
+ */
2514
+ override fun performAccessibilityAction(action: Int, arguments: android.os.Bundle?): Boolean {
2515
+ if (!isEditable && (
2516
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT ||
2517
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_PASTE ||
2518
+ action == android.view.accessibility.AccessibilityNodeInfo.ACTION_CUT
2519
+ )
2520
+ ) {
2521
+ return false
2522
+ }
2523
+ if (action == android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT) {
2524
+ return handleAccessibilitySetText(arguments)
2525
+ }
2526
+ return super.performAccessibilityAction(action, arguments)
2527
+ }
2528
+
2529
+ private fun handlePaste(plainTextOnly: Boolean) {
2530
+ if (editorId == 0L) {
2531
+ // Dev mode: default paste behavior.
2532
+ super.onTextContextMenuItem(
2533
+ if (plainTextOnly) android.R.id.pasteAsPlainText else android.R.id.paste
2534
+ )
2535
+ return
2536
+ }
2537
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2538
+ if (!prepareForExternalEditorUpdate()) return
2539
+
2540
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
2541
+ ?: return
2542
+ val clip = clipboard.primaryClip ?: return
2543
+ if (clip.itemCount == 0) return
2544
+
2545
+ val item = clip.getItemAt(0)
2546
+
2547
+ // Try HTML first for rich paste.
2548
+ val htmlText = item.htmlText
2549
+ if (!plainTextOnly && htmlText != null) {
2550
+ pasteHTML(htmlText)
2551
+ return
2552
+ }
2553
+
2554
+ // Fallback to plain text.
2555
+ val plainText = item.text?.toString() ?: item.coerceToText(context)?.toString()
2556
+ if (plainText != null) {
2557
+ pastePlainText(plainText)
2558
+ }
2559
+ }
2560
+
2561
+ private fun handleCut() {
2562
+ if (editorId == 0L) {
2563
+ super.onTextContextMenuItem(android.R.id.cut)
2564
+ return
2565
+ }
2566
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return
2567
+ if (!prepareForExternalEditorUpdate()) return
2568
+
2569
+ val currentText = text?.toString() ?: return
2570
+ val (selectionStart, selectionEnd) = normalizedUtf16SelectionRange(currentText) ?: return
2571
+ if (selectionStart == selectionEnd) return
2572
+
2573
+ val (utf16Start, utf16End) = PositionBridge.snapRangeToScalarBoundaries(
2574
+ selectionStart,
2575
+ selectionEnd,
2576
+ currentText
2577
+ )
2578
+ if (utf16Start >= utf16End) return
2579
+
2580
+ val selectedText = currentText.substring(utf16Start, utf16End)
2581
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager
2582
+ clipboard?.setPrimaryClip(ClipData.newPlainText(null, selectedText))
2583
+
2584
+ val scalarStart = PositionBridge.utf16ToScalar(utf16Start, currentText)
2585
+ val scalarEnd = PositionBridge.utf16ToScalar(utf16End, currentText)
2586
+ deleteRangeInRust(scalarStart, scalarEnd)
2587
+ }
2588
+
2589
+ private fun handleAccessibilitySetText(arguments: android.os.Bundle?): Boolean {
2590
+ val replacement = arguments
2591
+ ?.getCharSequence(
2592
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_ARGUMENT_SET_TEXT_CHARSEQUENCE
2593
+ )
2594
+ ?.toString()
2595
+ ?: return false
2596
+ if (editorId == 0L) {
2597
+ return super.performAccessibilityAction(
2598
+ android.view.accessibility.AccessibilityNodeInfo.ACTION_SET_TEXT,
2599
+ arguments
2600
+ )
2601
+ }
2602
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
2603
+ if (!prepareForExternalEditorUpdate()) return false
2604
+
2605
+ val currentText = text?.toString() ?: return false
2606
+ val scalarStart = 0
2607
+ val scalarEnd = currentText.codePointCount(0, currentText.length)
2608
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, replacement)
2609
+ return true
2610
+ }
2611
+
2612
+ // ── Selection Change ────────────────────────────────────────────────
2613
+
2614
+ /**
2615
+ * Override to notify the listener when selection changes.
2616
+ *
2617
+ * Converts the EditText selection to scalar offsets and notifies both
2618
+ * the listener and the Rust editor.
2619
+ */
2620
+ override fun onSelectionChanged(selStart: Int, selEnd: Int) {
2621
+ super.onSelectionChanged(selStart, selEnd)
2622
+ if (isApplyingRustState) return
2623
+ val spannable = text as? Spanned
2624
+ if (spannable != null && isExactImageSpanRange(spannable, selStart, selEnd)) {
2625
+ explicitSelectedImageRange = ImageSelectionRange(selStart, selEnd)
2626
+ }
2627
+ ensureSelectionVisible()
2628
+ onSelectionOrContentMayChange?.invoke()
2629
+ // Keep the custom caret solid at its new position, then resume blinking.
2630
+ restartCaretBlink()
2631
+
2632
+ syncCurrentSelectionToRust()
2633
+ }
2634
+
2635
+ private fun syncCurrentSelectionToRust() {
2636
+ if (!hasLiveEditor()) return
2637
+
2638
+ val currentText = text?.toString() ?: ""
2639
+ if (currentText != lastAuthorizedText) return
2640
+ val (scalarAnchor, scalarHead) = rawScalarSelection(currentText) ?: return
2641
+
2642
+ val selectionHook = onSetSelectionScalarInRustForTesting
2643
+ val docAnchor: Int
2644
+ val docHead: Int
2645
+ if (selectionHook != null) {
2646
+ selectionHook(scalarAnchor, scalarHead)
2647
+ docAnchor = scalarAnchor
2648
+ docHead = scalarHead
2649
+ } else {
2650
+ // Sync selection to Rust (converts scalar→doc internally).
2651
+ editorSetSelectionScalar(
2652
+ editorId.toULong(),
2653
+ scalarAnchor.toUInt(),
2654
+ scalarHead.toUInt()
2655
+ )
2656
+
2657
+ // Emit doc positions (not scalar offsets) to match the Selection contract.
2658
+ docAnchor = editorScalarToDoc(editorId.toULong(), scalarAnchor.toUInt()).toInt()
2659
+ docHead = editorScalarToDoc(editorId.toULong(), scalarHead.toUInt()).toInt()
2660
+ }
2661
+ editorListener?.onSelectionChanged(docAnchor, docHead)
2662
+ }
2663
+
2664
+ // ── Rust Integration ────────────────────────────────────────────────
2665
+
2666
+ // Samsung Keyboard may call finishComposingText() and then commitText(" ")
2667
+ // for one space tap. Defer the render from finishComposingText() by one
2668
+ // loop so setText() does not restart input before the pending space arrives.
2669
+ internal fun runWithDeferredRustUpdateApplication(block: () -> Unit) {
2670
+ recordImeTraceForTesting(
2671
+ "deferRustUpdateBegin",
2672
+ "depth=$deferredRustUpdateApplicationDepth pending=${deferredRustUpdateJSON != null}"
2673
+ )
2674
+ deferredRustUpdateApplicationDepth += 1
2675
+ try {
2676
+ block()
2677
+ } finally {
2678
+ deferredRustUpdateApplicationDepth -= 1
2679
+ recordImeTraceForTesting(
2680
+ "deferRustUpdateEnd",
2681
+ "depth=$deferredRustUpdateApplicationDepth pending=${deferredRustUpdateJSON != null}"
2682
+ )
2683
+ if (deferredRustUpdateApplicationDepth == 0) {
2684
+ scheduleDeferredRustUpdateApplication()
2685
+ }
2686
+ }
2687
+ }
2688
+
2689
+ private fun applyRustUpdateJSON(updateJSON: String) {
2690
+ if (deferredRustUpdateApplicationDepth > 0) {
2691
+ deferredRustUpdateJSON = updateJSON
2692
+ recordImeTraceForTesting(
2693
+ "rustUpdateDeferred",
2694
+ "jsonLength=${updateJSON.length} depth=$deferredRustUpdateApplicationDepth"
2695
+ )
2696
+ authorizeCurrentVisibleTextForDeferredRustUpdate()
2697
+ return
2698
+ }
2699
+ cancelDeferredRustUpdateApplication()
2700
+ recordImeTraceForTesting(
2701
+ "rustUpdateApply",
2702
+ "mode=immediate jsonLength=${updateJSON.length}"
2703
+ )
2704
+ applyUpdateJSON(updateJSON)
2705
+ }
2706
+
2707
+ private fun authorizeCurrentVisibleTextForDeferredRustUpdate() {
2708
+ lastAuthorizedText = text?.toString().orEmpty()
2709
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
2710
+ lastAuthorizedTextRevision += 1L
2711
+ currentRenderBlocksJson = null
2712
+ clearNativeTextMutationAdoptionSuppression()
2713
+ clearNativeTextMutationAfterBlurWindow()
2714
+ }
2715
+
2716
+ internal fun authorizeCurrentVisibleTextForPendingImeOperationForEditor() {
2717
+ pendingOptimisticRenderText = null
2718
+ authorizeCurrentVisibleTextForDeferredRustUpdate()
2719
+ recordImeTraceForTesting(
2720
+ "authorizePendingImeVisibleText",
2721
+ "textLength=${lastAuthorizedText.length}"
2722
+ )
2723
+ }
2724
+
2725
+ internal fun deleteScalarRangeForPendingImeOperationForEditor(scalarFrom: Int, scalarTo: Int) {
2726
+ deleteRangeInRust(scalarFrom, scalarTo)
2727
+ }
2728
+
2729
+ internal fun applyVisibleCompositionCommitForPendingImeOperationForEditor(
2730
+ committedText: String,
2731
+ replacementStartUtf16: Int,
2732
+ replacementEndUtf16: Int,
2733
+ newCursorPosition: Int
2734
+ ): Boolean {
2735
+ val editable = text ?: return false
2736
+ val currentText = editable.toString()
2737
+ val (startUtf16, endUtf16) = PositionBridge.snapRangeToScalarBoundaries(
2738
+ replacementStartUtf16,
2739
+ replacementEndUtf16,
2740
+ currentText
2741
+ )
2742
+ if (startUtf16 > endUtf16 || endUtf16 > editable.length) return false
2743
+ var didApply = false
2744
+ runWithTransientInputMutationGuard {
2745
+ editable.replace(startUtf16, endUtf16, committedText)
2746
+ val insertedEnd = startUtf16 + committedText.length
2747
+ val requestedCursor = when {
2748
+ newCursorPosition > 0 -> insertedEnd + newCursorPosition - 1
2749
+ newCursorPosition < 0 -> startUtf16 + newCursorPosition
2750
+ else -> insertedEnd
2751
+ }.coerceIn(0, editable.length)
2752
+ Selection.setSelection(editable, requestedCursor, requestedCursor)
2753
+ didApply = true
2754
+ true
2755
+ }
2756
+ if (didApply) {
2757
+ pendingOptimisticRenderText = null
2758
+ }
2759
+ return didApply
2760
+ }
2761
+
2762
+ internal fun commitAlreadyVisibleCompositionMutationForPendingImeOperationForEditor(
2763
+ committedText: String,
2764
+ newCursorPosition: Int
2765
+ ): Boolean {
2766
+ if (committedText.isEmpty()) return false
2767
+ val currentText = text?.toString() ?: return false
2768
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText) ?: return false
2769
+ val tokenRange = committedTokenRangeAroundMutation(
2770
+ currentText,
2771
+ mutation.replacementStartUtf16,
2772
+ mutation.replacementEndUtf16
2773
+ ) ?: run {
2774
+ recordImeTraceForTesting(
2775
+ "alreadyVisibleCompositionNoop",
2776
+ "reason=noToken committedLength=${committedText.length} visibleRange=${mutation.replacementStartUtf16}..${mutation.replacementEndUtf16}"
2777
+ )
2778
+ return false
2779
+ }
2780
+ val visibleToken = currentText.substring(tokenRange.first, tokenRange.second)
2781
+ if (visibleToken != committedText) {
2782
+ recordImeTraceForTesting(
2783
+ "alreadyVisibleCompositionNoop",
2784
+ "reason=tokenMismatch committedLength=${committedText.length} tokenLength=${visibleToken.length} visibleRange=${mutation.replacementStartUtf16}..${mutation.replacementEndUtf16}"
2785
+ )
2786
+ return false
2787
+ }
2788
+
2789
+ val authorizedText = lastAuthorizedText
2790
+ val requestedCursor = requestedCursorScalar(
2791
+ mutation.scalarFrom,
2792
+ mutation.scalarTo,
2793
+ authorizedText,
2794
+ mutation.replacementText,
2795
+ newCursorPosition
2796
+ )
2797
+ recordImeTraceForTesting(
2798
+ "alreadyVisibleCompositionApply",
2799
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} committedLength=${committedText.length} requestedCursor=$requestedCursor"
2800
+ )
2801
+ pendingOptimisticRenderText = null
2802
+ insertPlainTextRangeInRust(
2803
+ mutation.scalarFrom,
2804
+ mutation.scalarTo,
2805
+ mutation.replacementText,
2806
+ requestedCursorScalar = requestedCursor
2807
+ )
2808
+ return true
2809
+ }
2810
+
2811
+ private fun committedTokenRangeAroundMutation(
2812
+ currentText: String,
2813
+ replacementStartUtf16: Int,
2814
+ replacementEndUtf16: Int
2815
+ ): Pair<Int, Int>? {
2816
+ if (currentText.isEmpty()) return null
2817
+ val start = replacementStartUtf16.coerceIn(0, currentText.length)
2818
+ val end = replacementEndUtf16.coerceIn(start, currentText.length)
2819
+ val probe = when {
2820
+ start < end -> start
2821
+ start < currentText.length -> start
2822
+ start > 0 -> Character.offsetByCodePoints(currentText, start, -1)
2823
+ else -> return null
2824
+ }
2825
+ val tokenRange = missingOldTextCorrectionTokenRange(currentText, probe) ?: return null
2826
+ return if (start < end) {
2827
+ tokenRange.takeIf { it.first <= start && it.second >= end }
2828
+ } else {
2829
+ tokenRange.takeIf { start >= it.first && start <= it.second }
2830
+ }
2831
+ }
2832
+
2833
+ private fun scheduleDeferredRustUpdateApplication() {
2834
+ val pendingUpdateJSON = deferredRustUpdateJSON ?: return
2835
+ val generation = ++deferredRustUpdateGeneration
2836
+ recordImeTraceForTesting(
2837
+ "rustUpdateDeferredScheduled",
2838
+ "generation=$generation jsonLength=${pendingUpdateJSON.length}"
2839
+ )
2840
+ Handler(Looper.getMainLooper()).post {
2841
+ if (generation != deferredRustUpdateGeneration) {
2842
+ recordImeTraceForTesting(
2843
+ "rustUpdateDeferredSkip",
2844
+ "reason=generation generation=$generation current=$deferredRustUpdateGeneration"
2845
+ )
2846
+ return@post
2847
+ }
2848
+ if (deferredRustUpdateJSON != pendingUpdateJSON) {
2849
+ recordImeTraceForTesting("rustUpdateDeferredSkip", "reason=replaced generation=$generation")
2850
+ return@post
2851
+ }
2852
+ deferredRustUpdateJSON = null
2853
+ recordImeTraceForTesting(
2854
+ "rustUpdateApply",
2855
+ "mode=deferred generation=$generation jsonLength=${pendingUpdateJSON.length}"
2856
+ )
2857
+ applyUpdateJSON(pendingUpdateJSON)
2858
+ }
2859
+ }
2860
+
2861
+ private fun cancelDeferredRustUpdateApplication() {
2862
+ if (deferredRustUpdateJSON == null) return
2863
+ recordImeTraceForTesting(
2864
+ "rustUpdateDeferredCancel",
2865
+ "generation=$deferredRustUpdateGeneration"
2866
+ )
2867
+ deferredRustUpdateJSON = null
2868
+ deferredRustUpdateGeneration += 1L
2869
+ }
2870
+
2871
+ /**
2872
+ * Insert text at a scalar position via the Rust editor.
2873
+ */
2874
+ private fun insertTextInRust(text: String, atScalarPos: Int) {
2875
+ if (!hasLiveEditor()) return
2876
+ onInsertTextInRustForTesting?.let { callback ->
2877
+ callback(text, atScalarPos)
2878
+ return
2879
+ }
2880
+ val startedAt = System.nanoTime()
2881
+ val updateJSON = editorInsertTextScalar(editorId.toULong(), atScalarPos.toUInt(), text)
2882
+ recordImeTraceForTesting(
2883
+ "rustInsertText",
2884
+ "at=$atScalarPos textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2885
+ )
2886
+ applyRustUpdateJSON(updateJSON)
2887
+ }
2888
+
2889
+ private fun replaceTextRangeInRust(scalarFrom: Int, scalarTo: Int, text: String) {
2890
+ if (!hasLiveEditor()) return
2891
+ onReplaceTextInRustForTesting?.let { callback ->
2892
+ callback(scalarFrom, scalarTo, text)
2893
+ return
2894
+ }
2895
+ val startedAt = System.nanoTime()
2896
+ val updateJSON = editorReplaceTextScalar(
2897
+ editorId.toULong(),
2898
+ scalarFrom.toUInt(),
2899
+ scalarTo.toUInt(),
2900
+ text
2901
+ )
2902
+ recordImeTraceForTesting(
2903
+ "rustReplaceText",
2904
+ "range=$scalarFrom..$scalarTo textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2905
+ )
2906
+ applyRustUpdateJSON(updateJSON)
2907
+ }
2908
+
2909
+ private fun insertPlainTextRangeInRust(
2910
+ scalarFrom: Int,
2911
+ scalarTo: Int,
2912
+ text: String,
2913
+ requestedCursorScalar: Int? = null
2914
+ ) {
2915
+ if (!hasLiveEditor()) return
2916
+ recordImeTraceForTesting(
2917
+ "rustPlainTextRoute",
2918
+ "range=$scalarFrom..$scalarTo textLength=${text.length} requestedCursor=$requestedCursorScalar"
2919
+ )
2920
+ if (text.isEmpty()) {
2921
+ if (scalarFrom != scalarTo) {
2922
+ deleteRangeInRust(scalarFrom, scalarTo)
2923
+ }
2924
+ applyRequestedCursorScalar(requestedCursorScalar)
2925
+ return
2926
+ }
2927
+ if (text.indexOf('\n') >= 0 || text.indexOf('\r') >= 0) {
2928
+ val docJson = plainTextDocumentFragmentJson(text)
2929
+ onInsertContentJsonAtSelectionScalarForTesting?.let { callback ->
2930
+ callback(scalarFrom, scalarTo, docJson)
2931
+ applyRequestedCursorScalar(requestedCursorScalar)
2932
+ return
2933
+ }
2934
+ val startedAt = System.nanoTime()
2935
+ val updateJSON = editorInsertContentJsonAtSelectionScalar(
2936
+ editorId.toULong(),
2937
+ scalarFrom.toUInt(),
2938
+ scalarTo.toUInt(),
2939
+ docJson
2940
+ )
2941
+ recordImeTraceForTesting(
2942
+ "rustInsertContentJson",
2943
+ "range=$scalarFrom..$scalarTo textLength=${text.length} rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
2944
+ )
2945
+ applyRustUpdateJSON(updateJSON)
2946
+ applyRequestedCursorScalar(requestedCursorScalar)
2947
+ return
2948
+ }
2949
+
2950
+ if (scalarFrom != scalarTo) {
2951
+ replaceTextRangeInRust(scalarFrom, scalarTo, text)
2952
+ } else {
2953
+ insertTextInRust(text, scalarFrom)
2954
+ }
2955
+ applyRequestedCursorScalar(requestedCursorScalar)
2956
+ }
2957
+
2958
+ private fun requestedCursorScalar(
2959
+ scalarFrom: Int,
2960
+ scalarTo: Int,
2961
+ currentText: String,
2962
+ insertedText: String,
2963
+ newCursorPosition: Int
2964
+ ): Int? {
2965
+ if (newCursorPosition == 1) return null
2966
+ val insertedScalarLength = insertedText.codePointCount(0, insertedText.length)
2967
+ val currentScalarLength = currentText.codePointCount(0, currentText.length)
2968
+ val nextScalarLength =
2969
+ (currentScalarLength - (scalarTo - scalarFrom) + insertedScalarLength).coerceAtLeast(0)
2970
+ val requested = if (newCursorPosition > 0) {
2971
+ scalarFrom + insertedScalarLength + newCursorPosition - 1
2972
+ } else {
2973
+ scalarFrom + newCursorPosition
2974
+ }
2975
+ return requested.coerceIn(0, nextScalarLength)
2976
+ }
2977
+
2978
+ private fun applyRequestedCursorScalar(requestedCursorScalar: Int?) {
2979
+ val requested = requestedCursorScalar ?: return
2980
+ if (!hasLiveEditor()) return
2981
+ val currentText = text?.toString().orEmpty()
2982
+ val safeScalar = requested.coerceAtLeast(0)
2983
+ onSetSelectionScalarInRustForTesting?.let { callback ->
2984
+ callback(safeScalar, safeScalar)
2985
+ } ?: editorSetSelectionScalar(
2986
+ editorId.toULong(),
2987
+ safeScalar.toUInt(),
2988
+ safeScalar.toUInt()
2989
+ )
2990
+ val localScalar = safeScalar.coerceIn(0, currentText.codePointCount(0, currentText.length))
2991
+ val safeUtf16 = PositionBridge.scalarToUtf16(localScalar, currentText)
2992
+ .coerceIn(0, currentText.length)
2993
+ if (selectionStart != safeUtf16 || selectionEnd != safeUtf16) {
2994
+ setSelection(safeUtf16, safeUtf16)
2995
+ }
2996
+ }
2997
+
2998
+ private fun plainTextDocumentFragmentJson(text: String): String {
2999
+ val normalizedText = text.replace("\r\n", "\n").replace('\r', '\n')
3000
+ val content = org.json.JSONArray()
3001
+ for (line in normalizedText.split('\n')) {
3002
+ val paragraph = org.json.JSONObject().put("type", "paragraph")
3003
+ if (line.isNotEmpty()) {
3004
+ paragraph.put(
3005
+ "content",
3006
+ org.json.JSONArray().put(
3007
+ org.json.JSONObject()
3008
+ .put("type", "text")
3009
+ .put("text", line)
3010
+ )
3011
+ )
3012
+ }
3013
+ content.put(paragraph)
3014
+ }
3015
+ return org.json.JSONObject()
3016
+ .put("type", "doc")
3017
+ .put("content", content)
3018
+ .toString()
3019
+ }
3020
+
3021
+ private fun nativeTextMutationFromAuthorizedDiff(currentText: String): NativeTextMutation? {
3022
+ val authorizedText = lastAuthorizedText
3023
+ if (currentText == authorizedText) return null
3024
+
3025
+ var prefix = 0
3026
+ val sharedLength = minOf(authorizedText.length, currentText.length)
3027
+ while (
3028
+ prefix < sharedLength &&
3029
+ authorizedText[prefix] == currentText[prefix]
3030
+ ) {
3031
+ prefix++
3032
+ }
3033
+ prefix = minOf(
3034
+ PositionBridge.snapToScalarBoundary(prefix, authorizedText, biasForward = false),
3035
+ PositionBridge.snapToScalarBoundary(prefix, currentText, biasForward = false)
3036
+ )
3037
+
3038
+ var authorizedEnd = authorizedText.length
3039
+ var currentEnd = currentText.length
3040
+ while (
3041
+ authorizedEnd > prefix &&
3042
+ currentEnd > prefix &&
3043
+ authorizedText[authorizedEnd - 1] == currentText[currentEnd - 1]
3044
+ ) {
3045
+ authorizedEnd--
3046
+ currentEnd--
3047
+ }
3048
+ authorizedEnd = PositionBridge.snapToScalarBoundary(
3049
+ authorizedEnd,
3050
+ authorizedText,
3051
+ biasForward = true
3052
+ )
3053
+ currentEnd = PositionBridge.snapToScalarBoundary(
3054
+ currentEnd,
3055
+ currentText,
3056
+ biasForward = true
3057
+ )
3058
+
3059
+ val replacementText = currentText.substring(prefix, currentEnd)
3060
+ val rawSelectionStart = selectionStart
3061
+ val rawSelectionEnd = selectionEnd
3062
+ val selectionAnchorUtf16 = rawSelectionStart
3063
+ .takeIf { it >= 0 }
3064
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
3065
+ val selectionHeadUtf16 = rawSelectionEnd
3066
+ .takeIf { it >= 0 }
3067
+ ?.let { PositionBridge.snapToScalarBoundary(it, currentText, biasForward = true) }
3068
+ return NativeTextMutation(
3069
+ scalarFrom = PositionBridge.utf16ToScalar(prefix, authorizedText),
3070
+ scalarTo = PositionBridge.utf16ToScalar(authorizedEnd, authorizedText),
3071
+ replacementText = replacementText,
3072
+ resultingText = currentText,
3073
+ replacementStartUtf16 = prefix,
3074
+ replacementEndUtf16 = currentEnd,
3075
+ selectionScalarAnchor = selectionAnchorUtf16?.let {
3076
+ PositionBridge.utf16ToScalar(it, currentText)
3077
+ },
3078
+ selectionScalarHead = selectionHeadUtf16?.let {
3079
+ PositionBridge.utf16ToScalar(it, currentText)
3080
+ }
3081
+ )
3082
+ }
3083
+
3084
+ private fun shouldAdoptNativeTextMutation(
3085
+ mutation: NativeTextMutation,
3086
+ allowAfterBlur: Boolean = false
3087
+ ): Boolean {
3088
+ if (!isEditable) return false
3089
+ if (isNativeTextMutationAdoptionSuppressedForCurrentRevision()) return false
3090
+ if (!hasFocus()) {
3091
+ return allowAfterBlur &&
3092
+ canAdoptNativeTextMutationAfterBlur() &&
3093
+ shouldAdoptFinalNativeTextMutation(mutation)
3094
+ }
3095
+ return shouldAdoptFinalNativeTextMutation(mutation)
3096
+ }
3097
+
3098
+ private fun shouldAdoptFinalNativeTextMutation(mutation: NativeTextMutation): Boolean {
3099
+ if (composingTextForEditor() != null) return false
3100
+ val trackedRange = compositionReplacementRange() ?: return true
3101
+ val authorizedText = lastAuthorizedText
3102
+ val trackedStart = PositionBridge.utf16ToScalar(trackedRange.first, authorizedText)
3103
+ val trackedEnd = PositionBridge.utf16ToScalar(trackedRange.second, authorizedText)
3104
+ if (trackedStart == trackedEnd) {
3105
+ return mutation.scalarFrom == trackedStart &&
3106
+ mutation.scalarTo == trackedStart &&
3107
+ mutation.replacementText.isNotEmpty()
3108
+ }
3109
+ if (mutation.scalarFrom == mutation.scalarTo) {
3110
+ return mutation.replacementText.isNotEmpty() &&
3111
+ mutation.scalarFrom >= trackedStart &&
3112
+ mutation.scalarFrom <= trackedEnd
3113
+ }
3114
+ return mutation.scalarFrom < trackedEnd && mutation.scalarTo > trackedStart
3115
+ }
3116
+
3117
+ private fun drainNativeTextMutationIfNeeded(
3118
+ allowAfterBlur: Boolean,
3119
+ preserveInputConnectionForExternalUpdate: Boolean = false
3120
+ ): Boolean {
3121
+ if (editorId == 0L) return true
3122
+ if (discardTransientInputForDestroyedEditorIfNeeded()) return false
3123
+ val editable = text
3124
+ val currentText = editable?.toString() ?: ""
3125
+ if (currentText == lastAuthorizedText) return true
3126
+
3127
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
3128
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur)) {
3129
+ commitNativeTextMutation(
3130
+ mutation,
3131
+ preserveInputConnectionForExternalUpdate = preserveInputConnectionForExternalUpdate
3132
+ )
3133
+ return true
3134
+ }
3135
+ recordImeTraceForTesting(
3136
+ "nativeMutationNoop",
3137
+ "reason=${if (mutation == null) "noDiffRange" else "notAdoptable"} allowAfterBlur=$allowAfterBlur currentLength=${currentText.length} authorizedLength=${lastAuthorizedText.length}"
3138
+ )
3139
+ return false
3140
+ }
3141
+
3142
+ private fun beginNativeTextMutationAfterBlurWindow() {
3143
+ if (!hasLiveEditor()) {
3144
+ clearNativeTextMutationAfterBlurWindow()
3145
+ return
3146
+ }
3147
+ nativeTextMutationAfterBlurWindow = NativeTextMutationAfterBlurWindow(
3148
+ editorId = editorId,
3149
+ authorizedTextRevision = lastAuthorizedTextRevision,
3150
+ deadlineMs = SystemClock.uptimeMillis() + NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS
3151
+ )
3152
+ }
3153
+
3154
+ private fun clearNativeTextMutationAfterBlurWindow() {
3155
+ nativeTextMutationAfterBlurWindow = null
3156
+ }
3157
+
3158
+ private fun suppressNativeTextMutationAdoptionForCurrentRevision() {
3159
+ if (!hasLiveEditor()) {
3160
+ clearNativeTextMutationAdoptionSuppression()
3161
+ return
3162
+ }
3163
+ nativeTextMutationAdoptionSuppression = NativeTextMutationAdoptionSuppression(
3164
+ editorId = editorId,
3165
+ authorizedTextRevision = lastAuthorizedTextRevision
3166
+ )
3167
+ }
3168
+
3169
+ private fun clearNativeTextMutationAdoptionSuppression() {
3170
+ nativeTextMutationAdoptionSuppression = null
3171
+ }
3172
+
3173
+ private fun isNativeTextMutationAdoptionSuppressedForCurrentRevision(): Boolean {
3174
+ val suppression = nativeTextMutationAdoptionSuppression ?: return false
3175
+ if (
3176
+ suppression.editorId != editorId ||
3177
+ suppression.authorizedTextRevision != lastAuthorizedTextRevision
3178
+ ) {
3179
+ nativeTextMutationAdoptionSuppression = null
3180
+ return false
3181
+ }
3182
+ return true
3183
+ }
3184
+
3185
+ private fun canAdoptNativeTextMutationAfterBlur(): Boolean {
3186
+ val window = nativeTextMutationAfterBlurWindow ?: return false
3187
+ val now = SystemClock.uptimeMillis()
3188
+ if (now > window.deadlineMs ||
3189
+ window.editorId != editorId ||
3190
+ window.authorizedTextRevision != lastAuthorizedTextRevision ||
3191
+ window.didAdoptMutation
3192
+ ) {
3193
+ nativeTextMutationAfterBlurWindow = null
3194
+ return false
3195
+ }
3196
+ return true
3197
+ }
3198
+
3199
+ private fun commitNativeTextMutation(
3200
+ mutation: NativeTextMutation,
3201
+ preserveInputConnectionForExternalUpdate: Boolean = false
3202
+ ) {
3203
+ if (!hasLiveEditor()) return
3204
+ val startedAt = System.nanoTime()
3205
+ if ((text?.toString() ?: "") != mutation.resultingText) {
3206
+ recordImeTraceForTesting(
3207
+ "nativeMutationNoop",
3208
+ "reason=staleResult range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length}"
3209
+ )
3210
+ return
3211
+ }
3212
+ val shouldRestartInput = hasFocus()
3213
+ if (preserveInputConnectionForExternalUpdate) {
3214
+ clearInputStateForExternalReplacementPreservingConnection()
3215
+ } else {
3216
+ retireInputConnectionForEditor()
3217
+ }
3218
+ nativeTextMutationAfterBlurWindow?.didAdoptMutation = true
3219
+ clearNativeTextMutationAfterBlurWindow()
3220
+
3221
+ recordImeTraceForTesting(
3222
+ "nativeMutationApply",
3223
+ "range=${mutation.scalarFrom}..${mutation.scalarTo} replacementLength=${mutation.replacementText.length} restartInput=$shouldRestartInput preserveInputConnection=$preserveInputConnectionForExternalUpdate"
3224
+ )
3225
+ if (mutation.replacementText.isEmpty()) {
3226
+ deleteRangeInRust(mutation.scalarFrom, mutation.scalarTo)
3227
+ } else {
3228
+ insertPlainTextRangeInRust(
3229
+ mutation.scalarFrom,
3230
+ mutation.scalarTo,
3231
+ mutation.replacementText
3232
+ )
3233
+ }
3234
+ restoreSelectionAfterNativeTextMutation(mutation)
3235
+ if (shouldRestartInput) {
3236
+ restartInputForEditor(
3237
+ if (preserveInputConnectionForExternalUpdate) "externalUpdatePreflight" else "explicit"
3238
+ )
3239
+ }
3240
+ recordImeTraceForTesting(
3241
+ "nativeMutationApplyDone",
3242
+ "totalUs=${nanosToMicros(System.nanoTime() - startedAt)} restartInput=$shouldRestartInput"
3243
+ )
3244
+ }
3245
+
3246
+ private fun restoreSelectionAfterNativeTextMutation(mutation: NativeTextMutation) {
3247
+ val selectionScalarAnchor = mutation.selectionScalarAnchor ?: return
3248
+ val selectionScalarHead = mutation.selectionScalarHead ?: return
3249
+ val currentText = text?.toString() ?: return
3250
+ val anchorUtf16 = PositionBridge.scalarToUtf16(selectionScalarAnchor, currentText)
3251
+ val headUtf16 = PositionBridge.scalarToUtf16(selectionScalarHead, currentText)
3252
+ val length = currentText.length
3253
+ setSelection(anchorUtf16.coerceIn(0, length), headUtf16.coerceIn(0, length))
3254
+ }
3255
+
3256
+ /**
3257
+ * Delete a scalar range via the Rust editor.
3258
+ *
3259
+ * @param scalarFrom Start scalar offset (inclusive).
3260
+ * @param scalarTo End scalar offset (exclusive).
3261
+ */
3262
+ private fun deleteRangeInRust(scalarFrom: Int, scalarTo: Int) {
3263
+ if (!hasLiveEditor()) return
3264
+ if (scalarFrom >= scalarTo) return
3265
+ onDeleteRangeInRustForTesting?.let { callback ->
3266
+ callback(scalarFrom, scalarTo)
3267
+ return
3268
+ }
3269
+ val startedAt = System.nanoTime()
3270
+ val updateJSON = editorDeleteScalarRange(editorId.toULong(), scalarFrom.toUInt(), scalarTo.toUInt())
3271
+ recordImeTraceForTesting(
3272
+ "rustDeleteRange",
3273
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3274
+ )
3275
+ applyRustUpdateJSON(updateJSON)
3276
+ }
3277
+
3278
+ private fun deleteBackwardAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
3279
+ if (!hasLiveEditor()) return
3280
+ onDeleteBackwardAtSelectionScalarInRustForTesting?.let { callback ->
3281
+ callback(scalarAnchor, scalarHead)
3282
+ return
3283
+ }
3284
+ val startedAt = System.nanoTime()
3285
+ val updateJSON = editorDeleteBackwardAtSelectionScalar(
3286
+ editorId.toULong(),
3287
+ scalarAnchor.toUInt(),
3288
+ scalarHead.toUInt()
3289
+ )
3290
+ recordImeTraceForTesting(
3291
+ "rustDeleteBackward",
3292
+ "selection=$scalarAnchor..$scalarHead rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3293
+ )
3294
+ applyRustUpdateJSON(updateJSON)
3295
+ }
3296
+
3297
+ private fun toggleTaskItemCheckedAtSelectionScalarInRust(scalarAnchor: Int, scalarHead: Int) {
3298
+ if (!hasLiveEditor()) return
3299
+ onToggleTaskItemCheckedAtSelectionScalarInRustForTesting?.let { callback ->
3300
+ callback(scalarAnchor, scalarHead)
3301
+ return
3302
+ }
3303
+ val startedAt = System.nanoTime()
3304
+ val updateJSON = editorToggleTaskItemCheckedAtSelectionScalar(
3305
+ editorId.toULong(),
3306
+ scalarAnchor.toUInt(),
3307
+ scalarHead.toUInt()
3308
+ )
3309
+ recordImeTraceForTesting(
3310
+ "rustToggleTaskItemChecked",
3311
+ "selection=$scalarAnchor..$scalarHead rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3312
+ )
3313
+ applyRustUpdateJSON(updateJSON)
3314
+ }
3315
+
3316
+ /**
3317
+ * Split a block at a scalar position via the Rust editor.
3318
+ */
3319
+ private fun splitBlockInRust(atScalarPos: Int) {
3320
+ if (!hasLiveEditor()) return
3321
+ val startedAt = System.nanoTime()
3322
+ val updateJSON = editorSplitBlockScalar(editorId.toULong(), atScalarPos.toUInt())
3323
+ recordImeTraceForTesting(
3324
+ "rustSplitBlock",
3325
+ "at=$atScalarPos rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3326
+ )
3327
+ applyRustUpdateJSON(updateJSON)
3328
+ scheduleLineBoundaryInputRefreshForEditor("splitBlock")
3329
+ }
3330
+
3331
+ private fun deleteAndSplitInRust(scalarFrom: Int, scalarTo: Int) {
3332
+ if (!hasLiveEditor()) return
3333
+ onDeleteAndSplitScalarInRustForTesting?.let { callback ->
3334
+ callback(scalarFrom, scalarTo)
3335
+ return
3336
+ }
3337
+ val startedAt = System.nanoTime()
3338
+ val updateJSON = editorDeleteAndSplitScalar(
3339
+ editorId.toULong(),
3340
+ scalarFrom.toUInt(),
3341
+ scalarTo.toUInt()
3342
+ )
3343
+ recordImeTraceForTesting(
3344
+ "rustDeleteAndSplit",
3345
+ "range=$scalarFrom..$scalarTo rustUs=${nanosToMicros(System.nanoTime() - startedAt)} jsonLength=${updateJSON.length}"
3346
+ )
3347
+ applyRustUpdateJSON(updateJSON)
3348
+ scheduleLineBoundaryInputRefreshForEditor("deleteAndSplit")
3349
+ }
3350
+
3351
+ internal fun currentScalarSelection(): Pair<Int, Int>? {
3352
+ val currentText = text?.toString() ?: return null
3353
+ return normalizedScalarSelectionRange(currentText)
3354
+ }
3355
+
3356
+ internal fun cursorCapsModeForEditor(reqModes: Int, baseCapsMode: Int): Int {
3357
+ val sentenceCapsMode = InputType.TYPE_TEXT_FLAG_CAP_SENTENCES
3358
+ if ((reqModes and sentenceCapsMode) != sentenceCapsMode) return baseCapsMode
3359
+ if ((baseCapsMode and sentenceCapsMode) == sentenceCapsMode) return baseCapsMode
3360
+ if (!isCursorAtRenderedLineStartForSentenceCaps()) return baseCapsMode
3361
+ return baseCapsMode or sentenceCapsMode
3362
+ }
3363
+
3364
+ internal fun textBeforeCursorForImeContextForEditor(n: Int, flags: Int): CharSequence? {
3365
+ if (n <= 0) return ""
3366
+ val content = text ?: return null
3367
+ val start = selectionStart
3368
+ val end = selectionEnd
3369
+ if (start < 0 || end < 0) return null
3370
+ val cursor = minOf(start, end).coerceIn(0, content.length)
3371
+ var effectiveCursor = cursor
3372
+ while (
3373
+ effectiveCursor > 0 &&
3374
+ content[effectiveCursor - 1] == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3375
+ ) {
3376
+ effectiveCursor -= 1
3377
+ }
3378
+ val contextStart = maxOf(0, effectiveCursor - n)
3379
+ val context = content.subSequence(contextStart, effectiveCursor)
3380
+ return if ((flags and InputConnection.GET_TEXT_WITH_STYLES) != 0) {
3381
+ context
3382
+ } else {
3383
+ context.toString()
3384
+ }
3385
+ }
3386
+
3387
+ internal fun initialSurroundingTextForImeForEditor(): ImeInitialSurroundingText? {
3388
+ val rawText = text?.toString() ?: return null
3389
+ val placeholder = LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3390
+ if (rawText.indexOf(placeholder) < 0) return null
3391
+ val start = selectionStart
3392
+ val end = selectionEnd
3393
+ if (start < 0 || end < 0) return null
3394
+ val rawSelectionStart = start.coerceIn(0, rawText.length)
3395
+ val rawSelectionEnd = end.coerceIn(0, rawText.length)
3396
+
3397
+ val sanitized = StringBuilder(rawText.length)
3398
+ var removedCount = 0
3399
+ var removedBeforeSelectionStart = 0
3400
+ var removedBeforeSelectionEnd = 0
3401
+ rawText.forEachIndexed { index, ch ->
3402
+ if (ch == placeholder) {
3403
+ removedCount += 1
3404
+ if (index < rawSelectionStart) removedBeforeSelectionStart += 1
3405
+ if (index < rawSelectionEnd) removedBeforeSelectionEnd += 1
3406
+ } else {
3407
+ sanitized.append(ch)
3408
+ }
3409
+ }
3410
+
3411
+ return ImeInitialSurroundingText(
3412
+ text = sanitized.toString(),
3413
+ selectionStart = rawSelectionStart - removedBeforeSelectionStart,
3414
+ selectionEnd = rawSelectionEnd - removedBeforeSelectionEnd,
3415
+ originalSelectionStart = rawSelectionStart,
3416
+ originalSelectionEnd = rawSelectionEnd,
3417
+ removedPlaceholderCount = removedCount
3418
+ )
3419
+ }
3420
+
3421
+ private fun isCursorAtRenderedLineStartForSentenceCaps(): Boolean {
3422
+ val currentText = text?.toString() ?: return false
3423
+ val start = selectionStart
3424
+ val end = selectionEnd
3425
+ if (start < 0 || end < 0 || start != end) return false
3426
+
3427
+ val cursor = end.coerceIn(0, currentText.length)
3428
+ return isRenderedLineStartForSentenceCaps(currentText, cursor)
3429
+ }
3430
+
3431
+ private fun isRenderedLineStartForSentenceCaps(text: String, cursor: Int): Boolean {
3432
+ val cursor = cursor.coerceIn(0, text.length)
3433
+ if (cursor == 0) return true
3434
+
3435
+ val lineStart = lastRenderedLineBreakBefore(text, cursor) + 1
3436
+ var index = lineStart
3437
+ while (index < cursor && isIgnoredSentenceCapsLinePrefix(text[index])) {
3438
+ index += 1
3439
+ }
3440
+ if (index == cursor) return true
3441
+
3442
+ val markerEnd = renderedListMarkerEnd(text, index, cursor) ?: return false
3443
+ index = markerEnd
3444
+ while (index < cursor && isIgnoredSentenceCapsLinePrefix(text[index])) {
3445
+ index += 1
3446
+ }
3447
+ return index == cursor
3448
+ }
3449
+
3450
+ private fun isSamsungKeyboardActiveForEditor(): Boolean {
3451
+ val inputMethodId = Settings.Secure.getString(
3452
+ context.contentResolver,
3453
+ Settings.Secure.DEFAULT_INPUT_METHOD
3454
+ ) ?: return false
3455
+ return inputMethodId.contains("samsung", ignoreCase = true) ||
3456
+ inputMethodId.contains("honeyboard", ignoreCase = true)
3457
+ }
3458
+
3459
+ private fun lastRenderedLineBreakBefore(text: String, cursor: Int): Int {
3460
+ var index = cursor.coerceAtMost(text.length) - 1
3461
+ while (index >= 0) {
3462
+ when (text[index]) {
3463
+ '\n', '\r' -> return index
3464
+ }
3465
+ index -= 1
3466
+ }
3467
+ return -1
3468
+ }
3469
+
3470
+ private fun isIgnoredSentenceCapsLinePrefix(ch: Char): Boolean =
3471
+ ch == ' ' ||
3472
+ ch == '\t' ||
3473
+ ch == '\u00A0' ||
3474
+ ch == LayoutConstants.SYNTHETIC_PLACEHOLDER_CHARACTER[0]
3475
+
3476
+ private fun renderedListMarkerEnd(text: String, start: Int, endExclusive: Int): Int? {
3477
+ if (start >= endExclusive) return null
3478
+ if (renderedTaskListMarkerEnd(text, start, endExclusive) != null) {
3479
+ return start + 2
3480
+ }
3481
+ if (text[start] == LayoutConstants.UNORDERED_LIST_BULLET[0]) {
3482
+ return start + 1
3483
+ }
3484
+
3485
+ var index = start
3486
+ while (index < endExclusive && text[index].isDigit()) {
3487
+ index += 1
3488
+ }
3489
+ if (index == start || index >= endExclusive) return null
3490
+ return when (text[index]) {
3491
+ '.', ')' -> index + 1
3492
+ else -> null
3493
+ }
3494
+ }
3495
+
3496
+ private fun renderedTaskListMarkerEnd(text: String, start: Int, endExclusive: Int): Int? {
3497
+ if (start + 1 >= endExclusive) return null
3498
+ val marker = text[start]
3499
+ if (marker != '\u2610' && marker != '\u2611') return null
3500
+ return if (text[start + 1] == ' ') start + 2 else null
3501
+ }
3502
+
3503
+ private fun normalizedUtf16SelectionRange(currentText: String): Pair<Int, Int>? {
3504
+ val start = selectionStart
3505
+ val end = selectionEnd
3506
+ if (start < 0 || end < 0) return null
3507
+ val clampedStart = start.coerceIn(0, currentText.length)
3508
+ val clampedEnd = end.coerceIn(0, currentText.length)
3509
+ return minOf(clampedStart, clampedEnd) to maxOf(clampedStart, clampedEnd)
3510
+ }
3511
+
3512
+ private fun normalizedUtf16SelectionRange(): Pair<Int, Int>? {
3513
+ val currentText = text?.toString() ?: return null
3514
+ return normalizedUtf16SelectionRange(currentText)
3515
+ }
3516
+
3517
+ private fun normalizedScalarSelectionRange(currentText: String): Pair<Int, Int>? {
3518
+ val (start, end) = normalizedUtf16SelectionRange(currentText) ?: return null
3519
+ val (snappedStart, snappedEnd) = if (start == end) {
3520
+ val snapped = PositionBridge.snapToScalarBoundary(
3521
+ start,
3522
+ currentText,
3523
+ biasForward = true
3524
+ )
3525
+ snapped to snapped
3526
+ } else {
3527
+ PositionBridge.snapRangeToScalarBoundaries(start, end, currentText)
3528
+ }
3529
+ return PositionBridge.utf16ToScalar(snappedStart, currentText) to
3530
+ PositionBridge.utf16ToScalar(snappedEnd, currentText)
3531
+ }
3532
+
3533
+ private fun rawScalarSelection(currentText: String): Pair<Int, Int>? {
3534
+ val anchor = selectionStart
3535
+ val head = selectionEnd
3536
+ if (anchor < 0 || head < 0) return null
3537
+ val clampedAnchor = anchor.coerceIn(0, currentText.length)
3538
+ val clampedHead = head.coerceIn(0, currentText.length)
3539
+ if (clampedAnchor == clampedHead) {
3540
+ val snapped = PositionBridge.snapToScalarBoundary(
3541
+ clampedAnchor,
3542
+ currentText,
3543
+ biasForward = true
3544
+ )
3545
+ val scalar = PositionBridge.utf16ToScalar(snapped, currentText)
3546
+ return scalar to scalar
3547
+ }
3548
+ val (rangeStart, rangeEnd) = PositionBridge.snapRangeToScalarBoundaries(
3549
+ minOf(clampedAnchor, clampedHead),
3550
+ maxOf(clampedAnchor, clampedHead),
3551
+ currentText
3552
+ )
3553
+ val snappedAnchor = if (clampedAnchor < clampedHead) rangeStart else rangeEnd
3554
+ val snappedHead = if (clampedAnchor < clampedHead) rangeEnd else rangeStart
3555
+ return PositionBridge.utf16ToScalar(snappedAnchor, currentText) to
3556
+ PositionBridge.utf16ToScalar(snappedHead, currentText)
3557
+ }
3558
+
3559
+ fun selectedImageGeometry(): SelectedImageGeometry? {
3560
+ if (!imageResizingEnabled) return null
3561
+ val spannable = text as? Spanned ?: return null
3562
+ val selection = resolvedSelectedImageRange(spannable) ?: return null
3563
+ val start = selection.start
3564
+ val end = selection.end
3565
+ val imageSpan = spannable
3566
+ .getSpans(start, end, BlockImageSpan::class.java)
3567
+ .firstOrNull() ?: return null
3568
+ val spanStart = spannable.getSpanStart(imageSpan)
3569
+ val spanEnd = spannable.getSpanEnd(imageSpan)
3570
+ if (spanStart != start || spanEnd != end) return null
3571
+
3572
+ val textLayout = layout ?: return null
3573
+ val currentText = text?.toString() ?: return null
3574
+ val scalarPos = PositionBridge.utf16ToScalar(spanStart, currentText)
3575
+ val docPos = if (hasLiveEditor()) {
3576
+ editorScalarToDoc(editorId.toULong(), scalarPos.toUInt()).toInt()
3577
+ } else {
3578
+ 0
3579
+ }
3580
+ val line = textLayout.getLineForOffset(spanStart.coerceAtMost(maxOf(spannable.length - 1, 0)))
3581
+ val rect = resolvedImageRect(textLayout, imageSpan, spanStart, spanEnd)
3582
+ return SelectedImageGeometry(
3583
+ docPos = docPos,
3584
+ rect = rect
3585
+ )
3586
+ }
3587
+
3588
+ fun resizeImageAtDocPos(docPos: Int, widthPx: Float, heightPx: Float) {
3589
+ if (!hasLiveEditor()) return
3590
+ val density = resources.displayMetrics.density
3591
+ val widthDp = maxOf(48, (widthPx / density).roundToInt())
3592
+ val heightDp = maxOf(48, (heightPx / density).roundToInt())
3593
+ val updateJSON = editorResizeImageAtDocPos(
3594
+ editorId.toULong(),
3595
+ docPos.toUInt(),
3596
+ widthDp.toUInt(),
3597
+ heightDp.toUInt()
3598
+ )
3599
+ applyUpdateJSON(updateJSON)
3600
+ }
3601
+
3602
+ private fun isSelectionInsideList(): Boolean {
3603
+ if (!hasLiveEditor()) return false
3604
+
3605
+ return try {
3606
+ val state = org.json.JSONObject(editorGetCurrentState(editorId.toULong()))
3607
+ val nodes = state.optJSONObject("activeState")?.optJSONObject("nodes")
3608
+ nodes?.optBoolean("bulletList", false) == true ||
3609
+ nodes?.optBoolean("orderedList", false) == true
3610
+ } catch (_: Exception) {
3611
+ false
3612
+ }
3613
+ }
3614
+
3615
+ /**
3616
+ * Paste HTML content through Rust.
3617
+ */
3618
+ private fun pasteHTML(html: String) {
3619
+ if (!hasLiveEditor()) return
3620
+ syncCurrentSelectionToRust()
3621
+ onInsertContentHtmlInRustForTesting?.let { callback ->
3622
+ callback(html)
3623
+ return
3624
+ }
3625
+ val updateJSON = editorInsertContentHtml(editorId.toULong(), html)
3626
+ applyUpdateJSON(updateJSON)
3627
+ }
3628
+
3629
+ /**
3630
+ * Paste plain text through Rust.
3631
+ */
3632
+ private fun pastePlainText(text: String) {
3633
+ val (scalarStart, scalarEnd) = currentScalarSelection() ?: return
3634
+ insertPlainTextRangeInRust(scalarStart, scalarEnd, text)
3635
+ }
3636
+
3637
+ // ── Applying Rust State ─────────────────────────────────────────────
3638
+
3639
+ private fun parseRenderPatch(raw: org.json.JSONObject?): ParsedRenderPatch? {
3640
+ if (raw == null) return null
3641
+ val renderBlocks = raw.optJSONArray("renderBlocks") ?: return null
3642
+ return ParsedRenderPatch(
3643
+ startIndex = raw.optInt("startIndex", -1),
3644
+ deleteCount = raw.optInt("deleteCount", -1),
3645
+ renderBlocks = renderBlocks
3646
+ ).takeIf { it.startIndex >= 0 && it.deleteCount >= 0 }
3647
+ }
3648
+
3649
+ private fun hasTopLevelChildMetadata(content: Spanned): Boolean =
3650
+ content.getSpans(0, content.length, Annotation::class.java).any {
3651
+ it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION
3652
+ }
3653
+
3654
+ private fun firstCharacterOffsetForTopLevelChildIndex(content: Spanned, index: Int): Int? {
3655
+ val targetValue = index.toString()
3656
+ return content
3657
+ .getSpans(0, content.length, Annotation::class.java)
3658
+ .asSequence()
3659
+ .filter { it.key == RenderBridge.NATIVE_TOP_LEVEL_CHILD_INDEX_ANNOTATION && it.value == targetValue }
3660
+ .mapNotNull { span ->
3661
+ val spanStart = content.getSpanStart(span)
3662
+ val spanEnd = content.getSpanEnd(span)
3663
+ if (spanStart < 0 || spanEnd <= spanStart) {
3664
+ null
3665
+ } else {
3666
+ var candidate = spanStart
3667
+ while (candidate < spanEnd && candidate < content.length) {
3668
+ when (content[candidate]) {
3669
+ '\n', '\r' -> candidate += 1
3670
+ else -> return@mapNotNull candidate
3671
+ }
3672
+ }
3673
+ null
3674
+ }
3675
+ }
3676
+ .minOrNull()
3677
+ }
3678
+
3679
+ private fun replacementRangeForRenderPatch(
3680
+ content: Spanned,
3681
+ startIndex: Int,
3682
+ deleteCount: Int
3683
+ ): RenderReplaceRange? {
3684
+ val start = firstCharacterOffsetForTopLevelChildIndex(content, startIndex)
3685
+ ?: if (deleteCount == 0) content.length else return null
3686
+ val endExclusive = firstCharacterOffsetForTopLevelChildIndex(content, startIndex + deleteCount)
3687
+ ?: content.length
3688
+ if (start > endExclusive) return null
3689
+ return RenderReplaceRange(start = start, endExclusive = endExclusive)
3690
+ }
3691
+
3692
+ private fun spannedRangeContainsImageSpan(content: Spanned, start: Int, endExclusive: Int): Boolean {
3693
+ if (start >= endExclusive) return false
3694
+ return content.getSpans(start, endExclusive, BlockImageSpan::class.java).isNotEmpty()
3695
+ }
3696
+
3697
+ private fun spannedContainsImageSpan(content: Spanned): Boolean =
3698
+ spannedRangeContainsImageSpan(content, 0, content.length)
3699
+
3700
+ private fun applyRenderedSpannable(
3701
+ spannable: CharSequence,
3702
+ replaceRange: RenderReplaceRange? = null,
3703
+ usedPatch: Boolean,
3704
+ preserveInputConnectionForExternalUpdate: Boolean = false
3705
+ ) {
3706
+ val startedAt = System.nanoTime()
3707
+ val previousScrollX = scrollX
3708
+ val previousScrollY = scrollY
3709
+ val hadCompositionTracking = hasCompositionTrackingForEditor()
3710
+ var shouldRestartInput = false
3711
+ val mode = if (replaceRange != null) "replace" else "setText"
3712
+ isApplyingRustState = true
3713
+ beginBatchEdit()
3714
+ try {
3715
+ if (replaceRange != null) {
3716
+ editableText.replace(replaceRange.start, replaceRange.endExclusive, spannable)
3717
+ } else {
3718
+ setText(spannable)
3719
+ }
3720
+ lastAuthorizedText = text?.toString().orEmpty()
3721
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
3722
+ lastAuthorizedTextRevision += 1L
3723
+ clearNativeTextMutationAdoptionSuppression()
3724
+ if (hadCompositionTracking && preserveInputConnectionForExternalUpdate) {
3725
+ clearInputStateForExternalReplacementPreservingConnection()
3726
+ shouldRestartInput = true
3727
+ } else if (hadCompositionTracking) {
3728
+ retireInputConnectionForEditor()
3729
+ shouldRestartInput = true
3730
+ } else {
3731
+ clearCompositionTrackingForEditor()
3732
+ }
3733
+ lastRenderAppliedPatchForTesting = usedPatch
3734
+ clearNativeTextMutationAfterBlurWindow()
3735
+ } finally {
3736
+ endBatchEdit()
3737
+ isApplyingRustState = false
3738
+ }
3739
+ recordImeTraceForTesting(
3740
+ "applyRenderedSpannable",
3741
+ "mode=$mode usedPatch=$usedPatch incomingLength=${spannable.length} replace=${replaceRange?.start}..${replaceRange?.endExclusive} hadComposition=$hadCompositionTracking restartInput=$shouldRestartInput applyUs=${nanosToMicros(System.nanoTime() - startedAt)} scroll=$previousScrollX,$previousScrollY->$scrollX,$scrollY layout=${layout != null}"
3742
+ )
3743
+ invalidateRenderedContent()
3744
+ restartInputAfterCompositionInvalidationIfNeeded(shouldRestartInput)
3745
+ }
3746
+
3747
+ private fun invalidateRenderedContent() {
3748
+ invalidate()
3749
+ postInvalidateOnAnimation()
3750
+ }
3751
+
3752
+ private fun authorizeVisibleTextForMatchedOptimisticRender(spannable: CharSequence) {
3753
+ val startedAt = System.nanoTime()
3754
+ val visibleText = text?.toString().orEmpty()
3755
+ lastAuthorizedText = visibleText
3756
+ lastAuthorizedRenderedText = text?.let { SpannableStringBuilder(it) }
3757
+ ?: SpannableStringBuilder(spannable)
3758
+ lastAuthorizedTextRevision += 1L
3759
+ clearNativeTextMutationAdoptionSuppression()
3760
+ clearCompositionTrackingForEditor()
3761
+ lastRenderAppliedPatchForTesting = false
3762
+ clearNativeTextMutationAfterBlurWindow()
3763
+ recordImeTraceForTesting(
3764
+ "reuseOptimisticVisibleTextRender",
3765
+ "textLength=${visibleText.length} applyUs=${nanosToMicros(System.nanoTime() - startedAt)}"
3766
+ )
3767
+ }
3768
+
3769
+ private fun buildPatchedSpannable(patch: ParsedRenderPatch): android.text.SpannableStringBuilder =
3770
+ RenderBridge.buildSpannableFromBlocks(
3771
+ patch.renderBlocks,
3772
+ startIndex = patch.startIndex,
3773
+ baseFontSize = baseFontSize,
3774
+ textColor = baseTextColor,
3775
+ theme = theme,
3776
+ density = resources.displayMetrics.density,
3777
+ hostView = this
3778
+ )
3779
+
3780
+ private fun cloneJsonArray(array: org.json.JSONArray): org.json.JSONArray =
3781
+ org.json.JSONArray().also { clone ->
3782
+ for (index in 0 until array.length()) {
3783
+ clone.put(array.opt(index))
3784
+ }
3785
+ }
3786
+
3787
+ private fun normalizedJsonValue(value: Any?): Any? =
3788
+ if (value === org.json.JSONObject.NULL) null else value
3789
+
3790
+ private fun jsonValuesEqual(left: Any?, right: Any?): Boolean {
3791
+ val normalizedLeft = normalizedJsonValue(left)
3792
+ val normalizedRight = normalizedJsonValue(right)
3793
+ if (normalizedLeft === normalizedRight) return true
3794
+ if (normalizedLeft == null || normalizedRight == null) return false
3795
+
3796
+ if (normalizedLeft is org.json.JSONArray && normalizedRight is org.json.JSONArray) {
3797
+ if (normalizedLeft.length() != normalizedRight.length()) return false
3798
+ for (index in 0 until normalizedLeft.length()) {
3799
+ if (!jsonValuesEqual(normalizedLeft.opt(index), normalizedRight.opt(index))) {
3800
+ return false
3801
+ }
3802
+ }
3803
+ return true
3804
+ }
3805
+
3806
+ if (normalizedLeft is org.json.JSONObject && normalizedRight is org.json.JSONObject) {
3807
+ if (normalizedLeft.length() != normalizedRight.length()) return false
3808
+ val keys = normalizedLeft.keys()
3809
+ while (keys.hasNext()) {
3810
+ val key = keys.next()
3811
+ if (!normalizedRight.has(key)) return false
3812
+ if (!jsonValuesEqual(normalizedLeft.opt(key), normalizedRight.opt(key))) {
3813
+ return false
3814
+ }
3815
+ }
3816
+ return true
3817
+ }
3818
+
3819
+ if (normalizedLeft is Number && normalizedRight is Number) {
3820
+ return normalizedLeft.toDouble() == normalizedRight.toDouble()
3821
+ }
3822
+
3823
+ return normalizedLeft == normalizedRight
3824
+ }
3825
+
3826
+ private fun renderBlocksEqual(
3827
+ current: org.json.JSONArray,
3828
+ updated: org.json.JSONArray
3829
+ ): Boolean {
3830
+ if (current.length() != updated.length()) return false
3831
+ for (index in 0 until current.length()) {
3832
+ if (!jsonValuesEqual(current.opt(index), updated.opt(index))) {
3833
+ return false
3834
+ }
3835
+ }
3836
+ return true
3837
+ }
3838
+
3839
+ private fun mergeRenderBlocks(
3840
+ current: org.json.JSONArray,
3841
+ patch: ParsedRenderPatch
3842
+ ): org.json.JSONArray? {
3843
+ if (
3844
+ patch.startIndex < 0 ||
3845
+ patch.deleteCount < 0 ||
3846
+ patch.startIndex > current.length() ||
3847
+ patch.startIndex + patch.deleteCount > current.length()
3848
+ ) {
3849
+ return null
3850
+ }
3851
+
3852
+ return org.json.JSONArray().also { merged ->
3853
+ for (index in 0 until patch.startIndex) {
3854
+ merged.put(current.opt(index))
3855
+ }
3856
+ for (index in 0 until patch.renderBlocks.length()) {
3857
+ merged.put(patch.renderBlocks.opt(index))
3858
+ }
3859
+ for (index in (patch.startIndex + patch.deleteCount) until current.length()) {
3860
+ merged.put(current.opt(index))
3861
+ }
3862
+ }
3863
+ }
3864
+
3865
+ private fun applyRenderPatchIfPossible(patch: ParsedRenderPatch): PatchApplyTrace {
3866
+ val eligibilityStartedAt = System.nanoTime()
3867
+ val content = text as? Spanned ?: return PatchApplyTrace(
3868
+ applied = false,
3869
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3870
+ buildRenderNanos = 0L,
3871
+ applyRenderNanos = 0L
3872
+ )
3873
+ if (!hasTopLevelChildMetadata(content)) {
3874
+ return PatchApplyTrace(
3875
+ applied = false,
3876
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3877
+ buildRenderNanos = 0L,
3878
+ applyRenderNanos = 0L
3879
+ )
3880
+ }
3881
+
3882
+ val replaceRange = replacementRangeForRenderPatch(content, patch.startIndex, patch.deleteCount)
3883
+ ?: return PatchApplyTrace(
3884
+ applied = false,
3885
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3886
+ buildRenderNanos = 0L,
3887
+ applyRenderNanos = 0L
3888
+ )
3889
+ if (spannedRangeContainsImageSpan(content, replaceRange.start, replaceRange.endExclusive)) {
3890
+ return PatchApplyTrace(
3891
+ applied = false,
3892
+ eligibilityNanos = System.nanoTime() - eligibilityStartedAt,
3893
+ buildRenderNanos = 0L,
3894
+ applyRenderNanos = 0L
3895
+ )
3896
+ }
3897
+ val eligibilityNanos = System.nanoTime() - eligibilityStartedAt
3898
+
3899
+ val buildStartedAt = System.nanoTime()
3900
+ val patchedSpannable = buildPatchedSpannable(patch)
3901
+ val buildRenderNanos = System.nanoTime() - buildStartedAt
3902
+ if (spannedContainsImageSpan(patchedSpannable)) {
3903
+ return PatchApplyTrace(
3904
+ applied = false,
3905
+ eligibilityNanos = eligibilityNanos,
3906
+ buildRenderNanos = buildRenderNanos,
3907
+ applyRenderNanos = 0L
3908
+ )
3909
+ }
3910
+
3911
+ val applyStartedAt = System.nanoTime()
3912
+ applyRenderedSpannable(
3913
+ spannable = patchedSpannable,
3914
+ replaceRange = replaceRange,
3915
+ usedPatch = true
3916
+ )
3917
+ return PatchApplyTrace(
3918
+ applied = true,
3919
+ eligibilityNanos = eligibilityNanos,
3920
+ buildRenderNanos = buildRenderNanos,
3921
+ applyRenderNanos = System.nanoTime() - applyStartedAt
3922
+ )
3923
+ }
3924
+
3925
+ /**
3926
+ * Apply a full render update from Rust to the EditText.
3927
+ *
3928
+ * Parses the update JSON, converts render elements to [android.text.SpannableStringBuilder]
3929
+ * via [RenderBridge], and replaces the EditText's content.
3930
+ *
3931
+ * @param updateJSON The JSON string from editor_insert_text, etc.
3932
+ */
3933
+ fun applyUpdateJSON(
3934
+ updateJSON: String,
3935
+ notifyListener: Boolean = true,
3936
+ refreshInputConnectionForExternalUpdate: Boolean = false
3937
+ ) {
3938
+ val totalStartedAt = System.nanoTime()
3939
+ val previousVisibleText = text?.toString().orEmpty()
3940
+ val parseStartedAt = totalStartedAt
3941
+ val update = try {
3942
+ org.json.JSONObject(updateJSON)
3943
+ } catch (error: Exception) {
3944
+ recordImeTraceForTesting(
3945
+ "applyUpdateJSONNoop",
3946
+ "reason=parseError jsonLength=${updateJSON.length} error=${error.javaClass.simpleName}"
3947
+ )
3948
+ return
3949
+ }
3950
+ cancelDeferredRustUpdateApplication()
3951
+ val parseNanos = System.nanoTime() - parseStartedAt
3952
+
3953
+ val resolveRenderBlocksStartedAt = System.nanoTime()
3954
+ val renderElements = update.optJSONArray("renderElements")
3955
+ val renderBlocks = update.optJSONArray("renderBlocks")
3956
+ val renderPatch = parseRenderPatch(update.optJSONObject("renderPatch"))
3957
+ val resolvedRenderBlocks = renderBlocks
3958
+ ?: renderPatch?.let { patch ->
3959
+ currentRenderBlocksJson?.let { mergeRenderBlocks(it, patch) }
3960
+ }
3961
+ val resolveRenderBlocksNanos = System.nanoTime() - resolveRenderBlocksStartedAt
3962
+ val shouldSkipRender = !refreshInputConnectionForExternalUpdate &&
3963
+ resolvedRenderBlocks != null &&
3964
+ currentRenderBlocksJson?.let { current ->
3965
+ renderBlocksEqual(current, resolvedRenderBlocks)
3966
+ } == true &&
3967
+ text?.toString() == lastAuthorizedText &&
3968
+ lastAppliedRenderAppearanceRevision == renderAppearanceRevision
3969
+ val previousScrollX = scrollX
3970
+ val previousScrollY = scrollY
3971
+
3972
+ explicitSelectedImageRange = null
3973
+ val buildRenderNanos: Long
3974
+ val applyRenderNanos: Long
3975
+ if (shouldSkipRender) {
3976
+ pendingOptimisticRenderText = null
3977
+ lastRenderAppliedPatchForTesting = false
3978
+ currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
3979
+ clearNativeTextMutationAdoptionSuppression()
3980
+ clearNativeTextMutationAfterBlurWindow()
3981
+ buildRenderNanos = 0L
3982
+ applyRenderNanos = 0L
3983
+ } else {
3984
+ // Android's Editable.replace(...) path benchmarks substantially slower than
3985
+ // rebuilding from merged render blocks, so patch payloads are treated as a
3986
+ // transport optimization only. We still resolve the merged block state above,
3987
+ // then apply it through the faster full-text path here.
3988
+ val buildStartedAt = System.nanoTime()
3989
+ val fullSpannable = if (resolvedRenderBlocks != null) {
3990
+ RenderBridge.buildSpannableFromBlocks(
3991
+ resolvedRenderBlocks,
3992
+ baseFontSize = baseFontSize,
3993
+ textColor = baseTextColor,
3994
+ theme = theme,
3995
+ density = resources.displayMetrics.density,
3996
+ hostView = this
3997
+ )
3998
+ } else if (renderElements != null) {
3999
+ RenderBridge.buildSpannableFromArray(
4000
+ renderElements,
4001
+ baseFontSize,
4002
+ baseTextColor,
4003
+ theme,
4004
+ resources.displayMetrics.density,
4005
+ this
4006
+ )
4007
+ } else {
4008
+ recordImeTraceForTesting(
4009
+ "applyUpdateJSONNoop",
4010
+ "reason=noRenderPayload jsonLength=${updateJSON.length}"
4011
+ )
4012
+ return
4013
+ }
4014
+ buildRenderNanos = System.nanoTime() - buildStartedAt
4015
+ currentRenderBlocksJson = resolvedRenderBlocks?.let(::cloneJsonArray)
4016
+ val applyStartedAt = System.nanoTime()
4017
+ val optimisticText = pendingOptimisticRenderText
4018
+ val canReuseOptimisticVisibleText =
4019
+ optimisticText != null &&
4020
+ text?.toString() == optimisticText &&
4021
+ fullSpannable.toString() == optimisticText &&
4022
+ !spannedContainsImageSpan(fullSpannable)
4023
+ if (canReuseOptimisticVisibleText) {
4024
+ authorizeVisibleTextForMatchedOptimisticRender(fullSpannable)
4025
+ } else {
4026
+ applyRenderedSpannable(
4027
+ fullSpannable,
4028
+ usedPatch = false,
4029
+ preserveInputConnectionForExternalUpdate = refreshInputConnectionForExternalUpdate
4030
+ )
4031
+ }
4032
+ pendingOptimisticRenderText = null
4033
+ applyRenderNanos = System.nanoTime() - applyStartedAt
4034
+ lastAppliedRenderAppearanceRevision = renderAppearanceRevision
4035
+ }
4036
+
4037
+ // Apply the selection from the update.
4038
+ val selectionStartedAt = System.nanoTime()
4039
+ val selection = update.optJSONObject("selection")
4040
+ if (selection != null) {
4041
+ applySelectionFromJSON(selection)
4042
+ }
4043
+ val selectionNanos = System.nanoTime() - selectionStartedAt
4044
+
4045
+ val postApplyStartedAt = System.nanoTime()
4046
+ if (notifyListener) {
4047
+ editorListener?.onEditorUpdate(updateJSON)
4048
+ }
4049
+ onSelectionOrContentMayChange?.invoke()
4050
+ if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
4051
+ requestLayout()
4052
+ } else {
4053
+ preserveScrollPosition(previousScrollX, previousScrollY)
4054
+ }
4055
+ refreshInputConnectionAfterExternalTextReplacementIfNeeded(
4056
+ enabled = refreshInputConnectionForExternalUpdate,
4057
+ previousVisibleText = previousVisibleText
4058
+ )
4059
+ val postApplyNanos = System.nanoTime() - postApplyStartedAt
4060
+
4061
+ val totalNanos = System.nanoTime() - totalStartedAt
4062
+ recordImeTraceForTesting(
4063
+ "applyUpdateJSON",
4064
+ "notify=$notifyListener skippedRender=$shouldSkipRender attemptedPatch=${renderPatch != null} jsonLength=${updateJSON.length} parseUs=${nanosToMicros(parseNanos)} resolveUs=${nanosToMicros(resolveRenderBlocksNanos)} buildUs=${nanosToMicros(buildRenderNanos)} applyUs=${nanosToMicros(applyRenderNanos)} selectionUs=${nanosToMicros(selectionNanos)} postUs=${nanosToMicros(postApplyNanos)} totalUs=${nanosToMicros(totalNanos)}"
4065
+ )
4066
+
4067
+ if (captureApplyUpdateTraceForTesting) {
4068
+ lastApplyUpdateTraceForTesting = ApplyUpdateTrace(
4069
+ attemptedPatch = renderPatch != null,
4070
+ usedPatch = false,
4071
+ skippedRender = shouldSkipRender,
4072
+ parseNanos = parseNanos,
4073
+ resolveRenderBlocksNanos = resolveRenderBlocksNanos,
4074
+ patchEligibilityNanos = 0L,
4075
+ buildRenderNanos = buildRenderNanos,
4076
+ applyRenderNanos = applyRenderNanos,
4077
+ selectionNanos = selectionNanos,
4078
+ postApplyNanos = postApplyNanos,
4079
+ totalNanos = totalNanos
4080
+ )
4081
+ }
4082
+ }
4083
+
4084
+ private fun refreshInputConnectionAfterExternalTextReplacementIfNeeded(
4085
+ enabled: Boolean,
4086
+ previousVisibleText: String
4087
+ ) {
4088
+ if (!enabled || !hasFocus()) return
4089
+ val currentVisibleText = text?.toString().orEmpty()
4090
+ if (currentVisibleText == previousVisibleText) return
4091
+ clearInputStateForExternalReplacementPreservingConnection()
4092
+ restartInputForEditor("externalUpdate")
4093
+ }
4094
+
4095
+ private fun clearInputStateForExternalReplacementPreservingConnection() {
4096
+ activeInputConnection?.clearCompositionTrackingForEditor()
4097
+ clearCompositionTrackingForEditor()
4098
+ clearCompositionInvalidationForEditor()
4099
+ clearNativeComposingSpans()
4100
+ }
4101
+
4102
+ /**
4103
+ * Apply a render JSON string (just render elements, no update wrapper).
4104
+ *
4105
+ * Used for initial content loading (set_html / set_json return render
4106
+ * elements directly, not wrapped in an EditorUpdate).
4107
+ *
4108
+ * @param renderJSON The JSON array string of render elements.
4109
+ */
4110
+ fun applyRenderJSON(renderJSON: String) {
4111
+ val startedAt = System.nanoTime()
4112
+ val spannable = RenderBridge.buildSpannable(
4113
+ renderJSON,
4114
+ baseFontSize,
4115
+ baseTextColor,
4116
+ theme,
4117
+ resources.displayMetrics.density,
4118
+ this
4119
+ )
4120
+
4121
+ val previousScrollX = scrollX
4122
+ val previousScrollY = scrollY
4123
+
4124
+ explicitSelectedImageRange = null
4125
+ currentRenderBlocksJson = null
4126
+ pendingOptimisticRenderText = null
4127
+ applyRenderedSpannable(spannable, usedPatch = false)
4128
+ onSelectionOrContentMayChange?.invoke()
4129
+ if (heightBehavior == EditorHeightBehavior.AUTO_GROW) {
4130
+ requestLayout()
4131
+ } else {
4132
+ preserveScrollPosition(previousScrollX, previousScrollY)
4133
+ }
4134
+ recordImeTraceForTesting(
4135
+ "applyRenderJSON",
4136
+ "jsonLength=${renderJSON.length} totalUs=${nanosToMicros(System.nanoTime() - startedAt)}"
4137
+ )
4138
+ }
4139
+
4140
+ private fun textOffsetHitAt(x: Float, y: Float): Pair<Spanned, Int>? {
4141
+ val spannable = text as? Spanned ?: return null
4142
+ val layout = layout ?: return null
4143
+ if (spannable.isEmpty()) return null
4144
+
4145
+ val localX = x - totalPaddingLeft + scrollX
4146
+ val localY = y - totalPaddingTop + scrollY
4147
+ if (localY < 0f || localY > layout.height.toFloat()) {
4148
+ return null
4149
+ }
4150
+
4151
+ val line = layout.getLineForVertical(localY.toInt())
4152
+ val lineLeft = layout.getLineLeft(line)
4153
+ val lineRight = layout.getLineRight(line)
4154
+ if (localX < lineLeft || localX > lineRight) {
4155
+ return null
4156
+ }
4157
+
4158
+ val offset = layout.getOffsetForHorizontal(line, localX)
4159
+ .coerceIn(0, maxOf(spannable.length - 1, 0))
4160
+ return spannable to offset
4161
+ }
4162
+
4163
+ fun mentionHitAt(x: Float, y: Float): MentionHit? {
4164
+ val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
4165
+ val annotations = spannable.getSpans(
4166
+ offset,
4167
+ (offset + 1).coerceAtMost(spannable.length),
4168
+ Annotation::class.java
4169
+ )
4170
+ val mentionAnnotation = annotations.firstOrNull {
4171
+ it.key == "nativeVoidNodeType" && it.value == "mention"
4172
+ } ?: return null
4173
+ val docPos = annotations.firstOrNull { it.key == "nativeDocPos" }
4174
+ ?.value
4175
+ ?.toIntOrNull() ?: return null
4176
+ val start = spannable.getSpanStart(mentionAnnotation)
4177
+ val end = spannable.getSpanEnd(mentionAnnotation)
4178
+ if (start < 0 || end <= start) {
4179
+ return null
4180
+ }
4181
+
4182
+ return MentionHit(
4183
+ docPos = docPos,
4184
+ label = spannable.subSequence(start, end).toString()
4185
+ )
4186
+ }
4187
+
4188
+ fun linkHitAt(x: Float, y: Float): LinkHit? {
4189
+ val (spannable, offset) = textOffsetHitAt(x, y) ?: return null
4190
+ val annotations = spannable.getSpans(
4191
+ offset,
4192
+ (offset + 1).coerceAtMost(spannable.length),
4193
+ Annotation::class.java
4194
+ )
4195
+ val linkAnnotation = annotations.firstOrNull {
4196
+ it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION && it.value.isNotBlank()
4197
+ } ?: return null
4198
+ val start = spannable.getSpanStart(linkAnnotation)
4199
+ val end = spannable.getSpanEnd(linkAnnotation)
4200
+ if (start < 0 || end <= start) {
4201
+ return null
4202
+ }
4203
+
4204
+ return LinkHit(
4205
+ href = linkAnnotation.value,
4206
+ text = spannable.subSequence(start, end).toString()
4207
+ )
4208
+ }
4209
+
4210
+ private fun handleImageTap(event: MotionEvent): Boolean {
4211
+ if (!imageResizingEnabled) {
4212
+ return false
4213
+ }
4214
+ if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
4215
+ return false
4216
+ }
4217
+ val hit = imageSpanHitAt(event.x, event.y) ?: return false
4218
+ requestFocus()
4219
+ selectExplicitImageRange(hit.first, hit.second)
4220
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
4221
+ performClick()
4222
+ }
4223
+ return true
4224
+ }
4225
+
4226
+ private fun handleTaskListMarkerTap(event: MotionEvent): Boolean {
4227
+ if (event.actionMasked != MotionEvent.ACTION_DOWN && event.actionMasked != MotionEvent.ACTION_UP) {
4228
+ return false
4229
+ }
4230
+ val scalarHit = taskListMarkerScalarHitAt(event.x, event.y) ?: return false
4231
+ requestFocus()
4232
+ if (event.actionMasked == MotionEvent.ACTION_UP) {
4233
+ toggleTaskItemCheckedAtSelectionScalarInRust(scalarHit, scalarHit)
4234
+ performClick()
4235
+ }
4236
+ return true
4237
+ }
4238
+
4239
+ private fun taskListMarkerScalarHitAt(x: Float, y: Float): Int? {
4240
+ val currentText = text?.toString() ?: return null
4241
+ val textLayout = layout ?: return null
4242
+ if (currentText.isEmpty()) return null
4243
+
4244
+ val localX = x + scrollX - totalPaddingLeft
4245
+ val localY = y + scrollY - totalPaddingTop
4246
+ if (localY < 0) return null
4247
+
4248
+ val line = textLayout.getLineForVertical(localY.toInt().coerceAtLeast(0))
4249
+ val lineStart = textLayout.getLineStart(line).coerceIn(0, currentText.length)
4250
+ val lineEnd = textLayout.getLineEnd(line).coerceIn(lineStart, currentText.length)
4251
+ val markerEnd = renderedTaskListMarkerEnd(currentText, lineStart, lineEnd) ?: return null
4252
+ val markerRight = textLayout.getPrimaryHorizontal(markerEnd).coerceAtLeast(
4253
+ textLayout.getPrimaryHorizontal(lineStart)
4254
+ ) + (8f * resources.displayMetrics.density)
4255
+ if (localX > markerRight) {
4256
+ return null
4257
+ }
4258
+ val snappedUtf16 = PositionBridge.snapToScalarBoundary(
4259
+ lineStart,
4260
+ currentText,
4261
+ biasForward = true
4262
+ )
4263
+ return PositionBridge.utf16ToScalar(snappedUtf16, currentText)
4264
+ }
4265
+
4266
+ private fun imageSpanHitAt(x: Float, y: Float): Pair<Int, Int>? {
4267
+ val spannable = text as? Spanned ?: return null
4268
+ imageSpanRangeNearTouchOffset(spannable, x, y)?.let { return it }
4269
+ val textLayout = layout ?: return null
4270
+ return imageSpanRectHit(spannable, textLayout, x, y)
4271
+ }
4272
+
4273
+ private fun imageSpanRectHit(
4274
+ spannable: Spanned,
4275
+ textLayout: Layout,
4276
+ x: Float,
4277
+ y: Float
4278
+ ): Pair<Int, Int>? {
4279
+ val candidateSpans = spannable.getSpans(0, spannable.length, BlockImageSpan::class.java)
4280
+ for (span in candidateSpans) {
4281
+ val spanStart = spannable.getSpanStart(span)
4282
+ val spanEnd = spannable.getSpanEnd(span)
4283
+ if (spanStart < 0 || spanEnd <= spanStart) continue
4284
+ val rect = resolvedImageRect(textLayout, span, spanStart, spanEnd)
4285
+ if (rect.contains(x, y)) {
4286
+ return spanStart to spanEnd
4287
+ }
4288
+ }
4289
+ return null
4290
+ }
4291
+
4292
+ override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
4293
+ super.onFocusChanged(focused, direction, previouslyFocusedRect)
4294
+ if (focused) {
4295
+ clearNativeTextMutationAfterBlurWindow()
4296
+ restartCaretBlink()
4297
+ } else {
4298
+ beginNativeTextMutationAfterBlurWindow()
4299
+ clearExplicitSelectedImageRange()
4300
+ stopCaretBlink()
4301
+ }
4302
+ }
4303
+
4304
+ override fun onWindowFocusChanged(hasWindowFocus: Boolean) {
4305
+ super.onWindowFocusChanged(hasWindowFocus)
4306
+ if (hasWindowFocus) restartCaretBlink() else stopCaretBlink()
4307
+ }
4308
+
4309
+ override fun onAttachedToWindow() {
4310
+ super.onAttachedToWindow()
4311
+ restartCaretBlink()
4312
+ }
4313
+
4314
+ override fun onDetachedFromWindow() {
4315
+ removeCallbacks(caretBlinkRunnable)
4316
+ super.onDetachedFromWindow()
4317
+ }
4318
+
4319
+ private fun selectExplicitImageRange(start: Int, end: Int) {
4320
+ explicitSelectedImageRange = ImageSelectionRange(start, end)
4321
+ if (selectionStart == start && selectionEnd == end) {
4322
+ onSelectionOrContentMayChange?.invoke()
4323
+ return
4324
+ }
4325
+ setSelection(start, end)
4326
+ }
4327
+
4328
+ private fun clearExplicitSelectedImageRange() {
4329
+ if (explicitSelectedImageRange == null) return
4330
+ explicitSelectedImageRange = null
4331
+ onSelectionOrContentMayChange?.invoke()
4332
+ }
4333
+
4334
+ private fun resolvedSelectedImageRange(spannable: Spanned): ImageSelectionRange? {
4335
+ explicitSelectedImageRange?.let { explicit ->
4336
+ if (isExactImageSpanRange(spannable, explicit.start, explicit.end)) {
4337
+ return explicit
4338
+ }
4339
+ explicitSelectedImageRange = null
4340
+ }
4341
+
4342
+ val start = selectionStart
4343
+ val end = selectionEnd
4344
+ if (!isExactImageSpanRange(spannable, start, end)) return null
4345
+ return ImageSelectionRange(start, end)
4346
+ }
4347
+
4348
+ private fun isExactImageSpanRange(spannable: Spanned, start: Int, end: Int): Boolean {
4349
+ if (start < 0 || end != start + 1) return false
4350
+ val imageSpan = spannable
4351
+ .getSpans(start, end, BlockImageSpan::class.java)
4352
+ .firstOrNull() ?: return false
4353
+ return spannable.getSpanStart(imageSpan) == start && spannable.getSpanEnd(imageSpan) == end
4354
+ }
4355
+
4356
+ private fun imageSpanRangeNearTouchOffset(
4357
+ spannable: Spanned,
4358
+ x: Float,
4359
+ y: Float
4360
+ ): Pair<Int, Int>? {
4361
+ val safeOffset = runCatching { getOffsetForPosition(x, y) }.getOrNull() ?: return null
4362
+ val nearbyOffsets = linkedSetOf(
4363
+ safeOffset,
4364
+ (safeOffset - 1).coerceAtLeast(0),
4365
+ (safeOffset + 1).coerceAtMost(spannable.length)
4366
+ )
4367
+ for (offset in nearbyOffsets) {
4368
+ val searchStart = (offset - 1).coerceAtLeast(0)
4369
+ val searchEnd = (offset + 1).coerceAtMost(spannable.length)
4370
+ val imageSpan = spannable
4371
+ .getSpans(searchStart, searchEnd, BlockImageSpan::class.java)
4372
+ .firstOrNull() ?: continue
4373
+ val spanStart = spannable.getSpanStart(imageSpan)
4374
+ val spanEnd = spannable.getSpanEnd(imageSpan)
4375
+ if (spanStart >= 0 && spanEnd > spanStart) {
4376
+ return spanStart to spanEnd
4377
+ }
4378
+ }
4379
+ return null
4380
+ }
4381
+
4382
+ private fun resolvedImageRect(
4383
+ textLayout: Layout,
4384
+ imageSpan: BlockImageSpan,
4385
+ spanStart: Int,
4386
+ spanEnd: Int
4387
+ ): RectF {
4388
+ imageSpan.currentDrawRect()?.let { drawnRect ->
4389
+ return drawnRect
4390
+ }
4391
+
4392
+ val safeOffset = spanStart.coerceAtMost(maxOf((text?.length ?: 0) - 1, 0))
4393
+ val line = textLayout.getLineForOffset(safeOffset)
4394
+ val startHorizontal = textLayout.getPrimaryHorizontal(spanStart)
4395
+ val endHorizontal = textLayout.getPrimaryHorizontal(spanEnd)
4396
+ val (widthPx, heightPx) = imageSpan.currentSizePx()
4397
+ val left = compoundPaddingLeft + minOf(startHorizontal, endHorizontal)
4398
+ val right = compoundPaddingLeft + maxOf(
4399
+ maxOf(startHorizontal, endHorizontal),
4400
+ minOf(startHorizontal, endHorizontal) + widthPx
4401
+ )
4402
+ val top = extendedPaddingTop + textLayout.getLineBottom(line) - heightPx
4403
+ return RectF(left, top.toFloat(), right, top + heightPx.toFloat())
4404
+ }
4405
+
4406
+ /**
4407
+ * Apply a selection from a parsed JSON selection object.
4408
+ *
4409
+ * The selection JSON matches the format from `serialize_editor_update`:
4410
+ * ```json
4411
+ * {"type": "text", "anchor": 5, "head": 5}
4412
+ * {"type": "node", "pos": 10}
4413
+ * {"type": "all"}
4414
+ * ```
4415
+ *
4416
+ * anchor/head from Rust are **document positions** (include structural tokens).
4417
+ * We convert doc→scalar via [editorDocToScalar] before converting to UTF-16.
4418
+ */
4419
+ private fun applySelectionFromJSON(selection: org.json.JSONObject) {
4420
+ val type = selection.optString("type", "") ?: return
4421
+ if (isEditorDestroyedForInput()) return
4422
+
4423
+ isApplyingRustState = true
4424
+ try {
4425
+ val currentText = text?.toString() ?: ""
4426
+ when (type) {
4427
+ "text" -> {
4428
+ val docAnchor = selection.optInt("anchor", 0)
4429
+ val docHead = selection.optInt("head", 0)
4430
+ // Convert doc positions to scalar offsets.
4431
+ val scalarAnchor = editorDocToScalar(editorId.toULong(), docAnchor.toUInt()).toInt()
4432
+ val scalarHead = editorDocToScalar(editorId.toULong(), docHead.toUInt()).toInt()
4433
+ val anchorUtf16 = PositionBridge.scalarToUtf16(scalarAnchor, currentText)
4434
+ val headUtf16 = PositionBridge.scalarToUtf16(scalarHead, currentText)
4435
+ val len = text?.length ?: 0
4436
+ setSelection(
4437
+ anchorUtf16.coerceIn(0, len),
4438
+ headUtf16.coerceIn(0, len)
4439
+ )
4440
+ }
4441
+ "node" -> {
4442
+ val docPos = selection.optInt("pos", 0)
4443
+ // Convert doc position to scalar offset.
4444
+ val scalarPos = editorDocToScalar(editorId.toULong(), docPos.toUInt()).toInt()
4445
+ val startUtf16 = PositionBridge.scalarToUtf16(scalarPos, currentText)
4446
+ val len = text?.length ?: 0
4447
+ val clamped = startUtf16.coerceIn(0, len)
4448
+ // Select one character (the void node placeholder).
4449
+ val endClamped = (clamped + 1).coerceAtMost(len)
4450
+ setSelection(clamped, endClamped)
4451
+ }
4452
+ "all" -> {
4453
+ selectAll()
4454
+ }
4455
+ }
4456
+ } finally {
4457
+ isApplyingRustState = false
4458
+ }
4459
+ }
4460
+
4461
+ // ── Reconciliation ─────────────────────────────────────────────────
4462
+
4463
+ /**
4464
+ * [TextWatcher] that detects when the EditText's text diverges from the
4465
+ * last Rust-authorized content (e.g., due to IME autocorrect, accessibility
4466
+ * services, or other Android framework mutations that bypass our
4467
+ * [EditorInputConnection]).
4468
+ *
4469
+ * When divergence is detected, Rust's current state is re-fetched and
4470
+ * re-applied — "Rust wins" — to maintain the invariant that the Rust
4471
+ * editor-core is the single source of truth for document content.
4472
+ */
4473
+ private inner class ReconciliationWatcher : TextWatcher {
4474
+
4475
+ override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
4476
+ // No-op: we only need afterTextChanged.
4477
+ }
4478
+
4479
+ override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
4480
+ // No-op: we only need afterTextChanged.
4481
+ }
4482
+
4483
+ override fun afterTextChanged(s: Editable?) {
4484
+ if (isApplyingRustState) return
4485
+ if (!hasLiveEditor()) return
4486
+
4487
+ val currentText = s?.toString() ?: ""
4488
+ if (currentText == lastAuthorizedText) return
4489
+
4490
+ val mutation = nativeTextMutationFromAuthorizedDiff(currentText)
4491
+ if (mutation != null && shouldAdoptNativeTextMutation(mutation, allowAfterBlur = true)) {
4492
+ commitNativeTextMutation(mutation)
4493
+ return
4494
+ }
4495
+
4496
+ // Text has diverged from Rust's authorized state.
4497
+ reconciliationCount++
4498
+ Log.w(
4499
+ LOG_TAG,
4500
+ "reconciliation: EditText diverged from Rust state" +
4501
+ " (count=$reconciliationCount," +
4502
+ " editText=${currentText.length} chars," +
4503
+ " authorized=${lastAuthorizedText.length} chars)"
4504
+ )
4505
+
4506
+ // Re-fetch Rust's current state and re-apply ("Rust wins").
4507
+ val stateJSON = editorGetCurrentState(editorId.toULong())
4508
+ applyUpdateJSON(stateJSON)
4509
+ }
4510
+ }
4511
+
4512
+ companion object {
4513
+ private const val DEFAULT_AUTO_CAPITALIZE = "sentences"
4514
+ private const val DEFAULT_AUTO_CORRECT = true
4515
+ private const val DEFAULT_KEYBOARD_TYPE = "default"
4516
+ private const val EMPTY_BLOCK_PLACEHOLDER = '\u200B'
4517
+ private const val IME_TRACE_LIMIT_FOR_TESTING = 80
4518
+ private const val IME_TRACE_LOG_TAG = "NativeEditorIme"
4519
+ private const val NATIVE_TEXT_MUTATION_AFTER_BLUR_WINDOW_MS = 750L
4520
+ private const val RECENT_HANDLED_HARDWARE_KEY_DOWN_WINDOW_MS = 750L
4521
+ private const val LOG_TAG = "NativeEditor"
4522
+
4523
+ // Platform caret blink half-period (Editor.BLINK).
4524
+ private const val CARET_BLINK_INTERVAL_MS = 500L
4525
+ private const val MIN_CARET_WIDTH_PX = 2f
4526
+ }
4527
+ }