reconstruct tui framework

This commit is contained in:
2025-10-12 10:37:45 +08:00
parent bc8b5fec8b
commit 39598fe3e6
22 changed files with 2257 additions and 1468 deletions

View File

@@ -1,100 +0,0 @@
import { UIComponent } from "./UIComponent";
import { Signal } from "./Signal";
import { KeyEvent } from "../event";
/**
* Button component
*/
export class Button extends UIComponent {
private text: string;
private textColor: number;
private bgColor: number;
private pressed = false;
// Signal for when button is clicked
public onClick = new Signal<void>();
constructor(
objectName: string,
x: number,
y: number,
text: string,
textColor: number = colors.white,
bgColor: number = colors.black,
) {
// Width is based on text length plus 2 for the brackets ( [text] )
super(objectName, x, y, text.length + 2, 1);
this.text = text;
this.textColor = textColor;
this.bgColor = bgColor;
}
render(): void {
if (!this.visible) return;
const [originalX, originalY] = term.getCursorPos();
// Set colors based on state (normal, focused, or pressed)
let textColor = this.textColor;
let backgroundColor = this.bgColor;
if (this.pressed) {
textColor = this.bgColor; // Swap text and background colors when pressed
backgroundColor = this.textColor;
} else if (this.focused) {
textColor = colors.yellow; // Yellow text when focused
}
term.setTextColor(textColor);
term.setBackgroundColor(backgroundColor);
// Move cursor to position and draw button
term.setCursorPos(this.x, this.y);
term.write(`[${this.text}]`);
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
handleKeyInput(event: KeyEvent): void {
if (!this.focused) return;
this.onKeyPress.emit(event);
const key = event.key;
// Handle button activation with space or enter
if (key === keys.space || key === keys.enter) {
this.pressButton();
}
}
handleMouseClick(_event: { x: number; y: number }): void {
// Button was clicked, trigger press
this.pressButton();
}
private pressButton(): void {
// Set pressed state and render
this.pressed = true;
this.render();
// Trigger click signal
this.onClick.emit();
// Reset pressed state after a short delay to show visual feedback
this.startTimer(() => {
this.pressed = false;
this.render();
}, 100);
}
setText(newText: string): void {
this.text = newText;
this.width = newText.length + 2; // Update width based on new text
}
getText(): string {
return this.text;
}
}

View File

@@ -1,166 +0,0 @@
import { UIComponent } from "./UIComponent";
import { Signal } from "./Signal";
import { KeyEvent, CharEvent } from "../event";
/**
* Input field component
*/
export class InputField extends UIComponent {
private value: string;
private placeholder: string;
private maxLength: number;
private password: boolean;
private cursorPos = 0;
private isCursorBlink = false;
// Signal for when text changes
public onTextChanged = new Signal<string>();
constructor(
objectName: string,
x: number,
y: number,
width: number,
value: "",
placeholder = "",
maxLength = 50,
password = false,
) {
super(objectName, x, y, width, 1);
this.value = value;
this.placeholder = placeholder;
this.maxLength = maxLength;
this.password = password;
this.cursorPos = value.length;
}
render(): void {
if (!this.visible) return;
const [originalX, originalY] = term.getCursorPos();
// Set colors (different for focused vs unfocused)
if (this.focused) {
term.setTextColor(colors.black);
term.setBackgroundColor(colors.white);
} else {
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
}
// Move cursor to position
term.setCursorPos(this.x, this.y);
// Prepare text to display (mask if password)
let displayText = this.value;
if (this.password) {
displayText = "*".repeat(this.value.length);
} else if (this.value === "" && this.placeholder !== "") {
displayText = this.placeholder;
}
// Truncate or pad text to fit the field
if (displayText.length > this.width) {
displayText = displayText.substring(0, this.width);
} else {
displayText = displayText.padEnd(this.width);
}
// Draw the field
term.write(displayText);
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
handleKeyInput(event: KeyEvent): void {
if (!this.focused) return;
this.onKeyPress.emit(event);
const key = event.key;
this.log?.debug(`[${InputField.name}]: Get key ${keys.getName(key)}`);
// Handle backspace
if (key === keys.backspace) {
if (this.cursorPos > 0) {
this.value =
this.value.substring(0, this.cursorPos - 1) +
this.value.substring(this.cursorPos);
this.cursorPos--;
this.onTextChanged.emit(this.value);
}
}
// Handle delete
else if (key === keys.delete) {
if (this.cursorPos < this.value.length) {
this.value =
this.value.substring(0, this.cursorPos) +
this.value.substring(this.cursorPos + 1);
this.onTextChanged.emit(this.value);
}
}
// Handle left arrow
else if (key === keys.left) {
if (this.cursorPos > 0) {
this.cursorPos--;
}
}
// Handle right arrow
else if (key === keys.right) {
if (this.cursorPos < this.value.length) {
this.cursorPos++;
}
}
// Handle enter (could be used to submit form)
else if (key === keys.enter) {
// Could emit a submit signal here
}
// Handle regular characters
else {
// For printable characters, we need to check if they are actual characters
// Since the event system is complex, we'll implement a more direct approach
}
}
handleCharInput(event: CharEvent): void {
if (!this.focused) return;
const character = event.character;
this.log?.debug(`[${InputField.name}]: Get character ${character}`);
this.value += character;
this.cursorPos++;
this.onTextChanged.emit(this.value);
}
// Method to get user input directly (more suitable for ComputerCraft)
readInput(prompt = "", defaultValue = ""): string {
// Since we can't await for events in a standard way in this context,
// we'll use CC's read function which handles input internally
const oldX = this.x;
const oldY = this.y;
// Move cursor to the input field position
term.setCursorPos(oldX, oldY);
// Print the prompt
if (prompt != undefined) {
print(prompt);
}
// Use ComputerCraft's read function for actual input
const result = read(undefined, undefined, undefined, defaultValue);
return result;
}
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value.substring(0, this.maxLength);
this.cursorPos = value.length;
this.onTextChanged.emit(this.value);
}
}

View File

@@ -1,137 +0,0 @@
import { UIComponent } from "./UIComponent";
import { Signal } from "./Signal";
import { KeyEvent } from "../event";
/**
* Option selection component with prompt
*/
export class OptionSelector extends UIComponent {
private options: string[];
private currentIndex: number;
private prompt: string;
private displayOption: string;
// Signal for when selection changes
public onSelectionChanged = new Signal<{ index: number; value: string }>();
constructor(
objectName: string,
x: number,
y: number,
options: string[],
prompt = "Select:",
initialIndex = 0,
) {
super(objectName, x, y, 0, 1); // Width will be calculated dynamically
this.options = options;
this.currentIndex = initialIndex;
this.prompt = prompt;
// Calculate width based on prompt and longest option
const promptWidth = prompt.length + 1; // +1 for space
let maxOptionWidth = 0;
for (const option of options) {
if (option.length > maxOptionWidth) {
maxOptionWidth = option.length;
}
}
this.width = promptWidth + maxOptionWidth + 3; // +3 for brackets and space [ ]
this.displayOption = `[${this.options[this.currentIndex]}]`;
}
render(): void {
if (!this.visible) return;
const [originalX, originalY] = term.getCursorPos();
// Set colors
term.setTextColor(this.focused ? colors.yellow : colors.white);
term.setBackgroundColor(colors.black);
// Move cursor to position
term.setCursorPos(this.x, this.y);
// Draw prompt and selected option
const fullText = `${this.prompt} ${this.displayOption}`;
term.write(fullText.padEnd(this.width));
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
handleKeyInput(event: KeyEvent): void {
if (!this.focused) return;
this.onKeyPress.emit(event);
const key = event.key;
// Handle left arrow to go to previous option
if (key === keys.left) {
this.previousOption();
}
// Handle right arrow to go to next option
else if (key === keys.right) {
this.nextOption();
}
// Handle up arrow to go to previous option
else if (key === keys.up) {
this.previousOption();
}
// Handle down arrow to go to next option
else if (key === keys.down) {
this.nextOption();
}
}
private previousOption(): void {
this.currentIndex =
(this.currentIndex - 1 + this.options.length) % this.options.length;
this.updateDisplay();
this.onSelectionChanged.emit({
index: this.currentIndex,
value: this.options[this.currentIndex],
});
}
private nextOption(): void {
this.currentIndex = (this.currentIndex + 1) % this.options.length;
this.updateDisplay();
this.onSelectionChanged.emit({
index: this.currentIndex,
value: this.options[this.currentIndex],
});
}
private updateDisplay(): void {
this.displayOption = `[${this.options[this.currentIndex]}]`;
}
getSelectedIndex(): number {
return this.currentIndex;
}
getSelectedValue(): string {
return this.options[this.currentIndex];
}
setSelectedIndex(index: number): void {
if (index >= 0 && index < this.options.length) {
this.currentIndex = index;
this.updateDisplay();
this.onSelectionChanged.emit({
index: this.currentIndex,
value: this.options[this.currentIndex],
});
}
}
setOptions(newOptions: string[]): void {
this.options = newOptions;
if (this.currentIndex >= newOptions.length) {
this.currentIndex = 0;
}
this.updateDisplay();
}
}

View File

@@ -1,28 +0,0 @@
/**
* Signal and Slot system similar to Qt
* Allows components to communicate with each other
*/
export class Signal<T = void> {
private slots: ((data?: T) => void)[] = [];
connect(slot: (data?: T) => void): void {
this.slots.push(slot);
}
disconnect(slot: (data?: T) => void): void {
const index = this.slots.indexOf(slot);
if (index !== -1) {
this.slots.splice(index, 1);
}
}
emit(data?: T): void {
for (const slot of this.slots) {
try {
slot(data);
} catch (e) {
printError(e);
}
}
}
}

View File

@@ -1,98 +0,0 @@
import { CCLog, DAY } from "../ccLog";
import { UIWindow } from "./UIWindow";
import { UIComponent } from "./UIComponent";
import { KeyEvent, CharEvent, TimerEvent, pullEventAs } from "../event";
/**
* Main TUI Application class
*/
export class TUIApplication {
private log = new CCLog(`TUI.log`, false, DAY);
private window: UIWindow;
private running = false;
constructor() {
this.window = new UIWindow();
}
addComponent(component: UIComponent): void {
this.window.addComponent(component);
}
run(): void {
this.running = true;
// Initial render
term.setCursorBlink(false);
this.window.render();
parallel.waitForAll(
() => {
this.mainLoop();
},
() => {
this.keyLoop();
},
() => {
this.charLoop();
},
);
}
stop(): void {
this.running = false;
this.log.close();
}
mainLoop(): void {
// Main event loop
while (this.running) {
// Render the UI
this.window.render();
// Small delay to prevent excessive CPU usage
os.sleep(0.05);
}
}
keyLoop(): void {
while (this.running) {
// Handle input events
const keyEvent = pullEventAs(KeyEvent, "key");
this.log.debug(
`[${TUIApplication.name}]: Get Key Event: ${textutils.serialise(keyEvent ?? {})}`,
);
if (keyEvent == undefined) continue;
this.window.handleKeyInput(keyEvent);
}
}
charLoop(): void {
while (this.running) {
// Handle input events
const charEvent = pullEventAs(CharEvent, "char");
this.log.debug(
`[${TUIApplication.name}]: Get Char Event: ${textutils.serialise(charEvent ?? {})}`,
);
if (charEvent == undefined) continue;
this.window.handleCharInput(charEvent);
}
}
timerLoop(): void {
while (this.running) {
// Handle events
const timerEvent = pullEventAs(TimerEvent, "timer");
this.log.debug(
`[${TUIApplication.name}]: Get Timer Event: ${textutils.serialise(timerEvent ?? {})}`,
);
if (timerEvent == undefined) continue;
this.window.handleTimerTrigger(timerEvent);
}
}
getWindow(): UIWindow {
return this.window;
}
}

View File

@@ -1,427 +0,0 @@
import { UIComponent } from "./UIComponent";
import { Signal } from "./Signal";
import { KeyEvent } from "../event";
/**
* Tab component that allows switching between different pages
* Similar to QT's TabWidget, currently implementing horizontal tabs only
*/
export class TabBar extends UIComponent {
// Tab data structure - simple array of tab names
private tabs: string[];
private currentIndex: number;
// Tracks visible range of tabs to handle overflow scenarios
private firstVisibleIndex: number;
private lastVisibleIndex: number;
// Signal emitted when the current tab changes
public onTabChanged = new Signal<{ index: number; name: string }>();
/**
* Creates a new TabWidget component
* @param objectName Unique name for the component
* @param x X position on the terminal
* @param y Y position on the terminal
* @param width Width of the tab widget
* @param tabNames Initial list of tab names
* @param initialIndex Index of the initially selected tab (default: 0)
*/
constructor(
objectName: string,
x: number,
y: number,
width: number,
tabNames: string[],
initialIndex = 0,
) {
super(objectName, x, y, width, 1);
// Initialize tabs as simple string array
this.tabs = [...tabNames];
this.currentIndex = Math.max(
0,
Math.min(initialIndex, tabNames.length - 1),
);
this.firstVisibleIndex = 0;
this.lastVisibleIndex = -1;
// Calculate which tabs can be displayed based on available width
this.updateVisibleRange();
}
/**
* Updates the range of visible tabs based on available width
* This method ensures the current tab is always visible and calculates
* which other tabs can fit in the available space
*/
private updateVisibleRange(): void {
// If no tabs exist, nothing to update
if (this.tabs.length === 0) {
this.firstVisibleIndex = 0;
this.lastVisibleIndex = -1;
return;
}
// Calculate visible tabs range based on current position
this.calculateVisibleTabs();
}
/**
* Calculates visible tabs based on current position and available width
* Follows the new core rendering logic
*/
private calculateVisibleTabs(): void {
if (this.tabs.length === 0) {
this.firstVisibleIndex = 0;
this.lastVisibleIndex = -1;
return;
}
// Start with all tabs and build the complete string
let fullString = "| ";
for (let i = 0; i < this.tabs.length; i++) {
if (i > 0) {
fullString += " | ";
}
fullString += this.tabs[i];
}
fullString += " |";
// If the full string fits, show all tabs
if (fullString.length <= this.width) {
this.firstVisibleIndex = 0;
this.lastVisibleIndex = this.tabs.length - 1;
return;
}
// Find the range that can fit around the current tab
this.firstVisibleIndex = this.currentIndex;
this.lastVisibleIndex = this.currentIndex;
// Try to expand left and right alternately
while (
this.firstVisibleIndex > 0 ||
this.lastVisibleIndex < this.tabs.length - 1
) {
let expanded = false;
// Try expanding left first
if (this.firstVisibleIndex > 0) {
const newTestString =
"| " +
this.tabs[this.firstVisibleIndex - 1] +
" | " +
this.tabs
.slice(this.firstVisibleIndex, this.lastVisibleIndex + 1)
.join(" | ") +
" |";
if (newTestString.length <= this.width) {
this.firstVisibleIndex--;
expanded = true;
}
}
// Try expanding right
if (this.lastVisibleIndex < this.tabs.length - 1) {
const newTestString =
"| " +
this.tabs
.slice(this.firstVisibleIndex, this.lastVisibleIndex + 1)
.join(" | ") +
" | " +
this.tabs[this.lastVisibleIndex + 1] +
" |";
if (newTestString.length <= this.width) {
this.lastVisibleIndex++;
expanded = true;
}
}
// If no expansion was possible, break
if (!expanded) break;
}
}
/**
* Renders the tab widget to the terminal
* Follows the new core rendering logic:
* 1. Build complete string with all visible tabs
* 2. Calculate what can be displayed
* 3. Replace indicators based on hidden tabs
* 4. Determine highlight range and render
*/
render(): void {
if (!this.visible) return;
const [originalX, originalY] = term.getCursorPos();
// Move cursor to the position of the tab widget
term.setCursorPos(this.x, this.y);
if (this.tabs.length === 0) {
// Fill with spaces if no tabs
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
term.write(" ".repeat(this.width));
term.setCursorPos(originalX, originalY);
return;
}
// Step 1: Build complete string for visible tabs with "| " at start and " |" at end
let displayString = "| ";
for (let i = this.firstVisibleIndex; i <= this.lastVisibleIndex; i++) {
if (i > this.firstVisibleIndex) {
displayString += " | ";
}
displayString += this.tabs[i];
}
displayString += " |";
// Step 2: Check if the string fits, if not, truncate with "..."
if (displayString.length > this.width) {
// Need to truncate - find where to cut and add "..."
const maxLength = this.width - 3; // Reserve space for "..."
if (maxLength > 0) {
// Find the last complete tab that can fit
let cutPosition = maxLength;
// Try to cut at a tab boundary if possible
let lastPipePos = -1;
for (let i = cutPosition; i >= 0; i--) {
if (displayString.substring(i, i + 3) === " | ") {
lastPipePos = i;
break;
}
}
if (lastPipePos > 2) {
// Make sure we don't cut before the first tab
cutPosition = lastPipePos;
}
displayString = displayString.substring(0, cutPosition) + "...";
} else {
displayString = "...";
}
}
// Step 3: Replace boundary indicators based on hidden tabs
if (this.firstVisibleIndex > 0) {
// Left side has hidden tabs - replace "| " with "< "
displayString = "< " + displayString.substring(2);
}
if (this.lastVisibleIndex < this.tabs.length - 1) {
// Right side has hidden tabs - replace " |" with " >"
if (displayString.endsWith(" |")) {
displayString =
displayString.substring(0, displayString.length - 2) + " >";
} else if (displayString.endsWith("...")) {
// If we have "...", just ensure we show ">"
displayString =
displayString.substring(0, displayString.length - 3) + " >";
}
}
// Pad to maintain consistent width
while (displayString.length < this.width) {
displayString += " ";
}
// Ensure we don't exceed the width
if (displayString.length > this.width) {
displayString = displayString.substring(0, this.width);
}
// Step 4: Find current tab position for highlighting
let currentTabStart = -1;
let currentTabEnd = -1;
if (
this.currentIndex >= this.firstVisibleIndex &&
this.currentIndex <= this.lastVisibleIndex
) {
// Calculate position of current tab in display string
let searchPos = 2; // Start after "| " or "< "
// Find current tab position by iterating through visible tabs
for (let i = this.firstVisibleIndex; i <= this.lastVisibleIndex; i++) {
if (i > this.firstVisibleIndex) {
searchPos += 3; // " | " separator
}
if (i === this.currentIndex) {
currentTabStart = searchPos;
// Find the end of the current tab
const tabName = this.tabs[i];
const remainingString = displayString.substring(searchPos);
// Check if the tab is fully displayed or truncated
if (remainingString.startsWith(tabName)) {
// Tab is fully displayed
currentTabEnd = searchPos + tabName.length;
} else {
// Tab might be truncated, find where it ends
const nextSeparatorPos = remainingString.indexOf(" |");
const nextIndicatorPos = remainingString.indexOf(" >");
const ellipsisPos = remainingString.indexOf("...");
let endPos = remainingString.length;
if (nextSeparatorPos >= 0)
endPos = Math.min(endPos, nextSeparatorPos);
if (nextIndicatorPos >= 0)
endPos = Math.min(endPos, nextIndicatorPos);
if (ellipsisPos >= 0) endPos = Math.min(endPos, ellipsisPos);
currentTabEnd = searchPos + endPos;
}
break;
}
searchPos += this.tabs[i].length;
}
}
// Step 5: Render with highlighting
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
if (currentTabStart >= 0 && currentTabEnd > currentTabStart) {
// Render text before current tab
if (currentTabStart > 0) {
term.write(displayString.substring(0, currentTabStart));
}
// Render current tab with highlighting
term.setTextColor(colors.yellow);
term.setBackgroundColor(colors.gray);
term.write(displayString.substring(currentTabStart, currentTabEnd));
// Reset colors and render remaining text
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
term.write(displayString.substring(currentTabEnd));
} else {
// No highlighting needed, render entire string
term.write(displayString);
}
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
/**
* Handles key input events for the tab widget
* Supports left/right arrow keys to switch between tabs
* @param event The key event to handle
*/
handleKeyInput(event: KeyEvent): void {
if (!this.focused) return;
this.onKeyPress.emit(event);
const key = event.key;
// Handle left arrow to move to previous visible tab
if (key === keys.left && this.canMoveToPreviousTab()) {
this.moveToPreviousTab();
}
// Handle right arrow to move to next visible tab
else if (key === keys.right && this.canMoveToNextTab()) {
this.moveToNextTab();
}
}
/**
* Checks if there is a previous tab available to move to
* @returns True if there's a previous tab, false otherwise
*/
private canMoveToPreviousTab(): boolean {
return this.currentIndex > 0;
}
/**
* Moves to the previous tab
*/
private moveToPreviousTab(): void {
if (this.currentIndex > 0) {
this.currentIndex--;
this.updateVisibleRange();
this.onTabChanged.emit({
index: this.currentIndex,
name: this.tabs[this.currentIndex],
});
}
}
/**
* Checks if there is a next tab available to move to
* @returns True if there's a next tab, false otherwise
*/
private canMoveToNextTab(): boolean {
return this.currentIndex < this.tabs.length - 1;
}
/**
* Moves to the next tab
*/
private moveToNextTab(): void {
if (this.currentIndex < this.tabs.length - 1) {
this.currentIndex++;
this.updateVisibleRange();
this.onTabChanged.emit({
index: this.currentIndex,
name: this.tabs[this.currentIndex],
});
}
}
/**
* Gets the index of the currently selected tab
* @returns The index of the current tab
*/
getCurrentTabIndex(): number {
return this.currentIndex;
}
/**
* Gets the name of the currently selected tab
* @returns The name of the current tab
*/
getCurrentTabName(): string {
if (this.currentIndex >= 0 && this.currentIndex < this.tabs.length) {
return this.tabs[this.currentIndex];
}
return "";
}
/**
* Sets the currently selected tab by index
* @param index The index of the tab to select
*/
setCurrentTabIndex(index: number): void {
if (index >= 0 && index < this.tabs.length) {
this.currentIndex = index;
this.updateVisibleRange();
this.onTabChanged.emit({
index: this.currentIndex,
name: this.tabs[this.currentIndex],
});
}
}
/**
* Updates the list of tabs with new tab names
* @param tabNames The new list of tab names
*/
setTabNames(tabNames: string[]): void {
this.tabs = [...tabNames];
// Ensure current index is within bounds
if (this.currentIndex >= this.tabs.length) {
this.currentIndex = Math.max(0, this.tabs.length - 1);
}
this.updateVisibleRange();
}
}

View File

@@ -1,49 +0,0 @@
import { UIComponent } from "./UIComponent";
/**
* Text output component
*/
export class TextLabel extends UIComponent {
private text: string;
private textColor: number;
private bgColor: number;
constructor(
objectName: string,
x: number,
y: number,
text: string,
textColor: number = colors.white,
bgColor: number = colors.black,
) {
super(objectName, x, y, text.length, 1);
this.text = text;
this.textColor = textColor;
this.bgColor = bgColor;
}
render(): void {
if (!this.visible) return;
const [originalX, originalY] = term.getCursorPos();
// Set colors
term.setTextColor(this.textColor);
term.setBackgroundColor(this.bgColor);
// Move cursor to position and draw text
term.setCursorPos(this.x, this.y);
term.write(this.text);
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
setText(newText: string): void {
this.text = newText;
this.width = newText.length; // Update width based on new text
}
getText(): string {
return this.text;
}
}

View File

@@ -1,104 +0,0 @@
import { Signal } from "./Signal";
import { KeyEvent, CharEvent, TimerEvent } from "../event";
import { UIObject } from "./UIObject";
/**
* Base class for all UI components
*/
export abstract class UIComponent extends UIObject {
protected x: number;
protected y: number;
protected width: number;
protected height: number;
protected visible = true;
protected focused = false;
protected timerTasks: Record<number, (event: TimerEvent) => void> = {};
// Signals for UI events
public onFocus = new Signal<void>();
public onBlur = new Signal<void>();
public onKeyPress = new Signal<KeyEvent>();
public onMouseClick = new Signal<{ x: number; y: number }>();
constructor(objectName: string, x: number, y: number, width = 0, height = 0) {
super(objectName);
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
// Render the component to the terminal
abstract render(): void;
// Handle events
// Key
handleKeyInput(_event: KeyEvent): void {
// Do nothing
}
// Char
handleCharInput(_event: CharEvent): void {
// Do nothing
}
handleTimerTrigger(event: TimerEvent): void {
this.timerTasks[event.id]?.(event);
delete this.timerTasks[event.id];
}
// Get/set focus for the component
focus(): void {
this.focused = true;
this.onFocus.emit();
}
unfocus(): void {
this.focused = false;
this.onBlur.emit();
}
// Show/hide the component
show(): void {
this.visible = true;
}
hide(): void {
this.visible = false;
}
// Check if a point is inside the component
contains(pointX: number, pointY: number): boolean {
return (
pointX >= this.x &&
pointX < this.x + this.width &&
pointY >= this.y &&
pointY < this.y + this.height
);
}
// Start a timer for delay task
startTimer(task: (event: TimerEvent) => void, miliseconds: number): void {
const id = os.startTimer(miliseconds);
this.timerTasks[id] = task;
}
// Getter methods
getX(): number {
return this.x;
}
getY(): number {
return this.y;
}
getWidth(): number {
return this.width;
}
getHeight(): number {
return this.height;
}
isVisible(): boolean {
return this.visible;
}
isFocused(): boolean {
return this.focused;
}
}

View File

@@ -1,32 +1,236 @@
import { CCLog } from "../ccLog";
/**
* New UIObject system for functional component-based UI
* Represents a node in the UI tree
*/
export abstract class UIObject {
readonly objectName: string;
private parent?: UIObject;
private children: Record<string, UIObject> = {};
import { Accessor } from "./reactivity";
public log?: CCLog;
/**
* Layout properties for flexbox layout
*/
export interface LayoutProps {
/** Flexbox direction */
flexDirection?: "row" | "column";
/** Justify content (main axis alignment) */
justifyContent?: "start" | "center" | "end" | "between";
/** Align items (cross axis alignment) */
alignItems?: "start" | "center" | "end";
}
constructor(name: string, parent?: UIObject, log?: CCLog) {
this.objectName = name;
this.parent = parent;
this.log = log;
/**
* Computed layout result after flexbox calculation
*/
export interface ComputedLayout {
x: number;
y: number;
width: number;
height: number;
}
/**
* Base props that all components can accept
*/
export interface BaseProps {
/** CSS-like class names for layout (e.g., "flex flex-col") */
class?: string;
}
/**
* UIObject node type
*/
export type UIObjectType =
| "div"
| "label"
| "button"
| "input"
| "form"
| "h1"
| "h2"
| "h3"
| "for"
| "show"
| "fragment";
/**
* UIObject represents a node in the UI tree
* It can be a component, text, or a control flow element
*/
export class UIObject {
/** Type of the UI object */
type: UIObjectType;
/** Props passed to the component */
props: Record<string, unknown>;
/** Children UI objects */
children: UIObject[];
/** Parent UI object */
parent?: UIObject;
/** Computed layout after flexbox calculation */
layout?: ComputedLayout;
/** Layout properties parsed from class string */
layoutProps: LayoutProps;
/** Whether this component is currently mounted */
mounted: boolean;
/** Cleanup functions to call when unmounting */
cleanupFns: (() => void)[];
/** For text nodes - the text content (can be reactive) */
textContent?: string | Accessor<string>;
/** Event handlers */
handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
constructor(
type: UIObjectType,
props: Record<string, unknown> = {},
children: UIObject[] = []
) {
this.type = type;
this.props = props;
this.children = children;
this.layoutProps = {};
this.mounted = false;
this.cleanupFns = [];
this.handlers = {};
// Parse layout from class prop
this.parseLayout();
// Extract event handlers
this.extractHandlers();
}
public setParent(parent: UIObject) {
this.parent = parent;
this.log ??= parent.log;
}
/**
* Parse CSS-like class string into layout properties
*/
private parseLayout(): void {
const className = this.props.class as string | undefined;
if (className === undefined) return;
public addChild(child: UIObject) {
this.children[child.objectName] = child;
}
public removeChild(child: UIObject) {
Object.entries(this.children).forEach(([key, value]) => {
if (value === child) {
delete this.children[key];
const classes = className.split(" ").filter(c => c.length > 0);
for (const cls of classes) {
// Flex direction
if (cls === "flex-row") {
this.layoutProps.flexDirection = "row";
} else if (cls === "flex-col") {
this.layoutProps.flexDirection = "column";
}
});
// Justify content
else if (cls === "justify-start") {
this.layoutProps.justifyContent = "start";
} else if (cls === "justify-center") {
this.layoutProps.justifyContent = "center";
} else if (cls === "justify-end") {
this.layoutProps.justifyContent = "end";
} else if (cls === "justify-between") {
this.layoutProps.justifyContent = "between";
}
// Align items
else if (cls === "items-start") {
this.layoutProps.alignItems = "start";
} else if (cls === "items-center") {
this.layoutProps.alignItems = "center";
} else if (cls === "items-end") {
this.layoutProps.alignItems = "end";
}
}
// Set defaults
if (this.type === "div") {
this.layoutProps.flexDirection ??= "row";
}
this.layoutProps.justifyContent ??= "start";
this.layoutProps.alignItems ??= "start";
}
/**
* Extract event handlers from props
*/
private extractHandlers(): void {
for (const [key, value] of pairs(this.props)) {
if (typeof key === "string" && key.startsWith("on") && typeof value === "function") {
this.handlers[key] = value as (...args: unknown[]) => void;
}
}
}
/**
* Add a child to this UI object
*/
appendChild(child: UIObject): void {
child.parent = this;
this.children.push(child);
}
/**
* Remove a child from this UI object
*/
removeChild(child: UIObject): void {
const index = this.children.indexOf(child);
if (index !== -1) {
this.children.splice(index, 1);
child.parent = undefined;
}
}
/**
* Mount this component and all children
*/
mount(): void {
if (this.mounted) return;
this.mounted = true;
// Mount all children
for (const child of this.children) {
child.mount();
}
}
/**
* Unmount this component and run cleanup
*/
unmount(): void {
if (!this.mounted) return;
this.mounted = false;
// Unmount all children first
for (const child of this.children) {
child.unmount();
}
// Run cleanup functions
for (const cleanup of this.cleanupFns) {
try {
cleanup();
} catch (e) {
printError(e);
}
}
this.cleanupFns = [];
}
/**
* Register a cleanup function to be called on unmount
*/
onCleanup(fn: () => void): void {
this.cleanupFns.push(fn);
}
}
/**
* Create a text node
*/
export function createTextNode(text: string | Accessor<string>): UIObject {
const node = new UIObject("fragment", {}, []);
node.textContent = text;
return node;
}

View File

@@ -1,107 +0,0 @@
import { UIComponent } from "./UIComponent";
import { KeyEvent, CharEvent, TimerEvent } from "../event";
/**
* Base Window class to manage UI components
*/
export class UIWindow {
private components: UIComponent[] = [];
private focusedComponentIndex = -1;
addComponent(component: UIComponent): void {
this.components.push(component);
}
removeComponent(component: UIComponent): void {
const index = this.components.indexOf(component);
if (index !== -1) {
this.components.splice(index, 1);
}
}
render(): void {
// Clear the terminal
term.clear();
term.setCursorPos(1, 1);
// Render all visible components
for (const component of this.components) {
if (component.isVisible()) {
component.render();
}
}
}
handleKeyInput(event: KeyEvent): void {
// Handle input for the currently focused component
if (
this.focusedComponentIndex >= 0 &&
this.focusedComponentIndex < this.components.length
) {
const focusedComponent = this.components[this.focusedComponentIndex];
if (focusedComponent.isFocused()) {
focusedComponent.handleKeyInput(event);
}
}
}
handleCharInput(event: CharEvent): void {
// Handle input for the currently focused component
if (
this.focusedComponentIndex >= 0 &&
this.focusedComponentIndex < this.components.length
) {
const focusedComponent = this.components[this.focusedComponentIndex];
if (focusedComponent.isFocused()) {
focusedComponent.handleCharInput(event);
}
}
}
handleTimerTrigger(event: TimerEvent) {
for (const component of this.components) {
component.handleTimerTrigger(event);
}
}
setFocus(index: number): void {
// Unfocus current component
if (
this.focusedComponentIndex >= 0 &&
this.focusedComponentIndex < this.components.length
) {
this.components[this.focusedComponentIndex].unfocus();
}
// Change focus
this.focusedComponentIndex = index;
// Focus new component
if (
this.focusedComponentIndex >= 0 &&
this.focusedComponentIndex < this.components.length
) {
this.components[this.focusedComponentIndex].focus();
}
}
setFocusFor(component: UIComponent): void {
const index = this.components.indexOf(component);
if (index !== -1) {
this.setFocus(index);
}
}
getComponent(index: number): UIComponent | undefined {
return this.components[index];
}
getComponents(): UIComponent[] {
return this.components;
}
clear(): void {
term.clear();
term.setCursorPos(1, 1);
}
}

View File

@@ -0,0 +1,338 @@
/**
* Application class for managing the UI lifecycle
*/
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { createEffect } from "./reactivity";
/**
* 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;
constructor() {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
}
/**
* 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.renderFrame();
// Main event loop
parallel.waitForAll(
() => this.renderLoop(),
() => this.eventLoop(),
);
}
/**
* Stop the application
*/
stop(): void {
this.running = false;
if (this.root !== undefined) {
this.root.unmount();
}
clearScreen();
}
/**
* Render loop - continuously renders when needed
*/
private renderLoop(): void {
// Set up reactive rendering - re-render whenever any signal changes
createEffect(() => {
this.renderFrame();
});
while (this.running) {
// Keep the loop alive
os.sleep(0.05);
}
}
/**
* Render a single frame
*/
private renderFrame(): void {
if (this.root === undefined) return;
// Calculate layout
calculateLayout(this.root, this.termWidth, this.termHeight, 1, 1);
// Clear screen
clearScreen();
// Render the tree
renderTree(this.root, this.focusedNode);
}
/**
* Event loop - handles user input
*/
private eventLoop(): void {
while (this.running) {
const [eventType, ...eventData] = os.pullEvent() as LuaMultiReturn<
unknown[]
>;
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.handleMouseClick(
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;
}
}
}
}
}
/**
* 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") {
// Add character to text input
const onInputProp = this.focusedNode.props.onInput;
const valueProp = this.focusedNode.props.value;
if (
typeof onInputProp === "function" &&
typeof valueProp === "function"
) {
const currentValue = (valueProp as () => string)();
(onInputProp as (v: string) => void)(currentValue + char);
this.needsRender = true;
}
}
}
}
/**
* Handle mouse click events
*/
private handleMouseClick(button: number, x: number, y: number): void {
if (button !== 1 || this.root === undefined) return;
// Find which element was clicked
const clicked = this.findNodeAt(this.root, x, y);
if (clicked !== undefined) {
// Set focus
this.focusedNode = clicked;
// Trigger click handler
if (clicked.type === "button") {
const onClick = clicked.handlers.onClick;
if (onClick) {
(onClick as () => void)();
}
} 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;
}
}
/**
* 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;
if (x >= nx && x < nx + width && y >= ny && y < ny + height) {
// Only return interactive elements
if (node.type === "button" || node.type === "input") {
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];
}
}
/**
* 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();
}
}

219
src/lib/ccTUI/components.ts Normal file
View File

@@ -0,0 +1,219 @@
/**
* Functional component API
* Provides declarative UI building blocks
*/
import { UIObject, BaseProps, createTextNode } from "./UIObject";
import { Accessor, Setter, Signal } from "./reactivity";
/**
* Props for div component
*/
export type DivProps = BaseProps & Record<string, unknown>;
/**
* Props for label component
*/
export type LabelProps = BaseProps & Record<string, unknown>;
/**
* Props for button component
*/
export type ButtonProps = BaseProps & {
/** Click handler */
onClick?: () => void;
} & Record<string, unknown>;
/**
* Props for input component
*/
export type InputProps = BaseProps & {
/** Input type */
type?: "text" | "checkbox";
/** Value signal for text input */
value?: Accessor<string> | Signal<string>;
/** Input handler for text input */
onInput?: Setter<string> | ((value: string) => void);
/** Checked signal for checkbox */
checked?: Accessor<boolean> | Signal<boolean>;
/** Change handler for checkbox */
onChange?: Setter<boolean> | ((checked: boolean) => void);
/** Placeholder text */
placeholder?: string;
} & Record<string, unknown>;
/**
* Props for form component
*/
export type FormProps = BaseProps & {
/** Submit handler */
onSubmit?: () => void;
} & Record<string, unknown>;
/**
* Generic container component for layout
*
* @param props - Component props including layout classes
* @param children - Child components or text
* @returns UIObject representing the div
*
* @example
* ```typescript
* div({ class: "flex flex-col justify-center" },
* label({}, "Hello"),
* button({ onClick: () => print("clicked") }, "Click me")
* )
* ```
*/
export function div(
props: DivProps,
...children: (UIObject | string | Accessor<string>)[]
): UIObject {
// Convert string children to text nodes
const uiChildren = children.map((child) => {
if (typeof child === "string" || typeof child === "function") {
return createTextNode(child);
}
return child;
});
const node = new UIObject("div", props, uiChildren);
uiChildren.forEach((child) => (child.parent = node));
return node;
}
/**
* Text label component
*
* @param props - Component props
* @param text - Text content (can be a string or signal)
* @returns UIObject representing the label
*
* @example
* ```typescript
* const [name, setName] = createSignal("World");
* label({}, () => `Hello, ${name()}!`)
* ```
*/
export function label(
props: LabelProps,
text: string | Accessor<string>,
): UIObject {
const textNode = createTextNode(text);
const node = new UIObject("label", props, [textNode]);
textNode.parent = node;
return node;
}
/**
* Heading level 1 component
*
* @param text - Heading text
* @returns UIObject representing h1
*/
export function h1(text: string | Accessor<string>): UIObject {
return label({ class: "heading-1" }, text);
}
/**
* Heading level 2 component
*
* @param text - Heading text
* @returns UIObject representing h2
*/
export function h2(text: string | Accessor<string>): UIObject {
return label({ class: "heading-2" }, text);
}
/**
* Heading level 3 component
*
* @param text - Heading text
* @returns UIObject representing h3
*/
export function h3(text: string | Accessor<string>): UIObject {
return label({ class: "heading-3" }, text);
}
/**
* Button component
*
* @param props - Component props including onClick handler
* @param text - Button text
* @returns UIObject representing the button
*
* @example
* ```typescript
* button({ onClick: () => print("Clicked!") }, "Click me")
* ```
*/
export function button(props: ButtonProps, text: string): UIObject {
const textNode = createTextNode(text);
const node = new UIObject("button", props, [textNode]);
textNode.parent = node;
return node;
}
/**
* Input component (text input or checkbox)
*
* @param props - Component props
* @returns UIObject representing the input
*
* @example
* ```typescript
* // Text input
* const [text, setText] = createSignal("");
* input({ type: "text", value: text, onInput: setText, placeholder: "Enter text" })
*
* // Checkbox
* const [checked, setChecked] = createSignal(false);
* input({ type: "checkbox", checked: checked, onChange: setChecked })
* ```
*/
export function input(props: InputProps): UIObject {
// Normalize signal tuples to just the accessor
const normalizedProps = { ...props };
if (Array.isArray(normalizedProps.value)) {
normalizedProps.value = (normalizedProps.value)[0];
}
if (Array.isArray(normalizedProps.checked)) {
normalizedProps.checked = (normalizedProps.checked)[0];
}
return new UIObject("input", normalizedProps, []);
}
/**
* Form component for grouping inputs
*
* @param props - Component props including onSubmit handler
* @param children - Child components
* @returns UIObject representing the form
*
* @example
* ```typescript
* form({ onSubmit: () => print("Submitted!"), class: "flex flex-row" },
* input({ placeholder: "Enter text" }),
* button({}, "Submit")
* )
* ```
*/
export function form(
props: FormProps,
...children: (UIObject | string | Accessor<string>)[]
): UIObject {
// Convert string children to text nodes
const uiChildren = children.map((child) => {
if (typeof child === "string" || typeof child === "function") {
return createTextNode(child);
}
return child;
});
const node = new UIObject("form", props, uiChildren);
uiChildren.forEach((child) => (child.parent = node));
return node;
}

View File

@@ -0,0 +1,145 @@
/**
* Control flow components for conditional and list rendering
*/
import { UIObject } from "./UIObject";
import { Accessor, createEffect } from "./reactivity";
/**
* Props for For component
*/
export type ForProps<T> = {
/** Signal or accessor containing the array to iterate over */
each: Accessor<T[]>;
} & Record<string, unknown>;
/**
* Props for Show component
*/
export type ShowProps = {
/** Condition accessor - when true, shows the child */
when: Accessor<boolean>;
/** Optional fallback to show when condition is false */
fallback?: UIObject;
} & Record<string, unknown>;
/**
* For component - renders a list of items
* Efficiently updates when the array changes
*
* @template T - The type of items in the array
* @param props - Props containing the array accessor
* @param renderFn - Function to render each item
* @returns UIObject representing the list
*
* @example
* ```typescript
* const [todos, setTodos] = createStore<Todo[]>([]);
*
* For({ each: () => todos },
* (todo, i) => div({ class: "flex flex-row" },
* label({}, () => todo.title),
* button({ onClick: () => setTodos(arr => removeIndex(arr, i())) }, "X")
* )
* )
* ```
*/
export function For<T>(
props: ForProps<T>,
renderFn: (item: T, index: Accessor<number>) => UIObject
): UIObject {
const container = new UIObject("for", props, []);
// Track rendered items
let renderedItems: UIObject[] = [];
/**
* Update the list when the array changes
*/
const updateList = () => {
const items = props.each();
// Clear old items
renderedItems.forEach(item => item.unmount());
container.children = [];
renderedItems = [];
// Render new items
items.forEach((item, index) => {
const indexAccessor = () => index;
const rendered = renderFn(item, indexAccessor);
rendered.parent = container;
container.children.push(rendered);
renderedItems.push(rendered);
rendered.mount();
});
};
// Create effect to watch for changes
createEffect(() => {
updateList();
});
return container;
}
/**
* Show component - conditionally renders content
*
* @param props - Props containing condition and optional fallback
* @param child - Content to show when condition is true
* @returns UIObject representing the conditional content
*
* @example
* ```typescript
* const [loggedIn, setLoggedIn] = createSignal(false);
*
* Show(
* {
* when: loggedIn,
* fallback: button({ onClick: () => setLoggedIn(true) }, "Log In")
* },
* button({ onClick: () => setLoggedIn(false) }, "Log Out")
* )
* ```
*/
export function Show(props: ShowProps, child: UIObject): UIObject {
const container = new UIObject("show", props, []);
let currentChild: UIObject | undefined = undefined;
/**
* Update which child is shown based on condition
*/
const updateChild = () => {
const condition = props.when();
// Unmount current child
if (currentChild !== undefined) {
currentChild.unmount();
container.removeChild(currentChild);
}
// Mount appropriate child
if (condition) {
currentChild = child;
} else if (props.fallback !== undefined) {
currentChild = props.fallback;
} else {
currentChild = undefined;
return;
}
if (currentChild !== undefined) {
container.appendChild(currentChild);
currentChild.mount();
}
};
// Create effect to watch for condition changes
createEffect(() => {
updateChild();
});
return container;
}

438
src/lib/ccTUI/framework.md Normal file
View File

@@ -0,0 +1,438 @@
# ccTUI 框架重构指南
本文档旨在指导 ccTUI 框架的重构工作。框架将用于 Minecraft ComputerCraft (CC: Tweaked) 环境,其设计灵感来源于现代前端框架 SolidJS并采用声明式、组件化的编程模型。
## 核心理念
- **声明式 UI**: 像 SolidJS 或 React 一样,通过编写函数式组件来描述 UI 状态,而不是手动操作界面。
- **响应式状态管理**: UI 会根据状态State/Signal的变化自动更新开发者无需手动重绘。
- **组件化**: 将 UI 拆分为可复用的组件(函数),每个组件负责自身的状态和渲染。
- **Flexbox 布局**: 借鉴 Web 上的 Flexbox 模型,提供一套声明式的、强大的布局工具,以替代传统的绝对坐标布局。
---
## 目标 API 预览
为了直观地展示目标 API这里将 SolidJS 的 "Simple Todos" 示例与我们期望的 ccTUI 实现进行对比。
**SolidJS (Web) 版本:**
```typescript
// ... imports
const App = () => {
const [newTitle, setTitle] = createSignal("");
const [todos, setTodos] = createLocalStore<TodoItem[]>("todo list", []);
// ... logic
return (
<>
<h3>Simple Todos Example</h3>
<form onSubmit={addTodo}>
<input /* ...props */ />
<button>+</button>
</form>
<For each={todos}>
{(todo, i) => (
<div>
<input type="checkbox" /* ...props */ />
<input type="text" /* ...props */ />
<button /* ...props */>x</button>
</div>
)}
</For>
</>
);
};
render(App, document.getElementById("app")!);
```
**ccTUI (ComputerCraft) 目标版本:**
```typescript
// ... imports
type TodoItem = { title: string; done: boolean };
const App = () => {
const [newTitle, setTitle] = createSignal("");
const [todos, setTodos] = createStore<TodoItem[]>([]); // 使用简化版的 Store
const addTodo = () => {
batch(() => {
setTodos(todos.length, { title: newTitle(), done: false });
setTitle("");
});
};
return div({ class: "flex flex-col" }, // 使用类似 TailwindCSS 的类名进行布局
h3("Simple Todos Example"),
form({ onSubmit: addTodo, class: "flex flex-row" },
input({
placeholder: "enter todo and click +",
value: newTitle, // 直接传递 Signal
onInput: setTitle, // 直接传递 Setter
}),
button("+")
),
For({ each: todos },
(todo, i) => div({ class: "flex flex-row items-center" },
input({
type: "checkbox",
checked: () => todo.done, // 通过 accessor 获取
onChange: (checked) => setTodos(i(), "done", checked),
}),
input({
type: "text",
value: () => todo.title,
onChange: (newTitle) => setTodos(i(), "title", newTitle),
}),
button({ onClick: () => setTodos((t) => removeIndex(t, i())) }, "x")
)
)
);
};
render(App);
```
*注意:上述 ccTUI 代码是设计目标,具体实现(如 `createStore`, `removeIndex`)需要被创建。*
---
## 1. 基础组件 API
组件是返回 `UIObject` 的函数。第一个参数是 `props` 对象,后续参数是子组件。
### 容器与文本
- **`div(props: DivProps, ...children: UIObject[]): UIObject`**
- 通用容器组件,用于包裹其他组件并应用布局样式。
- `DivProps`: `{ class?: string }` - `class` 属性用于指定布局,详见“布局系统”。
- **`label(props: LabelProps, text: string | Signal<string>): UIObject`**
- 静态或动态文本标签。
- `LabelProps`: `{ class?: string }`
- **`h1`, `h2`, `h3`(text): UIObject**
- 预设样式的标题标签,本质是 `label` 的封装。
### 交互组件
- **`button(props: ButtonProps, text: string): UIObject`**
- 可点击的按钮。
- `ButtonProps`: `{ onClick?: () => void, class?: string }`
- 按钮会在被点击时调用 `onClick` 回调。
- **`input(props: InputProps): UIObject`**
- 文本或复选框输入。
- `InputProps`:
- `type?: "text" | "checkbox"` (默认为 "text")
- `value?: Signal<string>`: (用于 text) 文本内容的 Signal。
- `onInput?: (value: string) => void`: (用于 text) 内容变化时的回调。
- `checked?: Signal<boolean>`: (用于 checkbox) 选中状态的 Signal。
- `onChange?: (checked: boolean) => void`: (用于 checkbox) 状态变化时的回调。
- `placeholder?: string`
- `class?: string`
- **`form(props: FormProps, ...children: UIObject[]): UIObject`**
- 表单容器,主要用于组织输入组件。
- `FormProps`: `{ onSubmit?: () => void, class?: string }`
- 在表单内按回车键(或点击提交按钮,如果未来实现)会触发 `onSubmit`
---
## 2. 控制流
- **`For<T>(props: ForProps<T>, renderFn: (item: T, index: number) => UIObject): UIObject`**
- 用于渲染列表。它会根据 `each` 数组的变化,高效地创建、销毁或更新子组件。
- `ForProps`: `{ each: Signal<T[]> }`
- `renderFn`: 一个函数,接收当前项和索引,返回用于渲染该项的 `UIObject`
- **`Show(props: ShowProps, child: UIObject): UIObject`**
- 用于条件渲染。当 `when` 条件为 `true` 时渲染 `child`,否则渲染 `fallback`
- `ShowProps`:
- `when: () => boolean`: 一个返回布尔值的访问器函数 (accessor)。
- `fallback?: UIObject`: 当 `when` 返回 `false` 时要渲染的组件。
- `child`: 当 `when` 返回 `true` 时要渲染的组件。
**SolidJS 示例:**
```typescript
import { createSignal, Show } from "solid-js";
function App() {
const [loggedIn, setLoggedIn] = createSignal(false);
const toggle = () => setLoggedIn(!loggedIn());
return (
<Show
when={loggedIn()}
fallback={<button onClick={toggle}>Log In</button>}
>
<button onClick={toggle}>Log Out</button>
</Show>
);
}
```
**ccTUI 目标版本:**
```typescript
const App = () => {
const [loggedIn, setLoggedIn] = createSignal(false);
const toggle = () => setLoggedIn(!loggedIn());
return Show(
{
when: loggedIn, // 直接传递 Signal 的 getter
fallback: button({ onClick: toggle }, "Log In"),
},
button({ onClick: toggle }, "Log Out")
);
};
```
---
## 3. 布局系统 (Flexbox)
借鉴 TailwindCSS 的类名系统,通过 `class` 属性为 `div` 等容器组件提供布局指令。渲染引擎需要解析这些类名并应用 Flexbox 算法。
### 核心类名
- **`flex`**: 必须。将容器声明为 Flex 容器。
- **`flex-row`**: (默认) 主轴方向为水平。
- **`flex-col`**: 主轴方向为垂直。
### 对齐与分布 (Justify & Align)
- **`justify-start`**: (默认) 从主轴起点开始排列。
- **`justify-center`**: 主轴居中。
- **`justify-end`**: 从主轴终点开始排列。
- **`justify-between`**: 两端对齐,项目之间的间隔都相等。
- **`items-start`**: 交叉轴的起点对齐。
- **`items-center`**: 交叉轴的中点对齐。
- **`items-end`**: 交叉轴的终点对齐。
### 示例
```typescript
// 一个垂直居中的登录框
div({ class: "flex flex-col justify-center items-center" },
label("Username"),
input({}),
label("Password"),
input({}),
button("Login")
)
```
### 实现要点
渲染引擎在计算布局时:
1. 解析 `class` 字符串,转换为布局属性(如 `flexDirection`, `justifyContent`)。
2. 实现一个简化的 Flexbox 算法,该算法能根据容器尺寸、子元素尺寸和布局属性,为每个子元素计算出正确的 `(x, y)` 坐标和 `(width, height)`。
3. 在 `draw` 阶段,将计算出的区域传递给子组件进行绘制。
---
## 4. 响应式系统 (Reactivity System)
框架的核心是其细粒度的响应式系统。该系统由 Signal 和 Effect 组成,其设计深受 SolidJS 启发。理解这两者是构建动态UI的关键。
### `createSignal`: 响应式的基本单元
Signal 是一个包含值的“盒子”,当它的值发生变化时,它可以通知所有正在“监听”它的代码。
- **`createSignal<T>(initialValue: T): [() => T, (newValue: T) => void]`**
- 它接收一个初始值,并返回一个包含两个函数的数组:一个 `getter` 和一个 `setter`。
- **Getter** (`() => T`): 一个无参数的函数,调用它会返回 Signal 的当前值。**重要的是,在特定上下文(如组件渲染或 Effect 中)调用 getter 会自动将该上下文注册为监听者。**
- **Setter** (`(newValue: T) => void`): 一个函数,用于更新 Signal 的值。调用它会触发所有监听该 Signal 的上下文重新执行。
**示例:**
```typescript
// 1. 创建一个 signal
const [count, setCount] = createSignal(0);
// 2. 读取值 (这是一个函数调用)
print(count()); // 输出: 0
// 3. 更新值
setCount(1);
print(count()); // 输出: 1
// 4. 在组件中使用 (当 count 变化时label 会自动更新)
label({}, () => `Count: ${count()}`);
```
### `createEffect`: 响应 Signal 的变化
Effect 用于将响应式系统与外部世界如日志、计时器、手动API调用连接起来。它是一个自动跟踪其依赖即它内部读取的 Signal并重新执行的函数。
- **`createEffect(fn: () => void): void`**
- 它接收一个函数 `fn` 并立即执行一次。
- 框架会监视 `fn` 在执行期间读取了哪些 Signal (调用了哪些 getter)。
- 当任何一个被依赖的 Signal 更新时,`fn` 会被自动重新执行。
**示例:**
```typescript
const [count, setCount] = createSignal(0);
// 创建一个 effect 来响应 count 的变化
createEffect(() => {
// 这个 effect 读取了 count(),因此它依赖于 count Signal
print(`The current count is: ${count()}`);
});
// 控制台立即输出: "The current count is: 0"
// 稍后在代码的其他地方更新 signal
setCount(5);
// effect 会自动重新运行,控制台输出: "The current count is: 5"
```
### 更新与批处理
- **`batch(fn: () => void)`**
- 将多次状态更新合并为一次,以进行单次、高效的 UI 重绘。如果你需要在一个操作中连续多次调用 `setter`,应该将它们包裹在 `batch` 中以获得最佳性能。
```typescript
batch(() => {
setFirstName("John");
setLastName("Smith");
}); // UI 只会更新一次
```
### 复杂状态管理
- **`createStore<T extends object>(initialValue: T): [T, (updater: ...) => void]`**
- 用于响应式地管理对象和数组。与 `createSignal` 管理单个值不同,`createStore` 允许你独立地更新对象或数组的特定部分,并只触发关心这些部分的更新。其 API 应参考 SolidJS 的 `createStore`。
---
## 5. 代码规范与构建
- **代码规范**:
- 使用 `unknown` 代替 `any`。
- 使用 `undefined` 代替 `null`。
- 遵循 TSDoc 规范为所有函数、参数、返回值、分支和循环添加注释。
- **构建与验证**:
- 使用 `just build-example sync` 命令构建示例代码并检查编译时错误。
- 使用 `pnpm dlx eslint [file]` 命令对修改后的文件进行代码风格检查。
---
## 6. 文件结构说明
本节旨在说明 `ccTUI` 框架核心目录下的主要文件及其职责。
### `src/lib/ccTUI/`
- **`index.ts`**: 框架的公共 API 入口。所有可供外部使用的组件(如 `div`, `button`)和函数(如 `createSignal`)都应由此文件导出。
- **`Signal.ts`**: 包含框架的响应式系统核心,即 `createSignal`, `createEffect`, `batch` 等的实现。
- **`UIObject.ts`**: 所有 UI 元素的基类或基础类型。定义了如位置、尺寸、父子关系、绘制draw和更新update等通用接口。
- **`TUIApplication.ts`**: 应用程序的根实例。负责管理主窗口、事件循环event loop、焦点管理和全局重绘。
- **`UIWindow.ts`**: 代表一个独立的窗口(通常是整个终端屏幕),作为所有 UI 元素的根容器和绘制表面。
- **`TextLabel.ts`, `Button.ts`, `InputField.ts`**: 具体的基础组件实现。
- **`framework.md`**: (本文档) 框架的设计指南、API 参考和代码规范。
---
## 7. 框架示例
- **`src/tuiExample/main.ts`**
- 此文件是 `ccTUI` 框架的功能示例和测试场(使用旧的 API
- **`src/tuiExample/main.new.ts`**
- 新的响应式框架示例,展示 SolidJS 风格的 API。
- 在对框架进行任何修改或添加新功能后,都应在此文件中创建相应的示例来验证其正确性并进行展示。
- 使用 `just build-example sync` 命令可以编译此示例并将其同步到游戏内的 `computer` 目录中,以便在 Minecraft 环境中实际运行和查看效果。
---
## 8. 实现状态
### ✅ 已实现的功能
#### 响应式系统 (reactivity.ts)
- ✅ `createSignal<T>(initialValue: T)` - 创建响应式信号
- ✅ `createEffect(fn: () => void)` - 创建自动跟踪依赖的副作用
- ✅ `batch(fn: () => void)` - 批量更新多个信号
- ✅ `createMemo<T>(fn: () => T)` - 创建派生信号(计算属性)
#### Store (store.ts)
- ✅ `createStore<T>(initialValue: T)` - 创建响应式存储,用于管理对象和数组
- ✅ `removeIndex<T>(array: T[], index: number)` - 辅助函数:从数组中移除元素
- ✅ `insertAt<T>(array: T[], index: number, item: T)` - 辅助函数:插入元素到数组
#### 基础组件 (components.ts)
- ✅ `div(props, ...children)` - 通用容器组件
- ✅ `label(props, text)` - 文本标签组件
- ✅ `h1(text)`, `h2(text)`, `h3(text)` - 标题组件
- ✅ `button(props, text)` - 按钮组件
- ✅ `input(props)` - 输入组件(支持 text 和 checkbox 类型)
- ✅ `form(props, ...children)` - 表单容器组件
#### 控制流 (controlFlow.ts)
- ✅ `For<T>(props, renderFn)` - 列表渲染组件
- ✅ `Show(props, child)` - 条件渲染组件
#### 布局系统 (layout.ts)
- ✅ Flexbox 布局引擎实现
- ✅ 支持的类名:
- `flex-row`, `flex-col` - 设置 flex 方向
- `justify-start`, `justify-center`, `justify-end`, `justify-between` - 主轴对齐
- `items-start`, `items-center`, `items-end` - 交叉轴对齐
#### 渲染器 (renderer.ts)
- ✅ 将 UI 树渲染到 ComputerCraft 终端
- ✅ 支持响应式文本内容
- ✅ 处理焦点状态的视觉反馈
#### 应用程序 (application.ts)
- ✅ `Application` 类 - 管理应用生命周期
- ✅ `render(rootFn)` - 便捷的渲染函数
- ✅ 事件循环(键盘、鼠标)
- ✅ 自动焦点管理
- ✅ 响应式重渲染
### 📋 API 导出 (index.ts)
- ✅ 所有新 API 已正确导出
- ✅ 保留旧 API 以实现向后兼容
### 🎯 示例代码
- ✅ `main.new.ts` - 简单的计数器示例,演示响应式系统的基本用法
### 🔄 向后兼容性
- ✅ 旧的类组件系统Signal, UIComponent, Button 等)仍然可用
- ✅ 旧的示例代码 `main.ts` 不受影响
---
## 9. 使用指南
### 基本示例
```typescript
import { createSignal, div, label, button, render } from "../lib/ccTUI";
const App = () => {
const [count, setCount] = createSignal(0);
return div({ class: "flex flex-col" },
label({}, () => `Count: ${count()}`),
button({ onClick: () => setCount(count() + 1) }, "Increment")
);
};
render(App);
```
### 构建与运行
```bash
# 构建示例
just build-example
# 构建并同步到游戏
just build-example sync
# 或使用 pnpm 直接构建
pnpm tstl -p ./tsconfig.tuiExample.json
```
### 代码检查
```bash
# 运行 ESLint 检查
pnpm dlx eslint src/lib/ccTUI/reactivity.ts
```

View File

@@ -1,30 +1,55 @@
/**
* ComputerCraft TUI (Terminal User Interface) Framework
* Based on Qt signal/slot principles for event handling
* Provides input/output, option selection and keyboard event handling
* A declarative, reactive UI framework inspired by SolidJS
* Provides components, reactivity, and flexbox layout for ComputerCraft
*/
import { Signal } from "./Signal";
import { UIObject } from "./UIObject";
import { UIComponent } from "./UIComponent";
import { TextLabel } from "./TextLabel";
import { InputField } from "./InputField";
import { OptionSelector } from "./OptionSelector";
import { TabBar } from "./TabBar";
import { UIWindow } from "./UIWindow";
import { TUIApplication } from "./TUIApplication";
import { Button } from "./Button";
// Export the main classes for use in other modules
// Reactivity system
export {
createSignal,
createEffect,
createMemo,
batch,
type Accessor,
type Setter,
type Signal,
} from "./reactivity";
// Store for complex state
export {
createStore,
removeIndex,
insertAt,
type SetStoreFunction,
} from "./store";
// Components
export {
div,
label,
h1,
h2,
h3,
button,
input,
form,
type DivProps,
type LabelProps,
type ButtonProps,
type InputProps,
type FormProps,
} from "./components";
// Control flow
export { For, Show, type ForProps, type ShowProps } from "./controlFlow";
// Application
export { Application, render } from "./application";
// Core types
export {
Signal,
UIObject,
UIComponent,
TextLabel,
InputField,
OptionSelector,
TabBar,
UIWindow,
TUIApplication,
Button,
};
type LayoutProps,
type ComputedLayout,
type BaseProps,
} from "./UIObject";

209
src/lib/ccTUI/layout.ts Normal file
View File

@@ -0,0 +1,209 @@
/**
* Flexbox layout engine
* Calculates positions and sizes for UI elements based on flexbox rules
*/
import { UIObject } from "./UIObject";
/**
* Measure the natural size of a UI element
* This determines how much space an element wants to take up
*
* @param node - The UI node to measure
* @returns Width and height of the element
*/
function measureNode(node: UIObject): { width: number; height: number } {
// Get text content if it exists
const getTextContent = (): string => {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return node.textContent();
}
return node.textContent;
}
return "";
};
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent();
return { width: text.length, height: 1 };
}
case "button": {
const text = getTextContent();
// Buttons have brackets around them: [text]
return { width: text.length + 2, height: 1 };
}
case "input": {
const type = node.props.type as string | undefined;
if (type === "checkbox") {
return { width: 3, height: 1 }; // [X] or [ ]
}
// Text input - use a default width or from props
const width = (node.props.width as number | undefined) ?? 20;
return { width, height: 1 };
}
case "div":
case "form":
case "for":
case "show":
case "fragment": {
// Container elements size based on their children
let totalWidth = 0;
let totalHeight = 0;
if (node.children.length === 0) {
return { width: 0, height: 0 };
}
const direction = node.layoutProps.flexDirection ?? "row";
if (direction === "row") {
// In row direction, width is sum of children, height is max
for (const child of node.children) {
const childSize = measureNode(child);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
} else {
// In column direction, height is sum of children, width is max
for (const child of node.children) {
const childSize = measureNode(child);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
}
return { width: totalWidth, height: totalHeight };
}
default:
return { width: 0, height: 0 };
}
}
/**
* Apply flexbox layout algorithm to a container and its children
*
* @param node - The container node
* @param availableWidth - Available width for layout
* @param availableHeight - Available height for layout
* @param startX - Starting X position
* @param startY - Starting Y position
*/
export function calculateLayout(
node: UIObject,
availableWidth: number,
availableHeight: number,
startX = 1,
startY = 1
): void {
// Set this node's layout
node.layout = {
x: startX,
y: startY,
width: availableWidth,
height: availableHeight,
};
if (node.children.length === 0) {
return;
}
const direction = node.layoutProps.flexDirection ?? "row";
const justify = node.layoutProps.justifyContent ?? "start";
const align = node.layoutProps.alignItems ?? "start";
// Measure all children
const childMeasurements = node.children.map((child: UIObject) => measureNode(child));
// Calculate total size needed
let totalMainAxisSize = 0;
let maxCrossAxisSize = 0;
if (direction === "row") {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.width;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height);
}
} else {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.height;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width);
}
}
// Calculate starting position based on justify-content
let mainAxisPos = 0;
let spacing = 0;
if (direction === "row") {
const remainingSpace = availableWidth - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
} else {
const remainingSpace = availableHeight - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
}
// Position each child
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const measure = childMeasurements[i];
let childX = startX;
let childY = startY;
if (direction === "row") {
// Main axis is horizontal
childX = startX + math.floor(mainAxisPos);
// Cross axis (vertical) alignment
if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") {
childY = startY + (availableHeight - measure.height);
} else {
childY = startY; // start
}
mainAxisPos += measure.width + spacing;
} else {
// Main axis is vertical
childY = startY + math.floor(mainAxisPos);
// Cross axis (horizontal) alignment
if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") {
childX = startX + (availableWidth - measure.width);
} else {
childX = startX; // start
}
mainAxisPos += measure.height + spacing;
}
// Recursively calculate layout for child
calculateLayout(child, measure.width, measure.height, childX, childY);
}
}

