voonex 0.1.0 → 0.3.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 +205 -223
- package/dist/components/Button.d.ts +10 -3
- package/dist/components/Button.js +26 -14
- 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/Component.d.ts +34 -0
- package/dist/core/Component.js +31 -0
- 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/dist/core/Signal.d.ts +22 -0
- package/dist/core/Signal.js +42 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/package.json +4 -4
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,205 @@
|
|
|
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
|
-
Screen
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
//
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
Screen.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
-
|
|
75
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
###
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
```typescript
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
+
- **Reactive Signals**: Modern state management inspired by SolidJS. State changes automatically trigger updates.
|
|
10
|
+
- **Component Lifecycle**: Class-based components with `mount`, `unmount`, and lifecycle hooks.
|
|
11
|
+
- **Auto Layout**: Flexbox-like layout engine for responsive designs.
|
|
12
|
+
- **Layer Management**: Z-index support for Modals, Tooltips, and Popups.
|
|
13
|
+
- **Component System**: Built-in widgets like `Box`, `Menu`, `ProgressBar`, `Input`, `Table`, and more.
|
|
14
|
+
- **Focus Management**: Built-in keyboard navigation and focus delegation.
|
|
15
|
+
- **Styling Engine**: Simple yet powerful API for ANSI colors and text modifiers.
|
|
16
|
+
- **TypeScript Support**: Written in TypeScript with full type definitions included.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
Install via npm:
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install voonex
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
Here is a minimal example showing how to create a reactive counter app.
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import { Screen, Component, createSignal, Input } from 'voonex';
|
|
32
|
+
|
|
33
|
+
// 1. Create a Component
|
|
34
|
+
class CounterApp extends Component {
|
|
35
|
+
// Define reactive state
|
|
36
|
+
private count = createSignal(0);
|
|
37
|
+
|
|
38
|
+
// Getters/Setters for convenience
|
|
39
|
+
get value() { return this.count[0](); }
|
|
40
|
+
set value(v) { this.count[1](v); }
|
|
41
|
+
|
|
42
|
+
constructor() {
|
|
43
|
+
super();
|
|
44
|
+
|
|
45
|
+
// Handle input to increment
|
|
46
|
+
Input.onKey(key => {
|
|
47
|
+
if (key.name === 'up') this.value = this.value + 1;
|
|
48
|
+
if (key.name === 'down') this.value = this.value - 1;
|
|
49
|
+
if (key.name === 'q') {
|
|
50
|
+
Screen.leave();
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// 2. Implement render()
|
|
57
|
+
// It runs automatically whenever 'this.value' changes!
|
|
58
|
+
render() {
|
|
59
|
+
Screen.write(5, 5, `Count: ${this.value} `);
|
|
60
|
+
Screen.write(5, 7, "Press Up/Down to change, Q to quit.");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 3. Setup Screen
|
|
65
|
+
Screen.enter();
|
|
66
|
+
|
|
67
|
+
// 4. Mount the App
|
|
68
|
+
const app = new CounterApp();
|
|
69
|
+
app.mount();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Run it with:
|
|
73
|
+
```bash
|
|
74
|
+
npx ts-node my-app.ts
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Core Concepts
|
|
78
|
+
|
|
79
|
+
### Reactive Signals
|
|
80
|
+
Voonex uses a fine-grained reactivity system. When you update a signal, Voonex automatically schedules a render for the next tick. No manual `render()` calls required.
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
import { createSignal } from 'voonex';
|
|
84
|
+
|
|
85
|
+
const [count, setCount] = createSignal(0);
|
|
86
|
+
|
|
87
|
+
// Reading the value
|
|
88
|
+
console.log(count());
|
|
89
|
+
|
|
90
|
+
// Updating the value (triggers UI update)
|
|
91
|
+
setCount(5);
|
|
92
|
+
setCount(prev => prev + 1);
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Components & Lifecycle
|
|
96
|
+
Components extend the `Component` abstract class.
|
|
97
|
+
|
|
98
|
+
- `mount(zIndex?)`: Registers the component to the screen loop.
|
|
99
|
+
- `unmount()`: Removes the component.
|
|
100
|
+
- `render()`: The drawing logic.
|
|
101
|
+
|
|
102
|
+
**Lifecycle Hooks:**
|
|
103
|
+
- `init()`: Called on instantiation.
|
|
104
|
+
- `onMount()`: Called after mounting.
|
|
105
|
+
- `onUnmount()`: Called after unmounting.
|
|
106
|
+
- `destroy()`: Cleanup hook.
|
|
107
|
+
|
|
108
|
+
### The Screen
|
|
109
|
+
The `Screen` class is the heart of Voonex. It manages the terminal buffer, handles resizing, and optimizes rendering using a diffing algorithm.
|
|
110
|
+
|
|
111
|
+
- `Screen.enter()`: Switches to the alternate buffer (like `vim` or `nano`).
|
|
112
|
+
- `Screen.leave()`: Restores the original terminal state. **Always call this before exiting.**
|
|
113
|
+
|
|
114
|
+
#### Layer Management
|
|
115
|
+
Voonex uses a "Painter's Algorithm" with Z-index layers.
|
|
116
|
+
```typescript
|
|
117
|
+
import { Layer } from 'voonex';
|
|
118
|
+
|
|
119
|
+
// Components handle this automatically via mount()
|
|
120
|
+
myComponent.mount(Layer.MODAL);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
### Layout Engine
|
|
124
|
+
The `Layout` class helps calculate coordinates dynamically (Flexbox-style).
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { Layout, Screen } from 'voonex';
|
|
128
|
+
|
|
129
|
+
const { rects } = Layout.compute(Screen.size, {
|
|
130
|
+
direction: 'row',
|
|
131
|
+
children: [
|
|
132
|
+
{ weight: 1 }, // Left sidebar (33%)
|
|
133
|
+
{ weight: 2 } // Main content (66%)
|
|
134
|
+
]
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const sidebarRect = rects[0];
|
|
138
|
+
const contentRect = rects[1];
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## Built-in Components
|
|
142
|
+
|
|
143
|
+
### Box
|
|
144
|
+
A container for text with optional borders, padding, and titles.
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
Box.render([
|
|
148
|
+
"Line 1",
|
|
149
|
+
"Line 2"
|
|
150
|
+
], {
|
|
151
|
+
x: 2, y: 2,
|
|
152
|
+
width: 30,
|
|
153
|
+
borderColor: 'green',
|
|
154
|
+
style: 'round' // 'single', 'double', or 'round'
|
|
155
|
+
});
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Button (Reactive)
|
|
159
|
+
Interactive button that supports focus and press states.
|
|
160
|
+
|
|
161
|
+
```typescript
|
|
162
|
+
const btn = new Button({
|
|
163
|
+
id: 'submit',
|
|
164
|
+
text: "Submit",
|
|
165
|
+
x: 10, y: 10,
|
|
166
|
+
onPress: () => submitForm()
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
btn.mount(); // Don't forget to mount!
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### Input Field
|
|
173
|
+
A fully featured text editor with cursor support, scrolling, and editing keys.
|
|
174
|
+
|
|
175
|
+
```typescript
|
|
176
|
+
const nameInput = new InputField({
|
|
177
|
+
x: 2, y: 2,
|
|
178
|
+
width: 20,
|
|
179
|
+
placeholder: "Enter name..."
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
Input.onKey(key => {
|
|
183
|
+
// Navigate focus
|
|
184
|
+
if (key.name === 'tab') nameInput.focus();
|
|
185
|
+
|
|
186
|
+
// Handle typing (Home, End, Arrows, Backspace supported)
|
|
187
|
+
nameInput.handleKey(key);
|
|
188
|
+
});
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Popup
|
|
192
|
+
A modal dialog that overlays other content (uses `Layer.MODAL`).
|
|
193
|
+
|
|
194
|
+
```typescript
|
|
195
|
+
// Shows a message and waits for user to press Enter/Esc
|
|
196
|
+
await Popup.alert("This is an important message!", { title: "Alert" });
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
> Voonex is currently in Beta stage.
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
## License
|
|
204
|
+
|
|
205
|
+
This project is under the **MIT License**.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { Component } from '../core/Component';
|
|
1
2
|
import { Focusable } from '../core/Focus';
|
|
2
3
|
import * as readline from 'readline';
|
|
3
4
|
interface ButtonOptions {
|
|
@@ -9,15 +10,21 @@ interface ButtonOptions {
|
|
|
9
10
|
style?: 'simple' | 'brackets';
|
|
10
11
|
onPress: () => void;
|
|
11
12
|
}
|
|
12
|
-
export declare class Button implements Focusable {
|
|
13
|
+
export declare class Button extends Component implements Focusable {
|
|
13
14
|
id: string;
|
|
15
|
+
parent?: Focusable;
|
|
14
16
|
private options;
|
|
15
|
-
private
|
|
16
|
-
private
|
|
17
|
+
private isFocusedSignal;
|
|
18
|
+
private isPressedSignal;
|
|
19
|
+
private get isFocused();
|
|
20
|
+
private set isFocused(value);
|
|
21
|
+
private get isPressed();
|
|
22
|
+
private set isPressed(value);
|
|
17
23
|
constructor(options: ButtonOptions);
|
|
18
24
|
focus(): void;
|
|
19
25
|
blur(): void;
|
|
20
26
|
handleKey(key: readline.Key): boolean;
|
|
27
|
+
private triggerPress;
|
|
21
28
|
render(): void;
|
|
22
29
|
}
|
|
23
30
|
export {};
|
|
@@ -3,40 +3,53 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.Button = void 0;
|
|
4
4
|
const Styler_1 = require("../core/Styler");
|
|
5
5
|
const Screen_1 = require("../core/Screen");
|
|
6
|
-
|
|
6
|
+
const Component_1 = require("../core/Component");
|
|
7
|
+
const Signal_1 = require("../core/Signal");
|
|
8
|
+
class Button extends Component_1.Component {
|
|
9
|
+
// Getters/Setters for convenience
|
|
10
|
+
get isFocused() { return this.isFocusedSignal[0](); }
|
|
11
|
+
set isFocused(v) { this.isFocusedSignal[1](v); }
|
|
12
|
+
get isPressed() { return this.isPressedSignal[0](); }
|
|
13
|
+
set isPressed(v) { this.isPressedSignal[1](v); }
|
|
7
14
|
constructor(options) {
|
|
8
|
-
|
|
9
|
-
|
|
15
|
+
super();
|
|
16
|
+
// State managed by signals (implicit render on change)
|
|
17
|
+
this.isFocusedSignal = (0, Signal_1.createSignal)(false);
|
|
18
|
+
this.isPressedSignal = (0, Signal_1.createSignal)(false);
|
|
10
19
|
this.id = options.id;
|
|
11
20
|
this.options = { style: 'brackets', ...options };
|
|
12
21
|
}
|
|
13
22
|
focus() {
|
|
14
23
|
this.isFocused = true;
|
|
15
|
-
|
|
24
|
+
// No need to call render(), signal handles it
|
|
16
25
|
}
|
|
17
26
|
blur() {
|
|
18
27
|
this.isFocused = false;
|
|
19
28
|
this.isPressed = false;
|
|
20
|
-
|
|
29
|
+
// No need to call render(), signal handles it
|
|
21
30
|
}
|
|
22
31
|
handleKey(key) {
|
|
23
32
|
if (!this.isFocused)
|
|
24
33
|
return false;
|
|
25
34
|
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);
|
|
35
|
+
this.triggerPress();
|
|
34
36
|
return true;
|
|
35
37
|
}
|
|
36
38
|
return false;
|
|
37
39
|
}
|
|
40
|
+
triggerPress() {
|
|
41
|
+
this.isPressed = true;
|
|
42
|
+
// render triggered by signal
|
|
43
|
+
// Trigger action slightly delayed to show visual feedback
|
|
44
|
+
setTimeout(() => {
|
|
45
|
+
this.isPressed = false;
|
|
46
|
+
this.options.onPress();
|
|
47
|
+
}, 150);
|
|
48
|
+
}
|
|
38
49
|
render() {
|
|
39
50
|
const { x, y, text, width } = this.options;
|
|
51
|
+
// Reading signals here (isFocused, isPressed) creates the dependency
|
|
52
|
+
// although in our simple system, any signal write triggers global render.
|
|
40
53
|
let label = text;
|
|
41
54
|
// Simple visual centering if width is provided
|
|
42
55
|
if (width) {
|
|
@@ -58,7 +71,6 @@ class Button {
|
|
|
58
71
|
renderedText = ` ${label} `;
|
|
59
72
|
}
|
|
60
73
|
// Apply styles
|
|
61
|
-
// Note: Styler.style takes varargs, we need to handle types carefully or cast
|
|
62
74
|
Screen_1.Screen.write(x, y, Styler_1.Styler.style(renderedText, color, 'bold', bgStyle));
|
|
63
75
|
}
|
|
64
76
|
else {
|
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/Component.d.ts
CHANGED
|
@@ -4,3 +4,37 @@ export interface ComponentLifecycle {
|
|
|
4
4
|
onMount?(): void;
|
|
5
5
|
onUnmount?(): void;
|
|
6
6
|
}
|
|
7
|
+
export declare abstract class Component implements ComponentLifecycle {
|
|
8
|
+
private mounted;
|
|
9
|
+
private boundRender;
|
|
10
|
+
constructor();
|
|
11
|
+
/**
|
|
12
|
+
* Called when the component is instantiated.
|
|
13
|
+
*/
|
|
14
|
+
init?(): void;
|
|
15
|
+
/**
|
|
16
|
+
* Called before the component is destroyed.
|
|
17
|
+
*/
|
|
18
|
+
destroy?(): void;
|
|
19
|
+
/**
|
|
20
|
+
* Called when the component is mounted to the screen.
|
|
21
|
+
*/
|
|
22
|
+
onMount?(): void;
|
|
23
|
+
/**
|
|
24
|
+
* Called when the component is unmounted from the screen.
|
|
25
|
+
*/
|
|
26
|
+
onUnmount?(): void;
|
|
27
|
+
/**
|
|
28
|
+
* The render function that draws the component to the screen.
|
|
29
|
+
*/
|
|
30
|
+
abstract render(): void;
|
|
31
|
+
/**
|
|
32
|
+
* Mounts the component to the Screen rendering loop.
|
|
33
|
+
* @param zIndex Layer priority (default: Layer.CONTENT)
|
|
34
|
+
*/
|
|
35
|
+
mount(zIndex?: number): void;
|
|
36
|
+
/**
|
|
37
|
+
* Unmounts the component from the Screen rendering loop.
|
|
38
|
+
*/
|
|
39
|
+
unmount(): void;
|
|
40
|
+
}
|
package/dist/core/Component.js
CHANGED
|
@@ -3,3 +3,34 @@
|
|
|
3
3
|
// CORE: COMPONENT LIFECYCLE
|
|
4
4
|
// ==========================================
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.Component = void 0;
|
|
7
|
+
const Screen_1 = require("./Screen");
|
|
8
|
+
class Component {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.mounted = false;
|
|
11
|
+
this.boundRender = this.render.bind(this);
|
|
12
|
+
this.init?.();
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Mounts the component to the Screen rendering loop.
|
|
16
|
+
* @param zIndex Layer priority (default: Layer.CONTENT)
|
|
17
|
+
*/
|
|
18
|
+
mount(zIndex = Screen_1.Layer.CONTENT) {
|
|
19
|
+
if (this.mounted)
|
|
20
|
+
return;
|
|
21
|
+
this.mounted = true;
|
|
22
|
+
Screen_1.Screen.mount(this.boundRender, zIndex);
|
|
23
|
+
this.onMount?.();
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Unmounts the component from the Screen rendering loop.
|
|
27
|
+
*/
|
|
28
|
+
unmount() {
|
|
29
|
+
if (!this.mounted)
|
|
30
|
+
return;
|
|
31
|
+
this.mounted = false;
|
|
32
|
+
Screen_1.Screen.unmount(this.boundRender);
|
|
33
|
+
this.onUnmount?.();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
exports.Component = Component;
|
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
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type Accessor<T> = () => T;
|
|
2
|
+
export type Setter<T> = (value: T | ((prev: T) => T)) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Creates a reactive signal.
|
|
5
|
+
* When the value updates, it automatically schedules a screen render.
|
|
6
|
+
* @param initialValue The initial value of the signal.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createSignal<T>(initialValue: T): [Accessor<T>, Setter<T>];
|
|
9
|
+
/**
|
|
10
|
+
* Creates a memoized value that caches its result and only re-calculates
|
|
11
|
+
* when the result of the function changes (polled during access).
|
|
12
|
+
*
|
|
13
|
+
* Note: A true dependency graph is out of scope. This implementation
|
|
14
|
+
* re-runs the function on every access but could store the last result
|
|
15
|
+
* if we had a way to know if dependencies changed.
|
|
16
|
+
*
|
|
17
|
+
* Since we don't track dependencies, we can't safely cache unless we know inputs didn't change.
|
|
18
|
+
* BUT, often `createMemo` is used to avoid expensive calcs if called multiple times in one render pass.
|
|
19
|
+
*
|
|
20
|
+
* For now, this is a simple pass-through.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createMemo<T>(fn: () => T): Accessor<T>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSignal = createSignal;
|
|
4
|
+
exports.createMemo = createMemo;
|
|
5
|
+
const Screen_1 = require("./Screen");
|
|
6
|
+
/**
|
|
7
|
+
* Creates a reactive signal.
|
|
8
|
+
* When the value updates, it automatically schedules a screen render.
|
|
9
|
+
* @param initialValue The initial value of the signal.
|
|
10
|
+
*/
|
|
11
|
+
function createSignal(initialValue) {
|
|
12
|
+
let value = initialValue;
|
|
13
|
+
const read = () => value;
|
|
14
|
+
const write = (newValue) => {
|
|
15
|
+
const nextValue = newValue instanceof Function ? newValue(value) : newValue;
|
|
16
|
+
if (value !== nextValue) {
|
|
17
|
+
value = nextValue;
|
|
18
|
+
Screen_1.Screen.scheduleRender();
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return [read, write];
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Creates a memoized value that caches its result and only re-calculates
|
|
25
|
+
* when the result of the function changes (polled during access).
|
|
26
|
+
*
|
|
27
|
+
* Note: A true dependency graph is out of scope. This implementation
|
|
28
|
+
* re-runs the function on every access but could store the last result
|
|
29
|
+
* if we had a way to know if dependencies changed.
|
|
30
|
+
*
|
|
31
|
+
* Since we don't track dependencies, we can't safely cache unless we know inputs didn't change.
|
|
32
|
+
* BUT, often `createMemo` is used to avoid expensive calcs if called multiple times in one render pass.
|
|
33
|
+
*
|
|
34
|
+
* For now, this is a simple pass-through.
|
|
35
|
+
*/
|
|
36
|
+
function createMemo(fn) {
|
|
37
|
+
// Without a dependency graph (tracking which signals are read inside fn),
|
|
38
|
+
// we cannot safely memoize across time because we don't know when to invalidate.
|
|
39
|
+
// We would need a global "context stack" like SolidJS/React.
|
|
40
|
+
// For this architectural step, we provide the API surface.
|
|
41
|
+
return () => fn();
|
|
42
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export * from './core/Layout';
|
|
|
10
10
|
export * from './core/Focus';
|
|
11
11
|
export * from './core/Events';
|
|
12
12
|
export * from './core/Component';
|
|
13
|
+
export * from './core/Signal';
|
|
13
14
|
export * from './components/Box';
|
|
14
15
|
export * from './components/Menu';
|
|
15
16
|
export * from './components/ProgressBar';
|
package/dist/index.js
CHANGED
|
@@ -26,6 +26,7 @@ __exportStar(require("./core/Layout"), exports);
|
|
|
26
26
|
__exportStar(require("./core/Focus"), exports);
|
|
27
27
|
__exportStar(require("./core/Events"), exports);
|
|
28
28
|
__exportStar(require("./core/Component"), exports);
|
|
29
|
+
__exportStar(require("./core/Signal"), exports);
|
|
29
30
|
__exportStar(require("./components/Box"), exports);
|
|
30
31
|
__exportStar(require("./components/Menu"), exports);
|
|
31
32
|
__exportStar(require("./components/ProgressBar"), exports);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voonex",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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
|
-
"
|
|
21
|
-
"ts-node": "10
|
|
22
|
-
"
|
|
20
|
+
"@types/node": "^22",
|
|
21
|
+
"ts-node": "^10",
|
|
22
|
+
"typescript": "^5"
|
|
23
23
|
},
|
|
24
24
|
"files": [
|
|
25
25
|
"dist",
|