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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/android/build.gradle +35 -0
  2. package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
  3. package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
  4. package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
  5. package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
  6. package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
  7. package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
  8. package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
  9. package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
  10. package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
  11. package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
  12. package/dist/EditorToolbar.d.ts +1 -1
  13. package/dist/EditorToolbar.js +19 -0
  14. package/dist/NativeEditorBridge.d.ts +62 -35
  15. package/dist/NativeEditorBridge.js +122 -79
  16. package/dist/NativeProseViewer.d.ts +13 -34
  17. package/dist/NativeProseViewer.js +40 -465
  18. package/dist/NativeRichTextEditor.d.ts +10 -1
  19. package/dist/NativeRichTextEditor.js +72 -34
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +2 -1
  22. package/dist/schemas.d.ts +11 -0
  23. package/dist/schemas.js +149 -0
  24. package/dist/useNativeEditor.d.ts +3 -3
  25. package/dist/useNativeEditor.js +3 -3
  26. package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
  27. package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
  28. package/ios/Generated_editor_core.swift +97 -2
  29. package/ios/NativeBlockEditorSurface.swift +1393 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +30 -45
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +333 -786
  35. package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
  36. package/package.json +1 -1
  37. package/rust/android/arm64-v8a/libeditor_core.so +0 -0
  38. package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
  39. package/rust/android/x86_64/libeditor_core.so +0 -0
  40. package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
  41. package/android/src/androidTest/AndroidManifest.xml +0 -8
  42. package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
  43. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
  44. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
  45. package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
  46. package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
  47. package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
  48. package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
  49. package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
  50. package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
  51. package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
  52. package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
  53. package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
  54. package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
  55. package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
  56. package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
  57. package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
  58. package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
  59. package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
  60. package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
  61. package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
  62. package/ios/EditorLayoutManager.swift +0 -774
  63. package/ios/NativeProseViewerExpoView.swift +0 -276
  64. package/ios/RenderBridge.swift +0 -1708
  65. package/ios/Tests/NativePerformanceTests.swift +0 -687
  66. package/ios/Tests/PositionBridgeTests.swift +0 -706
  67. package/ios/Tests/RenderBridgeTests.swift +0 -2236
  68. package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
