add tui framework but not finish, reconstruct accesscontrol

This commit is contained in:
2025-10-10 13:20:00 +08:00
parent 1fb26cfb71
commit 57e7868c30
11 changed files with 960 additions and 156 deletions

2
.gitignore vendored
View File

@@ -3,3 +3,5 @@ node_modules
event/
build/
reference/
QWEN.md

View File

@@ -1,6 +1,6 @@
set windows-shell := ["powershell.exe", "-NoLogo", "-Command"]
build: build-autocraft build-accesscontrol build-test sync
build: build-autocraft build-accesscontrol build-test build-example sync
build-autocraft:
pnpm tstl -p ./tsconfig.autocraft.json
@@ -12,5 +12,8 @@ build-accesscontrol:
build-test:
pnpm tstl -p ./tsconfig.test.json
build-example:
pnpm tstl -p ./tsconfig.tuiExample.json
sync:
cp -r "./build/*" "C:\\Users\\sikongjueluo\\AppData\\Roaming\\CraftOS-PC\\computer\\0\\user\\"

View File

@@ -1,20 +1,55 @@
{
"detectRange": 64,
"detectRange": 256,
"detectInterval": 1,
"warnInterval": 7,
"watchInterval": 10,
"noticeTimes": 2,
"isWarn": false,
"adminGroupConfig": {
"groupName": "Admin",
"groupUsers": ["Selcon"],
"isAllowed": true,
"isNotice": true
},
"defaultToastConfig": {
"usersGroups": [
{
"groupName": "user",
"groupUsers": [],
"isAllowed": true,
"isNotice": true
},
{
"groupName": "VIP",
"groupUsers": [],
"isAllowed": true,
"isNotice": false
},
{
"groupName": "enemies",
"groupUsers": [],
"isAllowed": false,
"isNotice": false
}
],
"welcomeToastConfig": {
"title": {
"text": "Welcome",
"color": "green"
},
"msg": {
"text": "Hello %groupName% %playerName%",
"text": "Hello User %playerName%",
"color": "green"
},
"prefix": "Taohuayuan",
"brackets": "[]",
"bracketColor": ""
},
"noticeToastConfig": {
"title": {
"text": "Welcome",
"color": "green"
},
"msg": {
"text": "Hello User %playerName%",
"color": "green"
},
"prefix": "Taohuayuan",
@@ -33,35 +68,5 @@
"prefix": "Taohuayuan",
"brackets": "[]",
"bracketColor": ""
},
"usersGroups": [
{
"groupName": "user",
"groupUsers": [],
"isAllowed": true,
"isNotice": true
},
{
"groupName": "VIP",
"groupUsers": [],
"isAllowed": true,
"isNotice": false
},
{
"groupName": "enemy",
"groupUsers": [],
"isAllowed": false,
"isNotice": false,
"toastConfig": {
"title": {
"text": "Warn",
"color": "red"
},
"msg": {
"text": "Warn %playerName%",
"color": "red"
}
}
}
]
}
}

View File

