@tongue-core/engine 0.1.0-rc.1 → 0.1.0-rc.10

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/assets/codecs/types.d.ts +2 -0
  4. package/dist/assets/codecs/wav.d.ts +3 -0
  5. package/dist/assets/codecs/wav.js +26 -6
  6. package/dist/assets/store.d.ts +18 -0
  7. package/dist/assets/store.js +82 -6
  8. package/dist/audio/mix.d.ts +7 -1
  9. package/dist/audio/mix.js +14 -3
  10. package/dist/evaluate/evaluate.js +123 -10
  11. package/dist/evaluate/tree.d.ts +24 -1
  12. package/dist/index.d.ts +5 -4
  13. package/dist/index.js +5 -4
  14. package/dist/layout/layout.js +130 -82
  15. package/dist/paint/geometry.d.ts +12 -4
  16. package/dist/paint/geometry.js +25 -11
  17. package/dist/paint/list.d.ts +28 -1
  18. package/dist/paint/list.js +26 -2
  19. package/dist/paint/morph.js +11 -7
  20. package/dist/paint/paint.d.ts +18 -7
  21. package/dist/paint/paint.js +181 -16
  22. package/dist/paint/stroke.d.ts +26 -0
  23. package/dist/paint/stroke.js +233 -0
  24. package/dist/raster/raster.d.ts +3 -0
  25. package/dist/raster/raster.js +5 -0
  26. package/dist/raster/wasm-bytes.js +2 -2
  27. package/dist/render/compose.d.ts +1 -1
  28. package/dist/render/compose.js +33 -11
  29. package/dist/render/renderer.d.ts +7 -6
  30. package/dist/render/renderer.js +28 -7
  31. package/dist/render/size.d.ts +6 -0
  32. package/dist/render/size.js +7 -0
  33. package/dist/render/sounds.d.ts +4 -1
  34. package/dist/render/sounds.js +6 -2
  35. package/dist/style/computed.d.ts +14 -1
  36. package/dist/style/parse.js +2 -4
  37. package/dist/style/resolve.js +53 -4
  38. package/dist/text/fonts.d.ts +4 -1
  39. package/dist/text/fonts.js +9 -1
  40. package/dist/text/ttf.js +5 -1
  41. package/package.json +6 -4
  42. package/raster.wasm +0 -0
@@ -8,6 +8,8 @@ const hExtra = (b) => b.style.padding[1] + b.style.padding[3] + b.style.border[1
8
8
  const vExtra = (b) => b.style.padding[0] + b.style.padding[2] + b.style.border[0].width + b.style.border[2].width;
9
9
  const clampTo = (v, min, max) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v));
10
10
  // A grid with no column template is one flexible column.
11
+ /** Slack for fitting a line: widths from percentages add up to a hair over the room they were cut from. */
12
+ const FITS_WITHIN = 1e-6;
11
13
  const oneColumn = [{ kind: "fr", v: 1 }];
12
14
  const inFlow = (b) => b.style.display !== "none" && b.style.position !== "absolute";
13
15
  export function layout(root, width, height, env) {
@@ -31,11 +33,14 @@ class Engine {
31
33
  text: n.paragraph ? { paragraph: n.paragraph, x: x + n.textX, y: y + n.textY } : null,
32
34
  };
33
35
  }
