mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-29 12:57:50 +08:00
Compare commits
3 Commits
82a9fec46d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b46c6d70 | ||
|
|
de97fb4858 | ||
|
|
0612477325 |
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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
@@ -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...`,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user