pi-openui 0.0.0-stage → 0.1.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 +35 -0
- package/LICENSE +21 -0
- package/README.md +200 -2
- package/package.json +54 -4
- package/src/blocks/braille.ts +121 -0
- package/src/blocks/bubble.ts +30 -0
- package/src/blocks/charts.ts +370 -0
- package/src/blocks/code.ts +101 -0
- package/src/blocks/core.ts +343 -0
- package/src/blocks/diagrams.ts +142 -0
- package/src/blocks/geo.ts +206 -0
- package/src/blocks/interact.ts +252 -0
- package/src/blocks/markdown.ts +135 -0
- package/src/blocks/notes.ts +277 -0
- package/src/blocks/render.ts +148 -0
- package/src/blocks/sequence.ts +266 -0
- package/src/blocks/table.ts +70 -0
- package/src/blocks/types.ts +126 -0
- package/src/blocks/util.ts +171 -0
- package/src/data/land.ts +3 -0
- package/src/data/sample-image.ts +67 -0
- package/src/diagram/canvas.ts +182 -0
- package/src/diagram/dag.ts +174 -0
- package/src/diagram/decode.ts +241 -0
- package/src/diagram/erd.ts +190 -0
- package/src/diagram/infra.ts +444 -0
- package/src/diagram/layered.ts +334 -0
- package/src/diagram/layeredDiagram.ts +350 -0
- package/src/diagram/palette.ts +209 -0
- package/src/diagram/render.ts +60 -0
- package/src/diagram/router.ts +402 -0
- package/src/diagram/wires.ts +271 -0
- package/src/lang/docs.ts +575 -0
- package/src/lang/library.ts +613 -0
- package/src/lang/md.ts +47 -0
- package/src/lang/parse.ts +230 -0
- package/src/lang/samples.ts +227 -0
- package/src/pi/extension.ts +190 -0
- package/src/pi/images.ts +185 -0
- package/src/pi/widget.ts +562 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// GeoMap: braille land texture + coastline, pins with labels, dashed links. Equirectangular, square dots.
|
|
2
|
+
import { LAND } from "../data/land.ts";
|
|
3
|
+
import { textWidth, truncateText, type Cell } from "../diagram/canvas.ts";
|
|
4
|
+
import type { El } from "../lang/library.ts";
|
|
5
|
+
import { Dots } from "./braille.ts";
|
|
6
|
+
import type { Anchor } from "./core.ts";
|
|
7
|
+
import type { Block, Target } from "./types.ts";
|
|
8
|
+
import { cell, fromRows, textCells, vstack } from "./util.ts";
|
|
9
|
+
|
|
10
|
+
let rings: Float64Array[] | null = null;
|
|
11
|
+
function loadRings(): Float64Array[] {
|
|
12
|
+
if (rings) return rings;
|
|
13
|
+
rings = LAND.split("|").map((r) => {
|
|
14
|
+
const pts = r.split(";").map((p) => p.split(",").map(Number));
|
|
15
|
+
const out = new Float64Array(pts.length * 2);
|
|
16
|
+
let x = 0;
|
|
17
|
+
let y = 0;
|
|
18
|
+
pts.forEach(([a, b], i) => {
|
|
19
|
+
if (i === 0) {
|
|
20
|
+
x = a;
|
|
21
|
+
y = b;
|
|
22
|
+
} else {
|
|
23
|
+
x += a;
|
|
24
|
+
y += b;
|
|
25
|
+
}
|
|
26
|
+
out[i * 2] = x;
|
|
27
|
+
out[i * 2 + 1] = y;
|
|
28
|
+
});
|
|
29
|
+
return out;
|
|
30
|
+
});
|
|
31
|
+
return rings;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** boolean land mask for a view, W x H dots */
|
|
35
|
+
export function landMask(lonMin: number, lonMax: number, latMin: number, latMax: number, W: number, H: number): Uint8Array {
|
|
36
|
+
const mask = new Uint8Array(W * H);
|
|
37
|
+
const sx = W / (lonMax - lonMin);
|
|
38
|
+
const sy = H / (latMax - latMin);
|
|
39
|
+
const edges: Array<[number, number, number, number]> = [];
|
|
40
|
+
for (const ring of loadRings()) {
|
|
41
|
+
const n = ring.length / 2;
|
|
42
|
+
for (const off of [-3600, 0, 3600]) {
|
|
43
|
+
let minX = Infinity;
|
|
44
|
+
let maxX = -Infinity;
|
|
45
|
+
let minY = Infinity;
|
|
46
|
+
let maxY = -Infinity;
|
|
47
|
+
const px: number[] = [];
|
|
48
|
+
const py: number[] = [];
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const lon = (ring[i * 2] + off) / 10 - 180;
|
|
51
|
+
const lat = 90 - ring[i * 2 + 1] / 10;
|
|
52
|
+
const X = (lon - lonMin) * sx;
|
|
53
|
+
const Y = (latMax - lat) * sy;
|
|
54
|
+
px.push(X);
|
|
55
|
+
py.push(Y);
|
|
56
|
+
minX = Math.min(minX, X);
|
|
57
|
+
maxX = Math.max(maxX, X);
|
|
58
|
+
minY = Math.min(minY, Y);
|
|
59
|
+
maxY = Math.max(maxY, Y);
|
|
60
|
+
}
|
|
61
|
+
if (maxX < 0 || minX > W || maxY < 0 || minY > H) continue;
|
|
62
|
+
for (let i = 0; i < n; i++) {
|
|
63
|
+
const j = (i + 1) % n;
|
|
64
|
+
if (py[i] !== py[j]) edges.push([px[i], py[i], px[j], py[j]]);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
for (let y = 0; y < H; y++) {
|
|
69
|
+
const yc = y + 0.5;
|
|
70
|
+
const xs: number[] = [];
|
|
71
|
+
for (const [x0, y0, x1, y1] of edges) {
|
|
72
|
+
if ((y0 <= yc && y1 > yc) || (y1 <= yc && y0 > yc)) xs.push(x0 + ((yc - y0) / (y1 - y0)) * (x1 - x0));
|
|
73
|
+
}
|
|
74
|
+
xs.sort((a, b) => a - b);
|
|
75
|
+
for (let k = 0; k + 1 < xs.length; k += 2) {
|
|
76
|
+
const a = Math.max(0, Math.ceil(xs[k] - 0.5));
|
|
77
|
+
const b = Math.min(W - 1, Math.floor(xs[k + 1] - 0.5));
|
|
78
|
+
for (let x = a; x <= b; x++) mask[y * W + x] = 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return mask;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TONE: Record<string, string> = { good: "good", warn: "warn", bad: "bad", info: "info", neutral: "neutral" };
|
|
85
|
+
|
|
86
|
+
export function geoMap(el: El, width: number): { block: Block; anchors: Map<string, Anchor> } {
|
|
87
|
+
const pts: El[] = el.props.points ?? [];
|
|
88
|
+
const links: string[][] = el.props.links ?? [];
|
|
89
|
+
const anchors = new Map<string, Anchor>();
|
|
90
|
+
const head = el.props.title ? [fromRows(width, [textCells(truncateText(String(el.props.title), width), "chart.title")])] : [];
|
|
91
|
+
const cols = Math.max(24, Math.min(width, 84));
|
|
92
|
+
const W = cols * 2;
|
|
93
|
+
const lats = pts.map((p) => p.props.lat as number);
|
|
94
|
+
const lons = pts.map((p) => p.props.lon as number);
|
|
95
|
+
let lonMin = Math.min(...lons);
|
|
96
|
+
let lonMax = Math.max(...lons);
|
|
97
|
+
let latMin = Math.min(...lats);
|
|
98
|
+
let latMax = Math.max(...lats);
|
|
99
|
+
const padL = Math.max(6, (lonMax - lonMin) * 0.2);
|
|
100
|
+
const padA = Math.max(4, (latMax - latMin) * 0.25);
|
|
101
|
+
lonMin -= padL;
|
|
102
|
+
lonMax += padL;
|
|
103
|
+
latMin -= padA;
|
|
104
|
+
latMax += padA;
|
|
105
|
+
if (lonMax - lonMin < 24) {
|
|
106
|
+
const c = (lonMin + lonMax) / 2;
|
|
107
|
+
lonMin = c - 12;
|
|
108
|
+
lonMax = c + 12;
|
|
109
|
+
}
|
|
110
|
+
let sx = W / (lonMax - lonMin);
|
|
111
|
+
let rowsN = Math.ceil(((latMax - latMin) * sx) / 4);
|
|
112
|
+
if (rowsN > 22) {
|
|
113
|
+
sx = (22 * 4) / (latMax - latMin);
|
|
114
|
+
const c = (lonMin + lonMax) / 2;
|
|
115
|
+
lonMin = c - W / sx / 2;
|
|
116
|
+
lonMax = c + W / sx / 2;
|
|
117
|
+
rowsN = 22;
|
|
118
|
+
}
|
|
119
|
+
if (rowsN < 9) {
|
|
120
|
+
const need = (9 * 4) / sx;
|
|
121
|
+
const c = (latMin + latMax) / 2;
|
|
122
|
+
latMin = c - need / 2;
|
|
123
|
+
latMax = c + need / 2;
|
|
124
|
+
rowsN = 9;
|
|
125
|
+
}
|
|
126
|
+
if (latMax > 90) {
|
|
127
|
+
latMin -= latMax - 90;
|
|
128
|
+
latMax = 90;
|
|
129
|
+
}
|
|
130
|
+
if (latMin < -90) {
|
|
131
|
+
latMax += -90 - latMin;
|
|
132
|
+
latMin = -90;
|
|
133
|
+
}
|
|
134
|
+
const H = rowsN * 4;
|
|
135
|
+
const mask = landMask(lonMin, lonMax, latMin, latMax, W, H);
|
|
136
|
+
const d = new Dots(cols, rowsN);
|
|
137
|
+
const at = (x: number, y: number) => (x < 0 || y < 0 || x >= W || y >= H ? 0 : mask[y * W + x]);
|
|
138
|
+
for (let y = 0; y < H; y++)
|
|
139
|
+
for (let x = 0; x < W; x++) {
|
|
140
|
+
if (!mask[y * W + x]) continue;
|
|
141
|
+
const edge = !at(x - 1, y) || !at(x + 1, y) || !at(x, y - 1) || !at(x, y + 1);
|
|
142
|
+
if (edge) d.set(x, y, 1);
|
|
143
|
+
else if ((x + y) % 2 === 0) d.set(x, y, 0);
|
|
144
|
+
}
|
|
145
|
+
const proj = (lat: number, lon: number): [number, number] => [((lon - lonMin) / (lonMax - lonMin)) * W, ((latMax - lat) / (latMax - latMin)) * H];
|
|
146
|
+
const byLabel = new Map(pts.map((p) => [String(p.props.label), p]));
|
|
147
|
+
for (const [a, b] of links) {
|
|
148
|
+
const pa = byLabel.get(a);
|
|
149
|
+
const pb = byLabel.get(b);
|
|
150
|
+
if (!pa || !pb) continue;
|
|
151
|
+
const [x0, y0] = proj(pa.props.lat, pa.props.lon);
|
|
152
|
+
const [x1, y1] = proj(pb.props.lat, pb.props.lon);
|
|
153
|
+
const steps = Math.ceil(Math.hypot(x1 - x0, y1 - y0));
|
|
154
|
+
for (let s = 0; s <= steps; s++) if (Math.floor(s / 3) % 2 === 0) d.set(x0 + ((x1 - x0) * s) / (steps || 1), y0 + ((y1 - y0) * s) / (steps || 1), 2);
|
|
155
|
+
}
|
|
156
|
+
const grid = d.rows2cells((o) => (o === 0 ? "map.land" : o === 1 ? "map.coast" : o === 2 ? "map.link" : ""));
|
|
157
|
+
const rows: Cell[][] = grid.map((r) => r.slice());
|
|
158
|
+
const targets: Target[] = [];
|
|
159
|
+
const taken = new Set<string>();
|
|
160
|
+
const free = (x: number, y: number, w: number) => {
|
|
161
|
+
if (y < 0 || y >= rowsN || x < 0 || x + w > cols) return false;
|
|
162
|
+
for (let k = 0; k < w; k++) if (taken.has(`${x + k},${y}`)) return false;
|
|
163
|
+
return true;
|
|
164
|
+
};
|
|
165
|
+
const unlabeled: string[] = [];
|
|
166
|
+
pts.forEach((p, i) => {
|
|
167
|
+
const [dx, dy] = proj(p.props.lat, p.props.lon);
|
|
168
|
+
const cx = Math.max(0, Math.min(cols - 1, Math.floor(dx / 2)));
|
|
169
|
+
const cy = Math.max(0, Math.min(rowsN - 1, Math.floor(dy / 4)));
|
|
170
|
+
const role = `map.pin.${TONE[p.props.tone ?? "info"] ?? "info"}`;
|
|
171
|
+
rows[cy][cx] = cell("●", role);
|
|
172
|
+
taken.add(`${cx},${cy}`);
|
|
173
|
+
const label = truncateText(String(p.props.label), 18);
|
|
174
|
+
const lw = textWidth(label);
|
|
175
|
+
const tries: Array<[number, number]> = [[cx + 2, cy], [cx - 1 - lw, cy], [cx - Math.floor(lw / 2), cy - 1], [cx - Math.floor(lw / 2), cy + 1], [cx + 2, cy - 1], [cx + 2, cy + 1]];
|
|
176
|
+
let placed: [number, number] | null = null;
|
|
177
|
+
for (const [lx, ly] of tries) if (free(lx, ly, lw)) { placed = [lx, ly]; break; }
|
|
178
|
+
let rect = { x: cx, y: cy, w: 1, h: 1 };
|
|
179
|
+
if (placed) {
|
|
180
|
+
const [lx, ly] = placed;
|
|
181
|
+
textCells(label, "map.label").forEach((c, k) => {
|
|
182
|
+
rows[ly][lx + k] = c;
|
|
183
|
+
taken.add(`${lx + k},${ly}`);
|
|
184
|
+
});
|
|
185
|
+
const x0 = Math.min(cx, lx);
|
|
186
|
+
rect = { x: x0, y: Math.min(cy, ly), w: Math.max(cx + 1, lx + lw) - x0, h: Math.abs(ly - cy) + 1 };
|
|
187
|
+
} else unlabeled.push(String(p.props.label));
|
|
188
|
+
const id = `geo:${i}`;
|
|
189
|
+
targets.push({ id, kind: "node", rect, label: `${p.props.label} ${Number(p.props.lat).toFixed(2)}, ${Number(p.props.lon).toFixed(2)}` });
|
|
190
|
+
anchors.set(String(p.props.label), { rect, targetId: id });
|
|
191
|
+
});
|
|
192
|
+
for (const [a, b] of links) {
|
|
193
|
+
const pa = byLabel.get(a);
|
|
194
|
+
const pb = byLabel.get(b);
|
|
195
|
+
if (!pa || !pb) continue;
|
|
196
|
+
const [x0, y0] = proj(pa.props.lat, pa.props.lon);
|
|
197
|
+
const [x1, y1] = proj(pb.props.lat, pb.props.lon);
|
|
198
|
+
anchors.set(`${a}>${b}`, { rect: { x: Math.max(0, Math.min(cols - 3, Math.floor((x0 + x1) / 4) - 1)), y: Math.max(0, Math.min(rowsN - 1, Math.floor((y0 + y1) / 8))), w: 3, h: 1 } });
|
|
199
|
+
}
|
|
200
|
+
const map = fromRows(width, rows, targets);
|
|
201
|
+
const notes: Block[] = [];
|
|
202
|
+
if (unlabeled.length) notes.push(fromRows(width, [textCells(truncateText(`also: ${unlabeled.join(", ")}`, width), "text.muted")]));
|
|
203
|
+
const out = vstack([...head, map, ...notes], 0, width);
|
|
204
|
+
for (const [, a] of anchors) a.rect = { ...a.rect, y: a.rect.y + head.length };
|
|
205
|
+
return { block: out, anchors };
|
|
206
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
// Interaction logic, independent of any terminal: hit testing, activation, keyboard navigation, pan reveal.
|
|
2
|
+
import type { Block, Target, WState } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
export interface Env {
|
|
5
|
+
/** send text to the assistant as the user's next turn */
|
|
6
|
+
send(text: string): void;
|
|
7
|
+
/** put text on the clipboard */
|
|
8
|
+
copy(text: string): void;
|
|
9
|
+
/** show a short notice under the widget */
|
|
10
|
+
note(text: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const area = (t: Target) => t.rect.w * t.rect.h;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Which targets answer to the mouse and keyboard. Outside notes mode everything except note items. In notes mode: the note
|
|
17
|
+
* items, the notes tray, and tabs (so you can reach content on another tab). Buttons, choices and the like do not fire.
|
|
18
|
+
*/
|
|
19
|
+
export function isLive(t: Target, notes: boolean): boolean {
|
|
20
|
+
if (!notes) return t.kind !== "item";
|
|
21
|
+
return t.kind === "item" || t.kind === "tab" || t.kind === "note" || t.kind === "noteadd" || t.kind === "noteclear" || t.kind === "notes";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The most specific target at a point: an exact cell match (a pie wedge) beats a rectangle, a small rectangle beats a big one. */
|
|
25
|
+
export function targetAt(block: Block, x: number, y: number, notes = false): Target | null {
|
|
26
|
+
let best: Target | null = null;
|
|
27
|
+
let bestScore = Infinity;
|
|
28
|
+
for (const t of block.targets) {
|
|
29
|
+
if (!isLive(t, notes)) continue;
|
|
30
|
+
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;
|
|
32
|
+
const inCells = !!t.cells && t.cells.some(([cx, cy]) => cx === x && cy === y);
|
|
33
|
+
if (!inRect && !inCells) continue;
|
|
34
|
+
const score = inCells ? 0 : area(t);
|
|
35
|
+
if (score < bestScore) {
|
|
36
|
+
bestScore = score;
|
|
37
|
+
best = t;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return best;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function readingOrder(block: Block, notes = false): Target[] {
|
|
44
|
+
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
|
+
}
|
|
46
|
+
|
|
47
|
+
export type NavKey = "next" | "prev" | "left" | "right" | "up" | "down";
|
|
48
|
+
|
|
49
|
+
/** The neighbour of `cur` in a direction among `order`, or `cur` when there is none. Rectangles must be separated along the axis. */
|
|
50
|
+
function spatial(order: Target[], cur: Target, key: "left" | "right" | "up" | "down"): Target {
|
|
51
|
+
const cx = (t: Target) => t.rect.x + t.rect.w / 2;
|
|
52
|
+
const cy = (t: Target) => t.rect.y + t.rect.h / 2;
|
|
53
|
+
const horizontal = key === "left" || key === "right";
|
|
54
|
+
// "right of" means the rectangles are separated along that axis: a full-width row below is below, not to the right
|
|
55
|
+
const inDirection = order.filter((t) => {
|
|
56
|
+
if (t === cur) return false;
|
|
57
|
+
const r = t.rect;
|
|
58
|
+
const c = cur.rect;
|
|
59
|
+
return key === "left" ? r.x + r.w <= c.x : key === "right" ? r.x >= c.x + c.w : key === "up" ? r.y + r.h <= c.y : r.y >= c.y + c.h;
|
|
60
|
+
});
|
|
61
|
+
// targets that share the row (sideways) or the column (up and down) win over anything nearer on another line
|
|
62
|
+
const overlaps = (t: Target) => (horizontal ? t.rect.y < cur.rect.y + cur.rect.h && cur.rect.y < t.rect.y + t.rect.h : t.rect.x < cur.rect.x + cur.rect.w && cur.rect.x < t.rect.x + t.rect.w);
|
|
63
|
+
const aligned = inDirection.filter(overlaps);
|
|
64
|
+
// a target that has neighbours on its row stops at the end of the row instead of jumping to another line. Neighbours means
|
|
65
|
+
// sharing most of the row (a row of buttons), not touching one line of a tall box (a graph node).
|
|
66
|
+
const shared = (t: Target) => {
|
|
67
|
+
const a0 = horizontal ? t.rect.y : t.rect.x;
|
|
68
|
+
const a1 = a0 + (horizontal ? t.rect.h : t.rect.w);
|
|
69
|
+
const b0 = horizontal ? cur.rect.y : cur.rect.x;
|
|
70
|
+
const b1 = b0 + (horizontal ? cur.rect.h : cur.rect.w);
|
|
71
|
+
return Math.min(a1, b1) - Math.max(a0, b0) >= 0.5 * Math.min(a1 - a0, b1 - b0);
|
|
72
|
+
};
|
|
73
|
+
const hasRowMates = order.some((t) => t !== cur && shared(t));
|
|
74
|
+
if (!aligned.length && hasRowMates) return cur;
|
|
75
|
+
const pool = aligned.length ? aligned : inDirection;
|
|
76
|
+
let best: Target | null = null;
|
|
77
|
+
let bestScore = Infinity;
|
|
78
|
+
for (const t of pool) {
|
|
79
|
+
const along = Math.abs(horizontal ? cx(t) - cx(cur) : cy(t) - cy(cur));
|
|
80
|
+
const across = Math.abs(horizontal ? cy(t) - cy(cur) : cx(t) - cx(cur));
|
|
81
|
+
// cells are about twice as tall as wide, so a line of difference weighs more than a column
|
|
82
|
+
const score = along + across * (horizontal ? 3 : 2);
|
|
83
|
+
if (score < bestScore) {
|
|
84
|
+
bestScore = score;
|
|
85
|
+
best = t;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return best ?? cur;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Notes mode. Tab hops between components (and the tray, and tabs). Arrows move among the parts of the component you are in:
|
|
93
|
+
* from the whole component they step into its first or last part; past the last part they move on to the next component.
|
|
94
|
+
*/
|
|
95
|
+
function notesNav(pool: Target[], currentId: string | null, key: NavKey): Target | null {
|
|
96
|
+
const order = [...pool].sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x || a.id.localeCompare(b.id));
|
|
97
|
+
// one stop per component: its whole item; plus the tray rows, buttons and tabs
|
|
98
|
+
const ring = order.filter((t) => t.kind !== "item" || t.item?.key === "");
|
|
99
|
+
if (!ring.length) return null;
|
|
100
|
+
const cur = order.find((t) => t.id === currentId) ?? null;
|
|
101
|
+
const ringStop = (t: Target): Target => (t.kind === "item" && t.item!.key !== "" ? ring.find((r) => r.kind === "item" && r.item!.host === t.item!.host && r.item!.key === "") ?? ring[0] : t);
|
|
102
|
+
const step = (from: Target, d: 1 | -1): Target => {
|
|
103
|
+
const stop = ringStop(from);
|
|
104
|
+
const i = ring.indexOf(stop);
|
|
105
|
+
return ring[(i + d + ring.length) % ring.length];
|
|
106
|
+
};
|
|
107
|
+
if (!cur) return key === "prev" || key === "up" || key === "left" ? ring[ring.length - 1] : ring[0];
|
|
108
|
+
if (key === "next") return step(cur, 1);
|
|
109
|
+
if (key === "prev") return step(cur, -1);
|
|
110
|
+
const fwd = key === "down" || key === "right";
|
|
111
|
+
if (cur.kind === "item") {
|
|
112
|
+
const leaves = order.filter((t) => t.kind === "item" && t.item!.host === cur.item!.host && t.item!.key !== "");
|
|
113
|
+
if (cur.item!.key === "") {
|
|
114
|
+
if (!leaves.length) return step(cur, fwd ? 1 : -1);
|
|
115
|
+
return fwd ? leaves[0] : leaves[leaves.length - 1];
|
|
116
|
+
}
|
|
117
|
+
const to = spatial(leaves, cur, key);
|
|
118
|
+
if (to !== cur) return to;
|
|
119
|
+
return step(cur, fwd ? 1 : -1);
|
|
120
|
+
}
|
|
121
|
+
// tray rows, its buttons, tabs, the chip. Up and down go to the next line in reading order and land on its first stop (so
|
|
122
|
+
// down from a note reaches "Add", never "Clear"); left and right move along the line.
|
|
123
|
+
if (key === "up" || key === "down") {
|
|
124
|
+
const below = key === "down";
|
|
125
|
+
const lines = ring.filter((t) => (below ? t.rect.y >= cur.rect.y + cur.rect.h : t.rect.y + t.rect.h <= cur.rect.y));
|
|
126
|
+
if (!lines.length) return step(cur, below ? 1 : -1);
|
|
127
|
+
const y = below ? Math.min(...lines.map((t) => t.rect.y)) : Math.max(...lines.map((t) => t.rect.y));
|
|
128
|
+
return lines.filter((t) => t.rect.y === y).sort((a, b) => a.rect.x - b.rect.x)[0];
|
|
129
|
+
}
|
|
130
|
+
const same = order.filter((t) => t.kind !== "item");
|
|
131
|
+
return spatial(same, cur, key);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function nextTarget(block: Block, currentId: string | null, key: NavKey, notes = false): Target | null {
|
|
135
|
+
const pool = block.targets.filter((t) => isLive(t, notes));
|
|
136
|
+
if (notes) return notesNav(pool, currentId, key);
|
|
137
|
+
const order = [...pool].sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x || a.id.localeCompare(b.id));
|
|
138
|
+
if (!order.length) return null;
|
|
139
|
+
const cur = order.find((t) => t.id === currentId) ?? null;
|
|
140
|
+
if (key === "next" || key === "prev") {
|
|
141
|
+
if (!cur) return key === "next" ? order[0] : order[order.length - 1];
|
|
142
|
+
const i = order.indexOf(cur);
|
|
143
|
+
return order[(i + (key === "next" ? 1 : -1) + order.length) % order.length];
|
|
144
|
+
}
|
|
145
|
+
if (!cur) return order[0];
|
|
146
|
+
return spatial(order, cur, key);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lastColon = (id: string) => id.lastIndexOf(":");
|
|
150
|
+
|
|
151
|
+
/** Apply a target's action. Returns true when the layout changed and the block must be rebuilt. */
|
|
152
|
+
export function activate(t: Target, state: WState, env: Env): boolean {
|
|
153
|
+
switch (t.kind) {
|
|
154
|
+
case "tab": {
|
|
155
|
+
const k = lastColon(t.id);
|
|
156
|
+
const path = t.id.slice(4, k);
|
|
157
|
+
const i = Number(t.id.slice(k + 1));
|
|
158
|
+
if (state.tab[path] === i) return false;
|
|
159
|
+
state.tab[path] = i;
|
|
160
|
+
state.tip = null;
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
case "faq":
|
|
164
|
+
state.faq[t.id] = !state.faq[t.id];
|
|
165
|
+
return true;
|
|
166
|
+
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);
|
|
171
|
+
return true;
|
|
172
|
+
}
|
|
173
|
+
case "button":
|
|
174
|
+
if (t.send) env.send(t.send);
|
|
175
|
+
if (t.openUrl) {
|
|
176
|
+
env.copy(t.openUrl);
|
|
177
|
+
env.note(`link copied: ${t.openUrl}`);
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
case "copy":
|
|
181
|
+
if (t.copy !== undefined) env.copy(t.copy);
|
|
182
|
+
state.copied = t.id;
|
|
183
|
+
return true;
|
|
184
|
+
case "node":
|
|
185
|
+
case "image":
|
|
186
|
+
case "tip": {
|
|
187
|
+
const tip = t.tip;
|
|
188
|
+
if (!tip || !(tip.detail || tip.code || tip.link)) return false;
|
|
189
|
+
state.tip = state.tip === t.id ? null : t.id;
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Scroll a wide diagram so the target is visible. Returns true when the pan changed. */
|
|
197
|
+
export function reveal(t: Target, state: WState): boolean {
|
|
198
|
+
const p = t.pan;
|
|
199
|
+
if (!p) return false;
|
|
200
|
+
const cur = state.pan[p.key] ?? 0;
|
|
201
|
+
let next = cur;
|
|
202
|
+
if (p.x < cur) next = Math.max(0, p.x - 2);
|
|
203
|
+
else if (p.x + p.w > cur + p.view) next = Math.min(p.total - p.view, p.x + p.w - p.view + 2);
|
|
204
|
+
if (next === cur) return false;
|
|
205
|
+
state.pan[p.key] = next;
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The one-line explanation shown for a hovered or focused target. */
|
|
210
|
+
export function describe(t: Target): string {
|
|
211
|
+
const bits: string[] = [];
|
|
212
|
+
switch (t.kind) {
|
|
213
|
+
case "button":
|
|
214
|
+
bits.push(`[${t.label}]`, t.send ? `sends "${t.send.length > 70 ? t.send.slice(0, 69) + "…" : t.send}"` : t.openUrl ? `link ${t.openUrl}` : "");
|
|
215
|
+
break;
|
|
216
|
+
case "choice":
|
|
217
|
+
bits.push(t.label, "choose to answer");
|
|
218
|
+
break;
|
|
219
|
+
case "tab":
|
|
220
|
+
bits.push(`tab ${t.label}`);
|
|
221
|
+
break;
|
|
222
|
+
case "faq":
|
|
223
|
+
bits.push(t.label, "Enter to open or close");
|
|
224
|
+
break;
|
|
225
|
+
case "copy":
|
|
226
|
+
bits.push("copy the code to the clipboard");
|
|
227
|
+
break;
|
|
228
|
+
case "image":
|
|
229
|
+
bits.push(t.label);
|
|
230
|
+
break;
|
|
231
|
+
case "item":
|
|
232
|
+
bits.push(t.label, "Enter to write a note");
|
|
233
|
+
break;
|
|
234
|
+
case "note":
|
|
235
|
+
bits.push(`${t.label}`, "Enter to edit, Delete to remove");
|
|
236
|
+
break;
|
|
237
|
+
case "notes":
|
|
238
|
+
bits.push("notes: click an element, or Tab to it and press Enter, to write a note about it");
|
|
239
|
+
break;
|
|
240
|
+
case "noteadd":
|
|
241
|
+
bits.push("put the notes in your message box (nothing is sent)");
|
|
242
|
+
break;
|
|
243
|
+
case "noteclear":
|
|
244
|
+
bits.push("discard all notes on this widget");
|
|
245
|
+
break;
|
|
246
|
+
default:
|
|
247
|
+
bits.push(t.label);
|
|
248
|
+
}
|
|
249
|
+
if (t.tip?.hint) bits.push(t.tip.hint);
|
|
250
|
+
if (t.tip && (t.tip.detail || t.tip.code || t.tip.link)) bits.push("Enter for details");
|
|
251
|
+
return bits.filter(Boolean).join(" · ");
|
|
252
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { textWidth } from "../diagram/canvas.ts";
|
|
2
|
+
import { imageLine } from "../lang/md.ts";
|
|
3
|
+
import { codeBlock } from "./code.ts";
|
|
4
|
+
import { image } from "./core.ts";
|
|
5
|
+
import { tableBlock } from "./table.ts";
|
|
6
|
+
import type { Block, Ctx } from "./types.ts";
|
|
7
|
+
import { cell, fromRows, vstack, wrapRuns, textCells, type Run, padRow } from "./util.ts";
|
|
8
|
+
|
|
9
|
+
export function inline(src: string, base = ""): Run[] {
|
|
10
|
+
const runs: Run[] = [];
|
|
11
|
+
const re = /(\*\*|__)(.+?)\1|(\*|_)([^*_\s][^*_]*?)\3|`([^`]+)`|\[([^\]]+)\]\(([^)\s]+)\)|~~(.+?)~~/;
|
|
12
|
+
let rest = src;
|
|
13
|
+
while (rest) {
|
|
14
|
+
const m = re.exec(rest);
|
|
15
|
+
if (!m) {
|
|
16
|
+
runs.push({ t: rest, role: base });
|
|
17
|
+
break;
|
|
18
|
+
}
|
|
19
|
+
if (m.index > 0) runs.push({ t: rest.slice(0, m.index), role: base });
|
|
20
|
+
if (m[2] !== undefined) runs.push({ t: m[2], role: "md.bold" });
|
|
21
|
+
else if (m[4] !== undefined) runs.push({ t: m[4], role: "md.italic" });
|
|
22
|
+
else if (m[5] !== undefined) runs.push({ t: m[5], role: "md.code" });
|
|
23
|
+
else if (m[6] !== undefined) runs.push({ t: m[6], role: "md.link" }, { t: ` (${m[7]})`, role: "md.url" });
|
|
24
|
+
else if (m[8] !== undefined) runs.push({ t: m[8], role: "md.strike" });
|
|
25
|
+
rest = rest.slice(m.index + m[0].length);
|
|
26
|
+
}
|
|
27
|
+
return runs;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const isSep = (l: string) => /^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$/.test(l) && l.includes("-");
|
|
31
|
+
const cells = (l: string) =>
|
|
32
|
+
l
|
|
33
|
+
.trim()
|
|
34
|
+
.replace(/^\|/, "")
|
|
35
|
+
.replace(/\|$/, "")
|
|
36
|
+
.split("|")
|
|
37
|
+
.map((c) => c.trim());
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Markdown as a block. With a render context, a line that is only `` becomes a picture, drawn by the same
|
|
41
|
+
* code as the Image component (so pi-tui sizes and encodes it, and a terminal without graphics gets the labelled frame). Without
|
|
42
|
+
* one (a quote, a tooltip panel) that syntax stays text. `path` makes the picture's target ids unique within the widget.
|
|
43
|
+
*/
|
|
44
|
+
export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""): Block {
|
|
45
|
+
const lines = String(src ?? "").replace(/\r/g, "").split("\n");
|
|
46
|
+
const blocks: Block[] = [];
|
|
47
|
+
let i = 0;
|
|
48
|
+
let pictures = 0;
|
|
49
|
+
const para: string[] = [];
|
|
50
|
+
const flush = () => {
|
|
51
|
+
if (!para.length) return;
|
|
52
|
+
blocks.push(fromRows(width, wrapRuns(inline(para.join(" ")), width)));
|
|
53
|
+
para.length = 0;
|
|
54
|
+
};
|
|
55
|
+
while (i < lines.length) {
|
|
56
|
+
const ln = lines[i];
|
|
57
|
+
const fence = /^\s*```\s*([\w+-]*)\s*$/.exec(ln);
|
|
58
|
+
if (fence) {
|
|
59
|
+
flush();
|
|
60
|
+
const body: string[] = [];
|
|
61
|
+
i++;
|
|
62
|
+
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) body.push(lines[i++]);
|
|
63
|
+
i++;
|
|
64
|
+
blocks.push(codeBlock(body.join("\n"), fence[1] || undefined, undefined, width, null, false));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const pic = ctx ? imageLine(ln) : null;
|
|
68
|
+
if (pic && ctx) {
|
|
69
|
+
flush();
|
|
70
|
+
const el = { type: "Image", props: { src: pic.src, alt: pic.alt || undefined, caption: pic.caption }, line: 0 };
|
|
71
|
+
blocks.push(image(el, width, `${path}.m${pictures++}`, ctx).block);
|
|
72
|
+
i++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const h = /^(#{1,6})\s+(.*?)\s*#*\s*$/.exec(ln);
|
|
76
|
+
if (h) {
|
|
77
|
+
flush();
|
|
78
|
+
const lvl = Math.min(3, h[1].length);
|
|
79
|
+
blocks.push(fromRows(width, wrapRuns(inline(h[2], `md.h${lvl}`), width)));
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(ln)) {
|
|
84
|
+
flush();
|
|
85
|
+
blocks.push(fromRows(width, [Array.from({ length: width }, () => cell("─", "md.hr"))]));
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (/^\s*>/.test(ln)) {
|
|
90
|
+
flush();
|
|
91
|
+
const q: string[] = [];
|
|
92
|
+
while (i < lines.length && /^\s*>/.test(lines[i])) q.push(lines[i++].replace(/^\s*>\s?/, ""));
|
|
93
|
+
const inner = markdownBlock(q.join("\n"), Math.max(4, width - 2));
|
|
94
|
+
blocks.push(fromRows(width, inner.rows.map((r) => [cell("▎", "md.quoteBar"), cell(" "), ...r.map((c) => (c.role ? c : cell(c.ch, "md.quote")))])));
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ln.includes("|") && i + 1 < lines.length && isSep(lines[i + 1])) {
|
|
98
|
+
flush();
|
|
99
|
+
const head = cells(ln);
|
|
100
|
+
i += 2;
|
|
101
|
+
const body: string[][] = [];
|
|
102
|
+
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
|
|
103
|
+
blocks.push(tableBlock(head, body, width).block);
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(ln);
|
|
107
|
+
if (li) {
|
|
108
|
+
flush();
|
|
109
|
+
const items: Block[] = [];
|
|
110
|
+
while (i < lines.length) {
|
|
111
|
+
const m = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(lines[i]);
|
|
112
|
+
if (!m) break;
|
|
113
|
+
const indent = Math.min(6, Math.floor(m[1].replace(/\t/g, " ").length / 2) * 2);
|
|
114
|
+
const bullet = /\d/.test(m[2]) ? m[2] : "•";
|
|
115
|
+
const bw = textWidth(bullet) + 1;
|
|
116
|
+
const body = wrapRuns(inline(m[3]), Math.max(4, width - indent - bw));
|
|
117
|
+
const rows = body.map((r, k) => [...Array.from({ length: indent }, () => cell(" ")), ...(k === 0 ? [...textCells(bullet, "md.bullet"), cell(" ")] : Array.from({ length: bw }, () => cell(" "))), ...r]);
|
|
118
|
+
items.push(fromRows(width, rows));
|
|
119
|
+
i++;
|
|
120
|
+
}
|
|
121
|
+
blocks.push(vstack(items, 0, width));
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (!ln.trim()) {
|
|
125
|
+
flush();
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
para.push(ln.trim());
|
|
130
|
+
i++;
|
|
131
|
+
}
|
|
132
|
+
flush();
|
|
133
|
+
const out = vstack(blocks, 1, width);
|
|
134
|
+
return { ...out, rows: out.rows.map((r) => padRow(r, width)) };
|
|
135
|
+
}
|