Compare commits

..

30 Commits

Author SHA1 Message Date
SikongJueluo
a4e74dcfa0 docs: ccCLI framework and ChatManager 2025-11-03 22:32:18 +08:00
SikongJueluo
2f57d9ab3d feature: accesscontrol welcome message 2025-11-03 13:20:21 +08:00
SikongJueluo
7e03d960bd fix: wrong type, chat manager unicode string; feature: accesscontrol welcome message and chinese support 2025-11-02 21:04:12 +08:00
SikongJueluo
f76a3666b1 feature: chat manager support utf8 2025-11-01 16:58:18 +08:00
SikongJueluo
d6971fb22f fix: cli framework help option not work 2025-11-01 14:34:19 +08:00
SikongJueluo
796bf1c2dc feature: global timer manager; reconstruct: accesscontrol; fix: chat manager
feature:
- add global timer manager
reconstruct:
- use new cli framework for accesscontrol
- use chat manager for accesscontrol
fix:
- chat manager only send one time
2025-11-01 13:16:42 +08:00
SikongJueluo
959ec0c424 feature: ChatManager
feature:
- multi chatboxes manage
- chatbox message queue
2025-10-30 12:58:53 +08:00
SikongJueluo
e680ef0263 reconstruct: project compile 2025-10-27 22:33:27 +08:00
SikongJueluo
1891259ee7 fix: cli framework
fix:
- cli command path wrong
- help output nil
2025-10-27 22:02:53 +08:00
7a17ca7fbf reconstruct: cli framework 2025-10-27 16:50:04 +08:00
SikongJueluo
f7167576cd feature: cli framework 2025-10-27 11:55:32 +08:00
SikongJueluo
2ab091d939 fix: accesscontrol toast; feature: autocraft basic; reconstruct: autocraft
fix:
- accesscontrol send toast failed
- advanced peripherals BlockDetailData wrong record type
feature:
- autocraft support multi package craft
- autocraft more fast craft speed
reconstruct:
- CraftManager algorithm
- autocraft logic
2025-10-26 20:19:49 +08:00
SikongJueluo
119bc1997a reconstruct: autocraft algorithm; feature: rust-style result
reconstruct:
- move queue and sortedarray to dir datatype
- move semaphore and readwritelock to dir mutex
- reconstruct autocraft search algorithm, use hashmap instead of forloop
- adjust some code style
feature:
- add rust-style result lib
2025-10-26 10:06:50 +08:00
SikongJueluo
ac70e1acd3 fix: auto reload
reconstruct: semaphore and read write lock use undefined instead of null
fix: accesscontrol auto reload config
2025-10-19 15:46:56 +08:00
SikongJueluo
d90574e514 feature: rwlock; fix: test for semaphore
feature:
- add read & write lock and test
- sorted array add peek function
fix:
- fix semaphore test (sleep block in async)
reconstruct:
- semaphore releaser
2025-10-18 14:53:34 +08:00
SikongJueluo
4e71fbffc3 feature: add sortedarray semaphore 2025-10-17 21:41:23 +08:00
a3479865c8 feature: add data type queue 2025-10-17 16:40:44 +08:00
SikongJueluo
9d9dcade7b feature: log add min output log level; reconstruct: accesscontrol cli 2025-10-16 21:10:36 +08:00
SikongJueluo
6304518f0e fix: deepcopy did't run as expected 2025-10-16 19:38:52 +08:00
SikongJueluo
1fe3052e5d fix: notice toast; feature: auto-reload, deepcopy
fix: notcie toast only show the first one player
feature:
- auto reload config when start (maybe conflict in multi threads)
- add basic deepcopy function in common
2025-10-15 23:22:08 +08:00
SikongJueluo
b9ce947b9b fix: conflict with log and tui, position parse nothing
fix:
- fix ui conflict with log and tui
- fix player position parse failed
2025-10-14 23:26:53 +08:00
SikongJueluo
da2c6c1ebb feature: auto-gen configuration, notice settings, player position parse
feature:
- auto generate configuration file
- adopt notice toast settings
- toast config add new parse parameter: player position x, y, z
reconstruct:
- justfile: remove copy config file command from project
2025-10-14 23:04:00 +08:00
SikongJueluo
c85c072376 feature: tui onFocusChanged event
feature: add focus event for tui
reconstruct: tui props and accesscontrol parse logic
2025-10-14 22:29:17 +08:00
SikongJueluo
d41117cecc adjust input render 2025-10-14 19:41:56 +08:00
SikongJueluo
5a3a404bff merge readme 2025-10-14 19:27:34 +08:00
SikongJueluo
039ff7ff8d fix: long string only display the beginning 2025-10-14 19:26:22 +08:00
SikongJueluo
d02874da73 fix: save failed 2025-10-14 12:59:52 +08:00
SikongJueluo
7f2a51c5aa add word wrap for label 2025-10-14 12:41:32 +08:00
SikongJueluo
0ccafa2e2e try to wordwrap, but failed 2025-10-12 22:31:35 +08:00
SikongJueluo
bd8e1f9b8d finish basic tui for accesscontrol, add scroll for tui 2025-10-12 20:23:08 +08:00
57 changed files with 7614 additions and 1513 deletions

View File

@@ -5,17 +5,21 @@ 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
cp ./src/accesscontrol/access.config.json ./build/
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-tuiExample:
pnpm tstl -p ./targets/tsconfig.tuiExample.json
build-cliExample:
pnpm tstl -p ./targets/tsconfig.cliExample.json
sync:
rsync --delete -r "./build/" "{{ sync-path }}"

View File