@@ -261,10 +261,10 @@ class SetCommand implements CLICommand {
switch (option) {
case "warnInterval":
context.config.warnInterval = value;
context.config.watchInterval = value;
return {
success: true,
message: `Set warn interval to ${context.config.warnInterval}`,
message: `Set warn interval to ${context.config.watchInterval}`,
shouldSaveConfig: true,
};
@@ -549,20 +549,6 @@ class ShowConfigCommand implements CLICommand {
groupsMessage += ` Users: [${(group.groupUsers ?? []).join(", ")}]\n`;
groupsMessage += ` Allowed: ${group.isAllowed}\n`;
groupsMessage += ` Notice: ${group.isNotice}\n`;
if (group.toastConfig !== undefined) {
groupsMessage += ` Custom Toast Config:\n`;
groupsMessage += ` Title: ${group.toastConfig.title.text}\n`;
groupsMessage += ` Message: ${group.toastConfig.msg.text}\n`;
if (group.toastConfig.prefix !== undefined) {
groupsMessage += ` Prefix: ${group.toastConfig.prefix}\n`;
}
if (group.toastConfig.brackets !== undefined) {
groupsMessage += ` Brackets: ${group.toastConfig.brackets}\n`;
}
if (group.toastConfig.bracketColor !== undefined) {
groupsMessage += ` Bracket Color: ${group.toastConfig.bracketColor}\n`;
}
}
groupsMessage += "\n";
}
@@ -574,11 +560,11 @@ class ShowConfigCommand implements CLICommand {
case "toast": {
let toastMessage = "Default Toast Config:\n";
toastMessage += ` Title: ${context.config.defaultToastConfig.title.text}\n`;
toastMessage += ` Message: ${context.config.defaultToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${context.config.defaultToastConfig.prefix ?? "none"}\n`;
toastMessage += ` Brackets: ${context.config.defaultToastConfig.brackets ?? "none"}\n`;
toastMessage += ` Bracket Color: ${context.config.defaultToastConfig.bracketColor ?? "none"}\n\n`;
toastMessage += ` Title: ${context.config.welcomeToastConfig.title.text}\n`;
toastMessage += ` Message: ${context.config.welcomeToastConfig.msg.text}\n`;
toastMessage += ` Prefix: ${context.config.welcomeToastConfig.prefix ?? "none"}\n`;
toastMessage += ` Brackets: ${context.config.welcomeToastConfig.brackets ?? "none"}\n`;
toastMessage += ` Bracket Color: ${context.config.welcomeToastConfig.bracketColor ?? "none"}\n\n`;
toastMessage += "Warn Toast Config:\n";
toastMessage += ` Title: ${context.config.warnToastConfig.title.text}\n`;
@@ -596,7 +582,7 @@ class ShowConfigCommand implements CLICommand {
case "all": {
let allMessage = `Detect Range: ${context.config.detectRange}\n`;
allMessage += `Detect Interval: ${context.config.detectInterval}\n`;
allMessage += `Warn Interval: ${context.config.warnInterval}\n\n`;
allMessage += `Warn Interval: ${context.config.watchInterval}\n\n`;
allMessage +=
"Use 'showconfig groups' or 'showconfig toast' for detailed view";

View File

@@ -16,28 +16,32 @@ interface UserGroupConfig {
isAllowed: boolean;
isNotice: boolean;
groupUsers: string[];
toastConfig?: ToastConfig;
}
interface AccessConfig {
detectInterval: number;
warnInterval: number;
watchInterval: number;
noticeTimes: number;
detectRange: number;
isWarn: boolean;
adminGroupConfig: UserGroupConfig;
defaultToastConfig: ToastConfig;
welcomeToastConfig: ToastConfig;
warnToastConfig: ToastConfig;
noticeToastConfig: ToastConfig;
usersGroups: UserGroupConfig[];
}
const defaultConfig: AccessConfig = {
detectRange: 64,
detectInterval: 3,
warnInterval: 7,
detectRange: 256,
detectInterval: 1,
watchInterval: 10,
noticeTimes: 2,
isWarn: false,
adminGroupConfig: {
groupName: "Admin",
groupUsers: ["Selcon"],
isAllowed: true,
isNotice: false,
isNotice: true,
},
usersGroups: [
{
@@ -57,19 +61,22 @@ const defaultConfig: AccessConfig = {
groupUsers: [],
isAllowed: false,
isNotice: false,
toastConfig: {
title: {
text: "Warn",
color: "red",
},
msg: {
text: "Warn %playerName%",
color: "red",
},
},
},
],
defaultToastConfig: {
welcomeToastConfig: {
title: {
text: "Welcome",
color: "green",
},
msg: {
text: "Hello User %playerName%",
color: "green",
},
prefix: "Taohuayuan",
brackets: "[]",
bracketColor: "",
},
noticeToastConfig: {
title: {
text: "Welcome",
color: "green",

View File

@@ -16,12 +16,12 @@ const config = loadConfig(configFilepath);
log.info("Load config successfully!");
if (DEBUG) log.debug(textutils.serialise(config, { allow_repetitions: true }));
const groupNames = config.usersGroups.map((value) => value.groupName);
let warnTargetPlayers: string[];
let noticeTargetPlayers: string[];
const playerDetector = peripheralManager.findByNameRequired("playerDetector");
const chatBox = peripheralManager.findByNameRequired("chatBox");
let inRangePlayers: string[] = [];
let notAllowedPlayers: string[] = [];
let watchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
function safeParseTextComponent(
component: MinecraftTextComponent,
@@ -38,43 +38,50 @@ function safeParseTextComponent(
return textutils.serialiseJSON(component);
}
function sendToast(
toastConfig: ToastConfig,
player: string,
groupConfig?: UserGroupConfig,
) {
function sendToast(toastConfig: ToastConfig, targetPlayer: string) {
return chatBox.sendFormattedToastToPlayer(
safeParseTextComponent(
toastConfig.msg ?? config.defaultToastConfig.msg,
player,
groupConfig?.groupName,
textutils.serialiseJSON(toastConfig.msg ?? config.welcomeToastConfig.msg),
textutils.serialiseJSON(
toastConfig.title ?? config.welcomeToastConfig.title,
),
safeParseTextComponent(
toastConfig.title ?? config.defaultToastConfig.title,
player,
groupConfig?.groupName,
),
player,
toastConfig.prefix ?? config.defaultToastConfig.prefix,
toastConfig.brackets ?? config.defaultToastConfig.brackets,
toastConfig.bracketColor ?? config.defaultToastConfig.bracketColor,
targetPlayer,
toastConfig.prefix ?? config.welcomeToastConfig.prefix,
toastConfig.brackets ?? config.welcomeToastConfig.brackets,
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
undefined,
true,
);
}
function sendWarnAndNotice(player: string) {
const playerPos = playerDetector.getPlayerPos(player);
function sendNotice(player: string, playerInfo?: PlayerInfo) {
const onlinePlayers = playerDetector.getOnlinePlayers();
warnTargetPlayers = config.adminGroupConfig.groupUsers.concat(
noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.map((value) => value.groupUsers ?? [])
.flat(),
);
const warnMsg = `Not Allowed Player ${player} Break in Home at Position ${playerPos?.x}, ${playerPos?.y}, ${playerPos?.z}`;
const toastConfig: ToastConfig = {
title: {
text: "Notice",
color: "red",
},
msg: {
text: `Unfamiliar Player ${player} appeared at\n Position ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
color: "red",
},
};
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(toastConfig, targetPlayer);
}
}
function sendWarn(player: string) {
const warnMsg = `Not Allowed Player ${player} Break in Home `;
log.warn(warnMsg);
sendToast(config.warnToastConfig, player);
chatBox.sendFormattedMessageToPlayer(
safeParseTextComponent(config.warnToastConfig.msg, player),
@@ -85,37 +92,36 @@ function sendWarnAndNotice(player: string) {
undefined,
true,
);
for (const targetPlayer of warnTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
chatBox.sendFormattedMessageToPlayer(
textutils.serialise({
text: warnMsg,
color: "red",
} as MinecraftTextComponent),
targetPlayer,
"AccessControl",
"[]",
undefined,
undefined,
true,
);
}
}
function warnLoop() {
function watchLoop() {
while (true) {
for (const player of notAllowedPlayers) {
if (inRangePlayers.includes(player)) {
// sendWarnAndNotice(player);
for (const player of watchPlayersInfo) {
if (inRangePlayers.includes(player.name)) {
const playerInfo = playerDetector.getPlayerPos(player.name);
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
player.hasNoticeTimes += 1;
}
// Warn
if (config.isWarn) sendWarn(player.name);
// Record
log.warn(
`${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
notAllowedPlayers = notAllowedPlayers.filter(
(value) => value != player,
// Get rid of player from list
watchPlayersInfo = watchPlayersInfo.filter(
(value) => value.name != player.name,
);
}
}
os.sleep(config.warnInterval);
os.sleep(config.watchInterval);
}
}
@@ -131,39 +137,36 @@ function mainLoop() {
if (inRangePlayers.includes(player)) continue;
if (config.adminGroupConfig.groupUsers.includes(player)) {
log.info(`Admin ${player} enter`);
sendToast(
config.adminGroupConfig.toastConfig ?? config.defaultToastConfig,
player,
config.adminGroupConfig,
);
log.info(`Admin ${player} appear`);
continue;
}
let inUserGroup = false;
// New player appear
const playerInfo = playerDetector.getPlayerPos(player);
let groupConfig: UserGroupConfig = {
groupName: "Unfamiliar",
groupUsers: [],
isAllowed: false,
isNotice: false,
};
for (const userGroupConfig of config.usersGroups) {
if (userGroupConfig.groupUsers == undefined) continue;
if (!userGroupConfig.groupUsers.includes(player)) continue;
if (!userGroupConfig.isAllowed) {
sendWarnAndNotice(player);
notAllowedPlayers.push(player);
continue;
}
log.info(`${userGroupConfig.groupName} ${player} enter`);
sendToast(
userGroupConfig.toastConfig ?? config.defaultToastConfig,
player,
userGroupConfig,
groupConfig = userGroupConfig;
log.info(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
inUserGroup = true;
break;
}
if (inUserGroup) continue;
if (groupConfig.isAllowed) continue;
sendWarnAndNotice(player);
notAllowedPlayers.push(player);
log.warn(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.isWarn) sendWarn(player);
watchPlayersInfo.push({ name: player, hasNoticeTimes: 0 });
}
inRangePlayers = players;
@@ -192,7 +195,7 @@ function main(args: string[]) {
void cli.startConfigLoop();
},
() => {
warnLoop();
watchLoop();
},
);
return;

0
src/accesscontrol/tui.ts Normal file
View File

View File

@@ -18,12 +18,14 @@ export class CCLog {
private interval: number;
private startTime: number;
private currentTimePeriod: string;
private inTerm: boolean;
constructor(filename?: string, interval: number = DAY) {
constructor(filename?: string, inTerm = true, interval: number = DAY) {
term.clear();
term.setCursorPos(1, 1);
this.interval = interval;
this.inTerm = inTerm;
this.startTime = os.time(os.date("*t"));
this.currentTimePeriod = this.getTimePeriodString(this.startTime);
@@ -117,21 +119,23 @@ export class CCLog {
// Check if we need to rotate the log file
this.checkAndRotateLogFile();
let originalColor: Color = 0;
if (color != undefined) {
originalColor = term.getTextColor();
term.setTextColor(color);
if (this.inTerm) {
let originalColor: Color = 0;
if (color != undefined) {
originalColor = term.getTextColor();
term.setTextColor(color);
}
print(msg);
if (color != undefined) {
term.setTextColor(originalColor);
}
}
// Log
print(msg);
if (this.fp != undefined) {
this.fp.write(msg + "\r\n");
}
if (color != undefined) {
term.setTextColor(originalColor);
}
}
public debug(msg: string) {

708
src/lib/ccTUI.ts Normal file
View File

@@ -0,0 +1,708 @@
/**
* 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 required types from the ComputerCraft environment
import { CharEvent, KeyEvent, pullEventAs } from "./event";
import { CCLog, DAY } from "./ccLog";
/**
* Signal and Slot system similar to Qt
* Allows components to communicate with each other
*/
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);
}
}
}
}
abstract class UIObject {
private objectName: string;
private parent?: UIObject;
private children: Record<string, UIObject> = {};
private log?: CCLog;
constructor(name: string) {
this.objectName = name;
}
public setParent(parent: UIObject) {
this.parent = parent;
}
public addChild(child: UIObject) {
this.children[child.objectName] = child;
}
public removeChild(child: UIObject) {}
}
/**
* Base class for all UI components
*/
abstract class UIComponent extends UIObject {
protected x: number;
protected y: number;
protected width: number;
protected height: number;
protected visible = true;
protected focused = false;
// 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(x: number, y: number, width = 0, height = 0) {
this.x = x;
this.y = y;
this.width = width;
this.height = height;
}
// Render the component to the terminal
abstract render(): void;
// Handle input events
// Key
abstract handleKeyInput(event: KeyEvent): void;
// Char
abstract handleCharInput(event: CharEvent): void;
// 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
);
}
// 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;
}
}
/**
* Text output component
*/
class TextLabel extends UIComponent {
private text: string;
private textColor: number;
private bgColor: number;
constructor(
x: number,
y: number,
text: string,
textColor: number = colors.white,
bgColor: number = colors.black,
) {
super(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);
}
handleKeyInput(_event: KeyEvent): void {
// Do nothing
}
handleCharInput(_event: CharEvent): void {
// Do nothing
}
setText(newText: string): void {
this.text = newText;
this.width = newText.length; // Update width based on new text
}
getText(): string {
return this.text;
}
}
/**
* Input field component
*/
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(
x: number,
y: number,
width: number,
value: "",
placeholder = "",
maxLength = 50,
password = false,
) {
super(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);
// Move cursor to the correct position if focused
// if (this.focused) {
// const cursorX = Math.min(
// this.x + this.cursorPos,
// this.x + this.width - 1,
// );
// term.setCursorPos(cursorX, this.y);
// term.setCursorBlink(true);
// } else {
// term.setCursorBlink(false);
// }
// Restore original cursor position
term.setCursorPos(originalX, originalY);
}
handleKeyInput(event: KeyEvent): void {
if (!this.focused) return;
this.onKeyPress.emit(event);
const key = event.key;
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;
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);
}
}
/**
* Option selection component with prompt
*/
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(
x: number,
y: number,
options: string[],
prompt = "Select:",
initialIndex = 0,
) {
super(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();
}
}
handleCharInput(_event: CharEvent): void {
//Do nothing
}
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();
}
}
/**
* Base Window class to manage UI components
*/
class UIWindow {
private components: UIComponent[] = [];
private focusedComponentIndex = -1;
addComponent(component: UIComponent, manager: GlobalManager): 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);
}
}
}
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);
}
}
/**
* Main TUI Application class
*/
class TUIApplication {
private log = new CCLog(`TUI.log`, false, DAY);
private manager: GlobalManager;
private window: UIWindow;
private running = false;
private keyEvent?: KeyEvent;
private charEvent?: CharEvent;
constructor() {
this.window = new UIWindow();
this.manager = {
log: this.log,
};
}
addComponent(component: UIComponent): void {
this.window.addComponent(component, this.manager);
}
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.manager.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
this.keyEvent = pullEventAs(KeyEvent, "key");
this.manager.log.debug(
`[${TUIApplication.name}]: Get Key Event: ${textutils.serialise(this.keyEvent ?? {})}`,
);
if (this.keyEvent == undefined) continue;
this.window.handleKeyInput(this.keyEvent);
}
}
charLoop(): void {
while (this.running) {
// Handle input events
this.charEvent = pullEventAs(CharEvent, "char");
this.manager.log.debug(
`[${TUIApplication.name}]: Get Char Event: ${textutils.serialise(this.charEvent ?? {})}`,
);
if (this.charEvent == undefined) continue;
this.window.handleCharInput(this.charEvent);
}
}
getWindow(): UIWindow {
return this.window;
}
}
// Export the main classes for use in other modules
export {
Signal,
UIComponent,
TextLabel,
InputField,
OptionSelector,
UIWindow,
TUIApplication,
};

77
src/tuiExample/main.ts Normal file
View File

@@ -0,0 +1,77 @@
/**
* Example usage of the ComputerCraft TUI framework
*/
import {
TUIApplication,
TextLabel,
InputField,
OptionSelector,
} from "../lib/ccTUI";
// Create the main application
const app = new TUIApplication();
// Get terminal size
const [termWidth, _termHeight] = term.getSize();
// Create UI components
const title = new TextLabel(
Math.floor(termWidth / 2) - 10,
2,
"CC TUI Framework Demo",
colors.yellow,
colors.black,
);
const label1 = new TextLabel(5, 5, "Enter your name:");
const inputField = new InputField(5, 6, 30, "", "Type here...");
const optionLabel = new TextLabel(5, 8, "Select an option:");
const options = ["Option 1", "Option 2", "Option 3", "Option 4"];
const optionSelector = new OptionSelector(5, 9, options, "Choose:", 0);
const statusLabel = new TextLabel(5, 11, "Status: Ready");
// Add components to the application
app.addComponent(title);
app.addComponent(label1);
app.addComponent(inputField);
app.addComponent(optionLabel);
app.addComponent(optionSelector);
app.addComponent(statusLabel);
// Set focus to the input field initially
app.getWindow().setFocusFor(optionSelector);
// 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) {
statusLabel.setText(`Status: Input changed to "${value}"`);
} else {
statusLabel.setText("Status: Input cleared");
}
});
// Run the application
try {
print("Starting CC TUI Demo. Press Ctrl+T to quit.");
app.run();
} catch (e) {
if (e === "Terminated") {
print("Application terminated by user.");
} else {
print(`Error running application:`);
printError(e);
}
} finally {
app.stop();
}

9
tsconfig.tuiExample.json Normal file
View File

@@ -0,0 +1,9 @@
{
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
"extends": "./tsconfig.json",
"tstl": {
"luaBundle": "build/tuiExample.lua",
"luaBundleEntry": "src/tuiExample/main.ts"
},
"include": ["src/tuiExample/*.ts"]
}