Compare commits

..

7 Commits

25 changed files with 4709 additions and 4479 deletions

View File

@@ -5,6 +5,7 @@ A collection of advanced utilities and libraries for Minecraft ComputerCraft, wr
## Features
### 1. Access Control System
A comprehensive system for managing player access to a specific area. It uses a `playerDetector` to monitor for players in range and a `chatBox` to interact with them and administrators.
- **Player Detection:** Monitors a configurable range for players.
@@ -15,6 +16,7 @@ A comprehensive system for managing player access to a specific area. It uses a
- **Logging:** Detailed logging of events, viewable with the included `logviewer` program.
### 2. AutoCraft System
An automated crafting solution designed to work with the Create mod's packaged recipes.
- **Automated Crafting:** Detects cardboard packages in a chest and automatically crafts the recipes they contain.
@@ -22,6 +24,7 @@ An automated crafting solution designed to work with the Create mod's packaged r
- **Inventory Management:** Manages pulling ingredients from a source inventory and pushing crafted items to a destination.
### 3. ccTUI Framework
A declarative, reactive TUI (Terminal User Interface) framework inspired by [SolidJS](https://www.solidjs.com/) for building complex and interactive interfaces in ComputerCraft.
- **Declarative Syntax:** Build UIs with simple, composable functions like `div`, `label`, `button`, and `input`.
@@ -31,6 +34,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
- **Component-Based:** Structure your UI into reusable components. See `src/tuiExample/main.ts` for a demo.
### 4. ccCLI Framework
A lightweight, functional-style framework for building command-line interfaces (CLIs) within CC:Tweaked. It supports nested commands, arguments, options, and automatic help generation. See the [ccCLI Documentation](./docs/ccCLI.md) for more details.
- **Declarative API:** Define commands, arguments, and options using a simple, object-based structure.
@@ -40,6 +44,7 @@ A lightweight, functional-style framework for building command-line interfaces (
- **Type-Safe:** Built with TypeScript for robust development.
### 5. Core Libraries
- **`ChatManager`:** A powerful manager for `chatBox` peripherals that handles message queuing, cooldowns, and asynchronous sending/receiving. See the [ChatManager Documentation](./docs/ChatManager.md) for more details.
- **`ccStructLog`:** A modern, structured logging library inspired by Python's `structlog`. It provides a flexible, extensible framework based on processors, renderers, and streams, designed for CC:Tweaked. See the [ccStructLog Documentation](./docs/ccStructLog.md) for more details.
- **`PeripheralManager`:** A utility for easily finding and requiring peripherals by name or type.
@@ -54,6 +59,7 @@ A lightweight, functional-style framework for building command-line interfaces (
## Setup & Installation
1. **Clone the repository:**
```sh
git clone <repository-url>
cd cc-utils
@@ -69,6 +75,7 @@ A lightweight, functional-style framework for building command-line interfaces (
This project uses `just` to manage build tasks. The compiled Lua files will be placed in the `build/` directory.
- **Build all modules:**
```sh
just build
```
@@ -89,7 +96,7 @@ To deploy the built programs to your in-game computer, you need to configure the
```justfile
# Example for Linux
sync-path := "/home/user/.local/share/craftos-pc/computer/0/user/"
# Example for Windows
# sync-path := "/cygdrive/c/Users/YourUser/AppData/Roaming/CraftOS-PC/computer/0/user/"
```
@@ -105,44 +112,30 @@ To deploy the built programs to your in-game computer, you need to configure the
### Access Control
- **Start the system:**
```sh
accesscontrol start
```
- **Open the configuration TUI:**
```sh
accesscontrol config
```
Alternatively, press `c` while the main program is running.
- **View logs:**
```sh
logviewer accesscontrol.log
```
Alternatively, press `c` while the main program is running.
- **Admin Commands (in-game chat):**
```
@AC /help
@AC /add user Notch
@AC /list
@AC help
@AC add user Notch
@AC list
```
### AutoCraft
The autocraft program runs in the background. Simply run it on a turtle with the correct peripheral setup (see `src/autocraft/main.ts`). It will automatically process packages placed in the designated chest.
```sh
autocraft
```
### TUI Example
Run the example program to see a demonstration of the `ccTUI` framework.
```sh
tuiExample
```
## Development
- **Lint and format the code:**

View File

@@ -11,16 +11,37 @@ A modern, structured logging library for CC:Tweaked, inspired by Python's struct
## Quick Start
```typescript
import { createDevLogger } from "@/lib/ccStructLog";
The easiest way to get started is to create a `Logger` instance and configure it with processors, a renderer, and streams.
// Create a development logger
const logger = createDevLogger();
Here's a simple example of a logger that prints colored, human-readable messages to the console:
```typescript
import {
Logger,
LogLevel,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
// Create a logger
const logger = new Logger({
processors: [
processor.addTimestamp({ format: "%T" }), // Add HH:MM:SS timestamp
processor.filterByLevel(LogLevel.Info), // Log Info and higher
processor.addSource("MyApp"),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
// Log messages with context
logger.info("Server started", { port: 8080, version: "1.0.0" });
logger.warn("Low disk space", { available: 1024, threshold: 2048 });
logger.error("Connection failed", { host: "example.com", retries: 3 });
// This debug message will be filtered out by `filterByLevel`
logger.debug("This is a debug message.");
```
## Core Concepts
@@ -46,48 +67,69 @@ export enum LogLevel {
4. **Render**: The final event is converted to a string by a renderer (e.g., `textRenderer`, `jsonRenderer`).
5. **Output**: The string is sent to one or more streams (e.g., console, file).
## Pre-configured Loggers
## Common Configurations
### Development Logger
Optimized for development and debugging with human-readable console output.
A typical development logger is configured for human-readable console output with timestamps and colors.
```typescript
import { createDevLogger, LogLevel } from "@/lib/ccStructLog";
import {
Logger,
processor,
textRenderer,
ConsoleStream,
} from "@/lib/ccStructLog";
const logger = createDevLogger({
source: "MyApp",
includeComputerId: true,
const devLogger = new Logger({
processors: [
processor.addTimestamp({ format: "%F %T" }), // YYYY-MM-DD HH:MM:SS
processor.addSource("DevApp"),
processor.addComputerId(),
],
renderer: textRenderer,
streams: [new ConsoleStream()],
});
logger.debug("This is a debug message.");
devLogger.debug("This is a debug message.", { user: "dev" });
```
### Production Logger
Optimized for production with JSON-formatted file output and daily rotation.
```typescript
import { createProdLogger, DAY } from "@/lib/ccStructLog";
const logger = createProdLogger("app.log", {
source: "MyApp",
rotationInterval: DAY, // Rotate daily
includeConsole: false, // Don't log to console
});
logger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with a completely custom setup.
A production logger is often configured to write machine-readable JSON logs to a file with daily rotation.
```typescript
import {
Logger,
LogLevel,
addFullTimestamp,
addComputerId,
addSource,
processor,
jsonRenderer,
FileStream,
DAY,
} from "@/lib/ccStructLog";
const prodLogger = new Logger({
processors: [
processor.addTimestamp(), // Default format is %F %T
processor.filterByLevel(LogLevel.Info),
processor.addSource("ProdApp"),
processor.addComputerId(),
],
renderer: jsonRenderer,
streams: [
new FileStream("app.log", DAY), // Rotate daily
],
});
prodLogger.info("Application is running in production.");
```
## Custom Configuration
You can create a logger with any combination of processors, renderers, and streams.
```typescript
import {
Logger,
processor,
jsonRenderer,
FileStream,
ConsoleStream,
@@ -96,9 +138,9 @@ import {
const logger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("MyApplication"),
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("MyApplication"),
],
renderer: jsonRenderer,
streams: [
@@ -112,31 +154,40 @@ logger.info("Custom logger reporting for duty.", { user: "admin" });
## Processors
Processors are functions that modify, enrich, or filter log events before they are rendered.
Processors are functions that modify, enrich, or filter log events before they are rendered. They are all available under the `processor` namespace.
### Built-in Processors
```typescript
import {
addTimestamp, // Add structured timestamp
addFormattedTimestamp, // Add HH:MM:SS string
addFullTimestamp, // Add YYYY-MM-DD HH:MM:SS string
filterByLevel, // Filter by minimum level
filterBy, // Filter based on a custom predicate
addSource, // Add source/logger name
addComputerId, // Add computer ID
addComputerLabel, // Add computer label
addStaticFields, // Add static fields to all events
transformField, // Transform a specific field's value
removeFields, // Remove sensitive fields
} from "@/lib/ccStructLog";
import { Logger, LogLevel, processor } from "@/lib/ccStructLog";
// Usage example
const logger = new Logger({
processors: [
addTimestamp(),
addSource("MyApp"),
filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
removeFields(["password", "token"]),
// Adds a timestamp. Format is compatible with os.date().
// Default: "%F %T" (e.g., "2023-10-27 15:30:00")
processor.addTimestamp({ format: "%T" }), // e.g., "15:30:00"
// Filter by minimum level
processor.filterByLevel(LogLevel.Warn), // Only allow Warn, Error, Fatal
// Filter based on a custom predicate
processor.filterBy((event) => event.get("user") === "admin"),
// Add source/logger name
processor.addSource("MyApp"),
// Add computer ID or label
processor.addComputerId(),
processor.addComputerLabel(),
// Add static fields to all events
processor.addStaticFields({ env: "production", version: "1.2.3" }),
// Transform a specific field's value
processor.transformField("user_id", (id) => `user_${id}`),
// Remove sensitive fields
processor.removeFields(["password", "token"]),
],
// ... other config
});
@@ -172,10 +223,10 @@ Renderers convert the final `LogEvent` object into a string.
import { textRenderer, jsonRenderer } from "@/lib/ccStructLog";
// textRenderer: Human-readable, colored output for the console.
// Example: 15:30:45 [INFO] Message key=value
// Example: [15:30:45] [INFO] Message key=value
// jsonRenderer: Machine-readable JSON output.
// Example: {"level":"info","message":"Message","key":"value","timestamp":"..."}
// Example: {"level":2,"message":"Message","key":"value","timestamp":"15:30:45"}
```
## Streams
@@ -185,12 +236,14 @@ Streams handle the final output destination. You can use multiple streams to sen
### Built-in Streams
```typescript
import {
ConsoleStream, // Output to CC:Tweaked terminal with colors
FileStream, // Output to file with rotation support
BufferStream, // Store in an in-memory buffer
NullStream, // Discard all output
ConsoleStream,
FileStream,
BufferStream,
NullStream,
ConditionalStream,
LogLevel,
DAY,
} from "@/lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams"; // Note direct import
// File stream with daily rotation
const fileStream = new FileStream("app.log", DAY);
@@ -207,7 +260,7 @@ const errorStream = new ConditionalStream(
## File Rotation
`FileStream` supports automatic file rotation based on time intervals.
`FileStream` supports automatic file rotation based on time intervals. The rotation interval is specified in seconds as the second argument to the constructor.
```typescript
import { FileStream, HOUR, DAY, WEEK } from "@/lib/ccStructLog";
@@ -221,7 +274,7 @@ const dailyLog = new FileStream("app_daily.log", DAY);
// Rotate weekly
const weeklyLog = new FileStream("app_weekly.log", WEEK);
// No rotation
// No rotation (pass 0 or undefined)
const permanentLog = new FileStream("permanent.log", 0);
```
@@ -243,15 +296,22 @@ const permanentLog = new FileStream("permanent.log", 0);
- `error`: Errors that affect a single operation but not the whole app.
- `fatal`: Critical errors that require the application to shut down.
3. **Use a `source`**: Identify which component generated the log.
3. **Use a `source`**: Identify which component generated the log using `processor.addSource`.
```typescript
const logger = createDevLogger({ source: "UserService" });
import { Logger, processor } from "@/lib/ccStructLog";
const logger = new Logger({
processors: [processor.addSource("UserService")],
// ...
});
```
4. **Sanitize Sensitive Data**: Use a processor to remove passwords, API keys, etc.
```typescript
import { Logger, processor } from "@/lib/ccStructLog";
const secureLogger = new Logger({
processors: [ removeFields(["password", "token"]) ],
processors: [ processor.removeFields(["password", "token"]) ],
//...
});
```

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
import { createAccessControlCli } from "./cli";
import { launchAccessControlTUI } from "./tui";
@@ -7,15 +6,45 @@ import { ReadWriteLock } from "@/lib/mutex/ReadWriteLock";
import { ChatManager } from "@/lib/ChatManager";
import { gTimerManager } from "@/lib/TimerManager";
import { KeyEvent, pullEventAs } from "@/lib/event";
import {
ConditionalStream,
ConsoleStream,
DAY,
FileStream,
getStructLogger,
LoggerOptions,
LogLevel,
MB,
processor,
setStructLoggerConfig,
textRenderer,
} from "@/lib/ccStructLog";
const args = [...$vararg];
// Init Log
const logger = new CCLog("accesscontrol.log", {
printTerminal: true,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
let isOnConsoleStream = true;
const loggerConfig: LoggerOptions = {
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), () => isOnConsoleStream),
new FileStream({
filePath: "accesscontrol.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 7,
maxSizeBytes: MB,
},
}),
],
};
setStructLoggerConfig(loggerConfig);
const logger = getStructLogger();
// Load Config
const configFilepath = `${shell.dir()}/access.config.json`;
@@ -26,7 +55,7 @@ logger.debug(textutils.serialise(config, { allow_repetitions: true }));
// Peripheral
const playerDetector = peripheral.find(
"playerDetector",
"playerDetector",
)[0] as PlayerDetectorPeripheral;
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
const chatManager: ChatManager = new ChatManager([chatBox]);
@@ -37,410 +66,424 @@ let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gIsRunning = true;
interface ParseParams {
playerName?: string;
groupName?: string;
info?: PlayerInfo;
playerName?: string;
groupName?: string;
info?: PlayerInfo;
}
function reloadConfig() {
let releaser = configLock.tryAcquireWrite();
while (releaser === undefined) {
sleep(1);
releaser = configLock.tryAcquireWrite();
}
let releaser = configLock.tryAcquireWrite();
while (releaser === undefined) {
sleep(1);
releaser = configLock.tryAcquireWrite();
}
config = loadConfig(configFilepath)!;
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
config = loadConfig(configFilepath)!;
gInRangePlayers = [];
gWatchPlayersInfo = [];
releaser.release();
logger.info("Reload config successfully!");
const tutorial: string[] = [];
tutorial.push("Access Control System started.");
tutorial.push("\tPress 'c' to open configuration TUI.");
tutorial.push("\tPress 'r' to reload configuration.");
print(tutorial.join("\n"));
}
function safeParseTextComponent(
component: MinecraftTextComponent,
params?: ParseParams,
component: MinecraftTextComponent,
params?: ParseParams,
): MinecraftTextComponent {
const newComponent = deepCopy(component);
const newComponent = deepCopy(component);
if (newComponent.text == undefined) {
newComponent.text = "Wrong text, please contanct with admin";
} else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace(
"%playerName%",
params?.playerName ?? "UnknowPlayer",
);
newComponent.text = newComponent.text.replace(
"%groupName%",
params?.groupName ?? "UnknowGroup",
);
newComponent.text = newComponent.text.replace(
"%playerPosX%",
params?.info?.x.toString() ?? "UnknowPosX",
);
newComponent.text = newComponent.text.replace(
"%playerPosY%",
params?.info?.y.toString() ?? "UnknowPosY",
);
newComponent.text = newComponent.text.replace(
"%playerPosZ%",
params?.info?.z.toString() ?? "UnknowPosZ",
);
}
return newComponent;
if (newComponent.text == undefined) {
newComponent.text = "Wrong text, please contanct with admin";
} else if (newComponent.text.includes("%")) {
newComponent.text = newComponent.text.replace(
"%playerName%",
params?.playerName ?? "UnknowPlayer",
);
newComponent.text = newComponent.text.replace(
"%groupName%",
params?.groupName ?? "UnknowGroup",
);
newComponent.text = newComponent.text.replace(
"%playerPosX%",
params?.info?.x.toString() ?? "UnknowPosX",
);
newComponent.text = newComponent.text.replace(
"%playerPosY%",
params?.info?.y.toString() ?? "UnknowPosY",
);
newComponent.text = newComponent.text.replace(
"%playerPosZ%",
params?.info?.z.toString() ?? "UnknowPosZ",
);
}
return newComponent;
}
function sendMessage(
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
chatManager.sendMessage({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
chatManager.sendMessage({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
releaser.release();
releaser.release();
}
function sendToast(
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
toastConfig: ToastConfig,
targetPlayer: string,
params: ParseParams,
) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
chatManager.sendToast({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title,
params,
),
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
releaser.release();
chatManager.sendToast({
message: safeParseTextComponent(
toastConfig.msg ?? config.welcomeToastConfig.msg,
params,
),
title: safeParseTextComponent(
toastConfig.title ?? config.welcomeToastConfig.title,
params,
),
prefix: toastConfig.prefix ?? config.welcomeToastConfig.prefix,
brackets: toastConfig.brackets ?? config.welcomeToastConfig.brackets,
bracketColor:
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
targetPlayer: targetPlayer,
utf8Support: true,
});
releaser.release();
}
function sendNotice(player: string, playerInfo?: PlayerInfo) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const onlinePlayers = playerDetector.getOnlinePlayers();
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.flatMap((value) => value.groupUsers ?? []),
);
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
const onlinePlayers = playerDetector.getOnlinePlayers();
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.flatMap((value) => value.groupUsers ?? []),
);
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, {
playerName: player,
info: playerInfo,
});
sleep(1);
}
releaser.release();
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(config.noticeToastConfig, targetPlayer, {
playerName: player,
info: playerInfo,
});
sleep(1);
}
releaser.release();
}
function sendWarn(player: string) {
const warnMsg = `Not Allowed Player ${player} Break in Home `;
logger.warn(warnMsg);
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
sendToast(config.warnToastConfig, player, { playerName: player });
chatManager.sendMessage({
message: safeParseTextComponent(config.warnToastConfig.msg, {
playerName: player,
}),
targetPlayer: player,
prefix: "AccessControl",
brackets: "[]",
utf8Support: true,
});
releaser.release();
}
function watchLoop() {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
if (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
player.hasNoticeTimes += 1;
}
// Warn
if (config.isWarn) sendWarn(player.name);
// Record
logger.warn(
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(0.1);
continue;
}
const players = playerDetector.getPlayersInRange(config.detectRange);
const playersList = "[ " + players.join(",") + " ]";
logger.debug(`Detected ${players.length} players: ${playersList}`);
for (const player of players) {
if (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) {
logger.info(
`Admin ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.adminGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: "Admin",
info: playerInfo,
});
continue;
}
// New player appear
let groupConfig: UserGroupConfig = {
groupName: "Unfamiliar",
groupUsers: [],
isAllowed: false,
isNotice: false,
isWelcome: false,
};
// Get user group config
for (const userGroupConfig of config.usersGroups) {
if (userGroupConfig.groupUsers == undefined) continue;
if (!userGroupConfig.groupUsers.includes(player)) continue;
groupConfig = userGroupConfig;
logger.info(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (userGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
break;
}
if (groupConfig.isAllowed) continue;
logger.warn(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
if (config.isWarn) sendWarn(player);
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
while (gIsRunning) {
const event = pullEventAs(KeyEvent, "key");
if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
logger.setInTerminal(false);
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
} finally {
logger.setInTerminal(true);
reloadConfig();
}
} else if (event.key === keys.r) {
reloadConfig();
}
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
const warnMsg = `Not Allowed Player ${player} Break in Home `;
logger.warn(warnMsg);
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(ev.username);
sendToast(config.warnToastConfig, player, { playerName: player });
chatManager.sendMessage({
message: safeParseTextComponent(config.warnToastConfig.msg, {
playerName: player,
}),
targetPlayer: player,
prefix: "AccessControl",
brackets: "[]",
utf8Support: true,
});
releaser.release();
if (!isAdmin) continue;
if (!ev.message.startsWith("@AC")) continue;
}
printTargetPlayer = ev.username;
logger.info(
`Received command "${ev.message}" from admin ${printTargetPlayer}`,
);
function watchLoop() {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const commandArgs = ev.message
.substring(3)
.split(" ")
.filter((s) => s.length > 0);
logger.debug(`Command arguments: ${commandArgs.join(", ")}`);
const watchPlayerNames = gWatchPlayersInfo.flatMap(
(value) => value.name,
);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
if (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
player.hasNoticeTimes += 1;
}
cli(commandArgs);
printTargetPlayer = undefined;
}
// Warn
if (config.isWarn) sendWarn(player.name);
// Record
logger.warn(
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(0.1);
continue;
}
const players = playerDetector.getPlayersInRange(config.detectRange);
const playersList = "[ " + players.join(",") + " ]";
logger.debug(`Detected ${players.length} players: ${playersList}`);
for (const player of players) {
if (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) {
logger.info(
`Admin ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.adminGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: "Admin",
info: playerInfo,
});
continue;
}
// New player appear
let groupConfig: UserGroupConfig = {
groupName: "Unfamiliar",
groupUsers: [],
isAllowed: false,
isNotice: false,
isWelcome: false,
};
// Get user group config
for (const userGroupConfig of config.usersGroups) {
if (userGroupConfig.groupUsers == undefined) continue;
if (!userGroupConfig.groupUsers.includes(player)) continue;
groupConfig = userGroupConfig;
logger.info(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (userGroupConfig.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
break;
}
if (groupConfig.isAllowed) continue;
logger.warn(
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
if (config.isWelcome)
sendMessage(config.welcomeToastConfig, player, {
playerName: player,
groupName: groupConfig.groupName,
info: playerInfo,
});
if (config.isWarn) sendWarn(player);
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
while (gIsRunning) {
const event = pullEventAs(KeyEvent, "key");
if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
isOnConsoleStream = false;
launchAccessControlTUI();
logger.info("TUI closed, resuming normal operation");
} catch (error) {
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
} finally {
isOnConsoleStream = true;
reloadConfig();
}
} else if (event.key === keys.r) {
reloadConfig();
}
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const isAdmin = config.adminGroupConfig.groupUsers.includes(
ev.username,
);
releaser.release();
if (!isAdmin) continue;
if (!ev.message.startsWith("@AC")) continue;
printTargetPlayer = ev.username;
logger.info(
`Received command "${ev.message}" from admin ${printTargetPlayer}`,
);
const commandArgs = ev.message
.substring(3)
.split(" ")
.filter((s) => s.length > 0);
logger.debug(`Command arguments: ${commandArgs.join(", ")}`);
cli(commandArgs);
printTargetPlayer = undefined;
}
}
function main(args: string[]) {
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
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"));
parallel.waitForAll(
() => mainLoop(),
() => gTimerManager.run(),
() => cliLoop(),
() => watchLoop(),
() => keyboardLoop(),
() => chatManager.run(),
);
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
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"));
parallel.waitForAll(
() => mainLoop(),
() => gTimerManager.run(),
() => cliLoop(),
() => watchLoop(),
() => keyboardLoop(),
() => chatManager.run(),
);
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
logger.setInTerminal(false);
try {
launchAccessControlTUI();
} catch (error) {
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
}
return;
return;
} else if (args[0] == "config") {
logger.info("Launching Access Control TUI...");
isOnConsoleStream = false;
try {
launchAccessControlTUI();
} catch (error) {
logger.error(
`TUI error: ${textutils.serialise(error as object)}`,
);
}
return;
}
}
}
print(`Usage: accesscontrol start | config`);
print(" start - Start the access control system with monitoring");
print(" config - Open configuration TUI");
print(`Usage: accesscontrol start | config`);
print(" start - Start the access control system with monitoring");
print(" config - Open configuration TUI");
}
try {
main(args);
main(args);
} catch (error: unknown) {
logger.error(textutils.serialise(error as object));
logger.error(textutils.serialise(error as object));
} finally {
logger.close();
logger.close();
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,215 +1,275 @@
import {
CraftManager,
CraftRecipe,
CreatePackageTag,
CraftManager,
CraftRecipe,
CreatePackageTag,
} from "@/lib/CraftManager";
import { CCLog, LogLevel } from "@/lib/ccLog";
import { Queue } from "@/lib/datatype/Queue";
import {
ConsoleStream,
DAY,
FileStream,
Logger,
LogLevel,
processor,
textRenderer,
} from "@/lib/ccStructLog";
const logger = new CCLog("autocraft.log", { outputMinLevel: LogLevel.Info });
const logger = new Logger({
processors: [
processor.filterByLevel(LogLevel.Info),
processor.addTimestamp(),
],
renderer: textRenderer,
streams: [
new ConsoleStream(),
new FileStream({
filePath: "autocraft.log",
rotationInterval: DAY,
autoCleanup: {
enabled: true,
maxFiles: 3,
},
}),
],
});
const peripheralsNames = {
// packsInventory: "minecraft:chest_14",
// itemsInventory: "minecraft:chest_15",
// packageExtractor: "create:packager_3",
blockReader: "bottom",
wiredModem: "right",
redstone: "left",
packsInventory: "minecraft:chest_1121",
itemsInventory: "minecraft:chest_1120",
packageExtractor: "create:packager_0",
// packsInventory: "minecraft:chest_14",
// itemsInventory: "minecraft:chest_15",
// packageExtractor: "create:packager_3",
blockReader: "bottom",
wiredModem: "right",
redstone: "left",
packsInventory: "minecraft:chest_1121",
itemsInventory: "minecraft:chest_1120",
packageExtractor: "create:packager_0",
};
const packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
const itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
const packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
const blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
const wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
const turtleLocalName = wiredModem.getNameLocal();
let packsInventory: InventoryPeripheral;
let itemsInventory: InventoryPeripheral;
let packageExtractor: InventoryPeripheral;
let blockReader: BlockReaderPeripheral;
let wiredModem: WiredModemPeripheral;
let turtleLocalName: string;
enum State {
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
}
function main() {
const craftManager = new CraftManager(turtleLocalName, itemsInventory);
const recipesQueue = new Queue<CraftRecipe>();
const recipesWaitingMap = new Map<number, CraftRecipe[] | CraftRecipe>();
let currentState = State.IDLE;
let nextState = State.IDLE;
let hasPackage = redstone.getInput(peripheralsNames.redstone);
while (hasPackage) {
hasPackage = redstone.getInput(peripheralsNames.redstone);
logger.warn("redstone activated when init, please clear inventory");
sleep(1);
}
let isFinishedInitPeripheral = false;
while (!isFinishedInitPeripheral) {
try {
packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
turtleLocalName = wiredModem.getNameLocal();
logger.info("AutoCraft init finished...");
while (true) {
// Switch state
switch (currentState) {
case State.IDLE: {
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
break;
}
case State.READ_RECIPE: {
nextState = hasPackage ? State.READ_RECIPE : State.CRAFT_OUTPUT;
break;
}
case State.CRAFT_OUTPUT: {
nextState =
recipesQueue.size() > 0
? State.CRAFT_OUTPUT
: hasPackage
? State.READ_RECIPE
: State.IDLE;
break;
}
default: {
logger.error(`Unknown state`);
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
break;
}
logger.info("Peripheral initialization complete...");
isFinishedInitPeripheral = true;
} catch (error) {
logger.warn(
`Peripheral initialization failed for ${String(error)}, try again...`,
);
sleep(1);
}
}
// State logic
switch (currentState) {
case State.IDLE: {
if (!hasPackage) os.pullEvent("redstone");
const craftManager = new CraftManager(turtleLocalName, itemsInventory);
const recipesQueue = new Queue<CraftRecipe>();
const recipesWaitingMap = new Map<number, CraftRecipe[] | CraftRecipe>();
let currentState = State.IDLE;
let nextState = State.IDLE;
let hasPackage = redstone.getInput(peripheralsNames.redstone);
while (hasPackage) {
hasPackage = redstone.getInput(peripheralsNames.redstone);
break;
}
case State.READ_RECIPE: {
logger.info(`Package detected`);
const packagesInfoRecord = packsInventory.list();
for (const key in packagesInfoRecord) {
const slotNum = parseInt(key);
packsInventory.pushItems(turtleLocalName, slotNum);
// Get package NBT
logger.debug(
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
);
const packageDetailInfo = blockReader.getBlockData()?.Items[1];
if (packageDetailInfo === undefined) {
logger.error(`Package detail info not found`);
continue;
}
// Get OrderId and isFinal
const packageOrderId = (packageDetailInfo.tag as CreatePackageTag)
.Fragment.OrderId;
const packageIsFinal =
(packageDetailInfo.tag as CreatePackageTag).Fragment.IsFinal > 0
? true
: false;
// Get recipe
const packageRecipes =
CraftManager.getPackageRecipe(packageDetailInfo);
if (packageRecipes.isSome()) {
if (packageIsFinal) recipesQueue.enqueue(packageRecipes.value);
else recipesWaitingMap.set(packageOrderId, packageRecipes.value);
} else {
if (packageIsFinal && recipesWaitingMap.has(packageOrderId)) {
recipesQueue.enqueue(recipesWaitingMap.get(packageOrderId)!);
recipesWaitingMap.delete(packageOrderId);
} else {
logger.debug(`No recipe, just pass`);
}
}
packageExtractor.pullItems(turtleLocalName, 1);
}
if (
currentState === State.READ_RECIPE &&
nextState === State.CRAFT_OUTPUT
) {
craftManager.initItemsMap();
}
break;
}
case State.CRAFT_OUTPUT: {
// Check recipe
const recipe = recipesQueue.dequeue();
if (recipe === undefined) break;
let restCraftCnt = recipe.Count;
let maxSignleCraftCnt = restCraftCnt;
let craftItemDetail: ItemDetail | undefined = undefined;
do {
// Clear workbench
craftManager.clearTurtle();
logger.info(`Pull items according to a recipe`);
const craftCnt = craftManager
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
.unwrapOrElse((error) => {
logger.error(error.message);
return 0;
});
if (craftCnt == 0) break;
if (craftCnt < maxSignleCraftCnt) maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.info(`Craft ${craftCnt} times`);
restCraftCnt -= craftCnt;
} while (restCraftCnt > 0);
// Finally output
if (restCraftCnt > 0) {
logger.warn(
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
} else {
logger.info(
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
}
// Clear workbench and inventory
const turtleItemSlots = Object.values(
blockReader.getBlockData()!.Items,
).map((val) => val.Slot + 1);
craftManager.clearTurtle(turtleItemSlots);
break;
}
default: {
logger.warn("redstone activated when init, please clear inventory");
sleep(1);
break;
}
}
// Check packages
hasPackage = redstone.getInput(peripheralsNames.redstone);
// State update
currentState = nextState;
}
logger.info("AutoCraft init finished...");
while (true) {
// Switch state
switch (currentState) {
case State.IDLE: {
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
break;
}
case State.READ_RECIPE: {
nextState = hasPackage ? State.READ_RECIPE : State.CRAFT_OUTPUT;
break;
}
case State.CRAFT_OUTPUT: {
nextState =
recipesQueue.size() > 0
? State.CRAFT_OUTPUT
: hasPackage
? State.READ_RECIPE
: State.IDLE;
break;
}
default: {
logger.error(`Unknown state`);
nextState = hasPackage ? State.READ_RECIPE : State.IDLE;
break;
}
}
// State logic
switch (currentState) {
case State.IDLE: {
if (!hasPackage) os.pullEvent("redstone");
hasPackage = redstone.getInput(peripheralsNames.redstone);
break;
}
case State.READ_RECIPE: {
logger.info(`Package detected`);
const packagesInfoRecord = packsInventory.list();
for (const key in packagesInfoRecord) {
const slotNum = parseInt(key);
packsInventory.pushItems(turtleLocalName, slotNum);
// Get package NBT
logger.debug(
`Turtle:\n${textutils.serialise(blockReader.getBlockData()!, { allow_repetitions: true })}`,
);
const packageDetailInfo =
blockReader.getBlockData()?.Items[1];
if (packageDetailInfo === undefined) {
logger.error(`Package detail info not found`);
continue;
}
// Get OrderId and isFinal
const packageOrderId = (
packageDetailInfo.tag as CreatePackageTag
).Fragment.OrderId;
const packageIsFinal =
(packageDetailInfo.tag as CreatePackageTag).Fragment
.IsFinal > 0
? true
: false;
// Get recipe
const packageRecipes =
CraftManager.getPackageRecipe(packageDetailInfo);
if (packageRecipes.isSome()) {
if (packageIsFinal)
recipesQueue.enqueue(packageRecipes.value);
else
recipesWaitingMap.set(
packageOrderId,
packageRecipes.value,
);
} else {
if (
packageIsFinal &&
recipesWaitingMap.has(packageOrderId)
) {
recipesQueue.enqueue(
recipesWaitingMap.get(packageOrderId)!,
);
recipesWaitingMap.delete(packageOrderId);
} else {
logger.debug(`No recipe, just pass`);
}
}
packageExtractor.pullItems(turtleLocalName, 1);
}
if (
currentState === State.READ_RECIPE &&
nextState === State.CRAFT_OUTPUT
) {
craftManager.initItemsMap();
}
break;
}
case State.CRAFT_OUTPUT: {
// Check recipe
const recipe = recipesQueue.dequeue();
if (recipe === undefined) break;
let restCraftCnt = recipe.Count;
let maxSignleCraftCnt = restCraftCnt;
let craftItemDetail: ItemDetail | undefined = undefined;
do {
// Clear workbench
craftManager.clearTurtle();
logger.info(`Pull items according to a recipe`);
const craftCnt = craftManager
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
.unwrapOrElse((error) => {
logger.error(error.message);
return 0;
});
if (craftCnt == 0) break;
if (craftCnt < maxSignleCraftCnt)
maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.info(`Craft ${craftCnt} times`);
restCraftCnt -= craftCnt;
} while (restCraftCnt > 0);
// Finally output
if (restCraftCnt > 0) {
logger.warn(
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
} else {
logger.info(
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
}
// Clear workbench and inventory
const turtleItemSlots = Object.values(
blockReader.getBlockData()!.Items,
).map((val) => val.Slot + 1);
craftManager.clearTurtle(turtleItemSlots);
break;
}
default: {
sleep(1);
break;
}
}
// Check packages
hasPackage = redstone.getInput(peripheralsNames.redstone);
// State update
currentState = nextState;
}
}
try {
main();
main();
} catch (error: unknown) {
logger.error(textutils.serialise(error as object));
logger.error(textutils.serialise(error as object));
} finally {
logger.close();
logger.close();
}

View File

@@ -4,6 +4,9 @@
*/
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.
@@ -13,26 +16,19 @@ import { LogLevel, LoggerOptions, LogEvent, ILogger } from "./types";
*/
export class Logger implements ILogger {
private options: LoggerOptions;
private loggerName?: string;
/**
* Create a new Logger instance.
*
* @param options - Configuration options for the logger
* @param name - The name of the logger
*/
constructor(options: Partial<LoggerOptions>) {
this.options = {
processors: options.processors ?? [],
renderer: options.renderer ?? this.defaultRenderer,
streams: options.streams ?? [],
};
constructor(options: LoggerOptions, name?: string) {
this.options = options;
this.loggerName = name;
}
/**
* 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.
*
@@ -51,6 +47,8 @@ export class Logger implements ILogger {
["message", message],
...Object.entries(context),
]);
if (this.loggerName !== undefined)
event.set("loggerName", this.loggerName);
// 2. Process through the processor chain
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
if (event !== undefined) {
const finalEvent = event;
const output = this.options.renderer(finalEvent);
const output = this.options.renderer(event);
// Send to all configured streams
for (const stream of this.options.streams) {
stream.write(output, finalEvent);
stream.write(output, event);
}
}
}
@@ -163,3 +160,17 @@ export class Logger implements ILogger {
}
}
}
let globalLoggerConfig: LoggerOptions = {
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [new ConsoleStream()],
};
export function getStructLogger(name?: string): Logger {
return new Logger(globalLoggerConfig, name);
}
export function setStructLoggerConfig(config: LoggerOptions): void {
globalLoggerConfig = config;
}

View File

@@ -7,150 +7,14 @@
*/
// Re-export all core types and classes
export {
LogLevel,
LogEvent,
Processor,
Renderer,
Stream,
LoggerOptions,
ILogger,
} from "./types";
export { Logger } from "./Logger";
export * from "./types";
export * from "./Logger";
// Re-export all processors
export {
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
filterByLevel,
addSource,
addComputerId,
addComputerLabel,
filterBy,
transformField,
removeFields,
addStaticFields,
} from "./processors";
export * from "./processors";
// Re-export all renderers
export { jsonRenderer, textRenderer } from "./renderers";
export * from "./renderers";
// Re-export all streams
export {
ConsoleStream,
FileStream,
BufferStream,
NullStream,
SECOND,
MINUTE,
HOUR,
DAY,
WEEK,
} from "./streams";
import { Logger } from "./Logger";
import { LogLevel, LogEvent } from "./types";
import {
addFormattedTimestamp,
addFullTimestamp,
addComputerId,
} from "./processors";
import { textRenderer, jsonRenderer } from "./renderers";
import { ConsoleStream, FileStream, DAY } from "./streams";
/**
* Create a development logger with console output and colored formatting.
*
* This logger is optimized for development and debugging, with:
* - Debug level and above
* - Formatted timestamps
* - Computer ID tracking
* - Human-readable console output with colors
*
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for development
*/
export function createDevLogger(
options: {
level?: LogLevel;
source?: string;
includeComputerId?: boolean;
} = {},
): Logger {
const processors = [addFormattedTimestamp()];
if (options.includeComputerId !== false) {
processors.push(addComputerId());
}
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
return new Logger({
processors,
renderer: textRenderer,
streams: [new ConsoleStream()],
});
}
/**
* Create a production logger with file output and JSON formatting.
*
* This logger is optimized for production environments, with:
* - Info level and above
* - Full timestamps
* - Computer ID and label tracking
* - JSON output for machine processing
* - Daily file rotation
*
* @param filename - Base filename for log files
* @param options - Optional configuration to override defaults
* @returns A configured Logger instance for production
*/
export function createProdLogger(
filename: string,
options: {
level?: LogLevel;
source?: string;
rotationInterval?: number;
includeConsole?: boolean;
} = {},
): Logger {
const processors = [
addFullTimestamp(),
addComputerId(),
(event: LogEvent) => {
const label = os.getComputerLabel();
if (label) {
event.set("computer_label", label);
}
return event;
},
];
if (options.source) {
processors.push((event: LogEvent) => {
event.set("source", options.source);
return event;
});
}
const streams: Array<ConsoleStream | FileStream> = [
new FileStream(filename, options.rotationInterval ?? DAY),
];
if (options.includeConsole) {
streams.push(new ConsoleStream());
}
return new Logger({
processors,
renderer: jsonRenderer,
streams,
});
}
export * from "./streams";

View File

@@ -8,213 +8,180 @@
import { LogEvent, Processor, LogLevel } from "./types";
/**
* Adds a timestamp to the log event.
*
* This processor adds the current time as a structured timestamp object
* using CC:Tweaked's os.date() function. The timestamp includes year,
* month, day, hour, minute, and second components.
*
* Performance note: os.date() is relatively expensive, so this should
* typically be placed early in the processor chain and used only once.
*
* @param event - The log event to process
* @returns The event with timestamp added
*/
export function addTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
event.set("timestamp", timestamp);
return event;
};
}
/**
* Adds a human-readable timestamp string to the log event.
*
* This processor adds a formatted timestamp string that's easier to read
* in log output. The format is "HH:MM:SS" in UTC time.
*
* @param event - The log event to process
* @returns The event with formatted timestamp added
*/
export function addFormattedTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const timeStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("time", timeStr);
return event;
};
}
/**
* Adds a full ISO-like timestamp string to the log event.
*
* This processor adds a complete timestamp in YYYY-MM-DD HH:MM:SS format
* which is useful for file logging and structured output.
*
* @param event - The log event to process
* @returns The event with full timestamp added
*/
export function addFullTimestamp(): Processor {
return (event) => {
const timestamp = os.date("!*t") as LuaDate;
const fullTimeStr = `${timestamp.year}-${string.format("%02d", timestamp.month)}-${string.format("%02d", timestamp.day)} ${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
event.set("datetime", fullTimeStr);
return event;
};
}
/**
* Filters log events by minimum level.
*
* This processor drops log events that are below the specified minimum level.
* Note: The Logger class already does early filtering for performance, but
* this processor can be useful for dynamic filtering or when you need
* different levels for different streams.
*
* @param minLevel - The minimum log level to allow through
* @returns A processor function that filters by level
*/
export function filterByLevel(minLevel: LogLevel): Processor {
return (event) => {
const eventLevel = event.get("level") as LogLevel | undefined;
if (eventLevel === undefined) {
return event; // Pass through if no level is set
}
if (eventLevel !== undefined && eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* 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.
*
* In CC:Tweaked environments, this can help identify which computer
* generated the log when logs are aggregated from multiple sources.
*
* @param event - The log event to process
* @returns The event with computer ID added
*/
export function addComputerId(): Processor {
return (event) => {
event.set("computer_id", os.getComputerID());
return event;
};
}
/**
* Adds the current computer label to the log event.
*
* If the computer has a label set, this adds it to the log event.
* This can be more human-readable than the computer ID.
*
* @param event - The log event to process
* @returns The event with computer label added (if available)
*/
export function addComputerLabel(): Processor {
return (event) => {
const label = os.getComputerLabel();
if (label !== undefined && label !== null) {
event.set("computer_label", label);
}
return event;
};
}
/**
* Filters out events that match a specific condition.
*
* This is a generic processor that allows you to filter events based on
* any custom condition. The predicate function should return true to keep
* the event and false to drop it.
*
* @param predicate - Function that returns true to keep the event
* @returns A processor function that filters based on the predicate
*/
export function filterBy(predicate: (event: LogEvent) => boolean): Processor {
return (event) => {
return predicate(event) ? event : undefined;
};
}
/**
* Transforms a specific field in the log event.
*
* This processor allows you to modify the value of a specific field
* using a transformation function.
*
* @param fieldName - The name of the field to transform
* @param transformer - Function to transform the field value
* @returns A processor function that transforms the specified field
*/
export function transformField(
fieldName: string,
transformer: (value: unknown) => unknown,
): Processor {
return (event) => {
if (event.has(fieldName)) {
const currentValue = event.get(fieldName);
const newValue = transformer(currentValue);
event.set(fieldName, newValue);
}
return event;
};
}
/**
* Removes specified fields from the log event.
*
* This processor can be used to strip sensitive or unnecessary information
* from log events before they are rendered and output.
*
* @param fieldNames - Array of field names to remove
* @returns A processor function that removes the specified fields
*/
export function removeFields(fieldNames: string[]): Processor {
return (event) => {
for (const fieldName of fieldNames) {
event.delete(fieldName);
}
return event;
};
}
/**
* Adds static fields to every log event.
*
* This processor adds the same set of fields to every log event that
* passes through it. Useful for adding application name, version,
* environment, etc.
*
* @param fields - Object containing the static fields to add
* @returns A processor function that adds the static fields
*/
export function addStaticFields(fields: Record<string, unknown>): Processor {
return (event) => {
for (const [key, value] of Object.entries(fields)) {
event.set(key, value);
}
return event;
};
export namespace processor {
/**
* Configuration options for the timestamp processor.
*/
interface TimestampConfig {
/**
* The format string takes the same formats as C's strftime function.
*/
format?: string;
}
/**
* Adds a timestamp to each log event.
*
* This processor adds a "time" field to each log event with the current
* timestamp. The timestamp format can be customized using the `format`
* option.
*
* @param config - Configuration options for the timestamp processor.
* @returns A processor function that adds a timestamp to each log event.
*/
export function addTimestamp(config: TimestampConfig = {}): Processor {
return (event) => {
let time: string;
if (config.format === undefined) {
time = os.date("%F %T") as string;
} else {
time = os.date(config.format) as string;
}
event.set("timestamp", time);
return event;
};
}
/**
* Filters log events by minimum level.
*
* This processor drops log events that are below the specified minimum level.
* Note: The Logger class already does early filtering for performance, but
* this processor can be useful for dynamic filtering or when you need
* different levels for different streams.
*
* @param minLevel - The minimum log level to allow through
* @returns A processor function that filters by level
*/
export function filterByLevel(minLevel: LogLevel): Processor {
return (event) => {
const eventLevel = event.get("level") as LogLevel | undefined;
if (eventLevel === undefined) {
return event; // Pass through if no level is set
}
if (eventLevel < minLevel) {
return undefined; // Drop the log event
}
return event;
};
}
/**
* Adds the current computer ID to the log event.
*
* In CC:Tweaked environments, this can help identify which computer
* generated the log when logs are aggregated from multiple sources.
*
* @param event - The log event to process
* @returns The event with computer ID added
*/
export function addComputerId(): Processor {
return (event) => {
event.set("computer_id", os.getComputerID());
return event;
};
}
/**
* Adds the current computer label to the log event.
*
* If the computer has a label set, this adds it to the log event.
* This can be more human-readable than the computer ID.
*
* @param event - The log event to process
* @returns The event with computer label added (if available)
*/
export function addComputerLabel(): Processor {
return (event) => {
const label = os.getComputerLabel();
if (label !== undefined && label !== null) {
event.set("computer_label", label);
}
return event;
};
}
/**
* Filters out events that match a specific condition.
*
* This is a generic processor that allows you to filter events based on
* any custom condition. The predicate function should return true to keep
* the event and false to drop it.
*
* @param predicate - Function that returns true to keep the event
* @returns A processor function that filters based on the predicate
*/
export function filterBy(
predicate: (event: LogEvent) => boolean,
): Processor {
return (event) => {
return predicate(event) ? event : undefined;
};
}
/**
* Transforms a specific field in the log event.
*
* This processor allows you to modify the value of a specific field
* using a transformation function.
*
* @param fieldName - The name of the field to transform
* @param transformer - Function to transform the field value
* @returns A processor function that transforms the specified field
*/
export function transformField(
fieldName: string,
transformer: (value: unknown) => unknown,
): Processor {
return (event) => {
if (event.has(fieldName)) {
const currentValue = event.get(fieldName);
const newValue = transformer(currentValue);
event.set(fieldName, newValue);
}
return event;
};
}
/**
* Removes specified fields from the log event.
*
* This processor can be used to strip sensitive or unnecessary information
* from log events before they are rendered and output.
*
* @param fieldNames - Array of field names to remove
* @returns A processor function that removes the specified fields
*/
export function removeFields(fieldNames: string[]): Processor {
return (event) => {
for (const fieldName of fieldNames) {
event.delete(fieldName);
}
return event;
};
}
/**
* Adds static fields to every log event.
*
* This processor adds the same set of fields to every log event that
* passes through it. Useful for adding application name, version,
* environment, etc.
*
* @param fields - Object containing the static fields to add
* @returns A processor function that adds the static fields
*/
export function addStaticFields(
fields: Record<string, unknown>,
): Processor {
return (event) => {
for (const [key, value] of Object.entries(fields)) {
event.set(key, value);
}
return event;
};
}
}

View File

@@ -6,7 +6,7 @@
* different output formats (JSON, console-friendly, etc.).
*/
import { Renderer } from "./types";
import { LogLevel, Renderer } from "./types";
/**
* Renders log events as JSON strings.
@@ -43,44 +43,36 @@ export const jsonRenderer: Renderer = (event) => {
* timestamp, level, message, and additional context fields formatted
* in a readable way.
*
* Format: [HH:MM:SS] [LEVEL] message { key=value, key2=value2 }
* Format: [YYYY-MM-DD HH:MM:SS] [LEVEL] message key=value, key2=value2
*
* @param event - The log event to render
* @returns Human-readable string representation
*/
export const textRenderer: Renderer = (event) => {
// Extract core components
const timestamp = event.get("timestamp") as LuaDate | undefined;
const timeStr = event.get("time") as string | undefined;
const level = (event.get("level") as string)?.toUpperCase() ?? "UNKNOWN";
const timeStr = event.get("timestamp") as string | undefined;
const level: string | undefined = LogLevel[event.get("level") as LogLevel];
const message = (event.get("message") as string) ?? "";
// Format timestamp
let timestampStr = "";
if (timeStr) {
timestampStr = timeStr;
} else if (timestamp) {
timestampStr = `${string.format("%02d", timestamp.hour)}:${string.format("%02d", timestamp.min)}:${string.format("%02d", timestamp.sec)}`;
}
const loggerName = event.get("loggerName") as string | undefined;
// Start building the output
let output = `[${timestampStr}] [${level}] ${message}`;
let output = `${timeStr} [${level}] ${message} \t ${loggerName !== undefined ? "[" + loggerName + "]" : ""}`;
// Add context fields (excluding the core fields we already used)
const contextFields: string[] = [];
for (const [key, value] of event.entries()) {
if (
key !== "timestamp" &&
key !== "time" &&
key !== "level" &&
key !== "message"
key !== "message" &&
key !== "loggerName"
) {
contextFields.push(`${key}=${tostring(value)}`);
}
}
if (contextFields.length > 0) {
output += ` { ${contextFields.join(", ")} }`;
output += contextFields.join(", ");
}
return output;

View File

@@ -6,7 +6,34 @@
* implements the Stream interface and handles its own output logic.
*/
import { 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.
@@ -16,14 +43,14 @@ import { Stream, LogEvent } from "./types";
* color after writing each message.
*/
export class ConsoleStream implements Stream {
private levelColors: Map<string, number> = new Map([
["trace", colors.lightGray],
["debug", colors.gray],
["info", colors.white],
["warn", colors.orange],
["error", colors.red],
["fatal", colors.red],
]);
private levelColors: { [key: string]: number } = {
Trace: colors.lightGray,
Debug: colors.gray,
Info: colors.green,
Warn: colors.orange,
Error: colors.red,
Fatal: colors.red,
};
/**
* Write a formatted log message to the terminal.
@@ -32,8 +59,9 @@ export class ConsoleStream implements Stream {
* @param event - The original log event for context (used for level-based coloring)
*/
public write(message: string, event: LogEvent): void {
const level = event.get("level") as string | undefined;
const color = level ? this.levelColors.get(level) : undefined;
const level: string | undefined =
LogLevel[event.get("level") as LogLevel];
const color = level !== undefined ? this.levelColors[level] : undefined;
if (color !== undefined) {
const originalColor = term.getTextColor();
@@ -58,20 +86,20 @@ export class FileStream implements Stream {
private filePath: string;
private rotationInterval: 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 rotationInterval - Time in seconds between file rotations (0 = no rotation)
* @param config - FileStream configuration object
*/
constructor(filePath: string, rotationInterval: number = 0) {
this.filePath = filePath;
this.rotationInterval = rotationInterval;
constructor(config: FileStreamConfig) {
this.filePath = config.filePath;
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.baseFilename = filePath;
this.openFile();
}
@@ -93,24 +121,27 @@ export class FileStream implements Stream {
return;
}
this.fileHandle = handle;
// Perform auto-cleanup when opening file
this.performAutoCleanup();
}
/**
* Generate a filename with timestamp for file rotation.
*/
private getRotatedFilename(): string {
const currentTime = os.time();
const currentTime = os.time(os.date("*t"));
const rotationPeriod =
Math.floor(currentTime / this.rotationInterval) *
this.rotationInterval;
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
const splitStrs = this.baseFilename.split(".");
const splitStrs = this.filePath.split(".");
if (splitStrs.length === 1) {
return `${this.baseFilename}_${timestamp}.log`;
return `${this.filePath}_${timestamp}.log`;
}
const name = splitStrs[0];
@@ -125,19 +156,50 @@ export class FileStream implements Stream {
if (this.rotationInterval <= 0) return;
const currentTime = os.time();
const currentPeriod = Math.floor(currentTime / this.rotationInterval);
const lastPeriod = Math.floor(
this.lastRotationTime / this.rotationInterval,
);
if (currentPeriod > lastPeriod) {
if (
Math.floor(
(currentTime - this.lastRotationTime) / this.rotationInterval,
) > 0
) {
// Time to rotate
this.close();
this.lastRotationTime = currentTime;
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.
*
@@ -162,6 +224,123 @@ export class FileStream implements Stream {
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}`);
}
}
}
}
/**
@@ -306,3 +485,7 @@ export const MINUTE = 60 * SECOND;
export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
// Byte constants for file rotation
export const MB = 1024 * 1024;
export const KB = 1024;

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,8 +11,8 @@ import { UIObject } from "./UIObject";
* @returns Terminal width and height
*/
function getTerminalSize(): { width: number; height: number } {
const [w, h] = term.getSize();
return { width: w, height: h };
const [w, h] = term.getSize();
return { width: w, height: h };
}
/**
@@ -25,224 +25,227 @@ function getTerminalSize(): { width: number; height: number } {
* @returns Width and height of the element
*/
function measureNode(
node: UIObject,
parentWidth?: number,
parentHeight?: number,
node: UIObject,
parentWidth?: number,
parentHeight?: number,
): { width: number; height: number } {
// Get text content if it exists
const getTextContent = (): string => {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return node.textContent();
}
return node.textContent;
}
// For nodes with text children, get their content
if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return child.textContent();
}
return child.textContent!;
}
return "";
};
// Check for explicit size styling first
let measuredWidth: number | undefined;
let measuredHeight: number | undefined;
// Handle width styling
if (node.styleProps.width !== undefined) {
if (node.styleProps.width === "screen") {
const termSize = getTerminalSize();
measuredWidth = termSize.width;
} else if (node.styleProps.width === "full" && parentWidth !== undefined) {
measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width;
}
}
// Handle height styling
if (node.styleProps.height !== undefined) {
if (node.styleProps.height === "screen") {
const termSize = getTerminalSize();
measuredHeight = termSize.height;
} else if (
node.styleProps.height === "full" &&
parentHeight !== undefined
) {
measuredHeight = parentHeight;
} else if (typeof node.styleProps.height === "number") {
measuredHeight = node.styleProps.height;
}
}
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent();
const naturalWidth = text.length;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "button": {
const text = getTextContent();
// Buttons have brackets around them: [text]
const naturalWidth = text.length + 2;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "input": {
const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") {
const naturalWidth = 3; // [X] or [ ]
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
// Text input - use a default width or from props
const defaultWidth = node.props.width ?? 20;
const naturalHeight = 1;
return {
width: measuredWidth ?? defaultWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "div":
case "form":
case "for":
case "show":
case "switch":
case "match":
case "fragment":
case "scroll-container": {
// Container elements size based on their children
let totalWidth = 0;
let totalHeight = 0;
if (node.children.length === 0) {
const naturalWidth = 0;
const naturalHeight = 0;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
const direction = node.layoutProps.flexDirection ?? "row";
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// For scroll containers, calculate content size and update scroll bounds
if (node.type === "scroll-container" && node.scrollProps) {
// Calculate actual content size without viewport constraints
const childParentWidth = undefined; // No width constraint for content measurement
const childParentHeight = undefined; // No height constraint for content measurement
if (direction === "row") {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
if (node.children.length > 1) {
totalWidth += gap * (node.children.length - 1);
}
} else {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
if (node.children.length > 1) {
totalHeight += gap * (node.children.length - 1);
}
// Get text content if it exists
const getTextContent = (): string => {
if (node.textContent !== undefined) {
if (typeof node.textContent === "function") {
return node.textContent();
}
return node.textContent;
}
// Update scroll bounds with actual content size
node.updateScrollBounds(totalWidth, totalHeight);
// Return viewport size as the container size
return {
width: measuredWidth ?? node.scrollProps.viewportWidth,
height: measuredHeight ?? node.scrollProps.viewportHeight,
};
}
// Calculate available space for children (non-scroll containers)
const childParentWidth = measuredWidth ?? parentWidth;
const childParentHeight = measuredHeight ?? parentHeight;
if (direction === "row") {
// In row direction, width is sum of children, height is max
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
// For nodes with text children, get their content
if (
node.children.length > 0 &&
node.children[0].textContent !== undefined
) {
const child = node.children[0];
if (typeof child.textContent === "function") {
return child.textContent();
}
return child.textContent!;
}
if (node.children.length > 1) {
totalWidth += gap * (node.children.length - 1);
}
} else {
// In column direction, height is sum of children, width is max
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
if (node.children.length > 1) {
totalHeight += gap * (node.children.length - 1);
}
}
return {
width: measuredWidth ?? totalWidth,
height: measuredHeight ?? totalHeight,
};
return "";
};
// Check for explicit size styling first
let measuredWidth: number | undefined;
let measuredHeight: number | undefined;
// Handle width styling
if (node.styleProps.width !== undefined) {
if (node.styleProps.width === "screen") {
const termSize = getTerminalSize();
measuredWidth = termSize.width;
} else if (
node.styleProps.width === "full" &&
parentWidth !== undefined
) {
measuredWidth = parentWidth;
} else if (typeof node.styleProps.width === "number") {
measuredWidth = node.styleProps.width;
}
}
default:
return {
width: measuredWidth ?? 0,
height: measuredHeight ?? 0,
};
}
// Handle height styling
if (node.styleProps.height !== undefined) {
if (node.styleProps.height === "screen") {
const termSize = getTerminalSize();
measuredHeight = termSize.height;
} else if (
node.styleProps.height === "full" &&
parentHeight !== undefined
) {
measuredHeight = parentHeight;
} else if (typeof node.styleProps.height === "number") {
measuredHeight = node.styleProps.height;
}
}
switch (node.type) {
case "label":
case "h1":
case "h2":
case "h3": {
const text = getTextContent();
const naturalWidth = text.length;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "button": {
const text = getTextContent();
// Buttons have brackets around them: [text]
const naturalWidth = text.length + 2;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "input": {
const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") {
const naturalWidth = 3; // [X] or [ ]
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
// Text input - use a default width or from props
const defaultWidth = node.props.width ?? 20;
const naturalHeight = 1;
return {
width: measuredWidth ?? defaultWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "div":
case "form":
case "for":
case "show":
case "switch":
case "match":
case "fragment":
case "scroll-container": {
// Container elements size based on their children
let totalWidth = 0;
let totalHeight = 0;
if (node.children.length === 0) {
const naturalWidth = 0;
const naturalHeight = 0;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
const direction = node.layoutProps.flexDirection ?? "row";
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// For scroll containers, calculate content size and update scroll bounds
if (node.type === "scroll-container" && node.scrollProps) {
// Calculate actual content size without viewport constraints
const childParentWidth = undefined; // No width constraint for content measurement
const childParentHeight = undefined; // No height constraint for content measurement
if (direction === "row") {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
if (node.children.length > 1) {
totalWidth += gap * (node.children.length - 1);
}
} else {
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
if (node.children.length > 1) {
totalHeight += gap * (node.children.length - 1);
}
}
// Update scroll bounds with actual content size
node.updateScrollBounds(totalWidth, totalHeight);
// Return viewport size as the container size
return {
width: measuredWidth ?? node.scrollProps.viewportWidth,
height: measuredHeight ?? node.scrollProps.viewportHeight,
};
}
// Calculate available space for children (non-scroll containers)
const childParentWidth = measuredWidth ?? parentWidth;
const childParentHeight = measuredHeight ?? parentHeight;
if (direction === "row") {
// In row direction, width is sum of children, height is max
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
if (node.children.length > 1) {
totalWidth += gap * (node.children.length - 1);
}
} else {
// In column direction, height is sum of children, width is max
for (const child of node.children) {
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
if (node.children.length > 1) {
totalHeight += gap * (node.children.length - 1);
}
}
return {
width: measuredWidth ?? totalWidth,
height: measuredHeight ?? totalHeight,
};
}
default:
return {
width: measuredWidth ?? 0,
height: measuredHeight ?? 0,
};
}
}
/**
@@ -255,150 +258,158 @@ function measureNode(
* @param startY - Starting Y position
*/
export function calculateLayout(
node: UIObject,
availableWidth: number,
availableHeight: number,
startX = 1,
startY = 1,
node: UIObject,
availableWidth: number,
availableHeight: number,
startX = 1,
startY = 1,
): void {
// Set this node's layout
node.layout = {
x: startX,
y: startY,
width: availableWidth,
height: availableHeight,
};
// Set this node's layout
node.layout = {
x: startX,
y: startY,
width: availableWidth,
height: availableHeight,
};
if (node.children.length === 0) {
return;
}
const direction = node.layoutProps.flexDirection ?? "row";
const justify = node.layoutProps.justifyContent ?? "start";
const align = node.layoutProps.alignItems ?? "start";
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// Handle scroll container layout
if (node.type === "scroll-container" && node.scrollProps) {
// For scroll containers, position children based on scroll offset
const scrollOffsetX = -node.scrollProps.scrollX;
const scrollOffsetY = -node.scrollProps.scrollY;
for (const child of node.children) {
// Calculate child's natural size and position it with scroll offset
const childSize = measureNode(
child,
node.scrollProps.contentWidth,
node.scrollProps.contentHeight,
);
const childX = startX + scrollOffsetX;
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY);
if (node.children.length === 0) {
return;
}
return;
}
// Measure all children
const childMeasurements = node.children.map((child: UIObject) =>
measureNode(child, availableWidth, availableHeight),
);
const direction = node.layoutProps.flexDirection ?? "row";
const justify = node.layoutProps.justifyContent ?? "start";
const align = node.layoutProps.alignItems ?? "start";
// Calculate total size needed
let totalMainAxisSize = 0;
let maxCrossAxisSize = 0;
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
if (direction === "row") {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.width;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height);
// Handle scroll container layout
if (node.type === "scroll-container" && node.scrollProps) {
// For scroll containers, position children based on scroll offset
const scrollOffsetX = -node.scrollProps.scrollX;
const scrollOffsetY = -node.scrollProps.scrollY;
for (const child of node.children) {
// Calculate child's natural size and position it with scroll offset
const childSize = measureNode(
child,
node.scrollProps.contentWidth,
node.scrollProps.contentHeight,
);
const childX = startX + scrollOffsetX;
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(
child,
childSize.width,
childSize.height,
childX,
childY,
);
}
return;
}
} else {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.height;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width);
}
}
// Add gaps to total size
if (node.children.length > 1) {
totalMainAxisSize += gap * (node.children.length - 1);
}
// Measure all children
const childMeasurements = node.children.map((child: UIObject) =>
measureNode(child, availableWidth, availableHeight),
);
// Calculate starting position based on justify-content
let mainAxisPos = 0;
let spacing = 0;
if (direction === "row") {
const remainingSpace = availableWidth - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
} else {
const remainingSpace = availableHeight - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
}
// Position each child
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const measure = childMeasurements[i];
let childX = startX;
let childY = startY;
// Calculate total size needed
let totalMainAxisSize = 0;
let maxCrossAxisSize = 0;
if (direction === "row") {
// Main axis is horizontal
childX = startX + math.floor(mainAxisPos);
// Cross axis (vertical) alignment
if (align === "center") {
childY = startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") {
childY = startY + (availableHeight - measure.height);
} else {
childY = startY; // start
}
mainAxisPos += measure.width + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
for (const measure of childMeasurements) {
totalMainAxisSize += measure.width;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.height);
}
} else {
// Main axis is vertical
childY = startY + math.floor(mainAxisPos);
// Cross axis (horizontal) alignment
if (align === "center") {
childX = startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") {
childX = startX + (availableWidth - measure.width);
} else {
childX = startX; // start
}
mainAxisPos += measure.height + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
for (const measure of childMeasurements) {
totalMainAxisSize += measure.height;
maxCrossAxisSize = math.max(maxCrossAxisSize, measure.width);
}
}
// Recursively calculate layout for child
calculateLayout(child, measure.width, measure.height, childX, childY);
}
// Add gaps to total size
if (node.children.length > 1) {
totalMainAxisSize += gap * (node.children.length - 1);
}
// Calculate starting position based on justify-content
let mainAxisPos = 0;
let spacing = 0;
if (direction === "row") {
const remainingSpace = availableWidth - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
} else {
const remainingSpace = availableHeight - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
mainAxisPos = remainingSpace;
} else if (justify === "between" && node.children.length > 1) {
spacing = remainingSpace / (node.children.length - 1);
}
}
// Position each child
for (let i = 0; i < node.children.length; i++) {
const child = node.children[i];
const measure = childMeasurements[i];
let childX = startX;
let childY = startY;
if (direction === "row") {
// Main axis is horizontal
childX = startX + math.floor(mainAxisPos);
// Cross axis (vertical) alignment
if (align === "center") {
childY =
startY + math.floor((availableHeight - measure.height) / 2);
} else if (align === "end") {
childY = startY + (availableHeight - measure.height);
} else {
childY = startY; // start
}
mainAxisPos += measure.width + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
} else {
// Main axis is vertical
childY = startY + math.floor(mainAxisPos);
// Cross axis (horizontal) alignment
if (align === "center") {
childX =
startX + math.floor((availableWidth - measure.width) / 2);
} else if (align === "end") {
childX = startX + (availableWidth - measure.width);
} else {
childX = startX; // start
}
mainAxisPos += measure.height + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
}
// Recursively calculate layout for child
calculateLayout(child, measure.width, measure.height, childX, childY);
}
}

View File

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

View File

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

View File

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

View File

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

View File

View File

@@ -7,56 +7,16 @@
import {
Logger,
createDevLogger,
createProdLogger,
// Processors
addTimestamp,
addFormattedTimestamp,
addFullTimestamp,
addSource,
addComputerId,
addStaticFields,
transformField,
// Renderers
textRenderer,
jsonRenderer,
// Streams
processor,
ConsoleStream,
FileStream,
BufferStream,
DAY,
ConditionalStream,
HOUR,
jsonRenderer,
LogLevel,
textRenderer,
} from "../lib/ccStructLog";
import { ConditionalStream } from "@/lib/ccStructLog/streams";
// =============================================================================
// Basic Usage Examples
// =============================================================================
print("=== Basic Usage Examples ===");
// 1. Quick start with pre-configured loggers
const devLog = createDevLogger();
devLog.info("Application started", { version: "1.0.0", port: 8080 });
devLog.debug("Debug information", { userId: 123, action: "login" });
devLog.error("Something went wrong", {
error: "Connection failed",
retries: 3,
});
// 2. Production logging to file
const prodLog = createProdLogger("app.log", {
source: "MyApplication",
rotationInterval: DAY,
includeConsole: true,
});
prodLog.info("User action", { userId: 456, action: "purchase", amount: 29.99 });
prodLog.warn("Low disk space", { available: 1024, threshold: 2048 });
// =============================================================================
// Custom Logger Configurations
@@ -67,16 +27,22 @@ print("\n=== Custom Logger Configurations ===");
// 4. Custom logger with specific processors and renderer
const customLogger = new Logger({
processors: [
addFullTimestamp(),
addComputerId(),
addSource("CustomApp"),
addStaticFields({
processor.addTimestamp(),
processor.addComputerId(),
processor.addSource("CustomApp"),
processor.addStaticFields({
environment: "development",
version: "2.1.0",
}),
],
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", {
@@ -109,10 +75,10 @@ const sanitizePasswords = (event: Map<string, unknown>) => {
const secureLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
addRequestId,
sanitizePasswords,
transformField("message", (msg) => `[SECURE] ${msg}`),
processor.transformField("message", (msg) => `[SECURE] ${msg}`),
],
renderer: jsonRenderer,
streams: [new ConsoleStream()],
@@ -133,7 +99,7 @@ print("\n=== Stream Examples ===");
// 11. Buffer stream for batch processing
const bufferStream = new BufferStream(100); // Keep last 100 messages
const bufferLogger = new Logger({
processors: [addFormattedTimestamp()],
processors: [processor.addTimestamp()],
renderer: textRenderer,
streams: [
new ConditionalStream(new ConsoleStream(), (msg, event) => {
@@ -162,7 +128,7 @@ for (const msg of bufferedMessages) {
// 12. Multi-stream with different formats
const multiFormatLogger = new Logger({
processors: [addFullTimestamp(), addComputerId()],
processors: [processor.addTimestamp(), processor.addComputerId()],
renderer: (event) => "default", // This won't be used
streams: [
// Console with human-readable format
@@ -176,7 +142,10 @@ const multiFormatLogger = new Logger({
{
write: (_, event) => {
const formatted = jsonRenderer(event);
new FileStream("structured.log").write(formatted, event);
new FileStream({ filePath: "structured.log" }).write(
formatted,
event,
);
},
},
],
@@ -196,7 +165,7 @@ print("\n=== Error Handling Examples ===");
// 13. Robust error handling
const robustLogger = new Logger({
processors: [
addTimestamp(),
processor.addTimestamp(),
// Processor that might fail
(event) => {
try {
@@ -231,9 +200,9 @@ print("\n=== Cleanup Examples ===");
// 14. Proper cleanup
const fileLogger = new Logger({
processors: [addTimestamp()],
processors: [processor.addTimestamp()],
renderer: jsonRenderer,
streams: [new FileStream("temp.log")],
streams: [new FileStream({ filePath: "structured.log" })],
});
fileLogger.info("Temporary log entry");
@@ -249,37 +218,3 @@ print("- all.log (complete log)");
print("- debug.log (detailed debug info)");
print("- structured.log (JSON format)");
print("- temp.log (temporary file, now closed)");
// =============================================================================
// Performance Comparison (commented out to avoid noise)
// =============================================================================
/*
print("\n=== Performance Comparison ===");
const iterations = 1000;
// Test simple console logging
const startTime1 = os.clock();
const simpleLogger = createMinimalLogger();
for (let i = 0; i < iterations; i++) {
simpleLogger.info(`Simple message ${i}`);
}
const endTime1 = os.clock();
print(`Simple Console Logger: ${endTime1 - startTime1} seconds`);
// Test complex processor chain
const startTime2 = os.clock();
const complexLogger = createDetailedLogger("perf_test.log", {
source: "PerfTest"
});
for (let i = 0; i < iterations; i++) {
complexLogger.info(`Complex message ${i}`, {
iteration: i,
data: { nested: { value: i * 2 } }
});
}
complexLogger.close();
const endTime2 = os.clock();
print(`Complex Processor Chain: ${endTime2 - startTime2} seconds`);
*/