take4-console 0.15.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/CHANGELOG.md +287 -0
- package/LICENSE +21 -0
- package/README.md +986 -0
- package/dist/Screen/InterfaceBuilder.d.mts +43 -0
- package/dist/Screen/InterfaceBuilder.d.mts.map +1 -0
- package/dist/Screen/InterfaceBuilder.mjs +355 -0
- package/dist/Screen/InterfaceBuilder.mjs.map +1 -0
- package/dist/Screen/Pos.d.mts +52 -0
- package/dist/Screen/Pos.d.mts.map +1 -0
- package/dist/Screen/Pos.mjs +105 -0
- package/dist/Screen/Pos.mjs.map +1 -0
- package/dist/Screen/Region.d.mts +31 -0
- package/dist/Screen/Region.d.mts.map +1 -0
- package/dist/Screen/Region.mjs +73 -0
- package/dist/Screen/Region.mjs.map +1 -0
- package/dist/Screen/RegistryHolder.d.mts +6 -0
- package/dist/Screen/RegistryHolder.d.mts.map +1 -0
- package/dist/Screen/RegistryHolder.mjs +14 -0
- package/dist/Screen/RegistryHolder.mjs.map +1 -0
- package/dist/Screen/Screen.d.mts +25 -0
- package/dist/Screen/Screen.d.mts.map +1 -0
- package/dist/Screen/Screen.mjs +100 -0
- package/dist/Screen/Screen.mjs.map +1 -0
- package/dist/Screen/Size.d.mts +23 -0
- package/dist/Screen/Size.d.mts.map +1 -0
- package/dist/Screen/Size.mjs +48 -0
- package/dist/Screen/Size.mjs.map +1 -0
- package/dist/Screen/StyleRegistry.d.mts +32 -0
- package/dist/Screen/StyleRegistry.d.mts.map +1 -0
- package/dist/Screen/StyleRegistry.mjs +80 -0
- package/dist/Screen/StyleRegistry.mjs.map +1 -0
- package/dist/Screen/Window.d.mts +121 -0
- package/dist/Screen/Window.d.mts.map +1 -0
- package/dist/Screen/Window.mjs +407 -0
- package/dist/Screen/Window.mjs.map +1 -0
- package/dist/Screen/WindowManager.d.mts +86 -0
- package/dist/Screen/WindowManager.d.mts.map +1 -0
- package/dist/Screen/WindowManager.mjs +399 -0
- package/dist/Screen/WindowManager.mjs.map +1 -0
- package/dist/Screen/controls/BarChart.d.mts +29 -0
- package/dist/Screen/controls/BarChart.d.mts.map +1 -0
- package/dist/Screen/controls/BarChart.mjs +90 -0
- package/dist/Screen/controls/BarChart.mjs.map +1 -0
- package/dist/Screen/controls/Button.d.mts +16 -0
- package/dist/Screen/controls/Button.d.mts.map +1 -0
- package/dist/Screen/controls/Button.mjs +34 -0
- package/dist/Screen/controls/Button.mjs.map +1 -0
- package/dist/Screen/controls/Checkbox.d.mts +23 -0
- package/dist/Screen/controls/Checkbox.d.mts.map +1 -0
- package/dist/Screen/controls/Checkbox.mjs +55 -0
- package/dist/Screen/controls/Checkbox.mjs.map +1 -0
- package/dist/Screen/controls/LineChart.d.mts +29 -0
- package/dist/Screen/controls/LineChart.d.mts.map +1 -0
- package/dist/Screen/controls/LineChart.mjs +172 -0
- package/dist/Screen/controls/LineChart.mjs.map +1 -0
- package/dist/Screen/controls/ListBox.d.mts +34 -0
- package/dist/Screen/controls/ListBox.d.mts.map +1 -0
- package/dist/Screen/controls/ListBox.mjs +138 -0
- package/dist/Screen/controls/ListBox.mjs.map +1 -0
- package/dist/Screen/controls/ProgressBar.d.mts +26 -0
- package/dist/Screen/controls/ProgressBar.d.mts.map +1 -0
- package/dist/Screen/controls/ProgressBar.mjs +70 -0
- package/dist/Screen/controls/ProgressBar.mjs.map +1 -0
- package/dist/Screen/controls/ProgressBarV.d.mts +22 -0
- package/dist/Screen/controls/ProgressBarV.d.mts.map +1 -0
- package/dist/Screen/controls/ProgressBarV.mjs +61 -0
- package/dist/Screen/controls/ProgressBarV.mjs.map +1 -0
- package/dist/Screen/controls/Radio.d.mts +23 -0
- package/dist/Screen/controls/Radio.d.mts.map +1 -0
- package/dist/Screen/controls/Radio.mjs +55 -0
- package/dist/Screen/controls/Radio.mjs.map +1 -0
- package/dist/Screen/controls/Sparkline.d.mts +29 -0
- package/dist/Screen/controls/Sparkline.d.mts.map +1 -0
- package/dist/Screen/controls/Sparkline.mjs +82 -0
- package/dist/Screen/controls/Sparkline.mjs.map +1 -0
- package/dist/Screen/controls/Spinner.d.mts +37 -0
- package/dist/Screen/controls/Spinner.d.mts.map +1 -0
- package/dist/Screen/controls/Spinner.mjs +87 -0
- package/dist/Screen/controls/Spinner.mjs.map +1 -0
- package/dist/Screen/controls/StatusLED.d.mts +22 -0
- package/dist/Screen/controls/StatusLED.d.mts.map +1 -0
- package/dist/Screen/controls/StatusLED.mjs +51 -0
- package/dist/Screen/controls/StatusLED.mjs.map +1 -0
- package/dist/Screen/controls/Tabs.d.mts +42 -0
- package/dist/Screen/controls/Tabs.d.mts.map +1 -0
- package/dist/Screen/controls/Tabs.mjs +126 -0
- package/dist/Screen/controls/Tabs.mjs.map +1 -0
- package/dist/Screen/controls/TextArea.d.mts +41 -0
- package/dist/Screen/controls/TextArea.d.mts.map +1 -0
- package/dist/Screen/controls/TextArea.mjs +197 -0
- package/dist/Screen/controls/TextArea.mjs.map +1 -0
- package/dist/Screen/controls/TextBox.d.mts +35 -0
- package/dist/Screen/controls/TextBox.d.mts.map +1 -0
- package/dist/Screen/controls/TextBox.mjs +135 -0
- package/dist/Screen/controls/TextBox.mjs.map +1 -0
- package/dist/Screen/types.d.mts +399 -0
- package/dist/Screen/types.d.mts.map +1 -0
- package/dist/Screen/types.mjs +22 -0
- package/dist/Screen/types.mjs.map +1 -0
- package/dist/index.d.mts +26 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +41 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +72 -0
- package/src/Screen/InterfaceBuilder.mts +403 -0
- package/src/Screen/Pos.mts +119 -0
- package/src/Screen/Region.mts +88 -0
- package/src/Screen/RegistryHolder.mts +16 -0
- package/src/Screen/Screen.mts +103 -0
- package/src/Screen/Size.mts +55 -0
- package/src/Screen/StyleRegistry.mts +95 -0
- package/src/Screen/Window.mts +439 -0
- package/src/Screen/WindowManager.mts +472 -0
- package/src/Screen/controls/BarChart.mts +109 -0
- package/src/Screen/controls/Button.mts +40 -0
- package/src/Screen/controls/Checkbox.mts +66 -0
- package/src/Screen/controls/LineChart.mts +202 -0
- package/src/Screen/controls/ListBox.mts +154 -0
- package/src/Screen/controls/ProgressBar.mts +88 -0
- package/src/Screen/controls/ProgressBarV.mts +77 -0
- package/src/Screen/controls/Radio.mts +66 -0
- package/src/Screen/controls/Sparkline.mts +101 -0
- package/src/Screen/controls/Spinner.mts +102 -0
- package/src/Screen/controls/StatusLED.mts +65 -0
- package/src/Screen/controls/Tabs.mts +140 -0
- package/src/Screen/controls/TextArea.mts +194 -0
- package/src/Screen/controls/TextBox.mts +139 -0
- package/src/Screen/types.mts +416 -0
- package/src/demo.mts +171 -0
- package/src/index.mts +105 -0
- package/src/layout.yaml +236 -0
package/README.md
ADDED
|
@@ -0,0 +1,986 @@
|
|
|
1
|
+
# take4-console
|
|
2
|
+
|
|
3
|
+
A terminal cell-grid rendering library for Node.js. Build rich text-mode
|
|
4
|
+
interfaces out of nested `Window`s with styles, borders, percentage layouts,
|
|
5
|
+
focus-aware keyboard/mouse input, and a YAML-driven layout builder.
|
|
6
|
+
|
|
7
|
+
Ships 15 built-in controls — buttons, text inputs, checkboxes, radios,
|
|
8
|
+
listboxes, tabs, status LEDs, progress bars, line/bar/sparkline charts,
|
|
9
|
+
spinners — and an extension API for adding your own. Pure ESM, TypeScript
|
|
10
|
+
first, zero runtime dependencies besides a YAML parser, one `stdout.write()`
|
|
11
|
+
per frame.
|
|
12
|
+
|
|
13
|
+
> **Requirements:** Node.js ≥ 18, a terminal that supports ANSI escape
|
|
14
|
+
> sequences and 256-colour / 24-bit colour. NerdFonts glyphs are supported
|
|
15
|
+
> (box-drawing, braille, block characters).
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm install take4-console
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The package is published as an **ES module** — use `import`, not `require`.
|
|
26
|
+
TypeScript declarations ship inside the package (`dist/index.d.mts`), so no
|
|
27
|
+
`@types/...` companion is needed.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```typescript
|
|
34
|
+
import {
|
|
35
|
+
Screen,
|
|
36
|
+
WindowManager,
|
|
37
|
+
Button,
|
|
38
|
+
TextBox,
|
|
39
|
+
Pos,
|
|
40
|
+
Size,
|
|
41
|
+
} from 'take4-console';
|
|
42
|
+
|
|
43
|
+
// 1. Create the root screen — sized automatically to the terminal.
|
|
44
|
+
const screen = new Screen();
|
|
45
|
+
screen.fill(' ', screen.registerStyle({ background: 234 }));
|
|
46
|
+
|
|
47
|
+
// 2. Add controls. Screen registers a global StyleRegistry on construction;
|
|
48
|
+
// all controls created afterwards share it automatically.
|
|
49
|
+
const name = new TextBox(
|
|
50
|
+
{ pos: new Pos(2, 2), size: new Size(30, 3) },
|
|
51
|
+
{ placeholder: 'your name' },
|
|
52
|
+
);
|
|
53
|
+
screen.addChild(name);
|
|
54
|
+
|
|
55
|
+
const ok = new Button(
|
|
56
|
+
{ pos: new Pos(2, 6), size: new Size(10, 3), label: 'OK' },
|
|
57
|
+
{ onPress: () => console.error(`hello, ${name.getValue()}`) },
|
|
58
|
+
);
|
|
59
|
+
screen.addChild(ok);
|
|
60
|
+
|
|
61
|
+
// 3. Start the input loop. Tab cycles focus; q / Ctrl+C exits.
|
|
62
|
+
const wm = new WindowManager(screen, {
|
|
63
|
+
exitKeys: ['q', '\x03'],
|
|
64
|
+
onExit: () => process.exit(0),
|
|
65
|
+
mouse: true,
|
|
66
|
+
});
|
|
67
|
+
wm.register(name);
|
|
68
|
+
wm.register(ok);
|
|
69
|
+
wm.run();
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or build the same UI declaratively from a YAML file via `InterfaceBuilder`
|
|
73
|
+
— see [section 7](#7-yaml--interfacebuilder-integration).
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Public API
|
|
78
|
+
|
|
79
|
+
Every symbol below is exported from the package root:
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import {
|
|
83
|
+
// ── Core ───────────────────────────────────────────────────────────────
|
|
84
|
+
Screen, Window, Region, StyleRegistry, WindowManager,
|
|
85
|
+
|
|
86
|
+
// ── Geometry ───────────────────────────────────────────────────────────
|
|
87
|
+
Pos, Size, Pct, pct,
|
|
88
|
+
|
|
89
|
+
// ── Interactive controls (focusable) ───────────────────────────────────
|
|
90
|
+
Button, TextBox, TextArea, Checkbox, Radio, ListBox, Tabs,
|
|
91
|
+
|
|
92
|
+
// ── Read-only display controls ─────────────────────────────────────────
|
|
93
|
+
StatusLED, ProgressBar, ProgressBarV,
|
|
94
|
+
LineChart, BarChart, Sparkline, Spinner,
|
|
95
|
+
|
|
96
|
+
// ── YAML layout builder ────────────────────────────────────────────────
|
|
97
|
+
InterfaceBuilder,
|
|
98
|
+
|
|
99
|
+
// ── Built-in style name constants ──────────────────────────────────────
|
|
100
|
+
BUILTIN_WINDOW_BG,
|
|
101
|
+
BUILTIN_BORDER, BUILTIN_BORDER_FOCUSED, BUILTIN_BORDER_DISABLED,
|
|
102
|
+
BUILTIN_TEXT, BUILTIN_TEXT_FOCUSED, BUILTIN_TEXT_DISABLED,
|
|
103
|
+
BUILTIN_TEXT_PLACEHOLDER, BUILTIN_TEXT_CHECKED, BUILTIN_CURSOR,
|
|
104
|
+
} from 'take4-console';
|
|
105
|
+
|
|
106
|
+
import type {
|
|
107
|
+
// Primitives
|
|
108
|
+
Color, StyleId, Cell, CellAttributes, TerminalSize,
|
|
109
|
+
|
|
110
|
+
// Window / border
|
|
111
|
+
BorderStyle, WindowBorder, WindowProperties, WriteTextOptions,
|
|
112
|
+
|
|
113
|
+
// Control properties interfaces (first constructor arg)
|
|
114
|
+
// — WindowProperties is the base for all controls
|
|
115
|
+
// Control-specific options (second constructor arg)
|
|
116
|
+
ButtonProperties, TextBoxProperties, TextAreaProperties,
|
|
117
|
+
CheckboxProperties, RadioProperties,
|
|
118
|
+
StatusLEDProperties, ProgressBarProperties, ProgressBarVProperties,
|
|
119
|
+
LineChartProperties, BarChartProperties,
|
|
120
|
+
ListBoxProperties, TabsProperties, SparklineProperties, SpinnerProperties,
|
|
121
|
+
|
|
122
|
+
// Focus & input
|
|
123
|
+
Focusable, TerminalMouseEvent, WindowManagerOptions,
|
|
124
|
+
|
|
125
|
+
// YAML schema
|
|
126
|
+
YamlLayout, YamlWindowDef, YamlPosSpec, YamlSizeSpec,
|
|
127
|
+
YamlWindowType, YamlStyleDef, YamlAxisValue,
|
|
128
|
+
} from 'take4-console';
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Full control reference is in [section 9](#9-built-in-controls-reference);
|
|
132
|
+
style names are in [section 8](#8-built-in-style-reference); YAML layouts
|
|
133
|
+
in [section 7](#7-yaml--interfacebuilder-integration).
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
|
|
137
|
+
## Running the bundled demo
|
|
138
|
+
|
|
139
|
+
The repository ships a live demo that exercises every built-in control:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
git clone https://github.com/arcymag/take4-console.git
|
|
143
|
+
cd take4-console
|
|
144
|
+
npm install
|
|
145
|
+
npm run demo # tsx src/demo.mts
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Press `q` or `Ctrl+C` to exit. The demo's source (`src/demo.mts`) and layout
|
|
149
|
+
(`src/layout.yaml`) are good starting points for your own application.
|
|
150
|
+
|
|
151
|
+
---
|
|
152
|
+
|
|
153
|
+
## Table of contents
|
|
154
|
+
|
|
155
|
+
1. [Core concepts](#1-core-concepts)
|
|
156
|
+
2. [Architecture overview](#2-architecture-overview)
|
|
157
|
+
3. [Style system](#3-style-system)
|
|
158
|
+
4. [Implementing a custom control — step by step](#4-implementing-a-custom-control--step-by-step)
|
|
159
|
+
- 4.1 [Define the options interface](#41-define-the-options-interface)
|
|
160
|
+
- 4.2 [Create the class skeleton](#42-create-the-class-skeleton)
|
|
161
|
+
- 4.3 [Constructor: registry, super, styles](#43-constructor-registry-super-styles)
|
|
162
|
+
- 4.4 [State setters and getters](#44-state-setters-and-getters)
|
|
163
|
+
- 4.5 [Rendering pipeline](#45-rendering-pipeline)
|
|
164
|
+
- 4.6 [Dynamic borders](#46-dynamic-borders)
|
|
165
|
+
- 4.7 [Keyboard input — the Focusable interface](#47-keyboard-input--the-focusable-interface)
|
|
166
|
+
- 4.8 [Registering with WindowManager](#48-registering-with-windowmanager)
|
|
167
|
+
- 4.9 [Writing tests](#49-writing-tests)
|
|
168
|
+
5. [Complete example 1: ProgressBar (read-only)](#5-complete-example-1-progressbar-read-only)
|
|
169
|
+
6. [Complete example 2: NumberStepper (interactive)](#6-complete-example-2-numberstepper-interactive)
|
|
170
|
+
7. [YAML / InterfaceBuilder integration](#7-yaml--interfacebuilder-integration)
|
|
171
|
+
8. [Built-in style reference](#8-built-in-style-reference)
|
|
172
|
+
9. [Built-in controls reference](#9-built-in-controls-reference)
|
|
173
|
+
|
|
174
|
+
> **Note on import paths.** Examples in sections 4–6 use **relative imports**
|
|
175
|
+
> (e.g. `from '../Window.mjs'`) because they show how to build a new control
|
|
176
|
+
> *inside this repository*. If you are extending the library from an external
|
|
177
|
+
> package, substitute `from 'take4-console'` instead.
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT © Jarosław Mężyk
|
|
184
|
+
|
|
185
|
+
---
|
|
186
|
+
|
|
187
|
+
## 1. Core concepts
|
|
188
|
+
|
|
189
|
+
| Concept | What it is |
|
|
190
|
+
|---|---|
|
|
191
|
+
| `Window` | A rectangular region with a position, size, optional border and background. Everything is a Window — layouts, dialogs, and controls. |
|
|
192
|
+
| `Region` | A flat `chars[]` + `styleIds[]` buffer. Every Window has two: `content` (user writes) and `region` (composited on render). |
|
|
193
|
+
| `StyleRegistry` | Maps integer `StyleId` → `CellAttributes`. Identical attribute objects always get the same ID (deduplication). ID 0 = empty style. |
|
|
194
|
+
| `StyleId` | An opaque integer. Never construct cell attributes directly — always call `registry.register(attrs)` and keep the returned ID. |
|
|
195
|
+
| `Screen` | A `Window` sized to the terminal. Owns the root `StyleRegistry`. Serialises its `region` into a single ANSI escape string and writes it with one `process.stdout.write()`. |
|
|
196
|
+
| `Pos` | Encodes position: absolute `new Pos(x, y)`, edge-relative `new Pos(-1, -1)`, percentage, or named preset (`Pos.center()`, …). |
|
|
197
|
+
| `Size` | Encodes size: absolute `new Size(w, h)`, percentage, or fill shorthand (`Size.fill()`, `Size.fillWidth(h)`, …). |
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 2. Architecture overview
|
|
202
|
+
|
|
203
|
+
```
|
|
204
|
+
Screen (extends Window)
|
|
205
|
+
└─ Window ← user creates these, nests them
|
|
206
|
+
└─ Region ← flat cell buffer (chars[] + styleIds[])
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
The render pipeline for every `Window.render()` call:
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
0. syncBorderColor() update border colour from focused/disabled state
|
|
213
|
+
1. paintBackground() fill region with background style (if any)
|
|
214
|
+
2. blitContent() overlay the content buffer (what the user wrote)
|
|
215
|
+
3. paintBorder() draw box-drawing characters on edges
|
|
216
|
+
4. child loop render each child and blit its region onto ours
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**Two-buffer design:**
|
|
220
|
+
|
|
221
|
+
- `content` (private): what you write with `setCell`, `writeText`, `fill`, etc. Persists across `render()` calls.
|
|
222
|
+
- `region` (protected): rebuilt from scratch on every `render()`. Read by `getCell()` and `blitChild`.
|
|
223
|
+
|
|
224
|
+
Write to `content` → it shows up in `region` after the next `render()`.
|
|
225
|
+
|
|
226
|
+
`writeText()` picks the text style automatically when `style` is omitted: `disabledStyleId` when disabled, `focusedStyleId` when focused, otherwise `normalStyleId`. Pass an explicit `style` only for special cells (e.g. a filled bar or a cursor highlight).
|
|
227
|
+
|
|
228
|
+
---
|
|
229
|
+
|
|
230
|
+
## 3. Style system
|
|
231
|
+
|
|
232
|
+
### StyleRegistry
|
|
233
|
+
|
|
234
|
+
```typescript
|
|
235
|
+
const id = registry.register({ foreground: 75, bold: true }); // returns stable integer
|
|
236
|
+
registry.get(id); // → { foreground: 75, bold: true }
|
|
237
|
+
registry.merge(baseId, overId); // spread-merge, overId wins conflicts
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
ID 0 is always `{}` (empty / no style). Merging anything with 0 is a no-op.
|
|
241
|
+
|
|
242
|
+
### Named styles
|
|
243
|
+
|
|
244
|
+
`Screen` pre-registers ten *built-in* named styles. Controls look them up by name and fall back to hardcoded defaults when running without a Screen (e.g. in unit tests):
|
|
245
|
+
|
|
246
|
+
```typescript
|
|
247
|
+
import { BUILTIN_TEXT_FOCUSED } from './types.mjs';
|
|
248
|
+
|
|
249
|
+
const styleId = registry.getNamed(BUILTIN_TEXT_FOCUSED) // may be undefined (no Screen)
|
|
250
|
+
?? registry.register({ foreground: 255, bold: true }); // hardcoded fallback
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Override a built-in style for the entire application:
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
screen.setBuiltinStyle(BUILTIN_BORDER_FOCUSED, { foreground: 214 }); // amber focused borders
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
### CellAttributes fields
|
|
260
|
+
|
|
261
|
+
| Field | Type | Effect |
|
|
262
|
+
|---|---|---|
|
|
263
|
+
| `foreground` | `Color` | Text colour (ANSI 0–255 or `'#rrggbb'`) |
|
|
264
|
+
| `background` | `Color` | Cell background colour |
|
|
265
|
+
| `bold` | `boolean` | Bold / bright |
|
|
266
|
+
| `dim` | `boolean` | Dimmed / half-brightness |
|
|
267
|
+
| `italic` | `boolean` | Italic |
|
|
268
|
+
| `underline` | `boolean` | Underline |
|
|
269
|
+
| `strikethrough` | `boolean` | Strikethrough |
|
|
270
|
+
| `blink` | `boolean` | Blinking |
|
|
271
|
+
| `inverse` | `boolean` | Swap foreground ↔ background |
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
## 4. Implementing a custom control — step by step
|
|
276
|
+
|
|
277
|
+
### 4.1 Define the properties interface
|
|
278
|
+
|
|
279
|
+
Add your control-specific properties to `src/Screen/types.mts`. The first constructor argument always extends `WindowProperties` (position, size, border, background, label, focused, disabled, …). Define a separate interface for control-specific options passed as the second argument.
|
|
280
|
+
|
|
281
|
+
```typescript
|
|
282
|
+
// src/Screen/types.mts
|
|
283
|
+
|
|
284
|
+
/** Control-specific options for the Gauge (second constructor argument). */
|
|
285
|
+
export interface GaugeProperties {
|
|
286
|
+
/** Current value, 0–max. Default: 0. */
|
|
287
|
+
value?: number;
|
|
288
|
+
/** Maximum value. Default: 100. */
|
|
289
|
+
max?: number;
|
|
290
|
+
/** Called when the value changes via handleKey(). */
|
|
291
|
+
onChange?: (value: number) => void;
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### 4.2 Create the class skeleton
|
|
296
|
+
|
|
297
|
+
Place the file in `src/Screen/controls/MyControl.mts`. Import your properties interface and any style constants you need.
|
|
298
|
+
|
|
299
|
+
`focused`, `disabled`, `label`, `normalStyleId`, `disabledStyleId`, and `focusedStyleId` are all inherited from `Window` — do not re-declare them.
|
|
300
|
+
|
|
301
|
+
```typescript
|
|
302
|
+
// src/Screen/controls/Gauge.mts
|
|
303
|
+
|
|
304
|
+
import type { GaugeProperties, WindowProperties, StyleId } from '../types.mjs';
|
|
305
|
+
import { Window } from '../Window.mjs';
|
|
306
|
+
|
|
307
|
+
export class Gauge extends Window {
|
|
308
|
+
// control-specific state
|
|
309
|
+
private value: number = 0;
|
|
310
|
+
private max: number = 100;
|
|
311
|
+
private onChange?: (value: number) => void;
|
|
312
|
+
|
|
313
|
+
// cached style IDs (registered after super())
|
|
314
|
+
private filledStyleId!: StyleId;
|
|
315
|
+
private emptyStyleId!: StyleId;
|
|
316
|
+
|
|
317
|
+
// ...
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### 4.3 Constructor: WindowProperties, super, styles
|
|
322
|
+
|
|
323
|
+
**Key pattern:** forward `WindowProperties` to `super()`, adding a `defaultBorder` to set the border shape used when the caller doesn't provide one. Register your own style IDs *after* calling `super()` — `this.registry` is available immediately after.
|
|
324
|
+
|
|
325
|
+
The global `StyleRegistry` is set by `Screen` on construction (via `RegistryHolder`). All controls created after `new Screen()` share that registry automatically — no need to pass it as a parameter.
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
public constructor(wp: WindowProperties, cp?: GaugeProperties) {
|
|
329
|
+
// 1. Forward to Window. defaultBorder provides the shape when the caller
|
|
330
|
+
// omits border — Window.syncBorderColor() sets the colour automatically.
|
|
331
|
+
super({
|
|
332
|
+
...wp,
|
|
333
|
+
defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'single' },
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// 2. Store control-specific state (this.* is available after super()).
|
|
337
|
+
this.max = Math.max(1, cp?.max ?? 100);
|
|
338
|
+
this.value = Math.max(0, Math.min(cp?.value ?? 0, this.max));
|
|
339
|
+
this.onChange = cp?.onChange;
|
|
340
|
+
|
|
341
|
+
// 3. Register extra style IDs that aren't part of the inherited set.
|
|
342
|
+
// normalStyleId / focusedStyleId / disabledStyleId come from Window.
|
|
343
|
+
this.filledStyleId = this.registry.register({ background: 75 });
|
|
344
|
+
this.emptyStyleId = this.registry.register({ background: 238 });
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
**Rules:**
|
|
349
|
+
- Never pass `CellAttributes` directly to `setCell` / `writeText` / `fill`. Always convert to a `StyleId` first.
|
|
350
|
+
- `focused`, `disabled`, `label`, `normalStyleId`, `focusedStyleId`, and `disabledStyleId` are inherited from `Window`. Do not re-declare them.
|
|
351
|
+
- `Window.syncBorderColor()` updates the border colour automatically on every `render()` based on `focused`/`disabled` state. Do not call `updateBorder()` for colour-only updates.
|
|
352
|
+
- `writeText()` auto-picks `normalStyleId`, `focusedStyleId`, or `disabledStyleId` when you omit the `style` option — rely on this for most label rendering.
|
|
353
|
+
|
|
354
|
+
### 4.4 State setters and getters
|
|
355
|
+
|
|
356
|
+
Every stateful field needs a setter and a getter. The setters do *not* call `render()` — the caller decides when to re-render.
|
|
357
|
+
|
|
358
|
+
`isFocused()`, `setFocused()`, `isDisabled()`, and `setDisabled()` are already implemented by `Window`. Override them only if you need additional side effects (e.g. updating a cursor position). The border colour and text style update automatically on the next `render()`.
|
|
359
|
+
|
|
360
|
+
```typescript
|
|
361
|
+
/** Sets the current value (clamped to 0–max). */
|
|
362
|
+
public setValue(value: number): void {
|
|
363
|
+
this.value = Math.max(0, Math.min(value, this.max));
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Returns the current value. */
|
|
367
|
+
public getValue(): number {
|
|
368
|
+
return this.value;
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
### 4.5 Rendering pipeline
|
|
373
|
+
|
|
374
|
+
Override `render()` and follow this fixed order:
|
|
375
|
+
|
|
376
|
+
```
|
|
377
|
+
1. this.clear() — reset content buffer (mandatory at top of render)
|
|
378
|
+
2. draw your content — writeText / setCell / fill
|
|
379
|
+
3. super.render() — triggers Window pipeline: paintBackground → blitContent → paintBorder → children
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
**Never call `super.render()` first** — the base class's `paintBackground` and `blitContent` steps would overwrite your content.
|
|
383
|
+
|
|
384
|
+
```typescript
|
|
385
|
+
public override render(): void {
|
|
386
|
+
// ── 1. Clear ──────────────────────────────────────────────────────────────
|
|
387
|
+
this.clear();
|
|
388
|
+
|
|
389
|
+
// ── 2. Draw content ───────────────────────────────────────────────────────
|
|
390
|
+
const { width, height } = this.getInnerSize(); // excludes border cells
|
|
391
|
+
const filled = Math.round((this.value / this.max) * width);
|
|
392
|
+
|
|
393
|
+
for (let x = 0; x < width; x++) {
|
|
394
|
+
const styleId = x < filled ? this.filledStyleId : this.emptyStyleId;
|
|
395
|
+
this.setCell(x, 0, ' ', styleId);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Centred "value / max" label.
|
|
399
|
+
// No explicit style needed — writeText() auto-picks normal/focused/disabled.
|
|
400
|
+
const label = `${this.value}/${this.max}`;
|
|
401
|
+
const labelX = Math.max(0, Math.floor((width - label.length) / 2));
|
|
402
|
+
this.writeText(label, { x: labelX, y: 0 });
|
|
403
|
+
|
|
404
|
+
// ── 3. Composite ──────────────────────────────────────────────────────────
|
|
405
|
+
super.render();
|
|
406
|
+
}
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
**Coordinate system in `writeText` / `setCell`:**
|
|
410
|
+
|
|
411
|
+
Coordinates passed to `writeText` are always relative to the *inner content area* (i.e. the area inside any borders). `(0, 0)` is always the top-left of the content area regardless of whether a border is present. The same applies to coordinates returned by `getInnerSize()`. Use `getInnerOffset()` only when you need to translate to absolute cell coordinates for direct `Region` manipulation.
|
|
412
|
+
|
|
413
|
+
### 4.6 Dynamic borders
|
|
414
|
+
|
|
415
|
+
Border colour is managed automatically by `Window.syncBorderColor()`, which is called at the start of every `render()`. It uses the built-in named styles:
|
|
416
|
+
|
|
417
|
+
| State | Style constant | Default colour |
|
|
418
|
+
|---|---|---|
|
|
419
|
+
| Disabled | `BUILTIN_BORDER_DISABLED` | ANSI 238 (dark grey) |
|
|
420
|
+
| Focused | `BUILTIN_BORDER_FOCUSED` | ANSI 75 (blue) |
|
|
421
|
+
| Normal | `BUILTIN_BORDER` | ANSI 240 (grey) |
|
|
422
|
+
|
|
423
|
+
You do **not** need to call `updateBorder()` for colour changes. Simply set `defaultBorder` in the constructor — the colour updates automatically on every frame.
|
|
424
|
+
|
|
425
|
+
To use a **fixed** border colour that is never auto-updated, set the `color` field explicitly in the `border` property passed by the caller (or in `defaultBorder`):
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
// Fixed amber border — colour never changes even when focused.
|
|
429
|
+
super({ ...wp, defaultBorder: { top: true, right: true, bottom: true, left: true,
|
|
430
|
+
style: 'rounded', color: 214 } });
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
To override the focused/disabled border colours globally:
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
screen.setBuiltinStyle(BUILTIN_BORDER_FOCUSED, { foreground: 214 }); // amber
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### 4.7 Keyboard input — the Focusable interface
|
|
440
|
+
|
|
441
|
+
To integrate with `WindowManager`'s Tab-cycle and keyboard dispatch, your control must satisfy the `Focusable` interface:
|
|
442
|
+
|
|
443
|
+
```typescript
|
|
444
|
+
// From types.mts:
|
|
445
|
+
interface Focusable {
|
|
446
|
+
isFocused(): boolean;
|
|
447
|
+
setFocused(focused: boolean): void;
|
|
448
|
+
isDisabled(): boolean;
|
|
449
|
+
handleKey?(key: string): void; // optional — omit if the control is read-only
|
|
450
|
+
}
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Your class satisfies this automatically if it has `isFocused`, `setFocused`, `isDisabled`, and optionally `handleKey`. TypeScript uses structural typing, so no explicit `implements Focusable` keyword is required (though you may add it for clarity).
|
|
454
|
+
|
|
455
|
+
`handleKey` receives raw terminal escape strings **and** human-readable aliases:
|
|
456
|
+
|
|
457
|
+
| Physical key | Raw string | Alias |
|
|
458
|
+
|---|---|---|
|
|
459
|
+
| Enter | `'\r'` or `'\n'` | `'enter'` |
|
|
460
|
+
| Space | `' '` | `'space'` |
|
|
461
|
+
| Backspace | `'\x7f'` | `'backspace'` |
|
|
462
|
+
| Delete | `'\x1b[3~'` | `'delete'` |
|
|
463
|
+
| ← | `'\x1b[D'` | `'left'` |
|
|
464
|
+
| → | `'\x1b[C'` | `'right'` |
|
|
465
|
+
| ↑ | `'\x1b[A'` | `'up'` |
|
|
466
|
+
| ↓ | `'\x1b[B'` | `'down'` |
|
|
467
|
+
| Home | `'\x1b[H'` | `'home'` |
|
|
468
|
+
| End | `'\x1b[F'` | `'end'` |
|
|
469
|
+
|
|
470
|
+
Example for a Gauge that increments/decrements with arrow keys:
|
|
471
|
+
|
|
472
|
+
```typescript
|
|
473
|
+
public handleKey(key: string): void {
|
|
474
|
+
if (this.disabled) return;
|
|
475
|
+
|
|
476
|
+
let changed = false;
|
|
477
|
+
if (key === '\x1b[C' || key === 'right') {
|
|
478
|
+
this.value = Math.min(this.value + 1, this.max);
|
|
479
|
+
changed = true;
|
|
480
|
+
} else if (key === '\x1b[D' || key === 'left') {
|
|
481
|
+
this.value = Math.max(this.value - 1, 0);
|
|
482
|
+
changed = true;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
if (changed) this.onChange?.(this.value);
|
|
486
|
+
}
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
### 4.8 Registering with WindowManager
|
|
490
|
+
|
|
491
|
+
Register your control after adding it to the window tree:
|
|
492
|
+
|
|
493
|
+
```typescript
|
|
494
|
+
const screen = new Screen();
|
|
495
|
+
const wm = new WindowManager(screen);
|
|
496
|
+
|
|
497
|
+
// Screen sets the global registry on construction — Gauge picks it up automatically.
|
|
498
|
+
const gauge = new Gauge({ pos: Pos.center(), size: new Size(30, 3) }, { max: 50 });
|
|
499
|
+
screen.addChild(gauge);
|
|
500
|
+
|
|
501
|
+
// Register for Tab focus cycle. Pass all ancestor Windows after the control.
|
|
502
|
+
wm.register(gauge); // top-level control (no parent chain needed)
|
|
503
|
+
// — OR —
|
|
504
|
+
wm.register(gauge, panel, screen); // control is inside panel which is inside screen
|
|
505
|
+
|
|
506
|
+
wm.run();
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
`WindowManager.register(control, ...parents)` uses the parent chain to compute absolute screen coordinates for mouse hit-testing. Pass parents from closest to farthest (innermost first).
|
|
510
|
+
|
|
511
|
+
### 4.9 Writing tests
|
|
512
|
+
|
|
513
|
+
Tests run via Vitest. Import controls directly — no Screen or WindowManager needed.
|
|
514
|
+
|
|
515
|
+
Patterns to test:
|
|
516
|
+
|
|
517
|
+
```typescript
|
|
518
|
+
import { describe, it, expect } from 'vitest';
|
|
519
|
+
import { Gauge } from '../../src/Screen/controls/Gauge.mjs';
|
|
520
|
+
import { Pos } from '../../src/Screen/Pos.mjs';
|
|
521
|
+
import { Size } from '../../src/Screen/Size.mjs';
|
|
522
|
+
|
|
523
|
+
describe('Gauge', () => {
|
|
524
|
+
// ── 1. Constructor / state ─────────────────────────────────────────────────
|
|
525
|
+
it('defaults to value 0', () => {
|
|
526
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3) });
|
|
527
|
+
expect(g.getValue()).toBe(0);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it('clamps value to max in constructor', () => {
|
|
531
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3) }, { value: 999, max: 50 });
|
|
532
|
+
expect(g.getValue()).toBe(50);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ── 2. render() — visual output ───────────────────────────────────────────
|
|
536
|
+
it('renders a border', () => {
|
|
537
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(10, 3) });
|
|
538
|
+
g.render();
|
|
539
|
+
expect(g.getCell(0, 0).char).toBe('┌');
|
|
540
|
+
expect(g.getCell(9, 2).char).toBe('┘');
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('filled portion has a distinct background', () => {
|
|
544
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(12, 3) }, { value: 50, max: 100 });
|
|
545
|
+
g.render();
|
|
546
|
+
// inner width = 10 (border on both sides); 50% = 5 cells filled
|
|
547
|
+
const filledBg = g.getCell(1, 1).attributes.background; // first inner cell
|
|
548
|
+
const emptyBg = g.getCell(9, 1).attributes.background; // last inner cell
|
|
549
|
+
expect(filledBg).not.toBe(emptyBg);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ── 3. handleKey ──────────────────────────────────────────────────────────
|
|
553
|
+
it('right arrow increments value', () => {
|
|
554
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3) }, { value: 5, max: 10 });
|
|
555
|
+
g.handleKey('right');
|
|
556
|
+
expect(g.getValue()).toBe(6);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('does not exceed max', () => {
|
|
560
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3) }, { value: 10, max: 10 });
|
|
561
|
+
g.handleKey('right');
|
|
562
|
+
expect(g.getValue()).toBe(10);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('disabled control ignores key presses', () => {
|
|
566
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3), disabled: true }, { value: 5 });
|
|
567
|
+
g.handleKey('right');
|
|
568
|
+
expect(g.getValue()).toBe(5);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
// ── 4. Focusable interface ────────────────────────────────────────────────
|
|
572
|
+
it('isFocused / setFocused round-trip', () => {
|
|
573
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(20, 3) });
|
|
574
|
+
g.setFocused(true);
|
|
575
|
+
expect(g.isFocused()).toBe(true);
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it('focused state changes border color', () => {
|
|
579
|
+
const g = new Gauge({ pos: new Pos(0, 0), size: new Size(10, 3) });
|
|
580
|
+
const gFocused = new Gauge({ pos: new Pos(0, 0), size: new Size(10, 3), focused: true });
|
|
581
|
+
g.render();
|
|
582
|
+
gFocused.render();
|
|
583
|
+
const normalColor = g.getCell(0, 0).attributes.foreground;
|
|
584
|
+
const focusedColor = gFocused.getCell(0, 0).attributes.foreground;
|
|
585
|
+
expect(normalColor).not.toBe(focusedColor);
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## 5. Complete example 1: ProgressBar (read-only)
|
|
593
|
+
|
|
594
|
+
A simple horizontal progress bar with a percentage label. No keyboard interaction.
|
|
595
|
+
|
|
596
|
+
> **Note:** a real `ProgressBar` is shipped as a built-in control in `src/Screen/controls/ProgressBar.mts` (see section 9). The walkthrough below is kept as a tutorial — it shows the minimum viable implementation of a read-only control and is a good starting point if you want to build your own.
|
|
597
|
+
|
|
598
|
+
```typescript
|
|
599
|
+
// src/Screen/controls/ProgressBar.mts
|
|
600
|
+
|
|
601
|
+
import type { ProgressBarProperties, WindowProperties, StyleId } from '../types.mjs';
|
|
602
|
+
import { Window } from '../Window.mjs';
|
|
603
|
+
|
|
604
|
+
export class ProgressBar extends Window {
|
|
605
|
+
private value: number;
|
|
606
|
+
private max: number;
|
|
607
|
+
private filledStyleId: StyleId;
|
|
608
|
+
private emptyStyleId: StyleId;
|
|
609
|
+
|
|
610
|
+
/** Creates a ProgressBar. Recommended height: 1 (no border) or 3 (with border). */
|
|
611
|
+
public constructor(wp: WindowProperties, cp?: ProgressBarProperties) {
|
|
612
|
+
super(wp); // no defaultBorder — ProgressBar has none by default
|
|
613
|
+
|
|
614
|
+
this.max = Math.max(1, cp?.max ?? 100);
|
|
615
|
+
this.value = Math.max(0, Math.min(cp?.value ?? 0, this.max));
|
|
616
|
+
|
|
617
|
+
this.filledStyleId = this.registry.register({ background: cp?.fillColor ?? 75 });
|
|
618
|
+
this.emptyStyleId = this.registry.register({ background: 238 });
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Sets the current value (clamped to 0–max). Caller must call render() afterwards. */
|
|
622
|
+
public setValue(value: number): void {
|
|
623
|
+
this.value = Math.max(0, Math.min(value, this.max));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
/** Returns the current value. */
|
|
627
|
+
public getValue(): number {
|
|
628
|
+
return this.value;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
public override render(): void {
|
|
632
|
+
this.clear();
|
|
633
|
+
|
|
634
|
+
const { width } = this.getInnerSize();
|
|
635
|
+
const pct = this.value / this.max;
|
|
636
|
+
const filled = Math.round(pct * width);
|
|
637
|
+
|
|
638
|
+
// Draw bar cells.
|
|
639
|
+
for (let x = 0; x < width; x++) {
|
|
640
|
+
this.setCell(x, 0, ' ', x < filled ? this.filledStyleId : this.emptyStyleId);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Centred percentage label — writeText() auto-applies disabled style when needed.
|
|
644
|
+
const label = `${Math.round(pct * 100)}%`;
|
|
645
|
+
const labelX = Math.max(0, Math.floor((width - label.length) / 2));
|
|
646
|
+
this.writeText(label, { x: labelX, y: 0 });
|
|
647
|
+
|
|
648
|
+
super.render();
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
```
|
|
652
|
+
|
|
653
|
+
Usage:
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
const screen = new Screen();
|
|
657
|
+
const bar = new ProgressBar(
|
|
658
|
+
{ pos: Pos.center(), size: new Size(30, 1) },
|
|
659
|
+
{ value: 42, max: 100, fillColor: 75 },
|
|
660
|
+
);
|
|
661
|
+
screen.addChild(bar);
|
|
662
|
+
|
|
663
|
+
// Later, update and re-render:
|
|
664
|
+
bar.setValue(75);
|
|
665
|
+
screen.render();
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
---
|
|
669
|
+
|
|
670
|
+
## 6. Complete example 2: NumberStepper (interactive)
|
|
671
|
+
|
|
672
|
+
A control that lets the user increment/decrement a numeric value with ← / → arrow keys. Shows the full Focusable pattern with dynamic border colouring.
|
|
673
|
+
|
|
674
|
+
```typescript
|
|
675
|
+
// src/Screen/controls/NumberStepper.mts
|
|
676
|
+
|
|
677
|
+
import type { NumberStepperProperties, WindowProperties } from '../types.mjs';
|
|
678
|
+
import { Window } from '../Window.mjs';
|
|
679
|
+
|
|
680
|
+
export interface NumberStepperProperties {
|
|
681
|
+
value?: number;
|
|
682
|
+
min?: number;
|
|
683
|
+
max?: number;
|
|
684
|
+
step?: number;
|
|
685
|
+
onChange?: (value: number) => void;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export class NumberStepper extends Window {
|
|
689
|
+
private value: number;
|
|
690
|
+
private min: number;
|
|
691
|
+
private max: number;
|
|
692
|
+
private step: number;
|
|
693
|
+
private onChange?: (value: number) => void;
|
|
694
|
+
|
|
695
|
+
// focused, disabled, normalStyleId, focusedStyleId, disabledStyleId — inherited from Window
|
|
696
|
+
|
|
697
|
+
/** Creates a NumberStepper. Minimum recommended size: width 10, height 3. */
|
|
698
|
+
public constructor(wp: WindowProperties, cp?: NumberStepperProperties) {
|
|
699
|
+
super({
|
|
700
|
+
...wp,
|
|
701
|
+
defaultBorder: { top: true, right: true, bottom: true, left: true, style: 'rounded' },
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
this.min = cp?.min ?? 0;
|
|
705
|
+
this.max = cp?.max ?? 99;
|
|
706
|
+
this.step = cp?.step ?? 1;
|
|
707
|
+
this.value = Math.max(this.min, Math.min(cp?.value ?? this.min, this.max));
|
|
708
|
+
this.onChange = cp?.onChange;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
public getValue(): number { return this.value; }
|
|
712
|
+
|
|
713
|
+
public setValue(value: number): void {
|
|
714
|
+
this.value = Math.max(this.min, Math.min(value, this.max));
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
public handleKey(key: string): void {
|
|
718
|
+
if (this.disabled) return;
|
|
719
|
+
let changed = false;
|
|
720
|
+
|
|
721
|
+
if (key === '\x1b[C' || key === 'right') {
|
|
722
|
+
this.value = Math.min(this.value + this.step, this.max);
|
|
723
|
+
changed = true;
|
|
724
|
+
} else if (key === '\x1b[D' || key === 'left') {
|
|
725
|
+
this.value = Math.max(this.value - this.step, this.min);
|
|
726
|
+
changed = true;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (changed) this.onChange?.(this.value);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
public override render(): void {
|
|
733
|
+
this.clear();
|
|
734
|
+
|
|
735
|
+
// Draw "◀ value ▶" centred in the inner area.
|
|
736
|
+
// writeText() auto-picks normal/focused/disabled style — no explicit style needed.
|
|
737
|
+
const { width, height } = this.getInnerSize();
|
|
738
|
+
const label = `◀ ${this.value} ▶`;
|
|
739
|
+
const labelX = Math.max(0, Math.floor((width - label.length) / 2));
|
|
740
|
+
const labelY = Math.floor(height / 2);
|
|
741
|
+
this.writeText(label, { x: labelX, y: labelY });
|
|
742
|
+
|
|
743
|
+
super.render();
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
Compare with the old pattern: no `registry` parameter, no `BUILTIN_BORDER_*` imports, no `updateBorder()` call, no manual style selection — all handled by `Window`.
|
|
749
|
+
|
|
750
|
+
Usage with WindowManager:
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
import { Screen } from './src/Screen/Screen.mjs';
|
|
754
|
+
import { WindowManager } from './src/Screen/WindowManager.mjs';
|
|
755
|
+
import { NumberStepper } from './src/Screen/controls/NumberStepper.mjs';
|
|
756
|
+
import { Pos } from './src/Screen/Pos.mjs';
|
|
757
|
+
import { Size } from './src/Screen/Size.mjs';
|
|
758
|
+
|
|
759
|
+
const screen = new Screen();
|
|
760
|
+
const wm = new WindowManager(screen, { exitKeys: ['\x03'] });
|
|
761
|
+
|
|
762
|
+
const stepper = new NumberStepper(
|
|
763
|
+
{ pos: Pos.center(), size: new Size(16, 3) },
|
|
764
|
+
{ value: 10, min: 0, max: 99, step: 5, onChange: v => console.error(`value: ${v}`) },
|
|
765
|
+
);
|
|
766
|
+
screen.addChild(stepper);
|
|
767
|
+
wm.register(stepper);
|
|
768
|
+
|
|
769
|
+
wm.run();
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
---
|
|
773
|
+
|
|
774
|
+
## 7. YAML / InterfaceBuilder integration
|
|
775
|
+
|
|
776
|
+
`InterfaceBuilder` (`src/Screen/InterfaceBuilder.mts`) builds a window hierarchy from a YAML description:
|
|
777
|
+
|
|
778
|
+
```typescript
|
|
779
|
+
const screen = new Screen();
|
|
780
|
+
const wm = new WindowManager(screen);
|
|
781
|
+
const builder = new InterfaceBuilder();
|
|
782
|
+
const result = await builder.buildFromFile('layout.yaml', screen, wm);
|
|
783
|
+
// result is a Map<string, Window> keyed by the YAML `id` field
|
|
784
|
+
```
|
|
785
|
+
|
|
786
|
+
Focusable controls are automatically registered with `WindowManager` after the full tree is built, so Tab-cycling works out of the box.
|
|
787
|
+
|
|
788
|
+
### 7.1 Supported widget types
|
|
789
|
+
|
|
790
|
+
All eleven built-in control classes are directly instantiable from YAML via the `type:` field:
|
|
791
|
+
|
|
792
|
+
| `type:` | Class | Requires `size:`? | Key YAML fields |
|
|
793
|
+
|---|---|---|---|
|
|
794
|
+
| `window` | `Window` | yes | `background`, `border`, `active`, `content`, `children` |
|
|
795
|
+
| `button` | `Button` | yes | `label`, `focused`, `disabled`, `onPress` |
|
|
796
|
+
| `textbox` | `TextBox` | yes | `value`, `placeholder`, `focused`, `disabled`, `onChange` |
|
|
797
|
+
| `textarea` | `TextArea` | yes | `value`, `placeholder`, `focused`, `disabled`, `onChange` |
|
|
798
|
+
| `checkbox` | `Checkbox` | no (auto-sized) | `label`, `checked`, `focused`, `disabled`, `onChange` |
|
|
799
|
+
| `radio` | `Radio` | no (auto-sized) | `label`, `checked`, `focused`, `disabled`, `onChange` |
|
|
800
|
+
| `statusled` | `StatusLED` | no (auto-sized) | `state` (`ok`/`warn`/`error`/`off`), `label` |
|
|
801
|
+
| `progressbar` | `ProgressBar` | yes | `barValue`, `max`, `showLabel`, `fillColor`, `emptyColor` |
|
|
802
|
+
| `progressbarv` | `ProgressBarV` | yes | `barValue`, `max`, `fillColor`, `emptyColor` |
|
|
803
|
+
| `linechart` | `LineChart` | yes | `data`, `min`, `max`, `chartColor` |
|
|
804
|
+
| `barchart` | `BarChart` | yes | `data`, `barLabels`, `max`, `chartColor`, `barWidth` |
|
|
805
|
+
|
|
806
|
+
Custom (user-defined) control classes are not auto-discoverable. To use one with a YAML layout, either:
|
|
807
|
+
|
|
808
|
+
1. **Place a `window` placeholder** at the desired geometry, retrieve it from the returned map, and construct your control in code at the same `pos`/`size`.
|
|
809
|
+
2. **Extend `InterfaceBuilder`** and override `buildNode` to handle additional `type` values.
|
|
810
|
+
|
|
811
|
+
### 7.2 Position, size, and background
|
|
812
|
+
|
|
813
|
+
```yaml
|
|
814
|
+
pos: { x: 2, y: 5 } # absolute
|
|
815
|
+
pos: { x: -10, y: -3 } # edge-relative (negative = from right/bottom)
|
|
816
|
+
pos: { x: "50%", y: "25%" } # percentage of parent
|
|
817
|
+
pos: center # named preset
|
|
818
|
+
pos: { preset: bottom, offset: 2 }
|
|
819
|
+
|
|
820
|
+
size: { width: 30, height: 10 }
|
|
821
|
+
size: { width: "80%", height: "100%" }
|
|
822
|
+
size: fill # fill both axes
|
|
823
|
+
size: { fillWidth: 3 } # fill width, height = 3
|
|
824
|
+
size: { fillHeight: 20 } # fill height, width = 20
|
|
825
|
+
|
|
826
|
+
background: 237 # numeric StyleId (rarely used directly)
|
|
827
|
+
background: my-panel-bg # name of a style defined in `styles:` section
|
|
828
|
+
background: builtin:window-bg # name of a built-in style (see section 8)
|
|
829
|
+
```
|
|
830
|
+
|
|
831
|
+
### 7.3 Named styles
|
|
832
|
+
|
|
833
|
+
YAML layouts may declare named styles in an optional top-level `styles:` section. Entries are registered in the Screen's `StyleRegistry` **before** any window is built, so they are available as values for any `background:` field. Named styles may also **override** built-in names (e.g. `builtin:border-focused`) to re-skin every control in the application.
|
|
834
|
+
|
|
835
|
+
```yaml
|
|
836
|
+
styles:
|
|
837
|
+
- name: panel-bg # custom name
|
|
838
|
+
background: 235
|
|
839
|
+
- name: builtin:border-focused # override the built-in focused border colour
|
|
840
|
+
foreground: 214 # amber
|
|
841
|
+
|
|
842
|
+
windows:
|
|
843
|
+
- id: sidebar
|
|
844
|
+
pos: topLeft
|
|
845
|
+
size: { width: 30, height: "100%" }
|
|
846
|
+
background: panel-bg # reference the custom style by name
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
### 7.4 Callbacks
|
|
850
|
+
|
|
851
|
+
`onPress` and `onChange` YAML fields reference **callback IDs** registered with `InterfaceBuilder.registerCallback(id, fn)` before `build()` is called:
|
|
852
|
+
|
|
853
|
+
```typescript
|
|
854
|
+
const builder = new InterfaceBuilder();
|
|
855
|
+
builder.registerCallback('save', () => saveSettings());
|
|
856
|
+
builder.registerCallback('toggle', (checked: boolean) => setDarkMode(checked));
|
|
857
|
+
await builder.buildFromFile('layout.yaml', screen, wm);
|
|
858
|
+
```
|
|
859
|
+
|
|
860
|
+
```yaml
|
|
861
|
+
- id: btnSave
|
|
862
|
+
type: button
|
|
863
|
+
pos: { x: -15, y: -4 }
|
|
864
|
+
size: { width: 10, height: 3 }
|
|
865
|
+
label: "Save"
|
|
866
|
+
onPress: save # → fires the 'save' callback
|
|
867
|
+
- id: cbDark
|
|
868
|
+
type: checkbox
|
|
869
|
+
pos: { x: 1, y: 1 }
|
|
870
|
+
label: "Dark mode"
|
|
871
|
+
onChange: toggle # → fires the 'toggle' callback
|
|
872
|
+
```
|
|
873
|
+
|
|
874
|
+
### 7.5 Full example
|
|
875
|
+
|
|
876
|
+
```yaml
|
|
877
|
+
styles:
|
|
878
|
+
- name: builtin:border-focused
|
|
879
|
+
foreground: 214 # amber focused borders everywhere
|
|
880
|
+
|
|
881
|
+
windows:
|
|
882
|
+
- id: dialog
|
|
883
|
+
pos: center
|
|
884
|
+
size: { width: "80%", height: "80%" }
|
|
885
|
+
background: builtin:window-bg
|
|
886
|
+
border: { top: true, right: true, bottom: true, left: true, style: rounded, color: 240 }
|
|
887
|
+
children:
|
|
888
|
+
- id: ledStatus
|
|
889
|
+
type: statusled
|
|
890
|
+
pos: { x: 2, y: 2 }
|
|
891
|
+
state: ok
|
|
892
|
+
label: "API online"
|
|
893
|
+
|
|
894
|
+
- id: pbCpu
|
|
895
|
+
type: progressbar
|
|
896
|
+
pos: { x: 2, y: 4 }
|
|
897
|
+
size: { width: 30, height: 1 }
|
|
898
|
+
barValue: 73
|
|
899
|
+
max: 100
|
|
900
|
+
fillColor: 75
|
|
901
|
+
|
|
902
|
+
- id: chart
|
|
903
|
+
type: linechart
|
|
904
|
+
pos: { x: 2, y: 6 }
|
|
905
|
+
size: { width: 60, height: 15 }
|
|
906
|
+
data: [5, 18, 8, 45, 22, 60, 38, 72, 50, 65]
|
|
907
|
+
chartColor: 75
|
|
908
|
+
```
|
|
909
|
+
|
|
910
|
+
After `build()`, retrieve any window or control from the returned map and drive it at runtime:
|
|
911
|
+
|
|
912
|
+
```typescript
|
|
913
|
+
const result = await builder.buildFromFile('layout.yaml', screen, wm);
|
|
914
|
+
const ledStatus = result.get('ledStatus') as StatusLED;
|
|
915
|
+
const pbCpu = result.get('pbCpu') as ProgressBar;
|
|
916
|
+
|
|
917
|
+
setInterval(() => {
|
|
918
|
+
pbCpu.setValue(readCpuPercent());
|
|
919
|
+
ledStatus.setState(isHealthy() ? 'ok' : 'warn');
|
|
920
|
+
screen.render();
|
|
921
|
+
}, 1000);
|
|
922
|
+
```
|
|
923
|
+
|
|
924
|
+
---
|
|
925
|
+
|
|
926
|
+
## 8. Built-in style reference
|
|
927
|
+
|
|
928
|
+
All names are exported as constants from `src/Screen/types.mts`.
|
|
929
|
+
|
|
930
|
+
| Constant | Name string | Default attributes |
|
|
931
|
+
|---|---|---|
|
|
932
|
+
| `BUILTIN_WINDOW_BG` | `builtin:window-bg` | `{ background: 237 }` |
|
|
933
|
+
| `BUILTIN_BORDER` | `builtin:border` | `{ foreground: 240 }` |
|
|
934
|
+
| `BUILTIN_BORDER_FOCUSED` | `builtin:border-focused` | `{ foreground: 75 }` |
|
|
935
|
+
| `BUILTIN_BORDER_DISABLED` | `builtin:border-disabled` | `{ foreground: 238 }` |
|
|
936
|
+
| `BUILTIN_TEXT` | `builtin:text` | `{ foreground: 252 }` |
|
|
937
|
+
| `BUILTIN_TEXT_FOCUSED` | `builtin:text-focused` | `{ foreground: 255, bold: true }` |
|
|
938
|
+
| `BUILTIN_TEXT_DISABLED` | `builtin:text-disabled` | `{ foreground: 245, dim: true }` |
|
|
939
|
+
| `BUILTIN_TEXT_PLACEHOLDER` | `builtin:text-placeholder` | `{ foreground: 242, italic: true }` |
|
|
940
|
+
| `BUILTIN_TEXT_CHECKED` | `builtin:text-checked` | `{ foreground: 76, bold: true }` |
|
|
941
|
+
| `BUILTIN_CURSOR` | `builtin:cursor` | `{ inverse: true }` |
|
|
942
|
+
|
|
943
|
+
Override any of these for the whole application:
|
|
944
|
+
|
|
945
|
+
```typescript
|
|
946
|
+
// Amber focused borders everywhere.
|
|
947
|
+
screen.setBuiltinStyle(BUILTIN_BORDER_FOCUSED, { foreground: 214 });
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
Or override in YAML before building the layout:
|
|
951
|
+
|
|
952
|
+
```yaml
|
|
953
|
+
styles:
|
|
954
|
+
- name: builtin:border-focused
|
|
955
|
+
foreground: 214
|
|
956
|
+
windows:
|
|
957
|
+
- ...
|
|
958
|
+
```
|
|
959
|
+
|
|
960
|
+
---
|
|
961
|
+
|
|
962
|
+
## 9. Built-in controls reference
|
|
963
|
+
|
|
964
|
+
Every control extends `Window` and lives under `src/Screen/controls/`. Constructor signatures follow the two-argument pattern: `(wp: WindowProperties, cp?: ControlProperties)`. Controls created after `new Screen()` share the Screen's global `StyleRegistry` automatically — no registry parameter needed. Size behaviour, interactivity, and key methods are summarised below.
|
|
965
|
+
|
|
966
|
+
### Interactive controls (Focusable — registerable with `WindowManager`)
|
|
967
|
+
|
|
968
|
+
| Class | Size | Purpose | Key methods |
|
|
969
|
+
|---|---|---|---|
|
|
970
|
+
| `Button` | manual | Clickable button with rounded border and centred label. Enter/Space triggers `onPress`. | `setLabel`, `setFocused`, `setDisabled` |
|
|
971
|
+
| `TextBox` | manual | Single-line text input with scrolling, cursor, and placeholder. | `setValue`/`getValue`, `setCursor`/`getCursor`, `handleKey` |
|
|
972
|
+
| `TextArea` | manual | Multi-line text input with 2-D cursor and scrolling. | `setValue`/`getValue`, `setCursor`/`getCursor` (2-D), `handleKey` |
|
|
973
|
+
| `Checkbox` | auto | `[✓]`/`[ ]` toggle auto-sized to `4 + label.length`. Space toggles. | `setChecked`/`isChecked`, `setFocused`, `setDisabled` |
|
|
974
|
+
| `Radio` | auto | `(●)`/`( )` single-selection auto-sized to `4 + label.length`. Space selects. | `setChecked`/`isChecked`, `setFocused`, `setDisabled` |
|
|
975
|
+
|
|
976
|
+
### Read-only display controls (added 0.10.0)
|
|
977
|
+
|
|
978
|
+
| Class | Size | Purpose | Key methods |
|
|
979
|
+
|---|---|---|---|
|
|
980
|
+
| `StatusLED` | auto (`2 + label.length` × 1) | Coloured dot (●) with optional trailing label. States: `ok` / `warn` / `error` / `off`. | `setState`, `getState`, `setLabel` |
|
|
981
|
+
| `ProgressBar` | manual | Horizontal bar (`█`/`░`) with optional centred percentage label. | `setValue`/`getValue`, `setMax`/`getMax` |
|
|
982
|
+
| `ProgressBarV` | manual | Vertical bar (`█`/`░`) that fills from the bottom upward. | `setValue`/`getValue`, `setMax`/`getMax` |
|
|
983
|
+
| `LineChart` | manual | Line chart using box-drawing characters (`─`, `│`, `╭`, `╮`, `╯`, `╰`) with labelled Y-axis and X-axis. Min height 3, min width 4. | `setData`/`getData`, `setMin`/`setMax` |
|
|
984
|
+
| `BarChart` | manual | Vertical bar chart (`█`) with a one-row label strip at the bottom and configurable bar width. | `setData`/`getData`, `setLabels`, `setMax` |
|
|
985
|
+
|
|
986
|
+
All controls honour the built-in named style system (section 3): border colour and text style update automatically on every `render()` via `Window.syncBorderColor()` and `writeText()` auto-style. Override styles globally with `screen.setBuiltinStyle(BUILTIN_BORDER_FOCUSED, { foreground: 214 })`.
|