@solidrt/core 0.0.69 → 0.0.71

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.
@@ -102,7 +102,12 @@ Rules, in order of leverage:
102
102
  boundary node itself (or any ancestor) are hoisted out of the cache and
103
103
  applied at composite time, so animating x/y/scale/rotate/opacity of a
104
104
  boundary does NOT re-raster it (verified by A/B measurement - the damage
105
- system classifies these as Transform and keeps the node's own cache).
105
+ system classifies these as Compose and keeps the node's own cache). One
106
+ exception, for crispness: a boundary holding text that comes to rest at
107
+ a new scale re-records once there (a snapshot boundary re-rasterizes
108
+ once), so its content is rasterized at the resting size instead of
109
+ stretched, and a scale transition with a known end rasters for that end
110
+ as it starts; a press scale that returns to 1 never re-rasters.
106
111
  What DOES invalidate the cache is any paint or content change inside the
107
112
  subtree - colors, path data, text, a Show toggling - so drive animation
108
113
  with transforms and keep the cached content itself static. Off a boundary,
@@ -51,6 +51,7 @@ for the element/prop model see `@solidrt/core/AGENTS.md`.
51
51
  - `gpu-texture-blend.tsx` - compositing two shader targets as stacked `<texture>` layers with `blendMode` ("plus" additive over a base pass), the alternative to a third shader that samples both. Click toggles against `"source-over"` to show why. The tree-level counterpart to blending within one draw (`gpu-particles.tsx`); without `blend: "add"` a target's own draw runs with GL blending disabled.
52
52
  - `gpu-raw-program.tsx` - the raw shading layer: compileShader/linkProgram/createRenderPipeline/createShaderTarget, one vertex stage shared by two programs, with and without the standard header.
53
53
  - `gpu-pipeline.tsx` - `createPipelineTexture`: a custom vertex+fragment pair drawing an interleaved vertex buffer (position and color attributes resolved by name), a depth-tested spinning cube whose `uTime` uniform is driven through `<texture params>` exactly like a fragment shader's.
54
+ - `gpu-shader-chain.tsx` - a chain of targets: a plasma `createShaderTexture` bound as a cube `createPipelineTexture`'s sampler input (`textures: { uMap: plasmaId }`). Sampler bindings are live dependencies, so the app drives only the plasma's `uTime` and the cube re-renders after it every frame, in that order, with no uniform and no per-frame code of its own. The plasma shows beside the cube on the `<texture>` that carries its params.
54
55
  - `gpu-particles.tsx` - an additive particle field: `topology: "points"` with `blend: "add"`, so overlapping gaussian splats (`gl_PointSize` from the vertex stage, `gl_PointCoord` falloff, premultiplied output) accumulate into a glowing volume instead of overwriting. The tints are typed vec3 uniforms filled from 3-number array params.
55
56
  - `gpu-instancing.tsx` - instanced drawing: one 3-vertex triangle drawn hundreds of times via `instanceCount`, each instance placed and tinted on a phyllotaxis spiral from `gl_InstanceID` alone. `setDraw(id, { instanceCount })` merges into the draw range per frame (absent keys keep their values, like params), so the population breathes without touching the buffer.
56
57
  - `gpu-draw-list.tsx` - `createDrawTarget`: one render target holding an ordered, mutable LIST of draws - two orbiting triangles from different programs sharing one depth buffer (the target owns the depth storage, each pipeline the test/write behavior), a third entry added and removed live via its stable `DrawId`, and `setDrawParams` as the per-object channel.
@@ -0,0 +1,135 @@
1
+ // A chain of targets: a plasma fragment pass feeding a cube pipeline that
2
+ // samples it. Sampler bindings are live dependencies (the model in
3
+ // @solidrt/core/docs/reference/gpu.md): the cube's target is bound to the
4
+ // plasma's through `textures`, so whenever the plasma re-renders, the cube
5
+ // re-renders after it in the same frame, in that order. The app therefore
6
+ // drives only the first pass: the plasma's uTime through the <texture>
7
+ // params prop, as in gpu-shader.tsx. The cube has no uniform of its own
8
+ // and no per-frame code; its view is fixed in the vertex shader, and its
9
+ // faces move because what they sample moves.
10
+ //
11
+ // The plasma is shown beside the cube because that <texture> is where its
12
+ // params prop lives. A source pass that is not on screen is driven the same
13
+ // way from any <texture> of its id. The cube's buffers and index buffer are
14
+ // the gpu-pipeline.tsx mesh with a UV per corner in place of a color.
15
+ import { render, onFrame, createSignal } from "@solidrt/core"
16
+ import { createBuffer, createPipelineTexture, createShaderTexture, glsl } from "@solidrt/core/gpu"
17
+
18
+ // The plasma target's side: what the cube's faces sample.
19
+ let PLASMA_SIZE = 256
20
+ // The cube target's side: what the window shows.
21
+ let CUBE_SIZE = 512
22
+
23
+ // Pass 1: a plasma, the only thing the app animates.
24
+ let PLASMA = glsl`
25
+ uniform float uTime;
26
+ void main() {
27
+ vec2 uv = vUV * 6.0;
28
+ float t = uTime;
29
+ float v = sin(uv.x + t) + sin(uv.y + t * 0.8) + sin((uv.x + uv.y) * 0.7 + t * 1.3)
30
+ + sin(length(uv - 3.0) * 1.5 - t);
31
+ vec3 col = 0.5 + 0.5 * cos(v * 1.2 + vec3(0.0, 2.1, 4.2));
32
+ fragColor = vec4(col, 1.0);
33
+ }
34
+ `
35
+
36
+ // Pass 2: the cube, viewed from one fixed angle. The projection rig and the
37
+ // clip-y flip are gpu-pipeline.tsx's.
38
+ let VERTEX = glsl`
39
+ in vec3 aPos;
40
+ in vec2 aUV;
41
+ out vec2 vTex;
42
+
43
+ void main() {
44
+ float cy = cos(0.7), sy = sin(0.7);
45
+ float cx = cos(0.5), sx = sin(0.5);
46
+ mat3 rotY = mat3(cy, 0.0, -sy, 0.0, 1.0, 0.0, sy, 0.0, cy);
47
+ mat3 rotX = mat3(1.0, 0.0, 0.0, 0.0, cx, sx, 0.0, -sx, cx);
48
+ vec3 p = rotX * (rotY * aPos);
49
+ p.z -= 2.5;
50
+ float w = -p.z;
51
+ float f = 2.0;
52
+ float a = 11.0 / 9.0;
53
+ float b = -20.0 / 9.0;
54
+ gl_Position = vec4(p.x * f, -p.y * f, w * a + b, w);
55
+ vTex = aUV;
56
+ }
57
+ `
58
+
59
+ // The sampler is the chain: uMap is bound to the plasma target below.
60
+ let FRAGMENT = glsl`
61
+ in vec2 vTex;
62
+ uniform sampler2D uMap;
63
+ void main() {
64
+ fragColor = texture(uMap, vTex);
65
+ }
66
+ `
67
+
68
+ // Interleaved [pos vec3, uv vec2]: 24 vertices, 4 per face, each face the
69
+ // whole plasma once. Every face winds counter-clockwise seen from outside.
70
+ function cubeVertices(): Float32Array {
71
+ type Vec3 = [number, number, number]
72
+ let verts: number[] = []
73
+ let quad = (a: Vec3, b: Vec3, c: Vec3, d: Vec3) => {
74
+ let uvs = [[0, 1], [1, 1], [1, 0], [0, 0]]
75
+ for (let [i, p] of [a, b, c, d].entries()) verts.push(p[0], p[1], p[2], uvs[i]![0]!, uvs[i]![1]!)
76
+ }
77
+ let s = 0.5
78
+ quad([-s, -s, s], [s, -s, s], [s, s, s], [-s, s, s]) // front
79
+ quad([s, -s, -s], [-s, -s, -s], [-s, s, -s], [s, s, -s]) // back
80
+ quad([s, -s, s], [s, -s, -s], [s, s, -s], [s, s, s]) // right
81
+ quad([-s, -s, -s], [-s, -s, s], [-s, s, s], [-s, s, -s]) // left
82
+ quad([-s, s, s], [s, s, s], [s, s, -s], [-s, s, -s]) // top
83
+ quad([-s, -s, -s], [s, -s, -s], [s, -s, s], [-s, -s, s]) // bottom
84
+ return new Float32Array(verts)
85
+ }
86
+
87
+ // Two triangles per face over its 4 shared vertices: 36 uint16 indices.
88
+ function cubeIndices(): Uint16Array {
89
+ let indices: number[] = []
90
+ for (let face = 0; face < 6; face++) {
91
+ let v = face * 4
92
+ indices.push(v, v + 1, v + 2, v, v + 2, v + 3)
93
+ }
94
+ return new Uint16Array(indices)
95
+ }
96
+
97
+ function App() {
98
+ let plasmaId = createShaderTexture(PLASMA, PLASMA_SIZE, PLASMA_SIZE, { uTime: 0 }, { label: "plasma" })
99
+ let bufferId = createBuffer(cubeVertices(), { label: "cube-verts" })
100
+ let indexId = createBuffer(cubeIndices(), { label: "cube-indices" })
101
+ // No params: nothing of the cube's is ever written. Its one input is the
102
+ // plasma target, and that binding is what re-renders it.
103
+ let cubeId = createPipelineTexture(VERTEX, FRAGMENT, CUBE_SIZE, CUBE_SIZE, null, {
104
+ label: "cube",
105
+ textures: { uMap: plasmaId },
106
+ buffers: [
107
+ {
108
+ attributes: [
109
+ { name: "aPos", format: "float32x3" },
110
+ { name: "aUV", format: "float32x2" },
111
+ ],
112
+ buffer: bufferId,
113
+ },
114
+ ],
115
+ indexBuffer: indexId,
116
+ indexFormat: "uint16",
117
+ depth: true,
118
+ cull: "back",
119
+ clearColor: [0.08, 0.08, 0.12, 1],
120
+ })
121
+ let [time, setTime] = createSignal(0)
122
+ onFrame((tick) => setTime(tick / 1000))
123
+
124
+ return (
125
+ <window alignItems="center" justifyContent="center" flexDirection="row" gap={24}>
126
+ <texture src={cubeId} width={CUBE_SIZE} height={CUBE_SIZE} />
127
+ <view flexDirection="column" alignItems="center" gap={8}>
128
+ <texture src={plasmaId} params={{ uTime: time() }} width={PLASMA_SIZE} height={PLASMA_SIZE} />
129
+ <text fontSize={14} color="#888">the plasma pass, the only one driven</text>
130
+ </view>
131
+ </window>
132
+ )
133
+ }
134
+
135
+ render(() => <App />)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.69",
3
+ "version": "0.0.71",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -33,8 +33,8 @@
33
33
  "AGENTS.md"
34
34
  ],