@@ -30,7 +30,17 @@ 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
### 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.
- **`ccLog`:** A robust logging library with automatic, time-based log file rotation.
- **`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.

235
docs/ChatManager.md Normal file
View 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
Heres 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
View 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.

View File

@@ -1,72 +0,0 @@
{
"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
},
{
"groupName": "VIP",
"groupUsers": [],
"isAllowed": true,
"isNotice": false
},
{
"groupName": "enemies",
"groupUsers": [],
"isAllowed": false,
"isNotice": false
}
],
"welcomeToastConfig": {
"title": {
"text": "Welcome",
"color": "green"
},
"msg": {
"text": "Hello User %playerName%",
"color": "green"
},
"prefix": "Taohuayuan",
"brackets": "[]",
"bracketColor": ""
},
"noticeToastConfig": {
"title": {
"text": "Welcome",
"color": "green"
},
"msg": {
"text": "Hello User %playerName%",
"color": "green"
},
"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": ""
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,4 @@
import { CCLog } from "@/lib/ccLog";
import * as dkjson from "@sikongjueluo/dkjson-types";
let log: CCLog | undefined;
// import * as dkjson from "@sikongjueluo/dkjson-types";
interface ToastConfig {
title: MinecraftTextComponent;
@@ -15,6 +12,7 @@ interface UserGroupConfig {
groupName: string;
isAllowed: boolean;
isNotice: boolean;
isWelcome: boolean;
groupUsers: string[];
}
@@ -23,6 +21,7 @@ interface AccessConfig {
watchInterval: number;
noticeTimes: number;
detectRange: number;
isWelcome: boolean;
isWarn: boolean;
adminGroupConfig: UserGroupConfig;
welcomeToastConfig: ToastConfig;
@@ -37,11 +36,13 @@ const defaultConfig: AccessConfig = {
watchInterval: 10,
noticeTimes: 2,
isWarn: false,
isWelcome: true,
adminGroupConfig: {
groupName: "Admin",
groupUsers: ["Selcon"],
isAllowed: true,
isNotice: true,
isWelcome: false,
},
usersGroups: [
{
@@ -49,98 +50,116 @@ const defaultConfig: AccessConfig = {
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: "Welcome",
text: "欢迎",
color: "green",
},
msg: {
text: "Hello User %playerName%",
color: "green",
text: "欢迎 %playerName% 参观桃源星喵~",
color: "#EDC8DA",
},
prefix: "Taohuayuan",
brackets: "[]",
prefix: "桃源星",
brackets: "<>",
bracketColor: "",
},
noticeToastConfig: {
title: {
text: "Welcome",
color: "green",
text: "警告",
color: "red",
},
msg: {
text: "Hello User %playerName%",
color: "green",
text: "陌生玩家 %playerName% 出现在 %playerPosX%, %playerPosY%, %playerPosZ%",
color: "red",
},
prefix: "Taohuayuan",
brackets: "[]",
prefix: "桃源星",
brackets: "<>",
bracketColor: "",
},
warnToastConfig: {
title: {
text: "Attention!!!",
text: "注意",
color: "red",
},
msg: {
text: "%playerName% you are not allowed to be here",
text: "%playerName% 你已经进入桃源星领地",
color: "red",
},
prefix: "Taohuayuan",
brackets: "[]",
prefix: "桃源星",
brackets: "<>",
bracketColor: "",
},
};
function setLog(newLog: CCLog) {
log = newLog;
}
function loadConfig(filepath: string): AccessConfig {
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) {
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,
// });
const config = textutils.unserialiseJSON(configJson, {
parse_empty_array: true,
});
return config as AccessConfig;
}
function saveConfig(config: AccessConfig, filepath: string) {
const configJson = dkjson.encode(config, { indent: true }) as string;
// const configJson = dkjson.encode(config, { indent: true }) as string;
// Not use external lib
// const configJson = textutils.serializeJSON(config, { unicode_strings: true });
const configJson = textutils.serializeJSON(config, {
allow_repetitions: true,
unicode_strings: true,
});
if (configJson == undefined) {
print("Failed to save config");
}
@@ -155,11 +174,4 @@ function saveConfig(config: AccessConfig, filepath: string) {
fp.close();
}
export {
ToastConfig,
UserGroupConfig,
AccessConfig,
loadConfig,
saveConfig,
setLog,
};
export { ToastConfig, UserGroupConfig, AccessConfig, loadConfig, saveConfig };

View File

@@ -1,106 +1,213 @@
import { CCLog, DAY } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig, setLog } from "./config";
import { createAccessControlCLI } from "./cli";
import { CCLog, DAY, LogLevel } from "@/lib/ccLog";
import { ToastConfig, UserGroupConfig, loadConfig } from "./config";
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";
const DEBUG = false;
const args = [...$vararg];
// Init Log
const log = new CCLog("accesscontrol.log", true, DAY);
setLog(log);
const logger = new CCLog("accesscontrol.log", {
printTerminal: true,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
// Load Config
const configFilepath = `${shell.dir()}/access.config.json`;
const config = loadConfig(configFilepath);
log.info("Load config successfully!");
if (DEBUG) log.debug(textutils.serialise(config, { allow_repetitions: true }));
const groupNames = config.usersGroups.map((value) => value.groupName);
let noticeTargetPlayers: string[];
const playerDetector = peripheralManager.findByNameRequired("playerDetector");
const chatBox = peripheralManager.findByNameRequired("chatBox");
let config = loadConfig(configFilepath)!;
const configLock = new ReadWriteLock();
logger.info("Load config successfully!");
logger.debug(textutils.serialise(config, { allow_repetitions: true }));
let inRangePlayers: string[] = [];
let watchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
// Peripheral
const playerDetector = peripheral.find(
"playerDetector",
)[0] as PlayerDetectorPeripheral;
const chatBox = peripheral.find("chatBox")[0] as ChatBoxPeripheral;
const chatManager: ChatManager = new ChatManager([chatBox]);
// Global
let gInRangePlayers: string[] = [];
let gWatchPlayersInfo: { name: string; hasNoticeTimes: number }[] = [];
let gIsRunning = true;
interface ParseParams {
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!");
}
function safeParseTextComponent(
component: MinecraftTextComponent,
playerName: string,
groupName?: string,
): string {
if (component.text == undefined) {
component.text = "Wrong text, please contanct with admin";
} else if (component.text.includes("%")) {
component.text = component.text.replace("%playerName%", playerName);
if (groupName != undefined)
component.text = component.text.replace("%groupName%", groupName);
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?.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 textutils.serialiseJSON(component);
return newComponent;
}
function sendToast(toastConfig: ToastConfig, targetPlayer: string) {
return chatBox.sendFormattedToastToPlayer(
textutils.serialiseJSON(toastConfig.msg ?? config.welcomeToastConfig.msg),
textutils.serialiseJSON(
toastConfig.title ?? config.welcomeToastConfig.title,
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,
),
targetPlayer,
toastConfig.prefix ?? config.welcomeToastConfig.prefix,
toastConfig.brackets ?? config.welcomeToastConfig.brackets,
toastConfig.bracketColor ?? config.welcomeToastConfig.bracketColor,
undefined,
true,
);
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,
) {
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) {
let releaser = configLock.tryAcquireRead();
while (releaser === undefined) {
sleep(0.1);
releaser = configLock.tryAcquireRead();
}
const onlinePlayers = playerDetector.getOnlinePlayers();
noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
const noticeTargetPlayers = config.adminGroupConfig.groupUsers.concat(
config.usersGroups
.filter((value) => value.isNotice)
.map((value) => value.groupUsers ?? [])
.flat(),
.flatMap((value) => value.groupUsers ?? []),
);
logger.debug(`noticeTargetPlayers: ${noticeTargetPlayers.join(", ")}`);
const toastConfig: ToastConfig = {
title: {
text: "Notice",
color: "red",
},
msg: {
text: `Unfamiliar Player ${player} appeared at\n Position ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
color: "red",
},
};
for (const targetPlayer of noticeTargetPlayers) {
if (!onlinePlayers.includes(targetPlayer)) continue;
sendToast(toastConfig, targetPlayer);
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 `;
log.warn(warnMsg);
logger.warn(warnMsg);
sendToast(config.warnToastConfig, player);
chatBox.sendFormattedMessageToPlayer(
safeParseTextComponent(config.warnToastConfig.msg, 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) {
for (const player of watchPlayersInfo) {
if (inRangePlayers.includes(player.name)) {
const playerInfo = playerDetector.getPlayerPos(player.name);
while (gIsRunning) {
const releaser = configLock.tryAcquireRead();
if (releaser === undefined) {
os.sleep(1);
continue;
}
const watchPlayerNames = gWatchPlayersInfo.flatMap((value) => value.name);
logger.debug(`Watch Players [ ${watchPlayerNames.join(", ")} ]`);
for (const player of gWatchPlayersInfo) {
const playerInfo = playerDetector.getPlayerPos(player.name);
if (gInRangePlayers.includes(player.name)) {
// Notice
if (player.hasNoticeTimes < config.noticeTimes) {
sendNotice(player.name, playerInfo);
@@ -111,122 +218,215 @@ function watchLoop() {
if (config.isWarn) sendWarn(player.name);
// Record
log.warn(
`${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
logger.warn(
`Stranger ${player.name} appear at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
} else {
// Get rid of player from list
watchPlayersInfo = watchPlayersInfo.filter(
gWatchPlayersInfo = gWatchPlayersInfo.filter(
(value) => value.name != player.name,
);
logger.info(
`Stranger ${player.name} has left the range at ${playerInfo?.x}, ${playerInfo?.y}, ${playerInfo?.z}`,
);
}
os.sleep(1);
}
releaser.release();
os.sleep(config.watchInterval);
}
}
function mainLoop() {
while (true) {
const players = playerDetector.getPlayersInRange(config.detectRange);
if (DEBUG) {
const playersList = "[ " + players.join(",") + " ]";
log.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 (inRangePlayers.includes(player)) continue;
if (gInRangePlayers.includes(player)) continue;
// Get player Info
const playerInfo = playerDetector.getPlayerPos(player);
if (config.adminGroupConfig.groupUsers.includes(player)) {
log.info(`Admin ${player} appear`);
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
const playerInfo = playerDetector.getPlayerPos(player);
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;
log.info(
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;
log.warn(
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);
watchPlayersInfo.push({ name: player, hasNoticeTimes: 0 });
gWatchPlayersInfo = [
...gWatchPlayersInfo,
{ name: player, hasNoticeTimes: 0 },
];
}
inRangePlayers = players;
gInRangePlayers = players;
releaser.release();
os.sleep(config.detectInterval);
}
}
function keyboardLoop() {
while (true) {
const [eventType, key] = os.pullEvent("key");
if (eventType === "key" && key === keys.c) {
log.info("Launching Access Control TUI...");
while (gIsRunning) {
const event = pullEventAs(KeyEvent, "key");
if (event === undefined) continue;
if (event.key === keys.c) {
logger.info("Launching Access Control TUI...");
try {
logger.setInTerminal(false);
launchAccessControlTUI();
log.info("TUI closed, resuming normal operation");
logger.info("TUI closed, resuming normal operation");
} catch (error) {
log.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
} finally {
logger.setInTerminal(true);
reloadConfig();
}
} else if (event.key === keys.r) {
reloadConfig();
}
// else if (event.key === keys.q) {
// gIsRunning = false;
// }
}
}
function cliLoop() {
let printTargetPlayer: string | undefined;
const cli = createAccessControlCli({
configFilepath: configFilepath,
reloadConfig: () => reloadConfig(),
logger: logger,
print: (msg) =>
chatManager.sendMessage({
message: msg,
targetPlayer: printTargetPlayer,
prefix: "Access Control System",
brackets: "[]",
utf8Support: true,
}),
});
while (gIsRunning) {
const result = chatManager.getReceivedMessage();
if (result.isErr()) {
sleep(0.5);
continue;
}
logger.debug(`Received message: ${result.value.message}`);
const ev = result.value;
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[]) {
log.info("Starting access control system, get args: " + args.join(", "));
logger.info("Starting access control system, get args: " + args.join(", "));
if (args.length == 1) {
if (args[0] == "start") {
// 创建CLI处理器
const cli = createAccessControlCLI(
config,
configFilepath,
log,
chatBox,
groupNames,
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") {
log.info("Launching Access Control TUI...");
logger.info("Launching Access Control TUI...");
logger.setInTerminal(false);
try {
launchAccessControlTUI();
} catch (error) {
log.error(`TUI error: ${textutils.serialise(error as object)}`);
logger.error(`TUI error: ${textutils.serialise(error as object)}`);
}
return;
}
@@ -240,7 +440,7 @@ function main(args: string[]) {
try {
main(args);
} catch (error: unknown) {
log.error(textutils.serialise(error as object));
logger.error(textutils.serialise(error as object));
} finally {
log.close();
logger.close();
}

View File

@@ -3,6 +3,7 @@
* A text-based user interface for configuring access control settings
*/
import { context } from "@/lib/ccTUI/context";
import {
createSignal,
createStore,
@@ -14,6 +15,9 @@ import {
render,
Show,
For,
Switch,
Match,
ScrollContainer,
} from "../lib/ccTUI";
import {
AccessConfig,
@@ -43,8 +47,11 @@ interface ErrorState {
* Main TUI Application Component
*/
const AccessControlTUI = () => {
// Load configuration on initialization
const configFilepath = `${shell.dir()}/access.config.json`;
const loadedConfig = loadConfig(configFilepath)!;
// Configuration state
const [config, setConfig] = createStore<AccessConfig>({} as AccessConfig);
const [config, setConfig] = createStore<AccessConfig>(loadedConfig);
// UI state
const [currentTab, setCurrentTab] = createSignal<TabIndex>(TABS.BASIC);
@@ -57,19 +64,8 @@ const AccessControlTUI = () => {
// New user input for group management
const [newUserName, setNewUserName] = createSignal("");
// Load configuration on initialization
const configFilepath = `${shell.dir()}/access.config.json`;
const loadedConfig = loadConfig(configFilepath);
setConfig(() => loadedConfig);
// Tab navigation functions
const tabNames = [
"Basic",
"Groups",
"Welcome Toast",
"Warn Toast",
"Notice Toast",
];
const tabNames = ["Basic", "Groups", "Welcome", "Warn", "Notice"];
const showError = (message: string) => {
setErrorState("show", true);
@@ -168,6 +164,9 @@ const AccessControlTUI = () => {
}
// Save configuration
context.logger?.debug(
`Configuration : ${textutils.serialise(currentConfig, { allow_repetitions: true })}`,
);
saveConfig(currentConfig, configFilepath);
showError("Configuration saved successfully!");
} catch (error) {
@@ -244,55 +243,95 @@ const AccessControlTUI = () => {
/**
* Basic Configuration Tab
*/
const [getDetectInterval, setDetectInterval] = createSignal(
config().detectInterval.toString(),
);
const [getWatchInterval, setWatchInterval] = createSignal(
config().watchInterval.toString(),
);
const [getNoticeTimes, setNoticeTimes] = createSignal(
config().noticeTimes.toString(),
);
const [getDetectRange, setDetectRange] = createSignal(
config().detectRange.toString(),
);
const BasicTab = () => {
return div(
{ class: "flex flex-col" },
label({}, "Detect Interval (ms):"),
input({
type: "text",
value: () => config().detectInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectInterval", num);
},
}),
label({}, "Watch Interval (ms):"),
input({
type: "text",
value: () => config().watchInterval?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("watchInterval", num);
},
}),
label({}, "Notice Times:"),
input({
type: "text",
value: () => config().noticeTimes?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("noticeTimes", num);
},
}),
label({}, "Detect Range:"),
input({
type: "text",
value: () => config().detectRange?.toString() ?? "",
onInput: (value) => {
const num = validateNumber(value);
if (num !== null) setConfig("detectRange", num);
},
}),
label({}, "Is Warn:"),
input({
type: "checkbox",
checked: () => config().isWarn ?? false,
onChange: (checked) => setConfig("isWarn", checked),
}),
div(
{ class: "flex flex-row" },
label({}, "Detect Interval (ms):"),
input({
type: "text",
value: () => getDetectInterval(),
onInput: (value) => setDetectInterval(value),
onFocusChanged: () => {
const num = validateNumber(getDetectInterval());
if (num !== null) setConfig("detectInterval", num);
else setDetectInterval(config().detectInterval.toString());
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Watch Interval (ms):"),
input({
type: "text",
value: () => getWatchInterval(),
onInput: (value) => setWatchInterval(value),
onFocusChanged: () => {
const num = validateNumber(getWatchInterval());
if (num !== null) setConfig("watchInterval", num);
else setWatchInterval(config().watchInterval.toString());
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Notice Times:"),
input({
type: "text",
value: () => getNoticeTimes(),
onInput: (value) => setNoticeTimes(value),
onFocusChanged: () => {
const num = validateNumber(getNoticeTimes());
if (num !== null) setConfig("noticeTimes", num);
else setNoticeTimes(config().noticeTimes.toString());
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Detect Range:"),
input({
type: "text",
value: () => getDetectRange(),
onInput: (value) => setDetectRange(value),
onFocusChanged: () => {
const num = validateNumber(getDetectRange());
if (num !== null) setConfig("detectRange", num);
else setDetectRange(config().detectRange.toString());
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Warn:"),
input({
type: "checkbox",
checked: () => config().isWarn ?? false,
onChange: (checked) => setConfig("isWarn", checked),
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Welcome:"),
input({
type: "checkbox",
checked: () => config().isWelcome ?? false,
onChange: (checked) => setConfig("isWelcome", checked),
}),
),
);
};
@@ -301,7 +340,6 @@ const AccessControlTUI = () => {
*/
const GroupsTab = () => {
const groups = getAllGroups();
const selectedGroup = getSelectedGroup();
return div(
{ class: "flex flex-row" },
@@ -309,7 +347,7 @@ const AccessControlTUI = () => {
div(
{ class: "flex flex-col" },
label({}, "Groups:"),
For({ each: () => groups }, (group, index) =>
For({ each: () => groups, class: "flex flex-col" }, (group, index) =>
button(
{
class:
@@ -324,59 +362,92 @@ const AccessControlTUI = () => {
// Right side - Group details
div(
{ class: "flex flex-col ml-2" },
label({}, () => `Group: ${selectedGroup.groupName}`),
label({}, () => `Group: ${getSelectedGroup().groupName}`),
label({}, "Is Allowed:"),
input({
type: "checkbox",
checked: () => selectedGroup.isAllowed,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isAllowed: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isAllowed: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
label({}, "Is Notice:"),
input({
type: "checkbox",
checked: () => selectedGroup.isNotice,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isNotice: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isNotice: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
div(
{ class: "flex flex-row" },
label({}, "Is Welcome:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isWelcome,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isWelcome: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isWelcome: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Allowed:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isAllowed,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isAllowed: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isAllowed: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
div(
{ class: "flex flex-row" },
label({}, "Is Notice:"),
input({
type: "checkbox",
checked: () => getSelectedGroup().isNotice,
onChange: (checked) => {
const groupIndex = selectedGroupIndex();
if (groupIndex === 0) {
const currentAdmin = config().adminGroupConfig;
setConfig("adminGroupConfig", {
...currentAdmin,
isNotice: checked,
});
} else {
const actualIndex = groupIndex - 1;
const currentGroups = config().usersGroups;
const currentGroup = currentGroups[actualIndex];
const newGroups = [...currentGroups];
newGroups[actualIndex] = {
...currentGroup,
isNotice: checked,
};
setConfig("usersGroups", newGroups);
}
},
}),
),
label({}, "Group Users:"),
// User management
@@ -392,18 +463,23 @@ const AccessControlTUI = () => {
),
// Users list
For({ each: () => selectedGroup.groupUsers ?? [] }, (user) =>
div(
{ class: "flex flex-row items-center" },
label({}, user),
button(
{
class: "ml-1 bg-red text-white",
onClick: () => removeUser(user),
},
"Remove",
For(
{
class: "flex flex-col",
each: () => getSelectedGroup().groupUsers ?? [],
},
(user) =>
div(
{ class: "flex flex-row items-center" },
label({}, user),
button(
{
class: "ml-1 bg-red text-white",
onClick: () => removeUser(user),
},
"X",
),
),
),
),
),
);
@@ -417,83 +493,147 @@ const AccessControlTUI = () => {
) => {
return () => {
const toastConfig = config()[toastType];
const [getTempToastConfig, setTempToastConfig] = createSignal({
title: textutils.serialiseJSON(toastConfig.title),
msg: textutils.serialiseJSON(toastConfig.msg),
prefix: toastConfig.prefix ?? "",
brackets: toastConfig.brackets ?? "",
bracketColor: toastConfig.bracketColor ?? "",
});
return div(
{ class: "flex flex-col" },
{ class: "flex flex-col w-full" },
label({}, "Title (JSON):"),
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.title) ?? "",
onInput: (value) => {
value: () => getTempToastConfig().title,
onInput: (value) =>
setTempToastConfig({
...getTempToastConfig(),
title: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try {
const parsed = textutils.unserialiseJSON(value);
if (parsed != undefined && typeof parsed === "object") {
const currentConfig = config();
const currentToast = currentConfig[toastType];
const parsed = textutils.unserialiseJSON(
getTempToastConfig().title,
) as MinecraftTextComponent;
if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, {
...currentToast,
title: parsed as MinecraftTextComponent,
...currentToastConfig,
title: parsed,
});
}
} else throw new Error("Invalid JSON");
} catch {
// Invalid JSON, ignore
setTempToastConfig({
...getTempToastConfig(),
title: textutils.serialiseJSON(currentToastConfig.title),
});
}
},
}),
label({}, "Message (JSON):"),
input({
class: "w-full",
type: "text",
value: () => textutils.serialiseJSON(toastConfig?.msg) ?? "",
onInput: (value) => {
value: () => getTempToastConfig().msg,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), msg: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
try {
const parsed = textutils.unserialiseJSON(value);
if (parsed != undefined && typeof parsed === "object") {
const currentConfig = config();
const currentToast = currentConfig[toastType];
const parsed = textutils.unserialiseJSON(
getTempToastConfig().msg,
) as MinecraftTextComponent;
if (
typeof parsed === "object" &&
parsed.text !== undefined &&
parsed.color !== undefined
) {
setConfig(toastType, {
...currentToast,
msg: parsed as MinecraftTextComponent,
...currentToastConfig,
msg: parsed,
});
}
} else throw new Error("Invalid JSON");
} catch {
setTempToastConfig({
...getTempToastConfig(),
msg: textutils.serialiseJSON(currentToastConfig.msg),
});
// Invalid JSON, ignore
}
},
}),
label({}, "Prefix:"),
input({
type: "text",
value: () => toastConfig?.prefix ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, prefix: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Prefix:"),
input({
type: "text",
value: () => {
const str = textutils.serialiseJSON(getTempToastConfig().prefix, {
unicode_strings: true,
});
return str.substring(1, str.length - 1);
},
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), prefix: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
prefix: getTempToastConfig().prefix,
});
},
}),
),
label({}, "Brackets:"),
input({
type: "text",
value: () => toastConfig?.brackets ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, brackets: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Brackets:"),
input({
type: "text",
value: () => getTempToastConfig().brackets,
onInput: (value) =>
setTempToastConfig({ ...getTempToastConfig(), brackets: value }),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
brackets: getTempToastConfig().brackets,
});
},
}),
),
label({}, "Bracket Color:"),
input({
type: "text",
value: () => toastConfig?.bracketColor ?? "",
onInput: (value) => {
const currentConfig = config();
const currentToast = currentConfig[toastType];
setConfig(toastType, { ...currentToast, bracketColor: value });
},
}),
div(
{ class: "flex flex-row" },
label({}, "Bracket Color:"),
input({
type: "text",
value: () => getTempToastConfig().bracketColor,
onInput: (value) =>
setTempToastConfig({
...getTempToastConfig(),
bracketColor: value,
}),
onFocusChanged: () => {
const currentToastConfig = config()[toastType];
setConfig(toastType, {
...currentToastConfig,
bracketColor: getTempToastConfig().bracketColor,
});
},
}),
),
);
};
};
@@ -510,20 +650,17 @@ const AccessControlTUI = () => {
return Show(
{ when: () => errorState().show },
div(
{
class:
"fixed top-1/4 left-1/4 right-1/4 bottom-1/4 bg-red text-white border",
},
div(
{ class: "flex flex-col p-2" },
label({}, () => errorState().message),
button(
{
class: "mt-2 bg-white text-black",
onClick: hideError,
},
"OK",
),
{ class: "flex flex-col" },
label(
{ class: "w-50 text-white bg-red", wordWrap: true },
() => errorState().message,
),
button(
{
class: "bg-white text-black",
onClick: hideError,
},
"OK",
),
),
);
@@ -533,13 +670,20 @@ const AccessControlTUI = () => {
* Tab Content Renderer
*/
const TabContent = () => {
const tab = currentTab();
if (tab === TABS.BASIC) return BasicTab();
if (tab === TABS.GROUPS) return GroupsTab();
if (tab === TABS.WELCOME_TOAST) return WelcomeToastTab();
if (tab === TABS.WARN_TOAST) return WarnToastTab();
if (tab === TABS.NOTICE_TOAST) return NoticeToastTab();
return BasicTab(); // fallback
return Switch(
{ fallback: BasicTab() },
Match({ when: () => currentTab() === TABS.BASIC }, BasicTab()),
Match({ when: () => currentTab() === TABS.GROUPS }, GroupsTab()),
Match(
{ when: () => currentTab() === TABS.WELCOME_TOAST },
WelcomeToastTab(),
),
Match({ when: () => currentTab() === TABS.WARN_TOAST }, WarnToastTab()),
Match(
{ when: () => currentTab() === TABS.NOTICE_TOAST },
NoticeToastTab(),
),
);
};
/**
@@ -548,7 +692,10 @@ const AccessControlTUI = () => {
return div(
{ class: "flex flex-col h-full" },
// Header
h1("Access Control Configuration"),
div(
{ class: "flex flex-row justify-center" },
h1("Access Control Configuration"),
),
// Tab bar
div(
@@ -565,27 +712,36 @@ const AccessControlTUI = () => {
),
// Content area
div({ class: "flex-1 p-2" }, TabContent()),
Show(
{ when: () => !errorState().show },
div(
{ class: "flex flex-col" },
ScrollContainer(
{ class: "flex-1 p-2", width: 50, showScrollbar: true },
TabContent(),
),
// Action buttons
div(
{ class: "flex flex-row justify-center p-2" },
button(
{
class: "bg-green text-white mr-2",
onClick: handleSave,
},
"Save",
),
button(
{
class: "bg-gray text-white",
onClick: () => {
// Close TUI - this will be handled by the application framework
error("TUI_CLOSE");
},
},
"Close",
// Action buttons
div(
{ class: "flex flex-row justify-center p-2" },
button(
{
class: "bg-green text-white mr-2",
onClick: handleSave,
},
"Save",
),
button(
{
class: "bg-gray text-white",
onClick: () => {
// Close TUI - this will be handled by the application framework
error("TUI_CLOSE");
},
},
"Close",
),
),
),
),

View File

@@ -1,120 +1,215 @@
import { CraftManager } from "@/lib/CraftManager";
import * as peripheralManager from "../lib/PeripheralManager";
import { CCLog } from "@/lib/ccLog";
import {
CraftManager,
CraftRecipe,
CreatePackageTag,
} from "@/lib/CraftManager";
import { CCLog, LogLevel } from "@/lib/ccLog";
import { Queue } from "@/lib/datatype/Queue";
const log = new CCLog("autocraft.log");
const logger = new CCLog("autocraft.log", { outputMinLevel: LogLevel.Info });
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",
};
const packsInventory = peripheral.wrap(
peripheralsNames.packsInventory,
) as InventoryPeripheral;
const itemsInventory = peripheral.wrap(
peripheralsNames.itemsInventory,
) as InventoryPeripheral;
const packageExtractor = peripheral.wrap(
peripheralsNames.packageExtractor,
) as InventoryPeripheral;
const blockReader = peripheral.wrap(
peripheralsNames.blockReader,
) as BlockReaderPeripheral;
const wiredModem = peripheral.wrap(
peripheralsNames.wiredModem,
) as WiredModemPeripheral;
const turtleLocalName = wiredModem.getNameLocal();
enum State {
IDLE,
READ_RECIPE,
CRAFT_OUTPUT,
}
function main() {
const packagesContainer = peripheralManager.findByNameRequired(
"inventory",
peripheralsRelativeSides.packagesContainer,
);
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);
}
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");
logger.info("AutoCraft init finished...");
while (true) {
if (!hasPackage) os.pullEvent("redstone");
hasPackage = redstone.getInput("front");
if (!hasPackage) {
continue;
// 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;
}
}
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;
// State logic
switch (currentState) {
case State.IDLE: {
if (!hasPackage) os.pullEvent("redstone");
hasPackage = redstone.getInput(peripheralsNames.redstone);
break;
}
// Extract package
// log.info(`Get recipe ${textutils.serialise(recipe)}`);
packageExtractor.pullItems(turtleLocalName, 1);
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;
// Pull and craft multi recipe
for (const recipe of packageRecipes) {
let craftOutputItem: BlockItemDetailData | undefined = undefined;
let restCraftCnt = recipe.Count;
let maxSignleCraftCnt = restCraftCnt;
let craftItemDetail: ItemDetail | undefined = undefined;
do {
// Clear workbench
craftManager.pushAll(itemsContainer);
craftManager.clearTurtle();
logger.info(`Pull items according to a recipe`);
const craftCnt = craftManager
.pullItemsWithRecipe(recipe, maxSignleCraftCnt)
.unwrapOrElse((error) => {
logger.error(error.message);
return 0;
});
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`);
if (craftCnt < maxSignleCraftCnt) maxSignleCraftCnt = craftCnt;
const craftRet = craftManager.craft(maxSignleCraftCnt);
craftItemDetail ??= craftRet;
logger.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`);
logger.warn(
`Only craft ${recipe.Count - restCraftCnt}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
} else {
log.info(`Finish craft ${recipe.Count}x ${craftOutputItem?.id}`);
logger.info(
`Finish craft ${recipe.Count}x ${craftItemDetail?.name ?? "UnknownItem"}`,
);
}
craftManager.pushAll(itemsContainer);
// 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();
} 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
View 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
View 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);
}
}

