Files
cc-utils/src/lib/ccTUI/application.ts

554 lines
15 KiB
TypeScript

/**
* Application class for managing the UI lifecycle
*/
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { CCLog, HOUR } from "../ccLog";
import { setLogger } from "./context";
/**
* Main application class
* Manages the root UI component and handles rendering
*/
export class Application {
private root?: UIObject;
private running = false;
private needsRender = true;
private focusedNode?: UIObject;
private termWidth: number;
private termHeight: number;
private logger: CCLog;
private cursorBlinkState = false;
private lastBlinkTime = 0;
private readonly BLINK_INTERVAL = 0.5; // seconds
constructor() {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
this.logger = new CCLog("tui_debug.log", false, HOUR);
setLogger(this.logger);
this.logger.debug("Application constructed.");
}
/**
* Set the root component for the application
*
* @param rootComponent - The root UI component
*/
setRoot(rootComponent: UIObject): void {
// Unmount old root if it exists
if (this.root !== undefined) {
this.root.unmount();
}
this.root = rootComponent;
this.root.mount();
this.needsRender = true;
}
/**
* Request a re-render on the next frame
*/
requestRender(): void {
this.needsRender = true;
}
/**
* Run the application event loop
*/
run(): void {
if (this.root === undefined) {
error(
"Cannot run application without a root component. Call setRoot() first.",
);
}
this.running = true;
term.setCursorBlink(false);
clearScreen();
// Initial render
this.logger.debug("Initial renderFrame call.");
this.renderFrame();
// Main event loop
parallel.waitForAll(
() => this.renderLoop(),
() => this.eventLoop(),
() => this.timerLoop(),
);
}
/**
* Stop the application
*/
stop(): void {
this.logger.debug("Application stopping.");
this.running = false;
if (this.root !== undefined) {
this.root.unmount();
}
this.logger.close();
clearScreen();
}
/**
* Render loop - continuously renders when needed
*/
private renderLoop(): void {
while (this.running) {
if (this.needsRender) {
this.logger.debug(
"renderLoop: needsRender is true, calling renderFrame.",
);
this.needsRender = false;
this.renderFrame();
}
os.sleep(0.05);
}
}
/**
* Render a single frame
*/
private renderFrame(): void {
if (this.root === undefined) return;
this.logger.debug("renderFrame: Calculating layout.");
// Calculate layout
calculateLayout(this.root, this.termWidth, this.termHeight, 1, 1);
// Clear screen
clearScreen();
// Render the tree
this.logger.debug("renderFrame: Rendering tree.");
renderTree(this.root, this.focusedNode, this.cursorBlinkState);
this.logger.debug("renderFrame: Finished rendering tree.");
}
/**
* Timer loop - handles cursor blinking
*/
private timerLoop(): void {
while (this.running) {
const currentTime = os.clock();
if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) {
this.lastBlinkTime = currentTime;
this.cursorBlinkState = !this.cursorBlinkState;
// Only trigger render if we have a focused text input
if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input" &&
this.focusedNode.props.type !== "checkbox"
) {
this.needsRender = true;
}
}
os.sleep(0.05);
}
}
/**
* Event loop - handles user input
*/
private eventLoop(): void {
while (this.running) {
const [eventType, ...eventData] = os.pullEvent();
if (eventType === "key") {
this.handleKeyEvent(eventData[0] as number);
} else if (eventType === "char") {
this.handleCharEvent(eventData[0] as string);
} else if (eventType === "mouse_click") {
this.logger.debug(
string.format(
"eventLoop: Mouse click detected at (%d, %d)",
eventData[1],
eventData[2],
),
);
this.handleMouseClick(
eventData[0] as number,
eventData[1] as number,
eventData[2] as number,
);
} else if (eventType === "mouse_scroll") {
this.logger.debug(
string.format(
"eventLoop: Mouse scroll detected at (%d, %d) direction %d",
eventData[1],
eventData[2],
eventData[0],
),
);
this.handleMouseScroll(
eventData[0] as number,
eventData[1] as number,
eventData[2] as number,
);
}
}
}
/**
* Handle keyboard key events
*/
private handleKeyEvent(key: number): void {
if (key === keys.tab) {
// Focus next element
this.focusNext();
this.needsRender = true;
} else if (key === keys.enter && this.focusedNode !== undefined) {
// Trigger action on focused element
if (this.focusedNode.type === "button") {
const onClick = this.focusedNode.handlers.onClick;
if (onClick) {
(onClick as () => void)();
this.needsRender = true;
}
} else if (this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined;
if (type === "checkbox") {
// Toggle checkbox
const onChangeProp = this.focusedNode.props.onChange;
const checkedProp = this.focusedNode.props.checked;
if (
typeof onChangeProp === "function" &&
typeof checkedProp === "function"
) {
const currentValue = (checkedProp as () => boolean)();
(onChangeProp as (v: boolean) => void)(!currentValue);
this.needsRender = true;
}
}
}
} else if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
// Handle text input key events
const type = this.focusedNode.props.type as string | undefined;
if (type !== "checkbox") {
this.handleTextInputKey(key);
}
}
}
/**
* Handle keyboard events for text input
*/
private handleTextInputKey(key: number): void {
if (this.focusedNode === undefined) return;
const valueProp = this.focusedNode.props.value;
const onInputProp = this.focusedNode.props.onInput;
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
return;
}
const currentValue = (valueProp as () => string)();
const cursorPos = this.focusedNode.cursorPos ?? 0;
if (key === keys.left) {
// Move cursor left
this.focusedNode.cursorPos = math.max(0, cursorPos - 1);
this.needsRender = true;
} else if (key === keys.right) {
// Move cursor right
this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1);
this.needsRender = true;
} else if (key === keys.backspace) {
// Delete character before cursor
if (cursorPos > 0) {
const newValue =
currentValue.substring(0, cursorPos - 1) +
currentValue.substring(cursorPos);
(onInputProp as (v: string) => void)(newValue);
this.focusedNode.cursorPos = cursorPos - 1;
this.needsRender = true;
}
} else if (key === keys.delete) {
// Delete character after cursor
if (cursorPos < currentValue.length) {
const newValue =
currentValue.substring(0, cursorPos) +
currentValue.substring(cursorPos + 1);
(onInputProp as (v: string) => void)(newValue);
this.needsRender = true;
}
}
}
/**
* Handle character input events
*/
private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined;
if (type !== "checkbox") {
// Insert character at cursor position
const onInputProp = this.focusedNode.props.onInput;
const valueProp = this.focusedNode.props.value;
if (
typeof onInputProp === "function" &&
typeof valueProp === "function"
) {
const currentValue = (valueProp as () => string)();
const cursorPos = this.focusedNode.cursorPos ?? 0;
const newValue =
currentValue.substring(0, cursorPos) +
char +
currentValue.substring(cursorPos);
(onInputProp as (v: string) => void)(newValue);
this.focusedNode.cursorPos = cursorPos + 1;
this.needsRender = true;
}
}
}
}
/**
* Handle mouse click events
*/
private handleMouseClick(button: number, x: number, y: number): void {
if (button !== 1 || this.root === undefined) return;
this.logger.debug("handleMouseClick: Finding node.");
// Find which element was clicked
const clicked = this.findNodeAt(this.root, x, y);
if (clicked !== undefined) {
this.logger.debug(
string.format("handleMouseClick: Found node of type %s.", clicked.type),
);
// Set focus
this.focusedNode = clicked;
// Initialize cursor position for text inputs on focus
if (clicked.type === "input" && clicked.props.type !== "checkbox") {
const valueProp = clicked.props.value;
if (typeof valueProp === "function") {
const currentValue = (valueProp as () => string)();
clicked.cursorPos = currentValue.length;
}
}
// Trigger click handler
if (clicked.type === "button") {
const onClick = clicked.handlers.onClick;
if (onClick) {
this.logger.debug(
"handleMouseClick: onClick handler found, executing.",
);
(onClick as () => void)();
this.logger.debug("handleMouseClick: onClick handler finished.");
this.needsRender = true;
}
} else if (clicked.type === "input") {
const type = clicked.props.type as string | undefined;
if (type === "checkbox") {
const onChangeProp = clicked.props.onChange;
const checkedProp = clicked.props.checked;
if (
typeof onChangeProp === "function" &&
typeof checkedProp === "function"
) {
const currentValue = (checkedProp as () => boolean)();
(onChangeProp as (v: boolean) => void)(!currentValue);
this.needsRender = true;
}
}
}
this.needsRender = true;
} else {
this.logger.debug("handleMouseClick: No node found at click position.");
}
}
/**
* Find the UI node at a specific screen position
*/
private findNodeAt(
node: UIObject,
x: number,
y: number,
): UIObject | undefined {
// Check children first (depth-first)
for (const child of node.children) {
const found = this.findNodeAt(child, x, y);
if (found !== undefined) {
return found;
}
}
// Check this node
if (node.layout !== undefined) {
const { x: nx, y: ny, width, height } = node.layout;
const hit = x >= nx && x < nx + width && y >= ny && y < ny + height;
if (hit) {
this.logger.debug(
string.format(
"findNodeAt: Hit test TRUE for %s at (%d, %d)",
node.type,
nx,
ny,
),
);
// Only return interactive elements
if (node.type === "button" || node.type === "input") {
this.logger.debug("findNodeAt: Node is interactive, returning.");
return node;
}
}
}
return undefined;
}
/**
* Focus the next interactive element
*/
private focusNext(): void {
if (this.root === undefined) return;
const interactive = this.collectInteractive(this.root);
if (interactive.length === 0) {
this.focusedNode = undefined;
return;
}
if (this.focusedNode === undefined) {
this.focusedNode = interactive[0];
} else {
const currentIndex = interactive.indexOf(this.focusedNode);
const nextIndex = (currentIndex + 1) % interactive.length;
this.focusedNode = interactive[nextIndex];
}
}
/**
* Find the scrollable UI node at a specific screen position
*/
private findScrollableNodeAt(
node: UIObject,
x: number,
y: number,
): UIObject | undefined {
// Check children first (depth-first)
for (const child of node.children) {
const found = this.findScrollableNodeAt(child, x, y);
if (found !== undefined) {
return found;
}
}
// Check this node
if (node.layout !== undefined) {
const { x: nx, y: ny, width, height } = node.layout;
const hit = x >= nx && x < nx + width && y >= ny && y < ny + height;
if (hit) {
this.logger.debug(
string.format(
"findNodeAt: Hit test TRUE for %s at (%d, %d)",
node.type,
nx,
ny,
),
);
// Only return scrollable elements
if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning.");
return node;
}
}
}
return undefined;
}
/**
* Handle mouse scroll events
*/
private handleMouseScroll(direction: number, x: number, y: number): void {
if (this.root === undefined) return;
// Find which element was scrolled over
const scrollContainer = this.findScrollableNodeAt(this.root, x, y);
if (scrollContainer?.scrollProps) {
// Scroll by 1 line per wheel step
const scrollAmount = direction * 1;
scrollContainer.scrollBy(0, scrollAmount);
this.needsRender = true;
this.logger.debug(
string.format(
"handleMouseScroll: Scrolled container by %d, new position: (%d, %d)",
scrollAmount,
scrollContainer.scrollProps.scrollX,
scrollContainer.scrollProps.scrollY,
),
);
}
}
/**
* Collect all interactive elements in the tree
*/
private collectInteractive(node: UIObject): UIObject[] {
const result: UIObject[] = [];
if (node.type === "button" || node.type === "input") {
result.push(node);
}
for (const child of node.children) {
result.push(...this.collectInteractive(child));
}
return result;
}
}
/**
* Convenience function to create and run an application
*
* @param rootFn - Function that returns the root component
*
* @example
* ```typescript
* render(() => {
* const [count, setCount] = createSignal(0);
* return div({ class: "flex flex-col" },
* label({}, () => `Count: ${count()}`),
* button({ onClick: () => setCount(count() + 1) }, "Increment")
* );
* });
* ```
*/
export function render(rootFn: () => UIObject): void {
const app = new Application();
// Create the root component
const root = rootFn();
app.setRoot(root);
try {
app.run();
} finally {
app.stop();
}
}