pi-openui 0.1.2 → 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.
@@ -1,10 +1,11 @@
1
1
  // Layout and simple components: Stack, Text, Card, Callout, Metric, Button, Choice, Tabs, Faq, Image + tooltips.
2
- import { textWidth, truncateText, type Rect } from "../diagram/canvas.ts";
2
+ import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
3
3
  import type { El } from "../lang/library.ts";
4
+ import { buildMessage, cleanOther, makeChoice, progress, type Choice } from "./choice.ts";
4
5
  import { codeInner } from "./code.ts";
5
6
  import { markdownBlock } from "./markdown.ts";
6
7
  import { itemId, itemInfo, NOTEABLE, parentKeyOf, WHOLE_ONLY } from "./notes.ts";
7
- import type { Block, Ctx, Target, Tip } from "./types.ts";
8
+ import type { Block, Ctx, Target, Tip, WState } from "./types.ts";
8
9
  import { blank, boxed, cell, fromRows, hstack, padRow, shiftTargets, textCells, vstack, wrapRuns, wrapText } from "./util.ts";
9
10
 
10
11
  const ROW_GAP: Record<string, number> = { none: 0, xs: 0, s: 0, m: 1, l: 1, xl: 2 };
@@ -36,7 +37,11 @@ export function stack(el: El, width: number, ctx: Ctx, path: string): Block {
36
37
  const gap = COL_GAP[gapTok] ?? 2;
37
38
  const nat = kids.map(natural);
38
39
  const lines: number[][] = [];
39
- 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) {
40
45
  let cur: number[] = [];
41
46
  let used = 0;
42
47
  const share = Math.max(14, Math.floor((width - 2 * gap) / 3));
@@ -118,68 +123,129 @@ export function button(el: El, width: number, path: string): Block {
118
123
  return fromRows(Math.max(cells.length, 1), [cells], [t]);
119
124
  }
120
125
 
126
+ const SEND = "[ Send answers ]";
127
+ const CLEAR = "[ Clear ]";
128
+
129
+ /** One question: its tag, hint, one row per option and (unless `other` is false) an Other row. A click only stages the answer. */
121
130
  export function choice(el: El, width: number, ctx: Ctx, path: string): Block {
122
- const p = el.props;
123
- const q = String(p.question ?? "");
124
- const opts: string[] = p.options ?? [];
125
- const picked = ctx.state.picked[path];
126
- const head = wrapRuns([{ t: q, role: "choice.q" }], width);
127
- const hint = p.hint ? wrapText(String(p.hint), width, "text.muted") : [];
131
+ const c = makeChoice(el.props);
132
+ const ans = ctx.state.answers[c.key];
133
+ const locked = ctx.state.sent;
134
+ const head = wrapRuns([{ t: c.question, role: "choice.q" }], width);
135
+ const tag = [textCells(c.multiple ? "Choose all that apply" : "Choose one", "choice.tag")];
136
+ const hint = el.props.hint ? wrapText(String(el.props.hint), width, "text.muted") : [];
137
+ const rows: Cell[][] = [...head, ...tag, ...hint];
128
138
  const targets: Target[] = [];
129
- const lines: Array<ReturnType<typeof textCells>> = [];
130
- const optCells = opts.map((o, i) => {
131
- const on = picked === o;
132
- return { i, cells: [cell(on ? "●" : "○", on ? "choice.picked" : "choice"), cell(" "), ...textCells(truncateText(o, Math.max(4, width - 3)), on ? "choice.picked" : "choice")] };
133
- });
134
- const total = optCells.reduce((a, o, k) => a + o.cells.length + (k ? 3 : 0), 0);
135
- const baseY = head.length + hint.length;
136
- if (total <= width) {
137
- let line: typeof lines[number] = [];
138
- optCells.forEach((o, k) => {
139
- if (k) line.push(cell(" "), cell(" "), cell(" "));
140
- targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: line.length, y: baseY, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
141
- line.push(...o.cells);
142
- });
143
- lines.push(line);
139
+ const row = (on: boolean, label: string, index: number | "other") => {
140
+ const role = on ? "choice.picked" : locked ? "choice.locked" : "choice";
141
+ const mark = c.multiple ? (on ? "[✓]" : "[ ]") : on ? "●" : "○";
142
+ const indent = textWidth(mark) + 1;
143
+ // the whole option is drawn: what is sent is what the user could read. A long one wraps under its first word.
144
+ const lines = wrapText(label, Math.max(4, width - indent), role);
145
+ const y = rows.length;
146
+ lines.forEach((l, k) => rows.push(k === 0 ? [...textCells(mark, role), cell(" ", role), ...l] : [...Array.from({ length: indent }, () => cell(" ")), ...l]));
147
+ if (!locked) targets.push({ id: `choice:${path}:${index}`, kind: "choice", rect: { x: 0, y, w: width, h: lines.length }, label, choice: { of: c, index } });
148
+ };
149
+ c.options.forEach((o, i) => row(!!ans?.picks.includes(i), o, i));
150
+ if (c.other) {
151
+ const typed = ans?.other ? cleanOther(ans.text) : "";
152
+ row(typed !== "", "Other...", "other");
153
+ if (typed) {
154
+ const room = Math.max(4, width - 4);
155
+ const lines = wrapText(typed.replace(/\n/g, " ¶ "), room, "choice.other");
156
+ const shown = lines.slice(0, 2);
157
+ if (lines.length > 2) shown[1] = textCells(truncateText(shown[1].map((x) => x.ch).join("") + "…", room), "choice.other");
158
+ for (const l of shown) rows.push([cell(" "), cell(" "), cell(" "), cell(" "), ...l]);
159
+ }
160
+ }
161
+ return fromRows(width, rows, targets);
162
+ }
163
+
164
+ /**
165
+ * The bar under a widget that has Choices: how many are answered, and the buttons. Send carries the finished message, and has a
166
+ * target only when something is answered; Clear only when something is staged. After Send the bar only says so.
167
+ */
168
+ export function answerBar(choices: Choice[], state: WState, title: string, width: number): Block {
169
+ if (state.sent) return fromRows(width, [state.sending ? textCells("Sending to the agent…", "text.muted") : textCells("Sent to the agent.", "choice.sent")]);
170
+ const { answered, total } = progress(choices, state.answers);
171
+ const staged = Object.values(state.answers).some((a) => a.picks.length > 0 || a.other);
172
+ const count = textCells(`${answered} of ${total} answered`, "text.muted");
173
+ const sendW = textWidth(SEND);
174
+ const clearW = textWidth(CLEAR);
175
+ const send = textCells(SEND, answered > 0 ? "btn" : "choice.locked");
176
+ const clear = textCells(CLEAR, staged ? "btn" : "choice.locked");
177
+ const gap = [cell(" "), cell(" ")];
178
+ // as much as fits on one row: the count and both buttons, else the count above the buttons, else one button per row
179
+ const rows: Cell[][] = [];
180
+ let sendAt: { x: number; y: number };
181
+ let clearAt: { x: number; y: number };
182
+ if (count.length + 2 + sendW + 2 + clearW <= width) {
183
+ rows.push([...count, ...gap, ...send, ...gap, ...clear]);
184
+ sendAt = { x: count.length + 2, y: 0 };
185
+ clearAt = { x: count.length + 2 + sendW + 2, y: 0 };
186
+ } else if (sendW + 2 + clearW <= width) {
187
+ rows.push(count, [...send, ...gap, ...clear]);
188
+ sendAt = { x: 0, y: 1 };
189
+ clearAt = { x: sendW + 2, y: 1 };
144
190
  } else {
145
- optCells.forEach((o, k) => {
146
- targets.push({ id: `choice:${path}:${o.i}`, kind: "choice", rect: { x: 0, y: baseY + k, w: o.cells.length, h: 1 }, label: opts[o.i], send: `${q} — ${opts[o.i]}` });
147
- lines.push(o.cells);
148
- });
191
+ rows.push(count, send, clear);
192
+ sendAt = { x: 0, y: 1 };
193
+ clearAt = { x: 0, y: 2 };
149
194
  }
150
- return fromRows(width, [...head, ...hint, ...lines], targets);
195
+ const targets: Target[] = [];
196
+ if (answered > 0) targets.push({ id: "send", kind: "send", rect: { ...sendAt, w: sendW, h: 1 }, label: "Send answers", bar: { choices, title } });
197
+ if (staged) targets.push({ id: "clear", kind: "clear", rect: { ...clearAt, w: clearW, h: 1 }, label: "Clear" });
198
+ return fromRows(width, rows, targets);
151
199
  }
152
200
 
153
201
  export function tabs(el: El, width: number, ctx: Ctx, path: string): Block {
154
202
  const items: El[] = el.props.items ?? [];
155
203
  if (!items.length) return blank(width, 0);
156
204
  const sel = Math.min(Math.max(0, ctx.state.tab[path] ?? 0), items.length - 1);
157
- let labels = items.map((it) => truncateText(String(it.props.label ?? ""), 22));
158
- const need = () => labels.reduce((a, l) => a + textWidth(l) + 4, 0);
159
- let cap = 22;
160
- while (need() > width && cap > 4) labels = items.map((it) => truncateText(String(it.props.label ?? ""), --cap));
161
- const l0: ReturnType<typeof textCells> = [];
162
- const l1: typeof l0 = [];
163
- const l2: typeof l0 = [];
164
- 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;
165
211
  labels.forEach((lab, i) => {
166
- const iw = textWidth(lab) + 2;
167
- const on = i === sel;
168
- const role = on ? "tab.on" : "tab.off";
169
- const x0 = l1.length;
170
- if (on) {
171
- l0.push(cell("╭", role), ...Array.from({ length: iw }, () => cell("─", role)), cell("╮", role));
172
- l1.push(cell("│", role), cell(" ", role), ...textCells(lab, "tab.label"), cell(" ", role), cell("│", role));
173
- l2.push(cell("╯", role), ...Array.from({ length: iw }, () => cell(" ")), cell("╰", role));
174
- } else {
175
- l0.push(...Array.from({ length: iw + 2 }, () => cell(" ")));
176
- l1.push(cell(" "), cell(" "), ...textCells(lab, role), cell(" "), cell(" "));
177
- 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;
178
217
  }
179
- 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);
180
220
  });
181
- while (l2.length < width) l2.push(cell("─", "tab.rule"));
182
- const strip = fromRows(width, [l0, l1, l2], targets);
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);
247
+ });
248
+ const strip = fromRows(width, rows, targets);
183
249
  ctx.tabs.push(String(items[sel].props.label ?? ""));