View File

@@ -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
View 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
View 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
View 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
View 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
View 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
View 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[];
}

View File

@@ -1,4 +1,4 @@
enum LogLevel {
export enum LogLevel {
Debug = 0,
Info = 1,
Warn = 2,
@@ -12,20 +12,29 @@ export const HOUR = 60 * MINUTE;
export const DAY = 24 * HOUR;
export const WEEK = 7 * DAY;
export interface CCLogInitConfig {
printTerminal?: boolean;
logInterval?: number;
outputMinLevel?: LogLevel;
}
export class CCLog {
private fp: LuaFile | undefined;
private filename?: string;
private interval: number;
private logInterval: number;
private printTerminal: boolean;
private outputMinLevel: LogLevel;
private startTime: number;
private currentTimePeriod: string;
private inTerm: boolean;
constructor(filename?: string, inTerm = true, interval: number = DAY) {
constructor(filename?: string, config?: CCLogInitConfig) {
term.clear();
term.setCursorPos(1, 1);
this.interval = interval;
this.inTerm = inTerm;
this.logInterval = config?.logInterval ?? DAY;
this.printTerminal = config?.printTerminal ?? true;
this.outputMinLevel = config?.outputMinLevel ?? LogLevel.Debug;
this.startTime = os.time(os.date("*t"));
this.currentTimePeriod = this.getTimePeriodString(this.startTime);
@@ -49,31 +58,30 @@ export class CCLog {
* For SECOND interval: YYYY-MM-DD-HH-MM-SS
*/
private getTimePeriodString(time: number): string {
// Calculate which time period this timestamp falls into
const periodStart = Math.floor(time / this.interval) * this.interval;
const periodDate = os.date("*t", periodStart);
const periodStart = Math.floor(time / this.logInterval) * this.logInterval;
const d = os.date("*t", periodStart);
if (this.interval >= DAY) {
return `${periodDate.year}-${String(periodDate.month).padStart(2, "0")}-${String(periodDate.day).padStart(2, "0")}`;
} else {
return `[${periodDate.year}-${String(periodDate.month).padStart(2, "0")}-${String(periodDate.day).padStart(2, "0")}] - [${String(periodDate.hour).padStart(2, "0")}-${String(periodDate.min).padStart(2, "0")}-${String(periodDate.sec).padStart(2, "0")}]`;
if (this.logInterval >= DAY) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}`;
} else if (this.logInterval >= HOUR) {
return `${d.year}-${string.format("%02d", d.month)}-${string.format("%02d", d.day)}_${string.format("%02d", d.hour)}`;
} else if (this.logInterval >= 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 {
// Extract file extension if present
const fileNameSubStrings = baseFilename.split(".");
let filenameWithoutExt: string;
let extension = "";
const scriptDir = shell.dir() ?? "";
if (fileNameSubStrings.length > 1) {
filenameWithoutExt = fileNameSubStrings[0];
extension = fileNameSubStrings[1];
} else {
filenameWithoutExt = baseFilename;
}
const [filenameWithoutExt, extension] = baseFilename.includes(".")
? baseFilename.split(".")
: [baseFilename, "log"];
return `${shell.dir()}/${filenameWithoutExt}[${timePeriod}].${extension}`;
return fs.combine(
scriptDir,
`${filenameWithoutExt}_${timePeriod}.${extension}`,
);
}
private checkAndRotateLogFile() {
@@ -119,7 +127,7 @@ export class CCLog {
// Check if we need to rotate the log file
this.checkAndRotateLogFile();
if (this.inTerm) {
if (this.printTerminal) {
let originalColor: Color = 0;
if (color != undefined) {
originalColor = term.getTextColor();
@@ -139,19 +147,31 @@ export class CCLog {
}
public debug(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Debug), colors.gray);
if (LogLevel.Debug >= this.outputMinLevel)
this.writeLine(this.getFormatMsg(msg, LogLevel.Debug), colors.gray);
}
public info(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Info), colors.green);
if (LogLevel.Info >= this.outputMinLevel)
this.writeLine(this.getFormatMsg(msg, LogLevel.Info), colors.green);
}
public warn(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Warn), colors.orange);
if (LogLevel.Warn >= this.outputMinLevel)
this.writeLine(this.getFormatMsg(msg, LogLevel.Warn), colors.orange);
}
public error(msg: string) {
this.writeLine(this.getFormatMsg(msg, LogLevel.Error), colors.red);
if (LogLevel.Error >= this.outputMinLevel)
this.writeLine(this.getFormatMsg(msg, LogLevel.Error), colors.red);
}
public setInTerminal(value: boolean) {
this.printTerminal = value;
}
public setLogLevel(value: LogLevel) {
this.outputMinLevel = value;
}
public close() {

View File

@@ -3,7 +3,9 @@
* Represents a node in the UI tree
*/
import { Accessor } from "./reactivity";
import { ButtonProps, DivProps, InputProps, LabelProps } from "./components";
import { Accessor, Setter } from "./reactivity";
import { ScrollContainerProps } from "./scrollContainer";
/**
* Layout properties for flexbox layout
@@ -25,6 +27,32 @@ export interface StyleProps {
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;
}
/**
@@ -43,23 +71,37 @@ export interface ComputedLayout {
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);
}
/**
* UIObject node type
*/
export type UIObjectType =
| "div"
| "label"
| "button"
| "input"
export type UIObjectType =
| "div"
| "label"
| "button"
| "input"
| "form"
| "h1"
| "h2"
| "h3"
| "for"
| "show"
| "fragment";
| "switch"
| "match"
| "fragment"
| "scroll-container";
export type UIObjectProps =
| DivProps
| LabelProps
| InputProps
| ButtonProps
| ScrollProps
| ScrollContainerProps;
/**
* UIObject represents a node in the UI tree
@@ -68,44 +110,47 @@ export type UIObjectType =
export class UIObject {
/** Type of the UI object */
type: UIObjectType;
/** Props passed to the component */
props: Record<string, unknown>;
props: UIObjectProps;
/** Children UI objects */
children: UIObject[];
/** Parent UI object */
parent?: UIObject;
/** Computed layout after flexbox calculation */
layout?: ComputedLayout;
/** Layout properties parsed from class string */
layoutProps: LayoutProps;
/** Style properties parsed from class string */
styleProps: StyleProps;
/** Whether this component is currently mounted */
mounted: boolean;
/** Cleanup functions to call when unmounting */
cleanupFns: (() => void)[];
/** For text nodes - the text content (can be reactive) */
textContent?: string | Accessor<string>;
/** Event handlers */
handlers: Record<string, ((...args: unknown[]) => void) | undefined>;
/** For input text components - cursor position */
cursorPos?: number;
/** For scroll containers - scroll state */
scrollProps?: ScrollProps;
constructor(
type: UIObjectType,
props: Record<string, unknown> = {},
children: UIObject[] = []
props: UIObjectProps = {},
children: UIObject[] = [],
) {
this.type = type;
this.props = props;
@@ -115,45 +160,60 @@ export class UIObject {
this.mounted = false;
this.cleanupFns = [];
this.handlers = {};
// Parse layout and styles from class prop
this.parseClassNames();
// Extract event handlers
this.extractHandlers();
// Initialize cursor position for text inputs
if (type === "input" && props.type !== "checkbox") {
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,
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];
}
@@ -161,11 +221,11 @@ export class UIObject {
* Parse CSS-like class string into layout and style properties
*/
private parseClassNames(): void {
const className = this.props.class as string | undefined;
const className = this.props.class;
if (className === undefined) return;
const classes = className.split(" ").filter(c => c.length > 0);
const classes = className.split(" ").filter((c) => c.length > 0);
for (const cls of classes) {
// Flex direction
if (cls === "flex-row") {
@@ -173,7 +233,7 @@ export class UIObject {
} else if (cls === "flex-col") {
this.layoutProps.flexDirection = "column";
}
// Justify content
else if (cls === "justify-start") {
this.layoutProps.justifyContent = "start";
@@ -184,7 +244,7 @@ export class UIObject {
} else if (cls === "justify-between") {
this.layoutProps.justifyContent = "between";
}
// Align items
else if (cls === "items-start") {
this.layoutProps.alignItems = "start";
@@ -193,7 +253,7 @@ export class UIObject {
} 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
@@ -202,7 +262,7 @@ export class UIObject {
this.styleProps.textColor = color;
}
}
// Background color (bg-<color>)
else if (cls.startsWith("bg-")) {
const colorName = cls.substring(3); // Remove "bg-" prefix
@@ -211,8 +271,38 @@ export class UIObject {
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;
}
}
}
}
// Set defaults
if (this.type === "div") {
this.layoutProps.flexDirection ??= "row";
@@ -226,7 +316,11 @@ export class UIObject {
*/
private extractHandlers(): void {
for (const [key, value] of pairs(this.props)) {
if (typeof key === "string" && key.startsWith("on") && typeof value === "function") {
if (
typeof key === "string" &&
key.startsWith("on") &&
typeof value === "function"
) {
this.handlers[key] = value as (...args: unknown[]) => void;
}
}
@@ -257,7 +351,7 @@ export class UIObject {
mount(): void {
if (this.mounted) return;
this.mounted = true;
// Mount all children
for (const child of this.children) {
child.mount();
@@ -270,12 +364,12 @@ export class UIObject {
unmount(): void {
if (!this.mounted) return;
this.mounted = false;
// Unmount all children first
for (const child of this.children) {
child.unmount();
}
// Run cleanup functions
for (const cleanup of this.cleanupFns) {
try {
@@ -293,6 +387,75 @@ export class UIObject {
onCleanup(fn: () => void): void {
this.cleanupFns.push(fn);
}
/**
* 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;
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),
);
}
}
/**

View File

@@ -5,7 +5,10 @@
import { UIObject } from "./UIObject";
import { calculateLayout } from "./layout";
import { render as renderTree, clearScreen } from "./renderer";
import { CCLog } from "../ccLog";
import { CCLog, DAY, LogLevel } from "../ccLog";
import { setLogger } from "./context";
import { InputProps } from "./components";
import { Setter } from "./reactivity";
/**
* Main application class
@@ -27,7 +30,12 @@ export class Application {
const [width, height] = term.getSize();
this.termWidth = width;
this.termHeight = height;
this.logger = new CCLog("tui_debug.log", false);
this.logger = new CCLog("tui_debug.log", {
printTerminal: false,
logInterval: DAY,
outputMinLevel: LogLevel.Info,
});
setLogger(this.logger);
this.logger.debug("Application constructed.");
}
@@ -138,12 +146,12 @@ export class Application {
if (currentTime - this.lastBlinkTime >= this.BLINK_INTERVAL) {
this.lastBlinkTime = currentTime;
this.cursorBlinkState = !this.cursorBlinkState;
// Only trigger render if we have a focused text input
if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input" &&
this.focusedNode.props.type !== "checkbox"
(this.focusedNode.props as InputProps).type !== "checkbox"
) {
this.needsRender = true;
}
@@ -176,6 +184,20 @@ export class Application {
eventData[1] as number,
eventData[2] as number,
);
} else if (eventType === "mouse_scroll") {
this.logger.debug(
string.format(
"eventLoop: Mouse scroll detected at (%d, %d) direction %d",
eventData[1],
eventData[2],
eventData[0],
),
);
this.handleMouseScroll(
eventData[0] as number,
eventData[1] as number,
eventData[2] as number,
);
}
}
}
@@ -197,11 +219,13 @@ export class Application {
this.needsRender = true;
}
} else if (this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined;
const type = (this.focusedNode.props as InputProps).type as
| string
| undefined;
if (type === "checkbox") {
// Toggle checkbox
const onChangeProp = this.focusedNode.props.onChange;
const checkedProp = this.focusedNode.props.checked;
const onChangeProp = (this.focusedNode.props as InputProps).onChange;
const checkedProp = (this.focusedNode.props as InputProps).checked;
if (
typeof onChangeProp === "function" &&
@@ -213,9 +237,14 @@ export class Application {
}
}
}
} else if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
} else if (
this.focusedNode !== undefined &&
this.focusedNode.type === "input"
) {
// Handle text input key events
const type = this.focusedNode.props.type as string | undefined;
const type = (this.focusedNode.props as InputProps).type as
| string
| undefined;
if (type !== "checkbox") {
this.handleTextInputKey(key);
}
@@ -228,13 +257,10 @@ export class Application {
private handleTextInputKey(key: number): void {
if (this.focusedNode === undefined) return;
const valueProp = this.focusedNode.props.value;
const onInputProp = this.focusedNode.props.onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
if (
typeof valueProp !== "function" ||
typeof onInputProp !== "function"
) {
if (typeof valueProp !== "function" || typeof onInputProp !== "function") {
return;
}
@@ -276,11 +302,11 @@ export class Application {
*/
private handleCharEvent(char: string): void {
if (this.focusedNode !== undefined && this.focusedNode.type === "input") {
const type = this.focusedNode.props.type as string | undefined;
const type = (this.focusedNode.props as InputProps).type;
if (type !== "checkbox") {
// Insert character at cursor position
const onInputProp = this.focusedNode.props.onInput;
const valueProp = this.focusedNode.props.value;
const onInputProp = (this.focusedNode.props as InputProps).onInput;
const valueProp = (this.focusedNode.props as InputProps).value;
if (
typeof onInputProp === "function" &&
@@ -315,11 +341,26 @@ export class Application {
string.format("handleMouseClick: Found node of type %s.", clicked.type),
);
// Set focus
if (
this.focusedNode !== undefined &&
typeof this.focusedNode.props.onFocusChanged === "function"
) {
const onFocusChanged = this.focusedNode.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(false);
}
this.focusedNode = clicked;
if (typeof clicked.props.onFocusChanged === "function") {
const onFocusChanged = clicked.props.onFocusChanged as Setter<boolean>;
onFocusChanged(true);
}
// Initialize cursor position for text inputs on focus
if (clicked.type === "input" && clicked.props.type !== "checkbox") {
const valueProp = clicked.props.value;
if (
clicked.type === "input" &&
(clicked.props as InputProps).type !== "checkbox"
) {
const valueProp = (clicked.props as InputProps).value;
if (typeof valueProp === "function") {
const currentValue = (valueProp as () => string)();
clicked.cursorPos = currentValue.length;
@@ -338,10 +379,10 @@ export class Application {
this.needsRender = true;
}
} else if (clicked.type === "input") {
const type = clicked.props.type as string | undefined;
const type = (clicked.props as InputProps).type as string | undefined;
if (type === "checkbox") {
const onChangeProp = clicked.props.onChange;
const checkedProp = clicked.props.checked;
const onChangeProp = (clicked.props as InputProps).onChange;
const checkedProp = (clicked.props as InputProps).checked;
if (
typeof onChangeProp === "function" &&
@@ -408,6 +449,14 @@ export class Application {
const interactive = this.collectInteractive(this.root);
if (
this.focusedNode !== undefined &&
typeof this.focusedNode.props.onFocusChanged === "function"
) {
const onFocusChanged = this.focusedNode.props
.onFocusChanged as Setter<boolean>;
onFocusChanged(false);
}
if (interactive.length === 0) {
this.focusedNode = undefined;
return;
@@ -422,6 +471,72 @@ export class Application {
}
}
/**
* Find the scrollable UI node at a specific screen position
*/
private findScrollableNodeAt(
node: UIObject,
x: number,
y: number,
): UIObject | undefined {
// Check children first (depth-first)
for (const child of node.children) {
const found = this.findScrollableNodeAt(child, x, y);
if (found !== undefined) {
return found;
}
}
// Check this node
if (node.layout !== undefined) {
const { x: nx, y: ny, width, height } = node.layout;
const hit = x >= nx && x < nx + width && y >= ny && y < ny + height;
if (hit) {
this.logger.debug(
string.format(
"findNodeAt: Hit test TRUE for %s at (%d, %d)",
node.type,
nx,
ny,
),
);
// Only return scrollable elements
if (node.type === "scroll-container") {
this.logger.debug("findNodeAt: Node is scrollable, returning.");
return node;
}
}
}
return undefined;
}
/**
* Handle mouse scroll events
*/
private handleMouseScroll(direction: number, x: number, y: number): void {
if (this.root === undefined) return;
// Find which element was scrolled over
const scrollContainer = this.findScrollableNodeAt(this.root, x, y);
if (scrollContainer?.scrollProps) {
// Scroll by 1 line per wheel step
const scrollAmount = direction * 1;
scrollContainer.scrollBy(0, scrollAmount);
this.needsRender = true;
this.logger.debug(
string.format(
"handleMouseScroll: Scrolled container by %d, new position: (%d, %d)",
scrollAmount,
scrollContainer.scrollProps.scrollX,
scrollContainer.scrollProps.scrollY,
),
);
}
}
/**
* Collect all interactive elements in the tree
*/

View File

@@ -4,17 +4,23 @@
*/
import { UIObject, BaseProps, createTextNode } from "./UIObject";
import { Accessor, Setter, Signal } from "./reactivity";
import { Accessor, createMemo, Setter, Signal } from "./reactivity";
import { For } from "./controlFlow";
import { context } from "./context";
import { concatSentence } from "../common";
/**
* Props for div component
*/
export type DivProps = BaseProps & Record<string, unknown>;
export type DivProps = BaseProps;
/**
* Props for label component
*/
export type LabelProps = BaseProps & Record<string, unknown>;
export type LabelProps = BaseProps & {
/** Whether to automatically wrap long text. Defaults to false. */
wordWrap?: boolean;
};
/**
* Props for button component
@@ -22,7 +28,7 @@ export type LabelProps = BaseProps & Record<string, unknown>;
export type ButtonProps = BaseProps & {
/** Click handler */
onClick?: () => void;
} & Record<string, unknown>;
};
/**
* Props for input component
@@ -40,7 +46,7 @@ export type InputProps = BaseProps & {
onChange?: Setter<boolean> | ((checked: boolean) => void);
/** Placeholder text */
placeholder?: string;
} & Record<string, unknown>;
};
/**
* Props for form component
@@ -48,7 +54,7 @@ export type InputProps = BaseProps & {
export type FormProps = BaseProps & {
/** Submit handler */
onSubmit?: () => void;
} & Record<string, unknown>;
};
/**
* Generic container component for layout
@@ -95,10 +101,84 @@ export function div(
* label({}, () => `Hello, ${name()}!`)
* ```
*/
/**
* Splits a string by whitespace, keeping the whitespace as separate elements.
* This is a TSTL-compatible replacement for `text.split(/(\s+)/)`.
* @param text The text to split.
* @returns An array of words and whitespace.
*/
function splitByWhitespace(text: string): string[] {
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;
}
}
if (currentWord.length > 0) {
parts.push(currentWord);
}
if (currentWhitespace.length > 0) {
parts.push(currentWhitespace);
}
return parts;
}
export function label(
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`,
};
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),
);
return forNode;
}
}
const textNode = createTextNode(text);
const node = new UIObject("label", props, [textNode]);
textNode.parent = node;
@@ -176,11 +256,11 @@ export function input(props: InputProps): UIObject {
const normalizedProps = { ...props };
if (Array.isArray(normalizedProps.value)) {
normalizedProps.value = (normalizedProps.value)[0];
normalizedProps.value = normalizedProps.value[0];
}
if (Array.isArray(normalizedProps.checked)) {
normalizedProps.checked = (normalizedProps.checked)[0];
normalizedProps.checked = normalizedProps.checked[0];
}
return new UIObject("input", normalizedProps, []);

23
src/lib/ccTUI/context.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Global context for the TUI application.
* This is a simple way to provide global instances like a logger
* to all components without prop drilling.
*/
import type { CCLog } from "../ccLog";
/**
* 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,
};
/**
* Sets the global logger instance.
* @param l The logger instance.
*/
export function setLogger(l: CCLog): void {
context.logger = l;
}

View File

@@ -23,19 +23,35 @@ export type ShowProps = {
fallback?: UIObject;
} & Record<string, unknown>;
/**
* Props for Switch component
*/
export type SwitchProps = {
/** 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>;
} & Record<string, unknown>;
/**
* For component - renders a list of items
* Efficiently updates when the array changes
*
*
* @template T - The type of items in the array
* @param props - Props containing the array accessor
* @param renderFn - Function to render each item
* @returns UIObject representing the list
*
*
* @example
* ```typescript
* const [todos, setTodos] = createStore<Todo[]>([]);
*
*
* For({ each: () => todos },
* (todo, i) => div({ class: "flex flex-row" },
* label({}, () => todo.title),
@@ -46,24 +62,24 @@ export type ShowProps = {
*/
export function For<T>(
props: ForProps<T>,
renderFn: (item: T, index: Accessor<number>) => UIObject
renderFn: (item: T, index: Accessor<number>) => UIObject,
): UIObject {
const container = new UIObject("for", props, []);
// Track rendered items
let renderedItems: UIObject[] = [];
/**
* Update the list when the array changes
*/
const updateList = () => {
const items = props.each();
// Clear old items
renderedItems.forEach(item => item.unmount());
renderedItems.forEach((item) => item.unmount());
container.children = [];
renderedItems = [];
// Render new items
items.forEach((item, index) => {
const indexAccessor = () => index;
@@ -74,26 +90,26 @@ export function For<T>(
rendered.mount();
});
};
// Create effect to watch for changes
createEffect(() => {
updateList();
});
return container;
}
/**
* Show component - conditionally renders content
*
*
* @param props - Props containing condition and optional fallback
* @param child - Content to show when condition is true
* @returns UIObject representing the conditional content
*
*
* @example
* ```typescript
* const [loggedIn, setLoggedIn] = createSignal(false);
*
*
* Show(
* {
* when: loggedIn,
@@ -105,21 +121,21 @@ export function For<T>(
*/
export function Show(props: ShowProps, child: UIObject): UIObject {
const container = new UIObject("show", props, []);
let currentChild: UIObject | undefined = undefined;
/**
* 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);
}
// Mount appropriate child
if (condition) {
currentChild = child;
@@ -129,17 +145,115 @@ export function Show(props: ShowProps, child: UIObject): UIObject {
currentChild = undefined;
return;
}
if (currentChild !== undefined) {
container.appendChild(currentChild);
currentChild.mount();
}
};
// Create effect to watch for condition changes
createEffect(() => {
updateChild();
});
return container;
}
/**
* Switch component - renders the first Match whose condition is truthy
* Similar to a switch statement or if/else if/else chain
*
* @param props - Props containing optional fallback
* @param matches - Array of Match components to evaluate
* @returns UIObject representing the switch statement
*
* @example
* ```typescript
* const [status, setStatus] = createSignal("loading");
*
* Switch(
* { fallback: div({}, "Unknown status") },
* Match({ when: () => status() === "loading" }, div({}, "Loading...")),
* Match({ when: () => status() === "success" }, div({}, "Success!")),
* Match({ when: () => status() === "error" }, div({}, "Error occurred"))
* )
* ```
*/
export function Switch(props: SwitchProps, ...matches: UIObject[]): UIObject {
const container = new UIObject("switch", props, []);
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);
}
// 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];
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();
} else {
currentChild = undefined;
}
};
// Create effect to watch for condition changes
createEffect(() => {
updateChild();
});
return container;
}
/**
* Match component - represents a single case in a Switch
* Should only be used as a child of Switch
*
* @param props - Props containing the condition
* @param child - Content to render when condition is truthy
* @returns UIObject representing this match case
*
* @example
* ```typescript
* const [color, setColor] = createSignal("red");
*
* Match({ when: () => color() === "red" },
* div({ class: "text-red" }, "Stop")
* )
* ```
*/
export function Match(props: MatchProps, child: UIObject): UIObject {
const container = new UIObject("match", props, [child]);
child.parent = container;
return container;
}

View File

@@ -41,7 +41,26 @@ export {
} from "./components";
// Control flow
export { For, Show, type ForProps, type ShowProps } from "./controlFlow";
export {
For,
Show,
Switch,
Match,
type ForProps,
type ShowProps,
type SwitchProps,
type MatchProps,
} from "./controlFlow";
// Scroll container
export {
ScrollContainer,
isScrollContainer,
findScrollContainer,
isPointVisible,
screenToContent,
type ScrollContainerProps,
} from "./scrollContainer";
// Application
export { Application, render } from "./application";
@@ -51,6 +70,7 @@ export {
UIObject,
type LayoutProps,
type StyleProps,
type ScrollProps,
type ComputedLayout,
type BaseProps,
} from "./UIObject";

View File

@@ -3,83 +3,212 @@
* Calculates positions and sizes for UI elements based on flexbox rules
*/
import { InputProps } from "./components";
import { UIObject } from "./UIObject";
/**
* Get the terminal dimensions
* @returns Terminal width and height
*/
function getTerminalSize(): { width: number; height: number } {
const [w, h] = term.getSize();
return { width: w, height: h };
}
/**
* Measure the natural size of a UI element
* This determines how much space an element wants to take up
*
*
* @param node - The UI node to measure
* @param parentWidth - Available width from parent (for percentage calculations)
* @param parentHeight - Available height from parent (for percentage calculations)
* @returns Width and height of the element
*/
function measureNode(node: UIObject): { width: number; height: number } {
function measureNode(
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();
}
return node.textContent;
}
// For nodes with text children, get their content
if (node.children.length > 0 && node.children[0].textContent !== undefined) {
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 "";
};
// 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();
return { width: text.length, height: 1 };
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]
return { width: text.length + 2, height: 1 };
const naturalWidth = text.length + 2;
const naturalHeight = 1;
return {
width: measuredWidth ?? naturalWidth,
height: measuredHeight ?? naturalHeight,
};
}
case "input": {
const type = node.props.type as string | undefined;
const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") {
return { width: 3, height: 1 }; // [X] or [ ]
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 width = (node.props.width as number | undefined) ?? 20;
return { width, height: 1 };
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 "fragment": {
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) {
return { width: 0, height: 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);
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth += childSize.width;
totalHeight = math.max(totalHeight, childSize.height);
}
@@ -89,7 +218,11 @@ function measureNode(node: UIObject): { width: number; height: number } {
} else {
// In column direction, height is sum of children, width is max
for (const child of node.children) {
const childSize = measureNode(child);
const childSize = measureNode(
child,
childParentWidth,
childParentHeight,
);
totalWidth = math.max(totalWidth, childSize.width);
totalHeight += childSize.height;
}
@@ -97,18 +230,24 @@ function measureNode(node: UIObject): { width: number; height: number } {
totalHeight += gap * (node.children.length - 1);
}
}
return { width: totalWidth, height: totalHeight };
return {
width: measuredWidth ?? totalWidth,
height: measuredHeight ?? totalHeight,
};
}
default:
return { width: 0, height: 0 };
return {
width: measuredWidth ?? 0,
height: measuredHeight ?? 0,
};
}
}
/**
* Apply flexbox layout algorithm to a container and its children
*
*
* @param node - The container node
* @param availableWidth - Available width for layout
* @param availableHeight - Available height for layout
@@ -120,7 +259,7 @@ export function calculateLayout(
availableWidth: number,
availableHeight: number,
startX = 1,
startY = 1
startY = 1,
): void {
// Set this node's layout
node.layout = {
@@ -141,13 +280,37 @@ export function calculateLayout(
const isFlex = node.type === "div" || node.type === "form";
const gap = isFlex ? 1 : 0;
// Handle scroll container layout
if (node.type === "scroll-container" && node.scrollProps) {
// For scroll containers, position children based on scroll offset
const scrollOffsetX = -node.scrollProps.scrollX;
const scrollOffsetY = -node.scrollProps.scrollY;
for (const child of node.children) {
// Calculate child's natural size and position it with scroll offset
const childSize = measureNode(
child,
node.scrollProps.contentWidth,
node.scrollProps.contentHeight,
);
const childX = startX + scrollOffsetX;
const childY = startY + scrollOffsetY;
// Recursively calculate layout for child with its natural size
calculateLayout(child, childSize.width, childSize.height, childX, childY);
}
return;
}
// Measure all children
const childMeasurements = node.children.map((child: UIObject) => measureNode(child));
const childMeasurements = node.children.map((child: UIObject) =>
measureNode(child, availableWidth, availableHeight),
);
// Calculate total size needed
let totalMainAxisSize = 0;
let maxCrossAxisSize = 0;
if (direction === "row") {
for (const measure of childMeasurements) {
totalMainAxisSize += measure.width;
@@ -168,10 +331,10 @@ export function calculateLayout(
// 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") {
@@ -181,7 +344,7 @@ export function calculateLayout(
}
} else {
const remainingSpace = availableHeight - totalMainAxisSize;
if (justify === "center") {
mainAxisPos = remainingSpace / 2;
} else if (justify === "end") {
@@ -195,14 +358,14 @@ export function calculateLayout(
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);
@@ -211,7 +374,7 @@ export function calculateLayout(
} else {
childY = startY; // start
}
mainAxisPos += measure.width + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
@@ -219,7 +382,7 @@ export function calculateLayout(
} 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);
@@ -228,13 +391,13 @@ export function calculateLayout(
} else {
childX = startX; // start
}
mainAxisPos += measure.height + spacing;
if (i < node.children.length - 1) {
mainAxisPos += gap;
}
}
// Recursively calculate layout for child
calculateLayout(child, measure.width, measure.height, childX, childY);
}

View File

@@ -3,7 +3,8 @@
*/
import { UIObject } from "./UIObject";
import { Accessor } from "./reactivity";
import { InputProps } from "./components";
import { isScrollContainer } from "./scrollContainer";
/**
* Get text content from a node (resolving signals if needed)
@@ -11,50 +12,144 @@ import { Accessor } from "./reactivity";
function getTextContent(node: UIObject): string {
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)();
return child.textContent();
}
return child.textContent!;
}
return "";
}
/**
* Check if a position is within the visible area of all scroll container ancestors
*/
function isPositionVisible(
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;
// 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;
}
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(" ");
}
}
} finally {
term.setCursorPos(origX, origY);
}
}
/**
* Draw a single UI node to the terminal
*
*
* @param node - The node to draw
* @param focused - Whether this node has focus
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/
function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean): void {
function drawNode(
node: UIObject,
focused: boolean,
cursorBlinkState: boolean,
): void {
if (!node.layout) return;
const { x, y, width } = 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") {
@@ -67,18 +162,18 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
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);
@@ -87,23 +182,23 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.gray);
}
term.setCursorPos(x, y);
term.write(`[${text}]`);
break;
}
case "input": {
const type = node.props.type as string | undefined;
const type = (node.props as InputProps).type as string | undefined;
if (type === "checkbox") {
// Draw checkbox
let isChecked = false;
const checkedProp = node.props.checked;
const checkedProp = (node.props as InputProps).checked;
if (typeof checkedProp === "function") {
isChecked = (checkedProp as Accessor<boolean>)();
isChecked = checkedProp();
}
if (focused) {
term.setTextColor(textColor ?? colors.black);
term.setBackgroundColor(bgColor ?? colors.white);
@@ -111,27 +206,25 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black);
}
term.setCursorPos(x, y);
term.write(isChecked ? "[X]" : "[ ]");
} else {
// Draw text input
let value = "";
const valueProp = node.props.value;
let displayText = "";
const valueProp = (node.props as InputProps).value;
if (typeof valueProp === "function") {
value = (valueProp as Accessor<string>)();
displayText = valueProp();
}
const placeholder = node.props.placeholder as string | undefined;
const placeholder = (node.props as InputProps).placeholder;
const cursorPos = node.cursorPos ?? 0;
let displayText = value;
let currentTextColor = textColor;
let showPlaceholder = false;
const focusedBgColor = bgColor ?? colors.white;
const unfocusedBgColor = bgColor ?? colors.black;
if (value === "" && placeholder !== undefined && !focused) {
if (displayText === "" && placeholder !== undefined && !focused) {
displayText = placeholder;
showPlaceholder = true;
currentTextColor = currentTextColor ?? colors.gray;
@@ -150,16 +243,20 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
term.setCursorPos(x + 1, y); // Position cursor for text after padding
const renderWidth = width - 1;
let textToRender = displayText;
const textToRender = displayText + " ";
// Truncate text if it's too long for the padded area
if (textToRender.length > renderWidth) {
textToRender = textToRender.substring(0, renderWidth);
}
// 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 = 0; i < textToRender.length; i++) {
for (
let i = startDisPos;
i < textToRender.length && i < stopDisPos;
i++
) {
const char = textToRender.substring(i, i + 1);
if (i === cursorPos) {
// Invert colors for cursor
@@ -184,19 +281,26 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
}
} else {
// Not focused or no cursor, just write the text
term.write(textToRender);
term.write(textToRender.substring(startDisPos, stopDisPos));
}
}
break;
}
case "div":
case "form":
case "for":
case "show": {
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;
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++) {
@@ -206,16 +310,33 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
}
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;
term.setTextColor(textColor ?? colors.white);
term.setBackgroundColor(bgColor ?? colors.black);
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));
}
@@ -230,19 +351,34 @@ function drawNode(node: UIObject, focused: boolean, cursorBlinkState: boolean):
/**
* Recursively render a UI tree
*
*
* @param node - The root node to render
* @param focusedNode - The currently focused node (if any)
* @param cursorBlinkState - Whether the cursor should be visible (for blinking)
*/
export function render(node: UIObject, focusedNode?: UIObject, cursorBlinkState = false): void {
export function render(
node: UIObject,
focusedNode?: UIObject,
cursorBlinkState = false,
): void {
// Draw this node
const isFocused = node === focusedNode;
drawNode(node, isFocused, cursorBlinkState);
// Recursively draw children
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);
}
}
}

