@solidrt/core 0.0.53 → 0.0.55
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 +59 -7
- package/agents/painting.md +7 -0
- package/agents/performance.md +22 -3
- package/docs/reference/detached.md +12 -5
- package/docs/reference/drawing.md +9 -2
- package/docs/reference/text.md +34 -0
- package/examples/README.md +5 -0
- package/examples/line-points.tsx +21 -0
- package/examples/stagger.tsx +45 -0
- package/examples/text-flow.tsx +90 -0
- package/examples/text-glyphs.tsx +73 -0
- package/package.json +5 -5
- package/src/color.ts +17 -0
- package/src/core.ts +13 -5
- package/src/gpu.ts +8 -3
- package/src/index.ts +12 -2
- package/src/renderer.ts +16 -2
- package/src/svg.ts +3 -2
- package/src/types.d.ts +79 -32
package/AGENTS.md
CHANGED
|
@@ -111,7 +111,12 @@ 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.4); bun resolves them from peerDependencies. On a version bump,
|
|
115
|
+
`bun install` only warns ("incorrect peer dependency") and keeps the old
|
|
116
|
+
ones, and `bun update solid-js @solidjs/signals @solidjs/universal` does
|
|
117
|
+
not read the pin either: it adds all three to package.json at registry
|
|
118
|
+
latest (solid-js 1.x, off Solid 2.0 entirely). The recipe that works is
|
|
119
|
+
`rm -rf bun.lock node_modules/solid-js node_modules/@solidjs && bun install`.
|
|
115
120
|
|
|
116
121
|
## Element model (the parts that are easy to get wrong)
|
|
117
122
|
|
|
@@ -174,6 +179,22 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
174
179
|
Avoid pct()/keyword origins on a d-view - they resolve against the box
|
|
175
180
|
inherited from the nearest laid-out ancestor.
|
|
176
181
|
|
|
182
|
+
- Transforms live on views only. A draw primitive (`d-rect`, `d-text`,
|
|
183
|
+
`d-path`, ...) paints in its parent's space and has no `rotate`, `scale`
|
|
184
|
+
or origin of its own; to spin or scale one in place, wrap it in a `d-view`
|
|
185
|
+
carrying the transform, with the origin set in pixels as above. So a
|
|
186
|
+
rotating glyph is two nodes, the view at the pivot and the text placed
|
|
187
|
+
around it - `<d-view x={cx} y={cy} rotate={r}><d-text anchor="middle"
|
|
188
|
+
y={-h / 2}>{ch}</d-text></d-view>`.
|
|
189
|
+
|
|
190
|
+
- A `d-text` label is anchored, not boxed: `anchor="start" | "middle" |
|
|
191
|
+
"end"` makes `x` a point on the line (SVG's text-anchor), the text shapes
|
|
192
|
+
at its natural width (no wrap) and its lines align to the anchor's side.
|
|
193
|
+
Without `anchor` a d-text is a paragraph box wrapping at `w` or at the
|
|
194
|
+
inherited box, and `textAlign` aligns inside that width - so
|
|
195
|
+
`textAlign="right"` on a `w`-less d-text aligns to the ancestor's right
|
|
196
|
+
edge, not to `x`. Reach for `anchor` on chart, axis and diagram labels.
|
|
197
|
+
|
|
177
198
|
- Layout-affecting vs not (this matters for per-frame work). Props fall in three
|
|
178
199
|
buckets, split by where they take effect:
|
|
179
200
|
- `LayoutProps` - width/height, min/max sizes, margin, padding, `position` and
|
|
@@ -213,6 +234,23 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
213
234
|
inline atom flowing with the words as one unbreakable box on the baseline;
|
|
214
235
|
give it margins for spacing, since JSX trims the whitespace around it.
|
|
215
236
|
|
|
237
|
+
- App-side line breaking, for text a `<text>` box cannot express: text
|
|
238
|
+
poured into a shape, parting around an obstacle that is not in the flow,
|
|
239
|
+
continued across columns, fitted to a box by size, or placed glyph by
|
|
240
|
+
glyph. `prepareText(text, font)` shapes the words once (through the
|
|
241
|
+
shared word cache) into `units` carrying advance, ink width, ascent and
|
|
242
|
+
descent; `layoutNextLine(prepared, cursor, width)` is the greedy breaker,
|
|
243
|
+
returning one line and the `cursor` for the next, at whatever width you
|
|
244
|
+
hand it per call (a chord of a circle, a slot beside an obstacle, the next
|
|
245
|
+
column). Draw each line as a `<d-text>` of exactly its text with the same
|
|
246
|
+
font. Re-breaking every frame is arithmetic plus cache hits, so a moving
|
|
247
|
+
or breathing shape re-flows at frame rate; only the first shaping cost
|
|
248
|
+
anything. `carets: true` adds each unit's per-grapheme x positions - the
|
|
249
|
+
ONLY kerned per-glyph source; per-character `measureText` drops kerning
|
|
250
|
+
and visibly drifts on pairs like AV/TA. Styled runs come from `runs` (draw
|
|
251
|
+
such a line with a `<span>` per run). See examples/text-flow.tsx and
|
|
252
|
+
examples/text-glyphs.tsx.
|
|
253
|
+
|
|
216
254
|
- Events: there is NO `onClick`/`onPress`. A "button" is a `<view>`/`<rect>`
|
|
217
255
|
with `onPointerDown`. Handlers: onPointerDown/Up/Move/Enter/Leave, onWheel,
|
|
218
256
|
onKeyDown/Up, onTextInput, onFocus/onBlur. Text entry: focus a node with an
|
|
@@ -281,9 +319,20 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
281
319
|
session in seconds) and it is what keeps per-frame work cheap.
|
|
282
320
|
- Animation is target-shaped first: declare `transition` on the element and
|
|
283
321
|
write targets, and the runtime animates natively with no per-frame JS.
|
|
322
|
+
Enter and exit are per-property entries on that same spec: `from` is the
|
|
323
|
+
value the property animates in from at first attach, `exit` the value it
|
|
324
|
+
animates to on removal (the node stays painted until it settles, then
|
|
325
|
+
frees - no AnimatePresence equivalent needed). `stagger` (ms) goes on an
|
|
326
|
+
ANCESTOR, never on the animating elements: it delays each descendant's
|
|
327
|
+
enter/exit by `index * stagger`, and cascades nothing unless the
|
|
328
|
+
descendants declare `from`/`exit`. An enter plays once per mount; to
|
|
329
|
+
replay it, remount the subtree (`<Show when={epoch()} keyed>` around it,
|
|
330
|
+
then bump `epoch`). See examples/stagger.tsx.
|
|
284
331
|
Reach for per-frame work only for genuinely procedural motion:
|
|
285
|
-
`onFrame((tick, frame) => {})` is the native hook (runtime-paced,
|
|
286
|
-
cleanup, auto-cleaned inside a reactive scope); `
|
|
332
|
+
`onFrame((tick, frame, rate) => {})` is the native hook (runtime-paced,
|
|
333
|
+
returns a cleanup, auto-cleaned inside a reactive scope); `rate` is the
|
|
334
|
+
display's nominal refresh rate in Hz, which a fixed-timestep loop needs
|
|
335
|
+
(see @solidrt/cli agents/debugging.md on stepping); `requestAnimationFrame`
|
|
287
336
|
exists as a web-standard one-shot but is not the preferred driver. A JS
|
|
288
337
|
tween loop or an animation library pushing interpolated values through
|
|
289
338
|
signals is the single most expensive mistake available here - read
|
|
@@ -306,10 +355,13 @@ Peer deps @solidjs/signals and @solidjs/universal must match (currently
|
|
|
306
355
|
|
|
307
356
|
- Device/GPU access via subpath imports: @solidrt/core/camera, /microphone,
|
|
308
357
|
/speech, /gpu. Image flow: `decodeImage(bytes)` ->
|
|
309
|
-
`createTexture(data,w,h)` -> `<texture src={id} />`.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
358
|
+
`createTexture(data,w,h)` -> `<texture src={id} />`. Sampling (`filter`,
|
|
359
|
+
`wrap`, `mipmap`) is fixed at creation on every create* helper and follows
|
|
360
|
+
the id everywhere; agents/performance.md rule 2 says when to set which.
|
|
361
|
+
Pixels are premultiplied alpha from decode onward (the GPU contract);
|
|
362
|
+
`decodeImage(bytes, { alpha: "straight" })` keeps the file's color under
|
|
363
|
+
transparent pixels for CPU work, and `encodeImage` converts back to
|
|
364
|
+
straight for the file.
|
|
313
365
|
|
|
314
366
|
## Minimal app, core primitives only (verified to render)
|
|
315
367
|
|
package/agents/painting.md
CHANGED
|
@@ -38,6 +38,13 @@ no matter how complex the effect, which is why the performance model
|
|
|
38
38
|
|
|
39
39
|
## Web reflexes and what replaces them
|
|
40
40
|
|
|
41
|
+
- `color-mix()`, `oklch()`, `lab()` -> not in the color grammar (hex,
|
|
42
|
+
`rgb()`, `hsl()`, `hwb()`, named colors only; the prop throws `Invalid
|
|
43
|
+
color` otherwise). Mix in JS with `mixColors(a, b, t)` from @solidrt/core
|
|
44
|
+
(oklab, so a ramp between two colors stays perceptually even) and pass
|
|
45
|
+
the result; `withAlpha(color, a)` gives any color at an opacity (do not
|
|
46
|
+
rebuild `rgba()` strings by hand from a hex); `brightness(color)` picks
|
|
47
|
+
readable text over any fill.
|
|
41
48
|
- gradient background -> a gradient `color` on a `d-rect` (gradients are
|
|
42
49
|
paint values, usable anywhere a color is)
|
|
43
50
|
- `filter: blur/grayscale/hue-rotate`, and any "make this look processed" ->
|
package/agents/performance.md
CHANGED
|
@@ -60,9 +60,21 @@ Rules, in order of leverage:
|
|
|
60
60
|
the flip into its projection) or it draws upside down. Sampling is a
|
|
61
61
|
create-time option on every texture: `{ filter: "nearest" }` for
|
|
62
62
|
hard-pixel upscaling (render a small target, display it big - the
|
|
63
|
-
retro/pixel-art path)
|
|
64
|
-
shaders
|
|
65
|
-
|
|
63
|
+
retro/pixel-art path), `{ wrap: "repeat" }` to tile outside 0..1 in
|
|
64
|
+
shaders, and `{ mipmap: true }` wherever a shader samples the texture
|
|
65
|
+
smaller than it is (a 3d surface receding into the distance, a target
|
|
66
|
+
read at a fraction of its size): without a mip chain minification skips
|
|
67
|
+
texels and aliases. The chain is regenerated by the runtime after every
|
|
68
|
+
upload and every target render, nothing to schedule, but it is one GPU
|
|
69
|
+
pass each time, so a per-frame texture pays it per frame. Add
|
|
70
|
+
`anisotropy: 8` (any level; the device clamps) beside `mipmap: true` on a
|
|
71
|
+
tiled surface seen at a grazing angle - a road or floor receding to the
|
|
72
|
+
horizon - where trilinear alone smears the far half into the mip its
|
|
73
|
+
long axis chose; it does nothing without the chain. The defaults
|
|
74
|
+
are linear, clamp, no mips and anisotropy 1; the choice is fixed at creation (a change
|
|
75
|
+
means a new id) and applies both on screen and to shaders sampling the
|
|
76
|
+
texture, except that the `<texture>` display draw samples the full-size
|
|
77
|
+
level only, so mips never help a texture merely shown smaller.
|
|
66
78
|
3. Reduce setProperty calls wherever possible: one path string rebuilt per
|
|
67
79
|
frame beats N elements with N animated positions; a shader beats the path
|
|
68
80
|
string. get_stats' setPropsPerFrame is the counter to watch. Compiled JSX
|
|
@@ -140,6 +152,13 @@ Rules, in order of leverage:
|
|
|
140
152
|
platform the app targets. Use it when the app must call a native
|
|
141
153
|
library that already exists and already ships for those targets; for
|
|
142
154
|
speed, everything above comes first.
|
|
155
|
+
10. Text costs shaping, once per word per style, through a shared word cache;
|
|
156
|
+
line breaking is arithmetic over the shaped words. So re-breaking text
|
|
157
|
+
every frame with prepareText + layoutNextLine (a shape that breathes, an
|
|
158
|
+
obstacle that moves, an editor on every keystroke) is cheap - the lines'
|
|
159
|
+
d-texts hit the same cache - while changing a text's font, size or
|
|
160
|
+
weight re-shapes it. Animate text with transforms and paint (rule 5), or
|
|
161
|
+
by re-breaking; not by resizing it per frame.
|
|
143
162
|
|
|
144
163
|
## Isolates: heavy work off the JS thread
|
|
145
164
|
|
|
@@ -49,7 +49,10 @@ to detach from.
|
|
|
49
49
|
|
|
50
50
|
## Geometry
|
|
51
51
|
|
|
52
|
-
Detached geometry is paint-space pixels and never affects layout.
|
|
52
|
+
Detached geometry is paint-space pixels and never affects layout. The
|
|
53
|
+
reverse holds too: a laid-out tile holding only detached geometry must not
|
|
54
|
+
be allowed to flex-shrink (wrap the row, or give it `flexShrink={0}`),
|
|
55
|
+
since the box shrinks and the geometry does not.
|
|
53
56
|
|
|
54
57
|
{{ decl packages/core/src/types.d.ts PositionProps }}
|
|
55
58
|
|
|
@@ -62,15 +65,19 @@ ancestor, so a `d-rect` with only `x`/`y` still has something to draw:
|
|
|
62
65
|
|
|
63
66
|
{{ decl packages/core/src/types.d.ts OvalGeometryProps }}
|
|
64
67
|
|
|
65
|
-
|
|
66
|
-
|
|
68
|
+
`d-text` is boxed or anchored. Boxed, it wraps at `w` or at the inherited
|
|
69
|
+
box, and `textAlign` aligns lines inside that width. Anchored, `x` is a point
|
|
70
|
+
on the line (SVG's `text-anchor`) and the text does not wrap unless given a
|
|
71
|
+
`w`; that is the form for labels. Its reported bounds are the laid-out
|
|
72
|
+
paragraph, with `w`/`h` overriding a side each:
|
|
67
73
|
|
|
68
74
|
{{ decl packages/core/src/types.d.ts TextGeometryProps }}
|
|
69
75
|
|
|
70
76
|
`d-path` takes only a position, since its size is whatever its `d` string
|
|
71
77
|
draws. `d-line` has no width and height either: its geometry is its two
|
|
72
|
-
endpoints (or, as a polyline, its `points`),
|
|
73
|
-
primitive to reach for when the geometry moves.
|
|
78
|
+
endpoints (or, as a polyline, its `points`), offset as a whole by `x`/`y`,
|
|
79
|
+
which is what makes it the primitive to reach for when the geometry moves.
|
|
80
|
+
Both report their bounds
|
|
74
81
|
(getBoundingBox, the tree, a capture) as that geometry plus the stroke, not
|
|
75
82
|
the inherited box:
|
|
76
83
|
|
|
@@ -27,12 +27,13 @@ stroke rather than a box.
|
|
|
27
27
|
|
|
28
28
|
## Dashing
|
|
29
29
|
|
|
30
|
-
A stroke's dash pattern, on
|
|
30
|
+
A stroke's dash pattern, on every stroked primitive:
|
|
31
31
|
|
|
32
32
|
{{ decl packages/core/src/types.d.ts DashProps }}
|
|
33
33
|
|
|
34
34
|
The pattern is walked along the geometry itself: through a polyline's
|
|
35
|
-
vertices
|
|
35
|
+
vertices, along a path's curves (restarting at each subpath), and around a
|
|
36
|
+
rect's or oval's inset outline (inside the box, like the solid stroke).
|
|
36
37
|
`dashOffset` slides it - write it every frame for marching ants, or
|
|
37
38
|
transition it for a one-shot slide. A dashed stroke keeps its caps on every
|
|
38
39
|
dash, and a stroke-and-fill path dashes only the stroke.
|
|
@@ -47,10 +48,16 @@ the length).
|
|
|
47
48
|
|
|
48
49
|
{{ decl packages/core/src/types.d.ts RectProps }}
|
|
49
50
|
|
|
51
|
+
A dashed rect (a selection marquee, a drop zone) is the dash props on the
|
|
52
|
+
rect itself; see Dashing above. The pattern starts on the top edge after
|
|
53
|
+
the top-left corner and runs clockwise.
|
|
54
|
+
|
|
50
55
|
## oval
|
|
51
56
|
|
|
52
57
|
{{ decl packages/core/src/types.d.ts OvalProps }}
|
|
53
58
|
|
|
59
|
+
Dashes like a rect; the pattern starts at 3 o'clock and runs clockwise.
|
|
60
|
+
|
|
54
61
|
## line
|
|
55
62
|
|
|
56
63
|
{{ decl packages/core/src/types.d.ts LineProps }}
|
package/docs/reference/text.md
CHANGED
|
@@ -44,3 +44,37 @@ Inline only: its children are text and other spans, and it has no layout box,
|
|
|
44
44
|
which is why it is the one element with no detached form. Pointer handlers on
|
|
45
45
|
a span fire for the boxes its text occupies on each line it spans, and bubble
|
|
46
46
|
to the enclosing spans and the text.
|
|
47
|
+
|
|
48
|
+
## Line breaking in app code
|
|
49
|
+
|
|
50
|
+
A `<text>` wraps in a box. For anything else - text poured into a shape,
|
|
51
|
+
parting around an obstacle that is not in the flow, continued across columns,
|
|
52
|
+
fitted to a box by size, or placed glyph by glyph - the app does the breaking
|
|
53
|
+
itself over words the engine shaped once:
|
|
54
|
+
|
|
55
|
+
```tsx
|
|
56
|
+
let prepared = prepareText(article, { fontSize: 16, lineHeight: 1.4 })
|
|
57
|
+
let cursor = 0
|
|
58
|
+
for (let band of bands) { // any widths, in any order
|
|
59
|
+
let line = layoutNextLine(prepared, cursor, band.w)
|
|
60
|
+
if (!line) break
|
|
61
|
+
lines.push({ ...band, w: line.width, text: prepared.text.slice(line.start, line.end) })
|
|
62
|
+
cursor = line.cursor // the next box continues here
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`prepareText` returns the paragraph's wrap units (a word and its trailing
|
|
67
|
+
whitespace) with their advance, ink width, ascent and descent. `layoutNextLine`
|
|
68
|
+
is a greedy breaker over those numbers: it fills one line to the width you hand
|
|
69
|
+
it and returns the cursor for the next, so a circle is a stack of chords, a
|
|
70
|
+
column is the same loop with a new box, and a headline that must fit is a loop
|
|
71
|
+
over sizes. Each line is drawn as a `<d-text>` of exactly its own text in the
|
|
72
|
+
same font.
|
|
73
|
+
|
|
74
|
+
The split is what makes it usable: shaping happened once, through the shared
|
|
75
|
+
word cache the drawn lines hit again, so re-breaking three paragraphs every
|
|
76
|
+
frame as a shape moves costs arithmetic, not layout. `carets: true` adds each
|
|
77
|
+
unit's per-grapheme x positions from that same shaping - the kerned source for
|
|
78
|
+
per-glyph placement and animation, which measuring characters one at a time
|
|
79
|
+
cannot give. `runs` restyles ranges, with a unit crossing a run boundary coming
|
|
80
|
+
back as glued pieces that always land on one line.
|
package/examples/README.md
CHANGED
|
@@ -13,9 +13,14 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
|
|
|
13
13
|
- `detached-positioning.tsx` - the `d-` prefix: x/y placement, no reflow, detached-only children.
|
|
14
14
|
- `text-paint-styling.tsx` - the uniform `color` prop; `drawStyle="stroke"` vs fill.
|
|
15
15
|
|
|
16
|
+
## Text
|
|
17
|
+
- `text-flow.tsx` - app-side line breaking: `prepareText` shapes a paragraph's words once, `layoutNextLine` breaks one line per call at any width. Poured band by band into a circle (each band the chord at its height), then the cursor continues in a column beside it; every line is a `d-text` of its own text. Re-breaks fully on resize for arithmetic plus word-cache hits.
|
|
18
|
+
- `text-glyphs.tsx` - per-glyph positions: `prepareText(text, { carets: true })` reports each unit's kerned grapheme x positions from the shaping that is drawn. One `d-text` per glyph placed from carets sits exactly on the whole headline; the same glyphs placed by per-character `measureText` drift on every kerning pair.
|
|
19
|
+
|
|
16
20
|
## Frame and lifecycle
|
|
17
21
|
- `frame-animation.tsx` - `onFrame` driving a transform animation each frame.
|
|
18
22
|
- `on-layout-connect.tsx` - `onLayout` + `getBoundingBox` connecting laid-out boxes with a `d-path`.
|
|
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.
|
|
19
24
|
|
|
20
25
|
## Pointer input
|
|
21
26
|
- `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.
|
package/examples/line-points.tsx
CHANGED
|
@@ -19,6 +19,9 @@
|
|
|
19
19
|
// knowing its length.
|
|
20
20
|
// 5. A laid-out <line points>: the points are content (like a path's `d`), so
|
|
21
21
|
// the box measures from their extent and takes part in the row.
|
|
22
|
+
// 6. Dashed boxes: the same props on d-rect and d-oval dash the inset
|
|
23
|
+
// outline the solid stroke draws, so a marquee or a drop zone is one
|
|
24
|
+
// element and its dashes never leave the box.
|
|
22
25
|
// The two-endpoint form (x1..y2, on d-line only) is unchanged; while `points`
|
|
23
26
|
// is set it takes precedence over the endpoints.
|
|
24
27
|
import { render, onFrame, createSignal } from "@solidrt/core"
|
|
@@ -115,6 +118,24 @@ function App() {
|
|
|
115
118
|
</view>
|
|
116
119
|
</view>
|
|
117
120
|
|
|
121
|
+
<text fontSize={16} color="#8b949e">
|
|
122
|
+
dashed boxes: a rounded marquee, a stroke-and-fill rect, an oval drawing on
|
|
123
|
+
</text>
|
|
124
|
+
<view flexDirection="row" flexWrap="wrap" gap={20}>
|
|
125
|
+
<view width={140} height={120}>
|
|
126
|
+
<d-rect radius={8} color="#151b28" />
|
|
127
|
+
<d-rect x={20} y={20} w={100} h={80} radius={12} drawStyle="stroke" onLength={8} offLength={6} dashOffset={-ants()} color="#e3b341" strokeWidth={3} />
|
|
128
|
+
</view>
|
|
129
|
+
<view width={140} height={120}>
|
|
130
|
+
<d-rect radius={8} color="#151b28" />
|
|
131
|
+
<d-rect x={20} y={20} w={100} h={80} drawStyle="stroke-and-fill" onLength={10} offLength={6} color="#f8514966" strokeWidth={6} strokeCap="round" />
|
|
132
|
+
</view>
|
|
133
|
+
<view width={140} height={120}>
|
|
134
|
+
<d-rect radius={8} color="#151b28" />
|
|
135
|
+
<d-oval x={20} y={20} w={100} h={80} drawStyle="stroke" pathLength={1} onLength={drawn()} offLength={1} color="#3fb950" strokeWidth={4} strokeCap="round" />
|
|
136
|
+
</view>
|
|
137
|
+
</view>
|
|
138
|
+
|
|
118
139
|
<text fontSize={16} color="#8b949e">
|
|
119
140
|
partial draw: pathLength=1 makes the pattern fractional, 77% of the curve and a triangle drawing on
|
|
120
141
|
</text>
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Enter, exit and stagger are declared on elements; the runtime plays them
|
|
2
|
+
// with no per-frame JS. `from` is the value a property animates in from at
|
|
3
|
+
// first attach, `exit` the value it animates to on removal - the node stays
|
|
4
|
+
// painted until that settles, then frees. Both are per-property entries on
|
|
5
|
+
// the element's own transition spec. `stagger` (ms) goes on an ANCESTOR,
|
|
6
|
+
// never on the animating elements: every descendant enter or exit that
|
|
7
|
+
// begins in the same frame gets index * stagger of extra delay, in
|
|
8
|
+
// occurrence order. It cascades nothing on its own - the descendants must
|
|
9
|
+
// declare from/exit.
|
|
10
|
+
//
|
|
11
|
+
// An enter plays once per mount. To replay it, remount the subtree: a keyed
|
|
12
|
+
// <Show> re-creates its child whenever `when` changes value, so bumping an
|
|
13
|
+
// epoch replays the whole cascade. Tap anywhere: odd taps unmount the list
|
|
14
|
+
// (staggered exits), even taps mount a fresh one (staggered enters).
|
|
15
|
+
import { render, createSignal, Show } from "@solidrt/core"
|
|
16
|
+
import type { Transition } from "@solidrt/core"
|
|
17
|
+
|
|
18
|
+
const ROWS = ["Signals", "Effects", "Memos", "Stores", "Boundaries"]
|
|
19
|
+
// Extra delay per row before its enter or exit starts.
|
|
20
|
+
const STAGGER_MS = 70
|
|
21
|
+
// Slide in from the left, out to the right; fade both ways.
|
|
22
|
+
const SLIDE = { duration: 500, bounce: 0.2, from: -80, exit: 80 } satisfies Transition
|
|
23
|
+
const FADE = { duration: 350, curve: "ease-out", from: 0, exit: 0 } satisfies Transition
|
|
24
|
+
|
|
25
|
+
function App() {
|
|
26
|
+
let [epoch, setEpoch] = createSignal(1)
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<window padding={40} gap={12} onPointerDown={() => setEpoch((e) => e + 1)} transition={{ stagger: STAGGER_MS }}>
|
|
30
|
+
<text color="#8899aa" fontSize={14}>Tap to replay</text>
|
|
31
|
+
<Show when={epoch() % 2 === 1 ? epoch() : 0} keyed>
|
|
32
|
+
{(_epoch) =>
|
|
33
|
+
ROWS.map((label) => (
|
|
34
|
+
<view x={0} opacity={1} transition={{ x: SLIDE, opacity: FADE }} height={48} justifyContent="center" paddingLeft={16}>
|
|
35
|
+
<d-rect color="#2a3a55" radius={10} />
|
|
36
|
+
<text color="#e8eef6" fontSize={18}>{label}</text>
|
|
37
|
+
</view>
|
|
38
|
+
))
|
|
39
|
+
}
|
|
40
|
+
</Show>
|
|
41
|
+
</window>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
render(() => <App />)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// App-side line breaking. prepareText shapes a paragraph's words once (through
|
|
2
|
+
// the shared word cache) into units carrying advance, ink width, ascent and
|
|
3
|
+
// descent; layoutNextLine then breaks one line out of them per call, at
|
|
4
|
+
// whatever width it is handed, and returns the cursor the next call continues
|
|
5
|
+
// from. The engine never sees a "shape" or a "column": here the paragraph is
|
|
6
|
+
// poured band by band into a circle (each band's width is the chord at that
|
|
7
|
+
// height, its line centered on it) and, when the circle is full, the same
|
|
8
|
+
// cursor continues in a column beside it. Every line is a d-text of exactly its
|
|
9
|
+
// own text, so the whole break redoes on every resize for arithmetic plus
|
|
10
|
+
// word-cache hits; nothing is shaped twice.
|
|
11
|
+
import { For, createMemo, layoutNextLine, prepareText, render, windowSize } from "@solidrt/core"
|
|
12
|
+
|
|
13
|
+
const FONT = { fontSize: 16, lineHeight: 1.4 }
|
|
14
|
+
const PAD = 32
|
|
15
|
+
// Gap between the circle and the column that continues its text.
|
|
16
|
+
const GAP = 32
|
|
17
|
+
// A chord shorter than this holds no word: skip the band at the circle's poles.
|
|
18
|
+
const MIN_BAND = 90
|
|
19
|
+
// Breathing room between the ring and the line ends.
|
|
20
|
+
const INSET = 10
|
|
21
|
+
|
|
22
|
+
const TEXT =
|
|
23
|
+
"A word is shaped once and keeps its width. From there a line is a loop: add words while the pen stays " +
|
|
24
|
+
"inside the width you were given, stop, and hand the cursor to whoever wants the next line. Give each " +
|
|
25
|
+
"call the chord of a circle and the paragraph fills the circle; give the next call a column and it " +
|
|
26
|
+
"continues there, because a column is only the same loop in a new box. The engine shaped the words " +
|
|
27
|
+
"before the first frame and never hears about circles or columns at all. Resize the window: the circle " +
|
|
28
|
+
"grows, every band changes width, and the text re-breaks into it while the words stay as they were. " +
|
|
29
|
+
"That is the whole trick, and it is the oldest one in typesetting: a compositor with a case of type " +
|
|
30
|
+
"never reshaped a letter when the column narrowed. The letters kept their width and only the breaks " +
|
|
31
|
+
"moved. Text engines lost that when a paragraph became one opaque object laid out at one width, and " +
|
|
32
|
+
"every layout question turned into a re-shape. Prepared text gives the idea back, and this page is that " +
|
|
33
|
+
"loop and nothing else."
|
|
34
|
+
|
|
35
|
+
type Placed = { x: number, y: number, w: number, text: string }
|
|
36
|
+
|
|
37
|
+
function App() {
|
|
38
|
+
let prepared = prepareText(TEXT, FONT)
|
|
39
|
+
let lines = createMemo<Placed[]>(() => {
|
|
40
|
+
let { width, height } = windowSize()
|
|
41
|
+
let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
|
|
42
|
+
let cx = PAD + r
|
|
43
|
+
let cy = height / 2
|
|
44
|
+
let units = prepared.units
|
|
45
|
+
// Single style: every line is as tall as its first unit.
|
|
46
|
+
let lineH = units[0]!.ascent + units[0]!.descent
|
|
47
|
+
let out: Placed[] = []
|
|
48
|
+
let cursor = 0
|
|
49
|
+
// The circle, top to bottom: each band's width is the chord at its middle.
|
|
50
|
+
for (let y = cy - r; y + lineH <= cy + r && cursor < units.length; y += lineH) {
|
|
51
|
+
let dy = y + lineH / 2 - cy
|
|
52
|
+
let half = Math.sqrt(Math.max(0, (r - INSET) * (r - INSET) - dy * dy))
|
|
53
|
+
if (2 * half < MIN_BAND) continue
|
|
54
|
+
let line = layoutNextLine(prepared, cursor, 2 * half)
|
|
55
|
+
if (!line) break
|
|
56
|
+
out.push({ x: cx - line.width / 2, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
|
|
57
|
+
cursor = line.cursor
|
|
58
|
+
}
|
|
59
|
+
// The column beside it continues from the same cursor.
|
|
60
|
+
let colX = cx + r + GAP
|
|
61
|
+
let colW = width - colX - PAD
|
|
62
|
+
for (let y = PAD; y + lineH <= height - PAD && cursor < units.length; y += lineH) {
|
|
63
|
+
let line = layoutNextLine(prepared, cursor, colW)
|
|
64
|
+
if (!line) break
|
|
65
|
+
out.push({ x: colX, y, w: line.width, text: prepared.text.slice(line.start, line.end) })
|
|
66
|
+
cursor = line.cursor
|
|
67
|
+
}
|
|
68
|
+
return out
|
|
69
|
+
})
|
|
70
|
+
let circle = createMemo(() => {
|
|
71
|
+
let { width, height } = windowSize()
|
|
72
|
+
let r = Math.min(width * 0.24, (height - 2 * PAD) / 2)
|
|
73
|
+
return { x: PAD, y: height / 2 - r, d: 2 * r }
|
|
74
|
+
})
|
|
75
|
+
return (
|
|
76
|
+
<window>
|
|
77
|
+
<d-rect color="#f4efe6" />
|
|
78
|
+
<d-oval x={circle().x} y={circle().y} w={circle().d} h={circle().d} drawStyle="stroke" strokeWidth={1} color="#d8b26a" />
|
|
79
|
+
<For each={lines()} keyed={false}>
|
|
80
|
+
{line => (
|
|
81
|
+
<d-text x={line().x} y={line().y} w={line().w + 1} {...FONT} color="#2b2620">
|
|
82
|
+
{line().text}
|
|
83
|
+
</d-text>
|
|
84
|
+
)}
|
|
85
|
+
</For>
|
|
86
|
+
</window>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
render(() => <App />)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Per-glyph positions. prepareText with `carets: true` reports, per wrap unit,
|
|
2
|
+
// the x of every grapheme boundary from the shaping the engine draws - kerning
|
|
3
|
+
// included - so one d-text per glyph placed from them sits exactly on the whole
|
|
4
|
+
// headline. Measuring characters one at a time with measureText cannot know a
|
|
5
|
+
// glyph's neighbors, so pairs like AV and TA come out loose and the row drifts
|
|
6
|
+
// right. Three rows of the same headline: drawn whole, per glyph from carets,
|
|
7
|
+
// per glyph from measureText. Per-glyph d-texts are what a per-character effect
|
|
8
|
+
// (a wave, a color cycle, a stagger) animates.
|
|
9
|
+
import { measureText, prepareText, render } from "@solidrt/core"
|
|
10
|
+
|
|
11
|
+
const FONT = { fontSize: 48, fontWeight: 800 } as const
|
|
12
|
+
const HEADLINE = "AVATAR Two Ya"
|
|
13
|
+
const LEFT = 32
|
|
14
|
+
const TOP = 40
|
|
15
|
+
const ROW = 96
|
|
16
|
+
// A single glyph never wraps at this width; it only bounds the d-text.
|
|
17
|
+
const GLYPH_W = FONT.fontSize * 2
|
|
18
|
+
const LABEL = { fontSize: 13, color: "#8a8378" }
|
|
19
|
+
|
|
20
|
+
type Glyph = { x: number, text: string }
|
|
21
|
+
|
|
22
|
+
// Kerned: each unit's pen position plus its caret offsets.
|
|
23
|
+
function fromCarets(): Glyph[] {
|
|
24
|
+
let prepared = prepareText(HEADLINE, { ...FONT, carets: true })
|
|
25
|
+
let out: Glyph[] = []
|
|
26
|
+
let pen = 0
|
|
27
|
+
for (let unit of prepared.units) {
|
|
28
|
+
let carets = unit.carets!
|
|
29
|
+
for (let i = 0; i + 1 < carets.length; i++) {
|
|
30
|
+
out.push({ x: pen + carets[i]!.x, text: prepared.text.slice(carets[i]!.offset, carets[i + 1]!.offset) })
|
|
31
|
+
}
|
|
32
|
+
pen += unit.advance
|
|
33
|
+
}
|
|
34
|
+
return out
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Unkerned: each character measured alone, advanced by its own width.
|
|
38
|
+
function fromMeasure(): Glyph[] {
|
|
39
|
+
let out: Glyph[] = []
|
|
40
|
+
let x = 0
|
|
41
|
+
for (let ch of HEADLINE) {
|
|
42
|
+
out.push({ x, text: ch })
|
|
43
|
+
x += measureText(ch, FONT).width
|
|
44
|
+
}
|
|
45
|
+
return out
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function Row(props: { y: number, label: string, glyphs: Glyph[], color: string }) {
|
|
49
|
+
return (
|
|
50
|
+
<d-view x={LEFT} y={props.y}>
|
|
51
|
+
<d-text y={-20} {...LABEL}>{props.label}</d-text>
|
|
52
|
+
{props.glyphs.map(g => (
|
|
53
|
+
<d-text x={g.x} w={GLYPH_W} {...FONT} color={props.color}>{g.text}</d-text>
|
|
54
|
+
))}
|
|
55
|
+
</d-view>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function App() {
|
|
60
|
+
return (
|
|
61
|
+
<window>
|
|
62
|
+
<d-rect color="#f4efe6" />
|
|
63
|
+
<d-view x={LEFT} y={TOP}>
|
|
64
|
+
<d-text y={-20} {...LABEL}>whole headline</d-text>
|
|
65
|
+
<d-text {...FONT} color="#2b2620">{HEADLINE}</d-text>
|
|
66
|
+
</d-view>
|
|
67
|
+
<Row y={TOP + ROW} label="per glyph from carets: true (kerned)" glyphs={fromCarets()} color="#2b2620" />
|
|
68
|
+
<Row y={TOP + 2 * ROW} label="per glyph from measureText per character (drifts)" glyphs={fromMeasure()} color="#b3452b" />
|
|
69
|
+
</window>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
render(() => <App />)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solidrt/core",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.55",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"funding": "https://github.com/sponsors/wellawaretech",
|
|
6
6
|
"author": "Antoine van Wel",
|
|
@@ -29,11 +29,11 @@
|
|
|
29
29
|
"AGENTS.md"
|
|
30
30
|
],
|
|
31
31
|
"devDependencies": {
|
|
32
|
-
"@solidrt/flux-types": "0.0.
|
|
32
|
+
"@solidrt/flux-types": "0.0.55"
|
|
33
33
|
},
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@solidjs/signals": "2.0.0-rc.
|
|
36
|
-
"@solidjs/universal": "2.0.0-rc.
|
|
37
|
-
"solid-js": "2.0.0-rc.
|
|
35
|
+
"@solidjs/signals": "2.0.0-rc.4",
|
|
36
|
+
"@solidjs/universal": "2.0.0-rc.4",
|
|
37
|
+
"solid-js": "2.0.0-rc.4"
|
|
38
38
|
}
|
|
39
39
|
}
|
package/src/color.ts
CHANGED
|
@@ -26,6 +26,23 @@ export function mixColors(a: string, b: string, t: number): string {
|
|
|
26
26
|
return tree.mixColors(a, b, t)
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Alpha byte range of the packed 0xRRGGBBAA color.
|
|
30
|
+
const ALPHA_MAX = 255
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The same CSS color at opacity `alpha` (0 = transparent, 1 = opaque),
|
|
34
|
+
* replacing whatever alpha the input carried. Returns `#rrggbbaa`. Accepts
|
|
35
|
+
* any color `parseColor` does, so a named color or an `rgb()` is fine. For a
|
|
36
|
+
* transparent variant of a theme color prefer this over an `rgba()` literal
|
|
37
|
+
* rebuilt by hand; for a tone that must not depend on what is drawn beneath,
|
|
38
|
+
* `mixColors` against the backdrop.
|
|
39
|
+
*/
|
|
40
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
41
|
+
let rgb = parseColor(color) >>> 8
|
|
42
|
+
let a = Math.round(Math.min(1, Math.max(0, alpha)) * ALPHA_MAX)
|
|
43
|
+
return "#" + rgb.toString(16).padStart(6, "0") + a.toString(16).padStart(2, "0")
|
|
44
|
+
}
|
|
45
|
+
|
|
29
46
|
/**
|
|
30
47
|
* Perceived brightness of a CSS color, 0 (black) to 1 (white), YIQ-weighted.
|
|
31
48
|
* Compare a text color against its backdrop to decide rendering polarity,
|
package/src/core.ts
CHANGED
|
@@ -352,11 +352,19 @@ export function measureText(text: string, options?: tree.MeasureTextOptions): {
|
|
|
352
352
|
}
|
|
353
353
|
|
|
354
354
|
/**
|
|
355
|
-
* Segments `text` into wrap units (words with their
|
|
356
|
-
* shapes each in the given font, once,
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
355
|
+
* App-side line breaking. Segments `text` into wrap units (words with their
|
|
356
|
+
* trailing whitespace) and shapes each in the given font, once, through the
|
|
357
|
+
* shared word cache; laying lines out is then arithmetic over `units`, with
|
|
358
|
+
* layoutNextLine or a loop of your own. This is how text goes into a shape,
|
|
359
|
+
* parts around an obstacle that is not in the flow, pours across columns
|
|
360
|
+
* (layoutNextLine's `cursor` continues a paragraph in the next box), fits a
|
|
361
|
+
* box by trying sizes, and gets per-glyph positions (`carets: true`; the
|
|
362
|
+
* only kerned source, see TextUnit.carets). Draw each line as a d-text of
|
|
363
|
+
* exactly its own text. Re-breaking every frame is cheap - the words are
|
|
364
|
+
* shaped already and the lines' d-texts hit the same cache - so a shape
|
|
365
|
+
* that moves or breathes can re-flow at frame rate. A paragraph that just
|
|
366
|
+
* wraps in a box is a <text>, which also does floats, balancing and
|
|
367
|
+
* ellipsis.
|
|
360
368
|
*/
|
|
361
369
|
export function prepareText(text: string, options?: tree.MeasureTextOptions): tree.PreparedText {
|
|
362
370
|
return tree.prepareText(text, options)
|
package/src/gpu.ts
CHANGED
|
@@ -11,8 +11,9 @@
|
|
|
11
11
|
// `flux:gpu` module.
|
|
12
12
|
//
|
|
13
13
|
// Sampling is a per-texture property declared at creation: `filter`
|
|
14
|
-
// ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat")
|
|
15
|
-
// `mipmap` (default false)
|
|
14
|
+
// ("linear" default | "nearest"), `wrap` ("clamp" default | "repeat"),
|
|
15
|
+
// `mipmap` (default false) and `anisotropy` (default 1 = off; pair it with
|
|
16
|
+
// mipmap) on every create* helper. One state for every
|
|
16
17
|
// consumer - `<texture>` display and shader sampling both follow it - so a
|
|
17
18
|
// nearest texture upscales with hard pixels everywhere (the retro/pixel-art
|
|
18
19
|
// path: render small, display big). `mipmap: true` keeps a mip chain the
|
|
@@ -68,7 +69,7 @@ export type CreateOptions = { autoFree?: boolean; label?: string }
|
|
|
68
69
|
|
|
69
70
|
// Sampling options every texture-producing create* helper accepts, applied at
|
|
70
71
|
// creation as a property of the texture id (there is no set-sampler-later).
|
|
71
|
-
export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean }
|
|
72
|
+
export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode; mipmap?: boolean; anisotropy?: number }
|
|
72
73
|
export type { FilterMode, WrapMode, TextureBinding, TextureBindings } from "flux:gpu"
|
|
73
74
|
|
|
74
75
|
// Pixel format option for the pixel-upload creates (createTexture,
|
|
@@ -293,6 +294,10 @@ export function createMutableTexture(
|
|
|
293
294
|
* `in vec2 vUV;` yourself to read it. One naming trap: GLSL ES reserves
|
|
294
295
|
* `packed` as a keyword, so `vec4 packed = texture(...)` fails with a syntax
|
|
295
296
|
* error that does not name the identifier - pick another name.
|
|
297
|
+
*
|
|
298
|
+
* A bad source is a runtime failure, not one `srt check` catches: the compile
|
|
299
|
+
* throws at this call, so a shader created in a component body takes the app
|
|
300
|
+
* to the error window unless an `<Errored>` closer in the tree claims it.
|
|
296
301
|
*/
|
|
297
302
|
export function createShaderTexture(
|
|
298
303
|
fragmentSrc: string,
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from "./renderer"
|
|
2
2
|
export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, snapshotTexture, onPointerMove } from "./core"
|
|
3
3
|
export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
|
|
4
|
-
export { parseColor, mixColors, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
4
|
+
export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
|
|
5
5
|
export type { Gradient, GradientStop } from "./color"
|
|
6
6
|
export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, exit } from "./window"
|
|
7
7
|
export type { BackEvent } from "./window"
|
|
@@ -43,6 +43,10 @@ export type {
|
|
|
43
43
|
TextEvent,
|
|
44
44
|
TextInputHints,
|
|
45
45
|
PaintProps,
|
|
46
|
+
BlendMode,
|
|
47
|
+
DrawStyle,
|
|
48
|
+
StrokeCap,
|
|
49
|
+
StrokeJoin,
|
|
46
50
|
WindowProps,
|
|
47
51
|
WindowShaderProps,
|
|
48
52
|
ViewProps,
|
|
@@ -51,11 +55,17 @@ export type {
|
|
|
51
55
|
LineProps,
|
|
52
56
|
PathProps,
|
|
53
57
|
TextProps,
|
|
58
|
+
FontFamily,
|
|
59
|
+
FontStyle,
|
|
60
|
+
FontWeight,
|
|
61
|
+
TextDecoration,
|
|
62
|
+
TextAlign,
|
|
63
|
+
TextOverflow,
|
|
54
64
|
TextureProps,
|
|
55
65
|
Color,
|
|
56
66
|
Pct,
|
|
57
67
|
} from "./types"
|
|
58
|
-
export type { MeasureTextOptions, PreparedText, TextUnit } from "flux:rendertree"
|
|
68
|
+
export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
|
|
59
69
|
|
|
60
70
|
// A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
|
|
61
71
|
// half the element box. Keeps percentages a first-class branded value rather
|
package/src/renderer.ts
CHANGED
|
@@ -125,16 +125,18 @@ export function scanForOrphans(now: number): void {
|
|
|
125
125
|
counts.set(node.elementType, (counts.get(node.elementType) ?? 0) + 1)
|
|
126
126
|
}
|
|
127
127
|
if (total === 0) return
|
|
128
|
+
// Warn only when a new element type joins the orphans, but list every type
|
|
129
|
+
// with its count so the breakdown always adds up to the total.
|
|
128
130
|
let fresh = [...counts].filter(([type]) => !warnedLeakTypes.has(type))
|
|
129
131
|
if (fresh.length === 0) return
|
|
130
132
|
for (let [type] of fresh) warnedLeakTypes.add(type)
|
|
131
|
-
let list =
|
|
133
|
+
let list = [...counts].map(([type, n]) => `<${type}> x${n}`).join(", ")
|
|
132
134
|
console.warn(
|
|
133
135
|
`Leak sentinel: ${total} nodes are unreachable and will never be freed: ${list}. ` +
|
|
134
136
|
`The usual cause is reading an element-valued prop more than once (every read ` +
|
|
135
137
|
`builds a new subtree); read it once where it mounts, or resolve it with ` +
|
|
136
138
|
`children(). If these nodes are intentionally kept for later mounting, ignore ` +
|
|
137
|
-
`this.
|
|
139
|
+
`this. The next warning comes when a new element type joins the list.`,
|
|
138
140
|
)
|
|
139
141
|
}
|
|
140
142
|
|
|
@@ -261,6 +263,18 @@ let renderer = createRenderer<ProxyNode>({
|
|
|
261
263
|
insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
|
|
262
264
|
if (!node) return
|
|
263
265
|
|
|
266
|
+
// A value without an id is not a node: a signal accessor (<For>, <Repeat>,
|
|
267
|
+
// a memo) that reached the renderer unresolved. Without this check it
|
|
268
|
+
// surfaces as an FFI type error on node.id, which names nothing. Known
|
|
269
|
+
// cause: okf/upstream/signals-flatten-array-clobbers-needs-unwrap.md.
|
|
270
|
+
if (typeof node !== "object" || node.id === undefined) {
|
|
271
|
+
let what = typeof node === "function" ? "a signal accessor" : `a ${typeof node}`
|
|
272
|
+
throw new Error(
|
|
273
|
+
`insertNode received ${what} instead of an element under <${parent?.elementType ?? "?"}>; ` +
|
|
274
|
+
`resolve the children with children() or return one root element from the component.`,
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
264
278
|
// A re-inserted node is being moved, not destroyed: cancel its pending
|
|
265
279
|
// destroy so the end-of-tick sweep leaves it (and its subtree) alone.
|
|
266
280
|
pendingDestroy.delete(node.id)
|
package/src/svg.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { parseSvg as fluxParseSvg } from "flux:svg"
|
|
8
8
|
import { parseColor, type Gradient } from "./color"
|
|
9
|
+
import type { StrokeCap, StrokeJoin } from "./types"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Tags an inline SVG source, returning it unchanged. Documents small enough to
|
|
@@ -27,8 +28,8 @@ export type SvgDraw = {
|
|
|
27
28
|
drawStyle: "fill" | "stroke"
|
|
28
29
|
fillRule?: "nonzero" | "evenodd"
|
|
29
30
|
strokeWidth?: number
|
|
30
|
-
strokeCap?:
|
|
31
|
-
strokeJoin?:
|
|
31
|
+
strokeCap?: StrokeCap
|
|
32
|
+
strokeJoin?: StrokeJoin
|
|
32
33
|
}
|
|
33
34
|
|
|
34
35
|
/** A parsed document: intrinsic size (viewBox/width-height) plus the flat draw list. */
|
package/src/types.d.ts
CHANGED
|
@@ -148,17 +148,29 @@ export interface LayoutProps extends FlexboxProps, GridProps {
|
|
|
148
148
|
clear?: "left" | "right" | "both"
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* A CSS color string: hex (`#rgb`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`,
|
|
153
|
+
* `hsl()`/`hsla()`, `hwb()`, or a named color. The CSS Color 4 functions -
|
|
154
|
+
* `color-mix()`, `oklch()`, `lab()`, `color()` - are not parsed and throw
|
|
155
|
+
* `Invalid color`. To mix two colors, `mixColors(a, b, t)` (oklab, returns
|
|
156
|
+
* hex); for a color at a given opacity, `withAlpha(color, a)` (returns
|
|
157
|
+
* `#rrggbbaa`); `parseColor` gives the packed u32 the prop also accepts.
|
|
158
|
+
*/
|
|
152
159
|
export type Color = string
|
|
153
160
|
|
|
161
|
+
export type BlendMode = "clear" | "source" | "destination" | "source-over" | "destination-over" | "source-in" | "destination-in" | "source-out" | "destination-out" | "source-atop" | "destination-atop" | "xor" | "plus" | "modulate" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "multiply" | "hue" | "saturation" | "color" | "luminosity"
|
|
162
|
+
export type DrawStyle = "fill" | "stroke" | "stroke-and-fill"
|
|
163
|
+
export type StrokeCap = "butt" | "round" | "square"
|
|
164
|
+
export type StrokeJoin = "miter" | "round" | "bevel"
|
|
165
|
+
|
|
154
166
|
export interface PaintProps {
|
|
155
167
|
// A solid color, or a gradient from createLinearGradient/createRadialGradient.
|
|
156
168
|
color?: Color | Gradient
|
|
157
|
-
blendMode?:
|
|
169
|
+
blendMode?: BlendMode
|
|
158
170
|
/** Default "fill"; "stroke" on line, whose segment has no interior (see LineProps). */
|
|
159
|
-
drawStyle?:
|
|
160
|
-
strokeCap?:
|
|
161
|
-
strokeJoin?:
|
|
171
|
+
drawStyle?: DrawStyle
|
|
172
|
+
strokeCap?: StrokeCap
|
|
173
|
+
strokeJoin?: StrokeJoin
|
|
162
174
|
strokeMiter?: number
|
|
163
175
|
strokeWidth?: number
|
|
164
176
|
}
|
|
@@ -357,22 +369,41 @@ export interface OvalGeometryProps extends PositionProps {
|
|
|
357
369
|
h?: number
|
|
358
370
|
}
|
|
359
371
|
|
|
360
|
-
/**
|
|
372
|
+
/**
|
|
373
|
+
* See {@link PositionProps}: detached-only, never affects layout. A d-text
|
|
374
|
+
* is boxed or anchored. Boxed (no `anchor`): it wraps at `w`, or at the box
|
|
375
|
+
* inherited from the nearest laid-out ancestor, and `textAlign` aligns
|
|
376
|
+
* lines inside that width - so a right-aligned label needs a `w`. Anchored:
|
|
377
|
+
* `x` is a point on the line (SVG's text-anchor), which is what a label on a
|
|
378
|
+
* chart, an axis or a diagram wants. Either way the reported bounds
|
|
379
|
+
* (getBoundingBox, the tree) are the laid-out paragraph - widest line by
|
|
380
|
+
* line stack - unless `w`/`h` override a side.
|
|
381
|
+
*/
|
|
361
382
|
export interface TextGeometryProps extends PositionProps {
|
|
362
|
-
|
|
363
|
-
|
|
383
|
+
/**
|
|
384
|
+
* Where `x` sits on each line: its start, middle or end. With `w` unset
|
|
385
|
+
* the text shapes at its natural width (no wrap; `\n` still breaks), and
|
|
386
|
+
* `textAlign` defaults to the anchor's side. With `w` set, the `w`-wide
|
|
387
|
+
* box is what gets anchored at `x`.
|
|
388
|
+
*/
|
|
389
|
+
anchor?: "start" | "middle" | "end"
|
|
390
|
+
// Shaping (wrap) width; unset, boxed text wraps at the inherited box and
|
|
391
|
+
// anchored text does not wrap.
|
|
364
392
|
w?: number
|
|
365
|
-
// Reported-bounds height only; paragraph height always falls out
|
|
393
|
+
// Reported-bounds height override only; paragraph height always falls out
|
|
394
|
+
// of the text.
|
|
366
395
|
h?: number
|
|
367
396
|
}
|
|
368
397
|
|
|
369
398
|
/**
|
|
370
|
-
* See {@link PositionProps}: detached-only, never affects layout.
|
|
371
|
-
*
|
|
372
|
-
*
|
|
399
|
+
* See {@link PositionProps}: detached-only, never affects layout. The
|
|
400
|
+
* endpoints (or `points`) are the line's own geometry; `x`/`y` offset all of
|
|
401
|
+
* it, the way a `d-path`'s offset its `d`, so one write moves a polyline. A
|
|
402
|
+
* line's reported bounds (getBoundingBox, the tree, a detached capture) are
|
|
403
|
+
* its painted box: the geometry's extent plus the stroke's reach, not the
|
|
373
404
|
* inherited box.
|
|
374
405
|
*/
|
|
375
|
-
export interface LineGeometryProps {
|
|
406
|
+
export interface LineGeometryProps extends PositionProps {
|
|
376
407
|
/** Endpoints default to spanning the box: (0,0) to (box width, box height). */
|
|
377
408
|
x1?: number
|
|
378
409
|
y1?: number
|
|
@@ -520,7 +551,9 @@ export interface TransitionProps {
|
|
|
520
551
|
* its transform); the initial value never animates unless the entry sets
|
|
521
552
|
* `from` (an enter animation), and a non-numeric write (e.g. null)
|
|
522
553
|
* cancels the running animation and snaps. `null` clears the
|
|
523
|
-
* declaration; already-running animations finish.
|
|
554
|
+
* declaration; already-running animations finish. A spec built in a
|
|
555
|
+
* conditional widens `curve` to `string` for TypeScript; write
|
|
556
|
+
* `satisfies Transition` on the branch.
|
|
524
557
|
*/
|
|
525
558
|
transition?:
|
|
526
559
|
| ({
|
|
@@ -543,7 +576,7 @@ export interface TransitionProps {
|
|
|
543
576
|
|
|
544
577
|
// Primitives
|
|
545
578
|
|
|
546
|
-
export interface WindowProps extends LayoutProps, PointerProps {
|
|
579
|
+
export interface WindowProps extends LayoutProps, PointerProps, TransitionProps {
|
|
547
580
|
children?: Children
|
|
548
581
|
title?: string
|
|
549
582
|
fullscreen?: boolean
|
|
@@ -563,10 +596,11 @@ export interface WindowProps extends LayoutProps, PointerProps {
|
|
|
563
596
|
* `uniform sampler2D uSource` (top-left origin, like every sampled texture -
|
|
564
597
|
* so a vertex stage mapping it onto the window flips the v coordinate) and
|
|
565
598
|
* is drawn attributeless as triangles, `vertexCount` vertices fetched via
|
|
566
|
-
* gl_VertexID. `iResolution`,
|
|
567
|
-
*
|
|
568
|
-
*
|
|
569
|
-
*
|
|
599
|
+
* gl_VertexID. `iResolution`, the window size in physical pixels (the pass
|
|
600
|
+
* covers exactly that), is filled by name into a uniform the program declares
|
|
601
|
+
* itself or gets from compileShader's `header` option. The window is cleared
|
|
602
|
+
* to opaque black first, so geometry that does not cover it still presents a
|
|
603
|
+
* defined frame.
|
|
570
604
|
*/
|
|
571
605
|
export interface WindowShaderProps {
|
|
572
606
|
/** Linked program handle from linkProgram. */
|
|
@@ -673,8 +707,9 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
|
|
|
673
707
|
* A boundary shader declaration. The program contract matches shader targets,
|
|
674
708
|
* not the window pass: the subtree's rasterization binds as
|
|
675
709
|
* `uniform sampler2D uSource` (top-left origin, like every sampled texture)
|
|
676
|
-
* and the pass draws the covering triangle attributeless. `iResolution`,
|
|
677
|
-
* filled by name
|
|
710
|
+
* and the pass draws the covering triangle attributeless. `iResolution`, the
|
|
711
|
+
* boundary in physical pixels, is filled by name into a uniform the program
|
|
712
|
+
* declares itself or gets from compileShader's `header` option.
|
|
678
713
|
*/
|
|
679
714
|
export interface ViewShaderProps {
|
|
680
715
|
/** Linked program handle from linkProgram. */
|
|
@@ -719,8 +754,9 @@ export interface ViewProps extends ViewOwnProps, LayoutProps {}
|
|
|
719
754
|
// A stroked rect paints inside its box, like a CSS border: the stroke's outer
|
|
720
755
|
// edge sits on the box edge rather than straddling it, so nothing bleeds past
|
|
721
756
|
// the box for a clip to cut. `path` and `line` strokes stay centered on their
|
|
722
|
-
// geometry - there the geometry is the stroke, not a box.
|
|
723
|
-
|
|
757
|
+
// geometry - there the geometry is the stroke, not a box. A dashed stroke
|
|
758
|
+
// dashes that same inset outline (see DashProps).
|
|
759
|
+
export interface RectProps extends PaintProps, PointerProps, DashProps {
|
|
724
760
|
// Corner radius, measured on the box (the stroke's outer edge). A single
|
|
725
761
|
// number applies to all four corners; an array is [top-left, top-right,
|
|
726
762
|
// bottom-right, bottom-left] (CSS border-radius order).
|
|
@@ -728,11 +764,15 @@ export interface RectProps extends PaintProps, PointerProps {
|
|
|
728
764
|
}
|
|
729
765
|
|
|
730
766
|
// Strokes paint inside the box, same as `RectProps`.
|
|
731
|
-
export interface OvalProps extends PaintProps, PointerProps {}
|
|
767
|
+
export interface OvalProps extends PaintProps, PointerProps, DashProps {}
|
|
732
768
|
|
|
733
769
|
/**
|
|
734
|
-
* A stroke's dash pattern, on
|
|
735
|
-
* to dash; with either unset, or
|
|
770
|
+
* A stroke's dash pattern, on every stroked primitive (`rect`, `oval`,
|
|
771
|
+
* `line`, `path`). Both lengths must be set to dash; with either unset, or
|
|
772
|
+
* a gap of 0, the stroke is solid. A box primitive dashes its inset
|
|
773
|
+
* outline, the pattern starting where SVG's does (a rect on the top edge
|
|
774
|
+
* after the top-left corner, an oval at 3 o'clock) and running clockwise;
|
|
775
|
+
* its dashes stay inside the box like the solid stroke.
|
|
736
776
|
*/
|
|
737
777
|
export interface DashProps {
|
|
738
778
|
/**
|
|
@@ -809,13 +849,20 @@ export interface PathProps extends PaintProps, PointerProps, DashProps {
|
|
|
809
849
|
fillRule?: "nonzero" | "evenodd"
|
|
810
850
|
}
|
|
811
851
|
|
|
852
|
+
export type FontFamily = "sans" | "serif" | "mono" | (string & {})
|
|
853
|
+
export type FontStyle = "normal" | "italic"
|
|
854
|
+
export type FontWeight = 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
855
|
+
export type TextDecoration = "none" | "underline"
|
|
856
|
+
export type TextAlign = "left" | "right" | "center" | "justify"
|
|
857
|
+
export type TextOverflow = "clip" | "ellipsis" | (string & {})
|
|
858
|
+
|
|
812
859
|
/**
|
|
813
860
|
* Per-run text style: the paragraph default on <text>, an override on <span>.
|
|
814
861
|
* The cascade is intra-paragraph only: a span inherits from its enclosing
|
|
815
862
|
* span, then from the <text>; nothing inherits across the tree.
|
|
816
863
|
*/
|
|
817
864
|
export interface TextRunProps {
|
|
818
|
-
fontFamily?:
|
|
865
|
+
fontFamily?: FontFamily
|
|
819
866
|
fontSize?: number
|
|
820
867
|
/**
|
|
821
868
|
* Line height as a MULTIPLIER of fontSize, not pixels (the theme uses
|
|
@@ -823,15 +870,15 @@ export interface TextRunProps {
|
|
|
823
870
|
* font size, rendering the text as blank space.
|
|
824
871
|
*/
|
|
825
872
|
lineHeight?: number
|
|
826
|
-
fontStyle?:
|
|
827
|
-
fontWeight?:
|
|
873
|
+
fontStyle?: FontStyle
|
|
874
|
+
fontWeight?: FontWeight
|
|
828
875
|
/**
|
|
829
876
|
* Underline in the run's own color, drawn straight through descenders
|
|
830
877
|
* (no skip-ink). Position and thickness come from the font's own metrics
|
|
831
878
|
* unless overridden; a font Impeller resolves through the system fallback
|
|
832
879
|
* gets the shipped Noto values.
|
|
833
880
|
*/
|
|
834
|
-
textDecoration?:
|
|
881
|
+
textDecoration?: TextDecoration
|
|
835
882
|
/** Pixels from the baseline to the top of the underline. */
|
|
836
883
|
textUnderlineOffset?: number
|
|
837
884
|
/** Underline thickness in pixels. */
|
|
@@ -851,14 +898,14 @@ export interface SpanProps extends TextRunProps, PointerProps {
|
|
|
851
898
|
|
|
852
899
|
export interface TextProps extends PaintProps, PointerProps, TextRunProps {
|
|
853
900
|
children?: Children
|
|
854
|
-
textAlign?:
|
|
901
|
+
textAlign?: TextAlign
|
|
855
902
|
maxLines?: number
|
|
856
903
|
/**
|
|
857
904
|
* What happens to text cut off by maxLines: "clip" (default), "ellipsis"
|
|
858
905
|
* (a U+2026 at the end of the last line), or any other string to use as
|
|
859
906
|
* the ellipsis. Drawn in the paragraph's default style.
|
|
860
907
|
*/
|
|
861
|
-
textOverflow?:
|
|
908
|
+
textOverflow?: TextOverflow
|
|
862
909
|
/**
|
|
863
910
|
* A word (wrap unit) wider than the line: "anywhere" (default) splits it
|
|
864
911
|
* at grapheme boundaries so it stays inside the box, "normal" keeps it
|