@solidrt/core 0.0.66 → 0.0.68

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/AGENTS.md CHANGED
@@ -138,9 +138,9 @@ that reads exactly like Solid fallout.
138
138
  window (message, stack, a Reset button that retries the failed
139
139
  computations) and logs `Uncaught error`. `<Errored>` gives a subtree its
140
140
  own in-place fallback.
141
- An `onFrame` callback is its own boundary, event-listener style: a throw
142
- is caught and logged (`Error in onFrame callback`, repeating every
143
- frame), the subscription stays, and THE REST OF THAT CALLBACK IS
141
+ An `onFrame` (or `onBeforeRender`) callback is its own boundary,
142
+ event-listener style: a throw is caught and logged (`Error in onFrame
143
+ callback`, repeating every frame), the subscription stays, and THE REST OF THAT CALLBACK IS
144
144
  ABANDONED - what it wrote before the throw lands, what came after never
145
145
  does, frame after frame while the app keeps presenting. A partial frame
146
146
  is therefore a live possibility: every sprite drawn after the throwing
@@ -613,7 +613,17 @@ that reads exactly like Solid fallout.
613
613
  `onFrame(fn, { demand: false })` is the same hook without the standing
614
614
  frame request: it runs on every frame the runtime delivers and the app
615
615
  presents only when the body writes, for a loop that follows time but
