@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.
Files changed (104) hide show
  1. package/README.md +48 -0
  2. package/dist/__tests__/ansi.test.d.ts +1 -0
  3. package/dist/__tests__/ansi.test.js +520 -0
  4. package/dist/__tests__/chat-view.test.d.ts +4 -0
  5. package/dist/__tests__/chat-view.test.js +480 -0
  6. package/dist/__tests__/drawing.test.d.ts +4 -0
  7. package/dist/__tests__/drawing.test.js +426 -0
  8. package/dist/__tests__/input.test.d.ts +5 -0
  9. package/dist/__tests__/input.test.js +911 -0
  10. package/dist/__tests__/layout.test.d.ts +4 -0
  11. package/dist/__tests__/layout.test.js +689 -0
  12. package/dist/__tests__/pixel.test.d.ts +1 -0
  13. package/dist/__tests__/pixel.test.js +674 -0
  14. package/dist/__tests__/render.test.d.ts +1 -0
  15. package/dist/__tests__/render.test.js +400 -0
  16. package/dist/__tests__/styled.test.d.ts +4 -0
  17. package/dist/__tests__/styled.test.js +149 -0
  18. package/dist/__tests__/widgets.test.d.ts +5 -0
  19. package/dist/__tests__/widgets.test.js +924 -0
  20. package/dist/ansi/esc.d.ts +61 -0
  21. package/dist/ansi/esc.js +85 -0
  22. package/dist/ansi/output.d.ts +66 -0
  23. package/dist/ansi/output.js +192 -0
  24. package/dist/ansi/strip.d.ts +16 -0
  25. package/dist/ansi/strip.js +74 -0
  26. package/dist/app.d.ts +68 -0
  27. package/dist/app.js +297 -0
  28. package/dist/drawing/clip.d.ts +23 -0
  29. package/dist/drawing/clip.js +67 -0
  30. package/dist/drawing/context.d.ts +77 -0
  31. package/dist/drawing/context.js +275 -0
  32. package/dist/index.d.ts +48 -0
  33. package/dist/index.js +63 -0
  34. package/dist/input/escape-matcher.d.ts +27 -0
  35. package/dist/input/escape-matcher.js +253 -0
  36. package/dist/input/events.d.ts +49 -0
  37. package/dist/input/events.js +17 -0
  38. package/dist/input/index.d.ts +15 -0
  39. package/dist/input/index.js +14 -0
  40. package/dist/input/matcher.d.ts +23 -0
  41. package/dist/input/matcher.js +14 -0
  42. package/dist/input/mouse-matcher.d.ts +27 -0
  43. package/dist/input/mouse-matcher.js +142 -0
  44. package/dist/input/paste-matcher.d.ts +23 -0
  45. package/dist/input/paste-matcher.js +104 -0
  46. package/dist/input/processor.d.ts +51 -0
  47. package/dist/input/processor.js +145 -0
  48. package/dist/input/raw-mode.d.ts +13 -0
  49. package/dist/input/raw-mode.js +24 -0
  50. package/dist/input/text-matcher.d.ts +14 -0
  51. package/dist/input/text-matcher.js +32 -0
  52. package/dist/layout/box.d.ts +33 -0
  53. package/dist/layout/box.js +92 -0
  54. package/dist/layout/column.d.ts +21 -0
  55. package/dist/layout/column.js +90 -0
  56. package/dist/layout/control.d.ts +73 -0
  57. package/dist/layout/control.js +215 -0
  58. package/dist/layout/row.d.ts +21 -0
  59. package/dist/layout/row.js +95 -0
  60. package/dist/layout/stack.d.ts +18 -0
  61. package/dist/layout/stack.js +64 -0
  62. package/dist/layout/types.d.ts +27 -0
  63. package/dist/layout/types.js +4 -0
  64. package/dist/pixel/background.d.ts +16 -0
  65. package/dist/pixel/background.js +16 -0
  66. package/dist/pixel/box-pattern.d.ts +38 -0
  67. package/dist/pixel/box-pattern.js +57 -0
  68. package/dist/pixel/buffer.d.ts +25 -0
  69. package/dist/pixel/buffer.js +51 -0
  70. package/dist/pixel/color.d.ts +48 -0
  71. package/dist/pixel/color.js +92 -0
  72. package/dist/pixel/foreground.d.ts +31 -0
  73. package/dist/pixel/foreground.js +64 -0
  74. package/dist/pixel/pixel.d.ts +21 -0
  75. package/dist/pixel/pixel.js +38 -0
  76. package/dist/pixel/symbol.d.ts +38 -0
  77. package/dist/pixel/symbol.js +192 -0
  78. package/dist/render/regions.d.ts +54 -0
  79. package/dist/render/regions.js +102 -0
  80. package/dist/render/render-target.d.ts +42 -0
  81. package/dist/render/render-target.js +118 -0
  82. package/dist/styled.d.ts +113 -0
  83. package/dist/styled.js +176 -0
  84. package/dist/widgets/border.d.ts +34 -0
  85. package/dist/widgets/border.js +121 -0
  86. package/dist/widgets/chat-view.d.ts +239 -0
  87. package/dist/widgets/chat-view.js +993 -0
  88. package/dist/widgets/interview.d.ts +87 -0
  89. package/dist/widgets/interview.js +187 -0
  90. package/dist/widgets/markdown.d.ts +87 -0
  91. package/dist/widgets/markdown.js +611 -0
  92. package/dist/widgets/panel.d.ts +19 -0
  93. package/dist/widgets/panel.js +35 -0
  94. package/dist/widgets/scroll-view.d.ts +43 -0
  95. package/dist/widgets/scroll-view.js +182 -0
  96. package/dist/widgets/styled-text.d.ts +38 -0
  97. package/dist/widgets/styled-text.js +183 -0
  98. package/dist/widgets/syntax.d.ts +37 -0
  99. package/dist/widgets/syntax.js +670 -0
  100. package/dist/widgets/text-input.d.ts +121 -0
  101. package/dist/widgets/text-input.js +618 -0
  102. package/dist/widgets/text.d.ts +34 -0
  103. package/dist/widgets/text.js +168 -0
  104. 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,4 @@
1
+ /**
2
+ * Tests for the pen (styled text) API and StyledText widget.
3
+ */
4
+ export {};
@@ -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
+ });
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Comprehensive unit tests for Phase 7 widgets:
3
+ * Text, Border, Panel, TextInput, ScrollView
4
+ */
5
+ export {};