diff --git a/examples/debug-layer/asset-browser.js b/examples/debug-layer/asset-browser.js index 5adc2d24..0f2d34a6 100644 --- a/examples/debug-layer/asset-browser.js +++ b/examples/debug-layer/asset-browser.js @@ -129,7 +129,7 @@ class AssetBrowserScene extends Scene { txtNoAssets = new Text('globalThis.assets is not available.\nRun this example in the ExoJS playground.', { fillColor: C.dim, fontSize: 16 }); txtEmptyCat = new Text('(empty)', { fillColor: C.dimDark, fontSize: 13 }); txtNoSel = new Text('Select an asset from the list.', { fillColor: C.dimDark, fontSize: 14 }); - txtMeta = new Text('', { fillColor: C.white, fontSize: 13 }, { maxWidth: PREVIEW_W - 80 }); + txtMeta = new Text('', { fillColor: C.white, fontSize: 13, maxWidth: PREVIEW_W - 80 }); txtAudioIcon = new Text('', { fillColor: C.white, fontSize: 28 }); txtAnimPlay = new Text('', { fillColor: C.white, fontSize: 12 }); txtAnimFrame = new Text('', { fillColor: C.dim, fontSize: 11 }); diff --git a/examples/debug-layer/asset-browser.ts b/examples/debug-layer/asset-browser.ts index 3d31d156..77011c3d 100644 --- a/examples/debug-layer/asset-browser.ts +++ b/examples/debug-layer/asset-browser.ts @@ -157,7 +157,7 @@ class AssetBrowserScene extends Scene { ); txtEmptyCat = new Text('(empty)', { fillColor: C.dimDark, fontSize: 13 }); txtNoSel = new Text('Select an asset from the list.', { fillColor: C.dimDark, fontSize: 14 }); - txtMeta = new Text('', { fillColor: C.white, fontSize: 13 }, { maxWidth: PREVIEW_W - 80 }); + txtMeta = new Text('', { fillColor: C.white, fontSize: 13, maxWidth: PREVIEW_W - 80 }); txtAudioIcon = new Text('', { fillColor: C.white, fontSize: 28 }); txtAnimPlay = new Text('', { fillColor: C.white, fontSize: 12 }); txtAnimFrame = new Text('', { fillColor: C.dim, fontSize: 11 }); diff --git a/examples/showcase/dialog-system.js b/examples/showcase/dialog-system.js index 73773d45..4139c9af 100644 --- a/examples/showcase/dialog-system.js +++ b/examples/showcase/dialog-system.js @@ -42,7 +42,7 @@ class DialogSystemScene extends Scene { // Name plate sits just above the dialog body, like a classic VN UI. this.namePlate = new Text(lines[0].speaker, { fillColor: new Color(255, 214, 120), fontSize: 26, fontWeight: 'bold' }); this.namePlate.setPosition(textX, height * 0.5); - this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3 }, { maxWidth: width * 0.55 }); + this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.55 }); this.box.setPosition(textX, height * 0.56); this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); this.choicePrompt.setPosition(textX, height * 0.78); diff --git a/examples/showcase/dialog-system.ts b/examples/showcase/dialog-system.ts index 8fc97478..102fef56 100644 --- a/examples/showcase/dialog-system.ts +++ b/examples/showcase/dialog-system.ts @@ -56,7 +56,7 @@ class DialogSystemScene extends Scene { this.namePlate = new Text(lines[0].speaker, { fillColor: new Color(255, 214, 120), fontSize: 26, fontWeight: 'bold' }); this.namePlate.setPosition(textX, height * 0.5); - this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3 }, { maxWidth: width * 0.55 }); + this.box = new Text('', { fillColor: Color.white, fontSize: 32, lineHeight: 1.3, maxWidth: width * 0.55 }); this.box.setPosition(textX, height * 0.56); this.choicePrompt = new Text('', { fillColor: new Color(150, 220, 255), fontSize: 20 }); diff --git a/examples/showcase/typewriter-text.js b/examples/showcase/typewriter-text.js index 8ed2349f..482a5494 100644 --- a/examples/showcase/typewriter-text.js +++ b/examples/showcase/typewriter-text.js @@ -25,7 +25,7 @@ class TypewriterTextScene extends Scene { init(loader) { const { width, height } = this.app.canvas; this.sound = loader.get(Sound, 'tick'); - this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56 }, { maxWidth: 900 }); + this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56, maxWidth: 900 }); this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2); this.state = { count: 0 }; // Shown while the browser still blocks audio (`app.audio.locked`); the diff --git a/examples/showcase/typewriter-text.ts b/examples/showcase/typewriter-text.ts index e89ae01c..41e618d0 100644 --- a/examples/showcase/typewriter-text.ts +++ b/examples/showcase/typewriter-text.ts @@ -30,7 +30,7 @@ class TypewriterTextScene extends Scene { const { width, height } = this.app.canvas; this.sound = loader.get(Sound, 'tick'); - this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56 }, { maxWidth: 900 }); + this.text = new Text('', { fillColor: Color.white, fontSize: 40, lineHeight: 56, maxWidth: 900 }); this.text.setAnchor(0, 0.5).setPosition(width * 0.12, height / 2); this.state = { count: 0 }; diff --git a/examples/text-fonts/multiline-and-wrap.js b/examples/text-fonts/multiline-and-wrap.js index 68a28b51..cd80c508 100644 --- a/examples/text-fonts/multiline-and-wrap.js +++ b/examples/text-fonts/multiline-and-wrap.js @@ -32,11 +32,11 @@ class MultilineAndWrapScene extends Scene { this.textA.setPosition(colX(0), bodyY); this.titleB = new Text('Word wrap @ 360px — at word boundaries', { fillColor: titleColor, fontSize: 16 }); this.titleB.setPosition(colX(1), titleY); - this.textB = new Text(paragraph, { fillColor: Color.white, fontSize: 22 }, { maxWidth: 360 }); + this.textB = new Text(paragraph, { fillColor: Color.white, fontSize: 22, maxWidth: 360 }); this.textB.setPosition(colX(1), bodyY); this.titleC = new Text('Break words @ 280px — splits a token', { fillColor: titleColor, fontSize: 16 }); this.titleC.setPosition(colX(2), titleY); - this.textC = new Text(longToken, { fillColor: Color.white, fontSize: 22 }, { maxWidth: 280, breakWords: true }); + this.textC = new Text(longToken, { fillColor: Color.white, fontSize: 22, maxWidth: 280, breakWords: true }); this.textC.setPosition(colX(2), bodyY); } draw(context) { diff --git a/examples/text-fonts/multiline-and-wrap.ts b/examples/text-fonts/multiline-and-wrap.ts index 69685fbe..f5320e2e 100644 --- a/examples/text-fonts/multiline-and-wrap.ts +++ b/examples/text-fonts/multiline-and-wrap.ts @@ -39,12 +39,12 @@ class MultilineAndWrapScene extends Scene { this.titleB = new Text('Word wrap @ 360px — at word boundaries', { fillColor: titleColor, fontSize: 16 }); this.titleB.setPosition(colX(1), titleY); - this.textB = new Text(paragraph, { fillColor: Color.white, fontSize: 22 }, { maxWidth: 360 }); + this.textB = new Text(paragraph, { fillColor: Color.white, fontSize: 22, maxWidth: 360 }); this.textB.setPosition(colX(1), bodyY); this.titleC = new Text('Break words @ 280px — splits a token', { fillColor: titleColor, fontSize: 16 }); this.titleC.setPosition(colX(2), titleY); - this.textC = new Text(longToken, { fillColor: Color.white, fontSize: 22 }, { maxWidth: 280, breakWords: true }); + this.textC = new Text(longToken, { fillColor: Color.white, fontSize: 22, maxWidth: 280, breakWords: true }); this.textC.setPosition(colX(2), bodyY); } diff --git a/site/src/content/api/text.mdx b/site/src/content/api/text.mdx index 20f9aaee..108ab926 100644 --- a/site/src/content/api/text.mdx +++ b/site/src/content/api/text.mdx @@ -9,7 +9,7 @@ memberCount: 91 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/Text.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/text/Text.ts#L46" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/text/Text.ts#L63" --- ## Import @@ -47,7 +47,7 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. ## Constructors -- `new(text: string, style?: TextStyleOptions | TextStyle, layout?: LayoutOptions, options: object): Text` +- `new(text: string, options: TextOptions): Text` ## Methods @@ -150,4 +150,4 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. ## Source -[src/rendering/text/Text.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/text/Text.ts#L46) +[src/rendering/text/Text.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/text/Text.ts#L63) diff --git a/src/core/serialization/coreSerializers.ts b/src/core/serialization/coreSerializers.ts index d7cca1b9..29dacb39 100644 --- a/src/core/serialization/coreSerializers.ts +++ b/src/core/serialization/coreSerializers.ts @@ -97,7 +97,9 @@ const textSerializer: NodeSerializer = { read(data) { const layout = typeof data.layout === 'object' && data.layout !== null ? (data.layout as LayoutOptions) : undefined; - return new Text(typeof data.text === 'string' ? data.text : '', deserializeStyleOptions(data.style), layout, { + return new Text(typeof data.text === 'string' ? data.text : '', { + ...deserializeStyleOptions(data.style), + ...layout, colorGlyphs: data.colorGlyphs === true, sdfRadius: typeof data.sdfRadius === 'number' ? data.sdfRadius : undefined, }); diff --git a/src/debug/PerformanceLayer.ts b/src/debug/PerformanceLayer.ts index c812e9cc..436d4217 100644 --- a/src/debug/PerformanceLayer.ts +++ b/src/debug/PerformanceLayer.ts @@ -6,7 +6,7 @@ import { Graphics } from '#rendering/primitives/Graphics'; import type { RenderBackend } from '#rendering/RenderBackend'; import type { RenderNode } from '#rendering/RenderNode'; import { Text as Text } from '#rendering/text/Text'; -import { TextStyle } from '#rendering/text/TextStyle'; +import type { TextStyleOptions } from '#rendering/text/TextStyle'; import { DebugLayer, type DebugLayerViewMode } from './DebugLayer'; @@ -196,22 +196,22 @@ export class PerformanceLayer extends DebugLayer { // ----------------------------------------------------------------------- private _build(): void { - const style = new TextStyle({ + const style: TextStyleOptions = { fontSize: textSize, fontFamily: 'Arial', fontWeight: 'normal', fillColor: textColor, - }); + }; const bg = new Graphics(); bg.fillColor = bgColor; bg.drawRectangle(panelX, panelY, panelW, panelH); - this._textFps = new Text('FPS: -', style.clone()); - this._textFrame = new Text('Frame: -', style.clone()); - this._textDraws = new Text('Draws: -', style.clone()); - this._textNodes = new Text('Nodes: -', style.clone()); + this._textFps = new Text('FPS: -', style); + this._textFrame = new Text('Frame: -', style); + this._textDraws = new Text('Draws: -', style); + this._textNodes = new Text('Nodes: -', style); this._textFps.x = panelX + 8; this._textFps.y = panelY + 8; diff --git a/src/debug/PointerStackLayer.ts b/src/debug/PointerStackLayer.ts index 574667b5..6f296794 100644 --- a/src/debug/PointerStackLayer.ts +++ b/src/debug/PointerStackLayer.ts @@ -6,7 +6,7 @@ import { Graphics } from '#rendering/primitives/Graphics'; import type { RenderBackend } from '#rendering/RenderBackend'; import type { RenderNode } from '#rendering/RenderNode'; import { Text as Text } from '#rendering/text/Text'; -import { TextStyle } from '#rendering/text/TextStyle'; +import type { TextStyleOptions } from '#rendering/text/TextStyle'; import type { DebugLayerViewMode } from './DebugLayer'; import { DebugLayer } from './DebugLayer'; @@ -103,12 +103,12 @@ export class PointerStackLayer extends DebugLayer { // ----------------------------------------------------------------------- private _build(): void { - const style = new TextStyle({ + const style: TextStyleOptions = { fontSize: textSize, fontFamily: 'Arial', fontWeight: 'normal', fillColor: textColor, - }); + }; this._bg = new Graphics(); this._bg.fillColor = bgColor; @@ -122,7 +122,7 @@ export class PointerStackLayer extends DebugLayer { const totalLines = maxEntries + 2; // header row + cursor row + entries for (let i = 0; i < totalLines; i++) { - const t = new Text('', style.clone()); + const t = new Text('', style); t.x = panelPad; t.y = panelPad + i * lineH; diff --git a/src/debug/RenderPassInspectorLayer.ts b/src/debug/RenderPassInspectorLayer.ts index 1dff4756..fd783b59 100644 --- a/src/debug/RenderPassInspectorLayer.ts +++ b/src/debug/RenderPassInspectorLayer.ts @@ -8,7 +8,7 @@ import type { RenderBackend } from '#rendering/RenderBackend'; import type { RenderNode } from '#rendering/RenderNode'; import { RenderPipeline } from '#rendering/RenderPipeline'; import { Text as Text } from '#rendering/text/Text'; -import { TextStyle } from '#rendering/text/TextStyle'; +import type { TextStyleOptions } from '#rendering/text/TextStyle'; import { DebugLayer, type DebugLayerViewMode } from './DebugLayer'; @@ -212,23 +212,22 @@ export class RenderPassInspectorLayer extends DebugLayer { } private _build(): void { - const style = new TextStyle({ + const style: TextStyleOptions = { fontSize: textSize, fontFamily: 'Arial', fontWeight: 'normal', fillColor: textColor, - }); + }; this._bg = new Graphics(); - const headerStyle = style.clone(); - headerStyle.fillColor = headerColor; + const headerStyle: TextStyleOptions = { ...style, fillColor: headerColor }; this._header = new Text('', headerStyle); this._header.x = panelX + panelPadding; this._header.y = panelY + panelPadding; this._lines = []; for (let i = 0; i < panelMaxLines; i++) { - const line = new Text('', style.clone()); + const line = new Text('', style); line.x = panelX + panelPadding; line.y = panelY + panelPadding + panelLineH + i * panelLineH; this._lines.push(line); diff --git a/src/rendering/public.ts b/src/rendering/public.ts index 17807b79..aafac1f6 100644 --- a/src/rendering/public.ts +++ b/src/rendering/public.ts @@ -80,6 +80,7 @@ export { GlyphAtlas } from '#rendering/text/GlyphAtlas'; export type { FontFormat, HTMLTextOptions } from '#rendering/text/HTMLText'; export { HTMLText } from '#rendering/text/HTMLText'; export type { LayoutOptions } from '#rendering/text/LayoutOptions'; +export type { TextOptions } from '#rendering/text/Text'; export { Text } from '#rendering/text/Text'; export type { FontFamily, FontRegistry, FontWeight, GradientAxis, StyleChangeHint, TextStyleOptions } from '#rendering/text/TextStyle'; export { TextStyle } from '#rendering/text/TextStyle'; diff --git a/src/rendering/text/Text.ts b/src/rendering/text/Text.ts index 4243c72f..551f2e71 100644 --- a/src/rendering/text/Text.ts +++ b/src/rendering/text/Text.ts @@ -11,6 +11,23 @@ import type { TextPageQuads, TextSize } from './types'; export type { TextPageQuads }; +/** + * Construction options for a {@link Text} node — a flat merge of visual + * {@link TextStyleOptions} (appearance) and {@link LayoutOptions} (flow / + * overflow), plus two construction-only flags. The two source interfaces share + * no keys, so the flat shape is unambiguous. + * + * ```ts + * const label = new Text('Hello', { fillColor, fontSize: 24, maxWidth: 360 }); + * ``` + */ +export interface TextOptions extends TextStyleOptions, LayoutOptions { + /** Use a colour-glyph (emoji) atlas + the `text-color` shader. Construction-only. */ + colorGlyphs?: boolean; + /** SDF buffer radius in pixels. Construction-only. */ + sdfRadius?: number; +} + /** * GPU-accelerated text node that rasterizes individual glyphs into a shared * per-font-variant {@link GlyphAtlas} using the SDF (Signed Distance Field) @@ -59,17 +76,15 @@ export class Text extends AbstractText { private _pageQuads: TextPageQuads[] = []; private _textBounds: TextSize = { width: 0, height: 0 }; - public constructor(text: string, style?: TextStyle | TextStyleOptions, layout?: LayoutOptions, options: { colorGlyphs?: boolean; sdfRadius?: number } = {}) { + public constructor(text: string, options: TextOptions = {}) { super(text); - this._style = style instanceof TextStyle ? style : new TextStyle(style); - this._layout = layout ?? {}; + this._style = new TextStyle(options); + this._layout = options; this._colorGlyphs = options.colorGlyphs ?? false; this._sdfRadius = options.sdfRadius ?? SDF_RADIUS; - if (!(style instanceof TextStyle) && style !== undefined) { - const face = this._extractFace(style); - if (face !== null) this._faceLoadPromise = this._loadFace(face); - } + const face = this._extractFace(options); + if (face !== null) this._faceLoadPromise = this._loadFace(face); this._rebuild('font'); } diff --git a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap index 959e567b..47e8fb6b 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -343,6 +343,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "TextAsset: class", "TextFactory: class", "TextLayoutStyle: interface", + "TextOptions: interface", "TextPageQuads: interface", "TextSize: interface", "TextStyle: class", diff --git a/test/rendering/browser/webgl2-text-layout.test.ts b/test/rendering/browser/webgl2-text-layout.test.ts index 3bb57ac2..4642995a 100644 --- a/test/rendering/browser/webgl2-text-layout.test.ts +++ b/test/rendering/browser/webgl2-text-layout.test.ts @@ -292,7 +292,7 @@ describe('Text layout WebGL2 browser', () => { test('"ABC" lays out as horizontally separated, non-overlapping glyphs', async () => { const backend = await createBackend(192, 64); // Wide letterSpacing guarantees a visible gap between glyphs regardless of the font. - const text = new Text('ABC', { fillColor: Color.white, fontSize: 30 }, { letterSpacing: 18 }); + const text = new Text('ABC', { fillColor: Color.white, fontSize: 30, letterSpacing: 18 }); try { // Drives the real in-browser GPU path (real atlas texture, real shaders); @@ -322,7 +322,7 @@ describe('Text layout WebGL2 browser', () => { test('wrapped text splits across at least two vertical line bands', async () => { const backend = await createBackend(128, 128); // "AAA BBB" cannot fit both words within 56px → wraps to two lines. - const text = new Text('AAA BBB', { fillColor: Color.white, fontSize: 24, lineHeight: 1.6 }, { maxWidth: 56 }); + const text = new Text('AAA BBB', { fillColor: Color.white, fontSize: 24, lineHeight: 1.6, maxWidth: 56 }); try { render(backend, text); diff --git a/test/rendering/text/text.test.ts b/test/rendering/text/text.test.ts index 937cd4d0..c1300574 100644 --- a/test/rendering/text/text.test.ts +++ b/test/rendering/text/text.test.ts @@ -151,7 +151,8 @@ describe('Text', () => { test('style getter returns the current TextStyle', () => { const style = new TextStyle({ fontSize: 20 }); - const text = new Text('Hi', style); + const text = new Text('Hi'); + text.style = style; expect(text.style).toBe(style); }); @@ -179,8 +180,8 @@ describe('Text', () => { }); test('update() with tint-only hint does not rebuild geometry', () => { - const style = new TextStyle({ fontSize: 16 }); - const text = new Text('Hi', style); + const text = new Text('Hi', { fontSize: 16 }); + const style = text.style; const quadsBefore = text.pageQuads[0]; // Consume initial dirty from constructor @@ -195,8 +196,8 @@ describe('Text', () => { }); test('update() triggers rebuild for layout hint', () => { - const style = new TextStyle({ fontSize: 16 }); - const text = new Text('Hi', style); + const text = new Text('Hi', { fontSize: 16 }); + const style = text.style; const quadsBefore = text.pageQuads[0]; style.fontSize = 32; // layout hint @@ -207,8 +208,8 @@ describe('Text', () => { }); test('style property mutations are deferred to update()', () => { - const style = new TextStyle({ fontSize: 16 }); - const text = new Text('Hi', style); + const text = new Text('Hi', { fontSize: 16 }); + const style = text.style; const quadsBefore = text.pageQuads[0]; style.fontFamily = 'Georgia'; // font hint — must NOT rebuild immediately @@ -221,7 +222,7 @@ describe('Text', () => { test('colorGlyphs flag is accessible', () => { const normal = new Text('Hi'); - const emoji = new Text('👋', undefined, undefined, { colorGlyphs: true }); + const emoji = new Text('👋', { colorGlyphs: true }); expect(normal.colorGlyphs).toBe(false); expect(emoji.colorGlyphs).toBe(true); diff --git a/test/rendering/webgpu-backend.test.ts b/test/rendering/webgpu-backend.test.ts index ce176dd0..d0e7c284 100644 --- a/test/rendering/webgpu-backend.test.ts +++ b/test/rendering/webgpu-backend.test.ts @@ -1221,7 +1221,7 @@ describe('WebGpuBackend', () => { installCoreRenderers(manager); // "Hi" → 2 glyphs → 2 quads → 12 indices, batched into 1 drawIndexed - const text = new Text('Hi', new TextStyle({ fontSize: 16 })); + const text = new Text('Hi', { fontSize: 16 }); await manager.initialize(); @@ -1278,7 +1278,7 @@ describe('WebGpuBackend', () => { } as unknown as Application; const manager = new WebGpuBackend(app); installCoreRenderers(manager); - const text = new Text('Hi', new TextStyle({ fontSize: 16 })); + const text = new Text('Hi', { fontSize: 16 }); const firstQuads = text.pageQuads[0]; await manager.initialize(); @@ -1340,7 +1340,7 @@ describe('WebGpuBackend', () => { } as unknown as Application; const manager = new WebGpuBackend(app); installCoreRenderers(manager); - const text = new Text('Hi', new TextStyle({ fontSize: 16 })); + const text = new Text('Hi', { fontSize: 16 }); const firstQuads = text.pageQuads[0]; await manager.initialize();