@teammates/consolonia 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -0
- package/dist/__tests__/ansi.test.d.ts +1 -0
- package/dist/__tests__/ansi.test.js +520 -0
- package/dist/__tests__/chat-view.test.d.ts +4 -0
- package/dist/__tests__/chat-view.test.js +480 -0
- package/dist/__tests__/drawing.test.d.ts +4 -0
- package/dist/__tests__/drawing.test.js +426 -0
- package/dist/__tests__/input.test.d.ts +5 -0
- package/dist/__tests__/input.test.js +911 -0
- package/dist/__tests__/layout.test.d.ts +4 -0
- package/dist/__tests__/layout.test.js +689 -0
- package/dist/__tests__/pixel.test.d.ts +1 -0
- package/dist/__tests__/pixel.test.js +674 -0
- package/dist/__tests__/render.test.d.ts +1 -0
- package/dist/__tests__/render.test.js +400 -0
- package/dist/__tests__/styled.test.d.ts +4 -0
- package/dist/__tests__/styled.test.js +149 -0
- package/dist/__tests__/widgets.test.d.ts +5 -0
- package/dist/__tests__/widgets.test.js +924 -0
- package/dist/ansi/esc.d.ts +61 -0
- package/dist/ansi/esc.js +85 -0
- package/dist/ansi/output.d.ts +66 -0
- package/dist/ansi/output.js +192 -0
- package/dist/ansi/strip.d.ts +16 -0
- package/dist/ansi/strip.js +74 -0
- package/dist/app.d.ts +68 -0
- package/dist/app.js +297 -0
- package/dist/drawing/clip.d.ts +23 -0
- package/dist/drawing/clip.js +67 -0
- package/dist/drawing/context.d.ts +77 -0
- package/dist/drawing/context.js +275 -0
- package/dist/index.d.ts +48 -0
- package/dist/index.js +63 -0
- package/dist/input/escape-matcher.d.ts +27 -0
- package/dist/input/escape-matcher.js +253 -0
- package/dist/input/events.d.ts +49 -0
- package/dist/input/events.js +17 -0
- package/dist/input/index.d.ts +15 -0
- package/dist/input/index.js +14 -0
- package/dist/input/matcher.d.ts +23 -0
- package/dist/input/matcher.js +14 -0
- package/dist/input/mouse-matcher.d.ts +27 -0
- package/dist/input/mouse-matcher.js +142 -0
- package/dist/input/paste-matcher.d.ts +23 -0
- package/dist/input/paste-matcher.js +104 -0
- package/dist/input/processor.d.ts +51 -0
- package/dist/input/processor.js +145 -0
- package/dist/input/raw-mode.d.ts +13 -0
- package/dist/input/raw-mode.js +24 -0
- package/dist/input/text-matcher.d.ts +14 -0
- package/dist/input/text-matcher.js +32 -0
- package/dist/layout/box.d.ts +33 -0
- package/dist/layout/box.js +92 -0
- package/dist/layout/column.d.ts +21 -0
- package/dist/layout/column.js +90 -0
- package/dist/layout/control.d.ts +73 -0
- package/dist/layout/control.js +215 -0
- package/dist/layout/row.d.ts +21 -0
- package/dist/layout/row.js +95 -0
- package/dist/layout/stack.d.ts +18 -0
- package/dist/layout/stack.js +64 -0
- package/dist/layout/types.d.ts +27 -0
- package/dist/layout/types.js +4 -0
- package/dist/pixel/background.d.ts +16 -0
- package/dist/pixel/background.js +16 -0
- package/dist/pixel/box-pattern.d.ts +38 -0
- package/dist/pixel/box-pattern.js +57 -0
- package/dist/pixel/buffer.d.ts +25 -0
- package/dist/pixel/buffer.js +51 -0
- package/dist/pixel/color.d.ts +48 -0
- package/dist/pixel/color.js +92 -0
- package/dist/pixel/foreground.d.ts +31 -0
- package/dist/pixel/foreground.js +64 -0
- package/dist/pixel/pixel.d.ts +21 -0
- package/dist/pixel/pixel.js +38 -0
- package/dist/pixel/symbol.d.ts +38 -0
- package/dist/pixel/symbol.js +192 -0
- package/dist/render/regions.d.ts +54 -0
- package/dist/render/regions.js +102 -0
- package/dist/render/render-target.d.ts +42 -0
- package/dist/render/render-target.js +118 -0
- package/dist/styled.d.ts +113 -0
- package/dist/styled.js +176 -0
- package/dist/widgets/border.d.ts +34 -0
- package/dist/widgets/border.js +121 -0
- package/dist/widgets/chat-view.d.ts +239 -0
- package/dist/widgets/chat-view.js +993 -0
- package/dist/widgets/interview.d.ts +87 -0
- package/dist/widgets/interview.js +187 -0
- package/dist/widgets/markdown.d.ts +87 -0
- package/dist/widgets/markdown.js +611 -0
- package/dist/widgets/panel.d.ts +19 -0
- package/dist/widgets/panel.js +35 -0
- package/dist/widgets/scroll-view.d.ts +43 -0
- package/dist/widgets/scroll-view.js +182 -0
- package/dist/widgets/styled-text.d.ts +38 -0
- package/dist/widgets/styled-text.js +183 -0
- package/dist/widgets/syntax.d.ts +37 -0
- package/dist/widgets/syntax.js +670 -0
- package/dist/widgets/text-input.d.ts +121 -0
- package/dist/widgets/text-input.js +618 -0
- package/dist/widgets/text.d.ts +34 -0
- package/dist/widgets/text.js +168 -0
- package/package.json +45 -0
package/dist/app.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* App — the top-level shell that owns the entire terminal lifecycle.
|
|
3
|
+
*
|
|
4
|
+
* Creates and wires together all subsystems (PixelBuffer, AnsiOutput,
|
|
5
|
+
* RenderTarget, DrawingContext, InputProcessor) and drives the
|
|
6
|
+
* measure → arrange → render loop in response to input and resize events.
|
|
7
|
+
*/
|
|
8
|
+
import * as esc from "./ansi/esc.js";
|
|
9
|
+
import { AnsiOutput } from "./ansi/output.js";
|
|
10
|
+
import { DrawingContext } from "./drawing/context.js";
|
|
11
|
+
import { createInputProcessor } from "./input/processor.js";
|
|
12
|
+
import { disableRawMode, enableRawMode } from "./input/raw-mode.js";
|
|
13
|
+
import { PixelBuffer } from "./pixel/buffer.js";
|
|
14
|
+
import { DirtyRegions } from "./render/regions.js";
|
|
15
|
+
import { RenderTarget } from "./render/render-target.js";
|
|
16
|
+
// ── App ──────────────────────────────────────────────────────────────
|
|
17
|
+
export class App {
|
|
18
|
+
root;
|
|
19
|
+
_alternateScreen;
|
|
20
|
+
_mouse;
|
|
21
|
+
_title;
|
|
22
|
+
// Subsystems — created during run()
|
|
23
|
+
_output;
|
|
24
|
+
_buffer;
|
|
25
|
+
_dirtyRegions;
|
|
26
|
+
_renderTarget;
|
|
27
|
+
_drawingContext;
|
|
28
|
+
_processor;
|
|
29
|
+
_events;
|
|
30
|
+
// Lifecycle bookkeeping
|
|
31
|
+
_running = false;
|
|
32
|
+
_resolve = null;
|
|
33
|
+
_stdinListener = null;
|
|
34
|
+
_resizeListener = null;
|
|
35
|
+
_sigintListener = null;
|
|
36
|
+
_renderScheduled = false;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
this.root = options.root;
|
|
39
|
+
this._alternateScreen = options.alternateScreen ?? true;
|
|
40
|
+
this._mouse = options.mouse ?? false;
|
|
41
|
+
this._title = options.title;
|
|
42
|
+
}
|
|
43
|
+
// ── Public API ───────────────────────────────────────────────────
|
|
44
|
+
/**
|
|
45
|
+
* Start the app — enters raw mode, sets up terminal, runs the event
|
|
46
|
+
* loop. Returns a promise that resolves when the app stops.
|
|
47
|
+
*/
|
|
48
|
+
run() {
|
|
49
|
+
if (this._running) {
|
|
50
|
+
return Promise.reject(new Error("App is already running"));
|
|
51
|
+
}
|
|
52
|
+
this._running = true;
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
this._resolve = resolve;
|
|
55
|
+
try {
|
|
56
|
+
this._setup();
|
|
57
|
+
this._initialRender();
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
this._teardown();
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
/** Stop the app — restores terminal and exits the event loop. */
|
|
66
|
+
stop() {
|
|
67
|
+
if (!this._running)
|
|
68
|
+
return;
|
|
69
|
+
this._teardown();
|
|
70
|
+
}
|
|
71
|
+
/** Force a full re-render. */
|
|
72
|
+
refresh() {
|
|
73
|
+
if (!this._running)
|
|
74
|
+
return;
|
|
75
|
+
this._fullRender();
|
|
76
|
+
}
|
|
77
|
+
// ── Setup ────────────────────────────────────────────────────────
|
|
78
|
+
_setup() {
|
|
79
|
+
const stdout = process.stdout;
|
|
80
|
+
// 1. Enable raw mode
|
|
81
|
+
enableRawMode();
|
|
82
|
+
// 2. Create ANSI output
|
|
83
|
+
this._output = new AnsiOutput(stdout);
|
|
84
|
+
// 3. Prepare terminal (custom sequence instead of prepareTerminal()
|
|
85
|
+
// so we can conditionally enable mouse tracking)
|
|
86
|
+
this._prepareTerminal();
|
|
87
|
+
// 4. Set terminal title
|
|
88
|
+
if (this._title) {
|
|
89
|
+
stdout.write(esc.setTitle(this._title));
|
|
90
|
+
}
|
|
91
|
+
// 5. Create pixel buffer at terminal dimensions
|
|
92
|
+
const cols = stdout.columns || 80;
|
|
93
|
+
const rows = stdout.rows || 24;
|
|
94
|
+
this._createRenderPipeline(cols, rows);
|
|
95
|
+
// 6. Wire up input
|
|
96
|
+
this._setupInput();
|
|
97
|
+
// 7. Wire up resize
|
|
98
|
+
this._resizeListener = () => this._handleResize();
|
|
99
|
+
stdout.on("resize", this._resizeListener);
|
|
100
|
+
// 8. SIGINT fallback
|
|
101
|
+
this._sigintListener = () => this.stop();
|
|
102
|
+
process.on("SIGINT", this._sigintListener);
|
|
103
|
+
}
|
|
104
|
+
_prepareTerminal() {
|
|
105
|
+
const stream = process.stdout;
|
|
106
|
+
let seq = "";
|
|
107
|
+
if (this._alternateScreen) {
|
|
108
|
+
seq += esc.alternateScreenOn;
|
|
109
|
+
}
|
|
110
|
+
seq += esc.hideCursor;
|
|
111
|
+
seq += esc.bracketedPasteOn;
|
|
112
|
+
if (this._mouse) {
|
|
113
|
+
seq += esc.mouseTrackingOn;
|
|
114
|
+
}
|
|
115
|
+
seq += esc.clearScreen;
|
|
116
|
+
stream.write(seq);
|
|
117
|
+
}
|
|
118
|
+
_restoreTerminal() {
|
|
119
|
+
const stream = process.stdout;
|
|
120
|
+
let seq = esc.reset;
|
|
121
|
+
if (this._mouse) {
|
|
122
|
+
seq += esc.mouseTrackingOff;
|
|
123
|
+
}
|
|
124
|
+
seq += esc.bracketedPasteOff;
|
|
125
|
+
seq += esc.showCursor;
|
|
126
|
+
if (this._alternateScreen) {
|
|
127
|
+
seq += esc.alternateScreenOff;
|
|
128
|
+
}
|
|
129
|
+
stream.write(seq);
|
|
130
|
+
}
|
|
131
|
+
_createRenderPipeline(cols, rows) {
|
|
132
|
+
this._buffer = new PixelBuffer(cols, rows);
|
|
133
|
+
this._dirtyRegions = new DirtyRegions();
|
|
134
|
+
this._renderTarget = new RenderTarget(this._buffer, this._output);
|
|
135
|
+
this._drawingContext = new DrawingContext(this._buffer);
|
|
136
|
+
}
|
|
137
|
+
_setupInput() {
|
|
138
|
+
const { processor, events } = createInputProcessor();
|
|
139
|
+
this._processor = processor;
|
|
140
|
+
this._events = events;
|
|
141
|
+
// Listen for parsed input events
|
|
142
|
+
this._events.on("input", (event) => {
|
|
143
|
+
this._handleInput(event);
|
|
144
|
+
});
|
|
145
|
+
// Feed raw stdin data to the processor
|
|
146
|
+
this._stdinListener = (data) => {
|
|
147
|
+
this._processor.feed(data);
|
|
148
|
+
};
|
|
149
|
+
process.stdin.on("data", this._stdinListener);
|
|
150
|
+
}
|
|
151
|
+
// ── Input handling ───────────────────────────────────────────────
|
|
152
|
+
_handleInput(event) {
|
|
153
|
+
// Dispatch to root control (including Ctrl+C — let the app handle it)
|
|
154
|
+
this.root.handleInput(event);
|
|
155
|
+
// Schedule a render if the tree is dirty
|
|
156
|
+
this._scheduleRender();
|
|
157
|
+
}
|
|
158
|
+
// ── Resize handling ──────────────────────────────────────────────
|
|
159
|
+
_handleResize() {
|
|
160
|
+
const stdout = process.stdout;
|
|
161
|
+
const cols = stdout.columns || 80;
|
|
162
|
+
const rows = stdout.rows || 24;
|
|
163
|
+
// Recreate render pipeline at new size
|
|
164
|
+
this._buffer = new PixelBuffer(cols, rows);
|
|
165
|
+
this._dirtyRegions = new DirtyRegions();
|
|
166
|
+
this._renderTarget = new RenderTarget(this._buffer, this._output);
|
|
167
|
+
this._drawingContext = new DrawingContext(this._buffer);
|
|
168
|
+
// Mark root as dirty and do a full render
|
|
169
|
+
this.root.invalidate();
|
|
170
|
+
this._fullRender();
|
|
171
|
+
}
|
|
172
|
+
// ── Render loop ──────────────────────────────────────────────────
|
|
173
|
+
/**
|
|
174
|
+
* Schedule a render pass using setImmediate so multiple rapid events
|
|
175
|
+
* within the same tick coalesce into a single render.
|
|
176
|
+
*/
|
|
177
|
+
_scheduleRender() {
|
|
178
|
+
if (this._renderScheduled || !this._running)
|
|
179
|
+
return;
|
|
180
|
+
this._renderScheduled = true;
|
|
181
|
+
setImmediate(() => {
|
|
182
|
+
this._renderScheduled = false;
|
|
183
|
+
if (!this._running)
|
|
184
|
+
return;
|
|
185
|
+
if (this.root.dirty) {
|
|
186
|
+
this._renderFrame();
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
/** Perform a full measure → arrange → render cycle (used on init and resize). */
|
|
191
|
+
_fullRender() {
|
|
192
|
+
if (!this._running)
|
|
193
|
+
return;
|
|
194
|
+
const cols = this._buffer.width;
|
|
195
|
+
const rows = this._buffer.height;
|
|
196
|
+
// Clear buffer
|
|
197
|
+
this._buffer.clear();
|
|
198
|
+
// Measure
|
|
199
|
+
const constraint = {
|
|
200
|
+
minWidth: 0,
|
|
201
|
+
maxWidth: cols,
|
|
202
|
+
minHeight: 0,
|
|
203
|
+
maxHeight: rows,
|
|
204
|
+
};
|
|
205
|
+
this.root.measure(constraint);
|
|
206
|
+
// Arrange
|
|
207
|
+
const arrangeRect = { x: 0, y: 0, width: cols, height: rows };
|
|
208
|
+
this.root.arrange(arrangeRect);
|
|
209
|
+
// Render control tree into buffer
|
|
210
|
+
this.root.render(this._drawingContext);
|
|
211
|
+
this._clearDirty(this.root);
|
|
212
|
+
// Mark entire screen dirty and flush to terminal
|
|
213
|
+
this._dirtyRegions.addRect({
|
|
214
|
+
x: 0,
|
|
215
|
+
y: 0,
|
|
216
|
+
width: cols,
|
|
217
|
+
height: rows,
|
|
218
|
+
});
|
|
219
|
+
this._renderTarget.render(this._dirtyRegions);
|
|
220
|
+
}
|
|
221
|
+
/** Perform an incremental render for dirty regions. */
|
|
222
|
+
_renderFrame() {
|
|
223
|
+
const cols = this._buffer.width;
|
|
224
|
+
const rows = this._buffer.height;
|
|
225
|
+
// Clear buffer
|
|
226
|
+
this._buffer.clear();
|
|
227
|
+
// Measure
|
|
228
|
+
const constraint = {
|
|
229
|
+
minWidth: 0,
|
|
230
|
+
maxWidth: cols,
|
|
231
|
+
minHeight: 0,
|
|
232
|
+
maxHeight: rows,
|
|
233
|
+
};
|
|
234
|
+
this.root.measure(constraint);
|
|
235
|
+
// Arrange
|
|
236
|
+
const arrangeRect = { x: 0, y: 0, width: cols, height: rows };
|
|
237
|
+
this.root.arrange(arrangeRect);
|
|
238
|
+
// Mark the full area as dirty (controls re-render their full bounds)
|
|
239
|
+
this._dirtyRegions.addRect({
|
|
240
|
+
x: 0,
|
|
241
|
+
y: 0,
|
|
242
|
+
width: cols,
|
|
243
|
+
height: rows,
|
|
244
|
+
});
|
|
245
|
+
// Render control tree into buffer
|
|
246
|
+
this.root.render(this._drawingContext);
|
|
247
|
+
this._clearDirty(this.root);
|
|
248
|
+
// Diff and flush to terminal
|
|
249
|
+
this._renderTarget.render(this._dirtyRegions);
|
|
250
|
+
}
|
|
251
|
+
/** Recursively clear dirty flags on the entire control tree. */
|
|
252
|
+
_clearDirty(ctrl) {
|
|
253
|
+
ctrl.dirty = false;
|
|
254
|
+
for (const child of ctrl.children) {
|
|
255
|
+
this._clearDirty(child);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Run the initial render after setup. */
|
|
259
|
+
_initialRender() {
|
|
260
|
+
this._fullRender();
|
|
261
|
+
}
|
|
262
|
+
// ── Teardown ─────────────────────────────────────────────────────
|
|
263
|
+
_teardown() {
|
|
264
|
+
if (!this._running)
|
|
265
|
+
return;
|
|
266
|
+
this._running = false;
|
|
267
|
+
// Remove stdin listener
|
|
268
|
+
if (this._stdinListener) {
|
|
269
|
+
process.stdin.removeListener("data", this._stdinListener);
|
|
270
|
+
this._stdinListener = null;
|
|
271
|
+
}
|
|
272
|
+
// Remove resize listener
|
|
273
|
+
if (this._resizeListener) {
|
|
274
|
+
process.stdout.removeListener("resize", this._resizeListener);
|
|
275
|
+
this._resizeListener = null;
|
|
276
|
+
}
|
|
277
|
+
// Remove SIGINT listener
|
|
278
|
+
if (this._sigintListener) {
|
|
279
|
+
process.removeListener("SIGINT", this._sigintListener);
|
|
280
|
+
this._sigintListener = null;
|
|
281
|
+
}
|
|
282
|
+
// Destroy input processor (clears timers)
|
|
283
|
+
if (this._processor) {
|
|
284
|
+
this._processor.destroy();
|
|
285
|
+
}
|
|
286
|
+
// Restore terminal
|
|
287
|
+
this._restoreTerminal();
|
|
288
|
+
// Disable raw mode
|
|
289
|
+
disableRawMode();
|
|
290
|
+
// Resolve the run() promise
|
|
291
|
+
if (this._resolve) {
|
|
292
|
+
const resolve = this._resolve;
|
|
293
|
+
this._resolve = null;
|
|
294
|
+
resolve();
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clip stack: manages a stack of clip rectangles for restricting drawing regions.
|
|
3
|
+
*
|
|
4
|
+
* Each push intersects the new rect with the current top to produce the effective
|
|
5
|
+
* clip. Pop restores the previous effective clip.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rect } from "../layout/types.js";
|
|
8
|
+
export declare class ClipStack {
|
|
9
|
+
/**
|
|
10
|
+
* Stack of effective clip rects. Each entry is the intersection of the
|
|
11
|
+
* pushed rect with its parent. A null entry means the clip is fully
|
|
12
|
+
* degenerate (zero-area) but we still track it so pop() works correctly.
|
|
13
|
+
*/
|
|
14
|
+
private readonly stack;
|
|
15
|
+
/** Push a clip rectangle. Drawing outside this rect is ignored. */
|
|
16
|
+
push(rect: Rect): void;
|
|
17
|
+
/** Pop the last clip rectangle. */
|
|
18
|
+
pop(): void;
|
|
19
|
+
/** Check if a point is within the current clip bounds. */
|
|
20
|
+
contains(x: number, y: number): boolean;
|
|
21
|
+
/** Get the current effective clip rect (intersection of all pushed rects). */
|
|
22
|
+
current(): Rect | null;
|
|
23
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clip stack: manages a stack of clip rectangles for restricting drawing regions.
|
|
3
|
+
*
|
|
4
|
+
* Each push intersects the new rect with the current top to produce the effective
|
|
5
|
+
* clip. Pop restores the previous effective clip.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Intersect two rectangles, returning the overlapping region.
|
|
9
|
+
* Returns null if there is no overlap.
|
|
10
|
+
*/
|
|
11
|
+
function intersectRects(a, b) {
|
|
12
|
+
const x0 = Math.max(a.x, b.x);
|
|
13
|
+
const y0 = Math.max(a.y, b.y);
|
|
14
|
+
const x1 = Math.min(a.x + a.width, b.x + b.width);
|
|
15
|
+
const y1 = Math.min(a.y + a.height, b.y + b.height);
|
|
16
|
+
if (x1 <= x0 || y1 <= y0)
|
|
17
|
+
return null;
|
|
18
|
+
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
|
|
19
|
+
}
|
|
20
|
+
export class ClipStack {
|
|
21
|
+
/**
|
|
22
|
+
* Stack of effective clip rects. Each entry is the intersection of the
|
|
23
|
+
* pushed rect with its parent. A null entry means the clip is fully
|
|
24
|
+
* degenerate (zero-area) but we still track it so pop() works correctly.
|
|
25
|
+
*/
|
|
26
|
+
stack = [];
|
|
27
|
+
/** Push a clip rectangle. Drawing outside this rect is ignored. */
|
|
28
|
+
push(rect) {
|
|
29
|
+
const top = this.current();
|
|
30
|
+
if (top === null && this.stack.length > 0) {
|
|
31
|
+
// Already fully clipped — intersection stays null
|
|
32
|
+
this.stack.push(null);
|
|
33
|
+
}
|
|
34
|
+
else if (top === null) {
|
|
35
|
+
// First push — use the rect directly
|
|
36
|
+
this.stack.push(rect);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
this.stack.push(intersectRects(top, rect));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** Pop the last clip rectangle. */
|
|
43
|
+
pop() {
|
|
44
|
+
if (this.stack.length === 0) {
|
|
45
|
+
throw new Error("ClipStack underflow: cannot pop from an empty stack");
|
|
46
|
+
}
|
|
47
|
+
this.stack.pop();
|
|
48
|
+
}
|
|
49
|
+
/** Check if a point is within the current clip bounds. */
|
|
50
|
+
contains(x, y) {
|
|
51
|
+
const clip = this.current();
|
|
52
|
+
if (clip === null) {
|
|
53
|
+
// No clip means everything is visible (empty stack) or nothing is (degenerate)
|
|
54
|
+
return this.stack.length === 0;
|
|
55
|
+
}
|
|
56
|
+
return (x >= clip.x &&
|
|
57
|
+
x < clip.x + clip.width &&
|
|
58
|
+
y >= clip.y &&
|
|
59
|
+
y < clip.y + clip.height);
|
|
60
|
+
}
|
|
61
|
+
/** Get the current effective clip rect (intersection of all pushed rects). */
|
|
62
|
+
current() {
|
|
63
|
+
if (this.stack.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
return this.stack[this.stack.length - 1] ?? null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrawingContext: the main drawing API for rendering to a PixelBuffer.
|
|
3
|
+
*
|
|
4
|
+
* Port of Consolonia's DrawingContextImpl.cs + DrawingContextImpl.Boxes.cs,
|
|
5
|
+
* simplified for the Node.js/TypeScript environment.
|
|
6
|
+
*/
|
|
7
|
+
import type { Rect } from "../layout/types.js";
|
|
8
|
+
import type { PixelBuffer } from "../pixel/buffer.js";
|
|
9
|
+
import type { Color } from "../pixel/color.js";
|
|
10
|
+
import type { Pixel } from "../pixel/pixel.js";
|
|
11
|
+
export interface TextStyle {
|
|
12
|
+
fg?: Color;
|
|
13
|
+
bg?: Color;
|
|
14
|
+
bold?: boolean;
|
|
15
|
+
italic?: boolean;
|
|
16
|
+
underline?: boolean;
|
|
17
|
+
strikethrough?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface BoxStyle {
|
|
20
|
+
fg?: Color;
|
|
21
|
+
bg?: Color;
|
|
22
|
+
bold?: boolean;
|
|
23
|
+
}
|
|
24
|
+
export declare class DrawingContext {
|
|
25
|
+
private readonly buffer;
|
|
26
|
+
private readonly clipStack;
|
|
27
|
+
private translateStack;
|
|
28
|
+
private offsetX;
|
|
29
|
+
private offsetY;
|
|
30
|
+
constructor(buffer: PixelBuffer);
|
|
31
|
+
/** Push a clip rectangle. All drawing is clipped to this region. */
|
|
32
|
+
pushClip(rect: Rect): void;
|
|
33
|
+
/** Pop the last clip rectangle. */
|
|
34
|
+
popClip(): void;
|
|
35
|
+
/** Push a coordinate translation. All drawing coordinates are offset. */
|
|
36
|
+
pushTranslate(dx: number, dy: number): void;
|
|
37
|
+
/** Pop the last coordinate translation. */
|
|
38
|
+
popTranslate(): void;
|
|
39
|
+
/** Translate a point by the current offset. */
|
|
40
|
+
private tx;
|
|
41
|
+
private ty;
|
|
42
|
+
/** Check if a local-coord point is visible after translate. */
|
|
43
|
+
private isVisible;
|
|
44
|
+
/** Get a pixel at local coordinates (translated to buffer coords). */
|
|
45
|
+
private bufGet;
|
|
46
|
+
/** Set a pixel at local coordinates (translated to buffer coords). */
|
|
47
|
+
private bufSet;
|
|
48
|
+
/** Fill a rectangle with a solid color. */
|
|
49
|
+
fillRect(rect: Rect, color: Color): void;
|
|
50
|
+
/** Draw a single character at (x, y) with styling. */
|
|
51
|
+
drawChar(x: number, y: number, char: string, style?: TextStyle): void;
|
|
52
|
+
/** Draw a text string at (x, y). Handles wide characters and tabs. */
|
|
53
|
+
drawText(x: number, y: number, text: string, style?: TextStyle): void;
|
|
54
|
+
/**
|
|
55
|
+
* Draw an array of styled segments at (x, y).
|
|
56
|
+
* Each segment carries its own TextStyle; segments are drawn sequentially.
|
|
57
|
+
*/
|
|
58
|
+
drawStyledText(x: number, y: number, segments: {
|
|
59
|
+
text: string;
|
|
60
|
+
style: TextStyle;
|
|
61
|
+
}[]): void;
|
|
62
|
+
/** Draw a box-drawing rectangle (border) with smart corner merging. */
|
|
63
|
+
drawBox(rect: Rect, style?: BoxStyle): void;
|
|
64
|
+
/**
|
|
65
|
+
* Draw a single box-drawing character at (x, y), merging with any existing
|
|
66
|
+
* box pattern at that position.
|
|
67
|
+
*/
|
|
68
|
+
private drawBoxChar;
|
|
69
|
+
/** Draw a horizontal line using box-drawing characters. */
|
|
70
|
+
drawHLine(x: number, y: number, width: number, style?: TextStyle): void;
|
|
71
|
+
/** Draw a vertical line using box-drawing characters. */
|
|
72
|
+
drawVLine(x: number, y: number, height: number, style?: TextStyle): void;
|
|
73
|
+
/** Set a pixel directly. Respects clip. */
|
|
74
|
+
setPixel(x: number, y: number, pixel: Pixel): void;
|
|
75
|
+
/** Blend a pixel at (x, y) with what's already there. Respects clip. */
|
|
76
|
+
blendPixel(x: number, y: number, pixel: Pixel): void;
|
|
77
|
+
}
|