@solidrt/core 0.0.49 → 0.0.51

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.
@@ -88,8 +88,9 @@ declare module "srt:dev" {
88
88
  /**
89
89
  * Register a named debug command, listable and callable from the dev server
90
90
  * (the list_debug / call_debug MCP tools). `args` arrives JSON-parsed; the
91
- * return value must be JSON-serializable and synchronous (promises are not
92
- * awaited). Re-registering a name replaces it; registrations reset on hot
91
+ * return value must be JSON-serializable and synchronous (an async command's
92
+ * Promise is not awaited - the call errors). Re-registering a name replaces
93
+ * it; registrations reset on hot
93
94
  * reload, so register at module init. Callable in every build, but only dev
94
95
  * clients ever invoke commands.
95
96
  */
package/src/scroll.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // scrollable region -- the offset and its clamping against the measured content
3
3
  // and viewport sizes -- and nothing with a UI opinion. Wheel/drag input,
4
4
  // momentum, scrollbars and styling are policy and belong to the component (the
5
- // "skin") that composes this, the same way createCaretScroll backs TextInput.
5
+ // "skin") that composes this, the same way createTextEditorLayout backs TextInput.
6
6
 
7
7
  import { createSignal, flush } from "@solidjs/signals"
8
8
  import { getBoundingBox } from "./core"
package/src/text-input.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  // Headless text-input mechanism. These primitives own the objective parts of
2
- // an editable single-line field -- the value buffer (text + caret/selection)
3
- // and the scroll-to-caret geometry -- and nothing with a UI opinion. Caret
4
- // blink, keybindings, placeholder and styling are policy and belong to the
5
- // component (the "skin") that composes these.
2
+ // an editable field -- the value buffer (text + caret/selection) and the line
3
+ // and caret geometry with the scroll that keeps the caret in view -- and
4
+ // nothing with a UI opinion. Caret blink, keybindings, placeholder and styling
5
+ // are policy and belong to the component (the "skin") that composes these.
6
6
 
7
- import { createSignal, flush } from "@solidjs/signals"
8
- import { getBoundingBox, measureText } from "./core"
7
+ import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
8
+ import { getBoundingBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
9
+ import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
9
10
  import { onLayout } from "./window"
10
11
 
11
12
  /**
@@ -31,6 +32,20 @@ export type TextBufferOptions = {
31
32
  onInput?: (value: string) => void
32
33
  /** Max length; inserts past it are clamped. */
33
34
  maxLength?: () => number | undefined
35
+ /**
36
+ * The offset one caret step left/right of `offset` in `text`: what a
37
+ * single Left/Right, Backspace or Delete moves over. Defaults to one code
38
+ * unit, which splits surrogate pairs and combining marks; an editor with
39
+ * grapheme geometry (createTextEditorLayout.step) supplies the real one.
40
+ */
41
+ step?: (text: string, offset: number, direction: "left" | "right") => number
42
+ /**
43
+ * Called before every edit with the range it replaces and the text going
44
+ * in (already clamped to maxLength), for owners that keep parallel state
45
+ * over the text (a rich text document's attributed runs). setValue/clear
46
+ * report a whole-text replace.
47
+ */
48
+ onReplace?: (start: number, end: number, text: string) => void
34
49
  }
35
50
 
36
51
  export type TextBuffer = {
@@ -71,8 +86,10 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
71
86
  let initial = options.defaultValue ?? ""
72
87
  let [internalValue, setInternalValue] = createSignal(initial)
73
88
  // The caret starts at the end of the current text: for a controlled buffer
74
- // that is the owner's value, which defaultValue does not reflect.
75
- let initialCaret = (options.value?.() ?? initial).length
89
+ // that is the owner's value, which defaultValue does not reflect. A
90
+ // one-shot read by design, so untracked (the buffer is created in a
91
+ // component body, where a bare reactive read is flagged).
92
+ let initialCaret = untrack(() => options.value?.() ?? initial).length
76
93
  let [selectionState, setSelectionState] = createSignal<Selection>({
77
94
  anchor: initialCaret,
78
95
  focus: initialCaret,
@@ -94,18 +111,26 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
94
111
 
95
112
  let setCaret = (offset: number) => setSelectionState({ anchor: offset, focus: offset })
96
113
 
97
- // Apply a text edit and place the caret, clamping to maxLength. The flush
114
+ let step = (text: string, offset: number, direction: "left" | "right"): number => {
115
+ if (options.step) return Math.max(0, Math.min(options.step(text, offset, direction), text.length))
116
+ return direction === "left" ? Math.max(0, offset - 1) : Math.min(text.length, offset + 1)
117
+ }
118
+
119
+ // Every edit is a replace of [start, end) by `text`, caret after it. The
120
+ // inserted text is clamped to what maxLength leaves room for. The flush
98
121
  // commits the writes (including a controlled owner's from onInput) before
99
122
  // returning: edits must observe each other within one task, because event
100
123
  // bursts can dispatch several handlers with no microtask between them
101
124
  // (Android IME input arrives as backspace+commit bursts; see
102
125
  // okf/backlog/event-burst-stale-signal-reads.md).
103
- let apply = (next: string, caret: number) => {
126
+ let replace = (start: number, end: number, text: string) => {
127
+ let v = value()
104
128
  let max = options.maxLength?.()
105
- if (max != null && next.length > max) next = next.slice(0, max)
106
- caret = Math.min(caret, next.length)
129
+ if (max != null) text = text.slice(0, Math.max(0, max - (v.length - (end - start))))
130
+ options.onReplace?.(start, end, text)
131
+ let next = v.slice(0, start) + text + v.slice(end)
107
132
  if (options.value?.() == null) setInternalValue(next)
108
- setCaret(caret)
133
+ setCaret(start + text.length)
109
134
  options.onInput?.(next)
110
135
  flush()
111
136
  }
@@ -116,23 +141,21 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
116
141
  caret: () => selection().focus,
117
142
 
118
143
  insertText: (text) => {
119
- let v = value()
120
144
  let [start, end] = range()
121
- apply(v.slice(0, start) + text + v.slice(end), start + text.length)
145
+ replace(start, end, text)
122
146
  },
123
147
 
124
148
  deleteBackward: () => {
125
- let v = value()
126
149
  let [start, end] = range()
127
- if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
128
- else if (start > 0) apply(v.slice(0, start - 1) + v.slice(start), start - 1)
150
+ if (start !== end) replace(start, end, "")
151
+ else if (start > 0) replace(step(value(), start, "left"), start, "")
129
152
  },
130
153
 
131
154
  deleteForward: () => {
132
155
  let v = value()
133
156
  let [start, end] = range()
134
- if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
135
- else if (end < v.length) apply(v.slice(0, end) + v.slice(end + 1), end)
157
+ if (start !== end) replace(start, end, "")
158
+ else if (end < v.length) replace(end, step(v, end, "right"), "")
136
159
  },
137
160
 
138
161
  move: (direction, opts) => {
@@ -146,8 +169,8 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
146
169
  return
147
170
  }
148
171
  let next = focus
149
- if (direction === "left") next = Math.max(0, focus - 1)
150
- else if (direction === "right") next = Math.min(len, focus + 1)
172
+ if (direction === "left") next = step(value(), focus, "left")
173
+ else if (direction === "right") next = step(value(), focus, "right")
151
174
  else if (direction === "start") next = 0
152
175
  else if (direction === "end") next = len
153
176
  setSelectionState({ anchor: extend ? anchor : next, focus: next })
@@ -160,62 +183,276 @@ export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
160
183
  flush()
161
184
  },
162
185
 
163
- setValue: (next) => apply(next, next.length),
164
- clear: () => apply("", 0),
186
+ setValue: (next) => replace(0, value().length, next),
187
+ clear: () => replace(0, value().length, ""),
165
188
  }
166
189
  }
167
190
 
168
- export type CaretScrollInput = {
191
+ export type TextEditorLayoutInput = {
169
192
  text: string
170
- fontSize: number
171
- /** Caret offset into `text`. Defaults to the text end. */
172
- caret?: number
193
+ font: MeasureTextOptions
194
+ /** Styled ranges over `text` (prepareText `runs`): the geometry then follows per-run fonts. */
195
+ runs?: TextRunRange[]
196
+ /** Caret offset into `text`. */
197
+ caret: number
173
198
  /** Px reserved so the caret stays visible at the viewport edge. Default 0. */
174
199
  caretWidth?: number
200
+ /** Break lines at the viewport width; else one line per hard break. */
201
+ wrap: boolean
202
+ }
203
+
204
+ /** One drawn line of an editor: `text.slice(start, end)` at `y`, `height` tall. */
205
+ export type EditorLine = {
206
+ start: number
207
+ end: number
208
+ y: number
209
+ height: number
210
+ /** Ink width. */
211
+ width: number
212
+ }
213
+
214
+ /** The caret's box in content coordinates (before scroll). */
215
+ export type CaretRect = { x: number; y: number; height: number }
216
+
217
+ export type TextEditorLayout = {
218
+ lines(): EditorLine[]
219
+ caret(): CaretRect
220
+ /** Index into lines() of the line the caret sits on. */
221
+ caretLine(): number
222
+ /** The caret position (grapheme boundary) on line `line` nearest to content x. */
223
+ offsetAtX(line: number, x: number): number
224
+ /** Index of the line at content y (clamped to the first/last line). */
225
+ lineAtY(y: number): number
226
+ /** The caret position one grapheme left/right of `offset`; a break sequence is one step. For createTextBuffer's `step`. */
227
+ step(offset: number, direction: "left" | "right"): number
228
+ scrollX(): number
229
+ scrollY(): number
175
230
  }
176
231
 
177
232
  /**
178
- * Returns the horizontal scroll offset that keeps the caret within the viewport
179
- * node. The offset is retained between frames and only adjusted when the caret
180
- * would fall outside the visible range (scrolled left when the caret runs past
181
- * the right edge, right when it moves before the left edge), so stationary text
182
- * does not jump. The viewport width and offset are computed in onLayout and the
183
- * synchronous flush drains the update before paint, so the scroll tracks a caret
184
- * or width change in the same frame. Pure geometry: no caret rendering and no
233
+ * The line and caret geometry of an editable text, plus the scroll offsets
234
+ * that keep the caret within the viewport node. Lines come from prepareText
235
+ * (with caret stops) + layoutNextLine at the viewport width (or unbounded
236
+ * when not wrapping) and are drawn by the caller, one d-text per line. A unit
237
+ * wider than the wrap width is split into its graphemes first, so long
238
+ * unbroken text wraps instead of overflowing. An empty text, or one ending in
239
+ * a hard break, still gets a (blank) last line to sit the caret on. Caret
240
+ * positions are the units' grapheme stops: the caret x, the nearest position
241
+ * to an x, and a caret step all come from the same shaping that is drawn.
242
+ *
243
+ * The scroll offsets are retained between frames and only adjusted when the
244
+ * caret would fall outside the visible range, so stationary text does not
245
+ * jump. The viewport size is read in onLayout and the synchronous flush
246
+ * drains the update before paint, so lines and scroll track a caret, text or
247
+ * size change in the same frame. Pure geometry: no caret rendering and no
185
248
  * placeholder/visual policy.
186
249
  */
187
- export function createCaretScroll(
250
+ export function createTextEditorLayout(
188
251
  viewport: () => { id: number } | undefined,
189
- input: () => CaretScrollInput,
190
- ): () => number {
252
+ input: () => TextEditorLayoutInput,
253
+ ): TextEditorLayout {
254
+ let [viewportSize, setViewportSize] = createSignal({ width: 0, height: 0 }, { equals: (a, b) => a.width === b.width && a.height === b.height })
191
255
  let [scrollX, setScrollX] = createSignal(0)
256
+ let [scrollY, setScrollY] = createSignal(0)
257
+
258
+ let prepared = createMemo(() => {
259
+ let { text, font, runs } = input()
260
+ return prepareText(text, { ...font, runs, carets: true })
261
+ })
262
+
263
+ // Lines carry their unit range so the caret math walks only their units.
264
+ type PlacedLine = EditorLine & { from: number; to: number }
265
+ let placed = createMemo((): { units: TextUnit[]; lines: PlacedLine[] } => {
266
+ let { text, font, wrap, caretWidth = 0 } = input()
267
+ // Wrapped lines leave room for the caret at the end of a full line, so a
268
+ // wrapping editor never scrolls horizontally.
269
+ let width = wrap ? Math.max(0, viewportSize().width - caretWidth) : Infinity
270
+ let units = wrap ? splitWide(prepared(), width) : prepared()
271
+ let out: PlacedLine[] = []
272
+ let y = 0
273
+ let cursor = 0
274
+ let line = layoutNextLine(units, cursor, width)
275
+ let hardBreak = false
276
+ while (line) {
277
+ out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
278
+ y += line.height
279
+ hardBreak = line.hardBreak
280
+ line = layoutNextLine(units, line.cursor, width)
281
+ }
282
+ if (out.length === 0 || hardBreak) {
283
+ let height = measureText(" ", font).height
284
+ let n = units.units.length
285
+ out.push({ start: text.length, end: text.length, y, height, width: 0, from: n, to: n })
286
+ }
287
+ return { units: units.units, lines: out }
288
+ })
289
+ let lines = createMemo((): EditorLine[] => placed().lines)
290
+
291
+ // The caret stops of a line, left to right, with the pen advanced per unit;
292
+ // duplicates at unit seams (a unit's end is the next one's start) skipped.
293
+ let lineStops = (index: number): { offset: number; x: number }[] => {
294
+ let { units, lines } = placed()
295
+ let line = lines[index]
296
+ if (!line) return []
297
+ let stops: { offset: number; x: number }[] = []
298
+ let pen = 0
299
+ for (let u = line.from; u < line.to; u++) {
300
+ let unit = units[u]!
301
+ for (let stop of unit.carets ?? []) {
302
+ let x = pen + stop.x
303
+ if (stops.length && stops[stops.length - 1]!.offset === stop.offset) continue
304
+ stops.push({ offset: stop.offset, x })
305
+ }
306
+ pen += unit.advance
307
+ }
308
+ if (stops.length === 0) stops.push({ offset: line.start, x: 0 })
309
+ return stops
310
+ }
311
+
312
+ // The line an offset sits on: the first whose range extends past it, so an
313
+ // offset on a soft-wrap boundary is the start of the next line (one offset
314
+ // is one position; a caret does not hang after the wrap space, which would
315
+ // take an affinity flag). The very end of the text is on the last line.
316
+ let lineOf = (offset: number): number => {
317
+ let ls = lines()
318
+ for (let i = 0; i < ls.length; i++) {
319
+ if (offset < ls[i]!.end) return i
320
+ }
321
+ return ls.length - 1
322
+ }
323
+ let caretLine = createMemo(() => lineOf(input().caret))
324
+
325
+ // The caret sits at the last stop at or before its offset (an offset inside
326
+ // a grapheme, e.g. from a controlled value, snaps back).
327
+ let caret = createMemo((): CaretRect => {
328
+ let offset = input().caret
329
+ let index = caretLine()
330
+ let line = lines()[index]!
331
+ let x = 0
332
+ for (let stop of lineStops(index)) {
333
+ if (stop.offset > offset) break
334
+ x = stop.x
335
+ }
336
+ return { x, y: line.y, height: line.height }
337
+ })
338
+
339
+ // Only positions that show on this line are candidates: a boundary offset
340
+ // that displays on the next line is that line's start, not this one's end.
341
+ let offsetAtX = (index: number, x: number): number => {
342
+ let best = lines()[index]?.start ?? 0
343
+ let bestDistance = Infinity
344
+ for (let stop of lineStops(index)) {
345
+ if (lineOf(stop.offset) !== index) continue
346
+ let d = Math.abs(stop.x - x)
347
+ if (d < bestDistance) {
348
+ best = stop.offset
349
+ bestDistance = d
350
+ }
351
+ }
352
+ return best
353
+ }
354
+
355
+ let lineAtY = (y: number): number => {
356
+ let ls = lines()
357
+ let index = 0
358
+ while (index + 1 < ls.length && ls[index + 1]!.y <= y) index++
359
+ return index
360
+ }
361
+
362
+ let step = (offset: number, direction: "left" | "right"): number => {
363
+ let { units } = placed()
364
+ let text = input().text
365
+ if (direction === "right") {
366
+ for (let unit of units) {
367
+ if (unit.end <= offset) continue
368
+ for (let stop of unit.carets ?? []) if (stop.offset > offset) return stop.offset
369
+ // Past the unit's shaped text: over its break characters in one step.
370
+ return unit.end
371
+ }
372
+ return text.length
373
+ }
374
+ for (let u = units.length - 1; u >= 0; u--) {
375
+ let unit = units[u]!
376
+ if (unit.start >= offset) continue
377
+ let stops = unit.carets ?? []
378
+ for (let i = stops.length - 1; i >= 0; i--) if (stops[i]!.offset < offset) return stops[i]!.offset
379
+ return unit.start
380
+ }
381
+ return 0
382
+ }
192
383
 
193
384
  onLayout(() => {
194
385
  let node = viewport()
195
386
  if (!node) return
196
- let vw = getBoundingBox(node)?.width ?? 0
197
- let { text, fontSize, caret, caretWidth = 0 } = input()
198
- let len = text.length
199
- let c = caret == null ? len : Math.max(0, Math.min(caret, len))
200
-
201
- let totalWidth = measureText(text, { fontSize }).width
202
- let caretX = c >= len ? totalWidth : measureText(text.slice(0, c), { fontSize }).width
203
- let maxScroll = Math.max(0, totalWidth + caretWidth - vw)
204
-
205
- let cur = scrollX()
206
- let next = cur
207
- if (vw <= 0) {
208
- next = 0
209
- } else if (caretX < cur) {
210
- next = caretX
211
- } else if (caretX + caretWidth > cur + vw) {
212
- next = caretX + caretWidth - vw
213
- }
214
- next = Math.max(0, Math.min(next, maxScroll))
387
+ let box = getBoundingBox(node)
388
+ setViewportSize({ width: box?.width ?? 0, height: box?.height ?? 0 })
389
+ flush()
390
+ let { width: vw, height: vh } = viewportSize()
391
+ let { caretWidth = 0, wrap } = input()
392
+ let ls = lines()
393
+ let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
394
+ let last = ls[ls.length - 1]!
395
+ let contentHeight = last.y + last.height
396
+ let c = caret()
215
397
 
216
- if (next !== cur) setScrollX(next)
398
+ setScrollX(wrap ? 0 : follow(scrollX(), c.x, caretWidth, vw, contentWidth + caretWidth))
399
+ setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
217
400
  flush()
218
401
  })
219
402
 
220
- return scrollX
221
- }
403
+ return { lines, caret, caretLine, offsetAtX, lineAtY, step, scrollX, scrollY }
404
+ }
405
+
406
+ // Wrap units wider than `width` (through their glued pieces) split into one
407
+ // unit per grapheme (from their caret stops), so the greedy breaker wraps
408
+ // them like `<text>`'s overflowWrap "anywhere". Everything else is passed
409
+ // through as is.
410
+ function splitWide(prepared: PreparedText, width: number): PreparedText {
411
+ let all = prepared.units
412
+ if (!all.some((u, i) => !u.glue && unitInk(all, i) > width)) return prepared
413
+ let wide = false
414
+ let units: TextUnit[] = []
415
+ for (let u = 0; u < all.length; u++) {
416
+ let unit = all[u]!
417
+ if (!unit.glue) wide = unitInk(all, u) > width
418
+ let stops = unit.carets
419
+ if (!wide || !stops || stops.length <= 2) {
420
+ units.push(unit)
421
+ continue
422
+ }
423
+ for (let i = 1; i < stops.length; i++) {
424
+ let a = stops[i - 1]!
425
+ let b = stops[i]!
426
+ let last = i === stops.length - 1
427
+ let advance = last ? unit.advance - a.x : b.x - a.x
428
+ units.push({
429
+ text: prepared.text.slice(a.offset, b.offset),
430
+ start: a.offset,
431
+ end: last ? unit.end : b.offset,
432
+ advance,
433
+ width: Math.max(0, Math.min(b.x, unit.width) - a.x),
434
+ ascent: unit.ascent,
435
+ descent: unit.descent,
436
+ hardBreak: last && unit.hardBreak,
437
+ glue: i === 1 && unit.glue,
438
+ run: unit.run,
439
+ carets: [
440
+ { offset: a.offset, x: 0 },
441
+ { offset: b.offset, x: b.x - a.x },
442
+ ],
443
+ })
444
+ }
445
+ }
446
+ return { text: prepared.text, units }
447
+ }
448
+
449
+ // The scroll offset along one axis that keeps [pos, pos + size] within a
450
+ // viewport of `extent`, moved only when it is out of view and clamped to the
451
+ // content.
452
+ function follow(current: number, pos: number, size: number, extent: number, content: number): number {
453
+ if (extent <= 0) return 0
454
+ let next = current
455
+ if (pos < current) next = pos
456
+ else if (pos + size > current + extent) next = pos + size - extent
457
+ return Math.max(0, Math.min(next, Math.max(0, content - extent)))
458
+ }