34
- fontFor(box, weight, size) {
35
- const font = box.style.text.font;
36
- if (!font)
36
+ fontFor(box, style) {
37
+ const family = style.font;
38
+ if (!family)
37
39
  throw new LayoutError(`<${box.tag}> has text but no fontFamily (set one on the Stage or an ancestor).`);
38
- return this.env.fonts.resolve(font, weight, size);
40
+ const file = style.italic ? family.italic : family;
41
+ if (!file)
42
+ throw new LayoutError(`<${box.tag}> is italic, but font "${family.name}" has no italic file: declare it with font("${family.source}", { italic: "…" }).`);
43
+ return this.env.fonts.resolve(file, style.fontWeight, style.fontSize, style.axes);
39
44
  }
40
45
  runs(box) {
41
46
  if (box.content.kind !== "text")
@@ -43,7 +48,7 @@ class Engine {
43
48
  return box.content.runs.map((r) => ({
44
49
  text: r.text,
45
50
  style: r.style,
46
- font: this.fontFor(box, r.style.fontWeight, r.style.fontSize),
51
+ font: this.fontFor(box, r.style),
47
52
  owner: r.owner,
48
53
  visible: r.visible,
49
54
  }));
@@ -55,7 +60,7 @@ class Engine {
55
60
  align: t.textAlign,
56
61
  whiteSpace: t.whiteSpace,
57
62
  ellipsis: box.style.textOverflow === "ellipsis" && box.style.overflowHidden,
58
- strut: box.style.text.font ? { style: t, font: this.fontFor(box, t.fontWeight, t.fontSize) } : null,
63
+ strut: box.style.text.font ? { style: t, font: this.fontFor(box, t) } : null,
59
64
  });
60
65
  }
61
66
  natural(box) {
@@ -121,7 +126,8 @@ class Engine {
121
126
  else {
122
127
  const kids = box.content.children.filter(inFlow);
123
128
  const outer = (k) => this.minContent(k) + marginOf(k.style.margin[1], 0) + marginOf(k.style.margin[3], 0);
124
- if (s.display === "flex" && s.direction === "row")
129
+ // A row that wraps can narrow until each item has a line of its own.
130
+ if (s.display === "flex" && s.direction === "row" && !s.wrap)
125
131
  w = kids.reduce((sum, k) => sum + outer(k), 0) + s.columnGap * Math.max(0, kids.length - 1);
126
132
  else if (s.display === "grid")
127
133
  w = this.gridIntrinsic(box, kids, (k) => this.minContent(k));
@@ -353,8 +359,11 @@ class Engine {
353
359
  const s = box.style;
354
360
  const row = s.direction === "row";
355
361
  const gap = row ? s.columnGap : s.rowGap;
362
+ const crossGap = row ? s.rowGap : s.columnGap;
356
363
  const mainAvail = row ? innerW : innerH;
357
364
  const crossAvail = row ? innerH : innerW;
365
+ // A column with no height has nothing to wrap against: everything fits.
366
+ const wraps = s.wrap && mainAvail !== null;
358
367
  const alignOf = (k) => (k.style.alignSelf === "auto" ? s.align : k.style.alignSelf);
359
368
  const items = kids.map((k) => {
360
369
  const ks = k.style;
@@ -367,7 +376,8 @@ class Engine {
367
376
  const crossFor = () => {
368
377
  if (row)
369
378
  return 0;
370
- const stretch = alignOf(k) === "stretch" && ks.width.kind === "auto";
379
+ // Wrapped columns are as wide as their widest item, so an item can't fill one yet.
380
+ const stretch = !wraps && alignOf(k) === "stretch" && ks.width.kind === "auto";
371
381
  return stretch ? Math.max(0, innerW - crossMargin) : this.widthIn(k, innerW, "fit");
372
382
  };
373
383
  let basis = px(ks.basis, mainAvail) ?? specifiedMain;
@@ -383,83 +393,121 @@ class Engine {
383
393
  const autoMin = minMain ?? (ks.overflowHidden ? 0 : row ? this.minContent(k) : 0);
384
394
  return { box: k, basis, hyp: clampTo(basis, autoMin, maxMain), minMain: autoMin, maxMain, mainMargin, crossMargin, m, crossFor };
385
395
  });
386
- const totalGap = gap * Math.max(0, items.length - 1);
387
- const sizes = items.map((i) => i.hyp);
388
- if (mainAvail !== null) {
389
- const free = mainAvail - totalGap - items.reduce((sum, i, n) => sum + (sizes[n] ?? 0) + i.mainMargin, 0);
390
- const growTotal = items.reduce((sum, i) => sum + i.box.style.grow, 0);
391
- const shrinkTotal = items.reduce((sum, i) => sum + i.box.style.shrink * i.basis, 0);
392
- if (free > 0 && growTotal > 0)
393
- items.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.grow) / growTotal, i.minMain, i.maxMain); });
394
- else if (free < 0 && shrinkTotal > 0)
395
- items.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.shrink * i.basis) / shrinkTotal, i.minMain, i.maxMain); });
396
- }
397
- // Lay out each item at its main size to learn its cross size.
398
- const first = items.map((i, n) => {
399
- const main = sizes[n] ?? 0;
400
- if (row)
401
- return this.layout(i.box, { width: innerW, height: innerH }, { w: main });
402
- return this.layout(i.box, { width: innerW, height: innerH }, { w: i.crossFor(), h: main });
396
+ // Each line sizes its own items along the main axis, then lays them out to learn its cross size.
397
+ const lines = (s.wrap && mainAvail !== null ? breakLines(items, mainAvail, gap) : [items]).map((line) => {
398
+ const totalGap = gap * Math.max(0, line.length - 1);
399
+ const sizes = line.map((i) => i.hyp);
400
+ if (mainAvail !== null) {
401
+ const free = mainAvail - totalGap - line.reduce((sum, i, n) => sum + (sizes[n] ?? 0) + i.mainMargin, 0);
402
+ const growTotal = line.reduce((sum, i) => sum + i.box.style.grow, 0);
403
+ const shrinkTotal = line.reduce((sum, i) => sum + i.box.style.shrink * i.basis, 0);
404
+ if (free > 0 && growTotal > 0)
405
+ line.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.grow) / growTotal, i.minMain, i.maxMain); });
406
+ else if (free < 0 && shrinkTotal > 0)
407
+ line.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.shrink * i.basis) / shrinkTotal, i.minMain, i.maxMain); });
408
+ }
409
+ const first = line.map((i, n) => {
410
+ const main = sizes[n] ?? 0;
411
+ if (row)
412
+ return this.layout(i.box, { width: innerW, height: innerH }, { w: main });
413
+ return this.layout(i.box, { width: innerW, height: innerH }, { w: i.crossFor(), h: main });
414
+ });
415
+ const natural = first.reduce((mx, node, n) => Math.max(mx, (row ? node.h : node.w) + (line[n]?.crossMargin ?? 0)), 0);
416
+ return { items: line, first, totalGap, natural };
403
417
  });
