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 {
|
import { Accessor } from "./reactivity";
|
||||||
readonly objectName: string;
|
|
||||||
private parent?: UIObject;
|
|
||||||
private children: Record<string, UIObject> = {};
|
|
||||||
|
|
||||||
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;
|
* Computed layout result after flexbox calculation
|
||||||
this.parent = parent;
|
*/
|
||||||
this.log = log;
|
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;
|
* Parse CSS-like class string into layout properties
|
||||||
this.log ??= parent.log;
|
*/
|
||||||
|
private parseLayout(): void {
|
||||||
|
const className = this.props.class as string | undefined;
|
||||||
|
if (className === undefined) return;
|
||||||
|
|
||||||
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
public addChild(child: UIObject) {
|
// Justify content
|
||||||
this.children[child.objectName] = child;
|
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";
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeChild(child: UIObject) {
|
// Align items
|
||||||
Object.entries(this.children).forEach(([key, value]) => {
|
else if (cls === "items-start") {
|
||||||
if (value === child) {
|
this.layoutProps.alignItems = "start";
|
||||||
delete this.children[key];
|
} 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
|
* ComputerCraft TUI (Terminal User Interface) Framework
|
||||||
* Based on Qt signal/slot principles for event handling
|
* A declarative, reactive UI framework inspired by SolidJS
|
||||||
* Provides input/output, option selection and keyboard event handling
|
* Provides components, reactivity, and flexbox layout for ComputerCraft
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Signal } from "./Signal";
|
// Reactivity system
|
||||||
import { UIObject } from "./UIObject";
|
export {
|
||||||
import { UIComponent } from "./UIComponent";
|
createSignal,
|
||||||
import { TextLabel } from "./TextLabel";
|
createEffect,
|
||||||
import { InputField } from "./InputField";
|
createMemo,
|
||||||
import { OptionSelector } from "./OptionSelector";
|
batch,
|
||||||
import { TabBar } from "./TabBar";
|
type Accessor,
|
||||||
import { UIWindow } from "./UIWindow";
|
type Setter,
|
||||||
import { TUIApplication } from "./TUIApplication";
|
type Signal,
|
||||||
import { Button } from "./Button";
|
} from "./reactivity";
|
||||||
|
|
||||||
// Export the main classes for use in other modules
|
// 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 {
|
export {
|
||||||
Signal,
|
|
||||||
UIObject,
|
UIObject,
|
||||||
UIComponent,
|
type LayoutProps,
|
||||||
TextLabel,
|
type ComputedLayout,
|
||||||
InputField,
|
type BaseProps,
|
||||||
OptionSelector,
|
} from "./UIObject";
|
||||||
TabBar,
|
|
||||||
UIWindow,
|
|
||||||
TUIApplication,
|
|
||||||
Button,
|
|
||||||
};
|
|
||||||
|
|||||||
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 id = 0;
|
||||||
public success = false;
|
public success = false;
|
||||||
public error: string | undefined = undefined;
|
public error: string | undefined = undefined;
|
||||||
public params: any[] = [];
|
public params: unknown[] = [];
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return "task_complete";
|
return "task_complete";
|
||||||
}
|
}
|
||||||
public get_args() {
|
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];
|
else return [this.id, this.success, this.error];
|
||||||
}
|
}
|
||||||
public static init(args: unknown[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
@@ -120,10 +120,10 @@ export class RedstoneEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (!(typeof args[0] === "string") || (args[0] as string) != "redstone")
|
if (!(typeof args[0] === "string") || args[0] != "redstone")
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new RedstoneEvent();
|
const ev = new RedstoneEvent();
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,62 +135,61 @@ export class TerminateEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (!(typeof args[0] === "string") || (args[0] as string) != "terminate")
|
if (!(typeof args[0] === "string") || args[0] != "terminate")
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new TerminateEvent();
|
const ev = new TerminateEvent();
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DiskEvent implements IEvent {
|
export class DiskEvent implements IEvent {
|
||||||
public side: string = "";
|
public side = "";
|
||||||
public eject: boolean = false;
|
public eject = false;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return this.eject ? "disk_eject" : "disk";
|
return this.eject ? "disk_eject" : "disk";
|
||||||
}
|
}
|
||||||
public get_args() {
|
public get_args() {
|
||||||
return [this.side];
|
return [this.side];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (
|
||||||
!(typeof args[0] === "string") ||
|
!(typeof args[0] === "string") ||
|
||||||
((args[0] as string) != "disk" && (args[0] as string) != "disk_eject")
|
(args[0] != "disk" && args[0] != "disk_eject")
|
||||||
)
|
)
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new DiskEvent();
|
const ev = new DiskEvent();
|
||||||
ev.side = args[1] as string;
|
ev.side = args[1] as string;
|
||||||
ev.eject = (args[0] as string) == "disk_eject";
|
ev.eject = args[0] == "disk_eject";
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PeripheralEvent implements IEvent {
|
export class PeripheralEvent implements IEvent {
|
||||||
public side: string = "";
|
public side = "";
|
||||||
public detach: boolean = false;
|
public detach = false;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return this.detach ? "peripheral_detach" : "peripheral";
|
return this.detach ? "peripheral_detach" : "peripheral";
|
||||||
}
|
}
|
||||||
public get_args() {
|
public get_args() {
|
||||||
return [this.side];
|
return [this.side];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (
|
||||||
!(typeof args[0] === "string") ||
|
!(typeof args[0] === "string") ||
|
||||||
((args[0] as string) != "peripheral" &&
|
(args[0] != "peripheral" && args[0] != "peripheral_detach")
|
||||||
(args[0] as string) != "peripheral_detach")
|
|
||||||
)
|
)
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new PeripheralEvent();
|
const ev = new PeripheralEvent();
|
||||||
ev.side = args[1] as string;
|
ev.side = args[1] as string;
|
||||||
ev.detach = (args[0] as string) == "peripheral_detach";
|
ev.detach = args[0] == "peripheral_detach";
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RednetMessageEvent implements IEvent {
|
export class RednetMessageEvent implements IEvent {
|
||||||
public sender: number = 0;
|
public sender = 0;
|
||||||
public message: any;
|
public message: unknown;
|
||||||
public protocol: string | undefined = undefined;
|
public protocol: string | undefined = undefined;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return "rednet_message";
|
return "rednet_message";
|
||||||
@@ -198,13 +197,10 @@ export class RednetMessageEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [this.sender, this.message, this.protocol];
|
return [this.sender, this.message, this.protocol];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (!(typeof args[0] === "string") || args[0] != "rednet_message")
|
||||||
!(typeof args[0] === "string") ||
|
|
||||||
(args[0] as string) != "rednet_message"
|
|
||||||
)
|
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new RednetMessageEvent();
|
const ev = new RednetMessageEvent();
|
||||||
ev.sender = args[1] as number;
|
ev.sender = args[1] as number;
|
||||||
ev.message = args[2];
|
ev.message = args[2];
|
||||||
ev.protocol = args[3] as string;
|
ev.protocol = args[3] as string;
|
||||||
@@ -213,11 +209,11 @@ export class RednetMessageEvent implements IEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class ModemMessageEvent implements IEvent {
|
export class ModemMessageEvent implements IEvent {
|
||||||
public side: string = "";
|
public side = "";
|
||||||
public channel: number = 0;
|
public channel = 0;
|
||||||
public replyChannel: number = 0;
|
public replyChannel = 0;
|
||||||
public message: any;
|
public message: unknown;
|
||||||
public distance: number = 0;
|
public distance = 0;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return "modem_message";
|
return "modem_message";
|
||||||
}
|
}
|
||||||
@@ -230,13 +226,10 @@ export class ModemMessageEvent implements IEvent {
|
|||||||
this.distance,
|
this.distance,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (!(typeof args[0] === "string") || args[0] != "modem_message")
|
||||||
!(typeof args[0] === "string") ||
|
|
||||||
(args[0] as string) != "modem_message"
|
|
||||||
)
|
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new ModemMessageEvent();
|
const ev = new ModemMessageEvent();
|
||||||
ev.side = args[1] as string;
|
ev.side = args[1] as string;
|
||||||
ev.channel = args[2] as number;
|
ev.channel = args[2] as number;
|
||||||
ev.replyChannel = args[3] as number;
|
ev.replyChannel = args[3] as number;
|
||||||
@@ -247,7 +240,7 @@ export class ModemMessageEvent implements IEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class HTTPEvent implements IEvent {
|
export class HTTPEvent implements IEvent {
|
||||||
public url: string = "";
|
public url = "";
|
||||||
public handle: HTTPResponse | undefined = undefined;
|
public handle: HTTPResponse | undefined = undefined;
|
||||||
public error: string | undefined = undefined;
|
public error: string | undefined = undefined;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
@@ -256,25 +249,24 @@ export class HTTPEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [
|
return [
|
||||||
this.url,
|
this.url,
|
||||||
this.error == undefined ? this.handle : this.error,
|
this.error ?? this.handle,
|
||||||
this.error != undefined ? this.handle : undefined,
|
this.error != undefined ? this.handle : undefined,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (
|
||||||
!(typeof args[0] === "string") ||
|
!(typeof args[0] === "string") ||
|
||||||
((args[0] as string) != "http_success" &&
|
(args[0] != "http_success" && args[0] != "http_failure")
|
||||||
(args[0] as string) != "http_failure")
|
|
||||||
)
|
)
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new HTTPEvent();
|
const ev = new HTTPEvent();
|
||||||
ev.url = args[1] as string;
|
ev.url = args[1] as string;
|
||||||
if ((args[0] as string) == "http_success") {
|
if (args[0] == "http_success") {
|
||||||
ev.error = undefined;
|
ev.error = undefined;
|
||||||
ev.handle = args[2] as HTTPResponse;
|
ev.handle = args[2] as HTTPResponse;
|
||||||
} else {
|
} else {
|
||||||
ev.error = args[2] as string;
|
ev.error = args[2] as string;
|
||||||
if (ev.error == undefined) ev.error = "";
|
ev.error ??= "";
|
||||||
ev.handle = args[3] as HTTPResponse;
|
ev.handle = args[3] as HTTPResponse;
|
||||||
}
|
}
|
||||||
return ev;
|
return ev;
|
||||||
@@ -288,17 +280,16 @@ export class WebSocketEvent implements IEvent {
|
|||||||
return this.error == undefined ? "websocket_success" : "websocket_failure";
|
return this.error == undefined ? "websocket_success" : "websocket_failure";
|
||||||
}
|
}
|
||||||
public get_args() {
|
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 (
|
if (
|
||||||
!(typeof args[0] === "string") ||
|
!(typeof args[0] === "string") ||
|
||||||
((args[0] as string) != "websocket_success" &&
|
(args[0] != "websocket_success" && args[0] != "websocket_failure")
|
||||||
(args[0] as string) != "websocket_failure")
|
|
||||||
)
|
)
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new WebSocketEvent();
|
const ev = new WebSocketEvent();
|
||||||
if ((args[0] as string) == "websocket_success") {
|
if (args[0] == "websocket_success") {
|
||||||
ev.handle = args[1] as WebSocket;
|
ev.handle = args[1] as WebSocket;
|
||||||
ev.error = undefined;
|
ev.error = undefined;
|
||||||
} else {
|
} else {
|
||||||
@@ -319,9 +310,9 @@ export enum MouseEventType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class MouseEvent implements IEvent {
|
export class MouseEvent implements IEvent {
|
||||||
public button: number = 0;
|
public button = 0;
|
||||||
public x: number = 0;
|
public x = 0;
|
||||||
public y: number = 0;
|
public y = 0;
|
||||||
public side: string | undefined = undefined;
|
public side: string | undefined = undefined;
|
||||||
public type: MouseEventType = MouseEventType.Click;
|
public type: MouseEventType = MouseEventType.Click;
|
||||||
public get_name() {
|
public get_name() {
|
||||||
@@ -341,10 +332,10 @@ export class MouseEvent implements IEvent {
|
|||||||
this.y,
|
this.y,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (!(typeof args[0] === "string")) return undefined;
|
if (!(typeof args[0] === "string")) return undefined;
|
||||||
let ev = new MouseEvent();
|
const ev = new MouseEvent();
|
||||||
const type = args[0] as string;
|
const type = args[0];
|
||||||
if (type == "mouse_click") {
|
if (type == "mouse_click") {
|
||||||
ev.type = MouseEventType.Click;
|
ev.type = MouseEventType.Click;
|
||||||
ev.button = args[1] as number;
|
ev.button = args[1] as number;
|
||||||
@@ -384,15 +375,14 @@ export class ResizeEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [this.side];
|
return [this.side];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (
|
||||||
!(typeof args[0] === "string") ||
|
!(typeof args[0] === "string") ||
|
||||||
((args[0] as string) != "term_resize" &&
|
(args[0] != "term_resize" && args[0] != "monitor_resize")
|
||||||
(args[0] as string) != "monitor_resize")
|
|
||||||
)
|
)
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new ResizeEvent();
|
const ev = new ResizeEvent();
|
||||||
if ((args[0] as string) == "monitor_resize") ev.side = args[1] as string;
|
if (args[0] == "monitor_resize") ev.side = args[1] as string;
|
||||||
else ev.side = undefined;
|
else ev.side = undefined;
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
@@ -405,13 +395,10 @@ export class TurtleInventoryEvent implements IEvent {
|
|||||||
public get_args() {
|
public get_args() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (
|
if (!(typeof args[0] === "string") || args[0] != "turtle_inventory")
|
||||||
!(typeof args[0] === "string") ||
|
|
||||||
(args[0] as string) != "turtle_inventory"
|
|
||||||
)
|
|
||||||
return undefined;
|
return undefined;
|
||||||
let ev = new TurtleInventoryEvent();
|
const ev = new TurtleInventoryEvent();
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -465,11 +452,11 @@ class Event implements IEvent {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export class ChatBoxEvent implements IEvent {
|
export class ChatBoxEvent implements IEvent {
|
||||||
public username: string = "";
|
public username = "";
|
||||||
public message: string = "";
|
public message = "";
|
||||||
public uuid: string = "";
|
public uuid = "";
|
||||||
public isHidden: boolean = false;
|
public isHidden = false;
|
||||||
public messageUtf8: string = "";
|
public messageUtf8 = "";
|
||||||
|
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return "chat";
|
return "chat";
|
||||||
@@ -483,10 +470,9 @@ export class ChatBoxEvent implements IEvent {
|
|||||||
this.messageUtf8,
|
this.messageUtf8,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
if (!(typeof args[0] === "string") || (args[0] as string) != "chat")
|
if (!(typeof args[0] === "string") || args[0] != "chat") return undefined;
|
||||||
return undefined;
|
const ev = new ChatBoxEvent();
|
||||||
let ev = new ChatBoxEvent();
|
|
||||||
ev.username = args[1] as string;
|
ev.username = args[1] as string;
|
||||||
ev.message = args[2] as string;
|
ev.message = args[2] as string;
|
||||||
ev.uuid = args[3] as string;
|
ev.uuid = args[3] as string;
|
||||||
@@ -497,21 +483,21 @@ export class ChatBoxEvent implements IEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GenericEvent implements IEvent {
|
export class GenericEvent implements IEvent {
|
||||||
public args: any[] = [];
|
public args: unknown[] = [];
|
||||||
public get_name() {
|
public get_name() {
|
||||||
return this.args[0] as string;
|
return this.args[0] as string;
|
||||||
}
|
}
|
||||||
public get_args() {
|
public get_args() {
|
||||||
return this.args.slice(1);
|
return this.args.slice(1);
|
||||||
}
|
}
|
||||||
public static init(args: any[]): IEvent | undefined {
|
public static init(args: unknown[]): IEvent | undefined {
|
||||||
let ev = new GenericEvent();
|
const ev = new GenericEvent();
|
||||||
ev.args = args;
|
ev.args = args;
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
|
const eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
|
||||||
(args) => CharEvent.init(args),
|
(args) => CharEvent.init(args),
|
||||||
(args) => KeyEvent.init(args),
|
(args) => KeyEvent.init(args),
|
||||||
(args) => PasteEvent.init(args),
|
(args) => PasteEvent.init(args),
|
||||||
@@ -534,13 +520,13 @@ let eventInitializers: ((args: unknown[]) => IEvent | undefined)[] = [
|
|||||||
(args) => GenericEvent.init(args),
|
(args) => GenericEvent.init(args),
|
||||||
];
|
];
|
||||||
|
|
||||||
type Constructor<T extends {} = {}> = new (...args: any[]) => T;
|
type Constructor<T> = new (...args: unknown[]) => T;
|
||||||
export function pullEventRaw(
|
export function pullEventRaw(
|
||||||
filter: string | undefined = undefined,
|
filter: string | undefined = undefined,
|
||||||
): IEvent | undefined {
|
): IEvent | undefined {
|
||||||
let args = table.pack(...coroutine.yield(filter));
|
const args = table.pack(...coroutine.yield(filter));
|
||||||
for (let init of eventInitializers) {
|
for (const init of eventInitializers) {
|
||||||
let ev = init(args);
|
const ev = init(args);
|
||||||
if (ev != undefined) return ev;
|
if (ev != undefined) return ev;
|
||||||
}
|
}
|
||||||
return GenericEvent.init(args);
|
return GenericEvent.init(args);
|
||||||
@@ -548,23 +534,23 @@ export function pullEventRaw(
|
|||||||
export function pullEvent(
|
export function pullEvent(
|
||||||
filter: string | undefined = undefined,
|
filter: string | undefined = undefined,
|
||||||
): IEvent | undefined {
|
): IEvent | undefined {
|
||||||
let ev = pullEventRaw(filter);
|
const ev = pullEventRaw(filter);
|
||||||
if (ev instanceof TerminateEvent) throw "Terminated";
|
if (ev instanceof TerminateEvent) throw Error("Terminated");
|
||||||
return ev;
|
return ev;
|
||||||
}
|
}
|
||||||
export function pullEventRawAs<T extends IEvent>(
|
export function pullEventRawAs<T extends IEvent>(
|
||||||
type: Constructor<T>,
|
type: Constructor<T>,
|
||||||
filter: string | undefined = undefined,
|
filter: string | undefined = undefined,
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
let ev = pullEventRaw(filter);
|
const ev = pullEventRaw(filter);
|
||||||
if (ev instanceof type) return ev as T;
|
if (ev instanceof type) return ev;
|
||||||
else return undefined;
|
else return undefined;
|
||||||
}
|
}
|
||||||
export function pullEventAs<T extends IEvent>(
|
export function pullEventAs<T extends IEvent>(
|
||||||
type: Constructor<T>,
|
type: Constructor<T>,
|
||||||
filter: string | undefined = undefined,
|
filter: string | undefined = undefined,
|
||||||
): T | undefined {
|
): T | undefined {
|
||||||
let ev = pullEvent(filter);
|
const ev = pullEvent(filter);
|
||||||
if (ev instanceof type) return ev as T;
|
if (ev instanceof type) return ev;
|
||||||
else return undefined;
|
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 {
|
import {
|
||||||
TUIApplication,
|
createSignal,
|
||||||
TextLabel,
|
div,
|
||||||
InputField,
|
h3,
|
||||||
OptionSelector,
|
label,
|
||||||
TabBar,
|
button,
|
||||||
Button,
|
render,
|
||||||
} from "../lib/ccTUI";
|
} from "../lib/ccTUI";
|
||||||
|
|
||||||
// Create the main application
|
/**
|
||||||
const app = new TUIApplication();
|
* Simple counter example
|
||||||
|
*/
|
||||||
|
const CounterApp = () => {
|
||||||
|
const [count, setCount] = createSignal(0);
|
||||||
|
|
||||||
// Get terminal size
|
return div({ class: "flex flex-col" },
|
||||||
const [termWidth, _termHeight] = term.getSize();
|
h3("Counter Example"),
|
||||||
|
label({}, () => `Count: ${count()}`),
|
||||||
// Create UI components
|
div({ class: "flex flex-row" },
|
||||||
const title = new TextLabel(
|
button({ onClick: () => setCount(count() - 1) }, "-"),
|
||||||
"LabelTitle",
|
button({ onClick: () => setCount(count() + 1) }, "+")
|
||||||
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})`,
|
|
||||||
);
|
);
|
||||||
});
|
};
|
||||||
|
|
||||||
inputField.onTextChanged.connect((value) => {
|
/**
|
||||||
if (value != undefined && value.length > 0) {
|
* Main entry point
|
||||||
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
|
|
||||||
try {
|
try {
|
||||||
print("Starting CC TUI Demo. Press Ctrl+T to quit.");
|
print("Starting ccTUI Reactive Demo. Press Ctrl+T to quit.");
|
||||||
app.run();
|
|
||||||
|
// Render the application
|
||||||
|
render(CounterApp);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e === "Terminated") {
|
if (e === "Terminated") {
|
||||||
print("Application terminated by user.");
|
print("Application terminated by user.");
|
||||||
} else {
|
} else {
|
||||||
print(`Error running application:`);
|
print("Error running application:");
|
||||||
printError(e);
|
printError(e);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
app.stop();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,5 +5,5 @@
|
|||||||
"luaBundle": "build/tuiExample.lua",
|
"luaBundle": "build/tuiExample.lua",
|
||||||
"luaBundleEntry": "src/tuiExample/main.ts"
|
"luaBundleEntry": "src/tuiExample/main.ts"
|
||||||
},
|
},
|
||||||
"include": ["src/tuiExample/*.ts"]
|
"include": ["src/tuiExample/*.ts", "src/lib/ccTUI/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user