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,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
- return spatial(order, cur, key);
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
- const k = lastColon(t.id);
168
- const path = t.id.slice(7, k);
169
- state.picked[path] = t.label;
170
- if (t.send) env.send(t.send);
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
- if (p.x < cur) next = Math.max(0, p.x - 2);
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, "choose to answer");
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
  }
@@ -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 { tableBlock } from "./table.ts";
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
- blocks.push(tableBlock(head, body, width).block);
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
+ }
@@ -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 { decorateNotes } from "./notes.ts";
11
- import { tableBlock } from "./table.ts";
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 base = capRows(renderEl(root, Math.max(20, width), "0", ctx), warnings);
28
- const block = decorateNotes(base, state, Math.max(20, width));
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
- t.anchors.cols.forEach((r, i) => anchors.set(`col:${cols[i]}`, { rect: r, badge: pad(r, r.y) }));
84
- t.anchors.cells.forEach((rs, ri) => rs.forEach((r, ci) => anchors.set(`cell:${ri + 1}.${cols[ci]}`, { rect: r, badge: pad(r, r.y) })));
85
- const key = `pan:${path}`;
86
- const pb = panBlock(t.block, width, key, ctx);
87
- const dx = pb === t.block ? 0 : ctx.state.pan[key] ?? 0;
88
- if (dx) {
89
- for (const [, a] of anchors) {
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 pan = b.targets[0]?.pan;
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]];
@@ -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], [14, 10], [10, 8]] as const) {
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
- const cap = lay.total <= width ? 48 : 10;
94
- void cap;
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 = lay.total <= width ? 48 : 10;
96
+ const msgCap = 48;
97
97
  // rows
98
98
  const H0 = 3;
99
99
  let y = H0;