190
src/lib/ccTUI/reactivity.ts Normal file
View File

@@ -0,0 +1,190 @@
/**
* Reactive system inspired by SolidJS
* Provides fine-grained reactivity with Signals and Effects
*/
/**
* Type for a Signal getter function
*/
export type Accessor<T> = () => T;
/**
* Type for a Signal setter function
*/
export type Setter<T> = (value: T) => void;
/**
* Type for a Signal tuple [getter, setter]
*/
export type Signal<T> = [Accessor<T>, Setter<T>];
/**
* Listener function type that gets notified when a signal changes
*/
type Listener = () => void;
/**
* Context stack for tracking which effect is currently running
*/
let currentListener: Listener | undefined = undefined;
/**
* Batch update context - when true, effects won't run until batch completes
*/
let batchDepth = 0;
const pendingEffects = new Set<Listener>();
/**
* Creates a reactive signal with a getter and setter
*
* @template T - The type of the signal value
* @param initialValue - The initial value of the signal
* @returns A tuple containing [getter, setter]
*
* @example
* ```typescript
* const [count, setCount] = createSignal(0);
* print(count()); // 0
* setCount(5);
* print(count()); // 5
* ```
*/
export function createSignal<T>(initialValue: T): Signal<T> {
let value = initialValue;
const listeners = new Set<Listener>();
/**
* Getter function - reads the current value and subscribes the current listener
*/
const getter: Accessor<T> = () => {
// Subscribe the current running effect/computation
if (currentListener !== undefined) {
listeners.add(currentListener);
}
return value;
};
/**
* Setter function - updates the value and notifies all listeners
*/
const setter: Setter<T> = (newValue: T) => {
// Only update if value actually changed
if (value !== newValue) {
value = newValue;
// Notify all subscribed listeners
if (batchDepth > 0) {
// In batch mode, collect effects to run later
listeners.forEach(listener => pendingEffects.add(listener));
} else {
// Run effects immediately
listeners.forEach(listener => {
try {
listener();
} catch (e) {
printError(e);
}
});
}
}
};
return [getter, setter];
}
/**
* Creates an effect that automatically tracks its dependencies and reruns when they change
*
* @param fn - The effect function to run
*
* @example
* ```typescript
* const [count, setCount] = createSignal(0);
* createEffect(() => {
* print(`Count is: ${count()}`);
* });
* setCount(1); // Effect automatically reruns and prints "Count is: 1"
* ```
*/
export function createEffect(fn: () => void): void {
const effect = () => {
// Set this effect as the current listener
const prevListener = currentListener;
currentListener = effect;
try {
// Run the effect function - it will subscribe to any signals it reads
fn();
} finally {
// Restore previous listener
currentListener = prevListener;
}
};
// Run the effect immediately for the first time
effect();
}
/**
* Batches multiple signal updates to prevent excessive re-renders
* All signal updates within the batch function will only trigger effects once
*
* @param fn - Function containing multiple signal updates
*
* @example
* ```typescript
* batch(() => {
* setFirstName("John");
* setLastName("Doe");
* }); // Effects only run once after both updates
* ```
*/
export function batch(fn: () => void): void {
batchDepth++;
try {
fn();
} finally {
batchDepth--;
// If we're done with all batches, run pending effects
if (batchDepth === 0) {
const effects = Array.from(pendingEffects);
pendingEffects.clear();
effects.forEach(effect => {
try {
effect();
} catch (e) {
printError(e);
}
});
}
}
}
/**
* Creates a derived signal (memo) that computes a value based on other signals
* The computation is cached and only recomputed when dependencies change
*
* @template T - The type of the computed value
* @param fn - Function that computes the value
* @returns An accessor function for the computed value
*
* @example
* ```typescript
* const [firstName, setFirstName] = createSignal("John");
* const [lastName, setLastName] = createSignal("Doe");
* const fullName = createMemo(() => `${firstName()} ${lastName()}`);
* print(fullName()); // "John Doe"
* ```
*/
export function createMemo<T>(fn: () => T): Accessor<T> {
const [value, setValue] = createSignal<T>(undefined as unknown as T);
createEffect(() => {
setValue(fn());
});
return value;
}