View File

@@ -0,0 +1,204 @@
/**
* Scroll container component for handling scrollable content
*/
import { UIObject } from "./UIObject";
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;
} & Record<string, unknown>;
/**
* ScrollContainer component - provides scrollable viewport for content
* When content exceeds the container size, scrollbars appear and mouse wheel scrolling is enabled
*
* @param props - Props containing dimensions and scroll options
* @param content - Content to be scrolled
* @returns UIObject representing the scroll container
*
* @example
* ```typescript
* const [items, setItems] = createStore<string[]>([]);
*
* ScrollContainer(
* { width: 20, height: 10, showScrollbar: true },
* div({ class: "flex flex-col" },
* For({ each: () => items },
* (item, i) => div({}, item)
* )
* )
* )
* ```
*/
export function ScrollContainer(
props: ScrollContainerProps,
content: UIObject,
): UIObject {
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);
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;
}
/**
* Check if a UI node is a scroll container
* @param node - The UI node to check
* @returns True if the node is a scroll container
*/
export function isScrollContainer(node: UIObject): boolean {
return node.type === "scroll-container";
}
/**
* Find the nearest scroll container ancestor of a node
* @param node - The node to start searching from
* @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;
}
current = current.parent;
}
return undefined;
}
/**
* Check if a point is within the visible area of a scroll container
* @param container - The scroll container
* @param x - X coordinate relative to container
* @param y - Y coordinate relative to container
* @returns True if the point is visible
*/
export function isPointVisible(
container: UIObject,
x: number,
y: number,
): boolean {
if (!isScrollContainer(container) || !container.scrollProps) {
return true;
}
const { scrollX, scrollY, viewportWidth, viewportHeight } =
container.scrollProps;
return (
x >= scrollX &&
x < scrollX + viewportWidth &&
y >= scrollY &&
y < scrollY + viewportHeight
);
}
/**
* Convert screen coordinates to scroll container content coordinates
* @param container - The scroll container
* @param screenX - Screen X coordinate
* @param screenY - Screen Y coordinate
* @returns Content coordinates, or undefined if not within container
*/
export function screenToContent(
container: UIObject,
screenX: number,
screenY: number,
): { x: number; y: number } | undefined {
if (
!isScrollContainer(container) ||
!container.layout ||
!container.scrollProps
) {
return undefined;
}
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;
if (
relativeX < 0 ||
relativeY < 0 ||
relativeX >= container.scrollProps.viewportWidth ||
relativeY >= container.scrollProps.viewportHeight
) {
return undefined;
}
return {
x: relativeX + scrollX,
y: relativeY + scrollY,
};
}

