@openeditor/react-native-prose-editor 0.0.7 → 0.0.9

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 +1445 -0
  30. package/ios/NativeEditorExpoView.swift +3 -3
  31. package/ios/NativeEditorModule.swift +31 -46
  32. package/ios/NativeInputSupport.swift +396 -0
  33. package/ios/PositionBridge.swift +4 -4
  34. package/ios/RichTextEditorView.swift +352 -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,482 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.graphics.Color
4
- import java.util.Locale
5
- import kotlin.math.sqrt
6
- import org.json.JSONArray
7
- import org.json.JSONObject
8
- import uniffi.editor_core.*
9
-
10
- internal data class TimingStats(
11
- val name: String,
12
- val samplesNanos: List<Long>
13
- ) {
14
- val averageMillis: Double = samplesNanos.average() / 1_000_000.0
15
-
16
- private val relativeStdDev: Double = run {
17
- if (samplesNanos.size <= 1) {
18
- 0.0
19
- } else {
20
- val average = samplesNanos.average()
21
- val variance = samplesNanos
22
- .map { sample -> (sample - average) * (sample - average) }
23
- .average()
24
- if (average == 0.0) 0.0 else sqrt(variance) / average
25
- }
26
- }
27
-
28
- fun summaryString(tag: String = "NativePerformanceTest"): String {
29
- val formattedSamples = samplesNanos.joinToString(", ") { sample ->
30
- String.format(Locale.US, "%.3f", sample / 1_000_000.0)
31
- }
32
- return buildString {
33
- append("[")
34
- append(tag)
35
- append("] ")
36
- append(name)
37
- append(" avg=")
38
- append(String.format(Locale.US, "%.3fms", averageMillis))
39
- append(" rsd=")
40
- append(String.format(Locale.US, "%.3f%%", relativeStdDev * 100.0))
41
- append(" samplesMs=[")
42
- append(formattedSamples)
43
- append("]")
44
- }
45
- }
46
- }
47
-
48
- internal data class ApplyUpdateTraceStats(
49
- val name: String,
50
- val traces: List<EditorEditText.ApplyUpdateTrace>
51
- ) {
52
- private fun average(selector: (EditorEditText.ApplyUpdateTrace) -> Long): Double =
53
- if (traces.isEmpty()) 0.0 else traces.map(selector).average() / 1_000_000.0
54
-
55
- fun summaryString(tag: String = "NativePerformanceTest"): String {
56
- return buildString {
57
- append("[")
58
- append(tag)
59
- append("] ")
60
- append(name)
61
- append(" avgMs={")
62
- append("parse=")
63
- append(String.format(Locale.US, "%.3f", average { it.parseNanos }))
64
- append(", resolveBlocks=")
65
- append(String.format(Locale.US, "%.3f", average { it.resolveRenderBlocksNanos }))
66
- append(", patchEligibility=")
67
- append(String.format(Locale.US, "%.3f", average { it.patchEligibilityNanos }))
68
- append(", buildRender=")
69
- append(String.format(Locale.US, "%.3f", average { it.buildRenderNanos }))
70
- append(", applyRender=")
71
- append(String.format(Locale.US, "%.3f", average { it.applyRenderNanos }))
72
- append(", selection=")
73
- append(String.format(Locale.US, "%.3f", average { it.selectionNanos }))
74
- append(", postApply=")
75
- append(String.format(Locale.US, "%.3f", average { it.postApplyNanos }))
76
- append(", total=")
77
- append(String.format(Locale.US, "%.3f", average { it.totalNanos }))
78
- append("} patchUsage=")
79
- append(traces.count { it.usedPatch })
80
- append("/")
81
- append(traces.size)
82
- append(" skippedRender=")
83
- append(traces.count { it.skippedRender })
84
- append("/")
85
- append(traces.size)
86
- }
87
- }
88
- }
89
-
90
- internal object NativePerformanceFixtureFactory {
91
- private const val blockCount = 96
92
- private const val paragraphCharacterCount = 180
93
- private const val patchInsertIndex = 24
94
-
95
- fun largeRenderJson(): String = largeRenderElements().toString()
96
-
97
- fun largeDocumentJson(): String = JSONObject()
98
- .put("type", "doc")
99
- .put("content", largeDocumentContent())
100
- .toString()
101
-
102
- fun largeUpdateJson(): String {
103
- val renderBlocks = largeRenderBlocks()
104
- return JSONObject()
105
- .put("renderBlocks", renderBlocks)
106
- .toString()
107
- }
108
-
109
- fun largePatchedUpdateJson(): String {
110
- val originalBlocks = largeRenderBlocks()
111
- val insertedBlock = emptyParagraphRenderBlock()
112
- val patchedBlocks = JSONArray()
113
- for (index in 0 until originalBlocks.length()) {
114
- if (index == patchInsertIndex) {
115
- patchedBlocks.put(insertedBlock)
116
- }
117
- patchedBlocks.put(cloneJsonArray(originalBlocks.optJSONArray(index) ?: JSONArray()))
118
- }
119
- if (patchInsertIndex >= originalBlocks.length()) {
120
- patchedBlocks.put(insertedBlock)
121
- }
122
-
123
- val startIndex = if (patchInsertIndex > 0) patchInsertIndex - 1 else 0
124
- val oldDeleteCount = when {
125
- originalBlocks.length() == 0 -> 0
126
- patchInsertIndex == 0 -> minOf(1, originalBlocks.length())
127
- patchInsertIndex >= originalBlocks.length() -> 1
128
- else -> 2
129
- }
130
- val patchBlocks = JSONArray()
131
- for (index in startIndex until minOf(patchedBlocks.length(), startIndex + oldDeleteCount + 1)) {
132
- patchBlocks.put(cloneJsonArray(patchedBlocks.optJSONArray(index) ?: JSONArray()))
133
- }
134
-
135
- return JSONObject()
136
- .put(
137
- "renderPatch",
138
- JSONObject()
139
- .put("startIndex", startIndex)
140
- .put("deleteCount", oldDeleteCount)
141
- .put("renderBlocks", patchBlocks)
142
- )
143
- .toString()
144
- }
145
-
146
- fun loadLargeDocumentIntoEditor(editorId: ULong): String {
147
- editorSetJson(editorId, largeDocumentJson())
148
- return editorGetCurrentState(editorId)
149
- }
150
-
151
- fun remoteSelections(
152
- totalScalar: Int,
153
- peerCount: Int = 6,
154
- selectionWidth: Int = 0
155
- ): List<RemoteSelectionDecoration> {
156
- val upperBound = maxOf(1, totalScalar - 1)
157
- val colors = listOf(
158
- Color.parseColor("#007AFF"),
159
- Color.parseColor("#34C759"),
160
- Color.parseColor("#FF9500"),
161
- Color.parseColor("#FF2D55"),
162
- Color.parseColor("#AF52DE"),
163
- Color.parseColor("#30B0C7"),
164
- )
165
-
166
- return evenlySpacedValues(1, upperBound, peerCount)
167
- .mapIndexed { index, scalar ->
168
- val head = if (selectionWidth > 0 && index % 2 == 1) {
169
- minOf(upperBound, scalar + selectionWidth)
170
- } else {
171
- scalar
172
- }
173
- RemoteSelectionDecoration(
174
- clientId = index + 1,
175
- anchor = scalar,
176
- head = head,
177
- color = colors[index % colors.size],
178
- name = "Peer ${index + 1}",
179
- isFocused = true
180
- )
181
- }
182
- }
183
-
184
- fun typingCursorOffset(renderedText: CharSequence): Int =
185
- selectionScrubOffsets(renderedText, points = 1).firstOrNull() ?: 0
186
-
187
- fun selectionScrubOffsets(renderedText: CharSequence, points: Int): List<Int> {
188
- val candidates = renderedText
189
- .mapIndexedNotNull { index, char ->
190
- when (char) {
191
- '\uFFFC', '\u200B', '\n', '\r' -> null
192
- else -> index
193
- }
194
- }
195
-
196
- if (candidates.isEmpty()) {
197
- return listOf(0)
198
- }
199
-
200
- return evenlySpacedValues(0, candidates.lastIndex, points).map { candidates[it] }
201
- }
202
-
203
- private fun largeDocumentContent(): JSONArray {
204
- val content = JSONArray()
205
- content.put(
206
- JSONObject()
207
- .put("type", "h1")
208
- .put("content", JSONArray().put(textNode(textFragment(seed = 10_000, minCharacterCount = 40))))
209
- )
210
-
211
- for (index in 0 until blockCount) {
212
- if (index > 0 && index % 18 == 0) {
213
- content.put(JSONObject().put("type", "horizontalRule"))
214
- }
215
-
216
- when {
217
- index % 12 == 5 -> {
218
- content.put(
219
- JSONObject()
220
- .put("type", "blockquote")
221
- .put(
222
- "content",
223
- JSONArray().put(
224
- JSONObject()
225
- .put("type", "paragraph")
226
- .put(
227
- "content",
228
- richInlineDocContent(
229
- seed = index,
230
- totalCharacters = paragraphCharacterCount
231
- )
232
- )
233
- )
234
- )
235
- )
236
- }
237
-
238
- index % 9 == 3 -> {
239
- content.put(
240
- JSONObject()
241
- .put("type", "h2")
242
- .put(
243
- "content",
244
- JSONArray().put(
245
- textNode(
246
- textFragment(seed = index + 2_000, minCharacterCount = 72)
247
- )
248
- )
249
- )
250
- )
251
- }
252
-
253
- else -> {
254
- content.put(
255
- JSONObject()
256
- .put("type", "paragraph")
257
- .put(
258
- "content",
259
- richInlineDocContent(
260
- seed = index,
261
- totalCharacters = paragraphCharacterCount
262
- )
263
- )
264
- )
265
- }
266
- }
267
- }
268
-
269
- return content
270
- }
271
-
272
- private fun richInlineDocContent(seed: Int, totalCharacters: Int): JSONArray {
273
- val text = textFragment(seed = seed, minCharacterCount = totalCharacters)
274
- val cutA = text.length / 4
275
- val cutB = text.length / 2
276
- val cutC = (text.length * 3) / 4
277
-
278
- val content = JSONArray()
279
- appendTextNode(content, text.substring(0, cutA))
280
- appendTextNode(content, text.substring(cutA, cutB), JSONArray().put("bold"))
281
- appendTextNode(content, text.substring(cutB, cutC), JSONArray().put("italic"))
282
- appendTextNode(
283
- content,
284
- text.substring(cutC),
285
- JSONArray().put(
286
- JSONObject()
287
- .put("type", "link")
288
- .put(
289
- "attrs",
290
- JSONObject()
291
- .put("href", "https://example.com/item/$seed")
292
- .put("target", "_blank")
293
- .put("rel", "noopener noreferrer nofollow")
294
- .put("class", JSONObject.NULL)
295
- .put("title", JSONObject.NULL)
296
- )
297
- )
298
- )
299
- return content
300
- }
301
-
302
- private fun appendTextNode(content: JSONArray, text: String, marks: JSONArray? = null) {
303
- if (text.isEmpty()) return
304
- val node = textNode(text)
305
- if (marks != null && marks.length() > 0) {
306
- node.put("marks", marks)
307
- }
308
- content.put(node)
309
- }
310
-
311
- private fun textNode(text: String): JSONObject =
312
- JSONObject()
313
- .put("type", "text")
314
- .put("text", text)
315
-
316
- private fun evenlySpacedValues(start: Int, endInclusive: Int, count: Int): List<Int> {
317
- if (count <= 1 || endInclusive <= start) {
318
- return listOf(start.coerceAtMost(endInclusive))
319
- }
320
-
321
- return (0 until count).map { index ->
322
- start + (((endInclusive - start).toLong() * index.toLong()) / (count - 1).toLong()).toInt()
323
- }
324
- }
325
-
326
- private fun largeRenderElements(): JSONArray = flattenRenderBlocks(largeRenderBlocks())
327
-
328
- private fun largeRenderBlocks(): JSONArray {
329
- val blocks = JSONArray()
330
-
331
- blocks.put(
332
- JSONArray().apply {
333
- appendBlockStart(this, nodeType = "h1", depth = 0)
334
- appendTextRun(this, textFragment(seed = 10_000, minCharacterCount = 40))
335
- appendBlockEnd(this)
336
- }
337
- )
338
-
339
- for (index in 0 until blockCount) {
340
- if (index > 0 && index % 18 == 0) {
341
- blocks.put(
342
- JSONArray().apply {
343
- appendHorizontalRule(this)
344
- }
345
- )
346
- }
347
-
348
- blocks.put(
349
- JSONArray().apply {
350
- when {
351
- index % 12 == 5 -> {
352
- appendBlockStart(this, nodeType = "blockquote", depth = 0)
353
- appendBlockStart(this, nodeType = "paragraph", depth = 1)
354
- appendRichInlineContent(
355
- this,
356
- seed = index,
357
- totalCharacters = paragraphCharacterCount
358
- )
359
- appendBlockEnd(this)
360
- appendBlockEnd(this)
361
- }
362
-
363
- index % 9 == 3 -> {
364
- appendBlockStart(this, nodeType = "h2", depth = 0)
365
- appendTextRun(
366
- this,
367
- textFragment(seed = index + 2_000, minCharacterCount = 72)
368
- )
369
- appendBlockEnd(this)
370
- }
371
-
372
- else -> {
373
- appendBlockStart(this, nodeType = "paragraph", depth = 0)
374
- appendRichInlineContent(
375
- this,
376
- seed = index,
377
- totalCharacters = paragraphCharacterCount
378
- )
379
- appendBlockEnd(this)
380
- }
381
- }
382
- }
383
- )
384
- }
385
-
386
- return blocks
387
- }
388
-
389
- private fun flattenRenderBlocks(blocks: JSONArray): JSONArray {
390
- val flattened = JSONArray()
391
- for (blockIndex in 0 until blocks.length()) {
392
- val block = blocks.optJSONArray(blockIndex) ?: continue
393
- for (elementIndex in 0 until block.length()) {
394
- flattened.put(block.optJSONObject(elementIndex))
395
- }
396
- }
397
- return flattened
398
- }
399
-
400
- private fun emptyParagraphRenderBlock(): JSONArray =
401
- JSONArray().apply {
402
- appendBlockStart(this, nodeType = "paragraph", depth = 0)
403
- appendTextRun(this, "\u200B")
404
- appendBlockEnd(this)
405
- }
406
-
407
- private fun cloneJsonArray(array: JSONArray): JSONArray = JSONArray(array.toString())
408
-
409
- private fun appendRichInlineContent(
410
- elements: JSONArray,
411
- seed: Int,
412
- totalCharacters: Int
413
- ) {
414
- val text = textFragment(seed = seed, minCharacterCount = totalCharacters)
415
- val cutA = text.length / 4
416
- val cutB = text.length / 2
417
- val cutC = (text.length * 3) / 4
418
-
419
- appendTextRun(elements, text.substring(0, cutA))
420
- appendTextRun(elements, text.substring(cutA, cutB), marks = JSONArray().put("bold"))
421
- appendTextRun(elements, text.substring(cutB, cutC), marks = JSONArray().put("italic"))
422
- appendTextRun(
423
- elements,
424
- text.substring(cutC),
425
- marks = JSONArray().put(
426
- JSONObject()
427
- .put("type", "link")
428
- .put("href", "https://example.com/item/$seed")
429
- )
430
- )
431
- }
432
-
433
- private fun appendBlockStart(elements: JSONArray, nodeType: String, depth: Int) {
434
- elements.put(
435
- JSONObject()
436
- .put("type", "blockStart")
437
- .put("nodeType", nodeType)
438
- .put("depth", depth)
439
- )
440
- }
441
-
442
- private fun appendBlockEnd(elements: JSONArray) {
443
- elements.put(JSONObject().put("type", "blockEnd"))
444
- }
445
-
446
- private fun appendHorizontalRule(elements: JSONArray) {
447
- elements.put(
448
- JSONObject()
449
- .put("type", "voidBlock")
450
- .put("nodeType", "horizontalRule")
451
- .put("docPos", 0)
452
- )
453
- }
454
-
455
- private fun appendTextRun(elements: JSONArray, text: String, marks: JSONArray = JSONArray()) {
456
- elements.put(
457
- JSONObject()
458
- .put("type", "textRun")
459
- .put("text", text)
460
- .put("marks", marks)
461
- )
462
- }
463
-
464
- private fun textFragment(seed: Int, minCharacterCount: Int): String {
465
- val words = listOf(
466
- "alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india",
467
- "juliet", "kilo", "lima", "mike", "november", "oscar", "papa", "quebec", "romeo",
468
- "sierra", "tango", "uniform", "victor", "whiskey", "xray", "yankee", "zulu",
469
- )
470
-
471
- val builder = StringBuilder()
472
- var cursor = 0
473
- while (builder.length < minCharacterCount) {
474
- if (builder.isNotEmpty()) {
475
- builder.append(' ')
476
- }
477
- builder.append(words[(seed + cursor) % words.size])
478
- cursor += 1
479
- }
480
- return builder.substring(0, minCharacterCount)
481
- }
482
- }
@@ -1,137 +0,0 @@
1
- package com.openeditor.editor
2
-
3
- import android.graphics.Color
4
- import android.text.SpannableStringBuilder
5
- import android.text.Spanned
6
- import android.text.StaticLayout
7
- import android.text.TextPaint
8
- import org.junit.Assert.assertEquals
9
- import org.junit.Assert.assertFalse
10
- import org.junit.Assert.assertTrue
11
- import org.junit.Test
12
- import org.junit.runner.RunWith
13
- import org.robolectric.RobolectricTestRunner
14
- import org.robolectric.annotation.Config
15
-
16
- /**
17
- * Unit tests for [CaretGeometry] — the caret must clip to the rendered glyph
18
- * height and never extend into the paragraph gap that [ParagraphSpacerSpan]
19
- * adds below the line via inflated descent.
20
- */
21
- @RunWith(RobolectricTestRunner::class)
22
- @Config(sdk = [34])
23
- class CaretGeometryTest {
24
-
25
- private fun layoutFor(text: CharSequence, paint: TextPaint): StaticLayout =
26
- StaticLayout.Builder.obtain(text, 0, text.length, paint, WIDTH_PX).build()
27
-
28
- @Test
29
- fun `caret bottom clips paragraph spacer descent to the glyph height`() {
30
- val text = SpannableStringBuilder("Hello\nWorld")
31
- // Inter-block spacer inflates the descent of line 0 (the "Hello\n" line),
32
- // exactly as RenderBridge applies it between blocks.
33
- text.setSpan(
34
- ParagraphSpacerSpan(spacingPx = SPACER_PX, baseFontSize = FONT_SIZE_PX, textColor = Color.BLACK),
35
- 5,
36
- 6,
37
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
38
- )
39
- val paint = TextPaint().apply { textSize = FONT_SIZE_PX.toFloat() }
40
- val layout = layoutFor(text, paint)
41
- val line = 0
42
-
43
- // Reproduction guard: the spacer really did inflate the layout descent.
44
- assertTrue(
45
- "ParagraphSpacerSpan should inflate layout descent in this reproduction",
46
- layout.getLineDescent(line) > paint.fontMetrics.descent
47
- )
48
-
49
- val bounds = CaretGeometry.verticalBounds(layout, offset = 5, paint = paint)
50
-
51
- assertEquals(
52
- "caret top should be the line top",
53
- layout.getLineTop(line).toFloat(),
54
- bounds.top,
55
- TOLERANCE_PX
56
- )
57
- assertEquals(
58
- "caret bottom should be baseline + raw font descent, not the inflated getLineBottom",
59
- layout.getLineBaseline(line) + paint.fontMetrics.descent,
60
- bounds.bottom,
61
- TOLERANCE_PX
62
- )
63
- assertTrue(
64
- "caret must not extend into the paragraph gap below the line",
65
- bounds.bottom < layout.getLineBottom(line)
66
- )
67
- }
68
-
69
- @Test
70
- fun `caret height is identical with and without the paragraph spacer`() {
71
- val paint = TextPaint().apply { textSize = FONT_SIZE_PX.toFloat() }
72
-
73
- val plain = layoutFor(SpannableStringBuilder("Hello\nWorld"), paint)
74
-
75
- val spaced = SpannableStringBuilder("Hello\nWorld")
76
- spaced.setSpan(
77
- ParagraphSpacerSpan(spacingPx = SPACER_PX, baseFontSize = FONT_SIZE_PX, textColor = Color.BLACK),
78
- 5,
79
- 6,
80
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
81
- )
82
- val spacedLayout = layoutFor(spaced, paint)
83
-
84
- // Sanity: the spacer changed the layout (line 1 sits lower).
85
- assertTrue(
86
- "spacer should push the following line down",
87
- spacedLayout.getLineTop(1) > plain.getLineTop(1)
88
- )
89
-
90
- val plainBounds = CaretGeometry.verticalBounds(plain, offset = 5, paint = paint)
91
- val spacedBounds = CaretGeometry.verticalBounds(spacedLayout, offset = 5, paint = paint)
92
-
93
- assertEquals(
94
- "caret top must not depend on the paragraph spacer",
95
- plainBounds.top,
96
- spacedBounds.top,
97
- TOLERANCE_PX
98
- )
99
- assertEquals(
100
- "caret height must be the rendered glyph height regardless of paragraph spacing",
101
- plainBounds.bottom,
102
- spacedBounds.bottom,
103
- TOLERANCE_PX
104
- )
105
- }
106
-
107
- @Test
108
- fun `caret renders only when focused, window-focused, and selection collapsed`() {
109
- assertTrue(
110
- "collapsed caret in a focused field should render",
111
- CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = 3, selectionEnd = 3)
112
- )
113
- assertFalse(
114
- "no caret when the field is not focused",
115
- CaretGeometry.shouldRender(focused = false, windowFocused = true, selectionStart = 3, selectionEnd = 3)
116
- )
117
- assertFalse(
118
- "no caret when the window is not focused",
119
- CaretGeometry.shouldRender(focused = true, windowFocused = false, selectionStart = 3, selectionEnd = 3)
120
- )
121
- assertFalse(
122
- "no caret for a range selection (highlight shown instead)",
123
- CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = 3, selectionEnd = 5)
124
- )
125
- assertFalse(
126
- "no caret when there is no selection",
127
- CaretGeometry.shouldRender(focused = true, windowFocused = true, selectionStart = -1, selectionEnd = -1)
128
- )
129
- }
130
-
131
- private companion object {
132
- const val WIDTH_PX = 200
133
- const val FONT_SIZE_PX = 16
134
- const val SPACER_PX = 40
135
- const val TOLERANCE_PX = 0.01f
136
- }
137
- }