199
src/lib/ccTUI/renderer.ts Normal file
View File

@@ -0,0 +1,199 @@
/**
* Renderer for drawing UI to the ComputerCraft terminal
*/
import { UIObject } from "./UIObject";
import { Accessor } from "./reactivity";
/**
* Get text content from a node (resolving signals if needed)
*/
function getTextContent(node: UIObject): string {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return (node.textContent)();
}
return node.textContent;
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return (child.textContent)();
}
return child.textContent!;
}
return "";
}
/**
* Draw a single UI node to the terminal
*
* @param node - The node to draw
* @param focused - Whether this node has focus
*/
function drawNode(node: UIObject, focused: boolean): void {
if (!node.layout) return;
const { x, y, width } = node.layout;
// Save cursor position
const [origX, origY] = term.getCursorPos();
try {
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent(node);
// Set colors based on heading level
if (node.type === "h1") {
term.setTextColor(colors.yellow);
} else if (node.type === "h2") {
term.setTextColor(colors.orange);
} else if (node.type === "h3") {
term.setTextColor(colors.lightGray);
} else {
term.setTextColor(colors.white);
}
term.setBackgroundColor(colors.black);
term.setCursorPos(x, y);
term.write(text.substring(0, width));
break;
}
case "button": {
const text = getTextContent(node);
// Set colors based on focus
if (focused) {
term.setTextColor(colors.black);
term.setBackgroundColor(colors.yellow);
} else {
term.setTextColor(colors.white);
term.setBackgroundColor(colors.gray);
}
term.setCursorPos(x, y);
term.write(`[${text}]`);
break;
}
case "input": {
const type = node.props.type as string | undefined;
if (type === "checkbox") {
// Draw checkbox
let isChecked = false;
const checkedProp = node.props.checked;
if (typeof checkedProp === "function") {
isChecked = (checkedProp as Accessor<boolean>)();
}
if (focused) {
term.setTextColor(colors.black);
term.setBackgroundColor(colors.white);
} else {
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
}
term.setCursorPos(x, y);
term.write(isChecked ? "[X]" : "[ ]");
} else {
// Draw text input
let value = "";
const valueProp = node.props.value;
if (typeof valueProp === "function") {
value = (valueProp as Accessor<string>)();
}
const placeholder = node.props.placeholder as string | undefined;
let displayText = value;
if (value === "" && placeholder !== undefined) {
displayText = placeholder;
term.setTextColor(colors.gray);
} else if (focused) {
term.setTextColor(colors.black);
} else {
term.setTextColor(colors.white);
}
if (focused) {
term.setBackgroundColor(colors.white);
} else {
term.setBackgroundColor(colors.black);
}
term.setCursorPos(x, y);
// Pad or truncate to fit width
if (displayText.length > width) {
displayText = displayText.substring(0, width);
} else {
displayText = displayText.padEnd(width, " ");
}
term.write(displayText);
}
break;
}
case "div":
case "form":
case "for":
case "show": {
// Container elements don't draw themselves, just their children
break;
}
case "fragment": {
// Fragment with text content
if (node.textContent !== undefined) {
const text = typeof node.textContent === "function"
? (node.textContent)()
: node.textContent;
term.setTextColor(colors.white);
term.setBackgroundColor(colors.black);
term.setCursorPos(x, y);
term.write(text.substring(0, width));
}
break;
}
}
} finally {
// Restore cursor
term.setCursorPos(origX, origY);
}
}
/**
* Recursively render a UI tree
*
* @param node - The root node to render
* @param focusedNode - The currently focused node (if any)
*/
export function render(node: UIObject, focusedNode?: UIObject): void {
// Draw this node
const isFocused = node === focusedNode;
drawNode(node, isFocused);
// Recursively draw children
for (const child of node.children) {
render(child, focusedNode);
}
}
/**
* Clear the entire terminal screen
*/
export function clearScreen(): void {
term.setBackgroundColor(colors.black);
term.clear();
term.setCursorPos(1, 1);
}

