Compare commits

...

3 Commits

Author SHA1 Message Date
SikongJueluo
66b46c6d70 refactor(logging): migrate from CCLog to structured logger 2025-11-21 21:15:52 +08:00
SikongJueluo
de97fb4858 refactor(logger): simplify logger configuration and improve file rotation 2025-11-21 21:15:06 +08:00
SikongJueluo
0612477325 feat(logging): add auto-cleanup functionality to FileStream 2025-11-21 15:58:42 +08:00
22 changed files with 3745 additions and 3400 deletions

View File

@@ -1,400 +1,411 @@
import { Command, createCli } from "@/lib/ccCLI"; import { Command, createCli } from "@/lib/ccCLI";
import { Ok } from "@/lib/thirdparty/ts-result-es"; import { Ok } from "@/lib/thirdparty/ts-result-es";
import { CCLog } from "@/lib/ccLog";
import { import {
AccessConfig, AccessConfig,
UserGroupConfig, UserGroupConfig,
loadConfig, loadConfig,
saveConfig, saveConfig,
} from "./config"; } from "./config";
import { parseBoolean } from "@/lib/common"; import { parseBoolean } from "@/lib/common";
import { Logger } from "@/lib/ccStructLog";
// 1. Define AppContext // 1. Define AppContext
export interface AppContext { export interface AppContext {
configFilepath: string; configFilepath: string;
reloadConfig: () => void; reloadConfig: () => void;
logger: CCLog; logger: Logger;
print: ( print: (
message: string | MinecraftTextComponent | MinecraftTextComponent[], message: string | MinecraftTextComponent | MinecraftTextComponent[],
) => void; ) => void;
} }
function getGroupNames(config: AccessConfig) { function getGroupNames(config: AccessConfig) {
return config.usersGroups.map((value) => value.groupName); return config.usersGroups.map((value) => value.groupName);
} }
// 2. Define Commands // 2. Define Commands
const addCommand: Command<AppContext> = { const addCommand: Command<AppContext> = {
name: "add", name: "add",
description: "添加玩家到用户组", description: "添加玩家到用户组",
args: [ args: [
{ {
name: "userGroup", name: "userGroup",
description: "要添加到的用户组", description: "要添加到的用户组",
required: true, required: true,
}, },
{ name: "playerName", description: "要添加的玩家", required: true }, { name: "playerName", description: "要添加的玩家", required: true },
], ],
action: ({ args, context }) => { action: ({ args, context }) => {
const [groupName, playerName] = [ const [groupName, playerName] = [
args.userGroup as string, args.userGroup as string,
args.playerName as string, args.playerName as string,
]; ];
const config = loadConfig(context.configFilepath)!; const config = loadConfig(context.configFilepath)!;
if (groupName === "admin") { if (groupName === "admin") {
if (!config.adminGroupConfig.groupUsers.includes(playerName)) { if (!config.adminGroupConfig.groupUsers.includes(playerName)) {
config.adminGroupConfig.groupUsers.push(playerName); config.adminGroupConfig.groupUsers.push(playerName);
} }
} else { } else {
const group = config.usersGroups.find((g) => g.groupName === groupName); const group = config.usersGroups.find(
if (!group) { (g) => g.groupName === groupName,
const groupNames = getGroupNames(config); );
context.print({ if (!group) {
text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join( 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; 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<AppContext> = { const delCommand: Command<AppContext> = {
name: "del", name: "del",
description: "从用户组删除玩家", description: "从用户组删除玩家",
args: [ args: [
{ {
name: "userGroup", name: "userGroup",
description: "要从中删除玩家的用户组", description: "要从中删除玩家的用户组",
required: true, required: true,
}, },
{ name: "playerName", description: "要删除的玩家", required: true }, { name: "playerName", description: "要删除的玩家", required: true },
], ],
action: ({ args, context }) => { action: ({ args, context }) => {
const [groupName, playerName] = [ const [groupName, playerName] = [
args.userGroup as string, args.userGroup as string,
args.playerName as string, args.playerName as string,
]; ];
if (groupName === "admin") { if (groupName === "admin") {
context.print({ text: "无法删除管理员, 请直接编辑配置文件。" }); context.print({ text: "无法删除管理员, 请直接编辑配置文件。" });
return Ok.EMPTY; 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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
name: "config",
description: "配置访问控制设置",
args: [
{
name: "option",
description:
"要设置的选项 (warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn) 或用户组属性 (<groupName>.isAllowed, <groupName>.isNotice, <groupName>.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) { const config = loadConfig(context.configFilepath)!;
case "isWelcome": const group = config.usersGroups.find((g) => g.groupName === groupName);
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)) { if (!group) {
context.print({ text: `无效的值: ${valueStr}. 必须是一个数字。` }); const groupNames = getGroupNames(config);
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({ context.print({
text: `未知选项: ${option}. 可用选项: warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn 或 <groupName>.isAllowed, <groupName>.isNotice, <groupName>.isWelcome`, text: `无效的用户组: ${groupName}. 可用用户组: ${groupNames.join(
", ",
)}`,
}); });
return Ok.EMPTY; return Ok.EMPTY;
} }
}
saveConfig(config, context.configFilepath); if (group.groupUsers !== undefined) {
context.reloadConfig(); group.groupUsers = group.groupUsers.filter(
context.print({ text: message }); (user) => user !== playerName,
return Ok.EMPTY; );
} }
},
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: `已从 ${groupName} 中删除玩家 ${playerName}` });
return Ok.EMPTY;
},
};
const listUserCommand: Command<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
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<AppContext> = {
name: "config",
description: "配置访问控制设置",
args: [
{
name: "option",
description:
"要设置的选项 (warnInterval, detectInterval, detectRange, noticeTimes, isWelcome, isWarn) 或用户组属性 (<groupName>.isAllowed, <groupName>.isNotice, <groupName>.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 或 <groupName>.isAllowed, <groupName>.isNotice, <groupName>.isWelcome`,
});
return Ok.EMPTY;
}
}
saveConfig(config, context.configFilepath);
context.reloadConfig();
context.print({ text: message });
return Ok.EMPTY;
}
},
}; };
// Root command // Root command
const rootCommand: Command<AppContext> = { const rootCommand: Command<AppContext> = {
name: "@AC", name: "@AC",
description: "访问控制命令行界面", description: "访问控制命令行界面",
subcommands: new Map([ subcommands: new Map([
["add", addCommand], ["add", addCommand],
["del", delCommand], ["del", delCommand],
["list", listCommand], ["list", listCommand],
["config", configCommand], ["config", configCommand],
]), ]),
action: ({ context }) => { action: ({ context }) => {
context.print([ context.print([
{ {
text: "请使用 ", text: "请使用 ",
}, },
{ {
text: "@AC --help", text: "@AC --help",
clickEvent: { clickEvent: {
action: "copy_to_clipboard", action: "copy_to_clipboard",
value: "@AC --help", value: "@AC --help",
}, },
hoverEvent: { hoverEvent: {
action: "show_text", action: "show_text",
value: "点击复制命令", value: "点击复制命令",
}, },
}, },
{ {
text: " 获取门禁系统更详细的命令说明😊😊😊", text: " 获取门禁系统更详细的命令说明😊😊😊",
}, },
]); ]);
return Ok.EMPTY; return Ok.EMPTY;
}, },
}; };
export function createAccessControlCli(context: AppContext) { export function createAccessControlCli(context: AppContext) {
return createCli(rootCommand, { return createCli(rootCommand, {
globalContext: context, globalContext: context,
writer: (msg) => context.print({ text: msg }), writer: (msg) => context.print({ text: msg }),
}); });
} }

View File

@@ -1,177 +1,177 @@
// import * as dkjson from "@sikongjueluo/dkjson-types"; // import * as dkjson from "@sikongjueluo/dkjson-types";
interface ToastConfig { interface ToastConfig {
title: MinecraftTextComponent; title: MinecraftTextComponent;
msg: MinecraftTextComponent; msg: MinecraftTextComponent;
prefix?: string; prefix?: string;
brackets?: string; brackets?: string;
bracketColor?: string; bracketColor?: string;
} }
interface UserGroupConfig { interface UserGroupConfig {
groupName: string; groupName: string;
isAllowed: boolean; isAllowed: boolean;
isNotice: boolean; isNotice: boolean;
isWelcome: boolean; isWelcome: boolean;
groupUsers: string[]; groupUsers: string[];
} }
interface AccessConfig { interface AccessConfig {
detectInterval: number; detectInterval: number;
watchInterval: number; watchInterval: number;
noticeTimes: number; noticeTimes: number;
detectRange: number; detectRange: number;
isWelcome: boolean; isWelcome: boolean;
isWarn: boolean; isWarn: boolean;
adminGroupConfig: UserGroupConfig; adminGroupConfig: UserGroupConfig;
welcomeToastConfig: ToastConfig; welcomeToastConfig: ToastConfig;
warnToastConfig: ToastConfig; warnToastConfig: ToastConfig;
noticeToastConfig: ToastConfig; noticeToastConfig: ToastConfig;
usersGroups: UserGroupConfig[]; usersGroups: UserGroupConfig[];
} }
const defaultConfig: AccessConfig = { const defaultConfig: AccessConfig = {
detectRange: 256, detectRange: 256,
detectInterval: 1, detectInterval: 1,
watchInterval: 10, watchInterval: 10,
noticeTimes: 2, noticeTimes: 2,
isWarn: false, isWarn: false,
isWelcome: true, isWelcome: true,
adminGroupConfig: { adminGroupConfig: {
groupName: "Admin", groupName: "Admin",
groupUsers: ["Selcon"], groupUsers: ["Selcon"],
isAllowed: true, isAllowed: true,
isNotice: true, isNotice: true,
isWelcome: false, isWelcome: false,
},
usersGroups: [
{
groupName: "user",
groupUsers: [],
isAllowed: true,
isNotice: true,
isWelcome: false,
}, },
{ usersGroups: [
groupName: "TU", {
groupUsers: [], groupName: "user",
isAllowed: true, groupUsers: [],
isNotice: false, isAllowed: true,
isWelcome: false, 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: "",
}, },
{ noticeToastConfig: {
groupName: "VIP", title: {
groupUsers: [], text: "警告",
isAllowed: true, color: "red",
isNotice: false, },
isWelcome: true, msg: {
text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%",
color: "red",
},
prefix: "桃源星",
brackets: "<>",
bracketColor: "",
}, },
{ warnToastConfig: {
groupName: "enemies", title: {
groupUsers: [], text: "注意",
isAllowed: false, color: "red",
isNotice: false, },
isWelcome: false, 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( function loadConfig(
filepath: string, filepath: string,
useDefault = true, useDefault = true,
): AccessConfig | undefined { ): AccessConfig | undefined {
const [fp] = io.open(filepath, "r"); const [fp] = io.open(filepath, "r");
if (fp == undefined) { if (fp == undefined) {
if (useDefault === false) return undefined; if (useDefault === false) return undefined;
print("Failed to open config file " + filepath); print("Failed to open config file " + filepath);
print("Use default config"); print("Use default config");
saveConfig(defaultConfig, filepath); saveConfig(defaultConfig, filepath);
return defaultConfig; return defaultConfig;
} }
const configJson = fp.read("*a"); const configJson = fp.read("*a");
if (configJson == undefined) { if (configJson == undefined) {
if (useDefault === false) return undefined; if (useDefault === false) return undefined;
print("Failed to read config file"); print("Failed to read config file");
print("Use default config"); print("Use default config");
saveConfig(defaultConfig, filepath); saveConfig(defaultConfig, filepath);
return defaultConfig; return defaultConfig;
} }
// const [config, pos, err] = dkjson.decode(configJson); // const [config, pos, err] = dkjson.decode(configJson);
// if (config == undefined) { // if (config == undefined) {
// log?.warn( // log?.warn(
// `Config decode failed at ${pos}, use default instead. Error :${err}`, // `Config decode failed at ${pos}, use default instead. Error :${err}`,
// ); // );
// return defaultConfig; // return defaultConfig;
// } // }
// Not use external lib // Not use external lib
const config = textutils.unserialiseJSON(configJson, { const config = textutils.unserialiseJSON(configJson, {
parse_empty_array: true, parse_empty_array: true,
}); });
return config as AccessConfig; return config as AccessConfig;
} }
function saveConfig(config: AccessConfig, filepath: string) { function saveConfig(config: AccessConfig, filepath: string) {
// const configJson = dkjson.encode(config, { indent: true }) as string; // const configJson = dkjson.encode(config, { indent: true }) as string;
// Not use external lib // Not use external lib
const configJson = textutils.serializeJSON(config, { const configJson = textutils.serializeJSON(config, {
allow_repetitions: true, allow_repetitions: true,
unicode_strings: true, unicode_strings: true,
}); });
if (configJson == undefined) { if (configJson == undefined) {
print("Failed to save config"); print("Failed to save config");
} }
const [fp, _err] = io.open(filepath, "w+"); const [fp, _err] = io.open(filepath, "w+");
if (fp == undefined) { if (fp == undefined) {
print("Failed to open config file " + filepath); print("Failed to open config file " + filepath);
return; return;
} }
fp.write(configJson); fp.write(configJson);
fp.close(); fp.close();
} }
export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig }; export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig };

View File

@@ -11,9 +11,12 @@ import {
ConsoleStream, ConsoleStream,
DAY, DAY,
FileStream, FileStream,
Logger, getStructLogger,
LoggerOptions,
LogLevel, LogLevel,
MB,
processor, processor,
setStructLoggerConfig,
textRenderer, textRenderer,
} from "@/lib/ccStructLog"; } from "@/lib/ccStructLog";
@@ -21,7 +24,7 @@ const args = [...$vararg];
// Init Log // Init Log
let isOnConsoleStream = true; let isOnConsoleStream = true;
const logger = new Logger({ const loggerConfig: LoggerOptions = {
processors: [ processors: [
processor.filterByLevel(LogLevel.Info), processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(), processor.addTimestamp(),
@@ -29,9 +32,19 @@ const logger = new Logger({
renderer: textRenderer, renderer: textRenderer,
streams: [ streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream), new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream("accesscontrol.log", DAY), new FileStream({
filePath: "accesscontrol.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 7,
maxSizeBytes: MB,
},
}),
], ],
}); };
setStructLoggerConfig(loggerConfig);
const logger = getStructLogger();
// Load Config // Load Config
const configFilepath = `${shell.dir()}/access.config.json`; const configFilepath = `${shell.dir()}/access.config.json`;
@@ -70,6 +83,11 @@ function reloadConfig() {
gWatchPlayersInfo = []; gWatchPlayersInfo = [];
releaser.release(); releaser.release();
logger.info("Reload config successfully!"); 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( function safeParseTextComponent(

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,8 @@ import {
import { Queue } from "@/lib/datatype/Queue"; import { Queue } from "@/lib/datatype/Queue";
import { import {
ConsoleStream, ConsoleStream,
DAY,
FileStream,
Logger, Logger,
LogLevel, LogLevel,
processor, processor,
@@ -18,7 +20,17 @@ const logger = new Logger({
processor.addTimestamp(), processor.addTimestamp(),
], ],
renderer: textRenderer, renderer: textRenderer,
streams: [new ConsoleStream()], streams: [
new ConsoleStream(),
new FileStream({
filePath: "autocraft.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 3,
},
}),
],
}); });
const peripheralsNames = { const peripheralsNames = {
@@ -47,7 +59,8 @@ enum State {
} }
function main() { function main() {
while (true) { let isFinishedInitPeripheral = false;
while (!isFinishedInitPeripheral) {
try { try {
packsInventory = peripheral.wrap( packsInventory = peripheral.wrap(
peripheralsNames.packsInventory, peripheralsNames.packsInventory,
@@ -67,7 +80,7 @@ function main() {
turtleLocalName = wiredModem.getNameLocal(); turtleLocalName = wiredModem.getNameLocal();
logger.info("Peripheral initialization complete..."); logger.info("Peripheral initialization complete...");
break; isFinishedInitPeripheral = true;
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`, `Peripheral initialization failed for ${String(error)}, try again...`,

View File

@@ -4,6 +4,9 @@
*/ */
import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types"; import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
import { processor } from "./processors";
import { ConsoleStream } from "./streams";
import { textRenderer } from "./renderers";
/** /**
* The main Logger class that orchestrates the logging pipeline. * The main Logger class that orchestrates the logging pipeline.
@@ -13,26 +16,19 @@ import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
*/ */
export class Logger implements ILogger { export class Logger implements ILogger {
private options: LoggerOptions; private options: LoggerOptions;
private loggerName?: string;
/** /**
* Create a new Logger instance. * Create a new Logger instance.
* *
* @param options - Configuration options for the logger * @param options - Configuration options for the logger
* @param name - The name of the logger
*/ */
constructor(options: Partial<LoggerOptions>) { constructor(options: LoggerOptions, name?: string) {
this.options = { this.options = options;
processors: options.processors ?? [], this.loggerName = name;
renderer: options.renderer ?? this.defaultRenderer,
streams: options.streams ?? [],
};
} }
/**
* Default renderer that returns an empty string.
* Used as fallback when no renderer is provided.
*/
private defaultRenderer = (): string => "";
/** /**
* Main logging method that handles the complete logging pipeline. * Main logging method that handles the complete logging pipeline.
* *
@@ -51,6 +47,8 @@ export class Logger implements ILogger {
["message", message], ["message", message],
...Object.entries(context), ...Object.entries(context),
]); ]);
if (this.loggerName !== undefined)
event.set("loggerName", this.loggerName);
// 2. Process through the processor chain // 2. Process through the processor chain
for (const processor of this.options.processors) { for (const processor of this.options.processors) {
@@ -62,12 +60,11 @@ export class Logger implements ILogger {
// 3. Render and output if event wasn't dropped // 3. Render and output if event wasn't dropped
if (event !== undefined) { if (event !== undefined) {
const finalEvent = event; const output = this.options.renderer(event);
const output = this.options.renderer(finalEvent);
// Send to all configured streams // Send to all configured streams
for (const stream of this.options.streams) { for (const stream of this.options.streams) {
stream.write(output, finalEvent); stream.write(output, event);
} }
} }
} }
@@ -163,3 +160,17 @@ export class Logger implements ILogger {
} }
} }
} }
let globalLoggerConfig: LoggerOptions = {
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [new ConsoleStream()],
};
export function getStructLogger(name?: string): Logger {
return new Logger(globalLoggerConfig, name);
}
export function setStructLoggerConfig(config: LoggerOptions): void {
globalLoggerConfig = config;
}

View File

@@ -61,7 +61,7 @@ export namespace processor {
return event; // Pass through if no level is set return event; // Pass through if no level is set
} }
if (eventLevel !== undefined && eventLevel < minLevel) { if (eventLevel < minLevel) {
return undefined; // Drop the log event return undefined; // Drop the log event
} }
@@ -69,22 +69,6 @@ export namespace processor {
}; };
} }
/**
* Adds a logger name/source to the log event.
*
* This processor is useful when you have multiple loggers in your application
* and want to identify which component generated each log entry.
*
* @param name - The name/source to add to log events
* @returns A processor function that adds the source name
*/
export function addSource(name: string): Processor {
return (event) => {
event.set("source", name);
return event;
};
}
/** /**
* Adds the current computer ID to the log event. * Adds the current computer ID to the log event.
* *

View File

@@ -53,14 +53,20 @@ export const textRenderer: Renderer = (event) => {
const timeStr = event.get("timestamp") as string | undefined; const timeStr = event.get("timestamp") as string | undefined;
const level: string | undefined = LogLevel[event.get("level") as LogLevel]; const level: string | undefined = LogLevel[event.get("level") as LogLevel];
const message = (event.get("message") as string) ?? ""; const message = (event.get("message") as string) ?? "";
const loggerName = event.get("loggerName") as string | undefined;
// Start building the output // Start building the output
let output = `[${timeStr}] [${level}] ${message} \t`; let output = `${timeStr} [${level}] ${message} \t ${loggerName !== undefined ? "[" + loggerName + "]" : ""}`;
// Add context fields (excluding the core fields we already used) // Add context fields (excluding the core fields we already used)
const contextFields: string[] = []; const contextFields: string[] = [];
for (const [key, value] of event.entries()) { for (const [key, value] of event.entries()) {
if (key !== "timestamp" && key !== "level" && key !== "message") { if (
key !== "timestamp" &&
key !== "level" &&
key !== "message" &&
key !== "loggerName"
) {
contextFields.push(`${key}=${tostring(value)}`); contextFields.push(`${key}=${tostring(value)}`);
} }
} }

View File

@@ -8,6 +8,33 @@
import { LogLevel, Stream, LogEvent } from "./types"; import { LogLevel, Stream, LogEvent } from "./types";
/**
* Configuration interface for FileStream with auto-cleanup options.
*/
interface FileStreamConfig {
/** Path to the log file */
filePath: string;
/**
* Time in seconds between file rotations (0 = no rotation)
* Time must larger than one DAY
* @default 0
*/
rotationInterval?: number;
/** Auto-cleanup configuration */
autoCleanup?: {
/** Whether to enable auto-cleanup */
enabled: boolean;
/** Maximum number of log files to keep */
maxFiles?: number;
/** Maximum total size in bytes for all log files */
maxSizeBytes?: number;
/** Directory to search for log files (defaults to log file directory) */
logDir?: string;
/** File pattern to match (defaults to base filename pattern) */
pattern?: string;
};
}
/** /**
* Console stream that outputs to the CC:Tweaked terminal. * Console stream that outputs to the CC:Tweaked terminal.
* *
@@ -59,20 +86,20 @@ export class FileStream implements Stream {
private filePath: string; private filePath: string;
private rotationInterval: number; private rotationInterval: number;
private lastRotationTime: number; private lastRotationTime: number;
private baseFilename: string; private autoCleanupConfig?: FileStreamConfig["autoCleanup"];
/** /**
* Create a new file stream. * Create a new file stream with configuration object.
* *
* @param filePath - Path to the log file * @param config - FileStream configuration object
* @param rotationInterval - Time in seconds between file rotations (0 = no rotation)
*/ */
constructor(filePath: string, rotationInterval: number = 0) { constructor(config: FileStreamConfig) {
this.filePath = filePath; this.filePath = config.filePath;
this.rotationInterval = rotationInterval; this.rotationInterval = config.rotationInterval || 0;
if (this.rotationInterval !== 0 && this.rotationInterval < DAY)
throw Error("Rotation interval must be at least one day");
this.autoCleanupConfig = config.autoCleanup;
this.lastRotationTime = os.time(); this.lastRotationTime = os.time();
this.baseFilename = filePath;
this.openFile(); this.openFile();
} }
@@ -94,24 +121,27 @@ export class FileStream implements Stream {
return; return;
} }
this.fileHandle = handle; this.fileHandle = handle;
// Perform auto-cleanup when opening file
this.performAutoCleanup();
} }
/** /**
* Generate a filename with timestamp for file rotation. * Generate a filename with timestamp for file rotation.
*/ */
private getRotatedFilename(): string { private getRotatedFilename(): string {
const currentTime = os.time(); const currentTime = os.time(os.date("*t"));
const rotationPeriod = const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) * Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval; this.rotationInterval;
const date = os.date("*t", rotationPeriod) as LuaDate; const date = os.date("*t", rotationPeriod) as LuaDate;
const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}_${string.format("%02d", date.hour)}-${string.format("%02d", date.min)}`; const timestamp = `${date.year}-${string.format("%02d", date.month)}-${string.format("%02d", date.day)}`;
// Split filename and extension // Split filename and extension
const splitStrs = this.baseFilename.split("."); const splitStrs = this.filePath.split(".");
if (splitStrs.length === 1) { if (splitStrs.length === 1) {
return `${this.baseFilename}_${timestamp}.log`; return `${this.filePath}_${timestamp}.log`;
} }
const name = splitStrs[0]; const name = splitStrs[0];
@@ -126,19 +156,50 @@ export class FileStream implements Stream {
if (this.rotationInterval <= 0) return; if (this.rotationInterval <= 0) return;
const currentTime = os.time(); const currentTime = os.time();
const currentPeriod = Math.floor(currentTime / this.rotationInterval); if (
const lastPeriod = Math.floor( Math.floor(
this.lastRotationTime / this.rotationInterval, (currentTime - this.lastRotationTime) / this.rotationInterval,
); ) > 0
) {
if (currentPeriod > lastPeriod) {
// Time to rotate // Time to rotate
this.close(); this.close();
this.lastRotationTime = currentTime; this.lastRotationTime = currentTime;
this.openFile(); this.openFile();
// Auto-cleanup is performed in openFile()
} }
} }
/**
* Perform auto-cleanup based on configuration.
* This method is called automatically when opening files or rotating.
*/
private performAutoCleanup(): void {
if (!this.autoCleanupConfig || !this.autoCleanupConfig.enabled) {
return;
}
const config = this.autoCleanupConfig;
// Cleanup by file count if configured
if (config.maxFiles !== undefined && config.maxFiles > 0) {
this.cleanupOldLogFiles(config.maxFiles, config.logDir);
}
// Cleanup by total size if configured
if (config.maxSizeBytes !== undefined && config.maxSizeBytes > 0) {
this.cleanupLogFilesBySize(config.maxSizeBytes, config.logDir);
}
}
/**
* Enable or update auto-cleanup configuration at runtime.
*
* @param config - Auto-cleanup configuration
*/
public setAutoCleanup(config: FileStreamConfig["autoCleanup"]): void {
this.autoCleanupConfig = config;
}
/** /**
* Write a formatted log message to the file. * Write a formatted log message to the file.
* *
@@ -163,6 +224,123 @@ export class FileStream implements Stream {
this.fileHandle = undefined; this.fileHandle = undefined;
} }
} }
/**
* Search for log files matching the specified pattern in a directory.
*
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @returns Array of log file information including path, size, and modification time
*/
private searchLogFiles(
logDir?: string,
): Array<{ path: string; size: number; modified: number }> {
const directory = logDir || fs.getDir(this.filePath);
const splitStrs = this.filePath.split(".");
const name = splitStrs[0] + "_";
const ext = splitStrs.length > 1 ? splitStrs[1] : "log";
if (!fs.exists(directory) || !fs.isDir(directory)) {
return [];
}
const logFiles: Array<{
path: string;
size: number;
modified: number;
}> = [];
const files = fs.list(directory);
for (const file of files) {
const fullPath = fs.combine(directory, file);
if (
fs.isDir(fullPath) ||
!file.startsWith(name) ||
!file.endsWith(ext)
)
continue;
const attributes = fs.attributes(fullPath);
if (attributes !== undefined) {
logFiles.push({
path: fullPath,
size: attributes.size,
modified: attributes.modified,
});
}
}
return logFiles;
}
/**
* Clean up old log files by keeping only the specified number of most recent files.
*
* @param maxFiles - Maximum number of log files to keep
* @param logDir - Directory containing log files (defaults to directory of current log file)
*/
public cleanupOldLogFiles(maxFiles: number, logDir?: string): void {
if (maxFiles <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length <= maxFiles) return;
// Sort by modification time (newest first)
logFiles.sort((a, b) => b.modified - a.modified);
// Delete files beyond the limit
for (let i = maxFiles; i < logFiles.length; i++) {
try {
fs.delete(logFiles[i].path);
} catch (err) {
printError(
`Failed to delete old log file ${logFiles[i].path}: ${err}`,
);
}
}
}
/**
* Clean up log files by total size, deleting oldest files until total size is under limit.
*
* @param maxSizeBytes - Maximum total size in bytes for all log files
* @param logDir - Directory containing log files (defaults to directory of current log file)
* @param fileName - Base File Name
*/
public cleanupLogFilesBySize(maxSizeBytes: number, logDir?: string): void {
if (maxSizeBytes <= 0) return;
const logFiles = this.searchLogFiles(logDir);
if (logFiles.length === 0) return;
// Calculate total size
let totalSize = 0;
for (const logFile of logFiles) {
totalSize += logFile.size;
}
// If total size is within limit, no cleanup needed
if (totalSize <= maxSizeBytes) {
return;
}
// Sort by modification time (oldest first for deletion)
logFiles.sort((a, b) => a.modified - b.modified);
// Delete oldest files until we're under the size limit
for (const logFile of logFiles) {
if (totalSize <= maxSizeBytes) {
break;
}
try {
fs.delete(logFile.path);
totalSize -= logFile.size;
} catch (err) {
printError(`Failed to delete log file ${logFile.path}: ${err}`);
}
}
}
} }
/** /**
@@ -307,3 +485,7 @@ export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE; export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR; export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY; export const WEEK = 7 * DAY;
// Byte constants for file rotation
export const MB = 1024 * 1024;
export const KB = 1024;

View File

@@ -11,458 +11,464 @@ import { ScrollContainerProps } from "./scrollContainer";
* Layout properties for flexbox layout * Layout properties for flexbox layout
*/ */
export interface LayoutProps { export interface LayoutProps {
/** Flexbox direction */ /** Flexbox direction */
flexDirection?: "row" | "column"; flexDirection?: "row" | "column";
/** Justify content (main axis alignment) */ /** Justify content (main axis alignment) */
justifyContent?: "start" | "center" | "end" | "between"; justifyContent?: "start" | "center" | "end" | "between";
/** Align items (cross axis alignment) */ /** Align items (cross axis alignment) */
alignItems?: "start" | "center" | "end"; alignItems?: "start" | "center" | "end";
} }
/** /**
* Style properties for colors and appearance * Style properties for colors and appearance
*/ */
export interface StyleProps { export interface StyleProps {
/** Text color */ /** Text color */
textColor?: number; textColor?: number;
/** Background color */ /** Background color */
backgroundColor?: number; backgroundColor?: number;
/** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */ /** Width - can be a number (fixed), "full" (100% of parent), or "screen" (terminal width) */
width?: number | "full" | "screen"; width?: number | "full" | "screen";
/** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */ /** Height - can be a number (fixed), "full" (100% of parent), or "screen" (terminal height) */
height?: number | "full" | "screen"; height?: number | "full" | "screen";
} }
/** /**
* Scroll properties for scroll containers * Scroll properties for scroll containers
*/ */
export interface ScrollProps extends BaseProps { export interface ScrollProps extends BaseProps {
/** Current horizontal scroll position */ /** Current horizontal scroll position */
scrollX: number; scrollX: number;
/** Current vertical scroll position */ /** Current vertical scroll position */
scrollY: number; scrollY: number;
/** Maximum horizontal scroll (content width - viewport width) */ /** Maximum horizontal scroll (content width - viewport width) */
maxScrollX: number; maxScrollX: number;
/** Maximum vertical scroll (content height - viewport height) */ /** Maximum vertical scroll (content height - viewport height) */
maxScrollY: number; maxScrollY: number;
/** Content dimensions */ /** Content dimensions */
contentWidth: number; contentWidth: number;
contentHeight: number; contentHeight: number;
/** Whether to show scrollbars */ /** Whether to show scrollbars */
showScrollbar?: boolean; showScrollbar?: boolean;
/** Viewport dimensions (visible area) */ /** Viewport dimensions (visible area) */
viewportWidth: number; viewportWidth: number;
viewportHeight: number; viewportHeight: number;
} }
/** /**
* Computed layout result after flexbox calculation * Computed layout result after flexbox calculation
*/ */
export interface ComputedLayout { export interface ComputedLayout {
x: number; x: number;
y: number; y: number;
width: number; width: number;
height: number; height: number;
} }
/** /**
* Base props that all components can accept * Base props that all components can accept
*/ */
export interface BaseProps { export interface BaseProps {
/** CSS-like class names for layout (e.g., "flex flex-col") */ /** CSS-like class names for layout (e.g., "flex flex-col") */
class?: string; class?: string;
width?: number; width?: number;
height?: number; height?: number;
onFocusChanged?: Setter<boolean> | ((value: boolean) => void); onFocusChanged?: Setter<boolean> | ((value: boolean) => void);
} }
/** /**
* UIObject node type * UIObject node type
*/ */
export type UIObjectType = export type UIObjectType =
| "div" | "div"
| "label" | "label"
| "button" | "button"
| "input" | "input"
| "form" | "form"
| "h1" | "h1"
| "h2" | "h2"
| "h3" | "h3"
| "for" | "for"
| "show" | "show"
| "switch" | "switch"
| "match" | "match"
| "fragment" | "fragment"
| "scroll-container"; | "scroll-container";
export type UIObjectProps = export type UIObjectProps =
| DivProps | DivProps
| LabelProps | LabelProps
| InputProps | InputProps
| ButtonProps | ButtonProps
| ScrollProps | ScrollProps
| ScrollContainerProps; | ScrollContainerProps;
/** /**
* UIObject represents a node in the UI tree * UIObject represents a node in the UI tree
* It can be a component, text, or a control flow element * It can be a component, text, or a control flow element
*/ */
export class UIObject { export class UIObject {
/** Type of the UI object */ /** Type of the UI object */
type: UIObjectType; type: UIObjectType;
/** Props passed to the component */ /** Props passed to the component */
props: UIObjectProps; props: UIObjectProps;
/** Children UI objects */ /** Children UI objects */
children: UIObject[]; children: UIObject[];
/** Parent UI object */ /** Parent UI object */
parent?: UIObject; parent?: UIObject;
/** Computed layout after flexbox calculation */ /** Computed layout after flexbox calculation */
layout?: ComputedLayout; layout?: ComputedLayout;
/** Layout properties parsed from class string */ /** Layout properties parsed from class string */
layoutProps: LayoutProps; layoutProps: LayoutProps;
/** Style properties parsed from class string */ /** Style properties parsed from class string */
styleProps: StyleProps; styleProps: StyleProps;
/** Whether this component is currently mounted */ /** Whether this component is currently mounted */
mounted: boolean; mounted: boolean;
/** Cleanup functions to call when unmounting */ /** Cleanup functions to call when unmounting */
cleanupFns: (() => void)[]; cleanupFns: (() => void)[];
/** For text nodes - the text content (can be reactive) */ /** For text nodes - the text content (can be reactive) */
textContent?: string | Accessor<string>; textContent?: string | Accessor<string>;
/** Event handlers */ /** Event handlers */
handlers: Record<string, ((...args: unknown[]) => void) | undefined>; handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
/** For input text components - cursor position */ /** For input text components - cursor position */
cursorPos?: number; cursorPos?: number;
/** For scroll containers - scroll state */ /** For scroll containers - scroll state */
scrollProps?: ScrollProps; scrollProps?: ScrollProps;
constructor( constructor(
type: UIObjectType, type: UIObjectType,
props: UIObjectProps = {}, props: UIObjectProps = {},
children: UIObject[] = [], children: UIObject[] = [],
) { ) {
this.type = type; this.type = type;
this.props = props; this.props = props;
this.children = children; this.children = children;
this.layoutProps = {}; this.layoutProps = {};
this.styleProps = {}; this.styleProps = {};
this.mounted = false; this.mounted = false;
this.cleanupFns = []; this.cleanupFns = [];
this.handlers = {}; this.handlers = {};
// Parse layout and styles from class prop // Parse layout and styles from class prop
this.parseClassNames(); this.parseClassNames();
// Extract event handlers // Extract event handlers
this.extractHandlers(); this.extractHandlers();
// Initialize cursor position for text inputs // Initialize cursor position for text inputs
if (type === "input" && (props as InputProps).type !== "checkbox") { if (type === "input" && (props as InputProps).type !== "checkbox") {
this.cursorPos = 0; 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<string, number> = {
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-<color>)
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-<color>) // Initialize scroll properties for scroll containers
else if (cls.startsWith("bg-")) { if (type === "scroll-container") {
const colorName = cls.substring(3); // Remove "bg-" prefix this.scrollProps = {
const color = this.parseColor(colorName); scrollX: 0,
if (color !== undefined) { scrollY: 0,
this.styleProps.backgroundColor = color; 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-<size>) /**
else if (cls.startsWith("w-")) { * Map color name to ComputerCraft colors API value
const sizeValue = cls.substring(2); // Remove "w-" prefix *
if (sizeValue === "full") { * @param colorName - The color name from class (e.g., "white", "red")
this.styleProps.width = "full"; * @returns The color value from colors API, or undefined if invalid
} else if (sizeValue === "screen") { */
this.styleProps.width = "screen"; private parseColor(colorName: string): number | undefined {
} else { const colorMap: Record<string, number> = {
const numValue = tonumber(sizeValue); white: colors.white,
if (numValue !== undefined) { orange: colors.orange,
this.styleProps.width = numValue; 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-<color>)
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-<color>)
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-<size>)
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-<size>)
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-<size>) // Set defaults
else if (cls.startsWith("h-")) { if (this.type === "div") {
const sizeValue = cls.substring(2); // Remove "h-" prefix this.layoutProps.flexDirection ??= "row";
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;
}
} }
} this.layoutProps.justifyContent ??= "start";
this.layoutProps.alignItems ??= "start";
} }
// Set defaults /**
if (this.type === "div") { * Extract event handlers from props
this.layoutProps.flexDirection ??= "row"; */
} private extractHandlers(): void {
this.layoutProps.justifyContent ??= "start"; for (const [key, value] of pairs(this.props)) {
this.layoutProps.alignItems ??= "start"; if (
} typeof key === "string" &&
key.startsWith("on") &&
/** typeof value === "function"
* Extract event handlers from props ) {
*/ this.handlers[key] = value as (...args: unknown[]) => void;
private extractHandlers(): void { }
for (const [key, value] of pairs(this.props)) { }
if (
typeof key === "string" &&
key.startsWith("on") &&
typeof value === "function"
) {
this.handlers[key] = value as (...args: unknown[]) => void;
}
}
}
/**
* Add a child to this UI object
*/
appendChild(child: UIObject): void {
child.parent = this;
this.children.push(child);
}
/**
* Remove a child from this UI object
*/
removeChild(child: UIObject): void {
const index = this.children.indexOf(child);
if (index !== -1) {
this.children.splice(index, 1);
child.parent = undefined;
}
}
/**
* Mount this component and all children
*/
mount(): void {
if (this.mounted) return;
this.mounted = true;
// Mount all children
for (const child of this.children) {
child.mount();
}
}
/**
* Unmount this component and run cleanup
*/
unmount(): void {
if (!this.mounted) return;
this.mounted = false;
// Unmount all children first
for (const child of this.children) {
child.unmount();
} }
// Run cleanup functions /**
for (const cleanup of this.cleanupFns) { * Add a child to this UI object
try { */
cleanup(); appendChild(child: UIObject): void {
} catch (e) { child.parent = this;
printError(e); this.children.push(child);
}
} }
this.cleanupFns = [];
}
/** /**
* Register a cleanup function to be called on unmount * Remove a child from this UI object
*/ */
onCleanup(fn: () => void): void { removeChild(child: UIObject): void {
this.cleanupFns.push(fn); const index = this.children.indexOf(child);
} if (index !== -1) {
this.children.splice(index, 1);
child.parent = undefined;
}
}
/** /**
* Scroll the container by the given amount * Mount this component and all children
* @param deltaX - Horizontal scroll delta */
* @param deltaY - Vertical scroll delta mount(): void {
*/ if (this.mounted) return;
scrollBy(deltaX: number, deltaY: number): void { this.mounted = true;
if (this.type !== "scroll-container" || !this.scrollProps) return;
const newScrollX = Math.max( // Mount all children
0, for (const child of this.children) {
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX + deltaX), child.mount();
); }
const newScrollY = Math.max( }
0,
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY + deltaY),
);
this.scrollProps.scrollX = newScrollX; /**
this.scrollProps.scrollY = newScrollY; * Unmount this component and run cleanup
} */
unmount(): void {
if (!this.mounted) return;
this.mounted = false;
/** // Unmount all children first
* Scroll to a specific position for (const child of this.children) {
* @param x - Horizontal scroll position child.unmount();
* @param y - Vertical scroll position }
*/
scrollTo(x: number, y: number): void {
if (this.type !== "scroll-container" || !this.scrollProps) return;
this.scrollProps.scrollX = Math.max( // Run cleanup functions
0, for (const cleanup of this.cleanupFns) {
Math.min(this.scrollProps.maxScrollX, x), try {
); cleanup();
this.scrollProps.scrollY = Math.max( } catch (e) {
0, printError(e);
Math.min(this.scrollProps.maxScrollY, y), }
); }
} this.cleanupFns = [];
}
/** /**
* Update scroll bounds based on content size * Register a cleanup function to be called on unmount
* @param contentWidth - Total content width */
* @param contentHeight - Total content height onCleanup(fn: () => void): void {
*/ this.cleanupFns.push(fn);
updateScrollBounds(contentWidth: number, contentHeight: number): void { }
if (this.type !== "scroll-container" || !this.scrollProps) return;
this.scrollProps.contentWidth = contentWidth; /**
this.scrollProps.contentHeight = contentHeight; * Scroll the container by the given amount
this.scrollProps.maxScrollX = Math.max( * @param deltaX - Horizontal scroll delta
0, * @param deltaY - Vertical scroll delta
contentWidth - this.scrollProps.viewportWidth, */
); scrollBy(deltaX: number, deltaY: number): void {
this.scrollProps.maxScrollY = Math.max( if (this.type !== "scroll-container" || !this.scrollProps) return;
0,
contentHeight - this.scrollProps.viewportHeight,
);
// Clamp current scroll position to new bounds const newScrollX = Math.max(
this.scrollProps.scrollX = Math.max( 0,
0, Math.min(
Math.min(this.scrollProps.maxScrollX, this.scrollProps.scrollX), this.scrollProps.maxScrollX,
); this.scrollProps.scrollX + deltaX,
this.scrollProps.scrollY = Math.max( ),
0, );
Math.min(this.scrollProps.maxScrollY, this.scrollProps.scrollY), 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 * Create a text node
*/ */
export function createTextNode(text: string | Accessor<string>): UIObject { export function createTextNode(text: string | Accessor<string>): UIObject {
const node = new UIObject("fragment", {}, []); const node = new UIObject("fragment", {}, []);
node.textContent = text; node.textContent = text;
return node; return node;
} }

File diff suppressed because it is too large Load Diff

View File

@@ -18,42 +18,42 @@ export type DivProps = BaseProps;
* Props for label component * Props for label component
*/ */
export type LabelProps = BaseProps & { export type LabelProps = BaseProps & {
/** Whether to automatically wrap long text. Defaults to false. */ /** Whether to automatically wrap long text. Defaults to false. */
wordWrap?: boolean; wordWrap?: boolean;
}; };
/** /**
* Props for button component * Props for button component
*/ */
export type ButtonProps = BaseProps & { export type ButtonProps = BaseProps & {
/** Click handler */ /** Click handler */
onClick?: () => void; onClick?: () => void;
}; };
/** /**
* Props for input component * Props for input component
*/ */
export type InputProps = BaseProps & { export type InputProps = BaseProps & {
/** Input type */ /** Input type */
type?: "text" | "checkbox"; type?: "text" | "checkbox";
/** Value signal for text input */ /** Value signal for text input */
value?: Accessor<string> | Signal<string>; value?: Accessor<string> | Signal<string>;
/** Input handler for text input */ /** Input handler for text input */
onInput?: Setter<string> | ((value: string) => void); onInput?: Setter<string> | ((value: string) => void);
/** Checked signal for checkbox */ /** Checked signal for checkbox */
checked?: Accessor<boolean> | Signal<boolean>; checked?: Accessor<boolean> | Signal<boolean>;
/** Change handler for checkbox */ /** Change handler for checkbox */
onChange?: Setter<boolean> | ((checked: boolean) => void); onChange?: Setter<boolean> | ((checked: boolean) => void);
/** Placeholder text */ /** Placeholder text */
placeholder?: string; placeholder?: string;
}; };
/** /**
* Props for form component * Props for form component
*/ */
export type FormProps = BaseProps & { export type FormProps = BaseProps & {
/** Submit handler */ /** Submit handler */
onSubmit?: () => void; onSubmit?: () => void;
}; };
/** /**
@@ -72,20 +72,20 @@ export type FormProps = BaseProps & {
* ``` * ```
*/ */
export function div( export function div(
props: DivProps, props: DivProps,
...children: (UIObject | string | Accessor<string>)[] ...children: (UIObject | string | Accessor<string>)[]
): UIObject { ): UIObject {
// Convert string children to text nodes // Convert string children to text nodes
const uiChildren = children.map((child) => { const uiChildren = children.map((child) => {
if (typeof child === "string" || typeof child === "function") { if (typeof child === "string" || typeof child === "function") {
return createTextNode(child); return createTextNode(child);
} }
return child; return child;
}); });
const node = new UIObject("div", props, uiChildren); const node = new UIObject("div", props, uiChildren);
uiChildren.forEach((child) => (child.parent = node)); uiChildren.forEach((child) => (child.parent = node));
return node; return node;
} }
/** /**
@@ -108,81 +108,84 @@ export function div(
* @returns An array of words and whitespace. * @returns An array of words and whitespace.
*/ */
function splitByWhitespace(text: string): string[] { function splitByWhitespace(text: string): string[] {
if (!text) return []; if (!text) return [];
const parts: string[] = []; const parts: string[] = [];
let currentWord = ""; let currentWord = "";
let currentWhitespace = ""; let currentWhitespace = "";
for (const char of text) { for (const char of text) {
if (char === " " || char === "\t" || char === "\n" || char === "\r") { if (char === " " || char === "\t" || char === "\n" || char === "\r") {
if (currentWord.length > 0) { if (currentWord.length > 0) {
parts.push(currentWord); parts.push(currentWord);
currentWord = ""; currentWord = "";
} }
currentWhitespace += char; currentWhitespace += char;
} else { } else {
if (currentWhitespace.length > 0) { if (currentWhitespace.length > 0) {
parts.push(currentWhitespace); parts.push(currentWhitespace);
currentWhitespace = ""; currentWhitespace = "";
} }
currentWord += char; currentWord += char;
}
} }
}
if (currentWord.length > 0) { if (currentWord.length > 0) {
parts.push(currentWord); parts.push(currentWord);
} }
if (currentWhitespace.length > 0) { if (currentWhitespace.length > 0) {
parts.push(currentWhitespace); parts.push(currentWhitespace);
} }
return parts; return parts;
} }
export function label( export function label(
props: LabelProps, props: LabelProps,
text: string | Accessor<string>, text: string | Accessor<string>,
): UIObject { ): UIObject {
context.logger?.debug(`label : ${textutils.serialiseJSON(props)}`); context.logger?.debug(`label : ${textutils.serialiseJSON(props)}`);
context.logger?.debug( context.logger?.debug(
`label text: ${typeof text == "string" ? text : text()}`, `label text: ${typeof text == "string" ? text : text()}`,
); );
if (props.wordWrap === true) { if (props.wordWrap === true) {
const p = { ...props }; const p = { ...props };
delete p.wordWrap; delete p.wordWrap;
const containerProps: DivProps = { const containerProps: DivProps = {
...p, ...p,
class: `${p.class ?? ""} flex flex-col`, class: `${p.class ?? ""} flex flex-col`,
}; };
if (typeof text === "string") { if (typeof text === "string") {
// Handle static strings // Handle static strings
const words = splitByWhitespace(text); const words = splitByWhitespace(text);
const children = words.map((word) => createTextNode(word)); const children = words.map((word) => createTextNode(word));
const node = new UIObject("div", containerProps, children); const node = new UIObject("div", containerProps, children);
children.forEach((child) => (child.parent = node)); children.forEach((child) => (child.parent = node));
return node; return node;
} else { } else {
// Handle reactive strings (Accessor<string>) // Handle reactive strings (Accessor<string>)
const sentences = createMemo(() => { const sentences = createMemo(() => {
const words = splitByWhitespace(text()); const words = splitByWhitespace(text());
const ret = concatSentence(words, 40); const ret = concatSentence(words, 40);
context.logger?.debug(`label words changed : [ ${ret.join(",")} ]`); context.logger?.debug(
return ret; `label words changed : [ ${ret.join(",")} ]`,
}); );
return ret;
});
const forNode = For({ class: `flex flex-col`, each: sentences }, (word) => const forNode = For(
label({ class: p.class }, word), { class: `flex flex-col`, each: sentences },
); (word) => label({ class: p.class }, word),
);
return forNode; return forNode;
}
} }
}
const textNode = createTextNode(text); const textNode = createTextNode(text);
const node = new UIObject("label", props, [textNode]); const node = new UIObject("label", props, [textNode]);
textNode.parent = node; textNode.parent = node;
return node; return node;
} }
/** /**
@@ -192,7 +195,7 @@ export function label(
* @returns UIObject representing h1 * @returns UIObject representing h1
*/ */
export function h1(text: string | Accessor<string>): UIObject { export function h1(text: string | Accessor<string>): UIObject {
return label({ class: "heading-1" }, text); return label({ class: "heading-1" }, text);
} }
/** /**
@@ -202,7 +205,7 @@ export function h1(text: string | Accessor<string>): UIObject {
* @returns UIObject representing h2 * @returns UIObject representing h2
*/ */
export function h2(text: string | Accessor<string>): UIObject { export function h2(text: string | Accessor<string>): UIObject {
return label({ class: "heading-2" }, text); return label({ class: "heading-2" }, text);
} }
/** /**
@@ -212,7 +215,7 @@ export function h2(text: string | Accessor<string>): UIObject {
* @returns UIObject representing h3 * @returns UIObject representing h3
*/ */
export function h3(text: string | Accessor<string>): UIObject { export function h3(text: string | Accessor<string>): UIObject {
return label({ class: "heading-3" }, text); return label({ class: "heading-3" }, text);
} }
/** /**
@@ -228,10 +231,10 @@ export function h3(text: string | Accessor<string>): UIObject {
* ``` * ```
*/ */
export function button(props: ButtonProps, text: string): UIObject { export function button(props: ButtonProps, text: string): UIObject {
const textNode = createTextNode(text); const textNode = createTextNode(text);
const node = new UIObject("button", props, [textNode]); const node = new UIObject("button", props, [textNode]);
textNode.parent = node; textNode.parent = node;
return node; return node;
} }
/** /**
@@ -252,18 +255,18 @@ export function button(props: ButtonProps, text: string): UIObject {
* ``` * ```
*/ */
export function input(props: InputProps): UIObject { export function input(props: InputProps): UIObject {
// Normalize signal tuples to just the accessor // Normalize signal tuples to just the accessor
const normalizedProps = { ...props }; const normalizedProps = { ...props };
if (Array.isArray(normalizedProps.value)) { if (Array.isArray(normalizedProps.value)) {
normalizedProps.value = normalizedProps.value[0]; normalizedProps.value = normalizedProps.value[0];
} }
if (Array.isArray(normalizedProps.checked)) { if (Array.isArray(normalizedProps.checked)) {
normalizedProps.checked = normalizedProps.checked[0]; 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( export function form(
props: FormProps, props: FormProps,
...children: (UIObject | string | Accessor<string>)[] ...children: (UIObject | string | Accessor<string>)[]
): UIObject { ): UIObject {
// Convert string children to text nodes // Convert string children to text nodes
const uiChildren = children.map((child) => { const uiChildren = children.map((child) => {
if (typeof child === "string" || typeof child === "function") { if (typeof child === "string" || typeof child === "function") {
return createTextNode(child); return createTextNode(child);
} }
return child; return child;
}); });
const node = new UIObject("form", props, uiChildren); const node = new UIObject("form", props, uiChildren);
uiChildren.forEach((child) => (child.parent = node)); uiChildren.forEach((child) => (child.parent = node));
return node; return node;
} }

View File

@@ -4,20 +4,20 @@
* to all components without prop drilling. * to all components without prop drilling.
*/ */
import type { CCLog } from "../ccLog"; import { Logger } from "@/lib/ccStructLog";
/** /**
* The global context object for the TUI application. * The global context object for the TUI application.
* This will be set by the Application instance on creation. * This will be set by the Application instance on creation.
*/ */
export const context: { logger: CCLog | undefined } = { export const context: { logger: Logger | undefined } = {
logger: undefined, logger: undefined,
}; };
/** /**
* Sets the global logger instance. * Sets the global logger instance.
* @param l The logger instance. * @param l The logger instance.
*/ */
export function setLogger(l: CCLog): void { export function setLogger(l: Logger): void {
context.logger = l; context.logger = l;
} }

View File

@@ -9,34 +9,34 @@ import { Accessor, createEffect } from "./reactivity";
* Props for For component * Props for For component
*/ */
export type ForProps<T> = { export type ForProps<T> = {
/** Signal or accessor containing the array to iterate over */ /** Signal or accessor containing the array to iterate over */
each: Accessor<T[]>; each: Accessor<T[]>;
} & Record<string, unknown>; } & Record<string, unknown>;
/** /**
* Props for Show component * Props for Show component
*/ */
export type ShowProps = { export type ShowProps = {
/** Condition accessor - when true, shows the child */ /** Condition accessor - when true, shows the child */
when: Accessor<boolean>; when: Accessor<boolean>;
/** Optional fallback to show when condition is false */ /** Optional fallback to show when condition is false */
fallback?: UIObject; fallback?: UIObject;
} & Record<string, unknown>; } & Record<string, unknown>;
/** /**
* Props for Switch component * Props for Switch component
*/ */
export type SwitchProps = { export type SwitchProps = {
/** Optional fallback to show when no Match condition is met */ /** Optional fallback to show when no Match condition is met */
fallback?: UIObject; fallback?: UIObject;
} & Record<string, unknown>; } & Record<string, unknown>;
/** /**
* Props for Match component * Props for Match component
*/ */
export type MatchProps = { export type MatchProps = {
/** Condition accessor - when truthy, this Match will be selected */ /** Condition accessor - when truthy, this Match will be selected */
when: Accessor<boolean>; when: Accessor<boolean>;
} & Record<string, unknown>; } & Record<string, unknown>;
/** /**
@@ -61,42 +61,42 @@ export type MatchProps = {
* ``` * ```
*/ */
export function For<T>( export function For<T>(
props: ForProps<T>, props: ForProps<T>,
renderFn: (item: T, index: Accessor<number>) => UIObject, renderFn: (item: T, index: Accessor<number>) => UIObject,
): UIObject { ): UIObject {
const container = new UIObject("for", props, []); const container = new UIObject("for", props, []);
// Track rendered items // Track rendered items
let renderedItems: UIObject[] = []; let renderedItems: UIObject[] = [];
/** /**
* Update the list when the array changes * Update the list when the array changes
*/ */
const updateList = () => { const updateList = () => {
const items = props.each(); const items = props.each();
// Clear old items // Clear old items
renderedItems.forEach((item) => item.unmount()); renderedItems.forEach((item) => item.unmount());
container.children = []; container.children = [];
renderedItems = []; renderedItems = [];
// Render new items // Render new items
items.forEach((item, index) => { items.forEach((item, index) => {
const indexAccessor = () => index; const indexAccessor = () => index;
const rendered = renderFn(item, indexAccessor); const rendered = renderFn(item, indexAccessor);
rendered.parent = container; rendered.parent = container;
container.children.push(rendered); container.children.push(rendered);
renderedItems.push(rendered); renderedItems.push(rendered);
rendered.mount(); rendered.mount();
});
};
// Create effect to watch for changes
createEffect(() => {
updateList();
}); });
};
// Create effect to watch for changes return container;
createEffect(() => {
updateList();
});
return container;
} }
/** /**
@@ -120,44 +120,44 @@ export function For<T>(
* ``` * ```
*/ */
export function Show(props: ShowProps, child: UIObject): UIObject { 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 * Update which child is shown based on condition
*/ */
const updateChild = () => { const updateChild = () => {
const condition = props.when(); const condition = props.when();
// Unmount current child // Unmount current child
if (currentChild !== undefined) { if (currentChild !== undefined) {
currentChild.unmount(); currentChild.unmount();
container.removeChild(currentChild); container.removeChild(currentChild);
} }
// Mount appropriate child // Mount appropriate child
if (condition) { if (condition) {
currentChild = child; currentChild = child;
} else if (props.fallback !== undefined) { } else if (props.fallback !== undefined) {
currentChild = props.fallback; currentChild = props.fallback;
} else { } else {
currentChild = undefined; currentChild = undefined;
return; return;
} }
if (currentChild !== undefined) { if (currentChild !== undefined) {
container.appendChild(currentChild); container.appendChild(currentChild);
currentChild.mount(); currentChild.mount();
} }
}; };
// Create effect to watch for condition changes // Create effect to watch for condition changes
createEffect(() => { createEffect(() => {
updateChild(); 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 { 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 * Evaluate all Match conditions and show the first truthy one
*/ */
const updateChild = () => { const updateChild = () => {
// Unmount current child // Unmount current child
if (currentChild !== undefined) { if (currentChild !== undefined) {
currentChild.unmount(); currentChild.unmount();
container.removeChild(currentChild); container.removeChild(currentChild);
} }
// Find the first Match with a truthy condition // Find the first Match with a truthy condition
for (const match of matches) { for (const match of matches) {
if (match.type === "match") { if (match.type === "match") {
const matchProps = match.props as MatchProps; const matchProps = match.props as MatchProps;
const condition = matchProps.when(); const condition = matchProps.when();
if ( if (
condition !== undefined && condition !== undefined &&
condition !== null && condition !== null &&
condition !== false condition !== false
) { ) {
// This Match's condition is truthy, use it // This Match's condition is truthy, use it
if (match.children.length > 0) { if (match.children.length > 0) {
currentChild = match.children[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); container.appendChild(currentChild);
currentChild.mount(); currentChild.mount();
} } else {
return; currentChild = undefined;
} }
} };
}
// No Match condition was truthy, use fallback if available // Create effect to watch for condition changes
if (props.fallback !== undefined) { createEffect(() => {
currentChild = props.fallback; updateChild();
container.appendChild(currentChild); });
currentChild.mount();
} else {
currentChild = undefined;
}
};
// Create effect to watch for condition changes return container;
createEffect(() => {
updateChild();
});
return container;
} }
/** /**
@@ -253,7 +253,7 @@ export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject {
* ``` * ```
*/ */
export function Match(props: MatchProps, child: UIObject): UIObject { export function Match(props: MatchProps, child: UIObject): UIObject {
const container = new UIObject("match", props, [child]); const container = new UIObject("match", props, [child]);
child.parent = container; child.parent = container;
return container; return container;
} }

View File

@@ -6,60 +6,60 @@
// Reactivity system // Reactivity system
export { export {
createSignal, createSignal,
createEffect, createEffect,
createMemo, createMemo,
batch, batch,
type Accessor, type Accessor,
type Setter, type Setter,
type Signal, type Signal,
} from "./reactivity"; } from "./reactivity";
// Store for complex state // Store for complex state
export { export {
createStore, createStore,
removeIndex, removeIndex,
insertAt, insertAt,
type SetStoreFunction, type SetStoreFunction,
} from "./store"; } from "./store";
// Components // Components
export { export {
div, div,
label, label,
h1, h1,
h2, h2,
h3, h3,
button, button,
input, input,
form, form,
type DivProps, type DivProps,
type LabelProps, type LabelProps,
type ButtonProps, type ButtonProps,
type InputProps, type InputProps,
type FormProps, type FormProps,
} from "./components"; } from "./components";
// Control flow // Control flow
export { export {
For, For,
Show, Show,
Switch, Switch,
Match, Match,
type ForProps, type ForProps,
type ShowProps, type ShowProps,
type SwitchProps, type SwitchProps,
type MatchProps, type MatchProps,
} from "./controlFlow"; } from "./controlFlow";
// Scroll container // Scroll container
export { export {
ScrollContainer, ScrollContainer,
isScrollContainer, isScrollContainer,
findScrollContainer, findScrollContainer,
isPointVisible, isPointVisible,
screenToContent, screenToContent,
type ScrollContainerProps, type ScrollContainerProps,
} from "./scrollContainer"; } from "./scrollContainer";
// Application // Application
@@ -67,10 +67,10 @@ export { Application, render } from "./application";
// Core types // Core types
export { export {
UIObject, UIObject,
type LayoutProps, type LayoutProps,
type StyleProps, type StyleProps,
type ScrollProps, type ScrollProps,
type ComputedLayout, type ComputedLayout,
type BaseProps, type BaseProps,
} from "./UIObject"; } from "./UIObject";

View File

@@ -11,8 +11,8 @@ import { UIObject } from "./UIObject";
* @returns Terminal width and height * @returns Terminal width and height
*/ */
function getTerminalSize(): { width: number; height: number } { function getTerminalSize(): { width: number; height: number } {
const [w, h] = term.getSize(); const [w, h] = term.getSize();
return { width: w, height: h }; return { width: w, height: h };
} }
/** /**
@@ -25,224 +25,227 @@ function getTerminalSize(): { width: number; height: number } {
* @returns Width and height of the element * @returns Width and height of the element
*/ */
function measureNode( function measureNode(
node: UIObject, node: UIObject,
parentWidth?: number, parentWidth?: number,
parentHeight?: number, parentHeight?: number,
): { width: number; height: number } { ): { width: number; height: number } {
// Get text content if it exists // Get text content if it exists
const getTextContent = (): string => { const getTextContent = (): string => {
if (node.textContent !== undefined) { if (node.textContent !== undefined) {
if (typeof node.textContent === "function") { if (typeof node.textContent === "function") {
return node.textContent(); 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();
}
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);
}
} }
// Update scroll bounds with actual content size // For nodes with text children, get their content
node.updateScrollBounds(totalWidth, totalHeight); if (
node.children.length > 0 &&
// Return viewport size as the container size node.children[0].textContent !== undefined
return { ) {
width: measuredWidth ?? node.scrollProps.viewportWidth, const child = node.children[0];
height: measuredHeight ?? node.scrollProps.viewportHeight, if (typeof child.textContent === "function") {
}; return child.textContent();
} }
return child.textContent!;
// 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 { return "";
width: measuredWidth ?? totalWidth, };
height: measuredHeight ?? totalHeight,
}; // 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: // Handle height styling
return { if (node.styleProps.height !== undefined) {
width: measuredWidth ?? 0, if (node.styleProps.height === "screen") {
height: measuredHeight ?? 0, 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 * @param startY - Starting Y position
*/ */
export function calculateLayout( export function calculateLayout(
node: UIObject, node: UIObject,
availableWidth: number, availableWidth: number,
availableHeight: number, availableHeight: number,
startX = 1, startX = 1,
startY = 1, startY = 1,
): void { ): void {
// Set this node's layout // Set this node's layout
node.layout = { node.layout = {
x: startX, x: startX,
y: startY, y: startY,
width: availableWidth, width: availableWidth,
height: availableHeight, height: availableHeight,
}; };
if (node.children.length === 0) { if (node.children.length === 0) {
return; 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);
} }
return;
}
// Measure all children const direction = node.layoutProps.flexDirection ?? "row";
const childMeasurements = node.children.map((child: UIObject) => const justify = node.layoutProps.justifyContent ?? "start";
measureNode(child, availableWidth, availableHeight), const align = node.layoutProps.alignItems ?? "start";
);
// Calculate total size needed const isFlex = node.type === "div" || node.type === "form";
let totalMainAxisSize = 0; const gap = isFlex ? 1 : 0;
let maxCrossAxisSize = 0;
if (direction === "row") { // Handle scroll container layout
for (const measure of childMeasurements) { if (node.type === "scroll-container" && node.scrollProps) {
totalMainAxisSize += measure.width; // For scroll containers, position children based on scroll offset
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height); 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 // Measure all children
if (node.children.length > 1) { const childMeasurements = node.children.map((child: UIObject) =>
totalMainAxisSize += gap * (node.children.length - 1); measureNode(child, availableWidth, availableHeight),
} );
// Calculate starting position based on justify-content // Calculate total size needed
let mainAxisPos = 0; let totalMainAxisSize = 0;
let spacing = 0; let maxCrossAxisSize = 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") { if (direction === "row") {
// Main axis is horizontal for (const measure of childMeasurements) {
childX = startX + math.floor(mainAxisPos); totalMainAxisSize += measure.width;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height);
// 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 { } else {
// Main axis is vertical for (const measure of childMeasurements) {
childY = startY + math.floor(mainAxisPos); totalMainAxisSize += measure.height;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width);
// 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 // Add gaps to total size
calculateLayout(child, measure.width, measure.height, childX, childY); 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);
}
} }

View File

@@ -36,11 +36,11 @@ const pendingEffects = new Set<Listener>();
/** /**
* Creates a reactive signal with a getter and setter * Creates a reactive signal with a getter and setter
* *
* @template T - The type of the signal value * @template T - The type of the signal value
* @param initialValue - The initial value of the signal * @param initialValue - The initial value of the signal
* @returns A tuple containing [getter, setter] * @returns A tuple containing [getter, setter]
* *
* @example * @example
* ```typescript * ```typescript
* const [count, setCount] = createSignal(0); * const [count, setCount] = createSignal(0);
@@ -50,53 +50,53 @@ const pendingEffects = new Set<Listener>();
* ``` * ```
*/ */
export function createSignal<T>(initialValue: T): Signal<T> { export function createSignal<T>(initialValue: T): Signal<T> {
let value = initialValue; let value = initialValue;
const listeners = new Set<Listener>(); const listeners = new Set<Listener>();
/** /**
* Getter function - reads the current value and subscribes the current listener * Getter function - reads the current value and subscribes the current listener
*/ */
const getter: Accessor<T> = () => { const getter: Accessor<T> = () => {
// Subscribe the current running effect/computation // Subscribe the current running effect/computation
if (currentListener !== undefined) { if (currentListener !== undefined) {
listeners.add(currentListener); listeners.add(currentListener);
} }
return value; return value;
}; };
/** /**
* Setter function - updates the value and notifies all listeners * Setter function - updates the value and notifies all listeners
*/ */
const setter: Setter<T> = (newValue: T) => { const setter: Setter<T> = (newValue: T) => {
// Only update if value actually changed // Only update if value actually changed
if (value !== newValue) { if (value !== newValue) {
value = newValue; value = newValue;
// Notify all subscribed listeners
if (batchDepth > 0) {
// In batch mode, collect effects to run later
listeners.forEach(listener => pendingEffects.add(listener));
} else {
// Run effects immediately
listeners.forEach(listener => {
try {
listener();
} catch (e) {
printError(e);
}
});
}
}
};
return [getter, setter]; // 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 * Creates an effect that automatically tracks its dependencies and reruns when they change
* *
* @param fn - The effect function to run * @param fn - The effect function to run
* *
* @example * @example
* ```typescript * ```typescript
* const [count, setCount] = createSignal(0); * const [count, setCount] = createSignal(0);
@@ -107,30 +107,30 @@ export function createSignal<T>(initialValue: T): Signal<T> {
* ``` * ```
*/ */
export function createEffect(fn: () => void): void { export function createEffect(fn: () => void): void {
const effect = () => { const effect = () => {
// Set this effect as the current listener // Set this effect as the current listener
const prevListener = currentListener; const prevListener = currentListener;
currentListener = effect; currentListener = effect;
try {
// Run the effect function - it will subscribe to any signals it reads
fn();
} finally {
// Restore previous listener
currentListener = prevListener;
}
};
// Run the effect immediately for the first time try {
effect(); // 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 * Batches multiple signal updates to prevent excessive re-renders
* All signal updates within the batch function will only trigger effects once * All signal updates within the batch function will only trigger effects once
* *
* @param fn - Function containing multiple signal updates * @param fn - Function containing multiple signal updates
* *
* @example * @example
* ```typescript * ```typescript
* batch(() => { * batch(() => {
@@ -140,37 +140,37 @@ export function createEffect(fn: () => void): void {
* ``` * ```
*/ */
export function batch(fn: () => void): void { export function batch(fn: () => void): void {
batchDepth++; batchDepth++;
try { try {
fn(); fn();
} finally { } finally {
batchDepth--; batchDepth--;
// If we're done with all batches, run pending effects // If we're done with all batches, run pending effects
if (batchDepth === 0) { if (batchDepth === 0) {
const effects = Array.from(pendingEffects); const effects = Array.from(pendingEffects);
pendingEffects.clear(); pendingEffects.clear();
effects.forEach(effect => { effects.forEach((effect) => {
try { try {
effect(); effect();
} catch (e) { } catch (e) {
printError(e); printError(e);
}
});
} }
});
} }
}
} }
/** /**
* Creates a derived signal (memo) that computes a value based on other signals * Creates a derived signal (memo) that computes a value based on other signals
* The computation is cached and only recomputed when dependencies change * The computation is cached and only recomputed when dependencies change
* *
* @template T - The type of the computed value * @template T - The type of the computed value
* @param fn - Function that computes the value * @param fn - Function that computes the value
* @returns An accessor function for the computed value * @returns An accessor function for the computed value
* *
* @example * @example
* ```typescript * ```typescript
* const [firstName, setFirstName] = createSignal("John"); * const [firstName, setFirstName] = createSignal("John");
@@ -180,11 +180,11 @@ export function batch(fn: () => void): void {
* ``` * ```
*/ */
export function createMemo<T>(fn: () => T): Accessor<T> { export function createMemo<T>(fn: () => T): Accessor<T> {
const [value, setValue] = createSignal<T>(undefined as unknown as T); const [value, setValue] = createSignal<T>(undefined as unknown as T);
createEffect(() => { createEffect(() => {
setValue(fn()); setValue(fn());
}); });
return value; return value;
} }

View File

@@ -10,108 +10,115 @@ import { isScrollContainer } from "./scrollContainer";
* Get text content from a node (resolving signals if needed) * Get text content from a node (resolving signals if needed)
*/ */
function getTextContent(node: UIObject): string { function getTextContent(node: UIObject): string {
if (node.textContent !== undefined) { if (node.textContent !== undefined) {
if (typeof node.textContent === "function") { if (typeof node.textContent === "function") {
return node.textContent(); return node.textContent();
}
return node.textContent;
} }
return node.textContent;
}
// For nodes with text children, get their content // For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) { if (
const child = node.children[0]; node.children.length > 0 &&
if (typeof child.textContent === "function") { node.children[0].textContent !== undefined
return child.textContent(); ) {
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 * Check if a position is within the visible area of all scroll container ancestors
*/ */
function isPositionVisible( function isPositionVisible(
node: UIObject, node: UIObject,
screenX: number, screenX: number,
screenY: number, screenY: number,
): boolean { ): boolean {
let current = node.parent; let current = node.parent;
while (current) { while (current) {
if (isScrollContainer(current) && current.layout && current.scrollProps) { if (
const { x: containerX, y: containerY } = current.layout; isScrollContainer(current) &&
const { viewportWidth, viewportHeight } = current.scrollProps; 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 // Check if position is within the scroll container's viewport
if ( if (
screenX < containerX || screenX < containerX ||
screenX >= containerX + viewportWidth || screenX >= containerX + viewportWidth ||
screenY < containerY || screenY < containerY ||
screenY >= containerY + viewportHeight screenY >= containerY + viewportHeight
) { ) {
return false; return false;
} }
}
current = current.parent;
} }
current = current.parent; return true;
}
return true;
} }
/** /**
* Draw a scrollbar for a scroll container * Draw a scrollbar for a scroll container
*/ */
function drawScrollbar(container: UIObject): void { function drawScrollbar(container: UIObject): void {
if ( if (
!container.layout || !container.layout ||
!container.scrollProps || !container.scrollProps ||
container.scrollProps.showScrollbar === false container.scrollProps.showScrollbar === false
) { ) {
return; return;
} }
const { x, y, width, height } = container.layout; const { x, y, width, height } = container.layout;
const { scrollY, maxScrollY, viewportHeight, contentHeight } = const { scrollY, maxScrollY, viewportHeight, contentHeight } =
container.scrollProps; container.scrollProps;
// Only draw vertical scrollbar if content is scrollable // Only draw vertical scrollbar if content is scrollable
if (maxScrollY <= 0) return; if (maxScrollY <= 0) return;
const scrollbarX = x + width - 1; // Position scrollbar at the right edge const scrollbarX = x + width - 1; // Position scrollbar at the right edge
const scrollbarHeight = height; const scrollbarHeight = height;
// Calculate scrollbar thumb position and size // Calculate scrollbar thumb position and size
const thumbHeight = Math.max( const thumbHeight = Math.max(
1, 1,
Math.floor((viewportHeight / contentHeight) * scrollbarHeight), Math.floor((viewportHeight / contentHeight) * scrollbarHeight),
); );
const thumbPosition = Math.floor( const thumbPosition = Math.floor(
(scrollY / maxScrollY) * (scrollbarHeight - thumbHeight), (scrollY / maxScrollY) * (scrollbarHeight - thumbHeight),
); );
// Save current colors // Save current colors
const [origX, origY] = term.getCursorPos(); const [origX, origY] = term.getCursorPos();
try { try {
// Draw scrollbar track // Draw scrollbar track
term.setTextColor(colors.gray); term.setTextColor(colors.gray);
term.setBackgroundColor(colors.lightGray); term.setBackgroundColor(colors.lightGray);
for (let i = 0; i < scrollbarHeight; i++) { for (let i = 0; i < scrollbarHeight; i++) {
term.setCursorPos(scrollbarX, y + i); term.setCursorPos(scrollbarX, y + i);
if (i >= thumbPosition && i < thumbPosition + thumbHeight) { if (i >= thumbPosition && i < thumbPosition + thumbHeight) {
// Draw scrollbar thumb // Draw scrollbar thumb
term.setBackgroundColor(colors.gray); term.setBackgroundColor(colors.gray);
term.write(" "); term.write(" ");
} else { } else {
// Draw scrollbar track // Draw scrollbar track
term.setBackgroundColor(colors.lightGray); term.setBackgroundColor(colors.lightGray);
term.write(" "); 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) * @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/ */
function drawNode( function drawNode(
node: UIObject, node: UIObject,
focused: boolean, focused: boolean,
cursorBlinkState: boolean, cursorBlinkState: boolean,
): void { ): 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 // Check if this node is visible within scroll container viewports
if (!isPositionVisible(node, x, y)) { if (!isPositionVisible(node, x, y)) {
return; return;
} }
// Save cursor position // Save cursor position
const [origX, origY] = term.getCursorPos(); const [origX, origY] = term.getCursorPos();
try { try {
// Default colors that can be overridden by styleProps // Default colors that can be overridden by styleProps
let textColor = node.styleProps.textColor; let textColor = node.styleProps.textColor;
const bgColor = node.styleProps.backgroundColor; const bgColor = node.styleProps.backgroundColor;
switch (node.type) { switch (node.type) {
case "label": case "label":
case "h1": case "h1":
case "h2": case "h2":
case "h3": { case "h3": {
const text = getTextContent(node); const text = getTextContent(node);
// Set colors based on heading level (if not overridden by styleProps) // Set colors based on heading level (if not overridden by styleProps)
if (textColor === undefined) { if (textColor === undefined) {
if (node.type === "h1") { if (node.type === "h1") {
textColor = colors.yellow; textColor = colors.yellow;
} else if (node.type === "h2") { } else if (node.type === "h2") {
textColor = colors.orange; textColor = colors.orange;
} else if (node.type === "h3") { } else if (node.type === "h3") {
textColor = colors.lightGray; textColor = colors.lightGray;
} else { } else {
textColor = colors.white; textColor = colors.white;
} }
} }
term.setTextColor(textColor); term.setTextColor(textColor);
term.setBackgroundColor(bgColor ?? colors.black); term.setBackgroundColor(bgColor ?? colors.black);
term.setCursorPos(x, y); term.setCursorPos(x, y);
term.write(text.substring(0, width)); term.write(text.substring(0, width));
break; break;
} }
case "button": { case "button": {
const text = getTextContent(node); const text = getTextContent(node);
// Set colors based on focus (if not overridden by styleProps) // Set colors based on focus (if not overridden by styleProps)
if (focused) { if (focused) {
term.setTextColor(textColor ?? colors.black); term.setTextColor(textColor ?? colors.black);
term.setBackgroundColor(bgColor ?? colors.yellow); term.setBackgroundColor(bgColor ?? colors.yellow);
} else { } else {
term.setTextColor(textColor ?? colors.white); term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.gray); term.setBackgroundColor(bgColor ?? colors.gray);
} }
term.setCursorPos(x, y); term.setCursorPos(x, y);
term.write(`[${text}]`); term.write(`[${text}]`);
break; break;
} }
case "input": { case "input": {
const type = (node.props as InputProps).type as string | undefined; const type = (node.props as InputProps).type as
| string
if (type === "checkbox") { | undefined;
// Draw checkbox
let isChecked = false; if (type === "checkbox") {
const checkedProp = (node.props as InputProps).checked; // Draw checkbox
if (typeof checkedProp === "function") { let isChecked = false;
isChecked = checkedProp(); 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); if (focused) {
} else { term.setTextColor(textColor ?? colors.black);
term.setTextColor(textColor ?? colors.white); term.setBackgroundColor(bgColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black); } else {
} term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black);
term.setCursorPos(x, y); }
term.write(isChecked ? "[X]" : "[ ]");
} else { term.setCursorPos(x, y);
// Draw text input term.write(isChecked ? "[X]" : "[ ]");
let displayText = ""; } else {
const valueProp = (node.props as InputProps).value; // Draw text input
if (typeof valueProp === "function") { let displayText = "";
displayText = valueProp(); const valueProp = (node.props as InputProps).value;
} if (typeof valueProp === "function") {
const placeholder = (node.props as InputProps).placeholder; displayText = valueProp();
const cursorPos = node.cursorPos ?? 0; }
let currentTextColor = textColor; const placeholder = (node.props as InputProps).placeholder;
let showPlaceholder = false; const cursorPos = node.cursorPos ?? 0;
let currentTextColor = textColor;
const focusedBgColor = bgColor ?? colors.white; let showPlaceholder = false;
const unfocusedBgColor = bgColor ?? colors.black;
const focusedBgColor = bgColor ?? colors.white;
if (displayText === "" && placeholder !== undefined && !focused) { const unfocusedBgColor = bgColor ?? colors.black;
displayText = placeholder;
showPlaceholder = true; if (
currentTextColor = currentTextColor ?? colors.gray; displayText === "" &&
} else if (focused) { placeholder !== undefined &&
currentTextColor = currentTextColor ?? colors.black; !focused
} else { ) {
currentTextColor = currentTextColor ?? colors.white; displayText = placeholder;
} showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray;
// Set background and clear the input area, creating a 1-character padding on the left } else if (focused) {
term.setBackgroundColor(focused ? focusedBgColor : unfocusedBgColor); currentTextColor = currentTextColor ?? colors.black;
term.setCursorPos(x, y); } else {
term.write(" ".repeat(width)); currentTextColor = currentTextColor ?? colors.white;
}
term.setTextColor(currentTextColor);
term.setCursorPos(x + 1, y); // Position cursor for text after padding // Set background and clear the input area, creating a 1-character padding on the left
term.setBackgroundColor(
const renderWidth = width - 1; focused ? focusedBgColor : unfocusedBgColor,
const textToRender = displayText + " "; );
term.setCursorPos(x, y);
// Move text if it's too long for the padded area term.write(" ".repeat(width));
const startDisPos =
cursorPos >= renderWidth ? cursorPos - renderWidth + 1 : 0; term.setTextColor(currentTextColor);
const stopDisPos = startDisPos + renderWidth; term.setCursorPos(x + 1, y); // Position cursor for text after padding
if (focused && !showPlaceholder && cursorBlinkState) { const renderWidth = width - 1;
// Draw text with a block cursor by inverting colors at the cursor position const textToRender = displayText + " ";
for (
let i = startDisPos; // Move text if it's too long for the padded area
i < textToRender.length && i < stopDisPos; const startDisPos =
i++ cursorPos >= renderWidth
) { ? cursorPos - renderWidth + 1
const char = textToRender.substring(i, i + 1); : 0;
if (i === cursorPos) { const stopDisPos = startDisPos + renderWidth;
// Invert colors for cursor
term.setBackgroundColor(currentTextColor); if (focused && !showPlaceholder && cursorBlinkState) {
term.setTextColor(focusedBgColor); // Draw text with a block cursor by inverting colors at the cursor position
term.write(char); for (
// Restore colors let i = startDisPos;
term.setBackgroundColor(focusedBgColor); i < textToRender.length && i < stopDisPos;
term.setTextColor(currentTextColor); i++
} else { ) {
term.write(char); const char = textToRender.substring(i, i + 1);
} if (i === cursorPos) {
} // Invert colors for cursor
// Draw cursor at the end of the text if applicable term.setBackgroundColor(currentTextColor);
if (cursorPos === textToRender.length && cursorPos < renderWidth) { term.setTextColor(focusedBgColor);
term.setBackgroundColor(currentTextColor); term.write(char);
term.setTextColor(focusedBgColor); // Restore colors
term.write(" "); term.setBackgroundColor(focusedBgColor);
// Restore colors term.setTextColor(currentTextColor);
term.setBackgroundColor(focusedBgColor); } else {
term.setTextColor(currentTextColor); term.write(char);
} }
} else { }
// Not focused or no cursor, just write the text // Draw cursor at the end of the text if applicable
term.write(textToRender.substring(startDisPos, stopDisPos)); if (
} cursorPos === textToRender.length &&
} cursorPos < renderWidth
break; ) {
} term.setBackgroundColor(currentTextColor);
term.setTextColor(focusedBgColor);
case "div": term.write(" ");
case "form": // Restore colors
case "for": term.setBackgroundColor(focusedBgColor);
case "show": term.setTextColor(currentTextColor);
case "switch": }
case "match": { } else {
// Container elements may have background colors // Not focused or no cursor, just write the text
if (bgColor !== undefined && node.layout !== undefined) { term.write(
const { textToRender.substring(startDisPos, stopDisPos),
x: divX, );
y: divY, }
width: divWidth, }
height: divHeight, break;
} = node.layout; }
term.setBackgroundColor(bgColor);
// Fill the background area case "div":
for (let row = 0; row < divHeight; row++) { case "form":
term.setCursorPos(divX, divY + row); case "for":
term.write(string.rep(" ", divWidth)); case "show":
} case "switch":
} case "match": {
break; // Container elements may have background colors
} if (bgColor !== undefined && node.layout !== undefined) {
const {
case "scroll-container": { x: divX,
// Draw the scroll container background y: divY,
if (bgColor !== undefined) { width: divWidth,
term.setBackgroundColor(bgColor); height: divHeight,
for (let row = 0; row < height; row++) { } = node.layout;
term.setCursorPos(x, y + row); term.setBackgroundColor(bgColor);
term.write(string.rep(" ", width)); // Fill the background area
} for (let row = 0; row < divHeight; row++) {
} term.setCursorPos(divX, divY + row);
term.write(string.rep(" ", divWidth));
// Draw scrollbar after rendering children }
// (This will be called after children are rendered) }
break; break;
} }
case "fragment": { case "scroll-container": {
// Fragment with text content // Draw the scroll container background
if (node.textContent !== undefined) { if (bgColor !== undefined) {
const text = term.setBackgroundColor(bgColor);
typeof node.textContent === "function" for (let row = 0; row < height; row++) {
? node.textContent() term.setCursorPos(x, y + row);
: node.textContent; term.write(string.rep(" ", width));
}
if (bgColor !== undefined) { }
term.setBackgroundColor(bgColor);
} // Draw scrollbar after rendering children
term.setCursorPos(x, y); // (This will be called after children are rendered)
term.write(text.substring(0, width)); break;
} }
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) * @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/ */
export function render( export function render(
node: UIObject, node: UIObject,
focusedNode?: UIObject, focusedNode?: UIObject,
cursorBlinkState = false, cursorBlinkState = false,
): void { ): void {
// Draw this node // Draw this node
const isFocused = node === focusedNode; const isFocused = node === focusedNode;
drawNode(node, isFocused, cursorBlinkState); drawNode(node, isFocused, cursorBlinkState);
// For scroll containers, set up clipping region before rendering children // For scroll containers, set up clipping region before rendering children
if (isScrollContainer(node) && node.layout && node.scrollProps) { if (isScrollContainer(node) && node.layout && node.scrollProps) {
// Recursively draw children (they will be clipped by visibility checks) // Recursively draw children (they will be clipped by visibility checks)
for (const child of node.children) { for (const child of node.children) {
render(child, focusedNode, cursorBlinkState); render(child, focusedNode, cursorBlinkState);
} }
// Draw scrollbar after children // Draw scrollbar after children
drawScrollbar(node); drawScrollbar(node);
} else { } else {
// Recursively draw children normally // Recursively draw children normally
for (const child of node.children) { for (const child of node.children) {
render(child, focusedNode, cursorBlinkState); render(child, focusedNode, cursorBlinkState);
}
} }
}
} }
/** /**
* Clear the entire terminal screen * Clear the entire terminal screen
*/ */
export function clearScreen(): void { export function clearScreen(): void {
term.setBackgroundColor(colors.black); term.setBackgroundColor(colors.black);
term.clear(); term.clear();
term.setCursorPos(1, 1); term.setCursorPos(1, 1);
} }

View File

@@ -9,16 +9,16 @@ import { createSignal, createEffect } from "./reactivity";
* Props for ScrollContainer component * Props for ScrollContainer component
*/ */
export type ScrollContainerProps = { export type ScrollContainerProps = {
/** Maximum width of the scroll container viewport */ /** Maximum width of the scroll container viewport */
width?: number; width?: number;
/** Maximum height of the scroll container viewport */ /** Maximum height of the scroll container viewport */
height?: number; height?: number;
/** Whether to show scrollbars (default: true) */ /** Whether to show scrollbars (default: true) */
showScrollbar?: boolean; showScrollbar?: boolean;
/** CSS-like class names for styling */ /** CSS-like class names for styling */
class?: string; class?: string;
/** Callback when scroll position changes */ /** Callback when scroll position changes */
onScroll?: (scrollX: number, scrollY: number) => void; onScroll?: (scrollX: number, scrollY: number) => void;
} & Record<string, unknown>; } & Record<string, unknown>;
/** /**
@@ -44,69 +44,69 @@ export type ScrollContainerProps = {
* ``` * ```
*/ */
export function ScrollContainer( export function ScrollContainer(
props: ScrollContainerProps, props: ScrollContainerProps,
content: UIObject, content: UIObject,
): UIObject { ): UIObject {
const container = new UIObject("scroll-container", props, [content]); const container = new UIObject("scroll-container", props, [content]);
content.parent = container; content.parent = container;
// Set up scroll properties from props // 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);
if (container.scrollProps) { if (container.scrollProps) {
setScrollX(container.scrollProps.scrollX); container.scrollProps.viewportWidth = props.width ?? 10;
setScrollY(container.scrollProps.scrollY); container.scrollProps.viewportHeight = props.height ?? 10;
container.scrollProps.showScrollbar = props.showScrollbar !== false;
} }
};
container.scrollTo = (x: number, y: number): void => { // Create reactive signals for scroll position
originalScrollTo(x, y); const [scrollX, setScrollX] = createSignal(0);
if (container.scrollProps) { const [scrollY, setScrollY] = createSignal(0);
setScrollX(container.scrollProps.scrollX);
setScrollY(container.scrollProps.scrollY);
}
};
// Expose scroll control methods on the container // Update scroll position when signals change
const containerWithMethods = container as UIObject & { createEffect(() => {
getScrollX: () => number; const x = scrollX();
getScrollY: () => number; const y = scrollY();
setScrollX: (value: number) => void; container.scrollTo(x, y);
setScrollY: (value: number) => void;
};
containerWithMethods.getScrollX = () => scrollX(); // Call onScroll callback if provided
containerWithMethods.getScrollY = () => scrollY(); if (props.onScroll && typeof props.onScroll === "function") {
containerWithMethods.setScrollX = (value: number) => setScrollX(value); props.onScroll(x, y);
containerWithMethods.setScrollY = (value: number) => setScrollY(value); }
});
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 * @returns True if the node is a scroll container
*/ */
export function isScrollContainer(node: UIObject): boolean { 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 * @returns The nearest scroll container, or undefined if none found
*/ */
export function findScrollContainer(node: UIObject): UIObject | undefined { export function findScrollContainer(node: UIObject): UIObject | undefined {
let current = node.parent; let current = node.parent;
while (current) { while (current) {
if (isScrollContainer(current)) { if (isScrollContainer(current)) {
return 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 * @returns True if the point is visible
*/ */
export function isPointVisible( export function isPointVisible(
container: UIObject, container: UIObject,
x: number, x: number,
y: number, y: number,
): boolean { ): boolean {
if (!isScrollContainer(container) || !container.scrollProps) { if (!isScrollContainer(container) || !container.scrollProps) {
return true; return true;
} }
const { scrollX, scrollY, viewportWidth, viewportHeight } = const { scrollX, scrollY, viewportWidth, viewportHeight } =
container.scrollProps; container.scrollProps;
return ( return (
x >= scrollX && x >= scrollX &&
x < scrollX + viewportWidth && x < scrollX + viewportWidth &&
y >= scrollY && y >= scrollY &&
y < scrollY + viewportHeight y < scrollY + viewportHeight
); );
} }
/** /**
@@ -169,36 +169,36 @@ export function isPointVisible(
* @returns Content coordinates, or undefined if not within container * @returns Content coordinates, or undefined if not within container
*/ */
export function screenToContent( export function screenToContent(
container: UIObject, container: UIObject,
screenX: number, screenX: number,
screenY: number, screenY: number,
): { x: number; y: number } | undefined { ): { x: number; y: number } | undefined {
if ( if (
!isScrollContainer(container) || !isScrollContainer(container) ||
!container.layout || !container.layout ||
!container.scrollProps !container.scrollProps
) { ) {
return undefined; return undefined;
} }
const { x: containerX, y: containerY } = container.layout; const { x: containerX, y: containerY } = container.layout;
const { scrollX, scrollY } = container.scrollProps; const { scrollX, scrollY } = container.scrollProps;
// Check if point is within container bounds // Check if point is within container bounds
const relativeX = screenX - containerX; const relativeX = screenX - containerX;
const relativeY = screenY - containerY; const relativeY = screenY - containerY;
if ( if (
relativeX < 0 || relativeX < 0 ||
relativeY < 0 || relativeY < 0 ||
relativeX >= container.scrollProps.viewportWidth || relativeX >= container.scrollProps.viewportWidth ||
relativeY >= container.scrollProps.viewportHeight relativeY >= container.scrollProps.viewportHeight
) { ) {
return undefined; return undefined;
} }
return { return {
x: relativeX + scrollX, x: relativeX + scrollX,
y: relativeY + scrollY, y: relativeY + scrollY,
}; };
} }

View File

@@ -9,104 +9,109 @@ import { createSignal, Accessor } from "./reactivity";
* Store setter function type * Store setter function type
*/ */
export interface SetStoreFunction<T> { export interface SetStoreFunction<T> {
/** /**
* Set a specific property or array index * Set a specific property or array index
*/ */
<K extends keyof T>(key: K, value: T[K]): void; <K extends keyof T>(key: K, value: T[K]): void;
/** /**
* Set array index and property * Set array index and property
*/ */
(index: number, key: string, value: unknown): void; (index: number, key: string, value: unknown): void;
/** /**
* Set using an updater function * Set using an updater function
*/ */
(updater: (prev: T) => T): void; (updater: (prev: T) => T): void;
} }
/** /**
* Creates a reactive store for managing objects and arrays * Creates a reactive store for managing objects and arrays
* Returns an accessor for the store and a setter function * Returns an accessor for the store and a setter function
* *
* @template T - The type of the store (must be an object) * @template T - The type of the store (must be an object)
* @param initialValue - The initial value of the store * @param initialValue - The initial value of the store
* @returns A tuple of [accessor, setStore] * @returns A tuple of [accessor, setStore]
* *
* @example * @example
* ```typescript * ```typescript
* const [todos, setTodos] = createStore<Todo[]>([]); * const [todos, setTodos] = createStore<Todo[]>([]);
* *
* // Add a new todo * // Add a new todo
* setTodos(todos().length, { title: "New todo", done: false }); * setTodos(todos().length, { title: "New todo", done: false });
* *
* // Update a specific todo * // Update a specific todo
* setTodos(0, "done", true); * setTodos(0, "done", true);
* *
* // Replace entire store * // Replace entire store
* setTodos([{ title: "First", done: false }]); * setTodos([{ title: "First", done: false }]);
* ``` * ```
*/ */
export function createStore<T extends object>(initialValue: T): [Accessor<T>, SetStoreFunction<T>] { export function createStore<T extends object>(
// Use a signal to track the entire state initialValue: T,
const [get, set] = createSignal(initialValue); ): [Accessor<T>, SetStoreFunction<T>] {
// Use a signal to track the entire state
/** const [get, set] = createSignal(initialValue);
* Setter function with multiple overloads
*/ /**
const setStore: SetStoreFunction<T> = ((...args: unknown[]) => { * Setter function with multiple overloads
if (args.length === 1) { */
// Single argument - either a value or an updater function const setStore: SetStoreFunction<T> = ((...args: unknown[]) => {
const arg = args[0]; if (args.length === 1) {
if (typeof arg === "function") { // Single argument - either a value or an updater function
// Updater function const arg = args[0];
const updater = arg as (prev: T) => T; if (typeof arg === "function") {
set(updater(get())); // Updater function
} else { const updater = arg as (prev: T) => T;
// Direct value set(updater(get()));
set(arg as T); } else {
} // Direct value
} else if (args.length === 2) { set(arg as T);
// Two arguments - key and value for object property or array index }
const key = args[0] as keyof T; } else if (args.length === 2) {
const value = args[1] as T[keyof T]; // Two arguments - key and value for object property or array index
const current = get(); const key = args[0] as keyof T;
const value = args[1] as T[keyof T];
if (Array.isArray(current)) { const current = get();
// For arrays, create a new array with the updated element
const newArray = [...current] as T; if (Array.isArray(current)) {
(newArray as unknown[])[key as unknown as number] = value; // For arrays, create a new array with the updated element
set(newArray); const newArray = [...current] as T;
} else { (newArray as unknown[])[key as unknown as number] = value;
// For objects, create a new object with the updated property set(newArray);
set({ ...current, [key]: value }); } else {
} // For objects, create a new object with the updated property
} else if (args.length === 3) { set({ ...current, [key]: value });
// Three arguments - array index, property key, and value }
const index = args[0] as number; } else if (args.length === 3) {
const key = args[1] as string; // Three arguments - array index, property key, and value
const value = args[2]; const index = args[0] as number;
const current = get(); const key = args[1] as string;
const value = args[2];
if (Array.isArray(current)) { const current = get();
const newArray = [...current] as unknown[];
if (typeof newArray[index] === "object" && newArray[index] !== undefined) { if (Array.isArray(current)) {
newArray[index] = { ...(newArray[index]!), [key]: value }; 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<T>;
}
} return [get, setStore];
}) as SetStoreFunction<T>;
return [get, setStore];
} }
/** /**
* Helper function to remove an item from an array at a specific index * Helper function to remove an item from an array at a specific index
* *
* @template T - The type of array elements * @template T - The type of array elements
* @param array - The array to remove from * @param array - The array to remove from
* @param index - The index to remove * @param index - The index to remove
* @returns A new array with the item removed * @returns A new array with the item removed
* *
* @example * @example
* ```typescript * ```typescript
* const [todos, setTodos] = createStore([1, 2, 3, 4]); * const [todos, setTodos] = createStore([1, 2, 3, 4]);
@@ -114,12 +119,12 @@ export function createStore<T extends object>(initialValue: T): [Accessor<T>, Se
* ``` * ```
*/ */
export function removeIndex<T>(array: T[], index: number): T[] { export function removeIndex<T>(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 * Helper function to insert an item into an array at a specific index
* *
* @template T - The type of array elements * @template T - The type of array elements
* @param array - The array to insert into * @param array - The array to insert into
* @param index - The index to insert at * @param index - The index to insert at
@@ -127,5 +132,5 @@ export function removeIndex<T>(array: T[], index: number): T[] {
* @returns A new array with the item inserted * @returns A new array with the item inserted
*/ */
export function insertAt<T>(array: T[], index: number, item: T): T[] { export function insertAt<T>(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)];
} }

View File

View File

@@ -36,7 +36,13 @@ const customLogger = new Logger({
}), }),
], ],
renderer: jsonRenderer, renderer: jsonRenderer,
streams: [new ConsoleStream(), new FileStream("custom.log", HOUR)], streams: [
new ConsoleStream(),
new FileStream({
filePath: "custom.log",
rotationInterval: HOUR,
}),
],
}); });
customLogger.info("Custom logger example", { customLogger.info("Custom logger example", {
@@ -136,7 +142,10 @@ const multiFormatLogger = new Logger({
{ {
write: (_, event) => { write: (_, event) => {
const formatted = jsonRenderer(event); const formatted = jsonRenderer(event);
new FileStream("structured.log").write(formatted, event); new FileStream({ filePath: "structured.log" }).write(
formatted,
event,
);
}, },
}, },
], ],
@@ -193,7 +202,7 @@ print("\n=== Cleanup Examples ===");
const fileLogger = new Logger({ const fileLogger = new Logger({
processors: [processor.addTimestamp()], processors: [processor.addTimestamp()],
renderer: jsonRenderer, renderer: jsonRenderer,
streams: [new FileStream("temp.log")], streams: [new FileStream({ filePath: "structured.log" })],
}); });
fileLogger.info("Temporary log entry"); fileLogger.info("Temporary log entry");