0
src/lib/ccTUI/utils.ts Normal file
View File

View File

@@ -4,3 +4,48 @@ export function parseBoolean(obj: string): boolean | undefined {
else if (str === "false") return false;
else return undefined;
}
export function concatSentence(words: string[], length: number): string[] {
let i = 0,
j = 1;
const ret: string[] = [];
while (i < words.length) {
let sentence = words[i];
while (j < words.length && sentence.length + words[j].length < length) {
sentence += words[j];
j++;
}
ret.push(sentence);
i = j;
j++;
}
return ret;
}
/**
* Deep copy function for TypeScript.
* @param T Generic type of target/copied value.
* @param target Target value to be copied.
* @see Source project, ts-deepcopy https://github.com/ykdr2017/ts-deepcopy
* @see Code pen https://codepen.io/erikvullings/pen/ejyBYg
*/
export function deepCopy<T>(target: T): T {
if (target === null) {
return target;
}
if (Array.isArray(target)) {
return (target as unknown[]).map((v: unknown) => deepCopy(v)) as T;
}
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;
}

105
src/lib/datatype/Queue.ts Normal file
View 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 };
}
},
};
}
}

View 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 = [];
}
}

View 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
View 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
View 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.

View File

@@ -0,0 +1,2 @@
export * from "./result";
export * from "./option";

