pi-openui 0.2.0 → 0.3.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/CHANGELOG.md +59 -0
- package/README.md +40 -1
- package/package.json +3 -2
- package/src/blocks/code.ts +16 -3
- package/src/blocks/core.ts +78 -27
- package/src/blocks/diagrams.ts +8 -26
- package/src/blocks/fallback.ts +25 -0
- package/src/blocks/gesture.ts +112 -0
- package/src/blocks/interact.ts +55 -3
- package/src/blocks/markdown.ts +13 -3
- package/src/blocks/pan.ts +139 -0
- package/src/blocks/panblock.ts +44 -0
- package/src/blocks/render.ts +25 -17
- package/src/blocks/sequence.ts +4 -4
- package/src/blocks/table.ts +122 -14
- package/src/blocks/types.ts +14 -2
- package/src/blocks/util.ts +5 -0
- package/src/diagram/dag.ts +4 -3
- package/src/diagram/erd.ts +4 -2
- package/src/diagram/infra.ts +3 -2
- package/src/lang/docs.ts +38 -14
- package/src/lang/library.ts +122 -19
- package/src/lang/samples.ts +134 -0
- package/src/pi/config.ts +185 -0
- package/src/pi/events.ts +150 -0
- package/src/pi/extension.ts +63 -18
- package/src/pi/widget.ts +67 -14
package/src/blocks/util.ts
CHANGED
|
@@ -133,6 +133,11 @@ export interface BoxOpts {
|
|
|
133
133
|
|
|
134
134
|
/** Wrap a block in a rounded box. `inner` is placed with padX columns of padding. Returns the boxed block. */
|
|
135
135
|
export function boxed(inner: Block, opts: BoxOpts): Block {
|
|
136
|
+
// A title that does not fit its border is not cut: it moves inside as a wrapped heading, above the content.
|
|
137
|
+
if (opts.title && textWidth(opts.title) > Math.max(1, inner.w + 2 * (opts.padX ?? 1) - 4 - (opts.tag ? textWidth(opts.tag) + 2 : 0))) {
|
|
138
|
+
const head = wrapText(opts.title, Math.max(1, inner.w), opts.titleRole ?? opts.role).map((r) => padRow(r, inner.w));
|
|
139
|
+
return boxed({ w: inner.w, rows: [...head, ...inner.rows], targets: shiftTargets(inner.targets, 0, head.length) }, { ...opts, title: undefined });
|
|
140
|
+
}
|
|
136
141
|
const padX = opts.padX ?? 1;
|
|
137
142
|
const w = inner.w + 2 + 2 * padX;
|
|
138
143
|
const role = opts.role;
|
package/src/diagram/dag.ts
CHANGED
|
@@ -129,7 +129,8 @@ function attempt(norm: Norm, dir: "LR" | "TB", cap: number): DiagramResult {
|
|
|
129
129
|
return layeredDiagram("dag", { nodes, edges, dashReversed: true }, dir);
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
|
|
132
|
+
/** How far labels shrink to fit the pane. Below 14 characters a label is no longer a name, so past this the graph scrolls with whole labels. */
|
|
133
|
+
const CAPS = [28, 20, 14];
|
|
133
134
|
|
|
134
135
|
export function dag(nodesIn: unknown, edgesIn: unknown, opts: DagOptions = {}): DiagramResult {
|
|
135
136
|
return budgeted(() => dagLayout(nodesIn, edgesIn, opts));
|
|
@@ -159,8 +160,8 @@ function dagLayout(nodesIn: unknown, edgesIn: unknown, opts: DagOptions): Diagra
|
|
|
159
160
|
if (longest <= cap) break; // shrinking more changes nothing
|
|
160
161
|
}
|
|
161
162
|
if (!chosen) {
|
|
162
|
-
// nothing fits even with short labels: the view will
|
|
163
|
-
const cap =
|
|
163
|
+
// nothing fits even with short labels: the view will scroll, so scroll to whole labels instead of cutting them as well
|
|
164
|
+
const cap = maxLabel;
|
|
164
165
|
const tries = dirs.map((d) => attempt(norm, d, cap));
|
|
165
166
|
best = tries.reduce((a, b) => (b.width < a.width ? b : a));
|
|
166
167
|
}
|
package/src/diagram/erd.ts
CHANGED
|
@@ -146,7 +146,8 @@ function tableSpec(e: NormErd["entities"][number], fieldCap: number, selfRef: bo
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
/** How far an entity's lines shrink to fit the pane. Below 30 characters the types and flags are dropped, so past this the diagram scrolls. */
|
|
150
|
+
const CAPS = [60, 40, 30];
|
|
150
151
|
|
|
151
152
|
export function erd(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions = {}): DiagramResult {
|
|
152
153
|
return budgeted(() => erdLayout(entitiesIn, relationsIn, opts));
|
|
@@ -178,7 +179,8 @@ function erdLayout(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions):
|
|
|
178
179
|
if (chosen) break;
|
|
179
180
|
}
|
|
180
181
|
if (!chosen) {
|
|
181
|
-
|
|
182
|
+
// it will scroll whatever the fields: keep them whole (the widest cap) rather than cut them as well
|
|
183
|
+
const nodes = norm.entities.map((e, i) => tableSpec(e, CAPS[0], norm.selfRefs.has(i)));
|
|
182
184
|
const tries = dirs.map((d) => layeredDiagram("erd", { nodes, edges, portSpacing: 4 }, d));
|
|
183
185
|
best = tries.reduce((a, b) => (b.width < a.width ? b : a));
|
|
184
186
|
}
|
package/src/diagram/infra.ts
CHANGED
|
@@ -402,11 +402,11 @@ function build(norm: NormInfra, dir: "LR" | "TB", caps: Caps): DiagramResult {
|
|
|
402
402
|
};
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
/** How far labels shrink to fit the pane. Past the last row the map scrolls with whole labels, not 8-character ones. */
|
|
405
406
|
const CAPS: Caps[] = [
|
|
406
407
|
{ label: 22, detail: 26, link: 18 },
|
|
407
408
|
{ label: 16, detail: 18, link: 12 },
|
|
408
409
|
{ label: 12, detail: 12, link: 9 },
|
|
409
|
-
{ label: 8, detail: 8, link: 6 },
|
|
410
410
|
];
|
|
411
411
|
|
|
412
412
|
export function infra(zonesIn: unknown, linksIn: unknown, opts: InfraOptions = {}): DiagramResult {
|
|
@@ -434,7 +434,8 @@ function infraLayout(zonesIn: unknown, linksIn: unknown, opts: InfraOptions): Di
|
|
|
434
434
|
if (chosen) break;
|
|
435
435
|
}
|
|
436
436
|
if (!chosen) {
|
|
437
|
-
|
|
437
|
+
// it will scroll whatever the labels: use the whole ones (the first, widest caps) rather than cut them as well
|
|
438
|
+
const tries = dirs.map((d) => build(norm, d, CAPS[0]));
|
|
438
439
|
best = tries.reduce((a, b) => (b.width < a.width ? b : a));
|
|
439
440
|
}
|
|
440
441
|
const res = chosen ?? best!;
|
package/src/lang/docs.ts
CHANGED
|
@@ -29,7 +29,10 @@ export const DOCS: Record<string, ComponentDoc> = {
|
|
|
29
29
|
Stack: {
|
|
30
30
|
group: "Layout and text",
|
|
31
31
|
summary: "Lays its children out in a column (the default) or a row. Every widget starts with `root = Stack([...])`.",
|
|
32
|
-
shots: [
|
|
32
|
+
shots: [
|
|
33
|
+
{ file: "stack", caption: "A row of tiles, then a row of buttons that wraps when it runs out of width" },
|
|
34
|
+
{ file: "tiles-wrap", caption: "On a phone-width pane (52 columns) the same idea: tiles and buttons wrap onto more rows, every label whole" },
|
|
35
|
+
],
|
|
33
36
|
use: ["The root of every widget.", "A row of metric tiles or buttons.", "Grouping a few components with a chosen gap."],
|
|
34
37
|
avoid: "Do not put a Stack inside a Stack just to add space: use `gap`.",
|
|
35
38
|
args: {
|
|
@@ -46,7 +49,7 @@ b = Metric("Middle", "2")
|
|
|
46
49
|
c = Metric("Right", "3")`,
|
|
47
50
|
tips: [
|
|
48
51
|
"In a row, buttons, metric tiles and short text keep their natural width; every other child shares what is left equally.",
|
|
49
|
-
"A row that
|
|
52
|
+
"A row of buttons, tiles and short text that does not fit the terminal wraps onto more rows instead of squeezing them. A row with a child that shares the space (a Card, a Table) keeps its children side by side and shrinks the widest first, never below 6 columns.",
|
|
50
53
|
"Inside Tabs, wrap a tab's content in one Stack: items placed directly in a tab touch each other.",
|
|
51
54
|
],
|
|
52
55
|
related: ["Card", "Tabs", "Text"],
|
|
@@ -71,9 +74,12 @@ t3 = Text("Updated 2 minutes ago", "muted")`,
|
|
|
71
74
|
Card: {
|
|
72
75
|
group: "Layout and text",
|
|
73
76
|
summary: "A bordered panel with an optional title in the top border.",
|
|
74
|
-
shots: [
|
|
77
|
+
shots: [
|
|
78
|
+
{ file: "card", caption: "A card with a nested card" },
|
|
79
|
+
{ file: "card-wide", caption: "A title too long for the border moves inside as a heading; a callout breaks a long word instead of cutting it" },
|
|
80
|
+
],
|
|
75
81
|
use: ["Giving a chart or table a heading and a frame.", "Separating sections inside one widget."],
|
|
76
|
-
args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border.
|
|
82
|
+
args: { "Card.children": "The components inside. They are laid out as a column with the default gap.", "Card.title": "Shown in the top border. If it does not fit there it moves inside the card as a wrapped heading." },
|
|
77
83
|
example: `root = Stack([c])
|
|
78
84
|
c = Card([chart], "Load by node")
|
|
79
85
|
chart = BarChart(["a", "b", "c"], [3, 5, 2])`,
|
|
@@ -118,6 +124,9 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
|
|
|
118
124
|
{ file: "table", caption: "Numbers right-aligned, long text truncated with an ellipsis" },
|
|
119
125
|
{ file: "table-tooltip", caption: "Hovering the row with a tooltip shows its hint in a bubble" },
|
|
120
126
|
{ file: "table-panel", caption: "Clicking it, or pressing Enter, opens the detail panel" },
|
|
127
|
+
{ file: "table-cut", caption: "On a narrow pane a few columns fit by squeezing: the cells that were cut end in an ellipsis" },
|
|
128
|
+
{ file: "table-read-full", caption: "Tapping a cut cell (or Tab to it and Enter) opens it whole under the table, as plain text" },
|
|
129
|
+
{ file: "pan-table", caption: "Many columns do not fit: the table scrolls under a bar, and the columns the model pinned (Host and State here) stay in place" },
|
|
121
130
|
],
|
|
122
131
|
use: ["Comparing a handful of items across several fields.", "A list of results with a status column."],
|
|
123
132
|
avoid: "More than about 12 rows: summarise, or split across Tabs.",
|
|
@@ -125,14 +134,17 @@ b = Metric("p95 latency", "180 ms", "+22 ms", "warn")`,
|
|
|
125
134
|
"Table.columns": "The header strings.",
|
|
126
135
|
"Table.rows": "An array of rows. Each row has exactly one cell per column, a string or a number. A mismatch is a validation error that names the row.",
|
|
127
136
|
"Table.tooltips": "Targets: `row:3` (1-based, not counting the header), `col:status`, or `cell:3.status`. Duplicate header names make `col:` and `cell:` ambiguous.",
|
|
137
|
+
"Table.pin": "The 4th argument: header names of the columns that stay in place while a table wider than the terminal scrolls sideways, such as `[\"Host\"]` (write `null` for tooltips you do not have: `Table(cols, rows, null, [\"Host\"])`). Left out, nothing is pinned and the whole table scrolls. Pin only the one or two narrow columns that identify each row: pinned columns take at most half the width, and one that would not fit is not pinned (the table then scrolls whole). A name that is not a column is a validation error that lists the columns. On a table that fits, nothing changes.",
|
|
128
138
|
},
|
|
129
139
|
example: `root = Stack([t])
|
|
130
140
|
t = Table(["Node", "State", "GPU %"], [["spark-1", "serving", 91], ["spark-2", "draining", 84]], [tip])
|
|
131
141
|
tip = Tooltip("row:2", "Rebooting for a driver update")`,
|
|
132
|
-
interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel."],
|
|
142
|
+
interaction: ["A tooltip target shows a `◆` in the cell padding after its text.", "Hover, or Tab to the row, for the bubble; click or Enter for the panel.", "A cell that was cut to fit ends in `…`. Click it, or Tab to it and press Enter, to open it whole under the table as plain text; click it again or press Esc to close it. A cut cell that also has a tooltip shows the whole text in the tooltip's panel."],
|
|
133
143
|
tips: [
|
|
134
|
-
"When the table is wider than the terminal the widest columns shrink first,
|
|
135
|
-
"A table that still does not fit becomes a
|
|
144
|
+
"When the table is wider than the terminal the widest columns shrink first, but never below the minimum column width (12 characters unless the user changes it: see Settings in the README).",
|
|
145
|
+
"A table that still does not fit becomes a window that scrolls sideways under a pan bar. Nothing is pinned unless you list it in `pin`: pin the one or two narrow columns that identify each row (a host, an id, a date) when a row is hard to read without them, and leave `pin` out otherwise. Pinned columns never take more than half the width: they are tried in the order you list them, and one that would not fit is skipped (never squeezed), so a long column is not pinned by accident and nothing in the table is ever out of reach.",
|
|
146
|
+
"While a wide table scrolls, its pinned columns are drawn first, in the order they have in the table, then the others. A table that fits is drawn in the order you wrote.",
|
|
147
|
+
"You cannot see the width of the user's terminal, so do not shorten text to make a table fit: a column is squeezed only to the user's minimum width (and a cell cut by that squeeze ends in an ellipsis; the reader can tap it to read it whole); a table that needs more than that scrolls instead, and then no cell is cut.",
|
|
136
148
|
],
|
|
137
149
|
related: ["Markdown", "Metric", "Tooltip"],
|
|
138
150
|
},
|
|
@@ -143,12 +155,13 @@ tip = Tooltip("row:2", "Rebooting for a driver update")`,
|
|
|
143
155
|
shots: [
|
|
144
156
|
{ file: "tabs", caption: "The first tab is open when the widget is drawn" },
|
|
145
157
|
{ file: "tabs-nodes", caption: "After clicking Nodes" },
|
|
158
|
+
{ file: "tabs-wrap", caption: "On a narrow pane (44 columns) a strip that does not fit wraps onto more rows; every label stays whole and tappable" },
|
|
146
159
|
],
|
|
147
160
|
use: ["Alternate views of one subject: overview, nodes, notes.", "The same snippet in several languages."],
|
|
148
161
|
avoid: "Steps that must be read in order, or content the reader must see at once: a tab that is not selected is hidden.",
|
|
149
162
|
args: {
|
|
150
163
|
"Tabs.items": "One TabItem per tab. The first is open at the start.",
|
|
151
|
-
"TabItem.label": "The text in the strip.
|
|
164
|
+
"TabItem.label": "The text in the strip. When the labels do not fit the terminal the strip wraps onto more rows; a label wider than the whole terminal is the only one that is cut.",
|
|
152
165
|
"TabItem.content": "The components in the tab. Wrap them in a single Stack: `[panel]`.",
|
|
153
166
|
},
|
|
154
167
|
example: `root = Stack([tabs])
|
|
@@ -255,6 +268,7 @@ b = FaqItem("How do I undo it?", "Run \`uncordon\`.")`,
|
|
|
255
268
|
shots: [
|
|
256
269
|
{ file: "markdown", caption: "Headings, emphasis, lists, a quote, a table, a rule and a link" },
|
|
257
270
|
{ file: "markdown-image", caption: "A picture inside the text, written as a line of its own, with its caption underneath" },
|
|
271
|
+
{ file: "markdown-wide", caption: "At 60 columns a code fence and a table that are wider than the pane scroll on their own, each under its own bar" },
|
|
258
272
|
],
|
|
259
273
|
use: ["Release notes, explanations, anything with structure.", "A README-style view: text with pictures in it."],
|
|
260
274
|
args: { "Markdown.text": "Markdown. Use `\\n` for newlines. Supported: `#`-`######` headings, **bold**, *italic*, `code`, ~~strike~~, `[text](https://example.com)`, lists (nested), `>` quotes, tables, fenced code, `---`, and a picture: a line that is only ``." },
|
|
@@ -270,7 +284,10 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
|
|
|
270
284
|
CodeBlock: {
|
|
271
285
|
group: "Rich content",
|
|
272
286
|
summary: "Syntax-highlighted code in a frame, with a copy button.",
|
|
273
|
-
shots: [
|
|
287
|
+
shots: [
|
|
288
|
+
{ file: "code", caption: "Highlighting for JavaScript, SQL, YAML, JSON, bash, Go and Rust" },
|
|
289
|
+
{ file: "code-pan", caption: "A line wider than the pane is not cut: the code scrolls inside its box, under a bar, and [copy] still copies all of it" },
|
|
290
|
+
],
|
|
274
291
|
use: ["Commands to run, config to paste, a query."],
|
|
275
292
|
args: {
|
|
276
293
|
"CodeBlock.code": "The source. Use `\\n` for new lines. Tabs become two spaces; more than 200 lines are cut.",
|
|
@@ -280,7 +297,7 @@ m = Markdown("# Notes\\n\\nThis adds **streaming** and \`--fast\` mode.\\n\\n- f
|
|
|
280
297
|
example: `root = Stack([c])
|
|
281
298
|
c = CodeBlock("kubectl rollout restart deploy/api\\nkubectl rollout status deploy/api", "bash", "restart.sh")`,
|
|
282
299
|
interaction: ["Click `[copy]` (or Tab to it and press Enter) to put the exact code on the clipboard with an OSC 52 escape. The label changes to `✓ copied` for two seconds."],
|
|
283
|
-
tips: ["Long lines are
|
|
300
|
+
tips: ["Long lines are not wrapped (the code stays aligned) and not cut: when a line is wider than the box the code scrolls sideways inside it, under a pan bar. Lines are cut at 240 columns. The copy has every line in full.", "The clipboard needs a terminal that accepts OSC 52 (most modern ones do)."],
|
|
284
301
|
related: ["Markdown"],
|
|
285
302
|
},
|
|
286
303
|
Image: {
|
|
@@ -458,6 +475,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
|
|
|
458
475
|
shots: [
|
|
459
476
|
{ file: "dag", caption: "A CI run with every status: done, failed, running, skipped and pending" },
|
|
460
477
|
{ file: "dag-tooltip", caption: "A focused node: bold accent border, hint bubble, and the tooltip panel below" },
|
|
478
|
+
{ file: "dag-wide", caption: "Long step names on a 60-column pane: the labels shrink a little, then the graph scrolls with whole labels" },
|
|
461
479
|
],
|
|
462
480
|
use: ["A pipeline where each step has a state.", "A fan-out and fan-in.", "Which job waits for which."],
|
|
463
481
|
avoid: "Database tables (Erd) or a plain list of steps (Table).",
|
|
@@ -468,7 +486,7 @@ b = GeoPoint("Frankfurt", 50.11, 8.68, "warn")`,
|
|
|
468
486
|
"Dag.title": "A bold line above the graph.",
|
|
469
487
|
"Dag.tooltips": "Target: a node id.",
|
|
470
488
|
"DagNode.id": "Unique in the graph. Edges refer to it.",
|
|
471
|
-
"DagNode.label": "The text in the box. Shortened with
|
|
489
|
+
"DagNode.label": "The text in the box. Shortened to 14 characters at the least to fit the terminal; if it still does not fit, the graph scrolls sideways with whole labels (28 characters at most).",
|
|
472
490
|
"DagNode.status": "`done` ✓, `running` ◔, `failed` ✗, `pending` ○ or `skipped` ⊘ (also a dashed box). Omit it for a plain box. A legend names the statuses in use.",
|
|
473
491
|
},
|
|
474
492
|
example: `root = Stack([flow])
|
|
@@ -480,7 +498,7 @@ c = DagNode("deploy", "Deploy", "pending")`,
|
|
|
480
498
|
tips: [
|
|
481
499
|
"A cycle is allowed: the edge that closes it is drawn dashed, pointing back.",
|
|
482
500
|
"Every edge has its own wire, wires cross only at right angles (`┼`), and the layout is checked by decoding the picture back into edges. An edge that truly cannot be routed is left out and listed under the graph as `a -> b (not drawn)`.",
|
|
483
|
-
"Wider than the terminal: labels shrink first, then the direction switches, then the view scrolls sideways.",
|
|
501
|
+
"Wider than the terminal: labels shrink first (never below 14 characters), then the direction switches, then the view scrolls sideways with whole labels.",
|
|
484
502
|
],
|
|
485
503
|
related: ["Erd", "InfraMap", "SequenceDiagram"],
|
|
486
504
|
},
|
|
@@ -520,7 +538,10 @@ r = Relation("users", "projects", "1:N", "owns")`,
|
|
|
520
538
|
group: "Diagrams and maps",
|
|
521
539
|
summary: "Zones holding nodes (servers, databases, queues), with labelled arrows showing which talks to which.",
|
|
522
540
|
helpers: ["InfraZone", "InfraNode", "InfraLink"],
|
|
523
|
-
shots: [
|
|
541
|
+
shots: [
|
|
542
|
+
{ file: "infra", caption: "Three zones, with a degraded worker and a dashed link" },
|
|
543
|
+
{ file: "infra-wide", caption: "A four-zone map on a 60-column pane: whole labels, the first zone in view, the rest a swipe or a tap on » away" },
|
|
544
|
+
],
|
|
524
545
|
use: ["How a system fits together.", "Where a fault is: nodes carry a status."],
|
|
525
546
|
avoid: "More than about 4 zones and 12 nodes: split it in two.",
|
|
526
547
|
args: {
|
|
@@ -556,7 +577,10 @@ l = InfraLink("lb", "api", "HTTP")`,
|
|
|
556
577
|
group: "Diagrams and maps",
|
|
557
578
|
summary: "Calls between participants: columns left to right, steps top to bottom, with notes and alt/loop frames.",
|
|
558
579
|
helpers: ["SeqParticipant", "SeqMessage", "SeqNote", "SeqGroup"],
|
|
559
|
-
shots: [
|
|
580
|
+
shots: [
|
|
581
|
+
{ file: "sequence", caption: "An alt/else frame, a note, a self-call and activation bars" },
|
|
582
|
+
{ file: "sequence-wide", caption: "Five participants and long labels on a 60-column pane: it scrolls, labels whole" },
|
|
583
|
+
],
|
|
560
584
|
use: ["The order of calls in a request.", "Who calls whom, and what comes back."],
|
|
561
585
|
avoid: "More than about 5 participants or 20 steps: split it.",
|
|
562
586
|
args: {
|
package/src/lang/library.ts
CHANGED
|
@@ -104,7 +104,7 @@ export const SPECS: CompSpec[] = [
|
|
|
104
104
|
{ name: "Text", params: [p("text", S, false, LIMITS.text), p("variant", e("title", "heading", "body", "muted", "code"), true)], desc: "A line or paragraph of text (default body)." },
|
|
105
105
|
{ name: "Card", params: [p("children", kids), p("title", S, true)], desc: "Bordered panel with an optional title." },
|
|
106
106
|
{ name: "Metric", params: [p("label", S), p("value", S), p("delta", S, true), p("tone", e("good", "warn", "bad", "neutral"), true), p("tooltips", tips, true)], desc: "KPI tile. value and delta are strings. tone colours the delta." },
|
|
107
|
-
{ name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers)." },
|
|
107
|
+
{ name: "Table", params: [p("columns", { arr: S, max: LIMITS.tableCols }), p("rows", { arr: { arr: "cell", max: LIMITS.tableCols }, max: LIMITS.tableRows }), p("tooltips", tips, true), p("pin", { arr: S, max: LIMITS.tableCols }, true)], desc: "Data table. rows is an array of rows, each the same length as columns (strings or numbers). pin (4th argument, a list of header names) keeps those columns in place while a table wider than the reader's screen scrolls sideways. Nothing is pinned unless you list it: leave pin out when the rows are readable without a fixed column. Pin only the one or two narrow columns that identify each row (a name, an id). Pinned columns take at most half the width, and one that would not fit is not pinned: the table then just scrolls whole." },
|
|
108
108
|
{ name: "BarChart", params: [p("labels", { arr: S, max: LIMITS.bars }), p("values", { arr: "number", max: LIMITS.bars }), p("title", S, true), p("unit", S, true), p("tooltips", tips, true)], desc: "Bar chart of one series. labels and values are parallel arrays. unit is a suffix such as %." },
|
|
109
109
|
{ name: "Callout", params: [p("text", S), p("tone", e("info", "success", "warn", "error"), true)], desc: "Highlighted note." },
|
|
110
110
|
{ name: "Button", params: [p("label", S), p("action", "action", true)], desc: "Clickable button. Without an action it sends its label to the assistant." },
|
|
@@ -221,9 +221,71 @@ export interface Compiled {
|
|
|
221
221
|
ok: boolean;
|
|
222
222
|
errors: string[];
|
|
223
223
|
warnings: string[];
|
|
224
|
+
/** what `restore` mode changed to make an older source draw (values cut to a limit, arguments ignored, ...). Always empty in strict mode. */
|
|
225
|
+
notes: string[];
|
|
224
226
|
root?: El;
|
|
225
227
|
}
|
|
226
228
|
|
|
229
|
+
/**
|
|
230
|
+
* The version of OpenUI Lang this code reads. A widget written by an earlier version of the language is version 1 or whatever was
|
|
231
|
+
* stored with it. Bump it when a change would stop an older source from compiling (a removed or renamed component or argument, a
|
|
232
|
+
* positional argument inserted, a required one added, a lower limit), and add an alias or a migration below. AGENTS.md has the rule.
|
|
233
|
+
*/
|
|
234
|
+
export const LANG_VERSION = 1;
|
|
235
|
+
|
|
236
|
+
/** An old component name and what it is called now. Applied only when an old source is read again (`restore`), never to what the model writes. */
|
|
237
|
+
export const ALIASES: Record<string, string> = {};
|
|
238
|
+
|
|
239
|
+
/** Rewrites a source written under version `from` into one the next version reads. Run in order, only in `restore` mode. */
|
|
240
|
+
export const MIGRATIONS: Array<{ from: number; run(source: string): string }> = [];
|
|
241
|
+
|
|
242
|
+
export interface CompileOpts {
|
|
243
|
+
/** `strict` (default) is for what the model writes: every mistake is an error it can fix. `restore` is for a source read again from a session. */
|
|
244
|
+
mode?: "strict" | "restore";
|
|
245
|
+
/** the language version the source was written under (default 1) */
|
|
246
|
+
from?: number;
|
|
247
|
+
/** tests pass their own; the real tables above are the default */
|
|
248
|
+
migrations?: typeof MIGRATIONS;
|
|
249
|
+
aliases?: typeof ALIASES;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Rename `Old(` to `New(` outside string literals. */
|
|
253
|
+
function renameCalls(source: string, aliases: Record<string, string>): string {
|
|
254
|
+
const names = Object.keys(aliases);
|
|
255
|
+
if (!names.length) return source;
|
|
256
|
+
let out = "";
|
|
257
|
+
let i = 0;
|
|
258
|
+
while (i < source.length) {
|
|
259
|
+
const ch = source[i];
|
|
260
|
+
if (ch === '"') {
|
|
261
|
+
let j = i + 1;
|
|
262
|
+
while (j < source.length && source[j] !== '"') j += source[j] === "\\" ? 2 : 1;
|
|
263
|
+
out += source.slice(i, j + 1);
|
|
264
|
+
i = j + 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
const m = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(i, i + 80));
|
|
268
|
+
if (m) {
|
|
269
|
+
const word = m[0];
|
|
270
|
+
const after = source.slice(i + word.length).match(/^\s*\(/);
|
|
271
|
+
out += after && Object.prototype.hasOwnProperty.call(aliases, word) ? aliases[word] : word;
|
|
272
|
+
i += word.length;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
out += ch;
|
|
276
|
+
i++;
|
|
277
|
+
}
|
|
278
|
+
return out;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** An older source brought up to date: migrations for the versions after the one it was written under, then renamed components. */
|
|
282
|
+
function upgrade(source: string, opts: CompileOpts): string {
|
|
283
|
+
const from = opts.from ?? 1;
|
|
284
|
+
let text = source;
|
|
285
|
+
for (const m of [...(opts.migrations ?? MIGRATIONS)].sort((a, b) => a.from - b.from)) if (m.from >= from) text = m.run(text);
|
|
286
|
+
return renameCalls(text, opts.aliases ?? ALIASES);
|
|
287
|
+
}
|
|
288
|
+
|
|
227
289
|
export function showType(t: T): string {
|
|
228
290
|
if (typeof t === "string") return t === "child" ? "component" : t === "cell" ? "string | number" : t;
|
|
229
291
|
if ("enum" in t) return t.enum.map((x) => JSON.stringify(x)).join(" | ");
|
|
@@ -235,28 +297,32 @@ export function showType(t: T): string {
|
|
|
235
297
|
}
|
|
236
298
|
|
|
237
299
|
/** Reads and checks a program. Never throws: whatever goes wrong comes back as an error the model can read. */
|
|
238
|
-
export function compile(source: string): Compiled {
|
|
300
|
+
export function compile(source: string, opts: CompileOpts = {}): Compiled {
|
|
239
301
|
try {
|
|
240
|
-
const
|
|
241
|
-
|
|
242
|
-
|
|
302
|
+
const restoring = opts.mode === "restore";
|
|
303
|
+
let text = String(source ?? "");
|
|
304
|
+
if (text.length > LIMITS.source) return { ok: false, errors: [`the source is ${Math.round(text.length / 1000)} KB; the limit is ${LIMITS.source / 1000} KB. Show less data, or split it into several widgets`], warnings: [], notes: [] };
|
|
305
|
+
if (restoring) text = upgrade(text, opts);
|
|
306
|
+
const c = compileProgram(text, restoring);
|
|
243
307
|
if (c.errors.length > LIMITS.errors) {
|
|
244
308
|
const more = c.errors.length - LIMITS.errors;
|
|
245
309
|
return { ...c, errors: [...c.errors.slice(0, LIMITS.errors), `... and ${more} more error${more === 1 ? "" : "s"}; fix these first`], root: undefined, ok: false };
|
|
246
310
|
}
|
|
247
311
|
return c;
|
|
248
312
|
} catch (e) {
|
|
249
|
-
return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [] };
|
|
313
|
+
return { ok: false, errors: [`the widget could not be read: ${(e as Error).message}. Make it smaller and less nested`], warnings: [], notes: [] };
|
|
250
314
|
}
|
|
251
315
|
}
|
|
252
316
|
|
|
253
|
-
function compileProgram(source: string): Compiled {
|
|
317
|
+
function compileProgram(source: string, restore = false): Compiled {
|
|
254
318
|
const prog = parseProgram(source);
|
|
255
319
|
const errors = [...prog.errors];
|
|
256
320
|
const warnings: string[] = [];
|
|
321
|
+
/** restore mode: what was changed to make an older source draw. In strict mode the same things are errors. */
|
|
322
|
+
const notes: string[] = [];
|
|
257
323
|
if (!prog.statements.has("root")) {
|
|
258
324
|
errors.push("define the entry point: root = Stack([...]) (put it on the first line)");
|
|
259
|
-
return { ok: false, errors, warnings };
|
|
325
|
+
return { ok: false, errors, warnings, notes };
|
|
260
326
|
}
|
|
261
327
|
if (prog.order[0] !== "root") warnings.push("root is not the first statement; put it first so the shell appears immediately");
|
|
262
328
|
|
|
@@ -317,8 +383,15 @@ function compileProgram(source: string): Compiled {
|
|
|
317
383
|
}
|
|
318
384
|
};
|
|
319
385
|
|
|
320
|
-
|
|
321
|
-
|
|
386
|
+
/** A string within its limit. Over it: an error for the model, or (restoring an older widget) the string cut to the limit, with a note. */
|
|
387
|
+
const fitString = (v: string, max: number, ctx: string, line: number): string => {
|
|
388
|
+
if (v.length <= max) return v;
|
|
389
|
+
if (restore) {
|
|
390
|
+
notes.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}, so it was cut to ${max}`);
|
|
391
|
+
return v.slice(0, max);
|
|
392
|
+
}
|
|
393
|
+
errors.push(`${where(line)}${ctx} is ${v.length} characters; the limit is ${max}. Shorten it`);
|
|
394
|
+
return v;
|
|
322
395
|
};
|
|
323
396
|
const resolve = (x: Expr, t: T, ctx: string, maxChars: number = LIMITS.string): any => {
|
|
324
397
|
const d0 = x;
|
|
@@ -333,16 +406,14 @@ function compileProgram(source: string): Compiled {
|
|
|
333
406
|
switch (t) {
|
|
334
407
|
case "string":
|
|
335
408
|
if (d.k !== "str") return bad("a string");
|
|
336
|
-
|
|
337
|
-
return d.v;
|
|
409
|
+
return fitString(d.v, maxChars, ctx, d.line);
|
|
338
410
|
case "number":
|
|
339
411
|
return d.k === "num" ? d.v : bad("a number");
|
|
340
412
|
case "boolean":
|
|
341
413
|
return d.k === "bool" ? d.v : bad("true or false");
|
|
342
414
|
case "cell":
|
|
343
415
|
if (d.k !== "str" && d.k !== "num") return bad("a string or a number");
|
|
344
|
-
|
|
345
|
-
return d.v;
|
|
416
|
+
return d.k === "str" ? fitString(d.v, LIMITS.cell, ctx, d.line) : d.v;
|
|
346
417
|
case "any":
|
|
347
418
|
return plain(d);
|
|
348
419
|
case "action": {
|
|
@@ -395,7 +466,13 @@ function compileProgram(source: string): Compiled {
|
|
|
395
466
|
if (d.k !== "arr") return bad("an array [...]");
|
|
396
467
|
const out = d.items.map((it, i) => resolve(it, t.arr, `${ctx}[${i + 1}]`)).filter((v) => v !== undefined);
|
|
397
468
|
if (t.min !== undefined && d.items.length < t.min) errors.push(`${where(d.line)}${ctx} needs at least ${t.min} item(s)`);
|
|
398
|
-
if (t.max !== undefined && d.items.length > t.max)
|
|
469
|
+
if (t.max !== undefined && d.items.length > t.max) {
|
|
470
|
+
if (restore) {
|
|
471
|
+
notes.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}; the extra ones were dropped`);
|
|
472
|
+
return out.slice(0, t.max);
|
|
473
|
+
}
|
|
474
|
+
errors.push(`${where(d.line)}${ctx} allows at most ${t.max} item(s), got ${d.items.length}`);
|
|
475
|
+
}
|
|
399
476
|
return out;
|
|
400
477
|
}
|
|
401
478
|
// { comp: [...] }
|
|
@@ -437,7 +514,10 @@ function compileProgram(source: string): Compiled {
|
|
|
437
514
|
}
|
|
438
515
|
};
|
|
439
516
|
const buildInner = (call: Extract<Expr, { k: "call" }>, spec: CompSpec): El | undefined => {
|
|
440
|
-
if (call.args.length > spec.params.length)
|
|
517
|
+
if (call.args.length > spec.params.length) {
|
|
518
|
+
if (restore) notes.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}; the extra ones were ignored`);
|
|
519
|
+
else errors.push(`${where(call.line)}${spec.name} takes at most ${spec.params.length} argument(s), got ${call.args.length}`);
|
|
520
|
+
}
|
|
441
521
|
const props: Record<string, any> = {};
|
|
442
522
|
spec.params.forEach((param, i) => {
|
|
443
523
|
const arg = call.args[i];
|
|
@@ -503,6 +583,23 @@ function compileProgram(source: string): Compiled {
|
|
|
503
583
|
pr.rows.forEach((r: any[], i: number) => {
|
|
504
584
|
if (r.length !== pr.columns.length) err(`row ${i + 1} has ${r.length} cells but there are ${pr.columns.length} columns`);
|
|
505
585
|
});
|
|
586
|
+
if (Array.isArray(pr.pin) && Array.isArray(pr.columns)) {
|
|
587
|
+
// headers are compared the way the table draws them: spacing collapsed
|
|
588
|
+
const header = (c: unknown) => String(c ?? "").replace(/\s+/g, " ").trim();
|
|
589
|
+
const heads = pr.columns.map(header);
|
|
590
|
+
const kept: string[] = [];
|
|
591
|
+
for (const name of pr.pin as string[]) {
|
|
592
|
+
const h = header(name);
|
|
593
|
+
if (!heads.includes(h)) {
|
|
594
|
+
if (restore) notes.push(`${where(el.line)}Table: pin "${h}" is not a column, so it was dropped`);
|
|
595
|
+
else err(`pin "${h}" is not a column. Columns: ${heads.join(", ")}`);
|
|
596
|
+
} else if (kept.includes(h)) {
|
|
597
|
+
if (restore) notes.push(`${where(el.line)}Table: pin lists "${h}" twice, so the second was dropped`);
|
|
598
|
+
else err(`pin lists "${h}" twice`);
|
|
599
|
+
} else kept.push(h);
|
|
600
|
+
}
|
|
601
|
+
if (restore) pr.pin = kept;
|
|
602
|
+
}
|
|
506
603
|
break;
|
|
507
604
|
case "BarChart":
|
|
508
605
|
case "PieChart":
|
|
@@ -590,10 +687,15 @@ function compileProgram(source: string): Compiled {
|
|
|
590
687
|
const rootEl = resolve(rootStmt.expr, "child", "root");
|
|
591
688
|
visiting.pop();
|
|
592
689
|
if (rootEl && rootEl.type !== "Stack") errors.push(`${where(rootStmt.line)}root must be a Stack(...), not ${rootEl.type}`);
|
|
593
|
-
for (const name of prog.order)
|
|
690
|
+
for (const name of prog.order) {
|
|
691
|
+
if (used.has(name)) continue;
|
|
692
|
+
const line = where(prog.statements.get(name)!.line);
|
|
693
|
+
if (restore) notes.push(`${line}"${name}" is defined but not reachable from root, so it is not drawn`);
|
|
694
|
+
else errors.push(`${line}"${name}" is defined but not reachable from root, so it would not render. Add it to its parent's children or items array`);
|
|
695
|
+
}
|
|
594
696
|
const seen = new Set<string>();
|
|
595
697
|
const uniq = errors.filter((m) => (seen.has(m) ? false : (seen.add(m), true)));
|
|
596
|
-
return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, root: uniq.length === 0 ? rootEl : undefined };
|
|
698
|
+
return { ok: uniq.length === 0 && !!rootEl, errors: uniq, warnings, notes: [...new Set(notes)], root: uniq.length === 0 ? rootEl : undefined };
|
|
597
699
|
}
|
|
598
700
|
|
|
599
701
|
/** Text for the model: syntax rules, signatures, actions, tooltips, examples. */
|
|
@@ -613,7 +715,7 @@ export function reference(): string {
|
|
|
613
715
|
${SPECS.map(sig).join("\n")}
|
|
614
716
|
|
|
615
717
|
## Tooltips
|
|
616
|
-
\`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram,
|
|
718
|
+
\`tooltips: Tooltip[]\` is the LAST argument of Dag, Erd, InfraMap, SequenceDiagram, Metric, BarChart, LineChart, AreaChart, PieChart, RadarChart, GeoMap and Image, so write null for each skipped optional argument before it, e.g. Dag(nodes, edges, null, null, [t1]). A Table takes tooltips as its 3rd argument and \`pin\` as its 4th: Table(columns, rows, [t1], ["Host"]), or Table(columns, rows, null, ["Host"]) without tooltips.
|
|
617
719
|
Targets: Dag node id; Erd entity name or entity.column; InfraMap node id, zone:<zone name> or <fromId>><toId>; SequenceDiagram participant id or SeqMessage id; Table row:3, col:status or cell:3.status; Metric its own label; BarChart a bar label; GeoMap a point label or A>B; Image the word image; LineChart and AreaChart an x label (that point) or series:<name>; PieChart a slice label; RadarChart an axis label or series:<name>.
|
|
618
720
|
An element that has a tooltip shows a small ◆ on it. Hovering shows the hint; clicking (or Enter) opens the panel with detail, code and link. Use a tooltip for context the reader may want but should not have to read: why a point spiked, what a slice contains, what a node does.
|
|
619
721
|
hint is the short text shown on hover (140 characters). detail (800), code (40 lines) and link (http/https) open in a panel when the element is clicked.
|
|
@@ -627,6 +729,7 @@ Choice(question, options, hint, multiple, other) asks the user. A click only sta
|
|
|
627
729
|
|
|
628
730
|
## Choosing components
|
|
629
731
|
Metric for a number, Table for comparisons, BarChart for one series of categories, LineChart for trends with several series, AreaChart for stacked volume, PieChart for parts of a whole (8 slices at most), RadarChart for comparing entities across metrics, Dag for task graphs and pipelines, Erd for database schemas, InfraMap for how systems connect, SequenceDiagram for call flows, GeoMap for places, Tabs/Card to organise. Series values must have the same length as labels. All data is literal: no queries, no variables. Image shows a real picture in terminals that support it (Kitty, Ghostty, WezTerm, Warp, herdr): src is an absolute file path (png, jpg, gif, webp) or a data: URI.
|
|
732
|
+
The reader's terminal may be narrow (a phone is about 60 columns) and you cannot see its width. A Dag, Erd, InfraMap or SequenceDiagram wider than it, and a Table with many columns, scroll sideways under a bar and nothing in them is cut. A Table with only a few columns is squeezed to fit instead and a long cell ends in an ellipsis (the reader can tap it to read it whole), so still keep table cells short and put long text in Text or Markdown. A Table pins nothing unless you ask: when it has many columns and a row is hard to read without its first one, list the one or two narrow columns that identify each row (a name, an id) in \`pin\`, e.g. Table(columns, rows, null, ["Host"]). They stay in place while the rest scrolls. Do not pin a long column or one that is not needed to tell the rows apart: pinned columns take at most half the width, one that would not fit is not pinned, and the table scrolls whole without it.
|
|
630
733
|
|
|
631
734
|
## Example
|
|
632
735
|
root = Stack([title, kpis, load, go])
|