@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,611 @@
1
+ /**
2
+ * Markdown — terminal-rendered markdown widget.
3
+ *
4
+ * Parses markdown with marked.js and renders it to styled lines
5
+ * that can be displayed in a consolonia terminal UI.
6
+ *
7
+ * Supported elements:
8
+ * - Headings (h1–h6)
9
+ * - Paragraphs with inline bold, italic, code, strikethrough
10
+ * - Links (shown as text + URL)
11
+ * - Unordered and ordered lists (nested)
12
+ * - Task lists (checkboxes)
13
+ * - Code blocks (fenced and indented)
14
+ * - Blockquotes (nested)
15
+ * - Tables (aligned columns with box-drawing borders)
16
+ * - Horizontal rules
17
+ * - Images (alt text shown)
18
+ */
19
+ import { marked } from "marked";
20
+ import { CYAN, GRAY, GREEN, WHITE, YELLOW } from "../pixel/color.js";
21
+ import { DEFAULT_SYNTAX_THEME, highlightLine, } from "./syntax.js";
22
+ // ── Default theme ────────────────────────────────────────────────
23
+ const DEFAULT_THEME = {
24
+ text: { fg: WHITE },
25
+ bold: { fg: WHITE, bold: true },
26
+ italic: { fg: WHITE, italic: true },
27
+ boldItalic: { fg: WHITE, bold: true, italic: true },
28
+ code: { fg: YELLOW },
29
+ strikethrough: { fg: GRAY, strikethrough: true },
30
+ link: { fg: CYAN, underline: true },
31
+ linkUrl: { fg: GRAY },
32
+ h1: { fg: CYAN, bold: true },
33
+ h2: { fg: CYAN, bold: true },
34
+ h3: { fg: CYAN },
35
+ codeBlock: { fg: GREEN },
36
+ codeBlockChrome: { fg: GRAY },
37
+ blockquote: { fg: GRAY, italic: true },
38
+ listMarker: { fg: CYAN },
39
+ tableBorder: { fg: GRAY },
40
+ tableHeader: { fg: WHITE, bold: true },
41
+ hr: { fg: GRAY },
42
+ checkbox: { fg: CYAN },
43
+ };
44
+ /**
45
+ * Render a markdown string to an array of styled lines.
46
+ *
47
+ * Each line is an array of { text, style } segments suitable for
48
+ * DrawingContext.drawStyledText() or StyledText widget.
49
+ *
50
+ * This is a pure function — no widget state, no side effects.
51
+ */
52
+ export function renderMarkdown(source, options = {}) {
53
+ const width = options.width ?? 80;
54
+ const theme = { ...DEFAULT_THEME, ...options.theme };
55
+ const synTheme = {
56
+ ...DEFAULT_SYNTAX_THEME,
57
+ ...options.syntaxTheme,
58
+ };
59
+ const indent = options.indent ?? "";
60
+ const tokens = marked.lexer(source);
61
+ const lines = [];
62
+ renderTokens(tokens, lines, theme, synTheme, width, indent, {});
63
+ return lines;
64
+ }
65
+ /** Render a list of block-level tokens to lines. */
66
+ function renderTokens(tokens, lines, theme, synTheme, width, indent, ctx) {
67
+ for (const token of tokens) {
68
+ switch (token.type) {
69
+ case "heading":
70
+ renderHeading(token, lines, theme, width, indent);
71
+ break;
72
+ case "paragraph":
73
+ renderParagraph(token, lines, theme, width, indent, ctx);
74
+ break;
75
+ case "text": {
76
+ // Block-level text (e.g. inside list items)
77
+ const t = token;
78
+ if ("tokens" in t && t.tokens) {
79
+ const segs = inlineTokensToSegments(t.tokens, theme, ctx);
80
+ wordWrapSegments(segs, width - indent.length, indent, theme).forEach((l) => lines.push(l));
81
+ }
82
+ else {
83
+ const segs = [
84
+ { text: t.text, style: resolveInlineStyle(theme, ctx) },
85
+ ];
86
+ wordWrapSegments(segs, width - indent.length, indent, theme).forEach((l) => lines.push(l));
87
+ }
88
+ break;
89
+ }
90
+ case "code":
91
+ renderCodeBlock(token, lines, theme, synTheme, width, indent);
92
+ break;
93
+ case "blockquote":
94
+ renderBlockquote(token, lines, theme, synTheme, width, indent);
95
+ break;
96
+ case "list":
97
+ renderList(token, lines, theme, synTheme, width, indent, ctx);
98
+ break;
99
+ case "table":
100
+ renderTable(token, lines, theme, width, indent);
101
+ break;
102
+ case "hr":
103
+ renderHr(lines, theme, width, indent);
104
+ break;
105
+ case "space":
106
+ // Blank line between blocks
107
+ lines.push([{ text: indent, style: theme.text }]);
108
+ break;
109
+ case "html":
110
+ // Render raw HTML as plain text
111
+ lines.push([
112
+ { text: indent, style: theme.text },
113
+ { text: token.text.trim(), style: theme.text },
114
+ ]);
115
+ break;
116
+ default:
117
+ // Unknown token — render raw text if available
118
+ if ("text" in token && typeof token.text === "string") {
119
+ lines.push([
120
+ { text: indent, style: theme.text },
121
+ { text: token.text, style: theme.text },
122
+ ]);
123
+ }
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ // ── Headings ─────────────────────────────────────────────────────
129
+ function renderHeading(token, lines, theme, width, indent) {
130
+ const style = token.depth === 1 ? theme.h1 : token.depth === 2 ? theme.h2 : theme.h3;
131
+ const text = plainText(token.tokens);
132
+ lines.push([
133
+ { text: indent, style: theme.text },
134
+ { text, style },
135
+ ]);
136
+ // Underline for h1 and h2
137
+ if (token.depth === 1) {
138
+ const rule = "═".repeat(Math.min(text.length, width - indent.length));
139
+ lines.push([
140
+ { text: indent, style: theme.text },
141
+ { text: rule, style },
142
+ ]);
143
+ }
144
+ else if (token.depth === 2) {
145
+ const rule = "─".repeat(Math.min(text.length, width - indent.length));
146
+ lines.push([
147
+ { text: indent, style: theme.text },
148
+ { text: rule, style },
149
+ ]);
150
+ }
151
+ // Blank line after heading
152
+ lines.push([{ text: indent, style: theme.text }]);
153
+ }
154
+ // ── Paragraphs ───────────────────────────────────────────────────
155
+ function renderParagraph(token, lines, theme, width, indent, ctx) {
156
+ const segs = inlineTokensToSegments(token.tokens, theme, ctx);
157
+ wordWrapSegments(segs, width - indent.length, indent, theme).forEach((l) => lines.push(l));
158
+ // Blank line after paragraph
159
+ lines.push([{ text: indent, style: theme.text }]);
160
+ }
161
+ // ── Code blocks ──────────────────────────────────────────────────
162
+ function renderCodeBlock(token, lines, theme, synTheme, width, indent) {
163
+ const lang = token.lang ?? "";
164
+ const codeLines = token.text.split("\n");
165
+ const chrome = theme.codeBlockChrome;
166
+ // Find the longest code line
167
+ let maxCodeLen = 0;
168
+ for (const cl of codeLines) {
169
+ if (cl.length > maxCodeLen)
170
+ maxCodeLen = cl.length;
171
+ }
172
+ // Three breakpoints for box width:
173
+ // narrow: fits content + 4 (│ + space + content + space + │)
174
+ // medium: ~60% of available width
175
+ // full: available width - 4 padding
176
+ const avail = width - indent.length;
177
+ const contentNeeded = maxCodeLen + 4; // │ + space + content + space + │
178
+ const narrow = Math.min(contentNeeded, avail);
179
+ const medium = Math.max(narrow, Math.min(Math.round(avail * 0.6), avail));
180
+ const full = avail;
181
+ // Pick the smallest breakpoint that fits
182
+ let boxW;
183
+ if (contentNeeded <= Math.round(avail * 0.4)) {
184
+ boxW = Math.max(contentNeeded, 20); // narrow — at least 20 wide
185
+ }
186
+ else if (contentNeeded <= medium) {
187
+ boxW = medium;
188
+ }
189
+ else {
190
+ boxW = full;
191
+ }
192
+ boxW = Math.min(boxW, avail);
193
+ const innerW = boxW - 4; // space inside │ _ content _ │
194
+ // Top border: ┌─ lang ──────────┐
195
+ const labelText = lang ? ` ${lang} ` : "";
196
+ const topFill = Math.max(0, boxW - 2 - labelText.length); // -2 for ┌┐
197
+ lines.push([
198
+ { text: indent, style: theme.text },
199
+ { text: `┌${labelText}${"─".repeat(topFill)}┐`, style: chrome },
200
+ ]);
201
+ // Code lines: │ content │
202
+ for (const cl of codeLines) {
203
+ const lineSegs = [
204
+ { text: indent, style: theme.text },
205
+ { text: "│ ", style: chrome },
206
+ ];
207
+ // Truncate if too wide
208
+ const displayLine = cl.length > innerW ? `${cl.slice(0, innerW - 1)}…` : cl;
209
+ if (lang) {
210
+ const tokens = highlightLine(lang, displayLine);
211
+ for (const tok of tokens) {
212
+ lineSegs.push({
213
+ text: tok.text,
214
+ style: synTheme[tok.type] ?? theme.codeBlock,
215
+ });
216
+ }
217
+ }
218
+ else {
219
+ lineSegs.push({ text: displayLine, style: theme.codeBlock });
220
+ }
221
+ // Right padding + border
222
+ const rightPad = Math.max(0, innerW - displayLine.length);
223
+ lineSegs.push({ text: `${" ".repeat(rightPad)} │`, style: chrome });
224
+ lines.push(lineSegs);
225
+ }
226
+ // Bottom border: └──────────────────┘
227
+ lines.push([
228
+ { text: indent, style: theme.text },
229
+ { text: `└${"─".repeat(Math.max(0, boxW - 2))}┘`, style: chrome },
230
+ ]);
231
+ lines.push([{ text: indent, style: theme.text }]);
232
+ }
233
+ // ── Blockquotes ──────────────────────────────────────────────────
234
+ function renderBlockquote(token, lines, theme, synTheme, width, indent) {
235
+ const quoteIndent = `${indent}│ `;
236
+ const innerLines = [];
237
+ renderTokens(token.tokens, innerLines, theme, synTheme, width, quoteIndent, {});
238
+ // Apply blockquote style to all segments
239
+ for (const line of innerLines) {
240
+ for (const seg of line) {
241
+ if (seg.text.startsWith(quoteIndent) ||
242
+ seg.text === quoteIndent.trimEnd()) {
243
+ seg.style = theme.blockquote;
244
+ }
245
+ }
246
+ lines.push(line);
247
+ }
248
+ }
249
+ // ── Lists ────────────────────────────────────────────────────────
250
+ function renderList(token, lines, theme, synTheme, width, indent, ctx) {
251
+ for (let i = 0; i < token.items.length; i++) {
252
+ const item = token.items[i];
253
+ const marker = token.ordered ? `${(token.start || 1) + i}. ` : "• ";
254
+ const contIndent = `${indent} `; // 2-char continuation indent
255
+ // Task list checkbox
256
+ const prefix = [{ text: indent, style: theme.text }];
257
+ if (item.task) {
258
+ const check = item.checked ? "☑ " : "☐ ";
259
+ prefix.push({ text: check, style: theme.checkbox });
260
+ }
261
+ prefix.push({ text: marker, style: theme.listMarker });
262
+ // Render item content inline
263
+ const itemTokens = item.tokens;
264
+ let firstLine = true;
265
+ for (const sub of itemTokens) {
266
+ if (sub.type === "text" || sub.type === "paragraph") {
267
+ const toks = "tokens" in sub && sub.tokens ? sub.tokens : [];
268
+ const segs = inlineTokensToSegments(toks, theme, ctx);
269
+ const wrapped = wordWrapSegments(segs, width - contIndent.length, contIndent, theme);
270
+ for (let w = 0; w < wrapped.length; w++) {
271
+ if (firstLine && w === 0) {
272
+ // Replace the indent with the bullet/number prefix
273
+ lines.push([...prefix, ...wrapped[w].slice(1)]);
274
+ }
275
+ else {
276
+ lines.push(wrapped[w]);
277
+ }
278
+ }
279
+ firstLine = false;
280
+ }
281
+ else if (sub.type === "list") {
282
+ renderList(sub, lines, theme, synTheme, width, contIndent, ctx);
283
+ }
284
+ else {
285
+ const subLines = [];
286
+ renderTokens([sub], subLines, theme, synTheme, width, contIndent, ctx);
287
+ if (firstLine && subLines.length > 0) {
288
+ const first = subLines.shift();
289
+ lines.push([...prefix, ...first.slice(1)]);
290
+ firstLine = false;
291
+ }
292
+ subLines.forEach((l) => lines.push(l));
293
+ }
294
+ }
295
+ }
296
+ // Blank line after list
297
+ lines.push([{ text: indent, style: theme.text }]);
298
+ }
299
+ // ── Tables ───────────────────────────────────────────────────────
300
+ function renderTable(token, lines, theme, _width, indent) {
301
+ const numCols = token.header.length;
302
+ // Compute column widths from content
303
+ const colWidths = token.header.map((h) => plainText(h.tokens).length);
304
+ for (const row of token.rows) {
305
+ for (let c = 0; c < numCols; c++) {
306
+ if (row[c]) {
307
+ colWidths[c] = Math.max(colWidths[c], plainText(row[c].tokens).length);
308
+ }
309
+ }
310
+ }
311
+ // Minimum width of 3, pad by 2 for cell padding
312
+ for (let c = 0; c < numCols; c++) {
313
+ colWidths[c] = Math.max(3, colWidths[c]) + 2;
314
+ }
315
+ const border = theme.tableBorder;
316
+ // Helper to build a horizontal rule
317
+ const hRule = (left, mid, right) => {
318
+ const parts = colWidths.map((w) => "─".repeat(w));
319
+ return left + parts.join(mid) + right;
320
+ };
321
+ // Helper to build a data row
322
+ const dataRow = (cells, style) => {
323
+ const segs = [
324
+ { text: indent, style: theme.text },
325
+ { text: "│", style: border },
326
+ ];
327
+ for (let c = 0; c < numCols; c++) {
328
+ const cellText = cells[c] ? plainText(cells[c].tokens) : "";
329
+ const align = token.header[c]?.align;
330
+ const padded = padCell(cellText, colWidths[c], align);
331
+ segs.push({ text: padded, style });
332
+ segs.push({ text: "│", style: border });
333
+ }
334
+ return segs;
335
+ };
336
+ // Top border
337
+ lines.push([
338
+ { text: indent, style: theme.text },
339
+ { text: hRule("┌", "┬", "┐"), style: border },
340
+ ]);
341
+ // Header row
342
+ lines.push(dataRow(token.header, theme.tableHeader));
343
+ // Header separator
344
+ lines.push([
345
+ { text: indent, style: theme.text },
346
+ { text: hRule("├", "┼", "┤"), style: border },
347
+ ]);
348
+ // Data rows
349
+ for (const row of token.rows) {
350
+ lines.push(dataRow(row, theme.text));
351
+ }
352
+ // Bottom border
353
+ lines.push([
354
+ { text: indent, style: theme.text },
355
+ { text: hRule("└", "┴", "┘"), style: border },
356
+ ]);
357
+ lines.push([{ text: indent, style: theme.text }]);
358
+ }
359
+ // ── Horizontal rule ──────────────────────────────────────────────
360
+ function renderHr(lines, theme, width, indent) {
361
+ const ruleLen = Math.max(3, width - indent.length);
362
+ lines.push([
363
+ { text: indent, style: theme.text },
364
+ { text: "─".repeat(ruleLen), style: theme.hr },
365
+ ]);
366
+ lines.push([{ text: indent, style: theme.text }]);
367
+ }
368
+ // ── Inline token → segment conversion ───────────────────────────
369
+ function inlineTokensToSegments(tokens, theme, ctx) {
370
+ const segs = [];
371
+ for (const t of tokens) {
372
+ switch (t.type) {
373
+ case "text": {
374
+ const tt = t;
375
+ // Text tokens can contain nested tokens (e.g. from GFM autolinks)
376
+ if ("tokens" in tt && tt.tokens && tt.tokens.length > 0) {
377
+ segs.push(...inlineTokensToSegments(tt.tokens, theme, ctx));
378
+ }
379
+ else {
380
+ segs.push({ text: tt.text, style: resolveInlineStyle(theme, ctx) });
381
+ }
382
+ break;
383
+ }
384
+ case "strong": {
385
+ const st = t;
386
+ segs.push(...inlineTokensToSegments(st.tokens, theme, { ...ctx, bold: true }));
387
+ break;
388
+ }
389
+ case "em": {
390
+ const em = t;
391
+ segs.push(...inlineTokensToSegments(em.tokens, theme, { ...ctx, italic: true }));
392
+ break;
393
+ }
394
+ case "del": {
395
+ const del = t;
396
+ segs.push(...inlineTokensToSegments(del.tokens, theme, {
397
+ ...ctx,
398
+ strikethrough: true,
399
+ }));
400
+ break;
401
+ }
402
+ case "codespan": {
403
+ const cs = t;
404
+ segs.push({ text: cs.text, style: theme.code });
405
+ break;
406
+ }
407
+ case "link": {
408
+ const lk = t;
409
+ const linkText = plainText(lk.tokens);
410
+ segs.push({ text: linkText, style: theme.link });
411
+ if (lk.href && lk.href !== linkText) {
412
+ segs.push({ text: ` (${lk.href})`, style: theme.linkUrl });
413
+ }
414
+ break;
415
+ }
416
+ case "image": {
417
+ const img = t;
418
+ segs.push({
419
+ text: `[image: ${img.text || img.href}]`,
420
+ style: theme.linkUrl,
421
+ });
422
+ break;
423
+ }
424
+ case "br":
425
+ // Soft line break — we'll handle this during wrapping
426
+ segs.push({ text: "\n", style: theme.text });
427
+ break;
428
+ case "escape":
429
+ segs.push({
430
+ text: t.text,
431
+ style: resolveInlineStyle(theme, ctx),
432
+ });
433
+ break;
434
+ default:
435
+ // Fallback: render as plain text
436
+ if ("text" in t && typeof t.text === "string") {
437
+ segs.push({
438
+ text: t.text,
439
+ style: resolveInlineStyle(theme, ctx),
440
+ });
441
+ }
442
+ break;
443
+ }
444
+ }
445
+ return segs;
446
+ }
447
+ /** Resolve the inline style from the context flags. */
448
+ function resolveInlineStyle(theme, ctx) {
449
+ if (ctx.strikethrough)
450
+ return theme.strikethrough;
451
+ if (ctx.bold && ctx.italic)
452
+ return theme.boldItalic;
453
+ if (ctx.bold)
454
+ return theme.bold;
455
+ if (ctx.italic)
456
+ return theme.italic;
457
+ return theme.text;
458
+ }
459
+ // ── Word wrapping ────────────────────────────────────────────────
460
+ /**
461
+ * Word-wrap an array of segments to fit within maxWidth.
462
+ * Returns an array of Lines, each prefixed with the indent.
463
+ */
464
+ function wordWrapSegments(segs, maxWidth, indent, theme) {
465
+ if (maxWidth <= 0)
466
+ maxWidth = 1;
467
+ // Flatten into a stream of { char, style } for wrapping
468
+ const chars = [];
469
+ for (const seg of segs) {
470
+ for (const ch of seg.text) {
471
+ chars.push({ char: ch, style: seg.style });
472
+ }
473
+ }
474
+ const result = [];
475
+ let lineSegs = [{ text: indent, style: theme.text }];
476
+ let col = 0;
477
+ const flushLine = () => {
478
+ // Coalesce adjacent segments but keep the indent as a separate first segment
479
+ if (lineSegs.length > 1) {
480
+ result.push([lineSegs[0], ...coalesce(lineSegs.slice(1))]);
481
+ }
482
+ else {
483
+ result.push(lineSegs);
484
+ }
485
+ lineSegs = [{ text: indent, style: theme.text }];
486
+ col = 0;
487
+ };
488
+ let i = 0;
489
+ while (i < chars.length) {
490
+ const ch = chars[i];
491
+ // Hard line break
492
+ if (ch.char === "\n") {
493
+ flushLine();
494
+ i++;
495
+ continue;
496
+ }
497
+ // Check if we need to wrap — find the last space to break at
498
+ if (col >= maxWidth) {
499
+ // Walk back through lineSegs to find the last space
500
+ let broke = false;
501
+ let _backtrack = 0;
502
+ for (let s = lineSegs.length - 1; s >= 1; s--) {
503
+ const seg = lineSegs[s];
504
+ const spaceIdx = seg.text.lastIndexOf(" ");
505
+ if (spaceIdx >= 0) {
506
+ // Split this segment at the space
507
+ const overflow = [];
508
+ if (spaceIdx + 1 < seg.text.length) {
509
+ overflow.push({
510
+ text: seg.text.slice(spaceIdx + 1),
511
+ style: seg.style,
512
+ });
513
+ }
514
+ // Collect all segments after s
515
+ for (let a = s + 1; a < lineSegs.length; a++) {
516
+ overflow.push(lineSegs[a]);
517
+ }
518
+ // Trim this segment and remove everything after
519
+ seg.text = seg.text.slice(0, spaceIdx);
520
+ lineSegs.length = s + 1;
521
+ // Remove trailing empty segment
522
+ if (seg.text.length === 0)
523
+ lineSegs.length = s;
524
+ flushLine();
525
+ // Push overflow segments onto new line
526
+ for (const o of overflow) {
527
+ lineSegs.push(o);
528
+ col += o.text.length;
529
+ }
530
+ broke = true;
531
+ break;
532
+ }
533
+ _backtrack += seg.text.length;
534
+ }
535
+ if (!broke) {
536
+ // No space found — hard break at maxWidth
537
+ flushLine();
538
+ }
539
+ }
540
+ // Append character to current line (never merge into the indent segment)
541
+ const lastSeg = lineSegs.length > 1 ? lineSegs[lineSegs.length - 1] : null;
542
+ if (lastSeg && lastSeg.style === ch.style) {
543
+ lastSeg.text += ch.char;
544
+ }
545
+ else {
546
+ lineSegs.push({ text: ch.char, style: ch.style });
547
+ }
548
+ col++;
549
+ i++;
550
+ }
551
+ // Flush remaining
552
+ if (col > 0 || lineSegs.length > 1) {
553
+ flushLine();
554
+ }
555
+ if (result.length === 0) {
556
+ result.push([{ text: indent, style: theme.text }]);
557
+ }
558
+ return result;
559
+ }
560
+ /** Coalesce adjacent segments that share the same style reference. */
561
+ function coalesce(segs) {
562
+ if (segs.length <= 1)
563
+ return segs;
564
+ const out = [segs[0]];
565
+ for (let i = 1; i < segs.length; i++) {
566
+ const prev = out[out.length - 1];
567
+ if (prev.style === segs[i].style) {
568
+ prev.text += segs[i].text;
569
+ }
570
+ else {
571
+ out.push(segs[i]);
572
+ }
573
+ }
574
+ return out;
575
+ }
576
+ // ── Helpers ──────────────────────────────────────────────────────
577
+ /** Extract plain text from inline tokens. */
578
+ function plainText(tokens) {
579
+ let result = "";
580
+ for (const t of tokens) {
581
+ if ("tokens" in t && t.tokens) {
582
+ result += plainText(t.tokens);
583
+ }
584
+ else if ("text" in t) {
585
+ result += t.text;
586
+ }
587
+ }
588
+ return result;
589
+ }
590
+ /** Pad a cell value to a given width with alignment. */
591
+ function padCell(text, width, align) {
592
+ const inner = width - 2; // 1 char padding each side
593
+ const truncated = text.length > inner ? `${text.slice(0, inner - 1)}…` : text;
594
+ const pad = inner - truncated.length;
595
+ let content;
596
+ switch (align) {
597
+ case "right":
598
+ content = " ".repeat(pad) + truncated;
599
+ break;
600
+ case "center": {
601
+ const left = Math.floor(pad / 2);
602
+ const right = pad - left;
603
+ content = " ".repeat(left) + truncated + " ".repeat(right);
604
+ break;
605
+ }
606
+ default:
607
+ content = truncated + " ".repeat(pad);
608
+ break;
609
+ }
610
+ return ` ${content} `;
611
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Panel = Border + background fill.
3
+ *
4
+ * Fills its entire bounds with a background color before drawing
5
+ * the border and child, producing a filled, bordered container.
6
+ */
7
+ import type { DrawingContext } from "../drawing/context.js";
8
+ import type { Color } from "../pixel/color.js";
9
+ import { Border, type BorderOptions } from "./border.js";
10
+ export interface PanelOptions extends BorderOptions {
11
+ background?: Color;
12
+ }
13
+ export declare class Panel extends Border {
14
+ private _background;
15
+ constructor(options?: PanelOptions);
16
+ get background(): Color;
17
+ set background(value: Color);
18
+ render(ctx: DrawingContext): void;
19
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Panel = Border + background fill.
3
+ *
4
+ * Fills its entire bounds with a background color before drawing
5
+ * the border and child, producing a filled, bordered container.
6
+ */
7
+ import { TRANSPARENT } from "../pixel/color.js";
8
+ import { Border } from "./border.js";
9
+ export class Panel extends Border {
10
+ _background;
11
+ constructor(options = {}) {
12
+ super(options);
13
+ this._background = options.background ?? TRANSPARENT;
14
+ }
15
+ // ── Properties ────────────────────────────────────────────────
16
+ get background() {
17
+ return this._background;
18
+ }
19
+ set background(value) {
20
+ this._background = value;
21
+ this.invalidate();
22
+ }
23
+ // ── Render ────────────────────────────────────────────────────
24
+ render(ctx) {
25
+ const bounds = this.bounds;
26
+ if (!bounds)
27
+ return;
28
+ // Fill the background first
29
+ if (this._background.a > 0) {
30
+ ctx.fillRect(bounds, this._background);
31
+ }
32
+ // Then draw the border and child
33
+ super.render(ctx);
34
+ }
35
+ }