@oh-my-pi/pi-tui 16.5.2 → 17.0.1
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 +27 -0
- package/dist/types/components/editor.d.ts +2 -0
- package/dist/types/components/markdown.d.ts +10 -2
- package/dist/types/terminal.d.ts +5 -3
- package/dist/types/tui.d.ts +11 -1
- package/package.json +3 -3
- package/src/autocomplete.ts +14 -16
- package/src/components/editor.ts +26 -3
- package/src/components/markdown.ts +268 -31
- package/src/latex-block.ts +110 -2
- package/src/terminal-capabilities.ts +50 -3
- package/src/terminal.ts +29 -22
- package/src/tui.ts +66 -50
|
@@ -4,7 +4,7 @@ import { latexToBlock } from "../latex-block";
|
|
|
4
4
|
import { inlineMathSpanEnd, isBareMathEnvironment, latexToUnicode } from "../latex-to-unicode";
|
|
5
5
|
import type { SymbolTheme } from "../symbols";
|
|
6
6
|
import { TERMINAL } from "../terminal-capabilities";
|
|
7
|
-
import type { Component } from "../tui";
|
|
7
|
+
import type { Component, NativeScrollbackCommittedRows, NativeScrollbackReplay } from "../tui";
|
|
8
8
|
import {
|
|
9
9
|
applyBackgroundToLine,
|
|
10
10
|
Ellipsis,
|
|
@@ -607,11 +607,17 @@ const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
|
|
|
607
607
|
const RENDER_CACHE_MAX_SIZE = 512 * 1024;
|
|
608
608
|
const RENDER_CACHE_MAX_ENTRY_SIZE = 32 * 1024;
|
|
609
609
|
const EMPTY_RENDER_LINES: readonly string[] = [];
|
|
610
|
-
|
|
610
|
+
|
|
611
|
+
interface RenderCacheEntry {
|
|
612
|
+
lines: readonly string[];
|
|
613
|
+
tables: readonly RenderedTableLayout[];
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const renderCache = new LRUCache<string, RenderCacheEntry>({
|
|
611
617
|
max: RENDER_CACHE_MAX,
|
|
612
618
|
maxSize: RENDER_CACHE_MAX_SIZE,
|
|
613
619
|
maxEntrySize: RENDER_CACHE_MAX_ENTRY_SIZE,
|
|
614
|
-
sizeCalculation:
|
|
620
|
+
sizeCalculation: renderCacheEntrySize,
|
|
615
621
|
});
|
|
616
622
|
|
|
617
623
|
function renderedLinesCacheSize(lines: readonly string[]): number {
|
|
@@ -620,6 +626,12 @@ function renderedLinesCacheSize(lines: readonly string[]): number {
|
|
|
620
626
|
return Math.max(1, size);
|
|
621
627
|
}
|
|
622
628
|
|
|
629
|
+
function renderCacheEntrySize(entry: RenderCacheEntry): number {
|
|
630
|
+
let size = renderedLinesCacheSize(entry.lines);
|
|
631
|
+
for (const table of entry.tables) size += table.key.length + table.columnWidths.length + 4;
|
|
632
|
+
return size;
|
|
633
|
+
}
|
|
634
|
+
|
|
623
635
|
// A reference-link definition (`[label]: dest`) resolves across the whole
|
|
624
636
|
// document, so a split lex cannot reproduce it — disable the streaming fast path
|
|
625
637
|
// when one is present (rare in streamed output). The label may contain
|
|
@@ -951,6 +963,7 @@ interface StreamPrefixLineCache extends RenderSignature {
|
|
|
951
963
|
text: string;
|
|
952
964
|
tokenCount: number;
|
|
953
965
|
lines: readonly string[];
|
|
966
|
+
tables: readonly TableRenderSpec[];
|
|
954
967
|
}
|
|
955
968
|
interface StreamingDiffLineCache extends RenderSignature {
|
|
956
969
|
lang: string | undefined;
|
|
@@ -958,7 +971,25 @@ interface StreamingDiffLineCache extends RenderSignature {
|
|
|
958
971
|
lines: readonly string[];
|
|
959
972
|
}
|
|
960
973
|
|
|
961
|
-
|
|
974
|
+
interface TableLayoutLock {
|
|
975
|
+
availableWidth: number;
|
|
976
|
+
columnWidths: readonly number[];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
interface TableRenderSpec extends TableLayoutLock {
|
|
980
|
+
key: string;
|
|
981
|
+
lineCount: number;
|
|
982
|
+
startRow: number;
|
|
983
|
+
endRow: number;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
interface RenderedTableLayout extends TableLayoutLock {
|
|
987
|
+
key: string;
|
|
988
|
+
startRow: number;
|
|
989
|
+
endRow: number;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export class Markdown implements Component, NativeScrollbackCommittedRows, NativeScrollbackReplay {
|
|
962
993
|
#text: string;
|
|
963
994
|
#paddingX: number; // Left/right padding
|
|
964
995
|
#paddingY: number; // Top/bottom padding
|
|
@@ -1005,10 +1036,19 @@ export class Markdown implements Component {
|
|
|
1005
1036
|
#renderingFrozenPrefix = false;
|
|
1006
1037
|
#streamingDiffLineCache?: StreamingDiffLineCache;
|
|
1007
1038
|
#activeRenderSignature?: RenderSignature;
|
|
1039
|
+
// Streaming tables may grow naturally while wholly repaintable. Once any
|
|
1040
|
+
// physical row of a table enters native scrollback, its current column widths
|
|
1041
|
+
// are locked for the rest of this append-only text lineage: future wider cells
|
|
1042
|
+
// wrap inside those columns instead of reflowing immutable history above.
|
|
1043
|
+
#tableLayoutWidth?: number;
|
|
1044
|
+
#lockedTableLayouts = new Map<string, TableLayoutLock>();
|
|
1045
|
+
#lastRenderedTableLayouts: RenderedTableLayout[] = [];
|
|
1046
|
+
#activeTableRenderSpecs?: TableRenderSpec[];
|
|
1008
1047
|
|
|
1009
1048
|
#ignoreTight = false;
|
|
1010
1049
|
|
|
1011
1050
|
setIgnoreTight(ignore: boolean): this {
|
|
1051
|
+
if (this.#ignoreTight !== ignore) this.#clearTableLayouts();
|
|
1012
1052
|
this.#ignoreTight = ignore;
|
|
1013
1053
|
this.invalidate();
|
|
1014
1054
|
return this;
|
|
@@ -1037,6 +1077,7 @@ export class Markdown implements Component {
|
|
|
1037
1077
|
// full lex + wrap runs per re-emit — one of the top CPU hotspots during
|
|
1038
1078
|
// streaming (issue #4353). Mirrors `Text.setText`'s guard.
|
|
1039
1079
|
if (text === this.#text) return false;
|
|
1080
|
+
if (!text.startsWith(this.#text)) this.#clearTableLayouts();
|
|
1040
1081
|
this.#text = text;
|
|
1041
1082
|
if (!text.trim()) {
|
|
1042
1083
|
// Blank replacement: render() early-returns before #lexTokens can see
|
|
@@ -1080,6 +1121,41 @@ export class Markdown implements Component {
|
|
|
1080
1121
|
return this.#lastRenderSettledRows;
|
|
1081
1122
|
}
|
|
1082
1123
|
|
|
1124
|
+
/**
|
|
1125
|
+
* Freeze every table whose first physical row is already part of the native
|
|
1126
|
+
* scrollback prefix. The recorded widths came from the exact frame that was
|
|
1127
|
+
* just emitted, so the next streamed delta cannot retroactively widen it.
|
|
1128
|
+
*/
|
|
1129
|
+
setNativeScrollbackCommittedRows(rows: number): void {
|
|
1130
|
+
const committed = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
|
|
1131
|
+
let changed = false;
|
|
1132
|
+
for (const table of this.#lastRenderedTableLayouts) {
|
|
1133
|
+
if (table.startRow >= committed || this.#lockedTableLayouts.has(table.key)) continue;
|
|
1134
|
+
this.#lockedTableLayouts.set(table.key, {
|
|
1135
|
+
availableWidth: table.availableWidth,
|
|
1136
|
+
columnWidths: table.columnWidths.slice(),
|
|
1137
|
+
});
|
|
1138
|
+
changed = true;
|
|
1139
|
+
}
|
|
1140
|
+
if (changed) this.invalidate();
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
/** A destructive replay removes the immutable tape this layout was guarding. */
|
|
1144
|
+
prepareNativeScrollbackReplay(): void {
|
|
1145
|
+
this.#clearTableLayouts();
|
|
1146
|
+
this.#tableLayoutWidth = undefined;
|
|
1147
|
+
this.invalidate();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
#clearTableLayouts(): void {
|
|
1151
|
+
this.#lockedTableLayouts.clear();
|
|
1152
|
+
this.#lastRenderedTableLayouts = [];
|
|
1153
|
+
this.#activeTableRenderSpecs = undefined;
|
|
1154
|
+
// Same-width replay/non-append rewrites could otherwise reuse physical
|
|
1155
|
+
// prefix lines rendered with the retired locked widths.
|
|
1156
|
+
this.#streamPrefixLineCache = undefined;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1083
1159
|
// Lex `text` into block tokens, reusing the frozen stable prefix when the text
|
|
1084
1160
|
// only grew (the streaming path). Falls back to a full lex whenever the prefix
|
|
1085
1161
|
// is no longer a prefix (non-append edit), the text carries reference-link
|
|
@@ -1159,6 +1235,11 @@ export class Markdown implements Component {
|
|
|
1159
1235
|
}
|
|
1160
1236
|
|
|
1161
1237
|
render(width: number): readonly string[] {
|
|
1238
|
+
if (this.#tableLayoutWidth !== undefined && this.#tableLayoutWidth !== width) {
|
|
1239
|
+
this.#clearTableLayouts();
|
|
1240
|
+
this.invalidate();
|
|
1241
|
+
}
|
|
1242
|
+
this.#tableLayoutWidth = width;
|
|
1162
1243
|
// L1: per-instance cache — fastest path for repeated renders of the same
|
|
1163
1244
|
// instance at the same width (e.g. resize debounce, repeated redraws).
|
|
1164
1245
|
// Returning the cached reference is load-bearing: parents memoize their
|
|
@@ -1200,29 +1281,40 @@ export class Markdown implements Component {
|
|
|
1200
1281
|
// theme.heading is used as the representative theme probe — it's required
|
|
1201
1282
|
// by MarkdownTheme and is one of the most styling-sensitive entries.
|
|
1202
1283
|
let cacheKey: string | undefined;
|
|
1203
|
-
if (!this.transientRenderCache) {
|
|
1284
|
+
if (!this.transientRenderCache && this.#lockedTableLayouts.size === 0) {
|
|
1204
1285
|
cacheKey = this.#renderCacheKey(normalizedText, signature);
|
|
1205
1286
|
const cached = renderCache.get(cacheKey);
|
|
1206
1287
|
if (cached !== undefined) {
|
|
1288
|
+
// Restore both the rendered rows and the geometry metadata that produced
|
|
1289
|
+
// them. A later scrollback publication must never lock widths from an
|
|
1290
|
+
// older transient frame against rows served from this cache entry.
|
|
1291
|
+
this.#lastRenderedTableLayouts = cached.tables.map(table => ({
|
|
1292
|
+
...table,
|
|
1293
|
+
columnWidths: table.columnWidths.slice(),
|
|
1294
|
+
}));
|
|
1207
1295
|
// Populate L1 so subsequent calls from this instance are O(1) map lookup.
|
|
1208
1296
|
this.#cachedText = this.#text;
|
|
1209
1297
|
this.#cachedWidth = width;
|
|
1210
|
-
this.#cachedLines = cached;
|
|
1211
|
-
return cached;
|
|
1298
|
+
this.#cachedLines = cached.lines;
|
|
1299
|
+
return cached.lines;
|
|
1212
1300
|
}
|
|
1213
1301
|
}
|
|
1214
1302
|
|
|
1215
1303
|
// Parse markdown to HTML-like tokens
|
|
1216
1304
|
const tokens = this.#lexTokens(normalizedText);
|
|
1217
1305
|
let contentLines: string[];
|
|
1306
|
+
const tableRenderSpecs: TableRenderSpec[] = [];
|
|
1307
|
+
this.#activeTableRenderSpecs = tableRenderSpecs;
|
|
1218
1308
|
this.#activeRenderSignature = signature;
|
|
1219
1309
|
try {
|
|
1220
1310
|
contentLines = this.transientRenderCache
|
|
1221
1311
|
? this.#renderStreamingContentLines(tokens, normalizedText, signature, contentWidth)
|
|
1222
|
-
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1312
|
+
: this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
|
|
1223
1313
|
} finally {
|
|
1224
1314
|
this.#activeRenderSignature = undefined;
|
|
1315
|
+
this.#activeTableRenderSpecs = undefined;
|
|
1225
1316
|
}
|
|
1317
|
+
this.#lastRenderedTableLayouts = this.#resolveRenderedTableLayouts(tableRenderSpecs, signature.paddingY);
|
|
1226
1318
|
const emptyLines = this.#renderEmptyPaddingLines(signature);
|
|
1227
1319
|
|
|
1228
1320
|
// Combine top padding, content, and bottom padding
|
|
@@ -1239,7 +1331,13 @@ export class Markdown implements Component {
|
|
|
1239
1331
|
// Update L2 module-level LRU so future instances with the same key skip
|
|
1240
1332
|
// the marked.lexer + highlightCode (Rust FFI) work entirely.
|
|
1241
1333
|
if (cacheKey !== undefined) {
|
|
1242
|
-
renderCache.set(cacheKey,
|
|
1334
|
+
renderCache.set(cacheKey, {
|
|
1335
|
+
lines: result,
|
|
1336
|
+
tables: this.#lastRenderedTableLayouts.map(table => ({
|
|
1337
|
+
...table,
|
|
1338
|
+
columnWidths: table.columnWidths.slice(),
|
|
1339
|
+
})),
|
|
1340
|
+
});
|
|
1243
1341
|
}
|
|
1244
1342
|
|
|
1245
1343
|
return result;
|
|
@@ -1276,15 +1374,18 @@ export class Markdown implements Component {
|
|
|
1276
1374
|
const frozenText = this.#streamPrefixText;
|
|
1277
1375
|
const frozenTokenCount = this.#streamPrefixTokens?.length ?? 0;
|
|
1278
1376
|
if (frozenText === undefined || frozenTokenCount === 0 || !normalizedText.startsWith(frozenText)) {
|
|
1279
|
-
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature);
|
|
1377
|
+
return this.#renderContentLines(tokens, 0, tokens.length, contentWidth, signature, 0, 0);
|
|
1280
1378
|
}
|
|
1281
1379
|
|
|
1282
1380
|
const contentLines: string[] = [];
|
|
1283
1381
|
const reusablePrefix = this.#matchingStreamPrefixLineCache(normalizedText, frozenText, signature);
|
|
1284
1382
|
let renderedUntil = 0;
|
|
1383
|
+
let renderedSourceOffset = 0;
|
|
1285
1384
|
if (reusablePrefix && reusablePrefix.tokenCount <= frozenTokenCount) {
|
|
1286
1385
|
contentLines.push(...reusablePrefix.lines);
|
|
1386
|
+
this.#activeTableRenderSpecs?.push(...reusablePrefix.tables);
|
|
1287
1387
|
renderedUntil = reusablePrefix.tokenCount;
|
|
1388
|
+
renderedSourceOffset = reusablePrefix.text.length;
|
|
1288
1389
|
}
|
|
1289
1390
|
|
|
1290
1391
|
if (renderedUntil < frozenTokenCount) {
|
|
@@ -1293,7 +1394,15 @@ export class Markdown implements Component {
|
|
|
1293
1394
|
this.#renderingFrozenPrefix = true;
|
|
1294
1395
|
try {
|
|
1295
1396
|
contentLines.push(
|
|
1296
|
-
...this.#renderContentLines(
|
|
1397
|
+
...this.#renderContentLines(
|
|
1398
|
+
tokens,
|
|
1399
|
+
renderedUntil,
|
|
1400
|
+
frozenTokenCount,
|
|
1401
|
+
contentWidth,
|
|
1402
|
+
signature,
|
|
1403
|
+
contentLines.length,
|
|
1404
|
+
renderedSourceOffset,
|
|
1405
|
+
),
|
|
1297
1406
|
);
|
|
1298
1407
|
} finally {
|
|
1299
1408
|
this.#renderingFrozenPrefix = false;
|
|
@@ -1306,6 +1415,7 @@ export class Markdown implements Component {
|
|
|
1306
1415
|
text: frozenText,
|
|
1307
1416
|
tokenCount: frozenTokenCount,
|
|
1308
1417
|
lines: contentLines.slice(),
|
|
1418
|
+
tables: this.#activeTableRenderSpecs?.slice() ?? [],
|
|
1309
1419
|
};
|
|
1310
1420
|
|
|
1311
1421
|
// Settled exposure (hard-monotone): these rows are declared final to
|
|
@@ -1322,7 +1432,17 @@ export class Markdown implements Component {
|
|
|
1322
1432
|
}
|
|
1323
1433
|
|
|
1324
1434
|
if (renderedUntil < tokens.length) {
|
|
1325
|
-
contentLines.push(
|
|
1435
|
+
contentLines.push(
|
|
1436
|
+
...this.#renderContentLines(
|
|
1437
|
+
tokens,
|
|
1438
|
+
renderedUntil,
|
|
1439
|
+
tokens.length,
|
|
1440
|
+
contentWidth,
|
|
1441
|
+
signature,
|
|
1442
|
+
contentLines.length,
|
|
1443
|
+
frozenText.length,
|
|
1444
|
+
),
|
|
1445
|
+
);
|
|
1326
1446
|
}
|
|
1327
1447
|
|
|
1328
1448
|
return contentLines;
|
|
@@ -1356,23 +1476,57 @@ export class Markdown implements Component {
|
|
|
1356
1476
|
end: number,
|
|
1357
1477
|
contentWidth: number,
|
|
1358
1478
|
signature: RenderSignature,
|
|
1479
|
+
rowOffset: number,
|
|
1480
|
+
startingSourceOffset: number,
|
|
1359
1481
|
): string[] {
|
|
1360
|
-
const
|
|
1482
|
+
const wrappedLines: string[] = [];
|
|
1483
|
+
let sourceOffset = startingSourceOffset;
|
|
1361
1484
|
for (let i = start; i < end; i++) {
|
|
1362
1485
|
const token = tokens[i];
|
|
1363
1486
|
const nextToken = tokens[i + 1];
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1487
|
+
const tableSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1488
|
+
const tokenWrappedRowStart = wrappedLines.length;
|
|
1489
|
+
const tokenRowStart = rowOffset + tokenWrappedRowStart;
|
|
1490
|
+
const renderedTokenLines = this.#renderToken(
|
|
1491
|
+
token,
|
|
1492
|
+
contentWidth,
|
|
1493
|
+
nextToken?.type,
|
|
1494
|
+
undefined,
|
|
1495
|
+
`offset:${sourceOffset}`,
|
|
1496
|
+
);
|
|
1497
|
+
const tokenLineOffsets = [0];
|
|
1498
|
+
for (const line of renderedTokenLines) {
|
|
1499
|
+
// Skip wrapping for image protocol lines and OSC 66 sized headings
|
|
1500
|
+
// (would corrupt escape sequences / split the indivisible sized span).
|
|
1501
|
+
if (TERMINAL.isImageLine(line) || isOsc66Line(line)) {
|
|
1502
|
+
wrappedLines.push(line);
|
|
1503
|
+
} else {
|
|
1504
|
+
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
|
|
1505
|
+
}
|
|
1506
|
+
tokenLineOffsets.push(wrappedLines.length - tokenWrappedRowStart);
|
|
1375
1507
|
}
|
|
1508
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1509
|
+
if (tableSpecs !== undefined) {
|
|
1510
|
+
for (let specIndex = tableSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1511
|
+
const spec = tableSpecs[specIndex]!;
|
|
1512
|
+
let relativeStart: number;
|
|
1513
|
+
let relativeEnd: number;
|
|
1514
|
+
if (token.type === "table") {
|
|
1515
|
+
// Exclude the optional inter-block blank from a top-level table's span.
|
|
1516
|
+
relativeStart = 0;
|
|
1517
|
+
relativeEnd = Math.min(renderedTokenLines.length, spec.lineCount);
|
|
1518
|
+
} else {
|
|
1519
|
+
// Container renderers express nested table spans relative to their
|
|
1520
|
+
// returned lines. Preserve that exact span through this final wrap.
|
|
1521
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
1522
|
+
relativeStart = Math.min(renderedTokenLines.length, spec.startRow);
|
|
1523
|
+
relativeEnd = Math.min(renderedTokenLines.length, spec.endRow);
|
|
1524
|
+
}
|
|
1525
|
+
spec.startRow = tokenRowStart + tokenLineOffsets[relativeStart]!;
|
|
1526
|
+
spec.endRow = tokenRowStart + tokenLineOffsets[relativeEnd]!;
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
sourceOffset += token.raw.length;
|
|
1376
1530
|
}
|
|
1377
1531
|
|
|
1378
1532
|
const leftMargin = padding(signature.paddingX);
|
|
@@ -1416,6 +1570,21 @@ export class Markdown implements Component {
|
|
|
1416
1570
|
return contentLines;
|
|
1417
1571
|
}
|
|
1418
1572
|
|
|
1573
|
+
#resolveRenderedTableLayouts(specs: readonly TableRenderSpec[], topPadding: number): RenderedTableLayout[] {
|
|
1574
|
+
const layouts: RenderedTableLayout[] = [];
|
|
1575
|
+
for (const spec of specs) {
|
|
1576
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
1577
|
+
layouts.push({
|
|
1578
|
+
key: spec.key,
|
|
1579
|
+
availableWidth: spec.availableWidth,
|
|
1580
|
+
columnWidths: spec.columnWidths.slice(),
|
|
1581
|
+
startRow: topPadding + spec.startRow,
|
|
1582
|
+
endRow: topPadding + spec.endRow,
|
|
1583
|
+
});
|
|
1584
|
+
}
|
|
1585
|
+
return layouts;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1419
1588
|
#renderCodeBodyLines(token: Token, codeIndent: string): string[] {
|
|
1420
1589
|
const bodyLines: string[] = [];
|
|
1421
1590
|
const tokenText = "text" in token && typeof token.text === "string" ? token.text : "";
|
|
@@ -1626,7 +1795,13 @@ export class Markdown implements Component {
|
|
|
1626
1795
|
};
|
|
1627
1796
|
}
|
|
1628
1797
|
|
|
1629
|
-
#renderToken(
|
|
1798
|
+
#renderToken(
|
|
1799
|
+
token: Token,
|
|
1800
|
+
width: number,
|
|
1801
|
+
nextTokenType?: string,
|
|
1802
|
+
styleContext?: InlineStyleContext,
|
|
1803
|
+
tokenKey = "root",
|
|
1804
|
+
): string[] {
|
|
1630
1805
|
const lines: string[] = [];
|
|
1631
1806
|
|
|
1632
1807
|
// Display math block (own-line `$$…$$` / `\[…\]`): stack `\frac` vertically
|
|
@@ -1728,7 +1903,7 @@ export class Markdown implements Component {
|
|
|
1728
1903
|
}
|
|
1729
1904
|
|
|
1730
1905
|
case "table": {
|
|
1731
|
-
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext);
|
|
1906
|
+
const tableLines = this.#renderTable(token as TableToken, width, nextTokenType, styleContext, tokenKey);
|
|
1732
1907
|
lines.push(...tableLines);
|
|
1733
1908
|
break;
|
|
1734
1909
|
}
|
|
@@ -1741,20 +1916,59 @@ export class Markdown implements Component {
|
|
|
1741
1916
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
1742
1917
|
const quoteTokens = token.tokens || [];
|
|
1743
1918
|
const renderedQuoteLines: string[] = [];
|
|
1919
|
+
const blockquoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1744
1920
|
|
|
1745
1921
|
for (let i = 0; i < quoteTokens.length; i++) {
|
|
1746
1922
|
const quoteToken = quoteTokens[i];
|
|
1747
1923
|
const nextQuoteToken = quoteTokens[i + 1];
|
|
1748
|
-
renderedQuoteLines.
|
|
1749
|
-
|
|
1924
|
+
const quoteTokenRowStart = renderedQuoteLines.length;
|
|
1925
|
+
const quoteSpecStart = this.#activeTableRenderSpecs?.length ?? 0;
|
|
1926
|
+
const quoteTokenLines = this.#renderToken(
|
|
1927
|
+
quoteToken,
|
|
1928
|
+
quoteContentWidth,
|
|
1929
|
+
nextQuoteToken?.type,
|
|
1930
|
+
quoteInlineStyleContext,
|
|
1931
|
+
`${tokenKey}/quote:${i}`,
|
|
1750
1932
|
);
|
|
1933
|
+
renderedQuoteLines.push(...quoteTokenLines);
|
|
1934
|
+
|
|
1935
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1936
|
+
if (tableSpecs !== undefined) {
|
|
1937
|
+
for (let specIndex = quoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1938
|
+
const spec = tableSpecs[specIndex]!;
|
|
1939
|
+
if (spec.startRow < 0) {
|
|
1940
|
+
// Direct child tables initially have no row coordinates. Their
|
|
1941
|
+
// structural line count excludes any inter-block blank.
|
|
1942
|
+
spec.startRow = quoteTokenRowStart;
|
|
1943
|
+
spec.endRow = quoteTokenRowStart + Math.min(quoteTokenLines.length, spec.lineCount);
|
|
1944
|
+
} else {
|
|
1945
|
+
// A nested blockquote already mapped the table into its own
|
|
1946
|
+
// returned rows; translate those rows into this quote's input.
|
|
1947
|
+
spec.startRow += quoteTokenRowStart;
|
|
1948
|
+
spec.endRow += quoteTokenRowStart;
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1751
1952
|
}
|
|
1752
1953
|
|
|
1753
1954
|
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
|
|
1754
1955
|
renderedQuoteLines.pop();
|
|
1755
1956
|
}
|
|
1756
1957
|
|
|
1757
|
-
|
|
1958
|
+
const quoteRowOffsets: number[] = [];
|
|
1959
|
+
const borderedQuoteLines = this.#applyQuoteBorder(renderedQuoteLines, width, quoteRowOffsets);
|
|
1960
|
+
const tableSpecs = this.#activeTableRenderSpecs;
|
|
1961
|
+
if (tableSpecs !== undefined) {
|
|
1962
|
+
for (let specIndex = blockquoteSpecStart; specIndex < tableSpecs.length; specIndex++) {
|
|
1963
|
+
const spec = tableSpecs[specIndex]!;
|
|
1964
|
+
if (spec.startRow < 0 || spec.endRow <= spec.startRow) continue;
|
|
1965
|
+
const relativeStart = Math.min(renderedQuoteLines.length, spec.startRow);
|
|
1966
|
+
const relativeEnd = Math.min(renderedQuoteLines.length, spec.endRow);
|
|
1967
|
+
spec.startRow = quoteRowOffsets[relativeStart]!;
|
|
1968
|
+
spec.endRow = quoteRowOffsets[relativeEnd]!;
|
|
1969
|
+
}
|
|
1970
|
+
}
|
|
1971
|
+
lines.push(...borderedQuoteLines);
|
|
1758
1972
|
if (nextTokenType && nextTokenType !== "space") {
|
|
1759
1973
|
lines.push(""); // Add spacing after blockquotes (unless space token follows)
|
|
1760
1974
|
}
|
|
@@ -1801,7 +2015,7 @@ export class Markdown implements Component {
|
|
|
1801
2015
|
* Wrap already-rendered lines in the blockquote border and quote styling.
|
|
1802
2016
|
* `width` is the full content width; the border reserves two cells.
|
|
1803
2017
|
*/
|
|
1804
|
-
#applyQuoteBorder(renderedLines: string[], width: number): string[] {
|
|
2018
|
+
#applyQuoteBorder(renderedLines: string[], width: number, sourceRowOffsets?: number[]): string[] {
|
|
1805
2019
|
const quoteStyle = (text: string) => this.#theme.quote(this.#theme.italic(text));
|
|
1806
2020
|
const quoteStylePrefix = this.#getStylePrefix(quoteStyle);
|
|
1807
2021
|
const applyQuoteStyle = (line: string): string => {
|
|
@@ -1813,11 +2027,13 @@ export class Markdown implements Component {
|
|
|
1813
2027
|
};
|
|
1814
2028
|
const quoteContentWidth = Math.max(1, width - 2);
|
|
1815
2029
|
const lines: string[] = [];
|
|
2030
|
+
sourceRowOffsets?.push(0);
|
|
1816
2031
|
for (const quoteLine of renderedLines) {
|
|
1817
2032
|
const styledLine = applyQuoteStyle(quoteLine);
|
|
1818
2033
|
for (const wrappedLine of wrapTextWithAnsi(styledLine, quoteContentWidth)) {
|
|
1819
2034
|
lines.push(this.#theme.quoteBorder(`${this.#theme.symbols.quoteBorder} `) + wrappedLine);
|
|
1820
2035
|
}
|
|
2036
|
+
sourceRowOffsets?.push(lines.length);
|
|
1821
2037
|
}
|
|
1822
2038
|
return lines;
|
|
1823
2039
|
}
|
|
@@ -2149,6 +2365,7 @@ export class Markdown implements Component {
|
|
|
2149
2365
|
availableWidth: number,
|
|
2150
2366
|
nextTokenType?: string,
|
|
2151
2367
|
styleContext?: InlineStyleContext,
|
|
2368
|
+
tableKey = "table",
|
|
2152
2369
|
): string[] {
|
|
2153
2370
|
const lines: string[] = [];
|
|
2154
2371
|
const numCols = token.header.length;
|
|
@@ -2263,6 +2480,17 @@ export class Markdown implements Component {
|
|
|
2263
2480
|
}
|
|
2264
2481
|
}
|
|
2265
2482
|
|
|
2483
|
+
const lockedLayout = this.#lockedTableLayouts.get(tableKey);
|
|
2484
|
+
if (
|
|
2485
|
+
lockedLayout !== undefined &&
|
|
2486
|
+
lockedLayout.availableWidth === availableWidth &&
|
|
2487
|
+
lockedLayout.columnWidths.length === numCols &&
|
|
2488
|
+
lockedLayout.columnWidths.every(width => Number.isFinite(width) && width >= 1) &&
|
|
2489
|
+
lockedLayout.columnWidths.reduce((total, width) => total + width, borderOverhead) <= availableWidth
|
|
2490
|
+
) {
|
|
2491
|
+
columnWidths = lockedLayout.columnWidths.slice();
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2266
2494
|
const t = this.#theme.symbols.table;
|
|
2267
2495
|
const h = t.horizontal;
|
|
2268
2496
|
const v = t.vertical;
|
|
@@ -2316,7 +2544,16 @@ export class Markdown implements Component {
|
|
|
2316
2544
|
|
|
2317
2545
|
// Render bottom border
|
|
2318
2546
|
const bottomBorderCells = columnWidths.map(w => h.repeat(w));
|
|
2319
|
-
|
|
2547
|
+
const bottomBorder = `${t.bottomLeft}${h}${bottomBorderCells.join(`${h}${t.teeUp}${h}`)}${h}${t.bottomRight}`;
|
|
2548
|
+
lines.push(bottomBorder);
|
|
2549
|
+
this.#activeTableRenderSpecs?.push({
|
|
2550
|
+
key: tableKey,
|
|
2551
|
+
availableWidth,
|
|
2552
|
+
columnWidths: columnWidths.slice(),
|
|
2553
|
+
lineCount: lines.length,
|
|
2554
|
+
startRow: -1,
|
|
2555
|
+
endRow: -1,
|
|
2556
|
+
});
|
|
2320
2557
|
|
|
2321
2558
|
if (nextTokenType && nextTokenType !== "space") {
|
|
2322
2559
|
lines.push(""); // Add spacing after table
|
package/src/latex-block.ts
CHANGED
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// fractions and `\binom`, stretch delimiters (`\left…\right`, tall bare parens,
|
|
12
12
|
// matrix brackets), render matrix/cases/array environments as baseline-aligned
|
|
13
13
|
// grids, place big-operator limits (`\sum`, `\lim`, `\int\limits`) above and
|
|
14
|
-
// below the symbol, draw radicals, raise/lower block scripts,
|
|
14
|
+
// below the symbol, draw radicals, raise/lower block scripts, draw labeled
|
|
15
|
+
// horizontal braces (`\underbrace{x}_{lbl}`), stack `\overset`/`\underset`, and
|
|
15
16
|
// align `&` columns in `align`-family environments. Flat runs — symbols, fonts,
|
|
16
17
|
// colors, inline scripts — are delegated to `latexToUnicode`.
|
|
17
18
|
//
|
|
@@ -126,6 +127,25 @@ const INTEGRAL_OPERATORS: Record<string, true> = {
|
|
|
126
127
|
smallint: true,
|
|
127
128
|
};
|
|
128
129
|
|
|
130
|
+
// Horizontal brace/bracket decorations drawn as a rule row beside the content,
|
|
131
|
+
// with an optional limits-style label beyond the rule (`\underbrace{x}_{lbl}`).
|
|
132
|
+
interface HBraceSpec {
|
|
133
|
+
left: string;
|
|
134
|
+
mid: string;
|
|
135
|
+
center: string;
|
|
136
|
+
right: string;
|
|
137
|
+
over: boolean;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const HBRACE_COMMANDS: Record<string, HBraceSpec> = {
|
|
141
|
+
overbrace: { left: "╭", mid: "─", center: "┴", right: "╮", over: true },
|
|
142
|
+
underbrace: { left: "╰", mid: "─", center: "┬", right: "╯", over: false },
|
|
143
|
+
overbracket: { left: "┌", mid: "─", center: "─", right: "┐", over: true },
|
|
144
|
+
underbracket: { left: "└", mid: "─", center: "─", right: "┘", over: false },
|
|
145
|
+
overparen: { left: "╭", mid: "─", center: "─", right: "╮", over: true },
|
|
146
|
+
underparen: { left: "╰", mid: "─", center: "─", right: "╯", over: false },
|
|
147
|
+
};
|
|
148
|
+
|
|
129
149
|
// Vertical delimiter piece characters: `only` for single-line content, then
|
|
130
150
|
// top/mid/bot columns for stretched forms; `axis` replaces `mid` at the
|
|
131
151
|
// baseline row (the brace point).
|
|
@@ -363,6 +383,31 @@ function limitsBox(glyph: Box, sub: Box | null, sup: Box | null): Box {
|
|
|
363
383
|
return { lines, baseline, width };
|
|
364
384
|
}
|
|
365
385
|
|
|
386
|
+
/**
|
|
387
|
+
* `\underbrace{content}_{label}` / `\overbrace{content}^{label}`: the content
|
|
388
|
+
* with a drawn horizontal brace beside it and the label centered beyond the
|
|
389
|
+
* brace. The baseline stays on the content so neighbors align with it.
|
|
390
|
+
*/
|
|
391
|
+
function hbraceBox(content: Box, spec: HBraceSpec, label: Box | null): Box {
|
|
392
|
+
const braceWidth = Math.max(content.width, 3);
|
|
393
|
+
const width = Math.max(braceWidth, label?.width ?? 0);
|
|
394
|
+
const lead = (braceWidth - 3) >> 1;
|
|
395
|
+
const brace = center(
|
|
396
|
+
spec.left + spec.mid.repeat(lead) + spec.center + spec.mid.repeat(braceWidth - 3 - lead) + spec.right,
|
|
397
|
+
width,
|
|
398
|
+
);
|
|
399
|
+
const contentLines = content.lines.map(line => center(line, width));
|
|
400
|
+
const labelLines = label === null ? [] : label.lines.map(line => center(line, width));
|
|
401
|
+
if (spec.over) {
|
|
402
|
+
return {
|
|
403
|
+
lines: [...labelLines, brace, ...contentLines],
|
|
404
|
+
baseline: labelLines.length + 1 + content.baseline,
|
|
405
|
+
width,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
return { lines: [...contentLines, brace, ...labelLines], baseline: content.baseline, width };
|
|
409
|
+
}
|
|
410
|
+
|
|
366
411
|
/**
|
|
367
412
|
* Attach block scripts to `base` as one shared right-hand column: the
|
|
368
413
|
* superscript ends level with the base's top row (raised one row above a
|
|
@@ -878,6 +923,59 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
|
|
|
878
923
|
i = bottom.end;
|
|
879
924
|
continue;
|
|
880
925
|
}
|
|
926
|
+
if (name && HBRACE_COMMANDS[name]) {
|
|
927
|
+
flush();
|
|
928
|
+
const spec = HBRACE_COMMANDS[name];
|
|
929
|
+
const arg = readArg(src, j);
|
|
930
|
+
// Limits-style scripts: the brace-side script is the label; an
|
|
931
|
+
// opposite-side script attaches as a regular corner script.
|
|
932
|
+
let subText: string | null = null;
|
|
933
|
+
let supText: string | null = null;
|
|
934
|
+
let m = arg.end;
|
|
935
|
+
for (;;) {
|
|
936
|
+
let n = m;
|
|
937
|
+
while (src[n] === " ") n++;
|
|
938
|
+
if (src[n] === "_" && subText === null) {
|
|
939
|
+
const s = readArg(src, n + 1);
|
|
940
|
+
subText = s.text;
|
|
941
|
+
m = s.end;
|
|
942
|
+
continue;
|
|
943
|
+
}
|
|
944
|
+
if (src[n] === "^" && supText === null) {
|
|
945
|
+
const s = readArg(src, n + 1);
|
|
946
|
+
supText = s.text;
|
|
947
|
+
m = s.end;
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
break;
|
|
951
|
+
}
|
|
952
|
+
const labelText = spec.over ? supText : subText;
|
|
953
|
+
const otherText = spec.over ? subText : supText;
|
|
954
|
+
let box = hbraceBox(
|
|
955
|
+
parseExpr(arg.text, inner()),
|
|
956
|
+
spec,
|
|
957
|
+
labelText === null ? null : parseExpr(labelText, inner()),
|
|
958
|
+
);
|
|
959
|
+
if (otherText !== null) {
|
|
960
|
+
const other = parseExpr(otherText, inner());
|
|
961
|
+
box = attachScripts(box, spec.over ? other : null, spec.over ? null : other);
|
|
962
|
+
}
|
|
963
|
+
boxes.push(paint(box));
|
|
964
|
+
i = m;
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
if (name === "overset" || name === "underset" || name === "stackrel") {
|
|
968
|
+
flush();
|
|
969
|
+
const anno = readArg(src, j);
|
|
970
|
+
const base = readArg(src, anno.end);
|
|
971
|
+
const annoBox = parseExpr(anno.text, inner());
|
|
972
|
+
const baseBox = parseExpr(base.text, inner());
|
|
973
|
+
boxes.push(
|
|
974
|
+
paint(limitsBox(baseBox, name === "underset" ? annoBox : null, name === "underset" ? null : annoBox)),
|
|
975
|
+
);
|
|
976
|
+
i = base.end;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
881
979
|
if (name === "sqrt") {
|
|
882
980
|
let k = j;
|
|
883
981
|
while (src[k] === " ") k++;
|
|
@@ -1108,8 +1206,18 @@ function parseExpr(src: string, ctx: Ctx = ROOT_CTX): Box {
|
|
|
1108
1206
|
const flat = latexToUnicode(raw);
|
|
1109
1207
|
return flat.startsWith("^") || flat.startsWith("_");
|
|
1110
1208
|
};
|
|
1209
|
+
// Multi-letter script words (`N_{turns}`) would convert per-char into
|
|
1210
|
+
// Unicode glyphs of uneven height and read ragged; box them too.
|
|
1211
|
+
// Commands are stripped: their output (`\prime` → ′) is not letters.
|
|
1212
|
+
const ragged = (raw: string | undefined): boolean => {
|
|
1213
|
+
if (raw === undefined) return false;
|
|
1214
|
+
const letters = scriptArgOf(raw)
|
|
1215
|
+
.replace(/\\[A-Za-z]+/g, "")
|
|
1216
|
+
.match(/[A-Za-z]/g);
|
|
1217
|
+
return letters !== null && letters.length >= 2;
|
|
1218
|
+
};
|
|
1111
1219
|
const tall = (supBox !== null && supBox.lines.length > 1) || (subBox !== null && subBox.lines.length > 1);
|
|
1112
|
-
if (tall || unconvertible(supText) || unconvertible(subText)) {
|
|
1220
|
+
if (tall || unconvertible(supText) || unconvertible(subText) || ragged(supText) || ragged(subText)) {
|
|
1113
1221
|
// Block script (`x^{\frac{1}{2}}`, `x^q`): raise/lower the boxes
|
|
1114
1222
|
// against the run or box they follow.
|
|
1115
1223
|
flush();
|