voonex 0.1.0 → 0.2.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 -21
- package/README.md +175 -223
- package/dist/components/Button.d.ts +1 -0
- package/dist/components/Button.js +11 -8
- package/dist/components/Input.d.ts +2 -0
- package/dist/components/Input.js +87 -22
- package/dist/components/Popup.js +1 -2
- package/dist/core/Layout.d.ts +18 -0
- package/dist/core/Layout.js +53 -0
- package/dist/core/Screen.d.ts +9 -1
- package/dist/core/Screen.js +30 -63
- package/package.json +3 -3
package/LICENSE
CHANGED
|
@@ -1,21 +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.
|
|
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
CHANGED
|
@@ -1,223 +1,175 @@
|
|
|
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
|
-
- **
|
|
10
|
-
- **
|
|
11
|
-
- **
|
|
12
|
-
- **
|
|
13
|
-
- **
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
```
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
- `Screen.
|
|
75
|
-
- `Screen.
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
###
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
###
|
|
120
|
-
A
|
|
121
|
-
|
|
122
|
-
```typescript
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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**.
|
|
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
|
+
- **Auto Layout**: Flexbox-like layout engine for responsive designs.
|
|
10
|
+
- **Layer Management**: Z-index support for Modals, Tooltips, and Popups.
|
|
11
|
+
- **Component System**: Built-in widgets like `Box`, `Menu`, `ProgressBar`, `Input`, `Table`, and more.
|
|
12
|
+
- **Reactive Rendering**: Automated screen updates via `Screen.mount()` and `Screen.scheduleRender()`.
|
|
13
|
+
- **Focus Management**: Built-in keyboard navigation and focus delegation.
|
|
14
|
+
- **Styling Engine**: Simple yet powerful API for ANSI colors and text modifiers.
|
|
15
|
+
- **TypeScript Support**: Written in TypeScript with full type definitions included.
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Install via npm:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install voonex
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start
|
|
26
|
+
|
|
27
|
+
Here is a minimal example showing how to initialize the screen and display a simple box.
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { Screen, Box, Styler, Input } from 'voonex';
|
|
31
|
+
|
|
32
|
+
// 1. Enter the alternate screen buffer
|
|
33
|
+
Screen.enter();
|
|
34
|
+
|
|
35
|
+
// 2. Define your render function
|
|
36
|
+
function render() {
|
|
37
|
+
// Render a Box at (5, 5) with a title
|
|
38
|
+
Box.render([
|
|
39
|
+
"Welcome to Voonex!",
|
|
40
|
+
"Press 'q' to exit."
|
|
41
|
+
], {
|
|
42
|
+
title: "Hello World",
|
|
43
|
+
x: 5,
|
|
44
|
+
y: 5,
|
|
45
|
+
padding: 1,
|
|
46
|
+
style: 'double',
|
|
47
|
+
borderColor: 'cyan'
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// 3. Mount the render function to the screen loop
|
|
52
|
+
Screen.mount(render);
|
|
53
|
+
|
|
54
|
+
// 4. Handle Input
|
|
55
|
+
Input.onKey((key) => {
|
|
56
|
+
if (key.name === 'q') {
|
|
57
|
+
// Leave the screen buffer properly before exiting
|
|
58
|
+
Screen.leave();
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Run it with:
|
|
65
|
+
```bash
|
|
66
|
+
npx ts-node my-app.ts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Core Concepts
|
|
70
|
+
|
|
71
|
+
### The Screen
|
|
72
|
+
The `Screen` class is the heart of Voonex. It manages the terminal buffer, handles resizing, and optimizes rendering using a diffing algorithm.
|
|
73
|
+
|
|
74
|
+
- `Screen.enter()`: Switches to the alternate buffer (like `vim` or `nano`).
|
|
75
|
+
- `Screen.leave()`: Restores the original terminal state. **Always call this before exiting.**
|
|
76
|
+
- `Screen.mount(renderFn, layer?)`: Registers a function to be called during the render cycle.
|
|
77
|
+
- `Screen.scheduleRender()`: Triggers a screen update.
|
|
78
|
+
|
|
79
|
+
#### Layer Management
|
|
80
|
+
Voonex uses a "Painter's Algorithm" with Z-index layers.
|
|
81
|
+
```typescript
|
|
82
|
+
import { Screen, Layer } from 'voonex';
|
|
83
|
+
|
|
84
|
+
Screen.mount(drawBackground, Layer.BACKGROUND); // 0
|
|
85
|
+
Screen.mount(drawContent, Layer.CONTENT); // 10
|
|
86
|
+
Screen.mount(drawPopup, Layer.MODAL); // 100
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Input Handling
|
|
90
|
+
Voonex provides global input listeners.
|
|
91
|
+
|
|
92
|
+
**Keyboard:**
|
|
93
|
+
```typescript
|
|
94
|
+
Input.onKey((key) => {
|
|
95
|
+
console.log(key.name);
|
|
96
|
+
});
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### Layout Engine
|
|
100
|
+
The `Layout` class helps calculate coordinates dynamically (Flexbox-style).
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
import { Layout, Screen } from 'voonex';
|
|
104
|
+
|
|
105
|
+
const { rects } = Layout.compute(Screen.size, {
|
|
106
|
+
direction: 'row',
|
|
107
|
+
children: [
|
|
108
|
+
{ weight: 1 }, // Left sidebar (33%)
|
|
109
|
+
{ weight: 2 } // Main content (66%)
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
const sidebarRect = rects[0];
|
|
114
|
+
const contentRect = rects[1];
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Components
|
|
118
|
+
|
|
119
|
+
### Box
|
|
120
|
+
A container for text with optional borders, padding, and titles.
|
|
121
|
+
|
|
122
|
+
```typescript
|
|
123
|
+
Box.render([
|
|
124
|
+
"Line 1",
|
|
125
|
+
"Line 2"
|
|
126
|
+
], {
|
|
127
|
+
x: 2, y: 2,
|
|
128
|
+
width: 30,
|
|
129
|
+
borderColor: 'green',
|
|
130
|
+
style: 'round' // 'single', 'double', or 'round'
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Button
|
|
135
|
+
Interactive button that supports Enter.
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
const btn = new Button({
|
|
139
|
+
id: 'submit',
|
|
140
|
+
text: "Submit",
|
|
141
|
+
x: 10, y: 10,
|
|
142
|
+
onPress: () => submitForm()
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Input Field
|
|
147
|
+
A fully featured text editor with cursor support, scrolling, and editing keys.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
const nameInput = new InputField({
|
|
151
|
+
x: 2, y: 2,
|
|
152
|
+
width: 20,
|
|
153
|
+
placeholder: "Enter name..."
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
Input.onKey(key => {
|
|
157
|
+
// Navigate focus
|
|
158
|
+
if (key.name === 'tab') nameInput.focus();
|
|
159
|
+
|
|
160
|
+
// Handle typing (Home, End, Arrows, Backspace supported)
|
|
161
|
+
nameInput.handleKey(key);
|
|
162
|
+
});
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Popup
|
|
166
|
+
A modal dialog that overlays other content (uses `Layer.MODAL`).
|
|
167
|
+
|
|
168
|
+
```typescript
|
|
169
|
+
// Shows a message and waits for user to press Enter/Esc
|
|
170
|
+
await Popup.alert("This is an important message!", { title: "Alert" });
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
This project is under the **MIT License**.
|
|
@@ -23,18 +23,21 @@ class Button {
|
|
|
23
23
|
if (!this.isFocused)
|
|
24
24
|
return false;
|
|
25
25
|
if (key.name === 'return' || key.name === 'enter' || key.name === 'space') {
|
|
26
|
-
this.
|
|
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);
|
|
26
|
+
this.triggerPress();
|
|
34
27
|
return true;
|
|
35
28
|
}
|
|
36
29
|
return false;
|
|
37
30
|
}
|
|
31
|
+
triggerPress() {
|
|
32
|
+
this.isPressed = true;
|
|
33
|
+
this.render(); // Show pressed state visually
|
|
34
|
+
// Trigger action slightly delayed to show visual feedback
|
|
35
|
+
setTimeout(() => {
|
|
36
|
+
this.isPressed = false;
|
|
37
|
+
this.render();
|
|
38
|
+
this.options.onPress();
|
|
39
|
+
}, 150);
|
|
40
|
+
}
|
|
38
41
|
render() {
|
|
39
42
|
const { x, y, text, width } = this.options;
|
|
40
43
|
let label = text;
|
package/dist/components/Input.js
CHANGED
|
@@ -7,35 +7,78 @@ class InputField {
|
|
|
7
7
|
constructor(options) {
|
|
8
8
|
this.value = "";
|
|
9
9
|
this.isFocused = false;
|
|
10
|
+
// New properties for editing
|
|
11
|
+
this.cursorIndex = 0;
|
|
12
|
+
this.scrollOffset = 0;
|
|
10
13
|
this.id = options.id;
|
|
11
14
|
this.options = { width: 30, type: 'text', ...options };
|
|
12
15
|
}
|
|
13
16
|
focus() {
|
|
14
17
|
this.isFocused = true;
|
|
18
|
+
this.render();
|
|
15
19
|
}
|
|
16
20
|
blur() {
|
|
17
21
|
this.isFocused = false;
|
|
22
|
+
this.render();
|
|
18
23
|
}
|
|
19
24
|
setValue(val) {
|
|
20
25
|
this.value = val;
|
|
26
|
+
this.cursorIndex = val.length;
|
|
27
|
+
this.render();
|
|
21
28
|
}
|
|
22
29
|
handleKey(key) {
|
|
23
30
|
if (!this.isFocused)
|
|
24
31
|
return false;
|
|
32
|
+
let consumed = false;
|
|
25
33
|
if (key.name === 'backspace') {
|
|
26
|
-
this.
|
|
27
|
-
|
|
28
|
-
|
|
34
|
+
if (this.cursorIndex > 0) {
|
|
35
|
+
this.value = this.value.slice(0, this.cursorIndex - 1) + this.value.slice(this.cursorIndex);
|
|
36
|
+
this.cursorIndex--;
|
|
37
|
+
consumed = true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
else if (key.name === 'delete') { // Forward delete
|
|
41
|
+
if (this.cursorIndex < this.value.length) {
|
|
42
|
+
this.value = this.value.slice(0, this.cursorIndex) + this.value.slice(this.cursorIndex + 1);
|
|
43
|
+
consumed = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else if (key.name === 'left') {
|
|
47
|
+
if (this.cursorIndex > 0) {
|
|
48
|
+
this.cursorIndex--;
|
|
49
|
+
consumed = true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
else if (key.name === 'right') {
|
|
53
|
+
if (this.cursorIndex < this.value.length) {
|
|
54
|
+
this.cursorIndex++;
|
|
55
|
+
consumed = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else if (key.name === 'home') {
|
|
59
|
+
if (this.cursorIndex > 0) {
|
|
60
|
+
this.cursorIndex = 0;
|
|
61
|
+
consumed = true;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
else if (key.name === 'end') {
|
|
65
|
+
if (this.cursorIndex < this.value.length) {
|
|
66
|
+
this.cursorIndex = this.value.length;
|
|
67
|
+
consumed = true;
|
|
68
|
+
}
|
|
29
69
|
}
|
|
30
70
|
else if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
31
71
|
// Basic text input filtering
|
|
32
|
-
// Only accept printable characters (rough check)
|
|
33
72
|
if (/^[\x20-\x7E]$/.test(key.sequence)) {
|
|
34
|
-
this.value
|
|
35
|
-
this.
|
|
36
|
-
|
|
73
|
+
this.value = this.value.slice(0, this.cursorIndex) + key.sequence + this.value.slice(this.cursorIndex);
|
|
74
|
+
this.cursorIndex++;
|
|
75
|
+
consumed = true;
|
|
37
76
|
}
|
|
38
77
|
}
|
|
78
|
+
if (consumed) {
|
|
79
|
+
this.render();
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
39
82
|
// Did not consume (e.g., arrow keys, tabs)
|
|
40
83
|
return false;
|
|
41
84
|
}
|
|
@@ -51,25 +94,47 @@ class InputField {
|
|
|
51
94
|
// Draw Input Box Background
|
|
52
95
|
const inputX = x + labelLen;
|
|
53
96
|
const maxWidth = (width || 30) - labelLen;
|
|
54
|
-
|
|
97
|
+
// Adjust scrollOffset to keep cursor in view
|
|
98
|
+
if (this.cursorIndex < this.scrollOffset) {
|
|
99
|
+
this.scrollOffset = this.cursorIndex;
|
|
100
|
+
}
|
|
101
|
+
else if (this.cursorIndex >= this.scrollOffset + maxWidth) {
|
|
102
|
+
this.scrollOffset = this.cursorIndex - maxWidth + 1;
|
|
103
|
+
}
|
|
104
|
+
// Clamp scrollOffset
|
|
105
|
+
if (this.scrollOffset < 0)
|
|
106
|
+
this.scrollOffset = 0;
|
|
107
|
+
let displayValue = this.value.substring(this.scrollOffset, this.scrollOffset + maxWidth);
|
|
55
108
|
if (type === 'password') {
|
|
56
|
-
displayValue = '*'.repeat(
|
|
109
|
+
displayValue = '*'.repeat(displayValue.length);
|
|
57
110
|
}
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
111
|
+
// Prepare content with cursor
|
|
112
|
+
let renderedContent = "";
|
|
113
|
+
const cursorRelPos = this.cursorIndex - this.scrollOffset;
|
|
114
|
+
// Iterate through visible area
|
|
115
|
+
for (let i = 0; i < maxWidth; i++) {
|
|
116
|
+
const charIndex = this.scrollOffset + i;
|
|
117
|
+
let char = "";
|
|
118
|
+
if (charIndex < this.value.length) {
|
|
119
|
+
char = type === 'password' ? '*' : this.value[charIndex];
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
char = " ";
|
|
123
|
+
}
|
|
124
|
+
if (this.isFocused && i === cursorRelPos) {
|
|
125
|
+
// Draw cursor
|
|
126
|
+
renderedContent += Styler_1.Styler.style(char === " " ? " " : char, 'bgGreen', 'black');
|
|
127
|
+
}
|
|
128
|
+
else {
|
|
129
|
+
renderedContent += char;
|
|
130
|
+
}
|
|
66
131
|
}
|
|
67
|
-
|
|
68
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
132
|
+
// If cursor is at the very end (past last char), we need to handle it
|
|
133
|
+
// The loop above goes up to maxWidth.
|
|
134
|
+
// If cursorRelPos == displayValue.length, it might be drawn?
|
|
135
|
+
// Actually the loop ensures we draw maxWidth chars.
|
|
71
136
|
const style = this.isFocused ? 'white' : 'gray';
|
|
72
|
-
Screen_1.Screen.write(inputX, y, Styler_1.Styler.style(
|
|
137
|
+
Screen_1.Screen.write(inputX, y, Styler_1.Styler.style(renderedContent, style));
|
|
73
138
|
// Underline
|
|
74
139
|
const underline = '─'.repeat(maxWidth);
|
|
75
140
|
const underlineColor = this.isFocused ? 'brightGreen' : 'dim';
|
package/dist/components/Popup.js
CHANGED
|
@@ -33,8 +33,7 @@ class Popup {
|
|
|
33
33
|
});
|
|
34
34
|
Screen_1.Screen.write(x + 2, y + boxHeight - 1, Styler_1.Styler.style("[Press Enter]", 'dim'));
|
|
35
35
|
};
|
|
36
|
-
|
|
37
|
-
Screen_1.Screen.mount(renderPopup, POPUP_Z_INDEX);
|
|
36
|
+
Screen_1.Screen.mount(renderPopup, Screen_1.Layer.MODAL);
|
|
38
37
|
const handler = (key) => {
|
|
39
38
|
if (key.name === 'return' || key.name === 'enter' || key.name === 'escape') {
|
|
40
39
|
Screen_1.Screen.unmount(renderPopup);
|
package/dist/core/Layout.d.ts
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
import { Rect } from './Screen';
|
|
2
2
|
export { Rect };
|
|
3
|
+
export interface LayoutOptions {
|
|
4
|
+
direction: 'row' | 'column';
|
|
5
|
+
gap?: number;
|
|
6
|
+
children: LayoutItem[];
|
|
7
|
+
}
|
|
8
|
+
export interface LayoutItem {
|
|
9
|
+
weight?: number;
|
|
10
|
+
fixed?: number;
|
|
11
|
+
id?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface LayoutResult {
|
|
14
|
+
rects: Rect[];
|
|
15
|
+
map: Map<string, Rect>;
|
|
16
|
+
}
|
|
3
17
|
export declare class Layout {
|
|
4
18
|
/**
|
|
5
19
|
* Splits a rectangle vertically (into columns).
|
|
@@ -15,4 +29,8 @@ export declare class Layout {
|
|
|
15
29
|
* Creates a padded inner rectangle.
|
|
16
30
|
*/
|
|
17
31
|
static pad(rect: Rect, padding: number): Rect;
|
|
32
|
+
/**
|
|
33
|
+
* Advanced Layout Calculator (Flexbox-like)
|
|
34
|
+
*/
|
|
35
|
+
static compute(parent: Rect, options: LayoutOptions): LayoutResult;
|
|
18
36
|
}
|
package/dist/core/Layout.js
CHANGED
|
@@ -61,5 +61,58 @@ class Layout {
|
|
|
61
61
|
height: Math.max(0, rect.height - (padding * 2))
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* Advanced Layout Calculator (Flexbox-like)
|
|
66
|
+
*/
|
|
67
|
+
static compute(parent, options) {
|
|
68
|
+
const { direction, gap = 0, children } = options;
|
|
69
|
+
const count = children.length;
|
|
70
|
+
if (count === 0)
|
|
71
|
+
return { rects: [], map: new Map() };
|
|
72
|
+
const totalGap = gap * (count - 1);
|
|
73
|
+
const availableSpace = (direction === 'row' ? parent.width : parent.height) - totalGap;
|
|
74
|
+
// 1. Calculate Fixed Sizes
|
|
75
|
+
let usedSpace = 0;
|
|
76
|
+
let totalWeight = 0;
|
|
77
|
+
children.forEach(child => {
|
|
78
|
+
if (child.fixed !== undefined) {
|
|
79
|
+
usedSpace += child.fixed;
|
|
80
|
+
}
|
|
81
|
+
else {
|
|
82
|
+
totalWeight += (child.weight || 1);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
const remainingSpace = Math.max(0, availableSpace - usedSpace);
|
|
86
|
+
const unitSpace = totalWeight > 0 ? remainingSpace / totalWeight : 0;
|
|
87
|
+
// 2. Compute Rects
|
|
88
|
+
const rects = [];
|
|
89
|
+
const map = new Map();
|
|
90
|
+
let currentPos = direction === 'row' ? parent.x : parent.y;
|
|
91
|
+
children.forEach((child, i) => {
|
|
92
|
+
let size = 0;
|
|
93
|
+
if (child.fixed !== undefined) {
|
|
94
|
+
size = child.fixed;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
size = Math.floor((child.weight || 1) * unitSpace);
|
|
98
|
+
// Last dynamic item gets rounding dust?
|
|
99
|
+
// We should be careful about accumulating errors.
|
|
100
|
+
// But for now simple floor is okay, maybe we can improve later.
|
|
101
|
+
}
|
|
102
|
+
// Adjust for last item to fill gap if we used flooring?
|
|
103
|
+
// Actually, let's keep it simple.
|
|
104
|
+
const rect = {
|
|
105
|
+
x: direction === 'row' ? currentPos : parent.x,
|
|
106
|
+
y: direction === 'column' ? currentPos : parent.y,
|
|
107
|
+
width: direction === 'row' ? size : parent.width,
|
|
108
|
+
height: direction === 'column' ? size : parent.height
|
|
109
|
+
};
|
|
110
|
+
rects.push(rect);
|
|
111
|
+
if (child.id)
|
|
112
|
+
map.set(child.id, rect);
|
|
113
|
+
currentPos += size + gap;
|
|
114
|
+
});
|
|
115
|
+
return { rects, map };
|
|
116
|
+
}
|
|
64
117
|
}
|
|
65
118
|
exports.Layout = Layout;
|
package/dist/core/Screen.d.ts
CHANGED
|
@@ -4,6 +4,13 @@ export interface Rect {
|
|
|
4
4
|
width: number;
|
|
5
5
|
height: number;
|
|
6
6
|
}
|
|
7
|
+
export declare const Layer: {
|
|
8
|
+
BACKGROUND: number;
|
|
9
|
+
CONTENT: number;
|
|
10
|
+
MODAL: number;
|
|
11
|
+
TOOLTIP: number;
|
|
12
|
+
MAX: number;
|
|
13
|
+
};
|
|
7
14
|
export declare class Screen {
|
|
8
15
|
private static isAlternateBuffer;
|
|
9
16
|
private static resizeTimeout;
|
|
@@ -24,6 +31,7 @@ export declare class Screen {
|
|
|
24
31
|
* Leaves the Alternate Screen Buffer and restores cursor.
|
|
25
32
|
*/
|
|
26
33
|
static leave(): void;
|
|
34
|
+
private static setupSignalHandlers;
|
|
27
35
|
private static resizeBuffers;
|
|
28
36
|
/**
|
|
29
37
|
* Schedules a render flush in the next tick.
|
|
@@ -51,7 +59,7 @@ export declare class Screen {
|
|
|
51
59
|
/**
|
|
52
60
|
* Registers a root component for the rendering loop (Painter's Algorithm).
|
|
53
61
|
* @param renderFn The function that renders the component
|
|
54
|
-
* @param zIndex Priority (higher draws on top)
|
|
62
|
+
* @param zIndex Priority (higher draws on top). Use Layer.* constants.
|
|
55
63
|
*/
|
|
56
64
|
static mount(renderFn: () => void, zIndex?: number): void;
|
|
57
65
|
/**
|
package/dist/core/Screen.js
CHANGED
|
@@ -3,8 +3,17 @@
|
|
|
3
3
|
// CORE: SCREEN MANAGER (The "Canvas")
|
|
4
4
|
// ==========================================
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
-
exports.Screen = void 0;
|
|
6
|
+
exports.Screen = exports.Layer = void 0;
|
|
7
7
|
const Cursor_1 = require("./Cursor");
|
|
8
|
+
const Input_1 = require("./Input");
|
|
9
|
+
// Z-Index Layers
|
|
10
|
+
exports.Layer = {
|
|
11
|
+
BACKGROUND: 0,
|
|
12
|
+
CONTENT: 10,
|
|
13
|
+
MODAL: 100,
|
|
14
|
+
TOOLTIP: 1000,
|
|
15
|
+
MAX: 9999
|
|
16
|
+
};
|
|
8
17
|
class Screen {
|
|
9
18
|
/**
|
|
10
19
|
* Enters the Alternate Screen Buffer (like Vim or Nano).
|
|
@@ -21,6 +30,8 @@ class Screen {
|
|
|
21
30
|
this.resizeBuffers();
|
|
22
31
|
this.scheduleRender();
|
|
23
32
|
});
|
|
33
|
+
// Setup graceful shutdown signals
|
|
34
|
+
this.setupSignalHandlers();
|
|
24
35
|
}
|
|
25
36
|
/**
|
|
26
37
|
* Leaves the Alternate Screen Buffer and restores cursor.
|
|
@@ -28,11 +39,23 @@ class Screen {
|
|
|
28
39
|
static leave() {
|
|
29
40
|
if (!this.isAlternateBuffer)
|
|
30
41
|
return;
|
|
31
|
-
//
|
|
42
|
+
// Cleanup Input (disable mouse, reset raw mode)
|
|
43
|
+
Input_1.Input.reset();
|
|
32
44
|
Cursor_1.Cursor.show();
|
|
33
45
|
process.stdout.write('\x1b[?1049l'); // Leave alternate buffer
|
|
34
46
|
this.isAlternateBuffer = false;
|
|
35
47
|
}
|
|
48
|
+
static setupSignalHandlers() {
|
|
49
|
+
// Prevent multiple handlers if called multiple times
|
|
50
|
+
// But for static class, it's fine.
|
|
51
|
+
const cleanup = () => {
|
|
52
|
+
Screen.leave();
|
|
53
|
+
process.exit(0);
|
|
54
|
+
};
|
|
55
|
+
process.on('SIGINT', cleanup);
|
|
56
|
+
process.on('SIGTERM', cleanup);
|
|
57
|
+
// We can't catch SIGKILL
|
|
58
|
+
}
|
|
36
59
|
static resizeBuffers() {
|
|
37
60
|
this.width = process.stdout.columns || 80;
|
|
38
61
|
this.height = process.stdout.rows || 24;
|
|
@@ -166,9 +189,9 @@ class Screen {
|
|
|
166
189
|
/**
|
|
167
190
|
* Registers a root component for the rendering loop (Painter's Algorithm).
|
|
168
191
|
* @param renderFn The function that renders the component
|
|
169
|
-
* @param zIndex Priority (higher draws on top)
|
|
192
|
+
* @param zIndex Priority (higher draws on top). Use Layer.* constants.
|
|
170
193
|
*/
|
|
171
|
-
static mount(renderFn, zIndex =
|
|
194
|
+
static mount(renderFn, zIndex = exports.Layer.CONTENT) {
|
|
172
195
|
this.renderRoots.push({ render: renderFn, zIndex });
|
|
173
196
|
this.renderRoots.sort((a, b) => a.zIndex - b.zIndex);
|
|
174
197
|
this.scheduleRender();
|
|
@@ -188,49 +211,18 @@ class Screen {
|
|
|
188
211
|
static flush() {
|
|
189
212
|
// Painter's Algorithm Phase: Re-run all render functions if any exist
|
|
190
213
|
// This clears the buffer and redraws everything from scratch (logically)
|
|
191
|
-
// But to keep performance, we might want to just let them draw over currentBuffer?
|
|
192
|
-
// User asked for: "1. Xóa Buffer ảo. 2. Duyệt qua danh sách...".
|
|
193
|
-
// If we have registered roots, we should follow this.
|
|
194
214
|
if (this.renderRoots.length > 0) {
|
|
195
|
-
//
|
|
196
|
-
// Or maybe just let them overwrite. If transparency is involved, clearing is safer.
|
|
197
|
-
// But clearing everything is expensive if we just diff later.
|
|
198
|
-
// The Diff algorithm handles the "screen update" optimization.
|
|
199
|
-
// The "Virtual Buffer" update needs to be correct.
|
|
200
|
-
// If we have layers, and top layer moves, we need to redraw bottom layer to see what's behind.
|
|
201
|
-
// So YES, we must clear currentBuffer (or reset it to background state) and redraw all layers.
|
|
202
|
-
// Reset currentBuffer to empty/clean state WITHOUT affecting previousBuffer (screen state)
|
|
215
|
+
// Reset currentBuffer to empty/clean state
|
|
203
216
|
for (let y = 0; y < this.height; y++) {
|
|
204
217
|
for (let x = 0; x < this.width; x++) {
|
|
205
218
|
this.currentBuffer[y][x] = { char: ' ', style: '' };
|
|
206
219
|
}
|
|
207
|
-
// We don't mark dirtyRows here blindly, we mark them if they CHANGE in the diff phase.
|
|
208
|
-
// Wait, dirtyRows optimization relies on us knowing which rows MIGHT have changed.
|
|
209
|
-
// If we clear everything, we potentially change everything.
|
|
210
|
-
// So we should mark all as dirty?
|
|
211
|
-
// "Dirty" means "Virtual Buffer Row differs from Previous Buffer Row".
|
|
212
|
-
// Since we are rebuilding Virtual Buffer, we don't know yet.
|
|
213
|
-
// We should just run the diff loop on all rows?
|
|
214
|
-
// Or maybe we can track which rows are touched during render?
|
|
215
220
|
}
|
|
216
|
-
// Render all layers
|
|
221
|
+
// Render all layers in sorted order
|
|
217
222
|
for (const root of this.renderRoots) {
|
|
218
223
|
root.render();
|
|
219
224
|
}
|
|
220
|
-
// Mark all rows as dirty
|
|
221
|
-
// Since we rebuilt the buffer, any row could be different from previousBuffer.
|
|
222
|
-
// Optimization: Maybe only rows that were touched?
|
|
223
|
-
// But 'write' marks dirtyRows.
|
|
224
|
-
// So if we clear buffer (resetting chars), we are effectively writing spaces.
|
|
225
|
-
// If we use 'clear()' method, it marks all dirty.
|
|
226
|
-
// Let's rely on 'write' marking dirtyRows.
|
|
227
|
-
// But we just did manual reset loop above.
|
|
228
|
-
// Let's check:
|
|
229
|
-
// If we reset manual loop, we are changing 'currentBuffer'.
|
|
230
|
-
// Previous buffer holds what is on screen.
|
|
231
|
-
// If we don't mark dirtyRows, flush() skips the row.
|
|
232
|
-
// If screen has text, and we cleared it to spaces, and didn't mark dirty, screen stays text. Bad.
|
|
233
|
-
// So yes, we should mark all dirty if we do a full clear-redraw cycle.
|
|
225
|
+
// Mark all rows as dirty because we rebuilt the buffer from scratch
|
|
234
226
|
this.dirtyRows.fill(true);
|
|
235
227
|
}
|
|
236
228
|
if (!this.currentBuffer.length)
|
|
@@ -261,8 +253,6 @@ class Screen {
|
|
|
261
253
|
const diff = x - (lastX + 1);
|
|
262
254
|
if (diff > 0)
|
|
263
255
|
output += `\x1b[${diff}C`;
|
|
264
|
-
// If diff is 0 (next char), we do nothing.
|
|
265
|
-
// Actually if x = lastX + 2, diff is 1. We need \x1b[1C.
|
|
266
256
|
}
|
|
267
257
|
else {
|
|
268
258
|
// Absolute Move
|
|
@@ -279,23 +269,6 @@ class Screen {
|
|
|
279
269
|
while (nextX < this.width) {
|
|
280
270
|
const nextCell = this.currentBuffer[y][nextX];
|
|
281
271
|
const nextPrev = this.previousBuffer[y][nextX];
|
|
282
|
-
// We only batch if the NEXT cell also NEEDS update AND has SAME style
|
|
283
|
-
// Actually, even if next cell DOES NOT need update,
|
|
284
|
-
// if we write over it with same content, it's fine (redundant write but saves cursor move).
|
|
285
|
-
// BUT, if we write over it, we might overwrite something valid with something else?
|
|
286
|
-
// No, currentBuffer is truth.
|
|
287
|
-
// Strategy:
|
|
288
|
-
// 1. Only batch consecutive CHANGED cells?
|
|
289
|
-
// 2. Or batch consecutive cells regardless, as long as style matches, to avoid cursor jumps?
|
|
290
|
-
// If we skip a cell (not changed), we have to move cursor.
|
|
291
|
-
// Moving cursor costs bytes (e.g. \x1b[C is 3 bytes).
|
|
292
|
-
// Writing a char is 1 byte.
|
|
293
|
-
// So overwriting valid char is cheaper than skipping 1-2 chars.
|
|
294
|
-
// But if we skip 10 chars, move is cheaper.
|
|
295
|
-
// Let's stick to simple logic: Only batch if next cell ALSO needs update OR we just overwrite it anyway to jump gap?
|
|
296
|
-
// The user asked for "Gộp chuỗi ký tự liên tiếp có cùng style".
|
|
297
|
-
// If we strictly follow diff, we only write changed cells.
|
|
298
|
-
// So let's look for consecutive CHANGED cells with same style.
|
|
299
272
|
if (nextCell.char !== nextPrev.char || nextCell.style !== nextPrev.style) {
|
|
300
273
|
if (nextCell.style === cell.style) {
|
|
301
274
|
batch += nextCell.char;
|
|
@@ -308,10 +281,6 @@ class Screen {
|
|
|
308
281
|
}
|
|
309
282
|
}
|
|
310
283
|
else {
|
|
311
|
-
// Next cell is not changed.
|
|
312
|
-
// Should we continue batching to bridge the gap?
|
|
313
|
-
// If gap is small (1-2 chars), yes.
|
|
314
|
-
// But that complicates logic. Let's stop batching.
|
|
315
284
|
break;
|
|
316
285
|
}
|
|
317
286
|
}
|
|
@@ -326,8 +295,6 @@ class Screen {
|
|
|
326
295
|
}
|
|
327
296
|
}
|
|
328
297
|
if (output.length > 0) {
|
|
329
|
-
// Reset style
|
|
330
|
-
// output += '\x1b[0m';
|
|
331
298
|
process.stdout.write(output);
|
|
332
299
|
}
|
|
333
300
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voonex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A zero-dependency Terminal UI Library for Node.js.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "CodeTease",
|
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
"prepublishOnly": "npm run build"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
|
-
"
|
|
20
|
+
"@types/node": "^20.4.2",
|
|
21
21
|
"ts-node": "10.9.2",
|
|
22
|
-
"
|
|
22
|
+
"typescript": "5.9.3"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|