@solidrt/core 0.0.50 → 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.
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
+ }
package/src/types.d.ts CHANGED
@@ -236,6 +236,14 @@ export interface PointerEvent {
236
236
  */
237
237
  parentX: number
238
238
  parentY: number
239
+ /**
240
+ * Pointer movement since the previous move event, in logical pixels. Mouse
241
+ * reports hardware deltas (summed, never lost, and the only motion signal
242
+ * while the pointer is locked); touch reports position diffs. 0 on
243
+ * non-move events.
244
+ */
245
+ movementX: number
246
+ movementY: number
239
247
  /** Node id whose handler is currently running (bubbling changes it per call). */
240
248
  currentTarget: number
241
249
  /** Deepest node id of the event's path (the hit leaf). */
@@ -366,6 +374,154 @@ export interface LineGeometryProps {
366
374
  y2?: number
367
375
  }
368
376
 
377
+ // Native transitions (okf/done/native-transitions.md): declared once on
378
+ // the element, applied by the runtime to every later write of the covered
379
+ // properties. JS hands over targets; Rust interpolates every frame, so a
380
+ // running animation costs no JS per frame.
381
+
382
+ /** A cubic-bezier timing curve: a CSS name or [x1, y1, x2, y2] control values. */
383
+ export type TransitionCurve = "linear" | "ease" | "ease-in" | "ease-out" | "ease-in-out" | [number, number, number, number]
384
+
385
+ /**
386
+ * A perceptual spring - the default kind: a bare `{ duration }` is a
387
+ * critically damped spring. `duration` (ms) is the perceptual settling
388
+ * time, `bounce` in (-1, 1] the springiness - 0 (the default) settles
389
+ * without overshoot, positive values overshoot, negative values settle
390
+ * sluggishly. A new target while the spring runs keeps position and
391
+ * velocity, so the motion stays continuous - use springs for anything
392
+ * retargeted while moving.
393
+ */
394
+ export interface TransitionSpring {
395
+ duration: number
396
+ bounce?: number
397
+ /** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
398
+ delay?: number
399
+ /**
400
+ * Mount-time enter animation: at the element's first attach the property
401
+ * snaps to this value and animates to the value it mounted with. Numbers
402
+ * for the scalar properties; the color property takes a CSS color string
403
+ * or packed number. Per-property entries only (not under `all`); a later
404
+ * move or reorder re-runs nothing.
405
+ */
406
+ from?: number | string
407
+ /**
408
+ * Removal exit animation: an unmounted element stays visible, animates
409
+ * the property to this value (honoring `delay`), and is freed when its
410
+ * exit animations settle. Same value forms as `from`, per-property only.
411
+ * A move never plays it, the exiting element is hit-test invisible, its
412
+ * whole subtree stays painted with it, and no onTransitionEnd fires (the
413
+ * component is already disposed). An attached element keeps its layout
414
+ * slot until the exit finishes.
415
+ */
416
+ exit?: number | string
417
+ }
418
+
419
+ /**
420
+ * A duration/curve tween, opted into by naming the curve (a tween is
421
+ * always a specific curve; without one the spec reads as a spring).
422
+ * Duration in ms. A new target while the tween runs restarts it from the
423
+ * current value with the full duration (CSS semantics) - designer-timed,
424
+ * one-shot motion.
425
+ */
426
+ export interface TransitionTween {
427
+ duration: number
428
+ curve: TransitionCurve
429
+ /** Hold each write for this long (ms) before it applies; a newer write during the hold replaces it and restarts the delay. */
430
+ delay?: number
431
+ /**
432
+ * Mount-time enter animation: at the element's first attach the property
433
+ * snaps to this value and animates to the value it mounted with. Numbers
434
+ * for the scalar properties; the color property takes a CSS color string
435
+ * or packed number. Per-property entries only (not under `all`); a later
436
+ * move or reorder re-runs nothing.
437
+ */
438
+ from?: number | string
439
+ /**
440
+ * Removal exit animation: an unmounted element stays visible, animates
441
+ * the property to this value (honoring `delay`), and is freed when its
442
+ * exit animations settle. Same value forms as `from`, per-property only.
443
+ * A move never plays it, the exiting element is hit-test invisible, its
444
+ * whole subtree stays painted with it, and no onTransitionEnd fires (the
445
+ * component is already disposed). An attached element keeps its layout
446
+ * slot until the exit finishes.
447
+ */
448
+ exit?: number | string
449
+ }
450
+
451
+ /**
452
+ * The shorthand string: `"<duration>ms [curve] [<delay>ms]"` - `"300ms"` is
453
+ * a bounce-0 spring, `"300ms ease-out"` a tween, `"300ms ease-out 100ms"`
454
+ * delayed (first time value the duration, second the delay; ms only).
455
+ * Bounce, bezier control values and `from` need the object form.
456
+ */
457
+ export type TransitionShorthand = string
458
+
459
+ export type Transition = TransitionSpring | TransitionTween | TransitionShorthand
460
+
461
+ /** The property names a transition can cover (numeric scalars). */
462
+ export type TransitionPropName =
463
+ | "x"
464
+ | "y"
465
+ | "w"
466
+ | "h"
467
+ | "x1"
468
+ | "y1"
469
+ | "x2"
470
+ | "y2"
471
+ | "opacity"
472
+ | "rotate"
473
+ | "rotateX"
474
+ | "rotateY"
475
+ | "scale"
476
+ | "scaleX"
477
+ | "scaleY"
478
+ | "strokeWidth"
479
+ | "radius"
480
+ | "color"
481
+
482
+ /** Payload of onTransitionEnd: which animated property finished. */
483
+ export interface TransitionEndEvent {
484
+ property: TransitionPropName
485
+ }
486
+
487
+ export interface TransitionProps {
488
+ /**
489
+ * A runtime-side transition of one of this element's properties reached
490
+ * its target (natural settles only; a cancelled or retargeted animation
491
+ * does not fire until it finally settles). Delivered to this element
492
+ * only, no bubbling.
493
+ */
494
+ onTransitionEnd?: (event: TransitionEndEvent) => void
495
+ /**
496
+ * Animate later writes of the listed properties instead of snapping:
497
+ * `transition={{ x: { duration: 400, bounce: 0.2 }, opacity: "200ms ease-out" }}`.
498
+ * `all` covers every animatable property the element has, and a bare
499
+ * string is shorthand for it: `transition="300ms ease-out"`. Only
500
+ * properties the element carries animate (a d-rect has x, a view's x is
501
+ * its transform); the initial value never animates unless the entry sets
502
+ * `from` (an enter animation), and a non-numeric write (e.g. null)
503
+ * cancels the running animation and snaps. `null` clears the
504
+ * declaration; already-running animations finish.
505
+ */
506
+ transition?:
507
+ | ({
508
+ all?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand
509
+ /**
510
+ * Group stagger (ms): every descendant enter (`from`) or exit that
511
+ * begins in the same frame under this element gets `index * stagger`
512
+ * of extra delay, in occurrence order (enters and exits cascade
513
+ * separately). Nearest declaring ancestor wins; it orchestrates
514
+ * descendants only - ordinary writes and this element's own
515
+ * lifecycle are unaffected. Adds on top of a per-entry `delay`.
516
+ */
517
+ stagger?: number
518
+ } & {
519
+ [P in TransitionPropName]?: Transition
520
+ })
521
+ | TransitionShorthand
522
+ | null
523
+ }
524
+
369
525
  // Primitives
370
526
 
371
527
  export interface WindowProps extends LayoutProps, PointerProps {
@@ -462,9 +618,10 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
462
618
  repaintBoundary?: boolean | "snapshot" | "snapshot-no-aa"
463
619
  /**
464
620
  * Run this view's rasterized subtree through a GPU program and composite
465
- * the result in its place. Requires repaintBoundary="snapshot" (the cost
466
- * is snapshot semantics, kept explicit; declared without it the shader is
467
- * ignored with a warning). The pass is region-sized and split from content
621
+ * the result in its place. Requires a snapshot boundary
622
+ * (repaintBoundary="snapshot" or "snapshot-no-aa"; the cost is snapshot
623
+ * semantics, kept explicit; declared without one the shader is ignored
624
+ * with a warning). The pass is region-sized and split from content
468
625
  * invalidation: a params-only change re-runs just the pass against the
469
626
  * cached snapshot, so animating an effect over a static subtree never
470
627
  * re-rasterizes it.
@@ -576,6 +733,17 @@ export interface TextRunProps {
576
733
  lineHeight?: number
577
734
  fontStyle?: "normal" | "italic"
578
735
  fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
736
+ /**
737
+ * Underline in the run's own color, drawn straight through descenders
738
+ * (no skip-ink). Position and thickness come from the font's own metrics
739
+ * unless overridden; a font Impeller resolves through the system fallback
740
+ * gets the shipped Noto values.
741
+ */
742
+ textDecoration?: "none" | "underline"
743
+ /** Pixels from the baseline to the top of the underline. */
744
+ textUnderlineOffset?: number
745
+ /** Underline thickness in pixels. */
746
+ textDecorationThickness?: number
579
747
  }
580
748
 
581
749
  /**