616
- seldom changes the picture. `frameTime()` is the current frame's `tick`
616
+ seldom changes the picture. `onBeforeRender(fn)` is the frame's late
617
+ update: it runs after every onFrame callback and the reactive flush,
618
+ when the frame's state is final, for what must see everything the frame
619
+ wrote and write once more (a camera follow, a fit to a measured box;
620
+ Unity's LateUpdate); its signal writes are flushed before the paint
621
+ too, the way onLayout's are. `onBeforeRender(fn, {
622
+ publish: true })` runs after those, and again after a frame's onLayout
623
+ handlers: the pass where an extension hands the engine what the frame
624
+ wrote into its mirrors (a scene its record buffers and lights, a sprite
625
+ layer its style and count), so a write made anywhere in the frame's JS
626
+ is in that frame's picture, never the next. `frameTime()` is the current frame's `tick`
617
627
  read from anywhere (not reactive, requests nothing): the app's own
618
628
  clock for code with no tick in hand, where `performance.now()` would
619
629
  ignore the dev clock, `sol render` and tests. A JS
@@ -151,27 +151,29 @@ Rules, in order of leverage:
151
151
  snapshot instead of re-rasterizing. A window shader's output is invisible
152
152
  to get_snapshot and every other MCP tool; `bun run sol render` is the only
153
153
  way to see it (see @solidrt/cli AGENTS.md).
154
- 8. `flux:wasm` runs a pure interpreter (wasmi, no JIT), so temper browser
155
- expectations - but do not write it off for compute. A genuinely numeric
156
- kernel (typed-array math, tight inner loops, no host calls inside the
157
- loop) compiled from a systems language can come out a real multiple
158
- faster than the same loop in interpreted JavaScript, and when profiling
159
- shows such a kernel is what the app is spending its time on, that
160
- multiple is worth having: measure the JS loop, port the kernel, measure
161
- again, keep whichever wins. What wasm does not do is speed up
162
- render-path work (rules 1-3 are that leverage), and every host call
163
- costs marshalling, so batch at the boundary - one call over a byte
164
- buffer, not a call per element. It is also the way to ship one compiled
165
- module across every target with no native toolchain, and it pairs with
166
- an isolate when a call runs long enough to block.
167
- 9. `flux:ffi` (dlopen of a native library) is a binding tool, not a
168
- performance tool. It needs a shared library compiled per platform and
169
- architecture and shipped under each target's packing rules (Android
170
- loads only what arrives inside the APK as a lib*.so), so reaching for
171
- it "to make something fast" buys a build-and-packaging problem on every
172
- platform the app targets. Use it when the app must call a native
173
- library that already exists and already ships for those targets; for
174
- speed, everything above comes first.
154
+ 8. `flux:wasm` compiles a module to machine code on devices that allow it
155
+ (`Flux.capabilities` lists `wasm-native`: desktop, 64-bit Android) and
156
+ interprets it elsewhere (iOS, 32-bit Android). Both lanes are the fast
157
+ lane for compute: measured on a desktop, an arm64 tablet and a 32-bit
158
+ TV, a numeric kernel (typed-array math, tight inner loops, no host calls
159
+ inside the loop) runs 18x to 960x faster than the same loop in
160
+ JavaScript when compiled and 4x to 88x when interpreted, float at the low
161
+ end, integer and SIMD-shaped loops at the high end. So when profiling
162
+ shows such a kernel is where the app spends its time, port it: measure
163
+ the JS loop, port the kernel, measure again, keep whichever wins. The
164
+ boundary is the cost, not the code: a host call out of the module costs
165
+ 3x to 7x a plain JavaScript call, two to three hundred iterations of a
166
+ tight loop, so batch at the boundary - one call per frame over a byte
167
+ buffer, never a call per element. What wasm does not do is speed up
168
+ render-path work (rules 1-3 are that leverage). It is also the way to
169
+ ship one compiled module across every target with no native toolchain,
170
+ and it pairs with an isolate when a call runs long enough to block.
171
+ 9. There is no native-library route from app code. A shared library is
172
+ compiled per platform, so it cannot travel in a portable app payload,
173
+ and Android loads only what arrived inside the APK. Binding one is a
174
+ custom runtime build: the runtime crates with the library registered
175
+ beside the built-in modules. For speed, everything above comes first;
176
+ for a proven library that has a wasm build, rule 8.
175
177
  10. Text costs shaping, once per word per style, through a shared word cache;
176
178
  line breaking is arithmetic over the shaped words. So re-breaking text
177
179
  every frame with prepareText + layoutNextLine (a shape that breathes, an
@@ -188,7 +190,7 @@ Rules, in order of leverage:
188
190
  ## Isolates: heavy work off the JS thread
189
191
 
190
192
  A long synchronous computation (a big parse, a simulation step, a blocking
191
- `flux:ffi`/`flux:wasm` call) freezes rendering and input for its duration.
193
+ `flux:wasm` call) freezes rendering and input for its duration.
192
194
  Move it into an isolate module: a file whose first statement is the
193
195
  `"use isolate"` directive runs in a second runtime on its own thread, and
194
196
  main calls its exports as async functions.
package/docs/index.md CHANGED
@@ -91,9 +91,13 @@ ancestor with `position="relative"`.
91
91
  ## Input
92
92
 
93
93
  Pointer, wheel, and key events are props on any element:
94
- `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerEnter`,
95
- `onPointerLeave`, `onWheel`, `onKeyDown`, `onKeyUp`. Events travel from the
96
- hit leaf up to the root, and `stopPropagation()` ends the walk. Key events
94
+ `onPointerDown`, `onPointerMove`, `onPointerUp`, `onPointerCancel`,
95
+ `onPointerEnter`, `onPointerLeave`, `onWheel`, `onKeyDown`, `onKeyUp`.
96
+ Events travel from the hit leaf up to the root, and `stopPropagation()`
97
+ ends the walk. A pointer ends with an up or a cancel, never both: the
98
+ cancel is the system taking the touch away (a back swipe, palm rejection),
99
+ delivered along the pressed path with the pointer's last position, so a
100
+ press ends without firing and a drag ends without a fling. Key events
97
101
  bubble the same way, starting at the focused node - or at the window root
98
102
  when nothing is focused, so `onKeyDown` on the window is where app-global
99
103
  shortcuts live.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.66",
3
+ "version": "0.0.68",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -33,8 +33,8 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.66",
37
- "@solidrt/test": "0.0.66"
36
+ "@solidrt/flux-types": "0.0.68",
37
+ "@solidrt/test": "0.0.68"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@solidjs/signals": "2.0.0-rc.13",
package/src/core.ts CHANGED
@@ -8,8 +8,9 @@ let handlers = new Map<number, Map<string, Function>>()
8
8
  // Pointer-handler presence mirrored into the render tree, so the runtime can
9
9
  // skip building deliveries that would reach nobody (moves over static content
10
10
  // are the flood case). Bits match alloy's EventInterest (rendertree/hit.rs);
11
- // keep the two in sync. Down/up are recorded but the runtime never gates
12
- // them: focus and gesture side effects hang off them regardless of handlers.
11
+ // keep the two in sync. Down, up and cancel are recorded but the runtime
12
+ // never gates them: focus and gesture side effects hang off them regardless
13
+ // of handlers.
13
14
  const MOVE_BIT = 1
14
15
  const POINTER_INTEREST: Record<string, number> = {
15
16
  onPointerMove: MOVE_BIT,
@@ -18,6 +19,7 @@ const POINTER_INTEREST: Record<string, number> = {
18
19
  onPointerEnter: 8,
19
20
  onPointerLeave: 16,
20
21
  onWheel: 32,
22
+ onPointerCancel: 64,
21
23
  }
22
24
 
23
25
  let interests = new Map<number, number>()
@@ -388,6 +390,18 @@ export function prepareText(text: string, options?: tree.MeasureTextOptions): tr
388
390
  return tree.prepareText(text, options)
389
391
  }
390
392
 
393
+ /**
394
+ * Makes the glyph cells of printable ASCII for these font styles ahead of
395
+ * their first use, off the frame: call it at startup with the type scale
396
+ * the app draws in (the components' Window does it for the theme's), so a
397
+ * screen's first paint in any of them never waits for cells. Glyphs outside
398
+ * ASCII are made at first use. Each entry names a font as a <text> would;
399
+ * line height and maxLines are ignored.
400
+ */
401
+ export function warmText(styles: tree.MeasureTextOptions[]): void {
402
+ tree.warmText(styles)
403
+ }
404
+
391
405
  /** One laid-out line from layoutNextLine. */
392
406
  export type TextLine = {
393
407
  /** Unit range [from, to) into prepared.units. */
package/src/double-tap.ts CHANGED
@@ -48,8 +48,8 @@ export interface TapSequenceOptions {
48
48
  }
49
49
 
50
50
  /** The tap bookkeeping under a double-tap: one first tap at a time,
51
- * decided by the second down, by travel, by a long hold, by the window
52
- * timer or by cancel(). */
51
+ * decided by the second down, by travel, by a long hold, by a cancelled
52
+ * pointer, by the window timer or by cancel(). */
53
53
  export function createTapSequence(options: TapSequenceOptions) {
54
54
  let first: { id: number; at: PointerPoint; downAt: number; upAt: number | null } | null = null
55
55
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -99,6 +99,9 @@ export function createTapSequence(options: TapSequenceOptions) {
99
99
  settle(false)
100
100
  }, DOUBLE_TAP_MS)
101
101
  },
102
+ cancelled(e: PointerEvent) {
103
+ if (first && first.id === e.pointerId) settle(false)
104
+ },
102
105
  cancel: () => settle(false),
103
106
  }
104
107
  }
@@ -145,6 +148,10 @@ export function createDoubleTap(options: DoubleTapOptions) {
145
148
  if (second === e.pointerId) release()
146
149
  taps.up(e)
147
150
  },
151
+ onPointerCancel: (e: PointerEvent) => {
152
+ if (second === e.pointerId) release()
153
+ taps.cancelled(e)
154
+ },
148
155
  }
149
156
  return { handlers, cancel }
150
157
  }
package/src/gpu.ts CHANGED
@@ -97,7 +97,7 @@ export type { TextureFormat } from "flux:gpu"
97
97
  // runtime, distinct types to the checker, so a cross-space slip like
98
98
  // destroyBuffer(textureId) fails to compile. Exported so apps can annotate
99
99
  // storage (`let ids: TextureId[]`).
100
- export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
100
+ export type { BufferId, DrawId, ProgramId, RenderPipelineId, ShaderStageId, TextureId, TextureRect } from "flux:gpu"
101
101
 
102
102
  // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
103
103
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
@@ -196,22 +196,11 @@ export {
196
196
  programAttributes,
197
197
  } from "flux:gpu"
198
198
 
199
- /**
200
- * Tags an inline GLSL source, returning it unchanged. Shaders small enough to
201
- * belong beside the code that uses them stay in the file; the tag is what makes
202
- * them legible there, because editors highlight GLSL inside a template literal
203
- * only when a known tag marks it (the name matters - `glsl` is the one the
204
- * grammars look for).
205
- *
206
- * Interpolated values are stringified verbatim, with no GLSL-aware formatting:
207
- * `${2}` splices in the int literal `2`, which will not assign to a float. Pass
208
- * anything that varies as a uniform instead of building it into the source.
209
- *
210
- * Raw semantics, so backslashes reach the compiler as written: the GLSL
211
- * preprocessor continues a line with a trailing `\`, which a cooked template
212
- * would reject as an invalid escape and silently pass through as `undefined`.
213
- */
214
- export let glsl = String.raw
199
+ // The tag, and the screen-size clamp both sprite renderers share: the
200
+ // GLSL function a vertex stage pastes in, its JS twin for the picker, and
201
+ // the validation of a [minScreenPx, maxScreenPx] pair. Both live in the
202
+ // pure shaders.ts so headless tests and pure modules can reach them.
203
+ export { checkScreenSize, glsl, SCREEN_SIZE_GLSL, screenSizeScale } from "./shaders.ts"
215
204
 
216
205
  // captureSnapshot renders a node to pixels and readTexture reads any
217
206
  // texture's bytes back; both resolve the same { width, height, data } shape.
@@ -293,7 +282,9 @@ export function createCubeTexture(
293
282
 
294
283
  /**
295
284
  * Creates a GPU texture you intend to update over time: seed it with `data`,
296
- * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels.
285
+ * then call `uploadTexture(id, data)` (from flux:gpu) to push new pixels, or
286
+ * `uploadTexture(id, cell, { x, y, width, height })` to write one rect of it
287
+ * (a runtime atlas placing a cell without re-sending the sheet).
297
288
  * `data` must hold at least `width * height` pixels at the declared format's
298
289
  * size, in the view type matching the format (Uint8Array for "rgba8"/
299
290
  * "rgba8-srgb"/"r8", Float32Array for "r32f"/"rgba32f"/"rgba16f"); it may
package/src/index.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  export * from "./renderer"
2
- export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
2
+ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, warmText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, frameTime, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
7
- export type { BackEvent, FrameOptions, LifecycleHandler } from "./window"
6
+ export { onFrame, frameTime, onBeforeRender, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
7
+ export type { BackEvent, BeforeRenderOptions, FrameOptions, LifecycleHandler } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
9
9
  export { env } from "./environment"
10
10
  export type { InputDevices, SystemTheme, Orientation, Visibility, Launch } from "./environment"
@@ -133,6 +133,7 @@ export interface PointerFeed extends InputDevice {
133
133
  onPointerDown(event: PointerEvent): void
134
134
  onPointerMove(event: PointerEvent): void
135
135
  onPointerUp(event: PointerEvent): void
136
+ onPointerCancel(event: PointerEvent): void
136
137
  onPointerLeave(event: PointerEvent): void
137
138
  onWheel(event: WheelEvent): void
138
139
  }
@@ -481,6 +482,17 @@ export function createPointerFeed(options: PointerFeedOptions = {}): PointerFeed
481
482
  opening = null
482
483
  }
483
484
  },
485
+ onPointerCancel(e) {
486
+ if (!downs.has(e.pointerId)) return
487
+ // The brackets close with no velocity and no swipe: the pointer
488
+ // ended without its lift.
489
+ transform.handlers.onPointerCancel(e)
490
+ released = null
491
+ lifted(e)
492
+ hold.cancelled(e)
493
+ taps.cancelled(e)
494
+ if (opening && opening.id === e.pointerId) opening = null
495
+ },
484
496
  onPointerLeave() {},
485
497
  onWheel(e) {
486
498
  if (e.deltaY === 0) return
package/src/long-press.ts CHANGED
@@ -36,7 +36,8 @@ export interface HoldTimerOptions {
36
36
  }
37
37
 
38
38
  /** The arm / slop / timer machine under a long-press: one pointer at a
39
- * time, disarmed by travel, by a lift, by a second pointer or by cancel(). */
39
+ * time, disarmed by travel, by a lift, by a cancelled pointer, by a second
40
+ * pointer or by cancel(). */
40
41
  export function createHoldTimer(options: HoldTimerOptions) {
41
42
  let armed: { id: number; at: PointerPoint; mods: { shiftKey: boolean; ctrlKey: boolean; altKey: boolean; metaKey: boolean; button?: number } } | null = null
42
43
  let timer: ReturnType<typeof setTimeout> | null = null
@@ -69,6 +70,9 @@ export function createHoldTimer(options: HoldTimerOptions) {
69
70
  up(e: PointerEvent) {
70
71
  if (armed && armed.id === e.pointerId) disarm()
71
72
  },
73
+ cancelled(e: PointerEvent) {
74
+ if (armed && armed.id === e.pointerId) disarm()
75
+ },
72
76
  cancel: disarm,
73
77
  }
74
78
  }
@@ -84,6 +88,10 @@ export interface LongPressOptions {
84
88
  onLongPressMove?: (dx: number, dy: number) => void
85
89
  /** The lift after a fire. */
86
90
  onLongPressEnd?: () => void
91
+ /** A fired long press ended without its lift: the system cancelled the
92
+ * pointer (onPointerCancel), or another recognizer took it in the
93
+ * arena. Fired instead of onLongPressEnd. */
94
+ onLongPressCancel?: () => void
87
95
  }
88
96
 
89
97
  export function createLongPress(options: LongPressOptions) {
@@ -99,16 +107,26 @@ export function createLongPress(options: LongPressOptions) {
99
107
  options.onLongPress?.(at)
100
108
  },
101
109
  })
102
- let cancel = () => {
103
- hold.cancel()
110
+ let release = () => {
104
111
  if (fired !== null) {
105
112
  arena.release(fired, owner)
106
113
  fired = null
107
114
  last = null
108
115
  }
109
116
  }
117
+ // The press ends without its lift, whatever took the pointer: the arena
118
+ // (cancel) or the system (onPointerCancel). An unmount releases alone.
119
+ let cancel = () => {
120
+ hold.cancel()
121
+ let won = fired !== null
122
+ release()
123
+ if (won) options.onLongPressCancel?.()
124
+ }
110
125
  let owner: ArenaOwner = { cancel }
111
- onSettled(() => cancel)
126
+ onSettled(() => () => {
127
+ hold.cancel()
128
+ release()
129
+ })
112
130
 
113
131
  let handlers = {
114
132
  onPointerDown: (e: PointerEvent) => {
@@ -126,12 +144,16 @@ export function createLongPress(options: LongPressOptions) {
126
144
  },
127
145
  onPointerUp: (e: PointerEvent) => {
128
146
  if (fired === e.pointerId) {
129
- cancel()
147
+ release()
130
148
  options.onLongPressEnd?.()
131
149
  return
132
150
  }
133
151
  hold.up(e)
134
152
  },
153
+ onPointerCancel: (e: PointerEvent) => {
154
+ if (fired === e.pointerId) cancel()
155
+ else hold.cancelled(e)
156
+ },
135
157
  }
136
158
  return { handlers, cancel }
137
159
  }
package/src/pan.ts CHANGED
@@ -35,6 +35,13 @@ export interface PanOptions {
35
35
  * finger rested before lifting or moved under FLING_MIN_VELOCITY.
36
36
  */
37
37
  onPanEnd?: (velocity: Velocity) => void
38
+ /**
39
+ * The pan ended without its result: the system cancelled the pointer
40
+ * (onPointerCancel), or another recognizer took it in the arena. Fired
41
+ * instead of onPanEnd, only after onPanStart; whoever animates on
42
+ * settles here, nothing flings.
43
+ */
44
+ onPanCancel?: () => void
38
45
  }
39
46
 
40
47
  // The pan recognizer: turns a drag into a movement-delta stream. On a down it
@@ -49,8 +56,9 @@ export interface PanOptions {
49
56
  // fling minimum) for momentum. Moves and the up arrive on the frozen down
50
57
  // path, so an active pan keeps streaming when the pointer leaves the node
51
58
  // or the window.
52
- // cancel() is the external-cancel hook; it ends an active pan without
53
- // onPanEnd. Options are read at event time. Single-pointer by design; for
59
+ // cancel() is the external-cancel hook, the arena's; it and a pointer cancel
60
+ // end an active pan with onPanCancel instead of onPanEnd. Options are read
61
+ // at event time. Single-pointer by design; for
54
62
  // multi-pointer pinch/rotate compose createTransform instead.
55
63
  //
56
64
  // Frames: the slop is finger travel, so it is measured in window pixels
@@ -87,7 +95,13 @@ export function createPan(options: PanOptions) {
87
95
  armed = null
88
96
  origin = null
89
97
  }
90
- let cancel = reset
98
+ // The pan ends without its result, whatever took the pointer: the arena
99
+ // (cancel) or the system (onPointerCancel). An unmount resets alone.
100
+ let cancel = () => {
101
+ let started = active != null
102
+ reset()
103
+ if (started) options.onPanCancel?.()
104
+ }
91
105
  let owner = { cancel }
92
106
 
93
107
  // An unmount mid-drag must not leave a resolved claim behind.
@@ -134,6 +148,10 @@ export function createPan(options: PanOptions) {
134
148
  reset()
135
149
  }
136
150
  },
151
+ onPointerCancel: (e: PointerEvent) => {
152
+ if (active === e.pointerId) cancel()
153
+ else if (armed === e.pointerId) reset()
154
+ },
137
155
  }
138
156
 
139
157
  return { handlers, cancel }
@@ -26,7 +26,8 @@ declare module "*.frag" {
26
26
 
27
27
  // Binary asset imports: `import data from "./pic.png" with { type: "binary" }`.
28
28
  // The bundler inlines the file's bytes as a Uint8Array (see packages/cli
29
- // bundler `inlineImport`); feed it straight into createImage/decodeImage.
29
+ // bundler `inlineImport`); feed it straight into createImage/decodeImage, or a
30
+ // `.wasm` into `new Module` from flux:wasm.
30
31
  declare module "*.png" {
31
32
  const bytes: Uint8Array
32
33
  export default bytes
@@ -55,6 +56,10 @@ declare module "*.sol3m" {
55
56
  const bytes: Uint8Array
56
57
  export default bytes
57
58
  }
59
+ declare module "*.wasm" {
60
+ const bytes: Uint8Array
61
+ export default bytes
62
+ }
58
63
 
59
64
  // UI event bus (lattice), provided by the runtime as a builtin module.
60
65
  // on/once return an unsubscribe function. Notable events: the routed pointer
@@ -301,4 +306,12 @@ declare module "sol:test" {
301
306
  * frame runs and no app time passes.
302
307
  */
303
308
  export function capture(node: number): { width: number; height: number; data: Uint8Array }
309
+ /**
310
+ * Run one frame with a capture of `node` queued ahead of it, so the
311
+ * frame's own paint services it: fulfills, once the frame has run, with
312
+ * what that frame drew of the node (the shape of `capture`). Written
313
+ * and painted in one frame, where `capture` paints the tree afresh
314
+ * after the fact. One frame at a time, as `frame`.
315
+ */
316
+ export function painted(node: number): Promise<{ width: number; height: number; data: Uint8Array }>
304
317
  }
package/src/shaders.ts ADDED
@@ -0,0 +1,82 @@
1
+ // The GPU-free shader helpers: the `glsl` tag every shader source in the
2
+ // repo is written with, and the GLSL shared between renderers with its JS
3
+ // twins. Pure - no GPU or GUI imports - so the tests run headless on flux
4
+ // and the extensions' pure modules can import from here; gpu.ts re-exports
5
+ // all of it for apps.
6
+ //
7
+ // The screen-size clamp, shared by every renderer that draws a world-sized
8
+ // quad and lets it hold a size on screen: @solidrt/2d's sprites (the
9
+ // camera zoom is the pixels per world unit) and @solidrt/3d's sprite
10
+ // material (the projection at the quad's depth is). A quad `w x h` world
11
+ // units draws at its world size times the pixels per unit, clamped to
12
+ // [minPx, maxPx] on its smaller axis as ONE uniform scale, so its aspect
13
+ // holds. No bounds is world size; a floor alone is the marker that stays
14
+ // findable at the overview; a ceiling alone is the label that must not
15
+ // balloon; equal bounds is a constant screen size (Three's
16
+ // `sizeAttenuation: false`), where w and h carry the aspect alone. A zero
17
+ // bound is off. The GLSL and the JS below are the same function twice,
18
+ // and every picker applies the JS one to the rect it tests, so a hit is
19
+ // where the pixel is (tests/shaders.test.ts pins the pair).
20
+
21
+ /**
22
+ * Tags an inline GLSL source, returning it unchanged. Shaders small enough to
23
+ * belong beside the code that uses them stay in the file; the tag is what makes
24
+ * them legible there, because editors highlight GLSL inside a template literal
25
+ * only when a known tag marks it (the name matters - `glsl` is the one the
26
+ * grammars look for).
27
+ *
28
+ * Interpolated values are stringified verbatim, with no GLSL-aware formatting:
29
+ * `${2}` splices in the int literal `2`, which will not assign to a float. Pass
30
+ * anything that varies as a uniform instead of building it into the source.
31
+ *
32
+ * Raw semantics, so backslashes reach the compiler as written: the GLSL
33
+ * preprocessor continues a line with a trailing `\`, which a cooked template
34
+ * would reject as an invalid escape and silently pass through as `undefined`.
35
+ */
36
+ export let glsl = String.raw
37
+
38
+ /**
39
+ * The GLSL half: `screenSizeScale(size, pxPerUnit, screenPx)` returns the
40
+ * uniform scale a `size` (world units) quad takes so its smaller axis
41
+ * covers at least `screenPx.x` and at most `screenPx.y` pixels when one
42
+ * world unit is `pxPerUnit` pixels; a zero bound is off, a collapsed quad
43
+ * (zero axis) stays collapsed. Paste it before `main`, multiply the quad's
44
+ * size by it.
45
+ */
46
+ export const SCREEN_SIZE_GLSL = glsl`
47
+ float screenSizeScale(vec2 size, float pxPerUnit, vec2 screenPx) {
48
+ float smallest = min(abs(size.x), abs(size.y)) * pxPerUnit;
49
+ if (smallest <= 0.0) return 1.0;
50
+ float target = smallest;
51
+ if (screenPx.x > 0.0) target = max(target, screenPx.x);
52
+ if (screenPx.y > 0.0) target = min(target, screenPx.y);
53
+ return target / smallest;
54
+ }
55
+ `
56
+
57
+ /**
58
+ * The JS half of SCREEN_SIZE_GLSL, for the picker: the uniform scale a
59
+ * `w x h` quad draws at when one world unit is `pxPerUnit` pixels and
60
+ * its smaller axis is clamped to [minPx, maxPx] on screen. 1 with both
61
+ * bounds off, when the quad already lies within them, or when it is
62
+ * collapsed.
63
+ */
64
+ export function screenSizeScale(w: number, h: number, pxPerUnit: number, minPx: number, maxPx: number): number {
65
+ let smallest = Math.min(Math.abs(w), Math.abs(h)) * pxPerUnit
66
+ if (!(smallest > 0)) return 1
67
+ let target = smallest
68
+ if (minPx > 0) target = Math.max(target, minPx)
69
+ if (maxPx > 0) target = Math.min(target, maxPx)
70
+ return target / smallest
71
+ }
72
+
73
+ /**
74
+ * Validate a screen-size pair (throws - the dev validation policy): each
75
+ * bound finite and >= 0 (0 = off), and a ceiling never below a floor that
76
+ * is on. `site` names the caller in the message.
77
+ */
78
+ export function checkScreenSize(site: string, minPx: number, maxPx: number): void {
79
+ if (!(Number.isFinite(minPx) && minPx >= 0)) throw new Error(`${site}: minScreenPx must be a finite number >= 0, got ${minPx}`)
80
+ if (!(Number.isFinite(maxPx) && maxPx >= 0)) throw new Error(`${site}: maxScreenPx must be a finite number >= 0, got ${maxPx}`)
81
+ if (minPx > 0 && maxPx > 0 && maxPx < minPx) throw new Error(`${site}: maxScreenPx ${maxPx} is below minScreenPx ${minPx}`)
82
+ }
package/src/swipe.ts CHANGED
@@ -67,6 +67,10 @@ export interface SwipeOptions {
67
67
  onSwipe?: (direction: SwipeDirection, velocity: Velocity) => void
68
68
  /** Every lift of an active swipe, qualified or not. */
69
69
  onSwipeEnd?: (velocity: Velocity) => void
70
+ /** An active swipe ended without its lift: the system cancelled the
71
+ * pointer (onPointerCancel), or another recognizer took it in the
72
+ * arena. Fired instead of onSwipe and onSwipeEnd. */
73
+ onSwipeCancel?: () => void
70
74
  }
71
75
 
72
76
  export function createSwipe(options: SwipeOptions) {
@@ -97,6 +101,11 @@ export function createSwipe(options: SwipeOptions) {
97
101
  if (direction) options.onSwipe?.(direction, velocity)
98
102
  options.onSwipeEnd?.(velocity)
99
103
  },
104
+ onPanCancel: () => {
105
+ down = null
106
+ lift = null
107
+ options.onSwipeCancel?.()
108
+ },
100
109
  })
101
110
 
102
111
  let handlers = {
@@ -120,6 +129,13 @@ export function createSwipe(options: SwipeOptions) {
120
129
  lift = null
121
130
  }
122
131
  },
132
+ onPointerCancel: (e: PointerEvent) => {
133
+ pan.handlers.onPointerCancel(e)
134
+ if (down && down.id === e.pointerId) {
135
+ down = null
136
+ lift = null
137
+ }
138
+ },
123
139
  }
124
140
  let cancel = () => {
125
141
  pan.cancel()
package/src/transform.ts CHANGED
@@ -59,6 +59,10 @@ export interface TransformOptions {
59
59
  * parent-frame pixels per second (velocity.ts; zero after a rest or
60
60
  * under the fling minimum). */
61
61
  onTransformEnd?: (velocity: Velocity) => void
62
+ /** The gesture ended without its result: the system cancelled a finger
63
+ * (onPointerCancel), or another recognizer took the pointers in the
64
+ * arena. Fired instead of onTransformEnd, only after onTransformStart. */
65
+ onTransformCancel?: () => void
62
66
  }
63
67
 
64
68
  // The merged transform recognizer: pan + pinch + rotate as ONE gesture over
@@ -116,7 +120,8 @@ export interface TransformOptions {
116
120
  // configuration, so the change itself never emits a jump delta. The gesture
117
121
  // ends when the last finger lifts. Moves and ups arrive on the frozen down
118
122
  // path, so an active transform survives leaving the node or the window.
119
- // cancel() is the external-cancel hook; it ends an active gesture without
123
+ // cancel() is the external-cancel hook, the arena's; it and a cancelled
124
+ // finger end an active gesture with onTransformCancel instead of
120
125
  // onTransformEnd. Options are read at event time.
121
126
  //
122
127
  // Frames: the slop, the span filter and its rate gates are finger-travel
@@ -206,7 +211,11 @@ export function createTransform(options: TransformOptions) {
206
211
  rebase = false
207
212
  tracker.reset()
208
213
  }
209
- let cancel = reset
214
+ let cancel = () => {
215
+ let started = active
216
+ reset()
217
+ if (started) options.onTransformCancel?.()
218
+ }
210
219
  let owner: ArenaOwner = { cancel }
211
220
 
212
221
  // The per-frame measure point: runs at the pointerFrame terminator, when
@@ -355,6 +364,17 @@ export function createTransform(options: TransformOptions) {
355
364
  pointers.delete(e.pointerId)
356
365
  ref = pointers.size > 0 ? measure() : null
357
366
  },
367
+ onPointerCancel: (e: PointerEvent) => {
368
+ if (!pointers.has(e.pointerId)) return
369
+ // The system cancels the gesture, not a finger: every platform
370
+ // cancels all of them together, so the whole gesture ends.
371
+ if (active) {
372
+ cancel()
373
+ return
374
+ }
375
+ pointers.delete(e.pointerId)
376
+ ref = pointers.size > 0 ? measure() : null
377
+ },
358
378
  }
359
379
 
360
380
  return { handlers, cancel }
package/src/types.d.ts CHANGED
@@ -278,7 +278,8 @@ export interface TransformProps {
278
278
 
279
279
  // Window-relative pointer coordinates are reported as clientX/clientY (matching
280
280
  // the DOM MouseEvent). pointerType distinguishes mouse from touch; button is the
281
- // pressed button on down/up (0 = primary); the modifier flags mirror the DOM.
281
+ // pressed button on down/up (0 = primary) and absent on a cancel; the modifier
282
+ // flags mirror the DOM.
282
283
  export interface PointerEvent {
283
284
  /**
284
285
  * When the event happened, in milliseconds. Every input event carries one,
@@ -408,6 +409,15 @@ export interface NodeProps {
408
409
  export interface PointerProps extends NodeProps {
409
410
  onPointerDown?: (event: PointerEvent) => void
410
411
  onPointerUp?: (event: PointerEvent) => void
412
+ /**
413
+ * The system took the pointer away: a system gesture (the back swipe,
414
+ * the notification shade), palm rejection, the window losing the touch.
415
+ * The last event for that pointer, and no up follows it, so a press
416
+ * ends without firing and a drag ends without a fling. Routed along the
417
+ * pressed path like an up, with the pointer's last known position and
418
+ * no `button`.
419
+ */
420
+ onPointerCancel?: (event: PointerEvent) => void
411
421
  onPointerMove?: (event: PointerEvent) => void
412
422
  onPointerEnter?: (event: PointerEvent) => void
413
423
  onPointerLeave?: (event: PointerEvent) => void
@@ -1043,6 +1053,14 @@ export interface PathProps extends PaintProps, PointerProps, DashProps {
1043
1053
  shadow?: ShadowProps
1044
1054
  }
1045
1055
 
1056
+ /**
1057
+ * A font role ("sans", "serif", "mono": the font the app packages for it,
1058
+ * or the runtime's own Noto) or a registered family name; an unknown
1059
+ * family falls back to sans. A character the family's font lacks is drawn
1060
+ * from the next registered font that has it, roles first; one no
1061
+ * registered font has draws as the font's missing-glyph box. Whitespace
1062
+ * and control characters never draw a box: a tab advances like a space.
1063
+ */
1046
1064
  export type FontFamily = "sans" | "serif" | "mono" | (string & {})
1047
1065
  export type FontStyle = "normal" | "italic"
1048
1066
  export type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
@@ -1066,11 +1084,21 @@ export interface TextRunProps {
1066
1084
  lineHeight?: number
1067
1085
  fontStyle?: FontStyle
1068
1086
  fontWeight?: FontWeight
1087
+ /**
1088
+ * Width as a percentage of normal (CSS font-stretch: 100 normal, 75
1089
+ * condensed, 125 expanded), set on the font's width axis and clamped to
1090
+ * its range; a font without one draws at its one width. Default 100.
1091
+ */
1092
+ fontStretch?: number
1093
+ /**
1094
+ * Extra advance after every character (grapheme cluster) in pixels, the
1095
+ * last one included, as CSS letter-spacing; negative tightens. Default 0.
1096
+ */
1097
+ letterSpacing?: number
1069
1098
  /**
1070
1099
  * Underline in the run's own color, drawn straight through descenders
1071
1100
  * (no skip-ink). Position and thickness come from the font's own metrics
1072
- * unless overridden; a font Impeller resolves through the system fallback
1073
- * gets the shipped Noto values.
1101
+ * unless overridden.
1074
1102
  */
1075
1103
  textDecoration?: TextDecoration
1076
1104
  /** Pixels from the baseline to the top of the underline. */
package/src/window.ts CHANGED
@@ -422,7 +422,9 @@ export function keyboardHeight(): number {
422
422
  // write made by a handler would otherwise reach its node a frame late. The
423
423
  // drain lives here, the way runFrame drains before renderFrame, so that no
424
424
  // handler has to flush for itself. A throwing handler skips neither the
425
- // handlers after it nor the flush.
425
+ // handlers after it nor the flush. The publish pass (onBeforeRender) runs
426
+ // once more after the flush, for the same reason: what a handler hands an
427
+ // extension lands in this frame's paint too.
426
428
  let layoutHandlers: (() => void)[] = []
427
429
  let layoutSubscribed = false
428
430
 
@@ -439,6 +441,7 @@ function runLayoutHandlers() {
439
441
  } catch (err) {
440
442
  console.error("Error in reactive flush:", err)
441
443
  }
444
+ runPublish()
442
445
  }
443
446
 
444
447
  /**
@@ -462,6 +465,93 @@ export function onLayout(fn: () => void) {
462
465
  return unsubscribe
463
466
  }
464
467
 
468
+ // ------ Before render ----------------
469
+
470
+ // The last JS of a frame, in two passes. The plain handlers are the app's
471
+ // late update: they run once per frame after every onFrame callback and
472
+ // the reactive flush, so a follow or a fit reads the finished state of the
473
+ // frame (Unity's LateUpdate). The publish handlers run after them, and
474
+ // once more after the post-layout handlers' flush (the one other JS entry
475
+ // ahead of the paint): an extension hands its pending writes to the engine
476
+ // there - a scene's record bytes and lights, a layer's style and pose
477
+ // publish - so they are in the frame they were made in, the way the
478
+ // engine's own transform flush ahead of the paint is. Without this pass
479
+ // those writes land at the microtask after the whole frame closure, one
480
+ // frame after the paint that should have shown them. Each handler is its
481
+ // own boundary, listener style: a throw is logged and the rest still run.
482
+ let lateHandlers: (() => void)[] = []
483
+ let publishHandlers: (() => void)[] = []
484
+
485
+ function runHandlers(handlers: (() => void)[]) {
486
+ for (let fn of [...handlers]) {
487
+ try {
488
+ fn()
489
+ } catch (err) {
490
+ console.error("Error in onBeforeRender callback:", err)
491
+ }
492
+ }
493
+ }
494
+
495
+ // Before renderFrame(): the late pass (skipped on the bootstrap frame, which
496
+ // runs no frame callbacks either), its signal writes flushed, then the
497
+ // publish pass - the same handlers-then-flush shape as the post-layout
498
+ // entry, so a late handler's reactive write reaches its node in this frame
499
+ // and the publish pass sees it. An empty flush is a flag check.
500
+ function runBeforeRender(bootstrap: boolean) {
501
+ if (!bootstrap) {
502
+ runHandlers(lateHandlers)
503
+ try {
504
+ flush()
505
+ } catch (err) {
506
+ console.error("Error in reactive flush:", err)
507
+ }
508
+ }
509
+ runHandlers(publishHandlers)
510
+ }
511
+
512
+ // After the post-layout handlers' flush: the publish pass alone.
513
+ function runPublish() {
514
+ runHandlers(publishHandlers)
515
+ }
516
+
517
+ export interface BeforeRenderOptions {
518
+ /**
519
+ * Run in the publish pass: after every plain handler, and again after
520
+ * the `onLayout` handlers of a frame that has any. For handing pending
521
+ * state to the engine, never for deciding it.
522
+ */
523
+ publish?: boolean
524
+ }
525
+
526
+ /**
527
+ * Calls `fn` right before every frame renders: after the frame's `onFrame`
528
+ * callbacks and the reactive flush, when the frame's state is final. The
529
+ * frame's late update: a camera follow, a fit to a measured box, anything
530
+ * that must see what every frame callback wrote and write once more.
531
+ * Writes made here, to the engine or through signals, are in this frame:
532
+ * the pending reactive writes are flushed once every handler has run.
533
+ *
534
+ * `{ publish: true }` registers for the publish pass instead: after every
535
+ * plain handler, and once more after the `onLayout` handlers of a frame
536
+ * that has any. It is where an extension hands the engine what the frame
537
+ * wrote into its mirrors (a scene syncs its record buffers and lights, a
538
+ * sprite layer its style and pose publish), so a write made anywhere in
539
+ * the frame's JS is in that frame's paint. A publish handler must be
540
+ * idempotent (it may run twice per frame) and must not write app state.
541
+ *
542
+ * Returns a cleanup function; also auto-cleans within an owned scope.
543
+ */
544
+ export function onBeforeRender(fn: () => void, options?: BeforeRenderOptions) {
545
+ let handlers = options?.publish ? publishHandlers : lateHandlers
546
+ handlers.push(fn)
547
+ let unsubscribe = () => {
548
+ let i = handlers.indexOf(fn)
549
+ if (i >= 0) handlers.splice(i, 1)
550
+ }
551
+ if (getOwner()) onCleanup(unsubscribe)
552
+ return unsubscribe
553
+ }
554
+
465
555
  export function onWindowFocus(fn: () => void) {
466
556
  let unsubscribe = on("windowFocus", fn)
467
557
  if (getOwner()) onCleanup(unsubscribe)
@@ -553,6 +643,7 @@ export function attachWindow(nodeId: number) {
553
643
  let unsubscribe: () => void = null!
554
644
  let unsubDown: () => void = null!
555
645
  let unsubUp: () => void = null!
646
+ let unsubCancel: () => void = null!
556
647
  let unsubMove: () => void = null!
557
648
  let unsubEnter: () => void = null!
558
649
  let unsubLeave: () => void = null!
@@ -588,6 +679,7 @@ export function attachWindow(nodeId: number) {
588
679
  } catch (err) {
589
680
  console.error("Error in reactive flush:", err)
590
681
  }
682
+ runBeforeRender(bootstrap)
591
683
  scanForOrphans(t)
592
684
  renderFrame()
593
685
  }
@@ -664,6 +756,10 @@ export function attachWindow(nodeId: number) {
664
756
  bubble(raw, "onPointerUp")
665
757
  })
666
758
 
759
+ unsubCancel = on("pointerCancel", (raw: RawPointer) => {
760
+ bubble(raw, "onPointerCancel")
761
+ })
762
+
667
763
  unsubMove = on("pointerMove", (raw: RawPointer) => {
668
764
  bubble(raw, "onPointerMove")
669
765
  })
@@ -761,6 +857,7 @@ export function attachWindow(nodeId: number) {
761
857
  if (unsubscribe) unsubscribe()
762
858
  if (unsubDown) unsubDown()
763
859
  if (unsubUp) unsubUp()
860
+ if (unsubCancel) unsubCancel()
764
861
  if (unsubMove) unsubMove()
765
862
  if (unsubEnter) unsubEnter()
766
863
  if (unsubLeave) unsubLeave()