mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-05 03:37:50 +08:00
reconstruct tui framework
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
338
src/lib/ccTUI/application.ts
Normal file
338
src/lib/ccTUI/application.ts
Normal 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
219
src/lib/ccTUI/components.ts
Normal 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;
|
||||
}
|
||||
145
src/lib/ccTUI/controlFlow.ts
Normal file
145
src/lib/ccTUI/controlFlow.ts
Normal 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
438
src/lib/ccTUI/framework.md
Normal 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
|
||||
```
|
||||
|
||||
@@ -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
209
src/lib/ccTUI/layout.ts
Normal 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
190
src/lib/ccTUI/reactivity.ts
Normal 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
199
src/lib/ccTUI/renderer.ts
Normal 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
131
src/lib/ccTUI/store.ts
Normal 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)];
|
||||
}
|
||||
174
src/lib/event.ts
174
src/lib/event.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user