35
35
  "devDependencies": {
36
- "@solidrt/flux-types": "0.0.69",
37
- "@solidrt/test": "0.0.69"
36
+ "@solidrt/flux-types": "0.0.71",
37
+ "@solidrt/test": "0.0.71"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@solidjs/signals": "2.0.0-rc.13",
package/src/core.ts CHANGED
@@ -391,15 +391,42 @@ export function prepareText(text: string, options?: tree.MeasureTextOptions): tr
391
391
  }
392
392
 
393
393
  /**
394
- * Makes the glyph cells of printable ASCII for these font styles ahead of
395
- * their first use, off the frame: call it at startup with the type scale
396
- * the app draws in (the components' Window does it for the theme's), so a
397
- * screen's first paint in any of them never waits for cells. Glyphs outside
398
- * ASCII are made at first use. Each entry names a font as a <text> would;
399
- * line height and maxLines are ignored.
394
+ * Makes glyph cells ahead of their first use, off the frame: call it at
395
+ * startup with the type scale the app draws in (the components' Window
396
+ * does it for the theme's), so a screen's first paint in any of them never
397
+ * waits for cells. Each entry names a font as a <text> would (line height
398
+ * and maxLines are ignored) and warms its printable ASCII; an entry with a
399
+ * `text` also warms the glyphs that string shapes to in the font (a
400
+ * ligature, a curly quote, a letter beyond ASCII), so a headline or a
401
+ * label the app knows it will draw is warm too. Anything else is made at
402
+ * first use.
400
403
  */
