@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
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
import { Writable } from "node:stream";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { AnsiOutput } from "../ansi/output.js";
|
|
4
|
+
import { PixelBuffer } from "../pixel/buffer.js";
|
|
5
|
+
import { DirtyRegions, DirtySnapshot } from "../render/regions.js";
|
|
6
|
+
import { RenderTarget } from "../render/render-target.js";
|
|
7
|
+
// ── Helpers ────────────────────────────────────────────────────────
|
|
8
|
+
function mockStream() {
|
|
9
|
+
const stream = new Writable({
|
|
10
|
+
write(chunk, _encoding, callback) {
|
|
11
|
+
stream.output += chunk.toString();
|
|
12
|
+
callback();
|
|
13
|
+
},
|
|
14
|
+
});
|
|
15
|
+
stream.output = "";
|
|
16
|
+
return stream;
|
|
17
|
+
}
|
|
18
|
+
function makePixel(char, fg = {
|
|
19
|
+
r: 255,
|
|
20
|
+
g: 255,
|
|
21
|
+
b: 255,
|
|
22
|
+
a: 255,
|
|
23
|
+
}, bg = {
|
|
24
|
+
r: 0,
|
|
25
|
+
g: 0,
|
|
26
|
+
b: 0,
|
|
27
|
+
a: 255,
|
|
28
|
+
}) {
|
|
29
|
+
return {
|
|
30
|
+
foreground: {
|
|
31
|
+
symbol: { text: char, width: 1, pattern: 0 },
|
|
32
|
+
color: fg,
|
|
33
|
+
bold: false,
|
|
34
|
+
italic: false,
|
|
35
|
+
underline: false,
|
|
36
|
+
strikethrough: false,
|
|
37
|
+
},
|
|
38
|
+
background: { color: bg },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
42
|
+
// regions.ts
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
44
|
+
describe("DirtyRegions", () => {
|
|
45
|
+
let regions;
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
regions = new DirtyRegions();
|
|
48
|
+
});
|
|
49
|
+
describe("addRect and contains", () => {
|
|
50
|
+
it("contains returns false when no rects added", () => {
|
|
51
|
+
expect(regions.contains(0, 0)).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
it("contains returns true for point inside added rect", () => {
|
|
54
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 5 });
|
|
55
|
+
expect(regions.contains(0, 0)).toBe(true);
|
|
56
|
+
expect(regions.contains(9, 4)).toBe(true);
|
|
57
|
+
expect(regions.contains(5, 2)).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
it("contains uses exclusive upper bounds", () => {
|
|
60
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 5 });
|
|
61
|
+
// (10, 4) is outside because width is exclusive
|
|
62
|
+
expect(regions.contains(10, 4)).toBe(false);
|
|
63
|
+
// (9, 5) is outside because height is exclusive
|
|
64
|
+
expect(regions.contains(9, 5)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
it("handles multiple non-overlapping rects", () => {
|
|
67
|
+
regions.addRect({ x: 0, y: 0, width: 5, height: 5 });
|
|
68
|
+
regions.addRect({ x: 10, y: 10, width: 5, height: 5 });
|
|
69
|
+
expect(regions.contains(2, 2)).toBe(true);
|
|
70
|
+
expect(regions.contains(12, 12)).toBe(true);
|
|
71
|
+
expect(regions.contains(7, 7)).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
it("ignores empty rects (width 0)", () => {
|
|
74
|
+
regions.addRect({ x: 0, y: 0, width: 0, height: 5 });
|
|
75
|
+
expect(regions.contains(0, 0)).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
it("ignores empty rects (height 0)", () => {
|
|
78
|
+
regions.addRect({ x: 0, y: 0, width: 5, height: 0 });
|
|
79
|
+
expect(regions.contains(0, 0)).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
it("ignores negative dimensions", () => {
|
|
82
|
+
regions.addRect({ x: 0, y: 0, width: -1, height: 5 });
|
|
83
|
+
expect(regions.contains(0, 0)).toBe(false);
|
|
84
|
+
regions.addRect({ x: 0, y: 0, width: 5, height: -1 });
|
|
85
|
+
expect(regions.contains(0, 0)).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
describe("containment optimization", () => {
|
|
89
|
+
it("skips rect that is fully contained by an existing rect", () => {
|
|
90
|
+
regions.addRect({ x: 0, y: 0, width: 20, height: 20 });
|
|
91
|
+
regions.addRect({ x: 5, y: 5, width: 5, height: 5 }); // contained, should be skipped
|
|
92
|
+
// The big rect still contains everything
|
|
93
|
+
expect(regions.contains(0, 0)).toBe(true);
|
|
94
|
+
expect(regions.contains(19, 19)).toBe(true);
|
|
95
|
+
// Taking a snapshot: should have only 1 rect
|
|
96
|
+
const snapshot = regions.getSnapshotAndClear();
|
|
97
|
+
// We can verify indirectly: if we had 2 rects, the snapshot would still work
|
|
98
|
+
// But we test the optimization by checking snapshot contains the large area
|
|
99
|
+
expect(snapshot.contains(0, 0)).toBe(true);
|
|
100
|
+
expect(snapshot.contains(19, 19)).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
it("replaces existing rect when new rect fully contains it", () => {
|
|
103
|
+
regions.addRect({ x: 5, y: 5, width: 5, height: 5 }); // small
|
|
104
|
+
regions.addRect({ x: 0, y: 0, width: 20, height: 20 }); // big, should replace small
|
|
105
|
+
expect(regions.contains(0, 0)).toBe(true);
|
|
106
|
+
expect(regions.contains(19, 19)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
it("replaces multiple existing rects when new rect contains all of them", () => {
|
|
109
|
+
regions.addRect({ x: 0, y: 0, width: 3, height: 3 });
|
|
110
|
+
regions.addRect({ x: 5, y: 5, width: 3, height: 3 });
|
|
111
|
+
regions.addRect({ x: 8, y: 8, width: 2, height: 2 });
|
|
112
|
+
// Add a big rect that contains all three
|
|
113
|
+
regions.addRect({ x: 0, y: 0, width: 20, height: 20 });
|
|
114
|
+
const snapshot = regions.getSnapshotAndClear();
|
|
115
|
+
expect(snapshot.contains(0, 0)).toBe(true);
|
|
116
|
+
expect(snapshot.contains(19, 19)).toBe(true);
|
|
117
|
+
});
|
|
118
|
+
it("keeps non-overlapping rects when new rect does not contain them", () => {
|
|
119
|
+
regions.addRect({ x: 0, y: 0, width: 5, height: 5 });
|
|
120
|
+
regions.addRect({ x: 10, y: 10, width: 5, height: 5 });
|
|
121
|
+
// Neither contains the other, both should remain
|
|
122
|
+
expect(regions.contains(2, 2)).toBe(true);
|
|
123
|
+
expect(regions.contains(12, 12)).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
it("exact duplicate rect is treated as contained", () => {
|
|
126
|
+
regions.addRect({ x: 3, y: 3, width: 10, height: 10 });
|
|
127
|
+
regions.addRect({ x: 3, y: 3, width: 10, height: 10 }); // exact duplicate
|
|
128
|
+
// Should still work
|
|
129
|
+
expect(regions.contains(3, 3)).toBe(true);
|
|
130
|
+
expect(regions.contains(12, 12)).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
describe("clear", () => {
|
|
134
|
+
it("removes all tracked regions", () => {
|
|
135
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 10 });
|
|
136
|
+
expect(regions.contains(5, 5)).toBe(true);
|
|
137
|
+
regions.clear();
|
|
138
|
+
expect(regions.contains(5, 5)).toBe(false);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
describe("getSnapshotAndClear", () => {
|
|
142
|
+
it("returns a snapshot containing the dirty rects", () => {
|
|
143
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 10 });
|
|
144
|
+
const snapshot = regions.getSnapshotAndClear();
|
|
145
|
+
expect(snapshot.contains(5, 5)).toBe(true);
|
|
146
|
+
expect(snapshot.contains(10, 10)).toBe(false);
|
|
147
|
+
});
|
|
148
|
+
it("clears regions after snapshot", () => {
|
|
149
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 10 });
|
|
150
|
+
regions.getSnapshotAndClear();
|
|
151
|
+
// Regions should be empty now
|
|
152
|
+
expect(regions.contains(5, 5)).toBe(false);
|
|
153
|
+
});
|
|
154
|
+
it("snapshot is independent from subsequent mutations", () => {
|
|
155
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 10 });
|
|
156
|
+
const snapshot = regions.getSnapshotAndClear();
|
|
157
|
+
// Add new rect after snapshot
|
|
158
|
+
regions.addRect({ x: 20, y: 20, width: 5, height: 5 });
|
|
159
|
+
// Snapshot should not contain the new rect
|
|
160
|
+
expect(snapshot.contains(22, 22)).toBe(false);
|
|
161
|
+
// But the original rect is still in the snapshot
|
|
162
|
+
expect(snapshot.contains(5, 5)).toBe(true);
|
|
163
|
+
});
|
|
164
|
+
it("empty snapshot contains nothing", () => {
|
|
165
|
+
const snapshot = regions.getSnapshotAndClear();
|
|
166
|
+
expect(snapshot.contains(0, 0)).toBe(false);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
describe("DirtySnapshot", () => {
|
|
171
|
+
it("contains checks against all rects in the snapshot", () => {
|
|
172
|
+
const snapshot = new DirtySnapshot([
|
|
173
|
+
{ x: 0, y: 0, width: 5, height: 5 },
|
|
174
|
+
{ x: 10, y: 10, width: 5, height: 5 },
|
|
175
|
+
]);
|
|
176
|
+
expect(snapshot.contains(2, 2)).toBe(true);
|
|
177
|
+
expect(snapshot.contains(12, 12)).toBe(true);
|
|
178
|
+
expect(snapshot.contains(7, 7)).toBe(false);
|
|
179
|
+
});
|
|
180
|
+
it("returns false for empty snapshot", () => {
|
|
181
|
+
const snapshot = new DirtySnapshot([]);
|
|
182
|
+
expect(snapshot.contains(0, 0)).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
186
|
+
// render-target.ts
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════
|
|
188
|
+
describe("RenderTarget", () => {
|
|
189
|
+
let stream;
|
|
190
|
+
let ansiOutput;
|
|
191
|
+
let buffer;
|
|
192
|
+
beforeEach(() => {
|
|
193
|
+
stream = mockStream();
|
|
194
|
+
ansiOutput = new AnsiOutput(stream);
|
|
195
|
+
buffer = new PixelBuffer(10, 5);
|
|
196
|
+
});
|
|
197
|
+
describe("render with dirty regions", () => {
|
|
198
|
+
it("only writes pixels inside dirty regions", () => {
|
|
199
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
200
|
+
const regions = new DirtyRegions();
|
|
201
|
+
// Set specific pixels in the buffer
|
|
202
|
+
buffer.set(0, 0, makePixel("A"));
|
|
203
|
+
buffer.set(5, 2, makePixel("B"));
|
|
204
|
+
// Only mark (0,0) area as dirty
|
|
205
|
+
regions.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
206
|
+
target.render(regions);
|
|
207
|
+
// "A" should be in output but "B" should not (not in dirty region)
|
|
208
|
+
expect(stream.output).toContain("A");
|
|
209
|
+
expect(stream.output).not.toContain("B");
|
|
210
|
+
});
|
|
211
|
+
it("writes pixel when dirty region covers it", () => {
|
|
212
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
213
|
+
const regions = new DirtyRegions();
|
|
214
|
+
buffer.set(3, 2, makePixel("X"));
|
|
215
|
+
regions.addRect({ x: 0, y: 0, width: 10, height: 5 }); // entire buffer
|
|
216
|
+
target.render(regions);
|
|
217
|
+
expect(stream.output).toContain("X");
|
|
218
|
+
});
|
|
219
|
+
it("does not write anything when no dirty regions", () => {
|
|
220
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
221
|
+
const regions = new DirtyRegions();
|
|
222
|
+
buffer.set(0, 0, makePixel("A"));
|
|
223
|
+
target.render(regions); // empty dirty regions
|
|
224
|
+
// Should only contain hide/show cursor, no pixel data
|
|
225
|
+
expect(stream.output).not.toContain("A");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
describe("cache behavior", () => {
|
|
229
|
+
it("skips pixels that match the cache on second render", () => {
|
|
230
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
231
|
+
// First render: everything is new
|
|
232
|
+
buffer.set(0, 0, makePixel("A"));
|
|
233
|
+
const regions1 = new DirtyRegions();
|
|
234
|
+
regions1.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
235
|
+
target.render(regions1);
|
|
236
|
+
// Clear stream output
|
|
237
|
+
stream.output = "";
|
|
238
|
+
// Second render with same pixel, same dirty region
|
|
239
|
+
const regions2 = new DirtyRegions();
|
|
240
|
+
regions2.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
241
|
+
target.render(regions2);
|
|
242
|
+
// "A" should NOT appear again because the cache matches
|
|
243
|
+
expect(stream.output).not.toContain("A");
|
|
244
|
+
});
|
|
245
|
+
it("writes pixel when it changes between renders", () => {
|
|
246
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
247
|
+
// First render
|
|
248
|
+
buffer.set(0, 0, makePixel("A"));
|
|
249
|
+
const regions1 = new DirtyRegions();
|
|
250
|
+
regions1.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
251
|
+
target.render(regions1);
|
|
252
|
+
stream.output = "";
|
|
253
|
+
// Change pixel
|
|
254
|
+
buffer.set(0, 0, makePixel("B"));
|
|
255
|
+
const regions2 = new DirtyRegions();
|
|
256
|
+
regions2.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
257
|
+
target.render(regions2);
|
|
258
|
+
expect(stream.output).toContain("B");
|
|
259
|
+
});
|
|
260
|
+
it("updates cache after writing a pixel", () => {
|
|
261
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
262
|
+
const px = makePixel("Z");
|
|
263
|
+
buffer.set(2, 3, px);
|
|
264
|
+
const regions = new DirtyRegions();
|
|
265
|
+
regions.addRect({ x: 2, y: 3, width: 1, height: 1 });
|
|
266
|
+
target.render(regions);
|
|
267
|
+
// Cache should now have the pixel
|
|
268
|
+
const cached = target.getCachePixel(2, 3);
|
|
269
|
+
expect(cached).not.toBeNull();
|
|
270
|
+
expect(cached.foreground.symbol.text).toBe("Z");
|
|
271
|
+
});
|
|
272
|
+
it("getCachePixel returns null for never-rendered cells", () => {
|
|
273
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
274
|
+
expect(target.getCachePixel(0, 0)).toBeNull();
|
|
275
|
+
});
|
|
276
|
+
it("getCachePixel returns null for out-of-bounds", () => {
|
|
277
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
278
|
+
expect(target.getCachePixel(-1, 0)).toBeNull();
|
|
279
|
+
expect(target.getCachePixel(0, -1)).toBeNull();
|
|
280
|
+
expect(target.getCachePixel(100, 0)).toBeNull();
|
|
281
|
+
expect(target.getCachePixel(0, 100)).toBeNull();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
describe("fullRender", () => {
|
|
285
|
+
it("renders all cells in the buffer", () => {
|
|
286
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
287
|
+
// Set a few pixels
|
|
288
|
+
buffer.set(0, 0, makePixel("A"));
|
|
289
|
+
buffer.set(9, 4, makePixel("Z"));
|
|
290
|
+
target.fullRender();
|
|
291
|
+
expect(stream.output).toContain("A");
|
|
292
|
+
expect(stream.output).toContain("Z");
|
|
293
|
+
});
|
|
294
|
+
it("renders cells even without prior dirty region calls", () => {
|
|
295
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
296
|
+
buffer.set(5, 2, makePixel("M"));
|
|
297
|
+
target.fullRender();
|
|
298
|
+
expect(stream.output).toContain("M");
|
|
299
|
+
});
|
|
300
|
+
it("emits hideCursor before pixel data (cursor stays hidden)", () => {
|
|
301
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
302
|
+
target.fullRender();
|
|
303
|
+
const hideCursorIdx = stream.output.indexOf("\x1b[?25l");
|
|
304
|
+
expect(hideCursorIdx).toBeGreaterThanOrEqual(0);
|
|
305
|
+
// System cursor should stay hidden — consolonia renders its own cursor
|
|
306
|
+
const showCursorIdx = stream.output.indexOf("\x1b[?25h");
|
|
307
|
+
expect(showCursorIdx).toBe(-1);
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
describe("resize", () => {
|
|
311
|
+
it("resets cache so next render writes all pixels", () => {
|
|
312
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
313
|
+
// First render to populate cache
|
|
314
|
+
buffer.set(0, 0, makePixel("A"));
|
|
315
|
+
const regions1 = new DirtyRegions();
|
|
316
|
+
regions1.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
317
|
+
target.render(regions1);
|
|
318
|
+
stream.output = "";
|
|
319
|
+
// Resize (same dimensions for simplicity)
|
|
320
|
+
target.resize(10, 5);
|
|
321
|
+
// Now render again with same pixel — should re-emit because cache was cleared
|
|
322
|
+
const regions2 = new DirtyRegions();
|
|
323
|
+
regions2.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
324
|
+
target.render(regions2);
|
|
325
|
+
expect(stream.output).toContain("A");
|
|
326
|
+
});
|
|
327
|
+
it("handles different dimensions", () => {
|
|
328
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
329
|
+
// Resize to larger
|
|
330
|
+
target.resize(20, 10);
|
|
331
|
+
// getCachePixel should return null for all positions in new grid
|
|
332
|
+
expect(target.getCachePixel(15, 8)).toBeNull();
|
|
333
|
+
});
|
|
334
|
+
it("resize to smaller dimensions works", () => {
|
|
335
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
336
|
+
target.resize(3, 2);
|
|
337
|
+
// Old positions beyond new bounds should return null
|
|
338
|
+
expect(target.getCachePixel(5, 3)).toBeNull();
|
|
339
|
+
// New valid positions also null (fresh cache)
|
|
340
|
+
expect(target.getCachePixel(0, 0)).toBeNull();
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
describe("render emits correct ANSI structure", () => {
|
|
344
|
+
it("flushes after rendering", () => {
|
|
345
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
346
|
+
buffer.set(0, 0, makePixel("Q"));
|
|
347
|
+
const regions = new DirtyRegions();
|
|
348
|
+
regions.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
349
|
+
target.render(regions);
|
|
350
|
+
// Output should not be empty (flush was called)
|
|
351
|
+
expect(stream.output.length).toBeGreaterThan(0);
|
|
352
|
+
});
|
|
353
|
+
it("multiple renders accumulate output", () => {
|
|
354
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
355
|
+
buffer.set(0, 0, makePixel("A"));
|
|
356
|
+
const r1 = new DirtyRegions();
|
|
357
|
+
r1.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
358
|
+
target.render(r1);
|
|
359
|
+
buffer.set(0, 0, makePixel("B"));
|
|
360
|
+
const r2 = new DirtyRegions();
|
|
361
|
+
r2.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
362
|
+
target.render(r2);
|
|
363
|
+
expect(stream.output).toContain("A");
|
|
364
|
+
expect(stream.output).toContain("B");
|
|
365
|
+
});
|
|
366
|
+
});
|
|
367
|
+
describe("interaction between dirty regions and cache", () => {
|
|
368
|
+
it("pixel changed but not in dirty region is not written", () => {
|
|
369
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
370
|
+
// Render pixel at (0,0)
|
|
371
|
+
buffer.set(0, 0, makePixel("A"));
|
|
372
|
+
const r1 = new DirtyRegions();
|
|
373
|
+
r1.addRect({ x: 0, y: 0, width: 1, height: 1 });
|
|
374
|
+
target.render(r1);
|
|
375
|
+
stream.output = "";
|
|
376
|
+
// Change pixel at (0,0) but mark a different region as dirty
|
|
377
|
+
buffer.set(0, 0, makePixel("B"));
|
|
378
|
+
const r2 = new DirtyRegions();
|
|
379
|
+
r2.addRect({ x: 5, y: 5, width: 1, height: 1 }); // different area
|
|
380
|
+
target.render(r2);
|
|
381
|
+
// "B" should not be written because (0,0) is not in dirty region
|
|
382
|
+
expect(stream.output).not.toContain("B");
|
|
383
|
+
});
|
|
384
|
+
it("pixel unchanged but in dirty region is skipped via cache", () => {
|
|
385
|
+
const target = new RenderTarget(buffer, ansiOutput);
|
|
386
|
+
// Render pixel
|
|
387
|
+
buffer.set(2, 2, makePixel("X"));
|
|
388
|
+
const r1 = new DirtyRegions();
|
|
389
|
+
r1.addRect({ x: 2, y: 2, width: 1, height: 1 });
|
|
390
|
+
target.render(r1);
|
|
391
|
+
stream.output = "";
|
|
392
|
+
// Mark same region dirty again, pixel unchanged
|
|
393
|
+
const r2 = new DirtyRegions();
|
|
394
|
+
r2.addRect({ x: 2, y: 2, width: 1, height: 1 });
|
|
395
|
+
target.render(r2);
|
|
396
|
+
// "X" should not be re-emitted
|
|
397
|
+
expect(stream.output).not.toContain("X");
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the pen (styled text) API and StyledText widget.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { DrawingContext } from "../drawing/context.js";
|
|
6
|
+
import { PixelBuffer } from "../pixel/buffer.js";
|
|
7
|
+
import { CYAN, GRAY, RED, WHITE } from "../pixel/color.js";
|
|
8
|
+
import { concat, isStyledSpan, pen, spanLength, spanText } from "../styled.js";
|
|
9
|
+
import { StyledText } from "../widgets/styled-text.js";
|
|
10
|
+
// ── Helpers ──────────────────────────────────────────────────────────
|
|
11
|
+
function charAt(buffer, x, y) {
|
|
12
|
+
return buffer.get(x, y).foreground.symbol.text;
|
|
13
|
+
}
|
|
14
|
+
function fgColor(buffer, x, y) {
|
|
15
|
+
return buffer.get(x, y).foreground.color;
|
|
16
|
+
}
|
|
17
|
+
function layoutAndRender(widget, width = 40, height = 10) {
|
|
18
|
+
const buffer = new PixelBuffer(width, height);
|
|
19
|
+
const ctx = new DrawingContext(buffer);
|
|
20
|
+
widget.measure({
|
|
21
|
+
minWidth: 0,
|
|
22
|
+
minHeight: 0,
|
|
23
|
+
maxWidth: width,
|
|
24
|
+
maxHeight: height,
|
|
25
|
+
});
|
|
26
|
+
widget.arrange({
|
|
27
|
+
x: 0,
|
|
28
|
+
y: 0,
|
|
29
|
+
width,
|
|
30
|
+
height: widget.desiredSize.height || height,
|
|
31
|
+
});
|
|
32
|
+
widget.render(ctx);
|
|
33
|
+
return { buffer, ctx };
|
|
34
|
+
}
|
|
35
|
+
// ── pen API ──────────────────────────────────────────────────────────
|
|
36
|
+
describe("pen", () => {
|
|
37
|
+
it("creates unstyled span from plain text", () => {
|
|
38
|
+
const s = pen("hello");
|
|
39
|
+
expect(s).toHaveLength(1);
|
|
40
|
+
expect(s[0].text).toBe("hello");
|
|
41
|
+
expect(s[0].style).toEqual({});
|
|
42
|
+
});
|
|
43
|
+
it("creates colored span", () => {
|
|
44
|
+
const s = pen.cyan("hello");
|
|
45
|
+
expect(s).toHaveLength(1);
|
|
46
|
+
expect(s[0].text).toBe("hello");
|
|
47
|
+
expect(s[0].style.fg).toEqual(CYAN);
|
|
48
|
+
});
|
|
49
|
+
it("chains bold + color", () => {
|
|
50
|
+
const s = pen.bold.red("error");
|
|
51
|
+
expect(s[0].style.bold).toBe(true);
|
|
52
|
+
expect(s[0].style.fg).toEqual(RED);
|
|
53
|
+
});
|
|
54
|
+
it("supports background colors", () => {
|
|
55
|
+
const s = pen.bgRed.white("alert");
|
|
56
|
+
expect(s[0].style.bg).toEqual(RED);
|
|
57
|
+
expect(s[0].style.fg).toEqual(WHITE);
|
|
58
|
+
});
|
|
59
|
+
it("supports gray", () => {
|
|
60
|
+
const s = pen.gray("muted");
|
|
61
|
+
expect(s[0].style.fg).toEqual(GRAY);
|
|
62
|
+
});
|
|
63
|
+
it("supports bright variants", () => {
|
|
64
|
+
const s = pen.cyanBright("bright");
|
|
65
|
+
expect(s[0].style.fg.a).toBe(255);
|
|
66
|
+
expect(s[0].style.fg.r).toBe(85);
|
|
67
|
+
});
|
|
68
|
+
it("supports italic", () => {
|
|
69
|
+
const s = pen.italic("text");
|
|
70
|
+
expect(s[0].style.italic).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it("is a StyledSpan", () => {
|
|
73
|
+
expect(isStyledSpan(pen("x"))).toBe(true);
|
|
74
|
+
expect(isStyledSpan("plain")).toBe(false);
|
|
75
|
+
expect(isStyledSpan([])).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe("concat", () => {
|
|
79
|
+
it("joins multiple spans", () => {
|
|
80
|
+
const s = concat(pen.green("✔ "), pen.white("done"));
|
|
81
|
+
expect(s).toHaveLength(2);
|
|
82
|
+
expect(spanText(s)).toBe("✔ done");
|
|
83
|
+
expect(spanLength(s)).toBe(7); // ✔ is width 2 (dingbat emoji)
|
|
84
|
+
});
|
|
85
|
+
it("accepts plain strings", () => {
|
|
86
|
+
const s = concat("hello ", pen.cyan("world"));
|
|
87
|
+
expect(s).toHaveLength(2);
|
|
88
|
+
expect(s[0].style).toEqual({});
|
|
89
|
+
expect(s[1].style.fg).toEqual(CYAN);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
// ── StyledText widget ────────────────────────────────────────────────
|
|
93
|
+
describe("StyledText", () => {
|
|
94
|
+
it("renders plain string lines", () => {
|
|
95
|
+
const widget = new StyledText({ lines: ["hello"] });
|
|
96
|
+
const size = widget.measure({
|
|
97
|
+
minWidth: 0,
|
|
98
|
+
minHeight: 0,
|
|
99
|
+
maxWidth: 40,
|
|
100
|
+
maxHeight: 10,
|
|
101
|
+
});
|
|
102
|
+
expect(size.height).toBe(1);
|
|
103
|
+
expect(size.width).toBe(5);
|
|
104
|
+
const { buffer } = layoutAndRender(widget);
|
|
105
|
+
expect(charAt(buffer, 0, 0)).toBe("h");
|
|
106
|
+
expect(charAt(buffer, 4, 0)).toBe("o");
|
|
107
|
+
});
|
|
108
|
+
it("renders styled span lines with correct colors", () => {
|
|
109
|
+
const line = concat(pen.red("R"), pen.green("G"));
|
|
110
|
+
const widget = new StyledText({ lines: [line] });
|
|
111
|
+
const { buffer } = layoutAndRender(widget);
|
|
112
|
+
expect(charAt(buffer, 0, 0)).toBe("R");
|
|
113
|
+
expect(fgColor(buffer, 0, 0)).toEqual(RED);
|
|
114
|
+
expect(charAt(buffer, 1, 0)).toBe("G");
|
|
115
|
+
// Green color check
|
|
116
|
+
expect(fgColor(buffer, 1, 0).g).toBe(255);
|
|
117
|
+
});
|
|
118
|
+
it("applies defaultStyle to plain string lines", () => {
|
|
119
|
+
const widget = new StyledText({
|
|
120
|
+
lines: ["text"],
|
|
121
|
+
defaultStyle: { fg: CYAN },
|
|
122
|
+
});
|
|
123
|
+
const { buffer } = layoutAndRender(widget);
|
|
124
|
+
expect(fgColor(buffer, 0, 0)).toEqual(CYAN);
|
|
125
|
+
});
|
|
126
|
+
it("measures multiple lines", () => {
|
|
127
|
+
const widget = new StyledText({ lines: ["line1", "line2", "line3"] });
|
|
128
|
+
const size = widget.measure({
|
|
129
|
+
minWidth: 0,
|
|
130
|
+
minHeight: 0,
|
|
131
|
+
maxWidth: 40,
|
|
132
|
+
maxHeight: 10,
|
|
133
|
+
});
|
|
134
|
+
expect(size.height).toBe(3);
|
|
135
|
+
});
|
|
136
|
+
it("wraps long lines when wrap is true", () => {
|
|
137
|
+
const widget = new StyledText({
|
|
138
|
+
lines: ["abcdefghij"],
|
|
139
|
+
wrap: true,
|
|
140
|
+
});
|
|
141
|
+
const size = widget.measure({
|
|
142
|
+
minWidth: 0,
|
|
143
|
+
minHeight: 0,
|
|
144
|
+
maxWidth: 5,
|
|
145
|
+
maxHeight: 10,
|
|
146
|
+
});
|
|
147
|
+
expect(size.height).toBe(2); // "abcde" + "fghij"
|
|
148
|
+
});
|
|
149
|
+
});
|