184
250
  let content: Block;
185
251
  try {
@@ -226,6 +292,16 @@ export function image(el: El, width: number, path: string, ctx: Ctx): { block: B
226
292
  if (placed) {
227
293
  // the picture is drawn over these cells by the host; here they are reserved, framed and clickable
228
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");
229
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 } });
230
306
  const cap = p.caption ? fromRows(placed.cols, wrapText(String(p.caption), placed.cols, "text.muted")) : null;
231
307
  block = boxed(cap ? vstack([area, cap], 0, placed.cols) : area, { role: "image.border", title: alt, titleRole: "image.alt", padX: 0 });
@@ -252,6 +328,10 @@ export interface Anchor {
252
328
  badgeForce?: boolean;
253
329
  /** cells besides `rect` that belong to the element (a pie wedge) */
254
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;
255
335
  }
256
336
 
257
337
  /** The mark that says "this element has a tooltip". Present in the user's font (JetBrains Mono), unlike the circled i. */
@@ -295,6 +375,11 @@ export function tipPanel(label: string, tip: Tip, width: number): Block {
295
375
  if (tip.detail) parts.push(markdownBlock(tip.detail, iw));
296
376
  if (tip.code) parts.push(codeInner(tip.code, tip.language, iw));
297
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
+ }
298
383
  const inner = vstack(parts.length ? parts : [fromRows(iw, [textCells(label, "tip.hint")])], 1, iw);
