voonex 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +223 -0
- package/dist/components/Box.d.ts +33 -0
- package/dist/components/Box.js +242 -0
- package/dist/components/Button.d.ts +23 -0
- package/dist/components/Button.js +76 -0
- package/dist/components/Checkbox.d.ts +20 -0
- package/dist/components/Checkbox.js +40 -0
- package/dist/components/Input.d.ts +23 -0
- package/dist/components/Input.js +79 -0
- package/dist/components/Menu.d.ts +27 -0
- package/dist/components/Menu.js +93 -0
- package/dist/components/Popup.d.ts +7 -0
- package/dist/components/Popup.js +50 -0
- package/dist/components/ProgressBar.d.ts +28 -0
- package/dist/components/ProgressBar.js +47 -0
- package/dist/components/Radio.d.ts +22 -0
- package/dist/components/Radio.js +64 -0
- package/dist/components/Select.d.ts +24 -0
- package/dist/components/Select.js +126 -0
- package/dist/components/Tab.d.ts +24 -0
- package/dist/components/Tab.js +72 -0
- package/dist/components/Table.d.ts +25 -0
- package/dist/components/Table.js +116 -0
- package/dist/components/Textarea.d.ts +26 -0
- package/dist/components/Textarea.js +176 -0
- package/dist/components/Tree.d.ts +33 -0
- package/dist/components/Tree.js +166 -0
- package/dist/core/Component.d.ts +6 -0
- package/dist/core/Component.js +5 -0
- package/dist/core/Cursor.d.ts +7 -0
- package/dist/core/Cursor.js +59 -0
- package/dist/core/Events.d.ts +11 -0
- package/dist/core/Events.js +37 -0
- package/dist/core/Focus.d.ts +16 -0
- package/dist/core/Focus.js +69 -0
- package/dist/core/Input.d.ts +13 -0
- package/dist/core/Input.js +88 -0
- package/dist/core/Layout.d.ts +18 -0
- package/dist/core/Layout.js +65 -0
- package/dist/core/Screen.d.ts +78 -0
- package/dist/core/Screen.js +373 -0
- package/dist/core/Styler.d.ts +71 -0
- package/dist/core/Styler.js +157 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.js +41 -0
- package/package.json +29 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 CodeTease
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
# Voonex
|
|
2
|
+
|
|
3
|
+
**Voonex** is a modern, zero-dependency Terminal UI (TUI) library for Node.js, built with TypeScript. It provides a robust virtual buffer, reactive rendering system, and a rich set of widgets to build complex command-line interfaces with ease.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Zero Dependencies**: Lightweight and easy to audit.
|
|
8
|
+
- **Double Buffering & Diffing**: Efficient rendering that eliminates flickering and minimizes I/O.
|
|
9
|
+
- **Component System**: Built-in widgets like `Box`, `Menu`, `ProgressBar`, `Input`, `Table`, and more.
|
|
10
|
+
- **Reactive Rendering**: Automated screen updates via `Screen.mount()` and `Screen.scheduleRender()`.
|
|
11
|
+
- **Focus Management**: Built-in keyboard navigation and focus delegation.
|
|
12
|
+
- **Styling Engine**: Simple yet powerful API for ANSI colors and text modifiers.
|
|
13
|
+
- **TypeScript Support**: Written in TypeScript with full type definitions included.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
Install via npm:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npm install voonex
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
Here is a minimal example showing how to initialize the screen and display a simple box.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
import { Screen, Box, Styler, Input } from 'voonex';
|
|
29
|
+
|
|
30
|
+
// 1. Enter the alternate screen buffer
|
|
31
|
+
Screen.enter();
|
|
32
|
+
|
|
33
|
+
// 2. Define your render function
|
|
34
|
+
function render() {
|
|
35
|
+
// Render a Box at (5, 5) with a title
|
|
36
|
+
Box.render([
|
|
37
|
+
"Welcome to Voonex!",
|
|
38
|
+
"Press 'q' to exit."
|
|
39
|
+
], {
|
|
40
|
+
title: "Hello World",
|
|
41
|
+
x: 5,
|
|
42
|
+
y: 5,
|
|
43
|
+
padding: 1,
|
|
44
|
+
style: 'double',
|
|
45
|
+
borderColor: 'cyan'
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// 3. Mount the render function to the screen loop
|
|
50
|
+
Screen.mount(render);
|
|
51
|
+
|
|
52
|
+
// 4. Handle Input
|
|
53
|
+
Input.onKey((key) => {
|
|
54
|
+
if (key.name === 'q') {
|
|
55
|
+
// Leave the screen buffer properly before exiting
|
|
56
|
+
Screen.leave();
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Run it with:
|
|
63
|
+
```bash
|
|
64
|
+
npx ts-node my-app.ts
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Core Concepts
|
|
68
|
+
|
|
69
|
+
### The Screen
|
|
70
|
+
The `Screen` class is the heart of Voonex. It manages the terminal buffer, handles resizing, and optimizes rendering using a diffing algorithm.
|
|
71
|
+
|
|
72
|
+
- `Screen.enter()`: Switches to the alternate buffer (like `vim` or `nano`).
|
|
73
|
+
- `Screen.leave()`: Restores the original terminal state. **Always call this before exiting.**
|
|
74
|
+
- `Screen.mount(renderFn)`: Registers a function to be called during the render cycle. Voonex uses a "Painter's Algorithm", so functions mounted later are drawn on top.
|
|
75
|
+
- `Screen.scheduleRender()`: Triggers a screen update. This is automatically called by most interactive components, but you can call it manually if you update state asynchronously (e.g., inside a `setInterval`).
|
|
76
|
+
|
|
77
|
+
### Input Handling
|
|
78
|
+
Voonex provides a global input listener wrapper around Node's `process.stdin`.
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
import { Input } from 'voonex';
|
|
82
|
+
|
|
83
|
+
Input.onKey((key) => {
|
|
84
|
+
console.log(key.name); // 'up', 'down', 'enter', 'a', 'b', etc.
|
|
85
|
+
console.log(key.ctrl); // true if Ctrl is pressed
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Styling
|
|
90
|
+
The `Styler` class provides utilities for coloring and formatting text.
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { Styler } from 'voonex';
|
|
94
|
+
|
|
95
|
+
const text = Styler.style("Success!", 'green', 'bold', 'underline');
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Components
|
|
99
|
+
|
|
100
|
+
Voonex comes with several built-in components. Components can be used in two ways:
|
|
101
|
+
1. **Static Rendering**: Using static methods like `Box.render()`.
|
|
102
|
+
2. **Stateful Instances**: Creating an instance (e.g., `new Menu()`) and calling its methods.
|
|
103
|
+
|
|
104
|
+
### Box
|
|
105
|
+
A container for text with optional borders, padding, and titles.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
Box.render([
|
|
109
|
+
"Line 1",
|
|
110
|
+
"Line 2"
|
|
111
|
+
], {
|
|
112
|
+
x: 2, y: 2,
|
|
113
|
+
width: 30,
|
|
114
|
+
borderColor: 'green',
|
|
115
|
+
style: 'round' // 'single', 'double', or 'round'
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Menu
|
|
120
|
+
A vertical list of selectable items.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
const menu = new Menu({
|
|
124
|
+
title: "Main Menu",
|
|
125
|
+
x: 4, y: 4,
|
|
126
|
+
items: ["Start", "Options", "Exit"],
|
|
127
|
+
onSelect: (index, item) => {
|
|
128
|
+
console.log(`Selected: ${item}`);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// In your input handler:
|
|
133
|
+
Input.onKey((key) => {
|
|
134
|
+
menu.handleKey(key); // Passes key events to the menu
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// In your render function:
|
|
138
|
+
menu.render();
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### ProgressBar
|
|
142
|
+
Displays a progress bar.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
const bar = new ProgressBar({
|
|
146
|
+
width: 20,
|
|
147
|
+
total: 100,
|
|
148
|
+
x: 4, y: 10,
|
|
149
|
+
completeChar: '█',
|
|
150
|
+
incompleteChar: '░'
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// Update progress
|
|
154
|
+
bar.update(50); // 50%
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Popup
|
|
158
|
+
A modal dialog that overlays other content.
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// Shows a message and waits for user to press Enter/Esc
|
|
162
|
+
await Popup.alert("This is an important message!", { title: "Alert" });
|
|
163
|
+
|
|
164
|
+
// Asks for confirmation (returns boolean)
|
|
165
|
+
const confirmed = await Popup.confirm("Are you sure?", { title: "Confirm" });
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Input Field
|
|
169
|
+
A text input field for capturing user input.
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
const nameInput = new InputField({
|
|
173
|
+
x: 2, y: 2,
|
|
174
|
+
width: 20,
|
|
175
|
+
placeholder: "Enter name..."
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
Input.onKey(key => {
|
|
179
|
+
if (key.name === 'tab') {
|
|
180
|
+
nameInput.focus();
|
|
181
|
+
}
|
|
182
|
+
nameInput.handleKey(key);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// In render loop
|
|
186
|
+
nameInput.render();
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
## Advanced Usage
|
|
190
|
+
|
|
191
|
+
### Manual Layout
|
|
192
|
+
Voonex relies on absolute positioning `(x, y)`. For complex layouts, you can calculate coordinates dynamically based on `Screen.size`.
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
const { width, height } = Screen.size;
|
|
196
|
+
const centerX = Math.floor(width / 2);
|
|
197
|
+
const centerY = Math.floor(height / 2);
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Creating Custom Components
|
|
201
|
+
Any class can be a component. To integrate with the Voonex ecosystem, it's recommended (but not required) to implement the `Focusable` interface if the component handles input.
|
|
202
|
+
|
|
203
|
+
```typescript
|
|
204
|
+
import { Screen, Styler, Focusable } from 'voonex';
|
|
205
|
+
|
|
206
|
+
class MyWidget implements Focusable {
|
|
207
|
+
focus() { /* handle focus */ }
|
|
208
|
+
blur() { /* handle blur */ }
|
|
209
|
+
|
|
210
|
+
handleKey(key) {
|
|
211
|
+
// return true if key was consumed
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
render() {
|
|
216
|
+
Screen.write(10, 10, "My Custom Widget");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## License
|
|
222
|
+
|
|
223
|
+
This project is under the **MIT License**.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ColorName } from '../core/Styler';
|
|
2
|
+
import { Focusable } from '../core/Focus';
|
|
3
|
+
import * as readline from 'readline';
|
|
4
|
+
export interface BoxOptions {
|
|
5
|
+
id?: string;
|
|
6
|
+
x?: number;
|
|
7
|
+
y?: number;
|
|
8
|
+
width?: number;
|
|
9
|
+
height?: number;
|
|
10
|
+
padding?: number;
|
|
11
|
+
borderColor?: ColorName;
|
|
12
|
+
title?: string;
|
|
13
|
+
style?: 'single' | 'double' | 'round';
|
|
14
|
+
scrollable?: boolean;
|
|
15
|
+
wrap?: boolean;
|
|
16
|
+
}
|
|
17
|
+
export declare class Box implements Focusable {
|
|
18
|
+
id: string;
|
|
19
|
+
private options;
|
|
20
|
+
private content;
|
|
21
|
+
private processedLines;
|
|
22
|
+
private scrollTop;
|
|
23
|
+
private isFocused;
|
|
24
|
+
constructor(content: string | string[], options?: BoxOptions);
|
|
25
|
+
private calculateDimensions;
|
|
26
|
+
private processContent;
|
|
27
|
+
private wrapLine;
|
|
28
|
+
focus(): void;
|
|
29
|
+
blur(): void;
|
|
30
|
+
handleKey(key: readline.Key): boolean;
|
|
31
|
+
render(): void;
|
|
32
|
+
static render(content: string | string[], options?: BoxOptions): void;
|
|
33
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Box = void 0;
|
|
4
|
+
const Styler_1 = require("../core/Styler");
|
|
5
|
+
const Screen_1 = require("../core/Screen");
|
|
6
|
+
const BORDERS = {
|
|
7
|
+
single: { tl: '┌', tr: '┐', bl: '└', br: '┘', h: '─', v: '│' },
|
|
8
|
+
double: { tl: '╔', tr: '╗', bl: '╚', br: '╝', h: '═', v: '║' },
|
|
9
|
+
round: { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }
|
|
10
|
+
};
|
|
11
|
+
class Box {
|
|
12
|
+
constructor(content, options = {}) {
|
|
13
|
+
this.processedLines = [];
|
|
14
|
+
this.scrollTop = 0;
|
|
15
|
+
this.isFocused = false;
|
|
16
|
+
this.id = options.id || `box-${Math.random().toString(36).substr(2, 9)}`;
|
|
17
|
+
this.options = {
|
|
18
|
+
padding: 1,
|
|
19
|
+
style: 'round',
|
|
20
|
+
borderColor: 'white',
|
|
21
|
+
scrollable: false,
|
|
22
|
+
wrap: false,
|
|
23
|
+
x: 1,
|
|
24
|
+
y: 1,
|
|
25
|
+
...options
|
|
26
|
+
};
|
|
27
|
+
this.content = Array.isArray(content) ? content : content.split('\n');
|
|
28
|
+
this.calculateDimensions();
|
|
29
|
+
this.processContent();
|
|
30
|
+
}
|
|
31
|
+
calculateDimensions() {
|
|
32
|
+
const { padding } = this.options;
|
|
33
|
+
const pad = padding || 1;
|
|
34
|
+
// If width/height not provided, calculate from content
|
|
35
|
+
if (!this.options.width) {
|
|
36
|
+
const contentMaxWidth = Math.max(...this.content.map(l => Styler_1.Styler.len(l)));
|
|
37
|
+
this.options.width = contentMaxWidth + (pad * 2) + 2;
|
|
38
|
+
}
|
|
39
|
+
if (!this.options.height) {
|
|
40
|
+
this.options.height = this.content.length + (pad * 2) + 2;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
processContent() {
|
|
44
|
+
const { width, padding, wrap } = this.options;
|
|
45
|
+
const pad = padding || 1;
|
|
46
|
+
// Ensure innerWidth is at least 1 to avoid infinite loops
|
|
47
|
+
const calculatedInnerWidth = (width || 0) - 2 - (pad * 2);
|
|
48
|
+
const innerWidth = Math.max(1, calculatedInnerWidth);
|
|
49
|
+
if (wrap) {
|
|
50
|
+
this.processedLines = [];
|
|
51
|
+
for (const line of this.content) {
|
|
52
|
+
if (Styler_1.Styler.len(line) <= innerWidth) {
|
|
53
|
+
this.processedLines.push(line);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
// Simple wrap logic
|
|
57
|
+
let currentLine = line;
|
|
58
|
+
let safetyCounter = 0;
|
|
59
|
+
const MAX_LOOPS = 1000; // Prevent infinite loops
|
|
60
|
+
while (Styler_1.Styler.len(currentLine) > innerWidth) {
|
|
61
|
+
if (safetyCounter++ > MAX_LOOPS) {
|
|
62
|
+
console.error(`Box processContent infinite loop detected. line="${currentLine.substring(0, 20)}..."`);
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
const wrapped = this.wrapLine(currentLine, innerWidth);
|
|
66
|
+
// If head is empty, we force a split to avoid infinite loop
|
|
67
|
+
if (!wrapped.head && wrapped.tail === currentLine) {
|
|
68
|
+
this.processedLines.push(currentLine.substring(0, innerWidth));
|
|
69
|
+
currentLine = currentLine.substring(innerWidth);
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
this.processedLines.push(wrapped.head);
|
|
73
|
+
currentLine = wrapped.tail;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (currentLine)
|
|
77
|
+
this.processedLines.push(currentLine);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
this.processedLines = this.content;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
wrapLine(line, width) {
|
|
86
|
+
// Updated implementation to use Styler.len (visual width) and Styler.truncateWithState
|
|
87
|
+
const raw = line;
|
|
88
|
+
const visualLen = Styler_1.Styler.len(raw);
|
|
89
|
+
if (visualLen <= width)
|
|
90
|
+
return { head: raw, tail: '' };
|
|
91
|
+
// Use the new truncateWithState to correctly handle ANSI split
|
|
92
|
+
const { result, remaining } = Styler_1.Styler.truncateWithState(raw, width);
|
|
93
|
+
// Word wrapping optimization (optional but nice)
|
|
94
|
+
// If the cut was in the middle of a word, try to backtrack to last space.
|
|
95
|
+
// But backtracking with visual width and ANSI is hard.
|
|
96
|
+
// For now, stick to char wrap which is robust.
|
|
97
|
+
return {
|
|
98
|
+
head: result,
|
|
99
|
+
tail: remaining
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
focus() {
|
|
103
|
+
this.isFocused = true;
|
|
104
|
+
this.render(); // Re-render to show focus indicator (border color change?)
|
|
105
|
+
}
|
|
106
|
+
blur() {
|
|
107
|
+
this.isFocused = false;
|
|
108
|
+
this.render();
|
|
109
|
+
}
|
|
110
|
+
handleKey(key) {
|
|
111
|
+
if (!this.options.scrollable)
|
|
112
|
+
return false;
|
|
113
|
+
const { height, padding } = this.options;
|
|
114
|
+
const pad = padding || 1;
|
|
115
|
+
const innerHeight = (height || 0) - 2 - (pad * 2);
|
|
116
|
+
const maxScroll = Math.max(0, this.processedLines.length - innerHeight);
|
|
117
|
+
let consumed = false;
|
|
118
|
+
if (key.name === 'up') {
|
|
119
|
+
if (this.scrollTop > 0) {
|
|
120
|
+
this.scrollTop--;
|
|
121
|
+
consumed = true;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
else if (key.name === 'down') {
|
|
125
|
+
if (this.scrollTop < maxScroll) {
|
|
126
|
+
this.scrollTop++;
|
|
127
|
+
consumed = true;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
else if (key.name === 'pageup') {
|
|
131
|
+
this.scrollTop = Math.max(0, this.scrollTop - innerHeight);
|
|
132
|
+
consumed = true;
|
|
133
|
+
}
|
|
134
|
+
else if (key.name === 'pagedown') {
|
|
135
|
+
this.scrollTop = Math.min(maxScroll, this.scrollTop + innerHeight);
|
|
136
|
+
consumed = true;
|
|
137
|
+
}
|
|
138
|
+
if (consumed) {
|
|
139
|
+
this.render();
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
render() {
|
|
145
|
+
const { x, y, width, height, padding, title, style: borderStyle, borderColor, scrollable } = this.options;
|
|
146
|
+
const style = BORDERS[borderStyle || 'round'];
|
|
147
|
+
// Use bright color if focused
|
|
148
|
+
const color = this.isFocused ? 'brightCyan' : (borderColor || 'white');
|
|
149
|
+
const startX = x || 1;
|
|
150
|
+
const startY = y || 1;
|
|
151
|
+
const pad = padding || 1;
|
|
152
|
+
const innerWidth = (width || 0) - 2;
|
|
153
|
+
const innerHeight = (height || 0) - 2;
|
|
154
|
+
const contentHeight = innerHeight - (pad * 2);
|
|
155
|
+
// 1. Draw Top Border
|
|
156
|
+
let topBorder = style.tl + style.h.repeat(innerWidth) + style.tr;
|
|
157
|
+
if (title) {
|
|
158
|
+
const titleStr = ` ${title} `;
|
|
159
|
+
const leftLen = Math.floor((innerWidth - titleStr.length) / 2);
|
|
160
|
+
const rightLen = innerWidth - leftLen - titleStr.length;
|
|
161
|
+
if (leftLen >= 0) {
|
|
162
|
+
topBorder = style.tl + style.h.repeat(leftLen) + Styler_1.Styler.style(titleStr, 'bold') + style.h.repeat(rightLen) + style.tr;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
Screen_1.Screen.write(startX, startY, Styler_1.Styler.style(topBorder, color));
|
|
166
|
+
// 2. Draw Content Body
|
|
167
|
+
for (let i = 0; i < innerHeight; i++) {
|
|
168
|
+
// Check if we are in padding zone or content zone
|
|
169
|
+
// Padding is applied at top and bottom inside the box?
|
|
170
|
+
// Usually padding is around content.
|
|
171
|
+
// Let's assume uniform padding for simplicity in calculation,
|
|
172
|
+
// but here we iterate lines.
|
|
173
|
+
let lineContent = "";
|
|
174
|
+
if (i >= pad && i < innerHeight - pad) {
|
|
175
|
+
const contentIndex = (i - pad) + this.scrollTop;
|
|
176
|
+
if (contentIndex < this.processedLines.length) {
|
|
177
|
+
lineContent = this.processedLines[contentIndex];
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const rawLen = Styler_1.Styler.len(lineContent);
|
|
181
|
+
const contentWidth = innerWidth - (pad * 2);
|
|
182
|
+
// Ensure we don't overflow horizontally even if wrap failed or wasn't on
|
|
183
|
+
if (rawLen > contentWidth) {
|
|
184
|
+
lineContent = Styler_1.Styler.truncate(lineContent, contentWidth);
|
|
185
|
+
}
|
|
186
|
+
const remainingSpace = contentWidth - Styler_1.Styler.len(lineContent);
|
|
187
|
+
const rowString = ' '.repeat(pad) + lineContent + ' '.repeat(Math.max(0, remainingSpace)) + ' '.repeat(pad);
|
|
188
|
+
// Trim/Pad to exact innerWidth using visual logic if possible,
|
|
189
|
+
// but padEnd/substring use string length.
|
|
190
|
+
// Since we ensured lineContent visual length is correct and we added spaces (width 1),
|
|
191
|
+
// we should be careful.
|
|
192
|
+
// Re-calculate visual length of the constructed row
|
|
193
|
+
// We want 'rowString' to have visual width exactly 'innerWidth'
|
|
194
|
+
// If we constructed it correctly:
|
|
195
|
+
// pad (len) + lineContent (len) + remaining (len) + pad (len)
|
|
196
|
+
// = pad + (contentWidth - remaining) + remaining + pad = contentWidth + 2*pad = innerWidth.
|
|
197
|
+
// So visual width should be correct.
|
|
198
|
+
// However, padEnd/substring operate on string length.
|
|
199
|
+
// If lineContent has wide chars, string length < visual width.
|
|
200
|
+
// 'safeRowString' logic below was:
|
|
201
|
+
// const safeRowString = rowString.padEnd(innerWidth).substring(0, innerWidth);
|
|
202
|
+
// This is DANGEROUS because it truncates by string index!
|
|
203
|
+
// We should trust our construction above.
|
|
204
|
+
const safeRowString = rowString;
|
|
205
|
+
let rightBorder = style.v;
|
|
206
|
+
// Scrollbar logic
|
|
207
|
+
if (scrollable) {
|
|
208
|
+
const maxScroll = Math.max(0, this.processedLines.length - contentHeight);
|
|
209
|
+
if (maxScroll > 0) {
|
|
210
|
+
// Calculate scrollbar position
|
|
211
|
+
const scrollbarHeight = Math.max(1, Math.floor((contentHeight / this.processedLines.length) * contentHeight));
|
|
212
|
+
const scrollPercent = this.scrollTop / maxScroll;
|
|
213
|
+
const scrollPos = Math.floor(scrollPercent * (contentHeight - scrollbarHeight));
|
|
214
|
+
// Are we in the scrollbar zone? (i is relative to inner box, 0 to innerHeight-1)
|
|
215
|
+
// But content area is from pad to innerHeight-pad
|
|
216
|
+
if (i >= pad && i < innerHeight - pad) {
|
|
217
|
+
const contentRow = i - pad;
|
|
218
|
+
if (contentRow >= scrollPos && contentRow < scrollPos + scrollbarHeight) {
|
|
219
|
+
rightBorder = '│'; // Active scrollbar part, maybe color it differently
|
|
220
|
+
// Actually user suggested using unicode or color change
|
|
221
|
+
// Let's make it a colored block or pipe
|
|
222
|
+
rightBorder = Styler_1.Styler.style('│', 'brightWhite');
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
rightBorder = Styler_1.Styler.style('│', 'dim'); // Track
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const row = `${style.v}${safeRowString}${rightBorder}`;
|
|
231
|
+
Screen_1.Screen.write(startX, startY + 1 + i, Styler_1.Styler.style(row, color));
|
|
232
|
+
}
|
|
233
|
+
// 3. Draw Bottom Border
|
|
234
|
+
Screen_1.Screen.write(startX, startY + (height || 0) - 1, Styler_1.Styler.style(style.bl + style.h.repeat(innerWidth) + style.br, color));
|
|
235
|
+
}
|
|
236
|
+
// Static compatibility method
|
|
237
|
+
static render(content, options = {}) {
|
|
238
|
+
const box = new Box(content, options);
|
|
239
|
+
box.render();
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
exports.Box = Box;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { Focusable } from '../core/Focus';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
interface ButtonOptions {
|
|
4
|
+
id: string;
|
|
5
|
+
text: string;
|
|
6
|
+
x: number;
|
|
7
|
+
y: number;
|
|
8
|
+
width?: number;
|
|
9
|
+
style?: 'simple' | 'brackets';
|
|
10
|
+
onPress: () => void;
|
|
11
|
+
}
|
|
12
|
+
export declare class Button implements Focusable {
|
|
13
|
+
id: string;
|
|
14
|
+
private options;
|
|
15
|
+
private isFocused;
|
|
16
|
+
private isPressed;
|
|
17
|
+
constructor(options: ButtonOptions);
|
|
18
|
+
focus(): void;
|
|
19
|
+
blur(): void;
|
|
20
|
+
handleKey(key: readline.Key): boolean;
|
|
21
|
+
render(): void;
|
|
22
|
+
}
|
|
23
|
+
export {};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Button = void 0;
|
|
4
|
+
const Styler_1 = require("../core/Styler");
|
|
5
|
+
const Screen_1 = require("../core/Screen");
|
|
6
|
+
class Button {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.isFocused = false;
|
|
9
|
+
this.isPressed = false; // For visual feedback
|
|
10
|
+
this.id = options.id;
|
|
11
|
+
this.options = { style: 'brackets', ...options };
|
|
12
|
+
}
|
|
13
|
+
focus() {
|
|
14
|
+
this.isFocused = true;
|
|
15
|
+
this.render(); // Ensure render called on focus
|
|
16
|
+
}
|
|
17
|
+
blur() {
|
|
18
|
+
this.isFocused = false;
|
|
19
|
+
this.isPressed = false;
|
|
20
|
+
this.render();
|
|
21
|
+
}
|
|
22
|
+
handleKey(key) {
|
|
23
|
+
if (!this.isFocused)
|
|
24
|
+
return false;
|
|
25
|
+
if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
26
|
+
this.isPressed = true;
|
|
27
|
+
this.render(); // Show pressed state visually
|
|
28
|
+
// Trigger action slightly delayed to show visual feedback
|
|
29
|
+
setTimeout(() => {
|
|
30
|
+
this.isPressed = false;
|
|
31
|
+
this.render();
|
|
32
|
+
this.options.onPress();
|
|
33
|
+
}, 150);
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
render() {
|
|
39
|
+
const { x, y, text, width } = this.options;
|
|
40
|
+
let label = text;
|
|
41
|
+
// Simple visual centering if width is provided
|
|
42
|
+
if (width) {
|
|
43
|
+
const pad = Math.max(0, width - text.length);
|
|
44
|
+
const padLeft = Math.floor(pad / 2);
|
|
45
|
+
const padRight = pad - padLeft;
|
|
46
|
+
label = ' '.repeat(padLeft) + text + ' '.repeat(padRight);
|
|
47
|
+
}
|
|
48
|
+
let renderedText = "";
|
|
49
|
+
let color = 'white';
|
|
50
|
+
let bgStyle = '';
|
|
51
|
+
if (this.isFocused) {
|
|
52
|
+
color = this.isPressed ? 'black' : 'white';
|
|
53
|
+
bgStyle = this.isPressed ? 'bgGreen' : 'bgBlue'; // Green when clicked, Blue when focused
|
|
54
|
+
if (this.options.style === 'brackets') {
|
|
55
|
+
renderedText = `[ ${label} ]`;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
renderedText = ` ${label} `;
|
|
59
|
+
}
|
|
60
|
+
// Apply styles
|
|
61
|
+
// Note: Styler.style takes varargs, we need to handle types carefully or cast
|
|
62
|
+
Screen_1.Screen.write(x, y, Styler_1.Styler.style(renderedText, color, 'bold', bgStyle));
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
// Normal State
|
|
66
|
+
if (this.options.style === 'brackets') {
|
|
67
|
+
renderedText = `[ ${label} ]`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
renderedText = ` ${label} `;
|
|
71
|
+
}
|
|
72
|
+
Screen_1.Screen.write(x, y, Styler_1.Styler.style(renderedText, 'dim'));
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
exports.Button = Button;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Focusable } from '../core/Focus';
|
|
2
|
+
import * as readline from 'readline';
|
|
3
|
+
export interface CheckboxOptions {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
checked?: boolean;
|
|
7
|
+
x: number;
|
|
8
|
+
y: number;
|
|
9
|
+
}
|
|
10
|
+
export declare class Checkbox implements Focusable {
|
|
11
|
+
id: string;
|
|
12
|
+
checked: boolean;
|
|
13
|
+
private options;
|
|
14
|
+
private isFocused;
|
|
15
|
+
constructor(options: CheckboxOptions);
|
|
16
|
+
focus(): void;
|
|
17
|
+
blur(): void;
|
|
18
|
+
handleKey(key: readline.Key): boolean;
|
|
19
|
+
render(): void;
|
|
20
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Checkbox = void 0;
|
|
4
|
+
const Styler_1 = require("../core/Styler");
|
|
5
|
+
const Screen_1 = require("../core/Screen");
|
|
6
|
+
class Checkbox {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
this.isFocused = false;
|
|
9
|
+
this.id = options.id;
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.checked = options.checked || false;
|
|
12
|
+
}
|
|
13
|
+
focus() {
|
|
14
|
+
this.isFocused = true;
|
|
15
|
+
this.render();
|
|
16
|
+
}
|
|
17
|
+
blur() {
|
|
18
|
+
this.isFocused = false;
|
|
19
|
+
this.render();
|
|
20
|
+
}
|
|
21
|
+
handleKey(key) {
|
|
22
|
+
if (!this.isFocused)
|
|
23
|
+
return false;
|
|
24
|
+
if (key.name === 'space' || key.name === 'return' || key.name === 'enter') {
|
|
25
|
+
this.checked = !this.checked;
|
|
26
|
+
this.render();
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
render() {
|
|
32
|
+
const { x, y, label } = this.options;
|
|
33
|
+
const box = this.checked ? '[x]' : '[ ]';
|
|
34
|
+
const style = this.isFocused ? 'brightCyan' : 'white';
|
|
35
|
+
const boxStyle = this.checked ? 'green' : 'dim';
|
|
36
|
+
const content = Styler_1.Styler.style(box, boxStyle) + ' ' + Styler_1.Styler.style(label, style);
|
|
37
|
+
Screen_1.Screen.write(x, y, content);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
exports.Checkbox = Checkbox;
|