diff --git a/examples/examples.json b/examples/examples.json index 076b6c27..cf820f3a 100644 --- a/examples/examples.json +++ b/examples/examples.json @@ -1763,5 +1763,21 @@ "tween" ] } + ], + "ui": [ + { + "slug": "hud-and-widgets", + "path": "ui/hud-and-widgets.js", + "language": "typescript", + "title": "HUD and Widgets", + "description": "Build a screen-fixed HUD on scene.ui: anchored score and health widgets, plus a panel of clickable, keyboard-focusable buttons that update them.", + "backend": "core", + "tags": [ + "ui", + "hud", + "button", + "widget" + ] + } ] } diff --git a/examples/ui/hud-and-widgets.js b/examples/ui/hud-and-widgets.js new file mode 100644 index 00000000..a28e00c0 --- /dev/null +++ b/examples/ui/hud-and-widgets.js @@ -0,0 +1,74 @@ +// Auto-generated from hud-and-widgets.ts — edit the .ts source, not this file. +import { Application, Button, Color, Label, Panel, ProgressBar, Scene, Stack } from '@codexo/exojs'; +const app = new Application({ + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizingMode: 'fit', + }, + clearColor: new Color(18, 22, 32), +}); +/** + * UI-Core showcase: a screen-fixed HUD and interactive widgets live on + * `scene.ui`, which is auto-rendered above the world. Widgets anchor to the + * screen edges and re-layout on resize; buttons are clickable and keyboard- + * focusable (Tab to move focus, Enter / Space to activate). + */ +class HudScene extends Scene { + score = 0; + health = 1; + scoreLabel; + healthBar; + spinner; + angle = 0; + init() { + // A bit of "world" content so the UI clearly sits on top of it. + this.spinner = new Panel({ width: 160, height: 160, color: new Color(60, 130, 235, 1), cornerRadius: 24 }); + this.spinner.setAnchor(0.5, 0.5); + this.spinner.setPosition(640, 360); + this.addChild(this.spinner); + // HUD: score + health anchored to the top-left corner. + this.scoreLabel = new Label('Score: 0', { fontSize: 26 }); + this.scoreLabel.anchorIn(this.ui, 'top-left', 24, 20); + this.ui.addChild(this.scoreLabel); + this.healthBar = new ProgressBar({ width: 260, height: 16, value: 1 }); + this.healthBar.anchorIn(this.ui, 'top-left', 24, 60); + this.ui.addChild(this.healthBar); + // A panel of stacked buttons anchored to the bottom-right corner. + const panel = new Panel({ borderColor: new Color(255, 255, 255, 0.16), borderWidth: 1 }); + const buttons = new Stack({ direction: 'column', spacing: 10, padding: 14 }); + buttons.addItem(this.makeButton('+10 Score', new Color(54, 120, 220, 1), () => { + this.score += 10; + this.scoreLabel.text = `Score: ${this.score}`; + })); + buttons.addItem(this.makeButton('Take Damage', new Color(214, 92, 84, 1), () => { + this.health = Math.max(0, this.health - 0.2); + this.healthBar.value = this.health; + })); + buttons.addItem(this.makeButton('Reset', new Color(90, 96, 110, 1), () => { + this.score = 0; + this.health = 1; + this.scoreLabel.text = 'Score: 0'; + this.healthBar.value = 1; + })); + panel.setSize(buttons.uiWidth, buttons.uiHeight); + panel.addChild(buttons); + panel.anchorIn(this.ui, 'bottom-right', -24, -24); + this.ui.addChild(panel); + } + update(delta) { + this.angle += delta.seconds * 60; + this.spinner.setRotation(this.angle); + } + draw(context) { + // scene.root is explicit; scene.ui is auto-rendered above it. + context.render(this.root); + } + makeButton(label, color, onClick) { + const button = new Button({ label, color, width: 160, height: 44 }); + button.onClick.add(onClick); + return button; + } +} +void app.start(new HudScene()); diff --git a/examples/ui/hud-and-widgets.ts b/examples/ui/hud-and-widgets.ts new file mode 100644 index 00000000..955bd3cf --- /dev/null +++ b/examples/ui/hud-and-widgets.ts @@ -0,0 +1,87 @@ +import { Application, Button, Color, Label, Panel, ProgressBar, Scene, Stack } from '@codexo/exojs'; + +const app = new Application({ + canvas: { + width: 1280, + height: 720, + mount: document.body, + sizingMode: 'fit', + }, + clearColor: new Color(18, 22, 32), +}); + +/** + * UI-Core showcase: a screen-fixed HUD and interactive widgets live on + * `scene.ui`, which is auto-rendered above the world. Widgets anchor to the + * screen edges and re-layout on resize; buttons are clickable and keyboard- + * focusable (Tab to move focus, Enter / Space to activate). + */ +class HudScene extends Scene { + private score = 0; + private health = 1; + private scoreLabel!: Label; + private healthBar!: ProgressBar; + private spinner!: Panel; + private angle = 0; + + override init(): void { + // A bit of "world" content so the UI clearly sits on top of it. + this.spinner = new Panel({ width: 160, height: 160, color: new Color(60, 130, 235, 1), cornerRadius: 24 }); + this.spinner.setAnchor(0.5, 0.5); + this.spinner.setPosition(640, 360); + this.addChild(this.spinner); + + // HUD: score + health anchored to the top-left corner. + this.scoreLabel = new Label('Score: 0', { fontSize: 26 }); + this.scoreLabel.anchorIn(this.ui, 'top-left', 24, 20); + this.ui.addChild(this.scoreLabel); + + this.healthBar = new ProgressBar({ width: 260, height: 16, value: 1 }); + this.healthBar.anchorIn(this.ui, 'top-left', 24, 60); + this.ui.addChild(this.healthBar); + + // A panel of stacked buttons anchored to the bottom-right corner. + const panel = new Panel({ borderColor: new Color(255, 255, 255, 0.16), borderWidth: 1 }); + const buttons = new Stack({ direction: 'column', spacing: 10, padding: 14 }); + + buttons.addItem(this.makeButton('+10 Score', new Color(54, 120, 220, 1), () => { + this.score += 10; + this.scoreLabel.text = `Score: ${this.score}`; + })); + buttons.addItem(this.makeButton('Take Damage', new Color(214, 92, 84, 1), () => { + this.health = Math.max(0, this.health - 0.2); + this.healthBar.value = this.health; + })); + buttons.addItem(this.makeButton('Reset', new Color(90, 96, 110, 1), () => { + this.score = 0; + this.health = 1; + this.scoreLabel.text = 'Score: 0'; + this.healthBar.value = 1; + })); + + panel.setSize(buttons.uiWidth, buttons.uiHeight); + panel.addChild(buttons); + panel.anchorIn(this.ui, 'bottom-right', -24, -24); + this.ui.addChild(panel); + } + + override update(delta): void { + this.angle += delta.seconds * 60; + this.spinner.setRotation(this.angle); + } + + override draw(context): void { + // scene.root is explicit; scene.ui is auto-rendered above it. + context.render(this.root); + } + + private makeButton(label: string, color: Color, onClick: () => void): Button { + const button = new Button({ label, color, width: 160, height: 44 }); + + button.onClick.add(onClick); + + return button; + } +} + +void app.start(new HudScene()); diff --git a/site/src/content/api/abstract-text.mdx b/site/src/content/api/abstract-text.mdx index 822fb6fb..4ebe4ed3 100644 --- a/site/src/content/api/abstract-text.mdx +++ b/site/src/content/api/abstract-text.mdx @@ -5,7 +5,7 @@ symbol: "AbstractText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 75 +memberCount: 83 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/AbstractText.ts" @@ -32,10 +32,12 @@ Subclasses: - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -72,7 +74,9 @@ Subclasses: - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -99,9 +103,13 @@ Subclasses: ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/animated-sprite.mdx b/site/src/content/api/animated-sprite.mdx index 76e4fdca..65fe7168 100644 --- a/site/src/content/api/animated-sprite.mdx +++ b/site/src/content/api/animated-sprite.mdx @@ -5,7 +5,7 @@ symbol: "AnimatedSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 95 +memberCount: 103 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/AnimatedSprite.ts" @@ -35,11 +35,13 @@ from a Spritesheet's named animations. - `_invalidateChildrenTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `defineClip(name: string, clip: AnimatedSpriteClipDefinition): this` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -86,7 +88,9 @@ from a Spritesheet's named animations. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -124,9 +128,13 @@ from a Spritesheet's named animations. - `onComplete: Signal<[clip]>` - `onFrame: Signal<[clip, frame]>` +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/application-status.mdx b/site/src/content/api/application-status.mdx index 493524c5..8f3f65dc 100644 --- a/site/src/content/api/application-status.mdx +++ b/site/src/content/api/application-status.mdx @@ -9,7 +9,7 @@ memberCount: 4 tier: "stable" sections: ["Import", "Members", "Source"] sourcePath: "src/core/Application.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L35" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L36" --- ## Import @@ -24,4 +24,4 @@ sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts# ## Source -[src/core/Application.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L35) +[src/core/Application.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L36) diff --git a/site/src/content/api/application.mdx b/site/src/content/api/application.mdx index be042fbf..bc1b0930 100644 --- a/site/src/content/api/application.mdx +++ b/site/src/content/api/application.mdx @@ -5,11 +5,11 @@ symbol: "Application" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 40 +memberCount: 41 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/core/Application.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L215" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L216" --- ## Import @@ -55,6 +55,7 @@ background-active simulations. ## Properties - `canvas: HTMLCanvasElement` +- `focus: FocusManager` - `input: InputManager` - `interaction: InteractionManager` - `loader: Loader` @@ -91,4 +92,4 @@ background-active simulations. ## Source -[src/core/Application.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L215) +[src/core/Application.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Application.ts#L216) diff --git a/site/src/content/api/bitmap-text.mdx b/site/src/content/api/bitmap-text.mdx index 04782a56..c8da6bba 100644 --- a/site/src/content/api/bitmap-text.mdx +++ b/site/src/content/api/bitmap-text.mdx @@ -5,7 +5,7 @@ symbol: "BitmapText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 82 +memberCount: 90 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/BitmapText.ts" @@ -45,10 +45,12 @@ label.style.align = 'center'; // immediate rebuild - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -86,7 +88,9 @@ label.style.align = 'center'; // immediate rebuild - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -119,9 +123,13 @@ label.style.align = 'center'; // immediate rebuild ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/button.mdx b/site/src/content/api/button.mdx new file mode 100644 index 00000000..0c79ce12 --- /dev/null +++ b/site/src/content/api/button.mdx @@ -0,0 +1,136 @@ +--- +title: "Button" +description: "Clickable button with a rounded background, a centered label, hover/pressed visual states, and keyboard activation (Enter / Space while focused). Listen to Button.onClick for activation." +symbol: "Button" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 100 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/Button.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Button.ts#L31" +--- +## Import + +`import { Button } from '@codexo/exojs'` + +Clickable button with a rounded background, a centered label, hover/pressed +visual states, and keyboard activation (Enter / Space while focused). +Listen to Button.onClick for activation. + +## Constructors + +- `new(options: ButtonOptions): Button` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `label: string` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onClick: Signal<[Button]>` +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/Button.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Button.ts#L31) diff --git a/site/src/content/api/container.mdx b/site/src/content/api/container.mdx index 99a5f670..fb42893f 100644 --- a/site/src/content/api/container.mdx +++ b/site/src/content/api/container.mdx @@ -5,7 +5,7 @@ symbol: "Container" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 82 +memberCount: 90 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/Container.ts" @@ -43,10 +43,12 @@ etc. — the base `Container` is a non-drawing grouping node. - `addChild(children: RenderNode[]): this` - `addChildAt(child: RenderNode, index: number): this` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): RenderNode` - `getChildIndex(child: RenderNode): number` @@ -86,7 +88,9 @@ etc. — the base `Container` is a non-drawing grouping node. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -115,9 +119,13 @@ etc. — the base `Container` is a non-drawing grouping node. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/drawable.mdx b/site/src/content/api/drawable.mdx index f60f0c4f..1e322fab 100644 --- a/site/src/content/api/drawable.mdx +++ b/site/src/content/api/drawable.mdx @@ -5,7 +5,7 @@ symbol: "Drawable" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 71 +memberCount: 79 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/Drawable.ts" @@ -31,10 +31,12 @@ and are paired with a matching Renderer via RendererRegistry. - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -69,7 +71,9 @@ and are paired with a matching Renderer via RendererRegistry. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -94,9 +98,13 @@ and are paired with a matching Renderer via RendererRegistry. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/focus-manager.mdx b/site/src/content/api/focus-manager.mdx new file mode 100644 index 00000000..91d24ed7 --- /dev/null +++ b/site/src/content/api/focus-manager.mdx @@ -0,0 +1,51 @@ +--- +title: "FocusManager" +description: "Per-Application keyboard-focus service. Tracks the single focused RenderNode, routes keyboard input from the InputManager to it, and provides Tab-order traversal across the focusable nodes of the active focus scope. A node reaches this service through its Stage (`stage.focus`); app code reaches it through `app.focus`. Constructed automatically by Application; you do not instantiate this class yourself. Built-in key handling: `Tab` / `Shift+Tab` move focus to the next / previous focusable node. A focused node can call KeyEvent.preventDefault on its `onKeyDown` event to opt out of this and consume the key itself." +symbol: "FocusManager" +kind: "class" +subsystem: "input" +importPath: "@codexo/exojs" +memberCount: 9 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sourcePath: "src/input/FocusManager.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/input/FocusManager.ts#L23" +--- +## Import + +`import { FocusManager } from '@codexo/exojs'` + +Per-Application keyboard-focus service. Tracks the single focused +RenderNode, routes keyboard input from the InputManager to +it, and provides Tab-order traversal across the focusable nodes of the active +focus scope. + +A node reaches this service through its Stage (`stage.focus`); app +code reaches it through `app.focus`. Constructed automatically by +Application; you do not instantiate this class yourself. + +Built-in key handling: `Tab` / `Shift+Tab` move focus to the next / previous +focusable node. A focused node can call KeyEvent.preventDefault on its +`onKeyDown` event to opt out of this and consume the key itself. + +## Constructors + +- `new(app: Application): FocusManager` + +## Methods + +- `blur(node?: RenderNode): void` +- `destroy(): void` +- `focus(node: RenderNode): void` +- `focusNext(): void` +- `focusPrevious(): void` +- `popScope(): void` +- `pushScope(root: RenderNode): void` + +## Properties + +- `focused: RenderNode | null` + +## Source + +[src/input/FocusManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/input/FocusManager.ts#L23) diff --git a/site/src/content/api/graphics.mdx b/site/src/content/api/graphics.mdx index 465d2ee7..1d95ed60 100644 --- a/site/src/content/api/graphics.mdx +++ b/site/src/content/api/graphics.mdx @@ -5,7 +5,7 @@ symbol: "Graphics" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 102 +memberCount: 111 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/primitives/Graphics.ts" @@ -50,6 +50,7 @@ tint, and mask support from Container. - `addFilter(filter: Filter): this` - `arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): this` - `bezierCurveTo(cpX1: number, cpY1: number, cpX2: number, cpY2: number, toX: number, toY: number): this` +- `blur(): this` - `clear(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` @@ -62,7 +63,9 @@ tint, and mask support from Container. - `drawPath(path: number[]): this` - `drawPolygon(path: number[]): this` - `drawRectangle(x: number, y: number, width: number, height: number): this` +- `drawRoundedRectangle(x: number, y: number, width: number, height: number, radius: number): this` - `drawStar(centerX: number, centerY: number, points: number, radius: number, innerRadius: number, rotation: number): this` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): Mesh` - `getChildIndex(child: RenderNode): number` @@ -105,7 +108,9 @@ tint, and mask support from Container. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -140,9 +145,13 @@ tint, and mask support from Container. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/htmltext.mdx b/site/src/content/api/htmltext.mdx index 07b5595b..6b2078c3 100644 --- a/site/src/content/api/htmltext.mdx +++ b/site/src/content/api/htmltext.mdx @@ -5,7 +5,7 @@ symbol: "HTMLText" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 89 +memberCount: 97 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/HTMLText.ts" @@ -46,10 +46,12 @@ because it is embedded inside an XML document. - `addChildAt(child: RenderNode, index: number): this` - `addFilter(filter: Filter): this` - `addFont(family: string, data: ArrayBuffer, format: FontFormat): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): RenderNode` - `getChildIndex(child: RenderNode): number` @@ -91,7 +93,9 @@ because it is embedded inside an XML document. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -124,9 +128,13 @@ because it is embedded inside an XML document. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/interaction-manager.mdx b/site/src/content/api/interaction-manager.mdx index afece7af..d8346794 100644 --- a/site/src/content/api/interaction-manager.mdx +++ b/site/src/content/api/interaction-manager.mdx @@ -5,11 +5,11 @@ symbol: "InteractionManager" kind: "class" subsystem: "input" importPath: "@codexo/exojs" -memberCount: 5 +memberCount: 7 tier: "stable" sections: ["Import", "Constructors", "Methods", "Source"] sourcePath: "src/input/InteractionManager.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/input/InteractionManager.ts#L69" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/input/InteractionManager.ts#L70" --- ## Import @@ -40,8 +40,10 @@ this class yourself. - `destroy(): void` - `getCapturedNodes(): readonly RenderNode[]` - `getHoveredNode(pointerId?: number): RenderNode | null` +- `popInputCapture(): void` +- `pushInputCapture(root: RenderNode): void` - `update(): void` ## Source -[src/input/InteractionManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/input/InteractionManager.ts#L69) +[src/input/InteractionManager.ts](https://github.com/Exoridus/ExoJS/blob/main/src/input/InteractionManager.ts#L70) diff --git a/site/src/content/api/key-event.mdx b/site/src/content/api/key-event.mdx new file mode 100644 index 00000000..19641195 --- /dev/null +++ b/site/src/content/api/key-event.mdx @@ -0,0 +1,43 @@ +--- +title: "KeyEvent" +description: "Envelope dispatched by FocusManager to the focused RenderNode for keyboard input. `channel` is the input channel of the key — compare it with the `Keyboard` constants (e.g. `event.channel === Keyboard.Enter`). A handler may call KeyEvent.preventDefault to suppress the FocusManager's built-in handling for this key (currently `Tab` focus traversal), letting the focused widget consume the key itself." +symbol: "KeyEvent" +kind: "class" +subsystem: "input" +importPath: "@codexo/exojs" +memberCount: 6 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Source"] +sourcePath: "src/input/KeyEvent.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/input/KeyEvent.ts#L15" +--- +## Import + +`import { KeyEvent } from '@codexo/exojs'` + +Envelope dispatched by FocusManager to the focused RenderNode +for keyboard input. `channel` is the input channel of the key — compare it +with the `Keyboard` constants (e.g. `event.channel === Keyboard.Enter`). + +A handler may call KeyEvent.preventDefault to suppress the +FocusManager's built-in handling for this key (currently `Tab` focus +traversal), letting the focused widget consume the key itself. + +## Constructors + +- `new(type: KeyEventType, channel: number, target: RenderNode): KeyEvent` + +## Methods + +- `preventDefault(): void` + +## Properties + +- `channel: number` +- `target: RenderNode` +- `type: KeyEventType` +- `defaultPrevented: boolean` + +## Source + +[src/input/KeyEvent.ts](https://github.com/Exoridus/ExoJS/blob/main/src/input/KeyEvent.ts#L15) diff --git a/site/src/content/api/label.mdx b/site/src/content/api/label.mdx new file mode 100644 index 00000000..e8135f85 --- /dev/null +++ b/site/src/content/api/label.mdx @@ -0,0 +1,135 @@ +--- +title: "Label" +description: "Text label widget. Wraps a Text node and keeps the widget's layout size in sync with the measured text, so it anchors and stacks correctly." +symbol: "Label" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 100 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/Label.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Label.ts#L11" +--- +## Import + +`import { Label } from '@codexo/exojs'` + +Text label widget. Wraps a Text node and keeps the widget's layout +size in sync with the measured text, so it anchors and stacks correctly. + +## Constructors + +- `new(text: string, style: TextStyleOptions): Label` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `text: string` +- `textNode: Text` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/Label.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Label.ts#L11) diff --git a/site/src/content/api/mesh.mdx b/site/src/content/api/mesh.mdx index 47e7fb02..cdcbde77 100644 --- a/site/src/content/api/mesh.mdx +++ b/site/src/content/api/mesh.mdx @@ -5,7 +5,7 @@ symbol: "Mesh" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 81 +memberCount: 89 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/mesh/Mesh.ts" @@ -52,10 +52,12 @@ after in-place mutation is the caller's responsibility (call - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -92,10 +94,12 @@ after in-place mutation is the caller's responsibility (call - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `geometry: Geometry | null` - `indices: Uint16Array | null` - `material: MeshMaterial | null` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `uvs: Float32Array | null` - `vertices: Float32Array` - `anchor: ObservableVector` @@ -125,9 +129,13 @@ after in-place mutation is the caller's responsibility (call ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/nine-slice-sprite.mdx b/site/src/content/api/nine-slice-sprite.mdx index 57d201cd..94b269b8 100644 --- a/site/src/content/api/nine-slice-sprite.mdx +++ b/site/src/content/api/nine-slice-sprite.mdx @@ -5,7 +5,7 @@ symbol: "NineSliceSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 82 +memberCount: 90 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/NineSliceSprite.ts" @@ -28,10 +28,12 @@ Corners stay pixel-perfect; edges/center fill by stretch, repeat, or mirror-repe - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -70,7 +72,9 @@ Corners stay pixel-perfect; edges/center fill by stretch, repeat, or mirror-repe - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `border: Readonly` @@ -102,9 +106,13 @@ Corners stay pixel-perfect; edges/center fill by stretch, repeat, or mirror-repe ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/panel.mdx b/site/src/content/api/panel.mdx new file mode 100644 index 00000000..c2d9bcbf --- /dev/null +++ b/site/src/content/api/panel.mdx @@ -0,0 +1,135 @@ +--- +title: "Panel" +description: "Rectangular background container with rounded corners and an optional border. The base building block for HUD boxes, dialogs, and menus — add content with `panel.addChild(...)`." +symbol: "Panel" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 99 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/Panel.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Panel.ts#L22" +--- +## Import + +`import { Panel } from '@codexo/exojs'` + +Rectangular background container with rounded corners and an optional border. +The base building block for HUD boxes, dialogs, and menus — add content with +`panel.addChild(...)`. + +## Constructors + +- `new(options: PanelOptions): Panel` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `background: Graphics` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/Panel.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Panel.ts#L22) diff --git a/site/src/content/api/particle-system.mdx b/site/src/content/api/particle-system.mdx index fdf265e9..dec70ecf 100644 --- a/site/src/content/api/particle-system.mdx +++ b/site/src/content/api/particle-system.mdx @@ -5,7 +5,7 @@ symbol: "ParticleSystem" kind: "class" subsystem: "particles" importPath: "@codexo/exojs-particles" -memberCount: 110 +memberCount: 118 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-particles/src/ParticleSystem.ts" @@ -76,6 +76,7 @@ to `(0, 0)`. - `addFilter(filter: Filter): this` - `addSpawnModule(mod: SpawnModule): this` - `addUpdateModule(mod: UpdateModule): this` +- `blur(): this` - `clearDeathModules(): this` - `clearFilters(): this` - `clearParticles(): this` @@ -84,6 +85,7 @@ to `(0, 0)`. - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -127,6 +129,7 @@ to `(0, 0)`. - `draggable: boolean` - `elapsed: Float32Array` - `flags: Flags` +- `focusable: boolean` - `lifetime: Float32Array` - `liveCount: number` - `posX: Float32Array` @@ -136,6 +139,7 @@ to `(0, 0)`. - `rotationSpeeds: Float32Array` - `scaleX: Float32Array` - `scaleY: Float32Array` +- `tabIndex: number` - `textureIndex: Uint16Array` - `velX: Float32Array` - `velY: Float32Array` @@ -175,9 +179,13 @@ to `(0, 0)`. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/progress-bar.mdx b/site/src/content/api/progress-bar.mdx new file mode 100644 index 00000000..21a9a273 --- /dev/null +++ b/site/src/content/api/progress-bar.mdx @@ -0,0 +1,134 @@ +--- +title: "ProgressBar" +description: "Horizontal progress / health bar. ProgressBar.value is the fill fraction in `[0, 1]`; setting it redraws only the fill." +symbol: "ProgressBar" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 99 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/ProgressBar.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/ProgressBar.ts#L21" +--- +## Import + +`import { ProgressBar } from '@codexo/exojs'` + +Horizontal progress / health bar. ProgressBar.value is the fill +fraction in `[0, 1]`; setting it redraws only the fill. + +## Constructors + +- `new(options: ProgressBarOptions): ProgressBar` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `value: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/ProgressBar.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/ProgressBar.ts#L21) diff --git a/site/src/content/api/render-node.mdx b/site/src/content/api/render-node.mdx index f86a7e40..295ec850 100644 --- a/site/src/content/api/render-node.mdx +++ b/site/src/content/api/render-node.mdx @@ -5,11 +5,11 @@ symbol: "RenderNode" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 66 +memberCount: 74 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/RenderNode.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/RenderNode.ts#L80" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/rendering/RenderNode.ts#L81" --- ## Import @@ -41,10 +41,12 @@ ParticleSystem (particles). - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -77,7 +79,9 @@ ParticleSystem (particles). - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `cacheAsBitmap: boolean` - `cullable: boolean` @@ -99,9 +103,13 @@ ParticleSystem (particles). ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` @@ -111,4 +119,4 @@ ParticleSystem (particles). ## Source -[src/rendering/RenderNode.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/RenderNode.ts#L80) +[src/rendering/RenderNode.ts](https://github.com/Exoridus/ExoJS/blob/main/src/rendering/RenderNode.ts#L81) diff --git a/site/src/content/api/repeating-sprite.mdx b/site/src/content/api/repeating-sprite.mdx index 188dec42..56d35074 100644 --- a/site/src/content/api/repeating-sprite.mdx +++ b/site/src/content/api/repeating-sprite.mdx @@ -5,7 +5,7 @@ symbol: "RepeatingSprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 84 +memberCount: 92 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/RepeatingSprite.ts" @@ -41,10 +41,12 @@ renderer uses. - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -81,7 +83,9 @@ renderer uses. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -117,9 +121,13 @@ renderer uses. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/scene.mdx b/site/src/content/api/scene.mdx index ecf3c82e..c92b6bf3 100644 --- a/site/src/content/api/scene.mdx +++ b/site/src/content/api/scene.mdx @@ -5,11 +5,11 @@ symbol: "Scene" kind: "class" subsystem: "core" importPath: "@codexo/exojs" -memberCount: 17 +memberCount: 18 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Source"] sourcePath: "src/core/Scene.ts" -sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L118" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L119" --- ## Import @@ -57,7 +57,8 @@ For one-off scenes, an anonymous subclass works just as well: - `stackMode: SceneStackMode` - `systems: SystemRegistry` - `tweens: SceneTweens` +- `ui: UIRoot` ## Source -[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L118) +[src/core/Scene.ts](https://github.com/Exoridus/ExoJS/blob/main/src/core/Scene.ts#L119) diff --git a/site/src/content/api/sprite.mdx b/site/src/content/api/sprite.mdx index a8ff5290..caaf6429 100644 --- a/site/src/content/api/sprite.mdx +++ b/site/src/content/api/sprite.mdx @@ -5,7 +5,7 @@ symbol: "Sprite" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 80 +memberCount: 88 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/sprite/Sprite.ts" @@ -37,10 +37,12 @@ operate on the exact rotated quad rather than the AABB. - `_invalidateChildrenTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -79,7 +81,9 @@ operate on the exact rotated quad rather than the AABB. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `blendMode: BlendModes` - `cacheAsBitmap: boolean` @@ -111,9 +115,13 @@ operate on the exact rotated quad rather than the AABB. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/stack.mdx b/site/src/content/api/stack.mdx new file mode 100644 index 00000000..b118f28b --- /dev/null +++ b/site/src/content/api/stack.mdx @@ -0,0 +1,137 @@ +--- +title: "Stack" +description: "Linear layout container that flows its children in a row or column with even spacing and optional padding, then sizes itself to fit. Call Stack.layout after mutating children added with `addChild`, or use Stack.addItem to add and re-flow in one step." +symbol: "Stack" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 100 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/Stack.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Stack.ts#L22" +--- +## Import + +`import { Stack } from '@codexo/exojs'` + +Linear layout container that flows its children in a row or column with even +spacing and optional padding, then sizes itself to fit. Call +Stack.layout after mutating children added with `addChild`, or use +Stack.addItem to add and re-flow in one step. + +## Constructors + +- `new(options: StackOptions): Stack` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `addItem(child: RenderNode): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `layout(): this` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/Stack.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Stack.ts#L22) diff --git a/site/src/content/api/text.mdx b/site/src/content/api/text.mdx index 14c21b3f..dc750740 100644 --- a/site/src/content/api/text.mdx +++ b/site/src/content/api/text.mdx @@ -5,7 +5,7 @@ symbol: "Text" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 82 +memberCount: 90 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/text/Text.ts" @@ -55,10 +55,12 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. - `_invalidateChildrenTransform(): void` - `_invalidateSubtreeTransform(): void` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -95,7 +97,9 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `atlas: GlyphAtlas | null` - `atlasMode: AtlasMode` @@ -129,9 +133,13 @@ options. Colour-glyph nodes use the `text-color` shader instead of `text-sdf`. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/tile-layer-node.mdx b/site/src/content/api/tile-layer-node.mdx index 2ef959dc..03b686fa 100644 --- a/site/src/content/api/tile-layer-node.mdx +++ b/site/src/content/api/tile-layer-node.mdx @@ -5,7 +5,7 @@ symbol: "TileLayerNode" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 85 +memberCount: 93 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileLayerNode.ts" @@ -44,10 +44,12 @@ existing chunks are picked up automatically via chunk revisions. - `addChild(children: RenderNode[]): this` - `addChildAt(child: RenderNode, index: number): this` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): RenderNode` - `getChildIndex(child: RenderNode): number` @@ -88,7 +90,9 @@ existing chunks are picked up automatically via chunk revisions. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -119,9 +123,13 @@ existing chunks are picked up automatically via chunk revisions. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/tile-map-band.mdx b/site/src/content/api/tile-map-band.mdx index a7ad499f..d216ac24 100644 --- a/site/src/content/api/tile-map-band.mdx +++ b/site/src/content/api/tile-map-band.mdx @@ -5,7 +5,7 @@ symbol: "TileMapBand" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 85 +memberCount: 93 tier: "advanced" sections: ["Import", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileMapBand.ts" @@ -52,10 +52,12 @@ TileMapView rather than directly. - `addChild(children: RenderNode[]): this` - `addChildAt(child: RenderNode, index: number): this` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): RenderNode` - `getChildIndex(child: RenderNode): number` @@ -96,7 +98,9 @@ TileMapView rather than directly. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -128,9 +132,13 @@ TileMapView rather than directly. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/tile-map-node.mdx b/site/src/content/api/tile-map-node.mdx index 3a10791e..72dae049 100644 --- a/site/src/content/api/tile-map-node.mdx +++ b/site/src/content/api/tile-map-node.mdx @@ -5,7 +5,7 @@ symbol: "TileMapNode" kind: "class" subsystem: "tilemap" importPath: "@codexo/exojs-tilemap" -memberCount: 87 +memberCount: 95 tier: "advanced" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "packages/exojs-tilemap/src/TileMapNode.ts" @@ -44,10 +44,12 @@ after TileMapNode.refreshLayers. - `addChild(children: RenderNode[]): this` - `addChildAt(child: RenderNode, index: number): this` - `addFilter(filter: Filter): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getChildAt(index: number): RenderNode` - `getChildIndex(child: RenderNode): number` @@ -89,7 +91,9 @@ after TileMapNode.refreshLayers. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `anchor: ObservableVector` - `bottom: number` - `cacheAsBitmap: boolean` @@ -121,9 +125,13 @@ after TileMapNode.refreshLayers. ## Events +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/uiroot.mdx b/site/src/content/api/uiroot.mdx new file mode 100644 index 00000000..877e4aa7 --- /dev/null +++ b/site/src/content/api/uiroot.mdx @@ -0,0 +1,138 @@ +--- +title: "UIRoot" +description: "Root of a scene's screen-fixed UI layer. Reached through Scene.ui; you do not construct it directly. Unlike Scene.root, the UI layer is **auto-rendered** by the SceneManager after `Scene.draw()`, through the RenderingContext.screenView — so its children live in screen space (origin top-left, `0..width` × `0..height`) and never scroll with the camera. Pointer hit-testing and keyboard focus are routed to UI nodes in that same screen space, ahead of the world layer. Add widgets with `scene.ui.addChild(...)`. The UIRoot.onResize signal fires whenever the screen size changes, so anchored widgets can re-layout." +symbol: "UIRoot" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 93 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/UIRoot.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/UIRoot.ts#L20" +--- +## Import + +`import { UIRoot } from '@codexo/exojs'` + +Root of a scene's screen-fixed UI layer. Reached through Scene.ui; +you do not construct it directly. + +Unlike Scene.root, the UI layer is **auto-rendered** by the +SceneManager after `Scene.draw()`, through the +RenderingContext.screenView — so its children live in screen space +(origin top-left, `0..width` × `0..height`) and never scroll with the +camera. Pointer hit-testing and keyboard focus are routed to UI nodes in that +same screen space, ahead of the world layer. + +Add widgets with `scene.ui.addChild(...)`. The UIRoot.onResize signal +fires whenever the screen size changes, so anchored widgets can re-layout. + +## Constructors + +- `new(): UIRoot` + +## Methods + +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `screenHeight: number` +- `screenWidth: number` +- `skewX: number` +- `skewY: number` +- `top: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onResize: Signal<[width, height]>` +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/UIRoot.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/UIRoot.ts#L20) diff --git a/site/src/content/api/video.mdx b/site/src/content/api/video.mdx index 6484b44f..5d872463 100644 --- a/site/src/content/api/video.mdx +++ b/site/src/content/api/video.mdx @@ -5,7 +5,7 @@ symbol: "Video" kind: "class" subsystem: "rendering" importPath: "@codexo/exojs" -memberCount: 105 +memberCount: 113 tier: "stable" sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] sourcePath: "src/rendering/video/Video.ts" @@ -33,10 +33,12 @@ node and can be directed to any AudioBus. - `_invalidateChildrenTransform(): void` - `addFilter(filter: Filter): this` - `applyOptions(options: Partial): this` +- `blur(): this` - `clearFilters(): this` - `collidesWith(target: Collidable): CollisionResponse | null` - `contains(x: number, y: number): boolean` - `destroy(): void` +- `focus(): this` - `getBounds(): Rectangle` - `getGlobalTransform(): Matrix` - `getLocalBounds(): Rectangle` @@ -85,7 +87,9 @@ node and can be directed to any AudioBus. - `cursor: string | null` - `draggable: boolean` - `flags: Flags` +- `focusable: boolean` - `preserveDrawOrder: boolean` +- `tabIndex: number` - `analyserTarget: AudioNode | null` - `anchor: ObservableVector` - `blendMode: BlendModes` @@ -131,9 +135,13 @@ node and can be directed to any AudioBus. - `onStart: Signal<[]>` - `onStop: Signal<[]>` +- `onBlur: Signal<[RenderNode]>` - `onDrag: Signal<[InteractionEvent]>` - `onDragEnd: Signal<[InteractionEvent]>` - `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` - `onPointerDown: Signal<[InteractionEvent]>` - `onPointerMove: Signal<[InteractionEvent]>` - `onPointerOut: Signal<[InteractionEvent]>` diff --git a/site/src/content/api/widget.mdx b/site/src/content/api/widget.mdx new file mode 100644 index 00000000..7f8b8142 --- /dev/null +++ b/site/src/content/api/widget.mdx @@ -0,0 +1,137 @@ +--- +title: "Widget" +description: "Base class for UI widgets — a Container with an explicit layout size (independent of child bounds / scale), an `enabled` flag, and optional screen-edge anchoring that re-applies on resize. Subclasses redraw size-dependent content in Widget._relayout and react to enable/disable in Widget._onEnabledChanged." +symbol: "Widget" +kind: "class" +subsystem: "core" +importPath: "@codexo/exojs" +memberCount: 98 +tier: "stable" +sections: ["Import", "Constructors", "Methods", "Properties", "Events", "Source"] +sourcePath: "src/ui/Widget.ts" +sourceUrl: "https://github.com/Exoridus/ExoJS/blob/main/src/ui/Widget.ts#L36" +--- +## Import + +`import { Widget } from '@codexo/exojs'` + +Base class for UI widgets — a Container with an explicit layout size +(independent of child bounds / scale), an `enabled` flag, and optional +screen-edge anchoring that re-applies on resize. + +Subclasses redraw size-dependent content in Widget._relayout and +react to enable/disable in Widget._onEnabledChanged. + +## Constructors + +- `new(): Widget` + +## Methods + +- `_applyAnchor(containerWidth: number, containerHeight: number): void` +- `_invalidateBoundsCascade(): void` +- `_invalidateChildrenTransform(): void` +- `_invalidateSubtreeTransform(): void` +- `_onEnabledChanged(_enabled: boolean): void` +- `_relayout(): void` +- `addChild(children: RenderNode[]): this` +- `addChildAt(child: RenderNode, index: number): this` +- `addFilter(filter: Filter): this` +- `anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX: number, offsetY: number): this` +- `blur(): this` +- `clearFilters(): this` +- `collidesWith(target: Collidable): CollisionResponse | null` +- `contains(x: number, y: number): boolean` +- `destroy(): void` +- `focus(): this` +- `getBounds(): Rectangle` +- `getChildAt(index: number): RenderNode` +- `getChildIndex(child: RenderNode): number` +- `getGlobalTransform(): Matrix` +- `getLocalBounds(): Rectangle` +- `getNormals(): Vector[]` +- `getTransform(): Matrix` +- `intersectsWith(target: Collidable): boolean` +- `invalidateCache(): this` +- `inView(view: View): boolean` +- `move(x: number, y: number): this` +- `project(axis: Vector, result: Interval): Interval` +- `removeChild(child: RenderNode): this` +- `removeChildAt(index: number): this` +- `removeChildren(begin: number, end: number): this` +- `removeFilter(filter: Filter): this` +- `render(backend: RenderBackend): this` +- `rotate(degrees: number): this` +- `setAnchor(x: number, y: number): this` +- `setChildIndex(child: RenderNode, index: number): this` +- `setOrigin(x: number, y: number): this` +- `setPosition(x: number, y: number): this` +- `setRotation(degrees: number): this` +- `setScale(x: number, y: number): this` +- `setSize(width: number, height: number): this` +- `setSkew(x: number, y: number): this` +- `swapChildren(firstChild: RenderNode, secondChild: RenderNode): this` +- `updateBounds(): this` +- `updateParentTransform(): this` +- `updateTransform(): this` +- `setInternalSpriteFactory(factory: object | null): void` + +## Properties + +- `clip: boolean` +- `clipShape: Rectangle | Geometry | null` +- `collisionType: CollisionType` +- `cursor: string | null` +- `draggable: boolean` +- `flags: Flags` +- `focusable: boolean` +- `preserveDrawOrder: boolean` +- `tabIndex: number` +- `anchor: ObservableVector` +- `bottom: number` +- `cacheAsBitmap: boolean` +- `children: RenderNode[]` +- `cullable: boolean` +- `enabled: boolean` +- `filters: readonly Filter[]` +- `height: number` +- `interactive: boolean` +- `isAlignedBox: boolean` +- `left: number` +- `mask: MaskSource` +- `origin: ObservableVector` +- `parent: Container | null` +- `position: ObservableVector` +- `right: number` +- `rotation: number` +- `scale: ObservableVector` +- `skewX: number` +- `skewY: number` +- `top: number` +- `uiHeight: number` +- `uiWidth: number` +- `visible: boolean` +- `width: number` +- `x: number` +- `y: number` +- `zIndex: number` + +## Events + +- `onBlur: Signal<[RenderNode]>` +- `onDrag: Signal<[InteractionEvent]>` +- `onDragEnd: Signal<[InteractionEvent]>` +- `onDragStart: Signal<[InteractionEvent]>` +- `onFocus: Signal<[RenderNode]>` +- `onKeyDown: Signal<[KeyEvent]>` +- `onKeyUp: Signal<[KeyEvent]>` +- `onPointerDown: Signal<[InteractionEvent]>` +- `onPointerMove: Signal<[InteractionEvent]>` +- `onPointerOut: Signal<[InteractionEvent]>` +- `onPointerOver: Signal<[InteractionEvent]>` +- `onPointerTap: Signal<[InteractionEvent]>` +- `onPointerUp: Signal<[InteractionEvent]>` + +## Source + +[src/ui/Widget.ts](https://github.com/Exoridus/ExoJS/blob/main/src/ui/Widget.ts#L36) diff --git a/site/src/lib/chapters.ts b/site/src/lib/chapters.ts index 228e5553..29a06366 100644 --- a/site/src/lib/chapters.ts +++ b/site/src/lib/chapters.ts @@ -25,6 +25,7 @@ export const CHAPTERS: ReadonlyArray = [ { order: 17, slug: 'debug-layer', title: 'Debug Layer', complexity: 'Low' }, { order: 18, slug: 'custom-renderers', title: 'Custom Renderers', complexity: 'Very high' }, { order: 19, slug: 'showcase', title: 'Showcase', complexity: 'Mixed' }, + { order: 20, slug: 'ui', title: 'UI', complexity: 'Medium' }, ]; export const CHAPTER_BY_SLUG = new Map(CHAPTERS.map(chapter => [chapter.slug, chapter])); diff --git a/src/core/Application.ts b/src/core/Application.ts index 3fcf848f..952beb66 100644 --- a/src/core/Application.ts +++ b/src/core/Application.ts @@ -4,6 +4,7 @@ import type { Extension } from '#extensions/Extension'; import { getGlobalSnapshotInternal } from '#extensions/ExtensionRegistry'; import { materializeAssetBindings, materializeRendererBindings } from '#extensions/materialize'; import { buildSnapshot, type ExtensionSnapshot } from '#extensions/snapshot'; +import { FocusManager } from '#input/FocusManager'; import type { GamepadDefinition } from '#input/GamepadDefinitions'; import type { GamepadSlotStrategy } from '#input/InputManager'; import { InputManager } from '#input/InputManager'; @@ -217,6 +218,7 @@ export class Application { public readonly canvas: HTMLCanvasElement; public readonly loader: Loader; public readonly input: InputManager; + public readonly focus: FocusManager; public readonly interaction: InteractionManager; public readonly scene: SceneManager; /** Per-Application seedable RNG. Isolated from other Applications and from the global `rand()`. */ @@ -337,6 +339,7 @@ export class Application { this._backend = this.createBackend(this._backendType, this._snapshot); this._rendering = new RenderingContext(this._backend); this.input = new InputManager(this); + this.focus = new FocusManager(this); this.interaction = new InteractionManager(this); this.scene = new SceneManager(this); this.random = new Random(this.options.seed); @@ -773,6 +776,7 @@ export class Application { this.stop(); this.loader.destroy(); + this.focus.destroy(); this.systems.destroy(); this._backend.destroy(); this.scene.destroy(); diff --git a/src/core/Scene.ts b/src/core/Scene.ts index 21b27250..21ba223f 100644 --- a/src/core/Scene.ts +++ b/src/core/Scene.ts @@ -4,6 +4,7 @@ import { Container } from '#rendering/Container'; import type { RenderingContext } from '#rendering/RenderingContext'; import type { RenderNode } from '#rendering/RenderNode'; import type { Loader } from '#resources/Loader'; +import { UIRoot } from '#ui/UIRoot'; import type { Application } from './Application'; import { DisposalScope } from './DisposalScope'; @@ -217,6 +218,38 @@ export class Scene { this._systems?._tick(delta); } + private _ui: UIRoot | null = null; + + /** + * Scene-bound UI layer, rendered screen-fixed on top of the scene content + * (after {@link Scene.draw}). Lazily created and destroyed with the scene; + * add widgets via `this.ui.addChild(...)`. + * + * Unlike {@link Scene.root}, the UI layer **is** auto-rendered each frame — a + * first-class overlay that always sits above the world. Its children live in + * screen space (origin top-left, `0..width` × `0..height`); pointer and + * keyboard input route to them ahead of the world layer. + */ + public get ui(): UIRoot { + if (this._ui === null) { + this._ui = this._disposal.track(new UIRoot()); + + // If the scene is already active (its root carries a stage), bind the UI + // layer now; otherwise SceneManager attaches it when the scene activates. + if (this._root._getStage() !== null) { + this._app?.interaction.attachUIRoot(this._ui); + } + } + + return this._ui; + } + + /** @internal — the UI layer if materialized, else `null` (no lazy allocation). */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) + public _peekUI(): UIRoot | null { + return this._ui; + } + public get stackMode(): SceneStackMode { return this._stackMode; } diff --git a/src/core/SceneManager.ts b/src/core/SceneManager.ts index 184343f7..24171267 100644 --- a/src/core/SceneManager.ts +++ b/src/core/SceneManager.ts @@ -259,6 +259,9 @@ export class SceneManager { `[ExoJS] Scene.draw() returned a Promise. draw() must be synchronous — an async draw() produces incomplete frames and silently drops errors.`, ); } + + // Auto-render the scene's screen-fixed UI layer above its content. + scene._peekUI()?._render(this._app.rendering); } const transitionAlpha = this._getTransitionAlpha(); @@ -308,6 +311,13 @@ export class SceneManager { // Bind the scene's root to the app's interaction manager so its nodes // route picking/bounds notifications to this Application (no global). this._app.interaction.attachRoot(scene.root); + + // Bind the UI layer too, if it was materialized before activation. + const ui = scene._peekUI(); + + if (ui !== null) { + this._app.interaction.attachUIRoot(ui); + } } catch (error) { let cleanupError: unknown = null; @@ -341,6 +351,13 @@ export class SceneManager { private async _disposeScene(scene: Scene): Promise { this.onStopScene.dispatch(scene); await scene.unload(this._app.loader); + + const ui = scene._peekUI(); + + if (ui !== null) { + this._app.interaction.detachUIRoot(ui); + } + this._app.interaction.detachRoot(scene.root); scene.destroy(); scene.app = null; diff --git a/src/core/SceneNode.ts b/src/core/SceneNode.ts index c6e568db..0ce667eb 100644 --- a/src/core/SceneNode.ts +++ b/src/core/SceneNode.ts @@ -504,6 +504,11 @@ export class SceneNode implements Collidable, ObservableVectorOwner { this._stage = stage; } + /** @internal — the owning {@link Stage}, or `null` when this node is detached. */ + public _getStage(): Stage | null { + return this._stage; + } + /** * Routes a change from one of this node's reactive {@link ObservableVector} * components (position/scale/origin/anchor) to the matching dirty path. The diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 9462a1f8..f8079bc0 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -12,6 +12,22 @@ export interface InteractionHooks { _notifyBoundsInvalidated(node: RenderNode): void; } +/** + * Friend-class hooks a scene node uses to reach its owning keyboard-focus + * service. Implemented by `FocusManager`. Kept on the {@link Stage} so a node + * never needs a direct reference to the manager. + */ +export interface FocusHooks { + /** The node that currently holds keyboard focus, or `null`. */ + readonly focused: RenderNode | null; + /** Move keyboard focus to `node` (a no-op when `node.focusable` is `false`). */ + focus(node: RenderNode): void; + /** Clear focus, or only clear it when `node` currently holds it. */ + blur(node?: RenderNode): void; + /** @internal — drop focus when a focused node (or an ancestor) leaves the tree. */ + _notifyNodeRemoved(node: RenderNode): void; +} + /** * Per-Application service bundle that scene nodes reach through their owning * tree — set on attach via `SceneNode._setStage` and cleared on detach. @@ -24,4 +40,5 @@ export interface InteractionHooks { */ export interface Stage { readonly interaction: InteractionHooks; + readonly focus: FocusHooks; } diff --git a/src/index.ts b/src/index.ts index 5bc684be..5c9a9eed 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,3 +6,4 @@ export * from '#input/index'; export * from '#math/index'; export * from '#rendering/index'; export * from '#resources/index'; +export * from '#ui/index'; diff --git a/src/input/FocusManager.ts b/src/input/FocusManager.ts new file mode 100644 index 00000000..8f9867ce --- /dev/null +++ b/src/input/FocusManager.ts @@ -0,0 +1,205 @@ +import type { Application } from '#core/Application'; +import type { FocusHooks } from '#core/Stage'; +import { Container } from '#rendering/Container'; +import type { RenderNode } from '#rendering/RenderNode'; + +import { KeyEvent } from './KeyEvent'; +import { Keyboard } from './types'; + +/** + * Per-Application keyboard-focus service. Tracks the single focused + * {@link RenderNode}, routes keyboard input from the {@link InputManager} to + * it, and provides Tab-order traversal across the focusable nodes of the active + * focus scope. + * + * A node reaches this service through its {@link Stage} (`stage.focus`); app + * code reaches it through `app.focus`. Constructed automatically by + * {@link Application}; you do not instantiate this class yourself. + * + * Built-in key handling: `Tab` / `Shift+Tab` move focus to the next / previous + * focusable node. A focused node can call {@link KeyEvent.preventDefault} on its + * `onKeyDown` event to opt out of this and consume the key itself. + */ +export class FocusManager implements FocusHooks { + private readonly _app: Application; + private _focused: RenderNode | null = null; + private _shiftDown = false; + + // Stack of subtree roots that bound Tab traversal; a modal dialog pushes one. + private readonly _scopeStack: RenderNode[] = []; + + private readonly _onKeyDownHandler: (channel: number) => void; + private readonly _onKeyUpHandler: (channel: number) => void; + + public constructor(app: Application) { + this._app = app; + this._onKeyDownHandler = this._handleKeyDown.bind(this); + this._onKeyUpHandler = this._handleKeyUp.bind(this); + + app.input.onKeyDown.add(this._onKeyDownHandler); + app.input.onKeyUp.add(this._onKeyUpHandler); + } + + /** The node that currently holds keyboard focus, or `null`. */ + public get focused(): RenderNode | null { + return this._focused; + } + + /** + * Move keyboard focus to `node`. No-op when `node` is already focused or is + * not {@link RenderNode.focusable}. Fires `onBlur` on the previously focused + * node, then `onFocus` on `node`. + */ + public focus(node: RenderNode): void { + if (node === this._focused || !node.focusable) { + return; + } + + this.blur(); + this._focused = node; + node._peekFocusSignal('focus')?.dispatch(node); + } + + /** Clear focus, or only clear it when `node` currently holds it. Fires `onBlur`. */ + public blur(node?: RenderNode): void { + const previous = this._focused; + + if (previous === null || (node !== undefined && node !== previous)) { + return; + } + + this._focused = null; + previous._peekFocusSignal('blur')?.dispatch(previous); + } + + /** Push a subtree root that bounds subsequent Tab traversal (e.g. a modal dialog). */ + public pushScope(root: RenderNode): void { + this._scopeStack.push(root); + } + + /** Pop the most recently pushed focus scope. */ + public popScope(): void { + this._scopeStack.pop(); + } + + /** Move focus to the next focusable node in the active scope (Tab order). */ + public focusNext(): void { + this._step(1); + } + + /** Move focus to the previous focusable node in the active scope (Shift+Tab order). */ + public focusPrevious(): void { + this._step(-1); + } + + /** @internal — clear focus when a focused node (or an ancestor of it) leaves the tree. */ + public _notifyNodeRemoved(node: RenderNode): void { + let current: RenderNode | null = this._focused; + + while (current !== null) { + if (current === node) { + this.blur(); + + return; + } + + current = current.parent; + } + } + + public destroy(): void { + this._app.input.onKeyDown.remove(this._onKeyDownHandler); + this._app.input.onKeyUp.remove(this._onKeyUpHandler); + this._scopeStack.length = 0; + this._focused = null; + } + + private _handleKeyDown(channel: number): void { + if (channel === Keyboard.Shift) { + this._shiftDown = true; + } + + const focused = this._focused; + let defaultPrevented = false; + + if (focused !== null) { + const event = new KeyEvent('keydown', channel, focused); + + focused._peekKeySignal('keydown')?.dispatch(event); + defaultPrevented = event.defaultPrevented; + } + + if (!defaultPrevented && channel === Keyboard.Tab) { + if (this._shiftDown) { + this.focusPrevious(); + } else { + this.focusNext(); + } + } + } + + private _handleKeyUp(channel: number): void { + if (channel === Keyboard.Shift) { + this._shiftDown = false; + } + + const focused = this._focused; + + if (focused !== null) { + focused._peekKeySignal('keyup')?.dispatch(new KeyEvent('keyup', channel, focused)); + } + } + + /** Advance focus by `direction` (+1 next, -1 previous), wrapping around the scope. */ + private _step(direction: 1 | -1): void { + const focusables = this._collectFocusables(); + + if (focusables.length === 0) { + return; + } + + const currentIndex = this._focused === null ? -1 : focusables.indexOf(this._focused); + const count = focusables.length; + const nextIndex = currentIndex === -1 ? (direction === 1 ? 0 : count - 1) : (currentIndex + direction + count) % count; + + this.focus(focusables[nextIndex]); + } + + /** + * Collect the focusable nodes of the active scope (the topmost pushed scope, + * else the active scene root) in Tab order: ascending `tabIndex`, ties broken + * by document (tree) order. + */ + private _collectFocusables(): RenderNode[] { + const root: RenderNode | null = this._scopeStack.at(-1) ?? this._app.scene.currentScene?.root ?? null; + + if (root === null) { + return []; + } + + const collected: RenderNode[] = []; + + this._collectInto(root, collected); + + return collected + .map((node, index) => ({ node, index })) + .sort((a, b) => a.node.tabIndex - b.node.tabIndex || a.index - b.index) + .map(entry => entry.node); + } + + private _collectInto(node: RenderNode, out: RenderNode[]): void { + if (!node.visible) { + return; + } + + if (node.focusable) { + out.push(node); + } + + if (node instanceof Container) { + for (const child of node.children) { + this._collectInto(child, out); + } + } + } +} diff --git a/src/input/InteractionManager.ts b/src/input/InteractionManager.ts index b537d973..29f6a960 100644 --- a/src/input/InteractionManager.ts +++ b/src/input/InteractionManager.ts @@ -2,6 +2,7 @@ import type { Application } from '#core/Application'; import type { Signal } from '#core/Signal'; import type { InteractionHooks, Stage } from '#core/Stage'; import type { System } from '#core/System'; +import type { PointLike } from '#math/PointLike'; import type { QuadtreeItem } from '#math/Quadtree'; import { Quadtree } from '#math/Quadtree'; import { Rectangle } from '#math/Rectangle'; @@ -89,7 +90,23 @@ export class InteractionManager implements InteractionHooks, System { private readonly _quadtreeQueryBuffer: Array> = []; /** This manager's service bundle, installed on a scene root via {@link attachRoot}. */ - private readonly _stage: Stage = { interaction: this }; + private readonly _stage: Stage; + + /** + * UI-layer interaction hooks: no-ops, so screen-fixed UI nodes are kept OUT + * of the world quadtree. The UI layer is hit-tested by a direct subtree walk + * in screen space (see {@link _resolveHit}); per-node signal dispatch still + * works because it reads the lazy node signals, not the quadtree. + */ + private readonly _uiInteraction: InteractionHooks = { + _notifyNodeAdded: () => {}, + _notifyNodeRemoved: () => {}, + _notifyInteractiveChanged: () => {}, + _notifyBoundsInvalidated: () => {}, + }; + + /** Service bundle installed on a scene's UI layer; shares focus with the world stage. */ + private readonly _uiStage: Stage; /** Maps pointerId → the deepest interactive RenderNode that pointer is currently over. */ private readonly _lastHit = new Map(); @@ -103,6 +120,12 @@ export class InteractionManager implements InteractionHooks, System { /** Active drag states. Maps pointerId → drag metadata. */ private readonly _drags = new Map(); + /** + * Modal input-capture stack. While non-empty, hit-testing is confined to the + * topmost root's subtree, so a modal dialog shields the nodes beneath it. + */ + private readonly _captureStack: RenderNode[] = []; + /** Whether any pointer enqueued events since the last update(). */ private _dirty = false; @@ -115,6 +138,8 @@ export class InteractionManager implements InteractionHooks, System { public constructor(app: Application) { this._app = app; + this._stage = { interaction: this, focus: app.focus }; + this._uiStage = { interaction: this._uiInteraction, focus: app.focus }; this._onPointerDownHandler = this._handlePointerDown.bind(this); this._onPointerMoveHandler = this._handlePointerMove.bind(this); @@ -165,6 +190,22 @@ export class InteractionManager implements InteractionHooks, System { return [...this._capturedPointers.values()]; } + /** + * Confine pointer hit-testing to `root`'s subtree until a matching + * {@link popInputCapture}. Pointer events outside the subtree hit nothing, so + * a modal dialog (optionally with a full-screen backdrop to swallow clicks) + * shields the interactive nodes beneath it. Captures stack — the most + * recently pushed root wins. + */ + public pushInputCapture(root: RenderNode): void { + this._captureStack.push(root); + } + + /** Release the most recently pushed input capture (see {@link pushInputCapture}). */ + public popInputCapture(): void { + this._captureStack.pop(); + } + /** * Returns the internal quadtree used for spatial hit-testing, or null when * no interactive nodes are present. Used by {@link HitTestLayer} to render @@ -188,6 +229,7 @@ export class InteractionManager implements InteractionHooks, System { this._pending.clear(); this._capturedPointers.clear(); this._drags.clear(); + this._captureStack.length = 0; this._interactiveNodes.clear(); this._staleNodes.clear(); this._quadtreeItems.clear(); @@ -242,10 +284,29 @@ export class InteractionManager implements InteractionHooks, System { * @internal */ public detachRoot(root: Container): void { + this._app.focus.blur(); + this._captureStack.length = 0; this._notifyNodeRemoved(root); root._setStage(null); } + /** + * Bind a scene's UI layer to this manager. Installs the UI stage (no-op world + * hooks, shared focus) so its nodes route focus here but stay out of the world + * quadtree; the layer is hit-tested by a direct walk in screen space. + * @internal + */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) + public attachUIRoot(root: Container): void { + root._setStage(this._uiStage); + } + + /** Unbind a scene's UI layer. @internal */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) + public detachUIRoot(root: Container): void { + root._setStage(null); + } + // --------------------------------------------------------------------------- // Hooks called by RenderNode / Container / SceneNode // These are prefixed _ to signal "internal-but-public". @@ -356,18 +417,30 @@ export class InteractionManager implements InteractionHooks, System { const { pointer, events } = queue; const { id } = pointer; - // Pointer coordinates are in design space; convert to world space via the - // active camera so hit-testing, dragging and event coordinates all agree - // with node positions (correct under pixelRatio > 1, letterboxing, and a - // panned / zoomed / rotated camera). At the default centered camera this is - // the identity, so legacy screen-space behaviour is preserved. - const world = this._app.rendering.camera.screenToWorld(pointer.x, pointer.y); - const x = world.x; - const y = world.y; - - // Determine the hit node. Captured pointers short-circuit hit-testing. + // Resolve the hit node and the coordinate space it lives in. A captured + // pointer short-circuits hit-testing; otherwise the screen-fixed UI layer + // is tried before the camera-space world (see _resolveHit). Coordinates + // follow the hit's layer (UI = screen space, world = camera space) so + // dragging and event positions agree with node positions — correct under + // pixelRatio > 1, letterboxing, and a panned / zoomed / rotated camera. const captured = this._capturedPointers.get(id) ?? null; - const hit = captured !== null ? captured : this._hitTest(x, y); + let hit: RenderNode | null; + let x: number; + let y: number; + + if (captured !== null) { + const coords = this._pointerCoords(pointer, this._isUINode(captured)); + + hit = captured; + x = coords.x; + y = coords.y; + } else { + const resolved = this._resolveHit(pointer); + + hit = resolved.node; + x = resolved.x; + y = resolved.y; + } // --- Over / Out transitions --- // Skip while a drag is active for this pointer — the dragged node @@ -476,6 +549,65 @@ export class InteractionManager implements InteractionHooks, System { // Hit-testing // --------------------------------------------------------------------------- + /** + * Resolve the hit node and its coordinate space for a fresh pointer. An + * active modal capture confines hit-testing to its subtree; otherwise the + * screen-fixed UI layer is tried first (screen space), then the camera world. + */ + private _resolveHit(pointer: Pointer): { node: RenderNode | null; x: number; y: number } { + const capture = this._captureStack.at(-1); + + if (capture !== undefined) { + const coords = this._pointerCoords(pointer, this._isUINode(capture)); + + return { node: this._hitTestNode(capture, coords.x, coords.y), x: coords.x, y: coords.y }; + } + + const uiRoot = this._app.scene.currentScene?._peekUI() ?? null; + + if (uiRoot !== null) { + const ui = this._app.rendering.screenView.screenToWorld(pointer.x, pointer.y); + const uiHit = this._hitTestNode(uiRoot, ui.x, ui.y); + + if (uiHit !== null) { + return { node: uiHit, x: ui.x, y: ui.y }; + } + } + + const world = this._app.rendering.camera.screenToWorld(pointer.x, pointer.y); + + return { node: this._hitTest(world.x, world.y), x: world.x, y: world.y }; + } + + /** Map a design-space pointer into either the screen-fixed UI view or the camera world. */ + private _pointerCoords(pointer: Pointer, ui: boolean): PointLike { + const view = ui ? this._app.rendering.screenView : this._app.rendering.camera; + + return view.screenToWorld(pointer.x, pointer.y); + } + + /** Whether `node` lives inside the active scene's UI layer. */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) + private _isUINode(node: RenderNode): boolean { + const uiRoot = this._app.scene.currentScene?._peekUI() ?? null; + + if (uiRoot === null) { + return false; + } + + let current: RenderNode | null = node; + + while (current !== null) { + if (current === uiRoot) { + return true; + } + + current = current.parent; + } + + return false; + } + private _hitTest(x: number, y: number): RenderNode | null { if (this._quadtree !== null) { return this._hitTestIndexed(x, y); diff --git a/src/input/KeyEvent.ts b/src/input/KeyEvent.ts new file mode 100644 index 00000000..e29c52f4 --- /dev/null +++ b/src/input/KeyEvent.ts @@ -0,0 +1,37 @@ +import type { RenderNode } from '#rendering/RenderNode'; + +/** Keyboard phase delivered to a focused node. */ +export type KeyEventType = 'keydown' | 'keyup'; + +/** + * Envelope dispatched by {@link FocusManager} to the focused {@link RenderNode} + * for keyboard input. `channel` is the input channel of the key — compare it + * with the `Keyboard` constants (e.g. `event.channel === Keyboard.Enter`). + * + * A handler may call {@link KeyEvent.preventDefault} to suppress the + * FocusManager's built-in handling for this key (currently `Tab` focus + * traversal), letting the focused widget consume the key itself. + */ +export class KeyEvent { + public readonly type: KeyEventType; + /** Input channel of the key — compare with the `Keyboard.*` channel constants. */ + public readonly channel: number; + /** The focused node this event was delivered to. */ + public readonly target: RenderNode; + private _defaultPrevented = false; + + public constructor(type: KeyEventType, channel: number, target: RenderNode) { + this.type = type; + this.channel = channel; + this.target = target; + } + + public get defaultPrevented(): boolean { + return this._defaultPrevented; + } + + /** Suppress the FocusManager's built-in handling (e.g. Tab traversal) for this key. */ + public preventDefault(): void { + this._defaultPrevented = true; + } +} diff --git a/src/input/index.ts b/src/input/index.ts index 239baaa1..3bac8d7f 100644 --- a/src/input/index.ts +++ b/src/input/index.ts @@ -1,4 +1,5 @@ export * from './ArcadeStickGamepadMapping'; +export * from './FocusManager'; export * from './GameCubeGamepadMapping'; export * from './Gamepad'; export type { GamepadAxisOptions } from './GamepadAxis'; @@ -15,6 +16,7 @@ export * from './InteractionEvent'; export * from './InteractionManager'; export * from './JoyConLeftGamepadMapping'; export * from './JoyConRightGamepadMapping'; +export * from './KeyEvent'; export * from './PlayStationGamepadMapping'; export { Pointer, PointerState, PointerStateFlag } from './Pointer'; export * from './SteamControllerGamepadMapping'; diff --git a/src/math/geometry.ts b/src/math/geometry.ts index 657d6f68..7f1f864d 100644 --- a/src/math/geometry.ts +++ b/src/math/geometry.ts @@ -349,6 +349,83 @@ export const buildRectangle = (x: number, y: number, width: number, height: numb return { vertices, indices, points }; }; +/** + * Build a filled axis-aligned rounded rectangle as a triangle-list mesh. The + * corner `radius` is clamped to half the smaller side; a clamped radius of `0` + * falls back to {@link buildRectangle}. Each corner is a quarter-circle arc + * whose segment count scales with the radius (like {@link buildCircle}), and + * the shape is fan-triangulated from its center. The returned `points` form a + * closed outline (the first point is repeated) so stroking yields a seamless + * border. + */ +export const buildRoundedRectangle = (x: number, y: number, width: number, height: number, radius: number): MeshGeometryData => { + const r = Math.min(Math.abs(radius), width / 2, height / 2); + + if (r <= 0) { + return buildRectangle(x, y, width, height); + } + + // Segments per 90° corner, scaled like buildCircle's full-circle count. + const cornerSegments = Math.max(1, Math.floor((15 * Math.sqrt(r + r)) / 4)); + const step = Math.PI / 2 / cornerSegments; + + // Arc center + start angle per corner, clockwise (y-down): TL, TR, BR, BL. + const corners = [ + [x + r, y + r, Math.PI], // top-left: 180° -> 270° + [x + width - r, y + r, Math.PI * 1.5], // top-right: 270° -> 360° + [x + width - r, y + height - r, 0], // bottom-right: 0° -> 90° + [x + r, y + height - r, Math.PI * 0.5], // bottom-left: 90° -> 180° + ]; + + const perimeter: number[] = []; + + for (const [centerX, centerY, startAngle] of corners) { + for (let i = 0; i <= cornerSegments; i++) { + const angle = startAngle + step * i; + const px = centerX + Math.cos(angle) * r; + const py = centerY + Math.sin(angle) * r; + const last = perimeter.length; + + // Skip a point coincident with the previous one (a side of zero straight + // length, i.e. radius == half-side, makes adjacent arcs meet). + if (last >= 2 && Math.abs(perimeter[last - 2] - px) < 1e-4 && Math.abs(perimeter[last - 1] - py) < 1e-4) { + continue; + } + + perimeter.push(px, py); + } + } + + const perimeterCount = perimeter.length / 2; + const vertices = new Float32Array((perimeterCount + 1) * 2); + + // 1 center vertex + N perimeter vertices. + vertices[0] = x + width / 2; + vertices[1] = y + height / 2; + + for (let i = 0; i < perimeterCount; i++) { + const offset = (i + 1) * 2; + + vertices[offset] = perimeter[i * 2]; + vertices[offset + 1] = perimeter[i * 2 + 1]; + } + + const indices = new Uint16Array(perimeterCount * 3); + + for (let i = 0; i < perimeterCount; i++) { + const base = i * 3; + + indices[base] = 0; + indices[base + 1] = i + 1; + indices[base + 2] = i + 2 > perimeterCount ? 1 : i + 2; + } + + // Closed outline (repeat the first point) so the stroke pass seals the border. + const points = [...perimeter, perimeter[0], perimeter[1]]; + + return { vertices, indices, points }; +}; + /** * Build a filled star polygon as a triangle-list mesh. `points` is the number * of outer tips (e.g. `5` for a five-pointed star). `innerRadius` defaults to @@ -390,5 +467,6 @@ export const MeshBuilder = { ellipse: buildEllipse, polygon: buildPolygon, rectangle: buildRectangle, + roundedRectangle: buildRoundedRectangle, star: buildStar, } as const; diff --git a/src/rendering/Container.ts b/src/rendering/Container.ts index 7646512d..3711e241 100644 --- a/src/rendering/Container.ts +++ b/src/rendering/Container.ts @@ -170,6 +170,7 @@ export class Container extends RenderNode { child.parent = null; child._invalidateSubtreeTransform(); this._stage?.interaction._notifyNodeRemoved(child); + this._stage?.focus._notifyNodeRemoved(child); child._setStage(null); } @@ -201,6 +202,7 @@ export class Container extends RenderNode { child.parent = null; child._invalidateSubtreeTransform(); this._stage?.interaction._notifyNodeRemoved(child); + this._stage?.focus._notifyNodeRemoved(child); child._setStage(null); } } diff --git a/src/rendering/RenderNode.ts b/src/rendering/RenderNode.ts index 268bb1d6..e99272ae 100644 --- a/src/rendering/RenderNode.ts +++ b/src/rendering/RenderNode.ts @@ -2,6 +2,7 @@ import { Color } from '#core/Color'; import { SceneNode } from '#core/SceneNode'; import { Signal } from '#core/Signal'; import type { InteractionEvent, InteractionEventType } from '#input/InteractionEvent'; +import type { KeyEvent } from '#input/KeyEvent'; import { Rectangle } from '#math/Rectangle'; import type { Filter } from '#rendering/filters/Filter'; import type { Geometry } from '#rendering/geometry/Geometry'; @@ -209,6 +210,76 @@ export abstract class RenderNode extends SceneNode { return this._signals?.get(type) ?? null; } + // Focus & keyboard. Like the interaction signals these are lazily + // materialized — a node that never participates in focus allocates none. + // Routed by FocusManager (app.focus / stage.focus) to the focused node. + + /** + * When `true`, this node can receive keyboard focus — via {@link focus}, + * Tab traversal, or `app.focus.focus(node)` — and is delivered key events + * through {@link onKeyDown} / {@link onKeyUp} while focused. + * + * @default false + */ + public focusable = false; + + /** + * Tab-traversal order among focusable nodes in the same focus scope. Lower + * values are visited first; equal values keep document (tree) order. + * + * @default 0 + */ + public tabIndex = 0; + + private _onFocus: Signal<[RenderNode]> | null = null; + private _onBlur: Signal<[RenderNode]> | null = null; + private _onKeyDown: Signal<[KeyEvent]> | null = null; + private _onKeyUp: Signal<[KeyEvent]> | null = null; + + /** Fired when this node gains keyboard focus. */ + public get onFocus(): Signal<[RenderNode]> { + return (this._onFocus ??= new Signal<[RenderNode]>()); + } + + /** Fired when this node loses keyboard focus. */ + public get onBlur(): Signal<[RenderNode]> { + return (this._onBlur ??= new Signal<[RenderNode]>()); + } + + /** Fired for each key pressed while this node holds focus. */ + public get onKeyDown(): Signal<[KeyEvent]> { + return (this._onKeyDown ??= new Signal<[KeyEvent]>()); + } + + /** Fired for each key released while this node holds focus. */ + public get onKeyUp(): Signal<[KeyEvent]> { + return (this._onKeyUp ??= new Signal<[KeyEvent]>()); + } + + /** @internal — the focus/blur signal if materialized, else `null` (dispatch peek). */ + public _peekFocusSignal(type: 'focus' | 'blur'): Signal<[RenderNode]> | null { + return type === 'focus' ? this._onFocus : this._onBlur; + } + + /** @internal — the keydown/keyup signal if materialized, else `null` (dispatch peek). */ + public _peekKeySignal(type: 'keydown' | 'keyup'): Signal<[KeyEvent]> | null { + return type === 'keydown' ? this._onKeyDown : this._onKeyUp; + } + + /** Request keyboard focus for this node through its owning focus service. */ + public focus(): this { + this._stage?.focus.focus(this); + + return this; + } + + /** Release keyboard focus from this node if it currently holds it. */ + public blur(): this { + this._stage?.focus.blur(this); + + return this; + } + private readonly _filters: Filter[] = []; private readonly _cacheBounds: Rectangle = new Rectangle(); private _cacheSprite: RenderNodeSpriteLike | null = null; @@ -441,6 +512,12 @@ export abstract class RenderNode extends SceneNode { this._signals.clear(); this._signals = null; } + + this._onFocus?.destroy(); + this._onBlur?.destroy(); + this._onKeyDown?.destroy(); + this._onKeyUp?.destroy(); + this._onFocus = this._onBlur = this._onKeyDown = this._onKeyUp = null; } private _renderContentToTexture( diff --git a/src/rendering/primitives/Graphics.ts b/src/rendering/primitives/Graphics.ts index c0471ff1..7f570be8 100644 --- a/src/rendering/primitives/Graphics.ts +++ b/src/rendering/primitives/Graphics.ts @@ -1,6 +1,6 @@ import { Color } from '#core/Color'; import type { MeshGeometryData } from '#math/geometry'; -import { buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildStar } from '#math/geometry'; +import { buildCircle, buildEllipse, buildLine, buildPath, buildPolygon, buildRectangle, buildRoundedRectangle, buildStar } from '#math/geometry'; import { bezierCurveTo, clamp, quadraticCurveTo, tau } from '#math/utils'; import { Vector } from '#math/Vector'; import { Container } from '#rendering/Container'; @@ -346,6 +346,23 @@ export class Graphics extends Container { return this; } + /** + * Fill a rounded rectangle and optionally stroke its outline if + * `lineWidth > 0`. The corner `radius` is clamped to half the smaller side; a + * radius of `0` is equivalent to {@link drawRectangle}. + */ + public drawRoundedRectangle(x: number, y: number, width: number, height: number, radius: number): this { + const data = buildRoundedRectangle(x, y, width, height, radius); + + this.addChild(this._createFillMesh(data)); + + if (this._lineWidth > 0) { + this.drawPath(data.points); + } + + return this; + } + /** * Fill a regular star polygon and optionally stroke its outline. * `innerRadius` defaults to half of `radius`. diff --git a/src/ui/Button.ts b/src/ui/Button.ts new file mode 100644 index 00000000..788c300d --- /dev/null +++ b/src/ui/Button.ts @@ -0,0 +1,161 @@ +import { Color } from '#core/Color'; +import { Signal } from '#core/Signal'; +import type { KeyEvent } from '#input/KeyEvent'; +import { Keyboard } from '#input/types'; +import { Graphics } from '#rendering/primitives/Graphics'; +import { Text } from '#rendering/text/Text'; + +import { Widget } from './Widget'; + +export interface ButtonOptions { + width?: number; + height?: number; + label?: string; + cornerRadius?: number; + /** Fill color in the normal state. */ + color?: Color; + hoverColor?: Color; + pressedColor?: Color; + disabledColor?: Color; + textColor?: Color; + fontSize?: number; +} + +type ButtonState = 'normal' | 'hover' | 'pressed' | 'disabled'; + +/** + * Clickable button with a rounded background, a centered label, hover/pressed + * visual states, and keyboard activation (Enter / Space while focused). + * Listen to {@link Button.onClick} for activation. + */ +export class Button extends Widget { + /** Fires when the button is activated by click, tap, or Enter/Space. */ + public readonly onClick = new Signal<[Button]>(); + + private readonly _background = new Graphics(); + private readonly _label: Text; + private readonly _colors: Record; + private readonly _cornerRadius: number; + private _state: ButtonState = 'normal'; + private _pointerInside = false; + + public constructor(options: ButtonOptions = {}) { + super(); + + this._colors = { + normal: (options.color ?? new Color(54, 120, 220, 1)).clone(), + hover: (options.hoverColor ?? new Color(74, 140, 240, 1)).clone(), + pressed: (options.pressedColor ?? new Color(40, 96, 180, 1)).clone(), + disabled: (options.disabledColor ?? new Color(70, 76, 90, 1)).clone(), + }; + this._cornerRadius = options.cornerRadius ?? 8; + this._label = new Text(options.label ?? '', { + fillColor: options.textColor ?? Color.white, + fontSize: options.fontSize ?? 16, + align: 'center', + }); + + this.addChild(this._background); + this.addChild(this._label); + + this.interactive = true; + this.focusable = true; + this.cursor = 'pointer'; + + this.onPointerOver.add(this._onPointerOver); + this.onPointerOut.add(this._onPointerOut); + this.onPointerDown.add(this._onPointerDown); + this.onPointerUp.add(this._onPointerUp); + this.onPointerTap.add(this._activate); + this.onKeyDown.add(this._onKey); + + this.setSize(options.width ?? 120, options.height ?? 40); + } + + public get label(): string { + return this._label.text; + } + + public set label(value: string) { + this._label.text = value; + this._positionLabel(); + } + + private readonly _onPointerOver = (): void => { + this._pointerInside = true; + this._refreshState(); + }; + + private readonly _onPointerOut = (): void => { + this._pointerInside = false; + this._refreshState(); + }; + + private readonly _onPointerDown = (): void => { + if (this.enabled) { + this._state = 'pressed'; + this._draw(); + } + }; + + private readonly _onPointerUp = (): void => { + this._refreshState(); + }; + + private readonly _activate = (): void => { + if (this.enabled) { + this.onClick.dispatch(this); + } + }; + + private readonly _onKey = (event: KeyEvent): void => { + const channel = event.channel as Keyboard; + + if (this.enabled && (channel === Keyboard.Enter || channel === Keyboard.Space)) { + event.preventDefault(); + this.onClick.dispatch(this); + } + }; + + private _refreshState(): void { + let state: ButtonState = 'normal'; + + if (!this.enabled) { + state = 'disabled'; + } else if (this._pointerInside) { + state = 'hover'; + } + + this._state = state; + this._draw(); + } + + protected override _onEnabledChanged(enabled: boolean): void { + this.interactive = enabled; + this._refreshState(); + } + + protected override _relayout(): void { + this._draw(); + this._positionLabel(); + } + + private _draw(): void { + const g = this._background; + + g.clear(); + + if (this._uiWidth <= 0 || this._uiHeight <= 0) { + return; + } + + g.fillColor = this._colors[this._state]; + g.drawRoundedRectangle(0, 0, this._uiWidth, this._uiHeight, this._cornerRadius); + } + + private _positionLabel(): void { + const bounds = this._label.getLocalBounds(); + + this._label.setPosition((this._uiWidth - bounds.width) / 2, (this._uiHeight - bounds.height) / 2); + } +} diff --git a/src/ui/Label.ts b/src/ui/Label.ts new file mode 100644 index 00000000..d0fce8de --- /dev/null +++ b/src/ui/Label.ts @@ -0,0 +1,43 @@ +import { Color } from '#core/Color'; +import { Text } from '#rendering/text/Text'; +import type { TextStyleOptions } from '#rendering/text/TextStyle'; + +import { Widget } from './Widget'; + +/** + * Text label widget. Wraps a {@link Text} node and keeps the widget's layout + * size in sync with the measured text, so it anchors and stacks correctly. + */ +export class Label extends Widget { + private readonly _text: Text; + + public constructor(text = '', style: TextStyleOptions = {}) { + super(); + + this._text = new Text(text, { fillColor: Color.white, fontSize: 16, ...style }); + this.addChild(this._text); + this._syncSize(); + } + + public get text(): string { + return this._text.text; + } + + public set text(value: string) { + if (this._text.text !== value) { + this._text.text = value; + this._syncSize(); + } + } + + /** The underlying {@link Text} node, for advanced styling. */ + public get textNode(): Text { + return this._text; + } + + private _syncSize(): void { + const bounds = this._text.getLocalBounds(); + + this.setSize(bounds.width, bounds.height); + } +} diff --git a/src/ui/Panel.ts b/src/ui/Panel.ts new file mode 100644 index 00000000..a83723e7 --- /dev/null +++ b/src/ui/Panel.ts @@ -0,0 +1,63 @@ +import { Color } from '#core/Color'; +import { Graphics } from '#rendering/primitives/Graphics'; + +import { Widget } from './Widget'; + +export interface PanelOptions { + width?: number; + height?: number; + /** Fill color. Default: a translucent dark slate. */ + color?: Color; + /** Border color (only drawn when `borderWidth > 0`). */ + borderColor?: Color; + borderWidth?: number; + cornerRadius?: number; +} + +/** + * Rectangular background container with rounded corners and an optional border. + * The base building block for HUD boxes, dialogs, and menus — add content with + * `panel.addChild(...)`. + */ +export class Panel extends Widget { + private readonly _background = new Graphics(); + private readonly _color: Color; + private readonly _borderColor: Color; + private readonly _borderWidth: number; + private readonly _cornerRadius: number; + + public constructor(options: PanelOptions = {}) { + super(); + + this._color = (options.color ?? new Color(30, 34, 45, 0.92)).clone(); + this._borderColor = (options.borderColor ?? new Color(255, 255, 255, 0.12)).clone(); + this._borderWidth = options.borderWidth ?? 0; + this._cornerRadius = options.cornerRadius ?? 8; + + this.addChild(this._background); + this.setSize(options.width ?? 0, options.height ?? 0); + } + + /** The background {@link Graphics}, for advanced customization. */ + public get background(): Graphics { + return this._background; + } + + protected override _relayout(): void { + const g = this._background; + + g.clear(); + + if (this._uiWidth <= 0 || this._uiHeight <= 0) { + return; + } + + if (this._borderWidth > 0) { + g.lineWidth = this._borderWidth; + g.lineColor = this._borderColor; + } + + g.fillColor = this._color; + g.drawRoundedRectangle(0, 0, this._uiWidth, this._uiHeight, this._cornerRadius); + } +} diff --git a/src/ui/ProgressBar.ts b/src/ui/ProgressBar.ts new file mode 100644 index 00000000..89bd29de --- /dev/null +++ b/src/ui/ProgressBar.ts @@ -0,0 +1,88 @@ +import { Color } from '#core/Color'; +import { clamp } from '#math/utils'; +import { Graphics } from '#rendering/primitives/Graphics'; + +import { Widget } from './Widget'; + +export interface ProgressBarOptions { + width?: number; + height?: number; + /** Initial fill fraction in `[0, 1]`. */ + value?: number; + trackColor?: Color; + fillColor?: Color; + cornerRadius?: number; +} + +/** + * Horizontal progress / health bar. {@link ProgressBar.value} is the fill + * fraction in `[0, 1]`; setting it redraws only the fill. + */ +export class ProgressBar extends Widget { + private readonly _track = new Graphics(); + private readonly _fill = new Graphics(); + private readonly _trackColor: Color; + private readonly _fillColor: Color; + private readonly _cornerRadius: number; + private _value: number; + + public constructor(options: ProgressBarOptions = {}) { + super(); + + this._value = clamp(options.value ?? 0, 0, 1); + this._trackColor = (options.trackColor ?? new Color(255, 255, 255, 0.16)).clone(); + this._fillColor = (options.fillColor ?? new Color(80, 220, 120, 1)).clone(); + this._cornerRadius = options.cornerRadius ?? 4; + + this.addChild(this._track); + this.addChild(this._fill); + this.setSize(options.width ?? 200, options.height ?? 12); + } + + /** Fill fraction in `[0, 1]`. */ + public get value(): number { + return this._value; + } + + public set value(value: number) { + const next = clamp(value, 0, 1); + + if (this._value !== next) { + this._value = next; + this._drawFill(); + } + } + + protected override _relayout(): void { + this._drawTrack(); + this._drawFill(); + } + + private _drawTrack(): void { + const g = this._track; + + g.clear(); + + if (this._uiWidth <= 0 || this._uiHeight <= 0) { + return; + } + + g.fillColor = this._trackColor; + g.drawRoundedRectangle(0, 0, this._uiWidth, this._uiHeight, this._cornerRadius); + } + + private _drawFill(): void { + const g = this._fill; + + g.clear(); + + const width = this._uiWidth * this._value; + + if (width <= 0 || this._uiHeight <= 0) { + return; + } + + g.fillColor = this._fillColor; + g.drawRoundedRectangle(0, 0, width, this._uiHeight, Math.min(this._cornerRadius, width / 2)); + } +} diff --git a/src/ui/Stack.ts b/src/ui/Stack.ts new file mode 100644 index 00000000..798b708e --- /dev/null +++ b/src/ui/Stack.ts @@ -0,0 +1,78 @@ +import type { RenderNode } from '#rendering/RenderNode'; + +import { Widget } from './Widget'; + +export type StackDirection = 'row' | 'column'; + +export interface StackOptions { + /** Flow direction. Default `'column'`. */ + direction?: StackDirection; + /** Gap between items in pixels. Default `8`. */ + spacing?: number; + /** Inner padding around all items in pixels. Default `0`. */ + padding?: number; +} + +/** + * Linear layout container that flows its children in a row or column with even + * spacing and optional padding, then sizes itself to fit. Call + * {@link Stack.layout} after mutating children added with `addChild`, or use + * {@link Stack.addItem} to add and re-flow in one step. + */ +export class Stack extends Widget { + private readonly _direction: StackDirection; + private readonly _spacing: number; + private readonly _padding: number; + + public constructor(options: StackOptions = {}) { + super(); + + this._direction = options.direction ?? 'column'; + this._spacing = options.spacing ?? 8; + this._padding = options.padding ?? 0; + } + + /** Add a child and re-flow the stack. */ + public addItem(child: RenderNode): this { + this.addChild(child); + + return this.layout(); + } + + /** Re-flow children along the stack direction and resize to fit them. */ + public layout(): this { + const isRow = this._direction === 'row'; + let main = this._padding; + let crossMax = 0; + let first = true; + + for (const child of this.children) { + // Prefer a widget's explicit layout size; fall back to drawn bounds. + const width = child instanceof Widget ? child.uiWidth : child.getLocalBounds().width; + const height = child instanceof Widget ? child.uiHeight : child.getLocalBounds().height; + + if (!first) { + main += this._spacing; + } + + first = false; + + if (isRow) { + child.setPosition(main, this._padding); + main += width; + crossMax = Math.max(crossMax, height); + } else { + child.setPosition(this._padding, main); + main += height; + crossMax = Math.max(crossMax, width); + } + } + + const along = main + this._padding; + const cross = crossMax + this._padding * 2; + + this.setSize(isRow ? along : cross, isRow ? cross : along); + + return this; + } +} diff --git a/src/ui/UIRoot.ts b/src/ui/UIRoot.ts new file mode 100644 index 00000000..3d38aa4c --- /dev/null +++ b/src/ui/UIRoot.ts @@ -0,0 +1,54 @@ +import { Signal } from '#core/Signal'; +import { Container } from '#rendering/Container'; +import type { RenderingContext } from '#rendering/RenderingContext'; + +/** + * Root of a scene's screen-fixed UI layer. Reached through {@link Scene.ui}; + * you do not construct it directly. + * + * Unlike {@link Scene.root}, the UI layer is **auto-rendered** by the + * {@link SceneManager} after `Scene.draw()`, through the + * {@link RenderingContext.screenView} — so its children live in screen space + * (origin top-left, `0..width` × `0..height`) and never scroll with the + * camera. Pointer hit-testing and keyboard focus are routed to UI nodes in that + * same screen space, ahead of the world layer. + * + * Add widgets with `scene.ui.addChild(...)`. The {@link UIRoot.onResize} signal + * fires whenever the screen size changes, so anchored widgets can re-layout. + */ +// eslint-disable-next-line @typescript-eslint/naming-convention -- UI is an acronym (cf. HTMLText) +export class UIRoot extends Container { + /** Fires with `(width, height)` whenever the screen size changes. */ + public readonly onResize = new Signal<[width: number, height: number]>(); + + private _screenWidth = 0; + private _screenHeight = 0; + + /** Screen width the UI is laid out against, in logical pixels. */ + public get screenWidth(): number { + return this._screenWidth; + } + + /** Screen height the UI is laid out against, in logical pixels. */ + public get screenHeight(): number { + return this._screenHeight; + } + + /** @internal — render this UI layer screen-fixed, above the scene content. */ + public _render(context: RenderingContext): void { + const view = context.screenView; + + if (this._screenWidth !== view.width || this._screenHeight !== view.height) { + this._screenWidth = view.width; + this._screenHeight = view.height; + this.onResize.dispatch(view.width, view.height); + } + + context.render(this, { view }); + } + + public override destroy(): void { + this.onResize.destroy(); + super.destroy(); + } +} diff --git a/src/ui/Widget.ts b/src/ui/Widget.ts new file mode 100644 index 00000000..e572d983 --- /dev/null +++ b/src/ui/Widget.ts @@ -0,0 +1,135 @@ +import { Container } from '#rendering/Container'; + +import type { UIRoot } from './UIRoot'; + +/** Anchor position of a widget within its container's box. */ +export type WidgetAnchor = 'top-left' | 'top' | 'top-right' | 'left' | 'center' | 'right' | 'bottom-left' | 'bottom' | 'bottom-right'; + +/** Normalized (0..1) horizontal/vertical factors for an anchor. */ +const anchorFactors = (anchor: WidgetAnchor): readonly [number, number] => { + let x = 0.5; + let y = 0.5; + + if (anchor.endsWith('left')) { + x = 0; + } else if (anchor.endsWith('right')) { + x = 1; + } + + if (anchor.startsWith('top')) { + y = 0; + } else if (anchor.startsWith('bottom')) { + y = 1; + } + + return [x, y]; +}; + +/** + * Base class for UI widgets — a {@link Container} with an explicit layout size + * (independent of child bounds / scale), an `enabled` flag, and optional + * screen-edge anchoring that re-applies on resize. + * + * Subclasses redraw size-dependent content in {@link Widget._relayout} and + * react to enable/disable in {@link Widget._onEnabledChanged}. + */ +export abstract class Widget extends Container { + protected _uiWidth = 0; + protected _uiHeight = 0; + private _enabled = true; + private _uiAnchor: WidgetAnchor | null = null; + private _uiAnchorOffsetX = 0; + private _uiAnchorOffsetY = 0; + private _uiAnchorRoot: UIRoot | null = null; + private readonly _onAnchorResize = (width: number, height: number): void => { + this._applyAnchor(width, height); + }; + + /** Explicit layout width in pixels (not derived from children or scale). */ + public get uiWidth(): number { + return this._uiWidth; + } + + /** Explicit layout height in pixels (not derived from children or scale). */ + public get uiHeight(): number { + return this._uiHeight; + } + + /** Set the widget's layout size; triggers a redraw and re-anchors if anchored. */ + public setSize(width: number, height: number): this { + const w = Math.max(0, width); + const h = Math.max(0, height); + + if (this._uiWidth !== w || this._uiHeight !== h) { + this._uiWidth = w; + this._uiHeight = h; + this._relayout(); + + if (this._uiAnchorRoot !== null) { + this._applyAnchor(this._uiAnchorRoot.screenWidth, this._uiAnchorRoot.screenHeight); + } + } + + return this; + } + + /** Whether the widget responds to input. Disabled widgets typically dim and ignore clicks. */ + public get enabled(): boolean { + return this._enabled; + } + + public set enabled(value: boolean) { + if (this._enabled !== value) { + this._enabled = value; + this._onEnabledChanged(value); + } + } + + /** + * Anchor this widget within `root`'s screen box at `anchor`, offset by + * `(offsetX, offsetY)`. The position is recomputed whenever the screen + * resizes. E.g. `widget.anchorIn(scene.ui, 'bottom-right', -20, -20)` pins it + * to the bottom-right corner with a 20px margin. + */ + public anchorIn(root: UIRoot, anchor: WidgetAnchor, offsetX = 0, offsetY = 0): this { + this._uiAnchor = anchor; + this._uiAnchorOffsetX = offsetX; + this._uiAnchorOffsetY = offsetY; + + if (this._uiAnchorRoot !== root) { + this._uiAnchorRoot?.onResize.remove(this._onAnchorResize); + this._uiAnchorRoot = root; + root.onResize.add(this._onAnchorResize); + } + + this._applyAnchor(root.screenWidth, root.screenHeight); + + return this; + } + + protected _applyAnchor(containerWidth: number, containerHeight: number): void { + if (this._uiAnchor === null) { + return; + } + + const [ax, ay] = anchorFactors(this._uiAnchor); + + this.setPosition(ax * (containerWidth - this._uiWidth) + this._uiAnchorOffsetX, ay * (containerHeight - this._uiHeight) + this._uiAnchorOffsetY); + } + + /** Redraw size-dependent content (background, child positions). Override in subclasses. */ + protected _relayout(): void { + // Overridden by subclasses that draw a sized background. + } + + /** React to an enabled/disabled change. Override in subclasses. */ + protected _onEnabledChanged(_enabled: boolean): void { + // Overridden by interactive subclasses (e.g. Button dimming). + } + + public override destroy(): void { + this._uiAnchorRoot?.onResize.remove(this._onAnchorResize); + this._uiAnchorRoot = null; + super.destroy(); + } +} diff --git a/src/ui/index.ts b/src/ui/index.ts new file mode 100644 index 00000000..bea14b02 --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,7 @@ +export * from './Button'; +export * from './Label'; +export * from './Panel'; +export * from './ProgressBar'; +export * from './Stack'; +export * from './UIRoot'; +export * from './Widget'; diff --git a/test/core/__snapshots__/root-index-snapshot.test.ts.snap b/test/core/__snapshots__/root-index-snapshot.test.ts.snap index e10b0d30..9d9bb4eb 100644 --- a/test/core/__snapshots__/root-index-snapshot.test.ts.snap +++ b/test/core/__snapshots__/root-index-snapshot.test.ts.snap @@ -34,6 +34,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "BufferTypes", "BufferUsage", "BundleLoadError", + "Button", "CacheFirstStrategy", "CallbackRenderPass", "Camera", @@ -57,6 +58,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Envelope", "Filter", "Flags", + "FocusManager", "FontAsset", "FontFactory", "GameCubeGamepadMapping", @@ -88,7 +90,9 @@ exports[`root index export surface snapshot > sorted runtime export names match "Json", "JsonFactory", "JsonStore", + "KeyEvent", "Keyboard", + "Label", "Line", "LinearGradient", "Loader", @@ -106,6 +110,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "NineSliceSprite", "ObservableSize", "ObservableVector", + "Panel", "Perf", "PlayStationGamepadMapping", "Pointer", @@ -113,6 +118,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "PointerStateFlag", "PolarVector", "Polygon", + "ProgressBar", "Quadtree", "RadialGradient", "Random", @@ -149,6 +155,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "SpriteFlags", "SpriteMaterial", "Spritesheet", + "Stack", "SteamControllerGamepadMapping", "SteamDeckGamepadMapping", "SubtitleAsset", @@ -170,6 +177,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "Tween", "TweenManager", "TweenState", + "UIRoot", "Vector", "Video", "VideoFactory", @@ -196,6 +204,7 @@ exports[`root index export surface snapshot > sorted runtime export names match "WebGpuSpriteRenderer", "WebGpuStorageBuffer", "WebGpuTextRenderer", + "Widget", "WorkletEffect", "WrapModes", "XboxGamepadMapping", 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 f7e98044..7b1ac189 100644 --- a/test/core/__snapshots__/root-index-type-inventory.test.ts.snap +++ b/test/core/__snapshots__/root-index-type-inventory.test.ts.snap @@ -64,6 +64,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "BufferUsage: enum", "BuildInfo: interface", "BundleLoadError: class", + "Button: class", + "ButtonOptions: interface", "CacheFirstStrategy: class", "CacheRequest: interface", "CacheStore: interface", @@ -118,6 +120,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "FadeSceneTransition: interface", "Filter: class", "Flags: class", + "FocusManager: class", "FontAsset: class", "FontFactory: class", "FontFactoryOptions: interface", @@ -187,7 +190,10 @@ exports[`root index type-level export inventory > all exported symbols with kind "JsonFactory: class", "JsonStore: class", "JsonStoreOptions: interface", + "KeyEvent: class", + "KeyEventType: type alias", "Keyboard: enum", + "Label: class", "LayoutOptions: interface", "Line: class", "LineLike: interface", @@ -227,6 +233,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "ObservableSize: class", "ObservableVector: class", "OscillatorType: type alias", + "Panel: class", + "PanelOptions: interface", "Pausable: interface", "Perf: variable", "PixelSnapMode: type alias", @@ -242,6 +250,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "Polygon: class", "PolygonLike: interface", "PopSceneOptions: interface", + "ProgressBar: class", + "ProgressBarOptions: interface", "PushSceneOptions: interface", "Quadtree: class", "QuadtreeItem: interface", @@ -312,6 +322,9 @@ exports[`root index type-level export inventory > all exported symbols with kind "Spritesheet: class", "SpritesheetData: interface", "SpritesheetFrame: interface", + "Stack: class", + "StackDirection: type alias", + "StackOptions: interface", "SteamControllerGamepadMapping: class", "SteamDeckGamepadMapping: class", "StreamingLoadEvent: type alias", @@ -353,6 +366,7 @@ exports[`root index type-level export inventory > all exported symbols with kind "TweenUpdateCallback: type alias", "TypedArray: type alias", "TypedEnum: type alias", + "UIRoot: class", "UniformValue: type alias", "ValueOf: type alias", "Vector: class", @@ -392,6 +406,8 @@ exports[`root index type-level export inventory > all exported symbols with kind "WebGpuSpriteRenderer: class", "WebGpuStorageBuffer: class", "WebGpuTextRenderer: class", + "Widget: class", + "WidgetAnchor: type alias", "WorkletEffect: class", "WrapModes: enum", "XboxGamepadMapping: class", diff --git a/test/core/application-on-frame.test.ts b/test/core/application-on-frame.test.ts index 16eb6a37..5ad66756 100644 --- a/test/core/application-on-frame.test.ts +++ b/test/core/application-on-frame.test.ts @@ -80,6 +80,21 @@ const loadOnFrameHarness = async (): Promise => { return { update: vi.fn(), destroy: vi.fn(), onKeyDown, onCanvasFocusChange }; }), })); + vi.doMock('#input/FocusManager', () => ({ + FocusManager: vi.fn(function () { + return { + focused: null, + focus: vi.fn(), + blur: vi.fn(), + pushScope: vi.fn(), + popScope: vi.fn(), + focusNext: vi.fn(), + focusPrevious: vi.fn(), + _notifyNodeRemoved: vi.fn(), + destroy: vi.fn(), + }; + }), + })); vi.doMock('#input/InteractionManager', () => ({ InteractionManager: vi.fn(function () { return { diff --git a/test/core/application.test.ts b/test/core/application.test.ts index b83ad766..ced50197 100644 --- a/test/core/application.test.ts +++ b/test/core/application.test.ts @@ -139,6 +139,21 @@ const loadApplicationHarness = async ( vi.doMock('#input/InputManager', () => ({ InputManager: InputManagerMock, })); + vi.doMock('#input/FocusManager', () => ({ + FocusManager: vi.fn(function () { + return { + focused: null, + focus: vi.fn(), + blur: vi.fn(), + pushScope: vi.fn(), + popScope: vi.fn(), + focusNext: vi.fn(), + focusPrevious: vi.fn(), + _notifyNodeRemoved: vi.fn(), + destroy: vi.fn(), + }; + }), + })); vi.doMock('#input/InteractionManager', () => ({ InteractionManager: vi.fn(function () { return { diff --git a/test/core/focus-visibility.test.ts b/test/core/focus-visibility.test.ts index 7df3ccfc..9985c065 100644 --- a/test/core/focus-visibility.test.ts +++ b/test/core/focus-visibility.test.ts @@ -92,6 +92,21 @@ const loadHarness = async (): Promise => { return inputManagerMock; }), })); + vi.doMock('#input/FocusManager', () => ({ + FocusManager: vi.fn(function () { + return { + focused: null, + focus: vi.fn(), + blur: vi.fn(), + pushScope: vi.fn(), + popScope: vi.fn(), + focusNext: vi.fn(), + focusPrevious: vi.fn(), + _notifyNodeRemoved: vi.fn(), + destroy: vi.fn(), + }; + }), + })); vi.doMock('#input/InteractionManager', () => ({ InteractionManager: vi.fn(function () { return interactionMock; diff --git a/test/input/focus.test.ts b/test/input/focus.test.ts new file mode 100644 index 00000000..bde8b8dc --- /dev/null +++ b/test/input/focus.test.ts @@ -0,0 +1,291 @@ +import type { Application } from '#core/Application'; +import { Scene } from '#core/Scene'; +import { Signal } from '#core/Signal'; +import type { InteractionHooks, Stage } from '#core/Stage'; +import { FocusManager } from '#input/FocusManager'; +import type { InputManager } from '#input/InputManager'; +import type { KeyEvent } from '#input/KeyEvent'; +import { Keyboard } from '#input/types'; +import { Container } from '#rendering/Container'; + +const noopInteraction: InteractionHooks = { + _notifyNodeAdded() {}, + _notifyNodeRemoved() {}, + _notifyInteractiveChanged() {}, + _notifyBoundsInvalidated() {}, +}; + +/** Build a minimal Application mock wired to a real Scene root + a FocusManager. */ +const createFocusApp = (): { + scene: Scene; + focus: FocusManager; + onKeyDown: Signal<[number]>; + onKeyUp: Signal<[number]>; +} => { + const onKeyDown = new Signal<[number]>(); + const onKeyUp = new Signal<[number]>(); + const scene = new Scene(); + const app = { + input: { onKeyDown, onKeyUp } as unknown as InputManager, + scene: { + get currentScene(): Scene | null { + return scene; + }, + }, + } as unknown as Application; + const focus = new FocusManager(app); + const stage: Stage = { interaction: noopInteraction, focus }; + + scene.root._setStage(stage); + + return { scene, focus, onKeyDown, onKeyUp }; +}; + +const focusable = (tabIndex = 0): Container => { + const node = new Container(); + + node.focusable = true; + node.tabIndex = tabIndex; + + return node; +}; + +describe('FocusManager', () => { + test('focus sets the focused node and fires onFocus', () => { + const { scene, focus } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + + const onFocus = vi.fn(); + + node.onFocus.add(onFocus); + focus.focus(node); + + expect(focus.focused).toBe(node); + expect(onFocus).toHaveBeenCalledWith(node); + }); + + test('focus is a no-op for a non-focusable node', () => { + const { scene, focus } = createFocusApp(); + const node = new Container(); + + scene.root.addChild(node); + focus.focus(node); + + expect(focus.focused).toBeNull(); + }); + + test('moving focus blurs the previous node then focuses the new one', () => { + const { scene, focus } = createFocusApp(); + const a = focusable(); + const b = focusable(); + + scene.root.addChild(a).addChild(b); + + const order: string[] = []; + + a.onBlur.add(() => order.push('blur-a')); + b.onFocus.add(() => order.push('focus-b')); + + focus.focus(a); + focus.focus(b); + + expect(focus.focused).toBe(b); + expect(order).toEqual(['blur-a', 'focus-b']); + }); + + test('blur clears focus and fires onBlur', () => { + const { scene, focus } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + + const onBlur = vi.fn(); + + node.onBlur.add(onBlur); + focus.focus(node); + focus.blur(); + + expect(focus.focused).toBeNull(); + expect(onBlur).toHaveBeenCalledWith(node); + }); + + test('blur(node) only clears when that node currently holds focus', () => { + const { scene, focus } = createFocusApp(); + const a = focusable(); + const b = focusable(); + + scene.root.addChild(a).addChild(b); + focus.focus(a); + + focus.blur(b); + expect(focus.focused).toBe(a); + + focus.blur(a); + expect(focus.focused).toBeNull(); + }); + + test('routes keydown/keyup to the focused node', () => { + const { scene, focus, onKeyDown, onKeyUp } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + + const downs: KeyEvent[] = []; + const ups: KeyEvent[] = []; + + node.onKeyDown.add(event => downs.push(event)); + node.onKeyUp.add(event => ups.push(event)); + + focus.focus(node); + onKeyDown.dispatch(Keyboard.Enter); + onKeyUp.dispatch(Keyboard.Enter); + + expect(downs).toHaveLength(1); + expect(downs[0].channel).toBe(Keyboard.Enter); + expect(downs[0].type).toBe('keydown'); + expect(downs[0].target).toBe(node); + expect(ups[0].type).toBe('keyup'); + }); + + test('does not route keys when nothing is focused', () => { + const { scene, onKeyDown } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + + const handler = vi.fn(); + + node.onKeyDown.add(handler); + onKeyDown.dispatch(Keyboard.Enter); + + expect(handler).not.toHaveBeenCalled(); + }); + + test('Tab moves focus forward, Shift+Tab moves it backward', () => { + const { scene, focus, onKeyDown, onKeyUp } = createFocusApp(); + const a = focusable(); + const b = focusable(); + const c = focusable(); + + scene.root.addChild(a).addChild(b).addChild(c); + focus.focus(a); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(b); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(c); + + onKeyDown.dispatch(Keyboard.Shift); + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(b); + + onKeyUp.dispatch(Keyboard.Shift); + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(c); + }); + + test('Tab wraps around the scope', () => { + const { scene, focus, onKeyDown } = createFocusApp(); + const a = focusable(); + const b = focusable(); + + scene.root.addChild(a).addChild(b); + focus.focus(b); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(a); + }); + + test('Tab traversal honors tabIndex over document order', () => { + const { scene, focus, onKeyDown } = createFocusApp(); + const first = focusable(1); + const second = focusable(2); + + // Added in reverse document order; the lower tabIndex must still win. + scene.root.addChild(second).addChild(first); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(first); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(second); + }); + + test('preventDefault on a Tab keydown suppresses traversal', () => { + const { scene, focus, onKeyDown } = createFocusApp(); + const a = focusable(); + const b = focusable(); + + scene.root.addChild(a).addChild(b); + a.onKeyDown.add(event => event.preventDefault()); + focus.focus(a); + + onKeyDown.dispatch(Keyboard.Tab); + + expect(focus.focused).toBe(a); + }); + + test('removing a focused node from the tree clears focus', () => { + const { scene, focus } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + focus.focus(node); + scene.root.removeChild(node); + + expect(focus.focused).toBeNull(); + }); + + test('removing an ancestor of the focused node clears focus', () => { + const { scene, focus } = createFocusApp(); + const panel = new Container(); + const node = focusable(); + + panel.addChild(node); + scene.root.addChild(panel); + focus.focus(node); + scene.root.removeChild(panel); + + expect(focus.focused).toBeNull(); + }); + + test('node.focus()/blur() convenience routes through the stage', () => { + const { scene, focus } = createFocusApp(); + const node = focusable(); + + scene.root.addChild(node); + + node.focus(); + expect(focus.focused).toBe(node); + + node.blur(); + expect(focus.focused).toBeNull(); + }); + + test('pushScope restricts Tab traversal to a subtree', () => { + const { scene, focus, onKeyDown } = createFocusApp(); + const outside = focusable(); + const modal = new Container(); + const inA = focusable(); + const inB = focusable(); + + modal.addChild(inA).addChild(inB); + scene.root.addChild(outside).addChild(modal); + focus.pushScope(modal); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(inA); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(inB); + + onKeyDown.dispatch(Keyboard.Tab); + expect(focus.focused).toBe(inA); + + focus.popScope(); + expect(focus.focused).toBe(inA); + }); +}); diff --git a/test/input/interaction.test.ts b/test/input/interaction.test.ts index 1751935c..3e47f861 100644 --- a/test/input/interaction.test.ts +++ b/test/input/interaction.test.ts @@ -94,6 +94,7 @@ const createApp = (): { width: 800, height: 600, input: signals as unknown as InputManager, + focus: { focused: null, focus() {}, blur() {}, _notifyNodeRemoved() {} }, // Default centered camera: design-space pointer coords pass through to // world space unchanged (identity screenToWorld). rendering: { @@ -912,3 +913,80 @@ describe('InteractionManager — multi-Application isolation', () => { sprite.destroy(); }); }); + +// --------------------------------------------------------------------------- +// Modal input capture +// --------------------------------------------------------------------------- + +describe('InteractionManager — input capture', () => { + test('confines hit-testing to the captured subtree; outside pointers hit nothing', () => { + const { app, scene, signals } = createApp(); + const im = new InteractionManager(app); + im.attachRoot(scene.root); + + const modal = new Container(); + const inside = new TestSprite().setBounds(0, 0, 100, 100); + const outside = new TestSprite().setBounds(200, 0, 100, 100); + + inside.interactive = true; + outside.interactive = true; + modal.addChild(inside); + scene.addChild(modal); + scene.addChild(outside); + + const insideHandler = vi.fn(); + const outsideHandler = vi.fn(); + + inside.onPointerDown.add(insideHandler); + outside.onPointerDown.add(outsideHandler); + + im.pushInputCapture(modal); + + // Pointer over `outside` (not in the captured subtree) hits nothing. + signals.onPointerDown.dispatch(makePointer({ x: 250, y: 50 })); + flushInteractions(im); + expect(outsideHandler).not.toHaveBeenCalled(); + + // Pointer over `inside` (in the captured subtree) still hits. + signals.onPointerDown.dispatch(makePointer({ x: 50, y: 50 })); + flushInteractions(im); + expect(insideHandler).toHaveBeenCalledTimes(1); + + im.destroy(); + inside.destroy(); + outside.destroy(); + modal.destroy(); + }); + + test('popInputCapture restores hit-testing outside the previous subtree', () => { + const { app, scene, signals } = createApp(); + const im = new InteractionManager(app); + im.attachRoot(scene.root); + + const modal = new Container(); + const inside = new TestSprite().setBounds(0, 0, 100, 100); + const outside = new TestSprite().setBounds(200, 0, 100, 100); + + inside.interactive = true; + outside.interactive = true; + modal.addChild(inside); + scene.addChild(modal); + scene.addChild(outside); + + const outsideHandler = vi.fn(); + + outside.onPointerDown.add(outsideHandler); + + im.pushInputCapture(modal); + im.popInputCapture(); + + signals.onPointerDown.dispatch(makePointer({ x: 250, y: 50 })); + flushInteractions(im); + expect(outsideHandler).toHaveBeenCalledTimes(1); + + im.destroy(); + inside.destroy(); + outside.destroy(); + modal.destroy(); + }); +}); diff --git a/test/input/spatial-index.test.ts b/test/input/spatial-index.test.ts index 90202d9e..14779f99 100644 --- a/test/input/spatial-index.test.ts +++ b/test/input/spatial-index.test.ts @@ -93,6 +93,7 @@ const createApp = (): { width: 800, height: 600, input: signals as unknown as InputManager, + focus: { focused: null, focus() {}, blur() {}, _notifyNodeRemoved() {} }, // Default centered camera: design-space pointer coords pass through to // world space unchanged (identity screenToWorld). rendering: { diff --git a/test/math/geometry.test.ts b/test/math/geometry.test.ts new file mode 100644 index 00000000..2c47920a --- /dev/null +++ b/test/math/geometry.test.ts @@ -0,0 +1,89 @@ +import { buildRectangle, buildRoundedRectangle } from '#math/geometry'; + +describe('buildRoundedRectangle', () => { + test('falls back to a plain rectangle when the clamped radius is zero', () => { + const rect = buildRectangle(0, 0, 100, 60); + const rounded = buildRoundedRectangle(0, 0, 100, 60, 0); + + expect(rounded.vertices).toEqual(rect.vertices); + expect(rounded.indices).toEqual(rect.indices); + expect(rounded.points).toEqual(rect.points); + }); + + test('treats a negative radius as its magnitude', () => { + const positive = buildRoundedRectangle(0, 0, 100, 60, 10); + const negative = buildRoundedRectangle(0, 0, 100, 60, -10); + + expect(negative.vertices).toEqual(positive.vertices); + expect(negative.indices).toEqual(positive.indices); + }); + + test('produces a center-anchored fan with one triangle per perimeter vertex', () => { + const data = buildRoundedRectangle(10, 20, 100, 60, 12); + const perimeterCount = data.vertices.length / 2 - 1; + + // First vertex is the fan center. + expect(data.vertices[0]).toBeCloseTo(10 + 100 / 2); + expect(data.vertices[1]).toBeCloseTo(20 + 60 / 2); + // The fan wraps around, so every perimeter vertex spawns one triangle. + expect(data.indices.length).toBe(perimeterCount * 3); + }); + + test('references only existing vertices from the index buffer', () => { + const data = buildRoundedRectangle(0, 0, 80, 40, 8); + const vertexCount = data.vertices.length / 2; + + for (const index of data.indices) { + expect(index).toBeLessThan(vertexCount); + } + }); + + test('keeps every vertex inside the rectangle bounds', () => { + const [x, y, width, height] = [5, 7, 120, 80]; + const data = buildRoundedRectangle(x, y, width, height, 16); + + for (let i = 0; i < data.vertices.length; i += 2) { + expect(data.vertices[i]).toBeGreaterThanOrEqual(x - 1e-3); + expect(data.vertices[i]).toBeLessThanOrEqual(x + width + 1e-3); + expect(data.vertices[i + 1]).toBeGreaterThanOrEqual(y - 1e-3); + expect(data.vertices[i + 1]).toBeLessThanOrEqual(y + height + 1e-3); + } + }); + + test('rounds the corners so no vertex sits on the sharp corner', () => { + const data = buildRoundedRectangle(0, 0, 100, 100, 20); + + // Skip the center vertex (index 0); no perimeter vertex hits (0,0). + for (let i = 2; i < data.vertices.length; i += 2) { + const onSharpCorner = Math.abs(data.vertices[i]) < 1e-4 && Math.abs(data.vertices[i + 1]) < 1e-4; + + expect(onSharpCorner).toBe(false); + } + }); + + test('clamps the radius to half the smaller side', () => { + // radius 999 on a 40-tall rect clamps to 20 → the arcs still touch all edges. + const data = buildRoundedRectangle(0, 0, 200, 40, 999); + let minX = Infinity; + let minY = Infinity; + let maxY = -Infinity; + + for (let i = 0; i < data.vertices.length; i += 2) { + minX = Math.min(minX, data.vertices[i]); + minY = Math.min(minY, data.vertices[i + 1]); + maxY = Math.max(maxY, data.vertices[i + 1]); + } + + expect(minX).toBeCloseTo(0); + expect(minY).toBeCloseTo(0); + expect(maxY).toBeCloseTo(40); + }); + + test('returns a closed outline so the stroke seals the border', () => { + const data = buildRoundedRectangle(0, 0, 100, 60, 10); + const last = data.points.length; + + expect(data.points[0]).toBeCloseTo(data.points[last - 2]); + expect(data.points[1]).toBeCloseTo(data.points[last - 1]); + }); +}); diff --git a/test/rendering/webgl2-backend.test.ts b/test/rendering/webgl2-backend.test.ts index 41f9bc14..31a30ec1 100644 --- a/test/rendering/webgl2-backend.test.ts +++ b/test/rendering/webgl2-backend.test.ts @@ -156,6 +156,21 @@ describe('Application.setCursor', () => { }; }), })); + vi.doMock('#input/FocusManager', () => ({ + FocusManager: vi.fn(function () { + return { + focused: null, + focus: vi.fn(), + blur: vi.fn(), + pushScope: vi.fn(), + popScope: vi.fn(), + focusNext: vi.fn(), + focusPrevious: vi.fn(), + _notifyNodeRemoved: vi.fn(), + destroy: vi.fn(), + }; + }), + })); vi.doMock('#input/InteractionManager', () => ({ InteractionManager: vi.fn(function () { return { update: vi.fn(), destroy: vi.fn() }; diff --git a/test/ui/scene-ui.test.ts b/test/ui/scene-ui.test.ts new file mode 100644 index 00000000..80975bed --- /dev/null +++ b/test/ui/scene-ui.test.ts @@ -0,0 +1,176 @@ +import type { Application } from '#core/Application'; +import { Scene } from '#core/Scene'; +import { Signal } from '#core/Signal'; +import { FocusManager } from '#input/FocusManager'; +import type { InputManager } from '#input/InputManager'; +import { InteractionManager } from '#input/InteractionManager'; +import type { Pointer } from '#input/Pointer'; +import { Keyboard } from '#input/types'; +import { Rectangle } from '#math/Rectangle'; +import { Drawable } from '#rendering/Drawable'; + +// Camera offset so world-space coordinates differ from screen-space ones; the +// screenView stays identity, proving UI is hit-tested in screen space. +const CAMERA_OFFSET = 1000; + +class TestSprite extends Drawable { + private readonly _bounds = new Rectangle(0, 0, 0, 0); + + public setBounds(x: number, y: number, width: number, height: number): this { + this._bounds.set(x, y, width, height); + + return this; + } + + public override contains(x: number, y: number): boolean { + return x >= this._bounds.x && x < this._bounds.x + this._bounds.width && y >= this._bounds.y && y < this._bounds.y + this._bounds.height; + } + + public override getBounds(): Rectangle { + return this._bounds.clone(); + } +} + +const makePointer = (x: number, y: number, id = 1): Pointer => ({ id, x, y, type: 'mouse', isPrimary: true }) as unknown as Pointer; + +const createUIApp = (): { + scene: Scene; + im: InteractionManager; + focus: FocusManager; + signals: { + onPointerDown: Signal<[Pointer]>; + onKeyDown: Signal<[number]>; + }; +} => { + const signals = { + onPointerDown: new Signal<[Pointer]>(), + onPointerMove: new Signal<[Pointer]>(), + onPointerUp: new Signal<[Pointer]>(), + onPointerTap: new Signal<[Pointer]>(), + onPointerCancel: new Signal<[Pointer]>(), + onPointerLeave: new Signal<[Pointer]>(), + onKeyDown: new Signal<[number]>(), + onKeyUp: new Signal<[number]>(), + }; + const canvas = document.createElement('canvas'); + const scene = new Scene(); + const app = { + canvas, + width: 800, + height: 600, + input: signals as unknown as InputManager, + focus: null as FocusManager | null, + interaction: null as InteractionManager | null, + rendering: { + camera: { screenToWorld: (x: number, y: number): { x: number; y: number } => ({ x: x + CAMERA_OFFSET, y: y + CAMERA_OFFSET }) }, + screenView: { screenToWorld: (x: number, y: number): { x: number; y: number } => ({ x, y }) }, + }, + scene: { + get currentScene(): Scene | null { + return scene; + }, + }, + }; + const typed = app as unknown as Application; + + app.focus = new FocusManager(typed); + app.interaction = new InteractionManager(typed); + scene.app = typed; + app.interaction.attachRoot(scene.root); + + return { scene, im: app.interaction, focus: app.focus, signals }; +}; + +describe('Scene.ui', () => { + test('is created lazily', () => { + const { scene } = createUIApp(); + + expect(scene._peekUI()).toBeNull(); + + const ui = scene.ui; + + expect(scene._peekUI()).toBe(ui); + expect(scene.ui).toBe(ui); + }); +}); + +describe('UI interaction routing', () => { + test('hits a UI node in screen space even when the camera is panned', () => { + const { scene, im, signals } = createUIApp(); + const button = new TestSprite().setBounds(50, 50, 100, 100); + + button.interactive = true; + scene.ui.addChild(button); + + const handler = vi.fn(); + + button.onPointerDown.add(handler); + signals.onPointerDown.dispatch(makePointer(80, 80)); + im.update(); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('UI layer takes precedence over the world at the same screen point', () => { + const { scene, im, signals } = createUIApp(); + const button = new TestSprite().setBounds(50, 50, 100, 100); + // World sprite placed where screen (80,80) maps to in camera space. + const worldSprite = new TestSprite().setBounds(80 + CAMERA_OFFSET, 80 + CAMERA_OFFSET, 100, 100); + + button.interactive = true; + worldSprite.interactive = true; + scene.ui.addChild(button); + scene.addChild(worldSprite); + + const uiHandler = vi.fn(); + const worldHandler = vi.fn(); + + button.onPointerDown.add(uiHandler); + worldSprite.onPointerDown.add(worldHandler); + signals.onPointerDown.dispatch(makePointer(80, 80)); + im.update(); + + expect(uiHandler).toHaveBeenCalledTimes(1); + expect(worldHandler).not.toHaveBeenCalled(); + }); + + test('a pointer outside the UI falls through to the world layer', () => { + const { scene, im, signals } = createUIApp(); + const button = new TestSprite().setBounds(50, 50, 100, 100); + // World sprite under screen (400,400) → camera (1400,1400). + const worldSprite = new TestSprite().setBounds(400 + CAMERA_OFFSET, 400 + CAMERA_OFFSET, 100, 100); + + button.interactive = true; + worldSprite.interactive = true; + scene.ui.addChild(button); + scene.addChild(worldSprite); + + const uiHandler = vi.fn(); + const worldHandler = vi.fn(); + + button.onPointerDown.add(uiHandler); + worldSprite.onPointerDown.add(worldHandler); + signals.onPointerDown.dispatch(makePointer(400, 400)); + im.update(); + + expect(uiHandler).not.toHaveBeenCalled(); + expect(worldHandler).toHaveBeenCalledTimes(1); + }); + + test('a focused UI node receives routed keyboard input', () => { + const { scene, focus, signals } = createUIApp(); + const field = new TestSprite().setBounds(0, 0, 100, 30); + + field.focusable = true; + scene.ui.addChild(field); + + const keys: number[] = []; + + field.onKeyDown.add(event => keys.push(event.channel)); + focus.focus(field); + signals.onKeyDown.dispatch(Keyboard.Enter); + + expect(focus.focused).toBe(field); + expect(keys).toEqual([Keyboard.Enter]); + }); +}); diff --git a/test/ui/widgets.test.ts b/test/ui/widgets.test.ts new file mode 100644 index 00000000..3a91b5bc --- /dev/null +++ b/test/ui/widgets.test.ts @@ -0,0 +1,196 @@ +import { KeyEvent } from '#input/KeyEvent'; +import { Keyboard } from '#input/types'; +import type { GlyphAtlas } from '#rendering/text/GlyphAtlas'; +import type { GlyphAtlasPool } from '#rendering/text/GlyphAtlasPool'; +import { resetDefaultGlyphAtlasPool } from '#rendering/text/GlyphAtlasPool'; +import type { GlyphInfo } from '#rendering/text/types'; +import { Button } from '#ui/Button'; +import { Label } from '#ui/Label'; +import { Panel } from '#ui/Panel'; +import { ProgressBar } from '#ui/ProgressBar'; +import { Stack } from '#ui/Stack'; +import { UIRoot } from '#ui/UIRoot'; + +// Text (used by Label/Button) needs a glyph atlas; inject a deterministic mock +// so widgets are constructible without a real canvas (jsdom has no measureText). +const fixedGlyphInfo: GlyphInfo = { x: 0, y: 0, width: 8, height: 16, advance: 10, ascent: 13, page: 0, uvLeft: 0, uvTop: 0, uvRight: 0.01, uvBottom: 0.02 }; +const mockPage = { + texture: { + width: 1024, + height: 1024, + version: 1, + source: null, + scaleMode: 0, + wrapMode: 0, + premultiplyAlpha: false, + generateMipMap: false, + flipY: false, + addDestroyListener: () => mockPage.texture, + removeDestroyListener: () => mockPage.texture, + destroy: () => undefined, + }, + index: 0, + mode: 'sdf' as const, +}; +const mockAtlas: Partial = { + getGlyph: vi.fn(() => fixedGlyphInfo), + pages: [mockPage] as unknown as GlyphAtlas['pages'], + mode: 'sdf', + clear: vi.fn(), +}; +const mockPool = { getAtlas: vi.fn(() => mockAtlas) }; + +beforeEach(() => { + resetDefaultGlyphAtlasPool(mockPool as unknown as GlyphAtlasPool); +}); +afterEach(() => { + resetDefaultGlyphAtlasPool(); +}); + +describe('Panel', () => { + test('takes its explicit layout size', () => { + const panel = new Panel({ width: 200, height: 100 }); + + expect(panel.uiWidth).toBe(200); + expect(panel.uiHeight).toBe(100); + }); +}); + +describe('Button', () => { + test('is interactive and focusable', () => { + const button = new Button(); + + expect(button.interactive).toBe(true); + expect(button.focusable).toBe(true); + }); + + test('fires onClick on Enter and Space when focused', () => { + const button = new Button({ label: 'OK' }); + const handler = vi.fn(); + + button.onClick.add(handler); + + button.onKeyDown.dispatch(new KeyEvent('keydown', Keyboard.Enter, button)); + expect(handler).toHaveBeenCalledTimes(1); + + button.onKeyDown.dispatch(new KeyEvent('keydown', Keyboard.Space, button)); + expect(handler).toHaveBeenCalledTimes(2); + + // An unrelated key does nothing. + button.onKeyDown.dispatch(new KeyEvent('keydown', Keyboard.Escape, button)); + expect(handler).toHaveBeenCalledTimes(2); + }); + + test('fires onClick on pointer tap', () => { + const button = new Button(); + const handler = vi.fn(); + + button.onClick.add(handler); + button.onPointerTap.dispatch({} as never); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + test('disabled button ignores activation and is non-interactive', () => { + const button = new Button(); + const handler = vi.fn(); + + button.onClick.add(handler); + button.enabled = false; + + button.onKeyDown.dispatch(new KeyEvent('keydown', Keyboard.Enter, button)); + + expect(handler).not.toHaveBeenCalled(); + expect(button.interactive).toBe(false); + }); + + test('exposes and updates its label', () => { + const button = new Button({ label: 'Start' }); + + expect(button.label).toBe('Start'); + + button.label = 'Stop'; + expect(button.label).toBe('Stop'); + }); +}); + +describe('ProgressBar', () => { + test('clamps value to [0, 1]', () => { + const bar = new ProgressBar({ value: 0.5 }); + + expect(bar.value).toBe(0.5); + + bar.value = 2; + expect(bar.value).toBe(1); + + bar.value = -1; + expect(bar.value).toBe(0); + }); +}); + +describe('Label', () => { + test('exposes and updates its text', () => { + const label = new Label('Hello'); + + expect(label.text).toBe('Hello'); + + label.text = 'World'; + expect(label.text).toBe('World'); + }); +}); + +describe('Widget anchoring', () => { + test('anchors within a UIRoot box and re-applies on resize', () => { + const root = new UIRoot(); + const panel = new Panel({ width: 100, height: 50 }); + + root.addChild(panel); + panel.anchorIn(root, 'bottom-right', -10, -10); + root.onResize.dispatch(800, 600); + + expect(panel.position.x).toBe(800 - 100 - 10); + expect(panel.position.y).toBe(600 - 50 - 10); + }); + + test('centers when anchored to center', () => { + const root = new UIRoot(); + const panel = new Panel({ width: 100, height: 50 }); + + root.addChild(panel); + panel.anchorIn(root, 'center'); + root.onResize.dispatch(800, 600); + + expect(panel.position.x).toBe((800 - 100) / 2); + expect(panel.position.y).toBe((600 - 50) / 2); + }); +}); + +describe('Stack', () => { + test('flows children in a column and sizes to fit', () => { + const stack = new Stack({ direction: 'column', spacing: 10 }); + const a = new Panel({ width: 100, height: 30 }); + const b = new Panel({ width: 80, height: 40 }); + + stack.addItem(a); + stack.addItem(b); + + expect(a.position.y).toBe(0); + expect(b.position.y).toBe(40); + expect(stack.uiWidth).toBe(100); + expect(stack.uiHeight).toBe(80); + }); + + test('flows children in a row', () => { + const stack = new Stack({ direction: 'row', spacing: 5 }); + const a = new Panel({ width: 60, height: 20 }); + const b = new Panel({ width: 40, height: 30 }); + + stack.addItem(a); + stack.addItem(b); + + expect(a.position.x).toBe(0); + expect(b.position.x).toBe(65); + expect(stack.uiWidth).toBe(105); + expect(stack.uiHeight).toBe(30); + }); +});