mirror of
https://github.com/SikongJueluo/cc-utils.git
synced 2025-11-29 12:57:50 +08:00
Compare commits
27 Commits
1fe3052e5d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66b46c6d70 | ||
|
|
de97fb4858 | ||
|
|
0612477325 | ||
|
|
82a9fec46d | ||
|
|
94f0de4c90 | ||
|
|
cf7ddefc2e | ||
|
|
3287661318 | ||
|
|
6d5cf11f2b | ||
|
|
a4e74dcfa0 | ||
|
|
2f57d9ab3d | ||
|
|
7e03d960bd | ||
|
|
f76a3666b1 | ||
|
|
d6971fb22f | ||
|
|
796bf1c2dc | ||
|
|
959ec0c424 | ||
|
|
e680ef0263 | ||
|
|
1891259ee7 | ||
| 7a17ca7fbf | |||
|
|
f7167576cd | ||
|
|
2ab091d939 | ||
|
|
119bc1997a | ||
|
|
ac70e1acd3 | ||
|
|
d90574e514 | ||
|
|
4e71fbffc3 | ||
| a3479865c8 | |||
|
|
9d9dcade7b | ||
|
|
6304518f0e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,7 +5,7 @@ build/
|
||||
reference/
|
||||
src/**/*.md
|
||||
|
||||
QWEN.md
|
||||
.ai/
|
||||
|
||||
# Devenv
|
||||
.devenv*
|
||||
|
||||
18
.justfile
18
.justfile
@@ -5,16 +5,24 @@ sync-path := if os_family() == "windows" { "/cygdrive/c/Users/sikongjueluo/AppDa
|
||||
build: build-autocraft build-accesscontrol build-test build-example sync
|
||||
|
||||
build-autocraft:
|
||||
pnpm tstl -p ./tsconfig.autocraft.json
|
||||
pnpm tstl -p ./targets/tsconfig.autocraft.json
|
||||
|
||||
build-accesscontrol:
|
||||
pnpm tstl -p ./tsconfig.accesscontrol.json
|
||||
pnpm tstl -p ./targets/tsconfig.accesscontrol.json
|
||||
|
||||
build-test:
|
||||
pnpm tstl -p ./tsconfig.test.json
|
||||
pnpm tstl -p ./targets/tsconfig.test.json
|
||||
|
||||
build-example:
|
||||
pnpm tstl -p ./tsconfig.tuiExample.json
|
||||
build-example: build-tuiExample build-cliExample build-logExample
|
||||
|
||||
build-tuiExample:
|
||||
pnpm tstl -p ./targets/tsconfig.tuiExample.json
|
||||
|
||||
build-cliExample:
|
||||
pnpm tstl -p ./targets/tsconfig.cliExample.json
|
||||
|
||||
build-logExample:
|
||||
pnpm tstl -p ./targets/tsconfig.logExample.json
|
||||
|
||||
sync:
|
||||
rsync --delete -r "./build/" "{{ sync-path }}"
|
||||
|
||||
51
README.md
51
README.md
@@ -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`.
|
||||
@@ -30,8 +33,20 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
|
||||
- **Control Flow:** Includes `<For>` and `<Show>` components for conditional and list-based rendering.
|
||||
- **Component-Based:** Structure your UI into reusable components. See `src/tuiExample/main.ts` for a demo.
|
||||
|
||||
### 4. Core Libraries
|
||||
- **`ccLog`:** A robust logging library with automatic, time-based log file rotation.
|
||||
### 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.
|
||||
- **Nested Commands:** Organize complex applications with subcommands (e.g., `mycli command subcommand`).
|
||||
- **Automatic Help:** Generates detailed help messages for commands and subcommands.
|
||||
- **Global Context:** Inject shared state or services into command actions.
|
||||
- **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.
|
||||
- **`CraftManager`:** A library for parsing and executing crafting recipes from Create mod packages.
|
||||
|
||||
@@ -44,6 +59,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
|
||||
## Setup & Installation
|
||||
|
||||
1. **Clone the repository:**
|
||||
|
||||
```sh
|
||||
git clone <repository-url>
|
||||
cd cc-utils
|
||||
@@ -59,6 +75,7 @@ A declarative, reactive TUI (Terminal User Interface) framework inspired by [Sol
|
||||
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
|
||||
```
|
||||
@@ -79,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/"
|
||||
```
|
||||
@@ -95,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:**
|
||||
@@ -142,4 +145,4 @@ tuiExample
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
This project is licensed under the MIT License. See the [LICENSE](./LICENSE) file for details.
|
||||
|
||||
235
docs/ChatManager.md
Normal file
235
docs/ChatManager.md
Normal file
@@ -0,0 +1,235 @@
|
||||
# ChatManager Documentation
|
||||
|
||||
## Introduction
|
||||
|
||||
`ChatManager` is a powerful utility for managing interactions with one or more `chatBox` peripherals in ComputerCraft. It simplifies the process of sending and receiving chat messages by handling complexities like peripheral cooldowns, message queuing, and asynchronous operations.
|
||||
|
||||
It is designed for applications that need to reliably send a high volume of messages or toasts without getting bogged down by peripheral limitations, or for applications that need to listen for commands or messages from players.
|
||||
|
||||
## Features
|
||||
|
||||
* **Multi-Peripheral Management:** Seamlessly manages one or more `chatBox` peripherals.
|
||||
* **Message Queuing:** Automatically queues messages and toasts, sending them as chatboxes become available.
|
||||
* **Cooldown Handling:** Respects the 1-second cooldown of chatboxes to prevent message loss.
|
||||
* **Asynchronous Operation:** Can run in the background (`runAsync`) without blocking your main program loop.
|
||||
* **Message Buffering:** Receives and buffers incoming chat messages for your application to process.
|
||||
* **Queued and Immediate Sending:** Supports both adding messages to a queue and sending them immediately (if a chatbox is available).
|
||||
* **Rich Content Support:** Send simple strings or complex formatted messages using `MinecraftTextComponent`.
|
||||
* **Robust Error Handling:** Uses a `Result`-based API to make error handling explicit and reliable.
|
||||
* **Comprehensive API:** Provides methods for sending global messages, private messages, and toast notifications.
|
||||
|
||||
## Tutorial: Getting Started with ChatManager
|
||||
|
||||
Here’s how to integrate `ChatManager` into your project.
|
||||
|
||||
### 1. Initialization
|
||||
|
||||
First, find your available `chatBox` peripherals and create a `ChatManager` instance.
|
||||
|
||||
```typescript
|
||||
import { ChatManager } from '@/lib/ChatManager';
|
||||
|
||||
// Find all available chatbox peripherals
|
||||
const peripheralNames = peripheral.getNames();
|
||||
const chatboxPeripherals: ChatBoxPeripheral[] = [];
|
||||
|
||||
for (const name of peripheralNames) {
|
||||
const peripheralType = peripheral.getType(name);
|
||||
if (peripheralType[0] === "chatBox") {
|
||||
const chatbox = peripheral.wrap(name) as ChatBoxPeripheral;
|
||||
chatboxPeripherals.push(chatbox);
|
||||
}
|
||||
}
|
||||
|
||||
if (chatboxPeripherals.length === 0) {
|
||||
print("Error: No chatbox peripherals found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Create the manager instance
|
||||
const chatManager = new ChatManager(chatboxPeripherals);
|
||||
```
|
||||
|
||||
### 2. Running the Manager
|
||||
|
||||
To start the sending and receiving loops, you must run the manager. For most use cases, running it asynchronously is best.
|
||||
|
||||
```typescript
|
||||
// Start ChatManager in the background so it doesn't block the main program
|
||||
const runResult = chatManager.runAsync();
|
||||
|
||||
if (runResult.isErr()) {
|
||||
print(`Warning: Failed to start ChatManager: ${runResult.error.reason}`);
|
||||
} else {
|
||||
print("ChatManager started successfully!");
|
||||
}
|
||||
|
||||
// Your main program logic can continue here...
|
||||
```
|
||||
**Important:** `ChatManager` relies on `gTimerManager` to handle cooldowns. Ensure you are also running the global timer manager in your application.
|
||||
```typescript
|
||||
import { gTimerManager } from "@/lib/TimerManager";
|
||||
|
||||
// In your main parallel loop
|
||||
parallel.waitForAll(
|
||||
() => yourMainLoop(),
|
||||
() => chatManager.run(), // if you choose the blocking run
|
||||
() => gTimerManager.run()
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Sending a Message (Queued)
|
||||
|
||||
Use `sendMessage` to add a message to the queue. `ChatManager` will send it as soon as a chatbox is free.
|
||||
|
||||
```typescript
|
||||
// Send a global message
|
||||
chatManager.sendMessage({
|
||||
message: "Hello, world!",
|
||||
prefix: "MySystem",
|
||||
});
|
||||
|
||||
// Send a private message
|
||||
chatManager.sendMessage({
|
||||
message: "This is a secret.",
|
||||
targetPlayer: "Steve",
|
||||
prefix: "Whisper",
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Sending a Toast (Queued)
|
||||
|
||||
Similarly, use `sendToast` to queue a toast notification.
|
||||
|
||||
```typescript
|
||||
chatManager.sendToast({
|
||||
title: "Server Alert",
|
||||
message: "Restart in 5 minutes!",
|
||||
targetPlayer: "Steve",
|
||||
prefix: "Admin",
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Receiving Messages
|
||||
|
||||
Use `getReceivedMessage` to pull incoming chat events from the buffer. It's best to do this in a loop.
|
||||
|
||||
```typescript
|
||||
function myCliLoop() {
|
||||
while (true) {
|
||||
const result = chatManager.getReceivedMessage();
|
||||
if (result.isOk()) {
|
||||
const event = result.value;
|
||||
print(`[${event.username}]: ${event.message}`);
|
||||
// Process the command or message...
|
||||
} else {
|
||||
// Buffer is empty, wait a bit before checking again
|
||||
sleep(0.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Immediate Sending
|
||||
|
||||
If you need to send a message right away and bypass the queue, use the `Immediate` methods. These will fail if no chatbox is currently available.
|
||||
|
||||
```typescript
|
||||
const result = chatManager.sendMessageImmediate({
|
||||
message: "URGENT!",
|
||||
targetPlayer: "Admin",
|
||||
});
|
||||
|
||||
if (result.isErr()) {
|
||||
if (result.error.kind === "NoIdleChatbox") {
|
||||
print("Could not send immediately: all chatboxes are busy.");
|
||||
} else {
|
||||
print(`Failed to send message: ${result.error.reason}`);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rich Text Messages (`MinecraftTextComponent`)
|
||||
|
||||
You can send fully formatted messages by providing a `MinecraftTextComponent` object instead of a string.
|
||||
|
||||
```typescript
|
||||
const richMessage: MinecraftTextComponent = {
|
||||
text: "This is ",
|
||||
color: "gold",
|
||||
extra: [
|
||||
{ text: "important!", color: "red", bold: true }
|
||||
],
|
||||
};
|
||||
|
||||
chatManager.sendMessage({
|
||||
message: richMessage,
|
||||
targetPlayer: "AllPlayers",
|
||||
utf8Support: true, // Recommended for complex components
|
||||
});
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Methods return a `Result` object (`Ok` or `Err`). Always check the result to handle potential failures gracefully.
|
||||
|
||||
```typescript
|
||||
const result = chatManager.sendMessage(message);
|
||||
if (result.isErr()) {
|
||||
logger.error(`Failed to queue message: ${result.error.reason}`);
|
||||
}
|
||||
```
|
||||
|
||||
The possible error `kind`s are:
|
||||
* `ChatManagerError`: General errors, e.g., failure to enqueue.
|
||||
* `NoIdleChatboxError`: Returned by `Immediate` methods when no chatbox is free.
|
||||
* `SendFailureError`: A hardware or permission error occurred during sending.
|
||||
* `EmptyBufferError`: Returned by `getReceivedMessage` when the buffer is empty.
|
||||
|
||||
### Status and Management
|
||||
|
||||
You can inspect and control the `ChatManager` at runtime.
|
||||
|
||||
```typescript
|
||||
// Get the number of items waiting to be sent
|
||||
const pending = chatManager.getPendingMessageCount();
|
||||
print(`Messages in queue: ${pending}`);
|
||||
|
||||
// Get the number of received messages waiting to be processed
|
||||
const buffered = chatManager.getBufferedMessageCount();
|
||||
print(`Received messages in buffer: ${buffered}`);
|
||||
|
||||
// Get the status of each chatbox (true = idle, false = busy)
|
||||
const statuses = chatManager.getChatboxStatus();
|
||||
|
||||
// Clear the sending queues
|
||||
chatManager.clearQueues();
|
||||
|
||||
// Clear the received message buffer
|
||||
chatManager.clearBuffer();
|
||||
|
||||
// Stop the manager's background loops
|
||||
chatManager.stop();
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Core Class
|
||||
* `ChatManager(peripherals: ChatBoxPeripheral[])`
|
||||
|
||||
### Primary Methods
|
||||
* `run(): Result<void, ChatManagerError>`: Starts the manager (blocking).
|
||||
* `runAsync(): Result<LuaThread, ChatManagerError>`: Starts the manager in the background.
|
||||
* `stop(): Result<void, ChatManagerError>`: Stops the background loops.
|
||||
* `sendMessage(message: ChatMessage): Result<void, ChatManagerError>`: Queues a chat message.
|
||||
* `sendToast(toast: ChatToast): Result<void, ChatManagerError>`: Queues a toast.
|
||||
* `getReceivedMessage(): Result<ChatBoxEvent, EmptyBufferError>`: Retrieves a message from the receive buffer.
|
||||
* `sendMessageImmediate(message: ChatMessage): Result<void, ChatError>`: Sends a message immediately.
|
||||
* `sendToastImmediate(toast: ChatToast): Result<void, ChatError>`: Sends a toast immediately.
|
||||
|
||||
### Interfaces
|
||||
* `ChatMessage`
|
||||
* `ChatToast`
|
||||
* `ChatError` (union of all possible error types)
|
||||
248
docs/ccCLI.md
Normal file
248
docs/ccCLI.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# ccCLI Framework Documentation
|
||||
|
||||
## Introduction
|
||||
|
||||
`ccCLI` is a lightweight, functional-style framework for building command-line interfaces (CLIs) within the CC:Tweaked environment using TSTL (TypeScriptToLua). It provides a declarative and type-safe way to define commands, arguments, and options, with built-in support for nested commands, automatic help generation, and robust error handling.
|
||||
|
||||
Its design is inspired by modern CLI libraries and emphasizes simplicity and ease of use, allowing developers to quickly structure complex command-based applications.
|
||||
|
||||
## Features
|
||||
|
||||
* **Declarative API:** Define commands as simple objects.
|
||||
* **Type-Safe:** Leverage TypeScript for defining commands, arguments, options, and context.
|
||||
* **Nested Commands:** Easily create command groups and subcommands (e.g., `git remote add`).
|
||||
* **Automatic Help Generation:** Generates `--help` messages for the root command and all subcommands.
|
||||
* **Flexible Argument & Option Parsing:** Supports long names (`--verbose`), short names (`-v`), value assignment (`--file=path.txt`), and boolean flags.
|
||||
* **Global Context Injection:** Share state, services, or configuration across all commands.
|
||||
* **Result-Based Error Handling:** Command actions return a `Result` type, ensuring that errors are handled explicitly.
|
||||
* **No Dependencies:** Written in pure TypeScript with no external runtime dependencies.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
The framework is built around a few key interfaces:
|
||||
|
||||
* `Command<TContext>`: The central piece. It defines a command's name, description, arguments, options, subcommands, and the action to perform.
|
||||
* `Argument`: Defines a positional argument for a command. It can be marked as required.
|
||||
* `Option`: Defines a named option (flag). It can have a long name, a short name, a default value, and be marked as required.
|
||||
* `ActionContext<TContext>`: The object passed to every command's `action` function. It contains the parsed `args`, `options`, and the shared `context` object.
|
||||
|
||||
## Tutorial: Creating a Simple Calculator CLI
|
||||
|
||||
Let's build a simple calculator to see how `ccCLI` works.
|
||||
|
||||
### 1. Define the Global Context (Optional)
|
||||
|
||||
The global context is a powerful feature for sharing data or services. Let's define a context for our app.
|
||||
|
||||
```typescript
|
||||
// src/cliExample/main.ts
|
||||
|
||||
interface AppContext {
|
||||
appName: string;
|
||||
log: (message: string) => void;
|
||||
debugMode: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Define Commands
|
||||
|
||||
Commands are just JavaScript objects. The logic goes into the `action` function.
|
||||
|
||||
```typescript
|
||||
// src/cliExample/main.ts
|
||||
|
||||
import { Command, CliError } from "../lib/ccCLI/index";
|
||||
import { Ok, Result } from "../lib/thirdparty/ts-result-es";
|
||||
|
||||
const addCommand: Command<AppContext> = {
|
||||
name: "add",
|
||||
description: "Adds two numbers together",
|
||||
args: [
|
||||
{ name: "a", description: "The first number", required: true },
|
||||
{ name: "b", description: "The second number", required: true },
|
||||
],
|
||||
action: ({ args, context }): Result<void, CliError> => {
|
||||
context.log(`Executing 'add' command in '${context.appName}'`);
|
||||
|
||||
const a = tonumber(args.a as string);
|
||||
const b = tonumber(args.b as string);
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
print("Error: Arguments must be numbers.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const result = a + b;
|
||||
print(`${a} + ${b} = ${result}`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 3. Create Nested Commands
|
||||
|
||||
You can group commands under a parent command using the `subcommands` property.
|
||||
|
||||
```typescript
|
||||
// src/cliExample/main.ts
|
||||
|
||||
// (addCommand is defined above, subtractCommand would be similar)
|
||||
|
||||
const mathCommand: Command<AppContext> = {
|
||||
name: "math",
|
||||
description: "Mathematical operations",
|
||||
subcommands: new Map([
|
||||
["add", addCommand],
|
||||
["subtract", subtractCommand], // Assuming subtractCommand is defined
|
||||
]),
|
||||
};
|
||||
```
|
||||
If a command with subcommands is called without an action, it will automatically display its help page.
|
||||
|
||||
### 4. Define the Root Command
|
||||
|
||||
The root command is the entry point for your entire application. It contains all top-level commands and global options.
|
||||
|
||||
```typescript
|
||||
// src/cliExample/main.ts
|
||||
|
||||
const rootCommand: Command<AppContext> = {
|
||||
name: "calculator",
|
||||
description: "A feature-rich calculator program",
|
||||
options: new Map([
|
||||
[
|
||||
"debug",
|
||||
{
|
||||
name: "debug",
|
||||
shortName: "d",
|
||||
description: "Enable debug mode",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
subcommands: new Map([
|
||||
["math", mathCommand],
|
||||
// other commands...
|
||||
]),
|
||||
action: ({ context }) => {
|
||||
print(`Welcome to ${context.appName}!`);
|
||||
print("Use --help to see available commands");
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### 5. Create and Run the CLI
|
||||
|
||||
Finally, create the context instance and pass it along with the root command to `createCli`. This returns a handler function that you can call with the program's arguments.
|
||||
|
||||
```typescript
|
||||
// src/cliExample/main.ts
|
||||
|
||||
import { createCli } from "../lib/ccCLI/index";
|
||||
|
||||
// Create global context instance
|
||||
const appContext: AppContext = {
|
||||
appName: "MyAwesome Calculator",
|
||||
debugMode: false,
|
||||
log: (message) => {
|
||||
if (appContext.debugMode) {
|
||||
print(`[LOG] ${message}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
// Create the CLI handler
|
||||
const cli = createCli(rootCommand, { globalContext: appContext });
|
||||
|
||||
// Get arguments and run
|
||||
const args = [...$vararg];
|
||||
cli(args);
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
You can now run your CLI from the ComputerCraft terminal:
|
||||
|
||||
```sh
|
||||
> lua program.lua math add 5 7
|
||||
12
|
||||
|
||||
> lua program.lua --debug math add 5 7
|
||||
[LOG] Executing 'add' command in 'MyAwesome Calculator'
|
||||
12
|
||||
|
||||
> lua program.lua math --help
|
||||
# Displays help for the 'math' command
|
||||
```
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Arguments
|
||||
|
||||
Arguments are positional values passed after a command. They are defined in an array.
|
||||
|
||||
```typescript
|
||||
args: [
|
||||
{ name: "a", description: "The first number", required: true },
|
||||
{ name: "b", description: "The second number" }, // optional
|
||||
],
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
Options are named values (flags) that can appear anywhere. They are defined in a `Map`.
|
||||
|
||||
```typescript
|
||||
options: new Map([
|
||||
[
|
||||
"name", // The key in the map must match the option's name
|
||||
{
|
||||
name: "name",
|
||||
shortName: "n",
|
||||
description: "The name to greet",
|
||||
defaultValue: "World",
|
||||
},
|
||||
],
|
||||
[
|
||||
"force",
|
||||
{
|
||||
name: "force",
|
||||
description: "Force the operation",
|
||||
defaultValue: false, // For boolean flags
|
||||
},
|
||||
],
|
||||
]),
|
||||
```
|
||||
|
||||
They can be used like this:
|
||||
* `--name "John"` or `-n "John"`
|
||||
* `--name="John"`
|
||||
* `--force` (sets the value to `true`)
|
||||
|
||||
### Error Handling
|
||||
|
||||
The `action` function must return a `Result<void, CliError>`.
|
||||
* Return `Ok.EMPTY` on success.
|
||||
* The framework automatically handles parsing errors like missing arguments or unknown commands. You can return your own errors from within an action if needed, though this is less common. The primary mechanism is simply printing an error message and returning `Ok.EMPTY`.
|
||||
|
||||
## API Reference
|
||||
|
||||
The public API is exposed through `src/lib/ccCLI/index.ts`.
|
||||
|
||||
### Core Function
|
||||
|
||||
* `createCli<TContext>(rootCommand, options)`: Creates the main CLI handler function.
|
||||
* `rootCommand`: The top-level command of your application.
|
||||
* `options.globalContext`: The context object to be injected into all actions.
|
||||
* `options.writer`: An optional function to handle output (defaults to `textutils.pagedPrint`).
|
||||
|
||||
### Core Types
|
||||
|
||||
* `Command<TContext>`
|
||||
* `Argument`
|
||||
* `Option`
|
||||
* `ActionContext<TContext>`
|
||||
* `CliError`
|
||||
|
||||
This documentation provides a comprehensive overview of the `ccCLI` framework. By following the tutorial and referencing the examples, you can build powerful and well-structured command-line tools for CC:Tweaked.
|
||||
341
docs/ccStructLog.md
Normal file
341
docs/ccStructLog.md
Normal file
@@ -0,0 +1,341 @@
|
||||
# ccStructLog
|
||||
|
||||
A modern, structured logging library for CC:Tweaked, inspired by Python's structlog. This library provides a flexible, extensible logging framework based on processors, renderers, and streams.
|
||||
|
||||
## Features
|
||||
|
||||
- **Structured Logging**: Log events are represented as key-value pairs, not just strings.
|
||||
- **Extensible**: Easy to customize with processors, renderers, and streams.
|
||||
- **Type Safe**: Full TypeScript support with proper type definitions.
|
||||
- **CC:Tweaked Optimized**: Designed specifically for Minecraft's ComputerCraft environment, with features like file rotation and colored console output.
|
||||
|
||||
## Quick Start
|
||||
|
||||
The easiest way to get started is to create a `Logger` instance and configure it with processors, a renderer, and streams.
|
||||
|
||||
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
|
||||
|
||||
### Log Levels
|
||||
|
||||
```typescript
|
||||
export enum LogLevel {
|
||||
Trace = 0, // Very detailed diagnostic information
|
||||
Debug = 1, // Diagnostic information for development
|
||||
Info = 2, // General informational messages
|
||||
Warn = 3, // Potentially harmful situations
|
||||
Error = 4, // Error events that might allow continued execution
|
||||
Fatal = 5, // Very severe errors that might cause termination
|
||||
}
|
||||
```
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Capture**: User calls `logger.info("message", {key: "value"})`.
|
||||
2. **Package**: A `LogEvent` object (`Map<string, unknown>`) is created with the message, context, and metadata.
|
||||
3. **Process**: The event is passed through a chain of processors (e.g., to add a timestamp, filter by level).
|
||||
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).
|
||||
|
||||
## Common Configurations
|
||||
|
||||
### Development Logger
|
||||
A typical development logger is configured for human-readable console output with timestamps and colors.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Logger,
|
||||
processor,
|
||||
textRenderer,
|
||||
ConsoleStream,
|
||||
} from "@/lib/ccStructLog";
|
||||
|
||||
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()],
|
||||
});
|
||||
|
||||
devLogger.debug("This is a debug message.", { user: "dev" });
|
||||
```
|
||||
|
||||
### Production Logger
|
||||
A production logger is often configured to write machine-readable JSON logs to a file with daily rotation.
|
||||
|
||||
```typescript
|
||||
import {
|
||||
Logger,
|
||||
LogLevel,
|
||||
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,
|
||||
HOUR,
|
||||
} from "@/lib/ccStructLog";
|
||||
|
||||
const logger = new Logger({
|
||||
processors: [
|
||||
processor.addTimestamp(),
|
||||
processor.addComputerId(),
|
||||
processor.addSource("MyApplication"),
|
||||
],
|
||||
renderer: jsonRenderer,
|
||||
streams: [
|
||||
new ConsoleStream(),
|
||||
new FileStream("custom.log", HOUR), // Rotate every hour
|
||||
],
|
||||
});
|
||||
|
||||
logger.info("Custom logger reporting for duty.", { user: "admin" });
|
||||
```
|
||||
|
||||
## Processors
|
||||
|
||||
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 { Logger, LogLevel, processor } from "@/lib/ccStructLog";
|
||||
|
||||
// Usage example
|
||||
const logger = new Logger({
|
||||
processors: [
|
||||
// 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
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Processors
|
||||
A custom processor is a function that takes a `LogEvent` and returns a `LogEvent` or `undefined` (to drop the event).
|
||||
|
||||
```typescript
|
||||
import { LogEvent } from "@/lib/ccStructLog";
|
||||
|
||||
// Add a unique request ID to all log events
|
||||
const addRequestId = (event: LogEvent): LogEvent => {
|
||||
event.set("requestId", `req_${Math.random().toString(36).substr(2, 9)}`);
|
||||
return event;
|
||||
};
|
||||
|
||||
// Sanitize sensitive information
|
||||
const sanitizePasswords = (event: LogEvent): LogEvent => {
|
||||
if (event.has("password")) {
|
||||
event.set("password", "[REDACTED]");
|
||||
}
|
||||
return event;
|
||||
};
|
||||
```
|
||||
|
||||
## Renderers
|
||||
|
||||
Renderers convert the final `LogEvent` object into a string.
|
||||
|
||||
### Built-in Renderers
|
||||
```typescript
|
||||
import { textRenderer, jsonRenderer } from "@/lib/ccStructLog";
|
||||
|
||||
// textRenderer: Human-readable, colored output for the console.
|
||||
// Example: [15:30:45] [INFO] Message key=value
|
||||
|
||||
// jsonRenderer: Machine-readable JSON output.
|
||||
// Example: {"level":2,"message":"Message","key":"value","timestamp":"15:30:45"}
|
||||
```
|
||||
|
||||
## Streams
|
||||
|
||||
Streams handle the final output destination. You can use multiple streams to send logs to different places.
|
||||
|
||||
### Built-in Streams
|
||||
```typescript
|
||||
import {
|
||||
ConsoleStream,
|
||||
FileStream,
|
||||
BufferStream,
|
||||
NullStream,
|
||||
ConditionalStream,
|
||||
LogLevel,
|
||||
DAY,
|
||||
} from "@/lib/ccStructLog";
|
||||
|
||||
// File stream with daily rotation
|
||||
const fileStream = new FileStream("app.log", DAY);
|
||||
|
||||
// Buffer stream (useful for testing or UI display)
|
||||
const bufferStream = new BufferStream(100); // Keep last 100 messages
|
||||
|
||||
// Conditional stream (only send errors to a separate file)
|
||||
const errorStream = new ConditionalStream(
|
||||
new FileStream("errors.log"),
|
||||
(message, event) => (event.get("level") as LogLevel) >= LogLevel.Error
|
||||
);
|
||||
```
|
||||
|
||||
## File Rotation
|
||||
|
||||
`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";
|
||||
|
||||
// Rotate every hour
|
||||
const hourlyLog = new FileStream("app_hourly.log", HOUR);
|
||||
|
||||
// Rotate daily (recommended for most applications)
|
||||
const dailyLog = new FileStream("app_daily.log", DAY);
|
||||
|
||||
// Rotate weekly
|
||||
const weeklyLog = new FileStream("app_weekly.log", WEEK);
|
||||
|
||||
// No rotation (pass 0 or undefined)
|
||||
const permanentLog = new FileStream("permanent.log", 0);
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Structured Context**: Always provide relevant context as key-value pairs.
|
||||
```typescript
|
||||
// Good
|
||||
logger.info("User action completed", { userId: 123, action: "purchase" });
|
||||
|
||||
// Less useful
|
||||
logger.info("User 123 purchased an item");
|
||||
```
|
||||
|
||||
2. **Choose Appropriate Levels**:
|
||||
- `debug`: For developers to diagnose issues.
|
||||
- `info`: Normal application behavior.
|
||||
- `warn`: Potentially harmful situations that don't break functionality.
|
||||
- `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 using `processor.addSource`.
|
||||
```typescript
|
||||
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: [ processor.removeFields(["password", "token"]) ],
|
||||
//...
|
||||
});
|
||||
```
|
||||
|
||||
5. **Proper Cleanup**: Close loggers during application shutdown to ensure file streams are saved.
|
||||
```typescript
|
||||
// At application shutdown
|
||||
logger.close();
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
See `src/logExample/main.ts` for comprehensive usage examples including:
|
||||
- Basic logging patterns
|
||||
- Custom processor chains
|
||||
- Multiple output streams with different formats
|
||||
- Error handling strategies
|
||||
|
||||
## API Reference
|
||||
|
||||
For complete API documentation, refer to the TypeScript definitions in each module:
|
||||
- `src/lib/ccStructLog/types.ts` - Core interfaces and types
|
||||
- `src/lib/ccStructLog/Logger.ts` - Main Logger class
|
||||
- `src/lib/ccStructLog/processors.ts` - Built-in processors
|
||||
- `src/lib/ccStructLog/renderers.ts` - Built-in renderers
|
||||
- `src/lib/ccStructLog/streams.ts` - Built-in streams
|
||||
- `src/lib/ccStructLog/index.ts` - Convenience functions and exports
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,158 +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;
|
||||
groupUsers: string[];
|
||||
groupName: string;
|
||||
isAllowed: boolean;
|
||||
isNotice: boolean;
|
||||
isWelcome: boolean;
|
||||
groupUsers: string[];
|
||||
}
|
||||
|
||||
interface AccessConfig {
|
||||
detectInterval: number;
|
||||
watchInterval: number;
|
||||
noticeTimes: number;
|
||||
detectRange: number;
|
||||
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,
|
||||
adminGroupConfig: {
|
||||
groupName: "Admin",
|
||||
groupUsers: ["Selcon"],
|
||||
isAllowed: true,
|
||||
isNotice: true,
|
||||
},
|
||||
usersGroups: [
|
||||
{
|
||||
groupName: "user",
|
||||
groupUsers: [],
|
||||
isAllowed: true,
|
||||
isNotice: true,
|
||||
detectRange: 256,
|
||||
detectInterval: 1,
|
||||
watchInterval: 10,
|
||||
noticeTimes: 2,
|
||||
isWarn: false,
|
||||
isWelcome: true,
|
||||
adminGroupConfig: {
|
||||
groupName: "Admin",
|
||||
groupUsers: ["Selcon"],
|
||||
isAllowed: true,
|
||||
isNotice: true,
|
||||
isWelcome: false,
|
||||
},
|
||||
{
|
||||
groupName: "VIP",
|
||||
groupUsers: [],
|
||||
isAllowed: true,
|
||||
isNotice: 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: "enemies",
|
||||
groupUsers: [],
|
||||
isAllowed: false,
|
||||
isNotice: false,
|
||||
noticeToastConfig: {
|
||||
title: {
|
||||
text: "警告",
|
||||
color: "red",
|
||||
},
|
||||
msg: {
|
||||
text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%",
|
||||
color: "red",
|
||||
},
|
||||
prefix: "桃源星",
|
||||
brackets: "<>",
|
||||
bracketColor: "",
|
||||
},
|
||||
],
|
||||
welcomeToastConfig: {
|
||||
title: {
|
||||
text: "Welcome",
|
||||
color: "green",
|
||||
warnToastConfig: {
|
||||
title: {
|
||||
text: "注意",
|
||||
color: "red",
|
||||
},
|
||||
msg: {
|
||||
text: "%playerName% 你已经进入桃源星领地",
|
||||
color: "red",
|
||||
},
|
||||
prefix: "桃源星",
|
||||
brackets: "<>",
|
||||
bracketColor: "",
|
||||
},
|
||||
msg: {
|
||||
text: "Hello User %playerName%",
|
||||
color: "green",
|
||||
},
|
||||
prefix: "Taohuayuan",
|
||||
brackets: "[]",
|
||||
bracketColor: "",
|
||||
},
|
||||
noticeToastConfig: {
|
||||
title: {
|
||||
text: "Notice",
|
||||
color: "red",
|
||||
},
|
||||
msg: {
|
||||
text: "Unfamiliar player %playerName% appeared at Position %playerPosX%, %playerPosY%, %playerPosZ%",
|
||||
color: "red",
|
||||
},
|
||||
prefix: "Taohuayuan",
|
||||
brackets: "[]",
|
||||
bracketColor: "",
|
||||
},
|
||||
warnToastConfig: {
|
||||
title: {
|
||||
text: "Attention!!!",
|
||||
color: "red",
|
||||
},
|
||||
msg: {
|
||||
text: "%playerName% you are not allowed to be here",
|
||||
color: "red",
|
||||
},
|
||||
prefix: "Taohuayuan",
|
||||
brackets: "[]",
|
||||
bracketColor: "",
|
||||
},
|
||||
};
|
||||
|
||||
function loadConfig(filepath: string): AccessConfig {
|
||||
const [fp] = io.open(filepath, "r");
|
||||
if (fp == undefined) {
|
||||
print("Failed to open config file " + filepath);
|
||||
print("Use default config");
|
||||
saveConfig(defaultConfig, filepath);
|
||||
return defaultConfig;
|
||||
}
|
||||
function loadConfig(
|
||||
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 configJson = fp.read("*a");
|
||||
if (configJson == 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 };
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* Access Control Log Viewer
|
||||
* Simple log viewer that allows launching the TUI with 'c' key
|
||||
*/
|
||||
|
||||
import { launchAccessControlTUI } from "./tui";
|
||||
|
||||
const args = [...$vararg];
|
||||
|
||||
function displayLog(filepath: string, lines = 20) {
|
||||
const [file] = io.open(filepath, "r");
|
||||
if (!file) {
|
||||
print(`Failed to open log file: ${filepath}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = file.read("*a");
|
||||
file.close();
|
||||
|
||||
if (content === null || content === undefined || content === "") {
|
||||
print("Log file is empty");
|
||||
return;
|
||||
}
|
||||
|
||||
const logLines = content.split("\n");
|
||||
const startIndex = Math.max(0, logLines.length - lines);
|
||||
const displayLines = logLines.slice(startIndex);
|
||||
|
||||
term.clear();
|
||||
term.setCursorPos(1, 1);
|
||||
|
||||
print("=== Access Control Log Viewer ===");
|
||||
print("Press 'c' to open configuration TUI, 'q' to quit, 'r' to refresh");
|
||||
print("==========================================");
|
||||
print("");
|
||||
|
||||
for (const line of displayLines) {
|
||||
if (line.trim() !== "") {
|
||||
print(line);
|
||||
}
|
||||
}
|
||||
|
||||
print("");
|
||||
print("==========================================");
|
||||
print(`Showing last ${displayLines.length} lines of ${filepath}`);
|
||||
}
|
||||
|
||||
function main(args: string[]) {
|
||||
const logFilepath = args[0] || `${shell.dir()}/accesscontrol.log`;
|
||||
const lines = args[1] ? parseInt(args[1]) : 20;
|
||||
|
||||
if (isNaN(lines) || lines <= 0) {
|
||||
print("Usage: logviewer [logfile] [lines]");
|
||||
print(" logfile - Path to log file (default: accesscontrol.log)");
|
||||
print(" lines - Number of lines to display (default: 20)");
|
||||
return;
|
||||
}
|
||||
|
||||
let running = true;
|
||||
|
||||
// Initial display
|
||||
displayLog(logFilepath, lines);
|
||||
|
||||
while (running) {
|
||||
const [eventType, key] = os.pullEvent();
|
||||
|
||||
if (eventType === "key") {
|
||||
if (key === keys.c) {
|
||||
// Launch TUI
|
||||
print("Launching Access Control TUI...");
|
||||
try {
|
||||
launchAccessControlTUI();
|
||||
// Refresh display after TUI closes
|
||||
displayLog(logFilepath, lines);
|
||||
} catch (error) {
|
||||
if (error === "TUI_CLOSE" || error === "Terminated") {
|
||||
displayLog(logFilepath, lines);
|
||||
} else {
|
||||
print(`TUI error: ${String(error)}`);
|
||||
os.sleep(2);
|
||||
displayLog(logFilepath, lines);
|
||||
}
|
||||
}
|
||||
} else if (key === keys.q) {
|
||||
// Quit
|
||||
running = false;
|
||||
} else if (key === keys.r) {
|
||||
// Refresh
|
||||
displayLog(logFilepath, lines);
|
||||
}
|
||||
} else if (eventType === "terminate") {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
|
||||
term.clear();
|
||||
term.setCursorPos(1, 1);
|
||||
print("Log viewer closed.");
|
||||
}
|
||||
|
||||
try {
|
||||
main(args);
|
||||
} catch (error) {
|
||||
if (error === "Terminated") {
|
||||
print("Log viewer terminated by user.");
|
||||
} else {
|
||||
print("Error in log viewer:");
|
||||
printError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,291 +1,489 @@
|
||||
import { CCLog, DAY } from "@/lib/ccLog";
|
||||
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
|
||||
import { createAccessControlCLI } from "./cli";
|
||||
import { createAccessControlCli } from "./cli";
|
||||
import { launchAccessControlTUI } from "./tui";
|
||||
import * as peripheralManager from "../lib/PeripheralManager";
|
||||
import { deepCopy } from "@/lib/common";
|
||||
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 DEBUG = false;
|
||||
const args = [...$vararg];
|
||||
|
||||
// Init Log
|
||||
const logger = new CCLog("accesscontrol.log", true, DAY);
|
||||
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`;
|
||||
let config = loadConfig(configFilepath);
|
||||
let config = loadConfig(configFilepath)!;
|
||||
const configLock = new ReadWriteLock();
|
||||
logger.info("Load config successfully!");
|
||||
if (DEBUG)
|
||||
logger.debug(textutils.serialise(config, { allow_repetitions: true }));
|
||||
const groupNames = config.usersGroups.map((value) => value.groupName);
|
||||
logger.debug(textutils.serialise(config, { allow_repetitions: true }));
|
||||
|
||||
// Peripheral
|
||||
const playerDetector = peripheralManager.findByNameRequired("playerDetector");
|
||||
const chatBox = peripheralManager.findByNameRequired("chatBox");
|
||||
const playerDetector = peripheral.find(
|
||||
"playerDetector",
|
||||
)[0] as PlayerDetectorPeripheral;
|
||||
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
|
||||
const chatManager: ChatManager = new ChatManager([chatBox]);
|
||||
|
||||
// Global
|
||||
let noticeTargetPlayers: string[];
|
||||
let inRangePlayers: string[] = [];
|
||||
let watchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
|
||||
let gInRangePlayers: string[] = [];
|
||||
let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
|
||||
let gIsRunning = true;
|
||||
|
||||
interface ParseParams {
|
||||
name?: string;
|
||||
group?: string;
|
||||
info?: PlayerInfo;
|
||||
playerName?: string;
|
||||
groupName?: string;
|
||||
info?: PlayerInfo;
|
||||
}
|
||||
|
||||
function reloadConfig() {
|
||||
let releaser = configLock.tryAcquireWrite();
|
||||
while (releaser === undefined) {
|
||||
sleep(1);
|
||||
releaser = configLock.tryAcquireWrite();
|
||||
}
|
||||
|
||||
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,
|
||||
): string {
|
||||
const newComponent = deepCopy(component);
|
||||
component: MinecraftTextComponent,
|
||||
params?: ParseParams,
|
||||
): MinecraftTextComponent {
|
||||
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?.name ?? "UnknowPlayer",
|
||||
);
|
||||
newComponent.text = newComponent.text.replace(
|
||||
"%groupName%",
|
||||
params?.group ?? "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 textutils.serialiseJSON(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,
|
||||
) {
|
||||
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,
|
||||
});
|
||||
|
||||
releaser.release();
|
||||
}
|
||||
|
||||
function sendToast(
|
||||
toastConfig: ToastConfig,
|
||||
targetPlayer: string,
|
||||
params: ParseParams,
|
||||
toastConfig: ToastConfig,
|
||||
targetPlayer: string,
|
||||
params: ParseParams,
|
||||
) {
|
||||
return chatBox.sendFormattedToastToPlayer(
|
||||
safeParseTextComponent(
|
||||
toastConfig.msg ?? config.welcomeToastConfig.msg,
|
||||
params,
|
||||
),
|
||||
safeParseTextComponent(
|
||||
toastConfig.title ?? config.welcomeToastConfig.title,
|
||||
params,
|
||||
),
|
||||
targetPlayer,
|
||||
toastConfig.prefix ?? config.welcomeToastConfig.prefix,
|
||||
toastConfig.brackets ?? config.welcomeToastConfig.brackets,
|
||||
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
function sendNotice(player: string, playerInfo?: PlayerInfo) {
|
||||
const onlinePlayers = playerDetector.getOnlinePlayers();
|
||||
noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
|
||||
config.usersGroups
|
||||
.filter((value) => value.isNotice)
|
||||
.map((value) => value.groupUsers ?? [])
|
||||
.flat(),
|
||||
);
|
||||
let releaser = configLock.tryAcquireRead();
|
||||
while (releaser === undefined) {
|
||||
sleep(0.1);
|
||||
releaser = configLock.tryAcquireRead();
|
||||
}
|
||||
|
||||
for (const targetPlayer of noticeTargetPlayers) {
|
||||
if (!onlinePlayers.includes(targetPlayer)) continue;
|
||||
sendToast(config.noticeToastConfig, targetPlayer, {
|
||||
name: player,
|
||||
info: playerInfo,
|
||||
});
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
function sendWarn(player: string) {
|
||||
const warnMsg = `Not Allowed Player ${player} Break in Home `;
|
||||
logger.warn(warnMsg);
|
||||
const warnMsg = `Not Allowed Player ${player} Break in Home `;
|
||||
logger.warn(warnMsg);
|
||||
|
||||
sendToast(config.warnToastConfig, player, { name: player });
|
||||
chatBox.sendFormattedMessageToPlayer(
|
||||
safeParseTextComponent(config.warnToastConfig.msg, { name: player }),
|
||||
player,
|
||||
"AccessControl",
|
||||
"[]",
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
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 (true) {
|
||||
if (DEBUG) {
|
||||
const watchPlayerNames = watchPlayersInfo.flatMap((value) => value.name);
|
||||
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
|
||||
}
|
||||
for (const player of watchPlayersInfo) {
|
||||
const playerInfo = playerDetector.getPlayerPos(player.name);
|
||||
if (inRangePlayers.includes(player.name)) {
|
||||
// Notice
|
||||
if (player.hasNoticeTimes < config.noticeTimes) {
|
||||
sendNotice(player.name, playerInfo);
|
||||
player.hasNoticeTimes += 1;
|
||||
while (gIsRunning) {
|
||||
const releaser = configLock.tryAcquireRead();
|
||||
if (releaser === undefined) {
|
||||
os.sleep(1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Warn
|
||||
if (config.isWarn) sendWarn(player.name);
|
||||
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;
|
||||
}
|
||||
|
||||
// Record
|
||||
logger.warn(
|
||||
`${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
|
||||
);
|
||||
} else {
|
||||
// Get rid of player from list
|
||||
watchPlayersInfo = watchPlayersInfo.filter(
|
||||
(value) => value.name != player.name,
|
||||
);
|
||||
logger.info(
|
||||
`${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
|
||||
);
|
||||
}
|
||||
os.sleep(3);
|
||||
// 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);
|
||||
}
|
||||
|
||||
os.sleep(config.watchInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function mainLoop() {
|
||||
while (true) {
|
||||
const players = playerDetector.getPlayersInRange(config.detectRange);
|
||||
if (DEBUG) {
|
||||
const playersList = "[ " + players.join(",") + " ]";
|
||||
logger.debug(`Detected ${players.length} players: ${playersList}`);
|
||||
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);
|
||||
}
|
||||
|
||||
for (const player of players) {
|
||||
if (inRangePlayers.includes(player)) continue;
|
||||
|
||||
if (config.adminGroupConfig.groupUsers.includes(player)) {
|
||||
logger.info(`Admin ${player} appear`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// New player appear
|
||||
const playerInfo = playerDetector.getPlayerPos(player);
|
||||
let groupConfig: UserGroupConfig = {
|
||||
groupName: "Unfamiliar",
|
||||
groupUsers: [],
|
||||
isAllowed: false,
|
||||
isNotice: false,
|
||||
};
|
||||
for (const userGroupConfig of config.usersGroups) {
|
||||
if (userGroupConfig.groupUsers == undefined) continue;
|
||||
if (!userGroupConfig.groupUsers.includes(player)) continue;
|
||||
|
||||
groupConfig = userGroupConfig;
|
||||
logger.info(
|
||||
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
if (groupConfig.isAllowed) continue;
|
||||
|
||||
logger.warn(
|
||||
`${groupConfig.groupName} ${player} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
|
||||
);
|
||||
if (config.isWarn) sendWarn(player);
|
||||
watchPlayersInfo = [
|
||||
...watchPlayersInfo,
|
||||
{ name: player, hasNoticeTimes: 0 },
|
||||
];
|
||||
}
|
||||
|
||||
inRangePlayers = players;
|
||||
os.sleep(config.detectInterval);
|
||||
}
|
||||
}
|
||||
|
||||
function keyboardLoop() {
|
||||
while (true) {
|
||||
const [eventType, key] = os.pullEvent("key");
|
||||
if (eventType === "key" && 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);
|
||||
config = loadConfig(configFilepath);
|
||||
logger.info("Reload config successfully!");
|
||||
}
|
||||
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") {
|
||||
// 创建CLI处理器
|
||||
const cli = createAccessControlCLI(
|
||||
config,
|
||||
configFilepath,
|
||||
logger,
|
||||
chatBox,
|
||||
groupNames,
|
||||
);
|
||||
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(),
|
||||
);
|
||||
|
||||
print(
|
||||
"Access Control System started. Press 'c' to open configuration TUI.",
|
||||
);
|
||||
parallel.waitForAll(
|
||||
() => {
|
||||
mainLoop();
|
||||
},
|
||||
() => {
|
||||
void cli.startConfigLoop();
|
||||
},
|
||||
() => {
|
||||
watchLoop();
|
||||
},
|
||||
() => {
|
||||
keyboardLoop();
|
||||
},
|
||||
);
|
||||
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
@@ -1,120 +1,275 @@
|
||||
import { CraftManager } from "@/lib/CraftManager";
|
||||
import * as peripheralManager from "../lib/PeripheralManager";
|
||||
import { CCLog } from "@/lib/ccLog";
|
||||
import {
|
||||
CraftManager,
|
||||
CraftRecipe,
|
||||
CreatePackageTag,
|
||||
} from "@/lib/CraftManager";
|
||||
import { Queue } from "@/lib/datatype/Queue";
|
||||
import {
|
||||
ConsoleStream,
|
||||
DAY,
|
||||
FileStream,
|
||||
Logger,
|
||||
LogLevel,
|
||||
processor,
|
||||
textRenderer,
|
||||
} from "@/lib/ccStructLog";
|
||||
|
||||
const log = new CCLog("autocraft.log");
|
||||
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 peripheralsRelativeSides = {
|
||||
packagesContainer: "minecraft:chest_10",
|
||||
itemsContainer: "minecraft:chest_9",
|
||||
packageExtractor: "create:packager_1",
|
||||
blockReader: "front",
|
||||
wiredModem: "back",
|
||||
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",
|
||||
};
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
function main() {
|
||||
const packagesContainer = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.packagesContainer,
|
||||
);
|
||||
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();
|
||||
|
||||
const itemsContainer = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.itemsContainer,
|
||||
);
|
||||
|
||||
const packageExtractor = peripheralManager.findByNameRequired(
|
||||
"inventory",
|
||||
peripheralsRelativeSides.packageExtractor,
|
||||
);
|
||||
|
||||
const blockReader = peripheralManager.findByNameRequired(
|
||||
"blockReader",
|
||||
peripheralsRelativeSides.blockReader,
|
||||
);
|
||||
|
||||
const wiredModem = peripheralManager.findByNameRequired(
|
||||
"wiredModem",
|
||||
peripheralsRelativeSides.wiredModem,
|
||||
);
|
||||
const turtleLocalName = wiredModem.getNameLocal();
|
||||
|
||||
const craftManager = new CraftManager(turtleLocalName);
|
||||
|
||||
let hasPackage = redstone.getInput("front");
|
||||
while (true) {
|
||||
if (!hasPackage) os.pullEvent("redstone");
|
||||
hasPackage = redstone.getInput("front");
|
||||
if (!hasPackage) {
|
||||
continue;
|
||||
}
|
||||
log.info(`Package detected`);
|
||||
|
||||
const itemsInfo = packagesContainer.list();
|
||||
for (const key in itemsInfo) {
|
||||
const slot = parseInt(key);
|
||||
const item = itemsInfo[slot];
|
||||
log.info(`${item.count}x ${item.name} in slot ${key}`);
|
||||
|
||||
// Get package NBT
|
||||
packagesContainer.pushItems(turtleLocalName, slot);
|
||||
const packageInfo = blockReader.getBlockData()!.Items[1];
|
||||
// log.info(textutils.serialise(packageInfo));
|
||||
|
||||
// Get recipe
|
||||
const packageRecipes = CraftManager.getPackageRecipe(packageInfo);
|
||||
|
||||
// No recipe, just extract package
|
||||
if (packageRecipes == undefined) {
|
||||
packageExtractor.pullItems(turtleLocalName, 1);
|
||||
log.info(`No recipe, just pass`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract package
|
||||
// log.info(`Get recipe ${textutils.serialise(recipe)}`);
|
||||
packageExtractor.pullItems(turtleLocalName, 1);
|
||||
|
||||
// Pull and craft multi recipe
|
||||
for (const recipe of packageRecipes) {
|
||||
let craftOutputItem: BlockItemDetailData | undefined = undefined;
|
||||
let restCraftCnt = recipe.Count;
|
||||
|
||||
do {
|
||||
// Clear workbench
|
||||
craftManager.pushAll(itemsContainer);
|
||||
|
||||
log.info(`Pull items according to a recipe`);
|
||||
const craftCnt = craftManager.pullItems(
|
||||
recipe,
|
||||
itemsContainer,
|
||||
restCraftCnt,
|
||||
);
|
||||
if (craftCnt == 0) break;
|
||||
craftManager.craft();
|
||||
log.info(`Craft ${craftCnt} times`);
|
||||
restCraftCnt -= craftCnt;
|
||||
|
||||
// Get output item
|
||||
craftOutputItem ??= blockReader.getBlockData()!.Items[1];
|
||||
} while (restCraftCnt > 0);
|
||||
|
||||
// Finally output
|
||||
if (restCraftCnt > 0) {
|
||||
log.warn(`Only craft ${recipe.Count - restCraftCnt} times`);
|
||||
} else {
|
||||
log.info(`Finish craft ${recipe.Count}x ${craftOutputItem?.id}`);
|
||||
logger.info("Peripheral initialization complete...");
|
||||
isFinishedInitPeripheral = true;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Peripheral initialization failed for ${String(error)}, try again...`,
|
||||
);
|
||||
sleep(1);
|
||||
}
|
||||
craftManager.pushAll(itemsContainer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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) {
|
||||
log.error(textutils.serialise(error as object));
|
||||
logger.error(textutils.serialise(error as object));
|
||||
} finally {
|
||||
log.close();
|
||||
logger.close();
|
||||
}
|
||||
|
||||
616
src/cliExample/main.ts
Normal file
616
src/cliExample/main.ts
Normal file
@@ -0,0 +1,616 @@
|
||||
/**
|
||||
* Example CLI application demonstrating the ccCLI framework
|
||||
* This example shows how to create a calculator CLI with global context injection
|
||||
* and ChatManager integration for Minecraft chat functionality
|
||||
*/
|
||||
|
||||
import { Command, createCli, CliError } from "../lib/ccCLI/index";
|
||||
import { Ok, Result } from "../lib/thirdparty/ts-result-es";
|
||||
import { ChatManager, ChatMessage, ChatToast } from "../lib/ChatManager";
|
||||
|
||||
// 1. Define global context type
|
||||
interface AppContext {
|
||||
appName: string;
|
||||
log: (message: string) => void;
|
||||
debugMode: boolean;
|
||||
chatManager?: ChatManager;
|
||||
}
|
||||
|
||||
// 2. Define individual commands
|
||||
const addCommand: Command<AppContext> = {
|
||||
name: "add",
|
||||
description: "Adds two numbers together",
|
||||
args: [
|
||||
{ name: "a", description: "The first number", required: true },
|
||||
{ name: "b", description: "The second number", required: true },
|
||||
],
|
||||
action: ({ args, context }): Result<void, CliError> => {
|
||||
context.log(`Executing 'add' command in '${context.appName}'`);
|
||||
|
||||
const a = tonumber(args.a as string);
|
||||
const b = tonumber(args.b as string);
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
print("Error: Arguments must be numbers.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const result = a + b;
|
||||
print(`${a} + ${b} = ${result}`);
|
||||
|
||||
if (context.debugMode) {
|
||||
context.log(`Calculation result: ${result}`);
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const subtractCommand: Command<AppContext> = {
|
||||
name: "subtract",
|
||||
description: "Subtracts the second number from the first",
|
||||
args: [
|
||||
{ name: "a", description: "The minuend", required: true },
|
||||
{ name: "b", description: "The subtrahend", required: true },
|
||||
],
|
||||
action: ({ args, context }): Result<void, CliError> => {
|
||||
context.log(`Executing 'subtract' command in '${context.appName}'`);
|
||||
|
||||
const a = tonumber(args.a as string);
|
||||
const b = tonumber(args.b as string);
|
||||
|
||||
if (a === undefined || b === undefined) {
|
||||
print("Error: Arguments must be numbers.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const result = a - b;
|
||||
print(`${a} - ${b} = ${result}`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const greetCommand: Command<AppContext> = {
|
||||
name: "greet",
|
||||
description: "Prints a greeting message",
|
||||
options: new Map([
|
||||
[
|
||||
"name",
|
||||
{
|
||||
name: "name",
|
||||
shortName: "n",
|
||||
description: "The name to greet",
|
||||
defaultValue: "World",
|
||||
},
|
||||
],
|
||||
[
|
||||
"times",
|
||||
{
|
||||
name: "times",
|
||||
shortName: "t",
|
||||
description: "Number of times to repeat",
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ options, context }): Result<void, CliError> => {
|
||||
context.log(`Executing 'greet' command in '${context.appName}'`);
|
||||
|
||||
const name = options.name as string;
|
||||
const times = tonumber(options.times as string) ?? 1;
|
||||
|
||||
for (let i = 1; i <= times; i++) {
|
||||
print(`Hello, ${name}!`);
|
||||
|
||||
if (context.debugMode && times > 1) {
|
||||
context.log(`Greeting ${i}/${times}`);
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
// Math subcommands group
|
||||
const mathCommand: Command<AppContext> = {
|
||||
name: "math",
|
||||
description: "Mathematical operations",
|
||||
subcommands: new Map([
|
||||
["add", addCommand],
|
||||
["subtract", subtractCommand],
|
||||
]),
|
||||
};
|
||||
|
||||
// Config command with nested subcommands
|
||||
const configShowCommand: Command<AppContext> = {
|
||||
name: "show",
|
||||
description: "Show current configuration",
|
||||
action: ({ context }): Result<void, CliError> => {
|
||||
print(`App Name: ${context.appName}`);
|
||||
print(`Debug Mode: ${context.debugMode ? "on" : "off"}`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const configSetCommand: Command<AppContext> = {
|
||||
name: "set",
|
||||
description: "Set a configuration item",
|
||||
args: [
|
||||
{ name: "key", description: "The configuration key", required: true },
|
||||
{ name: "value", description: "The configuration value", required: true },
|
||||
],
|
||||
action: ({ args, context }): Result<void, CliError> => {
|
||||
const key = args.key as string;
|
||||
const value = args.value as string;
|
||||
|
||||
context.log(`Setting config: ${key} = ${value}`);
|
||||
print(`Config '${key}' has been set to '${value}'`);
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const configCommand: Command<AppContext> = {
|
||||
name: "config",
|
||||
description: "Configuration management commands",
|
||||
subcommands: new Map([
|
||||
["show", configShowCommand],
|
||||
["set", configSetCommand],
|
||||
]),
|
||||
};
|
||||
|
||||
// ChatManager commands
|
||||
const chatSendCommand: Command<AppContext> = {
|
||||
name: "send",
|
||||
description: "Send a chat message",
|
||||
args: [
|
||||
{ name: "message", description: "The message to send", required: true },
|
||||
],
|
||||
options: new Map([
|
||||
[
|
||||
"player",
|
||||
{
|
||||
name: "player",
|
||||
shortName: "p",
|
||||
description: "Target player for private message",
|
||||
defaultValue: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"prefix",
|
||||
{
|
||||
name: "prefix",
|
||||
description: "Message prefix",
|
||||
defaultValue: "CC",
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ args, options, context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print(
|
||||
"Error: ChatManager not initialized. No chatbox peripherals found.",
|
||||
);
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
message: args.message as string,
|
||||
targetPlayer: options.player as string | undefined,
|
||||
prefix: options.prefix as string,
|
||||
};
|
||||
|
||||
const result = context.chatManager.sendMessage(message);
|
||||
if (result.isOk()) {
|
||||
print(`Message queued: "${String(args.message)}"`);
|
||||
|
||||
const targetPlayer = options.player;
|
||||
if (
|
||||
targetPlayer !== undefined &&
|
||||
targetPlayer !== null &&
|
||||
typeof targetPlayer === "string"
|
||||
) {
|
||||
print(`Target: ${targetPlayer}`);
|
||||
} else {
|
||||
print("Target: Global chat");
|
||||
}
|
||||
} else {
|
||||
print(`Failed to queue message: ${result.error.reason}`);
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatToastCommand: Command<AppContext> = {
|
||||
name: "toast",
|
||||
description: "Send a toast notification to a player",
|
||||
args: [
|
||||
{ name: "player", description: "Target player username", required: true },
|
||||
{ name: "title", description: "Toast title", required: true },
|
||||
{ name: "message", description: "Toast message", required: true },
|
||||
],
|
||||
options: new Map([
|
||||
[
|
||||
"prefix",
|
||||
{
|
||||
name: "prefix",
|
||||
description: "Message prefix",
|
||||
defaultValue: "CC",
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ args, options, context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print(
|
||||
"Error: ChatManager not initialized. No chatbox peripherals found.",
|
||||
);
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const toast: ChatToast = {
|
||||
targetPlayer: args.player as string,
|
||||
title: args.title as string,
|
||||
message: args.message as string,
|
||||
prefix: options.prefix as string,
|
||||
};
|
||||
|
||||
const result = context.chatManager.sendToast(toast);
|
||||
if (result.isOk()) {
|
||||
print(
|
||||
`Toast queued for ${String(args.player)}: "${String(args.title)}" - "${String(args.message)}"`,
|
||||
);
|
||||
} else {
|
||||
print(`Failed to queue toast: ${result.error.reason}`);
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatStatusCommand: Command<AppContext> = {
|
||||
name: "status",
|
||||
description: "Show ChatManager status and queue information",
|
||||
action: ({ context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print("ChatManager: Not initialized (no chatbox peripherals found)");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
print("=== ChatManager Status ===");
|
||||
print(`Pending messages: ${context.chatManager.getPendingMessageCount()}`);
|
||||
print(`Pending toasts: ${context.chatManager.getPendingToastCount()}`);
|
||||
print(
|
||||
`Buffered received: ${context.chatManager.getBufferedMessageCount()}`,
|
||||
);
|
||||
|
||||
const chatboxStatus = context.chatManager.getChatboxStatus();
|
||||
print(`Chatboxes: ${chatboxStatus.length} total`);
|
||||
|
||||
for (let i = 0; i < chatboxStatus.length; i++) {
|
||||
const status = chatboxStatus[i] ? "idle" : "busy";
|
||||
print(` Chatbox ${i + 1}: ${status}`);
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatReceiveCommand: Command<AppContext> = {
|
||||
name: "receive",
|
||||
description: "Check for received chat messages",
|
||||
options: new Map([
|
||||
[
|
||||
"count",
|
||||
{
|
||||
name: "count",
|
||||
shortName: "c",
|
||||
description: "Number of messages to retrieve",
|
||||
defaultValue: 1,
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ options, context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print(
|
||||
"Error: ChatManager not initialized. No chatbox peripherals found.",
|
||||
);
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const count = tonumber(options.count as string) ?? 1;
|
||||
let retrieved = 0;
|
||||
|
||||
print("=== Received Messages ===");
|
||||
for (let i = 0; i < count; i++) {
|
||||
const result = context.chatManager.getReceivedMessage();
|
||||
if (result.isOk()) {
|
||||
const event = result.value;
|
||||
print(`[${event.username}]: ${event.message}`);
|
||||
if (event.uuid !== undefined) {
|
||||
print(` UUID: ${event.uuid}`);
|
||||
}
|
||||
retrieved++;
|
||||
} else {
|
||||
// Buffer is empty
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (retrieved === 0) {
|
||||
print("No messages in buffer");
|
||||
} else {
|
||||
print(`Retrieved ${retrieved} message(s)`);
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatSendImmediateCommand: Command<AppContext> = {
|
||||
name: "send-immediate",
|
||||
description: "Send a chat message immediately (bypass queue)",
|
||||
args: [
|
||||
{ name: "message", description: "The message to send", required: true },
|
||||
],
|
||||
options: new Map([
|
||||
[
|
||||
"player",
|
||||
{
|
||||
name: "player",
|
||||
shortName: "p",
|
||||
description: "Target player for private message",
|
||||
defaultValue: undefined,
|
||||
},
|
||||
],
|
||||
[
|
||||
"prefix",
|
||||
{
|
||||
name: "prefix",
|
||||
description: "Message prefix",
|
||||
defaultValue: "CC",
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ args, options, context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print(
|
||||
"Error: ChatManager not initialized. No chatbox peripherals found.",
|
||||
);
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const message: ChatMessage = {
|
||||
message: args.message as string,
|
||||
targetPlayer: options.player as string | undefined,
|
||||
prefix: options.prefix as string,
|
||||
};
|
||||
|
||||
const result = context.chatManager.sendMessageImmediate(message);
|
||||
if (result.isOk()) {
|
||||
print(`Message sent immediately: "${String(args.message)}"`);
|
||||
} else {
|
||||
print(`Failed to send message: ${result.error.reason}`);
|
||||
if (result.error.kind === "NoIdleChatbox") {
|
||||
print("All chatboxes are currently busy. Try queuing instead.");
|
||||
}
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatStopCommand: Command<AppContext> = {
|
||||
name: "stop",
|
||||
description: "Stop the ChatManager",
|
||||
action: ({ context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print("Error: ChatManager not initialized.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const result = context.chatManager.stop();
|
||||
if (result.isOk()) {
|
||||
print("ChatManager stopped successfully.");
|
||||
} else {
|
||||
print(`Failed to stop ChatManager: ${result.error.reason}`);
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatClearCommand: Command<AppContext> = {
|
||||
name: "clear",
|
||||
description: "Clear queues and buffer",
|
||||
options: new Map([
|
||||
[
|
||||
"queues",
|
||||
{
|
||||
name: "queues",
|
||||
shortName: "q",
|
||||
description: "Clear message and toast queues",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
[
|
||||
"buffer",
|
||||
{
|
||||
name: "buffer",
|
||||
shortName: "b",
|
||||
description: "Clear received message buffer",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
action: ({ options, context }): Result<void, CliError> => {
|
||||
if (!context.chatManager) {
|
||||
print("Error: ChatManager not initialized.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const clearQueues = options.queues as boolean;
|
||||
const clearBuffer = options.buffer as boolean;
|
||||
|
||||
if (!clearQueues && !clearBuffer) {
|
||||
print("Specify --queues or --buffer (or both) to clear.");
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
const results: string[] = [];
|
||||
|
||||
if (clearQueues) {
|
||||
const result = context.chatManager.clearQueues();
|
||||
if (result.isOk()) {
|
||||
results.push("Queues cleared");
|
||||
} else {
|
||||
results.push(`Failed to clear queues: ${result.error.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (clearBuffer) {
|
||||
const result = context.chatManager.clearBuffer();
|
||||
if (result.isOk()) {
|
||||
results.push("Buffer cleared");
|
||||
} else {
|
||||
results.push(`Failed to clear buffer: ${result.error.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
results.forEach((msg) => print(msg));
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
const chatCommand: Command<AppContext> = {
|
||||
name: "chat",
|
||||
description: "Chat management commands using ChatManager",
|
||||
subcommands: new Map([
|
||||
["send", chatSendCommand],
|
||||
["send-immediate", chatSendImmediateCommand],
|
||||
["toast", chatToastCommand],
|
||||
["status", chatStatusCommand],
|
||||
["receive", chatReceiveCommand],
|
||||
["stop", chatStopCommand],
|
||||
["clear", chatClearCommand],
|
||||
]),
|
||||
};
|
||||
|
||||
// 3. Define root command
|
||||
const rootCommand: Command<AppContext> = {
|
||||
name: "calculator",
|
||||
description: "A feature-rich calculator and chat management program",
|
||||
options: new Map([
|
||||
[
|
||||
"debug",
|
||||
{
|
||||
name: "debug",
|
||||
shortName: "d",
|
||||
description: "Enable debug mode",
|
||||
defaultValue: false,
|
||||
},
|
||||
],
|
||||
]),
|
||||
subcommands: new Map([
|
||||
["math", mathCommand],
|
||||
["greet", greetCommand],
|
||||
["config", configCommand],
|
||||
["chat", chatCommand],
|
||||
]),
|
||||
action: ({ options, context }): Result<void, CliError> => {
|
||||
// Update debug mode from command line option
|
||||
const debugFromOption = options.debug as boolean;
|
||||
if (debugFromOption) {
|
||||
context.debugMode = true;
|
||||
context.log("Debug mode enabled");
|
||||
}
|
||||
|
||||
print(`Welcome to ${context.appName}!`);
|
||||
print("Use --help to see available commands");
|
||||
|
||||
if (context.chatManager) {
|
||||
print("ChatManager initialized and ready!");
|
||||
} else {
|
||||
print("Note: No chatbox peripherals found - chat commands unavailable");
|
||||
}
|
||||
|
||||
return Ok.EMPTY;
|
||||
},
|
||||
};
|
||||
|
||||
// 4. Initialize ChatManager if chatbox peripherals are available
|
||||
function initializeChatManager(): ChatManager | undefined {
|
||||
// Find all available chatbox peripherals
|
||||
const peripheralNames = peripheral.getNames();
|
||||
const chatboxPeripherals: ChatBoxPeripheral[] = [];
|
||||
|
||||
for (const name of peripheralNames) {
|
||||
const peripheralType = peripheral.getType(name);
|
||||
if (peripheralType[0] === "chatBox") {
|
||||
const chatbox = peripheral.wrap(name) as ChatBoxPeripheral;
|
||||
chatboxPeripherals.push(chatbox);
|
||||
}
|
||||
}
|
||||
|
||||
if (chatboxPeripherals.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const chatManager = new ChatManager(chatboxPeripherals);
|
||||
|
||||
// Start ChatManager in async mode so it doesn't block the CLI
|
||||
const runResult = chatManager.runAsync();
|
||||
if (runResult.isErr()) {
|
||||
print(`Warning: Failed to start ChatManager: ${runResult.error.reason}`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return chatManager;
|
||||
}
|
||||
|
||||
// 5. Create global context instance
|
||||
const appContext: AppContext = {
|
||||
appName: "MyAwesome Calculator & Chat Manager",
|
||||
debugMode: false,
|
||||
log: (message) => {
|
||||
print(`[LOG] ${message}`);
|
||||
},
|
||||
chatManager: initializeChatManager(),
|
||||
};
|
||||
|
||||
// 6. Create and export CLI handler
|
||||
const cli = createCli(rootCommand, { globalContext: appContext });
|
||||
const args = [...$vararg];
|
||||
cli(args);
|
||||
|
||||
// Example usage (uncomment to test):
|
||||
/*
|
||||
// Simple math operations
|
||||
cli(['math', 'add', '5', '7']); // Output: 12
|
||||
cli(['math', 'subtract', '10', '3']); // Output: 7
|
||||
|
||||
// Greet with options
|
||||
cli(['greet', '--name', 'TypeScript']); // Output: Hello, TypeScript!
|
||||
cli(['greet', '-n', 'World', '-t', '3']); // Output: Hello, World! (3 times)
|
||||
|
||||
// Config management
|
||||
cli(['config', 'show']); // Shows current config
|
||||
cli(['config', 'set', 'theme', 'dark']); // Sets config
|
||||
|
||||
// Chat management (requires chatbox peripherals)
|
||||
cli(['chat', 'status']); // Shows ChatManager status
|
||||
cli(['chat', 'send', 'Hello World!']); // Sends global message (queued)
|
||||
cli(['chat', 'send', 'Hi there!', '--player', 'Steve']); // Private message (queued)
|
||||
cli(['chat', 'send-immediate', 'Urgent!', '--player', 'Admin']); // Immediate send
|
||||
cli(['chat', 'toast', 'Steve', 'Alert', 'Server restart in 5 minutes']); // Toast notification
|
||||
cli(['chat', 'receive', '--count', '5']); // Check for received messages
|
||||
cli(['chat', 'clear', '--queues']); // Clear pending queues
|
||||
cli(['chat', 'clear', '--buffer']); // Clear received buffer
|
||||
cli(['chat', 'stop']); // Stop ChatManager
|
||||
|
||||
// Help examples
|
||||
cli(['--help']); // Shows root help
|
||||
cli(['math', '--help']); // Shows math command help
|
||||
cli(['chat', '--help']); // Shows chat command help
|
||||
cli(['chat', 'send', '--help']); // Shows chat send help
|
||||
|
||||
// Debug mode
|
||||
cli(['--debug', 'math', 'add', '1', '2']); // Enables debug logging
|
||||
cli(['--debug', 'chat', 'status']); // Debug mode with chat status
|
||||
*/
|
||||
657
src/lib/ChatManager.ts
Normal file
657
src/lib/ChatManager.ts
Normal file
@@ -0,0 +1,657 @@
|
||||
import { Queue } from "./datatype/Queue";
|
||||
import { ChatBoxEvent, pullEventAs } from "./event";
|
||||
import { Result, Ok, Err } from "./thirdparty/ts-result-es";
|
||||
import { gTimerManager } from "./TimerManager";
|
||||
|
||||
/**
|
||||
* Chat manager error types
|
||||
*/
|
||||
export interface ChatManagerError {
|
||||
kind: "ChatManager";
|
||||
reason: string;
|
||||
chatboxIndex?: number;
|
||||
}
|
||||
|
||||
export interface NoIdleChatboxError {
|
||||
kind: "NoIdleChatbox";
|
||||
reason: "All chatboxes are busy";
|
||||
}
|
||||
|
||||
export interface SendFailureError {
|
||||
kind: "SendFailure";
|
||||
reason: string;
|
||||
chatboxIndex: number;
|
||||
}
|
||||
|
||||
export interface EmptyBufferError {
|
||||
kind: "EmptyBuffer";
|
||||
reason: "No messages in buffer";
|
||||
}
|
||||
|
||||
export type ChatError =
|
||||
| ChatManagerError
|
||||
| NoIdleChatboxError
|
||||
| SendFailureError
|
||||
| EmptyBufferError;
|
||||
|
||||
/**
|
||||
* Base interface for chat messages and toasts
|
||||
*/
|
||||
interface ChatBasicMessage {
|
||||
message: string | MinecraftTextComponent | MinecraftTextComponent[];
|
||||
prefix?: string;
|
||||
brackets?: string;
|
||||
bracketColor?: string;
|
||||
range?: number;
|
||||
utf8Support?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for chat toast notifications
|
||||
*/
|
||||
export interface ChatToast extends ChatBasicMessage {
|
||||
/** Target player username to send the toast to */
|
||||
targetPlayer: string;
|
||||
/** Title of the toast notification */
|
||||
title: string | MinecraftTextComponent | MinecraftTextComponent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for regular chat messages
|
||||
*/
|
||||
export interface ChatMessage extends ChatBasicMessage {
|
||||
/** Optional target player username for private messages */
|
||||
targetPlayer?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ChatManager class for managing multiple ChatBox peripherals
|
||||
* Handles message queuing, sending with cooldown management, and event receiving
|
||||
* Uses Result types for robust error handling
|
||||
*/
|
||||
export class ChatManager {
|
||||
/** Array of all available ChatBox peripherals */
|
||||
private chatboxes: ChatBoxPeripheral[];
|
||||
|
||||
/** Queue for pending chat messages */
|
||||
private messageQueue = new Queue<ChatMessage>();
|
||||
|
||||
/** Queue for pending toast notifications */
|
||||
private toastQueue = new Queue<ChatToast>();
|
||||
|
||||
/** Buffer for received chat events */
|
||||
private chatBuffer = new Queue<ChatBoxEvent>();
|
||||
|
||||
/** Array tracking which chatboxes are currently idle (not in cooldown) */
|
||||
private idleChatboxes: boolean[];
|
||||
|
||||
/** Flag
|
||||
to control the running state of loops */
|
||||
private isRunning = false;
|
||||
|
||||
/** Lua thread for managing chat operations */
|
||||
private thread?: LuaThread;
|
||||
|
||||
/**
|
||||
* Constructor - initializes the ChatManager with available ChatBox peripherals
|
||||
* @param peripherals Array of ChatBox peripherals to manage
|
||||
*/
|
||||
constructor(peripherals: ChatBoxPeripheral[]) {
|
||||
if (peripherals.length === 0) {
|
||||
throw new Error("ChatManager requires at least one ChatBox peripheral");
|
||||
}
|
||||
|
||||
this.chatboxes = peripherals;
|
||||
// Initially all chatboxes are idle
|
||||
this.idleChatboxes = peripherals.map(() => true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a chat message to the sending queue
|
||||
* @param message The chat message to send
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
public sendMessage(message: ChatMessage): Result<void, ChatManagerError> {
|
||||
try {
|
||||
this.messageQueue.enqueue(message);
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to enqueue message: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a toast notification to the sending queue
|
||||
* @param toast The toast notification to send
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
public sendToast(toast: ChatToast): Result<void, ChatManagerError> {
|
||||
try {
|
||||
this.toastQueue.enqueue(toast);
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to enqueue toast: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves and removes the next received chat event from the buffer
|
||||
* @returns Result containing the chat event or an error if buffer is empty
|
||||
*/
|
||||
public getReceivedMessage(): Result<ChatBoxEvent, EmptyBufferError> {
|
||||
const event = this.chatBuffer.dequeue();
|
||||
if (event === undefined) {
|
||||
return new Err({
|
||||
kind: "EmptyBuffer",
|
||||
reason: "No messages in buffer",
|
||||
});
|
||||
}
|
||||
return new Ok(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the first available (idle) chatbox
|
||||
* @returns Result containing chatbox index or error if none available
|
||||
*/
|
||||
private findIdleChatbox(): Result<number, NoIdleChatboxError> {
|
||||
for (let i = 0; i < this.idleChatboxes.length; i++) {
|
||||
if (this.idleChatboxes[i]) {
|
||||
return new Ok(i);
|
||||
}
|
||||
}
|
||||
return new Err({
|
||||
kind: "NoIdleChatbox",
|
||||
reason: "All chatboxes are busy",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a chatbox as busy and sets up a timer to mark it as idle after cooldown
|
||||
* @param chatboxIndex Index of the chatbox to mark as busy
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
private setChatboxBusy(chatboxIndex: number): Result<void, ChatManagerError> {
|
||||
if (chatboxIndex < 0 || chatboxIndex >= this.idleChatboxes.length) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: "Invalid chatbox index",
|
||||
chatboxIndex,
|
||||
});
|
||||
}
|
||||
|
||||
this.idleChatboxes[chatboxIndex] = false;
|
||||
|
||||
if (!gTimerManager.status()) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: "TimerManager is not running",
|
||||
});
|
||||
}
|
||||
|
||||
gTimerManager.setTimeOut(1, () => {
|
||||
this.idleChatboxes[chatboxIndex] = true;
|
||||
});
|
||||
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to send a chat message using an available chatbox
|
||||
* @param message The message to send
|
||||
* @returns Result indicating success or failure with error details
|
||||
*/
|
||||
private trySendMessage(message: ChatMessage): Result<void, ChatError> {
|
||||
const chatboxResult = this.findIdleChatbox();
|
||||
if (chatboxResult.isErr()) {
|
||||
return chatboxResult;
|
||||
}
|
||||
|
||||
const chatboxIndex = chatboxResult.value;
|
||||
const chatbox = this.chatboxes[chatboxIndex];
|
||||
|
||||
try {
|
||||
let success: boolean;
|
||||
let errorMsg: string | undefined;
|
||||
|
||||
// Determine the appropriate sending method based on message properties
|
||||
if (message.targetPlayer !== undefined) {
|
||||
// Send private message to specific player
|
||||
if (typeof message.message === "string") {
|
||||
[success, errorMsg] = chatbox.sendMessageToPlayer(
|
||||
textutils.serialiseJSON(message.message, {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
message.targetPlayer,
|
||||
textutils.serialiseJSON(message.prefix ?? "AP", {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
message.brackets,
|
||||
message.bracketColor,
|
||||
message.range,
|
||||
message.utf8Support,
|
||||
);
|
||||
} else {
|
||||
// Handle MinecraftTextComponent for private message
|
||||
[success, errorMsg] = chatbox.sendFormattedMessageToPlayer(
|
||||
textutils.serialiseJSON(message.message, {
|
||||
unicode_strings: message.utf8Support,
|
||||
allow_repetitions: true,
|
||||
}),
|
||||
message.targetPlayer,
|
||||
textutils.serialiseJSON(message.prefix ?? "AP", {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
message.brackets,
|
||||
message.bracketColor,
|
||||
message.range,
|
||||
message.utf8Support,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Send global message
|
||||
if (typeof message.message === "string") {
|
||||
[success, errorMsg] = chatbox.sendMessage(
|
||||
textutils.serialiseJSON(message.message, {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
textutils.serialiseJSON(message.prefix ?? "AP", {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
message.brackets,
|
||||
message.bracketColor,
|
||||
message.range,
|
||||
message.utf8Support,
|
||||
);
|
||||
} else {
|
||||
// Handle MinecraftTextComponent for global message
|
||||
[success, errorMsg] = chatbox.sendFormattedMessage(
|
||||
textutils.serialiseJSON(message.message, {
|
||||
unicode_strings: message.utf8Support,
|
||||
allow_repetitions: true,
|
||||
}),
|
||||
textutils.serialiseJSON(message.prefix ?? "AP", {
|
||||
unicode_strings: message.utf8Support,
|
||||
}),
|
||||
message.brackets,
|
||||
message.bracketColor,
|
||||
message.range,
|
||||
message.utf8Support,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Mark chatbox as busy for cooldown period
|
||||
const busyResult = this.setChatboxBusy(chatboxIndex);
|
||||
if (busyResult.isErr()) {
|
||||
return busyResult;
|
||||
}
|
||||
return new Ok(undefined);
|
||||
} else {
|
||||
return new Err({
|
||||
kind: "SendFailure",
|
||||
reason: errorMsg ?? "Unknown send failure",
|
||||
chatboxIndex,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "SendFailure",
|
||||
reason: `Exception during send: ${String(error)}`,
|
||||
chatboxIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to send a toast notification using an available chatbox
|
||||
* @param toast The toast to send
|
||||
* @returns Result indicating success or failure with error details
|
||||
*/
|
||||
private trySendToast(toast: ChatToast): Result<void, ChatError> {
|
||||
const chatboxResult = this.findIdleChatbox();
|
||||
if (chatboxResult.isErr()) {
|
||||
return chatboxResult;
|
||||
}
|
||||
|
||||
const chatboxIndex = chatboxResult.value;
|
||||
const chatbox = this.chatboxes[chatboxIndex];
|
||||
|
||||
try {
|
||||
let success: boolean;
|
||||
let errorMsg: string | undefined;
|
||||
|
||||
// Send toast notification
|
||||
if (
|
||||
typeof toast.message === "string" &&
|
||||
typeof toast.title === "string"
|
||||
) {
|
||||
[success, errorMsg] = chatbox.sendToastToPlayer(
|
||||
textutils.serialiseJSON(toast.message, {
|
||||
unicode_strings: toast.utf8Support,
|
||||
}),
|
||||
textutils.serialiseJSON(toast.title, {
|
||||
unicode_strings: toast.utf8Support,
|
||||
}),
|
||||
toast.targetPlayer,
|
||||
textutils.serialiseJSON(toast.prefix ?? "AP", {
|
||||
unicode_strings: toast.utf8Support,
|
||||
}),
|
||||
toast.brackets,
|
||||
toast.bracketColor,
|
||||
toast.range,
|
||||
toast.utf8Support,
|
||||
);
|
||||
} else {
|
||||
// Handle MinecraftTextComponent for toast
|
||||
const messageJson =
|
||||
typeof toast.message === "string"
|
||||
? toast.message
|
||||
: textutils.serialiseJSON(toast.message, {
|
||||
unicode_strings: true,
|
||||
allow_repetitions: toast.utf8Support,
|
||||
});
|
||||
const titleJson =
|
||||
typeof toast.title === "string"
|
||||
? toast.title
|
||||
: textutils.serialiseJSON(toast.title, {
|
||||
unicode_strings: true,
|
||||
allow_repetitions: toast.utf8Support,
|
||||
});
|
||||
|
||||
[success, errorMsg] = chatbox.sendFormattedToastToPlayer(
|
||||
messageJson,
|
||||
titleJson,
|
||||
toast.targetPlayer,
|
||||
textutils.serialiseJSON(toast.prefix ?? "AP", {
|
||||
unicode_strings: toast.utf8Support,
|
||||
}),
|
||||
toast.brackets,
|
||||
toast.bracketColor,
|
||||
toast.range,
|
||||
toast.utf8Support,
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
// Mark chatbox as busy for cooldown period
|
||||
const busyResult = this.setChatboxBusy(chatboxIndex);
|
||||
if (busyResult.isErr()) {
|
||||
return busyResult;
|
||||
}
|
||||
return new Ok(undefined);
|
||||
} else {
|
||||
return new Err({
|
||||
kind: "SendFailure",
|
||||
reason: errorMsg ?? "Unknown toast send failure",
|
||||
chatboxIndex,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "SendFailure",
|
||||
reason: `Exception during toast send: ${String(error)}`,
|
||||
chatboxIndex,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main sending loop - continuously processes message and toast queues
|
||||
* Runs in a separate coroutine to handle sending with proper timing
|
||||
*/
|
||||
private sendLoop(): void {
|
||||
while (this.isRunning) {
|
||||
let sentSomething = false;
|
||||
|
||||
// Try to send a message if queue is not empty
|
||||
if (this.messageQueue.size() > 0) {
|
||||
const message = this.messageQueue.peek();
|
||||
if (message) {
|
||||
const result = this.trySendMessage(message);
|
||||
if (result.isOk()) {
|
||||
this.messageQueue.dequeue(); // Remove from queue only if successfully sent
|
||||
sentSomething = true;
|
||||
} else if (result.error.kind === "SendFailure") {
|
||||
// Log send failures but keep trying
|
||||
print(`Failed to send message: ${result.error.reason}`);
|
||||
this.messageQueue.dequeue(); // Remove failed message to prevent infinite retry
|
||||
}
|
||||
// For NoIdleChatbox errors, we keep the message in queue and try again later
|
||||
}
|
||||
}
|
||||
|
||||
// Try to send a toast if queue is not empty
|
||||
if (this.toastQueue.size() > 0) {
|
||||
const toast = this.toastQueue.peek();
|
||||
if (toast) {
|
||||
const result = this.trySendToast(toast);
|
||||
if (result.isOk()) {
|
||||
this.toastQueue.dequeue(); // Remove from queue only if successfully sent
|
||||
sentSomething = true;
|
||||
} else if (result.error.kind === "SendFailure") {
|
||||
// Log send failures but keep trying
|
||||
print(`Failed to send toast: ${result.error.reason}`);
|
||||
this.toastQueue.dequeue(); // Remove failed toast to prevent infinite retry
|
||||
}
|
||||
// For NoIdleChatbox errors, we keep the toast in queue and try again later
|
||||
}
|
||||
}
|
||||
|
||||
// Small sleep to prevent busy waiting and allow other coroutines to run
|
||||
if (!sentSomething) {
|
||||
sleep(0.1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main receiving loop - continuously listens for chat events
|
||||
* Runs in a separate coroutine to handle incoming messages
|
||||
*/
|
||||
private receiveLoop(): void {
|
||||
while (this.isRunning) {
|
||||
try {
|
||||
// Listen for chatbox_message events
|
||||
const event = pullEventAs(ChatBoxEvent, "chat");
|
||||
|
||||
if (event) {
|
||||
// Store received event in buffer for user processing
|
||||
this.chatBuffer.enqueue(event);
|
||||
}
|
||||
} catch (error) {
|
||||
// Log receive errors but continue running
|
||||
print(`Error in receive loop: ${String(error)}`);
|
||||
sleep(0.1); // Brief pause before retrying
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the ChatManager's main operation loops
|
||||
* Launches both sending and receiving coroutines in parallel
|
||||
* @returns Result indicating success or failure of startup
|
||||
*/
|
||||
public run(): Result<void, ChatManagerError> {
|
||||
if (this.isRunning) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: "ChatManager is already running",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this.isRunning = true;
|
||||
|
||||
// Start both send and receive loops in parallel
|
||||
parallel.waitForAll(
|
||||
() => this.sendLoop(),
|
||||
() => this.receiveLoop(),
|
||||
);
|
||||
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to start ChatManager: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the ChatManager asynchronously without blocking
|
||||
* Useful when you need to run other code alongside the ChatManager
|
||||
* @returns Result indicating success or failure of async startup
|
||||
*/
|
||||
public runAsync(): Result<LuaThread, ChatManagerError> {
|
||||
if (this.isRunning) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: "ChatManager is already running",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this.isRunning = true;
|
||||
this.thread = coroutine.create(() => {
|
||||
const result = this.run();
|
||||
if (result.isErr()) {
|
||||
print(`ChatManager async error: ${result.error.reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Start the run method in a separate coroutine
|
||||
coroutine.resume(this.thread);
|
||||
|
||||
return new Ok(this.thread);
|
||||
} catch (error) {
|
||||
this.isRunning = false;
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to start ChatManager async: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the ChatManager loops gracefully
|
||||
* @returns Result indicating success or failure of shutdown
|
||||
*/
|
||||
public stop(): Result<void, ChatManagerError> {
|
||||
if (!this.isRunning) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: "ChatManager is not running",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this.isRunning = false;
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to stop ChatManager: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of pending messages in the queue
|
||||
* @returns Number of pending messages
|
||||
*/
|
||||
public getPendingMessageCount(): number {
|
||||
return this.messageQueue.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of pending toasts in the queue
|
||||
* @returns Number of pending toasts
|
||||
*/
|
||||
public getPendingToastCount(): number {
|
||||
return this.toastQueue.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the number of received messages in the buffer
|
||||
* @returns Number of buffered received messages
|
||||
*/
|
||||
public getBufferedMessageCount(): number {
|
||||
return this.chatBuffer.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current status of all chatboxes
|
||||
* @returns Array of boolean values indicating which chatboxes are idle
|
||||
*/
|
||||
public getChatboxStatus(): boolean[] {
|
||||
return [...this.idleChatboxes]; // Return a copy to prevent external modification
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the running state of the ChatManager
|
||||
* @returns true if ChatManager is currently running
|
||||
*/
|
||||
public isManagerRunning(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears all pending messages and toasts from queues
|
||||
* Does not affect the received message buffer
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
public clearQueues(): Result<void, ChatManagerError> {
|
||||
try {
|
||||
this.messageQueue.clear();
|
||||
this.toastQueue.clear();
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to clear queues: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the received message buffer
|
||||
* @returns Result indicating success or failure
|
||||
*/
|
||||
public clearBuffer(): Result<void, ChatManagerError> {
|
||||
try {
|
||||
this.chatBuffer.clear();
|
||||
return new Ok(undefined);
|
||||
} catch (error) {
|
||||
return new Err({
|
||||
kind: "ChatManager",
|
||||
reason: `Failed to clear buffer: ${String(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to send a message immediately, bypassing the queue
|
||||
* @param message The message to send immediately
|
||||
* @returns Result indicating success or failure with error details
|
||||
*/
|
||||
public sendMessageImmediate(message: ChatMessage): Result<void, ChatError> {
|
||||
return this.trySendMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to send a toast immediately, bypassing the queue
|
||||
* @param toast The toast to send immediately
|
||||
* @returns Result indicating success or failure with error details
|
||||
*/
|
||||
public sendToastImmediate(toast: ChatToast): Result<void, ChatError> {
|
||||
return this.trySendToast(toast);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { CCLog } from "./ccLog";
|
||||
|
||||
const log = new CCLog("CraftManager.log");
|
||||
import { Queue } from "./datatype/Queue";
|
||||
import { Result, Ok, Err, Option, Some, None } from "./thirdparty/ts-result-es";
|
||||
|
||||
// ComputerCraft Turtle inventory layout:
|
||||
// 1, 2, 3, 4
|
||||
@@ -9,23 +8,52 @@ const log = new CCLog("CraftManager.log");
|
||||
// 13, 14, 15, 16
|
||||
|
||||
const TURTLE_SIZE = 16;
|
||||
const CRAFT_OUTPUT_SLOT = 4;
|
||||
// const CRAFT_SLOT_CNT = 9;
|
||||
const CRAFT_SLOT_TABLE: number[] = [1, 2, 3, 5, 6, 7, 9, 10, 11];
|
||||
// const REST_SLOT_CNT = 7;
|
||||
// const REST_SLOT_TABLE: number[] = [4, 8, 12, 13, 14, 15, 16];
|
||||
|
||||
/**
|
||||
* Represents the NBT data of a Create mod package. This data is used for managing crafting and logistics,
|
||||
* especially in the context of multi-step crafting orders.
|
||||
* The structure is inspired by the logic in Create's own packaging and repackaging helpers.
|
||||
* @see https://github.com/Creators-of-Create/Create/blob/mc1.21.1/dev/src/main/java/com/simibubi/create/content/logistics/packager/repackager/PackageRepackageHelper.java
|
||||
*/
|
||||
interface CreatePackageTag {
|
||||
/**
|
||||
* The items contained within this package.
|
||||
*/
|
||||
Items: {
|
||||
/**
|
||||
* A list of the items stored in the package.
|
||||
*/
|
||||
Items: {
|
||||
id: string;
|
||||
Count: number;
|
||||
Slot: number;
|
||||
}[];
|
||||
/**
|
||||
* The number of slots in the package's inventory.
|
||||
*/
|
||||
Size: number;
|
||||
};
|
||||
/**
|
||||
* Information about this package's role as a fragment of a larger crafting order.
|
||||
* This is used to track progress and manage dependencies in a distributed crafting system.
|
||||
*/
|
||||
Fragment: {
|
||||
/**
|
||||
* The index of this fragment within the larger order.
|
||||
*/
|
||||
Index: number;
|
||||
/**
|
||||
* The context of the overall order this fragment belongs to.
|
||||
*/
|
||||
OrderContext: {
|
||||
/**
|
||||
* A list of crafting recipes required for the order.
|
||||
*/
|
||||
OrderedCrafts: {
|
||||
Pattern: {
|
||||
Entries: {
|
||||
@@ -39,6 +67,9 @@ interface CreatePackageTag {
|
||||
};
|
||||
Count: number;
|
||||
}[];
|
||||
/**
|
||||
* A list of pre-existing item stacks required for the order.
|
||||
*/
|
||||
OrderedStacks: {
|
||||
Entries: {
|
||||
Item: {
|
||||
@@ -49,11 +80,26 @@ interface CreatePackageTag {
|
||||
}[];
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Whether this is the final fragment in the sequence for this specific part of the order.
|
||||
*/
|
||||
IsFinal: number;
|
||||
/**
|
||||
* The unique identifier for the overall order.
|
||||
*/
|
||||
OrderId: number;
|
||||
/**
|
||||
* The index of this package in a linked list of packages for the same order.
|
||||
*/
|
||||
LinkIndex: number;
|
||||
/**
|
||||
* Whether this is the last package in the linked list.
|
||||
*/
|
||||
IsFinalLink: number;
|
||||
};
|
||||
/**
|
||||
* The destination address for this package.
|
||||
*/
|
||||
Address: string;
|
||||
}
|
||||
|
||||
@@ -70,12 +116,27 @@ interface CraftRecipe {
|
||||
Count: number;
|
||||
}
|
||||
|
||||
interface InventorySlotInfo {
|
||||
name: string;
|
||||
slotCountQueue: Queue<{
|
||||
slotNum: number;
|
||||
count: number;
|
||||
}>;
|
||||
maxCount: number;
|
||||
}
|
||||
|
||||
type CraftMode = "keep" | "keepProduct" | "keepIngredient";
|
||||
|
||||
class CraftManager {
|
||||
private localName: string;
|
||||
private inventory: InventoryPeripheral;
|
||||
|
||||
constructor(modem: WiredModemPeripheral | string) {
|
||||
private inventoryItemsMap = new Map<string, InventorySlotInfo>();
|
||||
|
||||
constructor(
|
||||
modem: WiredModemPeripheral | string,
|
||||
srcInventory: InventoryPeripheral,
|
||||
) {
|
||||
if (turtle == undefined) {
|
||||
throw new Error("Script must be run in a turtle computer");
|
||||
}
|
||||
@@ -99,98 +160,207 @@ class CraftManager {
|
||||
}
|
||||
this.localName = name;
|
||||
// log.info(`Get turtle name : ${name}`);
|
||||
}
|
||||
|
||||
public pushAll(outputInventory: InventoryPeripheral): void {
|
||||
for (let i = 1; i <= TURTLE_SIZE; i++) {
|
||||
outputInventory.pullItems(this.localName, i);
|
||||
}
|
||||
}
|
||||
|
||||
public craft(dstInventory?: InventoryPeripheral, limit?: number): void {
|
||||
turtle.craft(limit);
|
||||
|
||||
if (dstInventory != undefined) {
|
||||
dstInventory.pullItems(this.localName, 1, limit);
|
||||
}
|
||||
// Inventory
|
||||
this.inventory = srcInventory;
|
||||
}
|
||||
|
||||
public static getPackageRecipe(
|
||||
item: BlockItemDetailData,
|
||||
): CraftRecipe[] | undefined {
|
||||
): Option<CraftRecipe[]> {
|
||||
if (
|
||||
!item.id.includes("create:cardboard_package") ||
|
||||
(item.tag as CreatePackageTag)?.Fragment?.OrderContext
|
||||
?.OrderedCrafts?.[0] == undefined
|
||||
) {
|
||||
return undefined;
|
||||
return None;
|
||||
}
|
||||
|
||||
const orderedCraft = (item.tag as CreatePackageTag).Fragment.OrderContext
|
||||
.OrderedCrafts;
|
||||
return orderedCraft.map((value, _) => ({
|
||||
PatternEntries: value.Pattern.Entries,
|
||||
Count: value.Count,
|
||||
}));
|
||||
return new Some(
|
||||
orderedCraft.map((value, _) => ({
|
||||
PatternEntries: value.Pattern.Entries,
|
||||
Count: value.Count,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
public pullItems(
|
||||
recipe: CraftRecipe,
|
||||
inventory: InventoryPeripheral,
|
||||
limit: number,
|
||||
): number {
|
||||
let maxCraftCount = limit;
|
||||
public initItemsMap() {
|
||||
const ingredientList = this.inventory.list();
|
||||
for (const key in ingredientList) {
|
||||
const slotNum = parseInt(key);
|
||||
const item = this.inventory.getItemDetail(slotNum)!;
|
||||
|
||||
if (this.inventoryItemsMap.has(item.name)) {
|
||||
this.inventoryItemsMap.get(item.name)!.slotCountQueue.enqueue({
|
||||
slotNum: slotNum,
|
||||
count: item.count,
|
||||
});
|
||||
} else {
|
||||
this.inventoryItemsMap.set(item.name, {
|
||||
name: item.name,
|
||||
maxCount: item.maxCount,
|
||||
slotCountQueue: new Queue<{ slotNum: number; count: number }>([
|
||||
{ slotNum: slotNum, count: item.count },
|
||||
]),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public pullFromInventory(
|
||||
itemId: string,
|
||||
count?: number,
|
||||
toSlot?: number,
|
||||
): Result<number> {
|
||||
const item = this.inventoryItemsMap.get(itemId);
|
||||
if (item === undefined || item.slotCountQueue.size() === 0)
|
||||
return new Err(Error(`No item match ${itemId}`));
|
||||
|
||||
if (count === undefined) {
|
||||
const itemSlot = item.slotCountQueue.dequeue()!;
|
||||
const pullItemsCnt = this.inventory.pushItems(
|
||||
this.localName,
|
||||
itemSlot.slotNum,
|
||||
itemSlot.count,
|
||||
toSlot,
|
||||
);
|
||||
return new Ok(pullItemsCnt);
|
||||
}
|
||||
|
||||
let restCount = count;
|
||||
while (restCount > 0 && item.slotCountQueue.size() > 0) {
|
||||
const itemSlot = item.slotCountQueue.dequeue()!;
|
||||
const pullItemsCnt = this.inventory.pushItems(
|
||||
this.localName,
|
||||
itemSlot.slotNum,
|
||||
Math.min(restCount, itemSlot.count),
|
||||
toSlot,
|
||||
);
|
||||
if (pullItemsCnt < itemSlot.count) {
|
||||
item.slotCountQueue.enqueue({
|
||||
slotNum: itemSlot.slotNum,
|
||||
count: itemSlot.count - pullItemsCnt,
|
||||
});
|
||||
}
|
||||
restCount -= pullItemsCnt;
|
||||
}
|
||||
|
||||
return new Ok(count - restCount);
|
||||
}
|
||||
|
||||
public pushToInventoryEmpty(
|
||||
fromSlot: number,
|
||||
count?: number,
|
||||
): Result<number> {
|
||||
let emptySlot = 0;
|
||||
for (let i = this.inventory.size(); i > 0; i--) {
|
||||
const isEmpty = this.inventory.getItemDetail(i) === undefined;
|
||||
if (isEmpty) {
|
||||
emptySlot = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (emptySlot <= 0) return new Err(Error("No empty slot found"));
|
||||
|
||||
return new Ok(
|
||||
this.inventory.pullItems(this.localName, fromSlot, count, emptySlot),
|
||||
);
|
||||
}
|
||||
|
||||
public pushToInventory(fromSlot: number): Result<number> {
|
||||
const itemInfoDetail = turtle.getItemDetail(fromSlot) as
|
||||
| SlotDetail
|
||||
| undefined;
|
||||
if (itemInfoDetail === undefined) return new Ok(0);
|
||||
const inventoryItemInfo = this.inventoryItemsMap.get(itemInfoDetail.name);
|
||||
|
||||
if (inventoryItemInfo === undefined) {
|
||||
return this.pushToInventoryEmpty(fromSlot, itemInfoDetail.count);
|
||||
}
|
||||
|
||||
let restItemsCount = itemInfoDetail.count;
|
||||
for (const slotInfo of inventoryItemInfo.slotCountQueue) {
|
||||
const pullItemsCount = inventoryItemInfo.maxCount - slotInfo.count;
|
||||
if (pullItemsCount > 0) {
|
||||
this.inventory.pullItems(
|
||||
this.localName,
|
||||
fromSlot,
|
||||
pullItemsCount,
|
||||
slotInfo.slotNum,
|
||||
);
|
||||
restItemsCount -= pullItemsCount;
|
||||
if (restItemsCount <= 0) break;
|
||||
}
|
||||
}
|
||||
|
||||
if (restItemsCount > 0) {
|
||||
const pushRet = this.pushToInventoryEmpty(fromSlot, restItemsCount);
|
||||
if (pushRet.isErr()) return pushRet;
|
||||
}
|
||||
|
||||
return new Ok(itemInfoDetail.count);
|
||||
}
|
||||
|
||||
public clearTurtle(slots?: number[]): void {
|
||||
if (slots !== undefined) {
|
||||
for (const slotNum of slots) {
|
||||
this.pushToInventory(slotNum);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 1; i <= TURTLE_SIZE; i++) {
|
||||
this.pushToInventory(i);
|
||||
}
|
||||
}
|
||||
|
||||
public craft(limit?: number, outputSlot = CRAFT_OUTPUT_SLOT): ItemDetail {
|
||||
turtle.select(outputSlot);
|
||||
turtle.craft(limit);
|
||||
const craftItemDetail = turtle.getItemDetail(
|
||||
outputSlot,
|
||||
true,
|
||||
) as ItemDetail;
|
||||
|
||||
return craftItemDetail;
|
||||
}
|
||||
|
||||
public pullItemsWithRecipe(
|
||||
recipe: CraftRecipe,
|
||||
craftCnt: number,
|
||||
): Result<number> {
|
||||
let maxCraftCnt = craftCnt;
|
||||
for (const index in recipe.PatternEntries) {
|
||||
const entry = recipe.PatternEntries[index];
|
||||
if (entry.Item.Count == 0 || entry.Item.id == "minecraft:air") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const ingredientList = inventory.list();
|
||||
let restCount = maxCraftCount;
|
||||
for (const key in ingredientList) {
|
||||
// Get item detail and check max count
|
||||
const slot = parseInt(key);
|
||||
const ingredient = inventory.getItemDetail(slot)!;
|
||||
if (entry.Item.id != ingredient.name) {
|
||||
continue;
|
||||
}
|
||||
const ingredient = this.inventoryItemsMap.get(entry.Item.id);
|
||||
if (ingredient === undefined)
|
||||
return new Err(Error(`No ingredient match ${entry.Item.id}`));
|
||||
|
||||
const ingredientMaxCount = ingredient.maxCount;
|
||||
if (maxCraftCount > ingredientMaxCount) {
|
||||
maxCraftCount = ingredientMaxCount;
|
||||
restCount = maxCraftCount;
|
||||
}
|
||||
log.info(
|
||||
`Slot ${slot} ${ingredient.name} max count: ${ingredientMaxCount}`,
|
||||
);
|
||||
|
||||
// TODO: Process multi count entry item
|
||||
if (ingredient.count >= restCount) {
|
||||
inventory.pushItems(
|
||||
this.localName,
|
||||
slot,
|
||||
restCount,
|
||||
CRAFT_SLOT_TABLE[parseInt(index) - 1],
|
||||
);
|
||||
restCount = 0;
|
||||
break;
|
||||
} else {
|
||||
inventory.pushItems(
|
||||
this.localName,
|
||||
slot,
|
||||
ingredient.count,
|
||||
CRAFT_SLOT_TABLE[parseInt(index) - 1],
|
||||
);
|
||||
restCount -= ingredient.count;
|
||||
}
|
||||
// Check item max stack count
|
||||
if (ingredient.maxCount < maxCraftCnt) {
|
||||
maxCraftCnt = ingredient.maxCount;
|
||||
}
|
||||
|
||||
if (restCount > 0) return 0;
|
||||
// Pull items
|
||||
const pullItemsCnt = this.pullFromInventory(
|
||||
ingredient.name,
|
||||
maxCraftCnt,
|
||||
CRAFT_SLOT_TABLE[index],
|
||||
);
|
||||
if (pullItemsCnt.isErr()) return pullItemsCnt;
|
||||
|
||||
if (pullItemsCnt.value < maxCraftCnt)
|
||||
return new Err(Error("Not enough items in inventory"));
|
||||
}
|
||||
|
||||
return maxCraftCount;
|
||||
return new Ok(maxCraftCnt);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
36
src/lib/TimerManager.ts
Normal file
36
src/lib/TimerManager.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { pullEventAs, TimerEvent } from "./event";
|
||||
import { Result, Ok, Err, Option, Some, None } from "./thirdparty/ts-result-es";
|
||||
|
||||
class TimerManager {
|
||||
private isRunning = false;
|
||||
|
||||
private timerTaskMap = new Map<number, () => void>();
|
||||
|
||||
// Don't put heavy logic on callback function
|
||||
public setTimeOut(delay: number, callback: () => void): void {
|
||||
const timerId = os.startTimer(delay);
|
||||
this.timerTaskMap.set(timerId, callback);
|
||||
}
|
||||
|
||||
public run() {
|
||||
this.isRunning = true;
|
||||
while (this.isRunning) {
|
||||
const event = pullEventAs(TimerEvent, "timer");
|
||||
if (event === undefined) continue;
|
||||
|
||||
const task = this.timerTaskMap.get(event.id);
|
||||
if (task === undefined) continue;
|
||||
task();
|
||||
}
|
||||
}
|
||||
|
||||
public stop() {
|
||||
this.isRunning = false;
|
||||
}
|
||||
|
||||
public status(): boolean {
|
||||
return this.isRunning;
|
||||
}
|
||||
}
|
||||
|
||||
export const gTimerManager = new TimerManager();
|
||||
226
src/lib/ccCLI/cli.ts
Normal file
226
src/lib/ccCLI/cli.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { Ok, Err, Result } from "../thirdparty/ts-result-es";
|
||||
import {
|
||||
Command,
|
||||
ActionContext,
|
||||
Argument,
|
||||
Option,
|
||||
CliError,
|
||||
ParseResult,
|
||||
} from "./types";
|
||||
import {
|
||||
parseArguments,
|
||||
validateRequiredArgs,
|
||||
validateRequiredOptions,
|
||||
normalizeOptions,
|
||||
} from "./parser";
|
||||
import { generateHelp, shouldShowHelp, generateCommandList } from "./help";
|
||||
|
||||
/**
|
||||
* @interface CreateCliOptions
|
||||
* @description Optional configuration for the CLI handler.
|
||||
*/
|
||||
export interface CreateCliOptions<TContext extends object> {
|
||||
/** An optional global context object to be made available in all command actions. */
|
||||
globalContext?: TContext;
|
||||
/** An optional function to handle output.
|
||||
* Default: textutils.pagedPrint(msg, term.getCursorPos()[1] - 2)
|
||||
**/
|
||||
writer?: (message: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a CLI handler function from a root command definition.
|
||||
* @param rootCommand The root command for the entire CLI application.
|
||||
* @param globalContext An optional global context object to be made available in all command actions.
|
||||
* @returns A function that takes command-line arguments and executes the appropriate command.
|
||||
*/
|
||||
export function createCli<TContext extends object>(
|
||||
rootCommand: Command<TContext>,
|
||||
options: CreateCliOptions<TContext> = {},
|
||||
): (argv: string[]) => void {
|
||||
const {
|
||||
globalContext,
|
||||
writer = (msg) => textutils.pagedPrint(msg, term.getCursorPos()[1] - 2),
|
||||
} = options;
|
||||
|
||||
return (argv: string[]): void => {
|
||||
// Check for top-level help flags before any parsing.
|
||||
if (argv[0]?.startsWith("--help") || argv[0]?.startsWith("-h")) {
|
||||
writer(generateHelp(rootCommand, [rootCommand.name]));
|
||||
return;
|
||||
}
|
||||
|
||||
const parseResult = parseArguments(argv, rootCommand);
|
||||
|
||||
if (parseResult.isErr()) {
|
||||
const error = parseResult.error;
|
||||
writer(formatError(error, rootCommand));
|
||||
|
||||
// If it was an unknown command, suggest alternatives.
|
||||
if (error.kind === "UnknownCommand") {
|
||||
// Find parent command to suggest alternatives
|
||||
const parentResult = parseArguments(argv.slice(0, -1), rootCommand);
|
||||
if (parentResult.isOk() && parentResult.value.command.subcommands) {
|
||||
writer(generateCommandList(parentResult.value.command.subcommands));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const executionResult = processAndExecute(
|
||||
parseResult.value,
|
||||
globalContext,
|
||||
(msg: string) => writer(msg),
|
||||
);
|
||||
|
||||
if (executionResult.isErr()) {
|
||||
const error = executionResult.error;
|
||||
writer(formatError(error, rootCommand));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the parsed input and executes the resolved command.
|
||||
* @param parseResult The result from parsing with integrated command resolution.
|
||||
* @param globalContext The global context for the CLI.
|
||||
* @param writer Function to output messages.
|
||||
* @returns A `Result` indicating the success or failure of the execution.
|
||||
*/
|
||||
function processAndExecute<TContext extends object>(
|
||||
parseResult: ParseResult<TContext>,
|
||||
globalContext: TContext | undefined,
|
||||
writer: (message: string) => void,
|
||||
): Result<void, CliError> {
|
||||
const { command, commandPath, options, remaining } = parseResult;
|
||||
|
||||
// Unified Help Check:
|
||||
// A command should show its help page if:
|
||||
// 1. A help flag is explicitly passed (`--help` or `-h`). This has the highest priority.
|
||||
// 2. It's a command group that was called without a subcommand (i.e., it has no action).
|
||||
const isHelpFlagPassed = shouldShowHelp([
|
||||
...remaining,
|
||||
...Object.keys(options),
|
||||
]);
|
||||
const isCommandGroupWithoutAction =
|
||||
command.subcommands !== undefined &&
|
||||
command.subcommands.size > 0 &&
|
||||
command.action === undefined;
|
||||
|
||||
if (isHelpFlagPassed || isCommandGroupWithoutAction) {
|
||||
writer(generateHelp(command, commandPath));
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
// If we are here, it's a runnable command. It must have an action.
|
||||
if (command.action === undefined) {
|
||||
// This case should ideally not be reached if the parser and the logic above are correct.
|
||||
// It would mean a command has no action and no subcommands, which is a configuration error.
|
||||
return new Err({
|
||||
kind: "NoAction",
|
||||
commandPath: [...commandPath, command.name],
|
||||
});
|
||||
}
|
||||
|
||||
// Now we know it's a runnable command, and no help flag was passed.
|
||||
// We can now safely process the remaining items as arguments.
|
||||
return processArguments(command.args ?? [], remaining)
|
||||
.andThen((args) => {
|
||||
return processOptions(
|
||||
command.options !== undefined
|
||||
? Array.from(command.options.values())
|
||||
: [],
|
||||
options,
|
||||
).map((processedOptions) => ({ args, options: processedOptions }));
|
||||
})
|
||||
.andThen(({ args, options: processedOptions }) => {
|
||||
const context: ActionContext<TContext> = {
|
||||
args,
|
||||
options: processedOptions,
|
||||
context: globalContext!,
|
||||
};
|
||||
// Finally, execute the command's action.
|
||||
return command.action!(context);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and validates command arguments from the raw input.
|
||||
* @param argDefs The argument definitions for the command.
|
||||
* @param remainingArgs The remaining positional arguments.
|
||||
* @returns A `Result` with the processed arguments record or a `MissingArgumentError`.
|
||||
*/
|
||||
function processArguments(
|
||||
argDefs: Argument[],
|
||||
remainingArgs: string[],
|
||||
): Result<Record<string, unknown>, CliError> {
|
||||
const args: Record<string, unknown> = {};
|
||||
|
||||
for (let i = 0; i < argDefs.length; i++) {
|
||||
const argDef = argDefs[i];
|
||||
if (i < remainingArgs.length) {
|
||||
args[argDef.name] = remainingArgs[i];
|
||||
}
|
||||
}
|
||||
|
||||
const requiredArgs = argDefs
|
||||
.filter((arg) => arg.required ?? false)
|
||||
.map((arg) => arg.name);
|
||||
return validateRequiredArgs(args, requiredArgs).map(() => args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and validates command options from the raw input.
|
||||
* @param optionDefs The option definitions for the command.
|
||||
* @param rawOptions The raw options parsed from the command line.
|
||||
* @returns A `Result` with the processed options record or a `MissingOptionError`.
|
||||
*/
|
||||
function processOptions(
|
||||
optionDefs: Option[],
|
||||
rawOptions: Record<string, unknown>,
|
||||
): Result<Record<string, unknown>, CliError> {
|
||||
const shortToLongMap: Record<string, string> = {};
|
||||
const defaultValues: Record<string, unknown> = {};
|
||||
|
||||
for (const optionDef of optionDefs) {
|
||||
if (optionDef.shortName !== undefined) {
|
||||
shortToLongMap[optionDef.shortName] = optionDef.name;
|
||||
}
|
||||
if (optionDef.defaultValue !== undefined) {
|
||||
defaultValues[optionDef.name] = optionDef.defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedOptions = normalizeOptions(rawOptions, shortToLongMap);
|
||||
const options = { ...defaultValues, ...normalizedOptions };
|
||||
|
||||
const requiredOptions = optionDefs
|
||||
.filter((opt) => opt.required ?? false)
|
||||
.map((opt) => opt.name);
|
||||
return validateRequiredOptions(options, requiredOptions).map(() => options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a `CliError` into a user-friendly string.
|
||||
* @param error The `CliError` object.
|
||||
* @param rootCommand The root command, used for context in some errors.
|
||||
* @returns A formatted error message string.
|
||||
*/
|
||||
function formatError<TContext extends object>(
|
||||
error: CliError,
|
||||
_rootCommand: Command<TContext>,
|
||||
): string {
|
||||
switch (error.kind) {
|
||||
case "UnknownCommand":
|
||||
return `Error: Unknown command "${error.commandName}".`;
|
||||
case "MissingArgument":
|
||||
return `Error: Missing required argument "${error.argName}".`;
|
||||
case "MissingOption":
|
||||
return `Error: Missing required option "--${error.optionName}".`;
|
||||
case "NoAction":
|
||||
return `Error: Command "${error.commandPath.join(" ")}" is not runnable.`;
|
||||
default:
|
||||
// This should be unreachable if all error kinds are handled.
|
||||
return "An unexpected error occurred.";
|
||||
}
|
||||
}
|
||||
107
src/lib/ccCLI/help.ts
Normal file
107
src/lib/ccCLI/help.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { Command } from "./types";
|
||||
|
||||
/**
|
||||
* Generates a well-formatted help string for a given command.
|
||||
* @param command The command to generate help for.
|
||||
* @param commandPath The path to the command, used for showing the full command name.
|
||||
* @returns A formatted string containing the complete help message.
|
||||
*/
|
||||
export function generateHelp<TContext extends object>(
|
||||
command: Command<TContext>,
|
||||
commandPath: string[] = [],
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const fullCommandName = commandPath.join(" ");
|
||||
|
||||
// Description
|
||||
if (command.description !== undefined) {
|
||||
lines.push(command.description);
|
||||
}
|
||||
|
||||
// Usage
|
||||
const usageParts: string[] = ["Usage:", fullCommandName];
|
||||
if (command.options && command.options.size > 0) {
|
||||
usageParts.push("[OPTIONS]");
|
||||
}
|
||||
if (command.subcommands && command.subcommands.size > 0) {
|
||||
usageParts.push("<COMMAND>");
|
||||
}
|
||||
if (command.args && command.args.length > 0) {
|
||||
for (const arg of command.args) {
|
||||
usageParts.push(
|
||||
arg.required === true ? `<${arg.name}>` : `[${arg.name}]`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("\n" + usageParts.join(" "));
|
||||
|
||||
// Arguments
|
||||
if (command.args && command.args.length > 0) {
|
||||
lines.push("\nArguments:");
|
||||
for (const arg of command.args) {
|
||||
const requiredText = arg.required === true ? " (required)" : "";
|
||||
lines.push(` ${arg.name.padEnd(20)} ${arg.description}${requiredText}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Options
|
||||
if (command.options && command.options.size > 0) {
|
||||
lines.push("\nOptions:");
|
||||
for (const option of command.options.values()) {
|
||||
const short =
|
||||
option.shortName !== undefined ? `-${option.shortName}, ` : " ";
|
||||
const long = `--${option.name}`;
|
||||
const display = `${short}${long}`.padEnd(20);
|
||||
const requiredText = option.required === true ? " (required)" : "";
|
||||
const defaultText =
|
||||
option.defaultValue !== undefined
|
||||
? ` (default: ${textutils.serialise(option.defaultValue!)})`
|
||||
: "";
|
||||
lines.push(
|
||||
` ${display} ${option.description}${requiredText}${defaultText}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Subcommands
|
||||
if (command.subcommands && command.subcommands.size > 0) {
|
||||
lines.push("\nCommands:");
|
||||
for (const subcommand of command.subcommands.values()) {
|
||||
lines.push(` ${subcommand.name.padEnd(20)} ${subcommand.description}`);
|
||||
}
|
||||
lines.push(
|
||||
`\nRun '${fullCommandName} <COMMAND> --help' for more information on a command.`,
|
||||
);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a simple list of available commands, typically for error messages.
|
||||
* @param commands An array of command objects.
|
||||
* @returns A formatted string listing the available commands.
|
||||
*/
|
||||
export function generateCommandList<TContext extends object>(
|
||||
commands: Map<string, Command<TContext>>,
|
||||
): string {
|
||||
if (commands.size === 0) {
|
||||
return "No commands available.";
|
||||
}
|
||||
|
||||
const lines: string[] = ["Available commands:"];
|
||||
for (const command of commands.values()) {
|
||||
lines.push(` ${command.name.padEnd(20)} ${command.description}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the `--help` or `-h` flag is present in the arguments.
|
||||
* @param argv An array of command-line arguments.
|
||||
* @returns `true` if a help flag is found, otherwise `false`.
|
||||
*/
|
||||
export function shouldShowHelp(argv: string[]): boolean {
|
||||
return argv.includes("help") || argv.includes("h");
|
||||
}
|
||||
32
src/lib/ccCLI/index.ts
Normal file
32
src/lib/ccCLI/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* CC:Tweaked CLI Framework
|
||||
*
|
||||
* A functional-style CLI framework for CC:Tweaked and TSTL.
|
||||
* This framework provides a declarative way to define command-line interfaces with support
|
||||
* for nested commands, arguments, options, and Result-based error handling.
|
||||
*/
|
||||
|
||||
// --- Core public API ---
|
||||
export { createCli } from "./cli";
|
||||
|
||||
// --- Type definitions for creating commands ---
|
||||
export type {
|
||||
Command,
|
||||
Argument,
|
||||
Option,
|
||||
ActionContext,
|
||||
CliError,
|
||||
UnknownCommandError,
|
||||
MissingArgumentError,
|
||||
MissingOptionError,
|
||||
NoActionError,
|
||||
} from "./types";
|
||||
|
||||
// --- Utility functions for help generation and advanced parsing ---
|
||||
export { generateHelp, generateCommandList, shouldShowHelp } from "./help";
|
||||
export {
|
||||
parseArguments,
|
||||
validateRequiredArgs,
|
||||
validateRequiredOptions,
|
||||
normalizeOptions,
|
||||
} from "./parser";
|
||||
273
src/lib/ccCLI/parser.ts
Normal file
273
src/lib/ccCLI/parser.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import { Ok, Err, Result } from "../thirdparty/ts-result-es";
|
||||
import {
|
||||
ParseResult,
|
||||
MissingArgumentError,
|
||||
MissingOptionError,
|
||||
Command,
|
||||
Option,
|
||||
CliError,
|
||||
CommandResolution,
|
||||
} from "./types";
|
||||
|
||||
// Cache class to handle option maps with proper typing
|
||||
class OptionMapCache {
|
||||
private cache = new WeakMap<
|
||||
object,
|
||||
{
|
||||
optionMap: Map<string, Option>;
|
||||
shortNameMap: Map<string, string>;
|
||||
}
|
||||
>();
|
||||
|
||||
get<TContext extends object>(command: Command<TContext>) {
|
||||
return this.cache.get(command);
|
||||
}
|
||||
|
||||
set<TContext extends object>(
|
||||
command: Command<TContext>,
|
||||
value: {
|
||||
optionMap: Map<string, Option>;
|
||||
shortNameMap: Map<string, string>;
|
||||
},
|
||||
) {
|
||||
this.cache.set(command, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy option map builder with global caching
|
||||
function getOptionMaps<TContext extends object>(
|
||||
optionCache: OptionMapCache,
|
||||
command: Command<TContext>,
|
||||
) {
|
||||
// Quick check: if command has no options, return empty maps
|
||||
if (!command.options || command.options.size === 0) {
|
||||
return {
|
||||
optionMap: new Map<string, Option>(),
|
||||
shortNameMap: new Map<string, string>(),
|
||||
};
|
||||
}
|
||||
|
||||
let cached = optionCache.get(command);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const optionMap = new Map<string, Option>();
|
||||
const shortNameMap = new Map<string, string>();
|
||||
|
||||
for (const [optionName, option] of command.options) {
|
||||
optionMap.set(optionName, option);
|
||||
if (option.shortName !== undefined && option.shortName !== null) {
|
||||
shortNameMap.set(option.shortName, optionName);
|
||||
}
|
||||
}
|
||||
|
||||
cached = { optionMap, shortNameMap };
|
||||
optionCache.set(command, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses command line arguments with integrated command resolution.
|
||||
* This function dynamically finds the target command during parsing and uses
|
||||
* the command's option definitions for intelligent option handling.
|
||||
* @param argv Array of command line arguments.
|
||||
* @param rootCommand The root command to start parsing from.
|
||||
* @returns A `Result` containing the `ParseResult` or a `CliError`.
|
||||
*/
|
||||
export function parseArguments<TContext extends object>(
|
||||
argv: string[],
|
||||
rootCommand: Command<TContext>,
|
||||
): Result<ParseResult<TContext>, CliError> {
|
||||
const result: ParseResult<TContext> = {
|
||||
command: rootCommand,
|
||||
commandPath: [rootCommand.name],
|
||||
options: {},
|
||||
remaining: [],
|
||||
};
|
||||
|
||||
let currentCommand = rootCommand;
|
||||
let inOptions = false;
|
||||
const optionMapCache = new OptionMapCache();
|
||||
|
||||
// Cache option maps for current command - only updated when command changes
|
||||
let currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
|
||||
|
||||
// Helper function to update command context and refresh option maps
|
||||
const updateCommand = (
|
||||
newCommand: Command<TContext>,
|
||||
commandName: string,
|
||||
) => {
|
||||
currentCommand = newCommand;
|
||||
result.command = currentCommand;
|
||||
result.commandPath.push(commandName);
|
||||
currentOptionMaps = getOptionMaps(optionMapCache, currentCommand);
|
||||
};
|
||||
|
||||
// Helper function to process option value
|
||||
const processOption = (optionName: string, i: number): number => {
|
||||
const optionDef = currentOptionMaps.optionMap.get(optionName);
|
||||
const nextArg = argv[i + 1];
|
||||
const isKnownBooleanOption =
|
||||
optionDef !== undefined && optionDef.defaultValue === undefined;
|
||||
const nextArgLooksLikeValue =
|
||||
nextArg !== undefined && nextArg !== null && !nextArg.startsWith("-");
|
||||
|
||||
if (nextArgLooksLikeValue && !isKnownBooleanOption) {
|
||||
result.options[optionName] = nextArg;
|
||||
return i + 1; // Skip the value argument
|
||||
} else {
|
||||
result.options[optionName] = true;
|
||||
return i;
|
||||
}
|
||||
};
|
||||
|
||||
// Single pass through argv
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
|
||||
// Skip null/undefined arguments
|
||||
if (!arg) continue;
|
||||
|
||||
// Handle double dash (--) - everything after is treated as remaining
|
||||
if (arg === "--") {
|
||||
result.remaining.push(...argv.slice(i + 1));
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle long options (--option or --option=value)
|
||||
if (arg.startsWith("--")) {
|
||||
inOptions = true;
|
||||
const equalsIndex = arg.indexOf("=");
|
||||
|
||||
if (equalsIndex !== -1) {
|
||||
// --option=value format
|
||||
const optionName = arg.slice(2, equalsIndex);
|
||||
const optionValue = arg.slice(equalsIndex + 1);
|
||||
result.options[optionName] = optionValue;
|
||||
} else {
|
||||
// --option [value] format
|
||||
const optionName = arg.slice(2);
|
||||
i = processOption(optionName, i);
|
||||
}
|
||||
}
|
||||
// Handle short options (-o or -o value)
|
||||
else if (arg.startsWith("-") && arg.length > 1) {
|
||||
inOptions = true;
|
||||
const shortName = arg.slice(1);
|
||||
const optionName =
|
||||
currentOptionMaps.shortNameMap.get(shortName) ?? shortName;
|
||||
i = processOption(optionName, i);
|
||||
}
|
||||
// Handle positional arguments and command resolution
|
||||
else {
|
||||
if (!inOptions) {
|
||||
// Try to find this as a subcommand of the current command
|
||||
const subcommand = currentCommand.subcommands?.get(arg);
|
||||
if (subcommand) {
|
||||
updateCommand(subcommand, arg);
|
||||
} else {
|
||||
// Not a subcommand, treat as remaining argument
|
||||
result.remaining.push(arg);
|
||||
}
|
||||
} else {
|
||||
// After options have started, treat as remaining argument
|
||||
result.remaining.push(arg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Ok(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the target command based on a given path.
|
||||
* @param rootCommand The command to start searching from.
|
||||
* @param commandPath An array of strings representing the path to the command.
|
||||
* @returns A `Result` containing the `CommandResolution` or an `UnknownCommandError`.
|
||||
*/
|
||||
export function findCommand<TContext extends object>(
|
||||
rootCommand: Command<TContext>,
|
||||
commandPath: string[],
|
||||
): Result<CommandResolution<TContext>, CliError> {
|
||||
let currentCommand = rootCommand;
|
||||
const resolvedPath: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
for (const name of commandPath) {
|
||||
const subcommand = currentCommand.subcommands?.get(name);
|
||||
if (!subcommand) {
|
||||
// Part of the path was not a valid command, so the rest are arguments.
|
||||
return new Err({ kind: "UnknownCommand", commandName: name });
|
||||
}
|
||||
currentCommand = subcommand;
|
||||
resolvedPath.push(name);
|
||||
i++;
|
||||
}
|
||||
|
||||
const remainingArgs = commandPath.slice(i);
|
||||
return new Ok({
|
||||
command: currentCommand,
|
||||
commandPath: resolvedPath,
|
||||
remainingArgs,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all required arguments are present in the parsed arguments.
|
||||
* @param parsedArgs A record of the arguments that were parsed.
|
||||
* @param requiredArgs An array of names of required arguments.
|
||||
* @returns An `Ok` result if validation passes, otherwise an `Err` with a `MissingArgumentError`.
|
||||
*/
|
||||
export function validateRequiredArgs(
|
||||
parsedArgs: Record<string, unknown>,
|
||||
requiredArgs: string[],
|
||||
): Result<void, MissingArgumentError> {
|
||||
for (const argName of requiredArgs) {
|
||||
if (!(argName in parsedArgs) || parsedArgs[argName] === undefined) {
|
||||
return new Err({ kind: "MissingArgument", argName });
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that all required options are present in the parsed options.
|
||||
* @param parsedOptions A record of the options that were parsed.
|
||||
* @param requiredOptions An array of names of required options.
|
||||
* @returns An `Ok` result if validation passes, otherwise an `Err` with a `MissingOptionError`.
|
||||
*/
|
||||
export function validateRequiredOptions(
|
||||
parsedOptions: Record<string, unknown>,
|
||||
requiredOptions: string[],
|
||||
): Result<void, MissingOptionError> {
|
||||
for (const optionName of requiredOptions) {
|
||||
if (
|
||||
!(optionName in parsedOptions) ||
|
||||
parsedOptions[optionName] === undefined
|
||||
) {
|
||||
return new Err({ kind: "MissingOption", optionName });
|
||||
}
|
||||
}
|
||||
return Ok.EMPTY;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes option names by mapping short names to their corresponding long names.
|
||||
* @param options The raw parsed options record (may contain short names).
|
||||
* @param optionMapping A map from short option names to long option names.
|
||||
* @returns A new options record with all short names replaced by long names.
|
||||
*/
|
||||
export function normalizeOptions(
|
||||
options: Record<string, unknown>,
|
||||
optionMapping: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(options)) {
|
||||
const normalizedKey = optionMapping[key] ?? key;
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
146
src/lib/ccCLI/types.ts
Normal file
146
src/lib/ccCLI/types.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Result } from "../thirdparty/ts-result-es";
|
||||
|
||||
// --- Error Types ---
|
||||
|
||||
/**
|
||||
* Represents an error when an unknown command is used.
|
||||
* @property commandName - The name of the command that was not found.
|
||||
*/
|
||||
export interface UnknownCommandError {
|
||||
kind: "UnknownCommand";
|
||||
commandName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a required argument is missing.
|
||||
* @property argName - The name of the missing argument.
|
||||
*/
|
||||
export interface MissingArgumentError {
|
||||
kind: "MissingArgument";
|
||||
argName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a required option is missing.
|
||||
* @property optionName - The name of the missing option.
|
||||
*/
|
||||
export interface MissingOptionError {
|
||||
kind: "MissingOption";
|
||||
optionName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents an error when a command that requires an action has none.
|
||||
* @property commandPath - The path to the command without an action.
|
||||
*/
|
||||
export interface NoActionError {
|
||||
kind: "NoAction";
|
||||
commandPath: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A union of all possible CLI-related errors.
|
||||
* This allows for exhaustive error handling using pattern matching on the `kind` property.
|
||||
*/
|
||||
export type CliError =
|
||||
| UnknownCommandError
|
||||
| MissingArgumentError
|
||||
| MissingOptionError
|
||||
| NoActionError;
|
||||
|
||||
// --- Core CLI Structures ---
|
||||
|
||||
/**
|
||||
* @interface Argument
|
||||
* @description Defines a command-line argument for a command.
|
||||
*/
|
||||
export interface Argument {
|
||||
/** The name of the argument, used to access its value. */
|
||||
name: string;
|
||||
/** A brief description of what the argument does, shown in help messages. */
|
||||
description: string;
|
||||
/** Whether the argument is required. Defaults to false. */
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface Option
|
||||
* @description Defines a command-line option (also known as a flag).
|
||||
*/
|
||||
export interface Option {
|
||||
/** The long name of the option (e.g., "verbose" for `--verbose`). */
|
||||
name: string;
|
||||
/** An optional short name for the option (e.g., "v" for `-v`). */
|
||||
shortName?: string;
|
||||
/** A brief description of what the option does, shown in help messages. */
|
||||
description: string;
|
||||
/** Whether the option is required. Defaults to false. */
|
||||
required?: boolean;
|
||||
/** The default value for the option if it's not provided. */
|
||||
defaultValue?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface ActionContext
|
||||
* @description The context object passed to a command's action handler.
|
||||
* @template TContext - The type of the global context object.
|
||||
*/
|
||||
export interface ActionContext<TContext extends object> {
|
||||
/** A record of parsed argument values, keyed by argument name. */
|
||||
args: Record<string, unknown>;
|
||||
/** A record of parsed option values, keyed by option name. */
|
||||
options: Record<string, unknown>;
|
||||
/** The global context object, shared across all commands. */
|
||||
context: TContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* @interface Command
|
||||
* @description Defines a CLI command, which can have its own arguments, options, and subcommands.
|
||||
* @template TContext - The type of the global context object.
|
||||
*/
|
||||
export interface Command<TContext extends object> {
|
||||
/** The name of the command. */
|
||||
name: string;
|
||||
/** A brief description of the command, shown in help messages. */
|
||||
description: string;
|
||||
/** A map of argument definitions for the command, keyed by argument name. */
|
||||
args?: Argument[];
|
||||
/** A map of option definitions for the command, keyed by option name. */
|
||||
options?: Map<string, Option>;
|
||||
/**
|
||||
* The function to execute when the command is run.
|
||||
* It receives an `ActionContext` object.
|
||||
* Should return a `Result` to indicate success or failure.
|
||||
*/
|
||||
action?: (context: ActionContext<TContext>) => Result<void, CliError>;
|
||||
/** A map of subcommands, allowing for nested command structures, keyed by command name. */
|
||||
subcommands?: Map<string, Command<TContext>>;
|
||||
}
|
||||
|
||||
// --- Parsing and Execution Internals ---
|
||||
|
||||
/**
|
||||
* @interface ParseResult
|
||||
* @description Enhanced parsing result that includes command resolution.
|
||||
*/
|
||||
export interface ParseResult<TContext extends object> {
|
||||
/** The resolved command found during parsing. */
|
||||
command: Command<TContext>;
|
||||
/** The path to the resolved command. */
|
||||
commandPath: string[];
|
||||
/** A record of parsed option values. */
|
||||
options: Record<string, unknown>;
|
||||
/** Any remaining arguments that were not parsed as part of the command path or options. */
|
||||
remaining: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @type CommandResolution
|
||||
* @description The result of resolving a command path to a specific command.
|
||||
*/
|
||||
export interface CommandResolution<TContext extends object> {
|
||||
command: Command<TContext>;
|
||||
commandPath: string[];
|
||||
remainingArgs: string[];
|
||||
}
|
||||
166
src/lib/ccLog.ts
166
src/lib/ccLog.ts
@@ -1,166 +0,0 @@
|
||||
enum LogLevel {
|
||||
Debug = 0,
|
||||
Info = 1,
|
||||
Warn = 2,
|
||||
Error = 3,
|
||||
}
|
||||
|
||||
// Define time interval constants in seconds
|
||||
export const SECOND = 1;
|
||||
export const MINUTE = 60 * SECOND;
|
||||
export const HOUR = 60 * MINUTE;
|
||||
export const DAY = 24 * HOUR;
|
||||
export const WEEK = 7 * DAY;
|
||||
|
||||
export class CCLog {
|
||||
private fp: LuaFile | undefined;
|
||||
private filename?: string;
|
||||
private interval: number;
|
||||
private startTime: number;
|
||||
private currentTimePeriod: string;
|
||||
private inTerm: boolean;
|
||||
|
||||
constructor(filename?: string, inTerm = true, interval: number = DAY) {
|
||||
term.clear();
|
||||
term.setCursorPos(1, 1);
|
||||
|
||||
this.interval = interval;
|
||||
this.inTerm = inTerm;
|
||||
this.startTime = os.time(os.date("*t"));
|
||||
this.currentTimePeriod = this.getTimePeriodString(this.startTime);
|
||||
|
||||
if (filename != undefined && filename.length != 0) {
|
||||
this.filename = filename;
|
||||
const filepath = this.generateFilePath(filename, this.currentTimePeriod);
|
||||
const [file, error] = io.open(filepath, fs.exists(filepath) ? "a" : "w+");
|
||||
if (file != undefined) {
|
||||
this.fp = file;
|
||||
} else {
|
||||
throw Error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a time period string based on the interval
|
||||
* For DAY interval: YYYY-MM-DD
|
||||
* For HOUR interval: YYYY-MM-DD-HH
|
||||
* For MINUTE interval: YYYY-MM-DD-HH-MM
|
||||
* For SECOND interval: YYYY-MM-DD-HH-MM-SS
|
||||
*/
|
||||
private getTimePeriodString(time: number): string {
|
||||
const periodStart = Math.floor(time / this.interval) * this.interval;
|
||||
const d = os.date("*t", periodStart);
|
||||
|
||||
if (this.interval >= DAY) {
|
||||
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}`;
|
||||
} else if (this.interval >= HOUR) {
|
||||
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}`;
|
||||
} else if (this.interval >= MINUTE) {
|
||||
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}`;
|
||||
}
|
||||
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}-${string.format("%02d", d.min)}-${string.format("%02d", d.sec)}`;
|
||||
}
|
||||
|
||||
private generateFilePath(baseFilename: string, timePeriod: string): string {
|
||||
const scriptDir = shell.dir() ?? "";
|
||||
|
||||
const [filenameWithoutExt, extension] = baseFilename.includes(".")
|
||||
? baseFilename.split(".")
|
||||
: [baseFilename, "log"];
|
||||
|
||||
return fs.combine(
|
||||
scriptDir,
|
||||
`${filenameWithoutExt}_${timePeriod}.${extension}`,
|
||||
);
|
||||
}
|
||||
|
||||
private checkAndRotateLogFile() {
|
||||
if (this.filename != undefined && this.filename.length != 0) {
|
||||
const currentTime = os.time(os.date("*t"));
|
||||
const currentTimePeriod = this.getTimePeriodString(currentTime);
|
||||
|
||||
// If we're in a new time period, rotate the log file
|
||||
if (currentTimePeriod !== this.currentTimePeriod) {
|
||||
// Close current file if open
|
||||
if (this.fp) {
|
||||
this.fp.close();
|
||||
this.fp = undefined;
|
||||
}
|
||||
|
||||
// Update the current time period
|
||||
this.currentTimePeriod = currentTimePeriod;
|
||||
|
||||
// Open new log file for the new time period
|
||||
const filepath = this.generateFilePath(
|
||||
this.filename,
|
||||
this.currentTimePeriod,
|
||||
);
|
||||
const [file, error] = io.open(
|
||||
filepath,
|
||||
fs.exists(filepath) ? "a" : "w+",
|
||||
);
|
||||
if (file != undefined) {
|
||||
this.fp = file;
|
||||
} else {
|
||||
throw Error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getFormatMsg(msg: string, level: LogLevel): string {
|
||||
const date = os.date("*t");
|
||||
return `[ ${date.year}/${String(date.month).padStart(2, "0")}/${String(date.day).padStart(2, "0")} ${String(date.hour).padStart(2, "0")}:${String(date.min).padStart(2, "0")}:${String(date.sec).padStart(2, "0")} ${LogLevel[level]} ] : ${msg}`;
|
||||
}
|
||||
|
||||
public writeLine(msg: string, color?: Color) {
|
||||
// Check if we need to rotate the log file
|
||||
this.checkAndRotateLogFile();
|
||||
|
||||
if (this.inTerm) {
|
||||
let originalColor: Color = 0;
|
||||
if (color != undefined) {
|
||||
originalColor = term.getTextColor();
|
||||
term.setTextColor(color);
|
||||
}
|
||||
print(msg);
|
||||
|
||||
if (color != undefined) {
|
||||
term.setTextColor(originalColor);
|
||||
}
|
||||
}
|
||||
|
||||
// Log
|
||||
if (this.fp != undefined) {
|
||||
this.fp.write(msg + "\r\n");
|
||||
}
|
||||
}
|
||||
|
||||
public debug(msg: string) {
|
||||
this.writeLine(this.getFormatMsg(msg, LogLevel.Debug), colors.gray);
|
||||
}
|
||||
|
||||
public info(msg: string) {
|
||||
this.writeLine(this.getFormatMsg(msg, LogLevel.Info), colors.green);
|
||||
}
|
||||
|
||||
public warn(msg: string) {
|
||||
this.writeLine(this.getFormatMsg(msg, LogLevel.Warn), colors.orange);
|
||||
}
|
||||
|
||||
public error(msg: string) {
|
||||
this.writeLine(this.getFormatMsg(msg, LogLevel.Error), colors.red);
|
||||
}
|
||||
|
||||
public setInTerminal(value: boolean) {
|
||||
this.inTerm = value;
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this.fp !== undefined) {
|
||||
this.fp.close();
|
||||
this.fp = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
176
src/lib/ccStructLog/Logger.ts
Normal file
176
src/lib/ccStructLog/Logger.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Main Logger class implementation.
|
||||
* This is the primary entry point for users to interact with the logging system.
|
||||
*/
|
||||
|
||||
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.
|
||||
*
|
||||
* This class takes log messages, creates LogEvent objects, processes them through
|
||||
* a chain of processors, renders them to strings, and outputs them via streams.
|
||||
*/
|
||||
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: LoggerOptions, name?: string) {
|
||||
this.options = options;
|
||||
this.loggerName = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main logging method that handles the complete logging pipeline.
|
||||
*
|
||||
* @param level - The log level
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data as key-value pairs
|
||||
*/
|
||||
public log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context: Record<string, unknown> = {},
|
||||
): void {
|
||||
// 1. Create initial LogEvent with core fields
|
||||
let event: LogEvent | undefined = new Map<string, unknown>([
|
||||
["level", level],
|
||||
["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) {
|
||||
if (event === undefined) {
|
||||
break; // Event was dropped by a processor
|
||||
}
|
||||
event = processor(event);
|
||||
}
|
||||
|
||||
// 3. Render and output if event wasn't dropped
|
||||
if (event !== undefined) {
|
||||
const output = this.options.renderer(event);
|
||||
|
||||
// Send to all configured streams
|
||||
for (const stream of this.options.streams) {
|
||||
stream.write(output, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a trace message.
|
||||
* Typically used for very detailed diagnostic information.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public trace(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Trace, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a debug message.
|
||||
* Used for diagnostic information useful during development.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public debug(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Debug, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an info message.
|
||||
* Used for general informational messages about application flow.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public info(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Info, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a warning message.
|
||||
* Used for potentially harmful situations that don't stop execution.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public warn(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Warn, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log an error message.
|
||||
* Used for error events that might allow the application to continue.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public error(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Error, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a fatal message.
|
||||
* Used for very severe error events that might cause termination.
|
||||
*
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
public fatal(message: string, context?: Record<string, unknown>): void {
|
||||
this.log(LogLevel.Fatal, message, context);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the logger's configuration.
|
||||
* Useful for dynamically changing logging behavior at runtime.
|
||||
*
|
||||
* @param options - New configuration options to merge with existing ones
|
||||
*/
|
||||
public configure(options: Partial<LoggerOptions>): void {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all streams and clean up resources.
|
||||
* Should be called when the logger is no longer needed.
|
||||
*/
|
||||
public close(): void {
|
||||
for (const stream of this.options.streams) {
|
||||
if (stream.close) {
|
||||
stream.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
20
src/lib/ccStructLog/index.ts
Normal file
20
src/lib/ccStructLog/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Main entry point for the ccStructLog library.
|
||||
*
|
||||
* This module provides convenient factory functions and pre-configured
|
||||
* logger instances for common use cases. It exports all the core components
|
||||
* while providing easy-to-use defaults for typical logging scenarios.
|
||||
*/
|
||||
|
||||
// Re-export all core types and classes
|
||||
export * from "./types";
|
||||
export * from "./Logger";
|
||||
|
||||
// Re-export all processors
|
||||
export * from "./processors";
|
||||
|
||||
// Re-export all renderers
|
||||
export * from "./renderers";
|
||||
|
||||
// Re-export all streams
|
||||
export * from "./streams";
|
||||
187
src/lib/ccStructLog/processors.ts
Normal file
187
src/lib/ccStructLog/processors.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Standard processors for the ccStructLog library.
|
||||
*
|
||||
* Processors are functions that can modify, enrich, or filter log events
|
||||
* as they flow through the logging pipeline. Each processor receives a
|
||||
* LogEvent and can return a modified LogEvent or undefined to drop the log.
|
||||
*/
|
||||
|
||||
import { LogEvent, Processor, LogLevel } from "./types";
|
||||
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
79
src/lib/ccStructLog/renderers.ts
Normal file
79
src/lib/ccStructLog/renderers.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Standard renderers for the ccStructLog library.
|
||||
*
|
||||
* Renderers are functions that convert processed LogEvent objects into
|
||||
* their final string representation. Different renderers can produce
|
||||
* different output formats (JSON, console-friendly, etc.).
|
||||
*/
|
||||
|
||||
import { LogLevel, Renderer } from "./types";
|
||||
|
||||
/**
|
||||
* Renders log events as JSON strings.
|
||||
*
|
||||
* This renderer converts the LogEvent Map into a plain object and then
|
||||
* serializes it as JSON. This format is ideal for structured logging
|
||||
* and machine processing.
|
||||
*
|
||||
* Note: This assumes textutils.serialiseJSON is available (CC:Tweaked).
|
||||
* Falls back to a simple key=value format if JSON serialization fails.
|
||||
*
|
||||
* @param event - The log event to render
|
||||
* @returns JSON string representation of the event
|
||||
*/
|
||||
export const jsonRenderer: Renderer = (event) => {
|
||||
try {
|
||||
// Convert Map to plain object for JSON serialization
|
||||
const obj: Record<string, unknown> = {};
|
||||
for (const [key, value] of event.entries()) {
|
||||
obj[key] = value;
|
||||
}
|
||||
|
||||
// Use CC:Tweaked's JSON serialization if available
|
||||
return textutils.serialiseJSON(obj);
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders log events in a human-readable Text format.
|
||||
*
|
||||
* This renderer creates output suitable for terminal display, with
|
||||
* timestamp, level, message, and additional context fields formatted
|
||||
* in a readable way.
|
||||
*
|
||||
* 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 timeStr = event.get("timestamp") as string | undefined;
|
||||
const level: string | undefined = LogLevel[event.get("level") as LogLevel];
|
||||
const message = (event.get("message") as string) ?? "";
|
||||
const loggerName = event.get("loggerName") as string | undefined;
|
||||
|
||||
// Start building the output
|
||||
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 !== "level" &&
|
||||
key !== "message" &&
|
||||
key !== "loggerName"
|
||||
) {
|
||||
contextFields.push(`${key}=${tostring(value)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (contextFields.length > 0) {
|
||||
output += contextFields.join(", ");
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
491
src/lib/ccStructLog/streams.ts
Normal file
491
src/lib/ccStructLog/streams.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Standard output streams for the ccStructLog library.
|
||||
*
|
||||
* Streams are responsible for writing the final formatted log messages
|
||||
* to their destination (console, file, network, etc.). Each stream
|
||||
* implements the Stream interface and handles its own output logic.
|
||||
*/
|
||||
|
||||
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.
|
||||
*
|
||||
* This stream writes log messages to the computer's terminal with
|
||||
* color coding based on log levels. It preserves the original text
|
||||
* color after writing each message.
|
||||
*/
|
||||
export class ConsoleStream implements Stream {
|
||||
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.
|
||||
*
|
||||
* @param message - The formatted log message
|
||||
* @param event - The original log event for context (used for level-based coloring)
|
||||
*/
|
||||
public write(message: string, event: LogEvent): void {
|
||||
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();
|
||||
term.setTextColor(color);
|
||||
print(message);
|
||||
term.setTextColor(originalColor);
|
||||
} else {
|
||||
print(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* File stream that outputs to a file on disk.
|
||||
*
|
||||
* This stream writes log messages to a specified file, creating the file
|
||||
* if it doesn't exist and appending to it if it does. It handles file
|
||||
* rotation based on time intervals.
|
||||
*/
|
||||
export class FileStream implements Stream {
|
||||
private fileHandle: LuaFile | undefined;
|
||||
private filePath: string;
|
||||
private rotationInterval: number;
|
||||
private lastRotationTime: number;
|
||||
private autoCleanupConfig?: FileStreamConfig["autoCleanup"];
|
||||
|
||||
/**
|
||||
* Create a new file stream with configuration object.
|
||||
*
|
||||
* @param config - FileStream configuration object
|
||||
*/
|
||||
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.openFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the log file for writing.
|
||||
* Creates the file if it doesn't exist, appends if it does.
|
||||
*/
|
||||
private openFile(): void {
|
||||
const actualPath =
|
||||
this.rotationInterval > 0
|
||||
? this.getRotatedFilename()
|
||||
: this.filePath;
|
||||
|
||||
const [handle, err] = io.open(actualPath, "a");
|
||||
if (handle === undefined) {
|
||||
printError(
|
||||
`Failed to open log file ${actualPath}: ${err ?? "Unknown error"}`,
|
||||
);
|
||||
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(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)}`;
|
||||
|
||||
// Split filename and extension
|
||||
const splitStrs = this.filePath.split(".");
|
||||
if (splitStrs.length === 1) {
|
||||
return `${this.filePath}_${timestamp}.log`;
|
||||
}
|
||||
|
||||
const name = splitStrs[0];
|
||||
const ext = splitStrs[1];
|
||||
return `${name}_${timestamp}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file rotation is needed and rotate if necessary.
|
||||
*/
|
||||
private checkRotation(): void {
|
||||
if (this.rotationInterval <= 0) return;
|
||||
|
||||
const currentTime = os.time();
|
||||
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.
|
||||
*
|
||||
* @param message - The formatted log message
|
||||
* @param event - The original log event (unused in this implementation)
|
||||
*/
|
||||
public write(message: string, event: LogEvent): void {
|
||||
this.checkRotation();
|
||||
|
||||
if (this.fileHandle) {
|
||||
this.fileHandle.write(message + "\n");
|
||||
this.fileHandle.flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the file handle and release resources.
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.fileHandle) {
|
||||
this.fileHandle.close();
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer stream that collects log messages in memory.
|
||||
*
|
||||
* This stream stores log messages in an internal buffer, which can be
|
||||
* useful for testing, temporary storage, or implementing custom output
|
||||
* logic that processes multiple messages at once.
|
||||
*/
|
||||
export class BufferStream implements Stream {
|
||||
private buffer: string[] = [];
|
||||
private maxSize: number;
|
||||
|
||||
/**
|
||||
* Create a new buffer stream.
|
||||
*
|
||||
* @param maxSize - Maximum number of messages to store (0 = unlimited)
|
||||
*/
|
||||
constructor(maxSize: number = 0) {
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a formatted log message to the buffer.
|
||||
*
|
||||
* @param message - The formatted log message
|
||||
* @param event - The original log event (unused in this implementation)
|
||||
*/
|
||||
public write(message: string, event: LogEvent): void {
|
||||
this.buffer.push(message);
|
||||
|
||||
// Trim buffer if it exceeds max size
|
||||
if (this.maxSize > 0 && this.buffer.length > this.maxSize) {
|
||||
this.buffer.shift();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all buffered messages.
|
||||
*
|
||||
* @returns Array of all buffered log messages
|
||||
*/
|
||||
public getMessages(): string[] {
|
||||
return [...this.buffer];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get and clear all buffered messages.
|
||||
*
|
||||
* @returns Array of all buffered log messages
|
||||
*/
|
||||
public flush(): string[] {
|
||||
const messages = [...this.buffer];
|
||||
this.buffer = [];
|
||||
return messages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the buffer without returning messages.
|
||||
*/
|
||||
public clear(): void {
|
||||
this.buffer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current number of buffered messages.
|
||||
*
|
||||
* @returns Number of messages in the buffer
|
||||
*/
|
||||
public size(): number {
|
||||
return this.buffer.length;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Null stream that discards all log messages.
|
||||
*
|
||||
* This stream can be useful for testing or when you want to temporarily
|
||||
* disable logging output without reconfiguring the entire logger.
|
||||
*/
|
||||
export class NullStream implements Stream {
|
||||
/**
|
||||
* Discard the log message (do nothing).
|
||||
*
|
||||
* @param message - The formatted log message (ignored)
|
||||
* @param event - The original log event (ignored)
|
||||
*/
|
||||
public write(message: string, event: LogEvent): void {
|
||||
// Intentionally do nothing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Conditional stream that only writes messages meeting certain criteria.
|
||||
*
|
||||
* This stream wraps another stream and only forwards messages that
|
||||
* match the specified condition.
|
||||
*/
|
||||
export class ConditionalStream implements Stream {
|
||||
private targetStream: Stream;
|
||||
private condition: (message: string, event: LogEvent) => boolean;
|
||||
|
||||
/**
|
||||
* Create a new conditional stream.
|
||||
*
|
||||
* @param targetStream - The stream to write to when condition is met
|
||||
* @param condition - Function that returns true to allow writing
|
||||
*/
|
||||
constructor(
|
||||
targetStream: Stream,
|
||||
condition: (message: string, event: LogEvent) => boolean,
|
||||
) {
|
||||
this.targetStream = targetStream;
|
||||
this.condition = (message, event) => condition(message, event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a formatted log message if the condition is met.
|
||||
*
|
||||
* @param message - The formatted log message
|
||||
* @param event - The original log event
|
||||
*/
|
||||
public write(message: string, event: LogEvent): void {
|
||||
if (this.condition(message, event)) {
|
||||
this.targetStream.write(message, event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the target stream.
|
||||
*/
|
||||
public close(): void {
|
||||
if (this.targetStream.close) {
|
||||
this.targetStream.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time constants for file rotation
|
||||
export const SECOND = 1;
|
||||
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;
|
||||
107
src/lib/ccStructLog/types.ts
Normal file
107
src/lib/ccStructLog/types.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Core types for the ccStructLog library.
|
||||
* This module defines the fundamental interfaces and types used throughout the logging system.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Available log levels in order of severity.
|
||||
*/
|
||||
export enum LogLevel {
|
||||
Trace = 0,
|
||||
Debug = 1,
|
||||
Info = 2,
|
||||
Warn = 3,
|
||||
Error = 4,
|
||||
Fatal = 5,
|
||||
}
|
||||
|
||||
/**
|
||||
* A log event represented as a key-value map.
|
||||
* Uses Map to maintain insertion order of keys.
|
||||
*/
|
||||
export type LogEvent = Map<string, unknown>;
|
||||
|
||||
/**
|
||||
* A processor function that can modify, filter, or enrich log events.
|
||||
*
|
||||
* @param event - The log event to process
|
||||
* @returns The processed log event, or undefined to drop the log
|
||||
*/
|
||||
export type Processor = (event: LogEvent) => LogEvent | undefined;
|
||||
|
||||
/**
|
||||
* A renderer function that converts a log event to a string representation.
|
||||
*
|
||||
* @param event - The final log event after all processing
|
||||
* @returns The formatted string representation
|
||||
*/
|
||||
export type Renderer = (event: LogEvent) => string;
|
||||
|
||||
/**
|
||||
* Interface for output streams that handle the final log output.
|
||||
*/
|
||||
export interface Stream {
|
||||
/**
|
||||
* Write a formatted log message to the output destination.
|
||||
*
|
||||
* @param message - The formatted log message
|
||||
* @param event - The original log event for context
|
||||
*/
|
||||
write(message: string, event: LogEvent): void;
|
||||
|
||||
/**
|
||||
* Close the stream and release any resources.
|
||||
* Optional method for cleanup.
|
||||
*/
|
||||
close?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for creating a Logger instance.
|
||||
*/
|
||||
export interface LoggerOptions {
|
||||
/** Array of processors to apply to log events */
|
||||
processors: Processor[];
|
||||
|
||||
/** Renderer to format the final log output */
|
||||
renderer: Renderer;
|
||||
|
||||
/** Array of streams to output the formatted logs */
|
||||
streams: Stream[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for the main Logger class.
|
||||
*/
|
||||
export interface ILogger {
|
||||
/**
|
||||
* Log a message at the specified level.
|
||||
*
|
||||
* @param level - The log level
|
||||
* @param message - The log message
|
||||
* @param context - Additional context data
|
||||
*/
|
||||
log(
|
||||
level: LogLevel,
|
||||
message: string,
|
||||
context?: Record<string, unknown>,
|
||||
): void;
|
||||
|
||||
/** Log at trace level */
|
||||
trace(message: string, context?: Record<string, unknown>): void;
|
||||
|
||||
/** Log at debug level */
|
||||
debug(message: string, context?: Record<string, unknown>): void;
|
||||
|
||||
/** Log at info level */
|
||||
info(message: string, context?: Record<string, unknown>): void;
|
||||
|
||||
/** Log at warn level */
|
||||
warn(message: string, context?: Record<string, unknown>): void;
|
||||
|
||||
/** Log at error level */
|
||||
error(message: string, context?: Record<string, unknown>): void;
|
||||
|
||||
/** Log at fatal level */
|
||||
fatal(message: string, context?: Record<string, unknown>): void;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)];
|
||||
}
|
||||
|
||||
@@ -1,25 +1,23 @@
|
||||
class ccDate {
|
||||
private _timestamp: number;
|
||||
export class ccDate {
|
||||
private _timestamp: number;
|
||||
|
||||
constructor() {
|
||||
this._timestamp = os.time(os.date("*t"));
|
||||
}
|
||||
constructor() {
|
||||
this._timestamp = os.time(os.date("*t"));
|
||||
}
|
||||
|
||||
public static toDateTable(timestamp: number): LuaDate {
|
||||
return os.date("*t", timestamp) as LuaDate;
|
||||
}
|
||||
public static toDateTable(timestamp: number): LuaDate {
|
||||
return os.date("*t", timestamp) as LuaDate;
|
||||
}
|
||||
|
||||
public toDateTable(): LuaDate {
|
||||
return os.date("*t", this._timestamp) as LuaDate;
|
||||
}
|
||||
public toDateTable(): LuaDate {
|
||||
return os.date("*t", this._timestamp) as LuaDate;
|
||||
}
|
||||
|
||||
public static toTimestamp(date: LuaDate): number {
|
||||
return os.time(date);
|
||||
}
|
||||
public static toTimestamp(date: LuaDate): number {
|
||||
return os.time(date);
|
||||
}
|
||||
|
||||
public toTimestamp(): number {
|
||||
return this._timestamp;
|
||||
}
|
||||
public toTimestamp(): number {
|
||||
return this._timestamp;
|
||||
}
|
||||
}
|
||||
|
||||
export { ccDate };
|
||||
|
||||
@@ -30,25 +30,22 @@ export function concatSentence(words: string[], length: number): string[] {
|
||||
* @see Source project, ts-deepcopy https://github.com/ykdr2017/ts-deepcopy
|
||||
* @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
|
||||
*/
|
||||
export const deepCopy = <T>(target: T): T => {
|
||||
export function deepCopy<T>(target: T): T {
|
||||
if (target === null) {
|
||||
return target;
|
||||
}
|
||||
if (target instanceof Date) {
|
||||
return new Date(target.getTime()) as any;
|
||||
if (Array.isArray(target)) {
|
||||
return (target as unknown[]).map((v: unknown) => deepCopy(v)) as T;
|
||||
}
|
||||
if (target instanceof Array) {
|
||||
const cp = [] as any[];
|
||||
(target as any[]).forEach((v) => { cp.push(v); });
|
||||
return cp.map((n: any) => deepCopy<any>(n)) as any;
|
||||
}
|
||||
if (typeof target === 'object') {
|
||||
const cp = { ...(target as { [key: string]: any }) } as { [key: string]: any };
|
||||
Object.keys(cp).forEach(k => {
|
||||
cp[k] = deepCopy<any>(cp[k]);
|
||||
if (typeof target === "object") {
|
||||
const cp = { ...(target as Record<string, unknown>) } as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
Object.keys(cp).forEach((k) => {
|
||||
cp[k] = deepCopy<unknown>(cp[k]);
|
||||
});
|
||||
return cp as T;
|
||||
}
|
||||
return target;
|
||||
};
|
||||
@MohammadFakhreddin
|
||||
}
|
||||
|
||||
105
src/lib/datatype/Queue.ts
Normal file
105
src/lib/datatype/Queue.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
export class Node<T> {
|
||||
public value: T;
|
||||
public next?: Node<T>;
|
||||
public prev?: Node<T>;
|
||||
|
||||
constructor(value: T, next?: Node<T>, prev?: Node<T>) {
|
||||
this.value = value;
|
||||
this.next = next;
|
||||
this.prev = prev;
|
||||
}
|
||||
}
|
||||
|
||||
export class Queue<T> {
|
||||
private _head?: Node<T>;
|
||||
private _tail?: Node<T>;
|
||||
private _size: number;
|
||||
|
||||
constructor(data?: T[]) {
|
||||
this._head = undefined;
|
||||
this._tail = undefined;
|
||||
this._size = 0;
|
||||
|
||||
if (data === undefined) return;
|
||||
for (const item of data) {
|
||||
this.enqueue(item);
|
||||
}
|
||||
}
|
||||
|
||||
public enqueue(data: T | T[]): void {
|
||||
if (Array.isArray(data)) {
|
||||
for (const val of data) {
|
||||
this.enqueue(val);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const node = new Node(data);
|
||||
|
||||
if (this._head === undefined) {
|
||||
this._head = node;
|
||||
this._tail = node;
|
||||
} else {
|
||||
this._tail!.next = node;
|
||||
node.prev = this._tail;
|
||||
this._tail = node;
|
||||
}
|
||||
|
||||
this._size++;
|
||||
}
|
||||
|
||||
public dequeue(): T | undefined {
|
||||
if (this._head === undefined) return undefined;
|
||||
const node = this._head;
|
||||
|
||||
this._head = node.next;
|
||||
if (this._head !== undefined) this._head.prev = undefined;
|
||||
this._size--;
|
||||
|
||||
return node.value;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._head = undefined;
|
||||
this._tail = undefined;
|
||||
this._size = 0;
|
||||
}
|
||||
|
||||
public peek(): T | undefined {
|
||||
if (this._head === undefined) return undefined;
|
||||
return this._head.value;
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this._size;
|
||||
}
|
||||
|
||||
public toArray(): T[] | undefined {
|
||||
if (this._size === 0) return undefined;
|
||||
|
||||
const array: T[] = [];
|
||||
let currentNode: Node<T> = this._head!;
|
||||
for (let i = 0; i < this._size; i++) {
|
||||
if (currentNode.value !== undefined) array.push(currentNode.value);
|
||||
|
||||
currentNode = currentNode.next!;
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
let currentNode = this._head;
|
||||
|
||||
return {
|
||||
next(): IteratorResult<T> {
|
||||
if (currentNode === undefined) {
|
||||
return { value: undefined, done: true };
|
||||
} else {
|
||||
const data = currentNode.value;
|
||||
currentNode = currentNode.next;
|
||||
return { value: data, done: false };
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
86
src/lib/datatype/SortedArray.ts
Normal file
86
src/lib/datatype/SortedArray.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
interface Priority<T> {
|
||||
priority: number;
|
||||
data: T;
|
||||
}
|
||||
|
||||
export class SortedArray<T> {
|
||||
private _data: Priority<T>[];
|
||||
|
||||
constructor(data?: Priority<T>[]) {
|
||||
this._data = data ?? [];
|
||||
}
|
||||
|
||||
private findIndex(priority: number): number {
|
||||
const target = priority + 1;
|
||||
let left = 0;
|
||||
let right = this._data.length - 1;
|
||||
|
||||
while (left <= right) {
|
||||
const mid = Math.floor((left + right) / 2);
|
||||
if (this._data[mid].priority < target) {
|
||||
left = mid + 1;
|
||||
} else if (this._data[mid].priority > target) {
|
||||
right = mid - 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
|
||||
return left - 1;
|
||||
}
|
||||
|
||||
public push(value: Priority<T>): void {
|
||||
if (this._data.length === 0) {
|
||||
this._data.push(value);
|
||||
return;
|
||||
} else if (this._data.length === 1) {
|
||||
if (this._data[0].priority <= value.priority) this._data.push(value);
|
||||
else this._data = [value, ...this._data];
|
||||
return;
|
||||
}
|
||||
|
||||
const index = this.findIndex(value.priority);
|
||||
|
||||
if (index === this._data.length - 1) {
|
||||
if (this._data[index].priority <= value.priority) {
|
||||
this._data.push(value);
|
||||
} else {
|
||||
this._data = [
|
||||
...this._data.slice(0, index),
|
||||
value,
|
||||
...this._data.slice(index),
|
||||
];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const endIndex = index + 1;
|
||||
this._data = [
|
||||
...this._data.slice(0, endIndex),
|
||||
value,
|
||||
...this._data.slice(endIndex),
|
||||
];
|
||||
}
|
||||
|
||||
public shift(): T | undefined {
|
||||
const value = this._data.shift();
|
||||
return value?.data;
|
||||
}
|
||||
|
||||
public pop(): T | undefined {
|
||||
const value = this._data.pop();
|
||||
return value?.data;
|
||||
}
|
||||
|
||||
public peek(): T | undefined {
|
||||
return this._data[0]?.data;
|
||||
}
|
||||
|
||||
public toArray(): T[] {
|
||||
return this._data.map(({ data }) => data);
|
||||
}
|
||||
|
||||
public clear() {
|
||||
this._data = [];
|
||||
}
|
||||
}
|
||||
191
src/lib/mutex/ReadWriteLock.ts
Normal file
191
src/lib/mutex/ReadWriteLock.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { Semaphore } from "./Semaphore";
|
||||
|
||||
const E_CANCELED = new Error("Read-write lock canceled");
|
||||
|
||||
export interface ReadLockHandle {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export interface WriteLockHandle {
|
||||
release(): void;
|
||||
}
|
||||
|
||||
export class ReadWriteLock {
|
||||
private _semaphore: Semaphore;
|
||||
private _maxReaders: number;
|
||||
private _writerWeight: number;
|
||||
private _readerPriority: number;
|
||||
private _writerPriority: number;
|
||||
|
||||
constructor(
|
||||
maxReaders = 1000,
|
||||
readerPriority = 10,
|
||||
writerPriority = 0, // Lower number = higher priority
|
||||
cancelError: Error = E_CANCELED,
|
||||
) {
|
||||
if (maxReaders <= 0) {
|
||||
throw new Error("Max readers must be positive");
|
||||
}
|
||||
|
||||
this._maxReaders = maxReaders;
|
||||
this._writerWeight = maxReaders; // Writers need all capacity for exclusivity
|
||||
this._readerPriority = readerPriority;
|
||||
this._writerPriority = writerPriority;
|
||||
this._semaphore = new Semaphore(maxReaders, cancelError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a read lock. Multiple readers can hold the lock simultaneously.
|
||||
*/
|
||||
async acquireRead(): Promise<ReadLockHandle> {
|
||||
const [, release] = await this._semaphore.acquire(1, this._readerPriority);
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to acquire a read lock immediately. Returns null if not available.
|
||||
*/
|
||||
tryAcquireRead(): ReadLockHandle | undefined {
|
||||
const release = this._semaphore.tryAcquire(1);
|
||||
|
||||
if (release === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquires a write lock. Only one writer can hold the lock at a time,
|
||||
* and it has exclusive access (no readers can access simultaneously).
|
||||
*/
|
||||
async acquireWrite(): Promise<WriteLockHandle> {
|
||||
const [, release] = await this._semaphore.acquire(
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to acquire a write lock immediately. Returns null if not available.
|
||||
*/
|
||||
tryAcquireWrite(): WriteLockHandle | undefined {
|
||||
const release = this._semaphore.tryAcquire(this._writerWeight);
|
||||
|
||||
if (release === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { release };
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a callback with a read lock.
|
||||
*/
|
||||
async runWithReadLock<T>(callback: () => T | Promise<T>): Promise<T> {
|
||||
return this._semaphore.runExclusive(
|
||||
async () => await callback(),
|
||||
1,
|
||||
this._readerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a callback with a write lock (exclusive access).
|
||||
*/
|
||||
async runWithWriteLock<T>(callback: () => T | Promise<T>): Promise<T> {
|
||||
return this._semaphore.runExclusive(
|
||||
async () => await callback(),
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until a read lock could be acquired (but doesn't acquire it).
|
||||
*/
|
||||
async waitForReadUnlock(): Promise<void> {
|
||||
return this._semaphore.waitForUnlock(1, this._readerPriority);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits until a write lock could be acquired (but doesn't acquire it).
|
||||
*/
|
||||
async waitForWriteUnlock(): Promise<void> {
|
||||
return this._semaphore.waitForUnlock(
|
||||
this._writerWeight,
|
||||
this._writerPriority,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any locks are currently held.
|
||||
*/
|
||||
isLocked(): boolean {
|
||||
return this._semaphore.isLocked();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a write lock is currently held (exclusive access).
|
||||
*/
|
||||
isWriteLocked(): boolean {
|
||||
return this._semaphore.getValue() <= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if only read locks are held (no write lock).
|
||||
*/
|
||||
isReadLocked(): boolean {
|
||||
const currentValue = this._semaphore.getValue();
|
||||
return currentValue < this._maxReaders && currentValue > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of available read slots.
|
||||
*/
|
||||
getAvailableReads(): number {
|
||||
return Math.max(0, this._semaphore.getValue());
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current number of active readers (approximate).
|
||||
*/
|
||||
getActiveReaders(): number {
|
||||
const available = this._semaphore.getValue();
|
||||
if (available <= 0) {
|
||||
return 0; // Write lock is held
|
||||
}
|
||||
return this._maxReaders - available;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels all pending lock acquisitions.
|
||||
*/
|
||||
cancel(): void {
|
||||
this._semaphore.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the maximum number of concurrent readers allowed.
|
||||
*/
|
||||
getMaxReaders(): number {
|
||||
return this._maxReaders;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the maximum number of concurrent readers.
|
||||
* Note: This may affect currently waiting operations.
|
||||
*/
|
||||
setMaxReaders(maxReaders: number): void {
|
||||
if (maxReaders <= 0) {
|
||||
throw new Error("Max readers must be positive");
|
||||
}
|
||||
|
||||
this._maxReaders = maxReaders;
|
||||
this._writerWeight = maxReaders;
|
||||
this._semaphore.setValue(maxReaders);
|
||||
}
|
||||
}
|
||||
171
src/lib/mutex/Semaphore.ts
Normal file
171
src/lib/mutex/Semaphore.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { SortedArray } from "../datatype/SortedArray";
|
||||
|
||||
const E_CANCELED = new Error("Request canceled");
|
||||
// const E_INSUFFICIENT_RESOURCES = new Error("Insufficient resources");
|
||||
|
||||
interface QueueEntry {
|
||||
resolve(result: [number, () => void]): void;
|
||||
reject(error: unknown): void;
|
||||
weight: number;
|
||||
}
|
||||
|
||||
interface Waiter {
|
||||
resolve(): void;
|
||||
}
|
||||
|
||||
type Releaser = () => void;
|
||||
|
||||
export class Semaphore {
|
||||
private _value: number;
|
||||
private _cancelError: Error;
|
||||
private _queue = new SortedArray<QueueEntry>();
|
||||
private _waiters = new SortedArray<Waiter>();
|
||||
|
||||
constructor(value: number, cancelError: Error = E_CANCELED) {
|
||||
if (value < 0) {
|
||||
throw new Error("Semaphore value must be non-negative");
|
||||
}
|
||||
this._value = value;
|
||||
this._cancelError = cancelError;
|
||||
}
|
||||
|
||||
acquire(weight = 1, priority = 0): Promise<[number, Releaser]> {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const entry: QueueEntry = { resolve, reject, weight };
|
||||
|
||||
if (this._queue.toArray().length === 0 && weight <= this._value) {
|
||||
this._dispatchItem(entry);
|
||||
} else {
|
||||
this._queue.push({ priority, data: entry });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
tryAcquire(weight = 1): Releaser | undefined {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
|
||||
if (weight > this._value || this._queue.toArray().length > 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
this._value -= weight;
|
||||
return this._newReleaser(weight);
|
||||
}
|
||||
|
||||
async runExclusive<T>(
|
||||
callback: (value: number) => T | Promise<T>,
|
||||
weight = 1,
|
||||
priority = 0,
|
||||
): Promise<T> {
|
||||
const [value, release] = await this.acquire(weight, priority);
|
||||
try {
|
||||
return await callback(value);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
waitForUnlock(weight = 1, priority = 0): Promise<void> {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
|
||||
if (this._couldLockImmediately(weight)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const waiter: Waiter = { resolve };
|
||||
this._waiters.push({ priority, data: waiter });
|
||||
});
|
||||
}
|
||||
|
||||
isLocked(): boolean {
|
||||
return this._value <= 0;
|
||||
}
|
||||
|
||||
getValue(): number {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
setValue(value: number): void {
|
||||
if (value < 0) {
|
||||
throw new Error("Semaphore value must be non-negative");
|
||||
}
|
||||
this._value = value;
|
||||
this._dispatchQueue();
|
||||
}
|
||||
|
||||
release(weight = 1): void {
|
||||
if (weight <= 0) {
|
||||
throw new Error(`invalid weight ${weight}: must be positive`);
|
||||
}
|
||||
this._value += weight;
|
||||
this._dispatchQueue();
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
const queueItems = this._queue.toArray();
|
||||
queueItems.forEach((entry) => entry.reject(this._cancelError));
|
||||
this._queue.clear();
|
||||
|
||||
const waiters = this._waiters.toArray();
|
||||
waiters.forEach((waiter) => waiter.resolve());
|
||||
this._waiters.clear();
|
||||
}
|
||||
|
||||
private _dispatchQueue(): void {
|
||||
this._drainWaiters();
|
||||
|
||||
let next = this._peek();
|
||||
while (next && next.weight <= this._value) {
|
||||
const item = this._queue.shift();
|
||||
if (item) {
|
||||
this._dispatchItem(item);
|
||||
}
|
||||
this._drainWaiters();
|
||||
next = this._peek();
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatchItem(item: QueueEntry): void {
|
||||
const previousValue = this._value;
|
||||
this._value -= item.weight;
|
||||
item.resolve([previousValue, this._newReleaser(item.weight)]);
|
||||
}
|
||||
|
||||
private _peek(): QueueEntry | undefined {
|
||||
return this._queue.peek();
|
||||
}
|
||||
|
||||
private _newReleaser(weight: number): Releaser {
|
||||
let called = false;
|
||||
return () => {
|
||||
if (called) return;
|
||||
called = true;
|
||||
this.release(weight);
|
||||
};
|
||||
}
|
||||
|
||||
private _drainWaiters(): void {
|
||||
const waiters = this._waiters.toArray();
|
||||
if (waiters.length === 0) return;
|
||||
|
||||
// If no queue or resources available, resolve all waiters
|
||||
const hasQueue = this._queue.toArray().length > 0;
|
||||
if (!hasQueue || this._value > 0) {
|
||||
waiters.forEach((waiter) => waiter.resolve());
|
||||
this._waiters.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private _couldLockImmediately(weight: number): boolean {
|
||||
return this._queue.toArray().length === 0 && weight <= this._value;
|
||||
}
|
||||
}
|
||||
21
src/lib/thirdparty/ts-result-es/LICENSE
vendored
Normal file
21
src/lib/thirdparty/ts-result-es/LICENSE
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2019 vultix
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
2
src/lib/thirdparty/ts-result-es/index.ts
vendored
Normal file
2
src/lib/thirdparty/ts-result-es/index.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./result";
|
||||
export * from "./option";
|
||||
343
src/lib/thirdparty/ts-result-es/option.ts
vendored
Normal file
343
src/lib/thirdparty/ts-result-es/option.ts
vendored
Normal file
@@ -0,0 +1,343 @@
|
||||
import { toString } from "./utils";
|
||||
// import { Result, Ok, Err } from "./result";
|
||||
|
||||
interface BaseOption<T> extends Iterable<T> {
|
||||
/** `true` when the Option is Some */
|
||||
isSome(): this is SomeImpl<T>;
|
||||
|
||||
/** `true` when the Option is None */
|
||||
isNone(): this is None;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value, if exists. Throws an error if not.
|
||||
*
|
||||
* If you know you're dealing with `Some` and the compiler knows it too (because you tested
|
||||
* `isSome()` or `isNone()`) you should use `value` instead. While `Some`'s `expect()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* @param msg the message to throw if no Some value.
|
||||
*/
|
||||
expect(msg: string): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `None` case explicitly.
|
||||
*
|
||||
* If you know you're dealing with `Some` and the compiler knows it too (because you tested
|
||||
* `isSome()` or `isNone()`) you should use `value` instead. While `Some`'s `unwrap()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* Throws if the value is `None`.
|
||||
*/
|
||||
unwrap(): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value or a provided default.
|
||||
*
|
||||
* (This is the `unwrap_or` in rust)
|
||||
*/
|
||||
unwrapOr<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value or computes a value with a provided function.
|
||||
*
|
||||
* The function is called at most one time, only if needed.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* Some('OK').unwrapOrElse(
|
||||
* () => { console.log('Called'); return 'UGH'; }
|
||||
* ) // => 'OK', nothing printed
|
||||
*
|
||||
* None.unwrapOrElse(() => 'UGH') // => 'UGH'
|
||||
* ```
|
||||
*/
|
||||
unwrapOrElse<T2>(f: () => T2): T | T2;
|
||||
|
||||
/**
|
||||
* Calls `mapper` if the Option is `Some`, otherwise returns `None`.
|
||||
* This function can be used for control flow based on `Option` values.
|
||||
*/
|
||||
andThen<T2>(mapper: (val: T) => Option<T2>): Option<T2>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by applying a function to a contained `Some` value,
|
||||
* leaving a `None` value untouched.
|
||||
*
|
||||
* This function can be used to compose the Options of two functions.
|
||||
*/
|
||||
map<U>(mapper: (val: T) => U): Option<U>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by either converting `T` to `U` using `mapper` (in case
|
||||
* of `Some`) or using the `default_` value (in case of `None`).
|
||||
*
|
||||
* If `default` is a result of a function call consider using `mapOrElse()` instead, it will
|
||||
* only evaluate the function when needed.
|
||||
*/
|
||||
mapOr<U>(default_: U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to `Option<U>` by either converting `T` to `U` using `mapper` (in case
|
||||
* of `Some`) or producing a default value using the `default` function (in case of `None`).
|
||||
*/
|
||||
mapOrElse<U>(default_: () => U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Returns `Some()` if we have a value, otherwise returns `other`.
|
||||
*
|
||||
* `other` is evaluated eagerly. If `other` is a result of a function
|
||||
* call try `orElse()` instead – it evaluates the parameter lazily.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Some(1).or(Some(2)) // => Some(1)
|
||||
* None.or(Some(2)) // => Some(2)
|
||||
*/
|
||||
or(other: Option<T>): Option<T>;
|
||||
|
||||
/**
|
||||
* Returns `Some()` if we have a value, otherwise returns the result
|
||||
* of calling `other()`.
|
||||
*
|
||||
* `other()` is called *only* when needed.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Some(1).orElse(() => Some(2)) // => Some(1)
|
||||
* None.orElse(() => Some(2)) // => Some(2)
|
||||
*/
|
||||
orElse(other: () => Option<T>): Option<T>;
|
||||
|
||||
/**
|
||||
* Maps an `Option<T>` to a `Result<T, E>`.
|
||||
*/
|
||||
// toResult<E>(error: E): Result<T, E>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the None value
|
||||
*/
|
||||
class NoneImpl implements BaseOption<never> {
|
||||
isSome(): this is SomeImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
isNone(): this is NoneImpl {
|
||||
return true;
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<never, never, unknown> {
|
||||
return {
|
||||
next(): IteratorResult<never, never> {
|
||||
return { done: true, value: undefined! };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
unwrapOr<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOrElse<T2>(f: () => T2): T2 {
|
||||
return f();
|
||||
}
|
||||
|
||||
expect(msg: string): never {
|
||||
throw new Error(`${msg}`);
|
||||
}
|
||||
|
||||
unwrap(): never {
|
||||
throw new Error(`Tried to unwrap None`);
|
||||
}
|
||||
|
||||
map(_mapper: unknown): None {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapOr<T2>(default_: T2, _mapper: unknown): T2 {
|
||||
return default_;
|
||||
}
|
||||
|
||||
mapOrElse<U>(default_: () => U, _mapper: unknown): U {
|
||||
return default_();
|
||||
}
|
||||
|
||||
or<T>(other: Option<T>): Option<T> {
|
||||
return other;
|
||||
}
|
||||
|
||||
orElse<T>(other: () => Option<T>): Option<T> {
|
||||
return other();
|
||||
}
|
||||
|
||||
andThen(_op: unknown): None {
|
||||
return this;
|
||||
}
|
||||
|
||||
// toResult<E>(error: E): Err<E> {
|
||||
// return Err(error);
|
||||
// }
|
||||
|
||||
toString(): string {
|
||||
return "None";
|
||||
}
|
||||
}
|
||||
|
||||
// Export None as a singleton, then freeze it so it can't be modified
|
||||
export const None = new NoneImpl();
|
||||
export type None = NoneImpl;
|
||||
|
||||
/**
|
||||
* Contains the success value
|
||||
*/
|
||||
class SomeImpl<T> implements BaseOption<T> {
|
||||
static readonly EMPTY = new SomeImpl<void>(undefined);
|
||||
|
||||
isSome(): this is SomeImpl<T> {
|
||||
return true;
|
||||
}
|
||||
|
||||
isNone(): this is NoneImpl {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly value!: T;
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return [this.value][Symbol.iterator]();
|
||||
}
|
||||
|
||||
constructor(val: T) {
|
||||
if (!(this instanceof SomeImpl)) {
|
||||
return new SomeImpl(val);
|
||||
}
|
||||
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
unwrapOr(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOrElse(_f: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expect(_msg: string): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
map<T2>(mapper: (val: T) => T2): Some<T2> {
|
||||
return new Some(mapper(this.value));
|
||||
}
|
||||
|
||||
mapOr<T2>(_default_: T2, mapper: (val: T) => T2): T2 {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapOrElse<U>(_default_: () => U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
or(_other: Option<T>): Option<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
orElse(_other: () => Option<T>): Option<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
andThen<T2>(mapper: (val: T) => Option<T2>): Option<T2> {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
// toResult<E>(_error: E): Ok<T> {
|
||||
// return Ok(this.value);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Returns the contained `Some` value, but never throws.
|
||||
* Unlike `unwrap()`, this method doesn't throw and is only callable on an Some<T>
|
||||
*
|
||||
* Therefore, it can be used instead of `unwrap()` as a maintainability safeguard
|
||||
* that will fail to compile if the type of the Option is later changed to a None that can actually occur.
|
||||
*
|
||||
* (this is the `into_Some()` in rust)
|
||||
*/
|
||||
safeUnwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Some(${toString(this.value)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Some to be callable - possible because of the es5 compilation target
|
||||
// export const Some = SomeImpl as typeof SomeImpl & (<T>(val: T) => SomeImpl<T>);
|
||||
export const Some = SomeImpl;
|
||||
export type Some<T> = SomeImpl<T>;
|
||||
|
||||
export type Option<T> = Some<T> | None;
|
||||
|
||||
export type OptionSomeType<T extends Option<unknown>> =
|
||||
T extends Some<infer U> ? U : never;
|
||||
|
||||
export type OptionSomeTypes<T extends Option<unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Option<unknown>
|
||||
? OptionSomeType<T[key]>
|
||||
: never;
|
||||
};
|
||||
|
||||
export namespace Option {
|
||||
/**
|
||||
* Parse a set of `Option`s, returning an array of all `Some` values.
|
||||
* Short circuits with the first `None` found, if any
|
||||
*/
|
||||
export function all<T extends Option<any>[]>(
|
||||
...options: T
|
||||
): Option<OptionSomeTypes<T>> {
|
||||
const someOption: unknown[] = [];
|
||||
for (let option of options) {
|
||||
if (option.isSome()) {
|
||||
someOption.push(option.value);
|
||||
} else {
|
||||
return option as None;
|
||||
}
|
||||
}
|
||||
|
||||
return new Some(someOption as OptionSomeTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a set of `Option`s, short-circuits when an input value is `Some`.
|
||||
* If no `Some` is found, returns `None`.
|
||||
*/
|
||||
export function any<T extends Option<any>[]>(
|
||||
...options: T
|
||||
): Option<OptionSomeTypes<T>[number]> {
|
||||
// short-circuits
|
||||
for (const option of options) {
|
||||
if (option.isSome()) {
|
||||
return option as Some<OptionSomeTypes<T>[number]>;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// it must be None
|
||||
return None;
|
||||
}
|
||||
|
||||
export function isOption<T = any>(value: unknown): value is Option<T> {
|
||||
return value instanceof Some || value === None;
|
||||
}
|
||||
}
|
||||
536
src/lib/thirdparty/ts-result-es/result.ts
vendored
Normal file
536
src/lib/thirdparty/ts-result-es/result.ts
vendored
Normal file
@@ -0,0 +1,536 @@
|
||||
import { toString } from "./utils";
|
||||
// import { Option, None, Some } from "./option";
|
||||
|
||||
/*
|
||||
* Missing Rust Result type methods:
|
||||
* pub fn contains<U>(&self, x: &U) -> bool
|
||||
* pub fn contains_err<F>(&self, f: &F) -> bool
|
||||
* pub fn and<U>(self, res: Result<U, E>) -> Result<U, E>
|
||||
* pub fn expect_err(self, msg: &str) -> E
|
||||
* pub fn unwrap_or_default(self) -> T
|
||||
*/
|
||||
interface BaseResult<T, E> extends Iterable<T> {
|
||||
/** `true` when the result is Ok */
|
||||
isOk(): this is OkImpl<T>;
|
||||
|
||||
/** `true` when the result is Err */
|
||||
isErr(): this is ErrImpl<E>;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value, if exists. Throws an error if not.
|
||||
*
|
||||
* The thrown error's
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* is set to value contained in `Err`.
|
||||
*
|
||||
* If you know you're dealing with `Ok` and the compiler knows it too (because you tested
|
||||
* `isOk()` or `isErr()`) you should use `value` instead. While `Ok`'s `expect()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* @param msg the message to throw if no Ok value.
|
||||
*/
|
||||
expect(msg: string): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Err` value, if exists. Throws an error if not.
|
||||
* @param msg the message to throw if no Err value.
|
||||
*/
|
||||
expectErr(msg: string): E;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `Err` case explicitly.
|
||||
*
|
||||
* If you know you're dealing with `Ok` and the compiler knows it too (because you tested
|
||||
* `isOk()` or `isErr()`) you should use `value` instead. While `Ok`'s `unwrap()` and `value` will
|
||||
* both return the same value using `value` is preferable because it makes it clear that
|
||||
* there won't be an exception thrown on access.
|
||||
*
|
||||
* Throws if the value is an `Err`, with a message provided by the `Err`'s value and
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* set to the value.
|
||||
*/
|
||||
unwrap(): T;
|
||||
|
||||
/**
|
||||
* Returns the contained `Err` value.
|
||||
* Because this function may throw, its use is generally discouraged.
|
||||
* Instead, prefer to handle the `Ok` case explicitly and access the `error` property
|
||||
* directly.
|
||||
*
|
||||
* Throws if the value is an `Ok`, with a message provided by the `Ok`'s value and
|
||||
* [`cause'](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause)
|
||||
* set to the value.
|
||||
*/
|
||||
unwrapErr(): E;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or a provided default.
|
||||
*
|
||||
* @see unwrapOr
|
||||
* @deprecated in favor of unwrapOr
|
||||
*/
|
||||
else<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or a provided default.
|
||||
*
|
||||
* (This is the `unwrap_or` in rust)
|
||||
*/
|
||||
unwrapOr<T2>(val: T2): T | T2;
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value or computes a value with a provided function.
|
||||
*
|
||||
* The function is called at most one time, only if needed.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* Ok('OK').unwrapOrElse(
|
||||
* (error) => { console.log(`Called, got ${error}`); return 'UGH'; }
|
||||
* ) // => 'OK', nothing printed
|
||||
*
|
||||
* Err('A03B').unwrapOrElse((error) => `UGH, got ${error}`) // => 'UGH, got A03B'
|
||||
* ```
|
||||
*/
|
||||
unwrapOrElse<T2>(f: (error: E) => T2): T | T2;
|
||||
|
||||
/**
|
||||
* Calls `mapper` if the result is `Ok`, otherwise returns the `Err` value of self.
|
||||
* This function can be used for control flow based on `Result` values.
|
||||
*/
|
||||
andThen<T2, E2>(mapper: (val: T) => Result<T2, E2>): Result<T2, E | E2>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by applying a function to a contained `Ok` value,
|
||||
* leaving an `Err` value untouched.
|
||||
*
|
||||
* This function can be used to compose the results of two functions.
|
||||
*/
|
||||
map<U>(mapper: (val: T) => U): Result<U, E>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<T, F>` by applying a function to a contained `Err` value,
|
||||
* leaving an `Ok` value untouched.
|
||||
*
|
||||
* This function can be used to pass through a successful result while handling an error.
|
||||
*/
|
||||
mapErr<F>(mapper: (val: E) => F): Result<T, F>;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by either converting `T` to `U` using `mapper`
|
||||
* (in case of `Ok`) or using the `default_` value (in case of `Err`).
|
||||
*
|
||||
* If `default` is a result of a function call consider using `mapOrElse` instead, it will
|
||||
* only evaluate the function when needed.
|
||||
*/
|
||||
mapOr<U>(default_: U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Maps a `Result<T, E>` to `Result<U, E>` by either converting `T` to `U` using `mapper`
|
||||
* (in case of `Ok`) or producing a default value using the `default` function (in case of
|
||||
* `Err`).
|
||||
*/
|
||||
mapOrElse<U>(default_: (error: E) => U, mapper: (val: T) => U): U;
|
||||
|
||||
/**
|
||||
* Returns `Ok()` if we have a value, otherwise returns `other`.
|
||||
*
|
||||
* `other` is evaluated eagerly. If `other` is a result of a function
|
||||
* call try `orElse()` instead – it evaluates the parameter lazily.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Ok(1).or(Ok(2)) // => Ok(1)
|
||||
* Err('error here').or(Ok(2)) // => Ok(2)
|
||||
*/
|
||||
or<E2>(other: Result<T, E2>): Result<T, E2>;
|
||||
|
||||
/**
|
||||
* Returns `Ok()` if we have a value, otherwise returns the result
|
||||
* of calling `other()`.
|
||||
*
|
||||
* `other()` is called *only* when needed and is passed the error value in a parameter.
|
||||
*
|
||||
* @example
|
||||
*
|
||||
* Ok(1).orElse(() => Ok(2)) // => Ok(1)
|
||||
* Err('error').orElse(() => Ok(2)) // => Ok(2)
|
||||
*/
|
||||
orElse<T2, E2>(other: (error: E) => Result<T2, E2>): Result<T | T2, E2>;
|
||||
|
||||
/**
|
||||
* Converts from `Result<T, E>` to `Option<T>`, discarding the error if any
|
||||
*
|
||||
* Similar to rust's `ok` method
|
||||
*/
|
||||
// toOption(): Option<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Contains the error value
|
||||
*/
|
||||
export class ErrImpl<E> implements BaseResult<never, E> {
|
||||
/** An empty Err */
|
||||
static readonly EMPTY = new ErrImpl<void>(undefined);
|
||||
|
||||
isOk(): this is OkImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
isErr(): this is ErrImpl<E> {
|
||||
return true;
|
||||
}
|
||||
|
||||
readonly error!: E;
|
||||
|
||||
private readonly _stack!: string;
|
||||
|
||||
[Symbol.iterator](): Iterator<never, never, unknown> {
|
||||
return {
|
||||
next(): IteratorResult<never, never> {
|
||||
return { done: true, value: undefined! };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
constructor(val: E) {
|
||||
if (!(this instanceof ErrImpl)) {
|
||||
return new ErrImpl(val);
|
||||
}
|
||||
|
||||
this.error = val;
|
||||
|
||||
const stackLines = new Error().stack!.split("\n").slice(2);
|
||||
if (
|
||||
stackLines !== undefined &&
|
||||
stackLines.length > 0 &&
|
||||
stackLines[0].includes("ErrImpl")
|
||||
) {
|
||||
stackLines.shift();
|
||||
}
|
||||
|
||||
this._stack = stackLines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated in favor of unwrapOr
|
||||
* @see unwrapOr
|
||||
*/
|
||||
else<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOr<T2>(val: T2): T2 {
|
||||
return val;
|
||||
}
|
||||
|
||||
unwrapOrElse<T2>(f: (error: E) => T2): T2 {
|
||||
return f(this.error);
|
||||
}
|
||||
|
||||
expect(msg: string): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(`${msg} - Error: ${toString(this.error)}\n${this._stack}`, {
|
||||
cause: this.error as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
expectErr(_msg: string): E {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
unwrap(): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(
|
||||
`Tried to unwrap Error: ${toString(this.error)}\n${this._stack}`,
|
||||
{ cause: this.error as unknown },
|
||||
);
|
||||
}
|
||||
|
||||
unwrapErr(): E {
|
||||
return this.error;
|
||||
}
|
||||
|
||||
map(_mapper: unknown): Err<E> {
|
||||
return this;
|
||||
}
|
||||
|
||||
andThen<T2, E2>(_op: (val: never) => Result<T2, E2>): Result<T2, E | E2> {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapErr<E2>(mapper: (err: E) => E2): Err<E2> {
|
||||
return new Err(mapper(this.error));
|
||||
}
|
||||
|
||||
mapOr<U>(default_: U, _mapper: unknown): U {
|
||||
return default_;
|
||||
}
|
||||
|
||||
mapOrElse<U>(default_: (error: E) => U, _mapper: unknown): U {
|
||||
return default_(this.error);
|
||||
}
|
||||
|
||||
or<T>(other: Ok<T>): Result<T, never>;
|
||||
or<R extends Result<unknown, unknown>>(other: R): R;
|
||||
or<T, E2>(other: Result<T, E2>): Result<T, E2> {
|
||||
return other;
|
||||
}
|
||||
|
||||
orElse<T2, E2>(other: (error: E) => Result<T2, E2>): Result<T2, E2> {
|
||||
return other(this.error);
|
||||
}
|
||||
|
||||
// toOption(): Option<never> {
|
||||
// return None;
|
||||
// }
|
||||
|
||||
toString(): string {
|
||||
return `Err(${toString(this.error)})`;
|
||||
}
|
||||
|
||||
get stack(): string | undefined {
|
||||
return `${this.toString()}\n${this._stack}`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Err to be callable - possible because of the es5 compilation target
|
||||
// export const Err = ErrImpl as typeof ErrImpl & (<E>(err: E) => Err<E>);
|
||||
export const Err = ErrImpl;
|
||||
export type Err<E> = ErrImpl<E>;
|
||||
|
||||
/**
|
||||
* Contains the success value
|
||||
*/
|
||||
export class OkImpl<T> implements BaseResult<T, never> {
|
||||
static readonly EMPTY = new OkImpl<void>(undefined);
|
||||
|
||||
isOk(): this is OkImpl<T> {
|
||||
return true;
|
||||
}
|
||||
|
||||
isErr(): this is ErrImpl<never> {
|
||||
return false;
|
||||
}
|
||||
|
||||
readonly value!: T;
|
||||
|
||||
[Symbol.iterator](): Iterator<T> {
|
||||
return [this.value][Symbol.iterator]();
|
||||
}
|
||||
|
||||
constructor(val: T) {
|
||||
if (!(this instanceof OkImpl)) {
|
||||
return new OkImpl(val);
|
||||
}
|
||||
|
||||
this.value = val;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see unwrapOr
|
||||
* @deprecated in favor of unwrapOr
|
||||
*/
|
||||
else(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOr(_val: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapOrElse(_f: unknown): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expect(_msg: string): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
expectErr(msg: string): never {
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
unwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
unwrapErr(): never {
|
||||
// The cause casting required because of the current TS definition being overly restrictive
|
||||
// (the definition says it has to be an Error while it can be anything).
|
||||
// See https://github.com/microsoft/TypeScript/issues/45167
|
||||
throw new Error(`Tried to unwrap Ok: ${toString(this.value)}`, {
|
||||
cause: this.value as unknown,
|
||||
});
|
||||
}
|
||||
|
||||
map<T2>(mapper: (val: T) => T2): Ok<T2> {
|
||||
return new Ok(mapper(this.value));
|
||||
}
|
||||
|
||||
andThen<T2, E2>(mapper: (val: T) => Result<T2, E2>): Result<T2, E2> {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapErr(_mapper: unknown): Ok<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
mapOr<U>(_default_: U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
mapOrElse<U>(_default_: (_error: never) => U, mapper: (val: T) => U): U {
|
||||
return mapper(this.value);
|
||||
}
|
||||
|
||||
or(_other: Result<T, unknown>): Ok<T> {
|
||||
return this;
|
||||
}
|
||||
|
||||
orElse<T2, E2>(_other: (error: never) => Result<T2, E2>): Result<T, never> {
|
||||
return this;
|
||||
}
|
||||
|
||||
// toOption(): Option<T> {
|
||||
// return Some(this.value);
|
||||
// }
|
||||
|
||||
/**
|
||||
* Returns the contained `Ok` value, but never throws.
|
||||
* Unlike `unwrap()`, this method doesn't throw and is only callable on an Ok<T>
|
||||
*
|
||||
* Therefore, it can be used instead of `unwrap()` as a maintainability safeguard
|
||||
* that will fail to compile if the error type of the Result is later changed to an error that can actually occur.
|
||||
*
|
||||
* (this is the `into_ok()` in rust)
|
||||
*/
|
||||
safeUnwrap(): T {
|
||||
return this.value;
|
||||
}
|
||||
|
||||
toString(): string {
|
||||
return `Ok(${toString(this.value)})`;
|
||||
}
|
||||
}
|
||||
|
||||
// This allows Ok to be callable - possible because of the es5 compilation target
|
||||
// export const Ok = OkImpl as typeof OkImpl & (<T>(val: T) => Ok<T>);
|
||||
export const Ok = OkImpl;
|
||||
export type Ok<T> = OkImpl<T>;
|
||||
|
||||
export type Result<T, E = Error> = Ok<T> | Err<E>;
|
||||
|
||||
export type ResultOkType<T extends Result<unknown, unknown>> =
|
||||
T extends Ok<infer U> ? U : never;
|
||||
export type ResultErrType<T> = T extends Err<infer U> ? U : never;
|
||||
|
||||
export type ResultOkTypes<T extends Result<unknown, unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Result<infer _U, unknown>
|
||||
? ResultOkType<T[key]>
|
||||
: never;
|
||||
};
|
||||
export type ResultErrTypes<T extends Result<unknown, unknown>[]> = {
|
||||
[key in keyof T]: T[key] extends Result<infer _U, unknown>
|
||||
? ResultErrType<T[key]>
|
||||
: never;
|
||||
};
|
||||
|
||||
export namespace Result {
|
||||
/**
|
||||
* Parse a set of `Result`s, returning an array of all `Ok` values.
|
||||
* Short circuits with the first `Err` found, if any
|
||||
*/
|
||||
export function all<const T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): Result<ResultOkTypes<T>, ResultErrTypes<T>[number]> {
|
||||
const okResult: unknown[] = [];
|
||||
for (let result of results) {
|
||||
if (result.isOk()) {
|
||||
okResult.push(result.value);
|
||||
} else {
|
||||
return result as Err<ResultErrTypes<T>[number]>;
|
||||
}
|
||||
}
|
||||
|
||||
return new Ok(okResult as ResultOkTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a set of `Result`s, short-circuits when an input value is `Ok`.
|
||||
* If no `Ok` is found, returns an `Err` containing the collected error values
|
||||
*/
|
||||
export function any<const T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): Result<ResultOkTypes<T>[number], ResultErrTypes<T>> {
|
||||
const errResult: unknown[] = [];
|
||||
|
||||
// short-circuits
|
||||
for (const result of results) {
|
||||
if (result.isOk()) {
|
||||
return result as Ok<ResultOkTypes<T>[number]>;
|
||||
} else {
|
||||
errResult.push(result.error);
|
||||
}
|
||||
}
|
||||
|
||||
// it must be a Err
|
||||
return new Err(errResult as ResultErrTypes<T>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an operation that may throw an Error (`try-catch` style) into checked exception style
|
||||
* @param op The operation function
|
||||
*/
|
||||
export function wrap<T, E = unknown>(op: () => T): Result<T, E> {
|
||||
try {
|
||||
return new Ok(op());
|
||||
} catch (e) {
|
||||
return new Err<E>(e as E);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an async operation that may throw an Error (`try-catch` style) into checked exception style
|
||||
* @param op The operation function
|
||||
*/
|
||||
export function wrapAsync<T, E = unknown>(
|
||||
op: () => Promise<T>,
|
||||
): Promise<Result<T, E>> {
|
||||
try {
|
||||
return op()
|
||||
.then((val) => new Ok(val))
|
||||
.catch((e) => new Err(e));
|
||||
} catch (e) {
|
||||
return Promise.resolve(new Err(e as E));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Partitions a set of results, separating the `Ok` and `Err` values.
|
||||
*/
|
||||
export function partition<T extends Result<any, any>[]>(
|
||||
results: T,
|
||||
): [ResultOkTypes<T>, ResultErrTypes<T>] {
|
||||
return results.reduce(
|
||||
([oks, errors], v) =>
|
||||
v.isOk()
|
||||
? [[...oks, v.value] as ResultOkTypes<T>, errors]
|
||||
: [oks, [...errors, v.error] as ResultErrTypes<T>],
|
||||
[[], []] as [ResultOkTypes<T>, ResultErrTypes<T>],
|
||||
);
|
||||
}
|
||||
|
||||
export function isResult<T = any, E = any>(
|
||||
val: unknown,
|
||||
): val is Result<T, E> {
|
||||
return val instanceof Err || val instanceof Ok;
|
||||
}
|
||||
}
|
||||
11
src/lib/thirdparty/ts-result-es/utils.ts
vendored
Normal file
11
src/lib/thirdparty/ts-result-es/utils.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
export function toString(val: unknown): string {
|
||||
let value = String(val);
|
||||
if (value === "[object Object]") {
|
||||
try {
|
||||
value = textutils.serialize(val as object);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
220
src/logExample/main.ts
Normal file
220
src/logExample/main.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* Example usage of the ccStructLog library.
|
||||
*
|
||||
* This file demonstrates various ways to use the restructured logging system,
|
||||
* including basic usage, custom configurations, and advanced scenarios.
|
||||
*/
|
||||
|
||||
import {
|
||||
Logger,
|
||||
processor,
|
||||
ConsoleStream,
|
||||
FileStream,
|
||||
BufferStream,
|
||||
ConditionalStream,
|
||||
HOUR,
|
||||
jsonRenderer,
|
||||
LogLevel,
|
||||
textRenderer,
|
||||
} from "../lib/ccStructLog";
|
||||
|
||||
// =============================================================================
|
||||
// Custom Logger Configurations
|
||||
// =============================================================================
|
||||
|
||||
print("\n=== Custom Logger Configurations ===");
|
||||
|
||||
// 4. Custom logger with specific processors and renderer
|
||||
const customLogger = new Logger({
|
||||
processors: [
|
||||
processor.addTimestamp(),
|
||||
processor.addComputerId(),
|
||||
processor.addSource("CustomApp"),
|
||||
processor.addStaticFields({
|
||||
environment: "development",
|
||||
version: "2.1.0",
|
||||
}),
|
||||
],
|
||||
renderer: jsonRenderer,
|
||||
streams: [
|
||||
new ConsoleStream(),
|
||||
new FileStream({
|
||||
filePath: "custom.log",
|
||||
rotationInterval: HOUR,
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
customLogger.info("Custom logger example", {
|
||||
feature: "user_management",
|
||||
operation: "create_user",
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Advanced Processor Examples
|
||||
// =============================================================================
|
||||
|
||||
print("\n=== Advanced Processor Examples ===");
|
||||
|
||||
// 6. Custom processors
|
||||
const addRequestId = (event: Map<string, unknown>) => {
|
||||
event.set("requestId", `req_${Math.random().toString(36).substr(2, 9)}`);
|
||||
return event;
|
||||
};
|
||||
|
||||
const sanitizePasswords = (event: Map<string, unknown>) => {
|
||||
// Remove sensitive information
|
||||
if (event.has("password")) {
|
||||
event.set("password", "[REDACTED]");
|
||||
}
|
||||
if (event.has("token")) {
|
||||
event.set("token", "[REDACTED]");
|
||||
}
|
||||
return event;
|
||||
};
|
||||
|
||||
const secureLogger = new Logger({
|
||||
processors: [
|
||||
processor.addTimestamp(),
|
||||
addRequestId,
|
||||
sanitizePasswords,
|
||||
processor.transformField("message", (msg) => `[SECURE] ${msg}`),
|
||||
],
|
||||
renderer: jsonRenderer,
|
||||
streams: [new ConsoleStream()],
|
||||
});
|
||||
|
||||
secureLogger.info("User login attempt", {
|
||||
username: "john_doe",
|
||||
password: "secret123",
|
||||
token: "abc123def456",
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Stream Examples
|
||||
// =============================================================================
|
||||
|
||||
print("\n=== Stream Examples ===");
|
||||
|
||||
// 11. Buffer stream for batch processing
|
||||
const bufferStream = new BufferStream(100); // Keep last 100 messages
|
||||
const bufferLogger = new Logger({
|
||||
processors: [processor.addTimestamp()],
|
||||
renderer: textRenderer,
|
||||
streams: [
|
||||
new ConditionalStream(new ConsoleStream(), (msg, event) => {
|
||||
if (event.get("level") === LogLevel.Info) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}),
|
||||
bufferStream,
|
||||
],
|
||||
});
|
||||
|
||||
// Log several messages
|
||||
for (let i = 0; i < 5; i++) {
|
||||
bufferLogger.info(`Buffered info message ${i}`, { iteration: i });
|
||||
bufferLogger.warn(`Buffered warn message ${i}`, { iteration: i });
|
||||
}
|
||||
|
||||
// Get all buffered messages
|
||||
const bufferedMessages = bufferStream.getMessages();
|
||||
print(`Buffered ${bufferedMessages.length} messages:`);
|
||||
for (const msg of bufferedMessages) {
|
||||
print(` ${msg}`);
|
||||
}
|
||||
|
||||
// 12. Multi-stream with different formats
|
||||
const multiFormatLogger = new Logger({
|
||||
processors: [processor.addTimestamp(), processor.addComputerId()],
|
||||
renderer: (event) => "default", // This won't be used
|
||||
streams: [
|
||||
// Console with human-readable format
|
||||
{
|
||||
write: (_, event) => {
|
||||
const formatted = textRenderer(event);
|
||||
new ConsoleStream().write(formatted, event);
|
||||
},
|
||||
},
|
||||
// File with JSON format
|
||||
{
|
||||
write: (_, event) => {
|
||||
const formatted = jsonRenderer(event);
|
||||
new FileStream({ filePath: "structured.log" }).write(
|
||||
formatted,
|
||||
event,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
multiFormatLogger.info("Multi-format message", {
|
||||
feature: "logging",
|
||||
test: true,
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Error Handling and Edge Cases
|
||||
// =============================================================================
|
||||
|
||||
print("\n=== Error Handling Examples ===");
|
||||
|
||||
// 13. Robust error handling
|
||||
const robustLogger = new Logger({
|
||||
processors: [
|
||||
processor.addTimestamp(),
|
||||
// Processor that might fail
|
||||
(event) => {
|
||||
try {
|
||||
// Simulate potential failure
|
||||
if (Math.random() > 0.8) {
|
||||
throw new Error("Processor failed");
|
||||
}
|
||||
event.set("processed", true);
|
||||
return event;
|
||||
} catch (error) {
|
||||
// Log processor errors but don't break the chain
|
||||
printError(`Processor error: ${String(error)}`);
|
||||
event.set("processor_error", true);
|
||||
return event;
|
||||
}
|
||||
},
|
||||
],
|
||||
renderer: textRenderer,
|
||||
streams: [new ConsoleStream()],
|
||||
});
|
||||
|
||||
// Log multiple messages to see error handling in action
|
||||
for (let i = 0; i < 10; i++) {
|
||||
robustLogger.info(`Message ${i}`, { attempt: i });
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Cleanup Examples
|
||||
// =============================================================================
|
||||
|
||||
print("\n=== Cleanup Examples ===");
|
||||
|
||||
// 14. Proper cleanup
|
||||
const fileLogger = new Logger({
|
||||
processors: [processor.addTimestamp()],
|
||||
renderer: jsonRenderer,
|
||||
streams: [new FileStream({ filePath: "structured.log" })],
|
||||
});
|
||||
|
||||
fileLogger.info("Temporary log entry");
|
||||
|
||||
// Clean shutdown - close all streams
|
||||
fileLogger.close();
|
||||
|
||||
print("\n=== Examples Complete ===");
|
||||
print("Check the generated log files:");
|
||||
print("- app.log (daily rotation)");
|
||||
print("- custom.log (hourly rotation)");
|
||||
print("- all.log (complete log)");
|
||||
print("- debug.log (detailed debug info)");
|
||||
print("- structured.log (JSON format)");
|
||||
print("- temp.log (temporary file, now closed)");
|
||||
@@ -1,3 +1,22 @@
|
||||
import { testTimeBasedRotation } from "./testCcLog";
|
||||
import { testTimeBasedRotation } from "./testCCLog";
|
||||
import { testSortedArray } from "./testSortedArray";
|
||||
import { testSemaphore } from "./testSemaphore";
|
||||
import { testReadWriteLock } from "./testReadWriteLock";
|
||||
|
||||
testTimeBasedRotation();
|
||||
testSortedArray();
|
||||
testSemaphore()
|
||||
.then(() => {
|
||||
print("Semaphore test completed");
|
||||
return testReadWriteLock();
|
||||
})
|
||||
.catch((error) => {
|
||||
print(`Semaphore test failed: ${error}`);
|
||||
});
|
||||
testReadWriteLock()
|
||||
.then(() => {
|
||||
print("ReadWriteLock test completed");
|
||||
})
|
||||
.catch((error) => {
|
||||
print(`Test failed: ${error}`);
|
||||
});
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import { CCLog, MINUTE, HOUR, SECOND } from "@/lib/ccLog";
|
||||
|
||||
// Test the new time-based rotation functionality
|
||||
function testTimeBasedRotation() {
|
||||
print("Testing time-based log rotation functionality...");
|
||||
|
||||
// Test with default interval (1 day)
|
||||
const logger1 = new CCLog("test_log_default.txt");
|
||||
logger1.info("This is a test message with default interval (1 day)");
|
||||
|
||||
// Test with custom interval (1 hour)
|
||||
const logger2 = new CCLog("test_log_hourly.txt", HOUR);
|
||||
logger2.info("This is a test message with 1-hour interval");
|
||||
|
||||
// Test with custom interval (30 minutes)
|
||||
const logger3 = new CCLog("test_log_30min.txt", 30 * MINUTE);
|
||||
logger3.info("This is a test message with 30-minute interval");
|
||||
|
||||
// Test with custom interval (5 seconds)
|
||||
const logger4 = new CCLog("test_log_5sec.txt", 5 * SECOND);
|
||||
logger4.info("This is a test message with 5-second interval");
|
||||
for (let i = 0; i < 10; i++) {
|
||||
logger4.info(`This is a test message with 5-second interval ${i}`);
|
||||
sleep(1);
|
||||
}
|
||||
|
||||
logger1.close();
|
||||
logger2.close();
|
||||
logger3.close();
|
||||
logger4.close();
|
||||
|
||||
print("Test completed successfully!");
|
||||
}
|
||||
|
||||
export { testTimeBasedRotation };
|
||||
160
src/test/testReadWriteLock.ts
Normal file
160
src/test/testReadWriteLock.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { ReadWriteLock } from "../lib/ReadWriteLock";
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
error(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function testReadWriteLock() {
|
||||
print("Testing ReadWriteLock...");
|
||||
|
||||
async function testMultipleReaders() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const reader1 = await lock.acquireRead();
|
||||
const reader2 = await lock.acquireRead();
|
||||
assert(
|
||||
lock.getActiveReaders() === 2,
|
||||
"allows multiple readers: active readers should be 2",
|
||||
);
|
||||
reader1.release();
|
||||
assert(
|
||||
lock.getActiveReaders() === 1,
|
||||
"allows multiple readers: active readers should be 1",
|
||||
);
|
||||
reader2.release();
|
||||
assert(
|
||||
lock.getActiveReaders() === 0,
|
||||
"allows multiple readers: active readers should be 0",
|
||||
);
|
||||
print("testMultipleReaders passed");
|
||||
}
|
||||
|
||||
async function testSingleWriter() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const writer = await lock.acquireWrite();
|
||||
assert(
|
||||
lock.isWriteLocked() === true,
|
||||
"allows only one writer: isWriteLocked should be true",
|
||||
);
|
||||
writer.release();
|
||||
assert(
|
||||
lock.isWriteLocked() === false,
|
||||
"allows only one writer: isWriteLocked should be false",
|
||||
);
|
||||
print("testSingleWriter passed");
|
||||
}
|
||||
|
||||
async function testWriterBlocksReaders() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const writer = await lock.acquireWrite();
|
||||
let readerAcquired = false;
|
||||
const _ = lock.acquireRead().then(() => {
|
||||
readerAcquired = true;
|
||||
});
|
||||
assert(
|
||||
!readerAcquired,
|
||||
"blocks readers when a writer has the lock: reader should not be acquired yet",
|
||||
);
|
||||
writer.release();
|
||||
assert(
|
||||
readerAcquired,
|
||||
"blocks readers when a writer has the lock: reader should be acquired now",
|
||||
);
|
||||
print("testWriterBlocksReaders passed");
|
||||
}
|
||||
|
||||
async function testReaderBlocksWriters() {
|
||||
const lock = new ReadWriteLock(3);
|
||||
const reader = await lock.acquireRead();
|
||||
let writerAcquired = false;
|
||||
const _ = lock.acquireWrite().then(() => {
|
||||
writerAcquired = true;
|
||||
});
|
||||
assert(
|
||||
!writerAcquired,
|
||||
"blocks writers when a reader has the lock: writer should not be acquired yet",
|
||||
);
|
||||
reader.release();
|
||||
assert(
|
||||
writerAcquired,
|
||||
"blocks writers when a reader has the lock: writer should be acquired now",
|
||||
);
|
||||
print("testReaderBlocksWriters passed");
|
||||
}
|
||||
|
||||
function testTryAcquireRead() {
|
||||
const lock = new ReadWriteLock(1);
|
||||
const reader1 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader1 !== null,
|
||||
"tryAcquireRead works: first reader should be acquired",
|
||||
);
|
||||
const reader2 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader2 === null,
|
||||
"tryAcquireRead works: second reader should not be acquired",
|
||||
);
|
||||
reader1!.release();
|
||||
const reader3 = lock.tryAcquireRead();
|
||||
assert(
|
||||
reader3 !== null,
|
||||
"tryAcquireRead works: third reader should be acquired",
|
||||
);
|
||||
reader3!.release();
|
||||
print("testTryAcquireRead passed");
|
||||
}
|
||||
|
||||
function testTryAcquireWrite() {
|
||||
const lock = new ReadWriteLock();
|
||||
const writer1 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer1 !== null,
|
||||
"tryAcquireWrite works: first writer should be acquired",
|
||||
);
|
||||
const writer2 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer2 === null,
|
||||
"tryAcquireWrite works: second writer should not be acquired",
|
||||
);
|
||||
writer1!.release();
|
||||
const writer3 = lock.tryAcquireWrite();
|
||||
assert(
|
||||
writer3 !== null,
|
||||
"tryAcquireWrite works: third writer should be acquired",
|
||||
);
|
||||
writer3!.release();
|
||||
print("testTryAcquireWrite passed");
|
||||
}
|
||||
|
||||
async function testRunWithReadLock() {
|
||||
const lock = new ReadWriteLock();
|
||||
let value = 0;
|
||||
await lock.runWithReadLock(() => {
|
||||
value = 1;
|
||||
});
|
||||
assert(value === 1, "runWithReadLock works: value should be 1");
|
||||
print("testRunWithReadLock passed");
|
||||
}
|
||||
|
||||
async function testRunWithWriteLock() {
|
||||
const lock = new ReadWriteLock();
|
||||
let value = 0;
|
||||
await lock.runWithWriteLock(() => {
|
||||
value = 1;
|
||||
});
|
||||
assert(value === 1, "runWithWriteLock works: value should be 1");
|
||||
print("testRunWithWriteLock passed");
|
||||
}
|
||||
|
||||
await testMultipleReaders();
|
||||
await testSingleWriter();
|
||||
await testWriterBlocksReaders();
|
||||
await testReaderBlocksWriters();
|
||||
testTryAcquireRead();
|
||||
testTryAcquireWrite();
|
||||
await testRunWithReadLock();
|
||||
await testRunWithWriteLock();
|
||||
|
||||
print("ReadWriteLock tests passed!");
|
||||
}
|
||||
193
src/test/testSemaphore.ts
Normal file
193
src/test/testSemaphore.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Semaphore } from "@/lib/Semaphore";
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
error(message);
|
||||
}
|
||||
}
|
||||
|
||||
async function testBasicAcquireRelease() {
|
||||
print(" Running test: testBasicAcquireRelease");
|
||||
const s = new Semaphore(1);
|
||||
assert(s.getValue() === 1, "Initial value should be 1");
|
||||
|
||||
const [, release] = await s.acquire();
|
||||
assert(s.getValue() === 0, "Value after acquire should be 0");
|
||||
|
||||
release();
|
||||
assert(s.getValue() === 1, "Value after release should be 1");
|
||||
print(" Test passed: testBasicAcquireRelease");
|
||||
}
|
||||
|
||||
async function testRunExclusive() {
|
||||
print(" Running test: testRunExclusive");
|
||||
const s = new Semaphore(1);
|
||||
let inside = false;
|
||||
await s.runExclusive(() => {
|
||||
inside = true;
|
||||
assert(s.isLocked(), "Should be locked inside runExclusive");
|
||||
assert(s.getValue() === 0, "Value should be 0 inside runExclusive");
|
||||
});
|
||||
assert(inside, "Callback should have been executed");
|
||||
assert(!s.isLocked(), "Should be unlocked after runExclusive");
|
||||
assert(s.getValue() === 1, "Value should be 1 after runExclusive");
|
||||
print(" Test passed: testRunExclusive");
|
||||
}
|
||||
|
||||
function testTryAcquire() {
|
||||
print(" Running test: testTryAcquire");
|
||||
const s = new Semaphore(1);
|
||||
const release1 = s.tryAcquire();
|
||||
assert(release1 !== null, "tryAcquire should succeed");
|
||||
assert(s.getValue() === 0, "Value should be 0 after tryAcquire");
|
||||
|
||||
const release2 = s.tryAcquire();
|
||||
assert(release2 === null, "tryAcquire should fail when locked");
|
||||
|
||||
release1!();
|
||||
assert(s.getValue() === 1, "Value should be 1 after release");
|
||||
|
||||
const release3 = s.tryAcquire();
|
||||
assert(release3 !== null, "tryAcquire should succeed again");
|
||||
release3!();
|
||||
print(" Test passed: testTryAcquire");
|
||||
}
|
||||
|
||||
async function testQueueing() {
|
||||
print(" Running test: testQueueing");
|
||||
const s = new Semaphore(1);
|
||||
const events: string[] = [];
|
||||
|
||||
// Take the lock
|
||||
const [, release1] = await s.acquire();
|
||||
events.push("acquired1");
|
||||
|
||||
// These two will be queued. Store their promises.
|
||||
const p2 = s.acquire().then(([, release]) => {
|
||||
events.push("acquired2");
|
||||
events.push("released2");
|
||||
release();
|
||||
});
|
||||
|
||||
const p3 = s.acquire().then(([, release]) => {
|
||||
events.push("acquired3");
|
||||
events.push("released3");
|
||||
release();
|
||||
});
|
||||
|
||||
assert(events.length === 1, "Only first acquire should have completed");
|
||||
|
||||
// Release the first lock, allowing the queue to proceed
|
||||
events.push("released1");
|
||||
release1();
|
||||
|
||||
// Wait for all promises to finish
|
||||
await Promise.all([p2, p3]);
|
||||
|
||||
const expected = [
|
||||
"acquired1",
|
||||
"released1",
|
||||
"acquired2",
|
||||
"released2",
|
||||
"acquired3",
|
||||
"released3",
|
||||
];
|
||||
assert(
|
||||
textutils.serialiseJSON(events) === textutils.serialiseJSON(expected),
|
||||
`Event order incorrect: got ${textutils.serialiseJSON(events)}`,
|
||||
);
|
||||
print(" Test passed: testQueueing");
|
||||
}
|
||||
|
||||
async function testPriority() {
|
||||
print(" Running test: testPriority");
|
||||
const s = new Semaphore(1);
|
||||
const events: string[] = [];
|
||||
|
||||
const [, release1] = await s.acquire();
|
||||
events.push("acquired_main");
|
||||
|
||||
// Queue with low priority
|
||||
const p1 = s.acquire(1, 10).then(([, release]) => {
|
||||
events.push("acquired_low_prio");
|
||||
release();
|
||||
});
|
||||
|
||||
// Queue with high priority
|
||||
const p2 = s.acquire(1, 1).then(([, release]) => {
|
||||
events.push("acquired_high_prio");
|
||||
release();
|
||||
});
|
||||
|
||||
release1();
|
||||
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
const expected = ["acquired_main", "acquired_high_prio", "acquired_low_prio"];
|
||||
assert(
|
||||
textutils.serialiseJSON(events) === textutils.serialiseJSON(expected),
|
||||
`Priority order incorrect: got ${textutils.serialiseJSON(events)}`,
|
||||
);
|
||||
print(" Test passed: testPriority");
|
||||
}
|
||||
|
||||
async function testWaitForUnlock() {
|
||||
print(" Running test: testWaitForUnlock");
|
||||
const s = new Semaphore(1);
|
||||
let waited = false;
|
||||
|
||||
const [, release] = await s.acquire();
|
||||
assert(s.isLocked(), "Semaphore should be locked");
|
||||
|
||||
const p1 = s.waitForUnlock().then(() => {
|
||||
waited = true;
|
||||
assert(!s.isLocked(), "Should be unlocked when wait is over");
|
||||
});
|
||||
|
||||
assert(!waited, "waitForUnlock should not resolve yet");
|
||||
|
||||
release();
|
||||
await Promise.all([p1]);
|
||||
assert(waited, "waitForUnlock should have resolved");
|
||||
print(" Test passed: testWaitForUnlock");
|
||||
}
|
||||
|
||||
async function testCancel() {
|
||||
print(" Running test: testCancel");
|
||||
const cancelError = new Error("Canceled for test");
|
||||
const s = new Semaphore(1, cancelError);
|
||||
let rejected = false;
|
||||
|
||||
const [, release] = await s.acquire();
|
||||
|
||||
s.acquire().then(
|
||||
() => {
|
||||
assert(false, "acquire should have been rejected");
|
||||
},
|
||||
(err) => {
|
||||
assert(err === cancelError, "acquire rejected with wrong error");
|
||||
rejected = true;
|
||||
},
|
||||
);
|
||||
|
||||
s.cancel();
|
||||
|
||||
assert(rejected, "pending acquire should have been rejected");
|
||||
assert(s.getValue() === 0, "cancel should not affect current lock");
|
||||
|
||||
release();
|
||||
assert(s.getValue() === 1, "release should still work");
|
||||
print(" Test passed: testCancel");
|
||||
}
|
||||
|
||||
export async function testSemaphore() {
|
||||
print("Testing Semaphore...");
|
||||
await testBasicAcquireRelease();
|
||||
await testRunExclusive();
|
||||
testTryAcquire();
|
||||
await testQueueing();
|
||||
await testPriority();
|
||||
await testWaitForUnlock();
|
||||
await testCancel();
|
||||
print("Semaphore tests passed!");
|
||||
}
|
||||
62
src/test/testSortedArray.ts
Normal file
62
src/test/testSortedArray.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { SortedArray } from "@/lib/SortedArray";
|
||||
|
||||
function assert(condition: boolean, message: string) {
|
||||
if (!condition) {
|
||||
error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertDeepEquals(actual: object, expect: object, message: string) {
|
||||
const jsonExpect = textutils.serialiseJSON(expect, {
|
||||
allow_repetitions: true,
|
||||
});
|
||||
const jsonActual = textutils.serialiseJSON(actual, {
|
||||
allow_repetitions: true,
|
||||
});
|
||||
if (jsonExpect !== jsonActual) {
|
||||
error(`${message}: expected ${jsonExpect}, got ${jsonActual}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function testSortedArray() {
|
||||
print("Testing SortedArray...");
|
||||
|
||||
// Test constructor
|
||||
const sortedArray = new SortedArray<string>();
|
||||
assert(
|
||||
sortedArray.toArray().length === 0,
|
||||
"Constructor: initial length should be 0",
|
||||
);
|
||||
|
||||
// Test push (FIFO)
|
||||
const fifoArray = new SortedArray<string>([]);
|
||||
fifoArray.push({ priority: 2, data: "b" });
|
||||
fifoArray.push({ priority: 1, data: "a" });
|
||||
fifoArray.push({ priority: 3, data: "c" });
|
||||
fifoArray.push({ priority: 2, data: "b2" });
|
||||
assertDeepEquals(fifoArray.toArray(), ["a", "b", "b2", "c"], "Push (FIFO)");
|
||||
|
||||
// Test shift
|
||||
const shiftedValue = fifoArray.shift();
|
||||
assert(shiftedValue === "a", "Shift: should return the first element");
|
||||
assertDeepEquals(
|
||||
fifoArray.toArray(),
|
||||
["b", "b2", "c"],
|
||||
"Shift: array should be modified",
|
||||
);
|
||||
|
||||
// Test pop
|
||||
const poppedValue = fifoArray.pop();
|
||||
assert(poppedValue === "c", "Pop: should return the last element");
|
||||
assertDeepEquals(
|
||||
fifoArray.toArray(),
|
||||
["b", "b2"],
|
||||
"Pop: array should be modified",
|
||||
);
|
||||
|
||||
// Test clear
|
||||
fifoArray.clear();
|
||||
assert(fifoArray.toArray().length === 0, "Clear: array should be empty");
|
||||
|
||||
print("SortedArray tests passed!");
|
||||
}
|
||||
9
targets/tsconfig.accesscontrol.json
Normal file
9
targets/tsconfig.accesscontrol.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/accesscontrol.lua",
|
||||
"luaBundleEntry": "../src/accesscontrol/main.ts"
|
||||
},
|
||||
"include": ["../src/accesscontrol/*.ts"]
|
||||
}
|
||||
9
targets/tsconfig.autocraft.json
Normal file
9
targets/tsconfig.autocraft.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/autocraft.lua",
|
||||
"luaBundleEntry": "../src/autocraft/main.ts"
|
||||
},
|
||||
"include": ["../src/autocraft/*.ts"]
|
||||
}
|
||||
9
targets/tsconfig.cliExample.json
Normal file
9
targets/tsconfig.cliExample.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/cliExample.lua",
|
||||
"luaBundleEntry": "../src/cliExample/main.ts"
|
||||
},
|
||||
"include": ["../src/cliExample/*.ts", "../src/lib/ccCLI/*.ts"]
|
||||
}
|
||||
9
targets/tsconfig.logExample.json
Normal file
9
targets/tsconfig.logExample.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/logExample.lua",
|
||||
"luaBundleEntry": "../src/logExample/main.ts"
|
||||
},
|
||||
"include": ["../src/logExample/*.ts", "../src/lib/ccStructLog/*.ts"]
|
||||
}
|
||||
9
targets/tsconfig.test.json
Normal file
9
targets/tsconfig.test.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/test.lua",
|
||||
"luaBundleEntry": "../src/test/main.ts"
|
||||
},
|
||||
"include": ["../src/test/*.ts"]
|
||||
}
|
||||
9
targets/tsconfig.tuiExample.json
Normal file
9
targets/tsconfig.tuiExample.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "../tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "../build/tuiExample.lua",
|
||||
"luaBundleEntry": "../src/tuiExample/main.ts"
|
||||
},
|
||||
"include": ["../src/tuiExample/*.ts", "../src/lib/ccTUI/*.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "./tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "build/accesscontrol.lua",
|
||||
"luaBundleEntry": "src/accesscontrol/main.ts"
|
||||
},
|
||||
"include": ["src/accesscontrol/*.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "./tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "build/autocraft.lua",
|
||||
"luaBundleEntry": "src/autocraft/main.ts"
|
||||
},
|
||||
"include": ["src/autocraft/*.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "./tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "build/test.lua",
|
||||
"luaBundleEntry": "src/test/main.ts"
|
||||
},
|
||||
"include": ["src/test/*.ts"]
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/MCJack123/TypeScriptToLua/master/tsconfig-schema.json",
|
||||
"extends": "./tsconfig.json",
|
||||
"tstl": {
|
||||
"luaBundle": "build/tuiExample.lua",
|
||||
"luaBundleEntry": "src/tuiExample/main.ts"
|
||||
},
|
||||
"include": ["src/tuiExample/*.ts", "src/lib/ccTUI/*.ts"]
|
||||
}
|
||||
4
types/advanced-peripherals/shared.d.ts
vendored
4
types/advanced-peripherals/shared.d.ts
vendored
@@ -6,7 +6,7 @@ declare interface BlockItemDetailData {
|
||||
}
|
||||
|
||||
declare interface BlockDetailData {
|
||||
Items: Record<number, BlockItemDetailData>;
|
||||
Items: Record<string, BlockItemDetailData>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -30,7 +30,7 @@ declare type MinecraftColor =
|
||||
| "light_purple"
|
||||
| "yellow"
|
||||
| "white"
|
||||
| "reset"; // RGB color in #RRGGBB format
|
||||
| `#${string}`;
|
||||
|
||||
declare type MinecraftFont =
|
||||
| "minecraft:default"
|
||||
|
||||
20
types/craftos/index.d.ts
vendored
20
types/craftos/index.d.ts
vendored
@@ -925,10 +925,22 @@ declare namespace textutils {
|
||||
function pagedTabulate(...args: (LuaTable | Object | Color)[]): void;
|
||||
function serialize(tab: object, options?: SerializeOptions): string;
|
||||
function serialise(tab: object, options?: SerializeOptions): string;
|
||||
function serializeJSON(tab: object, nbtStyle?: boolean): string;
|
||||
function serializeJSON(tab: object, options: SerializeJSONOptions): string;
|
||||
function serialiseJSON(tab: object, nbtStyle?: boolean): string;
|
||||
function serialiseJSON(tab: object, options: SerializeJSONOptions): string;
|
||||
function serializeJSON(
|
||||
tab: object | string | number | boolean,
|
||||
nbtStyle?: boolean,
|
||||
): string;
|
||||
function serializeJSON(
|
||||
tab: object | string | number | boolean,
|
||||
options: SerializeJSONOptions,
|
||||
): string;
|
||||
function serialiseJSON(
|
||||
tab: object | string | number | boolean,
|
||||
nbtStyle?: boolean,
|
||||
): string;
|
||||
function serialiseJSON(
|
||||
tab: object | string | number | boolean,
|
||||
options: SerializeJSONOptions,
|
||||
): string;
|
||||
function unserialize(str: string): unknown;
|
||||
function unserialise(str: string): unknown;
|
||||
function unserializeJSON(
|
||||
|
||||
Reference in New Issue
Block a user