diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index f02f30b..ff7841a 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +#### setupMoxi +- `width` and `height` top-level props for convenient canvas sizing (alternative to `renderOptions.width/height`) +- `scaleMode` option ('none' | 'fit' | 'fill' | 'stretch') for automatic canvas scaling with CSS injection + +#### Entity Helpers +- `asEntityGraphics(props)` - Creates Graphics wrapped with `asEntity()` for Logic support +- `asEntitySprite(constructorArgs, props)` - Creates Sprite wrapped with `asEntity()` +- `asEntityContainer(props)` - Creates Container wrapped with `asEntity()` +- `asEntityText(constructorArgs, props)` - Creates Text wrapped with `asEntity()` + +### Changed + +#### ClientEvents +- Enhanced JSDoc documentation clarifying key format uses `event.key` (e.g., 'a', 'ArrowRight', ' ') not `event.code` +- Added common key values table and MDN reference link + +#### ActionManager +- Enhanced JSDoc with comprehensive examples for adding/removing listeners +- Documented common event targets (window, document, canvas) and event types +- Added cleanup pattern documentation for Logic components + ## [0.3.2] - 2025-12-06 ### Changed diff --git a/packages/core/llms.txt b/packages/core/llms.txt new file mode 100644 index 0000000..7bb4e27 --- /dev/null +++ b/packages/core/llms.txt @@ -0,0 +1,213 @@ +# @moxijs/core + +> Game framework on PIXI.js with ECS architecture and physics. + +## Install + +```bash +npm install @moxijs/core pixi.js +``` + +## Quick Start + +```ts +import { setupMoxi, asEntity, Logic } from '@moxijs/core'; + +const { scene, engine } = await setupMoxi({ + hostElement: document.getElementById('game'), +}); + +class RotateLogic extends Logic { + update(entity, dt) { entity.rotation += 0.01 * dt; } +} + +const sprite = asEntity(new PIXI.Sprite(texture)); +sprite.moxiEntity.addLogic(new RotateLogic()); +scene.addChild(sprite); +engine.start(); +``` + +## Core API + +### setupMoxi(options) → Promise +Main entry point. Returns `{ scene, engine, camera, physicsWorld?, loadAssets }`. + +```ts +await setupMoxi({ + hostElement: HTMLElement, // Required + renderOptions?: PIXIOptions, // PIXI renderer options + physics?: boolean | PhysicsOpts, // Enable Planck.js physics + pixelPerfect?: boolean, // Pixel-art mode +}); +``` + +### asEntity(displayObject) → AsEntity +Wraps PIXI.Container with logic system. + +```ts +const entity = asEntity(new PIXI.Sprite(texture)); +entity.moxiEntity.addLogic(logic); +entity.moxiEntity.getLogic('LogicName'); +entity.moxiEntity.removeLogic(logic); +``` + +### Logic +Base class for entity behaviors. + +```ts +class MyLogic extends Logic { + name = 'MyLogic'; + active = true; + + init(entity, renderer) { /* once on scene.init() */ } + update(entity, deltaTime) { /* every frame */ } +} +``` + +### Scene +Root container. Extends PIXI.Container. + +```ts +scene.addChild(entity); +scene.init(); // Initializes all entity logic +``` + +### Engine +Game loop manager. + +```ts +engine.start(); +engine.stop(); +engine.loadStage(newScene); +``` + +## Physics (Planck.js) + +```ts +const { physicsWorld } = await setupMoxi({ physics: true }); + +// Add physics body to entity +entity.moxiEntity.addLogic(new PhysicsBodyLogic(physicsWorld, { + type: 'dynamic', // 'static' | 'dynamic' | 'kinematic' + shape: { type: 'box', width: 32, height: 32 }, + // or: { type: 'circle', radius: 16 } + // or: { type: 'polygon', vertices: [{x,y}...] } + collisionTags: ['player'], + collidesWith: ['enemy', 'ground'], +})); + +// Collision events +physicsWorld.onCollision('player', 'enemy', (event) => { + console.log(event.bodyA, event.bodyB); +}); + +// Debug visualization +physicsWorld.enableDebugRenderer(scene); +``` + +### Physics Helpers + +```ts +import { asPhysicsEntity, hasPhysics, getPhysicsBody } from '@moxijs/core'; + +const entity = asPhysicsEntity(sprite, physicsWorld, bodyOptions); +if (hasPhysics(entity)) { + const body = getPhysicsBody(entity); +} +``` + +## State Machine + +```ts +import { StateMachine, StateLogic } from '@moxijs/core'; + +class IdleState extends StateLogic { + name = 'idle'; + onEnter() { } + onExit() { } + update(entity, dt) { + if (jumping) this.machine.transition('jump'); + } +} + +const fsm = new StateMachine(); +fsm.addState(new IdleState()); +fsm.start('idle'); +``` + +## Camera + +```ts +camera.follow(entity); +camera.setZoom(2); +camera.pan(100, 50); +camera.shake(intensity, duration); +``` + +## PIXI Helpers + +```ts +import { asSprite, asText, asGraphics, asContainer } from '@moxijs/core'; + +const sprite = asSprite(texture, { x: 100, y: 50, anchor: 0.5, scale: 2 }); +const text = asText('Hello', { style: { fill: 0xffffff } }); +``` + +## Texture Utilities + +```ts +import { asTextureFrames, TextureFrameSequences } from '@moxijs/core'; + +// Split spritesheet +const frames = asTextureFrames(texture, { frameWidth: 32, frameHeight: 32 }); + +// Named sequences +const seq = new TextureFrameSequences(texture, { + idle: { start: 0, end: 3 }, + walk: { start: 4, end: 11 }, +}); +const walkFrames = seq.get('walk'); +``` + +## Input + +```ts +import { ClientEvents } from '@moxijs/core'; + +const input = ClientEvents.getInstance(); +if (input.isKeyDown('ArrowRight')) player.x += 5; +const mousePos = input.movePosition; +``` + +## Events + +```ts +import { EventEmitter } from '@moxijs/core'; + +interface Events { + 'score': (n: number) => void; +} +const events = new EventEmitter(); +events.on('score', (n) => console.log(n)); +events.emit('score', 100); +``` + +## Exports Summary + +**Core:** `setupMoxi`, `Engine`, `Scene`, `asEntity`, `Logic`, `StateMachine`, `StateLogic` + +**Physics:** `PhysicsWorld`, `PhysicsBodyLogic`, `asPhysicsEntity`, `hasPhysics`, `getPhysicsBody` + +**Camera:** `Camera`, `CameraLogic` + +**Helpers:** `asSprite`, `asText`, `asGraphics`, `asContainer`, `asTextureFrames`, `TextureFrameSequences` + +**Input/Events:** `ClientEvents`, `EventEmitter`, `ActionManager` + +**Utils:** `utils.lerp`, `utils.clamp`, `utils.getRandomInt`, `utils.rad2deg`, `utils.deg2rad` + +## See Also + +- **@moxijs/ui** - UI components (UIButton, UILabel, FlexContainer, etc.) +- **Docs:** https://ineffably.github.io/moxijs/docs/core/ +- **Examples:** https://ineffably.github.io/moxijs/ diff --git a/packages/core/package.json b/packages/core/package.json index eac0a75..c93b824 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -8,7 +8,8 @@ "lib", "scripts", "README.md", - "LICENSE" + "LICENSE", + "llms.txt" ], "scripts": { "generate-msdf-font": "node scripts/generate-msdf-font.mjs", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04b545a..03382f0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,7 +12,7 @@ import { loadFonts } from './library/font-loader'; import { asEntity, MoxiEntity } from './main/moxi-entity'; import { Camera, CameraLogic } from './main/camera'; import { asTextureFrames } from './library/texture-frames'; -import { asBitmapText, asSprite, asText, asTextDPR, asGraphics, asContainer, asMSDFText, PixiProps, TextDPROptions } from './library/as-pixi'; +import { asBitmapText, asSprite, asText, asTextDPR, asGraphics, asContainer, asMSDFText, asEntityGraphics, asEntitySprite, asEntityContainer, asEntityText, PixiProps, TextDPROptions } from './library/as-pixi'; import { TextureFrameSequences, SequenceInfo } from './library/texture-frame-sequences'; import { createResizeHandler, setupResponsiveCanvas, ResizeHandlerOptions } from './library/resize-handler'; import { StateMachine } from './library/state-machine'; @@ -64,8 +64,8 @@ export type { } from './library/physics'; export type { SVGToTextureOptions } from './library/svg-utils/svg-to-texture'; export type { PixelGridConfig, BorderConfig } from './library/pixel-grid'; -export type { SpriteOptions, TextOptions, TextDPROptions, BitmapTextOptions, PixiProps, MSDFTextOptions } from './library/as-pixi'; -export type { SetupMoxiArgs, PixelPerfectOptions } from './library/setup'; +export type { SpriteOptions, TextOptions, TextDPROptions, BitmapTextOptions, PixiProps, PixiPropsWithEntity, MSDFTextOptions, GraphicsDrawOptions } from './library/as-pixi'; +export type { SetupMoxiArgs, PixelPerfectOptions, ScaleMode } from './library/setup'; export type { AssetLoaderEvents } from './main/asset-loader'; export type Asset = { src: string, alias?: string }; @@ -109,6 +109,10 @@ const exportedObjects = { asGraphics, asContainer, asMSDFText, + asEntityGraphics, + asEntitySprite, + asEntityContainer, + asEntityText, Logic, Camera, CameraLogic, @@ -169,6 +173,10 @@ export { asGraphics, asContainer, asMSDFText, + asEntityGraphics, + asEntitySprite, + asEntityContainer, + asEntityText, Logic, Camera, CameraLogic, diff --git a/packages/core/src/library/action-manager.ts b/packages/core/src/library/action-manager.ts index 8b428de..08124f5 100644 --- a/packages/core/src/library/action-manager.ts +++ b/packages/core/src/library/action-manager.ts @@ -1,24 +1,49 @@ /** - * ActionManager - Centralized event listener management utility + * ActionManager - Centralized event listener management utility. * * Prevents memory leaks by tracking all registered event listeners and providing * batch cleanup functionality. Particularly useful for Logic components that need to * register multiple window/document listeners and clean them up on destroy. * + * ## Why Use ActionManager? + * When adding event listeners in game logic, forgetting to remove them causes memory leaks. + * ActionManager tracks all listeners and provides a single `removeAll()` call for cleanup. + * + * ## Common Event Targets + * - `window` - Global events like resize, blur, focus + * - `document` - Keyboard events, pointer events that shouldn't be canvas-bound + * - `renderer.canvas` - Canvas-specific events + * - Any DOM element + * + * ## Common Event Types + * - Pointer: 'pointerdown', 'pointermove', 'pointerup', 'pointercancel' + * - Mouse: 'mousedown', 'mousemove', 'mouseup', 'wheel', 'contextmenu' + * - Keyboard: 'keydown', 'keyup' + * - Window: 'resize', 'blur', 'focus', 'visibilitychange' + * * @example * ```ts + * // In a Logic component - drag handling with cleanup * class DragLogic extends Logic { * private actions = new ActionManager(); * * init(entity: Container) { + * // Add listeners - they're tracked automatically * this.actions.add(window, 'pointermove', this.onMove.bind(this)); * this.actions.add(window, 'pointerup', this.onUp.bind(this)); + * this.actions.add(window, 'blur', this.onBlur.bind(this)); * } * * destroy() { + * // Clean up all listeners at once - no memory leaks! * this.actions.removeAll(); * } * } + * + * // Removing individual actions + * const wheelAction = actions.add(window, 'wheel', onWheel); + * // ... later ... + * actions.remove(wheelAction); // Remove just this one * ``` */ diff --git a/packages/core/src/library/as-pixi.ts b/packages/core/src/library/as-pixi.ts index eac1da7..6aaaff4 100644 --- a/packages/core/src/library/as-pixi.ts +++ b/packages/core/src/library/as-pixi.ts @@ -1,4 +1,5 @@ import PIXI from 'pixi.js'; +import { asEntity, AsEntity } from '../main/moxi-entity'; /** * Options for creating a BitmapText instance @@ -64,6 +65,17 @@ export interface PixiProps { pivot?: { x?: number; y?: number } | number; } +/** + * Props with optional entity wrapping + */ +export interface PixiPropsWithEntity extends PixiProps { + /** + * If true, wrap the result with asEntity() for logic component support. + * The returned object will have a `moxiEntity` property for adding Logic. + */ + entity?: boolean; +} + /** * Applies common properties to a PIXI display object */ @@ -335,3 +347,108 @@ export function asMSDFText( return applyProps(text, props); } +// ============================================================================ +// Entity-wrapped helpers +// These return objects with moxiEntity property for logic component support +// ============================================================================ + +/** + * Options for asGraphics with draw callback + */ +export interface GraphicsDrawOptions extends PixiProps { + /** Optional draw callback to set up graphics commands */ + draw?: (g: PIXI.Graphics) => void; +} + +/** + * Creates a Graphics instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const player = asEntityGraphics({ + * x: 100, + * y: 200, + * draw: (g) => g.circle(0, 0, 20).fill(0xff0000) + * }); + * player.moxiEntity.addLogic(new PlayerController()); + * scene.addChild(player); + * ``` + */ +export function asEntityGraphics(props?: GraphicsDrawOptions): AsEntity { + const { draw, ...pixiProps } = props || {}; + const graphics = new PIXI.Graphics(); + + if (draw) { + draw(graphics); + } + + const result = applyProps(graphics, pixiProps); + return asEntity(result); +} + +/** + * Creates a Sprite instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const player = asEntitySprite( + * { texture }, + * { x: 100, y: 100, anchor: 0.5 } + * ); + * player.moxiEntity.addLogic(new PlayerController()); + * scene.addChild(player); + * ``` + */ +export function asEntitySprite( + constructorArgs: SpriteOptions, + props?: PixiProps +): AsEntity { + const sprite = new PIXI.Sprite(constructorArgs as any); + const result = applyProps(sprite, props); + return asEntity(result); +} + +/** + * Creates a Container instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const group = asEntityContainer({ x: 100, y: 100 }); + * group.moxiEntity.addLogic(new GroupLogic()); + * scene.addChild(group); + * ``` + */ +export function asEntityContainer(props?: PixiProps): AsEntity { + const container = new PIXI.Container(); + const result = applyProps(container, props); + return asEntity(result); +} + +/** + * Creates a Text instance wrapped as an entity for logic support. + * + * @example + * ```typescript + * const label = asEntityText( + * { text: 'Score: 0', style: { fontFamily: 'Arial', fontSize: 24 } }, + * { x: 10, y: 10 } + * ); + * label.moxiEntity.addLogic(new ScoreDisplay()); + * scene.addChild(label); + * ``` + */ +export function asEntityText( + constructorArgs: TextOptions, + props?: PixiProps +): AsEntity { + const { pixelPerfect, ...textArgs } = constructorArgs; + const text = new PIXI.Text(textArgs as any); + + if (pixelPerfect) { + text.roundPixels = true; + } + + const result = applyProps(text, props); + return asEntity(result); +} + diff --git a/packages/core/src/library/client-events.ts b/packages/core/src/library/client-events.ts index 9d5609a..dabfbc2 100644 --- a/packages/core/src/library/client-events.ts +++ b/packages/core/src/library/client-events.ts @@ -5,13 +5,27 @@ import { OnEvent, ClientEventsArgs } from '..'; * Singleton input manager for keyboard, mouse, and wheel events. * Tracks current input state for polling-based input handling. * + * ## Key Format + * Keys are identified by `event.key` (the character or key name), NOT `event.code` (physical key). + * + * Common key values: + * - Letters: 'a', 'b', 'c', ... (lowercase when typed without shift) + * - Arrows: 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight' + * - Modifiers: 'Shift', 'Control', 'Alt', 'Meta' + * - Special: 'Enter', 'Escape', 'Tab', 'Backspace', ' ' (space) + * - Numbers: '0', '1', '2', ... (the character, not 'Digit1') + * + * @see https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values + * * @example * ```ts * const input = ClientEvents.getInstance(); * * // In update loop - check key state + * // Use event.key values (NOT event.code like 'KeyE' or 'Space') * if (input.isKeyDown('ArrowRight')) player.moveRight(); - * if (input.isKeyDown('Space')) player.jump(); + * if (input.isKeyDown(' ')) player.jump(); // Space key + * if (input.isKeyDown('e')) player.interact(); // lowercase 'e' * * // Mouse position and movement * const mousePos = input.movePosition; @@ -129,12 +143,18 @@ export class ClientEvents { ClientEvents.instance = this; } - /** True if key is currently pressed. */ + /** + * True if key is currently pressed. + * @param key - The key value (event.key), e.g., 'a', 'ArrowRight', ' ' for space + */ isKeyDown(key: string): boolean { return Boolean(this.keydown[key]); } - /** True if key is not pressed. */ + /** + * True if key is not pressed. + * @param key - The key value (event.key), e.g., 'a', 'ArrowRight', ' ' for space + */ isKeyUp(key: string): boolean { return !this.keydown[key]; } diff --git a/packages/core/src/library/setup.ts b/packages/core/src/library/setup.ts index a6fc502..b9dc23f 100644 --- a/packages/core/src/library/setup.ts +++ b/packages/core/src/library/setup.ts @@ -15,10 +15,31 @@ export interface PixelPerfectOptions { imageRendering?: boolean; } +/** Scale mode for canvas scaling within the host element. */ +export type ScaleMode = 'none' | 'fit' | 'fill' | 'stretch'; + /** Configuration for setupMoxi(). */ export interface SetupMoxiArgs { /** Container element to attach the canvas. */ hostElement: HTMLElement; + /** + * Canvas width in pixels. Convenience option (alternative to renderOptions.width). + * If both are provided, this takes precedence. + */ + width?: number; + /** + * Canvas height in pixels. Convenience option (alternative to renderOptions.height). + * If both are provided, this takes precedence. + */ + height?: number; + /** + * How to scale the canvas within the host element. + * - 'none': No scaling, canvas uses its native size (default) + * - 'fit': Scale to fit within host while maintaining aspect ratio (letterbox/pillarbox) + * - 'fill': Scale to fill host while maintaining aspect ratio (may crop) + * - 'stretch': Stretch to fill host (distorts aspect ratio) + */ + scaleMode?: ScaleMode; /** PIXI render options (width, height, backgroundColor, etc). */ renderOptions?: Partial; /** Background color (hex number like 0x1a1a2e). Convenience option. */ @@ -95,6 +116,9 @@ export const defaultRenderOptions = { */ export async function setupMoxi({ hostElement, + width, + height, + scaleMode = 'none', renderOptions = defaultRenderOptions, backgroundColor, physics, @@ -105,6 +129,15 @@ export async function setupMoxi({ } = {} as SetupMoxiArgs) { // Process pixel-perfect options let finalRenderOptions = { ...renderOptions }; + + // Apply top-level width/height if provided (takes precedence over renderOptions) + if (width !== undefined) { + finalRenderOptions.width = width; + } + if (height !== undefined) { + finalRenderOptions.height = height; + } + let applyImageRendering = false; if (pixelPerfect) { @@ -132,17 +165,22 @@ export async function setupMoxi({ renderer.background.color = backgroundColor; } + const canvas = renderer.canvas as HTMLCanvasElement; + // Apply canvas CSS for pixel-perfect scaling if requested if (applyImageRendering) { - const canvas = renderer.canvas as HTMLCanvasElement; canvas.style.imageRendering = 'pixelated'; canvas.style.imageRendering = '-moz-crisp-edges'; canvas.style.imageRendering = 'crisp-edges'; } + // Apply scale mode CSS + if (scaleMode !== 'none') { + applyScaleMode(hostElement, canvas, scaleMode, finalRenderOptions.width as number, finalRenderOptions.height as number); + } + // Suppress right-click context menu if requested if (suppressContextMenu) { - const canvas = renderer.canvas as HTMLCanvasElement; canvas.addEventListener('contextmenu', (e) => e.preventDefault()); } @@ -202,3 +240,50 @@ export async function setupMoxi({ }; } +/** + * Apply CSS styling for canvas scaling within host element. + * @internal + */ +function applyScaleMode( + hostElement: HTMLElement, + canvas: HTMLCanvasElement, + scaleMode: ScaleMode, + canvasWidth: number, + canvasHeight: number +): void { + // Host element styles for centering + hostElement.style.display = 'flex'; + hostElement.style.alignItems = 'center'; + hostElement.style.justifyContent = 'center'; + hostElement.style.overflow = 'hidden'; + + // Canvas styles based on scale mode + switch (scaleMode) { + case 'fit': + // Letterbox/pillarbox: fit within container while maintaining aspect ratio + canvas.style.maxWidth = '100%'; + canvas.style.maxHeight = '100%'; + canvas.style.width = 'auto'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = `${canvasWidth} / ${canvasHeight}`; + canvas.style.objectFit = 'contain'; + break; + + case 'fill': + // Fill container while maintaining aspect ratio (may crop) + canvas.style.minWidth = '100%'; + canvas.style.minHeight = '100%'; + canvas.style.width = 'auto'; + canvas.style.height = 'auto'; + canvas.style.aspectRatio = `${canvasWidth} / ${canvasHeight}`; + canvas.style.objectFit = 'cover'; + break; + + case 'stretch': + // Stretch to fill (distorts) + canvas.style.width = '100%'; + canvas.style.height = '100%'; + break; + } +} + diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index ba86796..eb17f99 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +#### UIComponent +- `x` and `y` getters/setters for convenient positioning (no need to access `.container` directly) + +#### UITextInput +- `fontFamily` prop for custom font support (inherits from parent if not specified) +- `setSize(width, height?)` method for dynamic resizing +- `setWidth(width)`, `getWidth()`, `getHeight()` convenience methods + +#### UITextArea +- `setSize(width, height?)` method for dynamic resizing +- `setWidth(width)`, `getWidth()`, `getHeight()` convenience methods + +#### UIButton +- `fontFamily` prop for canvas text (in addition to existing `msdfFontFamily` and `bitmapFontFamily`) + +#### UIScrollContainer +- Smooth scrolling enabled by default with new props: + - `smoothScroll` - Enable/disable eased scrolling (default: `true`) + - `scrollEasing` - Easing factor, 0.1=slow, 0.3=snappy (default: `0.15`) + - `scrollSpeed` - Wheel event multiplier (default: `1`) +- `scrollPaddingBottom` - Extra scrollable height at bottom (useful for chat UIs) +- `setScrollPaddingBottom(padding)` and `getScrollPaddingBottom()` methods +- `scrollTo(y, animate?)` now accepts optional `animate` parameter + +#### FlexContainer +- `removeAllChildren()` method for clearing all children at once + +#### UIScrollContainer +- `fontConfig` prop for font inheritance to nested children + +#### UILabel +- Now uses `getInheritedFontFamily()` for proper font inheritance from parent containers + +### Fixed +- Font inheritance now works through deeply nested containers (FlexContainer → UIScrollContainer → UILabel) +- Removed unnecessary `as any` cast in UIScrollContainer parent assignment + ## [0.3.2] - 2025-12-06 ### Added diff --git a/packages/ui/README.md b/packages/ui/README.md index 2bc6933..d69b48c 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -58,6 +58,9 @@ const row = new FlexContainer({ row.addChild(button1); row.addChild(button2); row.layout(400, 100); + +// Clear all children +row.removeAllChildren(); ``` ### Form Controls @@ -71,6 +74,9 @@ const button = new UIButton({ height: 40, backgroundColor: 0x4a90e2, textColor: 0xffffff, + fontFamily: 'PixelOperator', // Optional: custom font for canvas text + // Or use MSDF for crisp scaling: + // msdfFontFamily: 'MyMsdfFont', onClick: () => console.log('Clicked!'), onHover: () => console.log('Hovered!') }); @@ -84,8 +90,13 @@ const input = new UITextInput({ height: 32, placeholder: 'Enter text...', value: '', + fontFamily: 'PixelOperator', // Optional: custom font (inherits from parent) onChange: (value) => console.log('Input:', value) }); + +// Dynamic resizing (useful in resizable dialogs) +input.setSize(300, 40); +input.setWidth(250); ``` #### UITextArea @@ -97,6 +108,9 @@ const textarea = new UITextArea({ placeholder: 'Enter description...', onChange: (value) => console.log('Text:', value) }); + +// Dynamic resizing +textarea.setSize(400, 150); ``` #### UICheckbox @@ -163,6 +177,10 @@ const panel = new UIPanel({ borderColor: 0x444444, borderWidth: 1 }); + +// All UIComponents now have x/y getters/setters +panel.x = 100; +panel.y = 50; ``` ### Navigation @@ -188,11 +206,21 @@ const tabs = new UITabs({ const scroll = new UIScrollContainer({ width: 300, height: 400, - scrollbarWidth: 12 + scrollbarWidth: 12, + // Smooth scrolling (enabled by default) + smoothScroll: true, + scrollEasing: 0.15, // 0.1 = slow, 0.3 = snappy + scrollSpeed: 1, // Wheel sensitivity multiplier + scrollPaddingBottom: 50 // Extra scroll space at bottom (for chat UIs) }); -scroll.setContent(tallContent); -scroll.scrollTo(100); + +scroll.addChild(tallContent); +scroll.scrollTo(100); // Smooth scroll to position +scroll.scrollTo(100, false); // Instant scroll (no animation) scroll.scrollToBottom(); + +// Dynamic scroll padding +scroll.setScrollPaddingBottom(100); ``` ## Theming diff --git a/packages/ui/llms.txt b/packages/ui/llms.txt new file mode 100644 index 0000000..581dfa6 --- /dev/null +++ b/packages/ui/llms.txt @@ -0,0 +1,273 @@ +# @moxijs/ui + +> UI component library for PIXI.js games with theming and flexbox layout. + +## Install + +```bash +npm install @moxijs/ui pixi.js +``` + +## Quick Start + +```ts +import { FlexContainer, FlexDirection, UIButton, UILabel, EdgeInsets } from '@moxijs/ui'; + +const container = new FlexContainer({ + direction: FlexDirection.Column, + gap: 16, + padding: EdgeInsets.all(20), +}); + +container.addChild(new UILabel({ text: 'Welcome!', fontSize: 24 })); +container.addChild(new UIButton({ + label: 'Start', + width: 150, + height: 40, + onClick: () => console.log('Clicked!'), +})); + +container.layout(800, 600); +stage.addChild(container.container); +``` + +## Layout + +### FlexContainer +Flexbox-style layout container. + +```ts +const row = new FlexContainer({ + direction: FlexDirection.Row, // Row | Column + justify: FlexJustify.SpaceBetween, // Start | End | Center | SpaceBetween | SpaceAround + align: FlexAlign.Center, // Start | End | Center | Stretch + gap: 8, + padding: EdgeInsets.symmetric(16, 24), + backgroundColor: 0x333333, +}); +row.addChild(child1); +row.addChild(child2); +row.layout(width, height); +``` + +### EdgeInsets +Padding/margin helper. + +```ts +EdgeInsets.all(16); // All sides +EdgeInsets.symmetric(8, 16); // Vertical, Horizontal +EdgeInsets.only({ top: 10, left: 20 }); // Specific sides +EdgeInsets.zero(); // No padding +``` + +## Components + +### UIButton + +```ts +const button = new UIButton({ + label: 'Click Me', + width: 120, + height: 40, + backgroundColor: 0x4a90e2, + textColor: 0xffffff, + onClick: () => {}, + onHover: () => {}, +}); +``` + +### UILabel + +```ts +const label = new UILabel({ + text: 'Hello', + fontSize: 16, + color: 0xffffff, + fontFamily: 'Arial', + align: 'center', // 'left' | 'center' | 'right' +}); +label.setText('Updated'); +label.setColor(0xff0000); +``` + +### UITextInput + +```ts +const input = new UITextInput({ + width: 200, + height: 32, + placeholder: 'Enter text...', + value: '', + onChange: (value) => {}, +}); +input.getValue(); +input.setValue('new value'); +``` + +### UITextArea + +```ts +const textarea = new UITextArea({ + width: 300, + height: 100, + placeholder: 'Description...', + onChange: (value) => {}, +}); +``` + +### UICheckbox + +```ts +const checkbox = new UICheckbox({ + checked: false, + size: 20, + onChange: (checked) => {}, +}); +``` + +### UIRadioGroup + +```ts +const radio = new UIRadioGroup({ + options: [ + { value: 'a', label: 'Option A' }, + { value: 'b', label: 'Option B' }, + ], + value: 'a', + direction: 'vertical', + onChange: (value) => {}, +}); +``` + +### UISelect + +```ts +const select = new UISelect({ + width: 200, + options: [ + { value: 'opt1', label: 'Option 1' }, + { value: 'opt2', label: 'Option 2' }, + ], + value: 'opt1', + onChange: (value) => {}, +}); +``` + +### UIPanel + +```ts +const panel = new UIPanel({ + width: 300, + height: 200, + backgroundColor: 0x222222, + borderRadius: 8, + borderColor: 0x444444, + borderWidth: 1, +}); +``` + +### UITabs + +```ts +const tabs = new UITabs({ + width: 400, + height: 300, + items: [ + { key: 'tab1', label: 'Tab 1', content: panel1 }, + { key: 'tab2', label: 'Tab 2', content: panel2 }, + ], + activeKey: 'tab1', + onChange: (key) => {}, +}); +``` + +### UIScrollContainer + +```ts +const scroll = new UIScrollContainer({ + width: 300, + height: 400, + scrollbarWidth: 12, +}); +scroll.setContent(content); +scroll.scrollTo(y); +scroll.scrollToBottom(); +``` + +### CardPanel + +```ts +const card = new CardPanel({ + title: 'Settings', + width: 300, + height: 200, +}); +card.body.addChild(content); +``` + +## Theming + +### ThemeManager + +```ts +import { ThemeManager, createDefaultDarkTheme, createDefaultLightTheme } from '@moxijs/ui'; + +const manager = new ThemeManager('app-theme'); +manager.registerTheme({ name: 'Dark', variant: 'dark', theme: createDefaultDarkTheme() }); +manager.registerTheme({ name: 'Light', variant: 'light', theme: createDefaultLightTheme() }); +manager.setTheme('Dark'); +manager.addListener((theme, info) => { /* rebuild UI */ }); +``` + +### ThemeResolver + +```ts +import { ThemeResolver } from '@moxijs/ui'; + +const resolver = new ThemeResolver(manager.getTheme()); + +// Pass to components for automatic theming +const button = new UIButton({ themeResolver: resolver, label: 'OK' }); +const input = new UITextInput({ themeResolver: resolver }); +``` + +## Focus Management + +```ts +import { UIFocusManager } from '@moxijs/ui'; + +const focus = new UIFocusManager(); +focus.register(input1); +focus.register(input2); +focus.focusFirst(); +focus.focusNext(); // Tab navigation +``` + +## UILayer & Scaling + +```ts +import { UILayer, UIScaleMode } from '@moxijs/ui'; + +const ui = new UILayer({ + scaleMode: UIScaleMode.LockRatio, // None | ScaleUI | LockRatio + targetWidth: 1280, + targetHeight: 720, +}); +stage.addChild(ui); +``` + +## Exports Summary + +**Layout:** `FlexContainer`, `FlexDirection`, `FlexJustify`, `FlexAlign`, `EdgeInsets`, `BoxModel` + +**Components:** `UIButton`, `UILabel`, `UIPanel`, `UITextInput`, `UITextArea`, `UICheckbox`, `UIRadioGroup`, `UISelect`, `UITabs`, `UIScrollContainer`, `CardPanel` + +**Theming:** `ThemeManager`, `ThemeResolver`, `createDefaultDarkTheme`, `createDefaultLightTheme` + +**Base:** `UIComponent`, `UIFocusManager`, `UILayer`, `UIScaleMode` + +## See Also + +- **@moxijs/core** - Game engine, physics, entity system +- **Docs:** https://ineffably.github.io/moxijs/docs/ui/ +- **Examples:** https://ineffably.github.io/moxijs/ diff --git a/packages/ui/package.json b/packages/ui/package.json index 11d17d6..3653ec0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -7,7 +7,8 @@ "files": [ "lib", "README.md", - "LICENSE" + "LICENSE", + "llms.txt" ], "scripts": { "build": "rimraf ./lib && webpack --mode production", diff --git a/packages/ui/src/base/ui-component.ts b/packages/ui/src/base/ui-component.ts index 4e4e778..881bdb8 100644 --- a/packages/ui/src/base/ui-component.ts +++ b/packages/ui/src/base/ui-component.ts @@ -353,6 +353,36 @@ export abstract class UIComponent implements IFlexLayoutParticipant { }; } + /** + * Gets the x position of this component + */ + public get x(): number { + return this.container.x; + } + + /** + * Sets the x position of this component + */ + public set x(value: number) { + this.computedLayout.x = value; + this.container.x = value; + } + + /** + * Gets the y position of this component + */ + public get y(): number { + return this.container.y; + } + + /** + * Sets the y position of this component + */ + public set y(value: number) { + this.computedLayout.y = value; + this.container.y = value; + } + /** * Sets the position of this component */ diff --git a/packages/ui/src/components/ui-button.ts b/packages/ui/src/components/ui-button.ts index e27258a..af7617b 100644 --- a/packages/ui/src/components/ui-button.ts +++ b/packages/ui/src/components/ui-button.ts @@ -43,6 +43,8 @@ export interface UIButtonProps { textColor?: number; /** Font size */ fontSize?: number; + /** Font family for canvas text (inherits from parent if not specified) */ + fontFamily?: string; /** Border radius (only used with backgroundColor) */ borderRadius?: number; /** Padding inside button */ @@ -97,6 +99,8 @@ export class UIButton extends UIComponent { private bitmapFontFamily?: string; /** Local msdfFontFamily prop (can be overridden by parent inheritance) */ private localMsdfFontFamily?: string; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private localFontFamily?: string; private onClick?: () => void; private onHover?: () => void; @@ -144,6 +148,7 @@ export class UIButton extends UIComponent { this.useBitmapText = props.useBitmapText ?? false; this.bitmapFontFamily = props.bitmapFontFamily; this.localMsdfFontFamily = props.msdfFontFamily; + this.localFontFamily = props.fontFamily; this.onClick = props.onClick; this.onHover = props.onHover; @@ -244,12 +249,14 @@ export class UIButton extends UIComponent { padding: EdgeInsets.zero() }); } else { - // Regular canvas text + // Regular canvas text - resolve font family from local or parent + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily); this.label = new UILabel({ text: this.props.label, fontSize: this.props.fontSize, color: this.props.textColor, - align: 'center' + align: 'center', + fontFamily: effectiveFontFamily }, { padding: EdgeInsets.zero() }); diff --git a/packages/ui/src/components/ui-label.ts b/packages/ui/src/components/ui-label.ts index 49a4355..2779d38 100644 --- a/packages/ui/src/components/ui-label.ts +++ b/packages/ui/src/components/ui-label.ts @@ -26,7 +26,13 @@ export interface UILabelProps { wordWrapWidth?: number; /** Font weight */ fontWeight?: string; - /** Line height multiplier */ + /** + * Line height as a multiplier of fontSize (like CSS unitless line-height). + * - 1.0 = no extra spacing (tight) + * - 1.2 = 20% extra spacing (default) + * - 2.0 = double spacing + * Note: This is NOT absolute pixels - use 1.2, not 24 for an 18px font. + */ lineHeight?: number; /** MSDF font family name. If provided, uses MSDF (Multi-channel Signed Distance Field) text rendering for crisp text at any scale. Must match the loaded MSDF font's family name. */ msdfFontFamily?: string; @@ -65,6 +71,8 @@ export class UILabel extends UIComponent { private readonly dprScale = 2; // DPR scaling factor for crisp text /** Local msdfFontFamily prop (can be overridden by parent inheritance) */ private readonly localMsdfFontFamily?: string; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private readonly localFontFamily?: string; /** Track if text object has been initialized */ private textInitialized = false; @@ -73,13 +81,14 @@ export class UILabel extends UIComponent { constructor(props: UILabelProps, boxModel?: Partial) { super(boxModel); - // Store local prop - actual value will be resolved via inheritance + // Store local props - actual values will be resolved via inheritance this.localMsdfFontFamily = props.msdfFontFamily; + this.localFontFamily = props.fontFamily; this.props = { text: props.text, fontSize: props.fontSize ?? 16, - fontFamily: props.fontFamily ?? UI_DEFAULTS.FONT_FAMILY, // Default to pixel-perfect font + fontFamily: props.fontFamily ?? UI_DEFAULTS.FONT_FAMILY, // Default (may be overridden by inheritance) color: props.color ?? 0xffffff, align: props.align ?? 'left', wordWrap: props.wordWrap ?? false, @@ -131,9 +140,12 @@ export class UILabel extends UIComponent { /** @internal */ private getTextStyle(): { [key: string]: any } { + // Resolve fontFamily through inheritance chain (local -> parent -> default) + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; + // Note: fontSize is multiplied by dprScale in asTextDPR, so we pass display size here const style: { [key: string]: any } = { - fontFamily: this.props.fontFamily, + fontFamily: effectiveFontFamily, fontSize: this.props.fontSize, // Display size - asTextDPR will multiply by dprScale fill: this.props.color, align: this.props.align, diff --git a/packages/ui/src/components/ui-scroll-container.ts b/packages/ui/src/components/ui-scroll-container.ts index 552bab1..408d9e2 100644 --- a/packages/ui/src/components/ui-scroll-container.ts +++ b/packages/ui/src/components/ui-scroll-container.ts @@ -1,5 +1,5 @@ import * as PIXI from 'pixi.js'; -import { UIComponent } from '../base/ui-component'; +import { UIComponent, UIFontConfig } from '../base/ui-component'; import { BoxModel, MeasuredSize } from '../base/box-model'; import { EdgeInsets } from '../base/edge-insets'; import { LayoutEngine } from '../services'; @@ -30,6 +30,24 @@ export interface UIScrollContainerProps { scrollbarAutoHide?: boolean; /** Padding inside the scroll container */ padding?: EdgeInsets; + /** Enable smooth/eased scrolling (default: true) */ + smoothScroll?: boolean; + /** Easing factor for smooth scroll (0.1 = slow, 0.3 = snappy, default: 0.15) */ + scrollEasing?: number; + /** Scroll speed multiplier for wheel events (default: 1) */ + scrollSpeed?: number; + /** + * Extra height added to scrollable content area. + * Allows content to scroll beyond its natural bounds. + * Useful for chat interfaces where the last item should scroll to the top. + * (default: 0) + */ + scrollPaddingBottom?: number; + /** + * Font configuration that children will inherit (like CSS). + * Allows setting fontFamily, fontSize, etc. once at container level. + */ + fontConfig?: UIFontConfig; } /** @@ -51,7 +69,7 @@ export interface UIScrollContainerProps { * ``` */ export class UIScrollContainer extends UIComponent { - private props: Required; + private props: Required>; private background: PIXI.Graphics; private contentContainer: PIXI.Container; private contentMask: PIXI.Graphics; @@ -64,6 +82,7 @@ export class UIScrollContainer extends UIComponent { public children: UIComponent[] = []; private scrollY: number = 0; + private targetScrollY: number = 0; private maxScrollY: number = 0; private contentHeight: number = 0; private isDragging: boolean = false; @@ -71,6 +90,10 @@ export class UIScrollContainer extends UIComponent { private dragStartScrollY: number = 0; private isHoveringThumb: boolean = false; + // Smooth scrolling animation + private animationFrameId: number | null = null; + private isAnimating: boolean = false; + // Bound handlers for proper cleanup private boundGlobalPointerMove: ((e: PointerEvent) => void) | null = null; private boundGlobalPointerUp: (() => void) | null = null; @@ -78,6 +101,10 @@ export class UIScrollContainer extends UIComponent { constructor(props: UIScrollContainerProps, boxModel?: Partial) { super(boxModel); + // Set font config if provided (children will inherit this) + if (props.fontConfig) { + this.setFontConfig(props.fontConfig); + } this.props = { width: props.width, @@ -89,7 +116,11 @@ export class UIScrollContainer extends UIComponent { scrollbarThumbColor: props.scrollbarThumbColor ?? 0x888888, scrollbarThumbHoverColor: props.scrollbarThumbHoverColor ?? 0x555555, scrollbarAutoHide: props.scrollbarAutoHide ?? false, - padding: props.padding ?? EdgeInsets.all(0) + padding: props.padding ?? EdgeInsets.all(0), + smoothScroll: props.smoothScroll ?? true, + scrollEasing: props.scrollEasing ?? 0.15, + scrollSpeed: props.scrollSpeed ?? 1, + scrollPaddingBottom: props.scrollPaddingBottom ?? 0 }; // Set box model dimensions @@ -211,12 +242,56 @@ export class UIScrollContainer extends UIComponent { private handleWheel(e: PIXI.FederatedWheelEvent): void { e.preventDefault(); - const delta = e.deltaY; - this.scrollY += delta; - this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + const delta = e.deltaY * this.props.scrollSpeed; + + if (this.props.smoothScroll) { + // Smooth scrolling: set target and animate + this.targetScrollY += delta; + this.targetScrollY = Math.max(0, Math.min(this.maxScrollY, this.targetScrollY)); + this.startSmoothScroll(); + } else { + // Instant scrolling (legacy behavior) + this.scrollY += delta; + this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + this.targetScrollY = this.scrollY; + this.updateContentPosition(); + this.updateScrollbar(); + } + } + /** + * Starts the smooth scroll animation loop + */ + private startSmoothScroll(): void { + if (this.isAnimating) return; + + this.isAnimating = true; + this.animateSmoothScroll(); + } + + /** + * Animation frame for smooth scrolling + */ + private animateSmoothScroll(): void { + const diff = this.targetScrollY - this.scrollY; + + // Stop animation when close enough + if (Math.abs(diff) < 0.5) { + this.scrollY = this.targetScrollY; + this.isAnimating = false; + this.animationFrameId = null; + this.updateContentPosition(); + this.updateScrollbar(); + return; + } + + // Ease toward target + this.scrollY += diff * this.props.scrollEasing; this.updateContentPosition(); this.updateScrollbar(); + + // Continue animation + this.animationFrameId = requestAnimationFrame(() => this.animateSmoothScroll()); } /** @@ -240,11 +315,17 @@ export class UIScrollContainer extends UIComponent { // Calculate target scroll position const clickRatio = (localY - trackTop) / (trackBottom - trackTop); - this.scrollY = clickRatio * this.maxScrollY; - this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + const targetY = Math.max(0, Math.min(this.maxScrollY, clickRatio * this.maxScrollY)); - this.updateContentPosition(); - this.updateScrollbar(); + if (this.props.smoothScroll) { + this.targetScrollY = targetY; + this.startSmoothScroll(); + } else { + this.scrollY = targetY; + this.targetScrollY = targetY; + this.updateContentPosition(); + this.updateScrollbar(); + } } /** @@ -263,6 +344,8 @@ export class UIScrollContainer extends UIComponent { const scrollDelta = (deltaY / scrollableTrackHeight) * this.maxScrollY; this.scrollY = this.dragStartScrollY + scrollDelta; this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + // Sync target to prevent animation fighting with drag + this.targetScrollY = this.scrollY; this.updateContentPosition(); this.updateScrollbar(); @@ -380,10 +463,12 @@ export class UIScrollContainer extends UIComponent { this.contentHeight = maxHeight; const viewportHeight = this.props.height - this.props.padding.top - this.props.padding.bottom; - this.maxScrollY = Math.max(0, this.contentHeight - viewportHeight); + // Add scrollPaddingBottom to allow content to scroll beyond its natural bounds + this.maxScrollY = Math.max(0, this.contentHeight + this.props.scrollPaddingBottom - viewportHeight); - // Clamp current scroll position + // Clamp current scroll position and target this.scrollY = Math.max(0, Math.min(this.maxScrollY, this.scrollY)); + this.targetScrollY = Math.max(0, Math.min(this.maxScrollY, this.targetScrollY)); this.updateContentPosition(); this.updateScrollbar(); @@ -393,8 +478,8 @@ export class UIScrollContainer extends UIComponent { * Adds a child to the scrollable content */ addChild(child: UIComponent): void { - // Set parent reference for scroll-into-view support - child.parent = this as any; + // Set parent reference for font inheritance and scroll-into-view support + child.parent = this; // Track child for focus manager discovery this.children.push(child); @@ -427,11 +512,22 @@ export class UIScrollContainer extends UIComponent { /** * Scrolls to a specific Y position + * @param y - Target scroll position + * @param animate - Whether to animate the scroll (default: uses smoothScroll prop) */ - scrollTo(y: number): void { - this.scrollY = Math.max(0, Math.min(this.maxScrollY, y)); - this.updateContentPosition(); - this.updateScrollbar(); + scrollTo(y: number, animate?: boolean): void { + const shouldAnimate = animate ?? this.props.smoothScroll; + const clampedY = Math.max(0, Math.min(this.maxScrollY, y)); + + if (shouldAnimate) { + this.targetScrollY = clampedY; + this.startSmoothScroll(); + } else { + this.scrollY = clampedY; + this.targetScrollY = clampedY; + this.updateContentPosition(); + this.updateScrollbar(); + } } /** @@ -462,6 +558,22 @@ export class UIScrollContainer extends UIComponent { return this.maxScrollY; } + /** + * Sets the scroll padding bottom (extra scrollable area at bottom) + * @param padding - Extra height in pixels + */ + setScrollPaddingBottom(padding: number): void { + this.props.scrollPaddingBottom = padding; + this.updateContentBounds(); + } + + /** + * Gets the current scroll padding bottom + */ + getScrollPaddingBottom(): number { + return this.props.scrollPaddingBottom; + } + /** * Scrolls to make a child component visible */ @@ -518,6 +630,13 @@ export class UIScrollContainer extends UIComponent { * Cleanup when destroying */ destroy(): void { + // Cancel any pending animation frame + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.isAnimating = false; + if (typeof window !== 'undefined') { if (this.boundGlobalPointerMove) { window.removeEventListener('pointermove', this.boundGlobalPointerMove); diff --git a/packages/ui/src/components/ui-text-input.ts b/packages/ui/src/components/ui-text-input.ts index 947f2ad..d97908b 100644 --- a/packages/ui/src/components/ui-text-input.ts +++ b/packages/ui/src/components/ui-text-input.ts @@ -45,6 +45,8 @@ export interface UITextInputProps { borderRadius?: number; /** Font size */ fontSize?: number; + /** Font family (inherits from parent if not specified) */ + fontFamily?: string; /** Input type for validation */ type?: 'text' | 'number'; /** Optional ThemeResolver for automatic color resolution */ @@ -68,7 +70,9 @@ export interface UITextInputProps { */ export class UITextInput extends UIComponent { // Props - private props: Required>; + private props: Required>; + /** Local fontFamily prop (can be overridden by parent inheritance) */ + private localFontFamily?: string; // Services (composition) private stateManager: FormStateManager; @@ -120,6 +124,9 @@ export class UITextInput extends UIComponent { placeholderColor: props.placeholderColor }; + // Store fontFamily for inheritance + this.localFontFamily = props.fontFamily; + // Initialize theme resolver this.themeResolver = props.themeResolver; @@ -174,10 +181,13 @@ export class UITextInput extends UIComponent { ? this.resolvePlaceholderColor(this.colorOverrides.placeholderColor) : this.resolveTextColor(this.colorOverrides.textColor); + // Resolve font family - check local prop first, then inherit from parent + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; + this.textDisplay = asTextDPR({ text: displayText, style: { - fontFamily: UI_DEFAULTS.FONT_FAMILY, + fontFamily: effectiveFontFamily, fontSize: this.props.fontSize, fill: textColor, align: 'left' @@ -330,10 +340,12 @@ export class UITextInput extends UIComponent { this.inputHandler.setCursorPosition(value.length); } const dprScale = UI_DEFAULTS.DPR_SCALE; + // Use same font family as display text + const effectiveFontFamily = this.getInheritedFontFamily(this.localFontFamily) ?? UI_DEFAULTS.FONT_FAMILY; const tempText = new PIXI.Text({ text: textUpToCursor, style: { - fontFamily: UI_DEFAULTS.FONT_FAMILY, // Match the display font + fontFamily: effectiveFontFamily, // Match the display font fontSize: this.props.fontSize * dprScale, // Account for DPR scaling fill: this.textDisplay.style.fill, align: 'left' @@ -438,6 +450,71 @@ export class UITextInput extends UIComponent { this.updateCursor(); } + /** + * Sets the size of the input and redraws + * @param width - New width in pixels + * @param height - New height in pixels (optional, keeps current if not provided) + */ + setSize(width: number, height?: number): void { + this.props.width = width; + if (height !== undefined) { + this.props.height = height; + } + this.boxModel.width = this.props.width; + this.boxModel.height = this.props.height; + + // Recreate visuals with new size + this.recreateVisuals(); + this.markLayoutDirty(); + } + + /** + * Sets the width of the input + */ + setWidth(width: number): void { + this.setSize(width, this.props.height); + } + + /** + * Gets the current width + */ + getWidth(): number { + return this.props.width; + } + + /** + * Gets the current height + */ + getHeight(): number { + return this.props.height; + } + + /** + * Recreates visual elements (used after size change) + */ + private recreateVisuals(): void { + // Update background size + this.background.destroy(); + + const bgColor = this.resolveColor('background', this.colorOverrides.backgroundColor); + this.background = new UIPanel({ + backgroundColor: bgColor, + width: this.props.width, + height: this.props.height, + borderRadius: this.props.borderRadius + }); + + // Insert background at position 0 (behind other elements) + this.container.addChildAt(this.background.container, 0); + + // Update text position + const textY = Math.round((this.props.height - this.props.fontSize) / 2); + this.textDisplay.position.set(12, textY); + + // Update cursor + this.updateCursor(); + } + /** * Cleanup when destroying */ diff --git a/packages/ui/src/components/ui-textarea.ts b/packages/ui/src/components/ui-textarea.ts index b01f487..54ebe30 100644 --- a/packages/ui/src/components/ui-textarea.ts +++ b/packages/ui/src/components/ui-textarea.ts @@ -474,6 +474,98 @@ export class UITextArea extends UIComponent { this.updateText(); } + /** + * Sets the size of the textarea and redraws + * @param width - New width in pixels + * @param height - New height in pixels (optional, keeps current if not provided) + */ + setSize(width: number, height?: number): void { + this.props.width = width; + if (height !== undefined) { + this.props.height = height; + } + this.boxModel.width = this.props.width; + this.boxModel.height = this.props.height; + + // Recreate visuals with new size + this.recreateVisuals(); + this.markLayoutDirty(); + } + + /** + * Sets the width of the textarea + */ + setWidth(width: number): void { + this.setSize(width, this.props.height); + } + + /** + * Gets the current width + */ + getWidth(): number { + return this.props.width; + } + + /** + * Gets the current height + */ + getHeight(): number { + return this.props.height; + } + + /** + * Recreates visual elements (used after size change) + */ + private recreateVisuals(): void { + // Remove old visuals + this.background.destroy(); + this.container.removeChild(this.textDisplay); + this.textDisplay.destroy(); + + // Recreate background + const bgColor = this.focused + ? this.resolveColor('focus', this.colorOverrides.backgroundColor) + : this.resolveColor('background', this.colorOverrides.backgroundColor); + + this.background = new UIPanel({ + backgroundColor: bgColor, + width: this.props.width, + height: this.props.height, + borderRadius: this.props.borderRadius + }); + this.container.addChildAt(this.background.container, 0); + + // Recreate text display with new word wrap width + const displayText = this.getDisplayText(); + const dprScale = UI_DEFAULTS.DPR_SCALE; + const value = this.stateManager.getValue(); + const textColor = value + ? this.resolveTextColor(this.colorOverrides.textColor) + : this.resolvePlaceholderColor(this.colorOverrides.placeholderColor); + + this.textDisplay = asTextDPR({ + text: displayText, + style: { + fontFamily: UI_DEFAULTS.FONT_FAMILY, + fontSize: this.props.fontSize, + fill: textColor, + align: 'left', + wordWrap: true, + wordWrapWidth: (this.props.width - 24) * dprScale, + lineHeight: this.props.fontSize * this.props.lineHeight * dprScale + }, + dprScale, + pixelPerfect: true + }); + this.textDisplay.position.set(12, 12); + this.textDisplay.roundPixels = true; + // Insert before cursor (cursor should be last) + this.container.addChildAt(this.textDisplay, this.container.children.length - 1); + + // Update cursor position + this.updateCursor(); + } + /** * Cleanup when destroying */ diff --git a/packages/ui/src/layout/flex-container.ts b/packages/ui/src/layout/flex-container.ts index b5878ab..3c732d5 100644 --- a/packages/ui/src/layout/flex-container.ts +++ b/packages/ui/src/layout/flex-container.ts @@ -184,6 +184,14 @@ export class FlexContainer extends UIComponent { } } + /** Remove all children from this container. */ + removeAllChildren(): void { + // Remove in reverse order to avoid index shifting issues + while (this.children.length > 0) { + this.removeChild(this.children[this.children.length - 1]); + } + } + /** * Automatically unregisters a focusable component from the global focus manager. * Recursively checks children if the component is a container. diff --git a/packages/ui/tests/base/ui-component.test.ts b/packages/ui/tests/base/ui-component.test.ts index 439846b..ba38b7c 100644 --- a/packages/ui/tests/base/ui-component.test.ts +++ b/packages/ui/tests/base/ui-component.test.ts @@ -4,17 +4,32 @@ import { EdgeInsets } from '../../src/base/edge-insets'; // Mock PIXI.js jest.mock('pixi.js', () => ({ - Container: jest.fn().mockImplementation(() => ({ - addChild: jest.fn(), - addChildAt: jest.fn(), - removeChild: jest.fn(), - destroy: jest.fn(), - position: { set: jest.fn() }, - visible: true, - parent: null, - effects: [], - getGlobalPosition: jest.fn().mockReturnValue({ x: 0, y: 0 }), - })), + Container: jest.fn().mockImplementation(() => { + const container: any = { + addChild: jest.fn(), + addChildAt: jest.fn(), + removeChild: jest.fn(), + destroy: jest.fn(), + _x: 0, + _y: 0, + get x() { return this._x; }, + set x(v: number) { this._x = v; }, + get y() { return this._y; }, + set y(v: number) { this._y = v; }, + visible: true, + parent: null, + effects: [], + getGlobalPosition: jest.fn().mockReturnValue({ x: 0, y: 0 }), + }; + // position.set should update both x and y like real PIXI + container.position = { + set: jest.fn((x: number, y: number) => { + container._x = x; + container._y = y; + }) + }; + return container; + }), Graphics: jest.fn().mockImplementation(() => ({ clear: jest.fn().mockReturnThis(), roundRect: jest.fn().mockReturnThis(), @@ -239,13 +254,45 @@ describe('UIComponent', () => { }); describe('position', () => { - it('should set position', () => { + it('should set position via setPosition()', () => { component.setPosition(100, 200); const layout = component.getLayout(); expect(layout.x).toBe(100); expect(layout.y).toBe(200); }); + + it('should get x position', () => { + component.setPosition(150, 250); + expect(component.x).toBe(150); + }); + + it('should get y position', () => { + component.setPosition(150, 250); + expect(component.y).toBe(250); + }); + + it('should set x position via setter', () => { + component.x = 300; + expect(component.x).toBe(300); + expect(component.getLayout().x).toBe(300); + }); + + it('should set y position via setter', () => { + component.y = 400; + expect(component.y).toBe(400); + expect(component.getLayout().y).toBe(400); + }); + + it('should update container position when setting x', () => { + component.x = 100; + expect(component.container.x).toBe(100); + }); + + it('should update container position when setting y', () => { + component.y = 200; + expect(component.container.y).toBe(200); + }); }); describe('state', () => { diff --git a/packages/ui/tests/layout/flex-container.test.ts b/packages/ui/tests/layout/flex-container.test.ts index c906c48..7bc7273 100644 --- a/packages/ui/tests/layout/flex-container.test.ts +++ b/packages/ui/tests/layout/flex-container.test.ts @@ -220,6 +220,34 @@ describe('FlexContainer', () => { // (we can't directly test layoutDirty, but we can verify behavior) expect(container.children.length).toBe(1); }); + + it('should remove all children', () => { + const container = new FlexContainer(); + const child1 = new MockChildComponent(); + const child2 = new MockChildComponent(); + const child3 = new MockChildComponent(); + + container.addChild(child1); + container.addChild(child2); + container.addChild(child3); + expect(container.children.length).toBe(3); + + container.removeAllChildren(); + + expect(container.children.length).toBe(0); + expect(child1.parent).toBeUndefined(); + expect(child2.parent).toBeUndefined(); + expect(child3.parent).toBeUndefined(); + }); + + it('should handle removeAllChildren on empty container', () => { + const container = new FlexContainer(); + + // Should not throw + container.removeAllChildren(); + + expect(container.children.length).toBe(0); + }); }); describe('measure', () => {