@@ -1,8 +0,0 @@
1
- <manifest xmlns:android="http://schemas.android.com/apk/res/android">
2
- <application>
3
- <activity
4
- android:name="com.openeditor.editor.NativeEditorOutsideTapActivity"
5
- android:exported="false"
6
- android:theme="@style/Theme.AppCompat.Light.NoActionBar" />
7
- </application>
8
- </manifest>
@@ -1,53 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import androidx.test.ext.junit.runners.AndroidJUnit4
4
- import org.junit.Assert.assertTrue
5
- import org.junit.Test
6
- import org.junit.runner.RunWith
7
-
8
- @RunWith(AndroidJUnit4::class)
9
- class MeasureHeightInstrumentedTest {
10
-
11
- @Test
12
- fun measureHeight_narrowerWidthProducesTallerResult() {
13
- val longText = "word ".repeat(100)
14
- val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"$longText"},{"type":"blockEnd"}]"""
15
- val narrowHeight = RenderBridge.measureHeight(
16
- json = renderJSON,
17
- themeJson = null,
18
- width = 100f,
19
- density = 1f
20
- )
21
- val wideHeight = RenderBridge.measureHeight(
22
- json = renderJSON,
23
- themeJson = null,
24
- width = 1000f,
25
- density = 1f
26
- )
27
- assertTrue(
28
- "Narrower width ($narrowHeight) should be taller than wider ($wideHeight)",
29
- narrowHeight > wideHeight
30
- )
31
- }
32
-
33
- @Test
34
- fun measureHeight_largerFontProducesTallerResult() {
35
- val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"Hello world"},{"type":"blockEnd"}]"""
36
- val smallHeight = RenderBridge.measureHeight(
37
- json = renderJSON,
38
- themeJson = """{"text":{"fontSize":12}}""",
39
- width = 375f,
40
- density = 1f
41
- )
42
- val largeHeight = RenderBridge.measureHeight(
43
- json = renderJSON,
44
- themeJson = """{"text":{"fontSize":32}}""",
45
- width = 375f,
46
- density = 1f
47
- )
48
- assertTrue(
49
- "Larger font ($largeHeight) should be taller than smaller ($smallHeight)",
50
- largeHeight > smallHeight
51
- )
52
- }
53
- }
@@ -1,241 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Activity
4
- import android.app.Instrumentation
5
- import android.content.Context
6
- import android.graphics.Color
7
- import android.os.SystemClock
8
- import android.view.ViewGroup
9
- import android.widget.FrameLayout
10
- import androidx.test.core.app.ActivityScenario
11
- import androidx.test.ext.junit.runners.AndroidJUnit4
12
- import androidx.test.filters.LargeTest
13
- import androidx.test.platform.app.InstrumentationRegistry
14
- import expo.modules.core.ModuleRegistry
15
- import expo.modules.kotlin.AppContext
16
- import expo.modules.kotlin.ModulesProvider
17
- import expo.modules.kotlin.modules.Module
18
- import java.lang.ref.WeakReference
19
- import java.util.concurrent.atomic.AtomicReference
20
- import org.json.JSONArray
21
- import org.json.JSONObject
22
- import org.junit.Assert.assertTrue
23
- import org.junit.Test
24
- import org.junit.runner.RunWith
25
- import uniffi.editor_core.editorCreate
26
- import uniffi.editor_core.editorDestroy
27
- import uniffi.editor_core.editorGetCurrentState
28
- import uniffi.editor_core.editorReplaceJson
29
- import uniffi.editor_core.editorSetJson
30
-
31
- @RunWith(AndroidJUnit4::class)
32
- @LargeTest
33
- class NativeDeviceCollaborationInitialSyncTest {
34
- private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
35
-
36
- @Test
37
- fun propDrivenReplaceUpdateDisplaysRemoteDocumentWithoutTyping() {
38
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
39
- val editorRef = AtomicReference<NativeEditorExpoView>()
40
- val editorId = editorCreate("{}").toLong()
41
-
42
- try {
43
- scenario.onActivity { activity ->
44
- editorRef.set(createMountedEditor(activity, editorId))
45
- }
46
- instrumentation.waitForIdleSync()
47
- waitUntil("editor should bind initial id") {
48
- editorRef.get().richTextView.editorEditText.editorId == editorId
49
- }
50
-
51
- val updateJson = AtomicReference<String>()
52
- scenario.onActivity {
53
- val update = editorReplaceJson(
54
- editorId.toULong(),
55
- documentJson("Remote replace sync")
56
- )
57
- updateJson.set(update)
58
- editorRef.get().setPendingEditorUpdateJson(update)
59
- editorRef.get().setPendingEditorUpdateEditorId(editorId)
60
- editorRef.get().setPendingEditorUpdateRevision(1)
61
- editorRef.get().applyPendingEditorUpdateIfNeeded()
62
- }
63
- instrumentation.waitForIdleSync()
64
-
65
- waitUntil(
66
- "replace update should render remote document",
67
- detail = {
68
- "text=${editorRef.get().richTextView.editorEditText.text} " +
69
- "trace=${editorRef.get().richTextView.editorEditText.imeTraceSnapshotForTesting().joinToString("|")} " +
70
- "update=${updateJson.get()}"
71
- }
72
- ) {
73
- editorRef.get().richTextView.editorEditText.text.toString() ==
74
- "Remote replace sync"
75
- }
76
- } finally {
77
- editorDestroy(editorId.toULong())
78
- }
79
- }
80
- }
81
-
82
- @Test
83
- fun propDrivenResetUpdateDisplaysRemoteDocumentWithoutTyping() {
84
- ActivityScenario.launch(NativeEditorOutsideTapActivity::class.java).use { scenario ->
85
- val editorRef = AtomicReference<NativeEditorExpoView>()
86
- val editorId = editorCreate("{}").toLong()
87
-
88
- try {
89
- scenario.onActivity { activity ->
90
- editorRef.set(createMountedEditor(activity, editorId))
91
- }
92
- instrumentation.waitForIdleSync()
93
- waitUntil("editor should bind initial id") {
94
- editorRef.get().richTextView.editorEditText.editorId == editorId
95
- }
96
-
97
- val updateJson = AtomicReference<String>()
98
- scenario.onActivity {
99
- editorSetJson(editorId.toULong(), documentJson("Remote reset sync"))
100
- val update = editorGetCurrentState(editorId.toULong())
101
- updateJson.set(update)
102
- editorRef.get().setPendingEditorResetUpdateJson(update)
103
- editorRef.get().setPendingEditorResetUpdateEditorId(editorId)
104
- editorRef.get().setPendingEditorResetUpdateRevision(1)
105
- editorRef.get().applyPendingEditorResetUpdateIfNeeded()
106
- }
107
- instrumentation.waitForIdleSync()
108
-
109
- waitUntil(
110
- "reset update should render remote document",
111
- detail = {
112
- "text=${editorRef.get().richTextView.editorEditText.text} " +
113
- "trace=${editorRef.get().richTextView.editorEditText.imeTraceSnapshotForTesting().joinToString("|")} " +
114
- "update=${updateJson.get()}"
115
- }
116
- ) {
117
- editorRef.get().richTextView.editorEditText.text.toString() ==
118
- "Remote reset sync"
119
- }
120
- } finally {
121
- editorDestroy(editorId.toULong())
122
- }
123
- }
124
- }
125
-
126
- private fun createMountedEditor(
127
- activity: Activity,
128
- editorId: Long
129
- ): NativeEditorExpoView {
130
- initializeSoLoaderIfAvailable(activity)
131
- val root = FrameLayout(activity).apply {
132
- setBackgroundColor(Color.WHITE)
133
- }
134
- val expoContext = testExpoContext(activity)
135
- val editor = NativeEditorExpoView(expoContext.context, expoContext.appContext).apply {
136
- clipToPadding = false
137
- setShowToolbar(false)
138
- onFocusChangeForTesting = {}
139
- onAddonEventForTesting = {}
140
- onEditorUpdateForTesting = {}
141
- onEditorReadyForTesting = {}
142
- }
143
- root.addView(
144
- editor,
145
- FrameLayout.LayoutParams(
146
- ViewGroup.LayoutParams.MATCH_PARENT,
147
- dp(activity, 240)
148
- ).apply {
149
- topMargin = dp(activity, 48)
150
- leftMargin = dp(activity, 16)
151
- rightMargin = dp(activity, 16)
152
- }
153
- )
154
- activity.setContentView(root)
155
- editor.setEditorId(editorId)
156
- return editor
157
- }
158
-
159
- private fun documentJson(text: String): String =
160
- JSONObject()
161
- .put("type", "doc")
162
- .put(
163
- "content",
164
- JSONArray().put(
165
- JSONObject()
166
- .put("type", "paragraph")
167
- .put(
168
- "content",
169
- JSONArray().put(
170
- JSONObject()
171
- .put("type", "text")
172
- .put("text", text)
173
- )
174
- )
175
- )
176
- )
177
- .toString()
178
-
179
- private fun waitUntil(
180
- description: String,
181
- timeoutMs: Long = 4_000,
182
- detail: () -> String = { "" },
183
- condition: () -> Boolean
184
- ) {
185
- val start = SystemClock.uptimeMillis()
186
- while (SystemClock.uptimeMillis() - start < timeoutMs) {
187
- instrumentation.waitForIdleSync()
188
- if (condition()) return
189
- SystemClock.sleep(50)
190
- }
191
- assertTrue("$description\n${detail()}", condition())
192
- }
193
-
194
- private fun dp(context: Context, value: Int): Int =
195
- (value * context.resources.displayMetrics.density).toInt()
196
-
197
- private fun initializeSoLoaderIfAvailable(context: Context) {
198
- try {
199
- Class
200
- .forName("com.facebook.soloader.SoLoader")
201
- .getMethod(
202
- "init",
203
- Context::class.java,
204
- Boolean::class.javaPrimitiveType
205
- )
206
- .invoke(null, context, false)
207
- } catch (_: Throwable) {
208
- // Some test classpaths do not expose SoLoader directly; in that case the view can
209
- // still be exercised as long as the React Native draw path does not require it.
210
- }
211
- }
212
-
213
- private fun testExpoContext(activity: Activity): TestExpoContext {
214
- val reactContext = Class
215
- .forName("com.facebook.react.bridge.BridgeReactContext")
216
- .getConstructor(Context::class.java)
217
- .newInstance(activity) as Context
218
-
219
- reactContext.javaClass
220
- .getMethod("onHostResume", Activity::class.java)
221
- .invoke(reactContext, activity)
222
-
223
- val modulesProvider = object : ModulesProvider {
224
- override fun getModulesList(): List<Class<out Module>> = emptyList()
225
- }
226
- val constructor = AppContext::class.java.constructors.first { constructor ->
227
- constructor.parameterTypes.size == 3
228
- }
229
- val appContext = constructor.newInstance(
230
- modulesProvider,
231
- ModuleRegistry(emptyList(), emptyList()),
232
- WeakReference(reactContext)
233
- ) as AppContext
234
- return TestExpoContext(reactContext, appContext)
235
- }
236
-
237
- private data class TestExpoContext(
238
- val context: Context,
239
- val appContext: AppContext
240
- )
241
- }
@@ -1,338 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.app.Instrumentation
4
- import android.content.Context
5
- import android.text.Selection
6
- import android.view.inputmethod.BaseInputConnection
7
- import android.view.inputmethod.CompletionInfo
8
- import android.view.inputmethod.CorrectionInfo
9
- import android.view.inputmethod.EditorInfo
10
- import androidx.test.core.app.ApplicationProvider
11
- import androidx.test.ext.junit.runners.AndroidJUnit4
12
- import androidx.test.filters.LargeTest
13
- import androidx.test.platform.app.InstrumentationRegistry
14
- import java.util.concurrent.atomic.AtomicReference
15
- import org.json.JSONArray
16
- import org.json.JSONObject
17
- import org.junit.Assert.assertEquals
18
- import org.junit.Assert.assertNull
19
- import org.junit.Assert.assertTrue
20
- import org.junit.Test
21
- import org.junit.runner.RunWith
22
-
23
- @RunWith(AndroidJUnit4::class)
24
- @LargeTest
25
- class NativeDeviceImeRegressionTest {
26
- private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation()
27
- private val context = ApplicationProvider.getApplicationContext<Context>()
28
-
29
- @Test
30
- fun commitCompletionReplacesSelectedTextThroughRust() {
31
- val result = runOnMainSyncWithResult {
32
- val editText = createEditor("hel", selectionStart = 0, selectionEnd = 3)
33
- var replacement: Replacement? = null
34
- editText.onReplaceTextInRustForTesting = { scalarFrom, scalarTo, text ->
35
- replacement = Replacement(scalarFrom, scalarTo, text)
36
- }
37
-
38
- val inputConnection = createInputConnection(editText)
39
- assertTrue(inputConnection.commitCompletion(CompletionInfo(1L, 0, "hello")))
40
-
41
- ImeResult(
42
- replacement = replacement,
43
- trace = editText.imeTraceSnapshotForTesting()
44
- )
45
- }
46
-
47
- assertEquals(Replacement(0, 3, "hello"), result.replacement)
48
- assertTraceContains(result.trace, "commitCompletion")
49
- }
50
-
51
- @Test
52
- fun commitCorrectionCoversOldTextAndInferredTokenBoundaries() {
53
- val explicit = runCorrectionScenario("teh", offset = 0, oldText = "teh", newText = "the")
54
- assertEquals(Replacement(0, 3, "the"), explicit.replacement)
55
- assertNull(explicit.inserted)
56
- assertTraceContains(explicit.trace, "correctionExplicitApply")
57
-
58
- val trailingPeriod = runCorrectionScenario("teh.", offset = 0, oldText = null, newText = "the")
59
- assertEquals(Replacement(0, 3, "the"), trailingPeriod.replacement)
60
- assertNull(trailingPeriod.inserted)
61
- assertTraceContains(trailingPeriod.trace, "correctionInferredApply")
62
-
63
- val punctuationOffset = runCorrectionScenario("teh.", offset = 3, oldText = null, newText = "the")
64
- assertNull(punctuationOffset.replacement)
65
- assertNull(punctuationOffset.inserted)
66
- assertTraceContains(punctuationOffset.trace, "correctionInferredNoop")
67
-
68
- val whitespaceOffset = runCorrectionScenario("teh ", offset = 3, oldText = null, newText = "the")
69
- assertNull(whitespaceOffset.replacement)
70
- assertNull(whitespaceOffset.inserted)
71
- assertTraceContains(whitespaceOffset.trace, "correctionInferredNoop")
72
-
73
- val hyphenated = runCorrectionScenario(
74
- "dont-stop ",
75
- offset = 4,
76
- oldText = null,
77
- newText = "don't-stop"
78
- )
79
- assertEquals(Replacement(0, 9, "don't-stop"), hyphenated.replacement)
80
-
81
- val apostrophe = runCorrectionScenario("cant's ", offset = 4, oldText = null, newText = "can't")
82
- assertEquals(Replacement(0, 6, "can't"), apostrophe.replacement)
83
-
84
- val surrogate = runCorrectionScenario("te😀h ", offset = 3, oldText = null, newText = "term")
85
- assertEquals(Replacement(0, 4, "term"), surrogate.replacement)
86
- }
87
-
88
- @Test
89
- fun visibleCompositionCorrectionCommitDeleteAndPreflightAreRouted() {
90
- val correction = runOnMainSyncWithResult {
91
- val editText = createEditor("", selectionStart = 0, selectionEnd = 0)
92
- var inserted: Inserted? = null
93
- editText.onInsertTextInRustForTesting = { text, scalar ->
94
- inserted = Inserted(text, scalar)
95
- editText.applyUpdateJSON(renderUpdateJson(text), notifyListener = false)
96
- editText.setSelection(text.length)
97
- }
98
-
99
- val inputConnection = createInputConnection(editText)
100
- assertTrue(inputConnection.setComposingText("teh", 1))
101
- assertTrue(inputConnection.commitCorrection(CorrectionInfo(0, "teh", "the")))
102
-
103
- ImeResult(
104
- inserted = inserted,
105
- trace = editText.imeTraceSnapshotForTesting()
106
- )
107
- }
108
-
109
- assertEquals(Inserted("the", 0), correction.inserted)
110
- assertTraceContains(correction.trace, "setComposingText")
111
- assertTraceContains(correction.trace, "commitCorrectionComposition")
112
-
113
- val deletion = runOnMainSyncWithResult {
114
- val editText = createEditor("", selectionStart = 0, selectionEnd = 0)
115
- var inserted: Inserted? = null
116
- editText.onInsertTextInRustForTesting = { text, scalar ->
117
- inserted = Inserted(text, scalar)
118
- }
119
-
120
- val inputConnection = createInputConnection(editText)
121
- assertTrue(inputConnection.setComposingText("abcd", 1))
122
- assertTrue(inputConnection.deleteSurroundingText(1, 0))
123
- assertTrue(inputConnection.finishComposingText())
124
-
125
- ImeResult(
126
- inserted = inserted,
127
- trace = editText.imeTraceSnapshotForTesting()
128
- )
129
- }
130
-
131
- assertEquals(Inserted("abc", 0), deletion.inserted)
132
- assertTraceContains(deletion.trace, "finishComposingText")
133
-
134
- val preflight = runOnMainSyncWithResult {
135
- val editText = createEditor("", selectionStart = 0, selectionEnd = 0)
136
- var inserted: Inserted? = null
137
- editText.onInsertTextInRustForTesting = { text, scalar ->
138
- inserted = Inserted(text, scalar)
139
- editText.applyUpdateJSON(renderUpdateJson(text), notifyListener = false)
140
- }
141
-
142
- val inputConnection = createInputConnection(editText)
143
- assertTrue(inputConnection.setComposingText("abc", 1))
144
- val ready = editText.prepareForExternalEditorUpdate()
145
-
146
- ImeResult(
147
- inserted = inserted,
148
- trace = editText.imeTraceSnapshotForTesting(),
149
- ready = ready
150
- )
151
- }
152
-
153
- assertTrue(preflight.ready)
154
- assertEquals(Inserted("abc", 0), preflight.inserted)
155
- assertTraceContains(preflight.trace, "finishComposingText")
156
- }
157
-
158
- @Test
159
- fun nativeEditableAutocorrectBeforeAndAfterBlurIsAdoptedAndSelectionIsPreserved() {
160
- val focused = runOnMainSyncWithResult {
161
- val editText = createEditor("teh ", selectionStart = 4, selectionEnd = 4)
162
- assertTrue(editText.requestFocus())
163
- var replacement: Replacement? = null
164
- editText.onReplaceTextInRustForTesting = { scalarFrom, scalarTo, text ->
165
- replacement = Replacement(scalarFrom, scalarTo, text)
166
- }
167
-
168
- editText.text!!.replace(0, 3, "the")
169
-
170
- ImeResult(
171
- replacement = replacement,
172
- selection = editText.selectionStart to editText.selectionEnd,
173
- trace = editText.imeTraceSnapshotForTesting()
174
- )
175
- }
176
-
177
- assertEquals(Replacement(1, 3, "he"), focused.replacement)
178
- assertEquals(4 to 4, focused.selection)
179
- assertTraceContains(focused.trace, "nativeMutationApply")
180
-
181
- val afterBlur = runOnMainSyncWithResult {
182
- val editText = createEditor("teh ", selectionStart = 4, selectionEnd = 4)
183
- assertTrue(editText.requestFocus())
184
- var replacement: Replacement? = null
185
- editText.onReplaceTextInRustForTesting = { scalarFrom, scalarTo, text ->
186
- replacement = Replacement(scalarFrom, scalarTo, text)
187
- }
188
-
189
- editText.clearFocus()
190
- editText.runWithTransientInputMutationGuard {
191
- editText.text!!.replace(0, 3, "the")
192
- BaseInputConnection.setComposingSpans(editText.text!!)
193
- true
194
- }
195
- val ready = editText.prepareForExternalEditorUpdate()
196
-
197
- ImeResult(
198
- replacement = replacement,
199
- selection = editText.selectionStart to editText.selectionEnd,
200
- trace = editText.imeTraceSnapshotForTesting(),
201
- ready = ready
202
- )
203
- }
204
-
205
- assertTrue(afterBlur.ready)
206
- assertEquals(Replacement(1, 3, "he"), afterBlur.replacement)
207
- assertEquals(4 to 4, afterBlur.selection)
208
- assertTraceContains(afterBlur.trace, "nativeMutationApply")
209
- }
210
-
211
- @Test
212
- fun staleAndInvalidCorrectionsDoNotFallBackToInsertion() {
213
- val stale = runCorrectionScenario("tah ", offset = 0, oldText = "teh", newText = "the")
214
- assertNull(stale.replacement)
215
- assertNull(stale.inserted)
216
- assertTraceContains(stale.trace, "correctionExplicitNoop")
217
-
218
- val invalidExplicit = runCorrectionScenario("teh ", offset = -1, oldText = "teh", newText = "the")
219
- assertNull(invalidExplicit.replacement)
220
- assertNull(invalidExplicit.inserted)
221
- assertTraceContains(invalidExplicit.trace, "commitCorrectionResult")
222
-
223
- val invalidInferred = runCorrectionScenario("teh ", offset = -1, oldText = null, newText = "the")
224
- assertNull(invalidInferred.replacement)
225
- assertNull(invalidInferred.inserted)
226
- assertTraceContains(invalidInferred.trace, "correctionInferredNoop")
227
- }
228
-
229
- private fun runCorrectionScenario(
230
- text: String,
231
- offset: Int,
232
- oldText: String?,
233
- newText: String
234
- ): ImeResult =
235
- runOnMainSyncWithResult {
236
- val editText = createEditor(text, selectionStart = text.length, selectionEnd = text.length)
237
- var replacement: Replacement? = null
238
- var inserted: Inserted? = null
239
- editText.onReplaceTextInRustForTesting = { scalarFrom, scalarTo, replacementText ->
240
- replacement = Replacement(scalarFrom, scalarTo, replacementText)
241
- }
242
- editText.onInsertTextInRustForTesting = { insertedText, scalar ->
243
- inserted = Inserted(insertedText, scalar)
244
- }
245
-
246
- val inputConnection = createInputConnection(editText)
247
- assertTrue(inputConnection.commitCorrection(CorrectionInfo(offset, oldText, newText)))
248
-
249
- ImeResult(
250
- replacement = replacement,
251
- inserted = inserted,
252
- trace = editText.imeTraceSnapshotForTesting()
253
- )
254
- }
255
-
256
- private fun createEditor(
257
- text: String,
258
- selectionStart: Int,
259
- selectionEnd: Int
260
- ): EditorEditText =
261
- EditorEditText(context).apply {
262
- applyUpdateJSON(renderUpdateJson(text), notifyListener = false)
263
- Selection.setSelection(this.text, selectionStart, selectionEnd)
264
- onSetSelectionScalarInRustForTesting = { _, _ -> }
265
- editorId = 1
266
- clearImeTraceForTesting()
267
- }
268
-
269
- private fun createInputConnection(editText: EditorEditText): EditorInputConnection {
270
- val inputConnection = editText.onCreateInputConnection(EditorInfo())
271
- assertTrue(inputConnection is EditorInputConnection)
272
- return inputConnection as EditorInputConnection
273
- }
274
-
275
- private fun assertTraceContains(trace: List<String>, event: String) {
276
- assertTrue(
277
- "expected IME trace to contain $event but was $trace",
278
- trace.any { it.startsWith(event) }
279
- )
280
- }
281
-
282
- private fun renderUpdateJson(text: String): String =
283
- JSONObject()
284
- .put(
285
- "renderBlocks",
286
- JSONArray().put(
287
- JSONArray()
288
- .put(
289
- JSONObject()
290
- .put("type", "blockStart")
291
- .put("nodeType", "paragraph")
292
- .put("depth", 0)
293
- )
294
- .put(
295
- JSONObject()
296
- .put("type", "textRun")
297
- .put("text", text)
298
- .put("marks", JSONArray())
299
- )
300
- .put(JSONObject().put("type", "blockEnd"))
301
- )
302
- )
303
- .toString()
304
-
305
- @Suppress("UNCHECKED_CAST")
306
- private fun <T> runOnMainSyncWithResult(block: () -> T): T {
307
- val result = AtomicReference<Any?>()
308
- val error = AtomicReference<Throwable?>()
309
- instrumentation.runOnMainSync {
310
- try {
311
- result.set(block())
312
- } catch (throwable: Throwable) {
313
- error.set(throwable)
314
- }
315
- }
316
- error.get()?.let { throw it }
317
- return result.get() as T
318
- }
319
-
320
- private data class Replacement(
321
- val scalarFrom: Int,
322
- val scalarTo: Int,
323
- val text: String
324
- )
325
-
326
- private data class Inserted(
327
- val text: String,
328
- val scalar: Int
329
- )
330
-
331
- private data class ImeResult(
332
- val replacement: Replacement? = null,
333
- val inserted: Inserted? = null,
334
- val selection: Pair<Int, Int> = 0 to 0,
335
- val trace: List<String> = emptyList(),
336
- val ready: Boolean = false
337
- )
338
- }