@zenodinh/pi-render 0.1.3 → 0.1.5
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/index.ts +120 -22
- package/package.json +1 -1
- package/src/core/paint.ts +35 -4
- package/src/core/types/host.ts +4 -0
- package/src/core/types/paint.ts +14 -0
- package/src/renderers/content/table.ts +37 -13
package/index.ts
CHANGED
|
@@ -4,8 +4,11 @@
|
|
|
4
4
|
* Why one file: every lane ends in a plain value (a resolver, a transformer, a surface), so boot is a
|
|
5
5
|
* table of contents — build the registry, compose the two tables, register once per seam, and warm the
|
|
6
6
|
* engines the synchronous seams call. Each lane installs in its own try/catch: a broken lane costs
|
|
7
|
-
* itself, never the session (SA §2).
|
|
8
|
-
*
|
|
7
|
+
* itself, never the session (SA §2). One ordering constraint survives the predecessor's ordering lore:
|
|
8
|
+
* both live seams register synchronously, before the first await, because the host evaluates
|
|
9
|
+
* getMarkdownTransformers() when it constructs a restored message and stores that list on the component
|
|
10
|
+
* — a transformer registered after our engine warm-up is invisible to every message restored before it
|
|
11
|
+
* (issue #13).
|
|
9
12
|
*
|
|
10
13
|
* The command surface is empty on purpose (Req 2): the whole host surface is one tool resolver, one
|
|
11
14
|
* markdown transformer and one `session_start` hook — nothing for the user to invoke.
|
|
@@ -25,7 +28,15 @@ import { createLogger } from "./src/core/log.ts";
|
|
|
25
28
|
import { createContentPaint, createRowPaint } from "./src/core/paint.ts";
|
|
26
29
|
import { createRegistry } from "./src/core/registry.ts";
|
|
27
30
|
import { createSettingsStore } from "./src/core/settings.ts";
|
|
28
|
-
import type {
|
|
31
|
+
import type {
|
|
32
|
+
CodeTheme,
|
|
33
|
+
ContentPaint,
|
|
34
|
+
ExtensionApi,
|
|
35
|
+
Logger,
|
|
36
|
+
ModuleDescriptor,
|
|
37
|
+
Registry,
|
|
38
|
+
Surface,
|
|
39
|
+
} from "./src/core/types.ts";
|
|
29
40
|
import { type ArtifactCache, createArtifactCache, renderCacheKey } from "./src/renderers/content/artifacts/cache.ts";
|
|
30
41
|
import { artifactTitle, renderArtifactCard } from "./src/renderers/content/artifacts/cards.ts";
|
|
31
42
|
import {
|
|
@@ -117,12 +128,25 @@ function isArtifactForm(lang: string): lang is DiagramForm {
|
|
|
117
128
|
// ported from pi-pretty-tui/src/features/canvas/transformer.ts:134-226 — survives because: fence → card
|
|
118
129
|
// with a raw fence on failure is the whole artifacts surface; its settings gate and inline pixels do not.
|
|
119
130
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
131
|
+
* The artifacts lane's late-bound collaborators. The surface must exist when the transformer registers
|
|
132
|
+
* (before piRender's first await, see `piRender`), but the cache and server can only be built in the
|
|
133
|
+
* awaited artifacts lane, so the surface reads this holder per render: while a slot is still empty the
|
|
134
|
+
* whole pass is a no-op and every fence stays byte-identical.
|
|
122
135
|
*/
|
|
123
|
-
|
|
136
|
+
interface ArtifactsHolder {
|
|
137
|
+
cache?: ArtifactCache;
|
|
138
|
+
server?: ArtifactServer;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* shape: closure returning an object literal — trigger #4, one stateless Surface over the late-bound
|
|
143
|
+
* holder and the log.
|
|
144
|
+
*/
|
|
145
|
+
function createArtifactsSurface(holder: ArtifactsHolder, log: Logger): Surface {
|
|
124
146
|
/** One fence to one card, or undefined so the raw fence stays byte-identical. */
|
|
125
147
|
const card = (form: DiagramForm, source: string, width: number, paint: ContentPaint): string | undefined => {
|
|
148
|
+
const { cache, server } = holder;
|
|
149
|
+
if (cache === undefined || server === undefined) return undefined;
|
|
126
150
|
try {
|
|
127
151
|
const key = renderCacheKey(form, source, ARTIFACT_MODE, ARTIFACT_WIDTH_CELLS);
|
|
128
152
|
const result = renderDiagram(form, source, {
|
|
@@ -147,6 +171,10 @@ function createArtifactsSurface(cache: ArtifactCache, server: ArtifactServer, lo
|
|
|
147
171
|
|
|
148
172
|
return {
|
|
149
173
|
rewrite(markdown, ctx, paint) {
|
|
174
|
+
const { cache, server } = holder;
|
|
175
|
+
// Cold holder: the artifacts lane has not built its cache or bound its server yet. Return the input
|
|
176
|
+
// by reference so the fence walk never runs and every fence stays byte-identical.
|
|
177
|
+
if (cache === undefined || server === undefined) return markdown;
|
|
150
178
|
const width = panelWidth(ctx.availableWidth);
|
|
151
179
|
const fenced = mapFencedBlocks(markdown, (lang, code) =>
|
|
152
180
|
isArtifactForm(lang) ? card(lang, code, width, paint) : undefined,
|
|
@@ -177,6 +205,43 @@ function readLinkedFile(href: string): string | undefined {
|
|
|
177
205
|
}
|
|
178
206
|
}
|
|
179
207
|
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// The lazy code theme — the seam between the synchronous transformer and the awaited engine
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The host evaluates getMarkdownTransformers() when it constructs a restored message and stores the
|
|
214
|
+
* result on the component, so the transformer must register before piRender's first await — but
|
|
215
|
+
* createShikiEngine() is itself an await and the panel seam is synchronous. This holder bridges the
|
|
216
|
+
* two: the surfaces read `theme` on every render, and the content warm-up lane installs the real theme
|
|
217
|
+
* once shiki resolves. A cold render passes the source through unchanged, which is exactly the code
|
|
218
|
+
* panel's own degrade contract (bodyLines uses highlightSync's output as-is), so no async ever reaches
|
|
219
|
+
* the render path and nothing here throws.
|
|
220
|
+
*/
|
|
221
|
+
interface LazyCodeTheme {
|
|
222
|
+
/** The surfaces' theme: the real CodeTheme once warm, a plain passthrough while cold. */
|
|
223
|
+
readonly theme: CodeTheme;
|
|
224
|
+
/** Installs the warmed theme; the content warm-up lane calls this exactly once. */
|
|
225
|
+
warm(theme: CodeTheme): void;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// shape: closure returning an object literal — trigger #4, one mutable engine slot behind two delegates.
|
|
229
|
+
function createLazyCodeTheme(): LazyCodeTheme {
|
|
230
|
+
let engine: CodeTheme | undefined;
|
|
231
|
+
return {
|
|
232
|
+
theme: {
|
|
233
|
+
// Cold: plain source lines are createCodeTheme's own no-engine fallback; this never rejects.
|
|
234
|
+
highlight: (code, lang) =>
|
|
235
|
+
engine === undefined ? Promise.resolve(code.split("\n")) : engine.highlight(code, lang),
|
|
236
|
+
// Cold: the exact string the panel's catch branch produces, so a cold panel reads as a plain one.
|
|
237
|
+
highlightSync: (code, lang) => (engine === undefined ? code : engine.highlightSync(code, lang)),
|
|
238
|
+
},
|
|
239
|
+
warm(next) {
|
|
240
|
+
engine = next;
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
180
245
|
// ---------------------------------------------------------------------------
|
|
181
246
|
// Lanes — one install per seam, each settled on its own
|
|
182
247
|
// ---------------------------------------------------------------------------
|
|
@@ -187,26 +252,31 @@ function installRows(pi: ExtensionApi, registry: Registry, log: Logger): void {
|
|
|
187
252
|
}
|
|
188
253
|
|
|
189
254
|
/**
|
|
190
|
-
* The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then
|
|
191
|
-
* the
|
|
255
|
+
* The artifacts lane: warm the engines the synchronous fence pass calls, bind the one server, then fill
|
|
256
|
+
* the holder the already-registered surface reads. A failed bind degrades every card to a file:// link,
|
|
257
|
+
* and a throw costs only this slot — the transformer is registered before this lane runs.
|
|
192
258
|
*/
|
|
193
|
-
async function installArtifacts(log: Logger, deps: BootDeps): Promise<
|
|
259
|
+
async function installArtifacts(log: Logger, deps: BootDeps, holder: ArtifactsHolder): Promise<void> {
|
|
194
260
|
await (deps.warmArtifactEngines ?? warmup)(log);
|
|
195
261
|
const server = (deps.createArtifactServer ?? ((logger: Logger) => createArtifactServer({ log: logger })))(log);
|
|
196
262
|
await server.start();
|
|
197
|
-
|
|
263
|
+
holder.cache = createArtifactCache({ cacheDir: deps.cacheDir, log });
|
|
264
|
+
holder.server = server;
|
|
198
265
|
}
|
|
199
266
|
|
|
200
|
-
/**
|
|
201
|
-
|
|
267
|
+
/**
|
|
268
|
+
* Region 2's synchronous half: five surfaces over lazy holders, then the one registerMarkdownTransformer
|
|
269
|
+
* call. It runs before piRender's first await, so the host's restored-message construction sees the
|
|
270
|
+
* transformer (see `piRender`); the shiki theme and the artifact cache/server stay cold until the
|
|
271
|
+
* awaited lanes fill them.
|
|
272
|
+
*/
|
|
273
|
+
function registerContentSeam(
|
|
202
274
|
pi: ExtensionApi,
|
|
203
275
|
registry: Registry,
|
|
204
276
|
log: Logger,
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
// here, before this registration can be reached.
|
|
209
|
-
const codeTheme = createCodeTheme(await createShikiEngine(log), { registry, log });
|
|
277
|
+
codeTheme: CodeTheme,
|
|
278
|
+
artifacts: ArtifactsHolder,
|
|
279
|
+
): void {
|
|
210
280
|
const surfaces: ContentSurfaces = {
|
|
211
281
|
table,
|
|
212
282
|
codePanel: createCodePanel(codeTheme),
|
|
@@ -215,7 +285,7 @@ async function installContent(
|
|
|
215
285
|
projectDir: PROJECT_DIR,
|
|
216
286
|
log: (line) => log.logLine(SCOPE, line),
|
|
217
287
|
}),
|
|
218
|
-
artifacts,
|
|
288
|
+
artifacts: createArtifactsSurface(artifacts, log),
|
|
219
289
|
};
|
|
220
290
|
// The host's markdown theme is live per call, so its closures are captured once, here.
|
|
221
291
|
pi.registerMarkdownTransformer(
|
|
@@ -223,6 +293,11 @@ async function installContent(
|
|
|
223
293
|
);
|
|
224
294
|
}
|
|
225
295
|
|
|
296
|
+
/** Region 2's awaited half: fill the lazy theme once shiki resolves; renders before then already returned. */
|
|
297
|
+
async function warmCodeTheme(registry: Registry, log: Logger, lazy: LazyCodeTheme): Promise<void> {
|
|
298
|
+
lazy.warm(createCodeTheme(await createShikiEngine(log), { registry, log }));
|
|
299
|
+
}
|
|
300
|
+
|
|
226
301
|
/** shape: none — one guarded call; a lane reports itself and boot carries on. */
|
|
227
302
|
async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T>): Promise<T | undefined> {
|
|
228
303
|
try {
|
|
@@ -233,6 +308,18 @@ async function settle<T>(lane: string, log: Logger, install: () => T | Promise<T
|
|
|
233
308
|
}
|
|
234
309
|
}
|
|
235
310
|
|
|
311
|
+
/**
|
|
312
|
+
* The synchronous twin of {@link settle} for the two lanes that must register before piRender's first
|
|
313
|
+
* await: a throw is logged and contained exactly as in settle, but nothing yields the event loop.
|
|
314
|
+
*/
|
|
315
|
+
function settleSync(lane: string, log: Logger, install: () => void): void {
|
|
316
|
+
try {
|
|
317
|
+
install();
|
|
318
|
+
} catch (error) {
|
|
319
|
+
log.logOnce(`boot:${lane}`, SCOPE, `${lane} lane failed: ${messageOf(error)}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
236
323
|
/** shape: none — one message extraction for a caught value of unknown type. */
|
|
237
324
|
function messageOf(error: unknown): string {
|
|
238
325
|
return error instanceof Error ? error.message : String(error);
|
|
@@ -260,10 +347,21 @@ export default async function piRender(pi: ExtensionApi, deps: BootDeps = {}): P
|
|
|
260
347
|
const registry = createRegistry(createSettingsStore({ path: deps.settingsPath, logger: log }), log);
|
|
261
348
|
for (const descriptor of MODULES) registry.defineModule(descriptor);
|
|
262
349
|
|
|
263
|
-
//
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
350
|
+
// Both live seams register synchronously, before piRender's first await. The host evaluates
|
|
351
|
+
// getMarkdownTransformers() when it constructs a restored message (session rejoin builds
|
|
352
|
+
// AssistantMessageComponent while our engines are still warming) and stores that list on the
|
|
353
|
+
// component, so a transformer registered after an await is invisible to every message restored in
|
|
354
|
+
// the window — its tables render host-default for the whole session (issue #13). The rows table is
|
|
355
|
+
// synchronous already; the content seam takes lazy holders for the shiki theme and the artifact
|
|
356
|
+
// cache/server, which the awaited lanes below fill in.
|
|
357
|
+
const codeTheme = createLazyCodeTheme();
|
|
358
|
+
const artifacts: ArtifactsHolder = {};
|
|
359
|
+
settleSync("rows", log, () => installRows(pi, registry, log));
|
|
360
|
+
settleSync("content", log, () => registerContentSeam(pi, registry, log, codeTheme.theme, artifacts));
|
|
361
|
+
|
|
362
|
+
// The fallible, environment-bound lanes run next and only fill the holders above.
|
|
363
|
+
await settle("artifacts", log, () => installArtifacts(log, deps, artifacts));
|
|
364
|
+
await settle("content-warm", log, () => warmCodeTheme(registry, log, codeTheme));
|
|
267
365
|
|
|
268
366
|
// Collapse-first reading model (owner decision): every tool row starts on one line.
|
|
269
367
|
// The host exposes this on the per-event UI context (ctx.ui), not on the pi API — only
|
package/package.json
CHANGED
package/src/core/paint.ts
CHANGED
|
@@ -48,7 +48,6 @@ function tryStyle(fn: ((text: string) => string) | undefined, text: string): str
|
|
|
48
48
|
return undefined;
|
|
49
49
|
}
|
|
50
50
|
}
|
|
51
|
-
|
|
52
51
|
/** Region 1 — the tool-row factory. Reads `theme` live, so the next call paints the current theme. */
|
|
53
52
|
// shape: closure returning an object literal — trigger #4, eight stateless row roles over the captured theme.
|
|
54
53
|
export function createRowPaint(theme: HostTheme): RowPaint {
|
|
@@ -67,13 +66,45 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
67
66
|
};
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
/**
|
|
70
|
+
* How the header band fills its cell: `"reverse"` swaps the cell's ink and background (SGR reverse
|
|
71
|
+
* video), `"none"` leaves the bare header. Picked once at construction, so a side-by-side can sweep it.
|
|
72
|
+
*/
|
|
73
|
+
export type BandStyle = "reverse" | "none";
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* SGR reverse video, on and off. The band is the ONE escape pair that originates here rather than in a
|
|
77
|
+
* theme: no background token is reachable on the content seam, so the band is built from reverse video,
|
|
78
|
+
* which needs none and therefore survives a restore that happens before any theme is known.
|
|
79
|
+
*/
|
|
80
|
+
const REVERSE_ON = "\x1b[7m";
|
|
81
|
+
const REVERSE_OFF = "\x1b[27m";
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
|
|
85
|
+
*
|
|
86
|
+
* The markdown theme's own closures supply the emphasis ink: `bold`/`italic` for inline spans, and its
|
|
87
|
+
* `code` closure (the `mdCode` token, identical to `accent` in both stock themes) for the header ink.
|
|
88
|
+
* `bandStyle` picks the header band — `"reverse"` (the default) composes reverse video over the header,
|
|
89
|
+
* `"none"` leaves it bare — so the one band reachable on this seam is a construction-time choice.
|
|
90
|
+
*/
|
|
91
|
+
// shape: closure returning an object literal — trigger #4, eight stateless content roles over the
|
|
92
|
+
// captured markdown theme and the band style.
|
|
93
|
+
export function createContentPaint(mdTheme: MarkdownTheme, bandStyle: BandStyle = "reverse"): ContentPaint {
|
|
94
|
+
// The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
|
|
95
|
+
// degrades on its own, so a theme missing one still stamps whatever the other gives.
|
|
96
|
+
const header = (text: string): string => {
|
|
97
|
+
const inked = tryStyle(mdTheme.code, text) ?? text;
|
|
98
|
+
return tryStyle(mdTheme.bold, inked) ?? inked;
|
|
99
|
+
};
|
|
73
100
|
return {
|
|
74
101
|
rule: (text) => tryStyle(mdTheme.hr, text) ?? tryStyle(mdTheme.quoteBorder, text) ?? text,
|
|
75
102
|
quote: (text) => tryStyle(mdTheme.quote, text) ?? text,
|
|
76
103
|
code: (text) => tryStyle(mdTheme.code, text) ?? text,
|
|
77
104
|
codeBlockBorder: (text) => tryStyle(mdTheme.codeBlockBorder, text) ?? text,
|
|
105
|
+
strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
|
|
106
|
+
em: (text) => tryStyle(mdTheme.italic, text) ?? text,
|
|
107
|
+
header,
|
|
108
|
+
headerBand: (text) => (bandStyle === "reverse" ? `${REVERSE_ON}${header(text)}${REVERSE_OFF}` : header(text)),
|
|
78
109
|
};
|
|
79
110
|
}
|
package/src/core/types/host.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface MarkdownTheme {
|
|
|
32
32
|
code?(text: string): string;
|
|
33
33
|
/** Fenced-code-block border wrapper. Optional. */
|
|
34
34
|
codeBlockBorder?(text: string): string;
|
|
35
|
+
/** Bold wrapper for `**strong**` spans — the host markdown theme's own `bold` closure. Optional. */
|
|
36
|
+
bold?(text: string): string;
|
|
37
|
+
/** Italic wrapper for `*em*` spans — the host markdown theme's own `italic` closure. Optional. */
|
|
38
|
+
italic?(text: string): string;
|
|
35
39
|
}
|
|
36
40
|
|
|
37
41
|
/** The pi-tui component slice renderers return and receive. Structural host shape — no host import. */
|
package/src/core/types/paint.ts
CHANGED
|
@@ -51,4 +51,18 @@ export interface ContentPaint {
|
|
|
51
51
|
code(text: string): string;
|
|
52
52
|
/** Fenced-code-block border. Required. */
|
|
53
53
|
codeBlockBorder(text: string): string;
|
|
54
|
+
/** A `**strong**` inline span, drawn in the theme's bold. Required. */
|
|
55
|
+
strong(text: string): string;
|
|
56
|
+
/** An `*em*` inline span, drawn in the theme's italic. Required. */
|
|
57
|
+
em(text: string): string;
|
|
58
|
+
/**
|
|
59
|
+
* One stamped header cell: the accent ink under bold. The ink is the markdown theme's `code` closure
|
|
60
|
+
* (`mdCode`, identical to `accent` in both stock themes) and the weight its `bold` closure. Required.
|
|
61
|
+
*/
|
|
62
|
+
header(text: string): string;
|
|
63
|
+
/**
|
|
64
|
+
* One header-band cell: {@link header} carrying the band style — reverse video by default, so the
|
|
65
|
+
* cell's own ink becomes its background. Required.
|
|
66
|
+
*/
|
|
67
|
+
headerBand(text: string): string;
|
|
54
68
|
}
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
/**
|
|
8
8
|
* table.ts — the table surface: a markdown table becomes a banded box.
|
|
9
9
|
*
|
|
10
|
+
* The header row is stamped through the header-band role — bold over the accent ink inside a band — or,
|
|
11
|
+
* when the header row is empty (`| | |`), that stamp moves onto the first column so no blank band is
|
|
12
|
+
* spent. `**strong**` and `*em*` cells route through their own roles.
|
|
13
|
+
*
|
|
10
14
|
* Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
|
|
11
15
|
* out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
|
|
12
16
|
*
|
|
@@ -60,15 +64,22 @@ function isTableToken(token: Token): token is Token & TableToken {
|
|
|
60
64
|
return Array.isArray(token.rows) && token.rows.every((row) => Array.isArray(row) && row.every(isCell));
|
|
61
65
|
}
|
|
62
66
|
|
|
67
|
+
/** The inline text a token wraps, already styled by its own children; `token.text` is the fallback. */
|
|
68
|
+
function innerText(token: InlineToken, paint: ContentPaint): string {
|
|
69
|
+
return (token.tokens ?? []).map((child) => inlineText(child, paint)).join("") || (token.text ?? "");
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
/** Inline styling is a ContentPaint role or nothing — the host tokenizer's own `codespan` is code. */
|
|
64
73
|
function inlineText(token: InlineToken, paint: ContentPaint): string {
|
|
65
74
|
switch (token.type) {
|
|
66
75
|
case "codespan":
|
|
67
76
|
return paint.code(token.text ?? "");
|
|
68
77
|
case "strong":
|
|
78
|
+
return paint.strong(innerText(token, paint));
|
|
69
79
|
case "em":
|
|
80
|
+
return paint.em(innerText(token, paint));
|
|
70
81
|
case "link":
|
|
71
|
-
return (token
|
|
82
|
+
return innerText(token, paint);
|
|
72
83
|
default:
|
|
73
84
|
return token.text ?? token.raw ?? "";
|
|
74
85
|
}
|
|
@@ -108,6 +119,10 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
108
119
|
widths[widest] = (widths[widest] ?? 0) - 1;
|
|
109
120
|
}
|
|
110
121
|
|
|
122
|
+
// An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
|
|
123
|
+
// header row and its separator are dropped, and the band moves onto that first column instead.
|
|
124
|
+
const keyColumn = header.every((cell) => cell === "");
|
|
125
|
+
|
|
111
126
|
// A light box around and between everything: the frame is what keeps a wrapped cell visually
|
|
112
127
|
// inside the table instead of trailing off its right edge.
|
|
113
128
|
const ruleLine = (left: string, middle: string, right: string): string =>
|
|
@@ -115,24 +130,33 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
115
130
|
const bar = paint.rule("│");
|
|
116
131
|
const physical: string[] = [];
|
|
117
132
|
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
/** One logical row, wrapped into physical lines; `banded` picks the cells drawn through headerBand. */
|
|
134
|
+
const renderRow = (cells: string[], banded: (index: number) => boolean): void => {
|
|
135
|
+
const wrapped = cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
|
|
136
|
+
const height = Math.max(1, ...wrapped.map((lines) => lines.length));
|
|
120
137
|
for (let line = 0; line < height; line++) {
|
|
121
|
-
const parts =
|
|
138
|
+
const parts = wrapped.map((lines, index) => {
|
|
139
|
+
const padded = padCell(lines[line] ?? "", widths[index] ?? MIN_COLUMN);
|
|
140
|
+
return banded(index) ? ` ${paint.headerBand(padded)} ` : ` ${padded} `;
|
|
141
|
+
});
|
|
122
142
|
physical.push(`${bar}${parts.join(bar)}${bar}`);
|
|
123
143
|
}
|
|
124
144
|
};
|
|
125
145
|
|
|
126
|
-
const wrapBand = (cells: string[]): string[][] =>
|
|
127
|
-
cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
|
|
128
|
-
|
|
129
146
|
physical.push(ruleLine("┌", "┬", "┐"));
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
147
|
+
if (keyColumn) {
|
|
148
|
+
rows.forEach((row, index) => {
|
|
149
|
+
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
150
|
+
renderRow(row, (index) => index === 0);
|
|
151
|
+
});
|
|
152
|
+
} else {
|
|
153
|
+
renderRow(header, () => true);
|
|
154
|
+
physical.push(ruleLine("├", "┼", "┤"));
|
|
155
|
+
rows.forEach((row, index) => {
|
|
156
|
+
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
157
|
+
renderRow(row, () => false);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
136
160
|
physical.push(ruleLine("└", "┴", "┘"));
|
|
137
161
|
return physical.map((line) => (visibleWidth(line) > width ? truncateToWidth(line, width) : line));
|
|
138
162
|
}
|