pi-openui 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  // Interaction logic, independent of any terminal: hit testing, activation, keyboard navigation, pan reveal.
2
2
  import { buildMessage, progress, toggleOption } from "./choice.ts";
3
+ import { DEFAULT_FINE_STEP, fineStepPan, stepPan } from "./pan.ts";
3
4
  import type { Block, Target, WState } from "./types.ts";
4
5
 
5
6
  export interface Env {
@@ -18,6 +19,8 @@ const area = (t: Target) => t.rect.w * t.rect.h;
18
19
  * items, the notes tray, and tabs (so you can reach content on another tab). Buttons, choices and the like do not fire.
19
20
  */
20
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
21
24
  if (!notes) return t.kind !== "item";
22
25
  return t.kind === "item" || t.kind === "tab" || t.kind === "note" || t.kind === "noteadd" || t.kind === "noteclear" || t.kind === "notes";
23
26
  }
@@ -29,7 +32,7 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
29
32
  for (const t of block.targets) {
30
33
  if (!isLive(t, notes)) continue;
31
34
  const r = t.rect;
32
- 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);
33
36
  const inCells = !!t.cells && t.cells.some(([cx, cy]) => cx === x && cy === y);
34
37
  if (!inRect && !inCells) continue;
35
38
  const score = inCells ? 0 : area(t);
@@ -41,6 +44,40 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
41
44
  return best;
42
45
  }
43
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
+
44
81
  export function readingOrder(block: Block, notes = false): Target[] {
45
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));
46
83
  }
@@ -197,11 +234,21 @@ export function activate(t: Target, state: WState, env: Env): boolean {
197
234
  if (t.copy !== undefined) env.copy(t.copy);
198
235
  state.copied = t.id;
199
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
+ }
200
247
  case "node":
201
248
  case "image":
202
249
  case "tip": {
203
250
  const tip = t.tip;
204
- if (!tip || !(tip.detail || tip.code || tip.link)) return false;
251
+ if (!tip || !(tip.detail || tip.code || tip.link || tip.full !== undefined)) return false;
205
252
  state.tip = state.tip === t.id ? null : t.id;
206
253
  return true;
207
254
  }
@@ -215,7 +262,8 @@ export function reveal(t: Target, state: WState): boolean {
215
262
  if (!p) return false;
216
263
  const cur = state.pan[p.key] ?? 0;
217
264
  let next = cur;
218
- 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);
219
267
  else if (p.x + p.w > cur + p.view) next = Math.min(p.total - p.view, p.x + p.w - p.view + 2);
220
268
  if (next === cur) return false;
221
269
  state.pan[p.key] = next;
@@ -250,6 +298,9 @@ export function describe(t: Target): string {
250
298
  case "image":
251
299
  bits.push(t.label);
252
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;
253
304
  case "item":
254
305
  bits.push(t.label, "Enter to write a note");
255
306
  break;
@@ -270,5 +321,6 @@ export function describe(t: Target): string {
270
321
  }
271
322
  if (t.tip?.hint) bits.push(t.tip.hint);
272
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
273
325
  return bits.filter(Boolean).join(" · ");
274
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
+ }
@@ -9,7 +9,8 @@ import { markdownBlock } from "./markdown.ts";
9
9
  import { sequence } from "./sequence.ts";
10
10
  import { collectChoices } from "./choice.ts";
11
11
  import { decorateNotes, widgetTitle } from "./notes.ts";
12
- import { tableBlock } from "./table.ts";
12
+ import { frozenFor, shiftAnchor } from "./pan.ts";
13
+ import { cutCells, pinIndexes, tableBlock, tablePan } from "./table.ts";
13
14
  import type { Block, Ctx, Services, WState } from "./types.ts";
14
15
  import { blank, textBlock, vstack } from "./util.ts";
15
16
 
@@ -34,9 +35,17 @@ export function renderTree(root: El, width: number, state: WState, services: Ser
34
35
  return { block, warnings };
35
36
  }
