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,266 @@
1
+ // SequenceDiagram: columns left to right, steps top to bottom.
2
+ import { textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
3
+ import type { El } from "../lang/library.ts";
4
+ import type { Anchor } from "./core.ts";
5
+ import type { Block, Target } from "./types.ts";
6
+ import { cell, fromRows, textCells, wrapText } from "./util.ts";
7
+
8
+ const ICON: Record<string, string> = { actor: "⚇", service: "◇", database: "◎", queue: "≡", external: "◌" };
9
+
10
+ type Ev =
11
+ | { t: "msg"; from: number; to: number; label: string; style: string; id?: string; n: number; depth: number }
12
+ | { t: "note"; over: number; to?: number; text: string; depth: number }
13
+ | { t: "open"; kind: string; label: string; depth: number }
14
+ | { t: "else"; label: string; depth: number }
15
+ | { t: "close"; depth: number };
16
+
17
+ export function sequence(el: El, width: number, path: string): { block: Block; anchors: Map<string, Anchor>; overflow: boolean } {
18
+ const parts: El[] = el.props.participants ?? [];
19
+ const idx = new Map(parts.map((p, i) => [String(p.props.id), i]));
20
+ const numbered = el.props.numbered !== false;
21
+ const anchors = new Map<string, Anchor>();
22
+ const evs: Ev[] = [];
23
+ let n = 0;
24
+ let maxDepth = 0;
25
+ const walk = (steps: El[], depth: number) => {
26
+ for (const s of steps ?? []) {
27
+ if (s.type === "SeqMessage") evs.push({ t: "msg", from: idx.get(s.props.from) ?? 0, to: idx.get(s.props.to) ?? 0, label: String(s.props.label ?? ""), style: s.props.style ?? "sync", id: s.props.id, n: ++n, depth });
28
+ else if (s.type === "SeqNote") evs.push({ t: "note", over: idx.get(s.props.over) ?? 0, to: s.props.to !== undefined ? idx.get(s.props.to) : undefined, text: String(s.props.text ?? ""), depth });
29
+ else if (s.type === "SeqGroup") {
30
+ if (depth >= 3) {
31
+ walk(s.props.steps, depth);
32
+ continue;
33
+ }
34
+ maxDepth = Math.max(maxDepth, depth + 1);
35
+ evs.push({ t: "open", kind: s.props.kind, label: String(s.props.label ?? ""), depth });
36
+ walk(s.props.steps, depth + 1);
37
+ if (s.props.elseSteps && s.props.elseSteps.length) {
38
+ evs.push({ t: "else", label: String(s.props.elseLabel ?? ""), depth });
39
+ walk(s.props.elseSteps, depth + 1);
40
+ }
41
+ evs.push({ t: "close", depth });
42
+ }
43
+ }
44
+ };
45
+ walk(el.props.steps ?? [], 0);
46
+
47
+ const layout = (cap: number, headCap: number) => {
48
+ const heads = parts.map((p) => {
49
+ const label = truncateText(String(p.props.label ?? ""), headCap);
50
+ return { label, icon: ICON[p.props.kind ?? "service"] ?? "◇", w: textWidth(label) + 6 };
51
+ });
52
+ const gap = heads.slice(1).map((h, i) => Math.ceil((heads[i].w + h.w) / 2) + 2);
53
+ const lw = (e: Extract<Ev, { t: "msg" }>) => textWidth(truncateText(e.label, cap)) + (numbered ? String(e.n).length + 2 : 0);
54
+ const spans = evs.filter((e): e is Extract<Ev, { t: "msg" }> => e.t === "msg" && e.from !== e.to).sort((a, b) => Math.abs(a.from - a.to) - Math.abs(b.from - b.to));
55
+ for (const m of spans) {
56
+ const lo = Math.min(m.from, m.to);
57
+ const hi = Math.max(m.from, m.to);
58
+ const need = lw(m) + 6;
59
+ let sum = 0;
60
+ for (let j = lo; j < hi; j++) sum += gap[j];
61
+ if (sum < need) gap[hi - 1] += need - sum;
62
+ }
63
+ let rightExtra = 0;
64
+ let leftExtra = 0;
65
+ for (const e of evs) {
66
+ if (e.t === "msg" && e.from === e.to) {
67
+ const need = lw(e) + 8;
68
+ if (e.from < parts.length - 1) gap[e.from] = Math.max(gap[e.from], need);
69
+ else rightExtra = Math.max(rightExtra, need);
70
+ } else if (e.t === "note") {
71
+ const nw = Math.min(30, textWidth(e.text) + 4);
72
+ const half = Math.ceil(nw / 2) + 2;
73
+ if (e.to === undefined || e.to === e.over) {
74
+ if (e.over > 0) gap[e.over - 1] = Math.max(gap[e.over - 1], half + Math.ceil(heads[e.over - 1].w / 2));
75
+ else leftExtra = Math.max(leftExtra, half);
76
+ if (e.over < parts.length - 1) gap[e.over] = Math.max(gap[e.over], half + Math.ceil(heads[e.over + 1].w / 2));
77
+ else rightExtra = Math.max(rightExtra, half);
78
+ }
79
+ }
80
+ }
81
+ const margin = 2 * maxDepth + 1;
82
+ const x: number[] = [];
83
+ x.push(margin + Math.max(Math.ceil(heads[0].w / 2), leftExtra));
84
+ for (let i = 1; i < parts.length; i++) x.push(x[i - 1] + gap[i - 1]);
85
+ const total = x[x.length - 1] + Math.max(Math.ceil(heads[heads.length - 1].w / 2), rightExtra) + margin + 1;
86
+ return { heads, x, total, margin };
87
+ };
88
+ let lay = layout(48, 22);
89
+ for (const [cap, hc] of [[36, 22], [28, 18], [20, 14], [14, 10], [10, 8]] as const) {
90
+ if (lay.total <= width) break;
91
+ lay = layout(cap, hc);
92
+ }
93
+ const cap = lay.total <= width ? 48 : 10;
94
+ void cap;
95
+ const { heads, x, total } = lay;
96
+ const msgCap = lay.total <= width ? 48 : 10;
97
+ // rows
98
+ const H0 = 3;
99
+ let y = H0;
100
+ const rowOf: number[] = [];
101
+ const noteLines: string[][] = [];
102
+ const noteBox: Array<{ x0: number; x1: number } | null> = [];
103
+ evs.forEach((e, i) => {
104
+ rowOf[i] = y;
105
+ if (e.t === "msg") y += e.from === e.to ? 2 : 2;
106
+ else if (e.t === "note") {
107
+ const dx = e.to !== undefined ? Math.abs(x[e.to] - x[e.over]) : 0;
108
+ const nw = e.to !== undefined && e.to !== e.over ? dx + 5 : Math.min(30, textWidth(e.text) + 4);
109
+ const x0 = e.to !== undefined && e.to !== e.over ? Math.min(x[e.over], x[e.to]) - 2 : x[e.over] - Math.floor(nw / 2);
110
+ noteBox[i] = { x0, x1: x0 + nw - 1 };
111
+ const lines = wrapText(e.text, Math.max(3, nw - 4)).map((r) => r.map((c) => c.ch).join(""));
112
+ noteLines[i] = lines;
113
+ y += lines.length + 2;
114
+ } else y += 1;
115
+ });
116
+ const lastRow = y;
117
+ const height = lastRow + 1;
118
+ const rows: Cell[][] = Array.from({ length: height }, () => Array.from({ length: total }, () => cell(" ")));
119
+ const put = (xx: number, yy: number, ch: string, role: string) => {
120
+ if (yy >= 0 && yy < height && xx >= 0 && xx < total) rows[yy][xx] = cell(ch, role);
121
+ };
122
+ const text = (xx: number, yy: number, s: string, role: string) => textCells(s, role).forEach((c, k) => put(xx + k, yy, c.ch, c.role));
123
+ // lifelines
124
+ x.forEach((xx) => {
125
+ for (let r = H0; r <= lastRow; r++) put(xx, r, "┆", "seq.life");
126
+ });
127
+ // activation bars
128
+ const stacks: number[][] = parts.map(() => []);
129
+ evs.forEach((e, i) => {
130
+ if (e.t !== "msg" || e.from === e.to) return;
131
+ const arrow = rowOf[i] + 1;
132
+ if (e.style === "sync") stacks[e.to].push(arrow);
133
+ else if (e.style === "return" && stacks[e.from].length) {
134
+ const s = stacks[e.from].pop()!;
135
+ for (let r = s; r <= arrow; r++) put(x[e.from], r, "┃", "seq.act");
136
+ }
137
+ });
138
+ stacks.forEach((st, p) => st.forEach((s) => { for (let r = s; r <= lastRow - 1; r++) put(x[p], r, "┃", "seq.act"); }));
139
+ // headers
140
+ const targets: Target[] = [];
141
+ parts.forEach((p, i) => {
142
+ const h = heads[i];
143
+ const x0 = x[i] - Math.floor(h.w / 2);
144
+ put(x0, 0, "╭", "seq.head");
145
+ put(x0 + h.w - 1, 0, "╮", "seq.head");
146
+ put(x0, 2, "╰", "seq.head");
147
+ put(x0 + h.w - 1, 2, "╯", "seq.head");
148
+ for (let k = 1; k < h.w - 1; k++) {
149
+ put(x0 + k, 0, "─", "seq.head");
150
+ put(x0 + k, 2, "─", "seq.head");
151
+ }
152
+ put(x0, 1, "│", "seq.head");
153
+ put(x0 + h.w - 1, 1, "│", "seq.head");
154
+ put(x[i], 2, "┬", "seq.head");
155
+ put(x0 + 2, 1, h.icon, "seq.icon");
156
+ text(x0 + 4, 1, h.label, "seq.title");
157
+ const rect = { x: x0, y: 0, w: h.w, h: 3 };
158
+ const id = `seq:${path}:${p.props.id}`;
159
+ targets.push({ id, kind: "node", rect, label: `${p.props.label} (${p.props.kind ?? "service"})` });
160
+ anchors.set(String(p.props.id), { rect, targetId: id });
161
+ });
162
+ // events
163
+ const frames: Array<{ open: number; close: number; depth: number; elseRow?: number }> = [];
164
+ const openStack: number[] = [];
165
+ evs.forEach((e, i) => {
166
+ const r = rowOf[i];
167
+ if (e.t === "msg") {
168
+ const label = (numbered ? `${e.n}. ` : "") + truncateText(e.label, msgCap);
169
+ if (e.from === e.to) {
170
+ const xx = x[e.from];
171
+ put(xx, r, "├", "seq.arrow");
172
+ put(xx + 1, r, "─", "seq.arrow");
173
+ put(xx + 2, r, "─", "seq.arrow");
174
+ put(xx + 3, r, "╮", "seq.arrow");
175
+ put(xx + 3, r + 1, "╯", "seq.arrow");
176
+ put(xx + 2, r + 1, "─", "seq.arrow");
177
+ put(xx + 1, r + 1, e.style === "async" ? "◁" : "◀", "seq.arrow");
178
+ text(xx + 5, r, label, "seq.label");
179
+ if (e.id) anchors.set(e.id, { rect: { x: xx, y: r, w: Math.min(total - xx, textWidth(label) + 6), h: 2 } });
180
+ return;
181
+ }
182
+ const a = x[e.from];
183
+ const b = x[e.to];
184
+ const lo = Math.min(a, b);
185
+ const hi = Math.max(a, b);
186
+ const dashed = e.style === "return";
187
+ const ay = r + 1;
188
+ for (let xx = lo + 1; xx < hi; xx++) put(xx, ay, dashed ? "┄" : "─", "seq.arrow");
189
+ put(a, ay, a < b ? "├" : "┤", "seq.arrow");
190
+ const open = e.style !== "sync";
191
+ put(a < b ? hi - 1 : lo + 1, ay, a < b ? (open ? "▷" : "▶") : open ? "◁" : "◀", "seq.arrow");
192
+ const span = hi - lo - 1;
193
+ const lt = truncateText(label, Math.max(1, span - 2));
194
+ const lx = lo + 1 + Math.max(0, Math.floor((span - textWidth(lt)) / 2));
195
+ text(lx, r, lt, "seq.label");
196
+ if (numbered) {
197
+ const nlen = String(e.n).length + 1;
198
+ for (let k = 0; k < nlen; k++) if (rows[r][lx + k]) rows[r][lx + k] = cell(rows[r][lx + k].ch, "seq.num");
199
+ }
200
+ if (e.id) anchors.set(e.id, { rect: { x: lo, y: r, w: hi - lo + 1, h: 2 } });
201
+ } else if (e.t === "note") {
202
+ const b = noteBox[i]!;
203
+ const lines = noteLines[i];
204
+ const nw = b.x1 - b.x0 + 1;
205
+ for (let k = 0; k < lines.length + 2; k++) for (let xx = 0; xx < nw; xx++) put(b.x0 + xx, r + k, " ", "seq.note");
206
+ put(b.x0, r, "┌", "seq.noteBorder");
207
+ put(b.x1, r, "┐", "seq.noteBorder");
208
+ put(b.x0, r + lines.length + 1, "└", "seq.noteBorder");
209
+ put(b.x1, r + lines.length + 1, "┘", "seq.noteBorder");
210
+ for (let xx = 1; xx < nw - 1; xx++) {
211
+ put(b.x0 + xx, r, "─", "seq.noteBorder");
212
+ put(b.x0 + xx, r + lines.length + 1, "─", "seq.noteBorder");
213
+ }
214
+ lines.forEach((ln, k) => {
215
+ put(b.x0, r + 1 + k, "│", "seq.noteBorder");
216
+ put(b.x1, r + 1 + k, "│", "seq.noteBorder");
217
+ text(b.x0 + 2, r + 1 + k, ln, "seq.note");
218
+ });
219
+ } else if (e.t === "open") {
220
+ openStack.push(frames.length);
221
+ frames.push({ open: r, close: r, depth: e.depth });
222
+ const f = frames[frames.length - 1];
223
+ const left = 2 * e.depth;
224
+ const right = total - 1 - 2 * e.depth;
225
+ put(left, r, "┌", "seq.group");
226
+ put(right, r, "┐", "seq.group");
227
+ for (let xx = left + 1; xx < right; xx++) put(xx, r, "─", "seq.group");
228
+ text(left + 1, r, ` ${e.kind} `, "seq.groupKind");
229
+ text(left + 2 + textWidth(e.kind) + 1, r, truncateText(e.label, Math.max(1, right - left - textWidth(e.kind) - 6)), "seq.groupLabel");
230
+ void f;
231
+ } else if (e.t === "else") {
232
+ const f = frames[openStack[openStack.length - 1]];
233
+ f.elseRow = r;
234
+ const left = 2 * e.depth;
235
+ const right = total - 1 - 2 * e.depth;
236
+ put(left, r, "├", "seq.group");
237
+ put(right, r, "┤", "seq.group");
238
+ for (let xx = left + 1; xx < right; xx++) put(xx, r, "╌", "seq.group");
239
+ if (e.label) text(left + 2, r, ` ${truncateText(e.label, right - left - 6)} `, "seq.groupLabel");
240
+ } else if (e.t === "close") {
241
+ const f = frames[openStack.pop()!];
242
+ f.close = r;
243
+ const left = 2 * e.depth;
244
+ const right = total - 1 - 2 * e.depth;
245
+ put(left, r, "└", "seq.group");
246
+ put(right, r, "┘", "seq.group");
247
+ for (let xx = left + 1; xx < right; xx++) put(xx, r, "─", "seq.group");
248
+ }
249
+ });
250
+ for (const f of frames) {
251
+ const left = 2 * f.depth;
252
+ const right = total - 1 - 2 * f.depth;
253
+ for (let r = f.open + 1; r < f.close; r++) {
254
+ if (r === f.elseRow) continue;
255
+ put(left, r, "│", "seq.group");
256
+ put(right, r, "│", "seq.group");
257
+ }
258
+ }
259
+ const title = el.props.title ? [textCells(truncateText(String(el.props.title), total), "chart.title")] : [];
260
+ const all = [...title, ...rows];
261
+ const off = title.length;
262
+ for (const t of targets) t.rect.y += off;
263
+ for (const [, a] of anchors) a.rect.y += off;
264
+ return { block: { w: total, rows: all.map((r) => (r.length === total ? r : r.concat(Array.from({ length: total - r.length }, () => cell(" "))))), targets }, anchors, overflow: total > width };
265
+ }
266
+ void fromRows;
@@ -0,0 +1,70 @@
1
+ import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
2
+ import type { Block } from "./types.ts";
3
+ import { cell, fromRows, padRow, textCells } from "./util.ts";
4
+
5
+ export interface TableAnchors {
6
+ rows: Rect[];
7
+ cols: Rect[];
8
+ cells: Rect[][];
9
+ }
10
+
11
+ const NUMERIC = /^[-+]?[\d.,]+\s*(%|ms|s|kb|mb|gb|tb|x)?$/i;
12
+
13
+ export function tableBlock(columns: string[], rows: Array<Array<string | number>>, width: number): { block: Block; anchors: TableAnchors } {
14
+ const n = Math.max(1, columns.length);
15
+ const str = (v: string | number) => String(v ?? "").replace(/\s+/g, " ").trim();
16
+ const data = rows.map((r) => columns.map((_, i) => str(r[i] ?? "")));
17
+ const heads = columns.map(str);
18
+ const numeric = heads.map((_, i) => data.length > 0 && data.every((r) => r[i] === "" || NUMERIC.test(r[i])));
19
+ const nat = heads.map((h, i) => Math.max(textWidth(h), ...data.map((r) => textWidth(r[i])), 1));
20
+ const avail = Math.max(n, width - (3 * n + 1));
21
+ const w = nat.slice();
22
+ let sum = w.reduce((a, b) => a + b, 0);
23
+ while (sum > avail) {
24
+ let k = 0;
25
+ for (let i = 1; i < n; i++) if (w[i] > w[k]) k = i;
26
+ if (w[k] <= 3) break;
27
+ w[k]--;
28
+ sum--;
29
+ }
30
+ const total = w.reduce((a, b) => a + b, 0) + 3 * n + 1;
31
+ const line = (l: string, m: string, r: string): Cell[] => {
32
+ const out: Cell[] = [cell(l, "table.border")];
33
+ w.forEach((cw, i) => {
34
+ for (let k = 0; k < cw + 2; k++) out.push(cell("─", "table.border"));
35
+ out.push(cell(i === n - 1 ? r : m, "table.border"));
36
+ });
37
+ return out;
38
+ };
39
+ const rowCells = (vals: string[], role: string, align: boolean[]): Cell[] => {
40
+ const out: Cell[] = [cell("│", "table.border")];
41
+ vals.forEach((v, i) => {
42
+ const t = truncateText(v, w[i]);
43
+ const tw = textWidth(t);
44
+ const pad = w[i] - tw;
45
+ out.push(cell(" "));
46
+ if (align[i]) for (let k = 0; k < pad; k++) out.push(cell(" "));
47
+ out.push(...textCells(t, role));
48
+ if (!align[i]) for (let k = 0; k < pad; k++) out.push(cell(" "));
49
+ out.push(cell(" "), cell("│", "table.border"));
50
+ });
51
+ return out;
52
+ };
53
+ const out: Cell[][] = [line("┌", "┬", "┐")];
54
+ out.push(rowCells(heads, "table.head", heads.map(() => false)));
55
+ out.push(line("├", "┼", "┤"));
56
+ data.forEach((r) => out.push(rowCells(r, "", numeric)));
57
+ out.push(line("└", "┴", "┘"));
58
+ const colX: number[] = [];
59
+ let x = 1;
60
+ w.forEach((cw) => {
61
+ colX.push(x + 1);
62
+ x += cw + 3;
63
+ });
64
+ const anchors: TableAnchors = {
65
+ cols: w.map((cw, i) => ({ x: colX[i], y: 1, w: cw, h: 1 })),
66
+ rows: data.map((_, ri) => ({ x: 0, y: 3 + ri, w: total, h: 1 })),
67
+ cells: data.map((_, ri) => w.map((cw, i) => ({ x: colX[i], y: 3 + ri, w: cw, h: 1 }))),
68
+ };
69
+ return { block: fromRows(total, out.map((r) => padRow(r, total))), anchors };
70
+ }
@@ -0,0 +1,126 @@
1
+ import type { Cell, Rect } from "../diagram/canvas.ts";
2
+ import type { El } from "../lang/library.ts";
3
+
4
+ export interface Tip {
5
+ hint?: string;
6
+ detail?: string;
7
+ code?: string;
8
+ language?: string;
9
+ tone?: string;
10
+ link?: string;
11
+ linkLabel?: string;
12
+ }
13
+
14
+ export type TargetKind =
15
+ | "tab"
16
+ | "button"
17
+ | "choice"
18
+ | "faq"
19
+ | "node"
20
+ | "copy"
21
+ | "tip"
22
+ | "image"
23
+ /** something a note can be attached to (see notes.ts); only reachable in notes mode */
24
+ | "item"
25
+ /** a row of the notes tray */
26
+ | "note"
27
+ | "noteadd"
28
+ | "noteclear"
29
+ /** the footer chip that turns notes mode on and off */
30
+ | "notes";
31
+
32
+ /** What a note says about the element it is attached to. All words are already cleaned for a message the user will send. */
33
+ export interface ItemInfo {
34
+ /** the element's path in the tree: items of one component share it */
35
+ host: string;
36
+ type: string;
37
+ /** the variable the component was defined under */
38
+ name?: string;
39
+ /** the tooltip vocabulary key ("row:3", "a>b"), or "" for the whole component */
40
+ key: string;
41
+ /** where inside the component, in words: `row 3, cell "CrashLoop"` */
42
+ where: string;
43
+ /** a short label for the hover line and the tray: `Table > row 3` */
44
+ short: string;
45
+ /** the tab it sits under */
46
+ tab?: string;
47
+ /** source line, to identify a component that has no variable */
48
+ line: number;
49
+ /** the item that contains this one (a table cell's row, a column's table), by target id */
50
+ parent?: string;
51
+ }
52
+
53
+ export interface Note {
54
+ id: number;
55
+ itemId: string;
56
+ info: ItemInfo;
57
+ text: string;
58
+ }
59
+
60
+ /** Something the user can click or reach with the keyboard. Rects are relative to the block that owns them. */
61
+ export interface Target {
62
+ id: string;
63
+ kind: TargetKind;
64
+ rect: Rect;
65
+ label: string;
66
+ tip?: Tip;
67
+ /** text sent to the assistant when activated */
68
+ send?: string;
69
+ openUrl?: string;
70
+ copy?: string;
71
+ /** extra cells (block coordinates) that belong to the target besides its rect, e.g. the wedge of a pie slice */
72
+ cells?: Array<[number, number]>;
73
+ /** for kind "item": what a note on it is about */
74
+ item?: ItemInfo;
75
+ /** for kind "note": the note this tray row shows */
76
+ noteId?: number;
77
+ /** for kind "image": where the picture goes and how big it is, in cells */
78
+ image?: { key: string; cols: number; rows: number };
79
+ /** a diagram that may be wider than its view: focusing this target should reveal it */
80
+ pan?: { key: string; x: number; w: number; view: number; total: number };
81
+ }
82
+
83
+ export interface Block {
84
+ w: number;
85
+ rows: Cell[][];
86
+ targets: Target[];
87
+ }
88
+
89
+ export interface WState {
90
+ tab: Record<string, number>;
91
+ faq: Record<string, boolean>;
92
+ picked: Record<string, string>;
93
+ pan: Record<string, number>;
94
+ tip: string | null;
95
+ focus: string | null;
96
+ hover: string | null;
97
+ copied: string | null;
98
+ /** the user's notes on this widget, in the order they were written */
99
+ notes: Note[];
100
+ /** notes mode: clicks and Enter write notes instead of acting */
101
+ noteMode: boolean;
102
+ noteSeq: number;
103
+ }
104
+
105
+ export const newState = (): WState => ({ tab: {}, faq: {}, picked: {}, pan: {}, tip: null, focus: null, hover: null, copied: null, notes: [], noteMode: false, noteSeq: 0 });
106
+
107
+ /** Supplied by the host when it can draw bitmaps. Without it, an Image is drawn as a labelled frame. */
108
+ export interface ImageService {
109
+ /** Load the image and reserve a size for it. Null when it cannot be drawn here (no protocol, unreadable, unsupported). */
110
+ place(src: string, maxCols: number, maxRows: number): { key: string; cols: number; rows: number } | null;
111
+ /** Human text for the frame when the picture itself cannot be shown, e.g. "800×600 png, 120 KB". */
112
+ describe(src: string): string;
113
+ }
114
+
115
+ export interface Services {
116
+ images?: ImageService;
117
+ }
118
+
119
+ export interface Ctx {
120
+ state: WState;
121
+ warnings: string[];
122
+ services: Services;
123
+ /** labels of the tabs the element being drawn sits under, outermost first */
124
+ tabs: string[];
125
+ render: (el: El, width: number, path: string) => Block;
126
+ }
@@ -0,0 +1,171 @@
1
+ // Layout helpers for blocks: rows of cells with a fixed width.
2
+ import { cell, EMPTY, gWidth, graphemes, textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
3
+ import type { Block, Target } from "./types.ts";
4
+
5
+ export { cell, EMPTY };
6
+
7
+ export function textCells(s: string, role = ""): Cell[] {
8
+ const out: Cell[] = [];
9
+ for (const g of graphemes(s)) {
10
+ const w = gWidth(g);
11
+ if (w === 0) continue;
12
+ out.push(cell(g, role));
13
+ if (w === 2) out.push(cell("", role));
14
+ }
15
+ return out;
16
+ }
17
+
18
+ export function padRow(row: Cell[], w: number): Cell[] {
19
+ if (row.length === w) return row;
20
+ if (row.length > w) {
21
+ const cut = row.slice(0, w);
22
+ if (cut.length && cut[cut.length - 1].ch !== "" && w < row.length && row[w].ch === "") cut[cut.length - 1] = EMPTY;
23
+ return cut;
24
+ }
25
+ return row.concat(Array.from({ length: w - row.length }, () => EMPTY));
26
+ }
27
+
28
+ export const blank = (w: number, h: number): Block => ({ w, rows: Array.from({ length: h }, () => Array.from({ length: w }, () => EMPTY)), targets: [] });
29
+
30
+ export function fromRows(w: number, rows: Cell[][], targets: Target[] = []): Block {
31
+ return { w, rows: rows.map((r) => padRow(r, w)), targets };
32
+ }
33
+
34
+ export interface Run {
35
+ t: string;
36
+ role: string;
37
+ }
38
+
39
+ /** Word-wrap styled runs to `width` columns. Explicit "\n" starts a new line. */
40
+ export function wrapRuns(runs: Run[], width: number): Cell[][] {
41
+ width = Math.max(1, width);
42
+ const lines: Cell[][] = [[]];
43
+ let col = 0;
44
+ const cur = () => lines[lines.length - 1];
45
+ const newline = () => {
46
+ lines.push([]);
47
+ col = 0;
48
+ };
49
+ for (const run of runs) {
50
+ const parts = run.t.split("\n");
51
+ parts.forEach((part, pi) => {
52
+ if (pi > 0) newline();
53
+ // split into words and spaces
54
+ for (const tok of part.match(/\s+|\S+/g) ?? []) {
55
+ const isSpace = /^\s+$/.test(tok);
56
+ const tw = textWidth(tok);
57
+ if (isSpace) {
58
+ if (col === 0 || col + tw > width) continue; // no leading spaces, no trailing overflow
59
+ cur().push(...textCells(tok, run.role));
60
+ col += tw;
61
+ continue;
62
+ }
63
+ if (col + tw > width && col > 0) {
64
+ // trim one trailing space
65
+ while (cur().length && cur()[cur().length - 1].ch === " ") cur().pop();
66
+ newline();
67
+ }
68
+ if (tw <= width) {
69
+ cur().push(...textCells(tok, run.role));
70
+ col += tw;
71
+ } else {
72
+ // break a long word
73
+ for (const g of graphemes(tok)) {
74
+ const gw = gWidth(g);
75
+ if (col + gw > width) newline();
76
+ cur().push(...textCells(g, run.role));
77
+ col += gw;
78
+ }
79
+ }
80
+ }
81
+ });
82
+ }
83
+ while (lines.length > 1 && lines[lines.length - 1].length === 0) lines.pop();
84
+ for (const l of lines) while (l.length && l[l.length - 1].ch === " ") l.pop();
85
+ return lines;
86
+ }
87
+
88
+ export const wrapText = (s: string, width: number, role = ""): Cell[][] => wrapRuns([{ t: s, role }], width);
89
+
90
+ export function shiftTargets(ts: Target[], dx: number, dy: number): Target[] {
91
+ return ts.map((t) => ({ ...t, rect: { ...t.rect, x: t.rect.x + dx, y: t.rect.y + dy }, ...(t.cells ? { cells: t.cells.map(([x, y]) => [x + dx, y + dy] as [number, number]) } : {}) }));
92
+ }
93
+
94
+ export function vstack(blocks: Block[], gap: number, w?: number): Block {
95
+ const width = w ?? Math.max(0, ...blocks.map((b) => b.w));
96
+ const rows: Cell[][] = [];
97
+ const targets: Target[] = [];
98
+ blocks.forEach((b, i) => {
99
+ if (!b.rows.length) return;
100
+ if (rows.length && gap > 0) for (let g = 0; g < gap; g++) rows.push(Array.from({ length: width }, () => EMPTY));
101
+ targets.push(...shiftTargets(b.targets, 0, rows.length));
102
+ for (const r of b.rows) rows.push(padRow(r, width));
103
+ void i;
104
+ });
105
+ return { w: width, rows, targets };
106
+ }
107
+
108
+ export function hstack(blocks: Block[], gap: number): Block {
109
+ const h = Math.max(0, ...blocks.map((b) => b.rows.length));
110
+ const w = blocks.reduce((a, b, i) => a + b.w + (i ? gap : 0), 0);
111
+ const rows: Cell[][] = Array.from({ length: h }, () => []);
112
+ const targets: Target[] = [];
113
+ let x = 0;
114
+ blocks.forEach((b, i) => {
115
+ if (i) for (const r of rows) for (let g = 0; g < gap; g++) r.push(EMPTY);
116
+ for (let y = 0; y < h; y++) rows[y].push(...(b.rows[y] ?? Array.from({ length: b.w }, () => EMPTY)));
117
+ targets.push(...shiftTargets(b.targets, x, 0));
118
+ x += b.w + gap;
119
+ });
120
+ return { w, rows, targets };
121
+ }
122
+
123
+ export interface BoxOpts {
124
+ title?: string;
125
+ role: string;
126
+ titleRole?: string;
127
+ /** text drawn in the top border on the right */
128
+ tag?: string;
129
+ tagRole?: string;
130
+ padX?: number;
131
+ dashed?: boolean;
132
+ }
133
+
134
+ /** Wrap a block in a rounded box. `inner` is placed with padX columns of padding. Returns the boxed block. */
135
+ export function boxed(inner: Block, opts: BoxOpts): Block {
136
+ const padX = opts.padX ?? 1;
137
+ const w = inner.w + 2 + 2 * padX;
138
+ const role = opts.role;
139
+ const hz = opts.dashed ? "╌" : "─";
140
+ const vt = opts.dashed ? "╎" : "│";
141
+ const top: Cell[] = [cell("╭", role)];
142
+ for (let i = 0; i < w - 2; i++) top.push(cell(hz, role));
143
+ top.push(cell("╮", role));
144
+ if (opts.title) {
145
+ const t = truncateText(opts.title, Math.max(1, w - 6 - (opts.tag ? textWidth(opts.tag) + 2 : 0)));
146
+ const cells = textCells(` ${t} `, opts.titleRole ?? role);
147
+ for (let i = 0; i < cells.length && 2 + i < w - 1; i++) top[2 + i] = cells[i];
148
+ }
149
+ if (opts.tag) {
150
+ const cells = textCells(` ${opts.tag} `, opts.tagRole ?? role);
151
+ const start = w - 2 - cells.length;
152
+ for (let i = 0; i < cells.length; i++) if (start + i > 1) top[start + i] = cells[i];
153
+ }
154
+ const rows: Cell[][] = [top];
155
+ for (const r of inner.rows) rows.push([cell(vt, role), ...Array.from({ length: padX }, () => EMPTY), ...padRow(r, inner.w), ...Array.from({ length: padX }, () => EMPTY), cell(vt, role)]);
156
+ rows.push([cell("╰", role), ...Array.from({ length: w - 2 }, () => cell(hz, role)), cell("╯", role)]);
157
+ return { w, rows, targets: shiftTargets(inner.targets, 1 + padX, 1) };
158
+ }
159
+
160
+ export const rectOf = (x: number, y: number, w: number, h: number): Rect => ({ x, y, w, h });
161
+
162
+ export function textBlock(s: string, width: number, role = ""): Block {
163
+ const lines = wrapText(s, width, role);
164
+ return fromRows(width, lines);
165
+ }
166
+
167
+ export function lineOf(parts: Array<[string, string]>, w: number): Cell[] {
168
+ const out: Cell[] = [];
169
+ for (const [t, r] of parts) out.push(...textCells(t, r));
170
+ return padRow(out, w);
171
+ }