131
src/lib/ccTUI/store.ts Normal file
View File

@@ -0,0 +1,131 @@
/**
* Store for managing complex reactive state (objects and arrays)
* Inspired by SolidJS's createStore
*/
import { createSignal, Accessor } from "./reactivity";
/**
* Store setter function type
*/
export interface SetStoreFunction<T> {
/**
* Set a specific property or array index
*/
<K extends keyof T>(key: K, value: T[K]): void;
/**
* Set array index and property
*/
(index: number, key: string, value: unknown): void;
/**
* Set using an updater function
*/
(updater: (prev: T) => T): void;
}
/**
* Creates a reactive store for managing objects and arrays
* Returns an accessor for the store and a setter function
*
* @template T - The type of the store (must be an object)
* @param initialValue - The initial value of the store
* @returns A tuple of [accessor, setStore]
*
* @example
* ```typescript
* const [todos, setTodos] = createStore<Todo[]>([]);
*
* // Add a new todo
* setTodos(todos().length, { title: "New todo", done: false });
*
* // Update a specific todo
* setTodos(0, "done", true);
*
* // Replace entire store
* setTodos([{ title: "First", done: false }]);
* ```
*/
export function createStore<T extends object>(initialValue: T): [Accessor<T>, SetStoreFunction<T>] {
// Use a signal to track the entire state
const [get, set] = createSignal(initialValue);
/**
* Setter function with multiple overloads
*/
const setStore: SetStoreFunction<T> = ((...args: unknown[]) => {
if (args.length === 1) {
// Single argument - either a value or an updater function
const arg = args[0];
if (typeof arg === "function") {
// Updater function
const updater = arg as (prev: T) => T;
set(updater(get()));
} else {
// Direct value
set(arg as T);
}
} else if (args.length === 2) {
// Two arguments - key and value for object property or array index
const key = args[0] as keyof T;
const value = args[1] as T[keyof T];
const current = get();
if (Array.isArray(current)) {
// For arrays, create a new array with the updated element
const newArray = [...current] as T;
(newArray as unknown[])[key as unknown as number] = value;
set(newArray);
} else {
// For objects, create a new object with the updated property
set({ ...current, [key]: value });
}
} else if (args.length === 3) {
// Three arguments - array index, property key, and value
const index = args[0] as number;
const key = args[1] as string;
const value = args[2];
const current = get();
if (Array.isArray(current)) {
const newArray = [...current] as unknown[];
if (typeof newArray[index] === "object" && newArray[index] !== undefined) {
newArray[index] = { ...(newArray[index]!), [key]: value };
}
set(newArray as T);
}
}
}) as SetStoreFunction<T>;
return [get, setStore];
}
/**
* Helper function to remove an item from an array at a specific index
*
* @template T - The type of array elements
* @param array - The array to remove from
* @param index - The index to remove
* @returns A new array with the item removed
*
* @example
* ```typescript
* const [todos, setTodos] = createStore([1, 2, 3, 4]);
* setTodos(arr => removeIndex(arr, 1)); // Results in [1, 3, 4]
* ```
*/
export function removeIndex<T>(array: T[], index: number): T[] {
return [...array.slice(0, index), ...array.slice(index + 1)];
}
/**
* Helper function to insert an item into an array at a specific index
*
* @template T - The type of array elements
* @param array - The array to insert into
* @param index - The index to insert at
* @param item - The item to insert
* @returns A new array with the item inserted
*/
export function insertAt<T>(array: T[], index: number, item: T): T[] {
return [...array.slice(0, index), item, ...array.slice(index)];
}

