dsh-smooth-stream 0.6.0 → 0.7.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,180 @@
1
+ /**
2
+ * FrameCoordinator: unified per-document frame clock and phased layout scheduler.
3
+ *
4
+ * Replaces competing, uncoordinated rAF loops across text reveal, follow
5
+ * physics, fade painting, and diagnostic sampling. One document owns exactly
6
+ * one live animation frame while any task is active.
7
+ *
8
+ * Strict phase order in every frame:
9
+ * 1. Read Phase: layout reads ONLY when some task declared them dirty.
10
+ * 2. Simulate Phase: pure math / integration (pacing, spring, buckets).
11
+ * A task returns `true` to stay armed for the next frame.
12
+ * 3. Write Phase: batched DOM mutations (text nodes, transforms, CSS vars,
13
+ * at most one scrollTop write per owner).
14
+ *
15
+ * Tasks registered later never overtake earlier ones inside a phase, so a
16
+ * task's relative order is its registration order.
17
+ *
18
+ * `requestRead` is the bridge to the previous frame: a task that just wrote
19
+ * layout-affecting DOM (pacing revealed text, mutate() batch) declares that
20
+ * geometry must be re-read at the top of the NEXT frame, before the follow
21
+ * engine simulates. That keeps the "no layout read after a layout write in
22
+ * the same frame" invariant without giving up a same-reveal measurement.
23
+ */
24
+
25
+ export interface FrameTask {
26
+ readonly id: string
27
+ /** Called if any task marked layout as dirty during the frame cycle. */
28
+ onRead?: ((now: number) => void) | undefined
29
+ /** Physics, trajectory, and character pace calculation. Returns true if active. */
30
+ onSimulate?: ((dtMs: number, now: number) => boolean) | undefined
31
+ /** DOM and visual style mutation phase (transform, text, scrollTop). */
32
+ onWrite?: ((now: number) => void) | undefined
33
+ }
34
+
35
+ export class FrameCoordinator {
36
+ private static coordinators = new WeakMap<Document, FrameCoordinator>()
37
+
38
+ private tasks = new Map<string, FrameTask>()
39
+ private rafId: number | null = null
40
+ private lastTs: number | null = null
41
+ /**
42
+ * Owed layout read. Starts false: a fresh coordinator has no unread writes,
43
+ * and starting dirty would keep the clock armed forever after the first
44
+ * frame. Consumers declare reads via {@link markLayoutDirty}.
45
+ */
46
+ private layoutDirty = false
47
+ /** One id per consumer, so an unregister cannot evict a re-registered task. */
48
+ private nextTaskId = 0
49
+ private doc: Document
50
+
51
+ private constructor(doc: Document) {
52
+ this.doc = doc
53
+ }
54
+
55
+ static forDocument(doc: Document = document): FrameCoordinator {
56
+ let coordinator = FrameCoordinator.coordinators.get(doc)
57
+ if (coordinator === undefined) {
58
+ coordinator = new FrameCoordinator(doc)
59
+ FrameCoordinator.coordinators.set(doc, coordinator)
60
+ }
61
+ return coordinator
62
+ }
63
+
64
+ /**
65
+ * Mark that a DOM or geometry change requires a layout read next frame.
66
+ */
67
+ markLayoutDirty(): void {
68
+ this.layoutDirty = true
69
+ this.ensureLoop()
70
+ }
71
+
72
+ /**
73
+ * Declare that geometry written since the last frame must be re-read at the
74
+ * top of the next one, and keep the clock running for it.
75
+ */
76
+ requestRead(): void {
77
+ this.markLayoutDirty()
78
+ }
79
+
80
+ /**
81
+ * Register or replace an animation/render task. Returns the task id to hand
82
+ * back to {@link unregisterTask}.
83
+ */
84
+ registerTask(task: Omit<FrameTask, 'id'> & { id?: string }): string {
85
+ const id = task.id ?? `frame-task-${this.nextTaskId++}`
86
+ this.tasks.set(id, { ...task, id })
87
+ this.ensureLoop()
88
+ return id
89
+ }
90
+
91
+ /**
92
+ * Unregister a task when its stream completes or the element unmounts.
93
+ */
94
+ unregisterTask(id: string | null | undefined): void {
95
+ if (id === null || id === undefined) return
96
+ this.tasks.delete(id)
97
+ if (this.tasks.size === 0 && !this.layoutDirty) this.stopLoop()
98
+ }
99
+
100
+ private ensureLoop(): void {
101
+ if (this.rafId !== null) return
102
+ const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
103
+ if (win === null) return
104
+
105
+ const tick = (now: number) => {
106
+ this.rafId = null
107
+
108
+ if (this.lastTs === null) {
109
+ this.lastTs = now
110
+ // First frame after an idle gap only establishes the time base.
111
+ this.ensureLoop()
112
+ return
113
+ }
114
+
115
+ const frameIntervalMs = Math.max(0, now - this.lastTs)
116
+ // Clamp frame delta between 1ms and 100ms to avoid physics explosions
117
+ // during main-thread hiccups.
118
+ const dtMs = Math.max(1, Math.min(frameIntervalMs, 100))
119
+ this.lastTs = now
120
+
121
+ // 1. Read phase (only when some task declared layout dirty).
122
+ if (this.layoutDirty) {
123
+ this.layoutDirty = false
124
+ for (const task of this.tasks.values()) task.onRead?.(now)
125
+ }
126
+
127
+ // 2. Simulate phase (pacing step, spring, lag, bucket math).
128
+ let anyActive = false
129
+ for (const task of this.tasks.values()) {
130
+ if (task.onSimulate?.(dtMs, now) === true) anyActive = true
131
+ }
132
+
133
+ // 3. Write phase (transforms, text nodes, CSS variables, scrollTop).
134
+ for (const task of this.tasks.values()) task.onWrite?.(now)
135
+
136
+ // Stay armed while a task is active, while a pending read is owed, or
137
+ // while callers keep the clock explicitly running.
138
+ if ((anyActive || this.layoutDirty) && this.rafId === null) {
139
+ this.ensureLoop()
140
+ } else if (!anyActive && !this.layoutDirty) {
141
+ this.lastTs = null
142
+ }
143
+ }
144
+
145
+ this.rafId = win.requestAnimationFrame(tick)
146
+ }
147
+
148
+ private stopLoop(): void {
149
+ if (this.rafId !== null) {
150
+ const win = this.doc.defaultView ?? (typeof window !== 'undefined' ? window : null)
151
+ win?.cancelAnimationFrame(this.rafId)
152
+ this.rafId = null
153
+ }
154
+ this.lastTs = null
155
+ }
156
+
157
+ /**
158
+ * Whether this document currently owns a live, still-needed animation
159
+ * frame. A frame that is merely draining an owed read with no registered
160
+ * task left is not considered active work.
161
+ */
162
+ get active(): boolean {
163
+ return this.rafId !== null && (this.tasks.size > 0 || this.layoutDirty)
164
+ }
165
+
166
+ /** Number of registered tasks; used by tests to pin the single-clock contract. */
167
+ get taskCount(): number {
168
+ return this.tasks.size
169
+ }
170
+
171
+ /**
172
+ * Release every task, drop any owed read, and stop the clock. Used by tests
173
+ * for isolation; production consumers unregister their own task.
174
+ */
175
+ shutdown(): void {
176
+ this.tasks.clear()
177
+ this.layoutDirty = false
178
+ this.stopLoop()
179
+ }
180
+ }
@@ -0,0 +1,160 @@
1
+ /* Shared buckets change paint only; original DOM, layout and colors survive. */
2
+ .scope::highlight(dsh-smooth-stream-log-fade-0),
3
+ .scope *::highlight(dsh-smooth-stream-log-fade-0) {
4
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 0.00000%, transparent);
5
+ }
6
+
7
+ .scope::highlight(dsh-smooth-stream-log-fade-1),
8
+ .scope *::highlight(dsh-smooth-stream-log-fade-1) {
9
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 3.22581%, transparent);
10
+ }
11
+
12
+ .scope::highlight(dsh-smooth-stream-log-fade-2),
13
+ .scope *::highlight(dsh-smooth-stream-log-fade-2) {
14
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 6.45161%, transparent);
15
+ }
16
+
17
+ .scope::highlight(dsh-smooth-stream-log-fade-3),
18
+ .scope *::highlight(dsh-smooth-stream-log-fade-3) {
19
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 9.67742%, transparent);
20
+ }
21
+
22
+ .scope::highlight(dsh-smooth-stream-log-fade-4),
23
+ .scope *::highlight(dsh-smooth-stream-log-fade-4) {
24
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 12.90323%, transparent);
25
+ }
26
+
27
+ .scope::highlight(dsh-smooth-stream-log-fade-5),
28
+ .scope *::highlight(dsh-smooth-stream-log-fade-5) {
29
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 16.12903%, transparent);
30
+ }
31
+
32
+ .scope::highlight(dsh-smooth-stream-log-fade-6),
33
+ .scope *::highlight(dsh-smooth-stream-log-fade-6) {
34
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 19.35484%, transparent);
35
+ }
36
+
37
+ .scope::highlight(dsh-smooth-stream-log-fade-7),
38
+ .scope *::highlight(dsh-smooth-stream-log-fade-7) {
39
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 22.58065%, transparent);
40
+ }
41
+
42
+ .scope::highlight(dsh-smooth-stream-log-fade-8),
43
+ .scope *::highlight(dsh-smooth-stream-log-fade-8) {
44
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 25.80645%, transparent);
45
+ }
46
+
47
+ .scope::highlight(dsh-smooth-stream-log-fade-9),
48
+ .scope *::highlight(dsh-smooth-stream-log-fade-9) {
49
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 29.03226%, transparent);
50
+ }
51
+
52
+ .scope::highlight(dsh-smooth-stream-log-fade-10),
53
+ .scope *::highlight(dsh-smooth-stream-log-fade-10) {
54
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 32.25806%, transparent);
55
+ }
56
+
57
+ .scope::highlight(dsh-smooth-stream-log-fade-11),
58
+ .scope *::highlight(dsh-smooth-stream-log-fade-11) {
59
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 35.48387%, transparent);
60
+ }
61
+
62
+ .scope::highlight(dsh-smooth-stream-log-fade-12),
63
+ .scope *::highlight(dsh-smooth-stream-log-fade-12) {
64
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 38.70968%, transparent);
65
+ }
66
+
67
+ .scope::highlight(dsh-smooth-stream-log-fade-13),
68
+ .scope *::highlight(dsh-smooth-stream-log-fade-13) {
69
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 41.93548%, transparent);
70
+ }
71
+
72
+ .scope::highlight(dsh-smooth-stream-log-fade-14),
73
+ .scope *::highlight(dsh-smooth-stream-log-fade-14) {
74
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 45.16129%, transparent);
75
+ }
76
+
77
+ .scope::highlight(dsh-smooth-stream-log-fade-15),
78
+ .scope *::highlight(dsh-smooth-stream-log-fade-15) {
79
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 48.38710%, transparent);
80
+ }
81
+
82
+ .scope::highlight(dsh-smooth-stream-log-fade-16),
83
+ .scope *::highlight(dsh-smooth-stream-log-fade-16) {
84
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 51.61290%, transparent);
85
+ }
86
+
87
+ .scope::highlight(dsh-smooth-stream-log-fade-17),
88
+ .scope *::highlight(dsh-smooth-stream-log-fade-17) {
89
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 54.83871%, transparent);
90
+ }
91
+
92
+ .scope::highlight(dsh-smooth-stream-log-fade-18),
93
+ .scope *::highlight(dsh-smooth-stream-log-fade-18) {
94
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 58.06452%, transparent);
95
+ }
96
+
97
+ .scope::highlight(dsh-smooth-stream-log-fade-19),
98
+ .scope *::highlight(dsh-smooth-stream-log-fade-19) {
99
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 61.29032%, transparent);
100
+ }
101
+
102
+ .scope::highlight(dsh-smooth-stream-log-fade-20),
103
+ .scope *::highlight(dsh-smooth-stream-log-fade-20) {
104
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 64.51613%, transparent);
105
+ }
106
+
107
+ .scope::highlight(dsh-smooth-stream-log-fade-21),
108
+ .scope *::highlight(dsh-smooth-stream-log-fade-21) {
109
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 67.74194%, transparent);
110
+ }
111
+
112
+ .scope::highlight(dsh-smooth-stream-log-fade-22),
113
+ .scope *::highlight(dsh-smooth-stream-log-fade-22) {
114
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 70.96774%, transparent);
115
+ }
116
+
117
+ .scope::highlight(dsh-smooth-stream-log-fade-23),
118
+ .scope *::highlight(dsh-smooth-stream-log-fade-23) {
119
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 74.19355%, transparent);
120
+ }
121
+
122
+ .scope::highlight(dsh-smooth-stream-log-fade-24),
123
+ .scope *::highlight(dsh-smooth-stream-log-fade-24) {
124
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 77.41935%, transparent);
125
+ }
126
+
127
+ .scope::highlight(dsh-smooth-stream-log-fade-25),
128
+ .scope *::highlight(dsh-smooth-stream-log-fade-25) {
129
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 80.64516%, transparent);
130
+ }
131
+
132
+ .scope::highlight(dsh-smooth-stream-log-fade-26),
133
+ .scope *::highlight(dsh-smooth-stream-log-fade-26) {
134
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 83.87097%, transparent);
135
+ }
136
+
137
+ .scope::highlight(dsh-smooth-stream-log-fade-27),
138
+ .scope *::highlight(dsh-smooth-stream-log-fade-27) {
139
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 87.09677%, transparent);
140
+ }
141
+
142
+ .scope::highlight(dsh-smooth-stream-log-fade-28),
143
+ .scope *::highlight(dsh-smooth-stream-log-fade-28) {
144
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 90.32258%, transparent);
145
+ }
146
+
147
+ .scope::highlight(dsh-smooth-stream-log-fade-29),
148
+ .scope *::highlight(dsh-smooth-stream-log-fade-29) {
149
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 93.54839%, transparent);
150
+ }
151
+
152
+ .scope::highlight(dsh-smooth-stream-log-fade-30),
153
+ .scope *::highlight(dsh-smooth-stream-log-fade-30) {
154
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 96.77419%, transparent);
155
+ }
156
+
157
+ .scope::highlight(dsh-smooth-stream-log-fade-31),
158
+ .scope *::highlight(dsh-smooth-stream-log-fade-31) {
159
+ color: color-mix(in srgb, var(--dsh-smooth-stream-fade-color, currentColor) 100.00000%, transparent);
160
+ }
@@ -8,7 +8,7 @@
8
8
  import { useState } from 'react'
