@openeditor/react-native-prose-editor 0.0.8 → 0.0.10

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