jeopi-tui 16.2.13

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +1861 -0
  2. package/README.md +705 -0
  3. package/dist/types/autocomplete.d.ts +99 -0
  4. package/dist/types/bracketed-paste.d.ts +51 -0
  5. package/dist/types/components/box.d.ts +31 -0
  6. package/dist/types/components/cancellable-loader.d.ts +21 -0
  7. package/dist/types/components/editor.d.ts +155 -0
  8. package/dist/types/components/image.d.ts +112 -0
  9. package/dist/types/components/input.d.ts +23 -0
  10. package/dist/types/components/loader.d.ts +20 -0
  11. package/dist/types/components/markdown.d.ts +64 -0
  12. package/dist/types/components/scroll-view.d.ts +62 -0
  13. package/dist/types/components/select-list.d.ts +68 -0
  14. package/dist/types/components/settings-list.d.ts +123 -0
  15. package/dist/types/components/spacer.d.ts +11 -0
  16. package/dist/types/components/tab-bar.d.ts +89 -0
  17. package/dist/types/components/text.d.ts +14 -0
  18. package/dist/types/components/truncated-text.d.ts +10 -0
  19. package/dist/types/deccara.d.ts +49 -0
  20. package/dist/types/desktop-notify.d.ts +51 -0
  21. package/dist/types/editor-component.d.ts +38 -0
  22. package/dist/types/fuzzy.d.ts +32 -0
  23. package/dist/types/index.d.ts +32 -0
  24. package/dist/types/keybindings.d.ts +191 -0
  25. package/dist/types/keys.d.ts +208 -0
  26. package/dist/types/kill-ring.d.ts +20 -0
  27. package/dist/types/kitty-graphics.d.ts +79 -0
  28. package/dist/types/latex-block.d.ts +7 -0
  29. package/dist/types/latex-to-unicode.d.ts +33 -0
  30. package/dist/types/loop-watchdog.d.ts +39 -0
  31. package/dist/types/mouse.d.ts +67 -0
  32. package/dist/types/stdin-buffer.d.ts +60 -0
  33. package/dist/types/symbols.d.ts +25 -0
  34. package/dist/types/terminal-capabilities.d.ts +284 -0
  35. package/dist/types/terminal.d.ts +107 -0
  36. package/dist/types/ttyid.d.ts +9 -0
  37. package/dist/types/tui.d.ts +423 -0
  38. package/dist/types/utils.d.ts +95 -0
  39. package/package.json +73 -0
  40. package/src/autocomplete.ts +1026 -0
  41. package/src/bracketed-paste.ts +123 -0
  42. package/src/components/box.ts +194 -0
  43. package/src/components/cancellable-loader.ts +40 -0
  44. package/src/components/editor.ts +3092 -0
  45. package/src/components/image.ts +444 -0
  46. package/src/components/input.ts +474 -0
  47. package/src/components/loader.ts +103 -0
  48. package/src/components/markdown.ts +2068 -0
  49. package/src/components/scroll-view.ts +227 -0
  50. package/src/components/select-list.ts +531 -0
  51. package/src/components/settings-list.ts +793 -0
  52. package/src/components/spacer.ts +32 -0
  53. package/src/components/tab-bar.ts +300 -0
  54. package/src/components/text.ts +122 -0
  55. package/src/components/truncated-text.ts +69 -0
  56. package/src/deccara.ts +314 -0
  57. package/src/desktop-notify.ts +186 -0
  58. package/src/editor-component.ts +74 -0
  59. package/src/fuzzy.ts +356 -0
  60. package/src/index.ts +51 -0
  61. package/src/keybindings.ts +337 -0
  62. package/src/keys.ts +561 -0
  63. package/src/kill-ring.ts +51 -0
  64. package/src/kitty-graphics.ts +171 -0
  65. package/src/latex-block.ts +461 -0
  66. package/src/latex-to-unicode.ts +1994 -0
  67. package/src/loop-watchdog.ts +106 -0
  68. package/src/mouse.ts +105 -0
  69. package/src/stdin-buffer.ts +669 -0
  70. package/src/symbols.ts +26 -0
  71. package/src/terminal-capabilities.ts +1152 -0
  72. package/src/terminal.ts +1463 -0
  73. package/src/ttyid.ts +84 -0
  74. package/src/tui.ts +3901 -0
  75. package/src/utils.ts +570 -0
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Kitty graphics: Unicode placeholder placement (`U=1` + U+10EEEE), with
3
+ * runtime feature state and env overrides.
4
+ *
5
+ * Unicode placeholders let a transmitted image be displayed by writing ordinary
6
+ * text cells — the placeholder char U+10EEEE plus row/column combining
7
+ * diacritics — instead of a cursor-positioned `a=p` direct placement. The image
8
+ * then participates in the normal text grid, so it survives horizontal slicing,
9
+ * reflow and overlapping draws (each cell names its own row+column, so a sliced
10
+ * row still maps to the correct sub-region). See kitty
11
+ * `docs/graphics-protocol.rst` "Unicode placeholders for relative placements".
12
+ *
13
+ * This module is intentionally free of `./terminal-capabilities` imports so the
14
+ * dependency stays one-way (capabilities → kitty-graphics) and no import cycle
15
+ * forms. Protocol gating (`imageProtocol === Kitty`) lives in the caller.
16
+ */
17
+
18
+ /** Kitty Unicode placeholder base character (U+10EEEE, Plane 16 PUA). */
19
+ export const KITTY_PLACEHOLDER = "\u{10eeee}";
20
+
21
+ /**
22
+ * Row/column diacritics (Unicode combining class 230, no decomposition) used to
23
+ * name a placeholder cell's row and column. Index `i` → codepoint. Derived from
24
+ * kitty `gen/rowcolumn-diacritics.txt` (Unicode 6.0.0 NSM set). 297 entries, so
25
+ * a single image can address up to 297 rows/columns without ID-high-byte tricks.
26
+ */
27
+ const ROWCOLUMN_DIACRITICS: readonly number[] = [
28
+ 0x305, 0x30d, 0x30e, 0x310, 0x312, 0x33d, 0x33e, 0x33f, 0x346, 0x34a, 0x34b, 0x34c, 0x350, 0x351, 0x352, 0x357,
29
+ 0x35b, 0x363, 0x364, 0x365, 0x366, 0x367, 0x368, 0x369, 0x36a, 0x36b, 0x36c, 0x36d, 0x36e, 0x36f, 0x483, 0x484,
30
+ 0x485, 0x486, 0x487, 0x592, 0x593, 0x594, 0x595, 0x597, 0x598, 0x599, 0x59c, 0x59d, 0x59e, 0x59f, 0x5a0, 0x5a1,
31
+ 0x5a8, 0x5a9, 0x5ab, 0x5ac, 0x5af, 0x5c4, 0x610, 0x611, 0x612, 0x613, 0x614, 0x615, 0x616, 0x617, 0x657, 0x658,
32
+ 0x659, 0x65a, 0x65b, 0x65d, 0x65e, 0x6d6, 0x6d7, 0x6d8, 0x6d9, 0x6da, 0x6db, 0x6dc, 0x6df, 0x6e0, 0x6e1, 0x6e2,
33
+ 0x6e4, 0x6e7, 0x6e8, 0x6eb, 0x6ec, 0x730, 0x732, 0x733, 0x735, 0x736, 0x73a, 0x73d, 0x73f, 0x740, 0x741, 0x743,
34
+ 0x745, 0x747, 0x749, 0x74a, 0x7eb, 0x7ec, 0x7ed, 0x7ee, 0x7ef, 0x7f0, 0x7f1, 0x7f3, 0x816, 0x817, 0x818, 0x819,
35
+ 0x81b, 0x81c, 0x81d, 0x81e, 0x81f, 0x820, 0x821, 0x822, 0x823, 0x825, 0x826, 0x827, 0x829, 0x82a, 0x82b, 0x82c,
36
+ 0x82d, 0x951, 0x953, 0x954, 0xf82, 0xf83, 0xf86, 0xf87, 0x135d, 0x135e, 0x135f, 0x17dd, 0x193a, 0x1a17, 0x1a75,
37
+ 0x1a76, 0x1a77, 0x1a78, 0x1a79, 0x1a7a, 0x1a7b, 0x1a7c, 0x1b6b, 0x1b6d, 0x1b6e, 0x1b6f, 0x1b70, 0x1b71, 0x1b72,
38
+ 0x1b73, 0x1cd0, 0x1cd1, 0x1cd2, 0x1cda, 0x1cdb, 0x1ce0, 0x1dc0, 0x1dc1, 0x1dc3, 0x1dc4, 0x1dc5, 0x1dc6, 0x1dc7,
39
+ 0x1dc8, 0x1dc9, 0x1dcb, 0x1dcc, 0x1dd1, 0x1dd2, 0x1dd3, 0x1dd4, 0x1dd5, 0x1dd6, 0x1dd7, 0x1dd8, 0x1dd9, 0x1dda,
40
+ 0x1ddb, 0x1ddc, 0x1ddd, 0x1dde, 0x1ddf, 0x1de0, 0x1de1, 0x1de2, 0x1de3, 0x1de4, 0x1de5, 0x1de6, 0x1dfe, 0x20d0,
41
+ 0x20d1, 0x20d4, 0x20d5, 0x20d6, 0x20d7, 0x20db, 0x20dc, 0x20e1, 0x20e7, 0x20e9, 0x20f0, 0x2cef, 0x2cf0, 0x2cf1,
42
+ 0x2de0, 0x2de1, 0x2de2, 0x2de3, 0x2de4, 0x2de5, 0x2de6, 0x2de7, 0x2de8, 0x2de9, 0x2dea, 0x2deb, 0x2dec, 0x2ded,
43
+ 0x2dee, 0x2def, 0x2df0, 0x2df1, 0x2df2, 0x2df3, 0x2df4, 0x2df5, 0x2df6, 0x2df7, 0x2df8, 0x2df9, 0x2dfa, 0x2dfb,
44
+ 0x2dfc, 0x2dfd, 0x2dfe, 0x2dff, 0xa66f, 0xa67c, 0xa67d, 0xa6f0, 0xa6f1, 0xa8e0, 0xa8e1, 0xa8e2, 0xa8e3, 0xa8e4,
45
+ 0xa8e5, 0xa8e6, 0xa8e7, 0xa8e8, 0xa8e9, 0xa8ea, 0xa8eb, 0xa8ec, 0xa8ed, 0xa8ee, 0xa8ef, 0xa8f0, 0xa8f1, 0xaab0,
46
+ 0xaab2, 0xaab3, 0xaab7, 0xaab8, 0xaabe, 0xaabf, 0xaac1, 0xfe20, 0xfe21, 0xfe22, 0xfe23, 0xfe24, 0xfe25, 0xfe26,
47
+ 0x10a0f, 0x10a38, 0x1d185, 0x1d186, 0x1d187, 0x1d188, 0x1d189, 0x1d1aa, 0x1d1ab, 0x1d1ac, 0x1d1ad, 0x1d242, 0x1d243,
48
+ 0x1d244,
49
+ ];
50
+
51
+ /** Largest row/column index expressible with the diacritic table (one cell each). */
52
+ export const KITTY_PLACEHOLDER_MAX_CELLS = ROWCOLUMN_DIACRITICS.length;
53
+
54
+ export interface KittyGraphicsFeatures {
55
+ /** Display images via Unicode placeholders instead of direct `a=p` placement. */
56
+ unicodePlaceholders: boolean;
57
+ }
58
+
59
+ /**
60
+ * Whether the detected terminal renders Kitty Unicode placeholders (`U=1` +
61
+ * U+10EEEE with row/column diacritics).
62
+ *
63
+ * Only `kitty` (the protocol's origin) and `ghostty` ship a working
64
+ * implementation; WezTerm advertises Kitty graphics but treats placeholder
65
+ * cells as literal PUA glyphs (see wezterm/wezterm#986, "placeholder support"
66
+ * still unchecked), and the tmux/screen fallback can land on any outer
67
+ * terminal. Enabling placeholders on those paths emits a `columns × rows`
68
+ * grid of U+10EEEE per image per frame; the cells render as boxed fallback
69
+ * glyphs and re-emit on every repaint, which is exactly the
70
+ * "stuck/laggy scrolling + ASCII artifact" symptom reported in #1877.
71
+ *
72
+ * `PI_NO_KITTY_PLACEHOLDERS=1` forces off (e.g. for tmux passthrough to a
73
+ * non-supporting outer terminal); `PI_KITTY_PLACEHOLDERS=1` forces on (e.g.
74
+ * for a wezterm nightly that has merged placeholder support).
75
+ */
76
+ export function detectKittyUnicodePlaceholdersSupport(terminalId: string, env: NodeJS.ProcessEnv = Bun.env): boolean {
77
+ const offRaw = env.PI_NO_KITTY_PLACEHOLDERS?.trim().toLowerCase();
78
+ if (offRaw === "1" || offRaw === "true" || offRaw === "on" || offRaw === "yes" || offRaw === "y") return false;
79
+ const force = env.PI_KITTY_PLACEHOLDERS?.trim().toLowerCase();
80
+ if (force === "1" || force === "true" || force === "on" || force === "yes" || force === "y") return true;
81
+ if (force === "0" || force === "false" || force === "off" || force === "no" || force === "n") return false;
82
+ return terminalId === "kitty" || terminalId === "ghostty";
83
+ }
84
+
85
+ let features: KittyGraphicsFeatures = {
86
+ // Off until `terminal-capabilities` seeds it from the detected terminal id —
87
+ // the default-on path corrupts wezterm and tmux-passthrough sessions.
88
+ unicodePlaceholders: false,
89
+ };
90
+
91
+ export function getKittyGraphics(): Readonly<KittyGraphicsFeatures> {
92
+ return features;
93
+ }
94
+
95
+ export function setKittyGraphics(partial: Partial<KittyGraphicsFeatures>): void {
96
+ features = { ...features, ...partial };
97
+ }
98
+
99
+ /** Whether a `columns`×`rows` placeholder grid fits within the diacritic table. */
100
+ export function kittyPlaceholdersFit(columns: number, rows: number): boolean {
101
+ return columns >= 1 && rows >= 1 && columns <= KITTY_PLACEHOLDER_MAX_CELLS && rows <= KITTY_PLACEHOLDER_MAX_CELLS;
102
+ }
103
+
104
+ function diacritic(index: number): string {
105
+ const cp = ROWCOLUMN_DIACRITICS[index];
106
+ return cp === undefined ? "" : String.fromCodePoint(cp);
107
+ }
108
+
109
+ /**
110
+ * Virtual placement APC (`a=p,U=1`): tells the terminal that placeholder cells
111
+ * carrying image id `i` should display the transmitted image, scaled to fit the
112
+ * `c`×`r` cell box. Re-emitting with a stable `placementId` replaces in place.
113
+ */
114
+ export function encodeKittyVirtualPlacement(opts: {
115
+ imageId: number;
116
+ placementId?: number;
117
+ columns: number;
118
+ rows: number;
119
+ }): string {
120
+ const params = ["a=p", "U=1", "q=2", `i=${opts.imageId}`];
121
+ if (opts.placementId) params.push(`p=${opts.placementId}`);
122
+ params.push(`c=${opts.columns}`, `r=${opts.rows}`);
123
+ return `\x1b_G${params.join(",")}\x1b\\`;
124
+ }
125
+
126
+ /**
127
+ * Build the placeholder cell grid as one string per row. The image id is carried
128
+ * in each row's foreground color and the placement id (if any) in its underline
129
+ * color; every cell names its explicit row+column diacritic (robust to slicing,
130
+ * unlike left-inheritance). Returns exactly `rows` strings.
131
+ */
132
+ export function encodeKittyPlaceholderGrid(opts: {
133
+ imageId: number;
134
+ placementId?: number;
135
+ columns: number;
136
+ rows: number;
137
+ }): string[] {
138
+ const fg = `\x1b[38;2;${(opts.imageId >> 16) & 0xff};${(opts.imageId >> 8) & 0xff};${opts.imageId & 0xff}m`;
139
+ const underline = opts.placementId
140
+ ? `\x1b[58:2::${(opts.placementId >> 16) & 0xff}:${(opts.placementId >> 8) & 0xff}:${opts.placementId & 0xff}m`
141
+ : "";
142
+ const reset = "\x1b[39;59m";
143
+ const lead = fg + underline;
144
+ const out: string[] = [];
145
+ for (let r = 0; r < opts.rows; r++) {
146
+ const rowDiacritic = diacritic(r);
147
+ let row = lead;
148
+ for (let c = 0; c < opts.columns; c++) {
149
+ row += KITTY_PLACEHOLDER + rowDiacritic + diacritic(c);
150
+ }
151
+ out.push(row + reset);
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * Full placeholder render: the virtual-placement APC prefixes line 0, and every
158
+ * line carries placeholder cells. Returns exactly `rows` lines (no cursor moves).
159
+ */
160
+ export function renderKittyPlaceholderLines(opts: {
161
+ imageId: number;
162
+ placementId?: number;
163
+ columns: number;
164
+ rows: number;
165
+ }): string[] {
166
+ const grid = encodeKittyPlaceholderGrid(opts);
167
+ if (grid.length > 0) {
168
+ grid[0] = encodeKittyVirtualPlacement(opts) + grid[0];
169
+ }
170
+ return grid;
171
+ }
@@ -0,0 +1,461 @@
1
+ // Two-dimensional layout for *display* LaTeX math: stacks `\frac` numerator over
2
+ // denominator with a horizontal bar, aligning surrounding text to the bar's row.
3
+ //
4
+ // −b ± √(b² − 4ac)
5
+ // x = ────────────────
6
+ // 2a
7
+ //
8
+ // Only display blocks (`$$…$$`, `\[…\]`) use this; inline `$…$` stays single-line
9
+ // (`½`, `(a+b)/c`). Everything that is not a fraction — symbols, scripts, roots,
10
+ // matrices, environments — is delegated to `latexToUnicode`, so this engine only
11
+ // adds the vertical stacking the flat string form can't express.
12
+
13
+ import { latexToUnicode } from "./latex-to-unicode";
14
+ import { visibleWidth } from "./utils";
15
+
16
+ /**
17
+ * A rectangular block of rendered text. Every entry in `lines` is padded to
18
+ * exactly `width` visible columns; `baseline` is the row that aligns with the
19
+ * surrounding text when boxes are placed side by side (e.g. the fraction bar).
20
+ */
21
+ interface Box {
22
+ lines: string[];
23
+ baseline: number;
24
+ width: number;
25
+ }
26
+
27
+ const BAR = "─";
28
+ const FRAC_COMMANDS: Record<string, true> = { frac: true, dfrac: true, tfrac: true, cfrac: true };
29
+
30
+ // Display "wrapper" environments whose body is an expression (possibly with `\\`
31
+ // row breaks and `&` alignment). Their bodies are parsed so fractions inside
32
+ // stack; grid/structure environments (matrix/array/cases) stay opaque and are
33
+ // rendered flat by `latexToUnicode`.
34
+ const DISPLAY_ROW_ENVIRONMENTS: Record<string, true> = {
35
+ equation: true,
36
+ eqnarray: true,
37
+ align: true,
38
+ aligned: true,
39
+ alignat: true,
40
+ alignedat: true,
41
+ flalign: true,
42
+ split: true,
43
+ gather: true,
44
+ gathered: true,
45
+ gatheredat: true,
46
+ multline: true,
47
+ displaymath: true,
48
+ math: true,
49
+ };
50
+
51
+ function spaces(n: number): string {
52
+ return n > 0 ? " ".repeat(n) : "";
53
+ }
54
+
55
+ /** Pad `line` on the right to `width` visible columns. */
56
+ function padRight(line: string, width: number): string {
57
+ return line + spaces(width - visibleWidth(line));
58
+ }
59
+
60
+ /** Pad `line` symmetrically (left-biased) to `width` visible columns. */
61
+ function center(line: string, width: number): string {
62
+ const extra = width - visibleWidth(line);
63
+ if (extra <= 0) return line;
64
+ const left = extra >> 1;
65
+ return spaces(left) + line + spaces(extra - left);
66
+ }
67
+
68
+ /** A single rendered string (possibly multi-line) as a baseline-centered box. */
69
+ function textBox(text: string): Box {
70
+ const raw = text.split("\n");
71
+ let width = 0;
72
+ for (const line of raw) width = Math.max(width, visibleWidth(line));
73
+ return { lines: raw.map(line => padRight(line, width)), baseline: (raw.length - 1) >> 1, width };
74
+ }
75
+
76
+ /** Place boxes side by side, aligning their baselines. */
77
+ function hconcat(boxes: Box[]): Box {
78
+ if (boxes.length === 1) return boxes[0];
79
+ let above = 0;
80
+ let below = 0;
81
+ for (const b of boxes) {
82
+ above = Math.max(above, b.baseline);
83
+ below = Math.max(below, b.lines.length - 1 - b.baseline);
84
+ }
85
+ const height = above + below + 1;
86
+ const lines: string[] = [];
87
+ let width = 0;
88
+ for (const b of boxes) width += b.width;
89
+ for (let row = 0; row < height; row++) {
90
+ let line = "";
91
+ for (const b of boxes) {
92
+ const local = row - (above - b.baseline);
93
+ line += local >= 0 && local < b.lines.length ? b.lines[local] : spaces(b.width);
94
+ }
95
+ lines.push(line);
96
+ }
97
+ return { lines, baseline: above, width };
98
+ }
99
+
100
+ /** Stack `num` over `den`, separated by a bar; the bar becomes the baseline. */
101
+ function fracBox(num: Box, den: Box): Box {
102
+ const width = Math.max(num.width, den.width) + 2;
103
+ const lines = [
104
+ ...num.lines.map(line => center(line, width)),
105
+ BAR.repeat(width),
106
+ ...den.lines.map(line => center(line, width)),
107
+ ];
108
+ return { lines, baseline: num.lines.length, width };
109
+ }
110
+
111
+ /** Stack boxes vertically (left-aligned), e.g. the rows of an aligned block. */
112
+ function vconcat(boxes: Box[]): Box {
113
+ if (boxes.length === 1) return boxes[0];
114
+ let width = 0;
115
+ for (const b of boxes) width = Math.max(width, b.width);
116
+ const lines: string[] = [];
117
+ for (const b of boxes) for (const line of b.lines) lines.push(padRight(line, width));
118
+ return { lines, baseline: (lines.length - 1) >> 1, width };
119
+ }
120
+
121
+ interface Span {
122
+ text: string;
123
+ end: number;
124
+ }
125
+
126
+ /** Read a balanced `{…}` beginning at `i` (which must point at `{`). */
127
+ function readBraceGroup(src: string, i: number): Span {
128
+ let depth = 0;
129
+ let out = "";
130
+ let j = i;
131
+ for (; j < src.length; j++) {
132
+ const c = src[j];
133
+ if (c === "\\") {
134
+ out += c + (src[j + 1] ?? "");
135
+ j++;
136
+ continue;
137
+ }
138
+ if (c === "{") {
139
+ depth++;
140
+ if (depth > 1) out += c;
141
+ continue;
142
+ }
143
+ if (c === "}") {
144
+ depth--;
145
+ if (depth === 0) {
146
+ j++;
147
+ break;
148
+ }
149
+ out += c;
150
+ continue;
151
+ }
152
+ out += c;
153
+ }
154
+ return { text: out, end: j };
155
+ }
156
+
157
+ /**
158
+ * Read one fraction argument: a `{…}` group, a single char, or a `\command`
159
+ * together with its attached `[…]`/`{…}` arguments (or whole `\begin…\end`
160
+ * block), so e.g. `\frac\sqrt{a}{b}` reads `\sqrt{a}` as the numerator.
161
+ */
162
+ function readArg(src: string, i: number): Span {
163
+ while (src[i] === " ") i++;
164
+ if (i >= src.length) return { text: "", end: i };
165
+ if (src[i] === "{") return readBraceGroup(src, i);
166
+ if (src[i] !== "\\") return { text: src[i], end: i + 1 };
167
+ let j = i + 1;
168
+ let name = "";
169
+ while (/[A-Za-z]/.test(src[j] ?? "")) {
170
+ name += src[j];
171
+ j++;
172
+ }
173
+ if (name === "begin") {
174
+ const env = consumeEnvironment(src, i);
175
+ if (env) return env;
176
+ }
177
+ if (!name) return { text: src.slice(i, i + 2), end: i + 2 }; // non-letter command (\,, \{, …)
178
+ let end = j;
179
+ while (src[end] === "[" || src[end] === "{") {
180
+ if (src[end] === "{") end = readBraceGroup(src, end).end;
181
+ else {
182
+ const close = src.indexOf("]", end);
183
+ end = close === -1 ? src.length : close + 1;
184
+ }
185
+ }
186
+ return { text: src.slice(i, end), end };
187
+ }
188
+
189
+ interface EnvParts {
190
+ env: string;
191
+ bodyStart: number;
192
+ bodyEnd: number;
193
+ end: number;
194
+ }
195
+
196
+ /** Locate a `\begin{env}…\end{env}` block (balanced) starting at the backslash. */
197
+ function readEnvironment(src: string, start: number): EnvParts | null {
198
+ let i = start + 6; // past "\begin"
199
+ while (src[i] === " ") i++;
200
+ if (src[i] !== "{") return null;
201
+ const nameGroup = readBraceGroup(src, i);
202
+ let k = nameGroup.end;
203
+ let depth = 1;
204
+ let bodyEnd = src.length;
205
+ while (k < src.length && depth > 0) {
206
+ if (src.startsWith("\\begin", k)) {
207
+ depth++;
208
+ k += 6;
209
+ continue;
210
+ }
211
+ if (src.startsWith("\\end", k)) {
212
+ depth--;
213
+ if (depth === 0) bodyEnd = k;
214
+ k += 4;
215
+ while (src[k] === " ") k++;
216
+ if (src[k] === "{") k = readBraceGroup(src, k).end;
217
+ if (depth === 0) break;
218
+ continue;
219
+ }
220
+ k++;
221
+ }
222
+ return { env: nameGroup.text.trim(), bodyStart: nameGroup.end, bodyEnd, end: k };
223
+ }
224
+
225
+ /** The full `\begin{env}…\end{env}` substring as an inline run. */
226
+ function consumeEnvironment(src: string, start: number): Span | null {
227
+ const env = readEnvironment(src, start);
228
+ return env ? { text: src.slice(start, env.end), end: env.end } : null;
229
+ }
230
+
231
+ /** Split an environment body on top-level `\\` row breaks (depth-aware). */
232
+ function splitRows(body: string): string[] {
233
+ const rows: string[] = [];
234
+ let braceDepth = 0;
235
+ let envDepth = 0;
236
+ let last = 0;
237
+ let i = 0;
238
+ while (i < body.length) {
239
+ if (body.startsWith("\\begin", i)) {
240
+ envDepth++;
241
+ i += 6;
242
+ continue;
243
+ }
244
+ if (body.startsWith("\\end", i)) {
245
+ envDepth--;
246
+ i += 4;
247
+ continue;
248
+ }
249
+ const c = body[i];
250
+ if (c === "\\") {
251
+ if (body[i + 1] === "\\" && braceDepth === 0 && envDepth === 0) {
252
+ rows.push(body.slice(last, i));
253
+ i += 2;
254
+ while (body[i] === " ") i++;
255
+ if (body[i] === "[") {
256
+ const close = body.indexOf("]", i);
257
+ i = close === -1 ? body.length : close + 1;
258
+ }
259
+ last = i;
260
+ continue;
261
+ }
262
+ i += 2; // skip escaped char / second backslash so `\{`/`\\` never skew depth
263
+ continue;
264
+ }
265
+ if (c === "{") braceDepth++;
266
+ else if (c === "}") braceDepth--;
267
+ i++;
268
+ }
269
+ rows.push(body.slice(last));
270
+ return rows;
271
+ }
272
+
273
+ /**
274
+ * Render a `\begin{env}…\end{env}` block. Expression "wrapper" environments
275
+ * (`equation`, `align`, `gather`, …) have their rows parsed so fractions stack;
276
+ * grid/structure environments (matrix/array/cases) render flat via
277
+ * `latexToUnicode`.
278
+ */
279
+ function parseEnvironment(src: string, start: number): { box: Box; end: number } | null {
280
+ const env = readEnvironment(src, start);
281
+ if (env === null) return null;
282
+ const base = env.env.endsWith("*") ? env.env.slice(0, -1) : env.env;
283
+ if (!DISPLAY_ROW_ENVIRONMENTS[base]) {
284
+ return { box: textBox(latexToUnicode(src.slice(start, env.end))), end: env.end };
285
+ }
286
+ let bodyStart = env.bodyStart;
287
+ if (base === "alignat" || base === "alignedat" || base === "gatheredat") {
288
+ // These carry a required column-count argument `{n}` before the body.
289
+ let p = bodyStart;
290
+ while (src[p] === " " || src[p] === "\n") p++;
291
+ if (src[p] === "{") bodyStart = readBraceGroup(src, p).end;
292
+ }
293
+ const rows = splitRows(src.slice(bodyStart, env.bodyEnd))
294
+ .map(row => row.trim())
295
+ .filter(row => row !== "")
296
+ .map(row => parseExpr(row));
297
+ return { box: rows.length > 0 ? vconcat(rows) : textBox(""), end: env.end };
298
+ }
299
+
300
+ /** Append a script (`^`/`_`) and its argument to the inline run verbatim. */
301
+ function readScript(src: string, i: number): Span {
302
+ let out = src[i];
303
+ i++;
304
+ while (src[i] === " ") {
305
+ out += src[i];
306
+ i++;
307
+ }
308
+ if (src[i] === "{") {
309
+ const group = readBraceGroup(src, i);
310
+ return { text: `${out}{${group.text}}`, end: group.end };
311
+ }
312
+ if (src[i] === "\\") {
313
+ let j = i + 1;
314
+ if (/[A-Za-z]/.test(src[j] ?? "")) while (/[A-Za-z]/.test(src[j] ?? "")) j++;
315
+ else j++;
316
+ return { text: out + src.slice(i, j), end: j };
317
+ }
318
+ if (i < src.length) return { text: out + src[i], end: i + 1 };
319
+ return { text: out, end: i };
320
+ }
321
+
322
+ /**
323
+ * Parse a math fragment into a layout box, stacking top-level fractions (and
324
+ * fractions nested inside other fractions' arguments). Non-fraction runs —
325
+ * including scripts, roots, environments, and command arguments — are gathered
326
+ * into inline strings and rendered through `latexToUnicode`.
327
+ */
328
+ function parseExpr(src: string): Box {
329
+ const boxes: Box[] = [];
330
+ let inline = "";
331
+ const flush = (): void => {
332
+ if (inline) {
333
+ boxes.push(textBox(latexToUnicode(inline)));
334
+ inline = "";
335
+ }
336
+ };
337
+ let i = 0;
338
+ while (i < src.length) {
339
+ const c = src[i];
340
+ if (c === "\\") {
341
+ let j = i + 1;
342
+ let name = "";
343
+ while (j < src.length && /[A-Za-z]/.test(src[j])) {
344
+ name += src[j];
345
+ j++;
346
+ }
347
+ if (name && FRAC_COMMANDS[name]) {
348
+ flush();
349
+ const num = readArg(src, j);
350
+ const den = readArg(src, num.end);
351
+ boxes.push(fracBox(parseExpr(num.text), parseExpr(den.text)));
352
+ i = den.end;
353
+ continue;
354
+ }
355
+ if (name === "begin") {
356
+ const env = parseEnvironment(src, i);
357
+ if (env) {
358
+ flush();
359
+ boxes.push(env.box);
360
+ i = env.end;
361
+ continue;
362
+ }
363
+ }
364
+ if (!name) {
365
+ // Non-letter command (`\\`, `\,`, `\{`, …): keep the 2-char token inline.
366
+ inline += `\\${src[j] ?? ""}`;
367
+ i = j + 1;
368
+ continue;
369
+ }
370
+ // Other command: keep it and its bracket/brace arguments inline so a
371
+ // `{…}` argument is never mistaken for a top-level stacking group.
372
+ inline += `\\${name}`;
373
+ i = j;
374
+ while (src[i] === "[" || src[i] === "{") {
375
+ if (src[i] === "{") {
376
+ const group = readBraceGroup(src, i);
377
+ inline += `{${group.text}}`;
378
+ i = group.end;
379
+ } else {
380
+ const close = src.indexOf("]", i);
381
+ const end = close === -1 ? src.length : close + 1;
382
+ inline += src.slice(i, end);
383
+ i = end;
384
+ }
385
+ }
386
+ continue;
387
+ }
388
+ if (c === "^" || c === "_") {
389
+ const script = readScript(src, i);
390
+ inline += script.text;
391
+ i = script.end;
392
+ continue;
393
+ }
394
+ if (c === "{") {
395
+ const group = readBraceGroup(src, i);
396
+ flush();
397
+ boxes.push(parseExpr(group.text));
398
+ i = group.end;
399
+ continue;
400
+ }
401
+ inline += c;
402
+ i++;
403
+ }
404
+ flush();
405
+ if (boxes.length === 0) return textBox("");
406
+ return hconcat(boxes);
407
+ }
408
+
409
+ /** Split on top-level `\n` row separators (outside braces and environments). */
410
+ function splitLines(src: string): string[] {
411
+ const lines: string[] = [];
412
+ let braceDepth = 0;
413
+ let envDepth = 0;
414
+ let last = 0;
415
+ let i = 0;
416
+ while (i < src.length) {
417
+ if (src.startsWith("\\begin", i)) {
418
+ envDepth++;
419
+ i += 6;
420
+ continue;
421
+ }
422
+ if (src.startsWith("\\end", i)) {
423
+ envDepth--;
424
+ i += 4;
425
+ continue;
426
+ }
427
+ const c = src[i];
428
+ if (c === "\\") {
429
+ i += 2; // escaped char / second backslash — never a logical-line break
430
+ continue;
431
+ }
432
+ if (c === "{") braceDepth++;
433
+ else if (c === "}") braceDepth--;
434
+ else if (c === "\n" && braceDepth === 0 && envDepth === 0) {
435
+ lines.push(src.slice(last, i));
436
+ last = i + 1;
437
+ }
438
+ i++;
439
+ }
440
+ lines.push(src.slice(last));
441
+ return lines;
442
+ }
443
+
444
+ /**
445
+ * Render a display LaTeX math fragment to lines, stacking `\frac` vertically.
446
+ * Top-level source newlines become vertical rows (so a `lhs =` line stays above
447
+ * its block); each row stacks fractions via `parseExpr`. Inline math should use
448
+ * `latexToUnicode` instead — fractions there stay single-line.
449
+ */
450
+ export function latexToBlock(src: string): string[] {
451
+ if (typeof src !== "string" || src.trim() === "") return [];
452
+ const rows = splitLines(src.trim())
453
+ .map(line => line.trim())
454
+ .filter(line => line !== "")
455
+ .map(line => parseExpr(line));
456
+ if (rows.length === 0) return [];
457
+ let lines = vconcat(rows).lines;
458
+ while (lines.length > 1 && lines[lines.length - 1].trim() === "") lines = lines.slice(0, -1);
459
+ while (lines.length > 1 && lines[0].trim() === "") lines = lines.slice(1);
460
+ return lines;
461
+ }