mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-04 19:27:50 +08:00
reconstruct ccTUI, add Button component
This commit is contained in:
@@ -1,9 +1,6 @@
|
|||||||
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
|
||||||
sync-path := if os_family() == "windows" {
|
|
||||||
"C:\\Users\\sikongjueluo\\AppData\\Roaming\\CraftOS-PC\\computer\\0\\user\\"
|
sync-path := if os_family() == "windows" { "/cygdrive/c/Users/sikongjueluo/AppData/Roaming/CraftOS-PC/computer/0/user/" } else { "/home/sikongjueluo/.local/share/craftos-pc/computer/0/user/" }
|
||||||
} else {
|
|
||||||
"/home/sikongjueluo/.local/share/craftos-pc/computer/0/user/"
|
|
||||||
}
|
|
||||||
|
|
||||||
build: build-autocraft build-accesscontrol build-test build-example sync
|
build: build-autocraft build-accesscontrol build-test build-example sync
|
||||||
|
|
||||||
|
|||||||
1156
src/lib/ccTUI.ts
1156
src/lib/ccTUI.ts
File diff suppressed because it is too large
Load Diff
100
src/lib/ccTUI/Button.ts
Normal file
100
src/lib/ccTUI/Button.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
166
src/lib/ccTUI/InputField.ts
Normal file
166
src/lib/ccTUI/InputField.ts
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
src/lib/ccTUI/OptionSelector.ts
Normal file
137
src/lib/ccTUI/OptionSelector.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
28
src/lib/ccTUI/Signal.ts
Normal file
28
src/lib/ccTUI/Signal.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
98
src/lib/ccTUI/TUIApplication.ts
Normal file
98
src/lib/ccTUI/TUIApplication.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
427
src/lib/ccTUI/TabWidget.ts
Normal file
427
src/lib/ccTUI/TabWidget.ts
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
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 TabWidget 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/lib/ccTUI/TextLabel.ts
Normal file
49
src/lib/ccTUI/TextLabel.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/lib/ccTUI/UIComponent.ts
Normal file
104
src/lib/ccTUI/UIComponent.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/lib/ccTUI/UIObject.ts
Normal file
32
src/lib/ccTUI/UIObject.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { CCLog } from "../ccLog";
|
||||||
|
|
||||||
|
export abstract class UIObject {
|
||||||
|
readonly objectName: string;
|
||||||
|
private parent?: UIObject;
|
||||||
|
private children: Record<string, UIObject> = {};
|
||||||
|
|
||||||
|
public log?: CCLog;
|
||||||
|
|
||||||
|
constructor(name: string, parent?: UIObject, log?: CCLog) {
|
||||||
|
this.objectName = name;
|
||||||
|
this.parent = parent;
|
||||||
|
this.log = log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setParent(parent: UIObject) {
|
||||||
|
this.parent = parent;
|
||||||
|
this.log ??= parent.log;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addChild(child: UIObject) {
|
||||||
|
this.children[child.objectName] = child;
|
||||||
|
}
|
||||||
|
|
||||||
|
public removeChild(child: UIObject) {
|
||||||
|
Object.entries(this.children).forEach(([key, value]) => {
|
||||||
|
if (value === child) {
|
||||||
|
delete this.children[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/lib/ccTUI/UIWindow.ts
Normal file
107
src/lib/ccTUI/UIWindow.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/lib/ccTUI/index.ts
Normal file
30
src/lib/ccTUI/index.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* ComputerCraft TUI (Terminal User Interface) Framework
|
||||||
|
* Based on Qt signal/slot principles for event handling
|
||||||
|
* Provides input/output, option selection and keyboard event handling
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Signal } from "./signal";
|
||||||
|
import { UIObject } from "./UIObject";
|
||||||
|
import { UIComponent } from "./UIComponent";
|
||||||
|
import { TextLabel } from "./TextLabel";
|
||||||
|
import { InputField } from "./InputField";
|
||||||
|
import { OptionSelector } from "./OptionSelector";
|
||||||
|
import { TabWidget } from "./TabWidget";
|
||||||
|
import { UIWindow } from "./UIWindow";
|
||||||
|
import { TUIApplication } from "./TUIApplication";
|
||||||
|
import { Button } from "./Button";
|
||||||
|
|
||||||
|
// Export the main classes for use in other modules
|
||||||
|
export {
|
||||||
|
Signal,
|
||||||
|
UIObject,
|
||||||
|
UIComponent,
|
||||||
|
TextLabel,
|
||||||
|
InputField,
|
||||||
|
OptionSelector,
|
||||||
|
TabWidget,
|
||||||
|
UIWindow,
|
||||||
|
TUIApplication,
|
||||||
|
Button,
|
||||||
|
};
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
InputField,
|
InputField,
|
||||||
OptionSelector,
|
OptionSelector,
|
||||||
TabWidget,
|
TabWidget,
|
||||||
|
Button,
|
||||||
} from "../lib/ccTUI";
|
} from "../lib/ccTUI";
|
||||||
|
|
||||||
// Create the main application
|
// Create the main application
|
||||||
@@ -44,6 +45,9 @@ const optionSelector = new OptionSelector(
|
|||||||
|
|
||||||
const statusLabel = new TextLabel("LableStatus", 5, 11, "Status: Ready");
|
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
|
// 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 tabNames = ["Home", "Settings", "User Profile", "Messages", "About Us", "Documentation", "Advanced Settings", "Account Management"];
|
||||||
const tabWidget = new TabWidget("TabWidget", 5, 3, 50, tabNames, 0);
|
const tabWidget = new TabWidget("TabWidget", 5, 3, 50, tabNames, 0);
|
||||||
@@ -55,6 +59,7 @@ app.addComponent(inputField);
|
|||||||
app.addComponent(optionLabel);
|
app.addComponent(optionLabel);
|
||||||
app.addComponent(optionSelector);
|
app.addComponent(optionSelector);
|
||||||
app.addComponent(statusLabel);
|
app.addComponent(statusLabel);
|
||||||
|
app.addComponent(button);
|
||||||
app.addComponent(tabWidget);
|
app.addComponent(tabWidget);
|
||||||
|
|
||||||
// Set focus to the input field initially
|
// Set focus to the input field initially
|
||||||
@@ -81,6 +86,12 @@ tabWidget.onTabChanged.connect((data) => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
button.onClick.connect(() => {
|
||||||
|
const inputValue = inputField.getValue();
|
||||||
|
const selectedOption = optionSelector.getSelectedValue();
|
||||||
|
statusLabel.setText(`Status: Submitted - Input: "${inputValue}", Option: "${selectedOption}"`);
|
||||||
|
});
|
||||||
|
|
||||||
// Run the application
|
// Run the application
|
||||||
try {
|
try {
|
||||||
print("Starting CC TUI Demo. Press Ctrl+T to quit.");
|
print("Starting CC TUI Demo. Press Ctrl+T to quit.");
|
||||||
|
|||||||
Reference in New Issue
Block a user