@solidrt/core 0.0.59 → 0.0.61
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 +100 -16
- package/agents/performance.md +5 -1
- package/examples/README.md +2 -1
- package/examples/gestures.tsx +197 -0
- package/examples/layout-slide.tsx +44 -15
- package/package.json +6 -5
- package/src/arena.ts +55 -0
- package/src/audio.ts +4 -3
- package/src/camera-control.ts +428 -0
- package/src/cursor.ts +113 -0
- package/src/double-tap.ts +150 -0
- package/src/gesture.ts +32 -0
- package/src/index.ts +16 -2
- package/src/input-axes.ts +12 -7
- package/src/input-chord.ts +3 -0
- package/src/input-gamepad-device.ts +115 -6
- package/src/input-id.ts +92 -0
- package/src/input-keyboard.ts +49 -2
- package/src/input-map.ts +268 -68
- package/src/input-pointer.ts +240 -16
- package/src/input-processors.ts +214 -0
- package/src/input.ts +4 -2
- package/src/long-press.ts +137 -0
- package/src/pan.ts +23 -4
- package/src/renderer.ts +16 -6
- package/src/runtime-modules.d.ts +4 -0
- package/src/svg.ts +1 -0
- package/src/swipe.ts +130 -0
- package/src/text-input.ts +104 -46
- package/src/transform.ts +23 -3
- package/src/types.d.ts +44 -7
- package/src/velocity.ts +134 -0
- package/src/video.ts +82 -15
- package/src/window.ts +16 -9
package/AGENTS.md
CHANGED
|
@@ -111,7 +111,7 @@ tsconfig.json - the two load-bearing lines are jsx + jsxImportSource:
|
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
114
|
-
2.0.0-rc.
|
|
114
|
+
2.0.0-rc.9); bun resolves them from peerDependencies. On a version bump,
|
|
115
115
|
`bun install` only warns ("incorrect peer dependency") and keeps the old
|
|
116
116
|
ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
|
|
117
117
|
not read the pin either: it adds all three to package.json at registry
|
|
@@ -157,7 +157,9 @@ that reads exactly like Solid fallout.
|
|
|
157
157
|
- Paint color is the `color` prop (a CSS color string). There is NO `fill`,
|
|
158
158
|
`stroke`, or `background` prop (some older doc examples are wrong about this).
|
|
159
159
|
Outlines: `drawStyle="stroke"` (or "stroke-and-fill") plus `strokeWidth`.
|
|
160
|
-
Corner radius on draw primitives: `radius` (number or [tl, tr, br, bl])
|
|
160
|
+
Corner radius on draw primitives: `radius` (number or [tl, tr, br, bl]);
|
|
161
|
+
on `texture` too, drawn as a rounded image in one draw - use it instead of
|
|
162
|
+
a rounded `clipRadius` around an image, which tiled GPUs pay dearly for.
|
|
161
163
|
|
|
162
164
|
- Registered JSX intrinsics: `window`, `view`, `text`, `span`, `rect`, `oval`,
|
|
163
165
|
`line`, `path`, `texture`, `audio`, plus the `d-` variants `d-view`, `d-rect`,
|
|
@@ -252,6 +254,16 @@ that reads exactly like Solid fallout.
|
|
|
252
254
|
call `flush()` after the write so it lands in this frame's display list.
|
|
253
255
|
See examples/on-layout-connect.tsx.
|
|
254
256
|
|
|
257
|
+
- A flex item never shrinks below its content on its own (CSS `min-height:
|
|
258
|
+
auto`, the same here): in a column, a content-sized child - a paragraph, a
|
|
259
|
+
list, a multiline field - keeps its content height and overflows a parent
|
|
260
|
+
that is too short, whatever `flexGrow`/`flexShrink` say, because the
|
|
261
|
+
automatic minimum size pins it. Give the child that should fit
|
|
262
|
+
`minHeight={0}` (`minWidth={0}` in a row), or make it a scroll container
|
|
263
|
+
(`overflow="hidden"` or `"scroll"`, whose automatic minimum is 0). A
|
|
264
|
+
`flexGrow: 1` child that ends up TALLER than its parent is this trap, not
|
|
265
|
+
a broken grow.
|
|
266
|
+
|
|
255
267
|
- Text `lineHeight` is a MULTIPLIER of fontSize (1.3-1.6 is typical), not
|
|
256
268
|
pixels. A CSS-reflex value like 22 makes each line box 22x the font size:
|
|
257
269
|
the text becomes blank space and the parent balloons.
|
|
@@ -319,10 +331,34 @@ that reads exactly like Solid fallout.
|
|
|
319
331
|
finger degrades to a plain pan, and `pointers` is how a consumer gives one-
|
|
320
332
|
and two-finger translation different meanings - dx/dy alone cannot tell
|
|
321
333
|
them apart).
|
|
334
|
+
Both ends carry the lift velocity (`onPanEnd(velocity)`,
|
|
335
|
+
`onTransformEnd(velocity)`: parent-frame px/s from `createVelocityTracker`,
|
|
336
|
+
a least-squares fit over the last 100 ms of positions, never the last
|
|
337
|
+
two samples - the final sample before a lift is often stationary and
|
|
338
|
+
frame batching makes the last delta a frame old; zero after a 50 ms
|
|
339
|
+
rest or under `FLING_MIN_VELOCITY`), the fling fact for whoever animates
|
|
340
|
+
on. The discrete recognizers: `createSwipe({ directions?, onSwipe(direction,
|
|
341
|
+
velocity), onSwipeMove?, onSwipeEnd? })` (a pan classified at the lift:
|
|
342
|
+
24 px of travel at 300 px/s within 30 degrees of an axis, direction by
|
|
343
|
+
the velocity; `directions` sets the pan's axis, so a horizontal swipe
|
|
344
|
+
inside a vertical ScrollView takes only horizontal drags), `createLongPress({
|
|
345
|
+
ms?, onLongPress(at), onLongPressMove?, onLongPressEnd? })` (500 ms
|
|
346
|
+
within 10 px; arms silently, steals at the timer - a press under it
|
|
347
|
+
retracts, a pan that won first keeps the finger - then streams moves:
|
|
348
|
+
drag-after-hold), `createDoubleTap({ onDoubleTap(at) })` (second down
|
|
349
|
+
within 300 ms and 100 px of the first tap, at least 40 ms after it;
|
|
350
|
+
fires on that down). `at` is a `PointerPoint`, the down's coordinates
|
|
351
|
+
in every frame, copied - the event object is rewritten as it bubbles.
|
|
322
352
|
Spread the returned `.handlers` onto the receiving element. They
|
|
323
353
|
arbitrate through the exported `arena` (ONE per app): a press claims its
|
|
324
354
|
pointer provisionally, movement evidence steals and resolves it, the loser's
|
|
325
|
-
`cancel()` retracts its feedback.
|
|
355
|
+
`cancel()` retracts its feedback. One relation beyond claims: a
|
|
356
|
+
double-tap `arena.pend`s a decision on its first tap's pointer and
|
|
357
|
+
`decide`s it later, and a press that resolved at the lift
|
|
358
|
+
`arena.defer`s its firing on that (run when the decision is lost,
|
|
359
|
+
dropped when won; false and fire now when nothing is pending) - so a
|
|
360
|
+
single tap fires late only on a node that also has a double-tap.
|
|
361
|
+
Custom recognizers should join the arena
|
|
326
362
|
rather than track pointers ad hoc, or they will double-handle against
|
|
327
363
|
scrollers and pressables.
|
|
328
364
|
|
|
@@ -377,12 +413,26 @@ that reads exactly like Solid fallout.
|
|
|
377
413
|
`drag("Shift+Middle")`; bare means Left), a discriminator rather than
|
|
378
414
|
a modifier: a right drag feeds only Right specs. Touch carries no
|
|
379
415
|
modifiers or buttons: a chord is the desktop path, the bare binding
|
|
380
|
-
stays for fingers.
|
|
416
|
+
stays for fingers. The discrete gestures are button sources that pulse
|
|
417
|
+
(pressed for one task, so onPress fires once): `pointer.swipe("Left")`
|
|
418
|
+
(the direction is the trailing word, as a drag's button is;
|
|
419
|
+
`swipe("Ctrl+Right")`), `pointer.longPress`, `pointer.doubleTap`, each
|
|
420
|
+
chordable; ids `pointer:swipe:Left`, `pointer:longPress:Ctrl`. They
|
|
421
|
+
run on the feed's own events, no second arena claimant. A drag's `end`
|
|
422
|
+
carries the release velocity in element heights per second when the
|
|
423
|
+
finger lifted at speed (`GestureListener.end(velocity)`,
|
|
424
|
+
`AxesHooks.onEnd(name, velocity)`, `input.end(action, velocity?)` by
|
|
425
|
+
name), which is how the cameras fling. A
|
|
381
426
|
detached d-* leaf has no layout box: give the feed `{ layout }` or it
|
|
382
427
|
throws at the first press. `invert(source)` and `scale(source, k)` are
|
|
383
|
-
the
|
|
428
|
+
the value processors: keys and sticks move the CAMERA where a drag moves
|
|
384
429
|
the content, so a preset binds arrows or a stick to `pan`/`rotate`
|
|
385
|
-
through invert() and the action keeps one meaning.
|
|
430
|
+
through invert() and the action keeps one meaning. `hold(source, ms)`,
|
|
431
|
+
`tap(source, ms)`, `doubleTap(source, gapMs, tapMs)` and
|
|
432
|
+
`chord(...sources)` are the interactions (Unity's Hold/Tap/MultiTap):
|
|
433
|
+
button sources made from button sources, so a charged attack is
|
|
434
|
+
`hold(pad.button("west"))` on one action next to the plain press on
|
|
435
|
+
another; a tap reads pressed for one task, enough for onPress.
|
|
386
436
|
Consumers with the axes contract (`createAxes({ rotate: "vec2", zoom:
|
|
387
437
|
"axis" }, { onNudge, onBegin, onEnd })`, what every camera control
|
|
388
438
|
exposes as `.axes`) are connected by name with `input.drive(control.axes,
|
|
@@ -392,7 +442,23 @@ that reads exactly like Solid fallout.
|
|
|
392
442
|
name is the wire for scripts, debug commands, peers and replays:
|
|
393
443
|
`input.set("move", [0, -1])` holds a rate, `press`/`release` a button,
|
|
394
444
|
`nudge`/`begin`/`end` feed the delta channel. `bindings()` lists what is
|
|
395
|
-
bound
|
|
445
|
+
bound, `unbind` removes one. Every source has a `label` (display) and
|
|
446
|
+
an `id` (serializable, no device instance: `keyboard:key:Space`,
|
|
447
|
+
`gamepad:button:south`, `pointer:drag:Ctrl+Right`,
|
|
448
|
+
`invert(gamepad:leftStick)`, `hold(400,keyboard:key:Space)`) plus its
|
|
449
|
+
`device`. A settings screen is `input.save()` (ids per action, JSON it)
|
|
450
|
+
and `input.load(saved, { keyboard, gamepad, pointer })` (resolves
|
|
451
|
+
through the devices given, all-or-nothing, throws on an unknown id),
|
|
452
|
+
`input.rebind(action, devices, { signal?, replace?, part? })` (a
|
|
453
|
+
promise of the next fitting input bound: a key down captured through
|
|
454
|
+
the map's handlers, a pad button/trigger/stick, a pointer gesture's
|
|
455
|
+
first movement; `replace` (default) swaps that device's bindings on
|
|
456
|
+
the action; `part: "up"` etc. swaps one key of the bound keyboard
|
|
457
|
+
composite on a vec2/axis action; `signal` is an AbortSignal), and
|
|
458
|
+
`input.device()` (reactive: the device that last moved anything bound,
|
|
459
|
+
the button-prompt switch - show `bindings(action)` where
|
|
460
|
+
`source.device === input.device()`; glyph images are the app's, keyed
|
|
461
|
+
by id). A
|
|
396
462
|
preset (`orbitBindings`, `firstPersonBindings` in @solidrt/3d,
|
|
397
463
|
`camera2dBindings` in @solidrt/2d) is a plain bindings list the app
|
|
398
464
|
applies and edits - nothing binds unless the app says so (the one
|
|
@@ -408,14 +474,28 @@ that reads exactly like Solid fallout.
|
|
|
408
474
|
effects under it and the feed's recognizer registers its cleanup there. The vocabulary the
|
|
409
475
|
controls share, one kind and unit per word: `pan` vec2 (view heights),
|
|
410
476
|
`zoom` axis (octaves, positive in), `rotate` vec2 (orbit turns), `roll`
|
|
411
|
-
axis (turns about the view axis), `
|
|
412
|
-
|
|
477
|
+
axis (turns about the view axis), `focus` axis (a nudge at a focal: a
|
|
478
|
+
double tap's point), `look` vec2 (turns), `move` vec2 ([right,
|
|
479
|
+
forward], forward = -y), `rise` axis (up). The runtime-free
|
|
413
480
|
half (`createInputMap`, `createAxes`, `keyboard`, the processors) is
|
|
414
481
|
importable as `@solidrt/core/input` for headless checks;
|
|
415
482
|
checks/input-map-check.ts runs it on the bare flux binary, and
|
|
416
483
|
checks/input-gamepad-check.ts drives the gamepad device and the join
|
|
417
484
|
order over a signal of pad snapshots (input-gamepad-device.ts is
|
|
418
|
-
runtime-free; input-gamepad.ts hands it gamepads()).
|
|
485
|
+
runtime-free; input-gamepad.ts hands it gamepads()). The pointer
|
|
486
|
+
feed's discrete pulses (`doubleTap`, `longPress`) bound to an AXIS
|
|
487
|
+
action nudge 1 with the gesture's focal (where the tap landed) and
|
|
488
|
+
contribute NO rate (a one-task press is not a rate to integrate),
|
|
489
|
+
which is how the orbit camera's `focus` learns its point and the 2d
|
|
490
|
+
camera's double tap zooms one octave. The camera
|
|
491
|
+
controls' shared math - the ease, the framing of a followed point
|
|
492
|
+
(dead zone, hard limits, per-axis damping, lookahead) and the lanes
|
|
493
|
+
(offset, shakes), the activity gate every control's `active()` runs
|
|
494
|
+
on, and the shot blender (`createShotBlend`: several cameras, a
|
|
495
|
+
priority, a timed blend, over the packages' `createShots`) - is
|
|
496
|
+
`@solidrt/core/camera-control`, pure, pinned by
|
|
497
|
+
checks/camera-control-check.ts; okf/design/camera-controls.md is the
|
|
498
|
+
pipeline the 2d and 3d controls run over it.
|
|
419
499
|
|
|
420
500
|
- Reactivity is SolidJS 2.0 (`@solidjs/signals`), NOT Solid 1.x. `createSignal`
|
|
421
501
|
is as you expect, but `createEffect` takes the 2.0 two-function shape: a
|
|
@@ -480,12 +560,16 @@ that reads exactly like Solid fallout.
|
|
|
480
560
|
replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
|
|
481
561
|
then bump `epoch`). See examples/stagger.tsx. The companion to the exit
|
|
482
562
|
pop-out is `layout` on the same spec: an element declaring it slides
|
|
483
|
-
from
|
|
484
|
-
above it, one inserted, a `<For>` reorder
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
563
|
+
from the box it was painted at to the box a layout gives it (a row
|
|
564
|
+
removed above it, one inserted, a `<For>` reorder, a card growing to fit
|
|
565
|
+
more content), position and size, instead of jumping; it retargets if
|
|
566
|
+
the layout changes again mid-slide, and is painted, clipped, hit-tested
|
|
567
|
+
and backdrop-filtered at the box it is drawn at. While its size is in
|
|
568
|
+
flight its children are laid out against that box every frame (text
|
|
569
|
+
re-wraps, rows re-flow, like a CSS width transition), and a child's own
|
|
570
|
+
`layout` slide yields to the parent's motion. It is parent-relative, so
|
|
571
|
+
declare it on every level that should glide; `layout: true` borrows the
|
|
572
|
+
`all` motion, and `all` alone never slides. See
|
|
489
573
|
examples/layout-slide.tsx.
|
|
490
574
|
Per-frame work is for genuinely procedural motion, and a game or a
|
|
491
575
|
simulation is that in full: its natural shape is ONE `onFrame` doing one
|
package/agents/performance.md
CHANGED
|
@@ -104,7 +104,11 @@ Rules, in order of leverage:
|
|
|
104
104
|
`opacity` on a view is NOT cheap: it wraps the subtree in a compositing
|
|
105
105
|
layer (save_layer) for as long as it is below 1. To fade a single
|
|
106
106
|
primitive, put the alpha in its `color` (`rgba(...)`) - paint alpha is
|
|
107
|
-
free; reserve view `opacity` for fading a genuine group as a whole.
|
|
107
|
+
free; reserve view `opacity` for fading a genuine group as a whole. A
|
|
108
|
+
`backdropFilter` panel inside a fading group is re-filtered against the
|
|
109
|
+
window ahead of the group each frame of the fade (the glass keeps its
|
|
110
|
+
blur; get_stats' backdropsPrepainted counts them), one extra backdrop
|
|
111
|
+
capture per panel while the opacity is below 1.
|
|
108
112
|
Placement rule for animation-heavy screens: a boundary around a node
|
|
109
113
|
that animates its own paint (a moving d-*, a changing color) is useless
|
|
110
114
|
- its interior is damaged every frame, so the cache never survives. The
|
package/examples/README.md
CHANGED
|
@@ -21,9 +21,10 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
21
21
|
- `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
|
|
22
22
|
- `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
|
|
23
23
|
- `stagger.tsx` - enter/exit/stagger with no per-frame JS: `from` and `exit` per property on each row's own `transition`, `stagger` on the ANCESTOR cascading them in occurrence order, and the replay idiom - a keyed `<Show>` remounts the subtree when its `when` value changes, so bumping an epoch replays the whole cascade. Exiting rows stay painted until they settle.
|
|
24
|
-
- `layout-slide.tsx` - the `layout` transition, the companion to exit: rows declaring it slide from the box they had to the box a layout gives them instead of jumping - tap a row to remove it (its exit plays where it was while the rows below slide up), the header to shuffle the order (a `<For>` reorder), the footer to add one back. No per-frame JS, no hand-animated `y`.
|
|
24
|
+
- `layout-slide.tsx` - the `layout` transition, the companion to exit: rows declaring it slide from the box they had to the box a layout gives them, position and size, instead of jumping - tap a row to expand it (it grows to fit its detail text while the rows below slide down), its "remove" mark to remove it (its exit plays where it was while the rows below slide up), the header to shuffle the order (a `<For>` reorder), the footer to add one back. No per-frame JS, no hand-animated `y` or `height`.
|
|
25
25
|
|
|
26
26
|
## Pointer input
|
|
27
|
+
- `gestures.tsx` - the discrete recognizers test bed: a swipe card (`createSwipe`, follows the finger, reports the direction and speed, snaps back when too slow), a long-press tile (`createLongPress`, arms on a hold, then drags: the reorder idiom), a double-tap target beside a plain tap (the single tap fires late only where a double-tap is registered: the arena's pend/defer relation), a pan target printing the release velocity, and a pointer feed binding `swipe("Left")`, `longPress` and `doubleTap` on an input map. Every state is on screen and in the log, so `/input` sequences verify it from the tree.
|
|
27
28
|
- `pointer-local-coords.tsx` - the three pointer coordinate frames (`clientX` window, `localX` the handling node's own frame, `parentX` its path-parent's frame - where the node's x/y live) and the transform-proof drag idiom: grab offset from `localX` at down, place with `parentX - offset` on moves. Exact inside rotated/scaled ancestors and when the pointer leaves the node mid-drag.
|
|
28
29
|
|
|
29
30
|
## Gamepads
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// Discrete gestures test bed: the recognizer family beyond press and pan,
|
|
2
|
+
// each on its own target with its state on screen (and in the log):
|
|
3
|
+
// - swipe: a card that follows the finger and reports the direction it
|
|
4
|
+
// left in (createSwipe), snapping back when the drag was too slow;
|
|
5
|
+
// - long-press: a tile that arms on a hold and can then be dragged
|
|
6
|
+
// (createLongPress, the reorder idiom), while a plain drag moves it
|
|
7
|
+
// not at all;
|
|
8
|
+
// - double-tap beside a single tap: the tap fires late only where a
|
|
9
|
+
// double-tap is registered (the arena's pend/defer relation), at once
|
|
10
|
+
// on the plain target;
|
|
11
|
+
// - the pointer feed: an input map binding `dodge` to swipe("Left"),
|
|
12
|
+
// `charge` to longPress and `zoomIn` to doubleTap on one viewport, so
|
|
13
|
+
// the same gestures reach a game through the map.
|
|
14
|
+
// A pan target prints the release velocity core measures at every lift.
|
|
15
|
+
import { arena, createDoubleTap, createInputMap, createLongPress, createPan, createPointerFeed, createSignal, createSwipe, render } from "@solidrt/core"
|
|
16
|
+
import type { PointerEvent, PointerPoint } from "@solidrt/core"
|
|
17
|
+
|
|
18
|
+
const TILE = 120
|
|
19
|
+
const CARD_WIDTH = 240
|
|
20
|
+
const CARD_HEIGHT = 64
|
|
21
|
+
|
|
22
|
+
function Swipeable() {
|
|
23
|
+
let [x, setX] = createSignal(0)
|
|
24
|
+
let [dragging, setDragging] = createSignal(false)
|
|
25
|
+
let [status, setStatus] = createSignal("swipe me")
|
|
26
|
+
let swipe = createSwipe({
|
|
27
|
+
directions: ["Left", "Right"],
|
|
28
|
+
onSwipeStart: () => setDragging(true),
|
|
29
|
+
onSwipeMove: dx => setX(x() + dx),
|
|
30
|
+
onSwipe: (direction, v) => {
|
|
31
|
+
setStatus(`swiped ${direction} at ${Math.round(v.vx)} px/s`)
|
|
32
|
+
console.log(`swipe ${direction} ${Math.round(v.vx)}`)
|
|
33
|
+
},
|
|
34
|
+
onSwipeEnd: () => {
|
|
35
|
+
setDragging(false)
|
|
36
|
+
setX(0)
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
return (
|
|
40
|
+
<view width={CARD_WIDTH} height={CARD_HEIGHT} overflow="hidden" {...swipe.handlers}>
|
|
41
|
+
<d-rect color="#243044" radius={8} />
|
|
42
|
+
<view width={CARD_WIDTH} height={CARD_HEIGHT} x={x()} transition={dragging() ? null : { x: { duration: 250 } }} alignItems="center" justifyContent="center">
|
|
43
|
+
<d-rect color="#3b5f8a" radius={8} />
|
|
44
|
+
<d-text color="#ffffff">{status()}</d-text>
|
|
45
|
+
</view>
|
|
46
|
+
</view>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function LongPressable() {
|
|
51
|
+
let [pos, setPos] = createSignal({ x: 0, y: 0 })
|
|
52
|
+
let [held, setHeld] = createSignal(false)
|
|
53
|
+
let [status, setStatus] = createSignal("hold me")
|
|
54
|
+
let lp = createLongPress({
|
|
55
|
+
onLongPress: () => {
|
|
56
|
+
setHeld(true)
|
|
57
|
+
setStatus("held: now drag")
|
|
58
|
+
console.log("long-press")
|
|
59
|
+
},
|
|
60
|
+
onLongPressMove: (dx, dy) => setPos({ x: pos().x + dx, y: pos().y + dy }),
|
|
61
|
+
onLongPressEnd: () => {
|
|
62
|
+
setHeld(false)
|
|
63
|
+
setStatus(`dropped at ${Math.round(pos().x)},${Math.round(pos().y)}`)
|
|
64
|
+
console.log(`long-press end ${Math.round(pos().x)},${Math.round(pos().y)}`)
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
return (
|
|
68
|
+
<view width={TILE} height={TILE} x={pos().x} y={pos().y} alignItems="center" justifyContent="center" {...lp.handlers}>
|
|
69
|
+
<d-rect color={held() ? "#d9822b" : "#8a5f3b"} radius={12} />
|
|
70
|
+
<d-text color="#ffffff">{status()}</d-text>
|
|
71
|
+
</view>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// A press the way components' createPress does it: claim on the down,
|
|
76
|
+
// fire on the up, deferred while a double-tap is pending on the pointer.
|
|
77
|
+
function pressable(onPress: () => void) {
|
|
78
|
+
let active: number | null = null
|
|
79
|
+
let owner = {
|
|
80
|
+
cancel() {
|
|
81
|
+
active = null
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
onPointerDown(e: PointerEvent) {
|
|
86
|
+
if (arena.claim(e.pointerId, owner)) active = e.pointerId
|
|
87
|
+
},
|
|
88
|
+
onPointerUp(e: PointerEvent) {
|
|
89
|
+
if (active !== e.pointerId) return
|
|
90
|
+
active = null
|
|
91
|
+
arena.release(e.pointerId, owner)
|
|
92
|
+
if (!arena.defer(e.pointerId, onPress)) onPress()
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function Tappable(props: { double: boolean }) {
|
|
98
|
+
let [status, setStatus] = createSignal(props.double ? "tap or double-tap" : "tap")
|
|
99
|
+
let taps = 0
|
|
100
|
+
let press = pressable(() => {
|
|
101
|
+
taps++
|
|
102
|
+
setStatus(`tap ${taps}`)
|
|
103
|
+
console.log(`${props.double ? "double-target" : "plain-target"} tap ${taps}`)
|
|
104
|
+
})
|
|
105
|
+
let dt = createDoubleTap({
|
|
106
|
+
onDoubleTap: (at: PointerPoint) => {
|
|
107
|
+
setStatus(`double-tap at ${Math.round(at.localX)},${Math.round(at.localY)}`)
|
|
108
|
+
console.log("double-tap")
|
|
109
|
+
},
|
|
110
|
+
})
|
|
111
|
+
let down = (e: PointerEvent) => {
|
|
112
|
+
if (props.double) dt.handlers.onPointerDown(e)
|
|
113
|
+
press.onPointerDown(e)
|
|
114
|
+
}
|
|
115
|
+
let up = (e: PointerEvent) => {
|
|
116
|
+
if (props.double) dt.handlers.onPointerUp(e)
|
|
117
|
+
press.onPointerUp(e)
|
|
118
|
+
}
|
|
119
|
+
return (
|
|
120
|
+
<view width={TILE} height={TILE} alignItems="center" justifyContent="center" onPointerDown={down} onPointerMove={props.double ? dt.handlers.onPointerMove : undefined} onPointerUp={up}>
|
|
121
|
+
<d-rect color={props.double ? "#3b8a5f" : "#5f8a3b"} radius={12} />
|
|
122
|
+
<d-text color="#ffffff">{status()}</d-text>
|
|
123
|
+
</view>
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function Pannable() {
|
|
128
|
+
let [status, setStatus] = createSignal("drag and let go")
|
|
129
|
+
let pan = createPan({
|
|
130
|
+
onPanEnd: v => {
|
|
131
|
+
setStatus(`lift at ${Math.round(v.vx)},${Math.round(v.vy)} px/s`)
|
|
132
|
+
console.log(`pan end ${Math.round(v.vx)},${Math.round(v.vy)}`)
|
|
133
|
+
},
|
|
134
|
+
})
|
|
135
|
+
return (
|
|
136
|
+
<view width={CARD_WIDTH} height={CARD_HEIGHT} alignItems="center" justifyContent="center" {...pan.handlers}>
|
|
137
|
+
<d-rect color="#44304a" radius={8} />
|
|
138
|
+
<d-text color="#ffffff">{status()}</d-text>
|
|
139
|
+
</view>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function Viewport() {
|
|
144
|
+
let pointer = createPointerFeed()
|
|
145
|
+
let input = createInputMap({ dodge: "button", charge: "button", zoomIn: "button", look: "vec2" })
|
|
146
|
+
input.bind("dodge", pointer.swipe("Left"))
|
|
147
|
+
input.bind("charge", pointer.longPress)
|
|
148
|
+
input.bind("zoomIn", pointer.doubleTap)
|
|
149
|
+
input.bind("look", pointer.drag)
|
|
150
|
+
let [status, setStatus] = createSignal("feed: swipe left, hold, double-tap")
|
|
151
|
+
input.onPress("dodge", () => {
|
|
152
|
+
setStatus("feed: dodge")
|
|
153
|
+
console.log("feed dodge")
|
|
154
|
+
})
|
|
155
|
+
input.onPress("charge", () => {
|
|
156
|
+
setStatus("feed: charge")
|
|
157
|
+
console.log("feed charge")
|
|
158
|
+
})
|
|
159
|
+
input.onPress("zoomIn", () => {
|
|
160
|
+
setStatus("feed: zoomIn")
|
|
161
|
+
console.log("feed zoomIn")
|
|
162
|
+
})
|
|
163
|
+
input.onGesture("look", {
|
|
164
|
+
end: v => {
|
|
165
|
+
if (v) {
|
|
166
|
+
setStatus(`feed: look end ${v[0].toFixed(2)},${v[1].toFixed(2)} heights/s`)
|
|
167
|
+
console.log(`feed look end ${v[0].toFixed(2)},${v[1].toFixed(2)}`)
|
|
168
|
+
}
|
|
169
|
+
},
|
|
170
|
+
})
|
|
171
|
+
return (
|
|
172
|
+
<view width={CARD_WIDTH} height={TILE} alignItems="center" justifyContent="center" {...pointer.handlers}>
|
|
173
|
+
<d-rect color="#2b4a5a" radius={12} />
|
|
174
|
+
<d-text color="#ffffff">{status()}</d-text>
|
|
175
|
+
</view>
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function App() {
|
|
180
|
+
return (
|
|
181
|
+
<window flexDirection="column" gap={24} padding={24}>
|
|
182
|
+
<d-rect color="#101418" />
|
|
183
|
+
<view flexDirection="row" gap={24}>
|
|
184
|
+
<Swipeable />
|
|
185
|
+
<Pannable />
|
|
186
|
+
</view>
|
|
187
|
+
<view flexDirection="row" gap={24}>
|
|
188
|
+
<LongPressable />
|
|
189
|
+
<Tappable double={true} />
|
|
190
|
+
<Tappable double={false} />
|
|
191
|
+
</view>
|
|
192
|
+
<Viewport />
|
|
193
|
+
</window>
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
render(() => <App />)
|
|
@@ -1,22 +1,29 @@
|
|
|
1
1
|
// The `layout` transition, the companion to `exit`: a row declaring it
|
|
2
|
-
// slides from the box it had to the box the next layout gives it,
|
|
3
|
-
// list never jumps when a row leaves, arrives
|
|
4
|
-
// animates `y` by hand and there is no
|
|
5
|
-
// flex layout, and the runtime slides
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// slides from the box it had to the box the next layout gives it, position
|
|
3
|
+
// and size, so the list never jumps when a row leaves, arrives, moves or
|
|
4
|
+
// grows. Nothing here animates `y` or `height` by hand and there is no
|
|
5
|
+
// per-frame JS: the rows keep their flex layout, and the runtime slides
|
|
6
|
+
// each one from the box it was painted at. Tap a row to expand it: its box
|
|
7
|
+
// grows on the spring, its detail text is laid out against the growing box
|
|
8
|
+
// every frame, and the rows below slide down with its bottom edge. Tap a
|
|
9
|
+
// row's "remove" mark: its exit plays where it was (the pop-out) while the
|
|
10
|
+
// rows below slide up past it. Tap the header to shuffle: a `<For>`
|
|
8
11
|
// reorder moves the surviving nodes, so every displaced row slides. Tap
|
|
9
12
|
// the footer to add a row back: the rows below its slot slide apart and
|
|
10
13
|
// the new row enters through its `from`. A reflow that lands mid-slide
|
|
11
|
-
// retargets, and a spring keeps its momentum. `layout` is parent-relative
|
|
12
|
-
//
|
|
13
|
-
|
|
14
|
-
import { render, createSignal, For } from "@solidrt/core"
|
|
14
|
+
// retargets, and a spring keeps its momentum. `layout` is parent-relative:
|
|
15
|
+
// declare it on every level that should glide.
|
|
16
|
+
import { render, createSignal, For, Show } from "@solidrt/core"
|
|
15
17
|
import type { Transition } from "@solidrt/core"
|
|
16
18
|
|
|
17
19
|
const ALL_ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries", "Resources"]
|
|
18
20
|
// Rows the list starts with; the rest come back through the footer.
|
|
19
21
|
const INITIAL_ROWS = 4
|
|
22
|
+
// A row's collapsed height, and the detail text it grows to fit.
|
|
23
|
+
const ROW_HEIGHT = 48
|
|
24
|
+
const DETAIL =
|
|
25
|
+
"A reactive primitive of Solid 2.0: the runtime tracks its reads and re-runs the computations that depend on it when it changes."
|
|
26
|
+
|
|
20
27
|
// Every reflow slides on this spring.
|
|
21
28
|
const SLIDE = { duration: 400, bounce: 0.15 } satisfies Transition
|
|
22
29
|
// Enter from the left, leave to the right, each direction on its own curve.
|
|
@@ -42,6 +49,7 @@ function shuffled(items: string[]): string[] {
|
|
|
42
49
|
|
|
43
50
|
function App() {
|
|
44
51
|
let [rows, setRows] = createSignal(ALL_ROWS.slice(0, INITIAL_ROWS))
|
|
52
|
+
let [expanded, setExpanded] = createSignal<string | null>(null)
|
|
45
53
|
let addRow = () => {
|
|
46
54
|
let next = ALL_ROWS.find((label) => !rows().includes(label))
|
|
47
55
|
if (next) setRows([...rows(), next])
|
|
@@ -50,7 +58,7 @@ function App() {
|
|
|
50
58
|
return (
|
|
51
59
|
<window padding={40} gap={12}>
|
|
52
60
|
<view height={40} justifyContent="center" onPointerDown={() => setRows(shuffled(rows()))}>
|
|
53
|
-
<text color="#8899aa" fontSize={14}>Tap here to shuffle, a row to
|
|
61
|
+
<text color="#8899aa" fontSize={14}>Tap here to shuffle, a row to expand it</text>
|
|
54
62
|
</view>
|
|
55
63
|
<For each={rows()}>
|
|
56
64
|
{(label) => (
|
|
@@ -58,13 +66,34 @@ function App() {
|
|
|
58
66
|
x={0}
|
|
59
67
|
opacity={1}
|
|
60
68
|
transition={{ layout: SLIDE, x: ARRIVE, opacity: FADE }}
|
|
61
|
-
|
|
69
|
+
minHeight={ROW_HEIGHT}
|
|
70
|
+
flexDirection="column"
|
|
62
71
|
justifyContent="center"
|
|
63
|
-
|
|
64
|
-
|
|
72
|
+
padding={16}
|
|
73
|
+
paddingTop={12}
|
|
74
|
+
paddingBottom={12}
|
|
75
|
+
gap={8}
|
|
76
|
+
overflow="hidden"
|
|
77
|
+
clipRadius={10}
|
|
78
|
+
onPointerDown={() => setExpanded(expanded() === label ? null : label)}
|
|
65
79
|
>
|
|
66
80
|
<d-rect color="#2a3a55" radius={10} />
|
|
67
|
-
<
|
|
81
|
+
<view flexDirection="row" justifyContent="space-between">
|
|
82
|
+
<text color="#e8eef6" fontSize={18}>{label}</text>
|
|
83
|
+
<text
|
|
84
|
+
color="#8899aa"
|
|
85
|
+
fontSize={14}
|
|
86
|
+
onPointerDown={(e) => {
|
|
87
|
+
e.stopPropagation()
|
|
88
|
+
setRows(rows().filter((row) => row !== label))
|
|
89
|
+
}}
|
|
90
|
+
>
|
|
91
|
+
remove
|
|
92
|
+
</text>
|
|
93
|
+
</view>
|
|
94
|
+
<Show when={expanded() === label}>
|
|
95
|
+
<text color="#b8c4d4" fontSize={14}>{DETAIL}</text>
|
|
96
|
+
</Show>
|
|
68
97
|
</view>
|
|
69
98
|
)}
|
|
70
99
|
</For>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.61",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"exports": {
|
|
10
10
|
".": "./src/index.ts",
|
|
11
11
|
"./camera": "./src/camera.ts",
|
|
12
|
+
"./camera-control": "./src/camera-control.ts",
|
|
12
13
|
"./color": "./src/color.ts",
|
|
13
14
|
"./data": "./src/data.ts",
|
|
14
15
|
"./gpu": "./src/gpu.ts",
|
|
@@ -31,11 +32,11 @@
|
|
|
31
32
|
"AGENTS.md"
|
|
32
33
|
],
|
|
33
34
|
"devDependencies": {
|
|
34
|
-
"@solidrt/flux-types": "0.0.
|
|
35
|
+
"@solidrt/flux-types": "0.0.61"
|
|
35
36
|
},
|
|
36
37
|
"peerDependencies": {
|
|
37
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
38
|
-
"@solidjs/universal": "2.0.0-rc.
|
|
39
|
-
"solid-js": "2.0.0-rc.
|
|
38
|
+
"@solidjs/signals": "2.0.0-rc.9",
|
|
39
|
+
"@solidjs/universal": "2.0.0-rc.9",
|
|
40
|
+
"solid-js": "2.0.0-rc.9"
|
|
40
41
|
}
|
|
41
42
|
}
|
package/src/arena.ts
CHANGED
|
@@ -15,6 +15,15 @@
|
|
|
15
15
|
// outer axis of two nested scrollers cannot take a drag the inner one already
|
|
16
16
|
// owns. Plain state on purpose: claims must be visible across recognizers
|
|
17
17
|
// within one synchronous bubble dispatch, before any signal flush.
|
|
18
|
+
//
|
|
19
|
+
// One relation beyond claims: a recognizer that may still take a gesture
|
|
20
|
+
// AFTER the pointer lifts (a double-tap waiting for its second tap) pends
|
|
21
|
+
// a decision at the down, and a recognizer that resolved at the lift
|
|
22
|
+
// defers its firing on that decision (a press beside a double-tap must
|
|
23
|
+
// not fire on the first tap, or double-tap-to-zoom also single-taps).
|
|
24
|
+
// UIKit's require(toFail:), RNGH's waitFor, Flutter's delayed tap. A node
|
|
25
|
+
// with nothing pending fires at once: the wait costs nothing where no
|
|
26
|
+
// double-tap is registered.
|
|
18
27
|
|
|
19
28
|
export type ArenaOwner = {
|
|
20
29
|
/** Retract the gesture without firing; invoked when the pointer is stolen. */
|
|
@@ -22,8 +31,10 @@ export type ArenaOwner = {
|
|
|
22
31
|
}
|
|
23
32
|
|
|
24
33
|
type Claim = { owner: ArenaOwner; resolved: boolean }
|
|
34
|
+
type Pending = { owners: Set<ArenaOwner>; fires: (() => void)[] }
|
|
25
35
|
|
|
26
36
|
let claims = new Map<number, Claim>()
|
|
37
|
+
let pending = new Map<number, Pending>()
|
|
27
38
|
|
|
28
39
|
export let arena = {
|
|
29
40
|
/**
|
|
@@ -57,4 +68,48 @@ export let arena = {
|
|
|
57
68
|
release(pointerId: number, owner: ArenaOwner): void {
|
|
58
69
|
if (claims.get(pointerId)?.owner === owner) claims.delete(pointerId)
|
|
59
70
|
},
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Register that `owner` may still take the pointer's gesture after its
|
|
74
|
+
* lift, so firings resolved at the lift wait (defer) until `decide`.
|
|
75
|
+
*/
|
|
76
|
+
pend(pointerId: number, owner: ArenaOwner): void {
|
|
77
|
+
let p = pending.get(pointerId)
|
|
78
|
+
if (!p) {
|
|
79
|
+
p = { owners: new Set(), fires: [] }
|
|
80
|
+
pending.set(pointerId, p)
|
|
81
|
+
}
|
|
82
|
+
p.owners.add(owner)
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Settle a pending decision: `won` drops every deferred firing (the
|
|
87
|
+
* gesture was the pending recognizer's), lost runs them once no other
|
|
88
|
+
* decision is pending on the pointer. Idempotent for an owner that is
|
|
89
|
+
* not pending.
|
|
90
|
+
*/
|
|
91
|
+
decide(pointerId: number, owner: ArenaOwner, won: boolean): void {
|
|
92
|
+
let p = pending.get(pointerId)
|
|
93
|
+
if (!p || !p.owners.has(owner)) return
|
|
94
|
+
if (won) {
|
|
95
|
+
pending.delete(pointerId)
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
p.owners.delete(owner)
|
|
99
|
+
if (p.owners.size > 0) return
|
|
100
|
+
pending.delete(pointerId)
|
|
101
|
+
for (let fire of p.fires) fire()
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Hold a firing until every decision pending on the pointer is settled:
|
|
106
|
+
* run when the last one is lost, dropped when one is won. Returns false,
|
|
107
|
+
* storing nothing, when no decision is pending: the caller fires now.
|
|
108
|
+
*/
|
|
109
|
+
defer(pointerId: number, fire: () => void): boolean {
|
|
110
|
+
let p = pending.get(pointerId)
|
|
111
|
+
if (!p) return false
|
|
112
|
+
p.fires.push(fire)
|
|
113
|
+
return true
|
|
114
|
+
},
|
|
60
115
|
}
|
package/src/audio.ts
CHANGED
|
@@ -218,9 +218,10 @@ export function createPcmSound(
|
|
|
218
218
|
* Streams a large track, decoding on demand instead of loading it into memory.
|
|
219
219
|
* Single-voice: each play() restarts it. Pass a path (resolved like flux:fs,
|
|
220
220
|
* relative to the process cwd) or a `file()` from flux:fs; a path is wrapped in
|
|
221
|
-
* `file()` for you
|
|
222
|
-
*
|
|
223
|
-
* disposed. For imperative use, call
|
|
221
|
+
* `file()` for you. The track is read from local storage (the app's assets in
|
|
222
|
+
* a packed app), not over the network. Owns the stream's lifecycle: stopped
|
|
223
|
+
* and released when the reactive owner is disposed. For imperative use, call
|
|
224
|
+
* stream()/play() from "flux:audio".
|
|
224
225
|
*/
|
|
225
226
|
export function createSoundStream(source: string | FluxFile, options: SoundStreamOptions = {}): Sound {
|
|
226
227
|
let src = typeof source === "string" ? file(source) : source
|