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/interact.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
// Interaction logic, independent of any terminal: hit testing, activation, keyboard navigation, pan reveal.
|
|
2
|
+
import { buildMessage, progress, toggleOption } from "./choice.ts";
|
|
3
|
+
import { DEFAULT_FINE_STEP, fineStepPan, stepPan } from "./pan.ts";
|
|
2
4
|
import type { Block, Target, WState } from "./types.ts";
|
|
3
5
|
|
|
4
6
|
export interface Env {
|
|
5
|
-
/** send text to the assistant as the user's next turn */
|
|
6
|
-
send(text: string): void;
|
|
7
|
+
/** send text to the assistant as the user's next turn. `confirm`: the sender will lock something until the host says it took the text */
|
|
8
|
+
send(text: string, opts?: { confirm?: boolean }): void;
|
|
7
9
|
/** put text on the clipboard */
|
|
8
10
|
copy(text: string): void;
|
|
9
11
|
/** show a short notice under the widget */
|
|
@@ -17,6 +19,8 @@ const area = (t: Target) => t.rect.w * t.rect.h;
|
|
|
17
19
|
* items, the notes tray, and tabs (so you can reach content on another tab). Buttons, choices and the like do not fire.
|
|
18
20
|
*/
|
|
19
21
|
export function isLive(t: Target, notes: boolean): boolean {
|
|
22
|
+
if (t.kind === "panarea") return false; // found with panAreaAt, never selected
|
|
23
|
+
if (t.kind === "pan") return true; // a clipped table needs its bar in notes mode too, to reach the columns a note is about
|
|
20
24
|
if (!notes) return t.kind !== "item";
|
|
21
25
|
return t.kind === "item" || t.kind === "tab" || t.kind === "note" || t.kind === "noteadd" || t.kind === "noteclear" || t.kind === "notes";
|
|
22
26
|
}
|
|
@@ -28,7 +32,7 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
|
|
|
28
32
|
for (const t of block.targets) {
|
|
29
33
|
if (!isLive(t, notes)) continue;
|
|
30
34
|
const r = t.rect;
|
|
31
|
-
const inRect = x >= r.x && y >= r.y && x < r.x + r.w && y < r.y + r.h && r.x + r.w > 0 && r.x < block.w;
|
|
35
|
+
const inRect = x >= r.x && y >= r.y && x < r.x + r.w && y < r.y + r.h && r.x + r.w > 0 && r.x < block.w && x >= (t.pan?.min ?? 0);
|
|
32
36
|
const inCells = !!t.cells && t.cells.some(([cx, cy]) => cx === x && cy === y);
|
|
33
37
|
if (!inRect && !inCells) continue;
|
|
34
38
|
const score = inCells ? 0 : area(t);
|
|
@@ -40,6 +44,40 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
|
|
|
40
44
|
return best;
|
|
41
45
|
}
|
|
42
46
|
|
|
47
|
+
/** One step on a bar: a fine one (`sc.step` cells, anywhere) or a page (three quarters of the pane, to a column edge in a table). */
|
|
48
|
+
function movePan(sc: NonNullable<Target["scroll"]>, cur: number, dir: -1 | 1, page: boolean): number {
|
|
49
|
+
return page ? stepPan(cur, dir, sc.total, sc.view, sc.edges) : fineStepPan(cur, dir, sc.total, sc.view, sc.step ?? DEFAULT_FINE_STEP);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An arrow key while the pan bar is the focused target: scroll. Either arrow scrolls whichever part of the bar has the focus,
|
|
54
|
+
* because Left and Right mean left and right, not "activate this button". False when something else is focused, or when the
|
|
55
|
+
* block is already at that end (the caller then lets the key move the focus as usual). `page` is a page (Shift+arrow, PageUp,
|
|
56
|
+
* PageDown); without it a fine step.
|
|
57
|
+
*/
|
|
58
|
+
export function panKey(block: Block, state: WState, focusId: string | null, key: "left" | "right", page = false): boolean {
|
|
59
|
+
const t = focusId ? block.targets.find((x) => x.id === focusId) : undefined;
|
|
60
|
+
const sc = t?.kind === "pan" ? t.scroll : undefined;
|
|
61
|
+
if (!sc) return false;
|
|
62
|
+
const cur = state.pan[sc.key] ?? 0;
|
|
63
|
+
const next = movePan(sc, cur, key === "left" ? -1 : 1, page);
|
|
64
|
+
if (next === cur) return false;
|
|
65
|
+
state.pan[sc.key] = next;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The pan area under a point: what a wheel or a drag over a clipped block should move. The smallest one when they nest. */
|
|
70
|
+
export function panAreaAt(block: Block, x: number, y: number): Target | null {
|
|
71
|
+
let best: Target | null = null;
|
|
72
|
+
for (const t of block.targets) {
|
|
73
|
+
if (t.kind !== "panarea") continue;
|
|
74
|
+
const r = t.rect;
|
|
75
|
+
if (x < r.x || y < r.y || x >= r.x + r.w || y >= r.y + r.h) continue;
|
|
76
|
+
if (!best || area(t) < area(best)) best = t;
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
43
81
|
export function readingOrder(block: Block, notes = false): Target[] {
|
|
44
82
|
return block.targets.filter((t) => isLive(t, notes)).sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x || a.id.localeCompare(b.id));
|
|
45
83
|
}
|
|
@@ -143,7 +181,10 @@ export function nextTarget(block: Block, currentId: string | null, key: NavKey,
|
|
|
143
181
|
return order[(i + (key === "next" ? 1 : -1) + order.length) % order.length];
|
|
144
182
|
}
|
|
145
183
|
if (!cur) return order[0];
|
|
146
|
-
|
|
184
|
+
const to = spatial(order, cur, key);
|
|
185
|
+
// Down from the options reaches Send answers. The nearest centre would be Clear, and one Enter on it drops every answer.
|
|
186
|
+
if ((key === "down" || key === "up") && to.kind === "clear" && cur.kind !== "send" && cur.kind !== "clear") return order.find((t) => t.kind === "send") ?? to;
|
|
187
|
+
return to;
|
|
147
188
|
}
|
|
148
189
|
|
|
149
190
|
const lastColon = (id: string) => id.lastIndexOf(":");
|
|
@@ -164,12 +205,24 @@ export function activate(t: Target, state: WState, env: Env): boolean {
|
|
|
164
205
|
state.faq[t.id] = !state.faq[t.id];
|
|
165
206
|
return true;
|
|
166
207
|
case "choice": {
|
|
167
|
-
|
|
168
|
-
const
|
|
169
|
-
state.
|
|
170
|
-
|
|
208
|
+
// a click only stages the answer. The Other row opens pi's editor, which the widget handles (it is asynchronous).
|
|
209
|
+
const x = t.choice;
|
|
210
|
+
if (!x || state.sent || x.index === "other") return false;
|
|
211
|
+
state.answers = toggleOption(state.answers, x.of, x.index);
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
case "send": {
|
|
215
|
+
// built now, from the answers as they are: an answer staged since the last frame is in the message, a cleared one is not
|
|
216
|
+
const x = t.bar;
|
|
217
|
+
if (!x || state.sent || !progress(x.choices, state.answers).answered) return false;
|
|
218
|
+
env.send(buildMessage(x.choices, state.answers, x.title), { confirm: true });
|
|
219
|
+
state.sent = true;
|
|
171
220
|
return true;
|
|
172
221
|
}
|
|
222
|
+
case "clear":
|
|
223
|
+
if (state.sent) return false;
|
|
224
|
+
state.answers = {};
|
|
225
|
+
return true;
|
|
173
226
|
case "button":
|
|
174
227
|
if (t.send) env.send(t.send);
|
|
175
228
|
if (t.openUrl) {
|
|
@@ -181,11 +234,21 @@ export function activate(t: Target, state: WState, env: Env): boolean {
|
|
|
181
234
|
if (t.copy !== undefined) env.copy(t.copy);
|
|
182
235
|
state.copied = t.id;
|
|
183
236
|
return true;
|
|
237
|
+
case "pan": {
|
|
238
|
+
// the arrows step; the track is for the pointer (the widget places the thumb where it was pressed)
|
|
239
|
+
const sc = t.scroll;
|
|
240
|
+
if (!sc || sc.part === "track") return false;
|
|
241
|
+
const cur = state.pan[sc.key] ?? 0;
|
|
242
|
+
const next = movePan(sc, cur, sc.part === "left" || sc.part === "pageleft" ? -1 : 1, sc.part === "pageleft" || sc.part === "pageright");
|
|
243
|
+
if (next === cur) return false;
|
|
244
|
+
state.pan[sc.key] = next;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
184
247
|
case "node":
|
|
185
248
|
case "image":
|
|
186
249
|
case "tip": {
|
|
187
250
|
const tip = t.tip;
|
|
188
|
-
if (!tip || !(tip.detail || tip.code || tip.link)) return false;
|
|
251
|
+
if (!tip || !(tip.detail || tip.code || tip.link || tip.full !== undefined)) return false;
|
|
189
252
|
state.tip = state.tip === t.id ? null : t.id;
|
|
190
253
|
return true;
|
|
191
254
|
}
|
|
@@ -199,7 +262,8 @@ export function reveal(t: Target, state: WState): boolean {
|
|
|
199
262
|
if (!p) return false;
|
|
200
263
|
const cur = state.pan[p.key] ?? 0;
|
|
201
264
|
let next = cur;
|
|
202
|
-
|
|
265
|
+
const left = p.min ?? 0; // columns kept in place at the left cover the first `min` cells of the view
|
|
266
|
+
if (p.x < cur + left) next = Math.max(0, p.x - left - 2);
|
|
203
267
|
else if (p.x + p.w > cur + p.view) next = Math.min(p.total - p.view, p.x + p.w - p.view + 2);
|
|
204
268
|
if (next === cur) return false;
|
|
205
269
|
state.pan[p.key] = next;
|
|
@@ -214,7 +278,13 @@ export function describe(t: Target): string {
|
|
|
214
278
|
bits.push(`[${t.label}]`, t.send ? `sends "${t.send.length > 70 ? t.send.slice(0, 69) + "…" : t.send}"` : t.openUrl ? `link ${t.openUrl}` : "");
|
|
215
279
|
break;
|
|
216
280
|
case "choice":
|
|
217
|
-
bits.push(t.label, "
|
|
281
|
+
bits.push(t.label, t.choice?.index === "other" ? "Enter to type your own answer" : t.choice?.of.multiple ? "Enter to toggle" : "Enter to select");
|
|
282
|
+
break;
|
|
283
|
+
case "send":
|
|
284
|
+
bits.push("send all the answers to the agent as one message");
|
|
285
|
+
break;
|
|
286
|
+
case "clear":
|
|
287
|
+
bits.push("clear every answer");
|
|
218
288
|
break;
|
|
219
289
|
case "tab":
|
|
220
290
|
bits.push(`tab ${t.label}`);
|
|
@@ -228,6 +298,9 @@ export function describe(t: Target): string {
|
|
|
228
298
|
case "image":
|
|
229
299
|
bits.push(t.label);
|
|
230
300
|
break;
|
|
301
|
+
case "pan":
|
|
302
|
+
bits.push(t.label, t.scroll?.part === "track" ? "click to jump, or drag" : t.scroll?.part === "pageleft" || t.scroll?.part === "pageright" ? "a page at a time" : `${t.scroll?.step ?? DEFAULT_FINE_STEP} cells at a time`);
|
|
303
|
+
break;
|
|
231
304
|
case "item":
|
|
232
305
|
bits.push(t.label, "Enter to write a note");
|
|
233
306
|
break;
|
|
@@ -248,5 +321,6 @@ export function describe(t: Target): string {
|
|
|
248
321
|
}
|
|
249
322
|
if (t.tip?.hint) bits.push(t.tip.hint);
|
|
250
323
|
if (t.tip && (t.tip.detail || t.tip.code || t.tip.link)) bits.push("Enter for details");
|
|
324
|
+
else if (t.tip?.full !== undefined) bits.push("tap to read all"); // short: this line is cut at the width of the pane
|
|
251
325
|
return bits.filter(Boolean).join(" · ");
|
|
252
326
|
}
|
package/src/blocks/markdown.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { textWidth } from "../diagram/canvas.ts";
|
|
|
2
2
|
import { imageLine } from "../lang/md.ts";
|
|
3
3
|
import { codeBlock } from "./code.ts";
|
|
4
4
|
import { image } from "./core.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { panBlock } from "./panblock.ts";
|
|
6
|
+
import { withTips, type Anchor } from "./core.ts";
|
|
7
|
+
import { cutCells, tableBlock, tablePan } from "./table.ts";
|
|
6
8
|
import type { Block, Ctx } from "./types.ts";
|
|
7
9
|
import { cell, fromRows, vstack, wrapRuns, textCells, type Run, padRow } from "./util.ts";
|
|
8
10
|
|
|
@@ -46,6 +48,7 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
46
48
|
const blocks: Block[] = [];
|
|
47
49
|
let i = 0;
|
|
48
50
|
let pictures = 0;
|
|
51
|
+
let wide = 0; // code fences and tables: each scrolls on its own
|
|
49
52
|
const para: string[] = [];
|
|
50
53
|
const flush = () => {
|
|
51
54
|
if (!para.length) return;
|
|
@@ -61,7 +64,7 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
61
64
|
i++;
|
|
62
65
|
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) body.push(lines[i++]);
|
|
63
66
|
i++;
|
|
64
|
-
blocks.push(codeBlock(body.join("\n"), fence[1] || undefined, undefined, width, null, false));
|
|
67
|
+
blocks.push(codeBlock(body.join("\n"), fence[1] || undefined, undefined, width, null, false, ctx ? { ctx, key: `pan:${path}.w${wide++}` } : undefined));
|
|
65
68
|
continue;
|
|
66
69
|
}
|
|
67
70
|
const pic = ctx ? imageLine(ln) : null;
|
|
@@ -100,7 +103,14 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
100
103
|
i += 2;
|
|
101
104
|
const body: string[][] = [];
|
|
102
105
|
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
|
|
103
|
-
|
|
106
|
+
if (ctx) {
|
|
107
|
+
const t = tableBlock(head, body, width, { minCol: ctx.services.settings?.tableMinCol ?? 12 });
|
|
108
|
+
const id = `${path}.w${wide++}`;
|
|
109
|
+
const shown = panBlock(t.block, width, `pan:${id}`, ctx, tablePan(t, width));
|
|
110
|
+
// a cell cut to fit can be read whole, as in a Table (only where there is a context to hold targets)
|
|
111
|
+
const anchors = new Map<string, Anchor>(cutCells(t, head, body).map((c) => [c.key, { rect: c.rect, full: c.full, label: c.label }]));
|
|
112
|
+
blocks.push(anchors.size ? withTips({ type: "MarkdownTable", props: {}, line: 0 }, shown, anchors, id, ctx, width) : shown);
|
|
113
|
+
} else blocks.push(tableBlock(head, body, width).block);
|
|
104
114
|
continue;
|
|
105
115
|
}
|
|
106
116
|
const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(ln);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Panning a block that is wider than its pane: the maths, and the one-row bar that tells the reader it can be scrolled.
|
|
2
|
+
// Pure: no widget, no terminal, no pi.
|
|
3
|
+
import type { Cell, Rect } from "../diagram/canvas.ts";
|
|
4
|
+
import { cell, textCells } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/** One step of a tap or a key: three quarters of the view, so a little of what was on screen stays as a landmark. */
|
|
7
|
+
export const panStep = (view: number): number => Math.max(1, Math.floor((view * 3) / 4));
|
|
8
|
+
|
|
9
|
+
/** The offset kept inside 0..total-view. A block that fits has nothing to pan. */
|
|
10
|
+
export function clampPan(pan: number, total: number, view: number): number {
|
|
11
|
+
const max = Math.max(0, Math.floor(total) - Math.floor(view));
|
|
12
|
+
return Math.max(0, Math.min(Math.round(pan), max));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Where the thumb sits in a track `track` cells long: as long as the view is of the whole, at least one cell. */
|
|
16
|
+
export function thumbCells(track: number, pan: number, total: number, view: number): { start: number; len: number } {
|
|
17
|
+
if (track <= 0) return { start: 0, len: 0 };
|
|
18
|
+
if (total <= view) return { start: 0, len: track };
|
|
19
|
+
const len = Math.max(1, Math.min(track, Math.round((track * view) / total)));
|
|
20
|
+
const max = total - view;
|
|
21
|
+
const start = Math.round(((track - len) * clampPan(pan, total, view)) / max);
|
|
22
|
+
return { start: Math.max(0, Math.min(track - len, start)), len };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The offset that puts the middle of the thumb under cell `x` (a tap or a drag on the track). */
|
|
26
|
+
export function panFromTrack(x: number, track: { x: number; w: number }, total: number, view: number): number {
|
|
27
|
+
const { len } = thumbCells(track.w, 0, total, view);
|
|
28
|
+
const room = track.w - len;
|
|
29
|
+
if (room <= 0 || total <= view) return 0;
|
|
30
|
+
const start = Math.max(0, Math.min(room, x - track.x - len / 2));
|
|
31
|
+
return clampPan((start / room) * (total - view), total, view);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One tap on an arrow. Plain: `panStep` either way. With `edges` (the left edges of a table's columns, in block cells): the
|
|
36
|
+
* farthest edge within one step, so a column is never left cut in half after a tap, and a left tap undoes a right tap.
|
|
37
|
+
*/
|
|
38
|
+
export function stepPan(pan: number, dir: -1 | 1, total: number, view: number, edges?: number[]): number {
|
|
39
|
+
const step = panStep(view);
|
|
40
|
+
let to = pan + dir * step;
|
|
41
|
+
if (edges?.length) {
|
|
42
|
+
const hit = dir === 1 ? edges.filter((e) => e > pan && e <= pan + step) : edges.filter((e) => e < pan && e >= pan - step);
|
|
43
|
+
if (hit.length) to = dir === 1 ? Math.max(...hit) : Math.min(...hit);
|
|
44
|
+
}
|
|
45
|
+
return clampPan(to, total, view);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cells one tap on a fine arrow moves, unless the user's `scroll.step` says otherwise (src/pi/config.ts). */
|
|
49
|
+
export const DEFAULT_FINE_STEP = 8;
|
|
50
|
+
|
|
51
|
+
/** One tap on a fine arrow: exactly `cells`, clamped. No snapping: this is for reading along. */
|
|
52
|
+
export function fineStepPan(pan: number, dir: -1 | 1, total: number, view: number, cells: number): number {
|
|
53
|
+
return clampPan(pan + dir * Math.max(1, Math.round(cells)), total, view);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BarLayout {
|
|
57
|
+
/** the outer arrows, a page at a time; absent when the bar is too narrow for them */
|
|
58
|
+
pageLeft?: Rect;
|
|
59
|
+
/** the inner arrows, a few cells at a time */
|
|
60
|
+
left: Rect;
|
|
61
|
+
track: Rect;
|
|
62
|
+
right: Rect;
|
|
63
|
+
pageRight?: Rect;
|
|
64
|
+
/** the range shown, e.g. `1–63/175`; empty when the bar is too narrow for it */
|
|
65
|
+
label: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ARROW = 3; // a finger needs more than one cell: ` ‹ `
|
|
69
|
+
const MIN_TRACK = 6;
|
|
70
|
+
|
|
71
|
+
/** ` « ‹ ████░░░░ 1–63/175 › » `: page arrows outside, fine arrows inside, the track and the range between. */
|
|
72
|
+
export function barLayout(width: number, pan: number, total: number, view: number): BarLayout {
|
|
73
|
+
const paged = width - 4 * ARROW >= MIN_TRACK;
|
|
74
|
+
const side = paged ? 2 * ARROW : ARROW;
|
|
75
|
+
const mid = Math.max(0, width - 2 * side);
|
|
76
|
+
let label = `${pan + 1}–${Math.min(total, pan + view)}/${total}`;
|
|
77
|
+
let track = mid - (label.length + 2);
|
|
78
|
+
if (track < MIN_TRACK) {
|
|
79
|
+
label = "";
|
|
80
|
+
track = mid;
|
|
81
|
+
}
|
|
82
|
+
const at = (x: number): Rect => ({ x, y: 0, w: ARROW, h: 1 });
|
|
83
|
+
return {
|
|
84
|
+
...(paged ? { pageLeft: at(0), pageRight: at(width - ARROW) } : {}),
|
|
85
|
+
left: at(paged ? ARROW : 0),
|
|
86
|
+
right: at(width - side),
|
|
87
|
+
track: { x: side, y: 0, w: Math.max(0, track), h: 1 },
|
|
88
|
+
label,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** ` « ‹ ████░░░░░░░░ 1–63/175 › » `: exactly `width` cells. An arrow that cannot move is dim. */
|
|
93
|
+
export function barRow(width: number, pan: number, total: number, view: number): Cell[] {
|
|
94
|
+
const l = barLayout(width, pan, total, view);
|
|
95
|
+
const canLeft = pan > 0;
|
|
96
|
+
const canRight = pan < total - view;
|
|
97
|
+
const t = thumbCells(l.track.w, pan, total, view);
|
|
98
|
+
const arrow = (glyph: string, on: boolean): Cell[] => [cell(" "), cell(glyph, on ? "btn" : "text.muted"), cell(" ")];
|
|
99
|
+
const row: Cell[] = [...(l.pageLeft ? arrow("«", canLeft) : []), ...arrow("‹", canLeft)];
|
|
100
|
+
for (let i = 0; i < l.track.w; i++) row.push(i >= t.start && i < t.start + t.len ? cell("█", "btn") : cell("░", "text.muted"));
|
|
101
|
+
if (l.label) row.push(cell(" "), ...textCells(l.label, "text.muted"), cell(" "));
|
|
102
|
+
const right: Cell[] = [...arrow("›", canRight), ...(l.pageRight ? arrow("»", canRight) : [])];
|
|
103
|
+
while (row.length < width - right.length) row.push(cell(" "));
|
|
104
|
+
return [...row, ...right].slice(0, width);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pinned columns never take more than this share of the pane: a pinned part that fills it would leave nothing to scroll. */
|
|
108
|
+
export const PIN_SHARE = 0.5;
|
|
109
|
+
export const pinBudget = (view: number): number => Math.floor(view * PIN_SHARE);
|
|
110
|
+
|
|
111
|
+
/** How many cells at the left stay put while the rest scrolls: `frozen` when it is within the budget, else none. */
|
|
112
|
+
export const frozenFor = (frozen: number, view: number): number => (frozen > 0 && frozen <= pinBudget(view) ? frozen : 0);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Move an anchor (a rect and perhaps a badge position) to where its column is after the block was scrolled by `dx` with `frozen`
|
|
116
|
+
* columns kept in place. What is behind the frozen part is cut off, so a badge or a hit area never lands on the frozen column;
|
|
117
|
+
* what is entirely behind it keeps a rect one cell wide at the edge with no width (nothing to hit, nothing to draw).
|
|
118
|
+
*/
|
|
119
|
+
export function shiftAnchor<T extends { rect: Rect; badge?: [number, number] }>(a: T, dx: number, frozen: number): void {
|
|
120
|
+
const r = a.rect;
|
|
121
|
+
if (!dx) return;
|
|
122
|
+
if (!frozen) {
|
|
123
|
+
a.rect = { ...r, x: r.x - dx };
|
|
124
|
+
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (r.x < frozen) {
|
|
128
|
+
if (r.x + r.w > frozen) a.rect = { ...r, w: r.w - dx }; // a row across the frozen part: its far end moves
|
|
129
|
+
return; // in the frozen part: stays
|
|
130
|
+
}
|
|
131
|
+
let x = r.x - dx;
|
|
132
|
+
let w = r.w;
|
|
133
|
+
if (x < frozen) {
|
|
134
|
+
w -= frozen - x;
|
|
135
|
+
x = frozen;
|
|
136
|
+
}
|
|
137
|
+
a.rect = { ...r, x, w: Math.max(0, w) };
|
|
138
|
+
if (a.badge) a.badge = a.badge[0] - dx >= frozen && w > 0 ? [a.badge[0] - dx, a.badge[1]] : undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The one place a block wider than its pane is cropped, scrolled and given its pan bar (see pan.ts for the maths).
|
|
2
|
+
import type { Ctx, Block, Target } from "./types.ts";
|
|
3
|
+
import { barLayout, barRow, clampPan, DEFAULT_FINE_STEP, frozenFor } from "./pan.ts";
|
|
4
|
+
import { cell, padRow } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Crop a block wider than `width` to a window starting at the saved pan offset, and put a pan bar under it: the one place that
|
|
8
|
+
* says "there is more" and the buttons that move it. A block that fits is returned as it is, with no bar.
|
|
9
|
+
* `opts.edges` are the left edges of a table's columns, so a tap lands on a column. `opts.frozen` is how many cells at the left
|
|
10
|
+
* stay in place while the rest scrolls (a table's first column), when that is at most 40% of the view.
|
|
11
|
+
*/
|
|
12
|
+
export function panBlock(inner: Block, width: number, key: string, ctx: Ctx, opts: { edges?: number[]; frozen?: number } = {}): Block {
|
|
13
|
+
if (inner.w <= width) {
|
|
14
|
+
delete ctx.state.pan[key]; // it fits now (the pane grew): nothing is left to scroll, and a later narrow pane starts at the left
|
|
15
|
+
return inner;
|
|
16
|
+
}
|
|
17
|
+
const pan = clampPan(ctx.state.pan[key] ?? 0, inner.w, width);
|
|
18
|
+
ctx.state.pan[key] = pan;
|
|
19
|
+
const F = frozenFor(opts.frozen ?? 0, width);
|
|
20
|
+
const rows = inner.rows.map((r) => {
|
|
21
|
+
const full = padRow(r, inner.w);
|
|
22
|
+
// the window starts after the frozen cells: at pan 0 this is the plain crop
|
|
23
|
+
const seg = full.slice(F + pan, pan + width);
|
|
24
|
+
if (seg.length && seg[0].ch === "") seg[0] = cell(" ");
|
|
25
|
+
if (seg.length && pan + width < inner.w && seg[seg.length - 1].ch !== "" && full[pan + width]?.ch === "") seg[seg.length - 1] = cell(" ");
|
|
26
|
+
return F ? full.slice(0, F).concat(seg) : seg;
|
|
27
|
+
});
|
|
28
|
+
const targets: Target[] = inner.targets.map((t) => {
|
|
29
|
+
if (F && t.rect.x < F) return t; // in the frozen part, or a row across it: always where it is
|
|
30
|
+
return { ...t, rect: { ...t.rect, x: t.rect.x - pan }, pan: { key, x: t.rect.x, w: t.rect.w, view: width, total: inner.w, ...(F ? { min: F } : {}) } };
|
|
31
|
+
});
|
|
32
|
+
const y = rows.length;
|
|
33
|
+
const bar = barLayout(width, pan, inner.w, width);
|
|
34
|
+
const step = ctx.services.settings?.scrollStep ?? DEFAULT_FINE_STEP;
|
|
35
|
+
type Part = "left" | "right" | "pageleft" | "pageright" | "track";
|
|
36
|
+
const scroll = (part: Part) => ({ key, part, total: inner.w, view: width, edges: opts.edges, step });
|
|
37
|
+
const part = (id: Part, label: string, r: { x: number; w: number }): Target => ({ id: `bar:${key}:${id}`, kind: "pan", rect: { x: r.x, y, w: r.w, h: 1 }, label, scroll: scroll(id) });
|
|
38
|
+
if (bar.pageLeft) targets.push(part("pageleft", "page left", bar.pageLeft));
|
|
39
|
+
targets.push(part("left", "scroll left", bar.left), part("track", "scroll", bar.track), part("right", "scroll right", bar.right));
|
|
40
|
+
if (bar.pageRight) targets.push(part("pageright", "page right", bar.pageRight));
|
|
41
|
+
// the area carries the numbers a wheel or a drag needs (`part` means nothing here)
|
|
42
|
+
targets.push({ id: `area:${key}`, kind: "panarea", rect: { x: 0, y: 0, w: width, h: y + 1 }, label: "", scroll: scroll("track") });
|
|
43
|
+
return { w: width, rows: [...rows, barRow(width, pan, inner.w, width)], targets };
|
|
44
|
+
}
|
package/src/blocks/render.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
import type { El } from "../lang/library.ts";
|
|
3
3
|
import { barChart, lineArea, pieChart, radarChart } from "./charts.ts";
|
|
4
4
|
import { codeBlock } from "./code.ts";
|
|
5
|
-
import { addWholeItem, button, callout, card, choice, faq, image, metric, stack, tabs, text, withTips, type Anchor } from "./core.ts";
|
|
5
|
+
import { addWholeItem, answerBar, button, callout, card, choice, faq, image, metric, stack, tabs, text, withTips, type Anchor } from "./core.ts";
|
|
6
6
|
import { dagBlock, erdBlock, infraBlock, panBlock } from "./diagrams.ts";
|
|
7
7
|
import { geoMap } from "./geo.ts";
|
|
8
8
|
import { markdownBlock } from "./markdown.ts";
|
|
9
9
|
import { sequence } from "./sequence.ts";
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
10
|
+
import { collectChoices } from "./choice.ts";
|
|
11
|
+
import { decorateNotes, widgetTitle } from "./notes.ts";
|
|
12
|
+
import { frozenFor, shiftAnchor } from "./pan.ts";
|
|
13
|
+
import { cutCells, pinIndexes, tableBlock, tablePan } from "./table.ts";
|
|
12
14
|
import type { Block, Ctx, Services, WState } from "./types.ts";
|
|
13
15
|
import { blank, textBlock, vstack } from "./util.ts";
|
|
14
16
|
|
|
@@ -24,14 +26,26 @@ function capRows(block: Block, warnings: string[]): Block {
|
|
|
24
26
|
export function renderTree(root: El, width: number, state: WState, services: Services = {}): { block: Block; warnings: string[] } {
|
|
25
27
|
const warnings: string[] = [];
|
|
26
28
|
const ctx: Ctx = { state, warnings, services, tabs: [], render: (el, w, path) => renderEl(el, w, path, ctx) };
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
+
const w = Math.max(20, width);
|
|
30
|
+
let base = capRows(renderEl(root, w, "0", ctx), warnings);
|
|
31
|
+
// every Choice of the widget, also on tabs nobody opened, shares one bar under the widget (after the row cap, so it is never cut)
|
|
32
|
+
const choices = collectChoices(root);
|
|
33
|
+
if (choices.length) base = vstack([base, answerBar(choices, state, widgetTitle(root), w)], 1, Math.max(base.w, w));
|
|
34
|
+
const block = decorateNotes(base, state, w);
|
|
29
35
|
return { block, warnings };
|
|
30
36
|
}
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The net under every component. Each one shortens its own text or scrolls on its own, so nothing is wider than its pane today; a
|
|
40
|
+
* component that is wider anyway (a new one, a layout that cannot give) gets the pan bar here, never a silent cut by the widget.
|
|
41
|
+
*/
|
|
42
|
+
export function guardWidth(block: Block, width: number, path: string, ctx: Ctx): Block {
|
|
43
|
+
return block.w > width ? panBlock(block, width, `pan:${path}~`, ctx) : block;
|
|
44
|
+
}
|
|
45
|
+
|
|
32
46
|
export function renderEl(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
33
47
|
try {
|
|
34
|
-
const block = renderInner(el, width, path, ctx);
|
|
48
|
+
const block = guardWidth(renderInner(el, width, path, ctx), width, path, ctx);
|
|
35
49
|
addWholeItem(el, block, path, ctx);
|
|
36
50
|
return block;
|
|
37
51
|
} catch (e) {
|
|
@@ -63,7 +77,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
63
77
|
return markdownBlock(String(p.text ?? ""), width, ctx, path);
|
|
64
78
|
case "CodeBlock": {
|
|
65
79
|
const id = `copy:${path}`;
|
|
66
|
-
return codeBlock(String(p.code ?? ""), p.language, p.title, width, id, ctx.state.copied === id);
|
|
80
|
+
return codeBlock(String(p.code ?? ""), p.language, p.title, width, id, ctx.state.copied === id, { ctx, key: `pan:${path}` });
|
|
67
81
|
}
|
|
68
82
|
case "Metric": {
|
|
69
83
|
const m = metric(el, width, path);
|
|
@@ -74,23 +88,23 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
74
88
|
return withTips(el, m.block, m.anchors, path, ctx, width);
|
|
75
89
|
}
|
|
76
90
|
case "Table": {
|
|
77
|
-
const t = tableBlock(p.columns ?? [], p.rows ?? [], width);
|
|
78
|
-
const anchors = new Map<string, Anchor>();
|
|
79
91
|
const cols: string[] = (p.columns ?? []).map(String);
|
|
92
|
+
const t = tableBlock(p.columns ?? [], p.rows ?? [], width, { minCol: ctx.services.settings?.tableMinCol ?? 12, pin: pinIndexes(p.pin, cols) });
|
|
93
|
+
const anchors = new Map<string, Anchor>();
|
|
80
94
|
const pad = (c: { x: number; w: number }, y: number): [number, number] => [c.x + c.w, y];
|
|
81
95
|
const lastCol = t.anchors.cols[t.anchors.cols.length - 1];
|
|
82
96
|
t.anchors.rows.forEach((r, i) => anchors.set(`row:${i + 1}`, { rect: r, badge: lastCol ? pad(lastCol, r.y) : undefined }));
|
|
83
|
-
|
|
84
|
-
t.anchors.
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
91
|
-
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
92
|
-
}
|
|
97
|
+
// the columns may be drawn in another order (pinned ones first): the names follow them
|
|
98
|
+
t.anchors.cols.forEach((r, i) => anchors.set(`col:${cols[t.order[i]]}`, { rect: r, badge: pad(r, r.y) }));
|
|
99
|
+
t.anchors.cells.forEach((rs, ri) => rs.forEach((r, ci) => anchors.set(`cell:${ri + 1}.${cols[t.order[ci]]}`, { rect: r, badge: pad(r, r.y) })));
|
|
100
|
+
// a cell that was cut to fit can be read whole: it gets a target (and a panel) of its own
|
|
101
|
+
for (const c of cutCells(t, cols, p.rows ?? [])) {
|
|
102
|
+
const a = anchors.get(c.key);
|
|
103
|
+
if (a) Object.assign(a, { full: c.full, label: c.label });
|
|
93
104
|
}
|
|
105
|
+
const key = `pan:${path}`;
|
|
106
|
+
const pb = panBlock(t.block, width, key, ctx, tablePan(t, width));
|
|
107
|
+
if (pb !== t.block) for (const [, a] of anchors) shiftAnchor(a, ctx.state.pan[key] ?? 0, frozenFor(t.frozen, width));
|
|
94
108
|
return withTips(el, pb, anchors, path, ctx, width);
|
|
95
109
|
}
|
|
96
110
|
case "BarChart": {
|
|
@@ -130,8 +144,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
130
144
|
const s = sequence(el, width, path);
|
|
131
145
|
const b = panBlock(s.block, width, `pan:${path}`, ctx);
|
|
132
146
|
if (b !== s.block) {
|
|
133
|
-
const
|
|
134
|
-
const dx = pan ? pan.x - b.targets[0].rect.x : 0;
|
|
147
|
+
const dx = ctx.state.pan[`pan:${path}`] ?? 0;
|
|
135
148
|
for (const [, a] of s.anchors) {
|
|
136
149
|
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
137
150
|
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
package/src/blocks/sequence.ts
CHANGED
|
@@ -86,14 +86,14 @@ export function sequence(el: El, width: number, path: string): { block: Block; a
|
|
|
86
86
|
return { heads, x, total, margin };
|
|
87
87
|
};
|
|
88
88
|
let lay = layout(48, 22);
|
|
89
|
-
for (const [cap, hc] of [[36, 22], [28, 18], [20, 14]
|
|
89
|
+
for (const [cap, hc] of [[36, 22], [28, 18], [20, 14]] as const) {
|
|
90
90
|
if (lay.total <= width) break;
|
|
91
91
|
lay = layout(cap, hc);
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// nothing fits even with the shortest labels: it will scroll, so scroll to whole labels instead of cutting them as well
|
|
94
|
+
if (lay.total > width) lay = layout(48, 22);
|
|
95
95
|
const { heads, x, total } = lay;
|
|
96
|
-
const msgCap =
|
|
96
|
+
const msgCap = 48;
|
|
97
97
|
// rows
|
|
98
98
|
const H0 = 3;
|
|
99
99
|
let y = H0;
|