401
- export function warmText(styles: tree.MeasureTextOptions[]): void {
402
- tree.warmText(styles)
404
+ export function warmText(entries: tree.WarmTextEntry[]): void {
405
+ tree.warmText(entries)
406
+ }
407
+
408
+ export type TextRenderingOptions = tree.TextRenderingOptions
409
+ export type TextRenderingPolicy = tree.TextRenderingPolicy
410
+
411
+ /**
412
+ * Changes how every text is rendered: the coverage-to-color mode and its
413
+ * gamma or contrast, and the stem darkening and hinting of the glyph cells. Each option
414
+ * given replaces its part of the current policy, the rest stays; every text
415
+ * redraws at the next frame. The defaults are what an app that says nothing
416
+ * gets; this is the knob for judging a policy by eye on a running app.
417
+ */
418
+ export function setTextRendering(options: TextRenderingOptions): void {
419
+ tree.setTextRendering(options)
420
+ }
421
+
422
+ /**
423
+ * The text rendering policy as it stands, resolved for this display: the
424
+ * shape setTextRendering takes, with a darkening or hinting left at its
425
+ * default read as the value the display scale gives it. What a settings
426
+ * screen shows; setting it back changes nothing on this display.
427
+ */
428
+ export function textRendering(): TextRenderingPolicy {
429
+ return tree.textRendering()
403
430
  }
404
431
 
405
432
  /** One laid-out line from layoutNextLine. */
@@ -426,6 +453,13 @@ export type TextLine = {
426
453
  * every glued piece that follows, plus the last piece's ink. What must fit on
427
454
  * the line for the unit to go on it.
428
455
  */
456
+ // Slack a line's width may be exceeded by and still fit, logical px: the
457
+ // width a laid-out box hands back is a float ulp off the width its text
458
+ // measured (the rendertree's layout::FIT_SLACK, the same value), so an
459
+ // exact fit wraps a word inside its own box. 1/64 px is a browser layout
460
+ // unit: far above any ulp drift, far below a visible overhang.
461
+ export const FIT_SLACK = 1 / 64
462
+
429
463
  export function unitInk(units: tree.TextUnit[], index: number): number {
430
464
  let ink = units[index]!.width
431
465
  let advance = 0
@@ -439,7 +473,8 @@ export function unitInk(units: tree.TextUnit[], index: number): number {
439
473
  /**
440
474
  * The next line of `prepared` from unit `cursor` that fits `width`, or null
441
475
  * when the text is used up. Greedy: units go on the line while the pen plus
442
- * the unit's ink stays within `width`; a hard break ends the line; a unit
476
+ * the unit's ink stays within `width` (a hair of slack for float drift
477
+ * included); a hard break ends the line; a unit
443
478
  * wider than `width` on its own goes on the line whole and overflows. Draw a
444
479
  * line as `<d-text x y w={line.width + 1}>{prepared.text.slice(line.start, line.end)}</d-text>`
445
480
  * with the same font options; its words are already shaped, so that is
@@ -458,7 +493,7 @@ export function layoutNextLine(prepared: tree.PreparedText, cursor: number, widt
458
493
  let unit = units[i]!
459
494
  // Glued pieces stay with the unit they continue: the whole wrap unit
460
495
  // (this piece through the last glued one) must fit for any of it to go on.
461
- if (i > cursor && !unit.glue && pen + unitInk(units, i) > width) break
496
+ if (i > cursor && !unit.glue && pen + unitInk(units, i) > width + FIT_SLACK) break
462
497
  pen += unit.advance
463
498
  if (unit.ascent > ascent) ascent = unit.ascent
464
499
  if (unit.descent > descent) descent = unit.descent
package/src/gpu.ts CHANGED
@@ -753,6 +753,128 @@ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferVie
753
753
  gpu.writeBuffer(id, toUint8(data), byteOffset)
754
754
  }
755
755
 
756
+ /**
757
+ * A stream of fixed-size records with a GPU buffer, a CPU mirror and a
758
+ * dirty range: the publish model shared by every JS-stepped population
759
+ * (a 2d sprite layer's records, a 3d mesh's instance streams). Write the
760
+ * mirror, `mark` the records written, then `publish` at the flush: the
761
+ * dirty range goes out as one `writeBuffer` at its byte offset, or the
762
+ * drawn prefix whole through the write lease when the buffer carries an
763
+ * instance order (the engine gathers the copy into key order, and a byte
764
+ * range has no stable position under a permutation). Growth replaces the
765
+ * buffer and the mirror (never a resize) and marks what the mirror held,
766
+ * so the next publish fills the replacement; the old buffer is the
767
+ * caller's to destroy once nothing binds it. Read `buffer` and `bytes` at
768
+ * use time: both are replaced by growth.
769
+ */
770
+ export type RecordStream = {
771
+ /** The GPU buffer the records publish to; replaced by `grow`. */
772
+ readonly buffer: gpu.BufferId
773
+ /** The mirror, `capacity * stride` bytes; replaced by `grow`. View it as
774
+ * the record layout wants (a Float32Array over it for an all-float
775
+ * record). */
776
+ readonly bytes: Uint8Array<ArrayBuffer>
777
+ /** Bytes per record. */
778
+ readonly stride: number
779
+ /** Records the mirror and the buffer hold. */
780
+ readonly capacity: number
781
+ /** Whether any record is marked since the last publish. */
782
+ readonly dirty: boolean
783
+ /** Extend the dirty range over records [first, last). */
784
+ mark(first: number, last: number): void
785
+ /** Send what is dirty and clear the mark. `whole`: the first `limit`
786
+ * records go out through the lease regardless of the range (the
787
+ * ordered form, and a replacement buffer's first fill); otherwise the
788
+ * dirty range, clipped to `limit` records (the live sprites, or the
789
+ * capacity when records are written ahead of their count). Nothing is
790
+ * sent for an empty range or a zero `limit`. */
791
+ publish(whole: boolean, limit: number): void
792
+ /** Replace the buffer and the mirror with room for `capacity` records,
793
+ * the old contents copied over and marked whole; returns the OLD buffer
794
+ * for the caller to destroy once its draws point at the new one. */
795
+ grow(capacity: number): gpu.BufferId
796
+ /** Free the buffer. Safe to call more than once. */
797
+ destroy(): void
798
+ }
799
+
800
+ /**
801
+ * Creates a record stream of `capacity` records of `stride` bytes (see
802
+ * {@link RecordStream}): a sized, unwritten GPU buffer and a zeroed
803
+ * mirror. Freed with the reactive owner like a buffer (opt out with
804
+ * `{ autoFree: false }` and call `destroy`).
805
+ */
806
+ export function createRecordStream(stride: number, capacity: number, opts?: CreateOptions): RecordStream {
807
+ if (!(Number.isInteger(stride) && stride > 0)) throw new Error("createRecordStream: stride must be a positive integer, got " + stride)
808
+ if (!(Number.isInteger(capacity) && capacity > 0)) throw new Error("createRecordStream: capacity must be a positive integer, got " + capacity)
809
+ let buffer = createBuffer(capacity * stride, { label: opts?.label, autoFree: false })
810
+ let bytes = new Uint8Array(capacity * stride)
811
+ let dirty: [number, number] | null = null
812
+ let destroyed = false
813
+ let stream: RecordStream = {
814
+ get buffer() {
815
+ return buffer
816
+ },
817
+ get bytes() {
818
+ return bytes
819
+ },
820
+ stride,
821
+ get capacity() {
822
+ return bytes.length / stride
823
+ },
824
+ get dirty() {
825
+ return dirty !== null
826
+ },
827
+ mark(first, last) {
828
+ let capacity = bytes.length / stride
829
+ if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last > capacity || first > last) {
830
+ throw new Error("RecordStream.mark: range [" + first + ", " + last + ") is outside the stream's " + capacity + " records")
831
+ }
832
+ if (first === last) return
833
+ if (dirty === null) dirty = [first, last]
834
+ else {
835
+ if (first < dirty[0]) dirty[0] = first
836
+ if (last > dirty[1]) dirty[1] = last
837
+ }
838
+ },
839
+ publish(whole, limit) {
840
+ let range = dirty
841
+ dirty = null
842
+ if (whole) {
843
+ let byteLength = Math.min(limit, bytes.length / stride) * stride
844
+ if (byteLength <= 0) return
845
+ let out = beginBufferWrite(buffer)
846
+ new Uint8Array(out.buffer, 0, byteLength).set(bytes.subarray(0, byteLength))
847
+ gpu.endBufferWrite(buffer, byteLength)
848
+ return
849
+ }
850
+ if (range === null) return
851
+ let lo = range[0]
852
+ let hi = Math.min(range[1], limit)
853
+ if (hi > lo) writeBuffer(buffer, bytes.subarray(lo * stride, hi * stride), lo * stride)
854
+ },
855
+ grow(next) {
856
+ let previous = bytes.length / stride
857
+ if (!(Number.isInteger(next) && next > previous)) {
858
+ throw new Error("RecordStream.grow: capacity must be an integer above the current " + previous + ", got " + next)
859
+ }
860
+ let old = buffer
861
+ buffer = createBuffer(next * stride, { label: opts?.label, autoFree: false })
862
+ let grown = new Uint8Array(next * stride)
863
+ grown.set(bytes)
864
+ bytes = grown
865
+ dirty = [0, previous]
866
+ return old
867
+ },
868
+ destroy() {
869
+ if (destroyed) return
870
+ destroyed = true
871
+ gpu.destroyBuffer(buffer)
872
+ },
873
+ }
874
+ if (opts?.autoFree !== false && getOwner()) onCleanup(() => stream.destroy())
875
+ return stream
876
+ }
877
+
756
878
  /**
757
879
  * Hands an ordered instance buffer's full record set to the engine - the
758
880
  * write-once form: the engine keeps the one CPU copy and sorts,
package/src/index.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export * from "./renderer"
2
- export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, warmText, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
3
- export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
2
+ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables, measureText, prepareText, warmText, setTextRendering, textRendering, layoutNextLine, unitInk, getBoundingBox, getBoundingBoxViewport, getLayoutBox, snapshotTexture, onPointerMove } from "./core"
3
+ export type { BoundingBox, GlobalPointerEvent, TextLine, TextRenderingOptions, TextRenderingPolicy } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
6
  export { onFrame, frameTime, onBeforeRender, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
@@ -25,6 +25,7 @@ export { Logo } from "./logo"
25
25
  export type { LogoProps } from "./logo"
26
26
  export type { SvgDocument, SvgDraw } from "./svg"
27
27
  export { createScroll } from "./scroll"
28
+ export { warnOnce } from "./warn"
28
29
  export type { Scroll, ScrollAxis, ScrollBehavior, ScrollOffset, ScrollOptions, ScrollToOptions } from "./scroll"
29
30
  export { arena } from "./arena"
30
31
  export type { ArenaOwner } from "./arena"
@@ -93,7 +94,7 @@ export type {
93
94
  Color,
94
95
  Pct,
95
96
  } from "./types"
96
- export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
97
+ export type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit, WarmTextEntry } from "flux:rendertree"
97
98
 
98
99
  // A percentage value for dimensional props (e.g. transformOrigin): `pct(50)` is
99
100
  // half the element box. Keeps percentages a first-class branded value rather
package/src/scroll.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { createSignal } from "@solidjs/signals"
8
8
  import { getBoundingBox } from "./core"
9
9
  import { onLayout } from "./window"
10
+ import { warnOnce } from "./warn"
10
11
 
11
12
  export type ScrollAxis = "vertical" | "horizontal" | "both"
12
13
 
@@ -75,11 +76,9 @@ export function createScroll(
75
76
 
76
77
  // A scroll viewport with no explicit main-axis size resolves to 0 in flex
77
78
  // layout and its content silently vanishes - a classic trap (maxHeight alone
78
- // does not size it either). Detect it at measure time and warn once, with a
79
- // stack captured at creation so the warning points at the component that
80
- // built the scroller (the dev server remaps the frames to .tsx).
81
- let origin = new Error().stack ?? ""
82
- let warnedCollapsed = false
79
+ // does not size it either). Detect it at measure time and warn once, from
80
+ // the component that built the scroller (warnOnce captures its stack here).
81
+ let warnCollapsed = warnOnce()
83
82
 
84
83
  // Last measured overflow, refreshed each layout. scrollBy/scrollTo clamp
85
84
  // against these between layouts; onLayout re-clamps once new sizes are known.
@@ -110,17 +109,14 @@ export function createScroll(
110
109
  let vb = getBoundingBox(vp)
111
110
  let cb = getBoundingBox(ct)
112
111
  if (!vb || !cb) return
113
- if (!warnedCollapsed) {
114
- let zeroY = canY && vb.height === 0 && cb.height > 0
115
- let zeroX = canX && vb.width === 0 && cb.width > 0
116
- if (zeroY || zeroX) {
117
- warnedCollapsed = true
118
- let axisName = zeroY ? "height" : "width"
119
- console.warn(
120
- `Scroll container resolved to ${axisName} 0, so its content is invisible. ` +
121
- `Give it an explicit ${axisName} or flex; maxHeight/maxWidth alone does not size it.\n${origin}`,
122
- )
123
- }
112
+ let zeroY = canY && vb.height === 0 && cb.height > 0
113
+ let zeroX = canX && vb.width === 0 && cb.width > 0
114
+ if (zeroY || zeroX) {
115
+ let axisName = zeroY ? "height" : "width"
116
+ warnCollapsed(
117
+ `Scroll container resolved to ${axisName} 0, so its content is invisible. ` +
118
+ `Give it an explicit ${axisName} or flex; maxHeight/maxWidth alone does not size it.`,
119
+ )
124
120
  }
125
121
  maxX = Math.max(0, cb.width - vb.width)
126
122
  maxY = Math.max(0, cb.height - vb.height)
package/src/text-input.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // are policy and belong to the component (the "skin") that composes these.
6
6
 
7
7
  import { createMemo, createSignal, flush, untrack } from "@solidjs/signals"
8
- import { getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
8
+ import { FIT_SLACK, getLayoutBox, layoutNextLine, measureText, prepareText, unitInk } from "./core"
9
9
  import type { MeasureTextOptions, PreparedText, TextRunRange, TextUnit } from "flux:rendertree"
10
10
  import { onLayout } from "./window"
11
11
 
@@ -542,12 +542,12 @@ export function createTextEditorLayout(
542
542
  // through as is.
543
543
  function splitWide(prepared: PreparedText, width: number): PreparedText {
544
544
  let all = prepared.units
545
- if (!all.some((u, i) => !u.glue && unitInk(all, i) > width)) return prepared
545
+ if (!all.some((u, i) => !u.glue && unitInk(all, i) > width + FIT_SLACK)) return prepared
546
546
  let wide = false
547
547
  let units: TextUnit[] = []
548
548
  for (let u = 0; u < all.length; u++) {
549
549
  let unit = all[u]!
550
- if (!unit.glue) wide = unitInk(all, u) > width
550
+ if (!unit.glue) wide = unitInk(all, u) > width + FIT_SLACK
551
551
  let stops = unit.carets
552
552
  if (!wide || !stops || stops.length <= 2) {
553
553
  units.push(unit)
package/src/types.d.ts CHANGED
@@ -103,6 +103,21 @@ export interface LayoutProps extends FlexboxProps, GridProps {
103
103
  * resolves against whatever relative element is above all of them.
104
104
  */
105
105
  position?: "relative" | "absolute"
106
+ /**
107
+ * CSS size containment: the element sizes as if it had no content. A
108
+ * texture or text measures as zero on every axis layout does not hand it,
109
+ * and a view's children add nothing to its box; they lay out inside
110
+ * whatever box it gets. The one CSS `contain` value with a meaning here.
111
+ * The element still needs a size from its own props or its parent (a
112
+ * `flexGrow` in a sized column), or it comes out empty.
113
+ *
114
+ * What takes the place of the web's `minHeight={0}` escape from the flex
115
+ * automatic minimum, declared on the element whose content would
116
+ * otherwise pin its ancestors: a fill-mode `<Scene>` or `<SpriteLayer>`
117
+ * leaf sets it, so the target that follows the leaf's box never feeds its
118
+ * pixel size back into layout.
119
+ */
120
+ contain?: "size"
106
121
 
107
122
  top?: Dimension
108
123
  right?: Dimension
package/src/warn.ts ADDED
@@ -0,0 +1,22 @@
1
+ // A development warning for a misuse that shows later than the code that
2
+ // caused it: a layout that comes out empty, an event that never arrives. The
3
+ // site of the misuse is the component that built the thing, not the layout
4
+ // pass that notices, so the warning carries that component's stack.
5
+
6
+ /**
7
+ * A once-only dev warning bound to the place it is created: `let warn =
8
+ * warnOnce()` where a component builds something whose misuse surfaces
9
+ * later (a scroller, a fill-mode scene), `warn(message)` from the layout
10
+ * or event read that notices. The first call reports, with the stack of the
11
+ * creation site under the message (the dev server remaps the frames to
12
+ * .tsx); later calls are silent.
13
+ */
14
+ export function warnOnce(): (message: string) => void {
15
+ let origin = new Error().stack ?? ""
16
+ let warned = false
17
+ return message => {
18
+ if (warned) return
19
+ warned = true
20
+ console.warn(`${message}\n${origin}`)
21
+ }
22
+ }