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.
- package/CHANGELOG.md +96 -0
- package/README.md +49 -6
- package/package.json +3 -2
- package/src/blocks/choice.ts +152 -0
- package/src/blocks/code.ts +16 -3
- package/src/blocks/core.ts +150 -55
- 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 +85 -11
- 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 +34 -21
- package/src/blocks/sequence.ts +4 -4
- package/src/blocks/table.ts +122 -14
- package/src/blocks/types.ts +31 -4
- package/src/blocks/util.ts +5 -0
- package/src/diagram/canvas.ts +11 -4
- package/src/diagram/dag.ts +4 -3
- package/src/diagram/erd.ts +4 -2
- package/src/diagram/infra.ts +3 -2
- package/src/diagram/palette.ts +1 -1
- package/src/diagram/wide.ts +4 -5
- package/src/diagram/widefix.ts +56 -0
- package/src/lang/docs.ts +81 -25
- package/src/lang/library.ts +154 -22
- package/src/lang/samples.ts +144 -1
- package/src/pi/config.ts +185 -0
- package/src/pi/events.ts +150 -0
- package/src/pi/extension.ts +82 -20
- package/src/pi/widget.ts +189 -18
- package/src/pi/widths.ts +16 -0
package/src/blocks/core.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
const
|
|
126
|
-
const
|
|
127
|
-
const hint =
|
|
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
|
|
130
|
-
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
});
|
|
191
|
+
rows.push(count, send, clear);
|
|
192
|
+
sendAt = { x: 0, y: 1 };
|
|
193
|
+
clearAt = { x: 0, y: 2 };
|
|
149
194
|
}
|
|
150
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
218
|
+
used += w;
|
|
219
|
+
cur.push(i);
|
|
180
220
|
});
|
|
181
|
-
|
|
182
|
-
const
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
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
|
+
}
|