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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,65 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.3.0 (2026-11-14)
|
|
6
|
+
|
|
7
|
+
- **A cell that was cut to fit can be read whole.** A table with a few columns is squeezed to fit and a long cell ends in `…`; now
|
|
8
|
+
that cell is a target. Click it, or Tab to it and press Enter, and it opens under the table as plain text (line breaks kept,
|
|
9
|
+
nothing read as Markdown); click again or Esc closes it. A cut header too, and Markdown tables the same. If the agent gave that
|
|
10
|
+
cell a tooltip, the tooltip's panel opens with the whole text added. A table that scrolls cuts nothing, so it has none of these.
|
|
11
|
+
- **`/openui-gallery wide`** draws eleven examples of things that do not fit a phone: a wide table with a pinned column, long cells,
|
|
12
|
+
code with a long line, tabs and tiles that wrap, a wide InfraMap, a Dag, an ERD, a sequence diagram, Markdown with a wide fence
|
|
13
|
+
and table, and a card with a long title. `docs/gallery-narrow.txt` is the same set as text at 60 columns, `docs/gallery.txt` is
|
|
14
|
+
current again (it was not regenerated for the pan bar), and `npm run gallery:files` writes both; a test fails when they are stale.
|
|
15
|
+
`node scripts/langgallery.ts <prefix>` now matches by prefix, as the command does.
|
|
16
|
+
- **New screenshots** for the pan bar, pinned columns, cut and opened cells, code, tabs, tiles, an InfraMap, a Dag and a sequence
|
|
17
|
+
diagram at phone width, on the pages of the components they show.
|
|
18
|
+
|
|
19
|
+
- **A table's columns can be pinned.** `Table(columns, rows, tooltips, pin)`: `pin` (new, 4th argument, optional) lists header names
|
|
20
|
+
that stay in place while a table wider than the pane scrolls, e.g. `Table(cols, rows, null, ["Host", "State"])`. The agent decides:
|
|
21
|
+
left out, nothing is pinned and the whole table scrolls. Pinned columns never take more than half the pane: they are tried in the
|
|
22
|
+
order listed and one that does not fit is skipped, never squeezed, so no text is ever out of reach. While the table scrolls they
|
|
23
|
+
are drawn first, in the order they have in the table; a table that fits is drawn as it was written. A name that is not a column is
|
|
24
|
+
a validation error that lists the columns. Every source written before this still compiles and draws as it did. The reference the
|
|
25
|
+
agent reads says when to pin (one or two narrow columns that identify each row) and when not to.
|
|
26
|
+
- **Scrolling in two sizes.** The pan bar is now ` « ‹ ████░░░ 1–53/87 › » `. `‹ ›` move a few cells (`scroll.step`, 8 by default),
|
|
27
|
+
`« »` a page (three quarters of the pane, to a column edge in a table). Left and Right on a focused bar are fine steps; Shift+arrow,
|
|
28
|
+
PageUp and PageDown are pages. Dragging the bar's thumb is still cell-precise. A bar too narrow for four arrows keeps the fine pair.
|
|
29
|
+
- **`scroll.step` in the settings file**, with `PI_OPENUI_SCROLL_STEP` and `/openui-config scroll.step <1-40>`. `/openui-config reset`
|
|
30
|
+
now puts every setting back; `/openui-config <setting> reset` puts one back.
|
|
31
|
+
- **The language cannot be broken by accident any more.** `test/compat/signatures.json` records every component and argument the
|
|
32
|
+
language has had; a test fails if an argument is inserted, moved, removed or made required, if a list or choice accepts less, or if a
|
|
33
|
+
limit goes down. New arguments go at the end and are optional. `AGENTS.md` says so.
|
|
34
|
+
|
|
35
|
+
- **A pan bar says when a widget is wider than the pane.** Tables, Dag, Erd, InfraMap and SequenceDiagram that do not fit now show
|
|
36
|
+
`‹ ███░░░░░░ 1–53/87 ›` under them (only when something is cut off). Tap or click `‹` and `›`, click or drag the bar, swipe
|
|
37
|
+
sideways or use a horizontal wheel where your terminal app sends one, Shift+wheel, or Tab to the bar and press Left and Right
|
|
38
|
+
(also in notes mode). The plain wheel still scrolls the transcript. The old text hint `select a node to pan` is gone.
|
|
39
|
+
- **Tables keep a minimum column width.** A column is squeezed only down to 12 characters (not three) before the table scrolls
|
|
40
|
+
sideways. Markdown tables are unchanged. Tables that fit are unchanged.
|
|
41
|
+
- **A settings file.** `~/.pi/agent/openui.json` (`{ "table": { "minColumnWidth": 12 } }`, 4 to 40),
|
|
42
|
+
`PI_OPENUI_TABLE_MIN_COL`, and `/openui-config` to look at it, set it or `reset` it.
|
|
43
|
+
- **Old sessions keep working.** A session stores a widget's source and draws it again, so everything above reaches widgets from
|
|
44
|
+
earlier versions at once. New widgets store the language version (`details.lang`). A source that no longer compiles under today's
|
|
45
|
+
strict rules is now read leniently (a value over a limit is cut, extra arguments are ignored, a statement nothing uses is
|
|
46
|
+
ignored) with one quiet line saying it was redrawn with changes, and one that still cannot be read shows the reason and the start
|
|
47
|
+
of its source instead of a blank. `AGENTS.md` now says language changes must be additive, and `test/compat/` keeps sources as older
|
|
48
|
+
versions wrote them so a change that breaks one fails the tests.
|
|
49
|
+
- **Pan rather than cut.** On a narrow pane text used to be shortened with `…` wherever a layout could not give: now it scrolls or wraps
|
|
50
|
+
instead. Code scrolls inside its box, code fences and tables in Markdown scroll on their own, a row of buttons or metric tiles wraps
|
|
51
|
+
onto more rows, a tab strip wraps onto more rows, and a title that does not fit a box's border moves inside as a heading. A Dag,
|
|
52
|
+
InfraMap, Erd or SequenceDiagram that cannot fit scrolls with whole labels instead of 6-character ones (they shrink to 14
|
|
53
|
+
characters at the least), and a table that scrolls cuts no cell at all (before: at 20 characters). Anything still wider than the pane
|
|
54
|
+
gets the pan bar. `test/narrow-audit.test.ts` holds every component to it.
|
|
55
|
+
- **A picture's frame says what it is.** If the terminal does not draw the picture (a phone app that reports no cell size, or a second
|
|
56
|
+
client attached to the same herdr), the frame is no longer an empty box: it names the picture and its size.
|
|
57
|
+
- **`/openui-events`** records what a tap, a swipe or a flick sends to a widget, for five minutes, so what your terminal app really
|
|
58
|
+
does can be read instead of guessed. It never records what you type.
|
|
59
|
+
- **The agent is told the terminal may be narrow.** The reference `render_widget` returns says a phone is about 60 columns, that
|
|
60
|
+
wide blocks scroll under a bar, and when to pin a Table's columns.
|
|
61
|
+
- **A tap right after a sideways flick works.** pi-tui does not understand horizontal wheel events and leaves a press unreleased,
|
|
62
|
+
which made the next tap do nothing; the widget now recognises that tap itself.
|
|
63
|
+
|
|
5
64
|
## 0.2.0 (2026-11-02)
|
|
6
65
|
|
|
7
66
|
- **`Choice` takes several answers and an Other answer, and sends them together.** `Choice(question, options, hint, multiple, other)`.
|
package/README.md
CHANGED
|
@@ -62,8 +62,11 @@ update pi shows this package's [changelog](CHANGELOG.md). Maintainers: see [docs
|
|
|
62
62
|
|---|---|
|
|
63
63
|
| Let the agent draw | Ask for a dashboard, a chart, a diagram, a table. It calls `render_widget`. |
|
|
64
64
|
| One example of every component | `/openui-gallery`, or one kind: `/openui-gallery dag`, `erd`, `infra`, `sequence`, `geo`, `pie`, `line`, `image`, `table`, `tabs`, `markdown`, `choice` |
|
|
65
|
+
| The examples that do not fit a phone | `/openui-gallery wide`: a wide table, long cells, long code, tabs, tiles, diagrams. Try it in a narrow pane |
|
|
65
66
|
| Draw a file | `/openui-file path/to/widget.oui` |
|
|
66
67
|
| Use the keyboard without the mouse | `/openui-focus` |
|
|
68
|
+
| Change a setting | `/openui-config`, for example `/openui-config table.minColumnWidth 14` |
|
|
69
|
+
| See what a swipe or tap sends | `/openui-events on`, try it over a widget, `/openui-events show` |
|
|
67
70
|
| Write notes on a widget | Click `[ notes ]` under it, or `/openui-notes` |
|
|
68
71
|
|
|
69
72
|
Gallery clicks are echoed locally and cost nothing. In a widget the agent drew, a Button click is sent to the agent as your
|
|
@@ -71,6 +74,41 @@ next message, and the answers to its Choices are sent together, as one message,
|
|
|
71
74
|
|
|
72
75
|
The mouse needs pi's default fullscreen mode. Everything also works from the keyboard.
|
|
73
76
|
|
|
77
|
+
## Small screens
|
|
78
|
+
|
|
79
|
+
On a narrow terminal (a phone over SSH or herdr, a small split) a widget that is wider than the pane shows a **pan bar**
|
|
80
|
+
under it, `‹ ███░░░░░░ 1–53/87 ›`, and scrolls sideways. Tap the arrows or the bar, swipe or use a horizontal wheel where the
|
|
81
|
+
terminal app sends one, Shift+wheel, or Tab to the bar and use the arrow keys. `‹ ›` move a few cells at a time, `« »` a page.
|
|
82
|
+
A table scrolls whole; the agent can pin a column or two (never more than half the width) to keep them in view, and a table never
|
|
83
|
+
squeezes a column below a minimum width before it scrolls. A cell that was cut to fit ends in `…`: tap it to read it whole.
|
|
84
|
+
|
|
85
|
+

