diff --git a/src/accesscontrol/cli.ts b/src/accesscontrol/cli.ts index 0980656..2c7fe4f 100644 --- a/src/accesscontrol/cli.ts +++ b/src/accesscontrol/cli.ts @@ -1,400 +1,411 @@ import { Command, createCli } from "@/lib/ccCLI"; import { Ok } from "@/lib/thirdparty/ts-result-es"; -import { CCLog } from "@/lib/ccLog"; import { - AccessConfig, - UserGroupConfig, - loadConfig, - saveConfig, + AccessConfig, + UserGroupConfig, + loadConfig, + saveConfig, } from "./config"; import { parseBoolean } from "@/lib/common"; +import { Logger } from "@/lib/ccStructLog"; // 1. Define AppContext export interface AppContext { - configFilepath: string; - reloadConfig: () => void; - logger: CCLog; - print: ( - message: string | MinecraftTextComponent | MinecraftTextComponent[], - ) => void; + configFilepath: string; + reloadConfig: () => void; + logger: Logger; + print: ( + message: string | MinecraftTextComponent | MinecraftTextComponent[], + ) => void; } function getGroupNames(config: AccessConfig) { - return config.usersGroups.map((value) => value.groupName); + return config.usersGroups.map((value) => value.groupName); } // 2. Define Commands const addCommand: Command = { - name: "add", - description: "添加玩家到用户组", - args: [ - { - name: "userGroup", - description: "要添加到的用户组", - required: true, - }, - { name: "playerName", description: "要添加的玩家", required: true }, - ], - action: ({ args, context }) => { - const [groupName, playerName] = [ - args.userGroup as string, - args.playerName as string, - ]; - const config = loadConfig(context.configFilepath)!; + name: "add", + description: "添加玩家到用户组", + args: [ + { + name: "userGroup", + description: "要添加到的用户组", + required: true, + }, + { name: "playerName", description: "要添加的玩家", required: true }, + ], + action: ({ args, context }) => { + const [groupName, playerName] = [ + args.userGroup as string, + args.playerName as string, + ]; + const config = loadConfig(context.configFilepath)!; - if (groupName === "admin") { - if (!config.adminGroupConfig.groupUsers.includes(playerName)) { - config.adminGroupConfig.groupUsers.push(playerName); - } - } else { - const group = config.usersGroups.find((g) => g.groupName === groupName); - if (!group) { - const groupNames = getGroupNames(config); - context.print({ - text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join( - ", ", - )}`, - }); + if (groupName === "admin") { + if (!config.adminGroupConfig.groupUsers.includes(playerName)) { + config.adminGroupConfig.groupUsers.push(playerName); + } + } else { + const group = config.usersGroups.find( + (g) => g.groupName === groupName, + ); + if (!group) { + const groupNames = getGroupNames(config); + context.print({ + text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join( + ", ", + )}`, + }); + return Ok.EMPTY; + } + group.groupUsers ??= []; + if (!group.groupUsers.includes(playerName)) { + group.groupUsers.push(playerName); + } + } + + saveConfig(config, context.configFilepath); + context.reloadConfig(); + context.print({ text: `已添加玩家 ${playerName} 到 ${groupName}` }); return Ok.EMPTY; - } - group.groupUsers ??= []; - if (!group.groupUsers.includes(playerName)) { - group.groupUsers.push(playerName); - } - } - - saveConfig(config, context.configFilepath); - context.reloadConfig(); - context.print({ text: `已添加玩家 ${playerName} 到 ${groupName}` }); - return Ok.EMPTY; - }, + }, }; const delCommand: Command = { - name: "del", - description: "从用户组删除玩家", - args: [ - { - name: "userGroup", - description: "要从中删除玩家的用户组", - required: true, - }, - { name: "playerName", description: "要删除的玩家", required: true }, - ], - action: ({ args, context }) => { - const [groupName, playerName] = [ - args.userGroup as string, - args.playerName as string, - ]; + name: "del", + description: "从用户组删除玩家", + args: [ + { + name: "userGroup", + description: "要从中删除玩家的用户组", + required: true, + }, + { name: "playerName", description: "要删除的玩家", required: true }, + ], + action: ({ args, context }) => { + const [groupName, playerName] = [ + args.userGroup as string, + args.playerName as string, + ]; - if (groupName === "admin") { - context.print({ text: "无法删除管理员, 请直接编辑配置文件。" }); - return Ok.EMPTY; - } - - const config = loadConfig(context.configFilepath)!; - const group = config.usersGroups.find((g) => g.groupName === groupName); - - if (!group) { - const groupNames = getGroupNames(config); - context.print({ - text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join( - ", ", - )}`, - }); - return Ok.EMPTY; - } - - if (group.groupUsers !== undefined) { - group.groupUsers = group.groupUsers.filter((user) => user !== playerName); - } - - saveConfig(config, context.configFilepath); - context.reloadConfig(); - context.print({ text: `已从 ${groupName} 中删除玩家 ${playerName}` }); - return Ok.EMPTY; - }, -}; - -const listUserCommand: Command = { - name: "user", - description: "列出所有玩家及其所在的用户组", - action: ({ context }) => { - const config = loadConfig(context.configFilepath)!; - let message = `管理员 : [ ${config.adminGroupConfig.groupUsers.join( - ", ", - )} ]\n`; - for (const groupConfig of config.usersGroups) { - const users = groupConfig.groupUsers ?? []; - message += `${groupConfig.groupName} : [ ${users.join(", ")} ]\n`; - } - context.print({ text: message.trim() }); - return Ok.EMPTY; - }, -}; - -const listGroupCommand: Command = { - name: "group", - description: "显示详细的用户组配置信息", - action: ({ context }) => { - const config = loadConfig(context.configFilepath)!; - let groupsMessage = `管理员组: ${config.adminGroupConfig.groupName}\n`; - groupsMessage += ` 用户: [${config.adminGroupConfig.groupUsers.join( - ", ", - )}]\n`; - groupsMessage += ` 允许: ${config.adminGroupConfig.isAllowed}\n`; - groupsMessage += ` 通知: ${config.adminGroupConfig.isNotice}\n\n`; - - for (const group of config.usersGroups) { - groupsMessage += `用户组: ${group.groupName}\n`; - groupsMessage += ` 用户: [${(group.groupUsers ?? []).join(", ")}]\n`; - groupsMessage += ` 允许: ${group.isAllowed}\n`; - groupsMessage += ` 通知: ${group.isNotice}\n`; - groupsMessage += "\n"; - } - context.print({ text: groupsMessage.trim() }); - return Ok.EMPTY; - }, -}; - -const listToastCommand: Command = { - name: "toast", - description: "显示 Toast 配置信息", - action: ({ context }) => { - const config = loadConfig(context.configFilepath)!; - let toastMessage = "默认 Toast 配置:\n"; - toastMessage += ` 标题: ${config.welcomeToastConfig.title.text}\n`; - toastMessage += ` 消息: ${config.welcomeToastConfig.msg.text}\n`; - toastMessage += ` 前缀: ${config.welcomeToastConfig.prefix ?? "none"}\n`; - toastMessage += ` 括号: ${config.welcomeToastConfig.brackets ?? "none"}\n`; - toastMessage += ` 括号颜色: ${ - config.welcomeToastConfig.bracketColor ?? "none" - }\n\n`; - - toastMessage += "警告 Toast 配置:\n"; - toastMessage += ` 标题: ${config.warnToastConfig.title.text}\n`; - toastMessage += ` 消息: ${config.warnToastConfig.msg.text}\n`; - toastMessage += ` 前缀: ${config.warnToastConfig.prefix ?? "none"}\n`; - toastMessage += ` 括号: ${config.warnToastConfig.brackets ?? "none"}\n`; - toastMessage += ` 括号颜色: ${ - config.warnToastConfig.bracketColor ?? "none" - }`; - context.print({ text: toastMessage }); - return Ok.EMPTY; - }, -}; - -const listAllCommand: Command = { - name: "all", - description: "显示基本配置信息概览", - action: ({ context }) => { - const config = loadConfig(context.configFilepath)!; - let allMessage = `检测范围: ${config.detectRange}\n`; - allMessage += `检测间隔: ${config.detectInterval}\n`; - allMessage += `警告间隔: ${config.watchInterval}\n`; - allMessage += `通知次数: ${config.noticeTimes}\n`; - allMessage += `全局欢迎功能: ${config.isWelcome}\n`; - allMessage += `全局警告功能: ${config.isWarn}\n\n`; - allMessage += "使用 'list group' 或 'list toast' 查看详细信息"; - context.print({ text: allMessage }); - return Ok.EMPTY; - }, -}; - -const listCommand: Command = { - name: "list", - description: "列出玩家、组信息或配置", - subcommands: new Map([ - ["user", listUserCommand], - ["group", listGroupCommand], - ["toast", listToastCommand], - ["all", listAllCommand], - ]), - action: ({ context }) => { - const config = loadConfig(context.configFilepath)!; - let allMessage = `检测范围: ${config.detectRange}\n`; - allMessage += `检测间隔: ${config.detectInterval}\n`; - allMessage += `警告间隔: ${config.watchInterval}\n`; - allMessage += `通知次数: ${config.noticeTimes}\n`; - allMessage += `全局欢迎功能: ${config.isWelcome}\n`; - allMessage += `全局警告功能: ${config.isWarn}\n\n`; - allMessage += "使用 'list group' 或 'list toast' 查看详细信息"; - context.print({ text: allMessage }); - return Ok.EMPTY; - }, -}; - -const configCommand: Command = { - name: "config", - description: "配置访问控制设置", - args: [ - { - name: "option", - description: - "要设置的选项 (warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn) 或用户组属性 (.isAllowed, .isNotice, .isWelcome)", - required: true, - }, - { name: "value", description: "要设置的值", required: true }, - ], - action: ({ args, context }) => { - const [option, valueStr] = [args.option as string, args.value as string]; - const config = loadConfig(context.configFilepath)!; - - // Check if it's a group property (contains a dot) - if (option.includes(".")) { - const dotIndex = option.indexOf("."); - const groupName = option.substring(0, dotIndex); - const property = option.substring(dotIndex + 1); - - let groupConfig: UserGroupConfig | undefined; - if (groupName === "admin") { - groupConfig = config.adminGroupConfig; - } else { - groupConfig = config.usersGroups.find((g) => g.groupName === groupName); - } - - if (!groupConfig) { - context.print({ text: `用户组 ${groupName} 未找到` }); - return Ok.EMPTY; - } - - const boolValue = parseBoolean(valueStr); - if (boolValue === undefined) { - context.print({ - text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`, - }); - return Ok.EMPTY; - } - - let message = ""; - switch (property) { - case "isAllowed": - groupConfig.isAllowed = boolValue; - message = `已设置 ${groupName}.isAllowed 为 ${boolValue}`; - break; - case "isNotice": - groupConfig.isNotice = boolValue; - message = `已设置 ${groupName}.isNotice 为 ${boolValue}`; - break; - case "isWelcome": - groupConfig.isWelcome = boolValue; - message = `已设置 ${groupName}.isWelcome 为 ${boolValue}`; - break; - default: - context.print({ - text: `未知属性: ${property}. 可用属性: isAllowed, isNotice, isWelcome`, - }); - return Ok.EMPTY; - } - - saveConfig(config, context.configFilepath); - context.reloadConfig(); - context.print({ text: message }); - return Ok.EMPTY; - } else { - // Handle basic configuration options - let message = ""; - - // Check if it's a boolean option - if (option === "isWelcome" || option === "isWarn") { - const boolValue = parseBoolean(valueStr); - if (boolValue === undefined) { - context.print({ - text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`, - }); - return Ok.EMPTY; + if (groupName === "admin") { + context.print({ text: "无法删除管理员, 请直接编辑配置文件。" }); + return Ok.EMPTY; } - switch (option) { - case "isWelcome": - config.isWelcome = boolValue; - message = `已设置全局欢迎功能为 ${boolValue}`; - break; - case "isWarn": - config.isWarn = boolValue; - message = `已设置全局警告功能为 ${boolValue}`; - break; - } - } else { - // Handle numeric options - const value = parseInt(valueStr); + const config = loadConfig(context.configFilepath)!; + const group = config.usersGroups.find((g) => g.groupName === groupName); - if (isNaN(value)) { - context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` }); - return Ok.EMPTY; - } - - switch (option) { - case "warnInterval": - config.watchInterval = value; - message = `已设置警告间隔为 ${value}`; - break; - case "detectInterval": - config.detectInterval = value; - message = `已设置检测间隔为 ${value}`; - break; - case "detectRange": - config.detectRange = value; - message = `已设置检测范围为 ${value}`; - break; - case "noticeTimes": - config.noticeTimes = value; - message = `已设置通知次数为 ${value}`; - break; - default: + if (!group) { + const groupNames = getGroupNames(config); context.print({ - text: `未知选项: ${option}. 可用选项: warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn 或 .isAllowed, .isNotice, .isWelcome`, + text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join( + ", ", + )}`, }); return Ok.EMPTY; } - } - saveConfig(config, context.configFilepath); - context.reloadConfig(); - context.print({ text: message }); - return Ok.EMPTY; - } - }, + if (group.groupUsers !== undefined) { + group.groupUsers = group.groupUsers.filter( + (user) => user !== playerName, + ); + } + + saveConfig(config, context.configFilepath); + context.reloadConfig(); + context.print({ text: `已从 ${groupName} 中删除玩家 ${playerName}` }); + return Ok.EMPTY; + }, +}; + +const listUserCommand: Command = { + name: "user", + description: "列出所有玩家及其所在的用户组", + action: ({ context }) => { + const config = loadConfig(context.configFilepath)!; + let message = `管理员 : [ ${config.adminGroupConfig.groupUsers.join( + ", ", + )} ]\n`; + for (const groupConfig of config.usersGroups) { + const users = groupConfig.groupUsers ?? []; + message += `${groupConfig.groupName} : [ ${users.join(", ")} ]\n`; + } + context.print({ text: message.trim() }); + return Ok.EMPTY; + }, +}; + +const listGroupCommand: Command = { + name: "group", + description: "显示详细的用户组配置信息", + action: ({ context }) => { + const config = loadConfig(context.configFilepath)!; + let groupsMessage = `管理员组: ${config.adminGroupConfig.groupName}\n`; + groupsMessage += ` 用户: [${config.adminGroupConfig.groupUsers.join( + ", ", + )}]\n`; + groupsMessage += ` 允许: ${config.adminGroupConfig.isAllowed}\n`; + groupsMessage += ` 通知: ${config.adminGroupConfig.isNotice}\n\n`; + + for (const group of config.usersGroups) { + groupsMessage += `用户组: ${group.groupName}\n`; + groupsMessage += ` 用户: [${(group.groupUsers ?? []).join(", ")}]\n`; + groupsMessage += ` 允许: ${group.isAllowed}\n`; + groupsMessage += ` 通知: ${group.isNotice}\n`; + groupsMessage += "\n"; + } + context.print({ text: groupsMessage.trim() }); + return Ok.EMPTY; + }, +}; + +const listToastCommand: Command = { + name: "toast", + description: "显示 Toast 配置信息", + action: ({ context }) => { + const config = loadConfig(context.configFilepath)!; + let toastMessage = "默认 Toast 配置:\n"; + toastMessage += ` 标题: ${config.welcomeToastConfig.title.text}\n`; + toastMessage += ` 消息: ${config.welcomeToastConfig.msg.text}\n`; + toastMessage += ` 前缀: ${config.welcomeToastConfig.prefix ?? "none"}\n`; + toastMessage += ` 括号: ${config.welcomeToastConfig.brackets ?? "none"}\n`; + toastMessage += ` 括号颜色: ${ + config.welcomeToastConfig.bracketColor ?? "none" + }\n\n`; + + toastMessage += "警告 Toast 配置:\n"; + toastMessage += ` 标题: ${config.warnToastConfig.title.text}\n`; + toastMessage += ` 消息: ${config.warnToastConfig.msg.text}\n`; + toastMessage += ` 前缀: ${config.warnToastConfig.prefix ?? "none"}\n`; + toastMessage += ` 括号: ${config.warnToastConfig.brackets ?? "none"}\n`; + toastMessage += ` 括号颜色: ${ + config.warnToastConfig.bracketColor ?? "none" + }`; + context.print({ text: toastMessage }); + return Ok.EMPTY; + }, +}; + +const listAllCommand: Command = { + name: "all", + description: "显示基本配置信息概览", + action: ({ context }) => { + const config = loadConfig(context.configFilepath)!; + let allMessage = `检测范围: ${config.detectRange}\n`; + allMessage += `检测间隔: ${config.detectInterval}\n`; + allMessage += `警告间隔: ${config.watchInterval}\n`; + allMessage += `通知次数: ${config.noticeTimes}\n`; + allMessage += `全局欢迎功能: ${config.isWelcome}\n`; + allMessage += `全局警告功能: ${config.isWarn}\n\n`; + allMessage += "使用 'list group' 或 'list toast' 查看详细信息"; + context.print({ text: allMessage }); + return Ok.EMPTY; + }, +}; + +const listCommand: Command = { + name: "list", + description: "列出玩家、组信息或配置", + subcommands: new Map([ + ["user", listUserCommand], + ["group", listGroupCommand], + ["toast", listToastCommand], + ["all", listAllCommand], + ]), + action: ({ context }) => { + const config = loadConfig(context.configFilepath)!; + let allMessage = `检测范围: ${config.detectRange}\n`; + allMessage += `检测间隔: ${config.detectInterval}\n`; + allMessage += `警告间隔: ${config.watchInterval}\n`; + allMessage += `通知次数: ${config.noticeTimes}\n`; + allMessage += `全局欢迎功能: ${config.isWelcome}\n`; + allMessage += `全局警告功能: ${config.isWarn}\n\n`; + allMessage += "使用 'list group' 或 'list toast' 查看详细信息"; + context.print({ text: allMessage }); + return Ok.EMPTY; + }, +}; + +const configCommand: Command = { + name: "config", + description: "配置访问控制设置", + args: [ + { + name: "option", + description: + "要设置的选项 (warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn) 或用户组属性 (.isAllowed, .isNotice, .isWelcome)", + required: true, + }, + { name: "value", description: "要设置的值", required: true }, + ], + action: ({ args, context }) => { + const [option, valueStr] = [ + args.option as string, + args.value as string, + ]; + const config = loadConfig(context.configFilepath)!; + + // Check if it's a group property (contains a dot) + if (option.includes(".")) { + const dotIndex = option.indexOf("."); + const groupName = option.substring(0, dotIndex); + const property = option.substring(dotIndex + 1); + + let groupConfig: UserGroupConfig | undefined; + if (groupName === "admin") { + groupConfig = config.adminGroupConfig; + } else { + groupConfig = config.usersGroups.find( + (g) => g.groupName === groupName, + ); + } + + if (!groupConfig) { + context.print({ text: `用户组 ${groupName} 未找到` }); + return Ok.EMPTY; + } + + const boolValue = parseBoolean(valueStr); + if (boolValue === undefined) { + context.print({ + text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`, + }); + return Ok.EMPTY; + } + + let message = ""; + switch (property) { + case "isAllowed": + groupConfig.isAllowed = boolValue; + message = `已设置 ${groupName}.isAllowed 为 ${boolValue}`; + break; + case "isNotice": + groupConfig.isNotice = boolValue; + message = `已设置 ${groupName}.isNotice 为 ${boolValue}`; + break; + case "isWelcome": + groupConfig.isWelcome = boolValue; + message = `已设置 ${groupName}.isWelcome 为 ${boolValue}`; + break; + default: + context.print({ + text: `未知属性: ${property}. 可用属性: isAllowed, isNotice, isWelcome`, + }); + return Ok.EMPTY; + } + + saveConfig(config, context.configFilepath); + context.reloadConfig(); + context.print({ text: message }); + return Ok.EMPTY; + } else { + // Handle basic configuration options + let message = ""; + + // Check if it's a boolean option + if (option === "isWelcome" || option === "isWarn") { + const boolValue = parseBoolean(valueStr); + if (boolValue === undefined) { + context.print({ + text: `无效的布尔值: ${valueStr}. 请使用 'true' 或 'false'.`, + }); + return Ok.EMPTY; + } + + switch (option) { + case "isWelcome": + config.isWelcome = boolValue; + message = `已设置全局欢迎功能为 ${boolValue}`; + break; + case "isWarn": + config.isWarn = boolValue; + message = `已设置全局警告功能为 ${boolValue}`; + break; + } + } else { + // Handle numeric options + const value = parseInt(valueStr); + + if (isNaN(value)) { + context.print({ + text: `无效的值: ${valueStr}. 必须是一个数字。`, + }); + return Ok.EMPTY; + } + + switch (option) { + case "warnInterval": + config.watchInterval = value; + message = `已设置警告间隔为 ${value}`; + break; + case "detectInterval": + config.detectInterval = value; + message = `已设置检测间隔为 ${value}`; + break; + case "detectRange": + config.detectRange = value; + message = `已设置检测范围为 ${value}`; + break; + case "noticeTimes": + config.noticeTimes = value; + message = `已设置通知次数为 ${value}`; + break; + default: + context.print({ + text: `未知选项: ${option}. 可用选项: warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn 或 .isAllowed, .isNotice, .isWelcome`, + }); + return Ok.EMPTY; + } + } + + saveConfig(config, context.configFilepath); + context.reloadConfig(); + context.print({ text: message }); + return Ok.EMPTY; + } + }, }; // Root command const rootCommand: Command = { - name: "@AC", - description: "访问控制命令行界面", - subcommands: new Map([ - ["add", addCommand], - ["del", delCommand], - ["list", listCommand], - ["config", configCommand], - ]), - action: ({ context }) => { - context.print([ - { - text: "请使用 ", - }, - { - text: "@AC --help", - clickEvent: { - action: "copy_to_clipboard", - value: "@AC --help", - }, - hoverEvent: { - action: "show_text", - value: "点击复制命令", - }, - }, - { - text: " 获取门禁系统更详细的命令说明😊😊😊", - }, - ]); - return Ok.EMPTY; - }, + name: "@AC", + description: "访问控制命令行界面", + subcommands: new Map([ + ["add", addCommand], + ["del", delCommand], + ["list", listCommand], + ["config", configCommand], + ]), + action: ({ context }) => { + context.print([ + { + text: "请使用 ", + }, + { + text: "@AC --help", + clickEvent: { + action: "copy_to_clipboard", + value: "@AC --help", + }, + hoverEvent: { + action: "show_text", + value: "点击复制命令", + }, + }, + { + text: " 获取门禁系统更详细的命令说明😊😊😊", + }, + ]); + return Ok.EMPTY; + }, }; export function createAccessControlCli(context: AppContext) { - return createCli(rootCommand, { - globalContext: context, - writer: (msg) => context.print({ text: msg }), - }); + return createCli(rootCommand, { + globalContext: context, + writer: (msg) => context.print({ text: msg }), + }); } diff --git a/src/accesscontrol/config.ts b/src/accesscontrol/config.ts index 4e969a5..9a13f42 100644 --- a/src/accesscontrol/config.ts +++ b/src/accesscontrol/config.ts @@ -1,177 +1,177 @@ // import * as dkjson from "@sikongjueluo/dkjson-types"; interface ToastConfig { - title: MinecraftTextComponent; - msg: MinecraftTextComponent; - prefix?: string; - brackets?: string; - bracketColor?: string; + title: MinecraftTextComponent; + msg: MinecraftTextComponent; + prefix?: string; + brackets?: string; + bracketColor?: string; } interface UserGroupConfig { - groupName: string; - isAllowed: boolean; - isNotice: boolean; - isWelcome: boolean; - groupUsers: string[]; + groupName: string; + isAllowed: boolean; + isNotice: boolean; + isWelcome: boolean; + groupUsers: string[]; } interface AccessConfig { - detectInterval: number; - watchInterval: number; - noticeTimes: number; - detectRange: number; - isWelcome: boolean; - isWarn: boolean; - adminGroupConfig: UserGroupConfig; - welcomeToastConfig: ToastConfig; - warnToastConfig: ToastConfig; - noticeToastConfig: ToastConfig; - usersGroups: UserGroupConfig[]; + detectInterval: number; + watchInterval: number; + noticeTimes: number; + detectRange: number; + isWelcome: boolean; + isWarn: boolean; + adminGroupConfig: UserGroupConfig; + welcomeToastConfig: ToastConfig; + warnToastConfig: ToastConfig; + noticeToastConfig: ToastConfig; + usersGroups: UserGroupConfig[]; } const defaultConfig: AccessConfig = { - detectRange: 256, - detectInterval: 1, - watchInterval: 10, - noticeTimes: 2, - isWarn: false, - isWelcome: true, - adminGroupConfig: { - groupName: "Admin", - groupUsers: ["Selcon"], - isAllowed: true, - isNotice: true, - isWelcome: false, - }, - usersGroups: [ - { - groupName: "user", - groupUsers: [], - isAllowed: true, - isNotice: true, - isWelcome: false, + detectRange: 256, + detectInterval: 1, + watchInterval: 10, + noticeTimes: 2, + isWarn: false, + isWelcome: true, + adminGroupConfig: { + groupName: "Admin", + groupUsers: ["Selcon"], + isAllowed: true, + isNotice: true, + isWelcome: false, }, - { - groupName: "TU", - groupUsers: [], - isAllowed: true, - isNotice: false, - isWelcome: false, + usersGroups: [ + { + groupName: "user", + groupUsers: [], + isAllowed: true, + isNotice: true, + isWelcome: false, + }, + { + groupName: "TU", + groupUsers: [], + isAllowed: true, + isNotice: false, + isWelcome: false, + }, + { + groupName: "VIP", + groupUsers: [], + isAllowed: true, + isNotice: false, + isWelcome: true, + }, + { + groupName: "enemies", + groupUsers: [], + isAllowed: false, + isNotice: false, + isWelcome: false, + }, + ], + welcomeToastConfig: { + title: { + text: "欢迎", + color: "green", + }, + msg: { + text: "欢迎 %playerName% 参观桃源星喵~", + color: "#EDC8DA", + }, + prefix: "桃源星", + brackets: "<>", + bracketColor: "", }, - { - groupName: "VIP", - groupUsers: [], - isAllowed: true, - isNotice: false, - isWelcome: true, + noticeToastConfig: { + title: { + text: "警告", + color: "red", + }, + msg: { + text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%", + color: "red", + }, + prefix: "桃源星", + brackets: "<>", + bracketColor: "", }, - { - groupName: "enemies", - groupUsers: [], - isAllowed: false, - isNotice: false, - isWelcome: false, + warnToastConfig: { + title: { + text: "注意", + color: "red", + }, + msg: { + text: "%playerName% 你已经进入桃源星领地", + color: "red", + }, + prefix: "桃源星", + brackets: "<>", + bracketColor: "", }, - ], - welcomeToastConfig: { - title: { - text: "欢迎", - color: "green", - }, - msg: { - text: "欢迎 %playerName% 参观桃源星喵~", - color: "#EDC8DA", - }, - prefix: "桃源星", - brackets: "<>", - bracketColor: "", - }, - noticeToastConfig: { - title: { - text: "警告", - color: "red", - }, - msg: { - text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%", - color: "red", - }, - prefix: "桃源星", - brackets: "<>", - bracketColor: "", - }, - warnToastConfig: { - title: { - text: "注意", - color: "red", - }, - msg: { - text: "%playerName% 你已经进入桃源星领地", - color: "red", - }, - prefix: "桃源星", - brackets: "<>", - bracketColor: "", - }, }; function loadConfig( - filepath: string, - useDefault = true, + filepath: string, + useDefault = true, ): AccessConfig | undefined { - const [fp] = io.open(filepath, "r"); - if (fp == undefined) { - if (useDefault === false) return undefined; - print("Failed to open config file " + filepath); - print("Use default config"); - saveConfig(defaultConfig, filepath); - return defaultConfig; - } + const [fp] = io.open(filepath, "r"); + if (fp == undefined) { + if (useDefault === false) return undefined; + print("Failed to open config file " + filepath); + print("Use default config"); + saveConfig(defaultConfig, filepath); + return defaultConfig; + } - const configJson = fp.read("*a"); - if (configJson == undefined) { - if (useDefault === false) return undefined; - print("Failed to read config file"); - print("Use default config"); - saveConfig(defaultConfig, filepath); - return defaultConfig; - } + const configJson = fp.read("*a"); + if (configJson == undefined) { + if (useDefault === false) return undefined; + print("Failed to read config file"); + print("Use default config"); + saveConfig(defaultConfig, filepath); + return defaultConfig; + } - // const [config, pos, err] = dkjson.decode(configJson); - // if (config == undefined) { - // log?.warn( - // `Config decode failed at ${pos}, use default instead. Error :${err}`, - // ); - // return defaultConfig; - // } + // const [config, pos, err] = dkjson.decode(configJson); + // if (config == undefined) { + // log?.warn( + // `Config decode failed at ${pos}, use default instead. Error :${err}`, + // ); + // return defaultConfig; + // } - // Not use external lib - const config = textutils.unserialiseJSON(configJson, { - parse_empty_array: true, - }); + // Not use external lib + const config = textutils.unserialiseJSON(configJson, { + parse_empty_array: true, + }); - return config as AccessConfig; + return config as AccessConfig; } function saveConfig(config: AccessConfig, filepath: string) { - // const configJson = dkjson.encode(config, { indent: true }) as string; - // Not use external lib - const configJson = textutils.serializeJSON(config, { - allow_repetitions: true, - unicode_strings: true, - }); - if (configJson == undefined) { - print("Failed to save config"); - } + // const configJson = dkjson.encode(config, { indent: true }) as string; + // Not use external lib + const configJson = textutils.serializeJSON(config, { + allow_repetitions: true, + unicode_strings: true, + }); + if (configJson == undefined) { + print("Failed to save config"); + } - const [fp, _err] = io.open(filepath, "w+"); - if (fp == undefined) { - print("Failed to open config file " + filepath); - return; - } + const [fp, _err] = io.open(filepath, "w+"); + if (fp == undefined) { + print("Failed to open config file " + filepath); + return; + } - fp.write(configJson); - fp.close(); + fp.write(configJson); + fp.close(); } export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig }; diff --git a/src/accesscontrol/main.ts b/src/accesscontrol/main.ts index 82f1640..c8956dc 100644 --- a/src/accesscontrol/main.ts +++ b/src/accesscontrol/main.ts @@ -11,10 +11,12 @@ import { ConsoleStream, DAY, FileStream, - Logger, + getStructLogger, + LoggerOptions, LogLevel, MB, processor, + setStructLoggerConfig, textRenderer, } from "@/lib/ccStructLog"; @@ -22,7 +24,7 @@ const args = [...$vararg]; // Init Log let isOnConsoleStream = true; -const logger = new Logger({ +const loggerConfig: LoggerOptions = { processors: [ processor.filterByLevel(LogLevel.Info), processor.addTimestamp(), @@ -40,7 +42,9 @@ const logger = new Logger({ }, }), ], -}); +}; +setStructLoggerConfig(loggerConfig); +const logger = getStructLogger(); // Load Config const configFilepath = `${shell.dir()}/access.config.json`; @@ -79,6 +83,11 @@ function reloadConfig() { gWatchPlayersInfo = []; releaser.release(); logger.info("Reload config successfully!"); + const tutorial: string[] = []; + tutorial.push("Access Control System started."); + tutorial.push("\tPress 'c' to open configuration TUI."); + tutorial.push("\tPress 'r' to reload configuration."); + print(tutorial.join("\n")); } function safeParseTextComponent( diff --git a/src/accesscontrol/tui.ts b/src/accesscontrol/tui.ts index 7e26061..e510e82 100644 --- a/src/accesscontrol/tui.ts +++ b/src/accesscontrol/tui.ts @@ -5,766 +5,809 @@ import { context } from "@/lib/ccTUI/context"; import { - createSignal, - createStore, - div, - label, - button, - input, - h1, - render, - Show, - For, - Switch, - Match, - ScrollContainer, + createSignal, + createStore, + div, + label, + button, + input, + h1, + render, + Show, + For, + Switch, + Match, + ScrollContainer, } from "../lib/ccTUI"; import { - AccessConfig, - UserGroupConfig, - loadConfig, - saveConfig, + AccessConfig, + UserGroupConfig, + loadConfig, + saveConfig, } from "./config"; // Tab indices const TABS = { - BASIC: 0, - GROUPS: 1, - WELCOME_TOAST: 2, - WARN_TOAST: 3, - NOTICE_TOAST: 4, + BASIC: 0, + GROUPS: 1, + WELCOME_TOAST: 2, + WARN_TOAST: 3, + NOTICE_TOAST: 4, } as const; type TabIndex = (typeof TABS)[keyof typeof TABS]; // Error dialog state interface ErrorState { - show: boolean; - message: string; + show: boolean; + message: string; } /** * Main TUI Application Component */ const AccessControlTUI = () => { - // Load configuration on initialization - const configFilepath = `${shell.dir()}/access.config.json`; - const loadedConfig = loadConfig(configFilepath)!; - // Configuration state - const [config, setConfig] = createStore(loadedConfig); + // Load configuration on initialization + const configFilepath = `${shell.dir()}/access.config.json`; + const loadedConfig = loadConfig(configFilepath)!; + // Configuration state + const [config, setConfig] = createStore(loadedConfig); - // UI state - const [currentTab, setCurrentTab] = createSignal(TABS.BASIC); - const [selectedGroupIndex, setSelectedGroupIndex] = createSignal(0); - const [errorState, setErrorState] = createStore({ - show: false, - message: "", - }); + // UI state + const [currentTab, setCurrentTab] = createSignal(TABS.BASIC); + const [selectedGroupIndex, setSelectedGroupIndex] = createSignal(0); + const [errorState, setErrorState] = createStore({ + show: false, + message: "", + }); - // New user input for group management - const [newUserName, setNewUserName] = createSignal(""); + // New user input for group management + const [newUserName, setNewUserName] = createSignal(""); - // Tab navigation functions - const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice"]; + // Tab navigation functions + const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice"]; - const showError = (message: string) => { - setErrorState("show", true); - setErrorState("message", message); - }; + const showError = (message: string) => { + setErrorState("show", true); + setErrorState("message", message); + }; - const hideError = () => { - setErrorState("show", false); - setErrorState("message", ""); - }; + const hideError = () => { + setErrorState("show", false); + setErrorState("message", ""); + }; - // Validation functions - const validateNumber = (value: string): number | null => { - const num = parseInt(value); - return isNaN(num) ? null : num; - }; + // Validation functions + const validateNumber = (value: string): number | null => { + const num = parseInt(value); + return isNaN(num) ? null : num; + }; - const validateTextComponent = (value: string): boolean => { - try { - const parsed = textutils.unserialiseJSON(value); - return parsed !== undefined && typeof parsed === "object"; - } catch { - return false; - } - }; - - // Save configuration with validation - const handleSave = () => { - try { - const currentConfig = config(); - - // Validate numbers - if ( - validateNumber(currentConfig.detectInterval?.toString() ?? "") === null - ) { - showError("Invalid Detect Interval: must be a number"); - return; - } - if ( - validateNumber(currentConfig.watchInterval?.toString() ?? "") === null - ) { - showError("Invalid Watch Interval: must be a number"); - return; - } - if ( - validateNumber(currentConfig.noticeTimes?.toString() ?? "") === null - ) { - showError("Invalid Notice Times: must be a number"); - return; - } - if ( - validateNumber(currentConfig.detectRange?.toString() ?? "") === null - ) { - showError("Invalid Detect Range: must be a number"); - return; - } - - // Validate text components for toast configs - const toastConfigs = [ - { - name: "Welcome Toast Title", - value: currentConfig.welcomeToastConfig?.title, - }, - { - name: "Welcome Toast Message", - value: currentConfig.welcomeToastConfig?.msg, - }, - { - name: "Warn Toast Title", - value: currentConfig.warnToastConfig?.title, - }, - { - name: "Warn Toast Message", - value: currentConfig.warnToastConfig?.msg, - }, - { - name: "Notice Toast Title", - value: currentConfig.noticeToastConfig?.title, - }, - { - name: "Notice Toast Message", - value: currentConfig.noticeToastConfig?.msg, - }, - ]; - - for (const toastConfig of toastConfigs) { - if (toastConfig.value != undefined) { - const serialized = textutils.serialiseJSON(toastConfig.value); - if (!validateTextComponent(serialized)) { - showError( - `Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`, - ); - return; - } + const validateTextComponent = (value: string): boolean => { + try { + const parsed = textutils.unserialiseJSON(value); + return parsed !== undefined && typeof parsed === "object"; + } catch { + return false; } - } + }; - // Save configuration - context.logger?.debug( - `Configuration : ${textutils.serialise(currentConfig, { allow_repetitions: true })}`, - ); - saveConfig(currentConfig, configFilepath); - showError("Configuration saved successfully!"); - } catch (error) { - showError(`Failed to save configuration: ${String(error)}`); - } - }; + // Save configuration with validation + const handleSave = () => { + try { + const currentConfig = config(); - // Add user to selected group - const addUser = () => { - const userName = newUserName().trim(); - if (userName === "") return; + // Validate numbers + if ( + validateNumber( + currentConfig.detectInterval?.toString() ?? "", + ) === null + ) { + showError("Invalid Detect Interval: must be a number"); + return; + } + if ( + validateNumber( + currentConfig.watchInterval?.toString() ?? "", + ) === null + ) { + showError("Invalid Watch Interval: must be a number"); + return; + } + if ( + validateNumber(currentConfig.noticeTimes?.toString() ?? "") === + null + ) { + showError("Invalid Notice Times: must be a number"); + return; + } + if ( + validateNumber(currentConfig.detectRange?.toString() ?? "") === + null + ) { + showError("Invalid Detect Range: must be a number"); + return; + } - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - // Admin group - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - groupUsers: [...currentAdmin.groupUsers, userName], - }); - } else { - // Regular group - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - groupUsers: [...(currentGroup?.groupUsers ?? []), userName], - }; - setConfig("usersGroups", newGroups); - } - setNewUserName(""); - }; - - // Remove user from selected group - const removeUser = (userName: string) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - // Admin group - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - groupUsers: currentAdmin.groupUsers.filter((user) => user !== userName), - }); - } else { - // Regular group - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - groupUsers: (currentGroup?.groupUsers ?? []).filter( - (user) => user !== userName, - ), - }; - setConfig("usersGroups", newGroups); - } - }; - - // Get all groups for selection - const getAllGroups = (): UserGroupConfig[] => { - const currentConfig = config(); - return [currentConfig.adminGroupConfig, ...currentConfig.usersGroups]; - }; - - // Get currently selected group - const getSelectedGroup = (): UserGroupConfig => { - const groups = getAllGroups(); - return groups[selectedGroupIndex()] ?? config().adminGroupConfig; - }; - - /** - * Basic Configuration Tab - */ - const [getDetectInterval, setDetectInterval] = createSignal( - config().detectInterval.toString(), - ); - const [getWatchInterval, setWatchInterval] = createSignal( - config().watchInterval.toString(), - ); - const [getNoticeTimes, setNoticeTimes] = createSignal( - config().noticeTimes.toString(), - ); - const [getDetectRange, setDetectRange] = createSignal( - config().detectRange.toString(), - ); - const BasicTab = () => { - return div( - { class: "flex flex-col" }, - div( - { class: "flex flex-row" }, - label({}, "Detect Interval (ms):"), - input({ - type: "text", - value: () => getDetectInterval(), - onInput: (value) => setDetectInterval(value), - onFocusChanged: () => { - const num = validateNumber(getDetectInterval()); - if (num !== null) setConfig("detectInterval", num); - else setDetectInterval(config().detectInterval.toString()); - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Watch Interval (ms):"), - input({ - type: "text", - value: () => getWatchInterval(), - onInput: (value) => setWatchInterval(value), - onFocusChanged: () => { - const num = validateNumber(getWatchInterval()); - if (num !== null) setConfig("watchInterval", num); - else setWatchInterval(config().watchInterval.toString()); - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Notice Times:"), - input({ - type: "text", - value: () => getNoticeTimes(), - onInput: (value) => setNoticeTimes(value), - onFocusChanged: () => { - const num = validateNumber(getNoticeTimes()); - if (num !== null) setConfig("noticeTimes", num); - else setNoticeTimes(config().noticeTimes.toString()); - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Detect Range:"), - input({ - type: "text", - value: () => getDetectRange(), - onInput: (value) => setDetectRange(value), - onFocusChanged: () => { - const num = validateNumber(getDetectRange()); - if (num !== null) setConfig("detectRange", num); - else setDetectRange(config().detectRange.toString()); - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Is Warn:"), - input({ - type: "checkbox", - checked: () => config().isWarn ?? false, - onChange: (checked) => setConfig("isWarn", checked), - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Is Welcome:"), - input({ - type: "checkbox", - checked: () => config().isWelcome ?? false, - onChange: (checked) => setConfig("isWelcome", checked), - }), - ), - ); - }; - - /** - * Groups Configuration Tab - */ - const GroupsTab = () => { - const groups = getAllGroups(); - - return div( - { class: "flex flex-row" }, - // Left side - Groups list - div( - { class: "flex flex-col" }, - label({}, "Groups:"), - For({ each: () => groups, class: "flex flex-col" }, (group, index) => - button( - { - class: - selectedGroupIndex() === index() ? "bg-blue text-white" : "", - onClick: () => setSelectedGroupIndex(index()), - }, - group.groupName, - ), - ), - ), - - // Right side - Group details - div( - { class: "flex flex-col ml-2" }, - label({}, () => `Group: ${getSelectedGroup().groupName}`), - - div( - { class: "flex flex-row" }, - label({}, "Is Welcome:"), - input({ - type: "checkbox", - checked: () => getSelectedGroup().isWelcome, - onChange: (checked) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - isWelcome: checked, - }); - } else { - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - isWelcome: checked, - }; - setConfig("usersGroups", newGroups); - } - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Is Allowed:"), - input({ - type: "checkbox", - checked: () => getSelectedGroup().isAllowed, - onChange: (checked) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - isAllowed: checked, - }); - } else { - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - isAllowed: checked, - }; - setConfig("usersGroups", newGroups); - } - }, - }), - ), - div( - { class: "flex flex-row" }, - label({}, "Is Notice:"), - input({ - type: "checkbox", - checked: () => getSelectedGroup().isNotice, - onChange: (checked) => { - const groupIndex = selectedGroupIndex(); - if (groupIndex === 0) { - const currentAdmin = config().adminGroupConfig; - setConfig("adminGroupConfig", { - ...currentAdmin, - isNotice: checked, - }); - } else { - const actualIndex = groupIndex - 1; - const currentGroups = config().usersGroups; - const currentGroup = currentGroups[actualIndex]; - const newGroups = [...currentGroups]; - newGroups[actualIndex] = { - ...currentGroup, - isNotice: checked, - }; - setConfig("usersGroups", newGroups); - } - }, - }), - ), - - label({}, "Group Users:"), - // User management - div( - { class: "flex flex-row" }, - input({ - type: "text", - value: newUserName, - onInput: setNewUserName, - placeholder: "Enter username", - }), - button({ onClick: addUser }, "Add"), - ), - - // Users list - For( - { - class: "flex flex-col", - each: () => getSelectedGroup().groupUsers ?? [], - }, - (user) => - div( - { class: "flex flex-row items-center" }, - label({}, user), - button( + // Validate text components for toast configs + const toastConfigs = [ { - class: "ml-1 bg-red text-white", - onClick: () => removeUser(user), + name: "Welcome Toast Title", + value: currentConfig.welcomeToastConfig?.title, }, - "X", - ), + { + name: "Welcome Toast Message", + value: currentConfig.welcomeToastConfig?.msg, + }, + { + name: "Warn Toast Title", + value: currentConfig.warnToastConfig?.title, + }, + { + name: "Warn Toast Message", + value: currentConfig.warnToastConfig?.msg, + }, + { + name: "Notice Toast Title", + value: currentConfig.noticeToastConfig?.title, + }, + { + name: "Notice Toast Message", + value: currentConfig.noticeToastConfig?.msg, + }, + ]; + + for (const toastConfig of toastConfigs) { + if (toastConfig.value != undefined) { + const serialized = textutils.serialiseJSON( + toastConfig.value, + ); + if (!validateTextComponent(serialized)) { + showError( + `Invalid ${toastConfig.name}: must be valid MinecraftTextComponent JSON`, + ); + return; + } + } + } + + // Save configuration + context.logger?.debug( + `Configuration : ${textutils.serialise(currentConfig, { allow_repetitions: true })}`, + ); + saveConfig(currentConfig, configFilepath); + showError("Configuration saved successfully!"); + } catch (error) { + showError(`Failed to save configuration: ${String(error)}`); + } + }; + + // Add user to selected group + const addUser = () => { + const userName = newUserName().trim(); + if (userName === "") return; + + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + // Admin group + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + groupUsers: [...currentAdmin.groupUsers, userName], + }); + } else { + // Regular group + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + groupUsers: [...(currentGroup?.groupUsers ?? []), userName], + }; + setConfig("usersGroups", newGroups); + } + setNewUserName(""); + }; + + // Remove user from selected group + const removeUser = (userName: string) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + // Admin group + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + groupUsers: currentAdmin.groupUsers.filter( + (user) => user !== userName, + ), + }); + } else { + // Regular group + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + groupUsers: (currentGroup?.groupUsers ?? []).filter( + (user) => user !== userName, + ), + }; + setConfig("usersGroups", newGroups); + } + }; + + // Get all groups for selection + const getAllGroups = (): UserGroupConfig[] => { + const currentConfig = config(); + return [currentConfig.adminGroupConfig, ...currentConfig.usersGroups]; + }; + + // Get currently selected group + const getSelectedGroup = (): UserGroupConfig => { + const groups = getAllGroups(); + return groups[selectedGroupIndex()] ?? config().adminGroupConfig; + }; + + /** + * Basic Configuration Tab + */ + const [getDetectInterval, setDetectInterval] = createSignal( + config().detectInterval.toString(), + ); + const [getWatchInterval, setWatchInterval] = createSignal( + config().watchInterval.toString(), + ); + const [getNoticeTimes, setNoticeTimes] = createSignal( + config().noticeTimes.toString(), + ); + const [getDetectRange, setDetectRange] = createSignal( + config().detectRange.toString(), + ); + const BasicTab = () => { + return div( + { class: "flex flex-col" }, + div( + { class: "flex flex-row" }, + label({}, "Detect Interval (ms):"), + input({ + type: "text", + value: () => getDetectInterval(), + onInput: (value) => setDetectInterval(value), + onFocusChanged: () => { + const num = validateNumber(getDetectInterval()); + if (num !== null) setConfig("detectInterval", num); + else + setDetectInterval( + config().detectInterval.toString(), + ); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Watch Interval (ms):"), + input({ + type: "text", + value: () => getWatchInterval(), + onInput: (value) => setWatchInterval(value), + onFocusChanged: () => { + const num = validateNumber(getWatchInterval()); + if (num !== null) setConfig("watchInterval", num); + else + setWatchInterval(config().watchInterval.toString()); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Notice Times:"), + input({ + type: "text", + value: () => getNoticeTimes(), + onInput: (value) => setNoticeTimes(value), + onFocusChanged: () => { + const num = validateNumber(getNoticeTimes()); + if (num !== null) setConfig("noticeTimes", num); + else setNoticeTimes(config().noticeTimes.toString()); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Detect Range:"), + input({ + type: "text", + value: () => getDetectRange(), + onInput: (value) => setDetectRange(value), + onFocusChanged: () => { + const num = validateNumber(getDetectRange()); + if (num !== null) setConfig("detectRange", num); + else setDetectRange(config().detectRange.toString()); + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Warn:"), + input({ + type: "checkbox", + checked: () => config().isWarn ?? false, + onChange: (checked) => setConfig("isWarn", checked), + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Welcome:"), + input({ + type: "checkbox", + checked: () => config().isWelcome ?? false, + onChange: (checked) => setConfig("isWelcome", checked), + }), + ), + ); + }; + + /** + * Groups Configuration Tab + */ + const GroupsTab = () => { + const groups = getAllGroups(); + + return div( + { class: "flex flex-row" }, + // Left side - Groups list + div( + { class: "flex flex-col" }, + label({}, "Groups:"), + For( + { each: () => groups, class: "flex flex-col" }, + (group, index) => + button( + { + class: + selectedGroupIndex() === index() + ? "bg-blue text-white" + : "", + onClick: () => setSelectedGroupIndex(index()), + }, + group.groupName, + ), + ), + ), + + // Right side - Group details + div( + { class: "flex flex-col ml-2" }, + label({}, () => `Group: ${getSelectedGroup().groupName}`), + + div( + { class: "flex flex-row" }, + label({}, "Is Welcome:"), + input({ + type: "checkbox", + checked: () => getSelectedGroup().isWelcome, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isWelcome: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isWelcome: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Allowed:"), + input({ + type: "checkbox", + checked: () => getSelectedGroup().isAllowed, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isAllowed: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isAllowed: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + ), + div( + { class: "flex flex-row" }, + label({}, "Is Notice:"), + input({ + type: "checkbox", + checked: () => getSelectedGroup().isNotice, + onChange: (checked) => { + const groupIndex = selectedGroupIndex(); + if (groupIndex === 0) { + const currentAdmin = config().adminGroupConfig; + setConfig("adminGroupConfig", { + ...currentAdmin, + isNotice: checked, + }); + } else { + const actualIndex = groupIndex - 1; + const currentGroups = config().usersGroups; + const currentGroup = currentGroups[actualIndex]; + const newGroups = [...currentGroups]; + newGroups[actualIndex] = { + ...currentGroup, + isNotice: checked, + }; + setConfig("usersGroups", newGroups); + } + }, + }), + ), + + label({}, "Group Users:"), + // User management + div( + { class: "flex flex-row" }, + input({ + type: "text", + value: newUserName, + onInput: setNewUserName, + placeholder: "Enter username", + }), + button({ onClick: addUser }, "Add"), + ), + + // Users list + For( + { + class: "flex flex-col", + each: () => getSelectedGroup().groupUsers ?? [], + }, + (user) => + div( + { class: "flex flex-row items-center" }, + label({}, user), + button( + { + class: "ml-1 bg-red text-white", + onClick: () => removeUser(user), + }, + "X", + ), + ), + ), + ), + ); + }; + + /** + * Toast Configuration Tab Factory + */ + const createToastTab = ( + toastType: + | "welcomeToastConfig" + | "warnToastConfig" + | "noticeToastConfig", + ) => { + return () => { + const toastConfig = config()[toastType]; + const [getTempToastConfig, setTempToastConfig] = createSignal({ + title: textutils.serialiseJSON(toastConfig.title), + msg: textutils.serialiseJSON(toastConfig.msg), + prefix: toastConfig.prefix ?? "", + brackets: toastConfig.brackets ?? "", + bracketColor: toastConfig.bracketColor ?? "", + }); + + return div( + { class: "flex flex-col w-full" }, + label({}, "Title (JSON):"), + input({ + class: "w-full", + type: "text", + value: () => getTempToastConfig().title, + onInput: (value) => + setTempToastConfig({ + ...getTempToastConfig(), + title: value, + }), + onFocusChanged: () => { + const currentToastConfig = config()[toastType]; + + try { + const parsed = textutils.unserialiseJSON( + getTempToastConfig().title, + ) as MinecraftTextComponent; + if ( + typeof parsed === "object" && + parsed.text !== undefined && + parsed.color !== undefined + ) { + setConfig(toastType, { + ...currentToastConfig, + title: parsed, + }); + } else throw new Error("Invalid JSON"); + } catch { + setTempToastConfig({ + ...getTempToastConfig(), + title: textutils.serialiseJSON( + currentToastConfig.title, + ), + }); + } + }, + }), + + label({}, "Message (JSON):"), + input({ + class: "w-full", + type: "text", + value: () => getTempToastConfig().msg, + onInput: (value) => + setTempToastConfig({ + ...getTempToastConfig(), + msg: value, + }), + onFocusChanged: () => { + const currentToastConfig = config()[toastType]; + + try { + const parsed = textutils.unserialiseJSON( + getTempToastConfig().msg, + ) as MinecraftTextComponent; + if ( + typeof parsed === "object" && + parsed.text !== undefined && + parsed.color !== undefined + ) { + setConfig(toastType, { + ...currentToastConfig, + msg: parsed, + }); + } else throw new Error("Invalid JSON"); + } catch { + setTempToastConfig({ + ...getTempToastConfig(), + msg: textutils.serialiseJSON( + currentToastConfig.msg, + ), + }); + // Invalid JSON, ignore + } + }, + }), + + div( + { class: "flex flex-row" }, + label({}, "Prefix:"), + input({ + type: "text", + value: () => { + const str = textutils.serialiseJSON( + getTempToastConfig().prefix, + { + unicode_strings: true, + }, + ); + return str.substring(1, str.length - 1); + }, + onInput: (value) => + setTempToastConfig({ + ...getTempToastConfig(), + prefix: value, + }), + onFocusChanged: () => { + const currentToastConfig = config()[toastType]; + setConfig(toastType, { + ...currentToastConfig, + prefix: getTempToastConfig().prefix, + }); + }, + }), + ), + + div( + { class: "flex flex-row" }, + label({}, "Brackets:"), + input({ + type: "text", + value: () => getTempToastConfig().brackets, + onInput: (value) => + setTempToastConfig({ + ...getTempToastConfig(), + brackets: value, + }), + onFocusChanged: () => { + const currentToastConfig = config()[toastType]; + setConfig(toastType, { + ...currentToastConfig, + brackets: getTempToastConfig().brackets, + }); + }, + }), + ), + + div( + { class: "flex flex-row" }, + label({}, "Bracket Color:"), + input({ + type: "text", + value: () => getTempToastConfig().bracketColor, + onInput: (value) => + setTempToastConfig({ + ...getTempToastConfig(), + bracketColor: value, + }), + onFocusChanged: () => { + const currentToastConfig = config()[toastType]; + setConfig(toastType, { + ...currentToastConfig, + bracketColor: getTempToastConfig().bracketColor, + }); + }, + }), + ), + ); + }; + }; + + // Create toast tab components + const WelcomeToastTab = createToastTab("welcomeToastConfig"); + const WarnToastTab = createToastTab("warnToastConfig"); + const NoticeToastTab = createToastTab("noticeToastConfig"); + + /** + * Error Dialog + */ + const ErrorDialog = () => { + return Show( + { when: () => errorState().show }, + div( + { class: "flex flex-col" }, + label( + { class: "w-50 text-white bg-red", wordWrap: true }, + () => errorState().message, + ), + button( + { + class: "bg-white text-black", + onClick: hideError, + }, + "OK", + ), + ), + ); + }; + + /** + * Tab Content Renderer + */ + const TabContent = () => { + return Switch( + { fallback: BasicTab() }, + Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()), + Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()), + Match( + { when: () => currentTab() === TABS.WELCOME_TOAST }, + WelcomeToastTab(), + ), + Match( + { when: () => currentTab() === TABS.WARN_TOAST }, + WarnToastTab(), + ), + Match( + { when: () => currentTab() === TABS.NOTICE_TOAST }, + NoticeToastTab(), + ), + ); + }; + + /** + * Main UI Layout + */ + return div( + { class: "flex flex-col h-full" }, + // Header + div( + { class: "flex flex-row justify-center" }, + h1("Access Control Configuration"), + ), + + // Tab bar + div( + { class: "flex flex-row" }, + For({ each: () => tabNames }, (tabName, index) => + button( + { + class: + currentTab() === index() + ? "bg-blue text-white" + : "", + onClick: () => setCurrentTab(index() as TabIndex), + }, + tabName, + ), ), ), - ), + + // Content area + Show( + { when: () => !errorState().show }, + div( + { class: "flex flex-col" }, + ScrollContainer( + { class: "flex-1 p-2", width: 50, showScrollbar: true }, + TabContent(), + ), + + // Action buttons + div( + { class: "flex flex-row justify-center p-2" }, + button( + { + class: "bg-green text-white mr-2", + onClick: handleSave, + }, + "Save", + ), + button( + { + class: "bg-gray text-white", + onClick: () => { + // Close TUI - this will be handled by the application framework + error("TUI_CLOSE"); + }, + }, + "Close", + ), + ), + ), + ), + + // Error dialog overlay + ErrorDialog(), ); - }; - - /** - * Toast Configuration Tab Factory - */ - const createToastTab = ( - toastType: "welcomeToastConfig" | "warnToastConfig" | "noticeToastConfig", - ) => { - return () => { - const toastConfig = config()[toastType]; - const [getTempToastConfig, setTempToastConfig] = createSignal({ - title: textutils.serialiseJSON(toastConfig.title), - msg: textutils.serialiseJSON(toastConfig.msg), - prefix: toastConfig.prefix ?? "", - brackets: toastConfig.brackets ?? "", - bracketColor: toastConfig.bracketColor ?? "", - }); - - return div( - { class: "flex flex-col w-full" }, - label({}, "Title (JSON):"), - input({ - class: "w-full", - type: "text", - value: () => getTempToastConfig().title, - onInput: (value) => - setTempToastConfig({ - ...getTempToastConfig(), - title: value, - }), - onFocusChanged: () => { - const currentToastConfig = config()[toastType]; - - try { - const parsed = textutils.unserialiseJSON( - getTempToastConfig().title, - ) as MinecraftTextComponent; - if ( - typeof parsed === "object" && - parsed.text !== undefined && - parsed.color !== undefined - ) { - setConfig(toastType, { - ...currentToastConfig, - title: parsed, - }); - } else throw new Error("Invalid JSON"); - } catch { - setTempToastConfig({ - ...getTempToastConfig(), - title: textutils.serialiseJSON(currentToastConfig.title), - }); - } - }, - }), - - label({}, "Message (JSON):"), - input({ - class: "w-full", - type: "text", - value: () => getTempToastConfig().msg, - onInput: (value) => - setTempToastConfig({ ...getTempToastConfig(), msg: value }), - onFocusChanged: () => { - const currentToastConfig = config()[toastType]; - - try { - const parsed = textutils.unserialiseJSON( - getTempToastConfig().msg, - ) as MinecraftTextComponent; - if ( - typeof parsed === "object" && - parsed.text !== undefined && - parsed.color !== undefined - ) { - setConfig(toastType, { - ...currentToastConfig, - msg: parsed, - }); - } else throw new Error("Invalid JSON"); - } catch { - setTempToastConfig({ - ...getTempToastConfig(), - msg: textutils.serialiseJSON(currentToastConfig.msg), - }); - // Invalid JSON, ignore - } - }, - }), - - div( - { class: "flex flex-row" }, - label({}, "Prefix:"), - input({ - type: "text", - value: () => { - const str = textutils.serialiseJSON(getTempToastConfig().prefix, { - unicode_strings: true, - }); - return str.substring(1, str.length - 1); - }, - onInput: (value) => - setTempToastConfig({ ...getTempToastConfig(), prefix: value }), - onFocusChanged: () => { - const currentToastConfig = config()[toastType]; - setConfig(toastType, { - ...currentToastConfig, - prefix: getTempToastConfig().prefix, - }); - }, - }), - ), - - div( - { class: "flex flex-row" }, - label({}, "Brackets:"), - input({ - type: "text", - value: () => getTempToastConfig().brackets, - onInput: (value) => - setTempToastConfig({ ...getTempToastConfig(), brackets: value }), - onFocusChanged: () => { - const currentToastConfig = config()[toastType]; - setConfig(toastType, { - ...currentToastConfig, - brackets: getTempToastConfig().brackets, - }); - }, - }), - ), - - div( - { class: "flex flex-row" }, - label({}, "Bracket Color:"), - input({ - type: "text", - value: () => getTempToastConfig().bracketColor, - onInput: (value) => - setTempToastConfig({ - ...getTempToastConfig(), - bracketColor: value, - }), - onFocusChanged: () => { - const currentToastConfig = config()[toastType]; - setConfig(toastType, { - ...currentToastConfig, - bracketColor: getTempToastConfig().bracketColor, - }); - }, - }), - ), - ); - }; - }; - - // Create toast tab components - const WelcomeToastTab = createToastTab("welcomeToastConfig"); - const WarnToastTab = createToastTab("warnToastConfig"); - const NoticeToastTab = createToastTab("noticeToastConfig"); - - /** - * Error Dialog - */ - const ErrorDialog = () => { - return Show( - { when: () => errorState().show }, - div( - { class: "flex flex-col" }, - label( - { class: "w-50 text-white bg-red", wordWrap: true }, - () => errorState().message, - ), - button( - { - class: "bg-white text-black", - onClick: hideError, - }, - "OK", - ), - ), - ); - }; - - /** - * Tab Content Renderer - */ - const TabContent = () => { - return Switch( - { fallback: BasicTab() }, - Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()), - Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()), - Match( - { when: () => currentTab() === TABS.WELCOME_TOAST }, - WelcomeToastTab(), - ), - Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()), - Match( - { when: () => currentTab() === TABS.NOTICE_TOAST }, - NoticeToastTab(), - ), - ); - }; - - /** - * Main UI Layout - */ - return div( - { class: "flex flex-col h-full" }, - // Header - div( - { class: "flex flex-row justify-center" }, - h1("Access Control Configuration"), - ), - - // Tab bar - div( - { class: "flex flex-row" }, - For({ each: () => tabNames }, (tabName, index) => - button( - { - class: currentTab() === index() ? "bg-blue text-white" : "", - onClick: () => setCurrentTab(index() as TabIndex), - }, - tabName, - ), - ), - ), - - // Content area - Show( - { when: () => !errorState().show }, - div( - { class: "flex flex-col" }, - ScrollContainer( - { class: "flex-1 p-2", width: 50, showScrollbar: true }, - TabContent(), - ), - - // Action buttons - div( - { class: "flex flex-row justify-center p-2" }, - button( - { - class: "bg-green text-white mr-2", - onClick: handleSave, - }, - "Save", - ), - button( - { - class: "bg-gray text-white", - onClick: () => { - // Close TUI - this will be handled by the application framework - error("TUI_CLOSE"); - }, - }, - "Close", - ), - ), - ), - ), - - // Error dialog overlay - ErrorDialog(), - ); }; /** * Launch the Access Control TUI */ export function launchAccessControlTUI(): void { - try { - render(AccessControlTUI); - } catch (e) { - if (e === "TUI_CLOSE" || e === "Terminated") { - // Normal exit - return; - } else { - print("Error in Access Control TUI:"); - printError(e); + try { + render(AccessControlTUI); + } catch (e) { + if (e === "TUI_CLOSE" || e === "Terminated") { + // Normal exit + return; + } else { + print("Error in Access Control TUI:"); + printError(e); + } } - } } // Export the main component for external use diff --git a/src/autocraft/main.ts b/src/autocraft/main.ts index 275e04e..5160bef 100644 --- a/src/autocraft/main.ts +++ b/src/autocraft/main.ts @@ -6,6 +6,8 @@ import { import { Queue } from "@/lib/datatype/Queue"; import { ConsoleStream, + DAY, + FileStream, Logger, LogLevel, processor, @@ -18,7 +20,17 @@ const logger = new Logger({ processor.addTimestamp(), ], renderer: textRenderer, - streams: [new ConsoleStream()], + streams: [ + new ConsoleStream(), + new FileStream({ + filePath: "autocraft.log", + rotationInterval: DAY, + autoCleanup: { + enabled: true, + maxFiles: 3, + }, + }), + ], }); const peripheralsNames = { @@ -47,7 +59,8 @@ enum State { } function main() { - while (true) { + let isFinishedInitPeripheral = false; + while (!isFinishedInitPeripheral) { try { packsInventory = peripheral.wrap( peripheralsNames.packsInventory, @@ -67,7 +80,7 @@ function main() { turtleLocalName = wiredModem.getNameLocal(); logger.info("Peripheral initialization complete..."); - break; + isFinishedInitPeripheral = true; } catch (error) { logger.warn( `Peripheral initialization failed for ${String(error)}, try again...`, diff --git a/src/lib/ccTUI/UIObject.ts b/src/lib/ccTUI/UIObject.ts index c9fcc3e..cc250d3 100644 --- a/src/lib/ccTUI/UIObject.ts +++ b/src/lib/ccTUI/UIObject.ts @@ -11,458 +11,464 @@ import { ScrollContainerProps } from "./scrollContainer"; * 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"; + /** Flexbox direction */ + flexDirection?: "row" | "column"; + /** Justify content (main axis alignment) */ + justifyContent?: "start" | "center" | "end" | "between"; + /** Align items (cross axis alignment) */ + alignItems?: "start" | "center" | "end"; } /** * Style properties for colors and appearance */ export interface StyleProps { - /** Text color */ - textColor?: number; - /** Background color */ - backgroundColor?: number; - /** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */ - width?: number | "full" | "screen"; - /** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */ - height?: number | "full" | "screen"; + /** Text color */ + textColor?: number; + /** Background color */ + backgroundColor?: number; + /** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */ + width?: number | "full" | "screen"; + /** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */ + height?: number | "full" | "screen"; } /** * Scroll properties for scroll containers */ export interface ScrollProps extends BaseProps { - /** Current horizontal scroll position */ - scrollX: number; - /** Current vertical scroll position */ - scrollY: number; - /** Maximum horizontal scroll (content width - viewport width) */ - maxScrollX: number; - /** Maximum vertical scroll (content height - viewport height) */ - maxScrollY: number; - /** Content dimensions */ - contentWidth: number; - contentHeight: number; - /** Whether to show scrollbars */ - showScrollbar?: boolean; - /** Viewport dimensions (visible area) */ - viewportWidth: number; - viewportHeight: number; + /** Current horizontal scroll position */ + scrollX: number; + /** Current vertical scroll position */ + scrollY: number; + /** Maximum horizontal scroll (content width - viewport width) */ + maxScrollX: number; + /** Maximum vertical scroll (content height - viewport height) */ + maxScrollY: number; + /** Content dimensions */ + contentWidth: number; + contentHeight: number; + /** Whether to show scrollbars */ + showScrollbar?: boolean; + /** Viewport dimensions (visible area) */ + viewportWidth: number; + viewportHeight: number; } /** * Computed layout result after flexbox calculation */ export interface ComputedLayout { - x: number; - y: number; - width: number; - height: number; + 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; - width?: number; - height?: number; - onFocusChanged?: Setter | ((value: boolean) => void); + /** CSS-like class names for layout (e.g., "flex flex-col") */ + class?: string; + width?: number; + height?: number; + onFocusChanged?: Setter | ((value: boolean) => void); } /** * UIObject node type */ export type UIObjectType = - | "div" - | "label" - | "button" - | "input" - | "form" - | "h1" - | "h2" - | "h3" - | "for" - | "show" - | "switch" - | "match" - | "fragment" - | "scroll-container"; + | "div" + | "label" + | "button" + | "input" + | "form" + | "h1" + | "h2" + | "h3" + | "for" + | "show" + | "switch" + | "match" + | "fragment" + | "scroll-container"; export type UIObjectProps = - | DivProps - | LabelProps - | InputProps - | ButtonProps - | ScrollProps - | ScrollContainerProps; + | DivProps + | LabelProps + | InputProps + | ButtonProps + | ScrollProps + | ScrollContainerProps; /** * 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; + /** Type of the UI object */ + type: UIObjectType; - /** Props passed to the component */ - props: UIObjectProps; + /** Props passed to the component */ + props: UIObjectProps; - /** Children UI objects */ - children: UIObject[]; + /** Children UI objects */ + children: UIObject[]; - /** Parent UI object */ - parent?: UIObject; + /** Parent UI object */ + parent?: UIObject; - /** Computed layout after flexbox calculation */ - layout?: ComputedLayout; + /** Computed layout after flexbox calculation */ + layout?: ComputedLayout; - /** Layout properties parsed from class string */ - layoutProps: LayoutProps; + /** Layout properties parsed from class string */ + layoutProps: LayoutProps; - /** Style properties parsed from class string */ - styleProps: StyleProps; + /** Style properties parsed from class string */ + styleProps: StyleProps; - /** Whether this component is currently mounted */ - mounted: boolean; + /** Whether this component is currently mounted */ + mounted: boolean; - /** Cleanup functions to call when unmounting */ - cleanupFns: (() => void)[]; + /** Cleanup functions to call when unmounting */ + cleanupFns: (() => void)[]; - /** For text nodes - the text content (can be reactive) */ - textContent?: string | Accessor; + /** For text nodes - the text content (can be reactive) */ + textContent?: string | Accessor; - /** Event handlers */ - handlers: Record void) | undefined>; + /** Event handlers */ + handlers: Record void) | undefined>; - /** For input text components - cursor position */ - cursorPos?: number; + /** For input text components - cursor position */ + cursorPos?: number; - /** For scroll containers - scroll state */ - scrollProps?: ScrollProps; + /** For scroll containers - scroll state */ + scrollProps?: ScrollProps; - constructor( - type: UIObjectType, - props: UIObjectProps = {}, - children: UIObject[] = [], - ) { - this.type = type; - this.props = props; - this.children = children; - this.layoutProps = {}; - this.styleProps = {}; - this.mounted = false; - this.cleanupFns = []; - this.handlers = {}; + constructor( + type: UIObjectType, + props: UIObjectProps = {}, + children: UIObject[] = [], + ) { + this.type = type; + this.props = props; + this.children = children; + this.layoutProps = {}; + this.styleProps = {}; + this.mounted = false; + this.cleanupFns = []; + this.handlers = {}; - // Parse layout and styles from class prop - this.parseClassNames(); + // Parse layout and styles from class prop + this.parseClassNames(); - // Extract event handlers - this.extractHandlers(); + // Extract event handlers + this.extractHandlers(); - // Initialize cursor position for text inputs - if (type === "input" && (props as InputProps).type !== "checkbox") { - this.cursorPos = 0; - } - - // Initialize scroll properties for scroll containers - if (type === "scroll-container") { - this.scrollProps = { - scrollX: 0, - scrollY: 0, - maxScrollX: 0, - maxScrollY: 0, - contentWidth: 0, - contentHeight: 0, - showScrollbar: (props as ScrollProps).showScrollbar !== false, - viewportWidth: props.width ?? 10, - viewportHeight: props.height ?? 10, - }; - } - } - - /** - * Map color name to ComputerCraft colors API value - * - * @param colorName - The color name from class (e.g., "white", "red") - * @returns The color value from colors API, or undefined if invalid - */ - private parseColor(colorName: string): number | undefined { - const colorMap: Record = { - white: colors.white, - orange: colors.orange, - magenta: colors.magenta, - lightBlue: colors.lightBlue, - yellow: colors.yellow, - lime: colors.lime, - pink: colors.pink, - gray: colors.gray, - lightGray: colors.lightGray, - cyan: colors.cyan, - purple: colors.purple, - blue: colors.blue, - brown: colors.brown, - green: colors.green, - red: colors.red, - black: colors.black, - }; - - return colorMap[colorName]; - } - - /** - * Parse CSS-like class string into layout and style properties - */ - private parseClassNames(): void { - const className = this.props.class; - 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"; - } - - // Justify content - else if (cls === "justify-start") { - this.layoutProps.justifyContent = "start"; - } else if (cls === "justify-center") { - this.layoutProps.justifyContent = "center"; - } else if (cls === "justify-end") { - this.layoutProps.justifyContent = "end"; - } else if (cls === "justify-between") { - this.layoutProps.justifyContent = "between"; - } - - // Align items - else if (cls === "items-start") { - this.layoutProps.alignItems = "start"; - } else if (cls === "items-center") { - this.layoutProps.alignItems = "center"; - } else if (cls === "items-end") { - this.layoutProps.alignItems = "end"; - } - - // Text color (text-) - else if (cls.startsWith("text-")) { - const colorName = cls.substring(5); // Remove "text-" prefix - const color = this.parseColor(colorName); - if (color !== undefined) { - this.styleProps.textColor = color; + // Initialize cursor position for text inputs + if (type === "input" && (props as InputProps).type !== "checkbox") { + this.cursorPos = 0; } - } - // Background color (bg-) - else if (cls.startsWith("bg-")) { - const colorName = cls.substring(3); // Remove "bg-" prefix - const color = this.parseColor(colorName); - if (color !== undefined) { - this.styleProps.backgroundColor = color; + // Initialize scroll properties for scroll containers + if (type === "scroll-container") { + this.scrollProps = { + scrollX: 0, + scrollY: 0, + maxScrollX: 0, + maxScrollY: 0, + contentWidth: 0, + contentHeight: 0, + showScrollbar: (props as ScrollProps).showScrollbar !== false, + viewportWidth: props.width ?? 10, + viewportHeight: props.height ?? 10, + }; } - } + } - // Width sizing (w-) - else if (cls.startsWith("w-")) { - const sizeValue = cls.substring(2); // Remove "w-" prefix - if (sizeValue === "full") { - this.styleProps.width = "full"; - } else if (sizeValue === "screen") { - this.styleProps.width = "screen"; - } else { - const numValue = tonumber(sizeValue); - if (numValue !== undefined) { - this.styleProps.width = numValue; - } + /** + * Map color name to ComputerCraft colors API value + * + * @param colorName - The color name from class (e.g., "white", "red") + * @returns The color value from colors API, or undefined if invalid + */ + private parseColor(colorName: string): number | undefined { + const colorMap: Record = { + white: colors.white, + orange: colors.orange, + magenta: colors.magenta, + lightBlue: colors.lightBlue, + yellow: colors.yellow, + lime: colors.lime, + pink: colors.pink, + gray: colors.gray, + lightGray: colors.lightGray, + cyan: colors.cyan, + purple: colors.purple, + blue: colors.blue, + brown: colors.brown, + green: colors.green, + red: colors.red, + black: colors.black, + }; + + return colorMap[colorName]; + } + + /** + * Parse CSS-like class string into layout and style properties + */ + private parseClassNames(): void { + const className = this.props.class; + 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"; + } + + // Justify content + else if (cls === "justify-start") { + this.layoutProps.justifyContent = "start"; + } else if (cls === "justify-center") { + this.layoutProps.justifyContent = "center"; + } else if (cls === "justify-end") { + this.layoutProps.justifyContent = "end"; + } else if (cls === "justify-between") { + this.layoutProps.justifyContent = "between"; + } + + // Align items + else if (cls === "items-start") { + this.layoutProps.alignItems = "start"; + } else if (cls === "items-center") { + this.layoutProps.alignItems = "center"; + } else if (cls === "items-end") { + this.layoutProps.alignItems = "end"; + } + + // Text color (text-) + else if (cls.startsWith("text-")) { + const colorName = cls.substring(5); // Remove "text-" prefix + const color = this.parseColor(colorName); + if (color !== undefined) { + this.styleProps.textColor = color; + } + } + + // Background color (bg-) + else if (cls.startsWith("bg-")) { + const colorName = cls.substring(3); // Remove "bg-" prefix + const color = this.parseColor(colorName); + if (color !== undefined) { + this.styleProps.backgroundColor = color; + } + } + + // Width sizing (w-) + else if (cls.startsWith("w-")) { + const sizeValue = cls.substring(2); // Remove "w-" prefix + if (sizeValue === "full") { + this.styleProps.width = "full"; + } else if (sizeValue === "screen") { + this.styleProps.width = "screen"; + } else { + const numValue = tonumber(sizeValue); + if (numValue !== undefined) { + this.styleProps.width = numValue; + } + } + } + + // Height sizing (h-) + else if (cls.startsWith("h-")) { + const sizeValue = cls.substring(2); // Remove "h-" prefix + if (sizeValue === "full") { + this.styleProps.height = "full"; + } else if (sizeValue === "screen") { + this.styleProps.height = "screen"; + } else { + const numValue = tonumber(sizeValue); + if (numValue !== undefined) { + this.styleProps.height = numValue; + } + } + } } - } - // Height sizing (h-) - else if (cls.startsWith("h-")) { - const sizeValue = cls.substring(2); // Remove "h-" prefix - if (sizeValue === "full") { - this.styleProps.height = "full"; - } else if (sizeValue === "screen") { - this.styleProps.height = "screen"; - } else { - const numValue = tonumber(sizeValue); - if (numValue !== undefined) { - this.styleProps.height = numValue; - } + // Set defaults + if (this.type === "div") { + this.layoutProps.flexDirection ??= "row"; } - } + this.layoutProps.justifyContent ??= "start"; + this.layoutProps.alignItems ??= "start"; } - // 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(); + /** + * 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; + } + } } - // Run cleanup functions - for (const cleanup of this.cleanupFns) { - try { - cleanup(); - } catch (e) { - printError(e); - } + /** + * Add a child to this UI object + */ + appendChild(child: UIObject): void { + child.parent = this; + this.children.push(child); } - this.cleanupFns = []; - } - /** - * Register a cleanup function to be called on unmount - */ - onCleanup(fn: () => void): void { - this.cleanupFns.push(fn); - } + /** + * 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; + } + } - /** - * Scroll the container by the given amount - * @param deltaX - Horizontal scroll delta - * @param deltaY - Vertical scroll delta - */ - scrollBy(deltaX: number, deltaY: number): void { - if (this.type !== "scroll-container" || !this.scrollProps) return; + /** + * Mount this component and all children + */ + mount(): void { + if (this.mounted) return; + this.mounted = true; - const newScrollX = Math.max( - 0, - Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX), - ); - const newScrollY = Math.max( - 0, - Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY), - ); + // Mount all children + for (const child of this.children) { + child.mount(); + } + } - this.scrollProps.scrollX = newScrollX; - this.scrollProps.scrollY = newScrollY; - } + /** + * Unmount this component and run cleanup + */ + unmount(): void { + if (!this.mounted) return; + this.mounted = false; - /** - * Scroll to a specific position - * @param x - Horizontal scroll position - * @param y - Vertical scroll position - */ - scrollTo(x: number, y: number): void { - if (this.type !== "scroll-container" || !this.scrollProps) return; + // Unmount all children first + for (const child of this.children) { + child.unmount(); + } - this.scrollProps.scrollX = Math.max( - 0, - Math.min(this.scrollProps.maxScrollX, x), - ); - this.scrollProps.scrollY = Math.max( - 0, - Math.min(this.scrollProps.maxScrollY, y), - ); - } + // Run cleanup functions + for (const cleanup of this.cleanupFns) { + try { + cleanup(); + } catch (e) { + printError(e); + } + } + this.cleanupFns = []; + } - /** - * Update scroll bounds based on content size - * @param contentWidth - Total content width - * @param contentHeight - Total content height - */ - updateScrollBounds(contentWidth: number, contentHeight: number): void { - if (this.type !== "scroll-container" || !this.scrollProps) return; + /** + * Register a cleanup function to be called on unmount + */ + onCleanup(fn: () => void): void { + this.cleanupFns.push(fn); + } - this.scrollProps.contentWidth = contentWidth; - this.scrollProps.contentHeight = contentHeight; - this.scrollProps.maxScrollX = Math.max( - 0, - contentWidth - this.scrollProps.viewportWidth, - ); - this.scrollProps.maxScrollY = Math.max( - 0, - contentHeight - this.scrollProps.viewportHeight, - ); + /** + * Scroll the container by the given amount + * @param deltaX - Horizontal scroll delta + * @param deltaY - Vertical scroll delta + */ + scrollBy(deltaX: number, deltaY: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; - // Clamp current scroll position to new bounds - this.scrollProps.scrollX = Math.max( - 0, - Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX), - ); - this.scrollProps.scrollY = Math.max( - 0, - Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY), - ); - } + const newScrollX = Math.max( + 0, + Math.min( + this.scrollProps.maxScrollX, + this.scrollProps.scrollX + deltaX, + ), + ); + const newScrollY = Math.max( + 0, + Math.min( + this.scrollProps.maxScrollY, + this.scrollProps.scrollY + deltaY, + ), + ); + + this.scrollProps.scrollX = newScrollX; + this.scrollProps.scrollY = newScrollY; + } + + /** + * Scroll to a specific position + * @param x - Horizontal scroll position + * @param y - Vertical scroll position + */ + scrollTo(x: number, y: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; + + this.scrollProps.scrollX = Math.max( + 0, + Math.min(this.scrollProps.maxScrollX, x), + ); + this.scrollProps.scrollY = Math.max( + 0, + Math.min(this.scrollProps.maxScrollY, y), + ); + } + + /** + * Update scroll bounds based on content size + * @param contentWidth - Total content width + * @param contentHeight - Total content height + */ + updateScrollBounds(contentWidth: number, contentHeight: number): void { + if (this.type !== "scroll-container" || !this.scrollProps) return; + + this.scrollProps.contentWidth = contentWidth; + this.scrollProps.contentHeight = contentHeight; + this.scrollProps.maxScrollX = Math.max( + 0, + contentWidth - this.scrollProps.viewportWidth, + ); + this.scrollProps.maxScrollY = Math.max( + 0, + contentHeight - this.scrollProps.viewportHeight, + ); + + // Clamp current scroll position to new bounds + this.scrollProps.scrollX = Math.max( + 0, + Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX), + ); + this.scrollProps.scrollY = Math.max( + 0, + Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY), + ); + } } /** * Create a text node */ export function createTextNode(text: string | Accessor): UIObject { - const node = new UIObject("fragment", {}, []); - node.textContent = text; - return node; + const node = new UIObject("fragment", {}, []); + node.textContent = text; + return node; } diff --git a/src/lib/ccTUI/application.ts b/src/lib/ccTUI/application.ts index 89a78b5..968b3df 100644 --- a/src/lib/ccTUI/application.ts +++ b/src/lib/ccTUI/application.ts @@ -5,554 +5,575 @@ import { UIObject } from "./UIObject"; import { calculateLayout } from "./layout"; import { render as renderTree, clearScreen } from "./renderer"; -import { CCLog, DAY, LogLevel } from "../ccLog"; -import { setLogger } from "./context"; import { InputProps } from "./components"; import { Setter } from "./reactivity"; +import { getStructLogger, Logger } from "@/lib/ccStructLog"; +import { setLogger } from "./context"; /** * 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; - private logger: CCLog; - private cursorBlinkState = false; - private lastBlinkTime = 0; - private readonly BLINK_INTERVAL = 0.5; // seconds + private root?: UIObject; + private running = false; + private needsRender = true; + private focusedNode?: UIObject; + private termWidth: number; + private termHeight: number; + private logger: Logger; + private cursorBlinkState = false; + private lastBlinkTime = 0; + private readonly BLINK_INTERVAL = 0.5; // seconds - constructor() { - const [width, height] = term.getSize(); - this.termWidth = width; - this.termHeight = height; - this.logger = new CCLog("tui_debug.log", { - printTerminal: false, - logInterval: DAY, - outputMinLevel: LogLevel.Info, - }); - setLogger(this.logger); - this.logger.debug("Application constructed."); - } - - /** - * 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(); + constructor() { + const [width, height] = term.getSize(); + this.termWidth = width; + this.termHeight = height; + this.logger = getStructLogger("ccTUI"); + setLogger(this.logger); + this.logger.debug("Application constructed."); } - this.root = rootComponent; - this.root.mount(); - this.needsRender = true; - } + /** + * 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(); + } - /** - * 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.root = rootComponent; + this.root.mount(); + this.needsRender = true; } - this.running = true; - term.setCursorBlink(false); - clearScreen(); - - // Initial render - this.logger.debug("Initial renderFrame call."); - this.renderFrame(); - - // Main event loop - parallel.waitForAll( - () => this.renderLoop(), - () => this.eventLoop(), - () => this.timerLoop(), - ); - } - - /** - * Stop the application - */ - stop(): void { - this.logger.debug("Application stopping."); - this.running = false; - - if (this.root !== undefined) { - this.root.unmount(); + /** + * Request a re-render on the next frame + */ + requestRender(): void { + this.needsRender = true; } - this.logger.close(); - clearScreen(); - } + /** + * Run the application event loop + */ + run(): void { + if (this.root === undefined) { + error( + "Cannot run application without a root component. Call setRoot() first.", + ); + } - /** - * Render loop - continuously renders when needed - */ - private renderLoop(): void { - while (this.running) { - if (this.needsRender) { - this.logger.debug( - "renderLoop: needsRender is true, calling renderFrame.", - ); - this.needsRender = false; + this.running = true; + term.setCursorBlink(false); + clearScreen(); + + // Initial render + this.logger.debug("Initial renderFrame call."); this.renderFrame(); - } - os.sleep(0.05); + + // Main event loop + parallel.waitForAll( + () => this.renderLoop(), + () => this.eventLoop(), + () => this.timerLoop(), + ); } - } - /** - * Render a single frame - */ - private renderFrame(): void { - if (this.root === undefined) return; - this.logger.debug("renderFrame: Calculating layout."); - // Calculate layout - calculateLayout(this.root, this.termWidth, this.termHeight, 1, 1); + /** + * Stop the application + */ + stop(): void { + this.logger.debug("Application stopping."); + this.running = false; - // Clear screen - clearScreen(); - - // Render the tree - this.logger.debug("renderFrame: Rendering tree."); - renderTree(this.root, this.focusedNode, this.cursorBlinkState); - this.logger.debug("renderFrame: Finished rendering tree."); - } - - /** - * Timer loop - handles cursor blinking - */ - private timerLoop(): void { - while (this.running) { - const currentTime = os.clock(); - if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) { - this.lastBlinkTime = currentTime; - this.cursorBlinkState = !this.cursorBlinkState; - - // Only trigger render if we have a focused text input - if ( - this.focusedNode !== undefined && - this.focusedNode.type === "input" && - (this.focusedNode.props as InputProps).type !== "checkbox" - ) { - this.needsRender = true; + if (this.root !== undefined) { + this.root.unmount(); } - } - os.sleep(0.05); + + clearScreen(); } - } - /** - * Event loop - handles user input - */ - private eventLoop(): void { - while (this.running) { - const [eventType, ...eventData] = os.pullEvent(); - - 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.logger.debug( - string.format( - "eventLoop: Mouse click detected at (%d, %d)", - eventData[1], - eventData[2], - ), - ); - this.handleMouseClick( - eventData[0] as number, - eventData[1] as number, - eventData[2] as number, - ); - } else if (eventType === "mouse_scroll") { - this.logger.debug( - string.format( - "eventLoop: Mouse scroll detected at (%d, %d) direction %d", - eventData[1], - eventData[2], - eventData[0], - ), - ); - this.handleMouseScroll( - 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; + /** + * Render loop - continuously renders when needed + */ + private renderLoop(): void { + while (this.running) { + if (this.needsRender) { + this.logger.debug( + "renderLoop: needsRender is true, calling renderFrame.", + ); + this.needsRender = false; + this.renderFrame(); + } + os.sleep(0.05); } - } else if (this.focusedNode.type === "input") { - const type = (this.focusedNode.props as InputProps).type as - | string - | undefined; - if (type === "checkbox") { - // Toggle checkbox - const onChangeProp = (this.focusedNode.props as InputProps).onChange; - const checkedProp = (this.focusedNode.props as InputProps).checked; + } - if ( - typeof onChangeProp === "function" && - typeof checkedProp === "function" - ) { - const currentValue = (checkedProp as () => boolean)(); - (onChangeProp as (v: boolean) => void)(!currentValue); + /** + * Render a single frame + */ + private renderFrame(): void { + if (this.root === undefined) return; + this.logger.debug("renderFrame: Calculating layout."); + // Calculate layout + calculateLayout(this.root, this.termWidth, this.termHeight, 1, 1); + + // Clear screen + clearScreen(); + + // Render the tree + this.logger.debug("renderFrame: Rendering tree."); + renderTree(this.root, this.focusedNode, this.cursorBlinkState); + this.logger.debug("renderFrame: Finished rendering tree."); + } + + /** + * Timer loop - handles cursor blinking + */ + private timerLoop(): void { + while (this.running) { + const currentTime = os.clock(); + if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) { + this.lastBlinkTime = currentTime; + this.cursorBlinkState = !this.cursorBlinkState; + + // Only trigger render if we have a focused text input + if ( + this.focusedNode !== undefined && + this.focusedNode.type === "input" && + (this.focusedNode.props as InputProps).type !== "checkbox" + ) { + this.needsRender = true; + } + } + os.sleep(0.05); + } + } + + /** + * Event loop - handles user input + */ + private eventLoop(): void { + while (this.running) { + const [eventType, ...eventData] = os.pullEvent(); + + 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.logger.debug( + string.format( + "eventLoop: Mouse click detected at (%d, %d)", + eventData[1], + eventData[2], + ), + ); + this.handleMouseClick( + eventData[0] as number, + eventData[1] as number, + eventData[2] as number, + ); + } else if (eventType === "mouse_scroll") { + this.logger.debug( + string.format( + "eventLoop: Mouse scroll detected at (%d, %d) direction %d", + eventData[1], + eventData[2], + eventData[0], + ), + ); + this.handleMouseScroll( + 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 as InputProps).type as + | string + | undefined; + if (type === "checkbox") { + // Toggle checkbox + const onChangeProp = (this.focusedNode.props as InputProps) + .onChange; + const checkedProp = (this.focusedNode.props as InputProps) + .checked; + + if ( + typeof onChangeProp === "function" && + typeof checkedProp === "function" + ) { + const currentValue = (checkedProp as () => boolean)(); + (onChangeProp as (v: boolean) => void)(!currentValue); + this.needsRender = true; + } + } + } + } else if ( + this.focusedNode !== undefined && + this.focusedNode.type === "input" + ) { + // Handle text input key events + const type = (this.focusedNode.props as InputProps).type as + | string + | undefined; + if (type !== "checkbox") { + this.handleTextInputKey(key); + } } - } - } else if ( - this.focusedNode !== undefined && - this.focusedNode.type === "input" - ) { - // Handle text input key events - const type = (this.focusedNode.props as InputProps).type as - | string - | undefined; - if (type !== "checkbox") { - this.handleTextInputKey(key); - } - } - } - - /** - * Handle keyboard events for text input - */ - private handleTextInputKey(key: number): void { - if (this.focusedNode === undefined) return; - - const valueProp = (this.focusedNode.props as InputProps).value; - const onInputProp = (this.focusedNode.props as InputProps).onInput; - - if (typeof valueProp !== "function" || typeof onInputProp !== "function") { - return; } - const currentValue = (valueProp as () => string)(); - const cursorPos = this.focusedNode.cursorPos ?? 0; + /** + * Handle keyboard events for text input + */ + private handleTextInputKey(key: number): void { + if (this.focusedNode === undefined) return; - if (key === keys.left) { - // Move cursor left - this.focusedNode.cursorPos = math.max(0, cursorPos - 1); - this.needsRender = true; - } else if (key === keys.right) { - // Move cursor right - this.focusedNode.cursorPos = math.min(currentValue.length, cursorPos + 1); - this.needsRender = true; - } else if (key === keys.backspace) { - // Delete character before cursor - if (cursorPos > 0) { - const newValue = - currentValue.substring(0, cursorPos - 1) + - currentValue.substring(cursorPos); - (onInputProp as (v: string) => void)(newValue); - this.focusedNode.cursorPos = cursorPos - 1; - this.needsRender = true; - } - } else if (key === keys.delete) { - // Delete character after cursor - if (cursorPos < currentValue.length) { - const newValue = - currentValue.substring(0, cursorPos) + - currentValue.substring(cursorPos + 1); - (onInputProp as (v: string) => void)(newValue); - 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 as InputProps).type; - if (type !== "checkbox") { - // Insert character at cursor position - const onInputProp = (this.focusedNode.props as InputProps).onInput; const valueProp = (this.focusedNode.props as InputProps).value; + const onInputProp = (this.focusedNode.props as InputProps).onInput; if ( - typeof onInputProp === "function" && - typeof valueProp === "function" + typeof valueProp !== "function" || + typeof onInputProp !== "function" ) { - const currentValue = (valueProp as () => string)(); - const cursorPos = this.focusedNode.cursorPos ?? 0; - const newValue = - currentValue.substring(0, cursorPos) + - char + - currentValue.substring(cursorPos); - (onInputProp as (v: string) => void)(newValue); - this.focusedNode.cursorPos = cursorPos + 1; - this.needsRender = true; + return; } - } - } - } - /** - * Handle mouse click events - */ - private handleMouseClick(button: number, x: number, y: number): void { - if (button !== 1 || this.root === undefined) return; + const currentValue = (valueProp as () => string)(); + const cursorPos = this.focusedNode.cursorPos ?? 0; - this.logger.debug("handleMouseClick: Finding node."); - // Find which element was clicked - const clicked = this.findNodeAt(this.root, x, y); - - if (clicked !== undefined) { - this.logger.debug( - string.format("handleMouseClick: Found node of type %s.", clicked.type), - ); - // Set focus - if ( - this.focusedNode !== undefined && - typeof this.focusedNode.props.onFocusChanged === "function" - ) { - const onFocusChanged = this.focusedNode.props - .onFocusChanged as Setter; - onFocusChanged(false); - } - this.focusedNode = clicked; - if (typeof clicked.props.onFocusChanged === "function") { - const onFocusChanged = clicked.props.onFocusChanged as Setter; - onFocusChanged(true); - } - - // Initialize cursor position for text inputs on focus - if ( - clicked.type === "input" && - (clicked.props as InputProps).type !== "checkbox" - ) { - const valueProp = (clicked.props as InputProps).value; - if (typeof valueProp === "function") { - const currentValue = (valueProp as () => string)(); - clicked.cursorPos = currentValue.length; - } - } - - // Trigger click handler - if (clicked.type === "button") { - const onClick = clicked.handlers.onClick; - if (onClick) { - this.logger.debug( - "handleMouseClick: onClick handler found, executing.", - ); - (onClick as () => void)(); - this.logger.debug("handleMouseClick: onClick handler finished."); - this.needsRender = true; - } - } else if (clicked.type === "input") { - const type = (clicked.props as InputProps).type as string | undefined; - if (type === "checkbox") { - const onChangeProp = (clicked.props as InputProps).onChange; - const checkedProp = (clicked.props as InputProps).checked; - - if ( - typeof onChangeProp === "function" && - typeof checkedProp === "function" - ) { - const currentValue = (checkedProp as () => boolean)(); - (onChangeProp as (v: boolean) => void)(!currentValue); + if (key === keys.left) { + // Move cursor left + this.focusedNode.cursorPos = math.max(0, cursorPos - 1); this.needsRender = true; - } + } else if (key === keys.right) { + // Move cursor right + this.focusedNode.cursorPos = math.min( + currentValue.length, + cursorPos + 1, + ); + this.needsRender = true; + } else if (key === keys.backspace) { + // Delete character before cursor + if (cursorPos > 0) { + const newValue = + currentValue.substring(0, cursorPos - 1) + + currentValue.substring(cursorPos); + (onInputProp as (v: string) => void)(newValue); + this.focusedNode.cursorPos = cursorPos - 1; + this.needsRender = true; + } + } else if (key === keys.delete) { + // Delete character after cursor + if (cursorPos < currentValue.length) { + const newValue = + currentValue.substring(0, cursorPos) + + currentValue.substring(cursorPos + 1); + (onInputProp as (v: string) => void)(newValue); + this.needsRender = true; + } } - } - - this.needsRender = true; - } else { - this.logger.debug("handleMouseClick: No node found at click position."); - } - } - - /** - * 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; - const hit = x >= nx && x < nx + width && y >= ny && y < ny + height; - if (hit) { - this.logger.debug( - string.format( - "findNodeAt: Hit test TRUE for %s at (%d, %d)", - node.type, - nx, - ny, - ), - ); - // Only return interactive elements + /** + * Handle character input events + */ + private handleCharEvent(char: string): void { + if ( + this.focusedNode !== undefined && + this.focusedNode.type === "input" + ) { + const type = (this.focusedNode.props as InputProps).type; + if (type !== "checkbox") { + // Insert character at cursor position + const onInputProp = (this.focusedNode.props as InputProps) + .onInput; + const valueProp = (this.focusedNode.props as InputProps).value; + + if ( + typeof onInputProp === "function" && + typeof valueProp === "function" + ) { + const currentValue = (valueProp as () => string)(); + const cursorPos = this.focusedNode.cursorPos ?? 0; + const newValue = + currentValue.substring(0, cursorPos) + + char + + currentValue.substring(cursorPos); + (onInputProp as (v: string) => void)(newValue); + this.focusedNode.cursorPos = cursorPos + 1; + this.needsRender = true; + } + } + } + } + + /** + * Handle mouse click events + */ + private handleMouseClick(button: number, x: number, y: number): void { + if (button !== 1 || this.root === undefined) return; + + this.logger.debug("handleMouseClick: Finding node."); + // Find which element was clicked + const clicked = this.findNodeAt(this.root, x, y); + + if (clicked !== undefined) { + this.logger.debug( + string.format( + "handleMouseClick: Found node of type %s.", + clicked.type, + ), + ); + // Set focus + if ( + this.focusedNode !== undefined && + typeof this.focusedNode.props.onFocusChanged === "function" + ) { + const onFocusChanged = this.focusedNode.props + .onFocusChanged as Setter; + onFocusChanged(false); + } + this.focusedNode = clicked; + if (typeof clicked.props.onFocusChanged === "function") { + const onFocusChanged = clicked.props + .onFocusChanged as Setter; + onFocusChanged(true); + } + + // Initialize cursor position for text inputs on focus + if ( + clicked.type === "input" && + (clicked.props as InputProps).type !== "checkbox" + ) { + const valueProp = (clicked.props as InputProps).value; + if (typeof valueProp === "function") { + const currentValue = (valueProp as () => string)(); + clicked.cursorPos = currentValue.length; + } + } + + // Trigger click handler + if (clicked.type === "button") { + const onClick = clicked.handlers.onClick; + if (onClick) { + this.logger.debug( + "handleMouseClick: onClick handler found, executing.", + ); + (onClick as () => void)(); + this.logger.debug( + "handleMouseClick: onClick handler finished.", + ); + this.needsRender = true; + } + } else if (clicked.type === "input") { + const type = (clicked.props as InputProps).type as + | string + | undefined; + if (type === "checkbox") { + const onChangeProp = (clicked.props as InputProps).onChange; + const checkedProp = (clicked.props as InputProps).checked; + + if ( + typeof onChangeProp === "function" && + typeof checkedProp === "function" + ) { + const currentValue = (checkedProp as () => boolean)(); + (onChangeProp as (v: boolean) => void)(!currentValue); + this.needsRender = true; + } + } + } + + this.needsRender = true; + } else { + this.logger.debug( + "handleMouseClick: No node found at click position.", + ); + } + } + + /** + * 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; + const hit = x >= nx && x < nx + width && y >= ny && y < ny + height; + if (hit) { + this.logger.debug( + string.format( + "findNodeAt: Hit test TRUE for %s at (%d, %d)", + node.type, + nx, + ny, + ), + ); + // Only return interactive elements + if (node.type === "button" || node.type === "input") { + this.logger.debug( + "findNodeAt: Node is interactive, returning.", + ); + return node; + } + } + } + + return undefined; + } + + /** + * Focus the next interactive element + */ + private focusNext(): void { + if (this.root === undefined) return; + + const interactive = this.collectInteractive(this.root); + + if ( + this.focusedNode !== undefined && + typeof this.focusedNode.props.onFocusChanged === "function" + ) { + const onFocusChanged = this.focusedNode.props + .onFocusChanged as Setter; + onFocusChanged(false); + } + 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]; + } + } + + /** + * Find the scrollable UI node at a specific screen position + */ + private findScrollableNodeAt( + node: UIObject, + x: number, + y: number, + ): UIObject | undefined { + // Check children first (depth-first) + for (const child of node.children) { + const found = this.findScrollableNodeAt(child, x, y); + if (found !== undefined) { + return found; + } + } + + // Check this node + if (node.layout !== undefined) { + const { x: nx, y: ny, width, height } = node.layout; + const hit = x >= nx && x < nx + width && y >= ny && y < ny + height; + if (hit) { + this.logger.debug( + string.format( + "findNodeAt: Hit test TRUE for %s at (%d, %d)", + node.type, + nx, + ny, + ), + ); + // Only return scrollable elements + if (node.type === "scroll-container") { + this.logger.debug( + "findNodeAt: Node is scrollable, returning.", + ); + return node; + } + } + } + + return undefined; + } + + /** + * Handle mouse scroll events + */ + private handleMouseScroll(direction: number, x: number, y: number): void { + if (this.root === undefined) return; + + // Find which element was scrolled over + const scrollContainer = this.findScrollableNodeAt(this.root, x, y); + + if (scrollContainer?.scrollProps) { + // Scroll by 1 line per wheel step + const scrollAmount = direction * 1; + scrollContainer.scrollBy(0, scrollAmount); + this.needsRender = true; + + this.logger.debug( + string.format( + "handleMouseScroll: Scrolled container by %d, new position: (%d, %d)", + scrollAmount, + scrollContainer.scrollProps.scrollX, + scrollContainer.scrollProps.scrollY, + ), + ); + } + } + + /** + * Collect all interactive elements in the tree + */ + private collectInteractive(node: UIObject): UIObject[] { + const result: UIObject[] = []; + if (node.type === "button" || node.type === "input") { - this.logger.debug("findNodeAt: Node is interactive, returning."); - return node; + result.push(node); } - } - } - return undefined; - } - - /** - * Focus the next interactive element - */ - private focusNext(): void { - if (this.root === undefined) return; - - const interactive = this.collectInteractive(this.root); - - if ( - this.focusedNode !== undefined && - typeof this.focusedNode.props.onFocusChanged === "function" - ) { - const onFocusChanged = this.focusedNode.props - .onFocusChanged as Setter; - onFocusChanged(false); - } - 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]; - } - } - - /** - * Find the scrollable UI node at a specific screen position - */ - private findScrollableNodeAt( - node: UIObject, - x: number, - y: number, - ): UIObject | undefined { - // Check children first (depth-first) - for (const child of node.children) { - const found = this.findScrollableNodeAt(child, x, y); - if (found !== undefined) { - return found; - } - } - - // Check this node - if (node.layout !== undefined) { - const { x: nx, y: ny, width, height } = node.layout; - const hit = x >= nx && x < nx + width && y >= ny && y < ny + height; - if (hit) { - this.logger.debug( - string.format( - "findNodeAt: Hit test TRUE for %s at (%d, %d)", - node.type, - nx, - ny, - ), - ); - // Only return scrollable elements - if (node.type === "scroll-container") { - this.logger.debug("findNodeAt: Node is scrollable, returning."); - return node; + for (const child of node.children) { + result.push(...this.collectInteractive(child)); } - } + + return result; } - - return undefined; - } - - /** - * Handle mouse scroll events - */ - private handleMouseScroll(direction: number, x: number, y: number): void { - if (this.root === undefined) return; - - // Find which element was scrolled over - const scrollContainer = this.findScrollableNodeAt(this.root, x, y); - - if (scrollContainer?.scrollProps) { - // Scroll by 1 line per wheel step - const scrollAmount = direction * 1; - scrollContainer.scrollBy(0, scrollAmount); - this.needsRender = true; - - this.logger.debug( - string.format( - "handleMouseScroll: Scrolled container by %d, new position: (%d, %d)", - scrollAmount, - scrollContainer.scrollProps.scrollX, - scrollContainer.scrollProps.scrollY, - ), - ); - } - } - - /** - * 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; - } } /** @@ -572,15 +593,15 @@ export class Application { * ``` */ export function render(rootFn: () => UIObject): void { - const app = new Application(); + const app = new Application(); - // Create the root component - const root = rootFn(); - app.setRoot(root); + // Create the root component + const root = rootFn(); + app.setRoot(root); - try { - app.run(); - } finally { - app.stop(); - } + try { + app.run(); + } finally { + app.stop(); + } } diff --git a/src/lib/ccTUI/components.ts b/src/lib/ccTUI/components.ts index a9a661b..8ce8115 100644 --- a/src/lib/ccTUI/components.ts +++ b/src/lib/ccTUI/components.ts @@ -18,42 +18,42 @@ export type DivProps = BaseProps; * Props for label component */ export type LabelProps = BaseProps & { - /** Whether to automatically wrap long text. Defaults to false. */ - wordWrap?: boolean; + /** Whether to automatically wrap long text. Defaults to false. */ + wordWrap?: boolean; }; /** * Props for button component */ export type ButtonProps = BaseProps & { - /** Click handler */ - onClick?: () => void; + /** Click handler */ + onClick?: () => void; }; /** * Props for input component */ export type InputProps = BaseProps & { - /** Input type */ - type?: "text" | "checkbox"; - /** Value signal for text input */ - value?: Accessor | Signal; - /** Input handler for text input */ - onInput?: Setter | ((value: string) => void); - /** Checked signal for checkbox */ - checked?: Accessor | Signal; - /** Change handler for checkbox */ - onChange?: Setter | ((checked: boolean) => void); - /** Placeholder text */ - placeholder?: string; + /** Input type */ + type?: "text" | "checkbox"; + /** Value signal for text input */ + value?: Accessor | Signal; + /** Input handler for text input */ + onInput?: Setter | ((value: string) => void); + /** Checked signal for checkbox */ + checked?: Accessor | Signal; + /** Change handler for checkbox */ + onChange?: Setter | ((checked: boolean) => void); + /** Placeholder text */ + placeholder?: string; }; /** * Props for form component */ export type FormProps = BaseProps & { - /** Submit handler */ - onSubmit?: () => void; + /** Submit handler */ + onSubmit?: () => void; }; /** @@ -72,20 +72,20 @@ export type FormProps = BaseProps & { * ``` */ export function div( - props: DivProps, - ...children: (UIObject | string | Accessor)[] + props: DivProps, + ...children: (UIObject | string | Accessor)[] ): UIObject { - // Convert string children to text nodes - const uiChildren = children.map((child) => { - if (typeof child === "string" || typeof child === "function") { - return createTextNode(child); - } - return child; - }); + // 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; + const node = new UIObject("div", props, uiChildren); + uiChildren.forEach((child) => (child.parent = node)); + return node; } /** @@ -108,81 +108,84 @@ export function div( * @returns An array of words and whitespace. */ function splitByWhitespace(text: string): string[] { - if (!text) return []; - const parts: string[] = []; - let currentWord = ""; - let currentWhitespace = ""; + if (!text) return []; + const parts: string[] = []; + let currentWord = ""; + let currentWhitespace = ""; - for (const char of text) { - if (char === " " || char === "\t" || char === "\n" || char === "\r") { - if (currentWord.length > 0) { - parts.push(currentWord); - currentWord = ""; - } - currentWhitespace += char; - } else { - if (currentWhitespace.length > 0) { - parts.push(currentWhitespace); - currentWhitespace = ""; - } - currentWord += char; + for (const char of text) { + if (char === " " || char === "\t" || char === "\n" || char === "\r") { + if (currentWord.length > 0) { + parts.push(currentWord); + currentWord = ""; + } + currentWhitespace += char; + } else { + if (currentWhitespace.length > 0) { + parts.push(currentWhitespace); + currentWhitespace = ""; + } + currentWord += char; + } } - } - if (currentWord.length > 0) { - parts.push(currentWord); - } - if (currentWhitespace.length > 0) { - parts.push(currentWhitespace); - } + if (currentWord.length > 0) { + parts.push(currentWord); + } + if (currentWhitespace.length > 0) { + parts.push(currentWhitespace); + } - return parts; + return parts; } export function label( - props: LabelProps, - text: string | Accessor, + props: LabelProps, + text: string | Accessor, ): UIObject { - context.logger?.debug(`label : ${textutils.serialiseJSON(props)}`); - context.logger?.debug( - `label text: ${typeof text == "string" ? text : text()}`, - ); - if (props.wordWrap === true) { - const p = { ...props }; - delete p.wordWrap; - const containerProps: DivProps = { - ...p, - class: `${p.class ?? ""} flex flex-col`, - }; + context.logger?.debug(`label : ${textutils.serialiseJSON(props)}`); + context.logger?.debug( + `label text: ${typeof text == "string" ? text : text()}`, + ); + if (props.wordWrap === true) { + const p = { ...props }; + delete p.wordWrap; + const containerProps: DivProps = { + ...p, + class: `${p.class ?? ""} flex flex-col`, + }; - if (typeof text === "string") { - // Handle static strings - const words = splitByWhitespace(text); - const children = words.map((word) => createTextNode(word)); - const node = new UIObject("div", containerProps, children); - children.forEach((child) => (child.parent = node)); - return node; - } else { - // Handle reactive strings (Accessor) - const sentences = createMemo(() => { - const words = splitByWhitespace(text()); - const ret = concatSentence(words, 40); - context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`); - return ret; - }); + if (typeof text === "string") { + // Handle static strings + const words = splitByWhitespace(text); + const children = words.map((word) => createTextNode(word)); + const node = new UIObject("div", containerProps, children); + children.forEach((child) => (child.parent = node)); + return node; + } else { + // Handle reactive strings (Accessor) + const sentences = createMemo(() => { + const words = splitByWhitespace(text()); + const ret = concatSentence(words, 40); + context.logger?.debug( + `label words changed : [ ${ret.join(",")} ]`, + ); + return ret; + }); - const forNode = For({ class: `flex flex-col`, each: sentences }, (word) => - label({ class: p.class }, word), - ); + const forNode = For( + { class: `flex flex-col`, each: sentences }, + (word) => label({ class: p.class }, word), + ); - return forNode; + return forNode; + } } - } - const textNode = createTextNode(text); - const node = new UIObject("label", props, [textNode]); - textNode.parent = node; - return node; + const textNode = createTextNode(text); + const node = new UIObject("label", props, [textNode]); + textNode.parent = node; + return node; } /** @@ -192,7 +195,7 @@ export function label( * @returns UIObject representing h1 */ export function h1(text: string | Accessor): UIObject { - return label({ class: "heading-1" }, text); + return label({ class: "heading-1" }, text); } /** @@ -202,7 +205,7 @@ export function h1(text: string | Accessor): UIObject { * @returns UIObject representing h2 */ export function h2(text: string | Accessor): UIObject { - return label({ class: "heading-2" }, text); + return label({ class: "heading-2" }, text); } /** @@ -212,7 +215,7 @@ export function h2(text: string | Accessor): UIObject { * @returns UIObject representing h3 */ export function h3(text: string | Accessor): UIObject { - return label({ class: "heading-3" }, text); + return label({ class: "heading-3" }, text); } /** @@ -228,10 +231,10 @@ export function h3(text: string | Accessor): UIObject { * ``` */ export function button(props: ButtonProps, text: string): UIObject { - const textNode = createTextNode(text); - const node = new UIObject("button", props, [textNode]); - textNode.parent = node; - return node; + const textNode = createTextNode(text); + const node = new UIObject("button", props, [textNode]); + textNode.parent = node; + return node; } /** @@ -252,18 +255,18 @@ export function button(props: ButtonProps, text: string): UIObject { * ``` */ export function input(props: InputProps): UIObject { - // Normalize signal tuples to just the accessor - const normalizedProps = { ...props }; + // Normalize signal tuples to just the accessor + const normalizedProps = { ...props }; - if (Array.isArray(normalizedProps.value)) { - normalizedProps.value = normalizedProps.value[0]; - } + if (Array.isArray(normalizedProps.value)) { + normalizedProps.value = normalizedProps.value[0]; + } - if (Array.isArray(normalizedProps.checked)) { - normalizedProps.checked = normalizedProps.checked[0]; - } + if (Array.isArray(normalizedProps.checked)) { + normalizedProps.checked = normalizedProps.checked[0]; + } - return new UIObject("input", normalizedProps, []); + return new UIObject("input", normalizedProps, []); } /** @@ -282,18 +285,18 @@ export function input(props: InputProps): UIObject { * ``` */ export function form( - props: FormProps, - ...children: (UIObject | string | Accessor)[] + props: FormProps, + ...children: (UIObject | string | Accessor)[] ): UIObject { - // Convert string children to text nodes - const uiChildren = children.map((child) => { - if (typeof child === "string" || typeof child === "function") { - return createTextNode(child); - } - return child; - }); + // 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; + const node = new UIObject("form", props, uiChildren); + uiChildren.forEach((child) => (child.parent = node)); + return node; } diff --git a/src/lib/ccTUI/context.ts b/src/lib/ccTUI/context.ts index c079e33..1fe796f 100644 --- a/src/lib/ccTUI/context.ts +++ b/src/lib/ccTUI/context.ts @@ -4,20 +4,20 @@ * to all components without prop drilling. */ -import type { CCLog } from "../ccLog"; +import { Logger } from "@/lib/ccStructLog"; /** * The global context object for the TUI application. * This will be set by the Application instance on creation. */ -export const context: { logger: CCLog | undefined } = { - logger: undefined, +export const context: { logger: Logger | undefined } = { + logger: undefined, }; /** * Sets the global logger instance. * @param l The logger instance. */ -export function setLogger(l: CCLog): void { - context.logger = l; +export function setLogger(l: Logger): void { + context.logger = l; } diff --git a/src/lib/ccTUI/controlFlow.ts b/src/lib/ccTUI/controlFlow.ts index 1be68b5..6597973 100644 --- a/src/lib/ccTUI/controlFlow.ts +++ b/src/lib/ccTUI/controlFlow.ts @@ -9,34 +9,34 @@ import { Accessor, createEffect } from "./reactivity"; * Props for For component */ export type ForProps = { - /** Signal or accessor containing the array to iterate over */ - each: Accessor; + /** Signal or accessor containing the array to iterate over */ + each: Accessor; } & Record; /** * Props for Show component */ export type ShowProps = { - /** Condition accessor - when true, shows the child */ - when: Accessor; - /** Optional fallback to show when condition is false */ - fallback?: UIObject; + /** Condition accessor - when true, shows the child */ + when: Accessor; + /** Optional fallback to show when condition is false */ + fallback?: UIObject; } & Record; /** * Props for Switch component */ export type SwitchProps = { - /** Optional fallback to show when no Match condition is met */ - fallback?: UIObject; + /** Optional fallback to show when no Match condition is met */ + fallback?: UIObject; } & Record; /** * Props for Match component */ export type MatchProps = { - /** Condition accessor - when truthy, this Match will be selected */ - when: Accessor; + /** Condition accessor - when truthy, this Match will be selected */ + when: Accessor; } & Record; /** @@ -61,42 +61,42 @@ export type MatchProps = { * ``` */ export function For( - props: ForProps, - renderFn: (item: T, index: Accessor) => UIObject, + props: ForProps, + renderFn: (item: T, index: Accessor) => UIObject, ): UIObject { - const container = new UIObject("for", props, []); + const container = new UIObject("for", props, []); - // Track rendered items - let renderedItems: UIObject[] = []; + // Track rendered items + let renderedItems: UIObject[] = []; - /** - * Update the list when the array changes - */ - const updateList = () => { - const items = props.each(); + /** + * Update the list when the array changes + */ + const updateList = () => { + const items = props.each(); - // Clear old items - renderedItems.forEach((item) => item.unmount()); - container.children = []; - renderedItems = []; + // 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(); + // 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(); }); - }; - // Create effect to watch for changes - createEffect(() => { - updateList(); - }); - - return container; + return container; } /** @@ -120,44 +120,44 @@ export function For( * ``` */ export function Show(props: ShowProps, child: UIObject): UIObject { - const container = new UIObject("show", props, []); + const container = new UIObject("show", props, []); - let currentChild: UIObject | undefined = undefined; + let currentChild: UIObject | undefined = undefined; - /** - * Update which child is shown based on condition - */ - const updateChild = () => { - const condition = props.when(); + /** + * 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); - } + // 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; - } + // 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(); - } - }; + if (currentChild !== undefined) { + container.appendChild(currentChild); + currentChild.mount(); + } + }; - // Create effect to watch for condition changes - createEffect(() => { - updateChild(); - }); + // Create effect to watch for condition changes + createEffect(() => { + updateChild(); + }); - return container; + return container; } /** @@ -181,58 +181,58 @@ export function Show(props: ShowProps, child: UIObject): UIObject { * ``` */ export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject { - const container = new UIObject("switch", props, []); + const container = new UIObject("switch", props, []); - let currentChild: UIObject | undefined = undefined; + let currentChild: UIObject | undefined = undefined; - /** - * Evaluate all Match conditions and show the first truthy one - */ - const updateChild = () => { - // Unmount current child - if (currentChild !== undefined) { - currentChild.unmount(); - container.removeChild(currentChild); - } + /** + * Evaluate all Match conditions and show the first truthy one + */ + const updateChild = () => { + // Unmount current child + if (currentChild !== undefined) { + currentChild.unmount(); + container.removeChild(currentChild); + } - // Find the first Match with a truthy condition - for (const match of matches) { - if (match.type === "match") { - const matchProps = match.props as MatchProps; - const condition = matchProps.when(); + // Find the first Match with a truthy condition + for (const match of matches) { + if (match.type === "match") { + const matchProps = match.props as MatchProps; + const condition = matchProps.when(); - if ( - condition !== undefined && - condition !== null && - condition !== false - ) { - // This Match's condition is truthy, use it - if (match.children.length > 0) { - currentChild = match.children[0]; + if ( + condition !== undefined && + condition !== null && + condition !== false + ) { + // This Match's condition is truthy, use it + if (match.children.length > 0) { + currentChild = match.children[0]; + container.appendChild(currentChild); + currentChild.mount(); + } + return; + } + } + } + + // No Match condition was truthy, use fallback if available + if (props.fallback !== undefined) { + currentChild = props.fallback; container.appendChild(currentChild); currentChild.mount(); - } - return; + } else { + currentChild = undefined; } - } - } + }; - // No Match condition was truthy, use fallback if available - if (props.fallback !== undefined) { - currentChild = props.fallback; - container.appendChild(currentChild); - currentChild.mount(); - } else { - currentChild = undefined; - } - }; + // Create effect to watch for condition changes + createEffect(() => { + updateChild(); + }); - // Create effect to watch for condition changes - createEffect(() => { - updateChild(); - }); - - return container; + return container; } /** @@ -253,7 +253,7 @@ export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject { * ``` */ export function Match(props: MatchProps, child: UIObject): UIObject { - const container = new UIObject("match", props, [child]); - child.parent = container; - return container; + const container = new UIObject("match", props, [child]); + child.parent = container; + return container; } diff --git a/src/lib/ccTUI/index.ts b/src/lib/ccTUI/index.ts index bdc67ae..ee0e92f 100644 --- a/src/lib/ccTUI/index.ts +++ b/src/lib/ccTUI/index.ts @@ -6,60 +6,60 @@ // Reactivity system export { - createSignal, - createEffect, - createMemo, - batch, - type Accessor, - type Setter, - type Signal, + createSignal, + createEffect, + createMemo, + batch, + type Accessor, + type Setter, + type Signal, } from "./reactivity"; // Store for complex state export { - createStore, - removeIndex, - insertAt, - type SetStoreFunction, + 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, + 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, - Switch, - Match, - type ForProps, - type ShowProps, - type SwitchProps, - type MatchProps, + For, + Show, + Switch, + Match, + type ForProps, + type ShowProps, + type SwitchProps, + type MatchProps, } from "./controlFlow"; // Scroll container export { - ScrollContainer, - isScrollContainer, - findScrollContainer, - isPointVisible, - screenToContent, - type ScrollContainerProps, + ScrollContainer, + isScrollContainer, + findScrollContainer, + isPointVisible, + screenToContent, + type ScrollContainerProps, } from "./scrollContainer"; // Application @@ -67,10 +67,10 @@ export { Application, render } from "./application"; // Core types export { - UIObject, - type LayoutProps, - type StyleProps, - type ScrollProps, - type ComputedLayout, - type BaseProps, + UIObject, + type LayoutProps, + type StyleProps, + type ScrollProps, + type ComputedLayout, + type BaseProps, } from "./UIObject"; diff --git a/src/lib/ccTUI/layout.ts b/src/lib/ccTUI/layout.ts index 5e35b81..9b534eb 100644 --- a/src/lib/ccTUI/layout.ts +++ b/src/lib/ccTUI/layout.ts @@ -11,8 +11,8 @@ import { UIObject } from "./UIObject"; * @returns Terminal width and height */ function getTerminalSize(): { width: number; height: number } { - const [w, h] = term.getSize(); - return { width: w, height: h }; + const [w, h] = term.getSize(); + return { width: w, height: h }; } /** @@ -25,224 +25,227 @@ function getTerminalSize(): { width: number; height: number } { * @returns Width and height of the element */ function measureNode( - node: UIObject, - parentWidth?: number, - parentHeight?: number, + node: UIObject, + parentWidth?: number, + parentHeight?: number, ): { 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; - } - - // 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 ""; - }; - - // Check for explicit size styling first - let measuredWidth: number | undefined; - let measuredHeight: number | undefined; - - // Handle width styling - if (node.styleProps.width !== undefined) { - if (node.styleProps.width === "screen") { - const termSize = getTerminalSize(); - measuredWidth = termSize.width; - } else if (node.styleProps.width === "full" && parentWidth !== undefined) { - measuredWidth = parentWidth; - } else if (typeof node.styleProps.width === "number") { - measuredWidth = node.styleProps.width; - } - } - - // Handle height styling - if (node.styleProps.height !== undefined) { - if (node.styleProps.height === "screen") { - const termSize = getTerminalSize(); - measuredHeight = termSize.height; - } else if ( - node.styleProps.height === "full" && - parentHeight !== undefined - ) { - measuredHeight = parentHeight; - } else if (typeof node.styleProps.height === "number") { - measuredHeight = node.styleProps.height; - } - } - - switch (node.type) { - case "label": - case "h1": - case "h2": - case "h3": { - const text = getTextContent(); - const naturalWidth = text.length; - const naturalHeight = 1; - return { - width: measuredWidth ?? naturalWidth, - height: measuredHeight ?? naturalHeight, - }; - } - - case "button": { - const text = getTextContent(); - // Buttons have brackets around them: [text] - const naturalWidth = text.length + 2; - const naturalHeight = 1; - return { - width: measuredWidth ?? naturalWidth, - height: measuredHeight ?? naturalHeight, - }; - } - - case "input": { - const type = (node.props as InputProps).type as string | undefined; - if (type === "checkbox") { - const naturalWidth = 3; // [X] or [ ] - const naturalHeight = 1; - return { - width: measuredWidth ?? naturalWidth, - height: measuredHeight ?? naturalHeight, - }; - } - // Text input - use a default width or from props - const defaultWidth = node.props.width ?? 20; - const naturalHeight = 1; - return { - width: measuredWidth ?? defaultWidth, - height: measuredHeight ?? naturalHeight, - }; - } - - case "div": - case "form": - case "for": - case "show": - case "switch": - case "match": - case "fragment": - case "scroll-container": { - // Container elements size based on their children - let totalWidth = 0; - let totalHeight = 0; - - if (node.children.length === 0) { - const naturalWidth = 0; - const naturalHeight = 0; - return { - width: measuredWidth ?? naturalWidth, - height: measuredHeight ?? naturalHeight, - }; - } - - const direction = node.layoutProps.flexDirection ?? "row"; - const isFlex = node.type === "div" || node.type === "form"; - const gap = isFlex ? 1 : 0; - - // For scroll containers, calculate content size and update scroll bounds - if (node.type === "scroll-container" && node.scrollProps) { - // Calculate actual content size without viewport constraints - const childParentWidth = undefined; // No width constraint for content measurement - const childParentHeight = undefined; // No height constraint for content measurement - - if (direction === "row") { - for (const child of node.children) { - const childSize = measureNode( - child, - childParentWidth, - childParentHeight, - ); - totalWidth += childSize.width; - totalHeight = math.max(totalHeight, childSize.height); - } - if (node.children.length > 1) { - totalWidth += gap * (node.children.length - 1); - } - } else { - for (const child of node.children) { - const childSize = measureNode( - child, - childParentWidth, - childParentHeight, - ); - totalWidth = math.max(totalWidth, childSize.width); - totalHeight += childSize.height; - } - if (node.children.length > 1) { - totalHeight += gap * (node.children.length - 1); - } + // Get text content if it exists + const getTextContent = (): string => { + if (node.textContent !== undefined) { + if (typeof node.textContent === "function") { + return node.textContent(); + } + return node.textContent; } - // Update scroll bounds with actual content size - node.updateScrollBounds(totalWidth, totalHeight); - - // Return viewport size as the container size - return { - width: measuredWidth ?? node.scrollProps.viewportWidth, - height: measuredHeight ?? node.scrollProps.viewportHeight, - }; - } - - // Calculate available space for children (non-scroll containers) - const childParentWidth = measuredWidth ?? parentWidth; - const childParentHeight = measuredHeight ?? parentHeight; - - if (direction === "row") { - // In row direction, width is sum of children, height is max - for (const child of node.children) { - const childSize = measureNode( - child, - childParentWidth, - childParentHeight, - ); - totalWidth += childSize.width; - totalHeight = math.max(totalHeight, childSize.height); + // 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!; } - if (node.children.length > 1) { - totalWidth += gap * (node.children.length - 1); - } - } else { - // In column direction, height is sum of children, width is max - for (const child of node.children) { - const childSize = measureNode( - child, - childParentWidth, - childParentHeight, - ); - totalWidth = math.max(totalWidth, childSize.width); - totalHeight += childSize.height; - } - if (node.children.length > 1) { - totalHeight += gap * (node.children.length - 1); - } - } - return { - width: measuredWidth ?? totalWidth, - height: measuredHeight ?? totalHeight, - }; + return ""; + }; + + // Check for explicit size styling first + let measuredWidth: number | undefined; + let measuredHeight: number | undefined; + + // Handle width styling + if (node.styleProps.width !== undefined) { + if (node.styleProps.width === "screen") { + const termSize = getTerminalSize(); + measuredWidth = termSize.width; + } else if ( + node.styleProps.width === "full" && + parentWidth !== undefined + ) { + measuredWidth = parentWidth; + } else if (typeof node.styleProps.width === "number") { + measuredWidth = node.styleProps.width; + } } - default: - return { - width: measuredWidth ?? 0, - height: measuredHeight ?? 0, - }; - } + // Handle height styling + if (node.styleProps.height !== undefined) { + if (node.styleProps.height === "screen") { + const termSize = getTerminalSize(); + measuredHeight = termSize.height; + } else if ( + node.styleProps.height === "full" && + parentHeight !== undefined + ) { + measuredHeight = parentHeight; + } else if (typeof node.styleProps.height === "number") { + measuredHeight = node.styleProps.height; + } + } + + switch (node.type) { + case "label": + case "h1": + case "h2": + case "h3": { + const text = getTextContent(); + const naturalWidth = text.length; + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; + } + + case "button": { + const text = getTextContent(); + // Buttons have brackets around them: [text] + const naturalWidth = text.length + 2; + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; + } + + case "input": { + const type = (node.props as InputProps).type as string | undefined; + if (type === "checkbox") { + const naturalWidth = 3; // [X] or [ ] + const naturalHeight = 1; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; + } + // Text input - use a default width or from props + const defaultWidth = node.props.width ?? 20; + const naturalHeight = 1; + return { + width: measuredWidth ?? defaultWidth, + height: measuredHeight ?? naturalHeight, + }; + } + + case "div": + case "form": + case "for": + case "show": + case "switch": + case "match": + case "fragment": + case "scroll-container": { + // Container elements size based on their children + let totalWidth = 0; + let totalHeight = 0; + + if (node.children.length === 0) { + const naturalWidth = 0; + const naturalHeight = 0; + return { + width: measuredWidth ?? naturalWidth, + height: measuredHeight ?? naturalHeight, + }; + } + + const direction = node.layoutProps.flexDirection ?? "row"; + const isFlex = node.type === "div" || node.type === "form"; + const gap = isFlex ? 1 : 0; + + // For scroll containers, calculate content size and update scroll bounds + if (node.type === "scroll-container" && node.scrollProps) { + // Calculate actual content size without viewport constraints + const childParentWidth = undefined; // No width constraint for content measurement + const childParentHeight = undefined; // No height constraint for content measurement + + if (direction === "row") { + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth += childSize.width; + totalHeight = math.max(totalHeight, childSize.height); + } + if (node.children.length > 1) { + totalWidth += gap * (node.children.length - 1); + } + } else { + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth = math.max(totalWidth, childSize.width); + totalHeight += childSize.height; + } + if (node.children.length > 1) { + totalHeight += gap * (node.children.length - 1); + } + } + + // Update scroll bounds with actual content size + node.updateScrollBounds(totalWidth, totalHeight); + + // Return viewport size as the container size + return { + width: measuredWidth ?? node.scrollProps.viewportWidth, + height: measuredHeight ?? node.scrollProps.viewportHeight, + }; + } + + // Calculate available space for children (non-scroll containers) + const childParentWidth = measuredWidth ?? parentWidth; + const childParentHeight = measuredHeight ?? parentHeight; + + if (direction === "row") { + // In row direction, width is sum of children, height is max + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth += childSize.width; + totalHeight = math.max(totalHeight, childSize.height); + } + if (node.children.length > 1) { + totalWidth += gap * (node.children.length - 1); + } + } else { + // In column direction, height is sum of children, width is max + for (const child of node.children) { + const childSize = measureNode( + child, + childParentWidth, + childParentHeight, + ); + totalWidth = math.max(totalWidth, childSize.width); + totalHeight += childSize.height; + } + if (node.children.length > 1) { + totalHeight += gap * (node.children.length - 1); + } + } + + return { + width: measuredWidth ?? totalWidth, + height: measuredHeight ?? totalHeight, + }; + } + + default: + return { + width: measuredWidth ?? 0, + height: measuredHeight ?? 0, + }; + } } /** @@ -255,150 +258,158 @@ function measureNode( * @param startY - Starting Y position */ export function calculateLayout( - node: UIObject, - availableWidth: number, - availableHeight: number, - startX = 1, - startY = 1, + 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, - }; + // 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"; - - const isFlex = node.type === "div" || node.type === "form"; - const gap = isFlex ? 1 : 0; - - // Handle scroll container layout - if (node.type === "scroll-container" && node.scrollProps) { - // For scroll containers, position children based on scroll offset - const scrollOffsetX = -node.scrollProps.scrollX; - const scrollOffsetY = -node.scrollProps.scrollY; - - for (const child of node.children) { - // Calculate child's natural size and position it with scroll offset - const childSize = measureNode( - child, - node.scrollProps.contentWidth, - node.scrollProps.contentHeight, - ); - const childX = startX + scrollOffsetX; - const childY = startY + scrollOffsetY; - - // Recursively calculate layout for child with its natural size - calculateLayout(child, childSize.width, childSize.height, childX, childY); + if (node.children.length === 0) { + return; } - return; - } - // Measure all children - const childMeasurements = node.children.map((child: UIObject) => - measureNode(child, availableWidth, availableHeight), - ); + const direction = node.layoutProps.flexDirection ?? "row"; + const justify = node.layoutProps.justifyContent ?? "start"; + const align = node.layoutProps.alignItems ?? "start"; - // Calculate total size needed - let totalMainAxisSize = 0; - let maxCrossAxisSize = 0; + const isFlex = node.type === "div" || node.type === "form"; + const gap = isFlex ? 1 : 0; - if (direction === "row") { - for (const measure of childMeasurements) { - totalMainAxisSize += measure.width; - maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height); + // Handle scroll container layout + if (node.type === "scroll-container" && node.scrollProps) { + // For scroll containers, position children based on scroll offset + const scrollOffsetX = -node.scrollProps.scrollX; + const scrollOffsetY = -node.scrollProps.scrollY; + + for (const child of node.children) { + // Calculate child's natural size and position it with scroll offset + const childSize = measureNode( + child, + node.scrollProps.contentWidth, + node.scrollProps.contentHeight, + ); + const childX = startX + scrollOffsetX; + const childY = startY + scrollOffsetY; + + // Recursively calculate layout for child with its natural size + calculateLayout( + child, + childSize.width, + childSize.height, + childX, + childY, + ); + } + return; } - } else { - for (const measure of childMeasurements) { - totalMainAxisSize += measure.height; - maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width); - } - } - // Add gaps to total size - if (node.children.length > 1) { - totalMainAxisSize += gap * (node.children.length - 1); - } + // Measure all children + const childMeasurements = node.children.map((child: UIObject) => + measureNode(child, availableWidth, availableHeight), + ); - // 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; + // Calculate total size needed + let totalMainAxisSize = 0; + let maxCrossAxisSize = 0; 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; - if (i < node.children.length - 1) { - mainAxisPos += gap; - } + for (const measure of childMeasurements) { + totalMainAxisSize += measure.width; + maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height); + } } 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; - if (i < node.children.length - 1) { - mainAxisPos += gap; - } + for (const measure of childMeasurements) { + totalMainAxisSize += measure.height; + maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width); + } } - // Recursively calculate layout for child - calculateLayout(child, measure.width, measure.height, childX, childY); - } + // Add gaps to total size + if (node.children.length > 1) { + totalMainAxisSize += gap * (node.children.length - 1); + } + + // 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; + if (i < node.children.length - 1) { + mainAxisPos += gap; + } + } 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; + if (i < node.children.length - 1) { + mainAxisPos += gap; + } + } + + // Recursively calculate layout for child + calculateLayout(child, measure.width, measure.height, childX, childY); + } } diff --git a/src/lib/ccTUI/reactivity.ts b/src/lib/ccTUI/reactivity.ts index be57557..6d22921 100644 --- a/src/lib/ccTUI/reactivity.ts +++ b/src/lib/ccTUI/reactivity.ts @@ -36,11 +36,11 @@ const pendingEffects = new Set(); /** * 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); @@ -50,53 +50,53 @@ const pendingEffects = new Set(); * ``` */ export function createSignal(initialValue: T): Signal { - let value = initialValue; - const listeners = new Set(); + let value = initialValue; + const listeners = new Set(); - /** - * Getter function - reads the current value and subscribes the current listener - */ - const getter: Accessor = () => { - // Subscribe the current running effect/computation - if (currentListener !== undefined) { - listeners.add(currentListener); - } - return value; - }; + /** + * Getter function - reads the current value and subscribes the current listener + */ + const getter: Accessor = () => { + // 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 = (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); - } - }); - } - } - }; + /** + * Setter function - updates the value and notifies all listeners + */ + const setter: Setter = (newValue: T) => { + // Only update if value actually changed + if (value !== newValue) { + value = newValue; - return [getter, setter]; + // 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); @@ -107,30 +107,30 @@ export function createSignal(initialValue: T): Signal { * ``` */ 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; - } - }; + const effect = () => { + // Set this effect as the current listener + const prevListener = currentListener; + currentListener = effect; - // Run the effect immediately for the first time - 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(() => { @@ -140,37 +140,37 @@ export function createEffect(fn: () => void): void { * ``` */ 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); + 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"); @@ -180,11 +180,11 @@ export function batch(fn: () => void): void { * ``` */ export function createMemo(fn: () => T): Accessor { - const [value, setValue] = createSignal(undefined as unknown as T); - - createEffect(() => { - setValue(fn()); - }); - - return value; + const [value, setValue] = createSignal(undefined as unknown as T); + + createEffect(() => { + setValue(fn()); + }); + + return value; } diff --git a/src/lib/ccTUI/renderer.ts b/src/lib/ccTUI/renderer.ts index dfcce9b..30c6655 100644 --- a/src/lib/ccTUI/renderer.ts +++ b/src/lib/ccTUI/renderer.ts @@ -10,108 +10,115 @@ import { isScrollContainer } from "./scrollContainer"; * 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(); + if (node.textContent !== undefined) { + if (typeof node.textContent === "function") { + return node.textContent(); + } + 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(); + // 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 child.textContent!; - } - return ""; + return ""; } /** * Check if a position is within the visible area of all scroll container ancestors */ function isPositionVisible( - node: UIObject, - screenX: number, - screenY: number, + node: UIObject, + screenX: number, + screenY: number, ): boolean { - let current = node.parent; - while (current) { - if (isScrollContainer(current) && current.layout && current.scrollProps) { - const { x: containerX, y: containerY } = current.layout; - const { viewportWidth, viewportHeight } = current.scrollProps; + let current = node.parent; + while (current) { + if ( + isScrollContainer(current) && + current.layout && + current.scrollProps + ) { + const { x: containerX, y: containerY } = current.layout; + const { viewportWidth, viewportHeight } = current.scrollProps; - // Check if position is within the scroll container's viewport - if ( - screenX < containerX || - screenX >= containerX + viewportWidth || - screenY < containerY || - screenY >= containerY + viewportHeight - ) { - return false; - } + // Check if position is within the scroll container's viewport + if ( + screenX < containerX || + screenX >= containerX + viewportWidth || + screenY < containerY || + screenY >= containerY + viewportHeight + ) { + return false; + } + } + current = current.parent; } - current = current.parent; - } - return true; + return true; } /** * Draw a scrollbar for a scroll container */ function drawScrollbar(container: UIObject): void { - if ( - !container.layout || - !container.scrollProps || - container.scrollProps.showScrollbar === false - ) { - return; - } - - const { x, y, width, height } = container.layout; - const { scrollY, maxScrollY, viewportHeight, contentHeight } = - container.scrollProps; - - // Only draw vertical scrollbar if content is scrollable - if (maxScrollY <= 0) return; - - const scrollbarX = x + width - 1; // Position scrollbar at the right edge - const scrollbarHeight = height; - - // Calculate scrollbar thumb position and size - const thumbHeight = Math.max( - 1, - Math.floor((viewportHeight / contentHeight) * scrollbarHeight), - ); - const thumbPosition = Math.floor( - (scrollY / maxScrollY) * (scrollbarHeight - thumbHeight), - ); - - // Save current colors - const [origX, origY] = term.getCursorPos(); - - try { - // Draw scrollbar track - term.setTextColor(colors.gray); - term.setBackgroundColor(colors.lightGray); - - for (let i = 0; i < scrollbarHeight; i++) { - term.setCursorPos(scrollbarX, y + i); - if (i >= thumbPosition && i < thumbPosition + thumbHeight) { - // Draw scrollbar thumb - term.setBackgroundColor(colors.gray); - term.write(" "); - } else { - // Draw scrollbar track - term.setBackgroundColor(colors.lightGray); - term.write(" "); - } + if ( + !container.layout || + !container.scrollProps || + container.scrollProps.showScrollbar === false + ) { + return; + } + + const { x, y, width, height } = container.layout; + const { scrollY, maxScrollY, viewportHeight, contentHeight } = + container.scrollProps; + + // Only draw vertical scrollbar if content is scrollable + if (maxScrollY <= 0) return; + + const scrollbarX = x + width - 1; // Position scrollbar at the right edge + const scrollbarHeight = height; + + // Calculate scrollbar thumb position and size + const thumbHeight = Math.max( + 1, + Math.floor((viewportHeight / contentHeight) * scrollbarHeight), + ); + const thumbPosition = Math.floor( + (scrollY / maxScrollY) * (scrollbarHeight - thumbHeight), + ); + + // Save current colors + const [origX, origY] = term.getCursorPos(); + + try { + // Draw scrollbar track + term.setTextColor(colors.gray); + term.setBackgroundColor(colors.lightGray); + + for (let i = 0; i < scrollbarHeight; i++) { + term.setCursorPos(scrollbarX, y + i); + if (i >= thumbPosition && i < thumbPosition + thumbHeight) { + // Draw scrollbar thumb + term.setBackgroundColor(colors.gray); + term.write(" "); + } else { + // Draw scrollbar track + term.setBackgroundColor(colors.lightGray); + term.write(" "); + } + } + } finally { + term.setCursorPos(origX, origY); } - } finally { - term.setCursorPos(origX, origY); - } } /** @@ -122,231 +129,246 @@ function drawScrollbar(container: UIObject): void { * @param cursorBlinkState - Whether the cursor should be visible (for blinking) */ function drawNode( - node: UIObject, - focused: boolean, - cursorBlinkState: boolean, + node: UIObject, + focused: boolean, + cursorBlinkState: boolean, ): void { - if (!node.layout) return; + if (!node.layout) return; - const { x, y, width, height } = node.layout; + const { x, y, width, height } = node.layout; - // Check if this node is visible within scroll container viewports - if (!isPositionVisible(node, x, y)) { - return; - } - - // Save cursor position - const [origX, origY] = term.getCursorPos(); - - try { - // Default colors that can be overridden by styleProps - let textColor = node.styleProps.textColor; - const bgColor = node.styleProps.backgroundColor; - - switch (node.type) { - case "label": - case "h1": - case "h2": - case "h3": { - const text = getTextContent(node); - - // Set colors based on heading level (if not overridden by styleProps) - if (textColor === undefined) { - if (node.type === "h1") { - textColor = colors.yellow; - } else if (node.type === "h2") { - textColor = colors.orange; - } else if (node.type === "h3") { - textColor = colors.lightGray; - } else { - textColor = colors.white; - } - } - - term.setTextColor(textColor); - term.setBackgroundColor(bgColor ?? 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 not overridden by styleProps) - if (focused) { - term.setTextColor(textColor ?? colors.black); - term.setBackgroundColor(bgColor ?? colors.yellow); - } else { - term.setTextColor(textColor ?? colors.white); - term.setBackgroundColor(bgColor ?? colors.gray); - } - - term.setCursorPos(x, y); - term.write(`[${text}]`); - break; - } - - case "input": { - const type = (node.props as InputProps).type as string | undefined; - - if (type === "checkbox") { - // Draw checkbox - let isChecked = false; - const checkedProp = (node.props as InputProps).checked; - if (typeof checkedProp === "function") { - isChecked = checkedProp(); - } - - if (focused) { - term.setTextColor(textColor ?? colors.black); - term.setBackgroundColor(bgColor ?? colors.white); - } else { - term.setTextColor(textColor ?? colors.white); - term.setBackgroundColor(bgColor ?? colors.black); - } - - term.setCursorPos(x, y); - term.write(isChecked ? "[X]" : "[ ]"); - } else { - // Draw text input - let displayText = ""; - const valueProp = (node.props as InputProps).value; - if (typeof valueProp === "function") { - displayText = valueProp(); - } - const placeholder = (node.props as InputProps).placeholder; - const cursorPos = node.cursorPos ?? 0; - let currentTextColor = textColor; - let showPlaceholder = false; - - const focusedBgColor = bgColor ?? colors.white; - const unfocusedBgColor = bgColor ?? colors.black; - - if (displayText === "" && placeholder !== undefined && !focused) { - displayText = placeholder; - showPlaceholder = true; - currentTextColor = currentTextColor ?? colors.gray; - } else if (focused) { - currentTextColor = currentTextColor ?? colors.black; - } else { - currentTextColor = currentTextColor ?? colors.white; - } - - // Set background and clear the input area, creating a 1-character padding on the left - term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor); - term.setCursorPos(x, y); - term.write(" ".repeat(width)); - - term.setTextColor(currentTextColor); - term.setCursorPos(x + 1, y); // Position cursor for text after padding - - const renderWidth = width - 1; - const textToRender = displayText + " "; - - // Move text if it's too long for the padded area - const startDisPos = - cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0; - const stopDisPos = startDisPos + renderWidth; - - if (focused && !showPlaceholder && cursorBlinkState) { - // Draw text with a block cursor by inverting colors at the cursor position - for ( - let i = startDisPos; - i < textToRender.length && i < stopDisPos; - i++ - ) { - const char = textToRender.substring(i, i + 1); - if (i === cursorPos) { - // Invert colors for cursor - term.setBackgroundColor(currentTextColor); - term.setTextColor(focusedBgColor); - term.write(char); - // Restore colors - term.setBackgroundColor(focusedBgColor); - term.setTextColor(currentTextColor); - } else { - term.write(char); - } - } - // Draw cursor at the end of the text if applicable - if (cursorPos === textToRender.length && cursorPos < renderWidth) { - term.setBackgroundColor(currentTextColor); - term.setTextColor(focusedBgColor); - term.write(" "); - // Restore colors - term.setBackgroundColor(focusedBgColor); - term.setTextColor(currentTextColor); - } - } else { - // Not focused or no cursor, just write the text - term.write(textToRender.substring(startDisPos, stopDisPos)); - } - } - break; - } - - case "div": - case "form": - case "for": - case "show": - case "switch": - case "match": { - // Container elements may have background colors - if (bgColor !== undefined && node.layout !== undefined) { - const { - x: divX, - y: divY, - width: divWidth, - height: divHeight, - } = node.layout; - term.setBackgroundColor(bgColor); - // Fill the background area - for (let row = 0; row < divHeight; row++) { - term.setCursorPos(divX, divY + row); - term.write(string.rep(" ", divWidth)); - } - } - break; - } - - case "scroll-container": { - // Draw the scroll container background - if (bgColor !== undefined) { - term.setBackgroundColor(bgColor); - for (let row = 0; row < height; row++) { - term.setCursorPos(x, y + row); - term.write(string.rep(" ", width)); - } - } - - // Draw scrollbar after rendering children - // (This will be called after children are rendered) - break; - } - - case "fragment": { - // Fragment with text content - if (node.textContent !== undefined) { - const text = - typeof node.textContent === "function" - ? node.textContent() - : node.textContent; - - if (bgColor !== undefined) { - term.setBackgroundColor(bgColor); - } - term.setCursorPos(x, y); - term.write(text.substring(0, width)); - } - break; - } + // Check if this node is visible within scroll container viewports + if (!isPositionVisible(node, x, y)) { + return; + } + + // Save cursor position + const [origX, origY] = term.getCursorPos(); + + try { + // Default colors that can be overridden by styleProps + let textColor = node.styleProps.textColor; + const bgColor = node.styleProps.backgroundColor; + + switch (node.type) { + case "label": + case "h1": + case "h2": + case "h3": { + const text = getTextContent(node); + + // Set colors based on heading level (if not overridden by styleProps) + if (textColor === undefined) { + if (node.type === "h1") { + textColor = colors.yellow; + } else if (node.type === "h2") { + textColor = colors.orange; + } else if (node.type === "h3") { + textColor = colors.lightGray; + } else { + textColor = colors.white; + } + } + + term.setTextColor(textColor); + term.setBackgroundColor(bgColor ?? 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 not overridden by styleProps) + if (focused) { + term.setTextColor(textColor ?? colors.black); + term.setBackgroundColor(bgColor ?? colors.yellow); + } else { + term.setTextColor(textColor ?? colors.white); + term.setBackgroundColor(bgColor ?? colors.gray); + } + + term.setCursorPos(x, y); + term.write(`[${text}]`); + break; + } + + case "input": { + const type = (node.props as InputProps).type as + | string + | undefined; + + if (type === "checkbox") { + // Draw checkbox + let isChecked = false; + const checkedProp = (node.props as InputProps).checked; + if (typeof checkedProp === "function") { + isChecked = checkedProp(); + } + + if (focused) { + term.setTextColor(textColor ?? colors.black); + term.setBackgroundColor(bgColor ?? colors.white); + } else { + term.setTextColor(textColor ?? colors.white); + term.setBackgroundColor(bgColor ?? colors.black); + } + + term.setCursorPos(x, y); + term.write(isChecked ? "[X]" : "[ ]"); + } else { + // Draw text input + let displayText = ""; + const valueProp = (node.props as InputProps).value; + if (typeof valueProp === "function") { + displayText = valueProp(); + } + const placeholder = (node.props as InputProps).placeholder; + const cursorPos = node.cursorPos ?? 0; + let currentTextColor = textColor; + let showPlaceholder = false; + + const focusedBgColor = bgColor ?? colors.white; + const unfocusedBgColor = bgColor ?? colors.black; + + if ( + displayText === "" && + placeholder !== undefined && + !focused + ) { + displayText = placeholder; + showPlaceholder = true; + currentTextColor = currentTextColor ?? colors.gray; + } else if (focused) { + currentTextColor = currentTextColor ?? colors.black; + } else { + currentTextColor = currentTextColor ?? colors.white; + } + + // Set background and clear the input area, creating a 1-character padding on the left + term.setBackgroundColor( + focused ? focusedBgColor : unfocusedBgColor, + ); + term.setCursorPos(x, y); + term.write(" ".repeat(width)); + + term.setTextColor(currentTextColor); + term.setCursorPos(x + 1, y); // Position cursor for text after padding + + const renderWidth = width - 1; + const textToRender = displayText + " "; + + // Move text if it's too long for the padded area + const startDisPos = + cursorPos >= renderWidth + ? cursorPos - renderWidth + 1 + : 0; + const stopDisPos = startDisPos + renderWidth; + + if (focused && !showPlaceholder && cursorBlinkState) { + // Draw text with a block cursor by inverting colors at the cursor position + for ( + let i = startDisPos; + i < textToRender.length && i < stopDisPos; + i++ + ) { + const char = textToRender.substring(i, i + 1); + if (i === cursorPos) { + // Invert colors for cursor + term.setBackgroundColor(currentTextColor); + term.setTextColor(focusedBgColor); + term.write(char); + // Restore colors + term.setBackgroundColor(focusedBgColor); + term.setTextColor(currentTextColor); + } else { + term.write(char); + } + } + // Draw cursor at the end of the text if applicable + if ( + cursorPos === textToRender.length && + cursorPos < renderWidth + ) { + term.setBackgroundColor(currentTextColor); + term.setTextColor(focusedBgColor); + term.write(" "); + // Restore colors + term.setBackgroundColor(focusedBgColor); + term.setTextColor(currentTextColor); + } + } else { + // Not focused or no cursor, just write the text + term.write( + textToRender.substring(startDisPos, stopDisPos), + ); + } + } + break; + } + + case "div": + case "form": + case "for": + case "show": + case "switch": + case "match": { + // Container elements may have background colors + if (bgColor !== undefined && node.layout !== undefined) { + const { + x: divX, + y: divY, + width: divWidth, + height: divHeight, + } = node.layout; + term.setBackgroundColor(bgColor); + // Fill the background area + for (let row = 0; row < divHeight; row++) { + term.setCursorPos(divX, divY + row); + term.write(string.rep(" ", divWidth)); + } + } + break; + } + + case "scroll-container": { + // Draw the scroll container background + if (bgColor !== undefined) { + term.setBackgroundColor(bgColor); + for (let row = 0; row < height; row++) { + term.setCursorPos(x, y + row); + term.write(string.rep(" ", width)); + } + } + + // Draw scrollbar after rendering children + // (This will be called after children are rendered) + break; + } + + case "fragment": { + // Fragment with text content + if (node.textContent !== undefined) { + const text = + typeof node.textContent === "function" + ? node.textContent() + : node.textContent; + + if (bgColor !== undefined) { + term.setBackgroundColor(bgColor); + } + term.setCursorPos(x, y); + term.write(text.substring(0, width)); + } + break; + } + } + } finally { + // Restore cursor + term.setCursorPos(origX, origY); } - } finally { - // Restore cursor - term.setCursorPos(origX, origY); - } } /** @@ -357,36 +379,36 @@ function drawNode( * @param cursorBlinkState - Whether the cursor should be visible (for blinking) */ export function render( - node: UIObject, - focusedNode?: UIObject, - cursorBlinkState = false, + node: UIObject, + focusedNode?: UIObject, + cursorBlinkState = false, ): void { - // Draw this node - const isFocused = node === focusedNode; - drawNode(node, isFocused, cursorBlinkState); + // Draw this node + const isFocused = node === focusedNode; + drawNode(node, isFocused, cursorBlinkState); - // For scroll containers, set up clipping region before rendering children - if (isScrollContainer(node) && node.layout && node.scrollProps) { - // Recursively draw children (they will be clipped by visibility checks) - for (const child of node.children) { - render(child, focusedNode, cursorBlinkState); - } + // For scroll containers, set up clipping region before rendering children + if (isScrollContainer(node) && node.layout && node.scrollProps) { + // Recursively draw children (they will be clipped by visibility checks) + for (const child of node.children) { + render(child, focusedNode, cursorBlinkState); + } - // Draw scrollbar after children - drawScrollbar(node); - } else { - // Recursively draw children normally - for (const child of node.children) { - render(child, focusedNode, cursorBlinkState); + // Draw scrollbar after children + drawScrollbar(node); + } else { + // Recursively draw children normally + for (const child of node.children) { + render(child, focusedNode, cursorBlinkState); + } } - } } /** * Clear the entire terminal screen */ export function clearScreen(): void { - term.setBackgroundColor(colors.black); - term.clear(); - term.setCursorPos(1, 1); + term.setBackgroundColor(colors.black); + term.clear(); + term.setCursorPos(1, 1); } diff --git a/src/lib/ccTUI/scrollContainer.ts b/src/lib/ccTUI/scrollContainer.ts index 9af5893..c1101e3 100644 --- a/src/lib/ccTUI/scrollContainer.ts +++ b/src/lib/ccTUI/scrollContainer.ts @@ -9,16 +9,16 @@ import { createSignal, createEffect } from "./reactivity"; * Props for ScrollContainer component */ export type ScrollContainerProps = { - /** Maximum width of the scroll container viewport */ - width?: number; - /** Maximum height of the scroll container viewport */ - height?: number; - /** Whether to show scrollbars (default: true) */ - showScrollbar?: boolean; - /** CSS-like class names for styling */ - class?: string; - /** Callback when scroll position changes */ - onScroll?: (scrollX: number, scrollY: number) => void; + /** Maximum width of the scroll container viewport */ + width?: number; + /** Maximum height of the scroll container viewport */ + height?: number; + /** Whether to show scrollbars (default: true) */ + showScrollbar?: boolean; + /** CSS-like class names for styling */ + class?: string; + /** Callback when scroll position changes */ + onScroll?: (scrollX: number, scrollY: number) => void; } & Record; /** @@ -44,69 +44,69 @@ export type ScrollContainerProps = { * ``` */ export function ScrollContainer( - props: ScrollContainerProps, - content: UIObject, + props: ScrollContainerProps, + content: UIObject, ): UIObject { - const container = new UIObject("scroll-container", props, [content]); - content.parent = container; + const container = new UIObject("scroll-container", props, [content]); + content.parent = container; - // Set up scroll properties from props - if (container.scrollProps) { - container.scrollProps.viewportWidth = props.width ?? 10; - container.scrollProps.viewportHeight = props.height ?? 10; - container.scrollProps.showScrollbar = props.showScrollbar !== false; - } - - // Create reactive signals for scroll position - const [scrollX, setScrollX] = createSignal(0); - const [scrollY, setScrollY] = createSignal(0); - - // Update scroll position when signals change - createEffect(() => { - const x = scrollX(); - const y = scrollY(); - container.scrollTo(x, y); - - // Call onScroll callback if provided - if (props.onScroll && typeof props.onScroll === "function") { - props.onScroll(x, y); - } - }); - - // Override scroll methods to update signals - const originalScrollBy = container.scrollBy.bind(container); - const originalScrollTo = container.scrollTo.bind(container); - - container.scrollBy = (deltaX: number, deltaY: number): void => { - originalScrollBy(deltaX, deltaY); + // Set up scroll properties from props if (container.scrollProps) { - setScrollX(container.scrollProps.scrollX); - setScrollY(container.scrollProps.scrollY); + container.scrollProps.viewportWidth = props.width ?? 10; + container.scrollProps.viewportHeight = props.height ?? 10; + container.scrollProps.showScrollbar = props.showScrollbar !== false; } - }; - container.scrollTo = (x: number, y: number): void => { - originalScrollTo(x, y); - if (container.scrollProps) { - setScrollX(container.scrollProps.scrollX); - setScrollY(container.scrollProps.scrollY); - } - }; + // Create reactive signals for scroll position + const [scrollX, setScrollX] = createSignal(0); + const [scrollY, setScrollY] = createSignal(0); - // Expose scroll control methods on the container - const containerWithMethods = container as UIObject & { - getScrollX: () => number; - getScrollY: () => number; - setScrollX: (value: number) => void; - setScrollY: (value: number) => void; - }; + // Update scroll position when signals change + createEffect(() => { + const x = scrollX(); + const y = scrollY(); + container.scrollTo(x, y); - containerWithMethods.getScrollX = () => scrollX(); - containerWithMethods.getScrollY = () => scrollY(); - containerWithMethods.setScrollX = (value: number) => setScrollX(value); - containerWithMethods.setScrollY = (value: number) => setScrollY(value); + // Call onScroll callback if provided + if (props.onScroll && typeof props.onScroll === "function") { + props.onScroll(x, y); + } + }); - return container; + // Override scroll methods to update signals + const originalScrollBy = container.scrollBy.bind(container); + const originalScrollTo = container.scrollTo.bind(container); + + container.scrollBy = (deltaX: number, deltaY: number): void => { + originalScrollBy(deltaX, deltaY); + if (container.scrollProps) { + setScrollX(container.scrollProps.scrollX); + setScrollY(container.scrollProps.scrollY); + } + }; + + container.scrollTo = (x: number, y: number): void => { + originalScrollTo(x, y); + if (container.scrollProps) { + setScrollX(container.scrollProps.scrollX); + setScrollY(container.scrollProps.scrollY); + } + }; + + // Expose scroll control methods on the container + const containerWithMethods = container as UIObject & { + getScrollX: () => number; + getScrollY: () => number; + setScrollX: (value: number) => void; + setScrollY: (value: number) => void; + }; + + containerWithMethods.getScrollX = () => scrollX(); + containerWithMethods.getScrollY = () => scrollY(); + containerWithMethods.setScrollX = (value: number) => setScrollX(value); + containerWithMethods.setScrollY = (value: number) => setScrollY(value); + + return container; } /** @@ -115,7 +115,7 @@ export function ScrollContainer( * @returns True if the node is a scroll container */ export function isScrollContainer(node: UIObject): boolean { - return node.type === "scroll-container"; + return node.type === "scroll-container"; } /** @@ -124,14 +124,14 @@ export function isScrollContainer(node: UIObject): boolean { * @returns The nearest scroll container, or undefined if none found */ export function findScrollContainer(node: UIObject): UIObject | undefined { - let current = node.parent; - while (current) { - if (isScrollContainer(current)) { - return current; + let current = node.parent; + while (current) { + if (isScrollContainer(current)) { + return current; + } + current = current.parent; } - current = current.parent; - } - return undefined; + return undefined; } /** @@ -142,23 +142,23 @@ export function findScrollContainer(node: UIObject): UIObject | undefined { * @returns True if the point is visible */ export function isPointVisible( - container: UIObject, - x: number, - y: number, + container: UIObject, + x: number, + y: number, ): boolean { - if (!isScrollContainer(container) || !container.scrollProps) { - return true; - } + if (!isScrollContainer(container) || !container.scrollProps) { + return true; + } - const { scrollX, scrollY, viewportWidth, viewportHeight } = - container.scrollProps; + const { scrollX, scrollY, viewportWidth, viewportHeight } = + container.scrollProps; - return ( - x >= scrollX && - x < scrollX + viewportWidth && - y >= scrollY && - y < scrollY + viewportHeight - ); + return ( + x >= scrollX && + x < scrollX + viewportWidth && + y >= scrollY && + y < scrollY + viewportHeight + ); } /** @@ -169,36 +169,36 @@ export function isPointVisible( * @returns Content coordinates, or undefined if not within container */ export function screenToContent( - container: UIObject, - screenX: number, - screenY: number, + container: UIObject, + screenX: number, + screenY: number, ): { x: number; y: number } | undefined { - if ( - !isScrollContainer(container) || - !container.layout || - !container.scrollProps - ) { - return undefined; - } + if ( + !isScrollContainer(container) || + !container.layout || + !container.scrollProps + ) { + return undefined; + } - const { x: containerX, y: containerY } = container.layout; - const { scrollX, scrollY } = container.scrollProps; + const { x: containerX, y: containerY } = container.layout; + const { scrollX, scrollY } = container.scrollProps; - // Check if point is within container bounds - const relativeX = screenX - containerX; - const relativeY = screenY - containerY; + // Check if point is within container bounds + const relativeX = screenX - containerX; + const relativeY = screenY - containerY; - if ( - relativeX < 0 || - relativeY < 0 || - relativeX >= container.scrollProps.viewportWidth || - relativeY >= container.scrollProps.viewportHeight - ) { - return undefined; - } + if ( + relativeX < 0 || + relativeY < 0 || + relativeX >= container.scrollProps.viewportWidth || + relativeY >= container.scrollProps.viewportHeight + ) { + return undefined; + } - return { - x: relativeX + scrollX, - y: relativeY + scrollY, - }; + return { + x: relativeX + scrollX, + y: relativeY + scrollY, + }; } diff --git a/src/lib/ccTUI/store.ts b/src/lib/ccTUI/store.ts index 835a2fe..392a612 100644 --- a/src/lib/ccTUI/store.ts +++ b/src/lib/ccTUI/store.ts @@ -9,104 +9,109 @@ import { createSignal, Accessor } from "./reactivity"; * Store setter function type */ export interface SetStoreFunction { - /** - * Set a specific property or array index - */ - (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; + /** + * Set a specific property or array index + */ + (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([]); - * + * * // 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(initialValue: T): [Accessor, SetStoreFunction] { - // Use a signal to track the entire state - const [get, set] = createSignal(initialValue); - - /** - * Setter function with multiple overloads - */ - const setStore: SetStoreFunction = ((...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 }; +export function createStore( + initialValue: T, +): [Accessor, SetStoreFunction] { + // Use a signal to track the entire state + const [get, set] = createSignal(initialValue); + + /** + * Setter function with multiple overloads + */ + const setStore: SetStoreFunction = ((...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); + } } - set(newArray as T); - } - } - }) as SetStoreFunction; - - return [get, setStore]; + }) as SetStoreFunction; + + 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]); @@ -114,12 +119,12 @@ export function createStore(initialValue: T): [Accessor, Se * ``` */ export function removeIndex(array: T[], index: number): T[] { - return [...array.slice(0, index), ...array.slice(index + 1)]; + 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 @@ -127,5 +132,5 @@ export function removeIndex(array: T[], index: number): T[] { * @returns A new array with the item inserted */ export function insertAt(array: T[], index: number, item: T): T[] { - return [...array.slice(0, index), item, ...array.slice(index)]; + return [...array.slice(0, index), item, ...array.slice(index)]; } diff --git a/src/lib/ccTUI/utils.ts b/src/lib/ccTUI/utils.ts deleted file mode 100644 index e69de29..0000000