299
384
  return boxed(inner, { role: `tip.${tip.tone ?? "info"}`, title: label });
300
385
  }
@@ -320,8 +405,9 @@ export function addWholeItem(el: El, block: Block, path: string, ctx: Ctx): void
320
405
  export function withTips(el: El, block: Block, anchors: Map<string, Anchor>, path: string, ctx: Ctx, avail = block.w): Block {
321
406
  registerItems(el, block, anchors, path, ctx);
322
407
  const tips: El[] = el.props.tooltips ?? [];
323
- if (!tips.length) return block;
324
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>();
325
411
  for (const t of tips) {
326
412
  const key = String(t.props.target ?? "");
327
413
  const a = anchors.get(key);
@@ -329,13 +415,22 @@ export function withTips(el: El, block: Block, anchors: Map<string, Anchor>, pat
329
415
  ctx.warnings.push(`${el.type}: tooltip target "${key}" does not match anything in the component`);
330
416
  continue;
331
417
  }
332
- 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 } : {}) };
333
420
  const existing = a.targetId ? block.targets.find((x) => x.id === a.targetId) : undefined;
334
421
  const id = existing ? existing.id : `tip:${path}:${key}`;
335
422
  if (existing) existing.tip = tip;
336
423
  else block.targets.push({ id, kind: "tip", rect: a.rect, label: key, tip, ...(a.cells ? { cells: a.cells } : {}) });
337
424
  placeBadge(block, a, tip.tone ?? "info");
338
- 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());
339
434
  }
340
435
  return panel ? vstack([block, panel], 0, Math.max(block.w, panel.w)) : block;
341
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
+ }