View File

@@ -88,12 +88,12 @@ export class TaskCompleteEvent implements IEvent {
public id = 0;
public success = false;
public error: string | undefined = undefined;
public params: any[] = [];
public params: unknown[] = [];
public get_name() {
return "task_complete";
}
public get_args() {
if (this.success) return [this.id, this.success].concat(this.params);
if (this.success) return [this.id, this.success].concat(this.params as []);
else return [this.id, this.success, this.error];
}
public static init(args: unknown[]): IEvent | undefined {
@@ -120,10 +120,10 @@ export class RedstoneEvent implements IEvent {
public get_args() {
return [];
}
public static init(args: any[]): IEvent | undefined {
if (!(typeof args[0] === "string") || (args[0] as string) != "redstone")
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "redstone")
return undefined;
let ev = new RedstoneEvent();
const ev = new RedstoneEvent();
return ev;
}
}
@@ -135,62 +135,61 @@ export class TerminateEvent implements IEvent {
public get_args() {
return [];
}
public static init(args: any[]): IEvent | undefined {
if (!(typeof args[0] === "string") || (args[0] as string) != "terminate")
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "terminate")
return undefined;
let ev = new TerminateEvent();
const ev = new TerminateEvent();
return ev;
}
}
export class DiskEvent implements IEvent {
public side: string = "";
public eject: boolean = false;
public side = "";
public eject = false;
public get_name() {
return this.eject ? "disk_eject" : "disk";
}
public get_args() {
return [this.side];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
((args[0] as string) != "disk" && (args[0] as string) != "disk_eject")
(args[0] != "disk" && args[0] != "disk_eject")
)
return undefined;
let ev = new DiskEvent();
const ev = new DiskEvent();
ev.side = args[1] as string;
ev.eject = (args[0] as string) == "disk_eject";
ev.eject = args[0] == "disk_eject";
return ev;
}
}
export class PeripheralEvent implements IEvent {
public side: string = "";
public detach: boolean = false;
public side = "";
public detach = false;
public get_name() {
return this.detach ? "peripheral_detach" : "peripheral";
}
public get_args() {
return [this.side];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
((args[0] as string) != "peripheral" &&
(args[0] as string) != "peripheral_detach")
(args[0] != "peripheral" && args[0] != "peripheral_detach")
)
return undefined;
let ev = new PeripheralEvent();
const ev = new PeripheralEvent();
ev.side = args[1] as string;
ev.detach = (args[0] as string) == "peripheral_detach";
ev.detach = args[0] == "peripheral_detach";
return ev;
}
}
export class RednetMessageEvent implements IEvent {
public sender: number = 0;
public message: any;
public sender = 0;
public message: unknown;
public protocol: string | undefined = undefined;
public get_name() {
return "rednet_message";
@@ -198,13 +197,10 @@ export class RednetMessageEvent implements IEvent {
public get_args() {
return [this.sender, this.message, this.protocol];
}
public static init(args: any[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
(args[0] as string) != "rednet_message"
)
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "rednet_message")
return undefined;
let ev = new RednetMessageEvent();
const ev = new RednetMessageEvent();
ev.sender = args[1] as number;
ev.message = args[2];
ev.protocol = args[3] as string;
@@ -213,11 +209,11 @@ export class RednetMessageEvent implements IEvent {
}
export class ModemMessageEvent implements IEvent {
public side: string = "";
public channel: number = 0;
public replyChannel: number = 0;
public message: any;
public distance: number = 0;
public side = "";
public channel = 0;
public replyChannel = 0;
public message: unknown;
public distance = 0;
public get_name() {
return "modem_message";
}
@@ -230,13 +226,10 @@ export class ModemMessageEvent implements IEvent {
this.distance,
];
}
public static init(args: any[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
(args[0] as string) != "modem_message"
)
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "modem_message")
return undefined;
let ev = new ModemMessageEvent();
const ev = new ModemMessageEvent();
ev.side = args[1] as string;
ev.channel = args[2] as number;
ev.replyChannel = args[3] as number;
@@ -247,7 +240,7 @@ export class ModemMessageEvent implements IEvent {
}
export class HTTPEvent implements IEvent {
public url: string = "";
public url = "";
public handle: HTTPResponse | undefined = undefined;
public error: string | undefined = undefined;
public get_name() {
@@ -256,25 +249,24 @@ export class HTTPEvent implements IEvent {
public get_args() {
return [
this.url,
this.error == undefined ? this.handle : this.error,
this.error ?? this.handle,
this.error != undefined ? this.handle : undefined,
];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
((args[0] as string) != "http_success" &&
(args[0] as string) != "http_failure")
(args[0] != "http_success" && args[0] != "http_failure")
)
return undefined;
let ev = new HTTPEvent();
const ev = new HTTPEvent();
ev.url = args[1] as string;
if ((args[0] as string) == "http_success") {
if (args[0] == "http_success") {
ev.error = undefined;
ev.handle = args[2] as HTTPResponse;
} else {
ev.error = args[2] as string;
if (ev.error == undefined) ev.error = "";
ev.error ??= "";
ev.handle = args[3] as HTTPResponse;
}
return ev;
@@ -288,17 +280,16 @@ export class WebSocketEvent implements IEvent {
return this.error == undefined ? "websocket_success" : "websocket_failure";
}
public get_args() {
return [this.handle == undefined ? this.error : this.handle];
return [this.handle ?? this.error];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
((args[0] as string) != "websocket_success" &&
(args[0] as string) != "websocket_failure")
(args[0] != "websocket_success" && args[0] != "websocket_failure")
)
return undefined;
let ev = new WebSocketEvent();
if ((args[0] as string) == "websocket_success") {
const ev = new WebSocketEvent();
if (args[0] == "websocket_success") {
ev.handle = args[1] as WebSocket;
ev.error = undefined;
} else {
@@ -319,9 +310,9 @@ export enum MouseEventType {
}
export class MouseEvent implements IEvent {
public button: number = 0;
public x: number = 0;
public y: number = 0;
public button = 0;
public x = 0;
public y = 0;
public side: string | undefined = undefined;
public type: MouseEventType = MouseEventType.Click;
public get_name() {
@@ -341,10 +332,10 @@ export class MouseEvent implements IEvent {
this.y,
];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string")) return undefined;
let ev = new MouseEvent();
const type = args[0] as string;
const ev = new MouseEvent();
const type = args[0];
if (type == "mouse_click") {
ev.type = MouseEventType.Click;
ev.button = args[1] as number;
@@ -384,15 +375,14 @@ export class ResizeEvent implements IEvent {
public get_args() {
return [this.side];
}
public static init(args: any[]): IEvent | undefined {
public static init(args: unknown[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
((args[0] as string) != "term_resize" &&
(args[0] as string) != "monitor_resize")
(args[0] != "term_resize" && args[0] != "monitor_resize")
)
return undefined;
let ev = new ResizeEvent();
if ((args[0] as string) == "monitor_resize") ev.side = args[1] as string;
const ev = new ResizeEvent();
if (args[0] == "monitor_resize") ev.side = args[1] as string;
else ev.side = undefined;
return ev;
}
@@ -405,13 +395,10 @@ export class TurtleInventoryEvent implements IEvent {
public get_args() {
return [];
}
public static init(args: any[]): IEvent | undefined {
if (
!(typeof args[0] === "string") ||
(args[0] as string) != "turtle_inventory"
)
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "turtle_inventory")
return undefined;
let ev = new TurtleInventoryEvent();
const ev = new TurtleInventoryEvent();
return ev;
}
}
@@ -465,11 +452,11 @@ class Event implements IEvent {
*/
export class ChatBoxEvent implements IEvent {
public username: string = "";
public message: string = "";
public uuid: string = "";
public isHidden: boolean = false;
public messageUtf8: string = "";
public username = "";
public message = "";
public uuid = "";
public isHidden = false;
public messageUtf8 = "";
public get_name() {
return "chat";
@@ -483,10 +470,9 @@ export class ChatBoxEvent implements IEvent {
this.messageUtf8,
];
}
public static init(args: any[]): IEvent | undefined {
if (!(typeof args[0] === "string") || (args[0] as string) != "chat")
return undefined;
let ev = new ChatBoxEvent();
public static init(args: unknown[]): IEvent | undefined {
if (!(typeof args[0] === "string") || args[0] != "chat") return undefined;
const ev = new ChatBoxEvent();
ev.username = args[1] as string;
ev.message = args[2] as string;
ev.uuid = args[3] as string;
@@ -497,21 +483,21 @@ export class ChatBoxEvent implements IEvent {
}
export class GenericEvent implements IEvent {
public args: any[] = [];
public args: unknown[] = [];
public get_name() {
return this.args[0] as string;
}
public get_args() {
return this.args.slice(1);
}
public static init(args: any[]): IEvent | undefined {
let ev = new GenericEvent();
public static init(args: unknown[]): IEvent | undefined {
const ev = new GenericEvent();
ev.args = args;
return ev;
}
}
let eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
const eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
(args) => CharEvent.init(args),
(args) => KeyEvent.init(args),
(args) => PasteEvent.init(args),
@@ -534,13 +520,13 @@ let eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
(args) => GenericEvent.init(args),
];
type Constructor<T extends {} = {}> = new (...args: any[]) => T;
type Constructor<T> = new (...args: unknown[]) => T;
export function pullEventRaw(
filter: string | undefined = undefined,
): IEvent | undefined {
let args = table.pack(...coroutine.yield(filter));
for (let init of eventInitializers) {
let ev = init(args);
const args = table.pack(...coroutine.yield(filter));
for (const init of eventInitializers) {
const ev = init(args);
if (ev != undefined) return ev;
}
return GenericEvent.init(args);
@@ -548,23 +534,23 @@ export function pullEventRaw(
export function pullEvent(
filter: string | undefined = undefined,
): IEvent | undefined {
let ev = pullEventRaw(filter);
if (ev instanceof TerminateEvent) throw "Terminated";
const ev = pullEventRaw(filter);
if (ev instanceof TerminateEvent) throw Error("Terminated");
return ev;
}
export function pullEventRawAs<T extends IEvent>(
type: Constructor<T>,
filter: string | undefined = undefined,
): T | undefined {
let ev = pullEventRaw(filter);
if (ev instanceof type) return ev as T;
const ev = pullEventRaw(filter);
if (ev instanceof type) return ev;
else return undefined;
}
export function pullEventAs<T extends IEvent>(
type: Constructor<T>,
filter: string | undefined = undefined,
): T | undefined {
let ev = pullEvent(filter);
if (ev instanceof type) return ev as T;
const ev = pullEvent(filter);
if (ev instanceof type) return ev;
else return undefined;
}

View File

@@ -1,126 +1,47 @@
/**
* Example usage of the ComputerCraft TUI framework
* Example using the new ccTUI framework with reactive components
* Demonstrates the SolidJS-inspired API
*/
import {
TUIApplication,
TextLabel,
InputField,
OptionSelector,
TabBar,
Button,
createSignal,
div,
h3,
label,
button,
render,
} from "../lib/ccTUI";
// Create the main application
const app = new TUIApplication();
// Get terminal size
const [termWidth, _termHeight] = term.getSize();
// Create UI components
const title = new TextLabel(
"LabelTitle",
Math.floor(termWidth / 2) - 10,
2,
"CC TUI Framework Demo",
colors.yellow,
colors.black,
);
const label1 = new TextLabel("Label1", 5, 5, "Enter your name:");
const inputField = new InputField("LabelInput", 5, 6, 30, "", "Type here...");
const optionLabel = new TextLabel("LableOption", 5, 8, "Select an option:");
const options = ["Option 1", "Option 2", "Option 3", "Option 4"];
const optionSelector = new OptionSelector(
"OptionSelector",
5,
9,
options,
"Choose:",
0,
);
const statusLabel = new TextLabel("LableStatus", 5, 11, "Status: Ready");
// Create a button
const button = new Button(
"ButtonSubmit",
5,
13,
"Submit",
colors.white,
colors.blue,
);
// Create tab widget with sample tabs - using longer tab names for testing
const tabNames = [
"Home",
"Settings",
"User Profile",
"Messages",
"About Us",
"Documentation",
"Advanced Settings",
"Account Management",
];
const tabBar = new TabBar("TabWidget", 5, 3, 50, tabNames, 0);
// Add components to the application
app.addComponent(title);
app.addComponent(label1);
app.addComponent(inputField);
app.addComponent(optionLabel);
app.addComponent(optionSelector);
app.addComponent(statusLabel);
app.addComponent(button);
app.addComponent(tabBar);
// Set focus to the input field initially
app.getWindow().setFocusFor(tabBar);
// Connect events
optionSelector.onSelectionChanged.connect((data) => {
statusLabel.setText(
`Status: Selected ${data?.value} (index: ${data?.index})`,
/**
* Simple counter example
*/
const CounterApp = () => {
const [count, setCount] = createSignal(0);
return div({ class: "flex flex-col" },
h3("Counter Example"),
label({}, () => `Count: ${count()}`),
div({ class: "flex flex-row" },
button({ onClick: () => setCount(count() - 1) }, "-"),
button({ onClick: () => setCount(count() + 1) }, "+")
)
);
});
};
inputField.onTextChanged.connect((value) => {
if (value != undefined && value.length > 0) {
statusLabel.setText(`Status: Input changed to "${value}"`);
} else {
statusLabel.setText("Status: Input cleared");
}
});
tabBar.onTabChanged.connect((data) => {
statusLabel.setText(
`Status: Tab changed to ${data?.name} (index: ${data?.index})`,
);
});
button.onClick.connect(() => {
const inputValue = inputField.getValue();
const selectedOption = optionSelector.getSelectedValue();
statusLabel.setText(
`Status: Submitted - Input: "${inputValue}", Option: "${selectedOption}"`,
);
});
// Run the application
/**
* Main entry point
*/
try {
print("Starting CC TUI Demo. Press Ctrl+T to quit.");
app.run();
print("Starting ccTUI Reactive Demo. Press Ctrl+T to quit.");
// Render the application
render(CounterApp);
} catch (e) {
if (e === "Terminated") {
print("Application terminated by user.");
} else {
print(`Error running application:`);
print("Error running application:");
printError(e);
}
} finally {
app.stop();
}