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,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
  }
@@ -1,5 +1,6 @@
1
1
  import type { Cell, Rect } from "../diagram/canvas.ts";
2
2
  import type { El } from "../lang/library.ts";
3
+ import type { Answers, Choice } from "./choice.ts";
3
4
 
4
5
  export interface Tip {
5
6
  hint?: string;
@@ -9,12 +10,19 @@ export interface Tip {
9
10
  tone?: string;
10
11
  link?: string;
11
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;
12
15
  }
13
16
 
14
17
  export type TargetKind =
15
18
  | "tab"
16
19
  | "button"
20
+ /** one option of a Choice, or its Other row */
17
21
  | "choice"
22
+ /** the Send answers button under a widget that has Choices */
23
+ | "send"
24
+ /** the Clear button next to it */
25
+ | "clear"
18
26
  | "faq"
19
27
  | "node"
20
28
  | "copy"
@@ -27,7 +35,11 @@ export type TargetKind =
27
35
  | "noteadd"
28
36
  | "noteclear"
29
37
  /** the footer chip that turns notes mode on and off */
30
- | "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";
31
43
 
32
44
  /** What a note says about the element it is attached to. All words are already cleaned for a message the user will send. */
33
45
  export interface ItemInfo {
@@ -68,6 +80,10 @@ export interface Target {
68
80
  send?: string;
69
81
  openUrl?: string;
70
82
  copy?: string;
83
+ /** for kind "send": what it sends. The message is built from the answers as they are when it is pressed, never from a picture that may be a frame old. */
84
+ bar?: { choices: Choice[]; title: string };
85
+ /** for kind "choice": the question and which row of it this is (an option index, or its Other row) */
86
+ choice?: { of: Choice; index: number | "other" };
71
87
  /** extra cells (block coordinates) that belong to the target besides its rect, e.g. the wedge of a pie slice */
72
88
  cells?: Array<[number, number]>;
73
89
  /** for kind "item": what a note on it is about */
@@ -77,7 +93,11 @@ export interface Target {
77
93
  /** for kind "image": where the picture goes and how big it is, in cells */
78
94
  image?: { key: string; cols: number; rows: number };
79
95
  /** a diagram that may be wider than its view: focusing this target should reveal it */
80
- 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 };
81
101
  }
82
102
 
83
103
  export interface Block {
@@ -89,7 +109,12 @@ export interface Block {
89
109
  export interface WState {
90
110
  tab: Record<string, number>;
91
111
  faq: Record<string, boolean>;
92
- picked: Record<string, string>;
112
+ /** what the user has staged for each Choice, by question key. Nothing is sent until Send answers. */
113
+ answers: Answers;
114
+ /** the answers have been sent: every Choice is locked */
115
+ sent: boolean;
116
+ /** sent, but pi has not said yet that it took the message (the lock is held; the bar says Sending) */
117
+ sending: boolean;
93
118
  pan: Record<string, number>;
94
119
  tip: string | null;
95
120
  focus: string | null;
@@ -102,7 +127,7 @@ export interface WState {
102
127
  noteSeq: number;
103
128
  }
104
129
 
105
- export const newState = (): WState => ({ tab: {}, faq: {}, picked: {}, pan: {}, tip: null, focus: null, hover: null, copied: null, notes: [], noteMode: false, noteSeq: 0 });
130
+ export const newState = (): WState => ({ tab: {}, faq: {}, answers: {}, sent: false, sending: false, pan: {}, tip: null, focus: null, hover: null, copied: null, notes: [], noteMode: false, noteSeq: 0 });
106
131
 
107
132
  /** Supplied by the host when it can draw bitmaps. Without it, an Image is drawn as a labelled frame. */
108
133
  export interface ImageService {
@@ -114,6 +139,8 @@ export interface ImageService {
114
139
 
115
140
  export interface Services {
116
141
  images?: ImageService;
142
+ /** the user's settings (src/pi/config.ts); the defaults when absent */
143
+ settings?: { tableMinCol: number; scrollStep: number };
117
144
  }
118
145
 
119
146
  export interface Ctx {
@@ -133,6 +133,11 @@ export interface BoxOpts {
133
133
 
134
134
  /** Wrap a block in a rounded box. `inner` is placed with padX columns of padding. Returns the boxed block. */
135
135
  export function boxed(inner: Block, opts: BoxOpts): Block {
136
+ // A title that does not fit its border is not cut: it moves inside as a wrapped heading, above the content.
137
+ if (opts.title && textWidth(opts.title) > Math.max(1, inner.w + 2 * (opts.padX ?? 1) - 4 - (opts.tag ? textWidth(opts.tag) + 2 : 0))) {
138
+ const head = wrapText(opts.title, Math.max(1, inner.w), opts.titleRole ?? opts.role).map((r) => padRow(r, inner.w));
139
+ return boxed({ w: inner.w, rows: [...head, ...inner.rows], targets: shiftTargets(inner.targets, 0, head.length) }, { ...opts, title: undefined });
140
+ }
136
141
  const padX = opts.padX ?? 1;
137
142
  const w = inner.w + 2 + 2 * padX;
138
143
  const role = opts.role;
@@ -8,14 +8,21 @@ export function graphemes(s: string): string[] {
8
8
  return seg ? Array.from(seg.segment(s), (x) => x.segment) : Array.from(s);
9
9
  }
10
10
 
11
- /** Whether `cp` is in the generated table of code points pi draws two cells wide (binary search over [first, last] pairs). */
11
+ // The code points pi draws two cells wide: the generated table, which src/pi/widths.ts corrects against the running pi when the
12
+ // extension loads (the blocks where Unicode adds emoji). Flat [first, last] pairs, ascending.
13
+ let table: readonly number[] = WIDE;
14
+ export function setWide(t: readonly number[]): void {
15
+ table = t;
16
+ }
17
+
18
+ /** Whether `cp` is in the table of code points pi draws two cells wide (binary search over [first, last] pairs). */
12
19
  function isWide(cp: number): boolean {
13
20
  let lo = 0;
14
- let hi = WIDE.length / 2 - 1;
21
+ let hi = table.length / 2 - 1;
15
22
  while (lo <= hi) {
16
23
  const mid = (lo + hi) >> 1;
17
- if (cp < WIDE[mid * 2]) hi = mid - 1;
18
- else if (cp > WIDE[mid * 2 + 1]) lo = mid + 1;
24
+ if (cp < table[mid * 2]) hi = mid - 1;
25
+ else if (cp > table[mid * 2 + 1]) lo = mid + 1;
19
26
  else return true;
20
27
  }
21
28
  return false;
@@ -129,7 +129,8 @@ function attempt(norm: Norm, dir: "LR" | "TB", cap: number): DiagramResult {
129
129
  return layeredDiagram("dag", { nodes, edges, dashReversed: true }, dir);
130
130
  }
131
131
 
132
- const CAPS = [28, 20, 14, 10, 8, 6];
132
+ /** How far labels shrink to fit the pane. Below 14 characters a label is no longer a name, so past this the graph scrolls with whole labels. */
133
+ const CAPS = [28, 20, 14];
133
134
 
134
135
  export function dag(nodesIn: unknown, edgesIn: unknown, opts: DagOptions = {}): DiagramResult {
135
136
  return budgeted(() => dagLayout(nodesIn, edgesIn, opts));
@@ -159,8 +160,8 @@ function dagLayout(nodesIn: unknown, edgesIn: unknown, opts: DagOptions): Diagra
159
160
  if (longest <= cap) break; // shrinking more changes nothing
160
161
  }
161
162
  if (!chosen) {
162
- // nothing fits even with short labels: the view will pan anyway, so keep the labels readable
163
- const cap = Math.min(maxLabel, 14);
163
+ // nothing fits even with short labels: the view will scroll, so scroll to whole labels instead of cutting them as well
164
+ const cap = maxLabel;
164
165
  const tries = dirs.map((d) => attempt(norm, d, cap));
165
166
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
166
167
  }
@@ -146,7 +146,8 @@ function tableSpec(e: NormErd["entities"][number], fieldCap: number, selfRef: bo
146
146
  };
147
147
  }
148
148
 
149
- const CAPS = [60, 40, 30, 22, 16];
149
+ /** How far an entity's lines shrink to fit the pane. Below 30 characters the types and flags are dropped, so past this the diagram scrolls. */
150
+ const CAPS = [60, 40, 30];
150
151
 
151
152
  export function erd(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions = {}): DiagramResult {
152
153
  return budgeted(() => erdLayout(entitiesIn, relationsIn, opts));
@@ -178,7 +179,8 @@ function erdLayout(entitiesIn: unknown, relationsIn: unknown, opts: ErdOptions):
178
179
  if (chosen) break;
179
180
  }
180
181
  if (!chosen) {
181
- const nodes = norm.entities.map((e, i) => tableSpec(e, 30, norm.selfRefs.has(i)));
182
+ // it will scroll whatever the fields: keep them whole (the widest cap) rather than cut them as well
183
+ const nodes = norm.entities.map((e, i) => tableSpec(e, CAPS[0], norm.selfRefs.has(i)));
182
184
  const tries = dirs.map((d) => layeredDiagram("erd", { nodes, edges, portSpacing: 4 }, d));
183
185
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
184
186
  }
@@ -402,11 +402,11 @@ function build(norm: NormInfra, dir: "LR" | "TB", caps: Caps): DiagramResult {
402
402
  };
403
403
  }
404
404
 
405
+ /** How far labels shrink to fit the pane. Past the last row the map scrolls with whole labels, not 8-character ones. */
405
406
  const CAPS: Caps[] = [
406
407
  { label: 22, detail: 26, link: 18 },
407
408
  { label: 16, detail: 18, link: 12 },
408
409
  { label: 12, detail: 12, link: 9 },
409
- { label: 8, detail: 8, link: 6 },
410
410
  ];
411
411
 
412
412
  export function infra(zonesIn: unknown, linksIn: unknown, opts: InfraOptions = {}): DiagramResult {
@@ -434,7 +434,8 @@ function infraLayout(zonesIn: unknown, linksIn: unknown, opts: InfraOptions): Di
434
434
  if (chosen) break;
435
435
  }
436
436
  if (!chosen) {
437
- const tries = dirs.map((d) => build(norm, d, CAPS[1]));
437
+ // it will scroll whatever the labels: use the whole ones (the first, widest caps) rather than cut them as well
438
+ const tries = dirs.map((d) => build(norm, d, CAPS[0]));
438
439
  best = tries.reduce((a, b) => (b.width < a.width ? b : a));
439
440
  }
440
441
  const res = chosen ?? best!;
@@ -112,7 +112,7 @@ export function createPalette(t: PaletteTokens): Palette {
112
112
  f = compose(t.accent, t.bold);
113
113
  break;
114
114
  case "choice":
115
- f = a === "q" ? t.bold : a === "picked" ? compose(t.accent, t.bold) : undefined;
115
+ f = a === "q" ? t.bold : a === "picked" ? compose(t.accent, t.bold) : a === "tag" || a === "other" ? t.dim : a === "locked" ? t.dim : a === "sent" ? t.good : undefined;
116
116
  break;
117
117
  case "tab":
118
118
  f = a === "on" ? t.accent : a === "label" ? compose(t.accent, t.bold) : a === "off" ? t.dim : a === "rule" ? t.muted : undefined;
@@ -1,4 +1,4 @@
1
- // Generated by scripts/width-table.ts from pi's own width function: do not edit. Run `npm run width` after pi updates.
1
+ // Generated by scripts/width-table.ts from pi's own width function: do not edit. Run `npm run width` to refresh it (the extension corrects its emoji blocks from the running pi when it loads).
2
2
  // The code points pi draws two cells wide, as inclusive [first, last] pairs in ascending order.
3
3
  export const WIDE: readonly number[] = [
4
4
  0x1100, 0x115e, 0x231a, 0x231b, 0x2329, 0x232a, 0x23e9, 0x23ec,
@@ -27,10 +27,9 @@ export const WIDE: readonly number[] = [
27
27
  0x1f3e0, 0x1f3f0, 0x1f3f4, 0x1f3f4, 0x1f3f8, 0x1f43e, 0x1f440, 0x1f440,
28
28
  0x1f442, 0x1f4fc, 0x1f4ff, 0x1f53d, 0x1f54b, 0x1f54e, 0x1f550, 0x1f567,
29
29
  0x1f57a, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f5a4, 0x1f5fb, 0x1f64f,
30
- 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d8,
30
+ 0x1f680, 0x1f6c5, 0x1f6cc, 0x1f6cc, 0x1f6d0, 0x1f6d2, 0x1f6d5, 0x1f6d9,
31
31
  0x1f6dc, 0x1f6df, 0x1f6eb, 0x1f6ec, 0x1f6f4, 0x1f6fc, 0x1f7e0, 0x1f7eb,
32
32
  0x1f7f0, 0x1f7f0, 0x1f90c, 0x1f93a, 0x1f93c, 0x1f945, 0x1f947, 0x1f9ff,
33
- 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fa8a, 0x1fa8e, 0x1fac6, 0x1fac8, 0x1fac8,
34
- 0x1facd, 0x1fadc, 0x1fadf, 0x1faea, 0x1faef, 0x1faf8, 0x20000, 0x2fffd,
35
- 0x30000, 0x3fffd,
33
+ 0x1fa70, 0x1fa7c, 0x1fa80, 0x1fac6, 0x1fac8, 0x1fac8, 0x1facc, 0x1fadd,
34
+ 0x1fadf, 0x1faeb, 0x1faef, 0x1fafa, 0x20000, 0x2fffd, 0x30000, 0x3fffd,
36
35
  ];
@@ -0,0 +1,56 @@
1
+ // Keep the committed table of two-cell characters (wide.ts), but let the running pi decide the blocks where Unicode keeps adding
2
+ // emoji. Pure: the probe is any function from a code point to its width in columns, so nothing here needs pi.
3
+
4
+ /**
5
+ * Where new two-cell characters appear with each Unicode release: the arrows, symbols and dingbats that got an emoji form
6
+ * (U+2190-2BFF) and the emoji blocks (U+1F000-1FFFF). Everything else the table covers (CJK, Hangul, fullwidth forms) is stable.
7
+ * About 6,800 code points: cheap to ask pi about when the extension loads.
8
+ */
9
+ export const PROBE_SPANS: ReadonlyArray<readonly [number, number]> = [
10
+ [0x2190, 0x2bff],
11
+ [0x1f000, 0x1ffff],
12
+ ];
13
+
14
+ /**
15
+ * A table (flat [first, last] pairs, ascending, merged) that is `base` outside `spans` and what `probe` says inside them.
16
+ * `probe` is only ever asked about code points inside the spans.
17
+ */
18
+ export function healWide(base: readonly number[], probe: (cp: number) => number, spans: ReadonlyArray<readonly [number, number]> = PROBE_SPANS): number[] {
19
+ // the base ranges with the spans cut out of them
20
+ const keep: Array<[number, number]> = [];
21
+ for (let i = 0; i < base.length; i += 2) {
22
+ let pieces: Array<[number, number]> = [[base[i], base[i + 1]]];
23
+ for (const [s, e] of spans) {
24
+ const next: Array<[number, number]> = [];
25
+ for (const [a, b] of pieces) {
26
+ if (b < s || a > e) next.push([a, b]);
27
+ else {
28
+ if (a < s) next.push([a, s - 1]);
29
+ if (b > e) next.push([e + 1, b]);
30
+ }
31
+ }
32
+ pieces = next;
33
+ }
34
+ keep.push(...pieces);
35
+ }
36
+ // what the probe says about the spans
37
+ const asked: Array<[number, number]> = [];
38
+ for (const [s, e] of spans) {
39
+ let start = -1;
40
+ for (let cp = s; cp <= e; cp++) {
41
+ const wide = probe(cp) === 2;
42
+ if (wide && start < 0) start = cp;
43
+ else if (!wide && start >= 0) {
44
+ asked.push([start, cp - 1]);
45
+ start = -1;
46
+ }
47
+ }
48
+ if (start >= 0) asked.push([start, e]);
49
+ }
50
+ const out: number[] = [];
51
+ for (const [a, b] of [...keep, ...asked].sort((x, y) => x[0] - y[0])) {
52
+ if (out.length && a <= out[out.length - 1] + 1) out[out.length - 1] = Math.max(out[out.length - 1], b);
53
+ else out.push(a, b);
54
+ }
55
+ return out;
56
+ }