lecodes-web-canvas 2.0.0

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.
@@ -0,0 +1,141 @@
1
+ // Image-source resolution, shared by every Host. The authoring layer (worker) hands an image `src`
2
+ // (or `backgroundImage`) as one of: a string url, an inline `SvgSource` ({ svg }), or a host buffer
3
+ // handle ({ _id } — an SDK `FetchResponse`/`File`, both carrying the buffer id under `_id`). A raw
4
+ // DOM `File` is a separate, browser-embed-only case. Only the string case is platform-free; the rest
5
+ // need a host-provided URL (asset bytes / object URL), so each Host wires those callbacks in.
6
+
7
+ /** An inline SVG source (`SvgSource(...)` in the worker). */
8
+ export function isSvgSource(src: unknown): src is { svg: string } {
9
+ return typeof src === "object" && src !== null && "svg" in (src as Record<string, unknown>)
10
+ }
11
+
12
+ /** A host buffer handle: an SDK `FetchResponse` / `File` / uploaded asset, keyed by its buffer id.
13
+ * Checked BEFORE isFileLike — an SDK `File` is a plain object with `_id`, not a DOM File. */
14
+ function hasId(src: unknown): src is { _id: number } {
15
+ return typeof src === "object" && src !== null && "_id" in (src as Record<string, unknown>)
16
+ }
17
+
18
+ /** A baked `_creatorCanvas` surface (UIImage(canvas)) — resolved to a texture via a CanvasSurfaceProvider. */
19
+ function hasCanvasSurface(src: unknown): src is { canvasSurface: number } {
20
+ return typeof src === "object" && src !== null && "canvasSurface" in (src as Record<string, unknown>)
21
+ }
22
+
23
+ /** Synthetic url a `{ canvasSurface }` src resolves to; the host turns it back into a baked surface. */
24
+ export const CANVAS_SRC_PREFIX = "lecodes-canvas://"
25
+ /** Parse a `lecodes-canvas://N` url back to its surface id, or undefined. */
26
+ export function canvasSurfaceId(src: string): number | undefined {
27
+ return src.startsWith(CANVAS_SRC_PREFIX) ? Number(src.slice(CANVAS_SRC_PREFIX.length)) : undefined
28
+ }
29
+
30
+ /** A raw DOM `File` — only ever reaches here from a browser embed handing one in directly. The SDK's
31
+ * `File` is NOT one of these: it's a `{ _id, name, size }` buffer handle caught by hasId above. */
32
+ function isFileLike(src: unknown): boolean {
33
+ return typeof File !== "undefined" && src instanceof File
34
+ }
35
+
36
+ /** Inline SVG → a `data:image/svg+xml` URI an `<img>` / decoder can load (mirrors the DOM viewer). */
37
+ export function svgToDataUri(svg: string): string {
38
+ let s = svg.trim()
39
+ // A data: URI is parsed as a standalone XML document — xmlns is mandatory there (inline DOM <svg>
40
+ // tolerates its absence; a data-uri image without it renders broken).
41
+ if (!/\sxmlns\s*=/.test(s)) s = s.replace(/<svg\b/, '<svg xmlns="http://www.w3.org/2000/svg"')
42
+ const bytes = new TextEncoder().encode(s)
43
+ let binary = ""
44
+ for (const byte of bytes) binary += String.fromCharCode(byte)
45
+ return `data:image/svg+xml;base64,${btoa(binary)}`
46
+ }
47
+
48
+ /** True if a *resolved* src is an SVG (data-uri or `.svg` url) — i.e. a mask-capable, tintable image.
49
+ * Used to gate `tintColor`: SVG recolors (mask), an opaque raster does not (the DOM no-ops it). */
50
+ export function isSvgLikeSrc(src: string | undefined): boolean {
51
+ return !!src && (src.startsWith("data:image/svg+xml") || /\.svg(\?|#|$)/i.test(src))
52
+ }
53
+
54
+ /**
55
+ * Intrinsic size of an inline SVG, taken from its `viewBox` ("min-x min-y width height"), or its
56
+ * `<svg>` width/height attributes, or null. SVG is a vector, so this is the size to lay out at (and
57
+ * it may upscale). Matches the DOM, which reads the viewBox into `data-viewbox` for measuring.
58
+ */
59
+ export function extractSvgViewBox(svg: string): { width: number; height: number } | null {
60
+ // Only read the opening <svg> tag, so child element width/height never leak in.
61
+ const tag = svg.match(/<svg\b[^>]*>/i)?.[0] ?? ""
62
+ const vb = tag.match(/viewBox\s*=\s*["']\s*[\d.+-]+\s+[\d.+-]+\s+([\d.+-]+)\s+([\d.+-]+)\s*["']/i)
63
+ if (vb) {
64
+ const w = parseFloat(vb[1]), h = parseFloat(vb[2])
65
+ if (w > 0 && h > 0) return { width: w, height: h }
66
+ }
67
+ const w = parseFloat(tag.match(/\bwidth\s*=\s*["']?([\d.]+)/i)?.[1] ?? "")
68
+ const h = parseFloat(tag.match(/\bheight\s*=\s*["']?([\d.]+)/i)?.[1] ?? "")
69
+ if (w > 0 && h > 0) return { width: w, height: h }
70
+ return null
71
+ }
72
+
73
+ /** Decode an `data:image/svg+xml` URI (base64 or text/url-encoded) back to its markup, or null. */
74
+ export function decodeSvgDataUri(src: string): string | null {
75
+ const m = /^data:image\/svg\+xml(;[^,]*)?,([\s\S]*)$/.exec(src)
76
+ if (!m) return null
77
+ const data = m[2]
78
+ try {
79
+ if (/;base64/i.test(m[1] ?? "")) {
80
+ const bin = atob(data)
81
+ const bytes = new Uint8Array(bin.length)
82
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
83
+ return new TextDecoder().decode(bytes)
84
+ }
85
+ return decodeURIComponent(data)
86
+ } catch {
87
+ return null
88
+ }
89
+ }
90
+
91
+ /**
92
+ * The intrinsic (viewBox) size of an SVG from its resolved src. SVGs without explicit width/height
93
+ * have NO intrinsic size, so a browser `<img>` reports the CSS default 300×150 — wrong for layout +
94
+ * object-fit. Reading the viewBox instead makes canvas/native size SVGs to their box like the DOM
95
+ * does. Inline data-uris are decoded synchronously; a remote/blob `.svg` url needs `fetchText`.
96
+ */
97
+ export async function svgIntrinsicSize(
98
+ src: string,
99
+ fetchText?: (url: string) => Promise<string | null>,
100
+ ): Promise<{ width: number; height: number } | null> {
101
+ if (!isSvgLikeSrc(src)) return null
102
+ const inline = decodeSvgDataUri(src)
103
+ if (inline) return extractSvgViewBox(inline)
104
+ if (!fetchText) return null
105
+ const text = await fetchText(src).catch(() => null)
106
+ return text ? extractSvgViewBox(text) : null
107
+ }
108
+
109
+ export interface SrcResolverOptions {
110
+ /** Resolve a host buffer handle ({ _id } — SDK `FetchResponse` / `File`) to a url (e.g. a blob url
111
+ * for the fetched bytes). */
112
+ assetUrl?: (id: number) => string | undefined
113
+ /** Resolve a raw DOM `File` to a url (browser: `URL.createObjectURL`). Not the SDK `File` — that
114
+ * one is a buffer handle and goes through `assetUrl`. */
115
+ fileUrl?: (file: unknown) => string | undefined
116
+ }
117
+
118
+ /**
119
+ * Build a memoized `resolveSrc(rawSrc) → string | undefined`. Strings pass through; `SvgSource` →
120
+ * data-uri; `{ _id }` (SDK `FetchResponse`/`File`) and a raw DOM `File` go through the injected
121
+ * callbacks (undefined when not wired, e.g. headless). Memoized by object identity so SVG
122
+ * data-uris / blob urls are created once per source.
123
+ */
124
+ export function createSrcResolver(opts: SrcResolverOptions = {}): (src: unknown) => string | undefined {
125
+ const cache = new WeakMap<object, string>()
126
+ return (src: unknown): string | undefined => {
127
+ if (src == null) return undefined
128
+ if (typeof src === "string") return src
129
+ if (typeof src !== "object") return undefined
130
+ const obj = src as object
131
+ const cached = cache.get(obj)
132
+ if (cached !== undefined) return cached
133
+ let out: string | undefined
134
+ if (isSvgSource(obj)) out = svgToDataUri(obj.svg)
135
+ else if (hasCanvasSurface(obj)) out = CANVAS_SRC_PREFIX + obj.canvasSurface
136
+ else if (hasId(obj)) out = opts.assetUrl?.(obj._id)
137
+ else if (isFileLike(obj)) out = opts.fileUrl?.(obj)
138
+ if (out !== undefined) cache.set(obj, out)
139
+ return out
140
+ }
141
+ }
@@ -0,0 +1,132 @@
1
+ import type { ImageInfo } from "../types"
2
+ import { resolveCanvasImage, type CanvasSurfaceProvider, type FontSpec, type Host } from "./Host"
3
+ import { createSrcResolver, isSvgLikeSrc, svgIntrinsicSize } from "../core/util/svgSrc"
4
+
5
+ // The SDK spells the fixed-width system family "monospaced" (the iOS name); CSS spells it
6
+ // "monospace". Other generics ("serif", "sans-serif") are real CSS keywords and resolve natively.
7
+ const cssFamily = (family: string) => (family === "monospaced" ? "monospace" : family)
8
+
9
+ function fontCss(font: FontSpec): string {
10
+ return `${font.italic ? "italic " : ""}${font.weight} ${font.size}px ${cssFamily(font.family)}`
11
+ }
12
+
13
+ export interface BrowserHostOptions {
14
+ /** Resolve an uploaded asset ({ _id }) to a url — the app injects this (e.g. the viewer's
15
+ * `getUrl(id)` blob-url resolver). Without it, `{ _id }` image sources don't render. */
16
+ resolveAssetUrl?: (id: number) => string | undefined
17
+ /** Resolve `UIImage(canvas)` sources (baked `_creatorCanvas` surfaces) to textures. */
18
+ canvasSurfaces?: CanvasSurfaceProvider
19
+ }
20
+
21
+ /** Browser host: text metrics via a measuring 2D context, image decode via HTMLImageElement. */
22
+ export class BrowserHost implements Host {
23
+ private ctx: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
24
+ private images = new Map<string, ImageInfo>()
25
+ private pending = new Map<string, Promise<ImageInfo>>()
26
+ private resolver: (src: unknown) => string | undefined
27
+ private canvasSurfaces?: CanvasSurfaceProvider
28
+
29
+ constructor(opts: BrowserHostOptions = {}) {
30
+ // Measure on a DOM canvas whenever a document exists: a main-thread OffscreenCanvas context
31
+ // does not reliably resolve document.fonts faces in every browser (Safari), which would keep
32
+ // measuring custom faces with the fallback while the visible canvas paints the real one.
33
+ // OffscreenCanvas stays for workers, where there is no document.
34
+ const canvas =
35
+ typeof document !== "undefined"
36
+ ? (document.createElement("canvas") as HTMLCanvasElement)
37
+ : new OffscreenCanvas(8, 8)
38
+ this.ctx = (canvas as any).getContext("2d")
39
+ this.canvasSurfaces = opts.canvasSurfaces
40
+ // Inline SVG → data-uri; uploaded assets via the app's resolver; File → object url.
41
+ this.resolver = createSrcResolver({
42
+ assetUrl: opts.resolveAssetUrl,
43
+ fileUrl: (file) => URL.createObjectURL(file as File),
44
+ })
45
+ }
46
+
47
+ resolveSrc(src: unknown): string | undefined {
48
+ return this.resolver(src)
49
+ }
50
+
51
+ /** Image decodes still in flight — lets a host idle its paint loop until they land (the
52
+ * headless NodeCanvasHost exposes the same probe). */
53
+ pendingImages(): number {
54
+ return this.pending.size
55
+ }
56
+
57
+ resolveFontFamily(family: string): string {
58
+ return cssFamily(family)
59
+ }
60
+
61
+ measureText(text: string, font: FontSpec): number {
62
+ this.ctx.font = fontCss(font)
63
+ ;(this.ctx as any).letterSpacing = `${font.letterSpacing}px`
64
+ return this.ctx.measureText(text).width
65
+ }
66
+
67
+ fontMetrics(font: FontSpec) {
68
+ this.ctx.font = fontCss(font)
69
+ const m = this.ctx.measureText("Mg")
70
+ // The FONT's ascent/descent — the metrics CSS line-box layout centers inside line-height.
71
+ // The ink extent (actualBoundingBox of the sample glyphs) is smaller, which would place the
72
+ // baseline ~1px higher than the DOM/native render of the same text.
73
+ const ascent = m.fontBoundingBoxAscent || m.actualBoundingBoxAscent || font.size * 0.8
74
+ const descent = m.fontBoundingBoxDescent || m.actualBoundingBoxDescent || font.size * 0.2
75
+ return { ascent, descent }
76
+ }
77
+
78
+ getImage(src: string): ImageInfo | undefined {
79
+ return resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src)
80
+ }
81
+
82
+ loadImage(src: string): Promise<ImageInfo> {
83
+ const canvasImg = resolveCanvasImage(src, this.canvasSurfaces)
84
+ if (canvasImg) return Promise.resolve(canvasImg) // baked surface — already rasterized, no decode
85
+ const cached = this.images.get(src)
86
+ if (cached) return Promise.resolve(cached)
87
+ const inFlight = this.pending.get(src)
88
+ if (inFlight) return inFlight
89
+
90
+ const promise = (async (): Promise<ImageInfo> => {
91
+ const img = await this.decode(src)
92
+ let naturalWidth = img.naturalWidth
93
+ let naturalHeight = img.naturalHeight
94
+ // A browser gives a viewBox-only SVG the CSS default 300×150 intrinsic size, which breaks layout
95
+ // + object-fit (the icon won't size to its box). Use the real viewBox instead — matching the DOM
96
+ // (which reads the viewBox) and the native renderers.
97
+ if (isSvgLikeSrc(src)) {
98
+ const vb = await svgIntrinsicSize(src, (url) => fetch(url).then((r) => (r.ok ? r.text() : null)))
99
+ if (vb) { naturalWidth = vb.width; naturalHeight = vb.height }
100
+ }
101
+ const info: ImageInfo = { src, naturalWidth, naturalHeight, handle: img }
102
+ this.images.set(src, info)
103
+ this.pending.delete(src)
104
+ return info
105
+ })()
106
+ this.pending.set(src, promise)
107
+ promise.catch(() => this.pending.delete(src)) // a decode failure must clear the in-flight slot
108
+ return promise
109
+ }
110
+
111
+ /** Decode an image url into an HTMLImageElement (resolves on load, rejects on error). */
112
+ private decode(src: string): Promise<HTMLImageElement> {
113
+ return new Promise((resolve, reject) => {
114
+ const img = new Image()
115
+ img.onload = () => resolve(img)
116
+ img.onerror = (e) => reject(e)
117
+ img.src = src
118
+ })
119
+ }
120
+
121
+ // Browser host: CDN faces should come as woff2 (the font() macro's generated code reads this).
122
+ fontFormat = "woff2" as const
123
+
124
+ async loadFont(family: string, url: string, options: { weight?: number; style?: string }): Promise<void> {
125
+ if (typeof (globalThis as any).FontFace === "undefined") return
126
+ const face = new FontFace(family, `url(${url})`, options as FontFaceDescriptors)
127
+ // Added before load resolves: text swaps in the moment the bytes land, whether or not
128
+ // anything awaits the legacy completion callback (Renderer purges wrap caches on resolve).
129
+ ;(document as any).fonts.add(face)
130
+ await face.load()
131
+ }
132
+ }
@@ -0,0 +1,64 @@
1
+ import type { ImageInfo } from "../types"
2
+ import { resolveCanvasImage, type CanvasSurfaceProvider, type FontSpec, type Host } from "./Host"
3
+ import { createSrcResolver } from "../core/util/svgSrc"
4
+
5
+ export interface HeadlessHostOptions {
6
+ /** Per-character advance as a fraction of font size (default 0.5 — a stable monospace-ish model). */
7
+ charWidthFactor?: number
8
+ /** Preloaded intrinsic image sizes, keyed by src, so layout is deterministic with no I/O. */
9
+ images?: Record<string, { width: number; height: number }>
10
+ /** Resolve `UIImage(canvas)` sources (baked `_creatorCanvas` surfaces) to textures. */
11
+ canvasSurfaces?: CanvasSurfaceProvider
12
+ }
13
+
14
+ /**
15
+ * Deterministic, I/O-free host for headless runs and tests. Text metrics follow a simple, stable
16
+ * model (so snapshots don't depend on installed fonts), and image sizes come from a preloaded map.
17
+ */
18
+ export class HeadlessHost implements Host {
19
+ private factor: number
20
+ private images = new Map<string, ImageInfo>()
21
+ private canvasSurfaces?: CanvasSurfaceProvider
22
+
23
+ constructor(opts: HeadlessHostOptions = {}) {
24
+ this.factor = opts.charWidthFactor ?? 0.5
25
+ this.canvasSurfaces = opts.canvasSurfaces
26
+ for (const [src, size] of Object.entries(opts.images ?? {})) {
27
+ this.images.set(src, { src, naturalWidth: size.width, naturalHeight: size.height })
28
+ }
29
+ }
30
+
31
+ /** Register an intrinsic image size after construction. */
32
+ setImageSize(src: string, width: number, height: number) {
33
+ this.images.set(src, { src, naturalWidth: width, naturalHeight: height })
34
+ }
35
+
36
+ measureText(text: string, font: FontSpec): number {
37
+ if (text.length === 0) return 0
38
+ return text.length * (font.size * this.factor + font.letterSpacing)
39
+ }
40
+
41
+ fontMetrics(font: FontSpec) {
42
+ return { ascent: font.size * 0.8, descent: font.size * 0.2 }
43
+ }
44
+
45
+ getImage(src: string): ImageInfo | undefined {
46
+ return resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src)
47
+ }
48
+
49
+ loadImage(src: string): Promise<ImageInfo> {
50
+ return Promise.resolve(resolveCanvasImage(src, this.canvasSurfaces) ?? this.images.get(src) ?? { src, naturalWidth: 0, naturalHeight: 0 })
51
+ }
52
+
53
+ /** No real fonts headless — metrics are synthetic, so this is a no-op. */
54
+ loadFont(): Promise<void> {
55
+ return Promise.resolve()
56
+ }
57
+
58
+ // Inline SVG → data-uri; uploaded `{ _id }` / `File` have no bytes headless → undefined (project
59
+ // image assets compile to string urls in the CLI, so this is the rare case).
60
+ private resolver = createSrcResolver()
61
+ resolveSrc(src: unknown): string | undefined {
62
+ return this.resolver(src)
63
+ }
64
+ }
@@ -0,0 +1,85 @@
1
+ import type { ImageInfo } from "../types"
2
+ import { canvasSurfaceId } from "../core/util/svgSrc"
3
+
4
+ /**
5
+ * Access to baked `_creatorCanvas` surfaces, so a `UIImage(canvas)` resolves to a texture. `canvas` is
6
+ * a drawable the painter can `drawImage` (an OffscreenCanvas on web, a napi canvas headless); width/
7
+ * height are the surface's device pixels. Injected into a Host so it can turn a `lecodes-canvas://N`
8
+ * src into an ImageInfo.
9
+ */
10
+ export interface CanvasSurfaceProvider {
11
+ getSurface(surfaceId: number): { canvas: unknown, width: number, height: number } | null
12
+ }
13
+
14
+ /** If `src` is a `lecodes-canvas://N` url, resolve it to an ImageInfo via the provider (the baked
15
+ * surface is already rasterized → synchronous). Returns undefined for any other src. */
16
+ export function resolveCanvasImage(src: string, provider: CanvasSurfaceProvider | undefined): ImageInfo | undefined {
17
+ if (!provider) return undefined
18
+ const id = canvasSurfaceId(src)
19
+ if (id === undefined) return undefined
20
+ const surf = provider.getSurface(id)
21
+ if (!surf) return undefined
22
+ return { src, naturalWidth: surf.width, naturalHeight: surf.height, handle: surf.canvas }
23
+ }
24
+
25
+ /** A fully-resolved font, as the core asks the host to measure it. */
26
+ export interface FontSpec {
27
+ family: string
28
+ size: number
29
+ weight: number
30
+ italic: boolean
31
+ letterSpacing: number
32
+ }
33
+
34
+ /**
35
+ * Platform capabilities the portable core can't implement itself. Injected so the core runs
36
+ * headless under Bun (HeadlessHost) and in the browser (BrowserHost) unchanged.
37
+ */
38
+ export interface Host {
39
+ /** Advance width of a single line of text, in CSS pixels. */
40
+ measureText(text: string, font: FontSpec): number
41
+
42
+ /** Vertical metrics for a font (used for line-height fallback and the text baseline). */
43
+ fontMetrics(font: FontSpec): { ascent: number; descent: number }
44
+
45
+ /**
46
+ * Synchronous cache lookup for an image's intrinsic size. Returns undefined until the image is
47
+ * decoded — the core then measures it as 0×0 (collapsed), exactly like the DOM renderer before
48
+ * `naturalWidth` is known.
49
+ */
50
+ getImage(src: string): ImageInfo | undefined
51
+
52
+ /**
53
+ * Begin (or reuse) an async decode. Resolves with the intrinsic size; the caller marks the
54
+ * owning screen dirty so the next render picks up the now-known size. May resolve synchronously
55
+ * via the cache in HeadlessHost.
56
+ */
57
+ loadImage(src: string): Promise<ImageInfo>
58
+
59
+ /**
60
+ * Load a custom font so subsequent `measureText`/painting use it. Web: `FontFace`; headless:
61
+ * disk/network bytes into the canvas backend (NodeCanvasHost) or a no-op (synthetic metrics);
62
+ * desktop: a tgfx typeface load. Resolves once the font is usable (or has fallen back).
63
+ */
64
+ loadFont(family: string, url: string, options: { weight?: number; style?: string }): Promise<void>
65
+
66
+ /** Preferred CDN face container (`_creatorUI.fontFormat`): browser hosts "woff2"; absent = ttf. */
67
+ fontFormat?: "woff2" | "ttf"
68
+
69
+ /**
70
+ * Map a style-level fontFamily to what this host's text stack can actually resolve — the CSS
71
+ * generic families, mostly: browser hosts alias the SDK's "monospaced" to the CSS keyword
72
+ * "monospace"; headless pins every generic to a vendored concrete face (genericFamilies.ts),
73
+ * because Skia has no generic resolution. Applied by paintTree when building a FontSpec; hosts
74
+ * also apply it inside their own measureText/fontMetrics so layout and paint always agree.
75
+ */
76
+ resolveFontFamily?(family: string): string
77
+
78
+ /**
79
+ * Resolve an authoring image source to a loadable url string, or undefined if it can't be. The
80
+ * source is a string url, an inline `SvgSource` ({ svg }), an uploaded asset ({ _id }), or a
81
+ * `File`. String + SVG are platform-free; `{ _id }`/`File` need platform url resolution, which only
82
+ * some hosts wire (e.g. the browser host with the app's asset resolver). Memoized per source.
83
+ */
84
+ resolveSrc(src: unknown): string | undefined
85
+ }
@@ -0,0 +1,121 @@
1
+ // AUTO-GENERATED from engines/ui/properties.mjs — DO NOT EDIT. Regenerate: bun run gen:ui-props
2
+ //
3
+ // The CUIPaint record layout (engines/ui/include/creator-ui/paint.gen.h) for a reader over the wasm
4
+ // heap: `HEAPU32[(ptr >> 2) + word]` / `HEAPF32[...]`. Bits index the present / dirty masks
5
+ // (two u32 words each: lo at word 0 / 2, hi at word 1 / 3).
6
+
7
+ export const CUI_PAINT_WORDS = 78
8
+ export const CUI_PRESENT_LO = 0, CUI_PRESENT_HI = 1, CUI_DIRTY_LO = 2, CUI_DIRTY_HI = 3
9
+ export const CUI_INHERIT_MASK_LO = 4096, CUI_INHERIT_MASK_HI = 4080
10
+ export const CUI_MEASURE_MASK_LO = 0, CUI_MEASURE_MASK_HI = 5104 // a dirty measure field re-measures a text leaf
11
+
12
+ export type CuiKind = "float" | "color" | "enum" | "str" | "matrix" | "origin" | "gradient"
13
+
14
+ export const CUI_FIELDS = {
15
+ display: { bit: 0, word: 4, kind: "enum" },
16
+ overflow: { bit: 1, word: 5, kind: "enum" },
17
+ scrollDirection: { bit: 2, word: 6, kind: "enum" },
18
+ borderWidth: { bit: 3, word: 7, kind: "float" },
19
+ borderTopWidth: { bit: 4, word: 8, kind: "float" },
20
+ borderRightWidth: { bit: 5, word: 9, kind: "float" },
21
+ borderBottomWidth: { bit: 6, word: 10, kind: "float" },
22
+ borderLeftWidth: { bit: 7, word: 11, kind: "float" },
23
+ borderHorizontalWidth: { bit: 8, word: 12, kind: "float" },
24
+ borderVerticalWidth: { bit: 9, word: 13, kind: "float" },
25
+ borderStartWidth: { bit: 10, word: 14, kind: "float" },
26
+ borderEndWidth: { bit: 11, word: 15, kind: "float" },
27
+ color: { bit: 12, word: 16, kind: "color" },
28
+ backgroundColor: { bit: 13, word: 17, kind: "color" },
29
+ borderColor: { bit: 14, word: 18, kind: "color" },
30
+ borderTopColor: { bit: 15, word: 19, kind: "color" },
31
+ borderRightColor: { bit: 16, word: 20, kind: "color" },
32
+ borderBottomColor: { bit: 17, word: 21, kind: "color" },
33
+ borderLeftColor: { bit: 18, word: 22, kind: "color" },
34
+ placeholderColor: { bit: 19, word: 23, kind: "color" },
35
+ refreshControlColor: { bit: 20, word: 24, kind: "color" },
36
+ tintColor: { bit: 21, word: 25, kind: "color" },
37
+ rippleColor: { bit: 22, word: 26, kind: "color" },
38
+ overlayColor: { bit: 23, word: 27, kind: "color" },
39
+ backgroundImage: { bit: 24, word: 28, kind: "str" },
40
+ backgroundSize: { bit: 25, word: 29, kind: "enum" },
41
+ backgroundGradient: { bit: 26, word: 30, kind: "gradient" },
42
+ borderRadius: { bit: 27, word: 31, kind: "float" },
43
+ borderTopLeftRadius: { bit: 28, word: 32, kind: "float" },
44
+ borderTopRightRadius: { bit: 29, word: 33, kind: "float" },
45
+ borderBottomRightRadius: { bit: 30, word: 34, kind: "float" },
46
+ borderBottomLeftRadius: { bit: 31, word: 35, kind: "float" },
47
+ opacity: { bit: 32, word: 36, kind: "float" },
48
+ transform: { bit: 33, word: 37, kind: "matrix" },
49
+ transformOrigin: { bit: 34, word: 46, kind: "origin" },
50
+ pointerEvents: { bit: 35, word: 50, kind: "enum" },
51
+ fontSize: { bit: 36, word: 51, kind: "float" },
52
+ fontWeight: { bit: 37, word: 52, kind: "float" },
53
+ fontStyle: { bit: 38, word: 53, kind: "enum" },
54
+ fontFamily: { bit: 39, word: 54, kind: "str" },
55
+ lineHeight: { bit: 40, word: 55, kind: "float" },
56
+ letterSpacing: { bit: 41, word: 56, kind: "float" },
57
+ textAlign: { bit: 42, word: 57, kind: "enum" },
58
+ textDecoration: { bit: 43, word: 58, kind: "enum" },
59
+ lineClamp: { bit: 44, word: 59, kind: "float" },
60
+ textOverflow: { bit: 45, word: 60, kind: "enum" },
61
+ objectFit: { bit: 46, word: 61, kind: "enum" },
62
+ placeholder: { bit: 47, word: 62, kind: "str" },
63
+ type: { bit: 48, word: 63, kind: "enum" },
64
+ enterKey: { bit: 49, word: 64, kind: "enum" },
65
+ autocapitalize: { bit: 50, word: 65, kind: "enum" },
66
+ autocorrect: { bit: 51, word: 66, kind: "float" },
67
+ maxLength: { bit: 52, word: 67, kind: "float" },
68
+ keyboardShrink: { bit: 53, word: 68, kind: "float" },
69
+ keyboardDismiss: { bit: 54, word: 69, kind: "float" },
70
+ showScrollbar: { bit: 55, word: 70, kind: "float" },
71
+ overscrollMode: { bit: 56, word: 71, kind: "enum" },
72
+ keyboardDismissMode: { bit: 57, word: 72, kind: "enum" },
73
+ snap: { bit: 58, word: 73, kind: "enum" },
74
+ sheetDetents: { bit: 59, word: 74, kind: "str" },
75
+ sheetDetent: { bit: 60, word: 75, kind: "float" },
76
+ sheetDismissible: { bit: 61, word: 76, kind: "float" },
77
+ dim: { bit: 62, word: 77, kind: "float" },
78
+ } as const satisfies Record<string, { bit: number, word: number, kind: CuiKind }>
79
+
80
+ export type CuiFieldKey = keyof typeof CUI_FIELDS
81
+
82
+ export const CuiDisplay = { "flex": 0, "none": 1 } as const
83
+ export const CuiOverflow = { "visible": 0, "hidden": 1, "scroll": 2 } as const
84
+ export const CuiScrollDirection = { "vertical": 0, "horizontal": 1, "all": 2 } as const
85
+ export const CuiBackgroundSize = { "cover": 0, "contain": 1, "fill": 2, "tile": 3 } as const
86
+ export const CuiPointerEvents = { "all": 0, "none": 1 } as const
87
+ export const CuiFontStyle = { "normal": 0, "italic": 1 } as const
88
+ export const CuiTextAlign = { "start": 0, "center": 1, "end": 2, "left": 3, "right": 4 } as const
89
+ export const CuiTextDecoration = { "underline": 0, "line-through": 1, "none": 2 } as const
90
+ export const CuiTextOverflow = { "ellipsis": 0, "clip": 1 } as const
91
+ export const CuiObjectFit = { "cover": 0, "contain": 1, "fill": 2 } as const
92
+ export const CuiType = { "text": 0, "password": 1, "tel": 2, "email": 3, "number": 4, "decimal": 5, "url": 6, "search": 7, "date": 8, "time": 9 } as const
93
+ export const CuiEnterKey = { "done": 0, "go": 1, "next": 2, "search": 3, "send": 4 } as const
94
+ export const CuiAutocapitalize = { "none": 0, "sentences": 1, "words": 2, "characters": 3 } as const
95
+ export const CuiOverscrollMode = { "default": 0, "none": 1, "absorb": 2 } as const
96
+ export const CuiKeyboardDismissMode = { "interactive": 0, "scroll": 1, "none": 2 } as const
97
+ export const CuiSnap = { "none": 0, "start": 1, "center": 2, "end": 3 } as const
98
+
99
+ /** The registry spelling of each enum code, by field. */
100
+ export const CUI_NAMES = {
101
+ display: ["flex", "none"],
102
+ overflow: ["visible", "hidden", "scroll"],
103
+ scrollDirection: ["vertical", "horizontal", "all"],
104
+ backgroundSize: ["cover", "contain", "fill", "tile"],
105
+ pointerEvents: ["all", "none"],
106
+ fontStyle: ["normal", "italic"],
107
+ textAlign: ["start", "center", "end", "left", "right"],
108
+ textDecoration: ["underline", "line-through", "none"],
109
+ textOverflow: ["ellipsis", "clip"],
110
+ objectFit: ["cover", "contain", "fill"],
111
+ type: ["text", "password", "tel", "email", "number", "decimal", "url", "search", "date", "time"],
112
+ enterKey: ["done", "go", "next", "search", "send"],
113
+ autocapitalize: ["none", "sentences", "words", "characters"],
114
+ overscrollMode: ["default", "none", "absorb"],
115
+ keyboardDismissMode: ["interactive", "scroll", "none"],
116
+ snap: ["none", "start", "center", "end"],
117
+ } as const
118
+
119
+ /** present / dirty bit test on the (lo, hi) word pair. */
120
+ export const cuiBit = (lo: number, hi: number, bit: number): boolean =>
121
+ bit < 32 ? ((lo >>> bit) & 1) === 1 : ((hi >>> (bit - 32)) & 1) === 1
@@ -0,0 +1,78 @@
1
+ import type { Rect, ResolvedStyle } from "../types"
2
+
3
+ /** Returns the intrinsic size of a measured leaf (text / image), given the constraints. */
4
+ export type MeasureCallback = (
5
+ screenId: number,
6
+ nodeId: number,
7
+ width: number,
8
+ height: number,
9
+ ) => { width: number; height: number }
10
+
11
+ /**
12
+ * How the layout engine reaches a node's paint-ready style object so its per-prop style handlers
13
+ * (color, border, fontSize, …) can write resolved values into it.
14
+ */
15
+ export interface StyleSink {
16
+ getStyle(screenId: number, nodeId: number): ResolvedStyle | undefined
17
+ }
18
+
19
+ /** One safe-area edge, in the CSS inset order the tuples use. */
20
+ export type SafeEdge = "top" | "right" | "bottom" | "left"
21
+
22
+ /**
23
+ * The layout-engine seam. The core depends only on this; `CreatorUiLayout` implements it over the
24
+ * reused creator-ui (Yoga) WASM, and the C++ port implements it over the native creator-ui lib.
25
+ */
26
+ export interface LayoutEngine {
27
+ /** Create a screen/widget root node owned by `screenId`. Returns the node id. */
28
+ createRoot(screenId: number): number
29
+ /** Create a child of `parentId` at `index`. `hasMeasureFunc` for text/image leaves. */
30
+ createChild(parentId: number, index: number, hasMeasureFunc: boolean): number
31
+ removeChild(parentId: number, nodeId: number): void
32
+ freeTree(rootId: number, isWidget: boolean): void
33
+
34
+ /** Apply one style prop. `value: null` resets it. Layout props are consumed internally; paint
35
+ * props are forwarded to the registered handlers (→ the StyleSink's ResolvedStyle). */
36
+ setStyle(nodeId: number, key: string, value: number | string | boolean | null, layer?: number): void
37
+ markDirty(nodeId: number): void
38
+
39
+ /** The creator-ui layer a `$`-class style key maps to (`$hovered` / `$pressed` / `$focused` → the
40
+ * fixed interaction class layers 60267–60269, `$landscape` / `$portrait` → the global orientation
41
+ * layers 258 / 257, any other `$name` → its app-global layer allocated on first sight from 272);
42
+ * a key without `$` is not a state block → CUI_LAYER_NONE (0). */
43
+ getLayer(nodeId: number, key: string): number
44
+ /** Activate a layer's styles for a node (re-fires the style handlers with the layer's values). */
45
+ enableNodeLayer(nodeId: number, layer: number): void
46
+ /** Deactivate a layer's styles (re-fires the handlers with the remaining active layers). */
47
+ disableNodeLayer(nodeId: number, layer: number): void
48
+
49
+ calculate(rootId: number, width: number, height: number, isWidget: boolean): void
50
+ /** Parent-relative computed layout, or null if the node has none (e.g. display:none). */
51
+ getLayout(nodeId: number): Rect | null
52
+ /** Resolved padding edges `[left, top, right, bottom]` (px). The laid-out rect is the border box,
53
+ * so self-rendered content (text / input value) must be inset by these — Yoga gives no other hook. */
54
+ getPadding(nodeId: number): [number, number, number, number]
55
+ getScreenId(nodeId: number): number
56
+
57
+ /** Total laid-out content size of a node (incl. padding) — for scroll extent. */
58
+ getContentHeight(nodeId: number): number
59
+ getContentWidth(nodeId: number): number
60
+
61
+ setConditionValue(width: number, height: number): void
62
+ /** Safe-area insets in CSS order (top, right, bottom, left) — the values the `safe-*` padding/margin
63
+ * keywords resolve to. Passing `nodeId` re-applies those dynamic styles over that subtree now (so a
64
+ * live change takes effect without an orientation change); omit it to only update the globals. */
65
+ setSafePaddings(top: number, right: number, bottom: number, left: number, nodeId?: number): void
66
+ /** Declares which safe-area edges are exact-height system BARS (Android status bar / 3-button nav
67
+ * bar — content at the inset touches the chrome) vs clearance insets (iOS notch/home indicator,
68
+ * gesture nav). `comfort-*` ADDS its knob past a bar edge and floors (max) a clearance edge.
69
+ * Call before `setSafePaddings` — the paddings call is what re-resolves dynamic styles. */
70
+ setSafeAreaBars(bars: readonly SafeEdge[]): void
71
+ /** Merge app theme values (docs/ui-theme-plan.md): `var(--name)` refs + the comfort-* knobs.
72
+ * Numbers are lengths (px), null removes. Returns true when live styles re-dispatched —
73
+ * the caller should relayout + repaint. */
74
+ setTheme(values: Record<string, string | number | null>): boolean
75
+
76
+ setStyleSink(sink: StyleSink): void
77
+ setMeasureCallback(cb: MeasureCallback): void
78
+ }