View 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;
}
}

View 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;
}
}

View 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;
}

View File

@@ -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}`);
});

View File

@@ -1,4 +1,4 @@
import { CCLog, MINUTE, HOUR, SECOND } from "@/lib/ccLog";
import { CCLog, MINUTE, HOUR } from "@/lib/ccLog";
// Test the new time-based rotation functionality
function testTimeBasedRotation() {
@@ -9,25 +9,16 @@ function testTimeBasedRotation() {
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);
const logger2 = new CCLog("test_log_hourly.txt", { logInterval: 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);
const logger3 = new CCLog("test_log_30min.txt", { logInterval: 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!");
}

View 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
View 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!");
}

View 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!");
}

View File

@@ -15,6 +15,7 @@ import {
For,
createStore,
removeIndex,
ScrollContainer,
} from "../lib/ccTUI";
/**
@@ -30,7 +31,10 @@ const Counter = () => {
div(
{ class: "flex flex-row" },
button({ onClick: () => setCount(count() - 1), class: "text-red" }, "-"),
button({ onClick: () => setCount(count() + 1), class: "text-green" }, "+"),
button(
{ onClick: () => setCount(count() + 1), class: "text-green" },
"+",
),
),
);
};
@@ -80,10 +84,10 @@ const TodosApp = () => {
},
}),
label(
{
class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white"
},
() => todo.title
{
class: todo.completed ? "ml-1 text-gray" : "ml-1 text-white",
},
() => todo.title,
),
button(
{
@@ -99,6 +103,194 @@ const TodosApp = () => {
);
};
/**
* Example data type
*/
interface ListItem {
id: number;
title: string;
description: string;
}
/**
* Simple scroll example with a list of items
*/
function SimpleScrollExample() {
// Create a large list of items to demonstrate scrolling
const [items, setItems] = createStore<ListItem[]>([]);
const [itemCount, setItemCount] = createSignal(0);
// Generate initial items
const generateItems = (count: number) => {
const newItems: ListItem[] = [];
for (let i = 1; i <= count; i++) {
newItems.push({
id: i,
title: `Item ${i}`,
description: `Description for item ${i}`,
});
}
setItems(() => newItems);
setItemCount(count);
};
// Initialize with some items
generateItems(20);
return div(
{ class: "flex flex-col h-screen bg-black text-white" },
// Header
div(
{ class: "flex flex-row justify-center bg-blue text-white" },
label({}, "Scroll Container Demo"),
),
// Control buttons
div(
{ class: "flex flex-row justify-center bg-gray" },
button(
{ onClick: () => generateItems(itemCount() + 10) },
"Add 10 Items",
),
button(
{ onClick: () => generateItems(Math.max(0, itemCount() - 10)) },
"Remove 10 Items",
),
button({ onClick: () => generateItems(50) }, "Generate 50 Items"),
),
// Main scrollable content
div(
{ class: "flex flex-col" },
label({}, "Scrollable List:"),
ScrollContainer(
{
width: 40,
height: 15,
showScrollbar: true,
},
div(
{ class: "flex flex-col" },
For({ each: items }, (item: ListItem, index) =>
div(
{ class: "flex flex-col" },
label({}, () => `${index() + 1}. ${item.title}`),
label({}, item.description),
label({}, ""), // Empty line for spacing
),
),
),
),
),
// Instructions
div(
{ class: "flex flex-col bg-brown text-white" },
label({}, "Instructions:"),
label({}, "• Use mouse wheel to scroll within the container"),
label({}, "• Notice the scrollbar on the right side"),
label({}, "• Try adding/removing items to see scroll behavior"),
),
);
}
/**
* Example with static long content
*/
function StaticScrollExample() {
const longText = [
"Line 1: This is a demonstration of vertical scrolling.",
"Line 2: The content extends beyond the visible area.",
"Line 3: Use your mouse wheel to scroll up and down.",
"Line 4: Notice how the scrollbar appears on the right.",
"Line 5: The scrollbar thumb shows your current position.",
"Line 6: This content is much longer than the container.",
"Line 7: Keep scrolling to see more lines.",
"Line 8: The scroll container handles overflow automatically.",
"Line 9: You can also scroll horizontally if content is wide.",
"Line 10: This demonstrates the scroll functionality.",
"Line 11: More content here to fill the scrollable area.",
"Line 12: The framework handles all the complex scroll logic.",
"Line 13: Just wrap your content in a ScrollContainer.",
"Line 14: Set width and height to define the viewport.",
"Line 15: The end! Try scrolling back to the top.",
];
return div(
{ class: "flex flex-col justify-center items-center h-screen bg-black" },
label({}, "Static Scroll Example"),
ScrollContainer(
{
width: 50,
height: 10,
showScrollbar: true,
},
div(
{ class: "flex flex-col" },
...longText.map((line) => label({}, line)),
),
),
label({}, "Use mouse wheel to scroll"),
);
}
/**
* Example with multiple independent scroll containers
*/
function MultiScrollExample() {
return div(
{ class: "flex flex-col h-screen bg-black" },
label({}, "Multiple Scroll Containers"),
div(
{ class: "flex flex-row justify-between" },
// Left container - numbers
div(
{ class: "flex flex-col" },
label({}, "Numbers"),
ScrollContainer(
{ width: 15, height: 10 },
div(
{ class: "flex flex-col" },
For(
{
each: () =>
Array.from({ length: 30 }, (_, i) => i + 1) as number[],
},
(num: number) => label({}, () => `Number: ${num}`),
),
),
),
),
// Right container - letters
div(
{ class: "flex flex-col" },
label({}, "Letters"),
ScrollContainer(
{ width: 15, height: 10 },
div(
{ class: "flex flex-col" },
For(
{
each: () => "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("") as string[],
},
(letter: string, index) =>
label({}, () => `${index() + 1}. Letter ${letter}`),
),
),
),
),
),
label({}, "Each container scrolls independently"),
);
}
/**
* Main application component with tabs
*/
@@ -111,11 +303,32 @@ const App = () => {
{ class: "flex flex-row" },
button({ onClick: () => setTabIndex(0) }, "CountDemo"),
button({ onClick: () => setTabIndex(1) }, "TodosDemo"),
button({ onClick: () => setTabIndex(2) }, "SimpleScroll"),
button({ onClick: () => setTabIndex(3) }, "StaticScroll"),
button({ onClick: () => setTabIndex(4) }, "MultiScroll"),
),
Show(
{
when: () => tabIndex() === 0,
fallback: Show({ when: () => tabIndex() === 1 }, TodosApp()),
fallback: Show(
{
when: () => tabIndex() === 1,
fallback: Show(
{
when: () => tabIndex() === 2,
fallback: Show(
{
when: () => tabIndex() === 3,
fallback: MultiScrollExample(),
},
StaticScrollExample(),
),
},
SimpleScrollExample(),
),
},
TodosApp(),
),
},
Counter(),
),

View 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"]
}

View 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"]
}

View 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"]
}

View 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"]
}

View 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"]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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"

View File

@@ -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(