@solidrt/core 0.0.60 → 0.0.62

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
@@ -5,7 +5,7 @@
5
5
  // are policy and belong to the component (the "skin") that composes these.
6
6
 
7
7
  import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
8
- import { getBoundingBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
8
+ import { getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
9
9
  import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
10
10
  import { onLayout } from "./window"
11
11
 
@@ -247,50 +247,111 @@ export type TextEditorLayout = {
247
247
  *
248
248
  * The scroll offsets are retained between frames and only adjusted when the
249
249
  * caret would fall outside the visible range, so stationary text does not
250
- * jump. The viewport size is read in onLayout and the synchronous flush
251
- * drains the update before paint, so lines and scroll track a caret, text or
252
- * size change in the same frame. Pure geometry: no caret rendering and no
253
- * placeholder/visual policy.
250
+ * jump. They derive from the caret, the lines and the viewport size, so a
251
+ * frame that changes none of them does no scroll work. The viewport size is
252
+ * the node's layout box (the untransformed solved box: the lines are drawn in
253
+ * the node's own frame, so an ancestor's scale must not change the wrap
254
+ * width), read in onLayout; the post-layout flush drains the update before
255
+ * paint, so lines and scroll track a caret, text or size change in the same
256
+ * frame. A size change that re-breaks nothing (an empty field, lines that
257
+ * still fit, a height-only change) stops at the line placement: the lines,
258
+ * the caret and everything the caller derives from them keep their values
259
+ * and re-run nothing, and the placement reads the break inputs through a
260
+ * memo of its own rather than `input()` (which a caller builds per read),
261
+ * so a field inside a resizing box costs one placement of its units per
262
+ * frame, not a flush of its whole graph. Pure geometry: no caret rendering
263
+ * and no placeholder/visual policy.
254
264
  */
255
265
  export function createTextEditorLayout(
256
266
  viewport: () => { id: number } | undefined,
257
267
  input: () => TextEditorLayoutInput,
258
268
  ): TextEditorLayout {
259
- let [viewportSize, setViewportSize] = createSignal({ width: 0, height: 0 }, { equals: (a, b) => a.width === b.width && a.height === b.height })
260
- let [scrollX, setScrollX] = createSignal(0)
261
- let [scrollY, setScrollY] = createSignal(0)
269
+ // Width and height apart: wrapping depends on the width alone, so a
270
+ // height-only change (a field growing with its content) re-breaks nothing.
271
+ let [viewportWidth, setViewportWidth] = createSignal(0)
272
+ let [viewportHeight, setViewportHeight] = createSignal(0)
273
+
274
+ // The font as a value: input() builds a fresh options object per read, so
275
+ // field equality is what lets the metrics below key on the font itself.
276
+ let font = createMemo(() => input().font, { equals: sameOptions })
277
+
278
+ // One space in the font: its height sits the synthesized blank line, its
279
+ // width is the selection's break sliver. Measured once per font, not once
280
+ // per placement.
281
+ let space = createMemo(() => measureText(" ", font()))
262
282
 
263
283
  let prepared = createMemo(() => {
264
- let { text, font, runs } = input()
265
- return prepareText(text, { ...font, runs, carets: true })
284
+ let { text, runs } = input()
285
+ return prepareText(text, { ...font(), runs, carets: true })
266
286
  })
267
287
 
288
+ // What the breaks depend on, apart from the rest of the input: the caller
289
+ // builds input() per read (a field's value, font, caret and runs - some
290
+ // twenty reactive reads), and a placement re-run for a viewport width
291
+ // change must not pay for that. Equal by field, so only a real change
292
+ // reaches the placement.
293
+ let breaking = createMemo(
294
+ (): BreakInput => {
295
+ let { text, wrap, caretWidth = 0 } = input()
296
+ return { text, wrap, caretWidth }
297
+ },
298
+ { equals: sameBreakInput },
299
+ )
300
+
268
301
  // Lines carry their unit range so the caret math walks only their units.
269
- type PlacedLine = EditorLine & { from: number; to: number }
270
- let placed = createMemo((): { units: TextUnit[]; lines: PlacedLine[] } => {
271
- let { text, font, wrap, caretWidth = 0 } = input()
272
- // Wrapped lines leave room for the caret at the end of a full line, so a
273
- // wrapping editor never scrolls horizontally.
274
- let width = wrap ? Math.max(0, viewportSize().width - caretWidth) : Infinity
275
- let units = wrap ? splitWide(prepared(), width) : prepared()
276
- let out: PlacedLine[] = []
277
- let y = 0
278
- let cursor = 0
279
- let line = layoutNextLine(units, cursor, width)
280
- let hardBreak = false
281
- while (line) {
282
- out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
283
- y += line.height
284
- hardBreak = line.hardBreak
285
- line = layoutNextLine(units, line.cursor, width)
286
- }
287
- if (out.length === 0 || hardBreak) {
288
- let height = measureText(" ", font).height
289
- let n = units.units.length
290
- out.push({ start: text.length, end: text.length, y, height, width: 0, from: n, to: n })
291
- }
292
- return { units: units.units, lines: out }
293
- })
302
+ // Equal placements (same units, same breaks and metrics) keep the previous
303
+ // value, so nothing downstream re-runs for a width change that changed
304
+ // no line. Within a changed placement, a line equal to its predecessor
305
+ // keeps the previous object: a row bound to it (a non-keyed <For> item)
306
+ // sees no change and writes nothing, so a moved break costs the lines it
307
+ // touched, not every line of the field.
308
+ type PlacedLine = EditorLine & { from: number; to: number; hardBreak: boolean }
309
+ // `holds` is the range of wrap widths [min, max) the breaker yields these
310
+ // same lines at (see holdRange); the layout handler skips a width inside it.
311
+ type Placement = { units: TextUnit[]; lines: PlacedLine[]; holds: [number, number] }
312
+ let placed = createMemo(
313
+ (prev): Placement => {
314
+ let { text, wrap, caretWidth } = breaking()
315
+ // Wrapped lines leave room for the caret at the end of a full line, so a
316
+ // wrapping editor never scrolls horizontally.
317
+ let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
318
+ let units = wrap ? splitWide(prepared(), width) : prepared()
319
+ let out: PlacedLine[] = []
320
+ let y = 0
321
+ let cursor = 0
322
+ let line = layoutNextLine(units, cursor, width)
323
+ let hardBreak = false
324
+ while (line) {
325
+ out.push({
326
+ start: line.start,
327
+ end: line.end,
328
+ y,
329
+ height: line.height,
330
+ width: line.width,
331
+ from: line.from,
332
+ to: line.to,
333
+ hardBreak: line.hardBreak,
334
+ })
335
+ y += line.height
336
+ hardBreak = line.hardBreak
337
+ line = layoutNextLine(units, line.cursor, width)
338
+ }
339
+ if (out.length === 0 || hardBreak) {
340
+ let n = units.units.length
341
+ out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n, hardBreak: false })
342
+ }
343
+ if (prev && prev.units === units.units) {
344
+ for (let i = 0; i < out.length && i < prev.lines.length; i++) {
345
+ if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
346
+ }
347
+ }
348
+ // Split units are the width's own (a unit wider than it), so those
349
+ // lines hold for no other width.
350
+ let holds: [number, number] = units.units === prepared().units ? holdRange(units.units, out) : [Infinity, Infinity]
351
+ return { units: units.units, lines: out, holds }
352
+ },
353
+ { equals: samePlacement },
354
+ )
294
355
  let lines = createMemo((): EditorLine[] => placed().lines)
295
356
 
296
357
  // The caret stops of a line, left to right, with the pen advanced per unit;
@@ -375,7 +436,7 @@ export function createTextEditorLayout(
375
436
  let ls = lines()
376
437
  let first = lineOf(start)
377
438
  let last = lineOf(end)
378
- let breakWidth = measureText(" ", input().font).width
439
+ let breakWidth = space().width
379
440
  let out: SelectionRect[] = []
380
441
  for (let i = first; i <= last; i++) {
381
442
  let line = ls[i]!
@@ -416,22 +477,60 @@ export function createTextEditorLayout(
416
477
  return 0
417
478
  }
418
479
 
419
- onLayout(() => {
420
- let node = viewport()
421
- if (!node) return
422
- let box = getBoundingBox(node)
423
- setViewportSize({ width: box?.width ?? 0, height: box?.height ?? 0 })
424
- flush()
425
- let { width: vw, height: vh } = viewportSize()
426
- let { caretWidth = 0, wrap } = input()
480
+ // Each offset follows from its own previous value: `follow` moves it only
481
+ // when the caret has left the visible range, so the memo carries the
482
+ // retained position across recomputes.
483
+ let scrollX = createMemo((prev: number | undefined): number => {
484
+ let { caretWidth, wrap } = breaking()
485
+ if (wrap) return 0
486
+ let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
487
+ let c = caret()
488
+ return follow(prev ?? 0, c.x, caretWidth, viewportWidth(), contentWidth + caretWidth)
489
+ })
490
+ let scrollY = createMemo((prev: number | undefined): number => {
427
491
  let ls = lines()
428
- let contentWidth = ls.reduce((w, l) => Math.max(w, l.width), 0)
429
492
  let last = ls[ls.length - 1]!
430
- let contentHeight = last.y + last.height
431
493
  let c = caret()
494
+ return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
495
+ })
432
496
 
433
- setScrollX(wrap ? 0 : follow(scrollX(), c.x, caretWidth, vw, contentWidth + caretWidth))
434
- setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
497
+ // Whether the lines placed at the last written width are what the breaker
498
+ // yields at `width` too. Such a width is not written: the write would mark
499
+ // the whole graph below the placement for a re-check that finds nothing,
500
+ // which is what a field inside a resizing box paid per frame. So
501
+ // viewportWidth lags the box by less than a break while wrapping, and a
502
+ // placement re-run for a text change at the lagging width is checked here
503
+ // again on that frame and corrected before paint (writes from onLayout
504
+ // land in the same frame's re-layout; the same holds for the height
505
+ // below). Unwrapped, the width is the horizontal scroll's extent and is
506
+ // always written.
507
+ let keepsBreaks = (width: number): boolean => {
508
+ let { wrap, caretWidth } = breaking()
509
+ if (!wrap) return false
510
+ let [min, max] = placed().holds
511
+ let w = Math.max(0, width - caretWidth)
512
+ return w >= min && w < max
513
+ }
514
+
515
+ // The same for the height: a height at which the retained scroll offset
516
+ // still keeps the caret in view is not written, so the scroll graph stays
517
+ // untouched while a short field's box grows and shrinks.
518
+ let keepsScroll = (height: number): boolean => {
519
+ let ls = lines()
520
+ let last = ls[ls.length - 1]!
521
+ let c = caret()
522
+ let current = scrollY()
523
+ return follow(current, c.y, c.height, height, last.y + last.height) === current
524
+ }
525
+
526
+ onLayout(() => {
527
+ let node = viewport()
528
+ if (!node) return
529
+ let box = getLayoutBox(node)
530
+ let width = box?.width ?? 0
531
+ let height = box?.height ?? 0
532
+ if (!keepsBreaks(width)) setViewportWidth(width)
533
+ if (!keepsScroll(height)) setViewportHeight(height)
435
534
  })
436
535
 
437
536
  return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
@@ -480,6 +579,52 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
480
579
  return { text: prepared.text, units }
481
580
  }
482
581
 
582
+ // The wrap widths [min, max) at which the greedy breaker yields exactly
583
+ // `lines` over `units`: every line's ink fits (min), and no line's first
584
+ // unit fits at the end of the line before it (max), except across a hard
585
+ // break, where it never joins. Greedy breaking is decided line by line on
586
+ // just those two tests, so inside the range the placement is unchanged.
587
+ function holdRange(units: TextUnit[], lines: { from: number; to: number; width: number; hardBreak: boolean }[]): [number, number] {
588
+ let min = 0
589
+ let max = Infinity
590
+ for (let i = 0; i < lines.length; i++) {
591
+ let line = lines[i]!
592
+ if (line.width > min) min = line.width
593
+ let next = lines[i + 1]
594
+ if (!next || line.hardBreak || next.from >= units.length) continue
595
+ let pen = 0
596
+ for (let u = line.from; u < line.to; u++) pen += units[u]!.advance
597
+ let join = pen + unitInk(units, next.from)
598
+ if (join < max) max = join
599
+ }
600
+ return [min, max]
601
+ }
602
+
603
+ type BreakInput = { text: string; wrap: boolean; caretWidth: number }
604
+
605
+ function sameBreakInput(a: BreakInput, b: BreakInput): boolean {
606
+ return a.text === b.text && a.wrap === b.wrap && a.caretWidth === b.caretWidth
607
+ }
608
+
609
+ // Font options compared field by field: the same keys with the same values.
610
+ function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
611
+ let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
612
+ let kb = Object.keys(b) as (keyof MeasureTextOptions)[]
613
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k])
614
+ }
615
+
616
+ // The same break (text range) at the same place with the same metrics.
617
+ function sameLine(a: EditorLine, b: EditorLine): boolean {
618
+ return a.start === b.start && a.end === b.end && a.y === b.y && a.height === b.height && a.width === b.width
619
+ }
620
+
621
+ // Two placements of the same units with the same lines, break for break and
622
+ // metric for metric.
623
+ function samePlacement(a: { units: TextUnit[]; lines: EditorLine[] }, b: { units: TextUnit[]; lines: EditorLine[] }): boolean {
624
+ if (a.units !== b.units || a.lines.length !== b.lines.length) return false
625
+ return a.lines.every((l, i) => sameLine(l, b.lines[i]!))
626
+ }
627
+
483
628
  // The scroll offset along one axis that keeps [pos, pos + size] within a
484
629
  // viewport of `extent`, moved only when it is out of view and clamped to the
485
630
  // content.
package/src/transform.ts CHANGED
@@ -2,6 +2,8 @@ import { onSettled } from "@solidjs/signals"
2
2
  import { on } from "srt:events"
3
3
  import type { PointerEvent } from "./types"
4
4
  import { arena, type ArenaOwner } from "./arena"
5
+ import { createVelocityTracker, flingVelocity } from "./velocity"
6
+ import type { Velocity } from "./velocity"
5
7
 
6
8
  // Focal travel or span change in logical pixels before the transform
7
9
  // activates; the same threshold createPan uses, so the two race fairly.
@@ -53,7 +55,10 @@ export interface TransformOptions {
53
55
  onTransformStart?: () => void
54
56
  /** Streams one delta per frame; compose them multiplicatively (scale) / additively (dx, dy, rotation). */
55
57
  onTransformMove?: (t: TransformDelta) => void
56
- onTransformEnd?: () => void
58
+ /** The last finger's lift, with the focal point's velocity at it in
59
+ * parent-frame pixels per second (velocity.ts; zero after a rest or
60
+ * under the fling minimum). */
61
+ onTransformEnd?: (velocity: Velocity) => void
57
62
  }
58
63
 
59
64
  // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
@@ -147,6 +152,10 @@ export function createTransform(options: TransformOptions) {
147
152
  // immediately would bake a jolt into the next delta. A rebase frame
148
153
  // emits nothing.
149
154
  let rebase = false
155
+ // Parent-frame focal positions per delivering flush, for the lift's
156
+ // velocity; a rebase translates the history by the focal jump so a
157
+ // finger joining or leaving keeps it continuous.
158
+ let tracker = createVelocityTracker()
150
159
 
151
160
  // Centroids in all three frames, the span in window pixels (it feeds the
152
161
  // finger-travel gates), the pair angle in the parent frame.
@@ -193,6 +202,7 @@ export function createTransform(options: TransformOptions) {
193
202
  pinch = false
194
203
  dirty = false
195
204
  rebase = false
205
+ tracker.reset()
196
206
  }
197
207
  let cancel = reset
198
208
  let owner: ArenaOwner = { cancel }
@@ -204,8 +214,12 @@ export function createTransform(options: TransformOptions) {
204
214
  if (rebase) {
205
215
  // Anchor from same-age positions; emits nothing - an activation or
206
216
  // set change must not produce a jump delta. Motion that arrived in
207
- // the same batch folds into the anchor.
217
+ // the same batch folds into the anchor (and into the velocity
218
+ // history's shift, one frame the fit never sees).
219
+ let prev = ref
208
220
  ref = measure()
221
+ if (prev) tracker.shift(ref.px - prev.px, ref.py - prev.py)
222
+ tracker.push(ref.px, ref.py)
209
223
  spanBase = ref.span
210
224
  smoothSpan = ref.span
211
225
  rebase = false
@@ -215,6 +229,7 @@ export function createTransform(options: TransformOptions) {
215
229
  if (!dirty || !ref) return
216
230
  dirty = false
217
231
  let m = measure()
232
+ tracker.push(m.px, m.py)
218
233
  let prevSpan = smoothSpan
219
234
  smoothSpan += (m.span - smoothSpan) * SPAN_SMOOTH
220
235
  spanRate += (Math.abs(smoothSpan - prevSpan) - spanRate) * QUIET_SMOOTH
@@ -299,6 +314,7 @@ export function createTransform(options: TransformOptions) {
299
314
  pinch = Math.abs(m.span - ref.span) >= SLOP
300
315
  spanRate = pinch ? 1 : 0
301
316
  rebase = true
317
+ tracker.reset()
302
318
  options.onTransformStart?.()
303
319
  return
304
320
  }
@@ -310,12 +326,16 @@ export function createTransform(options: TransformOptions) {
310
326
  arena.release(e.pointerId, owner)
311
327
  pointers.delete(e.pointerId)
312
328
  if (pointers.size === 0) {
329
+ // The lift's own position is not measured (no terminator
330
+ // follows it); the fit over the delivered frames is the speed.
331
+ let velocity = flingVelocity(tracker.velocity())
313
332
  active = false
314
333
  ref = null
315
334
  pinch = false
316
335
  dirty = false
317
336
  rebase = false
318
- options.onTransformEnd?.()
337
+ tracker.reset()
338
+ options.onTransformEnd?.(velocity)
319
339
  } else {
320
340
  rebase = true
321
341
  pinch = pinch && pointers.size >= 2
package/src/types.d.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  /// <reference path="./runtime-modules.d.ts" />
3
3
 
4
4
  import type { Gradient } from "./color"
5
+ import type { CursorId, CursorName } from "./cursor"
5
6
  import type { ProgramId, TextureBindings, TextureId } from "flux:gpu"
6
7
  import type { TextInputHints } from "flux:rendertree"
7
8
  import type { Element } from "solid-js"
@@ -172,6 +173,11 @@ export interface PaintProps {
172
173
  strokeCap?: StrokeCap
173
174
  strokeJoin?: StrokeJoin
174
175
  strokeMiter?: number
176
+ /**
177
+ * Stroke thickness in logical px; default 1 (SVG's). 0 draws no stroke at
178
+ * all, like CSS `border-width: 0` - never a hairline - so a stroke-only
179
+ * shape (or a line) at 0 draws nothing.
180
+ */
175
181
  strokeWidth?: number
176
182
  }
177
183
 
@@ -380,6 +386,13 @@ export interface PointerProps {
380
386
  */
381
387
  focusable?: boolean
382
388
  pointerEvents?: "auto" | "none" | "all"
389
+ /**
390
+ * The mouse cursor while this element is hovered: a platform shape by its
391
+ * CSS name, `"none"` to hide it, or a createCursor handle for an image.
392
+ * The innermost hovered element that sets one wins; none on the path
393
+ * means the default arrow. Mouse and pen; touch shows no cursor.
394
+ */
395
+ cursor?: CursorName | CursorId
383
396
  }
384
397
 
385
398
  /**
@@ -643,17 +656,22 @@ export interface TransitionProps {
643
656
  */
644
657
  stagger?: number
645
658
  /**
646
- * Layout slide: when a layout moves this element (a row removed
647
- * above it, one inserted, a `<For>` reorder, a resize), it slides
648
- * from where it was painted to its new box on this motion instead
649
- * of jumping, and is hit-tested where it is drawn. A reflow
659
+ * Layout slide: when a layout moves or resizes this element (a row
660
+ * removed above it, one inserted, a `<For>` reorder, a card growing
661
+ * to fit more content, a window resize), it slides from the box it
662
+ * was painted at to its new box on this motion instead of jumping,
663
+ * position and size, and is painted, clipped, hit-tested and
664
+ * backdrop-filtered at the box it is drawn at. While its size is in
665
+ * flight its children are laid out against that box every frame
666
+ * (text re-wraps, rows re-flow), as under a CSS width transition; a
667
+ * child's own `layout` slide yields to the parent's motion. A reflow
650
668
  * mid-slide retargets (a spring keeps its velocity). Its first
651
669
  * layout, a reparent and a `display: none` toggle snap. The slide is
652
670
  * parent-relative: a parent that moves carries its children at
653
671
  * once, so declare it on every level that should glide. `true`
654
672
  * borrows the `all` entry's motion (an error without one); `all`
655
- * alone never slides. Position only today: a size change snaps.
656
- * Settles with onTransitionEnd property `"layout"`.
673
+ * alone never slides. Settles with onTransitionEnd property
674
+ * `"layout"`.
657
675
  */
658
676
  layout?: Omit<TransitionSpring, "from" | "exit"> | Omit<TransitionTween, "from" | "exit"> | TransitionShorthand | boolean
659
677
  } & {
@@ -804,7 +822,16 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
804
822
  * pixels beneath at that point in the frame, so treat it as a deliberate
805
823
  * panel, not a casual style. Inside a `repaintBoundary="snapshot"`
806
824
  * subtree it sees only that boundary's own offscreen content, never what
807
- * is behind the boundary (the same containment `blendMode` has there).
825
+ * is behind the boundary (the same containment `blendMode` has there),
826
+ * and under an ancestor with a `filter` it sees that ancestor's
827
+ * composited layer. An ancestor's `opacity` is not a containment: the
828
+ * glass keeps its blur through the fade (the runtime re-filters the
829
+ * window ahead of the fading group at the group's opacity), so a panel
830
+ * entering with `from: { opacity: 0 }` frosts in as it appears. That
831
+ * re-filter is an approximation: the group's sharp pixels then draw over
832
+ * the blurred ones at its opacity, so mid-fade the glass reads partly
833
+ * sharp, most at a low opacity; exact group blending would need the
834
+ * capture inside the group's layer, which the renderer cannot do.
808
835
  */
809
836
  backdropFilter?: FilterProps
810
837
  }
@@ -1052,6 +1079,16 @@ export interface TextProps extends PaintProps, PointerProps, TextRunProps {
1052
1079
  */
1053
1080
  export interface TextureProps extends PaintProps, PointerProps {
1054
1081
  src?: TextureId
1082
+ /**
1083
+ * Corner radius, as on a rect: the image is drawn as a rounded rect filled
1084
+ * with the texture, in one draw and with no clip, so a frosted or image
1085
+ * pane keeps rounded corners at the cost of the plain draw (a rounded
1086
+ * `clipRadius` around it costs a tiled GPU a third of the frame per ten
1087
+ * panes while the box is in flight). A single number rounds all four
1088
+ * corners; an array is [top-left, top-right, bottom-right, bottom-left].
1089
+ * Animatable in its single-number form, like a rect's.
1090
+ */
1091
+ radius?: number | [number, number, number, number]
1055
1092
  /**
1056
1093
  * How the texture's pixels map to the element box (CSS object-fit).
1057
1094
  * "fill" (default) stretches; "cover" and "none" crop; "contain" and
@@ -0,0 +1,134 @@
1
+ // The velocity tracker every recognizer reads at a lift: the pointer's
2
+ // speed from the positions of its last VELOCITY_WINDOW_MS as a
3
+ // least-squares line, not the last two samples. The final sample before a
4
+ // lift is often stationary, and frame batching makes the last delta a
5
+ // whole frame old, so a two-point estimate reads anything from zero to a
6
+ // jolt; a fit over the window reads the finger's actual speed. One
7
+ // estimator whatever recognizer asks (createPan, createTransform, the
8
+ // pointer feed), so the 2d camera and a control after it never re-derive
9
+ // their own.
10
+ //
11
+ // Samples are positions in whatever frame the caller measures (the
12
+ // recognizers push the node's parent frame, so the velocity applies 1:1
13
+ // as their deltas do; a swipe classifier pushes window pixels, the
14
+ // finger's own travel) with performance.now() at handler time, the
15
+ // precedent in transform.ts: PointerEvent carries no timestamp.
16
+
17
+ // Fit horizon: Flutter's and Android's VelocityTracker window.
18
+ const VELOCITY_WINDOW_MS = 100
19
+ // Largest speed reported, px/s (Flutter's kMaxFlingVelocity): a sensor
20
+ // glitch cannot launch the content across the world.
21
+ const VELOCITY_MAX = 8000
22
+ // A position unchanged for this long at the read means the finger rested
23
+ // before lifting: the velocity is zero, whatever the window still holds.
24
+ // Measured from the last sample that MOVED, not the newest sample's age:
25
+ // the runtime's resampler delivers an extrapolated step and its correction
26
+ // up to two frames after the finger stopped, and any same-position
27
+ // re-delivery must not restart the clock either.
28
+ const VELOCITY_REST_MS = 50
29
+ // Ring capacity: a 100 ms window holds 12 samples at 120 Hz; the rest is
30
+ // room for a burst of same-frame samples.
31
+ const VELOCITY_SAMPLES = 20
32
+ /** Release speeds under this (px/s, Flutter's kMinFlingVelocity) are not a
33
+ * fling: a recognizer delivers zero for them, so a finger that slowed to a
34
+ * stop leaves the content where it is. */
35
+ export const FLING_MIN_VELOCITY = 50
36
+
37
+ export type Velocity = { vx: number; vy: number }
38
+
39
+ export interface VelocityTracker {
40
+ /** Record a position; `at` defaults to performance.now(). */
41
+ push(x: number, y: number, at?: number): void
42
+ /** Translate every sample: a recognizer rebasing its reference point
43
+ * (a finger joining or leaving a transform) keeps the history
44
+ * continuous instead of dropping it. */
45
+ shift(dx: number, dy: number): void
46
+ reset(): void
47
+ /** The speed at `at` (default now), px/s per axis: a least-squares fit
48
+ * over the window, zero after a rest, clamped to VELOCITY_MAX. */
49
+ velocity(at?: number): Velocity
50
+ }
51
+
52
+ const ZERO: Velocity = { vx: 0, vy: 0 }
53
+
54
+ /** Zero under FLING_MIN_VELOCITY, else the velocity itself. */
55
+ export let flingVelocity = (v: Velocity): Velocity => (Math.hypot(v.vx, v.vy) < FLING_MIN_VELOCITY ? ZERO : v)
56
+
57
+ export function createVelocityTracker(): VelocityTracker {
58
+ let xs = new Float64Array(VELOCITY_SAMPLES)
59
+ let ys = new Float64Array(VELOCITY_SAMPLES)
60
+ let ts = new Float64Array(VELOCITY_SAMPLES)
61
+ // Ring: `count` samples ending at index `head - 1`.
62
+ let head = 0
63
+ let count = 0
64
+ // When the position last changed (the rest rule's clock).
65
+ let movedAt = -Infinity
66
+ return {
67
+ push(x, y, at = performance.now()) {
68
+ if (count === 0) movedAt = at
69
+ else {
70
+ let last = (head - 1 + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
71
+ if (xs[last] !== x || ys[last] !== y) movedAt = at
72
+ }
73
+ xs[head] = x
74
+ ys[head] = y
75
+ ts[head] = at
76
+ head = (head + 1) % VELOCITY_SAMPLES
77
+ if (count < VELOCITY_SAMPLES) count++
78
+ },
79
+ shift(dx, dy) {
80
+ for (let i = 0; i < count; i++) {
81
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
82
+ xs[k] = xs[k]! + dx
83
+ ys[k] = ys[k]! + dy
84
+ }
85
+ },
86
+ reset() {
87
+ head = 0
88
+ count = 0
89
+ },
90
+ velocity(at = performance.now()) {
91
+ if (count < 2) return ZERO
92
+ if (at - movedAt > VELOCITY_REST_MS) return ZERO
93
+ // Means over the window, then the slope of each axis against time.
94
+ let n = 0
95
+ let tm = 0
96
+ let xm = 0
97
+ let ym = 0
98
+ for (let i = 0; i < count; i++) {
99
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
100
+ if (at - ts[k]! > VELOCITY_WINDOW_MS) break
101
+ n++
102
+ tm += ts[k]!
103
+ xm += xs[k]!
104
+ ym += ys[k]!
105
+ }
106
+ if (n < 2) return ZERO
107
+ tm /= n
108
+ xm /= n
109
+ ym /= n
110
+ let tt = 0
111
+ let tx = 0
112
+ let ty = 0
113
+ for (let i = 0; i < n; i++) {
114
+ let k = (head - 1 - i + VELOCITY_SAMPLES) % VELOCITY_SAMPLES
115
+ let dt = ts[k]! - tm
116
+ tt += dt * dt
117
+ tx += dt * (xs[k]! - xm)
118
+ ty += dt * (ys[k]! - ym)
119
+ }
120
+ // Every sample the same age (one batched frame): no slope to read.
121
+ if (tt === 0) return ZERO
122
+ // Slopes are px per ms; the result is px per second.
123
+ let vx = (tx / tt) * 1000
124
+ let vy = (ty / tt) * 1000
125
+ let speed = Math.hypot(vx, vy)
126
+ if (speed > VELOCITY_MAX) {
127
+ let f = VELOCITY_MAX / speed
128
+ vx *= f
129
+ vy *= f
130
+ }
131
+ return { vx, vy }
132
+ },
133
+ }
134
+ }
package/src/video.ts CHANGED
@@ -8,9 +8,11 @@
8
8
  // A plane player (`present: "plane"`, Android) has no texture: the platform
9
9
  // composites the picture fullscreen beneath the UI on the video's own clock,
10
10
  // and the UI simply draws over it. It exists from open until the owner is
11
- // disposed, and it streams: the source may be a URL, read as it plays. On a
12
- // platform without a plane the open fails (error() with kind "no-plane"),
13
- // and an app that runs everywhere falls back to a texture player itself.
11
+ // disposed. On a platform without a plane the open fails (error() with kind
12
+ // "no-plane"), and an app that runs everywhere falls back to a texture
13
+ // player itself. Both players stream (the source may be a URL, read as it
14
+ // plays) and both run off the frame loop: a texture player's frames are
15
+ // decoded on their own thread and shown at the UI's cadence.
14
16
  //
15
17
  // The imperative primitive lives in the `flux:video` module; import { open }
16
18
  // from "flux:video" for non-reactive use.
@@ -56,9 +58,8 @@ export type VideoStream = {
56
58
  /** Pause playback; the current frame stays displayed. */
57
59
  pause(): void
58
60
  /**
59
- * Seek to a time in seconds. A no-op while opening, on a texture player,
60
- * and on a source that cannot seek (see `seekable`). While paused the
61
- * target frame is shown.
61
+ * Seek to a time in seconds. A no-op while opening and on a source that
62
+ * cannot seek (see `seekable`). While paused the target frame is shown.
62
63
  */
63
64
  seek(seconds: number): void
64
65
  /** Whether `seek` does anything (plain read, not a signal). */