@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.
- package/android/build.gradle +35 -0
- package/android/src/main/java/com/apollohg/editor/CaretGeometry.kt +2 -2
- package/android/src/main/java/com/apollohg/editor/EditorEditText.kt +138 -597
- package/android/src/main/java/com/apollohg/editor/EditorInputConnection.kt +13 -3
- package/android/src/main/java/com/apollohg/editor/InputSnapshotSupport.kt +6 -0
- package/android/src/main/java/com/apollohg/editor/NativeBlockEditorSurface.kt +1086 -0
- package/android/src/main/java/com/apollohg/editor/NativeEditorExpoView.kt +1 -1
- package/android/src/main/java/com/apollohg/editor/NativeEditorModule.kt +26 -50
- package/android/src/main/java/com/apollohg/editor/NativeImageLoader.kt +89 -0
- package/android/src/main/java/com/apollohg/editor/RemoteSelectionOverlayView.kt +31 -66
- package/android/src/main/java/com/apollohg/editor/RichTextEditorView.kt +98 -33
- package/dist/EditorToolbar.d.ts +1 -1
- package/dist/EditorToolbar.js +19 -0
- package/dist/NativeEditorBridge.d.ts +62 -35
- package/dist/NativeEditorBridge.js +122 -79
- package/dist/NativeProseViewer.d.ts +13 -34
- package/dist/NativeProseViewer.js +40 -465
- package/dist/NativeRichTextEditor.d.ts +10 -1
- package/dist/NativeRichTextEditor.js +72 -34
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/schemas.d.ts +11 -0
- package/dist/schemas.js +149 -0
- package/dist/useNativeEditor.d.ts +3 -3
- package/dist/useNativeEditor.js +3 -3
- package/ios/EditorCore.xcframework/ios-arm64/libeditor_core.a +0 -0
- package/ios/EditorCore.xcframework/ios-arm64_x86_64-simulator/libeditor_core.a +0 -0
- package/ios/Generated_editor_core.swift +97 -2
- package/ios/NativeBlockEditorSurface.swift +1393 -0
- package/ios/NativeEditorExpoView.swift +3 -3
- package/ios/NativeEditorModule.swift +30 -45
- package/ios/NativeInputSupport.swift +396 -0
- package/ios/PositionBridge.swift +4 -4
- package/ios/RichTextEditorView.swift +333 -786
- package/ios/editor_coreFFI/editor_coreFFI.h +147 -70
- package/package.json +1 -1
- package/rust/android/arm64-v8a/libeditor_core.so +0 -0
- package/rust/android/armeabi-v7a/libeditor_core.so +0 -0
- package/rust/android/x86_64/libeditor_core.so +0 -0
- package/rust/bindings/kotlin/uniffi/editor_core/editor_core.kt +2087 -1399
- package/android/src/androidTest/AndroidManifest.xml +0 -8
- package/android/src/androidTest/java/com/apollohg/editor/MeasureHeightInstrumentedTest.kt +0 -53
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceCollaborationInitialSyncTest.kt +0 -241
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceImeRegressionTest.kt +0 -338
- package/android/src/androidTest/java/com/apollohg/editor/NativeDeviceOutsideTapTest.kt +0 -789
- package/android/src/androidTest/java/com/apollohg/editor/NativeDevicePerformanceTest.kt +0 -350
- package/android/src/androidTest/java/com/apollohg/editor/NativeEditorOutsideTapActivity.kt +0 -5
- package/android/src/main/java/com/apollohg/editor/NativeProseViewerExpoView.kt +0 -315
- package/android/src/main/java/com/apollohg/editor/RenderBridge.kt +0 -2172
- package/android/src/sharedTest/java/com/apollohg/editor/NativePerformanceSupport.kt +0 -482
- package/android/src/test/java/com/apollohg/editor/CaretGeometryTest.kt +0 -137
- package/android/src/test/java/com/apollohg/editor/EditorEditTextHardwareKeyTest.kt +0 -321
- package/android/src/test/java/com/apollohg/editor/EditorInputConnectionTest.kt +0 -4202
- package/android/src/test/java/com/apollohg/editor/NativeEditorExpoViewTest.kt +0 -2581
- package/android/src/test/java/com/apollohg/editor/NativeEditorModuleTest.kt +0 -27
- package/android/src/test/java/com/apollohg/editor/NativePerformanceTest.kt +0 -197
- package/android/src/test/java/com/apollohg/editor/NativeProseViewerExpoViewTest.kt +0 -84
- package/android/src/test/java/com/apollohg/editor/NativeToolbarTest.kt +0 -544
- package/android/src/test/java/com/apollohg/editor/PositionBridgeTest.kt +0 -461
- package/android/src/test/java/com/apollohg/editor/RenderBridgeTest.kt +0 -2054
- package/android/src/test/java/com/apollohg/editor/RichTextEditorViewTest.kt +0 -1367
- package/ios/EditorLayoutManager.swift +0 -774
- package/ios/NativeProseViewerExpoView.swift +0 -276
- package/ios/RenderBridge.swift +0 -1708
- package/ios/Tests/NativePerformanceTests.swift +0 -687
- package/ios/Tests/PositionBridgeTests.swift +0 -706
- package/ios/Tests/RenderBridgeTests.swift +0 -2236
- package/ios/Tests/RichTextEditorViewTests.swift +0 -6548
|
@@ -1,2054 +0,0 @@
|
|
|
1
|
-
package com.openeditor.editor
|
|
2
|
-
|
|
3
|
-
import android.graphics.Bitmap
|
|
4
|
-
import android.graphics.Color
|
|
5
|
-
import android.graphics.Paint
|
|
6
|
-
import android.graphics.Typeface
|
|
7
|
-
import android.text.Annotation
|
|
8
|
-
import android.text.Layout
|
|
9
|
-
import android.text.Spanned
|
|
10
|
-
import android.text.SpannableStringBuilder
|
|
11
|
-
import android.text.StaticLayout
|
|
12
|
-
import android.text.TextPaint
|
|
13
|
-
import android.util.Base64
|
|
14
|
-
import android.view.View
|
|
15
|
-
import android.widget.TextView
|
|
16
|
-
import kotlin.math.abs
|
|
17
|
-
import android.text.style.AbsoluteSizeSpan
|
|
18
|
-
import android.text.style.BackgroundColorSpan
|
|
19
|
-
import android.text.style.ForegroundColorSpan
|
|
20
|
-
import android.text.style.LeadingMarginSpan
|
|
21
|
-
import android.text.style.StrikethroughSpan
|
|
22
|
-
import android.text.style.StyleSpan
|
|
23
|
-
import android.text.style.TypefaceSpan
|
|
24
|
-
import android.text.style.URLSpan
|
|
25
|
-
import android.text.style.UnderlineSpan
|
|
26
|
-
import org.junit.Assert.assertEquals
|
|
27
|
-
import org.junit.Assert.assertFalse
|
|
28
|
-
import org.junit.Assert.assertNotNull
|
|
29
|
-
import org.junit.Assert.assertTrue
|
|
30
|
-
import org.junit.Assert.fail
|
|
31
|
-
import org.junit.Test
|
|
32
|
-
import org.junit.runner.RunWith
|
|
33
|
-
import org.robolectric.RobolectricTestRunner
|
|
34
|
-
import org.robolectric.annotation.Config
|
|
35
|
-
import java.util.concurrent.CountDownLatch
|
|
36
|
-
import java.util.concurrent.TimeUnit
|
|
37
|
-
import java.util.concurrent.atomic.AtomicInteger
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Unit tests for [RenderBridge] — conversion of RenderElement JSON into
|
|
41
|
-
* [SpannableStringBuilder] with appropriate spans.
|
|
42
|
-
*
|
|
43
|
-
* Uses Robolectric to provide Android framework classes (SpannableStringBuilder,
|
|
44
|
-
* span types, etc.) in a JVM test environment.
|
|
45
|
-
*
|
|
46
|
-
* NOTE: Robolectric must be in the test dependencies for these to run:
|
|
47
|
-
* ```gradle
|
|
48
|
-
* testImplementation("org.robolectric:robolectric:4.11.1")
|
|
49
|
-
* ```
|
|
50
|
-
*
|
|
51
|
-
* These tests mirror the iOS RenderBridgeTests.swift test suite.
|
|
52
|
-
*/
|
|
53
|
-
@RunWith(RobolectricTestRunner::class)
|
|
54
|
-
@Config(sdk = [34])
|
|
55
|
-
class RenderBridgeTest {
|
|
56
|
-
|
|
57
|
-
// ── Test Fixtures ───────────────────────────────────────────────────
|
|
58
|
-
|
|
59
|
-
private val baseFontSize = 16f
|
|
60
|
-
private val textColor = Color.BLACK
|
|
61
|
-
|
|
62
|
-
@Test
|
|
63
|
-
fun `viewer empty collapse detects documents with only empty top-level paragraphs`() {
|
|
64
|
-
val json = """
|
|
65
|
-
[
|
|
66
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
67
|
-
{"type": "textRun", "text": "\u200B", "marks": []},
|
|
68
|
-
{"type": "blockEnd"},
|
|
69
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
70
|
-
{"type": "textRun", "text": "", "marks": []},
|
|
71
|
-
{"type": "blockEnd"}
|
|
72
|
-
]
|
|
73
|
-
""".trimIndent()
|
|
74
|
-
|
|
75
|
-
assertTrue(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
@Test
|
|
79
|
-
fun `viewer empty collapse keeps visible rendered content measurable`() {
|
|
80
|
-
val json = """
|
|
81
|
-
[
|
|
82
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
83
|
-
{"type": "textRun", "text": "Hello", "marks": []},
|
|
84
|
-
{"type": "blockEnd"}
|
|
85
|
-
]
|
|
86
|
-
""".trimIndent()
|
|
87
|
-
|
|
88
|
-
assertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
@Test
|
|
92
|
-
fun `viewer empty collapse keeps non-paragraph rendered blocks measurable`() {
|
|
93
|
-
val json = """
|
|
94
|
-
[
|
|
95
|
-
{"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {}}
|
|
96
|
-
]
|
|
97
|
-
""".trimIndent()
|
|
98
|
-
|
|
99
|
-
assertFalse(NativeProseViewerExpoView.renderJsonContainsOnlyEmptyParagraphs(json))
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// ── Plain Text Rendering ────────────────────────────────────────────
|
|
103
|
-
|
|
104
|
-
/** A single paragraph with unstyled text should produce the text content. */
|
|
105
|
-
@Test
|
|
106
|
-
fun `render - plain paragraph`() {
|
|
107
|
-
val json = """
|
|
108
|
-
[
|
|
109
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
110
|
-
{"type": "textRun", "text": "Hello, world!", "marks": []},
|
|
111
|
-
{"type": "blockEnd"}
|
|
112
|
-
]
|
|
113
|
-
""".trimIndent()
|
|
114
|
-
|
|
115
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
116
|
-
|
|
117
|
-
assertEquals(
|
|
118
|
-
"Plain paragraph should render as the text content",
|
|
119
|
-
"Hello, world!", result.toString()
|
|
120
|
-
)
|
|
121
|
-
|
|
122
|
-
// Verify foreground color span is present.
|
|
123
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
124
|
-
assertTrue(
|
|
125
|
-
"Should have at least one ForegroundColorSpan",
|
|
126
|
-
colorSpans.isNotEmpty()
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// ── Bold Text Rendering ─────────────────────────────────────────────
|
|
131
|
-
|
|
132
|
-
/** Bold mark should produce a StyleSpan with Typeface.BOLD. */
|
|
133
|
-
@Test
|
|
134
|
-
fun `render - bold text`() {
|
|
135
|
-
val json = """
|
|
136
|
-
[
|
|
137
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
138
|
-
{"type": "textRun", "text": "bold text", "marks": ["bold"]},
|
|
139
|
-
{"type": "blockEnd"}
|
|
140
|
-
]
|
|
141
|
-
""".trimIndent()
|
|
142
|
-
|
|
143
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
144
|
-
|
|
145
|
-
assertEquals("bold text", result.toString())
|
|
146
|
-
|
|
147
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
148
|
-
assertTrue("Should have a StyleSpan", styleSpans.isNotEmpty())
|
|
149
|
-
|
|
150
|
-
val boldSpan = styleSpans.find { it.style == Typeface.BOLD }
|
|
151
|
-
assertNotNull(
|
|
152
|
-
"Should have a BOLD StyleSpan. Styles found: ${styleSpans.map { it.style }}",
|
|
153
|
-
boldSpan
|
|
154
|
-
)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// ── Italic Text Rendering ───────────────────────────────────────────
|
|
158
|
-
|
|
159
|
-
@Test
|
|
160
|
-
fun `render - italic text`() {
|
|
161
|
-
val json = """
|
|
162
|
-
[
|
|
163
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
164
|
-
{"type": "textRun", "text": "italic text", "marks": ["italic"]},
|
|
165
|
-
{"type": "blockEnd"}
|
|
166
|
-
]
|
|
167
|
-
""".trimIndent()
|
|
168
|
-
|
|
169
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
170
|
-
|
|
171
|
-
assertEquals("italic text", result.toString())
|
|
172
|
-
|
|
173
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
174
|
-
val italicSpan = styleSpans.find { it.style == Typeface.ITALIC }
|
|
175
|
-
assertNotNull(
|
|
176
|
-
"Should have an ITALIC StyleSpan. Styles found: ${styleSpans.map { it.style }}",
|
|
177
|
-
italicSpan
|
|
178
|
-
)
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// ── Bold + Italic Combined ──────────────────────────────────────────
|
|
182
|
-
|
|
183
|
-
@Test
|
|
184
|
-
fun `render - bold italic`() {
|
|
185
|
-
val json = """
|
|
186
|
-
[
|
|
187
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
188
|
-
{"type": "textRun", "text": "bold italic", "marks": ["bold", "italic"]},
|
|
189
|
-
{"type": "blockEnd"}
|
|
190
|
-
]
|
|
191
|
-
""".trimIndent()
|
|
192
|
-
|
|
193
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
194
|
-
|
|
195
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
196
|
-
val boldItalicSpan = styleSpans.find { it.style == Typeface.BOLD_ITALIC }
|
|
197
|
-
assertNotNull(
|
|
198
|
-
"Should have a BOLD_ITALIC StyleSpan. Styles found: ${styleSpans.map { it.style }}",
|
|
199
|
-
boldItalicSpan
|
|
200
|
-
)
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// ── Underline ───────────────────────────────────────────────────────
|
|
204
|
-
|
|
205
|
-
@Test
|
|
206
|
-
fun `render - underline`() {
|
|
207
|
-
val json = """
|
|
208
|
-
[
|
|
209
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
210
|
-
{"type": "textRun", "text": "underlined", "marks": ["underline"]},
|
|
211
|
-
{"type": "blockEnd"}
|
|
212
|
-
]
|
|
213
|
-
""".trimIndent()
|
|
214
|
-
|
|
215
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
216
|
-
|
|
217
|
-
assertEquals("underlined", result.toString())
|
|
218
|
-
|
|
219
|
-
val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
|
|
220
|
-
assertTrue(
|
|
221
|
-
"Should have an UnderlineSpan",
|
|
222
|
-
underlineSpans.isNotEmpty()
|
|
223
|
-
)
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
// ── Strikethrough ───────────────────────────────────────────────────
|
|
227
|
-
|
|
228
|
-
@Test
|
|
229
|
-
fun `render - strikethrough`() {
|
|
230
|
-
val json = """
|
|
231
|
-
[
|
|
232
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
233
|
-
{"type": "textRun", "text": "struck", "marks": ["strike"]},
|
|
234
|
-
{"type": "blockEnd"}
|
|
235
|
-
]
|
|
236
|
-
""".trimIndent()
|
|
237
|
-
|
|
238
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
239
|
-
|
|
240
|
-
assertEquals("struck", result.toString())
|
|
241
|
-
|
|
242
|
-
val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
|
|
243
|
-
assertTrue(
|
|
244
|
-
"Should have a StrikethroughSpan",
|
|
245
|
-
strikeSpans.isNotEmpty()
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// ── Code Mark (Monospace) ───────────────────────────────────────────
|
|
250
|
-
|
|
251
|
-
@Test
|
|
252
|
-
fun `render - code inline`() {
|
|
253
|
-
val json = """
|
|
254
|
-
[
|
|
255
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
256
|
-
{"type": "textRun", "text": "code", "marks": ["code"]},
|
|
257
|
-
{"type": "blockEnd"}
|
|
258
|
-
]
|
|
259
|
-
""".trimIndent()
|
|
260
|
-
|
|
261
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
262
|
-
|
|
263
|
-
assertEquals("code", result.toString())
|
|
264
|
-
|
|
265
|
-
val typefaceSpans = result.getSpans(0, result.length, TypefaceSpan::class.java)
|
|
266
|
-
val monoSpan = typefaceSpans.find { it.family == "monospace" }
|
|
267
|
-
assertNotNull(
|
|
268
|
-
"Code mark should produce monospace TypefaceSpan. " +
|
|
269
|
-
"Families found: ${typefaceSpans.map { it.family }}",
|
|
270
|
-
monoSpan
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
val bgSpans = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
|
|
274
|
-
assertTrue(
|
|
275
|
-
"Code mark should have a background color span",
|
|
276
|
-
bgSpans.isNotEmpty()
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// ── Hard Break (Void Inline) ────────────────────────────────────────
|
|
281
|
-
|
|
282
|
-
/** A hardBreak void inline should render as a newline character. */
|
|
283
|
-
@Test
|
|
284
|
-
fun `render - hard break`() {
|
|
285
|
-
val json = """
|
|
286
|
-
[
|
|
287
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
288
|
-
{"type": "textRun", "text": "Line 1", "marks": []},
|
|
289
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 7},
|
|
290
|
-
{"type": "textRun", "text": "Line 2", "marks": []},
|
|
291
|
-
{"type": "blockEnd"}
|
|
292
|
-
]
|
|
293
|
-
""".trimIndent()
|
|
294
|
-
|
|
295
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
296
|
-
|
|
297
|
-
assertEquals(
|
|
298
|
-
"Hard break should render as newline. Got: '${result}'",
|
|
299
|
-
"Line 1\nLine 2", result.toString()
|
|
300
|
-
)
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
// ── Horizontal Rule (Void Block) ────────────────────────────────────
|
|
304
|
-
|
|
305
|
-
/** A horizontalRule should render as FFFC with a HorizontalRuleSpan. */
|
|
306
|
-
@Test
|
|
307
|
-
fun `render - horizontal rule`() {
|
|
308
|
-
val json = """
|
|
309
|
-
[
|
|
310
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
311
|
-
{"type": "textRun", "text": "Above", "marks": []},
|
|
312
|
-
{"type": "blockEnd"},
|
|
313
|
-
{"type": "voidBlock", "nodeType": "horizontalRule", "docPos": 7},
|
|
314
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
315
|
-
{"type": "textRun", "text": "Below", "marks": []},
|
|
316
|
-
{"type": "blockEnd"}
|
|
317
|
-
]
|
|
318
|
-
""".trimIndent()
|
|
319
|
-
|
|
320
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
321
|
-
|
|
322
|
-
val string = result.toString()
|
|
323
|
-
assertTrue(
|
|
324
|
-
"Horizontal rule should contain object replacement character. Got: '$string'",
|
|
325
|
-
string.contains("\uFFFC")
|
|
326
|
-
)
|
|
327
|
-
|
|
328
|
-
val hrSpans = result.getSpans(0, result.length, HorizontalRuleSpan::class.java)
|
|
329
|
-
assertTrue(
|
|
330
|
-
"Should have a HorizontalRuleSpan",
|
|
331
|
-
hrSpans.isNotEmpty()
|
|
332
|
-
)
|
|
333
|
-
|
|
334
|
-
val replacementMetrics = Paint.FontMetricsInt()
|
|
335
|
-
val hrOffset = string.indexOf('\uFFFC')
|
|
336
|
-
val hrSpan = hrSpans.single()
|
|
337
|
-
assertEquals(
|
|
338
|
-
"Horizontal rule should not reserve glyph width for the replacement character",
|
|
339
|
-
0,
|
|
340
|
-
hrSpan.getSize(TextPaint().apply { textSize = baseFontSize }, result, hrOffset, hrOffset + 1, replacementMetrics)
|
|
341
|
-
)
|
|
342
|
-
|
|
343
|
-
val layout = StaticLayout.Builder
|
|
344
|
-
.obtain(result, 0, result.length, TextPaint().apply { textSize = baseFontSize }, 240)
|
|
345
|
-
.build()
|
|
346
|
-
val hrLine = layout.getLineForOffset(hrOffset)
|
|
347
|
-
assertTrue(
|
|
348
|
-
"Horizontal rule line should not report a visible replacement glyph width; actual width=${layout.getLineWidth(hrLine)}",
|
|
349
|
-
layout.getLineWidth(hrLine) <= 1f
|
|
350
|
-
)
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
@Test
|
|
354
|
-
fun `render - image span honors preferred dimensions`() {
|
|
355
|
-
val json = """
|
|
356
|
-
[
|
|
357
|
-
{"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
|
|
358
|
-
"src": "https://example.com/cat.png",
|
|
359
|
-
"width": 140,
|
|
360
|
-
"height": 80
|
|
361
|
-
}}
|
|
362
|
-
]
|
|
363
|
-
""".trimIndent()
|
|
364
|
-
|
|
365
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, density = 1f)
|
|
366
|
-
|
|
367
|
-
assertTrue(
|
|
368
|
-
"Image should contain object replacement character. Got: '$result'",
|
|
369
|
-
result.toString().contains("\uFFFC")
|
|
370
|
-
)
|
|
371
|
-
|
|
372
|
-
val imageSpans = result.getSpans(0, result.length, BlockImageSpan::class.java)
|
|
373
|
-
assertEquals("Should have one BlockImageSpan", 1, imageSpans.size)
|
|
374
|
-
|
|
375
|
-
val (widthPx, heightPx) = imageSpans.single().currentSizePx()
|
|
376
|
-
assertEquals(140, widthPx)
|
|
377
|
-
assertEquals(80, heightPx)
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
@Test
|
|
381
|
-
fun `render - oversized preferred image dimensions scale to host width`() {
|
|
382
|
-
val hostView = TextView(org.robolectric.RuntimeEnvironment.getApplication()).apply {
|
|
383
|
-
measure(
|
|
384
|
-
View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY),
|
|
385
|
-
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
|
386
|
-
)
|
|
387
|
-
layout(0, 0, measuredWidth, measuredHeight)
|
|
388
|
-
}
|
|
389
|
-
val json = """
|
|
390
|
-
[
|
|
391
|
-
{"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
|
|
392
|
-
"src": "https://example.com/cat.png",
|
|
393
|
-
"width": 4000,
|
|
394
|
-
"height": 2000
|
|
395
|
-
}}
|
|
396
|
-
]
|
|
397
|
-
""".trimIndent()
|
|
398
|
-
|
|
399
|
-
val result = RenderBridge.buildSpannable(
|
|
400
|
-
json,
|
|
401
|
-
baseFontSize,
|
|
402
|
-
textColor,
|
|
403
|
-
density = 1f,
|
|
404
|
-
hostView = hostView
|
|
405
|
-
)
|
|
406
|
-
|
|
407
|
-
val imageSpan = result.getSpans(0, result.length, BlockImageSpan::class.java).single()
|
|
408
|
-
val (widthPx, heightPx) = imageSpan.currentSizePx()
|
|
409
|
-
assertTrue(widthPx <= hostView.width)
|
|
410
|
-
assertTrue(abs(heightPx - (widthPx / 2)) <= 1)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
@Test
|
|
414
|
-
fun `render - data url decoder handles expo style payloads`() {
|
|
415
|
-
val dataUrl =
|
|
416
|
-
"data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
|
|
417
|
-
|
|
418
|
-
val bitmap = RenderImageDecoder.decodeSource(dataUrl)
|
|
419
|
-
|
|
420
|
-
assertNotNull("Standard base64 image data URLs should decode", bitmap)
|
|
421
|
-
assertEquals(1, bitmap?.width)
|
|
422
|
-
assertEquals(1, bitmap?.height)
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
@Test
|
|
426
|
-
fun `render - data url decoder accepts url safe base64`() {
|
|
427
|
-
val standardDataUrl =
|
|
428
|
-
"data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
|
|
429
|
-
val bytes = RenderImageDecoder.decodeDataUrlBytes(standardDataUrl)
|
|
430
|
-
assertNotNull(bytes)
|
|
431
|
-
|
|
432
|
-
val urlSafePayload = Base64.encodeToString(
|
|
433
|
-
bytes,
|
|
434
|
-
Base64.URL_SAFE or Base64.NO_WRAP
|
|
435
|
-
)
|
|
436
|
-
val bitmap = RenderImageDecoder.decodeSource("data:image/gif;base64,$urlSafePayload")
|
|
437
|
-
|
|
438
|
-
assertNotNull("URL-safe base64 image data URLs should decode", bitmap)
|
|
439
|
-
assertEquals(1, bitmap?.width)
|
|
440
|
-
assertEquals(1, bitmap?.height)
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
@Test
|
|
444
|
-
fun `render - data url image span is ready on first render`() {
|
|
445
|
-
val dataUrl =
|
|
446
|
-
"data:image/gif;base64,R0lGODdhAQABAIAAAP///////ywAAAAAAQABAAACAkQBADs="
|
|
447
|
-
val hostView = TextView(org.robolectric.RuntimeEnvironment.getApplication()).apply {
|
|
448
|
-
measure(
|
|
449
|
-
View.MeasureSpec.makeMeasureSpec(320, View.MeasureSpec.EXACTLY),
|
|
450
|
-
View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED)
|
|
451
|
-
)
|
|
452
|
-
layout(0, 0, measuredWidth, measuredHeight)
|
|
453
|
-
}
|
|
454
|
-
val json = """
|
|
455
|
-
[
|
|
456
|
-
{"type": "voidBlock", "nodeType": "image", "docPos": 1, "attrs": {
|
|
457
|
-
"src": "$dataUrl"
|
|
458
|
-
}}
|
|
459
|
-
]
|
|
460
|
-
""".trimIndent()
|
|
461
|
-
|
|
462
|
-
val result = RenderBridge.buildSpannable(
|
|
463
|
-
json,
|
|
464
|
-
baseFontSize,
|
|
465
|
-
textColor,
|
|
466
|
-
density = 1f,
|
|
467
|
-
hostView = hostView
|
|
468
|
-
)
|
|
469
|
-
|
|
470
|
-
val imageSpan = result.getSpans(0, result.length, BlockImageSpan::class.java).single()
|
|
471
|
-
val (widthPx, heightPx) = imageSpan.currentSizePx()
|
|
472
|
-
assertEquals(1, widthPx)
|
|
473
|
-
assertEquals(1, heightPx)
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
@Test
|
|
477
|
-
fun `render - image loader deduplicates concurrent remote loads`() {
|
|
478
|
-
RenderImageLoader.resetForTesting()
|
|
479
|
-
val decodeCount = AtomicInteger(0)
|
|
480
|
-
val decodeStarted = CountDownLatch(1)
|
|
481
|
-
val releaseDecode = CountDownLatch(1)
|
|
482
|
-
val callbacks = CountDownLatch(2)
|
|
483
|
-
val bitmap = Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
|
484
|
-
val loaded = mutableListOf<Bitmap?>()
|
|
485
|
-
|
|
486
|
-
RenderImageLoader.decodeSourceOverride = {
|
|
487
|
-
decodeCount.incrementAndGet()
|
|
488
|
-
decodeStarted.countDown()
|
|
489
|
-
assertTrue(releaseDecode.await(2, TimeUnit.SECONDS))
|
|
490
|
-
bitmap
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
try {
|
|
494
|
-
RenderImageLoader.load("https://example.com/cat.png") {
|
|
495
|
-
synchronized(loaded) {
|
|
496
|
-
loaded += it
|
|
497
|
-
}
|
|
498
|
-
callbacks.countDown()
|
|
499
|
-
}
|
|
500
|
-
assertTrue(decodeStarted.await(2, TimeUnit.SECONDS))
|
|
501
|
-
RenderImageLoader.load("https://example.com/cat.png") {
|
|
502
|
-
synchronized(loaded) {
|
|
503
|
-
loaded += it
|
|
504
|
-
}
|
|
505
|
-
callbacks.countDown()
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
releaseDecode.countDown()
|
|
509
|
-
assertTrue(callbacks.await(2, TimeUnit.SECONDS))
|
|
510
|
-
assertEquals(1, decodeCount.get())
|
|
511
|
-
assertEquals(2, loaded.size)
|
|
512
|
-
assertTrue(loaded.all { loadedBitmap -> loadedBitmap === bitmap })
|
|
513
|
-
} finally {
|
|
514
|
-
releaseDecode.countDown()
|
|
515
|
-
RenderImageLoader.resetForTesting()
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
@Test
|
|
520
|
-
fun `render - large images are downsampled for decode`() {
|
|
521
|
-
assertEquals(1, RenderImageDecoder.calculateInSampleSize(width = 1024, height = 768))
|
|
522
|
-
assertEquals(2, RenderImageDecoder.calculateInSampleSize(width = 4096, height = 2048))
|
|
523
|
-
assertEquals(4, RenderImageDecoder.calculateInSampleSize(width = 8192, height = 4096))
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
// ── Multiple Paragraphs ─────────────────────────────────────────────
|
|
527
|
-
|
|
528
|
-
/** Two consecutive paragraphs should be separated by a newline. */
|
|
529
|
-
@Test
|
|
530
|
-
fun `render - multiple paragraphs`() {
|
|
531
|
-
val json = """
|
|
532
|
-
[
|
|
533
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
534
|
-
{"type": "textRun", "text": "First", "marks": []},
|
|
535
|
-
{"type": "blockEnd"},
|
|
536
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
537
|
-
{"type": "textRun", "text": "Second", "marks": []},
|
|
538
|
-
{"type": "blockEnd"}
|
|
539
|
-
]
|
|
540
|
-
""".trimIndent()
|
|
541
|
-
|
|
542
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
543
|
-
|
|
544
|
-
assertEquals(
|
|
545
|
-
"Two paragraphs should be separated by a newline",
|
|
546
|
-
"First\nSecond", result.toString()
|
|
547
|
-
)
|
|
548
|
-
}
|
|
549
|
-
|
|
550
|
-
// ── Mixed Marks in Same Paragraph ───────────────────────────────────
|
|
551
|
-
|
|
552
|
-
@Test
|
|
553
|
-
fun `render - mixed marks in paragraph`() {
|
|
554
|
-
val json = """
|
|
555
|
-
[
|
|
556
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
557
|
-
{"type": "textRun", "text": "normal ", "marks": []},
|
|
558
|
-
{"type": "textRun", "text": "bold", "marks": ["bold"]},
|
|
559
|
-
{"type": "textRun", "text": " end", "marks": []},
|
|
560
|
-
{"type": "blockEnd"}
|
|
561
|
-
]
|
|
562
|
-
""".trimIndent()
|
|
563
|
-
|
|
564
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
565
|
-
|
|
566
|
-
assertEquals("normal bold end", result.toString())
|
|
567
|
-
|
|
568
|
-
// Check "normal " (offset 0-7) has no bold StyleSpan.
|
|
569
|
-
val normalStyleSpans = result.getSpans(0, 7, StyleSpan::class.java)
|
|
570
|
-
val normalBold = normalStyleSpans.find { it.style == Typeface.BOLD }
|
|
571
|
-
// The bold span should NOT cover the "normal " range.
|
|
572
|
-
if (normalBold != null) {
|
|
573
|
-
val spanStart = result.getSpanStart(normalBold)
|
|
574
|
-
assertTrue(
|
|
575
|
-
"'normal' range should not overlap with bold span (span starts at $spanStart)",
|
|
576
|
-
spanStart >= 7
|
|
577
|
-
)
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
// Check "bold" (offset 7-11) has bold StyleSpan.
|
|
581
|
-
val boldStyleSpans = result.getSpans(7, 11, StyleSpan::class.java)
|
|
582
|
-
val boldSpan = boldStyleSpans.find { it.style == Typeface.BOLD }
|
|
583
|
-
assertNotNull("'bold' should have BOLD StyleSpan", boldSpan)
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
// ── Mark Aliases ────────────────────────────────────────────────────
|
|
587
|
-
|
|
588
|
-
@Test
|
|
589
|
-
fun `render - strong alias for bold`() {
|
|
590
|
-
val json = """
|
|
591
|
-
[
|
|
592
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
593
|
-
{"type": "textRun", "text": "strong", "marks": ["strong"]},
|
|
594
|
-
{"type": "blockEnd"}
|
|
595
|
-
]
|
|
596
|
-
""".trimIndent()
|
|
597
|
-
|
|
598
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
599
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
600
|
-
val boldSpan = styleSpans.find { it.style == Typeface.BOLD }
|
|
601
|
-
assertNotNull("'strong' should produce BOLD StyleSpan", boldSpan)
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
@Test
|
|
605
|
-
fun `render - em alias for italic`() {
|
|
606
|
-
val json = """
|
|
607
|
-
[
|
|
608
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
609
|
-
{"type": "textRun", "text": "emphasis", "marks": ["em"]},
|
|
610
|
-
{"type": "blockEnd"}
|
|
611
|
-
]
|
|
612
|
-
""".trimIndent()
|
|
613
|
-
|
|
614
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
615
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
616
|
-
val italicSpan = styleSpans.find { it.style == Typeface.ITALIC }
|
|
617
|
-
assertNotNull("'em' should produce ITALIC StyleSpan", italicSpan)
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
@Test
|
|
621
|
-
fun `render - strikethrough alias for strike`() {
|
|
622
|
-
val json = """
|
|
623
|
-
[
|
|
624
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
625
|
-
{"type": "textRun", "text": "deleted", "marks": ["strikethrough"]},
|
|
626
|
-
{"type": "blockEnd"}
|
|
627
|
-
]
|
|
628
|
-
""".trimIndent()
|
|
629
|
-
|
|
630
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
631
|
-
val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
|
|
632
|
-
assertTrue("'strikethrough' should produce StrikethroughSpan", strikeSpans.isNotEmpty())
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
// ── All Marks Combined ──────────────────────────────────────────────
|
|
636
|
-
|
|
637
|
-
@Test
|
|
638
|
-
fun `render - all marks combined`() {
|
|
639
|
-
val json = """
|
|
640
|
-
[
|
|
641
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
642
|
-
{"type": "textRun", "text": "everything", "marks": ["bold", "italic", "underline", "strike"]},
|
|
643
|
-
{"type": "blockEnd"}
|
|
644
|
-
]
|
|
645
|
-
""".trimIndent()
|
|
646
|
-
|
|
647
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
648
|
-
|
|
649
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
650
|
-
val boldItalicSpan = styleSpans.find { it.style == Typeface.BOLD_ITALIC }
|
|
651
|
-
assertNotNull("Should have BOLD_ITALIC", boldItalicSpan)
|
|
652
|
-
|
|
653
|
-
val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
|
|
654
|
-
assertTrue("Should have underline", underlineSpans.isNotEmpty())
|
|
655
|
-
|
|
656
|
-
val strikeSpans = result.getSpans(0, result.length, StrikethroughSpan::class.java)
|
|
657
|
-
assertTrue("Should have strikethrough", strikeSpans.isNotEmpty())
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// ── Ordered List ────────────────────────────────────────────────────
|
|
661
|
-
|
|
662
|
-
@Test
|
|
663
|
-
fun `render - ordered list item`() {
|
|
664
|
-
val json = """
|
|
665
|
-
[
|
|
666
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
667
|
-
"listContext": {"ordered": true, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
|
|
668
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
669
|
-
{"type": "textRun", "text": "First item", "marks": []},
|
|
670
|
-
{"type": "blockEnd"},
|
|
671
|
-
{"type": "blockEnd"},
|
|
672
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
673
|
-
"listContext": {"ordered": true, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
|
|
674
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
675
|
-
{"type": "textRun", "text": "Second item", "marks": []},
|
|
676
|
-
{"type": "blockEnd"},
|
|
677
|
-
{"type": "blockEnd"}
|
|
678
|
-
]
|
|
679
|
-
""".trimIndent()
|
|
680
|
-
|
|
681
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
682
|
-
val string = result.toString()
|
|
683
|
-
|
|
684
|
-
assertTrue(
|
|
685
|
-
"Ordered list should contain '1. ' marker. Got: '$string'",
|
|
686
|
-
string.contains("1. ")
|
|
687
|
-
)
|
|
688
|
-
assertTrue(
|
|
689
|
-
"Ordered list should contain '2. ' marker. Got: '$string'",
|
|
690
|
-
string.contains("2. ")
|
|
691
|
-
)
|
|
692
|
-
assertTrue("Should contain first item text", string.contains("First item"))
|
|
693
|
-
assertTrue("Should contain second item text", string.contains("Second item"))
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
// ── Unordered List ──────────────────────────────────────────────────
|
|
697
|
-
|
|
698
|
-
@Test
|
|
699
|
-
fun `render - unordered list item`() {
|
|
700
|
-
val json = """
|
|
701
|
-
[
|
|
702
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
703
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
704
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
705
|
-
{"type": "textRun", "text": "Bullet item", "marks": []},
|
|
706
|
-
{"type": "blockEnd"},
|
|
707
|
-
{"type": "blockEnd"}
|
|
708
|
-
]
|
|
709
|
-
""".trimIndent()
|
|
710
|
-
|
|
711
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
712
|
-
val string = result.toString()
|
|
713
|
-
|
|
714
|
-
assertTrue(
|
|
715
|
-
"Unordered list should contain bullet character. Got: '$string'",
|
|
716
|
-
string.contains("\u2022")
|
|
717
|
-
)
|
|
718
|
-
assertTrue("Should contain item text", string.contains("Bullet item"))
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
@Test
|
|
722
|
-
fun `render - unordered list marker keeps body text font metrics`() {
|
|
723
|
-
val json = """
|
|
724
|
-
[
|
|
725
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
726
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
727
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
728
|
-
{"type": "textRun", "text": "Bullet item", "marks": []},
|
|
729
|
-
{"type": "blockEnd"},
|
|
730
|
-
{"type": "blockEnd"}
|
|
731
|
-
]
|
|
732
|
-
""".trimIndent()
|
|
733
|
-
|
|
734
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
735
|
-
val markerSpans = result.getSpans(0, 1, AbsoluteSizeSpan::class.java)
|
|
736
|
-
val textSpans = result.getSpans(2, 3, AbsoluteSizeSpan::class.java)
|
|
737
|
-
|
|
738
|
-
assertTrue("Marker should have a size span", markerSpans.isNotEmpty())
|
|
739
|
-
assertTrue("Text should have a size span", textSpans.isNotEmpty())
|
|
740
|
-
assertEquals(textSpans[0].size, markerSpans[0].size)
|
|
741
|
-
assertEquals(baseFontSize.toInt(), textSpans[0].size)
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ── Opaque Atoms ────────────────────────────────────────────────────
|
|
745
|
-
|
|
746
|
-
@Test
|
|
747
|
-
fun `render - opaque inline atom`() {
|
|
748
|
-
val json = """
|
|
749
|
-
[
|
|
750
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
751
|
-
{"type": "textRun", "text": "before ", "marks": []},
|
|
752
|
-
{"type": "opaqueInlineAtom", "label": "widget", "docPos": 8},
|
|
753
|
-
{"type": "textRun", "text": " after", "marks": []},
|
|
754
|
-
{"type": "blockEnd"}
|
|
755
|
-
]
|
|
756
|
-
""".trimIndent()
|
|
757
|
-
|
|
758
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
759
|
-
|
|
760
|
-
assertTrue(
|
|
761
|
-
"Opaque inline atom should render as '[widget]'. Got: '${result}'",
|
|
762
|
-
result.toString().contains("[widget]")
|
|
763
|
-
)
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
@Test
|
|
767
|
-
fun `render - mention inline atom uses visible label and mention theme`() {
|
|
768
|
-
val json = """
|
|
769
|
-
[
|
|
770
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
771
|
-
{"type": "textRun", "text": "Hello ", "marks": []},
|
|
772
|
-
{"type": "opaqueInlineAtom", "nodeType": "mention", "label": "@Alice", "docPos": 7},
|
|
773
|
-
{"type": "textRun", "text": "!", "marks": []},
|
|
774
|
-
{"type": "blockEnd"}
|
|
775
|
-
]
|
|
776
|
-
""".trimIndent()
|
|
777
|
-
val theme = EditorTheme(
|
|
778
|
-
mentions = EditorMentionTheme(
|
|
779
|
-
textColor = 0xff112233.toInt(),
|
|
780
|
-
backgroundColor = 0xffddeeff.toInt(),
|
|
781
|
-
fontWeight = "bold"
|
|
782
|
-
)
|
|
783
|
-
)
|
|
784
|
-
|
|
785
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
|
|
786
|
-
|
|
787
|
-
assertTrue(
|
|
788
|
-
"Mention inline atom should render its visible label. Got: '${result}'",
|
|
789
|
-
result.toString().contains("@Alice")
|
|
790
|
-
)
|
|
791
|
-
assertTrue(
|
|
792
|
-
"Mention inline atom should not use generic opaque brackets. Got: '${result}'",
|
|
793
|
-
!result.toString().contains("[@Alice]")
|
|
794
|
-
)
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
@Test
|
|
798
|
-
fun `render - mention inline atom merges element mention theme override`() {
|
|
799
|
-
val json = """
|
|
800
|
-
[
|
|
801
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
802
|
-
{
|
|
803
|
-
"type": "opaqueInlineAtom",
|
|
804
|
-
"nodeType": "mention",
|
|
805
|
-
"label": "@Alice",
|
|
806
|
-
"docPos": 1,
|
|
807
|
-
"mentionTheme": {"textColor": "#445566"}
|
|
808
|
-
},
|
|
809
|
-
{"type": "blockEnd"}
|
|
810
|
-
]
|
|
811
|
-
""".trimIndent()
|
|
812
|
-
val theme = EditorTheme(
|
|
813
|
-
mentions = EditorMentionTheme(
|
|
814
|
-
textColor = 0xff112233.toInt(),
|
|
815
|
-
backgroundColor = 0xffddeeff.toInt(),
|
|
816
|
-
fontWeight = "bold"
|
|
817
|
-
)
|
|
818
|
-
)
|
|
819
|
-
|
|
820
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
|
|
821
|
-
|
|
822
|
-
assertEquals("@Alice", result.toString())
|
|
823
|
-
val foreground = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
824
|
-
.firstOrNull()
|
|
825
|
-
val background = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
|
|
826
|
-
.firstOrNull()
|
|
827
|
-
val boldSpan = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
828
|
-
.firstOrNull { it.style == Typeface.BOLD }
|
|
829
|
-
|
|
830
|
-
assertEquals(Color.parseColor("#445566"), foreground?.foregroundColor)
|
|
831
|
-
assertEquals(0xffddeeff.toInt(), background?.backgroundColor)
|
|
832
|
-
assertNotNull("Mention override should preserve global bold styling", boldSpan)
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
@Test
|
|
836
|
-
fun `render - opaque block atom`() {
|
|
837
|
-
val json = """
|
|
838
|
-
[
|
|
839
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
840
|
-
{"type": "textRun", "text": "Above", "marks": []},
|
|
841
|
-
{"type": "blockEnd"},
|
|
842
|
-
{"type": "opaqueBlockAtom", "label": "widgetBlock", "docPos": 7}
|
|
843
|
-
]
|
|
844
|
-
""".trimIndent()
|
|
845
|
-
|
|
846
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
847
|
-
|
|
848
|
-
assertTrue(
|
|
849
|
-
"Opaque block atom should render as '[widgetBlock]'. Got: '${result}'",
|
|
850
|
-
result.toString().contains("[widgetBlock]")
|
|
851
|
-
)
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// ── Invalid / Edge Cases ────────────────────────────────────────────
|
|
855
|
-
|
|
856
|
-
@Test
|
|
857
|
-
fun `render - invalid JSON`() {
|
|
858
|
-
val result = RenderBridge.buildSpannable("not valid json", baseFontSize, textColor)
|
|
859
|
-
assertEquals(
|
|
860
|
-
"Invalid JSON should produce empty SpannableStringBuilder",
|
|
861
|
-
"", result.toString()
|
|
862
|
-
)
|
|
863
|
-
}
|
|
864
|
-
|
|
865
|
-
@Test
|
|
866
|
-
fun `render - empty array`() {
|
|
867
|
-
val result = RenderBridge.buildSpannable("[]", baseFontSize, textColor)
|
|
868
|
-
assertEquals(
|
|
869
|
-
"Empty array should produce empty SpannableStringBuilder",
|
|
870
|
-
"", result.toString()
|
|
871
|
-
)
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
// ── List Marker Generation ──────────────────────────────────────────
|
|
875
|
-
|
|
876
|
-
@Test
|
|
877
|
-
fun `list marker - ordered`() {
|
|
878
|
-
val ctx = org.json.JSONObject("""{"ordered": true, "index": 3}""")
|
|
879
|
-
val marker = RenderBridge.listMarkerString(ctx)
|
|
880
|
-
assertEquals("Ordered list item 3 should produce '3. '", "3. ", marker)
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
@Test
|
|
884
|
-
fun `list marker - unordered`() {
|
|
885
|
-
val ctx = org.json.JSONObject("""{"ordered": false, "index": 1}""")
|
|
886
|
-
val marker = RenderBridge.listMarkerString(ctx)
|
|
887
|
-
assertEquals(
|
|
888
|
-
"Unordered list should produce bullet + space",
|
|
889
|
-
"\u2022 ", marker
|
|
890
|
-
)
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
@Test
|
|
894
|
-
fun `list marker - task unchecked`() {
|
|
895
|
-
val ctx = org.json.JSONObject("""{"kind": "task", "checked": false}""")
|
|
896
|
-
val marker = RenderBridge.listMarkerString(ctx)
|
|
897
|
-
assertEquals(
|
|
898
|
-
"Unchecked task list should produce ballot box + space",
|
|
899
|
-
"\u2610 ", marker
|
|
900
|
-
)
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
@Test
|
|
904
|
-
fun `render - task item includes checkbox marker and text`() {
|
|
905
|
-
val json = """
|
|
906
|
-
[
|
|
907
|
-
{"type": "blockStart", "nodeType": "taskItem", "depth": 1,
|
|
908
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true, "kind": "task", "checked": false}},
|
|
909
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
910
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
911
|
-
{"type": "blockEnd"},
|
|
912
|
-
{"type": "blockEnd"}
|
|
913
|
-
]
|
|
914
|
-
""".trimIndent()
|
|
915
|
-
|
|
916
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
917
|
-
assertEquals("\u2610 A", result.toString())
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
// ── Link Mark ───────────────────────────────────────────────────────
|
|
921
|
-
|
|
922
|
-
@Test
|
|
923
|
-
fun `render - link mark`() {
|
|
924
|
-
val json = """
|
|
925
|
-
[
|
|
926
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
927
|
-
{"type": "textRun", "text": "click here", "marks": [{"type":"link","href":"https://example.com"}]},
|
|
928
|
-
{"type": "blockEnd"}
|
|
929
|
-
]
|
|
930
|
-
""".trimIndent()
|
|
931
|
-
|
|
932
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
933
|
-
assertEquals("click here", result.toString())
|
|
934
|
-
val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
|
|
935
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
936
|
-
val urlSpans = result.getSpans(0, result.length, URLSpan::class.java)
|
|
937
|
-
val hrefAnnotations = result.getSpans(0, result.length, Annotation::class.java)
|
|
938
|
-
.filter { it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION }
|
|
939
|
-
|
|
940
|
-
assertTrue("Link text should be underlined", underlineSpans.isNotEmpty())
|
|
941
|
-
assertTrue(
|
|
942
|
-
"Link text should use link color",
|
|
943
|
-
colorSpans.any { it.foregroundColor == Color.parseColor("#1B73E8") }
|
|
944
|
-
)
|
|
945
|
-
assertTrue("Editor render should not expose clickable URL spans", urlSpans.isEmpty())
|
|
946
|
-
assertEquals(1, hrefAnnotations.size)
|
|
947
|
-
assertEquals("https://example.com", hrefAnnotations.first().value)
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
@Test
|
|
951
|
-
fun `render - themed link mark`() {
|
|
952
|
-
val json = """
|
|
953
|
-
[
|
|
954
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
955
|
-
{"type": "textRun", "text": "click here", "marks": [{"type":"link","href":"https://example.com"}]},
|
|
956
|
-
{"type": "blockEnd"}
|
|
957
|
-
]
|
|
958
|
-
""".trimIndent()
|
|
959
|
-
val theme = EditorTheme.fromJson(
|
|
960
|
-
"""
|
|
961
|
-
{
|
|
962
|
-
"links": {
|
|
963
|
-
"color": "#445566",
|
|
964
|
-
"backgroundColor": "#eef6ff",
|
|
965
|
-
"fontSize": 18,
|
|
966
|
-
"fontWeight": "700",
|
|
967
|
-
"fontStyle": "italic",
|
|
968
|
-
"underline": false
|
|
969
|
-
}
|
|
970
|
-
}
|
|
971
|
-
""".trimIndent()
|
|
972
|
-
)
|
|
973
|
-
|
|
974
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme)
|
|
975
|
-
assertEquals("click here", result.toString())
|
|
976
|
-
val underlineSpans = result.getSpans(0, result.length, UnderlineSpan::class.java)
|
|
977
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
978
|
-
val backgroundSpans = result.getSpans(0, result.length, BackgroundColorSpan::class.java)
|
|
979
|
-
val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
|
|
980
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
981
|
-
val hrefAnnotations = result.getSpans(0, result.length, Annotation::class.java)
|
|
982
|
-
.filter { it.key == RenderBridge.NATIVE_LINK_HREF_ANNOTATION }
|
|
983
|
-
|
|
984
|
-
assertTrue("Link underline should be disabled by theme", underlineSpans.isEmpty())
|
|
985
|
-
assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#445566") })
|
|
986
|
-
assertTrue(backgroundSpans.any { it.backgroundColor == Color.parseColor("#eef6ff") })
|
|
987
|
-
assertTrue(sizeSpans.any { it.size == 18 })
|
|
988
|
-
assertTrue(styleSpans.any { it.style == Typeface.BOLD_ITALIC })
|
|
989
|
-
assertEquals(1, hrefAnnotations.size)
|
|
990
|
-
assertEquals("https://example.com", hrefAnnotations.first().value)
|
|
991
|
-
}
|
|
992
|
-
|
|
993
|
-
// ── Depth Indentation ───────────────────────────────────────────────
|
|
994
|
-
|
|
995
|
-
@Test
|
|
996
|
-
fun `render - nested block indentation`() {
|
|
997
|
-
val json = """
|
|
998
|
-
[
|
|
999
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1000
|
-
{"type": "textRun", "text": "indented", "marks": []},
|
|
1001
|
-
{"type": "blockEnd"}
|
|
1002
|
-
]
|
|
1003
|
-
""".trimIndent()
|
|
1004
|
-
|
|
1005
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
1006
|
-
|
|
1007
|
-
assertEquals("indented", result.toString())
|
|
1008
|
-
|
|
1009
|
-
// Check for LeadingMarginSpan with expected indent.
|
|
1010
|
-
val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1011
|
-
assertTrue(
|
|
1012
|
-
"Depth 2 paragraph should have LeadingMarginSpan",
|
|
1013
|
-
marginSpans.isNotEmpty()
|
|
1014
|
-
)
|
|
1015
|
-
val expectedIndent = (2 * LayoutConstants.INDENT_PER_DEPTH).toInt()
|
|
1016
|
-
val actualIndent = marginSpans[0].getLeadingMargin(true)
|
|
1017
|
-
assertEquals(
|
|
1018
|
-
"Depth 2 paragraph should have ${expectedIndent}px indent",
|
|
1019
|
-
expectedIndent, actualIndent
|
|
1020
|
-
)
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
@Test
|
|
1024
|
-
fun `render - blockquote applies quote span and blockquote text style`() {
|
|
1025
|
-
val json = """
|
|
1026
|
-
[
|
|
1027
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1028
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1029
|
-
{"type": "textRun", "text": "Quoted", "marks": []},
|
|
1030
|
-
{"type": "blockEnd"},
|
|
1031
|
-
{"type": "blockEnd"}
|
|
1032
|
-
]
|
|
1033
|
-
""".trimIndent()
|
|
1034
|
-
val theme = EditorTheme.fromJson(
|
|
1035
|
-
"""
|
|
1036
|
-
{
|
|
1037
|
-
"blockquote": {
|
|
1038
|
-
"indent": 20,
|
|
1039
|
-
"borderColor": "#aa5500",
|
|
1040
|
-
"borderWidth": 4,
|
|
1041
|
-
"markerGap": 10,
|
|
1042
|
-
"text": { "color": "#334455" }
|
|
1043
|
-
}
|
|
1044
|
-
}
|
|
1045
|
-
""".trimIndent()
|
|
1046
|
-
)
|
|
1047
|
-
|
|
1048
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1049
|
-
|
|
1050
|
-
val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
|
|
1051
|
-
assertTrue("Quoted paragraph should receive BlockquoteSpan", quoteSpans.isNotEmpty())
|
|
1052
|
-
assertEquals(20, quoteSpans.single().getLeadingMargin(true))
|
|
1053
|
-
|
|
1054
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
1055
|
-
assertTrue(
|
|
1056
|
-
"Blockquote text style should override text color",
|
|
1057
|
-
colorSpans.any { it.foregroundColor == Color.parseColor("#334455") }
|
|
1058
|
-
)
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
@Test
|
|
1062
|
-
fun `render - blockquote does not insert extra leading paragraph break`() {
|
|
1063
|
-
val json = """
|
|
1064
|
-
[
|
|
1065
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1066
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1067
|
-
{"type": "textRun", "text": "Hello", "marks": []},
|
|
1068
|
-
{"type": "blockEnd"},
|
|
1069
|
-
{"type": "blockEnd"},
|
|
1070
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1071
|
-
{"type": "textRun", "text": "World", "marks": []},
|
|
1072
|
-
{"type": "blockEnd"}
|
|
1073
|
-
]
|
|
1074
|
-
""".trimIndent()
|
|
1075
|
-
|
|
1076
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1077
|
-
|
|
1078
|
-
assertEquals("Hello\nWorld", result.toString())
|
|
1079
|
-
}
|
|
1080
|
-
|
|
1081
|
-
@Test
|
|
1082
|
-
fun `render - consecutive blockquote paragraphs share one quote span`() {
|
|
1083
|
-
val json = """
|
|
1084
|
-
[
|
|
1085
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1086
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1087
|
-
{"type": "textRun", "text": "Hello", "marks": []},
|
|
1088
|
-
{"type": "blockEnd"},
|
|
1089
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1090
|
-
{"type": "textRun", "text": "World", "marks": []},
|
|
1091
|
-
{"type": "blockEnd"},
|
|
1092
|
-
{"type": "blockEnd"}
|
|
1093
|
-
]
|
|
1094
|
-
""".trimIndent()
|
|
1095
|
-
|
|
1096
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1097
|
-
|
|
1098
|
-
assertEquals("Hello\nWorld", result.toString())
|
|
1099
|
-
val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
|
|
1100
|
-
assertEquals(1, quoteSpans.size)
|
|
1101
|
-
}
|
|
1102
|
-
|
|
1103
|
-
@Test
|
|
1104
|
-
fun `render - trailing hard break in blockquote preserves quote span into following paragraph`() {
|
|
1105
|
-
val json = """
|
|
1106
|
-
[
|
|
1107
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1108
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1109
|
-
{"type": "textRun", "text": "Hello", "marks": []},
|
|
1110
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 6},
|
|
1111
|
-
{"type": "blockEnd"},
|
|
1112
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1113
|
-
{"type": "textRun", "text": "Tail", "marks": []},
|
|
1114
|
-
{"type": "blockEnd"},
|
|
1115
|
-
{"type": "blockEnd"}
|
|
1116
|
-
]
|
|
1117
|
-
""".trimIndent()
|
|
1118
|
-
|
|
1119
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1120
|
-
assertEquals("Hello\n\u200B\nTail", result.toString())
|
|
1121
|
-
val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
|
|
1122
|
-
assertEquals(1, quoteSpans.size)
|
|
1123
|
-
}
|
|
1124
|
-
|
|
1125
|
-
@Test
|
|
1126
|
-
fun `render - trailing hard break in blockquote appends synthetic placeholder with quote styling`() {
|
|
1127
|
-
val json = """
|
|
1128
|
-
[
|
|
1129
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1130
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1131
|
-
{"type": "textRun", "text": "A", "marks": []},
|
|
1132
|
-
{"type": "voidInline", "nodeType": "hardBreak", "docPos": 2},
|
|
1133
|
-
{"type": "blockEnd"},
|
|
1134
|
-
{"type": "blockEnd"}
|
|
1135
|
-
]
|
|
1136
|
-
""".trimIndent()
|
|
1137
|
-
|
|
1138
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1139
|
-
assertEquals("A\n\u200B", result.toString())
|
|
1140
|
-
|
|
1141
|
-
val placeholderIndex = result.length - 1
|
|
1142
|
-
val placeholderAnnotations =
|
|
1143
|
-
result.getSpans(placeholderIndex, placeholderIndex + 1, Annotation::class.java)
|
|
1144
|
-
assertTrue(
|
|
1145
|
-
"Trailing hard-break placeholder should be marked as synthetic",
|
|
1146
|
-
placeholderAnnotations.any { it.key == "nativeSyntheticPlaceholder" }
|
|
1147
|
-
)
|
|
1148
|
-
assertTrue(
|
|
1149
|
-
"Trailing hard-break placeholder should keep blockquote styling",
|
|
1150
|
-
placeholderAnnotations.any { it.key == "nativeBlockquote" }
|
|
1151
|
-
)
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
@Test
|
|
1155
|
-
fun `render - blockquote span ends before separator newline to plain paragraph`() {
|
|
1156
|
-
val json = """
|
|
1157
|
-
[
|
|
1158
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1159
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1160
|
-
{"type": "textRun", "text": "Hello", "marks": []},
|
|
1161
|
-
{"type": "blockEnd"},
|
|
1162
|
-
{"type": "blockEnd"},
|
|
1163
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1164
|
-
{"type": "textRun", "text": "World", "marks": []},
|
|
1165
|
-
{"type": "blockEnd"}
|
|
1166
|
-
]
|
|
1167
|
-
""".trimIndent()
|
|
1168
|
-
|
|
1169
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1170
|
-
assertEquals("Hello\nWorld", result.toString())
|
|
1171
|
-
|
|
1172
|
-
val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
|
|
1173
|
-
assertEquals(1, quoteSpans.size)
|
|
1174
|
-
assertEquals(
|
|
1175
|
-
"Blockquote span should end at the separator newline boundary to following plain content",
|
|
1176
|
-
6,
|
|
1177
|
-
result.getSpanEnd(quoteSpans.single())
|
|
1178
|
-
)
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
@Test
|
|
1182
|
-
fun `render - paragraph preceding blockquote does not inherit quote indent`() {
|
|
1183
|
-
val json = """
|
|
1184
|
-
[
|
|
1185
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1186
|
-
{"type": "textRun", "text": "Intro", "marks": []},
|
|
1187
|
-
{"type": "blockEnd"},
|
|
1188
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1189
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1190
|
-
{"type": "textRun", "text": "Quote", "marks": []},
|
|
1191
|
-
{"type": "blockEnd"},
|
|
1192
|
-
{"type": "blockEnd"}
|
|
1193
|
-
]
|
|
1194
|
-
""".trimIndent()
|
|
1195
|
-
|
|
1196
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1197
|
-
assertEquals("Intro\nQuote", result.toString())
|
|
1198
|
-
|
|
1199
|
-
val quoteSpans = result.getSpans(0, result.length, BlockquoteSpan::class.java)
|
|
1200
|
-
assertEquals(1, quoteSpans.size)
|
|
1201
|
-
assertEquals(
|
|
1202
|
-
"Blockquote span should start at the quoted paragraph, not the preceding plain paragraph",
|
|
1203
|
-
6,
|
|
1204
|
-
result.getSpanStart(quoteSpans.single())
|
|
1205
|
-
)
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
@Test
|
|
1209
|
-
fun `render - nested list item inside blockquote indents more than parent item`() {
|
|
1210
|
-
val json = """
|
|
1211
|
-
[
|
|
1212
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1213
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1, "listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
|
|
1214
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1215
|
-
{"type": "textRun", "text": "Parent", "marks": []},
|
|
1216
|
-
{"type": "blockEnd"},
|
|
1217
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 2, "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1218
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 3},
|
|
1219
|
-
{"type": "textRun", "text": "Child", "marks": []},
|
|
1220
|
-
{"type": "blockEnd"},
|
|
1221
|
-
{"type": "blockEnd"},
|
|
1222
|
-
{"type": "blockEnd"},
|
|
1223
|
-
{"type": "blockEnd"}
|
|
1224
|
-
]
|
|
1225
|
-
""".trimIndent()
|
|
1226
|
-
|
|
1227
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1228
|
-
|
|
1229
|
-
val parentIndex = result.indexOf("Parent")
|
|
1230
|
-
val childIndex = result.indexOf("Child")
|
|
1231
|
-
assertTrue(parentIndex >= 0)
|
|
1232
|
-
assertTrue(childIndex >= 0)
|
|
1233
|
-
val parentMargin = result
|
|
1234
|
-
.getSpans(parentIndex, parentIndex + 1, LeadingMarginSpan::class.java)
|
|
1235
|
-
.sumOf { it.getLeadingMargin(true) }
|
|
1236
|
-
val childMargin = result
|
|
1237
|
-
.getSpans(childIndex, childIndex + 1, LeadingMarginSpan::class.java)
|
|
1238
|
-
.sumOf { it.getLeadingMargin(true) }
|
|
1239
|
-
|
|
1240
|
-
assertTrue(
|
|
1241
|
-
"nested list item inside a blockquote should indent more than its parent item",
|
|
1242
|
-
childMargin > parentMargin
|
|
1243
|
-
)
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
@Test
|
|
1247
|
-
fun `render - first level list inside blockquote keeps extra list indent`() {
|
|
1248
|
-
val json = """
|
|
1249
|
-
[
|
|
1250
|
-
{"type": "blockStart", "nodeType": "blockquote", "depth": 0},
|
|
1251
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1, "listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1252
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1253
|
-
{"type": "textRun", "text": "Quoted item", "marks": []},
|
|
1254
|
-
{"type": "blockEnd"},
|
|
1255
|
-
{"type": "blockEnd"},
|
|
1256
|
-
{"type": "blockEnd"}
|
|
1257
|
-
]
|
|
1258
|
-
""".trimIndent()
|
|
1259
|
-
|
|
1260
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1261
|
-
val quotedIndex = result.indexOf("Quoted item")
|
|
1262
|
-
assertTrue(quotedIndex >= 0)
|
|
1263
|
-
|
|
1264
|
-
val quotedMargins = result.getSpans(
|
|
1265
|
-
quotedIndex,
|
|
1266
|
-
quotedIndex + 1,
|
|
1267
|
-
LeadingMarginSpan::class.java
|
|
1268
|
-
)
|
|
1269
|
-
val totalMargin = quotedMargins.sumOf { it.getLeadingMargin(true) }
|
|
1270
|
-
|
|
1271
|
-
assertEquals(
|
|
1272
|
-
"first-level list text inside a blockquote should keep its extra list indent",
|
|
1273
|
-
42,
|
|
1274
|
-
totalMargin
|
|
1275
|
-
)
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
@Test
|
|
1279
|
-
fun `blockquote span trims bottom on final quoted line before plain content`() {
|
|
1280
|
-
val text = SpannableStringBuilder("Quote\nPlain")
|
|
1281
|
-
text.setSpan(
|
|
1282
|
-
Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
|
|
1283
|
-
0,
|
|
1284
|
-
5,
|
|
1285
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1286
|
-
)
|
|
1287
|
-
text.setSpan(
|
|
1288
|
-
Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
|
|
1289
|
-
5,
|
|
1290
|
-
6,
|
|
1291
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1292
|
-
)
|
|
1293
|
-
|
|
1294
|
-
val paint = TextPaint().apply { textSize = 16f }
|
|
1295
|
-
val layout = StaticLayout.Builder
|
|
1296
|
-
.obtain(text, 0, text.length, paint, 200)
|
|
1297
|
-
.build()
|
|
1298
|
-
val span = BlockquoteSpan(
|
|
1299
|
-
baseIndentPx = 0,
|
|
1300
|
-
totalIndentPx = 18,
|
|
1301
|
-
stripeColor = Color.BLACK,
|
|
1302
|
-
stripeWidthPx = 3,
|
|
1303
|
-
gapWidthPx = 8
|
|
1304
|
-
)
|
|
1305
|
-
val line = 0
|
|
1306
|
-
val bottom = span.resolvedStripeBottom(
|
|
1307
|
-
text = text,
|
|
1308
|
-
start = layout.getLineStart(line),
|
|
1309
|
-
end = layout.getLineEnd(line),
|
|
1310
|
-
baseline = layout.getLineBaseline(line),
|
|
1311
|
-
bottom = layout.getLineBottom(line),
|
|
1312
|
-
layout = layout,
|
|
1313
|
-
paint = paint
|
|
1314
|
-
)
|
|
1315
|
-
|
|
1316
|
-
assertEquals(
|
|
1317
|
-
"Final quoted line before plain content should trim stripe to baseline + font descent",
|
|
1318
|
-
layout.getLineBaseline(line) + paint.fontMetrics.descent,
|
|
1319
|
-
bottom,
|
|
1320
|
-
0.01f
|
|
1321
|
-
)
|
|
1322
|
-
}
|
|
1323
|
-
|
|
1324
|
-
@Test
|
|
1325
|
-
fun `blockquote span ignores paragraph spacer when trimming final quoted line`() {
|
|
1326
|
-
val text = SpannableStringBuilder("Quote\nPlain")
|
|
1327
|
-
text.setSpan(
|
|
1328
|
-
Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
|
|
1329
|
-
0,
|
|
1330
|
-
5,
|
|
1331
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1332
|
-
)
|
|
1333
|
-
text.setSpan(
|
|
1334
|
-
Annotation(RenderBridge.NATIVE_BLOCKQUOTE_ANNOTATION, "1"),
|
|
1335
|
-
5,
|
|
1336
|
-
6,
|
|
1337
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1338
|
-
)
|
|
1339
|
-
text.setSpan(
|
|
1340
|
-
ParagraphSpacerSpan(
|
|
1341
|
-
spacingPx = 40,
|
|
1342
|
-
baseFontSize = 16,
|
|
1343
|
-
textColor = Color.BLACK
|
|
1344
|
-
),
|
|
1345
|
-
5,
|
|
1346
|
-
6,
|
|
1347
|
-
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
|
|
1348
|
-
)
|
|
1349
|
-
|
|
1350
|
-
val paint = TextPaint().apply { textSize = 16f }
|
|
1351
|
-
val layout = StaticLayout.Builder
|
|
1352
|
-
.obtain(text, 0, text.length, paint, 200)
|
|
1353
|
-
.build()
|
|
1354
|
-
val span = BlockquoteSpan(
|
|
1355
|
-
baseIndentPx = 0,
|
|
1356
|
-
totalIndentPx = 18,
|
|
1357
|
-
stripeColor = Color.BLACK,
|
|
1358
|
-
stripeWidthPx = 3,
|
|
1359
|
-
gapWidthPx = 8
|
|
1360
|
-
)
|
|
1361
|
-
val line = 0
|
|
1362
|
-
val bottom = span.resolvedStripeBottom(
|
|
1363
|
-
text = text,
|
|
1364
|
-
start = layout.getLineStart(line),
|
|
1365
|
-
end = layout.getLineEnd(line),
|
|
1366
|
-
baseline = layout.getLineBaseline(line),
|
|
1367
|
-
bottom = layout.getLineBottom(line),
|
|
1368
|
-
layout = layout,
|
|
1369
|
-
paint = paint
|
|
1370
|
-
)
|
|
1371
|
-
|
|
1372
|
-
assertTrue("Paragraph spacer should inflate line metrics in this reproduction", layout.getLineDescent(line) > paint.fontMetrics.descent)
|
|
1373
|
-
assertEquals(
|
|
1374
|
-
"Final quoted line should trim to font descent even when paragraph spacing inflates layout descent",
|
|
1375
|
-
layout.getLineBaseline(line) + paint.fontMetrics.descent,
|
|
1376
|
-
bottom,
|
|
1377
|
-
0.01f
|
|
1378
|
-
)
|
|
1379
|
-
}
|
|
1380
|
-
|
|
1381
|
-
@Test
|
|
1382
|
-
fun `render - theme overrides paragraph typography`() {
|
|
1383
|
-
val json = """
|
|
1384
|
-
[
|
|
1385
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1386
|
-
{"type": "textRun", "text": "Styled", "marks": []},
|
|
1387
|
-
{"type": "blockEnd"}
|
|
1388
|
-
]
|
|
1389
|
-
""".trimIndent()
|
|
1390
|
-
val theme = EditorTheme.fromJson(
|
|
1391
|
-
"""
|
|
1392
|
-
{
|
|
1393
|
-
"text": { "fontSize": 18, "color": "#112233" },
|
|
1394
|
-
"paragraph": { "lineHeight": 28, "spacingAfter": 14 }
|
|
1395
|
-
}
|
|
1396
|
-
""".trimIndent()
|
|
1397
|
-
)
|
|
1398
|
-
|
|
1399
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1400
|
-
|
|
1401
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
1402
|
-
val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
|
|
1403
|
-
val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
|
|
1404
|
-
|
|
1405
|
-
assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#112233") })
|
|
1406
|
-
assertTrue(sizeSpans.any { it.size == 18 })
|
|
1407
|
-
assertTrue(lineHeightSpans.isNotEmpty())
|
|
1408
|
-
}
|
|
1409
|
-
|
|
1410
|
-
@Test
|
|
1411
|
-
fun `render - theme overrides specific heading level typography`() {
|
|
1412
|
-
val json = """
|
|
1413
|
-
[
|
|
1414
|
-
{"type": "blockStart", "nodeType": "h2", "depth": 0},
|
|
1415
|
-
{"type": "textRun", "text": "Styled heading", "marks": []},
|
|
1416
|
-
{"type": "blockEnd"}
|
|
1417
|
-
]
|
|
1418
|
-
""".trimIndent()
|
|
1419
|
-
val theme = EditorTheme.fromJson(
|
|
1420
|
-
"""
|
|
1421
|
-
{
|
|
1422
|
-
"text": { "fontSize": 16, "color": "#112233" },
|
|
1423
|
-
"headings": {
|
|
1424
|
-
"h2": { "fontSize": 28, "fontWeight": "700", "color": "#445566", "lineHeight": 34, "spacingAfter": 12 },
|
|
1425
|
-
"h4": { "fontSize": 18, "color": "#AA5500" }
|
|
1426
|
-
}
|
|
1427
|
-
}
|
|
1428
|
-
""".trimIndent()
|
|
1429
|
-
)
|
|
1430
|
-
|
|
1431
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1432
|
-
|
|
1433
|
-
val colorSpans = result.getSpans(0, result.length, ForegroundColorSpan::class.java)
|
|
1434
|
-
val sizeSpans = result.getSpans(0, result.length, AbsoluteSizeSpan::class.java)
|
|
1435
|
-
val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
|
|
1436
|
-
val styleSpans = result.getSpans(0, result.length, StyleSpan::class.java)
|
|
1437
|
-
|
|
1438
|
-
assertTrue(colorSpans.any { it.foregroundColor == Color.parseColor("#445566") })
|
|
1439
|
-
assertTrue(sizeSpans.any { it.size == 28 })
|
|
1440
|
-
assertTrue(lineHeightSpans.isNotEmpty())
|
|
1441
|
-
assertTrue(styleSpans.any { it.style == Typeface.BOLD })
|
|
1442
|
-
}
|
|
1443
|
-
|
|
1444
|
-
@Test
|
|
1445
|
-
fun `render - paragraph does not inherit text line height when paragraph line height is unset`() {
|
|
1446
|
-
val json = """
|
|
1447
|
-
[
|
|
1448
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1449
|
-
{"type": "textRun", "text": "Styled", "marks": []},
|
|
1450
|
-
{"type": "blockEnd"}
|
|
1451
|
-
]
|
|
1452
|
-
""".trimIndent()
|
|
1453
|
-
val theme = EditorTheme.fromJson(
|
|
1454
|
-
"""
|
|
1455
|
-
{
|
|
1456
|
-
"text": { "fontSize": 18, "lineHeight": 28 },
|
|
1457
|
-
"paragraph": { "spacingAfter": 14 }
|
|
1458
|
-
}
|
|
1459
|
-
""".trimIndent()
|
|
1460
|
-
)
|
|
1461
|
-
|
|
1462
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1463
|
-
val lineHeightSpans = result.getSpans(0, result.length, FixedLineHeightSpan::class.java)
|
|
1464
|
-
|
|
1465
|
-
assertTrue(lineHeightSpans.isEmpty())
|
|
1466
|
-
}
|
|
1467
|
-
|
|
1468
|
-
@Test
|
|
1469
|
-
fun `render - no spacer span when spacingAfter is unset`() {
|
|
1470
|
-
val json = """
|
|
1471
|
-
[
|
|
1472
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1473
|
-
{"type": "textRun", "text": "First paragraph", "marks": []},
|
|
1474
|
-
{"type": "blockEnd"},
|
|
1475
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1476
|
-
{"type": "textRun", "text": "Second paragraph", "marks": []},
|
|
1477
|
-
{"type": "blockEnd"}
|
|
1478
|
-
]
|
|
1479
|
-
""".trimIndent()
|
|
1480
|
-
|
|
1481
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor)
|
|
1482
|
-
val spacerSpans = result.getSpans(0, result.length, ParagraphSpacerSpan::class.java)
|
|
1483
|
-
|
|
1484
|
-
assertTrue("No spacer spans when theme has no spacingAfter", spacerSpans.isEmpty())
|
|
1485
|
-
}
|
|
1486
|
-
|
|
1487
|
-
@Test
|
|
1488
|
-
fun `render - paragraph spacing applied to inter-block newline via ParagraphSpacerSpan`() {
|
|
1489
|
-
val json = """
|
|
1490
|
-
[
|
|
1491
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1492
|
-
{"type": "textRun", "text": "First paragraph", "marks": []},
|
|
1493
|
-
{"type": "blockEnd"},
|
|
1494
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 0},
|
|
1495
|
-
{"type": "textRun", "text": "Second paragraph", "marks": []},
|
|
1496
|
-
{"type": "blockEnd"}
|
|
1497
|
-
]
|
|
1498
|
-
""".trimIndent()
|
|
1499
|
-
val theme = EditorTheme.fromJson(
|
|
1500
|
-
"""
|
|
1501
|
-
{
|
|
1502
|
-
"paragraph": { "spacingAfter": 14 }
|
|
1503
|
-
}
|
|
1504
|
-
""".trimIndent()
|
|
1505
|
-
)
|
|
1506
|
-
|
|
1507
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1508
|
-
val separatorIndex = result.toString().indexOf('\n')
|
|
1509
|
-
|
|
1510
|
-
// Spacer span should be on the inter-block newline character.
|
|
1511
|
-
val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
|
|
1512
|
-
assertTrue("Inter-block newline should have a ParagraphSpacerSpan", spacerSpans.isNotEmpty())
|
|
1513
|
-
|
|
1514
|
-
// No spacer span on paragraph content.
|
|
1515
|
-
val firstParaSpans = result.getSpans(0, separatorIndex, ParagraphSpacerSpan::class.java)
|
|
1516
|
-
assertTrue("Paragraph content should not have spacer spans", firstParaSpans.isEmpty())
|
|
1517
|
-
|
|
1518
|
-
val secondParaSpans = result.getSpans(separatorIndex + 1, result.length, ParagraphSpacerSpan::class.java)
|
|
1519
|
-
assertTrue("Second paragraph content should not have spacer spans", secondParaSpans.isEmpty())
|
|
1520
|
-
}
|
|
1521
|
-
|
|
1522
|
-
@Test
|
|
1523
|
-
fun `render - list item spacing applies to sibling list item separator`() {
|
|
1524
|
-
val json = """
|
|
1525
|
-
[
|
|
1526
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1527
|
-
"listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
|
|
1528
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1529
|
-
{"type": "textRun", "text": "First item", "marks": []},
|
|
1530
|
-
{"type": "blockEnd"},
|
|
1531
|
-
{"type": "blockEnd"},
|
|
1532
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1533
|
-
"listContext": {"ordered": false, "index": 2, "total": 2, "start": 1, "isFirst": false, "isLast": true}},
|
|
1534
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1535
|
-
{"type": "textRun", "text": "Second item", "marks": []},
|
|
1536
|
-
{"type": "blockEnd"},
|
|
1537
|
-
{"type": "blockEnd"}
|
|
1538
|
-
]
|
|
1539
|
-
""".trimIndent()
|
|
1540
|
-
val theme = EditorTheme.fromJson(
|
|
1541
|
-
"""
|
|
1542
|
-
{
|
|
1543
|
-
"list": { "itemSpacing": 14 }
|
|
1544
|
-
}
|
|
1545
|
-
""".trimIndent()
|
|
1546
|
-
)
|
|
1547
|
-
|
|
1548
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1549
|
-
val separatorIndex = result.toString().indexOf('\n')
|
|
1550
|
-
|
|
1551
|
-
assertTrue("Expected a separator newline between list items", separatorIndex >= 0)
|
|
1552
|
-
val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
|
|
1553
|
-
assertTrue("List item separator should receive ParagraphSpacerSpan from itemSpacing", spacerSpans.isNotEmpty())
|
|
1554
|
-
}
|
|
1555
|
-
|
|
1556
|
-
@Test
|
|
1557
|
-
fun `render - nested first list item does not inherit paragraph spacing when itemSpacing is zero`() {
|
|
1558
|
-
val json = """
|
|
1559
|
-
[
|
|
1560
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1561
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1562
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1563
|
-
{"type": "textRun", "text": "Parent item", "marks": []},
|
|
1564
|
-
{"type": "blockEnd"},
|
|
1565
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1566
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1567
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1568
|
-
{"type": "textRun", "text": "Nested item", "marks": []},
|
|
1569
|
-
{"type": "blockEnd"},
|
|
1570
|
-
{"type": "blockEnd"},
|
|
1571
|
-
{"type": "blockEnd"}
|
|
1572
|
-
]
|
|
1573
|
-
""".trimIndent()
|
|
1574
|
-
val theme = EditorTheme.fromJson(
|
|
1575
|
-
"""
|
|
1576
|
-
{
|
|
1577
|
-
"paragraph": { "spacingAfter": 14 },
|
|
1578
|
-
"list": { "itemSpacing": 0 }
|
|
1579
|
-
}
|
|
1580
|
-
""".trimIndent()
|
|
1581
|
-
)
|
|
1582
|
-
|
|
1583
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1584
|
-
val separatorIndex = result.toString().indexOf('\n')
|
|
1585
|
-
|
|
1586
|
-
assertTrue("Expected a separator newline before nested list item", separatorIndex >= 0)
|
|
1587
|
-
val spacerSpans = result.getSpans(separatorIndex, separatorIndex + 1, ParagraphSpacerSpan::class.java)
|
|
1588
|
-
assertTrue(
|
|
1589
|
-
"Nested list separator should not keep parent paragraph spacing when itemSpacing is zero",
|
|
1590
|
-
spacerSpans.isEmpty()
|
|
1591
|
-
)
|
|
1592
|
-
}
|
|
1593
|
-
|
|
1594
|
-
@Test
|
|
1595
|
-
fun `render - theme overrides list indentation`() {
|
|
1596
|
-
val json = """
|
|
1597
|
-
[
|
|
1598
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1599
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1600
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1601
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
1602
|
-
{"type": "blockEnd"},
|
|
1603
|
-
{"type": "blockEnd"}
|
|
1604
|
-
]
|
|
1605
|
-
""".trimIndent()
|
|
1606
|
-
val theme = EditorTheme.fromJson(
|
|
1607
|
-
"""
|
|
1608
|
-
{
|
|
1609
|
-
"list": { "indent": 32, "markerScale": 1.5, "markerColor": "#334455" }
|
|
1610
|
-
}
|
|
1611
|
-
""".trimIndent()
|
|
1612
|
-
)
|
|
1613
|
-
|
|
1614
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1615
|
-
val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1616
|
-
assertTrue(marginSpans.isNotEmpty())
|
|
1617
|
-
assertEquals(64, marginSpans[0].getLeadingMargin(true))
|
|
1618
|
-
}
|
|
1619
|
-
|
|
1620
|
-
@Test
|
|
1621
|
-
fun `render - list base indent multiplier can collapse top-level list indent`() {
|
|
1622
|
-
val json = """
|
|
1623
|
-
[
|
|
1624
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1625
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1626
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1627
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
1628
|
-
{"type": "blockEnd"},
|
|
1629
|
-
{"type": "blockEnd"}
|
|
1630
|
-
]
|
|
1631
|
-
""".trimIndent()
|
|
1632
|
-
val theme = EditorTheme.fromJson(
|
|
1633
|
-
"""
|
|
1634
|
-
{
|
|
1635
|
-
"list": { "indent": 32, "baseIndentMultiplier": 0 }
|
|
1636
|
-
}
|
|
1637
|
-
""".trimIndent()
|
|
1638
|
-
)
|
|
1639
|
-
|
|
1640
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1641
|
-
val marginSpan = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java).single()
|
|
1642
|
-
|
|
1643
|
-
assertEquals(0, marginSpan.getLeadingMargin(true))
|
|
1644
|
-
assertEquals(LayoutConstants.LIST_MARKER_WIDTH.toInt(), marginSpan.getLeadingMargin(false))
|
|
1645
|
-
}
|
|
1646
|
-
|
|
1647
|
-
@Test
|
|
1648
|
-
fun `render - unordered marker scale does not widen list text gutter`() {
|
|
1649
|
-
val json = """
|
|
1650
|
-
[
|
|
1651
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1652
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1653
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1654
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
1655
|
-
{"type": "blockEnd"},
|
|
1656
|
-
{"type": "blockEnd"}
|
|
1657
|
-
]
|
|
1658
|
-
""".trimIndent()
|
|
1659
|
-
val baseTheme = EditorTheme.fromJson(
|
|
1660
|
-
"""
|
|
1661
|
-
{
|
|
1662
|
-
"text": { "fontSize": 40 },
|
|
1663
|
-
"list": { "indent": 32, "markerScale": 1 }
|
|
1664
|
-
}
|
|
1665
|
-
""".trimIndent()
|
|
1666
|
-
)
|
|
1667
|
-
val scaledTheme = EditorTheme.fromJson(
|
|
1668
|
-
"""
|
|
1669
|
-
{
|
|
1670
|
-
"text": { "fontSize": 40 },
|
|
1671
|
-
"list": { "indent": 32, "markerScale": 2 }
|
|
1672
|
-
}
|
|
1673
|
-
""".trimIndent()
|
|
1674
|
-
)
|
|
1675
|
-
|
|
1676
|
-
val baseResult = RenderBridge.buildSpannable(json, baseFontSize, textColor, baseTheme, 1f)
|
|
1677
|
-
val scaledResult = RenderBridge.buildSpannable(json, baseFontSize, textColor, scaledTheme, 1f)
|
|
1678
|
-
val baseMargin = baseResult.getSpans(0, baseResult.length, LeadingMarginSpan.Standard::class.java).single()
|
|
1679
|
-
val scaledMargin = scaledResult.getSpans(0, scaledResult.length, LeadingMarginSpan.Standard::class.java).single()
|
|
1680
|
-
|
|
1681
|
-
assertEquals(baseMargin.getLeadingMargin(false), scaledMargin.getLeadingMargin(false))
|
|
1682
|
-
}
|
|
1683
|
-
|
|
1684
|
-
@Test
|
|
1685
|
-
fun `render - themed list marker receives line height span`() {
|
|
1686
|
-
val json = """
|
|
1687
|
-
[
|
|
1688
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1689
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1690
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1691
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
1692
|
-
{"type": "blockEnd"},
|
|
1693
|
-
{"type": "blockEnd"}
|
|
1694
|
-
]
|
|
1695
|
-
""".trimIndent()
|
|
1696
|
-
val theme = EditorTheme.fromJson(
|
|
1697
|
-
"""
|
|
1698
|
-
{
|
|
1699
|
-
"paragraph": { "lineHeight": 28 }
|
|
1700
|
-
}
|
|
1701
|
-
""".trimIndent()
|
|
1702
|
-
)
|
|
1703
|
-
|
|
1704
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, theme, 1f)
|
|
1705
|
-
val markerLineHeightSpans = result.getSpans(0, 1, FixedLineHeightSpan::class.java)
|
|
1706
|
-
assertTrue(markerLineHeightSpans.isNotEmpty())
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
@Test
|
|
1710
|
-
fun `render - indented list item has larger leading margin than non-indented`() {
|
|
1711
|
-
val json = """
|
|
1712
|
-
[
|
|
1713
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1714
|
-
"listContext": {"ordered": false, "index": 1, "total": 2, "start": 1, "isFirst": true, "isLast": false}},
|
|
1715
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1716
|
-
{"type": "textRun", "text": "First item", "marks": []},
|
|
1717
|
-
{"type": "blockEnd"},
|
|
1718
|
-
{"type": "blockEnd"},
|
|
1719
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1720
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1721
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1722
|
-
{"type": "textRun", "text": "Indented item", "marks": []},
|
|
1723
|
-
{"type": "blockEnd"},
|
|
1724
|
-
{"type": "blockEnd"}
|
|
1725
|
-
]
|
|
1726
|
-
""".trimIndent()
|
|
1727
|
-
|
|
1728
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1729
|
-
val text = result.toString()
|
|
1730
|
-
val newlineIndex = text.indexOf('\n')
|
|
1731
|
-
|
|
1732
|
-
val allMargins = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1733
|
-
assertTrue("List items should have LeadingMarginSpans", allMargins.isNotEmpty())
|
|
1734
|
-
|
|
1735
|
-
val firstItemMargin = allMargins.firstOrNull { result.getSpanStart(it) == 0 }
|
|
1736
|
-
assertNotNull("First item should have a paragraph-scoped LeadingMarginSpan", firstItemMargin)
|
|
1737
|
-
|
|
1738
|
-
val indentedItemMargin = allMargins.firstOrNull { result.getSpanStart(it) > newlineIndex }
|
|
1739
|
-
assertNotNull("Indented item should have its own paragraph-scoped LeadingMarginSpan", indentedItemMargin)
|
|
1740
|
-
|
|
1741
|
-
val firstIndent = firstItemMargin!!.getLeadingMargin(true)
|
|
1742
|
-
val indentedIndent = indentedItemMargin!!.getLeadingMargin(true)
|
|
1743
|
-
|
|
1744
|
-
assertTrue(
|
|
1745
|
-
"Indented item margin ($indentedIndent) should be greater than first item margin ($firstIndent)",
|
|
1746
|
-
indentedIndent > firstIndent
|
|
1747
|
-
)
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
@Test
|
|
1751
|
-
fun `render - list indentation uses paragraph span flags`() {
|
|
1752
|
-
val json = """
|
|
1753
|
-
[
|
|
1754
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1755
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1756
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1757
|
-
{"type": "textRun", "text": "Indented item", "marks": []},
|
|
1758
|
-
{"type": "blockEnd"},
|
|
1759
|
-
{"type": "blockEnd"}
|
|
1760
|
-
]
|
|
1761
|
-
""".trimIndent()
|
|
1762
|
-
|
|
1763
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1764
|
-
val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1765
|
-
|
|
1766
|
-
assertTrue("Indented list item should have a LeadingMarginSpan", marginSpans.isNotEmpty())
|
|
1767
|
-
assertEquals(
|
|
1768
|
-
"LeadingMarginSpan should be paragraph-scoped",
|
|
1769
|
-
Spanned.SPAN_PARAGRAPH,
|
|
1770
|
-
result.getSpanFlags(marginSpans[0])
|
|
1771
|
-
)
|
|
1772
|
-
assertEquals(
|
|
1773
|
-
"LeadingMarginSpan should start at the list paragraph start, including the marker",
|
|
1774
|
-
0,
|
|
1775
|
-
result.getSpanStart(marginSpans[0])
|
|
1776
|
-
)
|
|
1777
|
-
}
|
|
1778
|
-
|
|
1779
|
-
@Test
|
|
1780
|
-
fun `render - list paragraph uses a single leading margin span across multiple text runs`() {
|
|
1781
|
-
val json = """
|
|
1782
|
-
[
|
|
1783
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1784
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1785
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1786
|
-
{"type": "textRun", "text": "Alpha", "marks": []},
|
|
1787
|
-
{"type": "textRun", "text": " Beta", "marks": ["bold"]},
|
|
1788
|
-
{"type": "blockEnd"},
|
|
1789
|
-
{"type": "blockEnd"}
|
|
1790
|
-
]
|
|
1791
|
-
""".trimIndent()
|
|
1792
|
-
|
|
1793
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1794
|
-
val marginSpans = result.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1795
|
-
val paragraphSpans = marginSpans.filter { result.getSpanStart(it) == 0 }
|
|
1796
|
-
|
|
1797
|
-
assertEquals("Paragraph should have exactly one LeadingMarginSpan", 1, paragraphSpans.size)
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
@Test
|
|
1801
|
-
fun `layout - sibling list items at same depth share the same visual left offset`() {
|
|
1802
|
-
val json = """
|
|
1803
|
-
[
|
|
1804
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1805
|
-
"listContext": {"ordered": false, "index": 1, "total": 3, "start": 1, "isFirst": true, "isLast": false}},
|
|
1806
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1807
|
-
{"type": "textRun", "text": "First", "marks": []},
|
|
1808
|
-
{"type": "blockEnd"},
|
|
1809
|
-
{"type": "blockEnd"},
|
|
1810
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1811
|
-
"listContext": {"ordered": false, "index": 2, "total": 3, "start": 1, "isFirst": false, "isLast": false}},
|
|
1812
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1813
|
-
{"type": "textRun", "text": "Second", "marks": []},
|
|
1814
|
-
{"type": "blockEnd"},
|
|
1815
|
-
{"type": "blockEnd"},
|
|
1816
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1817
|
-
"listContext": {"ordered": false, "index": 3, "total": 3, "start": 1, "isFirst": false, "isLast": true}},
|
|
1818
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1819
|
-
{"type": "textRun", "text": "Third", "marks": []},
|
|
1820
|
-
{"type": "blockEnd"},
|
|
1821
|
-
{"type": "blockEnd"}
|
|
1822
|
-
]
|
|
1823
|
-
""".trimIndent()
|
|
1824
|
-
|
|
1825
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1826
|
-
val paint = TextPaint().apply { textSize = baseFontSize }
|
|
1827
|
-
val layout = StaticLayout.Builder
|
|
1828
|
-
.obtain(result, 0, result.length, paint, 400)
|
|
1829
|
-
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
|
1830
|
-
.setIncludePad(false)
|
|
1831
|
-
.build()
|
|
1832
|
-
|
|
1833
|
-
assertEquals("Expected one line per list item", 3, layout.lineCount)
|
|
1834
|
-
|
|
1835
|
-
val firstLeft = layout.getLineLeft(0)
|
|
1836
|
-
val secondLeft = layout.getLineLeft(1)
|
|
1837
|
-
val thirdLeft = layout.getLineLeft(2)
|
|
1838
|
-
|
|
1839
|
-
assertEquals("First and second sibling items should align", firstLeft, secondLeft, 0.01f)
|
|
1840
|
-
assertEquals("Second and third sibling items should align", secondLeft, thirdLeft, 0.01f)
|
|
1841
|
-
}
|
|
1842
|
-
|
|
1843
|
-
@Test
|
|
1844
|
-
fun `layout - nested middle item does not shift trailing outer sibling`() {
|
|
1845
|
-
val json = """
|
|
1846
|
-
[
|
|
1847
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1848
|
-
"listContext": {"ordered": false, "index": 1, "total": 3, "start": 1, "isFirst": true, "isLast": false}},
|
|
1849
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1850
|
-
{"type": "textRun", "text": "First", "marks": []},
|
|
1851
|
-
{"type": "blockEnd"},
|
|
1852
|
-
{"type": "blockEnd"},
|
|
1853
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1854
|
-
"listContext": {"ordered": false, "index": 2, "total": 3, "start": 1, "isFirst": false, "isLast": false}},
|
|
1855
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1856
|
-
{"type": "textRun", "text": "Second", "marks": []},
|
|
1857
|
-
{"type": "blockEnd"},
|
|
1858
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 1,
|
|
1859
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1860
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 2},
|
|
1861
|
-
{"type": "textRun", "text": "Nested", "marks": []},
|
|
1862
|
-
{"type": "blockEnd"},
|
|
1863
|
-
{"type": "blockEnd"},
|
|
1864
|
-
{"type": "blockEnd"},
|
|
1865
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1866
|
-
"listContext": {"ordered": false, "index": 3, "total": 3, "start": 1, "isFirst": false, "isLast": true}},
|
|
1867
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1868
|
-
{"type": "textRun", "text": "Third", "marks": []},
|
|
1869
|
-
{"type": "blockEnd"},
|
|
1870
|
-
{"type": "blockEnd"}
|
|
1871
|
-
]
|
|
1872
|
-
""".trimIndent()
|
|
1873
|
-
|
|
1874
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1875
|
-
val paint = TextPaint().apply { textSize = baseFontSize }
|
|
1876
|
-
val layout = StaticLayout.Builder
|
|
1877
|
-
.obtain(result, 0, result.length, paint, 400)
|
|
1878
|
-
.setAlignment(Layout.Alignment.ALIGN_NORMAL)
|
|
1879
|
-
.setIncludePad(false)
|
|
1880
|
-
.build()
|
|
1881
|
-
|
|
1882
|
-
val text = result.toString()
|
|
1883
|
-
val firstOffset = text.indexOf("First")
|
|
1884
|
-
val secondOffset = text.indexOf("Second")
|
|
1885
|
-
val nestedOffset = text.indexOf("Nested")
|
|
1886
|
-
val thirdOffset = text.indexOf("Third")
|
|
1887
|
-
|
|
1888
|
-
val firstLeft = layout.getPrimaryHorizontal(firstOffset)
|
|
1889
|
-
val secondLeft = layout.getPrimaryHorizontal(secondOffset)
|
|
1890
|
-
val nestedLeft = layout.getPrimaryHorizontal(nestedOffset)
|
|
1891
|
-
val thirdLeft = layout.getPrimaryHorizontal(thirdOffset)
|
|
1892
|
-
val marginSummary = result
|
|
1893
|
-
.getSpans(0, result.length, LeadingMarginSpan.Standard::class.java)
|
|
1894
|
-
.joinToString(" | ") {
|
|
1895
|
-
"start=${result.getSpanStart(it)} end=${result.getSpanEnd(it)} margin=${it.getLeadingMargin(true)}"
|
|
1896
|
-
}
|
|
1897
|
-
|
|
1898
|
-
val outerAligned = kotlin.math.abs(firstLeft - secondLeft) <= 0.01f
|
|
1899
|
-
val nestedIndented = nestedLeft > secondLeft
|
|
1900
|
-
val trailingAligned = kotlin.math.abs(firstLeft - thirdLeft) <= 0.01f
|
|
1901
|
-
|
|
1902
|
-
if (!outerAligned || !nestedIndented || !trailingAligned) {
|
|
1903
|
-
fail(
|
|
1904
|
-
"Unexpected nested list layout: first=$firstLeft second=$secondLeft " +
|
|
1905
|
-
"nested=$nestedLeft third=$thirdLeft text=$text margins=$marginSummary"
|
|
1906
|
-
)
|
|
1907
|
-
}
|
|
1908
|
-
}
|
|
1909
|
-
|
|
1910
|
-
@Test
|
|
1911
|
-
fun `render - unordered list marker uses centered bullet span`() {
|
|
1912
|
-
val json = """
|
|
1913
|
-
[
|
|
1914
|
-
{"type": "blockStart", "nodeType": "listItem", "depth": 0,
|
|
1915
|
-
"listContext": {"ordered": false, "index": 1, "total": 1, "start": 1, "isFirst": true, "isLast": true}},
|
|
1916
|
-
{"type": "blockStart", "nodeType": "paragraph", "depth": 1},
|
|
1917
|
-
{"type": "textRun", "text": "Item", "marks": []},
|
|
1918
|
-
{"type": "blockEnd"},
|
|
1919
|
-
{"type": "blockEnd"}
|
|
1920
|
-
]
|
|
1921
|
-
""".trimIndent()
|
|
1922
|
-
|
|
1923
|
-
val result = RenderBridge.buildSpannable(json, baseFontSize, textColor, null, 1f)
|
|
1924
|
-
val bulletSpans = result.getSpans(0, 2, CenteredBulletSpan::class.java)
|
|
1925
|
-
|
|
1926
|
-
assertTrue(bulletSpans.isNotEmpty())
|
|
1927
|
-
}
|
|
1928
|
-
|
|
1929
|
-
@Test
|
|
1930
|
-
fun `FixedLineHeightSpan - pushes all extra space below baseline`() {
|
|
1931
|
-
val span = FixedLineHeightSpan(30)
|
|
1932
|
-
val fm = android.graphics.Paint.FontMetricsInt()
|
|
1933
|
-
fm.ascent = -14
|
|
1934
|
-
fm.top = -14
|
|
1935
|
-
fm.descent = 6
|
|
1936
|
-
fm.bottom = 6
|
|
1937
|
-
// currentHeight = 6 - (-14) = 20, extra = 30 - 20 = 10
|
|
1938
|
-
|
|
1939
|
-
span.chooseHeight("x", 0, 1, 0, 0, fm)
|
|
1940
|
-
|
|
1941
|
-
assertEquals("ascent unchanged", -14, fm.ascent)
|
|
1942
|
-
assertEquals("top unchanged", -14, fm.top)
|
|
1943
|
-
assertEquals("descent increased by extra", 16, fm.descent)
|
|
1944
|
-
assertEquals("bottom matches descent", 16, fm.bottom)
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
@Test
|
|
1948
|
-
fun `FixedLineHeightSpan - no change when height matches target`() {
|
|
1949
|
-
val span = FixedLineHeightSpan(20)
|
|
1950
|
-
val fm = android.graphics.Paint.FontMetricsInt()
|
|
1951
|
-
fm.ascent = -14
|
|
1952
|
-
fm.top = -14
|
|
1953
|
-
fm.descent = 6
|
|
1954
|
-
fm.bottom = 6
|
|
1955
|
-
|
|
1956
|
-
span.chooseHeight("x", 0, 1, 0, 0, fm)
|
|
1957
|
-
|
|
1958
|
-
assertEquals(-14, fm.ascent)
|
|
1959
|
-
assertEquals(-14, fm.top)
|
|
1960
|
-
assertEquals(6, fm.descent)
|
|
1961
|
-
assertEquals(6, fm.bottom)
|
|
1962
|
-
}
|
|
1963
|
-
|
|
1964
|
-
@Test
|
|
1965
|
-
fun `CenteredBulletSpan - restores paint state after draw`() {
|
|
1966
|
-
val bulletRadius = 3f
|
|
1967
|
-
val markerWidth = 24f
|
|
1968
|
-
val bodyFontSize = 16f
|
|
1969
|
-
val markerFontSize = 32f
|
|
1970
|
-
val span = CenteredBulletSpan(
|
|
1971
|
-
Color.BLACK,
|
|
1972
|
-
markerWidth,
|
|
1973
|
-
bulletRadius,
|
|
1974
|
-
bodyFontSize,
|
|
1975
|
-
LayoutConstants.LIST_MARKER_TEXT_GAP
|
|
1976
|
-
)
|
|
1977
|
-
|
|
1978
|
-
val paint = Paint()
|
|
1979
|
-
paint.textSize = markerFontSize
|
|
1980
|
-
paint.color = Color.RED
|
|
1981
|
-
paint.style = Paint.Style.STROKE
|
|
1982
|
-
|
|
1983
|
-
val bitmap = android.graphics.Bitmap.createBitmap(100, 100, android.graphics.Bitmap.Config.ARGB_8888)
|
|
1984
|
-
val canvas = android.graphics.Canvas(bitmap)
|
|
1985
|
-
|
|
1986
|
-
span.draw(canvas, "•", 0, 1, 0f, 0, 20, 40, paint)
|
|
1987
|
-
|
|
1988
|
-
assertEquals("textSize should be restored", markerFontSize, paint.textSize)
|
|
1989
|
-
assertEquals("color should be restored", Color.RED, paint.color)
|
|
1990
|
-
assertEquals("style should be restored", Paint.Style.STROKE, paint.style)
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
@Test
|
|
1994
|
-
fun `CenteredBulletSpan - larger bullet preserves text side gap`() {
|
|
1995
|
-
val markerWidth = 24f
|
|
1996
|
-
val bodyFontSize = 16f
|
|
1997
|
-
val gapToText = LayoutConstants.LIST_MARKER_TEXT_GAP
|
|
1998
|
-
val normalSpan = CenteredBulletSpan(Color.BLACK, markerWidth, 3f, bodyFontSize, gapToText)
|
|
1999
|
-
val scaledSpan = CenteredBulletSpan(Color.BLACK, markerWidth, 6f, bodyFontSize, gapToText)
|
|
2000
|
-
|
|
2001
|
-
assertEquals(normalSpan.textSideGapPx(0f), scaledSpan.textSideGapPx(0f), 0.01f)
|
|
2002
|
-
assertEquals(gapToText, scaledSpan.textSideGapPx(0f), 0.01f)
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
// ── Height Measurement ──────────────────────────────────────────────
|
|
2006
|
-
|
|
2007
|
-
@Test
|
|
2008
|
-
fun `measureHeight returns positive height for single paragraph`() {
|
|
2009
|
-
val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"Hello world"},{"type":"blockEnd"}]"""
|
|
2010
|
-
val height = RenderBridge.measureHeight(
|
|
2011
|
-
json = renderJSON,
|
|
2012
|
-
themeJson = null,
|
|
2013
|
-
width = 375f,
|
|
2014
|
-
density = 1f
|
|
2015
|
-
)
|
|
2016
|
-
assertTrue("Single paragraph should have positive height, got $height", height > 0f)
|
|
2017
|
-
}
|
|
2018
|
-
|
|
2019
|
-
@Test
|
|
2020
|
-
fun `measureHeight returns zero for empty content`() {
|
|
2021
|
-
val height = RenderBridge.measureHeight(
|
|
2022
|
-
json = "[]",
|
|
2023
|
-
themeJson = null,
|
|
2024
|
-
width = 375f,
|
|
2025
|
-
density = 1f
|
|
2026
|
-
)
|
|
2027
|
-
assertEquals("Empty content should have zero height", 0f, height)
|
|
2028
|
-
}
|
|
2029
|
-
|
|
2030
|
-
@Test
|
|
2031
|
-
fun `measureHeight adds content insets`() {
|
|
2032
|
-
val renderJSON = """[{"type":"blockStart","nodeType":"paragraph","depth":0},{"type":"textRun","text":"Hello world"},{"type":"blockEnd"}]"""
|
|
2033
|
-
val noInsetHeight = RenderBridge.measureHeight(
|
|
2034
|
-
json = renderJSON,
|
|
2035
|
-
themeJson = null,
|
|
2036
|
-
width = 375f,
|
|
2037
|
-
density = 1f
|
|
2038
|
-
)
|
|
2039
|
-
val insetHeight = RenderBridge.measureHeight(
|
|
2040
|
-
json = renderJSON,
|
|
2041
|
-
themeJson = """{"contentInsets":{"top":20,"bottom":20}}""",
|
|
2042
|
-
width = 375f,
|
|
2043
|
-
density = 1f
|
|
2044
|
-
)
|
|
2045
|
-
assertEquals(
|
|
2046
|
-
"Content insets should add 40 to height",
|
|
2047
|
-
noInsetHeight + 40f,
|
|
2048
|
-
insetHeight,
|
|
2049
|
-
1f
|
|
2050
|
-
)
|
|
2051
|
-
}
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
}
|