UNPKG

asciitorium

Version:
553 lines (552 loc) 20.2 kB
import { LayoutRegistry } from './layouts/Layout.js'; import { resolveGap } from './utils/gapUtils.js'; import { resolveSize } from './utils/sizeUtils.js'; import { requestRender } from './RenderScheduler.js'; // Border character set const SINGLE_BORDER_CHARS = { topLeft: '╭', topRight: '╮', bottomLeft: '╰', bottomRight: '╯', horizontal: '─', vertical: '│' }; /** * Merges individual style properties with a style object. * Individual properties take precedence over style object properties. * * @param props Component properties containing individual and/or consolidated styles * @returns Merged style configuration */ function mergeStyles(props) { const style = props.style || {}; return { width: props.width ?? style.width, height: props.height ?? style.height, border: props.border ?? style.border, background: props.background ?? style.background, align: props.align ?? style.align, position: props.position ?? style.position, gap: props.gap ?? style.gap, font: style.font, layout: style.layout, }; } // ============================================================================ // Component Base Class // ============================================================================ /** * Abstract base class for all UI components in the asciitorium framework. * * Provides core functionality including: * - **Position and size management**: Supports absolute sizing, percentages, 'auto', and 'fill' * - **Child component management**: Automatic layout calculation via Row/Column layouts * - **Focus handling**: Visual indicators and keyboard navigation support * - **State binding**: Reactive updates when bound state changes * - **ASCII-based rendering**: Character-based 2D buffers with transparency * - **Dynamic content switching**: Runtime component replacement based on state * * ## Rendering System * * Components use a character-based rendering system where each component * renders to a 2D string array buffer. The transparent character '‽' allows * for overlay effects and complex compositions. * * ## Focus System * * Focus-enabled components automatically switch between single-line borders * (╭╮╰╯─│) and double-line borders (╔╗╚╝═║) when focused. * * ## Layout System * * Components can be positioned in two ways: * 1. **Relative positioning**: Using Row/Column layouts (default) * 2. **Absolute positioning**: Using the `position` prop with x/y/z coordinates * * @example * Creating a custom component: * ```typescript * class MyComponent extends Component { * constructor(options: ComponentProps) { * super({ * ...options, * width: options.width ?? 20, * height: options.height ?? 10, * border: true * }); * } * * override draw(): string[][] { * // Custom rendering logic * this.buffer = Array.from({ length: this.height }, () => * Array.from({ length: this.width }, () => ' ') * ); * return this.buffer; * } * } * ``` * * @example * Using component props with JSX: * ```tsx * <Button * width="50%" * height={5} * border * align="center" * position={{ x: 10, y: 5, z: 100 }} * gap={{ x: 2, y: 1 }} * > * Click Me * </Button> * ``` */ export class Component { /** * Initializes a new Component with the provided properties. * * @param props Configuration object containing style, layout, and behavior options */ constructor(props) { /** Whether to display the label when provided */ this.showLabel = true; /** Whether the component uses fixed positioning */ this.fixed = false; /** Absolute X position */ this.x = 0; /** Absolute Y position */ this.y = 0; /** Z-index for rendering order (higher values on top) */ this.z = 0; /** Spacing around the component */ this.gap = 0; /** Whether this component can receive keyboard focus */ this.focusable = false; /** Whether this component currently has focus */ this.hasFocus = false; /** * When true, component captures ALL keyboard input except bypass keys. * Used by input components (TextInput, etc.) to receive all keystrokes. */ this.captureModeActive = false; /** Character used for transparency in rendering ('‽' allows overlays) */ this.transparentChar = '‽'; /** Cleanup functions for state subscriptions */ this.unbindFns = []; /** Cleanup functions registered via registerCleanup() */ this.cleanupFns = []; /** Child components managed by this component */ this.children = []; const mergedStyle = mergeStyles(props); // Store original size values for relative sizing calculations this.originalWidth = mergedStyle.width; this.originalHeight = mergedStyle.height; // Initialize dimensions - use explicit values or temporary defaults if (typeof mergedStyle.width === 'number') { this.width = mergedStyle.width; } else { this.width = 1; // Temporary - recalculated after layout } if (typeof mergedStyle.height === 'number') { this.height = mergedStyle.height; } else { this.height = 1; // Temporary - recalculated after layout } // Initialize basic properties this.label = props.label; this.comment = props.comment; this.showLabel = props.showLabel ?? true; this.border = mergedStyle.border ?? false; this.fill = mergedStyle.background ?? ' '; this.align = mergedStyle.align; // Handle positioning if (mergedStyle.position) { this.x = mergedStyle.position.x ?? 0; this.y = mergedStyle.position.y ?? 0; this.z = mergedStyle.position.z ?? 0; this.fixed = true; // position property implies fixed positioning } else { this.x = 0; this.y = 0; this.z = 0; this.fixed = false; } this.gap = mergedStyle.gap ?? 0; this.hotkey = props.hotkey; this.buffer = []; // Store visibility state reference (defaults to always visible if not provided) this.visibleState = props.visible; // Initialize layout system this.layoutType = props.layout ?? 'column'; this.layoutOptions = props.layoutOptions; // Initialize children if provided this.initializeChildren(props); } setParent(parent) { this.parent = parent; } /** * Gets the current visibility state of the component. * @returns True if the component is visible (default), false if explicitly hidden */ get visible() { return this.visibleState?.value ?? true; } /** * Initializes child components from props and sets up layout. * * @param props Component properties containing potential children */ initializeChildren(props) { if (!props.children) return; const childList = Array.isArray(props.children) ? props.children : [props.children]; for (const child of childList) { if (this.isValidChild(child)) { child.setParent(this); this.children.push(child); } } this.recalculateLayout(); } /** * Validates that a potential child is a valid Component. * * @param child Potential child component to validate * @returns True if the child is a valid Component */ isValidChild(child) { return child && typeof child === 'object' && typeof child.setParent === 'function'; } // Child management methods addChild(child) { child.setParent(this); this.children.push(child); this.recalculateLayout(); requestRender(); } removeChild(child) { const index = this.children.indexOf(child); if (index !== -1) { this.children.splice(index, 1); this.recalculateLayout(); requestRender(); } } getChildren() { return this.children; } setChildren(children) { for (const child of children) { if (this.isValidChild(child)) { child.setParent(this); this.children.push(child); } } this.recalculateLayout(); requestRender(); } getAllDescendants() { const result = []; for (const child of this.children) { result.push(child); const grandChildren = child.getAllDescendants(); result.push(...grandChildren); } return result; } invalidateLayout() { this.layout = undefined; } recalculateLayout() { if (this.children.length === 0) return; if (!this.layout) { this.layout = LayoutRegistry.create(this.layoutType, this.layoutOptions); } this.layout.layout(this, this.children); // After layout, recalculate auto-sizing if needed this.recalculateAutoSize(); } recalculateAutoSize() { let sizeChanged = false; // Recalculate width if it should be auto-sized if (this.originalWidth === undefined && this.children.length > 0) { const autoWidth = Component.calculateAutoWidth(this.children, this.layoutType); const borderAdjustment = this.border ? 2 : 0; const newWidth = Math.max(1, autoWidth + borderAdjustment); if (newWidth !== this.width) { this.width = newWidth; sizeChanged = true; } } // Recalculate height if it should be auto-sized if (this.originalHeight === undefined && this.children.length > 0) { const autoHeight = Component.calculateAutoHeight(this.children, this.layoutType); const borderAdjustment = this.border ? 2 : 0; const newHeight = Math.max(1, autoHeight + borderAdjustment); if (newHeight !== this.height) { this.height = newHeight; sizeChanged = true; } } // If our size changed, we need to notify parent to recalculate its layout if (sizeChanged && this.parent) { this.parent.recalculateLayout(); } } bind(state, apply) { const listener = (val) => { apply(val); }; state.subscribe(listener); this.unbindFns.push(() => state.unsubscribe(listener)); } /** * Registers a cleanup function to be called when the component is destroyed. * Use this for cleaning up timers, intervals, event listeners, and other resources. * * Example: * ```typescript * const intervalId = setInterval(() => {...}, 1000); * component.registerCleanup(() => clearInterval(intervalId)); * ``` * * Note: State subscriptions created via bind() are automatically cleaned up * and do not need to be registered with registerCleanup(). * * @param fn Cleanup function to execute on destroy */ registerCleanup(fn) { this.cleanupFns.push(fn); } destroy() { // Recursively destroy all children first const childrenToDestroy = [...this.children]; for (const child of childrenToDestroy) { child.destroy(); } // Run registered cleanup functions for (const cleanup of this.cleanupFns) { try { cleanup(); } catch (error) { console.error('Error during cleanup:', error); } } this.cleanupFns = []; // Then clean up state subscriptions for (const unbind of this.unbindFns) unbind(); this.unbindFns = []; } // Auto-sizing methods static calculateAutoWidth(children, layout) { if (!children || children.length === 0) return 1; if (layout === 'row') { // Sum widths + gaps for row layout return children.reduce((sum, child) => { const gap = resolveGap(child.gap); return sum + child.width + gap.left + gap.right; }, 0); } else { // Max width for column layout (including horizontal gaps) return Math.max(...children.map((child) => { const gap = resolveGap(child.gap); return child.width + gap.left + gap.right; })); } } static calculateAutoHeight(children, layout) { if (!children || children.length === 0) return 1; if (layout === 'column') { // Sum heights + gaps for column layout return children.reduce((sum, child) => { const gap = resolveGap(child.gap); return sum + child.height + gap.top + gap.bottom; }, 0); } else { // Max height for row layout (including vertical gaps) return Math.max(...children.map((child) => { const gap = resolveGap(child.gap); return child.height + gap.top + gap.bottom; })); } } notifyAppOfFocusRefresh() { // Walk up the parent chain to find the App let current = this; while (current && !current.isApp) { current = current.parent; } // If we found the App, refresh its focus manager (preserves current focus) if (current && current.focus) { current.focus.refresh(current); } } /** * Notifies the application's focus manager that the component tree has changed * and focus needs to be completely reset (e.g., when swapping out child components). */ notifyAppOfFocusReset() { // Walk up the parent chain to find the App let current = this; while (current && !current.isApp) { current = current.parent; } // If we found the App, reset its focus manager if (current && current.focus) { current.focus.reset(current); } } // Size resolution methods getOriginalWidth() { return this.originalWidth; } getOriginalHeight() { return this.originalHeight; } resolveSize(context) { if (this.originalWidth !== undefined) { const resolved = resolveSize(this.originalWidth, context, 'width'); if (resolved !== undefined) { this.width = resolved; } } if (this.originalHeight !== undefined) { const resolved = resolveSize(this.originalHeight, context, 'height'); if (resolved !== undefined) { this.height = resolved; } } // Ensure minimum size if (this.width < 1) this.width = 1; if (this.height < 1) this.height = 1; } handleEvent(_event) { return false; } /** * Draws the border around the component with diamond corners for focus indication. * * @param drawChar Helper function for safe character drawing within bounds */ drawBorder(drawChar) { const w = this.width; const h = this.height; // Draw corners drawChar(0, 0, SINGLE_BORDER_CHARS.topLeft); drawChar(w - 1, 0, SINGLE_BORDER_CHARS.topRight); drawChar(0, h - 1, SINGLE_BORDER_CHARS.bottomLeft); drawChar(w - 1, h - 1, SINGLE_BORDER_CHARS.bottomRight); // Draw horizontal lines for (let x = 1; x < w - 1; x++) { drawChar(x, 0, SINGLE_BORDER_CHARS.horizontal); drawChar(x, h - 1, SINGLE_BORDER_CHARS.horizontal); } // Draw vertical lines for (let y = 1; y < h - 1; y++) { drawChar(0, y, SINGLE_BORDER_CHARS.vertical); drawChar(w - 1, y, SINGLE_BORDER_CHARS.vertical); } } /** * Renders all child components sorted by z-index and composites them into the buffer. */ renderChildren() { // Sort children by z-index (lower values render first, higher on top) const sorted = [...this.children].sort((a, b) => a.z - b.z); for (const child of sorted) { this.compositeChildBuffer(child); } } /** * Composites a single child component's buffer into this component's buffer. * * @param child The child component to composite */ compositeChildBuffer(child) { const childBuffer = child.draw(); for (let j = 0; j < childBuffer.length; j++) { for (let i = 0; i < childBuffer[j].length; i++) { const px = child.x + i; const py = child.y + j; if (px >= 0 && px < this.width && py >= 0 && py < this.height) { // Defensive check: ensure buffer row exists (race condition protection) if (!this.buffer[py]) continue; const char = childBuffer[j][i]; if (char !== child.transparentChar) { // Prevent children from overwriting border positions const isBorderPosition = this.border && (py === 0 || py === this.height - 1 || px === 0 || px === this.width - 1); if (!isBorderPosition) { // Defensive check: ensure buffer column exists (race condition protection) if (px < this.buffer[py].length) { this.buffer[py][px] = char; } } } } } } } /** * Check if hotkey visibility is enabled by finding the App's FocusManager */ isHotkeyVisibilityEnabled() { let current = this; while (current && !current.isApp) { current = current.parent; } if (current && current.focus) { return current.focus.hotkeyVisibilityState.value; } return false; } draw() { if (!this.visible) { // If not visible, return empty buffer return []; } // Recalculate layout for children this.recalculateLayout(); // Create buffer and fill only if not transparent this.buffer = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => this.fill === this.transparentChar ? '‽' : this.fill)); // Helper function for safe character drawing within bounds const drawChar = (x, y, char) => { if (x >= 0 && x < this.width && y >= 0 && y < this.height) { this.buffer[y][x] = char; } }; // Draw border if enabled if (this.border) { this.drawBorder(drawChar); } if (this.label && this.showLabel) { const label = ` ${this.label} `; const start = Math.max(1, Math.floor((this.width - label.length) / 2)); for (let i = 0; i < label.length && i + start < this.width - 1; i++) { drawChar(i + start, 0, label[i]); } } // Draw hotkey indicator at position (1, 0) if border is enabled and hotkey visibility is on if (this.border && this.hotkey && this.isHotkeyVisibilityEnabled()) { const hotkeyDisplay = `[${this.hotkey.toUpperCase()}]`; for (let i = 0; i < hotkeyDisplay.length && i + 1 < this.width - 1; i++) { drawChar(i + 1, 0, hotkeyDisplay[i]); } } // Render child components this.renderChildren(); return this.buffer; } } /** Keys that bypass capture mode and always work for navigation */ Component.BYPASS_KEYS = ['Tab', 'Shift+Tab'];