expo-pretext 0.2.0

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.
@@ -0,0 +1,647 @@
1
+ // Ported from chenglou/pretext src/inline-flow.ts
2
+ //
3
+ // Experimental sidecar for mixed inline runs under `white-space: normal`.
4
+ // It keeps the core layout API low-level while taking over the boring shared
5
+ // work that rich inline demos kept reimplementing in userland:
6
+ // - collapsed boundary whitespace across item boundaries
7
+ // - atomic inline boxes like pills
8
+ // - per-item extra horizontal chrome such as padding/borders
9
+ //
10
+ // Modifications from the original Pretext version:
11
+ // - InlineFlowItem.font (Canvas font shorthand) -> InlineFlowItem.style (TextStyle)
12
+ // - InlineFlowItem.break ('normal' | 'never') -> InlineFlowItem.atomic (boolean)
13
+ // atomic=true corresponds to break='never'
14
+
15
+ import {
16
+ materializeLineRange,
17
+ measureNaturalWidth,
18
+ type LayoutCursor,
19
+ type LayoutLineRange,
20
+ type LayoutResult,
21
+ type PreparedTextWithSegments,
22
+ } from './layout'
23
+ import { prepareWithSegments } from './prepare'
24
+ import {
25
+ layoutNextLineRange as stepPreparedLineRangeRaw,
26
+ type LineBreakCursor,
27
+ stepPreparedLineGeometry as stepPreparedLineGeometryRaw,
28
+ } from './line-break'
29
+
30
+ import type { PreparedLineBreakData } from './line-break'
31
+
32
+ // The opaque PreparedTextWithSegments is structurally compatible with
33
+ // PreparedLineBreakData at runtime, but TypeScript's branded types hide that.
34
+ // Cast through the internal type instead of `any` so the boundary is typed.
35
+ const asPrepared = (p: PreparedTextWithSegments) => p as unknown as PreparedLineBreakData
36
+ const stepPreparedLineRange = (prepared: PreparedTextWithSegments, start: LineBreakCursor, maxWidth: number) =>
37
+ stepPreparedLineRangeRaw(asPrepared(prepared), start, maxWidth)
38
+ const stepPreparedLineGeometry = (prepared: PreparedTextWithSegments, start: LineBreakCursor, maxWidth: number) =>
39
+ stepPreparedLineGeometryRaw(asPrepared(prepared), start, maxWidth)
40
+ import type {
41
+ TextStyle,
42
+ InlineFlowItem,
43
+ PreparedInlineFlow,
44
+ InlineFlowCursor,
45
+ InlineFlowFragment,
46
+ InlineFlowLine,
47
+ } from './types'
48
+
49
+ export type { InlineFlowItem, PreparedInlineFlow, InlineFlowCursor, InlineFlowFragment, InlineFlowLine }
50
+
51
+ export type InlineFlowFragmentRange = {
52
+ itemIndex: number // Index into the original InlineFlowItem array
53
+ gapBefore: number // Collapsed inter-item gap paid before this fragment on this line
54
+ occupiedWidth: number // Text width plus the item's extraWidth contribution
55
+ start: LayoutCursor // Start cursor within the item's prepared text
56
+ end: LayoutCursor // End cursor within the item's prepared text
57
+ }
58
+
59
+ export type InlineFlowLineRange = {
60
+ fragments: InlineFlowFragmentRange[]
61
+ width: number
62
+ end: InlineFlowCursor
63
+ }
64
+
65
+ export type InlineFlowGeometry = {
66
+ lineCount: number
67
+ maxLineWidth: number
68
+ }
69
+
70
+ type InternalPreparedInlineFlow = PreparedInlineFlow & {
71
+ items: PreparedInlineFlowItem[]
72
+ itemsBySourceItemIndex: Array<PreparedInlineFlowItem | undefined>
73
+ }
74
+
75
+ type PreparedInlineFlowItem = {
76
+ atomic: boolean
77
+ endGraphemeIndex: number
78
+ endSegmentIndex: number
79
+ extraWidth: number
80
+ gapBefore: number
81
+ naturalWidth: number
82
+ prepared: PreparedTextWithSegments
83
+ sourceItemIndex: number
84
+ }
85
+
86
+ const COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+/
87
+ const LEADING_COLLAPSIBLE_BOUNDARY_RE = /^[ \t\n\f\r]+/
88
+ const TRAILING_COLLAPSIBLE_BOUNDARY_RE = /[ \t\n\f\r]+$/
89
+ const EMPTY_LAYOUT_CURSOR: LayoutCursor = { segmentIndex: 0, graphemeIndex: 0 }
90
+ const FLOW_START_CURSOR: InlineFlowCursor = {
91
+ itemIndex: 0,
92
+ segmentIndex: 0,
93
+ graphemeIndex: 0,
94
+ }
95
+
96
+ function getInternalPreparedInlineFlow(prepared: PreparedInlineFlow): InternalPreparedInlineFlow {
97
+ return prepared as InternalPreparedInlineFlow
98
+ }
99
+
100
+ function cloneCursor(cursor: LayoutCursor): LayoutCursor {
101
+ return {
102
+ segmentIndex: cursor.segmentIndex,
103
+ graphemeIndex: cursor.graphemeIndex,
104
+ }
105
+ }
106
+
107
+ function isLineStartCursor(cursor: LayoutCursor): boolean {
108
+ return cursor.segmentIndex === 0 && cursor.graphemeIndex === 0
109
+ }
110
+
111
+ function getCollapsedSpaceWidth(style: TextStyle, cache: Map<string, number>): number {
112
+ // Cache key derived from the style properties that affect space width
113
+ const key = `${style.fontFamily}|${style.fontSize}|${style.fontWeight ?? '400'}|${style.fontStyle ?? 'normal'}`
114
+ const cached = cache.get(key)
115
+ if (cached !== undefined) return cached
116
+
117
+ const joinedWidth = measureNaturalWidth(prepareWithSegments('A A', style))
118
+ const compactWidth = measureNaturalWidth(prepareWithSegments('AA', style))
119
+ const collapsedWidth = Math.max(0, joinedWidth - compactWidth)
120
+ cache.set(key, collapsedWidth)
121
+ return collapsedWidth
122
+ }
123
+
124
+ function prepareWholeItemLine(prepared: PreparedTextWithSegments): {
125
+ endGraphemeIndex: number
126
+ endSegmentIndex: number
127
+ width: number
128
+ } | null {
129
+ const line = stepPreparedLineRange(prepared, EMPTY_LAYOUT_CURSOR, Number.POSITIVE_INFINITY)
130
+ if (line === null) return null
131
+ return {
132
+ endGraphemeIndex: line.endGraphemeIndex,
133
+ endSegmentIndex: line.endSegmentIndex,
134
+ width: line.width,
135
+ }
136
+ }
137
+
138
+ type InlineFlowFragmentCollector = (
139
+ item: PreparedInlineFlowItem,
140
+ gapBefore: number,
141
+ occupiedWidth: number,
142
+ start: LayoutCursor,
143
+ end: LayoutCursor,
144
+ ) => void
145
+
146
+ function endsInsideFirstSegment(segmentIndex: number, graphemeIndex: number): boolean {
147
+ return segmentIndex === 0 && graphemeIndex > 0
148
+ }
149
+
150
+ export function prepareInlineFlow(items: InlineFlowItem[]): PreparedInlineFlow {
151
+ const preparedItems: PreparedInlineFlowItem[] = []
152
+ const itemsBySourceItemIndex = Array.from<PreparedInlineFlowItem | undefined>({ length: items.length })
153
+ const collapsedSpaceWidthCache = new Map<string, number>()
154
+ let pendingGapWidth = 0
155
+
156
+ for (let index = 0; index < items.length; index++) {
157
+ const item = items[index]!
158
+ const hasLeadingWhitespace = LEADING_COLLAPSIBLE_BOUNDARY_RE.test(item.text)
159
+ const hasTrailingWhitespace = TRAILING_COLLAPSIBLE_BOUNDARY_RE.test(item.text)
160
+ const trimmedText = item.text
161
+ .replace(LEADING_COLLAPSIBLE_BOUNDARY_RE, '')
162
+ .replace(TRAILING_COLLAPSIBLE_BOUNDARY_RE, '')
163
+
164
+ if (trimmedText.length === 0) {
165
+ if (COLLAPSIBLE_BOUNDARY_RE.test(item.text) && pendingGapWidth === 0) {
166
+ pendingGapWidth = getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache)
167
+ }
168
+ continue
169
+ }
170
+
171
+ const gapBefore =
172
+ pendingGapWidth > 0
173
+ ? pendingGapWidth
174
+ : hasLeadingWhitespace
175
+ ? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache)
176
+ : 0
177
+ const prepared = prepareWithSegments(trimmedText, item.style)
178
+ const wholeLine = prepareWholeItemLine(prepared)
179
+ if (wholeLine === null) {
180
+ pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache) : 0
181
+ continue
182
+ }
183
+
184
+ const preparedItem = {
185
+ atomic: item.atomic ?? false,
186
+ endGraphemeIndex: wholeLine.endGraphemeIndex,
187
+ endSegmentIndex: wholeLine.endSegmentIndex,
188
+ extraWidth: item.extraWidth ?? 0,
189
+ gapBefore,
190
+ naturalWidth: wholeLine.width,
191
+ prepared,
192
+ sourceItemIndex: index,
193
+ } satisfies PreparedInlineFlowItem
194
+ preparedItems.push(preparedItem)
195
+ itemsBySourceItemIndex[index] = preparedItem
196
+
197
+ pendingGapWidth = hasTrailingWhitespace ? getCollapsedSpaceWidth(item.style, collapsedSpaceWidthCache) : 0
198
+ }
199
+
200
+ return {
201
+ items: preparedItems,
202
+ itemsBySourceItemIndex,
203
+ } as InternalPreparedInlineFlow
204
+ }
205
+
206
+ function stepInlineFlowLine(
207
+ flow: InternalPreparedInlineFlow,
208
+ maxWidth: number,
209
+ cursor: InlineFlowCursor,
210
+ collectFragment?: InlineFlowFragmentCollector,
211
+ ): number | null {
212
+ if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) return null
213
+
214
+ const safeWidth = Math.max(1, maxWidth)
215
+ let lineWidth = 0
216
+ let remainingWidth = safeWidth
217
+ let itemIndex = cursor.itemIndex
218
+ const textCursor: LineBreakCursor = {
219
+ segmentIndex: cursor.segmentIndex,
220
+ graphemeIndex: cursor.graphemeIndex,
221
+ }
222
+
223
+ lineLoop:
224
+ while (itemIndex < flow.items.length) {
225
+ const item = flow.items[itemIndex]!
226
+ if (
227
+ !isLineStartCursor(textCursor) &&
228
+ textCursor.segmentIndex === item.endSegmentIndex &&
229
+ textCursor.graphemeIndex === item.endGraphemeIndex
230
+ ) {
231
+ itemIndex++
232
+ textCursor.segmentIndex = 0
233
+ textCursor.graphemeIndex = 0
234
+ continue
235
+ }
236
+
237
+ const gapBefore = lineWidth === 0 ? 0 : item.gapBefore
238
+ const atItemStart = isLineStartCursor(textCursor)
239
+
240
+ if (item.atomic) {
241
+ if (!atItemStart) {
242
+ itemIndex++
243
+ textCursor.segmentIndex = 0
244
+ textCursor.graphemeIndex = 0
245
+ continue
246
+ }
247
+
248
+ const occupiedWidth = item.naturalWidth + item.extraWidth
249
+ const totalWidth = gapBefore + occupiedWidth
250
+ if (lineWidth > 0 && totalWidth > remainingWidth) break lineLoop
251
+
252
+ collectFragment?.(
253
+ item,
254
+ gapBefore,
255
+ occupiedWidth,
256
+ cloneCursor(EMPTY_LAYOUT_CURSOR),
257
+ {
258
+ segmentIndex: item.endSegmentIndex,
259
+ graphemeIndex: item.endGraphemeIndex,
260
+ },
261
+ )
262
+ lineWidth += totalWidth
263
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
264
+ itemIndex++
265
+ textCursor.segmentIndex = 0
266
+ textCursor.graphemeIndex = 0
267
+ continue
268
+ }
269
+
270
+ const reservedWidth = gapBefore + item.extraWidth
271
+ if (lineWidth > 0 && reservedWidth >= remainingWidth) break lineLoop
272
+
273
+ if (atItemStart) {
274
+ const totalWidth = reservedWidth + item.naturalWidth
275
+ if (totalWidth <= remainingWidth) {
276
+ collectFragment?.(
277
+ item,
278
+ gapBefore,
279
+ item.naturalWidth + item.extraWidth,
280
+ cloneCursor(EMPTY_LAYOUT_CURSOR),
281
+ {
282
+ segmentIndex: item.endSegmentIndex,
283
+ graphemeIndex: item.endGraphemeIndex,
284
+ },
285
+ )
286
+ lineWidth += totalWidth
287
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
288
+ itemIndex++
289
+ textCursor.segmentIndex = 0
290
+ textCursor.graphemeIndex = 0
291
+ continue
292
+ }
293
+ }
294
+
295
+ const availableWidth = Math.max(1, remainingWidth - reservedWidth)
296
+ const line = stepPreparedLineRange(item.prepared, textCursor, availableWidth)
297
+ if (line === null) {
298
+ itemIndex++
299
+ textCursor.segmentIndex = 0
300
+ textCursor.graphemeIndex = 0
301
+ continue
302
+ }
303
+ if (
304
+ textCursor.segmentIndex === line.endSegmentIndex &&
305
+ textCursor.graphemeIndex === line.endGraphemeIndex
306
+ ) {
307
+ itemIndex++
308
+ textCursor.segmentIndex = 0
309
+ textCursor.graphemeIndex = 0
310
+ continue
311
+ }
312
+
313
+ // If the only thing we can fit after paying the boundary gap is a partial
314
+ // slice of the item's first segment, prefer wrapping before the item so we
315
+ // keep whole-word-style boundaries when they exist. But once the current
316
+ // line can consume a real breakable unit from the item, stay greedy and
317
+ // keep filling the line.
318
+ if (
319
+ lineWidth > 0 &&
320
+ atItemStart &&
321
+ gapBefore > 0 &&
322
+ endsInsideFirstSegment(line.endSegmentIndex, line.endGraphemeIndex)
323
+ ) {
324
+ const freshLine = stepPreparedLineRange(
325
+ item.prepared,
326
+ EMPTY_LAYOUT_CURSOR,
327
+ Math.max(1, safeWidth - item.extraWidth),
328
+ )
329
+ if (
330
+ freshLine !== null &&
331
+ (
332
+ freshLine.endSegmentIndex > line.endSegmentIndex ||
333
+ (
334
+ freshLine.endSegmentIndex === line.endSegmentIndex &&
335
+ freshLine.endGraphemeIndex > line.endGraphemeIndex
336
+ )
337
+ )
338
+ ) {
339
+ break lineLoop
340
+ }
341
+ }
342
+
343
+ collectFragment?.(
344
+ item,
345
+ gapBefore,
346
+ line.width + item.extraWidth,
347
+ cloneCursor(textCursor),
348
+ {
349
+ segmentIndex: line.endSegmentIndex,
350
+ graphemeIndex: line.endGraphemeIndex,
351
+ },
352
+ )
353
+ lineWidth += gapBefore + line.width + item.extraWidth
354
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
355
+
356
+ if (
357
+ line.endSegmentIndex === item.endSegmentIndex &&
358
+ line.endGraphemeIndex === item.endGraphemeIndex
359
+ ) {
360
+ itemIndex++
361
+ textCursor.segmentIndex = 0
362
+ textCursor.graphemeIndex = 0
363
+ continue
364
+ }
365
+
366
+ textCursor.segmentIndex = line.endSegmentIndex
367
+ textCursor.graphemeIndex = line.endGraphemeIndex
368
+ break
369
+ }
370
+
371
+ if (lineWidth === 0) return null
372
+
373
+ cursor.itemIndex = itemIndex
374
+ cursor.segmentIndex = textCursor.segmentIndex
375
+ cursor.graphemeIndex = textCursor.graphemeIndex
376
+ return lineWidth
377
+ }
378
+
379
+ function stepInlineFlowLineGeometry(
380
+ flow: InternalPreparedInlineFlow,
381
+ maxWidth: number,
382
+ cursor: InlineFlowCursor,
383
+ ): number | null {
384
+ if (flow.items.length === 0 || cursor.itemIndex >= flow.items.length) return null
385
+
386
+ const safeWidth = Math.max(1, maxWidth)
387
+ let lineWidth = 0
388
+ let remainingWidth = safeWidth
389
+ let itemIndex = cursor.itemIndex
390
+
391
+ lineLoop:
392
+ while (itemIndex < flow.items.length) {
393
+ const item = flow.items[itemIndex]!
394
+ if (
395
+ !isLineStartCursor(cursor) &&
396
+ cursor.segmentIndex === item.endSegmentIndex &&
397
+ cursor.graphemeIndex === item.endGraphemeIndex
398
+ ) {
399
+ itemIndex++
400
+ cursor.segmentIndex = 0
401
+ cursor.graphemeIndex = 0
402
+ continue
403
+ }
404
+
405
+ const gapBefore = lineWidth === 0 ? 0 : item.gapBefore
406
+ const atItemStart = isLineStartCursor(cursor)
407
+
408
+ if (item.atomic) {
409
+ if (!atItemStart) {
410
+ itemIndex++
411
+ cursor.segmentIndex = 0
412
+ cursor.graphemeIndex = 0
413
+ continue
414
+ }
415
+
416
+ const occupiedWidth = item.naturalWidth + item.extraWidth
417
+ const totalWidth = gapBefore + occupiedWidth
418
+ if (lineWidth > 0 && totalWidth > remainingWidth) break lineLoop
419
+
420
+ lineWidth += totalWidth
421
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
422
+ itemIndex++
423
+ cursor.segmentIndex = 0
424
+ cursor.graphemeIndex = 0
425
+ continue
426
+ }
427
+
428
+ const reservedWidth = gapBefore + item.extraWidth
429
+ if (lineWidth > 0 && reservedWidth >= remainingWidth) break lineLoop
430
+
431
+ if (atItemStart) {
432
+ const totalWidth = reservedWidth + item.naturalWidth
433
+ if (totalWidth <= remainingWidth) {
434
+ lineWidth += totalWidth
435
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
436
+ itemIndex++
437
+ cursor.segmentIndex = 0
438
+ cursor.graphemeIndex = 0
439
+ continue
440
+ }
441
+ }
442
+
443
+ const availableWidth = Math.max(1, remainingWidth - reservedWidth)
444
+ const lineEnd: LineBreakCursor = {
445
+ segmentIndex: cursor.segmentIndex,
446
+ graphemeIndex: cursor.graphemeIndex,
447
+ }
448
+ const lineWidthForItem = stepPreparedLineGeometry(item.prepared, lineEnd, availableWidth)
449
+ if (lineWidthForItem === null) {
450
+ itemIndex++
451
+ cursor.segmentIndex = 0
452
+ cursor.graphemeIndex = 0
453
+ continue
454
+ }
455
+ if (cursor.segmentIndex === lineEnd.segmentIndex && cursor.graphemeIndex === lineEnd.graphemeIndex) {
456
+ itemIndex++
457
+ cursor.segmentIndex = 0
458
+ cursor.graphemeIndex = 0
459
+ continue
460
+ }
461
+
462
+ if (
463
+ lineWidth > 0 &&
464
+ atItemStart &&
465
+ gapBefore > 0 &&
466
+ endsInsideFirstSegment(lineEnd.segmentIndex, lineEnd.graphemeIndex)
467
+ ) {
468
+ const freshLineEnd: LineBreakCursor = {
469
+ segmentIndex: 0,
470
+ graphemeIndex: 0,
471
+ }
472
+ const freshLineWidth = stepPreparedLineGeometry(
473
+ item.prepared,
474
+ freshLineEnd,
475
+ Math.max(1, safeWidth - item.extraWidth),
476
+ )
477
+ if (
478
+ freshLineWidth !== null &&
479
+ (
480
+ freshLineEnd.segmentIndex > lineEnd.segmentIndex ||
481
+ (
482
+ freshLineEnd.segmentIndex === lineEnd.segmentIndex &&
483
+ freshLineEnd.graphemeIndex > lineEnd.graphemeIndex
484
+ )
485
+ )
486
+ ) {
487
+ break lineLoop
488
+ }
489
+ }
490
+
491
+ lineWidth += gapBefore + lineWidthForItem + item.extraWidth
492
+ remainingWidth = Math.max(0, safeWidth - lineWidth)
493
+
494
+ if (lineEnd.segmentIndex === item.endSegmentIndex && lineEnd.graphemeIndex === item.endGraphemeIndex) {
495
+ itemIndex++
496
+ cursor.segmentIndex = 0
497
+ cursor.graphemeIndex = 0
498
+ continue
499
+ }
500
+
501
+ cursor.segmentIndex = lineEnd.segmentIndex
502
+ cursor.graphemeIndex = lineEnd.graphemeIndex
503
+ break
504
+ }
505
+
506
+ if (lineWidth === 0) return null
507
+
508
+ cursor.itemIndex = itemIndex
509
+ return lineWidth
510
+ }
511
+
512
+ export function layoutNextInlineFlowLineRange(
513
+ prepared: PreparedInlineFlow,
514
+ maxWidth: number,
515
+ start: InlineFlowCursor = FLOW_START_CURSOR,
516
+ ): InlineFlowLineRange | null {
517
+ const flow = getInternalPreparedInlineFlow(prepared)
518
+ const end: InlineFlowCursor = {
519
+ itemIndex: start.itemIndex,
520
+ segmentIndex: start.segmentIndex,
521
+ graphemeIndex: start.graphemeIndex,
522
+ }
523
+ const fragments: InlineFlowFragmentRange[] = []
524
+ const width = stepInlineFlowLine(flow, maxWidth, end, (item, gapBefore, occupiedWidth, fragmentStart, fragmentEnd) => {
525
+ fragments.push({
526
+ itemIndex: item.sourceItemIndex,
527
+ gapBefore,
528
+ occupiedWidth,
529
+ start: fragmentStart,
530
+ end: fragmentEnd,
531
+ })
532
+ })
533
+ if (width === null) return null
534
+
535
+ return {
536
+ fragments,
537
+ width,
538
+ end,
539
+ }
540
+ }
541
+
542
+ function materializeFragmentText(
543
+ item: PreparedInlineFlowItem,
544
+ fragment: InlineFlowFragmentRange,
545
+ ): string {
546
+ const line = materializeLineRange(item.prepared, {
547
+ width: fragment.occupiedWidth - item.extraWidth,
548
+ start: fragment.start,
549
+ end: fragment.end,
550
+ } satisfies LayoutLineRange)
551
+ return line.text
552
+ }
553
+
554
+ export function layoutNextInlineFlowLine(
555
+ prepared: PreparedInlineFlow,
556
+ maxWidth: number,
557
+ start: InlineFlowCursor = FLOW_START_CURSOR,
558
+ ): InlineFlowLine | null {
559
+ const flow = getInternalPreparedInlineFlow(prepared)
560
+ const line = layoutNextInlineFlowLineRange(prepared, maxWidth, start)
561
+ if (line === null) return null
562
+
563
+ return {
564
+ fragments: line.fragments.map(fragment => {
565
+ const item = flow.itemsBySourceItemIndex[fragment.itemIndex]
566
+ if (item === undefined) throw new Error('Missing inline-flow item for fragment')
567
+ return {
568
+ ...fragment,
569
+ text: materializeFragmentText(item, fragment),
570
+ }
571
+ }),
572
+ width: line.width,
573
+ end: line.end,
574
+ }
575
+ }
576
+
577
+ export function walkInlineFlowLineRanges(
578
+ prepared: PreparedInlineFlow,
579
+ maxWidth: number,
580
+ onLine: (line: InlineFlowLineRange) => void,
581
+ ): number {
582
+ let lineCount = 0
583
+ let cursor = FLOW_START_CURSOR
584
+
585
+ while (true) {
586
+ const line = layoutNextInlineFlowLineRange(prepared, maxWidth, cursor)
587
+ if (line === null) return lineCount
588
+ onLine(line)
589
+ lineCount++
590
+ cursor = line.end
591
+ }
592
+ }
593
+
594
+ export function measureInlineFlowGeometry(
595
+ prepared: PreparedInlineFlow,
596
+ maxWidth: number,
597
+ ): InlineFlowGeometry {
598
+ const flow = getInternalPreparedInlineFlow(prepared)
599
+ let lineCount = 0
600
+ let maxLineWidth = 0
601
+ const cursor: InlineFlowCursor = {
602
+ itemIndex: 0,
603
+ segmentIndex: 0,
604
+ graphemeIndex: 0,
605
+ }
606
+
607
+ while (true) {
608
+ const lineWidth = stepInlineFlowLineGeometry(flow, maxWidth, cursor)
609
+ if (lineWidth === null) {
610
+ return {
611
+ lineCount,
612
+ maxLineWidth,
613
+ }
614
+ }
615
+ lineCount++
616
+ if (lineWidth > maxLineWidth) maxLineWidth = lineWidth
617
+ }
618
+ }
619
+
620
+ export function walkInlineFlowLines(
621
+ prepared: PreparedInlineFlow,
622
+ maxWidth: number,
623
+ onLine: (line: InlineFlowLine) => void,
624
+ ): number {
625
+ let lineCount = 0
626
+ let cursor = FLOW_START_CURSOR
627
+
628
+ while (true) {
629
+ const line = layoutNextInlineFlowLine(prepared, maxWidth, cursor)
630
+ if (line === null) return lineCount
631
+ onLine(line)
632
+ lineCount++
633
+ cursor = line.end
634
+ }
635
+ }
636
+
637
+ export function measureInlineFlow(
638
+ prepared: PreparedInlineFlow,
639
+ maxWidth: number,
640
+ lineHeight: number,
641
+ ): LayoutResult {
642
+ const { lineCount } = measureInlineFlowGeometry(prepared, maxWidth)
643
+ return {
644
+ lineCount,
645
+ height: lineCount * lineHeight,
646
+ }
647
+ }
@@ -0,0 +1,61 @@
1
+ import { getNativeModule } from './ExpoPretext'
2
+ import { prepare } from './prepare'
3
+ import { textStyleToFontDescriptor, getFontKey } from './font-utils'
4
+ import { cacheNativeResult } from './cache'
5
+ import type { TextStyle, PreparedText, PrepareOptions } from './types'
6
+
7
+ type StreamingState = {
8
+ sourceText: string
9
+ prepared: PreparedText
10
+ }
11
+
12
+ const streamingStates = new WeakMap<object, StreamingState>()
13
+
14
+ export function prepareStreaming(
15
+ key: object,
16
+ text: string,
17
+ style: TextStyle,
18
+ options?: PrepareOptions
19
+ ): PreparedText {
20
+ if (!text) {
21
+ const prepared = prepare('', style, options)
22
+ streamingStates.delete(key)
23
+ return prepared
24
+ }
25
+
26
+ const prev = streamingStates.get(key)
27
+
28
+ // No previous state or text is not an append → full prepare
29
+ if (!prev || !text.startsWith(prev.sourceText) || prev.sourceText === text) {
30
+ if (prev && prev.sourceText === text) {
31
+ return prev.prepared // same text, return cached
32
+ }
33
+ const prepared = prepare(text, style, options)
34
+ streamingStates.set(key, { sourceText: text, prepared })
35
+ return prepared
36
+ }
37
+
38
+ // Text grew — measure the new suffix to warm up the cache
39
+ const native = getNativeModule()
40
+ if (native) {
41
+ const newSuffix = text.slice(prev.sourceText.length)
42
+ if (newSuffix.length > 0) {
43
+ const font = textStyleToFontDescriptor(style)
44
+ const nativeOpts = options
45
+ ? { whiteSpace: options.whiteSpace, locale: options.locale }
46
+ : undefined
47
+ const result = native.segmentAndMeasure(newSuffix, font, nativeOpts)
48
+ const fontKey = getFontKey(style)
49
+ cacheNativeResult(fontKey, result.segments, result.widths)
50
+ }
51
+ }
52
+
53
+ // Full prepare with warmed cache — most segments will be JS cache hits
54
+ const prepared = prepare(text, style, options)
55
+ streamingStates.set(key, { sourceText: text, prepared })
56
+ return prepared
57
+ }
58
+
59
+ export function clearStreamingState(key: object): void {
60
+ streamingStates.delete(key)
61
+ }