@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,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
|
+
}
|