36
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
+
37
46
  export function renderEl(el: El, width: number, path: string, ctx: Ctx): Block {
38
47
  try {
39
- const block = renderInner(el, width, path, ctx);
48
+ const block = guardWidth(renderInner(el, width, path, ctx), width, path, ctx);
40
49
  addWholeItem(el, block, path, ctx);
41
50
  return block;
42
51
  } catch (e) {
@@ -68,7 +77,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
68
77
  return markdownBlock(String(p.text ?? ""), width, ctx, path);
69
78
  case "CodeBlock": {
70
79
  const id = `copy:${path}`;
71
- 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}` });
72
81
  }
73
82
  case "Metric": {
74
83
  const m = metric(el, width, path);
@@ -79,23 +88,23 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
79
88
  return withTips(el, m.block, m.anchors, path, ctx, width);
80
89
  }
81
90
  case "Table": {
82
- const t = tableBlock(p.columns ?? [], p.rows ?? [], width);
83
- const anchors = new Map<string, Anchor>();
84
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>();
85
94
  const pad = (c: { x: number; w: number }, y: number): [number, number] => [c.x + c.w, y];
86
95
  const lastCol = t.anchors.cols[t.anchors.cols.length - 1];
87
96
  t.anchors.rows.forEach((r, i) => anchors.set(`row:${i + 1}`, { rect: r, badge: lastCol ? pad(lastCol, r.y) : undefined }));
88
- t.anchors.cols.forEach((r, i) => anchors.set(`col:${cols[i]}`, { rect: r, badge: pad(r, r.y) }));
89
- t.anchors.cells.forEach((rs, ri) => rs.forEach((r, ci) => anchors.set(`cell:${ri + 1}.${cols[ci]}`, { rect: r, badge: pad(r, r.y) })));
90
- const key = `pan:${path}`;
91
- const pb = panBlock(t.block, width, key, ctx);
92
- const dx = pb === t.block ? 0 : ctx.state.pan[key] ?? 0;
93
- if (dx) {
94
- for (const [, a] of anchors) {
95
- a.rect = { ...a.rect, x: a.rect.x - dx };
96
- if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
97
- }
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 });
98
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));
99
108
  return withTips(el, pb, anchors, path, ctx, width);
100
109
  }
101
110
  case "BarChart": {
@@ -135,8 +144,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
135
144
  const s = sequence(el, width, path);
136
145
  const b = panBlock(s.block, width, `pan:${path}`, ctx);
137
146
  if (b !== s.block) {
138
- const pan = b.targets[0]?.pan;
139
- const dx = pan ? pan.x - b.targets[0].rect.x : 0;
147
+ const dx = ctx.state.pan[`pan:${path}`] ?? 0;
140
148
  for (const [, a] of s.anchors) {
141
149
  a.rect = { ...a.rect, x: a.rect.x - dx };
142
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;
@@ -1,5 +1,6 @@
1
1
  import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
2
2
  import type { Block } from "./types.ts";
3
+ import { frozenFor, pinBudget } from "./pan.ts";
3
4
  import { cell, fromRows, padRow, textCells } from "./util.ts";
4
5
 
5
6
  export interface TableAnchors {
@@ -10,23 +11,81 @@ export interface TableAnchors {
10
11
 
11
12
  const NUMERIC = /^[-+]?[\d.,]+\s*(%|ms|s|kb|mb|gb|tb|x)?$/i;
12
13
 
13
- export function tableBlock(columns: string[], rows: Array<Array<string | number>>, width: number): { block: Block; anchors: TableAnchors } {
14
+ /**
15
+ * `opts.minCol` is the user's minimum column width. With it, a table that does not fit shrinks its widest columns only down to
16
+ * min(natural, minCol); if even that does not fit, every column takes its natural width and the block is wider than the pane (the
17
+ * caller scrolls it): a table that scrolls cuts nothing, whatever a cell holds. Without it (a markdown table, which has nowhere to scroll) the old rule stands: columns shrink
18
+ * to three characters and no further. `edges` are the left borders of the columns, so a scroll step can land on one.
19
+ *
20
+ * `opts.pin` lists the columns (indexes) that stay in place while the table scrolls; left out, nothing is pinned and the whole table
21
+ * scrolls. Only a table that is wider than the pane has pinned columns, and then they are drawn first, in the order they have in the
22
+ * table (`order` says which column is where). They take at most half the pane (`pinBudget`): they are considered in the order
23
+ * listed, and one that does not fit is skipped. It is never squeezed: a pinned column that lost text could not be scrolled to.
24
+ * `frozen` is their width with their borders, 0 when nothing is pinned.
25
+ */
26
+ export interface TableBlock {
27
+ block: Block;
28
+ anchors: TableAnchors;
29
+ edges: number[];
30
+ frozen: number;
31
+ order: number[];
32
+ /** per body row and drawn column: the cell was cut to fit (it ends in an ellipsis). Only a table that fits by squeezing cuts anything. */
33
+ cut: boolean[][];
34
+ /** per drawn column: the header was cut */
35
+ headCut: boolean[];
36
+ }
37
+
38
+ export function tableBlock(columns: string[], rows: Array<Array<string | number>>, width: number, opts: { minCol?: number; pin?: number[] } = {}): TableBlock {
14
39
  const n = Math.max(1, columns.length);
15
40
  const str = (v: string | number) => String(v ?? "").replace(/\s+/g, " ").trim();
16
- const data = rows.map((r) => columns.map((_, i) => str(r[i] ?? "")));
17
- const heads = columns.map(str);
18
- const numeric = heads.map((_, i) => data.length > 0 && data.every((r) => r[i] === "" || NUMERIC.test(r[i])));
19
- const nat = heads.map((h, i) => Math.max(textWidth(h), ...data.map((r) => textWidth(r[i])), 1));
41
+ const data0 = rows.map((r) => columns.map((_, i) => str(r[i] ?? "")));
42
+ const heads0 = columns.map(str);
43
+ const numeric0 = heads0.map((_, i) => data0.length > 0 && data0.every((r) => r[i] === "" || NUMERIC.test(r[i])));
44
+ const nat = heads0.map((h, i) => Math.max(textWidth(h), ...data0.map((r) => textWidth(r[i])), 1));
20
45
  const avail = Math.max(n, width - (3 * n + 1));
21
- const w = nat.slice();
22
- let sum = w.reduce((a, b) => a + b, 0);
23
- while (sum > avail) {
24
- let k = 0;
25
- for (let i = 1; i < n; i++) if (w[i] > w[k]) k = i;
26
- if (w[k] <= 3) break;
27
- w[k]--;
28
- sum--;
46
+ const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
47
+ const minCol = opts.minCol ?? 3;
48
+ const floors = nat.map((x) => Math.min(x, minCol));
49
+ let w = nat.slice();
50
+ let order = heads0.map((_, i) => i);
51
+ let frozen = 0;
52
+ if (sum(w) > avail) {
53
+ if (sum(floors) <= avail || opts.minCol === undefined) {
54
+ // shrink the widest column, one character at a time, never below its floor
55
+ let total = sum(w);
56
+ while (total > avail) {
57
+ let k = -1;
58
+ for (let i = 0; i < n; i++) if (w[i] > floors[i] && (k < 0 || w[i] > w[k])) k = i;
59
+ if (k < 0) break;
60
+ w[k]--;
61
+ total--;
62
+ }
63
+ } else {
64
+ // the floors do not fit either: every column at its natural width, the table is wider than the pane and scrolls, and what
65
+ // the model asked to pin is pinned if there is room for it
66
+ w = nat.slice();
67
+ const budget = pinBudget(width);
68
+ const chosen: number[] = [];
69
+ let used = 1; // the left border
70
+ for (const i of opts.pin ?? []) {
71
+ if (!Number.isInteger(i) || i < 0 || i >= n || chosen.includes(i)) continue;
72
+ if (used + w[i] + 3 > budget) continue; // not squeezed: text that is cut here could never be scrolled to
73
+ used += w[i] + 3;
74
+ chosen.push(i);
75
+ }
76
+ if (chosen.length && chosen.length < n) {
77
+ chosen.sort((a, b) => a - b);
78
+ order = [...chosen, ...order.filter((i) => !chosen.includes(i))];
79
+ frozen = used;
80
+ }
81
+ }
29
82
  }
83
+ // from here on the columns are in the order they are drawn
84
+ const by = <T>(a: T[]): T[] => order.map((i) => a[i]);
85
+ const heads = by(heads0);
86
+ const data = data0.map(by);
87
+ const numeric = by(numeric0);
88
+ w = by(w);
30
89
  const total = w.reduce((a, b) => a + b, 0) + 3 * n + 1;
31
90
  const line = (l: string, m: string, r: string): Cell[] => {
32
91
  const out: Cell[] = [cell(l, "table.border")];
@@ -66,5 +125,54 @@ export function tableBlock(columns: string[], rows: Array<Array<string | number>
66
125
  rows: data.map((_, ri) => ({ x: 0, y: 3 + ri, w: total, h: 1 })),
67
126
  cells: data.map((_, ri) => w.map((cw, i) => ({ x: colX[i], y: 3 + ri, w: cw, h: 1 }))),
68
127
  };
69
- return { block: fromRows(total, out.map((r) => padRow(r, total))), anchors };
128
+ return {
129
+ block: fromRows(total, out.map((r) => padRow(r, total))),
130
+ anchors,
131
+ edges: colX.map((x) => x - 2),
132
+ frozen,
133
+ order,
134
+ cut: data.map((r) => r.map((v, i) => textWidth(v) > w[i])),
135
+ headCut: heads.map((h, i) => textWidth(h) > w[i]),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * How `panBlock` should scroll a table. With the first column frozen the window starts F cells after the offset, so an offset that
141
+ * puts a column border there is the border's position minus F (and the start, 0, stays a place to stop). Without that a step lands
142
+ * in the middle of a column.
143
+ */
144
+ export function tablePan(t: { edges: number[]; frozen: number }, width: number): { edges: number[]; frozen: number } {
145
+ const F = frozenFor(t.frozen, width);
146
+ return { edges: F ? [0, ...t.edges.map((e) => e - F).filter((e) => e > 0)] : t.edges, frozen: t.frozen };
147
+ }
148
+
149
+ /**
150
+ * The columns a Table's `pin` names, as indexes: by header, the way the table draws it (spacing collapsed). Undefined when the
151
+ * model did not say (the first column is pinned then); a name that matches nothing is left out.
152
+ */
153
+ export function pinIndexes(pin: unknown, columns: string[]): number[] | undefined {
154
+ if (!Array.isArray(pin)) return undefined;
155
+ const norm = (v: unknown) => String(v ?? "").replace(/\s+/g, " ").trim();
156
+ const heads = columns.map(norm);
157
+ return pin.map((name) => heads.indexOf(norm(name))).filter((i) => i >= 0);
158
+ }
159
+
160
+ /**
161
+ * The cells a table cut to fit, each with what it takes to read it whole: the anchor key a tooltip would use (`cell:3.Notes`,
162
+ * `col:Notes`), where it is drawn, its full text as it was written, and a short name for the hover line. For a Table and a
163
+ * Markdown table alike.
164
+ */
165
+ export function cutCells(t: TableBlock, columns: string[], rows: Array<Array<string | number>>): Array<{ key: string; rect: Rect; full: string; label: string }> {
166
+ const out: Array<{ key: string; rect: Rect; full: string; label: string }> = [];
167
+ const head = columns.map((c) => String(c ?? ""));
168
+ t.headCut.forEach((cut, ci) => {
169
+ if (cut) out.push({ key: `col:${head[t.order[ci]]}`, rect: t.anchors.cols[ci], full: head[t.order[ci]].trim(), label: `column ${head[t.order[ci]].replace(/\s+/g, " ").trim()}` });
170
+ });
171
+ t.cut.forEach((row, ri) =>
172
+ row.forEach((cut, ci) => {
173
+ const col = t.order[ci];
174
+ if (cut) out.push({ key: `cell:${ri + 1}.${head[col]}`, rect: t.anchors.cells[ri][ci], full: String(rows[ri]?.[col] ?? "").trim(), label: `${head[col].replace(/\s+/g, " ").trim()}, row ${ri + 1}` });
175
+ }),
176
+ );
177
+ return out;
70
178
  }
@@ -10,6 +10,8 @@ export interface Tip {
10
10
  tone?: string;
11
11
  link?: string;
12
12
  linkLabel?: string;
13
+ /** the whole text of a cell that was cut to fit the pane: the panel shows it as plain text, under whatever else the tip says */
14
+ full?: string;
13
15
  }
14
16
 
15
17
  export type TargetKind =
@@ -33,7 +35,11 @@ export type TargetKind =
33
35
  | "noteadd"
34
36
  | "noteclear"
35
37
  /** the footer chip that turns notes mode on and off */
36
- | "notes";
38
+ | "notes"
39
+ /** a part of the pan bar under a block that is wider than its pane: the left arrow, the right arrow or the track */
40
+ | "pan"
41
+ /** the area of a clipped block, body and bar. Never reachable: the widget asks which one is under a wheel or a drag */
42
+ | "panarea";
37
43
 
38
44
  /** What a note says about the element it is attached to. All words are already cleaned for a message the user will send. */
39
45
  export interface ItemInfo {
@@ -87,7 +93,11 @@ export interface Target {
87
93
  /** for kind "image": where the picture goes and how big it is, in cells */
88
94
  image?: { key: string; cols: number; rows: number };
89
95
  /** a diagram that may be wider than its view: focusing this target should reveal it */
90
- pan?: { key: string; x: number; w: number; view: number; total: number };
96
+ /** `min` is where the scrolling part begins when the first columns are frozen: what is left of it is behind them */
97
+ pan?: { key: string; x: number; w: number; view: number; total: number; min?: number };
98
+ /** for kind "pan": which part of which bar this is, and what it needs to move the offset */
99
+ /** `left`/`right` are the fine arrows (`step` cells), `pageleft`/`pageright` the page arrows (three quarters of the pane, to a column edge) */
100
+ scroll?: { key: string; part: "left" | "right" | "pageleft" | "pageright" | "track"; total: number; view: number; edges?: number[]; step?: number };
91
101
  }
92
102
 
93
103
  export interface Block {
@@ -129,6 +139,8 @@ export interface ImageService {
129
139
 
130
140
  export interface Services {
131
141
  images?: ImageService;
142
+ /** the user's settings (src/pi/config.ts); the defaults when absent */
143
+ settings?: { tableMinCol: number; scrollStep: number };
132
144
  }
133
145
 
134
146
  export interface Ctx {