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 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
+ ![A wide table scrolled sideways: Host and State stayed in place, the bar has fine and page arrows](https://raw.githubusercontent.com/newtonapple/pi-openui/main/docs/screenshots/pan-table-scrolled.png)
86
+
87
+ ![A cell cut to fit, tapped: its whole text under the table](https://raw.githubusercontent.com/newtonapple/pi-openui/main/docs/screenshots/table-read-full.png)
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.2.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
  }
@@ -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
- export function codeBlock(code: string, lang: string | undefined, title: string | undefined, width: number, copyId: string | null, copied: boolean): Block {
88
- const inner = codeInner(code, lang, Math.max(8, width - 4));
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" });
@@ -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
- if (el.props.wrap) {
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
- let labels = items.map((it) => truncateText(String(it.props.label ?? ""), 22));
202
- const need = () => labels.reduce((a, l) => a + textWidth(l) + 4, 0);
203
- let cap = 22;
204
- while (need() > width && cap > 4) labels = items.map((it) => truncateText(String(it.props.label ?? ""), --cap));
205
- const l0: ReturnType<typeof textCells> = [];
206
- const l1: typeof l0 = [];
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 iw = textWidth(lab) + 2;
211
- const on = i === sel;
212
- const role = on ? "tab.on" : "tab.off";
213
- const x0 = l1.length;
214
- if (on) {
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
- targets.push({ id: `tab:${path}:${i}`, kind: "tab", rect: { x: x0, y: 1, w: iw + 2, h: 1 }, label: String(items[i].props.label ?? "") });
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
- while (l2.length < width) l2.push(cell("─", "tab.rule"));
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
- 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 };
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, Math.min(Math.max(avail, 20), Math.max(block.w, 44)));
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
  }
@@ -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, anchors must follow the targets they point at */
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
+ }