asciitorium
Version:
an ASCII CLUI framework
428 lines (427 loc) • 17.3 kB
JavaScript
import { Component } from './Component.js';
import { FocusManager } from './FocusManager.js';
import { DOMRenderer } from './renderers/DOMRenderer.js';
import { TTYRenderer } from './renderers/TTYRenderer.js';
import { setRenderCallback } from './RenderScheduler.js';
import { setupKeyboardHandling, validateWebEnvironment, } from './environment.js';
import { createSizeContext } from './utils/sizeUtils.js';
/**
* Root application component for asciitorium.
*
* The App class is the entry point for all asciitorium applications. It handles:
* - **Renderer Selection**: Automatically detects environment (web/CLI) and initializes appropriate renderer
* - **Focus Management**: Manages keyboard navigation between focusable components
* - **Performance Monitoring**: Tracks FPS, CPU, and memory usage
* - **Keyboard Handling**: Sets up global keyboard event handling
* - **Resize Handling**: Responds to terminal/window resize events
* - **Keybind Registry**: Manages global keyboard shortcuts
*
* The App automatically sizes itself to the screen if no dimensions are provided,
* and uses a column layout by default.
*
* @example
* Basic web application:
* ```tsx
* import { App, Button } from 'asciitorium';
*
* const app = (
* <App>
* <Button onClick={() => console.log('Clicked!')}>
* Click Me
* </Button>
* </App>
* );
* ```
*
* @example
* CLI application with custom size:
* ```tsx
* const app = (
* <App width={80} height={24} layout="column">
* <Text>Content</Text>
* </App>
* );
* ```
*/
export class App extends Component {
/**
* Creates a new App instance.
*
* Automatically detects the environment (web vs CLI) and initializes the
* appropriate renderer. Sets up keyboard handling, focus management, and
* performance monitoring.
*
* @param props - App configuration options
*/
constructor(props) {
// Extract font from props or style object
const fontFromStyle = props.style?.font;
const selectedFont = props.font ?? fontFromStyle;
// Initialize renderer first to get screen size
const renderer = getDefaultRenderer(selectedFont);
const screenSize = renderer.getScreenSize();
// Extract dimensions from style object if present
const widthFromStyle = props.style?.width;
const heightFromStyle = props.style?.height;
const layoutFromStyle = props.style?.layout;
// Determine if dimensions are fixed (numeric values were explicitly provided)
const hasFixedWidth = typeof (props.width ?? widthFromStyle) === 'number';
const hasFixedHeight = typeof (props.height ?? heightFromStyle) === 'number';
// Set column layout as default for Asciitorium
// Use screen size if width/height not explicitly provided
const asciitoriumProps = {
...props,
width: props.width ?? widthFromStyle ?? screenSize.width,
height: props.height ?? heightFromStyle ?? screenSize.height,
layout: props.layout ?? layoutFromStyle ?? 'column',
layoutOptions: props.layoutOptions ?? {},
};
super(asciitoriumProps);
/** Reliable identifier for App class */
this.isApp = true;
/** FPS counter for current second */
this.fpsCounter = 0;
/** Total render time for current second (ms) */
this.totalRenderTime = 0;
/** Current frames per second */
this.currentFPS = 0;
/** Current CPU usage percentage */
this.currentCPU = 0;
/** Current memory usage in MB */
this.currentMemory = 0;
/** Registry for global keyboard shortcuts */
this.keybindRegistry = new Map();
/** Registry for mobile controller components */
this.mobileControllerRegistry = [];
this.renderer = renderer;
this.focus = new FocusManager();
this.fixedWidth = hasFixedWidth;
this.fixedHeight = hasFixedHeight;
// Setup resize handling
this.setupResizeHandling();
this.focus.reset(this);
// Subscribe to hotkey visibility changes to trigger re-renders
this.focus.hotkeyVisibilityState.subscribe(() => {
this.render();
});
this.render();
// Initialize performance monitoring
this.updatePerformanceMetrics();
// Start FPS and render time reporting
const fpsIntervalId = setInterval(() => {
this.currentFPS = this.fpsCounter;
this.fpsCounter = 0;
this.totalRenderTime = 0;
this.updatePerformanceMetrics();
}, 1000);
// Register cleanup for the FPS monitoring interval
this.registerCleanup(() => clearInterval(fpsIntervalId));
}
/**
* Renders the application to the screen.
*
* Traverses the component tree, renders each component to buffers, and
* composites them based on z-index. Updates performance metrics and
* increments the FPS counter.
*
* This method is called automatically on initialization and whenever
* components request a re-render via the RenderScheduler.
*/
render() {
const start = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
this.fpsCounter++;
const screenBuffer = Array.from({ length: this.height }, () => Array.from({ length: this.width }, () => ' '));
// Flatten and sort components by z-index
const allComponents = this.getAllDescendants().concat([this]);
allComponents.sort((a, b) => (a.z ?? 0) - (b.z ?? 0));
for (const component of allComponents) {
// Skip rendering invisible components
if (!component.visible)
continue;
const buffer = component.draw();
const x = component.x;
const y = component.y;
const transparentChar = component.transparentChar;
for (let row = 0; row < buffer.length; row++) {
const globalY = y + row;
if (globalY < 0 || globalY >= this.height)
continue;
for (let col = 0; col < buffer[row].length; col++) {
const globalX = x + col;
if (globalX < 0 || globalX >= this.width)
continue;
const char = buffer[row][col];
if (char !== transparentChar) {
screenBuffer[globalY][globalX] = char;
}
}
}
}
this.renderer.render(screenBuffer);
const end = typeof performance !== 'undefined' && performance.now
? performance.now()
: Date.now();
this.totalRenderTime += end - start;
}
addChild(component) {
super.addChild(component);
this.focus?.reset(this); // avoid crashing
this.render();
}
removeChild(component) {
super.removeChild(component);
this.focus.reset(this);
this.render();
}
getFPS() {
return this.currentFPS;
}
getRenderTime() {
return this.totalRenderTime;
}
getCPUUsage() {
return this.currentCPU;
}
getMemoryUsage() {
return this.currentMemory;
}
updatePerformanceMetrics() {
// CPU and Memory monitoring - cross-platform
if (typeof process !== 'undefined' &&
process.cpuUsage &&
process.memoryUsage) {
// Node.js environment (CLI)
try {
const currentUsage = process.cpuUsage(this.lastCPUUsage);
const totalUsage = currentUsage.user + currentUsage.system;
// Convert microseconds to percentage (approximate)
this.currentCPU = Math.min(100, totalUsage / 10000); // Rough estimation
this.lastCPUUsage = process.cpuUsage();
const memUsage = process.memoryUsage();
this.currentMemory = memUsage.heapUsed / (1024 * 1024); // Convert to MB
}
catch (e) {
this.currentCPU = 0;
this.currentMemory = 0;
}
}
else if (typeof performance !== 'undefined' &&
performance.memory) {
// Browser environment with memory API
try {
const memInfo = performance.memory;
this.currentMemory = memInfo.usedJSHeapSize / (1024 * 1024); // Convert to MB
// No direct CPU access in browser, estimate from render performance
this.currentCPU = Math.min(100, Math.max(0, this.totalRenderTime * 6)); // Rough estimation
}
catch (e) {
this.currentCPU = 0;
this.currentMemory = 0;
}
}
else {
// Fallback - no metrics available
this.currentCPU = 0;
this.currentMemory = 0;
}
}
// Add keybind registration methods
registerKeybind(keybind) {
if (this.keybindRegistry.has(keybind.keyBinding)) {
console.warn(`Warning: Duplicate keybinding for "${keybind.keyBinding}". The previous binding will be overwritten.`);
}
this.keybindRegistry.set(keybind.keyBinding, keybind);
}
unregisterKeybind(keybind) {
this.keybindRegistry.delete(keybind.keyBinding);
}
// Add mobile controller registration methods
registerMobileController(controller) {
this.mobileControllerRegistry.push(controller);
}
unregisterMobileController(controller) {
const index = this.mobileControllerRegistry.indexOf(controller);
if (index !== -1) {
this.mobileControllerRegistry.splice(index, 1);
}
}
// Handle mobile button press
handleMobileButton(buttonId) {
// Find all enabled controllers in the component tree, sorted by priority (highest first)
const activeControllers = this.mobileControllerRegistry
.filter((c) => !c.disabled && this.isComponentInTree(c))
.sort((a, b) => b.priority - a.priority);
// Execute first controller that handles this button
for (const controller of activeControllers) {
if (controller.handleButton(buttonId)) {
this.render();
return;
}
}
}
handleKey(key, event) {
// Check if focused component is in capture mode
const focusedComponent = this.getFocusedComponent();
if (focusedComponent?.captureModeActive) {
// Only bypass keys (Tab, Shift+Tab) escape capture mode
if (!Component.BYPASS_KEYS.includes(key)) {
// Send key directly to component, skip keybindings/hotkeys
if (focusedComponent.handleEvent(key)) {
this.render();
event?.preventDefault();
}
return;
}
}
// Check for app-level keybinds first
const keybind = this.keybindRegistry.get(key);
if (keybind && !keybind.disabled && this.isKeybindActive(keybind)) {
keybind.action();
this.render();
event?.preventDefault();
return;
}
// Continue with existing focus manager delegation
if (this.focus.handleKey(key)) {
this.render();
event?.preventDefault();
}
}
getFocusedComponent() {
return this.getAllDescendants().find((c) => c.hasFocus);
}
isKeybindActive(keybind) {
// Keybind is active if it's in the component tree and visible
return this.isComponentInTree(keybind);
}
isComponentInTree(component) {
// Check if component is in the tree by walking up to find this App
let current = component;
while (current) {
if (current === this)
return true;
current = current.parent;
}
return false;
}
hasComponentWithFocus() {
// Check if any component currently has focus
return this.getAllDescendants().some((c) => c.hasFocus);
}
setupResizeHandling() {
const handleResize = () => {
const newSize = this.renderer.getScreenSize();
let changed = false;
// Only update width if it wasn't fixed
if (!this.fixedWidth && newSize.width !== this.width) {
this.width = newSize.width;
changed = true;
}
// Only update height if it wasn't fixed
if (!this.fixedHeight && newSize.height !== this.height) {
this.height = newSize.height;
changed = true;
}
if (changed) {
this.render();
}
};
// Web environment resize handling
if (typeof window !== 'undefined') {
window.addEventListener('resize', handleResize);
}
// Terminal environment resize handling (SIGWINCH signal)
if (typeof process !== 'undefined' && process.on) {
process.on('SIGWINCH', handleResize);
}
}
getScreenSize() {
return this.renderer.getScreenSize();
}
async start() {
validateWebEnvironment();
await setupKeyboardHandling((key, event) => this.handleKey(key, event), (buttonId) => this.handleMobileButton(buttonId));
setRenderCallback(() => this.render());
// Trigger initial render to ensure all components are displayed
this.render();
}
resolveSizesRecursively() {
// Recursively resolve sizes and layouts top-down
// This ensures parents calculate their children's dimensions before those children draw
console.log('=== PASS 1: Size Resolution Starting ===');
this.resolveSizesForComponent(this);
console.log('=== PASS 1: Size Resolution Complete ===');
// Validate that all sizes are resolved
this.validateSizesResolved();
}
validateSizesResolved() {
console.log('=== Validating All Sizes Are Resolved ===');
const allComponents = this.getAllDescendants().concat([this]);
let invalidCount = 0;
for (const component of allComponents) {
const name = component.constructor.name;
const isInvalid = typeof component.width !== 'number' ||
component.width < 1 ||
typeof component.height !== 'number' ||
component.height < 1;
if (isInvalid && component.visible) {
console.error(`❌ ${name} has invalid dimensions: ${component.width}x${component.height}, ` +
`originalWidth=${component.originalWidth}, ` +
`originalHeight=${component.originalHeight}, ` +
`visible=${component.visible}`);
invalidCount++;
}
}
if (invalidCount === 0) {
console.log('✅ All visible components have valid numeric dimensions');
}
else {
console.error(`❌ ${invalidCount} visible components have invalid dimensions!`);
}
}
resolveSizesForComponent(component) {
const name = component.constructor.name;
const beforeWidth = component.width;
const beforeHeight = component.height;
// Step 1: Resolve this component's size based on parent context
if (component.parent) {
const borderPad = component.parent.border ? 1 : 0;
const context = createSizeContext(component.parent.width, component.parent.height, borderPad);
component.resolveSize(context);
}
else {
// Root component (App) uses screen size
const context = createSizeContext(this.width, this.height, 0);
component.resolveSize(context);
}
// Step 2: Calculate layout ONLY for components with VISIBLE children
// This will set the dimensions (width/height) of child components with "fill" sizing
// Data-only children (Option, OptionGroup) have visible=false and are skipped
// This prevents recursive layout calls on components like Select that have
// data children but handle their own rendering
const children = component.children || [];
const visibleChildren = children.filter((c) => c.visible);
if (visibleChildren.length > 0) {
const beforeLayoutHeight = component.height;
component.recalculateLayout();
}
// Step 3: Recursively resolve children
// Now that parent has calculated their sizes via layout, children can proceed
for (const child of children) {
this.resolveSizesForComponent(child);
}
}
}
function getDefaultRenderer(font) {
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
const screen = document.getElementById('screen');
if (!screen)
throw new Error('No #screen element found for DOM rendering');
return new DOMRenderer(screen, font);
}
else {
return new TTYRenderer();
}
}