pi-openui 0.0.0-stage → 0.1.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.
@@ -0,0 +1,370 @@
1
+ // BarChart, LineChart, AreaChart, PieChart, RadarChart.
2
+ import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
3
+ import type { El } from "../lang/library.ts";
4
+ import { Dots, fmtNum, monotone, niceNum } from "./braille.ts";
5
+ import type { Anchor } from "./core.ts";
6
+ import type { Block } from "./types.ts";
7
+ import { cell, fromRows, padRow, textCells, vstack } from "./util.ts";
8
+
9
+ const SER = (i: number) => `series.${((i % 8) + 8) % 8}`;
10
+ const EIGHTHS = ["", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
11
+
12
+ /** One legend line. `rects[i]` is where entry i sits on that line (for tooltips on a series). */
13
+ function legendRow(names: string[], width: number): { block: Block; rects: Rect[] } {
14
+ const cells: Cell[] = [];
15
+ const rects: Rect[] = [];
16
+ names.forEach((n, i) => {
17
+ if (i) cells.push(cell(" "), cell(" "));
18
+ const x0 = cells.length;
19
+ cells.push(cell("■", SER(i)), cell(" "), ...textCells(truncateText(n, 24), "chart.legend"));
20
+ rects.push({ x: x0, y: 0, w: cells.length - x0, h: 1 });
21
+ });
22
+ return { block: fromRows(width, [cells]), rects };
23
+ }
24
+
25
+ function titleRow(t: string | undefined, width: number): Block[] {
26
+ return t ? [fromRows(width, [textCells(truncateText(t, width), "chart.title")])] : [];
27
+ }
28
+
29
+ // ---------------- BarChart ----------------
30
+
31
+ export function barChart(el: El, width: number): { block: Block; anchors: Map<string, Anchor> } {
32
+ const labels: string[] = el.props.labels ?? [];
33
+ const values: number[] = (el.props.values ?? []).map((v: number) => (Number.isFinite(v) ? v : 0));
34
+ const unit = String(el.props.unit ?? "");
35
+ const anchors = new Map<string, Anchor>();
36
+ const n = labels.length;
37
+ const max = Math.max(0, ...values);
38
+ const hasNeg = values.some((v) => v < 0);
39
+ const lw = Math.max(1, ...labels.map((l) => textWidth(l)));
40
+ const colW = Math.min(14, Math.max(6, lw + 1));
41
+ const head = titleRow(el.props.title, width);
42
+ if (!n) return { block: vstack(head, 0, width), anchors };
43
+ if (!hasNeg && n * colW <= width - 1) {
44
+ const H = 8;
45
+ const bw = colW - 2;
46
+ const rows: Cell[][] = [];
47
+ const heights = values.map((v) => (max > 0 ? Math.round((v / max) * H * 8) : 0));
48
+ for (let r = H - 1; r >= 0; r--) {
49
+ const row: Cell[] = [];
50
+ values.forEach((_, i) => {
51
+ const inner = heights[i] - r * 8;
52
+ const ch = inner >= 8 ? "█" : inner > 0 ? EIGHTHS[inner] : " ";
53
+ row.push(cell(" "));
54
+ for (let k = 0; k < bw; k++) row.push(cell(ch, ch === " " ? "" : SER(0)));
55
+ row.push(cell(" "));
56
+ });
57
+ rows.push(row);
58
+ }
59
+ const vals: Cell[] = [];
60
+ values.forEach((v, i) => {
61
+ const t = truncateText(fmtNum(v) + unit, colW);
62
+ const pad = Math.floor((colW - textWidth(t)) / 2);
63
+ for (let k = 0; k < pad; k++) vals.push(cell(" "));
64
+ vals.push(...textCells(t, "chart.value"));
65
+ for (let k = 0; k < colW - pad - textWidth(t); k++) vals.push(cell(" "));
66
+ void i;
67
+ });
68
+ const axis = Array.from({ length: n * colW }, () => cell("─", "chart.axis"));
69
+ const labs: Cell[] = [];
70
+ labels.forEach((l) => {
71
+ const t = truncateText(l, colW - 1);
72
+ const pad = Math.floor((colW - textWidth(t)) / 2);
73
+ for (let k = 0; k < pad; k++) labs.push(cell(" "));
74
+ labs.push(...textCells(t, "chart.label"));
75
+ for (let k = 0; k < colW - pad - textWidth(t); k++) labs.push(cell(" "));
76
+ });
77
+ const body = fromRows(n * colW, [vals, ...rows, axis, labs]);
78
+ const off = 1 + head.length; // not used for anchors inside body; anchors are relative to the final block
79
+ labels.forEach((l, i) => anchors.set(l, { rect: { x: i * colW, y: head.length, w: colW, h: body.rows.length } }));
80
+ void off;
81
+ return { block: vstack([...head, body], 0, width), anchors };
82
+ }
83
+ // horizontal bars
84
+ const lab = Math.min(lw, Math.max(6, Math.floor(width * 0.3)));
85
+ const vw = Math.max(...values.map((v) => textWidth(fmtNum(v) + unit)));
86
+ const bwid = Math.max(4, width - lab - vw - 4);
87
+ const lo = Math.min(0, ...values);
88
+ const span = max - lo || 1;
89
+ const rows: Cell[][] = [];
90
+ values.forEach((v, i) => {
91
+ const zero = Math.round((-lo / span) * bwid);
92
+ const end = Math.round(((v - lo) / span) * bwid);
93
+ const from = Math.min(zero, end);
94
+ const to = Math.max(zero, end);
95
+ const t = truncateText(labels[i], lab);
96
+ const row: Cell[] = [...textCells(t, "chart.label"), ...Array.from({ length: lab - textWidth(t) + 1 }, () => cell(" "))];
97
+ for (let x = 0; x < bwid; x++) row.push(x >= from && x < to ? cell("█", SER(0)) : x === zero ? cell("│", "chart.axis") : cell(" "));
98
+ row.push(cell(" "), ...textCells(fmtNum(v) + unit, "chart.value"));
99
+ rows.push(row);
100
+ anchors.set(labels[i], { rect: { x: 0, y: head.length + i, w: width, h: 1 } });
101
+ });
102
+ return { block: vstack([...head, fromRows(width, rows)], 0, width), anchors };
103
+ }
104
+
105
+ // ---------------- Line / Area ----------------
106
+
107
+ export function lineArea(el: El, width: number, stacked: boolean): { block: Block; anchors: Map<string, Anchor> } {
108
+ const anchors = new Map<string, Anchor>();
109
+ const labels: string[] = el.props.labels ?? [];
110
+ const series: El[] = el.props.series ?? [];
111
+ const variant = el.props.variant ?? "natural";
112
+ const n = labels.length;
113
+ const names = series.map((s) => String(s.props.category ?? ""));
114
+ const data = series.map((s) => (s.props.values ?? []).map((v: number) => (Number.isFinite(v) ? v : 0)) as number[]);
115
+ if (!n || !data.length) return { block: fromRows(width, [textCells("(no data)", "text.muted")]), anchors };
116
+ const cum = data.map((d) => d.slice());
117
+ if (stacked) for (let s = 1; s < cum.length; s++) for (let i = 0; i < n; i++) cum[s][i] += cum[s - 1][i];
118
+ const all = (stacked ? cum : data).flat();
119
+ let hi = Math.max(...all);
120
+ let lo = stacked ? 0 : Math.min(...all);
121
+ if (!stacked && lo > 0 && lo / (hi || 1) < 0.6) lo = 0;
122
+ if (hi === lo) hi = lo + 1;
123
+ const step = niceNum((hi - lo) / 4, true);
124
+ hi = Math.ceil(hi / step) * step;
125
+ lo = lo < 0 || stacked ? Math.floor(lo / step) * step : lo === 0 ? 0 : Math.floor(lo / step) * step;
126
+ const axisW = Math.max(textWidth(fmtNum(hi)), textWidth(fmtNum(lo)), 2) + 1;
127
+ const plotW = Math.max(10, width - axisW - 1);
128
+ const rowsN = Math.min(16, Math.max(6, Math.round((el.props.height ?? 200) / 20)));
129
+ const d = new Dots(plotW, rowsN);
130
+ const X = (i: number) => (n === 1 ? (d.w - 1) / 2 : (i / (n - 1)) * (d.w - 1));
131
+ const Y = (v: number) => (d.h - 1) * (1 - (v - lo) / (hi - lo));
132
+ const xs = Array.from({ length: n }, (_, i) => X(i));
133
+ // y(x) of a series in dot space
134
+ const fnFor = (vals: number[]): ((x: number) => number) => {
135
+ const ys = vals.map(Y);
136
+ if (variant === "step") {
137
+ return (x: number) => {
138
+ let i = 0;
139
+ while (i < n - 1 && x >= (xs[i] + xs[i + 1]) / 2) i++;
140
+ return ys[i];
141
+ };
142
+ }
143
+ if (variant === "natural" && n > 2) {
144
+ const f = monotone(xs, ys);
145
+ return (x: number) => Math.min(d.h - 1, Math.max(0, f(x)));
146
+ }
147
+ return (x: number) => {
148
+ if (n === 1) return ys[0];
149
+ let i = 0;
150
+ while (i < n - 2 && x > xs[i + 1]) i++;
151
+ const t = (x - xs[i]) / (xs[i + 1] - xs[i] || 1);
152
+ return ys[i] + (ys[i + 1] - ys[i]) * Math.min(1, Math.max(0, t));
153
+ };
154
+ };
155
+ const fns = (stacked ? cum : data).map(fnFor);
156
+ if (stacked) {
157
+ for (let s = 0; s < fns.length; s++) {
158
+ const lower = s === 0 ? () => Y(lo) : fns[s - 1];
159
+ for (let x = 0; x < d.w; x++) {
160
+ const top = fns[s](x);
161
+ const bot = lower(x);
162
+ for (let y = Math.round(top); y <= Math.round(bot); y++) d.set(x, y, s);
163
+ }
164
+ }
165
+ }
166
+ for (let s = 0; s < fns.length; s++) {
167
+ let py = fns[s](0);
168
+ for (let x = 1; x < d.w; x++) {
169
+ const y = fns[s](x);
170
+ d.line(x - 1, py, x, y, s);
171
+ py = y;
172
+ }
173
+ if (variant === "step" || n <= 12) for (let i = 0; i < n; i++) d.set(xs[i], fns[s](xs[i]), s);
174
+ }
175
+ const body = d.rows2cells((o) => (o >= 0 ? SER(o) : ""));
176
+ const out: Cell[][] = [];
177
+ const tickRows = new Map<number, number>([
178
+ [0, hi],
179
+ [Math.floor((rowsN - 1) / 2), (hi + lo) / 2],
180
+ [rowsN - 1, lo],
181
+ ]);
182
+ body.forEach((row, r) => {
183
+ const t = tickRows.has(r) ? fmtNum(tickRows.get(r)!) : "";
184
+ const lab = Array.from({ length: axisW - textWidth(t) - 1 }, () => cell(" ")).concat(textCells(t, "chart.axisLabel"));
185
+ out.push([...lab, cell("┤", "chart.axis"), ...row]);
186
+ });
187
+ const base: Cell[] = [...Array.from({ length: axisW }, () => cell(" ")), cell("└", "chart.axis"), ...Array.from({ length: plotW }, () => cell("─", "chart.axis"))];
188
+ out.push(base);
189
+ // x labels: first, last, and evenly spaced ones that fit
190
+ const xl: Cell[] = Array.from({ length: axisW + 1 + plotW }, () => cell(" "));
191
+ const placed: Array<[number, number]> = [];
192
+ const put = (i: number, align: "l" | "c" | "r") => {
193
+ const t = truncateText(labels[i], 10);
194
+ const tw = textWidth(t);
195
+ const cx = axisW + 1 + Math.round(X(i) / 2);
196
+ let x0 = align === "l" ? cx : align === "r" ? cx - tw + 1 : cx - Math.floor(tw / 2);
197
+ x0 = Math.max(axisW + 1, Math.min(axisW + 1 + plotW - tw, x0));
198
+ if (placed.some(([a, b]) => x0 < b + 1 && x0 + tw > a - 1)) return;
199
+ placed.push([x0, x0 + tw]);
200
+ textCells(t, "chart.label").forEach((c, k) => (xl[x0 + k] = c));
201
+ };
202
+ put(0, "l");
203
+ put(n - 1, "r");
204
+ const stepI = Math.max(1, Math.ceil(n / Math.max(2, Math.floor(plotW / 12))));
205
+ for (let i = stepI; i < n - 1; i += stepI) put(i, "c");
206
+ out.push(xl);
207
+ const meta: Block[] = [];
208
+ const axisNames = [el.props.yLabel ? `↑ ${el.props.yLabel}` : "", el.props.xLabel ? `→ ${el.props.xLabel}` : ""].filter(Boolean).join(" ");
209
+ if (axisNames) meta.push(fromRows(width, [textCells(axisNames, "text.muted")]));
210
+ const legend = legendRow(names, width);
211
+ const top = meta.length;
212
+ // a tooltip on a category: hover anywhere in its column of the plot; the badge sits on the curve at that point
213
+ const perPoint = n > 1 ? plotW / (n - 1) : plotW;
214
+ const colW = Math.max(1, Math.min(3, Math.floor(perPoint)));
215
+ labels.forEach((l, i) => {
216
+ const cx = axisW + 1 + Math.floor(X(i) / 2); // the cell that holds the dot at dot-column X(i)
217
+ const ys = fns.map((f) => f(xs[i]));
218
+ const yTop = Math.min(...ys);
219
+ const by = top + Math.max(0, Math.min(rowsN - 1, Math.floor(yTop / 4)));
220
+ const x0 = Math.max(axisW + 1, Math.min(axisW + plotW + 1 - colW, cx - Math.floor(colW / 2)));
221
+ if (!anchors.has(String(l))) anchors.set(String(l), { rect: { x: x0, y: top, w: colW, h: rowsN }, badge: [cx, by], badgeForce: true });
222
+ });
223
+ names.forEach((nm, i) => {
224
+ const r = legend.rects[i];
225
+ anchors.set(`series:${nm}`, { rect: { x: r.x, y: top + rowsN + 2, w: r.w, h: 1 } });
226
+ });
227
+ return { block: vstack([...meta, fromRows(width, out.map((r) => padRow(r, width))), legend.block], 0, width), anchors };
228
+ }
229
+
230
+ // ---------------- Pie ----------------
231
+
232
+ export function pieChart(el: El, width: number): { block: Block; anchors: Map<string, Anchor> } {
233
+ const anchors = new Map<string, Anchor>();
234
+ const labels: string[] = el.props.labels ?? [];
235
+ const vals: number[] = (el.props.values ?? []).map((v: number) => Math.max(0, Number.isFinite(v) ? v : 0));
236
+ const total = vals.reduce((a, b) => a + b, 0);
237
+ if (!labels.length || total <= 0) return { block: fromRows(width, [textCells("(no data)", "text.muted")]), anchors };
238
+ const order = labels.map((_, i) => i).sort((a, b) => vals[b] - vals[a] || a - b);
239
+ const frac = order.map((i) => vals[i] / total);
240
+ const cumulative: number[] = [];
241
+ frac.reduce((a, f, k) => ((cumulative[k] = a + f), a + f), 0);
242
+ const semi = el.props.appearance === "semiCircular";
243
+ const donut = el.props.variant === "donut";
244
+ const legendW = Math.min(36, Math.max(...labels.map((l) => textWidth(l))) + 14);
245
+ const stackLegend = width < 22 + legendW + 3;
246
+ // a half disc is only half as tall, so it can be drawn wider (and so smoother) than a full one in the same space
247
+ const D = Math.max(10, Math.min(semi ? 44 : 24, (stackLegend ? width : width - legendW - 3) & ~1));
248
+ const R = D / 2;
249
+ const pxH = semi ? Math.ceil(D / 2) : D;
250
+ const rowsN = Math.ceil(pxH / 2);
251
+ const sliceAt = (x: number, y: number): number | "x" => {
252
+ const dx = x + 0.5 - R;
253
+ const dy = y + 0.5 - R;
254
+ const r2 = dx * dx + dy * dy;
255
+ if (r2 > R * R || (donut && r2 < (R * 0.55) ** 2)) return "x";
256
+ let f = semi ? (Math.atan2(dx, -dy) + Math.PI / 2) / Math.PI : (Math.atan2(dx, -dy) + 2 * Math.PI) % (2 * Math.PI) / (2 * Math.PI);
257
+ if (semi && (f < 0 || f > 1)) return "x";
258
+ f = Math.min(0.999999, Math.max(0, f));
259
+ let k = 0;
260
+ while (k < cumulative.length - 1 && f >= cumulative[k]) k++;
261
+ return k;
262
+ };
263
+ const disc: Cell[][] = [];
264
+ const wedge: Array<Array<[number, number]>> = order.map(() => []);
265
+ for (let r = 0; r < rowsN; r++) {
266
+ const row: Cell[] = [];
267
+ for (let x = 0; x < D; x++) {
268
+ const a = sliceAt(x, r * 2);
269
+ const b = r * 2 + 1 < pxH ? sliceAt(x, r * 2 + 1) : "x";
270
+ if (a !== "x") wedge[a].push([x, r]);
271
+ if (b !== "x" && b !== a) wedge[b].push([x, r]);
272
+ if (a === "x" && b === "x") row.push(cell(" "));
273
+ else if (b === "x") row.push(cell("▀", `pie.${a}.x`));
274
+ else if (a === "x") row.push(cell("▄", `pie.x.${b}`));
275
+ else row.push(cell("▀", `pie.${a}.${b}`));
276
+ }
277
+ disc.push(row);
278
+ }
279
+ const leg: Cell[][] = order.map((i, k) => {
280
+ const pct = `${Math.round((vals[i] / total) * 100)}%`;
281
+ const left = [cell("■", `pie.${k}.x`.replace(/^pie\.(\d+)\.x$/, "series.$1")), cell(" "), ...textCells(truncateText(labels[i], legendW - textWidth(pct) - 8), "chart.label")];
282
+ const right = textCells(`${fmtNum(vals[i])} ${pct}`, "chart.value");
283
+ const gap = Math.max(1, legendW - left.length - right.length);
284
+ return [...left, ...Array.from({ length: gap }, () => cell(" ")), ...right];
285
+ });
286
+ const legendW2 = leg[0]?.length ?? legendW;
287
+ if (stackLegend) {
288
+ order.forEach((li, k) => anchors.set(String(labels[li]), { rect: { x: 0, y: disc.length + 1 + k, w: legendW2, h: 1 }, cells: wedge[k] }));
289
+ return { block: vstack([fromRows(width, disc), fromRows(width, leg)], 1, width), anchors };
290
+ }
291
+ const h = Math.max(disc.length, leg.length);
292
+ const rows: Cell[][] = [];
293
+ const top = Math.floor((h - leg.length) / 2);
294
+ const legX = D + 3;
295
+ for (let y = 0; y < h; y++) {
296
+ const dr = disc[y] ?? Array.from({ length: D }, () => cell(" "));
297
+ const lr = leg[y - top] ?? [];
298
+ rows.push([...dr, cell(" "), cell(" "), cell(" "), ...lr]);
299
+ }
300
+ order.forEach((li, k) => anchors.set(String(labels[li]), { rect: { x: legX, y: top + k, w: legendW2, h: 1 }, cells: wedge[k] }));
301
+ return { block: fromRows(width, rows), anchors };
302
+ }
303
+
304
+ // ---------------- Radar ----------------
305
+
306
+ export function radarChart(el: El, width: number): { block: Block; anchors: Map<string, Anchor> } {
307
+ const anchors = new Map<string, Anchor>();
308
+ const labels: string[] = el.props.labels ?? [];
309
+ const series: El[] = el.props.series ?? [];
310
+ const n = labels.length;
311
+ const names = series.map((s) => String(s.props.category ?? ""));
312
+ const data = series.map((s) => (s.props.values ?? []).map((v: number) => (Number.isFinite(v) ? Math.max(0, v) : 0)) as number[]);
313
+ if (n < 3 || !data.length) return { block: fromRows(width, [textCells("(a radar chart needs at least 3 axes)", "text.muted")]), anchors };
314
+ const labW = Math.min(14, Math.max(...labels.map((l) => textWidth(l))));
315
+ const plotCols = Math.max(16, Math.min(40, width - 2 * (labW + 2)) & ~1);
316
+ const rowsN = Math.floor(plotCols / 2);
317
+ const d = new Dots(plotCols, rowsN);
318
+ const cx = (d.w - 1) / 2;
319
+ const cy = (d.h - 1) / 2;
320
+ const R = Math.min(cx, cy) - 1;
321
+ const max = niceNum(Math.max(1, ...data.flat()), false);
322
+ const ang = (i: number) => -Math.PI / 2 + (i * 2 * Math.PI) / n;
323
+ const pt = (i: number, f: number): [number, number] => [cx + R * f * Math.cos(ang(i)), cy + R * f * Math.sin(ang(i))];
324
+ for (const ring of [1 / 3, 2 / 3, 1]) {
325
+ for (let i = 0; i < n; i++) {
326
+ const [x0, y0] = pt(i, ring);
327
+ const [x1, y1] = pt((i + 1) % n, ring);
328
+ const steps = Math.ceil(Math.hypot(x1 - x0, y1 - y0));
329
+ for (let s = 0; s <= steps; s += ring === 1 ? 1 : 2) d.set(x0 + ((x1 - x0) * s) / (steps || 1), y0 + ((y1 - y0) * s) / (steps || 1), 99);
330
+ }
331
+ }
332
+ for (let i = 0; i < n; i++) {
333
+ const [x1, y1] = pt(i, 1);
334
+ const steps = Math.ceil(Math.hypot(x1 - cx, y1 - cy));
335
+ for (let s = 0; s <= steps; s += 3) d.set(cx + ((x1 - cx) * s) / (steps || 1), cy + ((y1 - cy) * s) / (steps || 1), 99);
336
+ }
337
+ data.forEach((vals, s) => {
338
+ for (let i = 0; i < n; i++) {
339
+ const [x0, y0] = pt(i, Math.min(1, (vals[i] ?? 0) / max));
340
+ const [x1, y1] = pt((i + 1) % n, Math.min(1, (vals[(i + 1) % n] ?? 0) / max));
341
+ d.line(x0, y0, x1, y1, s);
342
+ }
343
+ });
344
+ const grid = d.rows2cells((o) => (o === 99 ? "chart.grid" : o >= 0 ? SER(o) : ""));
345
+ const totalW = plotCols + 2 * (labW + 2);
346
+ const rows: Cell[][] = grid.map((r) => [...Array.from({ length: labW + 2 }, () => cell(" ")), ...r, ...Array.from({ length: labW + 2 }, () => cell(" "))]);
347
+ labels.forEach((l, i) => {
348
+ const t = truncateText(l, labW);
349
+ const tw = textWidth(t);
350
+ const px = cx + (R + 3) * Math.cos(ang(i));
351
+ const py = cy + (R + 3) * Math.sin(ang(i));
352
+ const col = labW + 2 + Math.round(px / 2);
353
+ const row = Math.max(0, Math.min(rowsN - 1, Math.round(py / 4)));
354
+ const c = Math.cos(ang(i));
355
+ const x0 = Math.abs(c) < 0.3 ? col - Math.floor(tw / 2) : c > 0 ? col + 1 : col - tw;
356
+ textCells(t, "chart.label").forEach((ce, k) => {
357
+ const x = x0 + k;
358
+ if (x >= 0 && x < totalW && row >= 0) rows[row][x] = ce;
359
+ });
360
+ if (!anchors.has(String(l))) anchors.set(String(l), { rect: { x: Math.max(0, x0), y: row, w: Math.max(1, Math.min(tw, totalW - Math.max(0, x0))), h: 1 } });
361
+ });
362
+ const meta = fromRows(width, [textCells(`scale 0–${fmtNum(max)}`, "text.muted")]);
363
+ const legend = legendRow(names, width);
364
+ names.forEach((nm, i) => {
365
+ const r = legend.rects[i];
366
+ anchors.set(`series:${nm}`, { rect: { x: r.x, y: rows.length + 1, w: r.w, h: 1 } });
367
+ });
368
+ return { block: vstack([fromRows(width, rows.map((r) => padRow(r, Math.max(width, totalW)).slice(0, width))), meta, legend.block], 0, width), anchors };
369
+ }
370
+ void cell;
@@ -0,0 +1,101 @@
1
+ import { textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
2
+ import type { Block, Target } from "./types.ts";
3
+ import { boxed, cell, fromRows, textCells, vstack } from "./util.ts";
4
+
5
+ const KW: Record<string, string[]> = {
6
+ python: "def class return if elif else for while in not and or import from as with try except finally raise lambda None True False pass break continue yield async await global nonlocal is del assert".split(" "),
7
+ js: "const let var function return if else for while of in new class extends import export from default async await try catch finally throw switch case break continue typeof instanceof null undefined true false this interface type enum implements public private readonly".split(" "),
8
+ bash: "if then else elif fi for do done while until case esac function in echo export cd sudo set local return exit source".split(" "),
9
+ sql: "select from where join left right inner outer on group by order limit offset insert into values update set delete create table alter drop index and or not null as distinct having union all primary key foreign references default count sum avg min max like in is between exists case when then else end with".split(" "),
10
+ yaml: ["true", "false", "null", "yes", "no"],
11
+ json: ["true", "false", "null"],
12
+ go: "func package import var const type struct interface return if else for range switch case default go defer chan map select break continue nil true false".split(" "),
13
+ rust: "fn let mut pub struct enum impl trait use mod return if else match for while loop in as ref self Self true false const static async await move where type unsafe".split(" "),
14
+ };
15
+ const ALIAS: Record<string, string> = { py: "python", python3: "python", javascript: "js", jsx: "js", typescript: "js", ts: "js", tsx: "js", node: "js", sh: "bash", shell: "bash", zsh: "bash", yml: "yaml", golang: "go", rs: "rust", postgresql: "sql", postgres: "sql", mysql: "sql", jsonc: "json" };
16
+ const COMMENT: Record<string, string> = { python: "#", bash: "#", yaml: "#", js: "//", go: "//", rust: "//", sql: "--", json: "" };
17
+
18
+ export function normLang(lang?: string): string | null {
19
+ const l = (lang ?? "").toLowerCase().trim();
20
+ const k = ALIAS[l] ?? l;
21
+ return KW[k] ? k : null;
22
+ }
23
+
24
+ function highlightLine(line: string, lang: string | null): Cell[] {
25
+ if (!lang) return textCells(line, "code");
26
+ const kw = new Set(KW[lang]);
27
+ const ci = lang === "sql";
28
+ const cm = COMMENT[lang];
29
+ const out: Cell[] = [];
30
+ let i = 0;
31
+ const push = (s: string, role: string) => out.push(...textCells(s, role));
32
+ while (i < line.length) {
33
+ const c = line[i];
34
+ if (cm && line.startsWith(cm, i)) {
35
+ push(line.slice(i), "code.com");
36
+ break;
37
+ }
38
+ if (c === '"' || c === "'" || c === "`") {
39
+ let j = i + 1;
40
+ while (j < line.length && line[j] !== c) j += line[j] === "\\" ? 2 : 1;
41
+ const s = line.slice(i, Math.min(j + 1, line.length));
42
+ const next = line.slice(j + 1).trimStart()[0];
43
+ push(s, (lang === "json" || lang === "yaml") && next === ":" ? "code.key" : "code.str");
44
+ i = j + 1;
45
+ continue;
46
+ }
47
+ if (/[0-9]/.test(c) && !/[A-Za-z_]/.test(line[i - 1] ?? "")) {
48
+ const m = /^\d[\d_.]*(e[+-]?\d+)?[a-z]*/i.exec(line.slice(i))!;
49
+ push(m[0], "code.num");
50
+ i += m[0].length;
51
+ continue;
52
+ }
53
+ if (/[A-Za-z_$]/.test(c)) {
54
+ const m = /^[A-Za-z_$][\w$-]*/.exec(line.slice(i))!;
55
+ const w = m[0];
56
+ let role = "code";
57
+ if (kw.has(ci ? w.toLowerCase() : w)) role = "code.kw";
58
+ else if (lang === "yaml" && line.slice(i + w.length).startsWith(":")) role = "code.key";
59
+ else if (line[i + w.length] === "(") role = "code.fn";
60
+ else if (/^[A-Z][a-z]/.test(w) && lang !== "bash") role = "code.type";
61
+ else if (lang === "bash" && line[i - 1] === "$") role = "code.fn";
62
+ push(w, role);
63
+ i += w.length;
64
+ continue;
65
+ }
66
+ if (lang === "bash" && c === "$") {
67
+ push("$", "code.fn");
68
+ i++;
69
+ continue;
70
+ }
71
+ push(c, "code");
72
+ i++;
73
+ }
74
+ return out;
75
+ }
76
+
77
+ export function codeInner(code: string, lang: string | undefined, width: number): Block {
78
+ const l = normLang(lang);
79
+ const lines = String(code ?? "").replace(/\t/g, " ").replace(/\n$/, "").split("\n").slice(0, 200);
80
+ const rows = lines.map((ln) => {
81
+ const tw = textWidth(ln);
82
+ return highlightLine(tw > width ? truncateText(ln, width) : ln, l);
83
+ });
84
+ return fromRows(width, rows);
85
+ }
86
+
87
+ export function codeBlock(code: string, lang: string | undefined, title: string | undefined, width: number, copyId: string | null, copied: boolean): Block {
88
+ const inner = codeInner(code, lang, Math.max(8, width - 4));
89
+ const label = [title, lang && !title ? lang : title && lang ? lang : ""].filter(Boolean).join(" · ");
90
+ const tagText = copyId ? (copied ? "✓ copied" : "[copy]") : undefined;
91
+ const b = boxed(inner, { role: "code.border", title: label || undefined, titleRole: "code.title", tag: tagText, tagRole: copied ? "code.copied" : "btn" });
92
+ if (copyId && tagText) {
93
+ const len = textWidth(tagText) + 2;
94
+ const t: Target = { id: copyId, kind: "copy", rect: { x: b.w - 2 - len, y: 0, w: len, h: 1 }, label: "copy code", copy: code };
95
+ b.targets.push(t);
96
+ }
97
+ return b;
98
+ }
99
+
100
+ void cell;
101
+ void vstack;