asciitorium
Version:
an ASCII CLUI framework
553 lines (552 loc) • 20.2 kB
JavaScript
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'];