9
9
  import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
10
10
  import type {} from '@deepseek-ai/dsh-client-ui-settings-plugins/client'
11
- import { IconChevronDownOutline14, IconRefreshOutline14 } from '@deepseek-ai/dsh-client-ui-primitives'
11
+ import { IconChevronDown, IconRefresh } from './harnessIcons.ts'
12
12
  import type { SmoothStreamCardFace } from './smooth-stream-card-controller.ts'
13
13
  import css from './SmoothStreamCard.module.css'
14
14
 
@@ -43,7 +43,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
43
43
  </span>
44
44
  {versionLabel === null ? null : <span className={css.version}>{versionLabel}</span>}
45
45
  {state.dirty ? <span className={css.pending}>{t('unsaved')}</span> : null}
46
- <IconChevronDownOutline14 className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
46
+ <IconChevronDown className={open ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
47
47
  </button>
48
48
  {open
49
49
  ? (
@@ -84,6 +84,19 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
84
84
  </span>
85
85
  <span className={css.hint}>{t('controlScrollHint')}</span>
86
86
  </label>
87
+ <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
88
+ <span className={css.fieldHead}>
89
+ <span className={css.label}>{t('logarithmicFade')}</span>
90
+ <input
91
+ type="checkbox"
92
+ className={css.toggle}
93
+ checked={state.logarithmicFade}
94
+ disabled={!state.writable || state.saving || !state.enabled}
95
+ onChange={(event) => { props.edit({ logarithmicFade: event.target.checked }) }}
96
+ />
97
+ </span>
98
+ <span className={css.hint}>{t('logarithmicFadeHint')}</span>
99
+ </label>
87
100
  {/* Motion preference is a radio group, so this row is a div:
88
101
  nesting the choice labels inside a field <label> would be
89
102
  illegal HTML (label within label) and browsers route the
@@ -113,6 +126,31 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
113
126
  ))}
114
127
  </span>
115
128
  </div>
129
+ <div className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
130
+ <span className={css.fieldHead}>
131
+ <span className={css.label}>{t('preset')}</span>
132
+ </span>
133
+ <span className={css.hint}>{t('presetHint')}</span>
134
+ <span className={css.choiceRow} role="radiogroup" aria-label={t('preset')}>
135
+ {([
136
+ ['realtime', 'presetRealtime', 'presetRealtimeHint'],
137
+ ['balanced', 'presetBalanced', 'presetBalancedHint'],
138
+ ['silky', 'presetSilky', 'presetSilkyHint'],
139
+ ] as const).map(([value, label, hint]) => (
140
+ <label key={value} className={css.choice} title={t(hint)}>
141
+ <input
142
+ type="radio"
143
+ className={css.choiceInput}
144
+ name="smooth-stream-preset"
145
+ checked={state.preset === value}
146
+ disabled={!state.writable || state.saving || !state.enabled}
147
+ onChange={() => { props.edit({ preset: value }) }}
148
+ />
149
+ {t(label)}
150
+ </label>
151
+ ))}
152
+ </span>
153
+ </div>
116
154
  <label className={state.enabled ? css.field : `${css.field} ${css.fieldDisabled}`}>
117
155
  <span className={css.fieldHead}>
118
156
  <span className={css.label}>{t('thinkAutoExpand')}</span>
@@ -156,7 +194,7 @@ export function SmoothStreamCard(props: SmoothStreamCardProps) {
156
194
  title={state.canUpgrade ? undefined : t('updateUnavailable')}
157
195
  onClick={props.upgrade}
158
196
  >
159
- <span aria-hidden="true"><IconRefreshOutline14 /></span>
197
+ <span aria-hidden="true"><IconRefresh /></span>
160
198
  {t(state.upgrading ? 'updating' : 'update')}
161
199
  </button>
162
200
  </div>
@@ -0,0 +1,145 @@
1
+ /**
2
+ * High-performance append-only stream buffer with code-point cursor addressing.
3
+ *
4
+ * Avoids per-frame `[...content].slice().join('')` or repeated full-string allocations.
5
+ * Chunks are stored in an indexed rope array, and code-point to UTF-16 offset maps
6
+ * are updated incrementally per chunk.
7
+ */
8
+
9
+ export class StreamBuffer {
10
+ private chunks: string[] = []
11
+ private chunkCodePoints: number[] = []
12
+ private chunkCharLengths: number[] = []
13
+ private totalCodePoints = 0
14
+ private fullStringCache: string | null = null
15
+
16
+ constructor(initialText = '') {
17
+ if (initialText.length > 0) {
18
+ this.append(initialText)
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Count Unicode code points (perceived characters, UTF-16 surrogate pairs counted as 1).
24
+ */
25
+ static countCodePoints(str: string): number {
26
+ let count = 0
27
+ for (let i = 0; i < str.length; i++) {
28
+ const code = str.charCodeAt(i)
29
+ if (code >= 0xd800 && code <= 0xdbff && i + 1 < str.length) {
30
+ const next = str.charCodeAt(i + 1)
31
+ if (next >= 0xdc00 && next <= 0xdfff) {
32
+ i++
33
+ }
34
+ }
35
+ count++
36
+ }
37
+ return count
38
+ }
39
+
40
+ /**
41
+ * Convert code-point index into UTF-16 unit offset within a single chunk.
42
+ */
43
+ private static codePointToOffset(str: string, targetCp: number): number {
44
+ let cp = 0
45
+ let offset = 0
46
+ while (offset < str.length && cp < targetCp) {
47
+ const code = str.charCodeAt(offset)
48
+ if (code >= 0xd800 && code <= 0xdbff && offset + 1 < str.length) {
49
+ const next = str.charCodeAt(offset + 1)
50
+ if (next >= 0xdc00 && next <= 0xdfff) {
51
+ offset++
52
+ }
53
+ }
54
+ offset++
55
+ cp++
56
+ }
57
+ return offset
58
+ }
59
+
60
+ /**
61
+ * Append a new chunk to the buffer.
62
+ */
63
+ append(chunk: string): void {
64
+ if (chunk.length === 0) return
65
+ const cps = StreamBuffer.countCodePoints(chunk)
66
+ this.chunks.push(chunk)
67
+ this.chunkCodePoints.push(cps)
68
+ this.chunkCharLengths.push(chunk.length)
69
+ this.totalCodePoints += cps
70
+ this.fullStringCache = null
71
+ }
72
+
73
+ /**
74
+ * Total perceived characters (code points) in the buffer.
75
+ */
76
+ get length(): number {
77
+ return this.totalCodePoints
78
+ }
79
+
80
+ /**
81
+ * Reset the buffer to empty or to a new baseline string.
82
+ */
83
+ reset(text = ''): void {
84
+ this.chunks = []
85
+ this.chunkCodePoints = []
86
+ this.chunkCharLengths = []
87
+ this.totalCodePoints = 0
88
+ this.fullStringCache = null
89
+ if (text.length > 0) {
90
+ this.append(text)
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Extract a slice of text between `fromCp` and `toCp` (both in code points).
96
+ */
97
+ slice(fromCp: number, toCp: number): string {
98
+ const start = Math.max(0, fromCp)
99
+ const end = Math.min(this.totalCodePoints, toCp)
100
+ if (start >= end) return ''
101
+
102
+ // Fast path: if requesting the full string
103
+ if (start === 0 && end === this.totalCodePoints) {
104
+ return this.toString()
105
+ }
106
+
107
+ let result = ''
108
+ let accumulatedCp = 0
109
+
110
+ for (let i = 0; i < this.chunks.length; i++) {
111
+ const chunkCps = this.chunkCodePoints[i]!
112
+ const chunkEndCp = accumulatedCp + chunkCps
113
+
114
+ if (chunkEndCp <= start) {
115
+ accumulatedCp = chunkEndCp
116
+ continue
117
+ }
118
+ if (accumulatedCp >= end) {
119
+ break
120
+ }
121
+
122
+ const chunk = this.chunks[i]!
123
+ const localStartCp = Math.max(0, start - accumulatedCp)
124
+ const localEndCp = Math.min(chunkCps, end - accumulatedCp)
125
+
126
+ const startOffset = localStartCp === 0 ? 0 : StreamBuffer.codePointToOffset(chunk, localStartCp)
127
+ const endOffset = localEndCp === chunkCps ? chunk.length : StreamBuffer.codePointToOffset(chunk, localEndCp)
128
+
129
+ result += chunk.slice(startOffset, endOffset)
130
+ accumulatedCp = chunkEndCp
131
+ }
132
+
133
+ return result
134
+ }
135
+
136
+ /**
137
+ * Get the full concatenated string (cached until next append/reset).
138
+ */
139
+ toString(): string {
140
+ if (this.fullStringCache === null) {
141
+ this.fullStringCache = this.chunks.join('')
142
+ }
143
+ return this.fullStringCache
144
+ }
145
+ }