404
- const crossSize = crossAvail ?? first.reduce((mx, node, n) => Math.max(mx, (row ? node.h : node.w) + (items[n]?.crossMargin ?? 0)), 0);
405
- const nodes = items.map((i, n) => {
406
- const node = first[n];
407
- if (!node)
408
- throw new LayoutError("flex item missing");
409
- const stretch = alignOf(i.box) === "stretch" && (row ? i.box.style.height.kind === "auto" : i.box.style.width.kind === "auto");
410
- if (!stretch)
418
+ // A line that can't wrap fills the box. Wrapped lines take their tallest item, then share what
419
+ // is left of a definite size equally (CSS's align-content: stretch, the default).
420
+ const naturalTotal = lines.reduce((sum, l) => sum + l.natural, 0) + crossGap * Math.max(0, lines.length - 1);
421
+ const share = wraps && crossAvail !== null && lines.length > 0 ? Math.max(0, crossAvail - naturalTotal) / lines.length : 0;
422
+ const crossSizes = lines.map((l) => (wraps ? l.natural + share : crossAvail ?? l.natural));
423
+ const placed = [];
424
+ let lineStart = 0, mainEnd = 0;
425
+ lines.forEach((line, lineIndex) => {
426
+ const crossSize = crossSizes[lineIndex] ?? 0;
427
+ const nodes = line.items.map((i, n) => {
428
+ const node = line.first[n];
429
+ if (!node)
430
+ throw new LayoutError("flex item missing");
431
+ const stretch = alignOf(i.box) === "stretch" && (row ? i.box.style.height.kind === "auto" : i.box.style.width.kind === "auto");
432
+ if (!stretch)
433
+ return node;
434
+ const target = Math.max(0, crossSize - i.crossMargin);
435
+ if (row && node.h !== target)
436
+ return this.layout(i.box, { width: innerW, height: target }, { w: node.w, h: target });
437
+ if (!row && node.w !== target)
438
+ return this.layout(i.box, { width: innerW, height: innerH }, { w: target, h: node.h });
411
439
  return node;
412
- const target = Math.max(0, crossSize - i.crossMargin);
413
- if (row && node.h !== target)
414
- return this.layout(i.box, { width: innerW, height: target }, { w: node.w, h: target });
415
- return node;
416
- });
417
- const used = nodes.reduce((sum, node, n) => sum + (row ? node.w : node.h) + (items[n]?.mainMargin ?? 0), 0) + totalGap;
418
- const space = mainAvail === null ? 0 : mainAvail - used;
419
- const autoMargins = items.reduce((c, i) => c + (row ? [i.box.style.margin[1], i.box.style.margin[3]] : [i.box.style.margin[0], i.box.style.margin[2]]).filter((d) => d.kind === "auto").length, 0);
420
- let pos = 0, between = gap;
421
- if (autoMargins === 0 && space > 0) {
422
- const count = items.length;
423
- if (s.justify === "center")
424
- pos = space / 2;
425
- else if (s.justify === "end")
426
- pos = space;
427
- else if (s.justify === "space-between" && count > 1)
428
- between = gap + space / (count - 1);
429
- else if (s.justify === "space-around" && count > 0) {
430
- pos = space / count / 2;
431
- between = gap + space / count;
432
- }
433
- else if (s.justify === "space-evenly" && count > 0) {
434
- pos = space / (count + 1);
435
- between = gap + space / (count + 1);
436
- }
437
- }
438
- const perAuto = autoMargins > 0 && space > 0 ? space / autoMargins : 0;
439
- nodes.forEach((node, n) => {
440
- const i = items[n];
441
- if (!i)
442
- return;
443
- const ms = i.box.style.margin;
444
- const [before, after] = row ? [ms[3], ms[1]] : [ms[0], ms[2]];
445
- const lead = (row ? (i.m[3] ?? 0) : (i.m[0] ?? 0)) + (before.kind === "auto" ? perAuto : 0);
446
- const trail = (row ? (i.m[1] ?? 0) : (i.m[2] ?? 0)) + (after.kind === "auto" ? perAuto : 0);
447
- const main = row ? node.w : node.h;
448
- const cross = row ? node.h : node.w;
449
- const crossLead = row ? (i.m[0] ?? 0) : (i.m[3] ?? 0);
450
- const a = alignOf(i.box);
451
- const crossOffset = a === "center" ? (crossSize - cross - i.crossMargin) / 2 : a === "end" ? crossSize - cross - i.crossMargin : 0;
452
- if (row) {
453
- node.x = left + pos + lead;
454
- node.y = top + crossLead + crossOffset;
455
- }
456
- else {
457
- node.y = top + pos + lead;
458
- node.x = left + crossLead + crossOffset;
440
+ });
441
+ const used = nodes.reduce((sum, node, n) => sum + (row ? node.w : node.h) + (line.items[n]?.mainMargin ?? 0), 0) + line.totalGap;
442
+ const space = mainAvail === null ? 0 : mainAvail - used;
443
+ const autoMargins = line.items.reduce((c, i) => c + (row ? [i.box.style.margin[1], i.box.style.margin[3]] : [i.box.style.margin[0], i.box.style.margin[2]]).filter((d) => d.kind === "auto").length, 0);
444
+ let pos = 0, between = gap;
445
+ if (autoMargins === 0 && space > 0) {
446
+ const count = line.items.length;
447
+ if (s.justify === "center")
448
+ pos = space / 2;
449
+ else if (s.justify === "end")
450
+ pos = space;
451
+ else if (s.justify === "space-between" && count > 1)
452
+ between = gap + space / (count - 1);
453
+ else if (s.justify === "space-around" && count > 0) {
454
+ pos = space / count / 2;
455
+ between = gap + space / count;
456
+ }
457
+ else if (s.justify === "space-evenly" && count > 0) {
458
+ pos = space / (count + 1);
459
+ between = gap + space / (count + 1);
460
+ }
459
461
  }
460
- pos += lead + main + trail + (n < nodes.length - 1 ? between : 0);
462
+ const perAuto = autoMargins > 0 && space > 0 ? space / autoMargins : 0;
463
+ nodes.forEach((node, n) => {
464
+ const i = line.items[n];
465
+ if (!i)
466
+ return;
467
+ const ms = i.box.style.margin;
468
+ const [before, after] = row ? [ms[3], ms[1]] : [ms[0], ms[2]];
469
+ const lead = (row ? (i.m[3] ?? 0) : (i.m[0] ?? 0)) + (before.kind === "auto" ? perAuto : 0);
470
+ const trail = (row ? (i.m[1] ?? 0) : (i.m[2] ?? 0)) + (after.kind === "auto" ? perAuto : 0);
471
+ const main = row ? node.w : node.h;
472
+ const cross = row ? node.h : node.w;
473
+ const crossLead = lineStart + (row ? (i.m[0] ?? 0) : (i.m[3] ?? 0));
474
+ const a = alignOf(i.box);
475
+ const crossOffset = a === "center" ? (crossSize - cross - i.crossMargin) / 2 : a === "end" ? crossSize - cross - i.crossMargin : 0;
476
+ if (row) {
477
+ node.x = left + pos + lead;
478
+ node.y = top + crossLead + crossOffset;
479
+ }
480
+ else {
481
+ node.y = top + pos + lead;
482
+ node.x = left + crossLead + crossOffset;
483
+ }
484
+ pos += lead + main + trail + (n < nodes.length - 1 ? between : 0);
485
+ });
486
+ mainEnd = Math.max(mainEnd, pos);
487
+ lineStart += crossSize + crossGap;
488
+ placed.push(...nodes);
461
489
  });
462
- const height = row ? crossSize : innerH ?? pos;
463
- return { nodes, height };
490
+ const crossTotal = crossSizes.reduce((sum, size) => sum + size, 0) + crossGap * Math.max(0, lines.length - 1);
491
+ const height = row ? innerH ?? crossTotal : innerH ?? mainEnd;
492
+ return { nodes: placed, height };
493
+ }
494
+ }
495
+ /** CSS's line breaking: an item starts a new line when it wouldn't fit on this one, and every line
496
+ * holds at least one item, however wide. */
497
+ function breakLines(items, room, gap) {
498
+ const lines = [];
499
+ let used = 0;
500
+ for (const item of items) {
501
+ const outer = item.hyp + item.mainMargin;
502
+ const line = lines.at(-1);
503
+ if (line && used + gap + outer <= room + FITS_WITHIN) {
504
+ line.push(item);
505
+ used += gap + outer;
506
+ }
507
+ else {
508
+ lines.push([item]);
509
+ used = outer;
510
+ }
464
511
  }
512
+ return lines;
465
513
  }
@@ -9,7 +9,7 @@ export declare const apply: (m: Affine, x: number, y: number) => {
9
9
  x: number;
10
10
  y: number;
11
11
  };
12
- /** Scale, rotate about a point (CSS transform-origin at the box center). */
12
+ /** Scale, rotate about a point (CSS transform-origin). */
13
13
  export declare function about(cx: number, cy: number, scale: number, rotate: number): Affine;
14
14
  /** The transform that maps rectangle `from` onto rectangle `to`. */
15
15
  export declare const fitRect: (from: Rect, to: Rect) => Affine;
@@ -21,6 +21,14 @@ export declare const inflate: (r: Rect, by: number) => Rect;
21
21
  export declare const contains: (r: Rect, x: number, y: number) => boolean;
22
22
  /** Uniform scale of an affine (for radii and blur under a camera). */
23
23
  export declare const scaleOf: (m: Affine) => number;
24
- /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`,
25
- * about the rect's center, in the same coordinate space. */
26
- export declare function perspective(r: Rect, turn: number, tilt: number, depth: number): Homography;
24
+ export interface Point {
25
+ readonly x: number;
26
+ readonly y: number;
27
+ }
28
+ /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`, in
29
+ * the same coordinate space. It pivots on `pivot` and looks at `vanish`, both by default the
30
+ * rect's center. */
31
+ export declare function perspective(r: Rect, turn: number, tilt: number, depth: number, pivot?: Point, vanish?: Point): Homography;
32
+ /** The homography putting a rect on a four-sided shape; null when the shape has collapsed to a
33
+ * line or a point, which nothing can be drawn on. */
34
+ export declare function ontoQuad(r: Rect, quad: ReadonlyArray<Point>): Homography | null;
@@ -19,7 +19,7 @@ export const rotation = (degrees) => {
19
19
  return { a: cs, b: sn, c: -sn, d: cs, e: 0, f: 0 };
20
20
  };
21
21
  export const apply = (m, x, y) => ({ x: m.a * x + m.c * y + m.e, y: m.b * x + m.d * y + m.f });
22
- /** Scale, rotate about a point (CSS transform-origin at the box center). */
22
+ /** Scale, rotate about a point (CSS transform-origin). */
23
23
  export function about(cx, cy, scale, rotate) {
24
24
  let m = translate(cx, cy);
25
25
  if (rotate !== 0)
@@ -73,21 +73,35 @@ function mulH(m, n) {
73
73
  const [a = 0, b = 0, c = 0, d = 0, e = 0, f = 0, g = 0, h = 0, i = 1] = out;
74
74
  return [a, b, c, d, e, f, g, h, i];
75
75
  }
76
- /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`,
77
- * about the rect's center, in the same coordinate space. */
78
- export function perspective(r, turn, tilt, depth) {
79
- const cx = r.x + r.w / 2, cy = r.y + r.h / 2;
76
+ /** The homography taking a rect's corners (top left, clockwise) to a quad's. */
77
+ function rectMap(r, quad) {
78
+ // Compose: rect → unit square, then unit square → quad.
79
+ const toUnit = [1 / r.w, 0, -r.x / r.w, 0, 1 / r.h, -r.y / r.h, 0, 0, 1];
80
+ return mulH(squareToQuad(quad), toUnit);
81
+ }
82
+ /** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`, in
83
+ * the same coordinate space. It pivots on `pivot` and looks at `vanish`, both by default the
84
+ * rect's center. */
85
+ export function perspective(r, turn, tilt, depth, pivot, vanish) {
86
+ const center = { x: r.x + r.w / 2, y: r.y + r.h / 2 };
87
+ const { x: px, y: py } = pivot ?? center;
88
+ const { x: vx, y: vy } = vanish ?? center;
80
89
  const ty = radians(turn), tx = radians(tilt);
81
90
  const project = (x, y) => {
82
- const lx = x - cx, ly = y - cy;
91
+ const lx = x - px, ly = y - py;
83
92
  // rotateY then rotateX, like `rotateY(turn) rotateX(tilt)`.
84
93
  const x1 = lx * cos(ty), z1 = -lx * sin(ty);
85
94
  const y2 = ly * cos(tx) - z1 * sin(tx), z2 = ly * sin(tx) + z1 * cos(tx);
86
95
  const w = 1 - z2 / depth;
87
- return { x: cx + x1 / w, y: cy + y2 / w };
96
+ return { x: vx + (px + x1 - vx) / w, y: vy + (py + y2 - vy) / w };
88
97
  };
89
- const quad = [project(r.x, r.y), project(r.x + r.w, r.y), project(r.x + r.w, r.y + r.h), project(r.x, r.y + r.h)];
90
- // Compose: rect → unit square, then unit square → quad.
91
- const toUnit = [1 / r.w, 0, -r.x / r.w, 0, 1 / r.h, -r.y / r.h, 0, 0, 1];
92
- return mulH(squareToQuad(quad), toUnit);
98
+ return rectMap(r, [project(r.x, r.y), project(r.x + r.w, r.y), project(r.x + r.w, r.y + r.h), project(r.x, r.y + r.h)]);
99
+ }
100
+ /** The homography putting a rect on a four-sided shape; null when the shape has collapsed to a
101
+ * line or a point, which nothing can be drawn on. */
102
+ export function ontoQuad(r, quad) {
103
+ if (!(r.w > 0) || !(r.h > 0))
104
+ return null;
105
+ const m = rectMap(r, quad);
106
+ return m.every((v) => Number.isFinite(v)) ? m : null;
93
107
  }
@@ -1,6 +1,7 @@
1
+ import type { BlendMode } from "@tongue-core/film/model";
1
2
  import type { PathVerb } from "../text/font-types.js";
2
3
  export declare const MAGIC = 1279544402;
3
- export declare const VERSION = 1;
4
+ export declare const VERSION = 2;
4
5
  export declare const OP_END = 0;
5
6
  export declare const OP_TRANSFORM = 1;
6
7
  export declare const OP_FILL_RRECT = 2;
@@ -13,6 +14,7 @@ export declare const OP_PUSH_CLIP = 8;
13
14
  export declare const OP_POP_CLIP = 9;
14
15
  export declare const OP_PUSH_LAYER = 10;
15
16
  export declare const OP_POP_LAYER = 11;
17
+ export declare const OP_POP_MASK = 12;
16
18
  /** Premultiplied color, each channel 0..1. */
17
19
  export interface Rgba {
18
20
  readonly r: number;
@@ -41,6 +43,17 @@ export type Paint = {
41
43
  readonly rx: number;
42
44
  readonly ry: number;
43
45
  readonly stops: ReadonlyArray<Stop>;
46
+ }
47
+ /** A color through noise. Solid: where the noise is below `threshold`, feathered by `soft`. Band:
48
+ * only near the threshold, within `soft`. */
49
+ | {
50
+ readonly kind: "noise";
51
+ readonly ridged: boolean;
52
+ readonly band: boolean;
53
+ readonly size: number;
54
+ readonly threshold: number;
55
+ readonly soft: number;
56
+ readonly color: Rgba;
44
57
  };
45
58
  /** `x' = a·x + c·y + e`, `y' = b·x + d·y + f`. */
46
59
  export interface Affine {
@@ -65,11 +78,22 @@ export interface Radii {
65
78
  }
66
79
  /** Row-major 3×3 projective map. */
67
80
  export type Homography = readonly [number, number, number, number, number, number, number, number, number];
81
+ /** How a layer lands on its parent. "plus" adds, for averaging samples; it isn't an author's mode. */
82
+ export type LayerBlend = BlendMode | "plus";
68
83
  export interface LayerEffects {
69
84
  readonly opacity: number;
70
85
  readonly brightness: number;
71
86
  readonly saturate: number;
72
87
  readonly blur: number;
88
+ /** Default 1. */
89
+ readonly contrast?: number;
90
+ /** A hue rotation, as the cosine and sine of its angle (the raster has no trigonometry of its own). Default none. */
91
+ readonly hue?: {
92
+ readonly cos: number;
93
+ readonly sin: number;
94
+ };
95
+ /** Default "normal". */
96
+ readonly blend?: LayerBlend;
73
97
  }
74
98
  export interface PlacedGlyph {
75
99
  readonly glyph: number;
@@ -100,6 +124,9 @@ export declare class DisplayList {
100
124
  /** Composites the innermost layer: in place, or through `projective` (from where its pixels
101
125
  * were drawn to where they land). */
102
126
  popLayer(projective?: Homography): void;
127
+ /** Pops the innermost layer as a mask: instead of drawing onto its parent, it multiplies the
128
+ * parent's pixels by its alpha (or, inverted, by one minus its alpha). The parent stays open. */
129
+ popMask(invert: boolean): void;
103
130
  /** Appends END and returns the bytes. The writer is spent afterwards. */
104
131
  finish(): Uint8Array;
105
132
  }
@@ -1,5 +1,5 @@
1
1
  export const MAGIC = 0x4c444c52;
2
- export const VERSION = 1;
2
+ export const VERSION = 2;
3
3
  export const OP_END = 0;
4
4
  export const OP_TRANSFORM = 1;
5
5
  export const OP_FILL_RRECT = 2;
@@ -12,6 +12,7 @@ export const OP_PUSH_CLIP = 8;
12
12
  export const OP_POP_CLIP = 9;
13
13
  export const OP_PUSH_LAYER = 10;
14
14
  export const OP_POP_LAYER = 11;
15
+ export const OP_POP_MASK = 12;
15
16
  const VERB_MOVE = 0;
16
17
  const VERB_LINE = 1;
17
18
  const VERB_QUAD = 2;
@@ -20,6 +21,8 @@ const VERB_CLOSE = 4;
20
21
  const PAINT_SOLID = 0;
21
22
  const PAINT_LINEAR = 1;
22
23
  const PAINT_RADIAL = 2;
24
+ const PAINT_NOISE = 3;
25
+ const BLENDS = { normal: 0, multiply: 1, screen: 2, overlay: 3, "soft-light": 4, "color-dodge": 5, plus: 6 };
23
26
  /** A growable little-endian word buffer. Rejects non-finite numbers early, where the mistake is
24
27
  * easy to trace, instead of letting the raster refuse the whole list. */
25
28
  class Words {
@@ -79,6 +82,14 @@ function writePaint(w, p) {
79
82
  w.f32(v);
80
83
  writeStops(w, p.stops);
81
84
  return;
85
+ case "noise":
86
+ w.u32(PAINT_NOISE);
87
+ w.u32(p.ridged ? 1 : 0);
88
+ w.u32(p.band ? 1 : 0);
89
+ for (const v of [p.size, p.threshold, p.soft])
90
+ w.f32(v);
91
+ writeColor(w, p.color);
92
+ return;
82
93
  }
83
94
  }
84
95
  function writeStops(w, stops) {
@@ -210,8 +221,9 @@ export class DisplayList {
210
221
  }
211
222
  pushLayer(bounds, effects) {
212
223
  const w = this.op(OP_PUSH_LAYER);
213
- for (const v of [bounds.x, bounds.y, bounds.w, bounds.h, effects.opacity, effects.brightness, effects.saturate, effects.blur])
224
+ for (const v of [bounds.x, bounds.y, bounds.w, bounds.h, effects.opacity, effects.brightness, effects.saturate, effects.blur, effects.contrast ?? 1, effects.hue?.cos ?? 1, effects.hue?.sin ?? 0])
214
225
  w.f32(v);
226
+ w.u32(BLENDS[effects.blend ?? "normal"]);
215
227
  this.layers += 1;
216
228
  this.clips.push(0);
217
229
  }
@@ -233,6 +245,18 @@ export class DisplayList {
233
245
  this.layers -= 1;
234
246
  this.clips.pop();
235
247
  }
248
+ /** Pops the innermost layer as a mask: instead of drawing onto its parent, it multiplies the
249
+ * parent's pixels by its alpha (or, inverted, by one minus its alpha). The parent stays open. */
250
+ popMask(invert) {
251
+ if (this.layers < 2)
252
+ throw new Error("display list: popMask needs a mask layer and a layer to mask");
253
+ if ((this.clips.at(-1) ?? 0) > 0)
254
+ throw new Error("display list: popMask with clips still pushed inside the layer");
255
+ const w = this.op(OP_POP_MASK);
256
+ w.u32(invert ? 1 : 0);
257
+ this.layers -= 1;
258
+ this.clips.pop();
259
+ }
236
260
  /** Appends END and returns the bytes. The writer is spent afterwards. */
237
261
  finish() {
238
262
  if (this.layers !== 0)
@@ -91,13 +91,17 @@ const scaleSide = (s, z) => ({ width: s.width * z, color: s.color });
91
91
  const mixSide = (a, b, t) => ({ width: lerp(a.width, b.width, t), color: mixColor(a.color, b.color, t) });
92
92
  /** Corners, borders and shadows blend; a solid background blends, anything else cross-fades. */
93
93
  function mixLook(a, b, t, tone) {
94
- const count = Math.max(a.shadows.length, b.shadows.length);
95
- const clear = { x: 0, y: 0, blur: 0, spread: 0, color: { r: 0, g: 0, b: 0, a: 0 } };
96
- const shadows = Array.from({ length: count }, (_, i) => {
97
- const sa = a.shadows[i] ?? { ...(b.shadows[i] ?? clear), color: { ...(b.shadows[i] ?? clear).color, a: 0 } };
98
- const sb = b.shadows[i] ?? { ...sa, color: { ...sa.color, a: 0 } };
99
- return { x: lerp(sa.x, sb.x, t), y: lerp(sa.y, sb.y, t), blur: lerp(sa.blur, sb.blur, t), spread: lerp(sa.spread, sb.spread, t), color: mixColor(sa.color, sb.color, t) };
100
- });
94
+ // Outer shadows pair with outer ones and inset with inset: one can't become the other.
95
+ const mixed = (inset) => {
96
+ const from = a.shadows.filter((s) => s.inset === inset), to = b.shadows.filter((s) => s.inset === inset);
97
+ const clear = { x: 0, y: 0, blur: 0, spread: 0, color: { r: 0, g: 0, b: 0, a: 0 }, inset };
98
+ return Array.from({ length: Math.max(from.length, to.length) }, (_, i) => {
99
+ const sa = from[i] ?? { ...(to[i] ?? clear), color: { ...(to[i] ?? clear).color, a: 0 } };
100
+ const sb = to[i] ?? { ...sa, color: { ...sa.color, a: 0 } };
101
+ return { x: lerp(sa.x, sb.x, t), y: lerp(sa.y, sb.y, t), blur: lerp(sa.blur, sb.blur, t), spread: lerp(sa.spread, sb.spread, t), color: mixColor(sa.color, sb.color, t), inset };
102
+ });
103
+ };
104
+ const shadows = [...mixed(false), ...mixed(true)];
101
105
  const radii = { tl: lerp(a.radii.tl, b.radii.tl, t), tr: lerp(a.radii.tr, b.radii.tr, t), br: lerp(a.radii.br, b.radii.br, t), bl: lerp(a.radii.bl, b.radii.bl, t) };
102
106
  const border = [mixSide(a.border[0], b.border[0], t), mixSide(a.border[1], b.border[1], t), mixSide(a.border[2], b.border[2], t), mixSide(a.border[3], b.border[3], t)];
103
107
  const fa = a.background.length === 1 ? a.background[0] : undefined;
@@ -2,7 +2,7 @@ import { type FullPose } from "@tongue-core/film/model";
2
2
  import type { LBox } from "../layout/layout.js";
3
3
  import type { BackgroundLayer, Color, Computed, Shadow } from "../style/computed.js";
4
4
  import type { PlacedGlyph as LaidGlyph } from "../text/paragraph.js";
5
- import type { Affine, DisplayList, Homography, Radii, Rect, Rgba } from "./list.js";
5
+ import type { Affine, DisplayList, Homography, LayerEffects, Paint, Radii, Rect, Rgba } from "./list.js";
6
6
  import { type SceneFrame, type ScreenGlyph } from "../evaluate/scene-frame.js";
7
7
  import type { Picture } from "../evaluate/tree.js";
8
8
  export interface Resources {
@@ -42,6 +42,8 @@ export declare class Painter {
42
42
  private lifted;
43
43
  /** Screen → surface for the scene being painted (render scale, plus any whole-scene move). */
44
44
  private screenBase;
45
+ /** The camera the children now being painted share (their parent's `perspective`), in surface pixels. */
46
+ private view;
45
47
  constructor(list: DisplayList, res: Resources, device: Affine);
46
48
  private setTransform;
47
49
  /** The stage → surface transform for a scene (camera, then render scale). */
@@ -49,12 +51,7 @@ export declare class Painter {
49
51
  /** Paints a whole scene; `outer` moves the whole picture in screen space (transitions). */
50
52
  scene(sf: SceneFrame, hooks?: PaintHooks, outer?: Affine): void;
51
53
  surfaceRect(): Rect;
52
- layer(bounds: Rect, effects: {
53
- readonly opacity: number;
54
- readonly brightness: number;
55
- readonly saturate: number;
56
- readonly blur: number;
57
- }): void;
54
+ layer(bounds: Rect, effects: LayerEffects): void;
58
55
  /** Paints children in z order; `childPose` adds a pose per child index (transition presets). */
59
56
  children(sf: SceneFrame, lb: LBox, m: Affine, cam: Affine, hooks: PaintHooks, childPose?: (index: number) => FullPose): void;
60
57
  /** Paints one box and its subtree. `m` maps stage to surface for this box (it includes any
@@ -66,6 +63,9 @@ export declare class Painter {
66
63
  paintWith(sf: SceneFrame, lb: LBox, base: Affine, cam: Affine, hooks: PaintHooks, pose: FullPose, look: Look, part: "full" | "content"): void;
67
64
  decorate(lb: LBox, m: Affine, look: Look, alpha?: number): void;
68
65
  decorateRect(rect: Rect, m: Affine, look: Look, alpha?: number): void;
66
+ /** A shadow cast inside a box: everything outside the box's shape, shifted and blurred, seen
67
+ * through the box. */
68
+ private insetShadow;
69
69
  private paintOf;
70
70
  /** Identical text moving to a new place: each glyph flies from where it was to where it is
71
71
  * going, resizing on the way (so text re-wraps smoothly). Null when the text differs. */
@@ -80,14 +80,25 @@ export declare class Painter {
80
80
  /** Clips what follows to a rectangle of the surface, in surface pixels (wipe transitions). */
81
81
  clipSurface(rect: Rect): void;
82
82
  unclip(): void;
83
+ /** Fills a rect of stage pixels with a paint (transitions). */
84
+ fillStage(rect: Rect, paint: Paint): void;
85
+ popMask(invert: boolean): void;
83
86
  popLayer(projective?: Homography): void;
84
87
  content(sf: SceneFrame, lb: LBox, m: Affine, cam: Affine, hooks: PaintHooks): void;
88
+ /** Motion blur: each sample in its own layer, added into one with its share of the weight. The
89
+ * layers add rather than cover, so a partly transparent trail averages correctly. */
90
+ private sampleGroup;
91
+ /** The content in a layer, then the shape in another, which is multiplied into the first. Both
92
+ * layers span at least the whole frame: anything inside may be transformed beyond its box. */
93
+ private maskGroup;
85
94
  image(lb: LBox, m: Affine, picture: Picture, alpha?: number): void;
86
95
  imageRect(picture: Picture, box: Rect, m: Affine, fit: Computed["objectFit"], pos: readonly [number, number], radius: number, alpha: number): void;
87
96
  svg(lb: LBox, m: Affine, alpha?: number): void;
88
97
  /** Paragraph text: glyph runs, Say words (each with its own pose) and the caret. */
89
98
  private text;
90
99
  textColor(sf: SceneFrame, lb: LBox): ((g: LaidGlyph) => Color);
100
+ /** Text shadows, each run's own, the last listed lowest (as in CSS). */
101
+ private textShadows;
91
102
  /** Draws glyphs (positions relative to a paragraph origin), batching runs that share a font,
92
103
  * size and color. */
93
104
  glyphs(list: ReadonlyArray<LaidGlyph>, ox: number, oy: number, m: Affine, color: (g: LaidGlyph) => Color, alpha: number): void;