|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
`/openui-gallery wide` draws eleven examples of things that would be cut on a phone, so you can see what each does in your pane. Code scrolls inside its box, rows of buttons and tabs wrap, and
|
|
90
|
+
diagrams scroll with whole labels: nothing is cut with `…` when it can be reached another way. Not sure what your app sends for a
|
|
91
|
+
swipe? `/openui-events on`, try it over a widget, `/openui-events show`. Details are in
|
|
92
|
+
[docs/interaction.md](https://github.com/newtonapple/pi-openui/blob/main/docs/interaction.md#wide-diagrams-and-tables).
|
|
93
|
+
|
|
94
|
+
## Settings
|
|
95
|
+
|
|
96
|
+
One file, `~/.pi/agent/openui.json` (or `$PI_CODING_AGENT_DIR/openui.json`):
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{ "table": { "minColumnWidth": 12 }, "scroll": { "step": 8 } }
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| Setting | Default | Meaning |
|
|
103
|
+
|---|---|---|
|
|
104
|
+
| `table.minColumnWidth` | `12` (4 to 40) | The narrowest a table column is squeezed to. A table that does not fit at that width scrolls sideways. Lower it to see more columns at once; raise it to read more of each cell. |
|
|
105
|
+
| `scroll.step` | `8` (1 to 40) | Cells one tap on a fine arrow (`‹ ›`) scrolls. The page arrows (`« »`) always go about three quarters of the pane. Lower it if scrolling feels jumpy. |
|
|
106
|
+
|
|
107
|
+
`PI_OPENUI_TABLE_MIN_COL` and `PI_OPENUI_SCROLL_STEP` override the file. `/openui-config` shows what is in force,
|
|
108
|
+
`/openui-config scroll.step 5` writes the file (handy on a phone) and redraws every widget, `/openui-config scroll.step reset` puts
|
|
109
|
+
one setting back, and `/openui-config reset` puts all of them back. A bad value falls back to the
|
|
110
|
+
default with one notice, and the file is never overwritten if it is not valid JSON.
|
|
111
|
+
|
|
74
112
|
## Notes
|
|
75
113
|
|
|
76
114
|
Pin a comment to one part of a widget, then send all your comments to the agent as a single message. Use it to say "this row
|
|
@@ -157,7 +195,8 @@ After you change the code, run `/reload` in pi. The rest:
|
|
|
157
195
|
npm test # a few seconds, no network, no model calls
|
|
158
196
|
npm run docs # regenerate docs/components/ from the signature table
|
|
159
197
|
npm run screenshots # regenerate docs/screenshots/, and the two README composites (needs Python with Pillow, and the font)
|
|
160
|
-
npm run gallery # print every component to the terminal (-- --plain for no colour)
|
|
198
|
+
npm run gallery # print every component to the terminal (-- --plain for no colour; a key prefix, e.g. `wide`, picks some)
|
|
199
|
+
npm run gallery:files # regenerate docs/gallery.txt and docs/gallery-narrow.txt (a test fails when they are stale)
|
|
161
200
|
make help # the release process (check, version, publish, tag, push)
|
|
162
201
|
```
|
|
163
202
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-openui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Pi extension: interactive dashboards, charts, diagrams and maps in the terminal. The agent draws with OpenUI Lang; you click, hover and pin notes.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"width": "node --import ./test/register-pi.mjs scripts/width-table.ts",
|
|
53
53
|
"screenshots": "node --import ./test/register-pi.mjs scripts/shots.ts && python3 scripts/render_shots.py && python3 scripts/hero.py",
|
|
54
54
|
"docs": "node scripts/gen_docs.ts",
|
|
55
|
-
"prepublishOnly": "npm test"
|
|
55
|
+
"prepublishOnly": "npm test",
|
|
56
|
+
"gallery:files": "node scripts/gen-gallery.ts"
|
|
56
57
|
}
|
|
57
58
|
}
|
package/src/blocks/code.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
|
|
2
|
-
import type { Block, Target } from "./types.ts";
|
|
2
|
+
import type { Block, Ctx, Target } from "./types.ts";
|
|
3
|
+
import { panBlock } from "./panblock.ts";
|
|
3
4
|
import { boxed, cell, fromRows, textCells, vstack } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
const KW: Record<string, string[]> = {
|
|
@@ -84,8 +85,20 @@ export function codeInner(code: string, lang: string | undefined, width: number)
|
|
|
84
85
|
return fromRows(width, rows);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
/** The longest line of the code that is drawn, in cells. */
|
|
89
|
+
const longestLine = (code: string) => Math.max(0, ...String(code ?? "").replace(/\t/g, " ").replace(/\n$/, "").split("\n").slice(0, 200).map((l) => textWidth(l)));
|
|
90
|
+
|
|
91
|
+
/** A line longer than this is cut: even a scrolling view needs an end. */
|
|
92
|
+
export const MAX_CODE_COLS = 240;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `pan` (a render context and a key for the scroll offset) lets code wider than its box scroll inside it, with a pan bar under the
|
|
96
|
+
* last line; the box and its copy button stay where they are. Without it (a quote, a tooltip panel) a long line is cut as it was.
|
|
97
|
+
*/
|
|
98
|
+
export function codeBlock(code: string, lang: string | undefined, title: string | undefined, width: number, copyId: string | null, copied: boolean, pan?: { ctx: Ctx; key: string }): Block {
|
|
99
|
+
const innerW = Math.max(8, width - 4);
|
|
100
|
+
const longest = longestLine(code);
|
|
101
|
+
const inner = pan && longest > innerW ? panBlock(codeInner(code, lang, Math.min(longest, MAX_CODE_COLS)), innerW, pan.key, pan.ctx) : codeInner(code, lang, innerW);
|
|
89
102
|
const label = [title, lang && !title ? lang : title && lang ? lang : ""].filter(Boolean).join(" · ");
|
|
90
103
|
const tagText = copyId ? (copied ? "✓ copied" : "[copy]") : undefined;
|
|
91
104
|
const b = boxed(inner, { role: "code.border", title: label || undefined, titleRole: "code.title", tag: tagText, tagRole: copied ? "code.copied" : "btn" });
|
package/src/blocks/core.ts
CHANGED
|
@@ -37,7 +37,11 @@ export function stack(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
|
37
37
|
const gap = COL_GAP[gapTok] ?? 2;
|
|
38
38
|
const nat = kids.map(natural);
|
|
39
39
|
const lines: number[][] = [];
|
|
40
|
-
|
|
40
|
+
// A row of fixed-size children (tiles, buttons, short text) that cannot sit side by side wraps instead of being squeezed until
|
|
41
|
+
// their labels are cut. A row with a child that shares the space (a Card, a Table) keeps its old behaviour.
|
|
42
|
+
const fixedRow = nat.length > 1 && nat.every((n) => n !== null);
|
|
43
|
+
const overflows = fixedRow && nat.reduce((a, n) => a + (n ?? 0), 0) + gap * (nat.length - 1) > width;
|
|
44
|
+
if (el.props.wrap || overflows) {
|
|
41
45
|
let cur: number[] = [];
|
|
42
46
|
let used = 0;
|
|
43
47
|
const share = Math.max(14, Math.floor((width - 2 * gap) / 3));
|
|
@@ -198,32 +202,50 @@ export function tabs(el: El, width: number, ctx: Ctx, path: string): Block {
|
|
|
198
202
|
const items: El[] = el.props.items ?? [];
|
|
199
203
|
if (!items.length) return blank(width, 0);
|
|
200
204
|
const sel = Math.min(Math.max(0, ctx.state.tab[path] ?? 0), items.length - 1);
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const l2: typeof l0 = [];
|
|
208
|
-
const targets: Target[] = [];
|
|
205
|
+
// Labels are never shortened to make the bar fit (a label wider than the whole pane is the one exception). A bar that does not
|
|
206
|
+
// fit wraps onto more rows, each a little tab bar of its own.
|
|
207
|
+
const labels = items.map((it) => truncateText(String(it.props.label ?? ""), Math.max(1, width - 4)));
|
|
208
|
+
const lines: number[][] = [];
|
|
209
|
+
let cur: number[] = [];
|
|
210
|
+
let used = 0;
|
|
209
211
|
labels.forEach((lab, i) => {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
l0.push(cell("╭", role), ...Array.from({ length: iw }, () => cell("─", role)), cell("╮", role));
|
|
216
|
-
l1.push(cell("│", role), cell(" ", role), ...textCells(lab, "tab.label"), cell(" ", role), cell("│", role));
|
|
217
|
-
l2.push(cell("╯", role), ...Array.from({ length: iw }, () => cell(" ")), cell("╰", role));
|
|
218
|
-
} else {
|
|
219
|
-
l0.push(...Array.from({ length: iw + 2 }, () => cell(" ")));
|
|
220
|
-
l1.push(cell(" "), cell(" "), ...textCells(lab, role), cell(" "), cell(" "));
|
|
221
|
-
l2.push(...Array.from({ length: iw + 2 }, () => cell("─", "tab.rule")));
|
|
212
|
+
const w = textWidth(lab) + 4;
|
|
213
|
+
if (cur.length && used + w > width) {
|
|
214
|
+
lines.push(cur);
|
|
215
|
+
cur = [];
|
|
216
|
+
used = 0;
|
|
222
217
|
}
|
|
223
|
-
|
|
218
|
+
used += w;
|
|
219
|
+
cur.push(i);
|
|
220
|
+
});
|
|
221
|
+
if (cur.length) lines.push(cur);
|
|
222
|
+
const rows: Cell[][] = [];
|
|
223
|
+
const targets: Target[] = [];
|
|
224
|
+
lines.forEach((idxs, k) => {
|
|
225
|
+
const l0: ReturnType<typeof textCells> = [];
|
|
226
|
+
const l1: typeof l0 = [];
|
|
227
|
+
const l2: typeof l0 = [];
|
|
228
|
+
idxs.forEach((i) => {
|
|
229
|
+
const lab = labels[i];
|
|
230
|
+
const iw = textWidth(lab) + 2;
|
|
231
|
+
const on = i === sel;
|
|
232
|
+
const role = on ? "tab.on" : "tab.off";
|
|
233
|
+
const x0 = l1.length;
|
|
234
|
+
if (on) {
|
|
235
|
+
l0.push(cell("╭", role), ...Array.from({ length: iw }, () => cell("─", role)), cell("╮", role));
|
|
236
|
+
l1.push(cell("│", role), cell(" ", role), ...textCells(lab, "tab.label"), cell(" ", role), cell("│", role));
|
|
237
|
+
l2.push(cell("╯", role), ...Array.from({ length: iw }, () => cell(" ")), cell("╰", role));
|
|
238
|
+
} else {
|
|
239
|
+
l0.push(...Array.from({ length: iw + 2 }, () => cell(" ")));
|
|
240
|
+
l1.push(cell(" "), cell(" "), ...textCells(lab, role), cell(" "), cell(" "));
|
|
241
|
+
l2.push(...Array.from({ length: iw + 2 }, () => cell("─", "tab.rule")));
|
|
242
|
+
}
|
|
243
|
+
targets.push({ id: `tab:${path}:${i}`, kind: "tab", rect: { x: x0, y: 3 * k + 1, w: iw + 2, h: 1 }, label: String(items[i].props.label ?? "") });
|
|
244
|
+
});
|
|
245
|
+
while (l2.length < width) l2.push(cell("─", "tab.rule"));
|
|
246
|
+
rows.push(l0, l1, l2);
|
|
224
247
|
});
|
|
225
|
-
|
|
226
|
-
const strip = fromRows(width, [l0, l1, l2], targets);
|
|
248
|
+
const strip = fromRows(width, rows, targets);
|
|
227
249
|
ctx.tabs.push(String(items[sel].props.label ?? ""));
|
|
228
250
|
let content: Block;
|
|
229
251
|
try {
|
|
@@ -270,6 +292,16 @@ export function image(el: El, width: number, path: string, ctx: Ctx): { block: B
|
|
|
270
292
|
if (placed) {
|
|
271
293
|
// the picture is drawn over these cells by the host; here they are reserved, framed and clickable
|
|
272
294
|
const area = blank(placed.cols, placed.rows);
|
|
295
|
+
// The terminal draws the picture over these cells, so what is written here is only ever seen when it does not: a phone app that
|
|
296
|
+
// reports no cell size, a second client attached, a terminal without graphics. An empty box looks broken; this says what it is.
|
|
297
|
+
const say = (y: number, text: string) => {
|
|
298
|
+
if (y < 0 || y >= placed.rows || placed.cols < 8) return;
|
|
299
|
+
const cells = textCells(truncateText(text, placed.cols - 2), "text.muted");
|
|
300
|
+
area.rows[y] = padRow([...Array.from({ length: Math.max(0, Math.floor((placed.cols - cells.length) / 2)) }, () => cell(" ")), ...cells], placed.cols);
|
|
301
|
+
};
|
|
302
|
+
const mid = Math.floor((placed.rows - 1) / 2);
|
|
303
|
+
say(placed.rows >= 4 ? mid - 1 : mid, svc ? svc.describe(src) : name);
|
|
304
|
+
if (placed.rows >= 4) say(mid + 1, "no picture? terminal can't show it");
|
|
273
305
|
area.targets.push({ id: `image:${path}`, kind: "image", rect: { x: 0, y: 0, w: placed.cols, h: placed.rows }, label: `${alt}`, image: { key: placed.key, cols: placed.cols, rows: placed.rows } });
|
|
274
306
|
const cap = p.caption ? fromRows(placed.cols, wrapText(String(p.caption), placed.cols, "text.muted")) : null;
|
|
275
307
|
block = boxed(cap ? vstack([area, cap], 0, placed.cols) : area, { role: "image.border", title: alt, titleRole: "image.alt", padX: 0 });
|
|
@@ -296,6 +328,10 @@ export interface Anchor {
|
|
|
296
328
|
badgeForce?: boolean;
|
|
297
329
|
/** cells besides `rect` that belong to the element (a pie wedge) */
|
|
298
330
|
cells?: Array<[number, number]>;
|
|
331
|
+
/** a cell that was cut to fit: its whole text, readable in a panel (no tooltip needed) */
|
|
332
|
+
full?: string;
|
|
333
|
+
/** what to call the element in the hover line and the panel's title, when its tooltip key (`cell:3.Notes`) would read badly */
|
|
334
|
+
label?: string;
|
|
299
335
|
}
|
|
300
336
|
|
|
301
337
|
/** The mark that says "this element has a tooltip". Present in the user's font (JetBrains Mono), unlike the circled i. */
|
|
@@ -339,6 +375,11 @@ export function tipPanel(label: string, tip: Tip, width: number): Block {
|
|
|
339
375
|
if (tip.detail) parts.push(markdownBlock(tip.detail, iw));
|
|
340
376
|
if (tip.code) parts.push(codeInner(tip.code, tip.language, iw));
|
|
341
377
|
if (tip.link) parts.push(fromRows(iw, [textCells(truncateText(`↗ ${tip.linkLabel ?? tip.link}${tip.linkLabel ? ` — ${tip.link}` : ""}`, iw), "md.link")]));
|
|
378
|
+
if (tip.full !== undefined) {
|
|
379
|
+
// plain text, as it was written: not Markdown, and wrapped rather than cut
|
|
380
|
+
const body = wrapText(tip.full, iw, "");
|
|
381
|
+
parts.push(parts.length ? vstack([fromRows(iw, [textCells("the whole cell", "tip.hint")]), fromRows(iw, body)], 0, iw) : fromRows(iw, body));
|
|
382
|
+
}
|
|
342
383
|
const inner = vstack(parts.length ? parts : [fromRows(iw, [textCells(label, "tip.hint")])], 1, iw);
|
|
343
384
|
return boxed(inner, { role: `tip.${tip.tone ?? "info"}`, title: label });
|
|
344
385
|
}
|
|
@@ -364,8 +405,9 @@ export function addWholeItem(el: El, block: Block, path: string, ctx: Ctx): void
|
|
|
364
405
|
export function withTips(el: El, block: Block, anchors: Map<string, Anchor>, path: string, ctx: Ctx, avail = block.w): Block {
|
|
365
406
|
registerItems(el, block, anchors, path, ctx);
|
|
366
407
|
const tips: El[] = el.props.tooltips ?? [];
|
|
367
|
-
if (!tips.length) return block;
|
|
368
408
|
let panel: Block | null = null;
|
|
409
|
+
const panelWidth = () => Math.min(Math.max(avail, 20), Math.max(block.w, 44));
|
|
410
|
+
const tipped = new Set<string>();
|
|
369
411
|
for (const t of tips) {
|
|
370
412
|
const key = String(t.props.target ?? "");
|
|
371
413
|
const a = anchors.get(key);
|
|
@@ -373,13 +415,22 @@ export function withTips(el: El, block: Block, anchors: Map<string, Anchor>, pat
|
|
|
373
415
|
ctx.warnings.push(`${el.type}: tooltip target "${key}" does not match anything in the component`);
|
|
374
416
|
continue;
|
|
375
417
|
}
|
|
376
|
-
|
|
418
|
+
tipped.add(key);
|
|
419
|
+
const tip: Tip = { hint: t.props.hint, detail: t.props.detail, code: t.props.code, language: t.props.language, tone: t.props.tone, link: t.props.link, linkLabel: t.props.linkLabel, ...(a.full !== undefined ? { full: a.full } : {}) };
|
|
377
420
|
const existing = a.targetId ? block.targets.find((x) => x.id === a.targetId) : undefined;
|
|
378
421
|
const id = existing ? existing.id : `tip:${path}:${key}`;
|
|
379
422
|
if (existing) existing.tip = tip;
|
|
380
423
|
else block.targets.push({ id, kind: "tip", rect: a.rect, label: key, tip, ...(a.cells ? { cells: a.cells } : {}) });
|
|
381
424
|
placeBadge(block, a, tip.tone ?? "info");
|
|
382
|
-
if (ctx.state.tip === id && (tip.detail || tip.code || tip.link)) panel = tipPanel(key, tip,
|
|
425
|
+
if (ctx.state.tip === id && (tip.detail || tip.code || tip.link || tip.full !== undefined)) panel = tipPanel(key, tip, panelWidth());
|
|
426
|
+
}
|
|
427
|
+
// a cell that was cut to fit and has no tooltip of its own: a tap reads it whole (no badge: the ellipsis is the cue)
|
|
428
|
+
for (const [key, a] of anchors) {
|
|
429
|
+
if (a.full === undefined || tipped.has(key)) continue;
|
|
430
|
+
const id = `tip:${path}:${key}`;
|
|
431
|
+
const tip: Tip = { full: a.full };
|
|
432
|
+
block.targets.push({ id, kind: "tip", rect: a.rect, label: a.label ?? key, tip });
|
|
433
|
+
if (ctx.state.tip === id) panel = tipPanel(a.label ?? key, tip, panelWidth());
|
|
383
434
|
}
|
|
384
435
|
return panel ? vstack([block, panel], 0, Math.max(block.w, panel.w)) : block;
|
|
385
436
|
}
|
package/src/blocks/diagrams.ts
CHANGED
|
@@ -7,25 +7,9 @@ import type { DiagramResult } from "../diagram/layeredDiagram.ts";
|
|
|
7
7
|
import type { El } from "../lang/library.ts";
|
|
8
8
|
import type { Anchor } from "./core.ts";
|
|
9
9
|
import type { Block, Ctx, Target } from "./types.ts";
|
|
10
|
+
import { panBlock } from "./panblock.ts";
|
|
10
11
|
import { cell, fromRows, padRow, textCells, vstack } from "./util.ts";
|
|
11
12
|
|
|
12
|
-
/** Crop a block wider than `width` to a window starting at the saved pan offset. */
|
|
13
|
-
export function panBlock(inner: Block, width: number, key: string, ctx: Ctx): Block {
|
|
14
|
-
if (inner.w <= width) return inner;
|
|
15
|
-
const maxPan = inner.w - width;
|
|
16
|
-
const pan = Math.min(maxPan, Math.max(0, ctx.state.pan[key] ?? 0));
|
|
17
|
-
ctx.state.pan[key] = pan;
|
|
18
|
-
const rows = inner.rows.map((r) => {
|
|
19
|
-
const seg = padRow(r, inner.w).slice(pan, pan + width);
|
|
20
|
-
if (seg.length && seg[0].ch === "") seg[0] = cell(" ");
|
|
21
|
-
if (seg.length && pan + width < inner.w && seg[seg.length - 1].ch !== "" && r[pan + width]?.ch === "") seg[seg.length - 1] = cell(" ");
|
|
22
|
-
return seg;
|
|
23
|
-
});
|
|
24
|
-
const targets: Target[] = inner.targets.map((t) => ({ ...t, rect: { ...t.rect, x: t.rect.x - pan }, pan: { key, x: t.rect.x, w: t.rect.w, view: width, total: inner.w } }));
|
|
25
|
-
const hint = textCells(`${pan > 0 ? "‹" : " "} columns ${pan + 1}–${pan + width} of ${inner.w} ${pan < maxPan ? "›" : " "} select a node to pan`, "text.muted");
|
|
26
|
-
return { w: width, rows: [...rows, padRow(hint, width)], targets };
|
|
27
|
-
}
|
|
28
|
-
|
|
29
13
|
/**
|
|
30
14
|
* A diagram's layout depends only on the component and the width, but the widget lays out again for a note, a tab, a resize
|
|
31
15
|
* or a late picture. Routing wires is the costly part, so each result is kept with its component (and dropped with it). Nothing
|
|
@@ -85,7 +69,7 @@ export function dagBlock(el: El, width: number, path: string, ctx: Ctx): { block
|
|
|
85
69
|
const dy = p.title ? 1 : 0;
|
|
86
70
|
for (const n of res.nodes) anchors.set(n.id, { rect: { ...n.rect, y: n.rect.y + dy }, targetId: `node:${path}:${n.id}` });
|
|
87
71
|
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
88
|
-
remapAnchors(anchors, inner, block);
|
|
72
|
+
remapAnchors(anchors, inner, block, ctx.state.pan[`pan:${path}`] ?? 0);
|
|
89
73
|
return { block, anchors };
|
|
90
74
|
}
|
|
91
75
|
|
|
@@ -105,7 +89,7 @@ export function erdBlock(el: El, width: number, path: string, ctx: Ctx): { block
|
|
|
105
89
|
});
|
|
106
90
|
}
|
|
107
91
|
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
108
|
-
remapAnchors(anchors, inner, block);
|
|
92
|
+
remapAnchors(anchors, inner, block, ctx.state.pan[`pan:${path}`] ?? 0);
|
|
109
93
|
return { block, anchors };
|
|
110
94
|
}
|
|
111
95
|
|
|
@@ -124,19 +108,17 @@ export function infraBlock(el: El, width: number, path: string, ctx: Ctx): { blo
|
|
|
124
108
|
if (c) anchors.set(`${e.from}>${e.to}`, { rect: { x: Math.max(0, c[0] - 1), y: c[1] + dy, w: Math.max(3, e.labelCells.length + 2), h: 1 } });
|
|
125
109
|
}
|
|
126
110
|
const block = panBlock(inner, width, `pan:${path}`, ctx);
|
|
127
|
-
remapAnchors(anchors, inner, block);
|
|
111
|
+
remapAnchors(anchors, inner, block, ctx.state.pan[`pan:${path}`] ?? 0);
|
|
128
112
|
return { block, anchors };
|
|
129
113
|
}
|
|
130
114
|
|
|
131
|
-
/** after panning
|
|
132
|
-
function remapAnchors(anchors: Map<string, Anchor>, inner: Block, panned: Block) {
|
|
133
|
-
if (panned === inner) return;
|
|
134
|
-
const pan = panned.targets[0]?.pan;
|
|
135
|
-
const dx = pan ? pan.x - panned.targets[0].rect.x : 0;
|
|
115
|
+
/** after panning by `dx`, anchors must follow the targets they point at */
|
|
116
|
+
function remapAnchors(anchors: Map<string, Anchor>, inner: Block, panned: Block, dx: number) {
|
|
117
|
+
if (panned === inner || !dx) return;
|
|
136
118
|
for (const [, a] of anchors) {
|
|
137
119
|
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
138
120
|
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
139
121
|
}
|
|
140
122
|
}
|
|
141
123
|
|
|
142
|
-
export { offsetAnchors, textWidth };
|
|
124
|
+
export { offsetAnchors, panBlock, textWidth };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// What a widget shows when its source cannot be drawn again (a language change an old session was never migrated across, or a
|
|
2
|
+
// source from a newer version than this extension). Never a blank: the reader gets the reason and the start of the source.
|
|
3
|
+
// Pure: lines of plain text; the caller colours them.
|
|
4
|
+
|
|
5
|
+
const SOURCE_LINES = 12;
|
|
6
|
+
const WIDTH = 80;
|
|
7
|
+
|
|
8
|
+
/** One short line of plain text: control and escape characters are what a stored source must never be allowed to send to a terminal. */
|
|
9
|
+
const tidy = (s: string, max: number): string => {
|
|
10
|
+
const clean = s.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, " ");
|
|
11
|
+
return clean.length > max ? clean.slice(0, max - 1) + "…" : clean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function fallbackLines(source: string, errors: string[], lang: number): string[] {
|
|
15
|
+
const lines = [`This widget could not be drawn again (OpenUI Lang v${lang}).`];
|
|
16
|
+
if (errors.length) lines.push(`Why: ${tidy(errors[0], WIDTH)}`);
|
|
17
|
+
const text = String(source ?? "");
|
|
18
|
+
if (text.trim()) {
|
|
19
|
+
lines.push("The source it was drawn from:");
|
|
20
|
+
const all = text.split("\n");
|
|
21
|
+
for (const l of all.slice(0, SOURCE_LINES)) lines.push(` ${tidy(l, WIDTH)}`);
|
|
22
|
+
if (all.length > SOURCE_LINES) lines.push(` … ${all.length - SOURCE_LINES} more line${all.length - SOURCE_LINES === 1 ? "" : "s"}`);
|
|
23
|
+
}
|
|
24
|
+
return lines;
|
|
25
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// What a pointer does to a block that is wider than its pane. Events in, actions out: no terminal and no pi, so the cases that
|
|
2
|
+
// matter (a sideways flick, a drag on the thumb, a tap after a flick) are tested with plain objects.
|
|
3
|
+
//
|
|
4
|
+
// Why a recognizer at all: pi-tui understands only the vertical wheel. A horizontal one (SGR buttons 66 and 67) reaches a
|
|
5
|
+
// widget as a `press` with button "right" or "none" that is never released, and while pi believes that press is still down
|
|
6
|
+
// it does not turn the next tap into a `click`. The recognizer pans on those presses, remembers that it saw one, and
|
|
7
|
+
// recognises the next tap itself from its press and release. Everything else about taps stays with pi's `click`.
|
|
8
|
+
import { panAreaAt, targetAt } from "./interact.ts";
|
|
9
|
+
import { clampPan, panFromTrack } from "./pan.ts";
|
|
10
|
+
import type { Block, Target } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/** The fields of pi's mouse event that are used. Coordinates are local to the block. */
|
|
13
|
+
export interface MouseIn {
|
|
14
|
+
type: string;
|
|
15
|
+
button: string;
|
|
16
|
+
x: number;
|
|
17
|
+
y: number;
|
|
18
|
+
shift: boolean;
|
|
19
|
+
alt: boolean;
|
|
20
|
+
wheelDelta?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type Action =
|
|
24
|
+
| { kind: "pan"; key: string; to: number; capture?: boolean }
|
|
25
|
+
| { kind: "tap"; x: number; y: number }
|
|
26
|
+
/** a click that was already handled as a tap */
|
|
27
|
+
| { kind: "swallow" }
|
|
28
|
+
| { kind: "none" };
|
|
29
|
+
|
|
30
|
+
/** Columns for one notch of a wheel or one horizontal flick event. */
|
|
31
|
+
export const WHEEL_COLUMNS = 3;
|
|
32
|
+
|
|
33
|
+
const NONE: Action = { kind: "none" };
|
|
34
|
+
const same = (a: { x: number; y: number } | null, x: number, y: number) => !!a && a.x === x && a.y === y;
|
|
35
|
+
|
|
36
|
+
export class Recognizer {
|
|
37
|
+
/** pi has been sent a press that no release will match (a horizontal flick) */
|
|
38
|
+
stuck = false;
|
|
39
|
+
private pressAt: { x: number; y: number } | null = null;
|
|
40
|
+
private ignoreClickAt: { x: number; y: number } | null = null;
|
|
41
|
+
/** the track being dragged: where it is and what it scrolls */
|
|
42
|
+
private drag: { key: string; track: Target["rect"]; total: number; view: number } | null = null;
|
|
43
|
+
|
|
44
|
+
reset(): void {
|
|
45
|
+
this.stuck = false;
|
|
46
|
+
this.pressAt = null;
|
|
47
|
+
this.ignoreClickAt = null;
|
|
48
|
+
this.drag = null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private shift(block: Block, pan: Record<string, number>, ev: MouseIn, columns: number): Action {
|
|
52
|
+
const sc = panAreaAt(block, ev.x, ev.y)?.scroll;
|
|
53
|
+
if (!sc) return NONE;
|
|
54
|
+
return { kind: "pan", key: sc.key, to: clampPan((pan[sc.key] ?? 0) + columns, sc.total, sc.view) };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
feed(ev: MouseIn, block: Block, pan: Record<string, number>): Action {
|
|
58
|
+
switch (ev.type) {
|
|
59
|
+
case "press": {
|
|
60
|
+
if (ev.button === "none" || ev.button === "right") {
|
|
61
|
+
// a horizontal wheel: "none" is SGR 67 (right), "right" is SGR 66 (left)
|
|
62
|
+
const a = this.shift(block, pan, ev, ev.button === "none" ? WHEEL_COLUMNS : -WHEEL_COLUMNS);
|
|
63
|
+
if (a.kind !== "none") this.stuck = true;
|
|
64
|
+
return a;
|
|
65
|
+
}
|
|
66
|
+
this.ignoreClickAt = null; // a new gesture: the click that followed an earlier tap is long gone
|
|
67
|
+
if (ev.button !== "left") return NONE;
|
|
68
|
+
this.pressAt = { x: ev.x, y: ev.y };
|
|
69
|
+
const t = targetAt(block, ev.x, ev.y);
|
|
70
|
+
if (t?.kind === "pan" && t.scroll?.part === "track") {
|
|
71
|
+
this.drag = { key: t.scroll.key, track: t.rect, total: t.scroll.total, view: t.scroll.view };
|
|
72
|
+
return { kind: "pan", key: t.scroll.key, to: panFromTrack(ev.x, t.rect, t.scroll.total, t.scroll.view), capture: true };
|
|
73
|
+
}
|
|
74
|
+
return NONE;
|
|
75
|
+
}
|
|
76
|
+
case "drag": {
|
|
77
|
+
const d = this.drag;
|
|
78
|
+
if (!d || ev.button !== "left") return NONE;
|
|
79
|
+
return { kind: "pan", key: d.key, to: panFromTrack(ev.x, d.track, d.total, d.view), capture: true };
|
|
80
|
+
}
|
|
81
|
+
case "release": {
|
|
82
|
+
if (ev.button !== "left") return NONE;
|
|
83
|
+
this.drag = null;
|
|
84
|
+
const wasStuck = this.stuck;
|
|
85
|
+
const press = this.pressAt;
|
|
86
|
+
this.pressAt = null;
|
|
87
|
+
this.stuck = false; // pi has seen a release: whatever it was holding is let go
|
|
88
|
+
if (wasStuck && same(press, ev.x, ev.y)) {
|
|
89
|
+
this.ignoreClickAt = { x: ev.x, y: ev.y };
|
|
90
|
+
return { kind: "tap", x: ev.x, y: ev.y };
|
|
91
|
+
}
|
|
92
|
+
return NONE;
|
|
93
|
+
}
|
|
94
|
+
case "click": {
|
|
95
|
+
this.stuck = false; // pi sent a click: it is healthy
|
|
96
|
+
if (same(this.ignoreClickAt, ev.x, ev.y)) {
|
|
97
|
+
this.ignoreClickAt = null;
|
|
98
|
+
return { kind: "swallow" };
|
|
99
|
+
}
|
|
100
|
+
this.ignoreClickAt = null;
|
|
101
|
+
return NONE;
|
|
102
|
+
}
|
|
103
|
+
case "wheel": {
|
|
104
|
+
if (!ev.shift && !ev.alt) return NONE; // the plain wheel scrolls the transcript
|
|
105
|
+
const d = Math.sign(ev.wheelDelta ?? 0);
|
|
106
|
+
return d ? this.shift(block, pan, ev, d * WHEEL_COLUMNS) : NONE;
|
|
107
|
+
}
|
|
108
|
+
default:
|
|
109
|
+
return NONE;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|