@zenodinh/pi-render 0.1.8 → 0.1.10
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/package.json +1 -1
- package/src/core/paint.ts +8 -26
- package/src/core/types/host.ts +2 -17
- package/src/core/types/paint.ts +9 -22
- package/src/renderers/content/table.ts +38 -40
package/package.json
CHANGED
package/src/core/paint.ts
CHANGED
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Boundary: renderers call role methods and never emit an escape (AGENTS: every escape originates
|
|
5
5
|
* here); both theme inputs are structural, so a plain-object fixture drives either factory with no
|
|
6
|
-
* host import.
|
|
7
|
-
*
|
|
6
|
+
* host import. Every content role is a live lookup over the markdown theme's own closures, so a theme
|
|
7
|
+
* switch repaints on the next call.
|
|
8
8
|
*
|
|
9
9
|
* shape: none at module level — a role is a token lookup with no discriminator. The two closure
|
|
10
|
-
* factories
|
|
10
|
+
* factories declare their own shape.
|
|
11
11
|
*
|
|
12
12
|
* ported from pi-pretty-tui/src/config.ts:71-100 — survives because: "read the theme token, degrade to
|
|
13
13
|
* default when it is absent" is what resolveBaseBackground did; its dead links (`toolBg`,
|
|
@@ -71,33 +71,15 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/**
|
|
75
|
-
* The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
|
|
76
|
-
* Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
|
|
77
|
-
* contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
|
|
78
|
-
* the band theme-driven, because the header ink is the theme's own. No token, no construction-time
|
|
79
|
-
* choice (owner ruling 2026-10-09; issue #52).
|
|
80
|
-
*/
|
|
81
|
-
/**
|
|
82
|
-
* SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
|
|
83
|
-
* Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
|
|
84
|
-
*/
|
|
85
|
-
const REVERSE_ON = "\x1b[7m";
|
|
86
|
-
const REVERSE_OFF = "\x1b[27m";
|
|
87
|
-
|
|
88
|
-
/** The band: reverse video over the header ink — the cell's own ink and background swapped. */
|
|
89
|
-
function reverseBand(text: string): string {
|
|
90
|
-
return `${REVERSE_ON}${text}${REVERSE_OFF}`;
|
|
91
|
-
}
|
|
92
|
-
|
|
93
74
|
/**
|
|
94
75
|
* Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
|
|
95
76
|
*
|
|
96
|
-
* The markdown theme's own closures supply the
|
|
97
|
-
* `
|
|
77
|
+
* The markdown theme's own closures supply the ink: `code` (`mdCode`) under `bold` for the table header,
|
|
78
|
+
* `heading` (`mdHeading`, the accent purple) for a body cell's code-span highlight, `bold`/`italic` for
|
|
79
|
+
* spans.
|
|
98
80
|
*/
|
|
99
81
|
// shape: closure returning an object literal — trigger #4, eight stateless content roles over the
|
|
100
|
-
// captured markdown theme
|
|
82
|
+
// captured markdown theme.
|
|
101
83
|
export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
102
84
|
// The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
|
|
103
85
|
// degrades on its own, so a theme missing one still stamps whatever the other gives.
|
|
@@ -113,6 +95,6 @@ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
|
113
95
|
strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
|
|
114
96
|
em: (text) => tryStyle(mdTheme.italic, text) ?? text,
|
|
115
97
|
header,
|
|
116
|
-
|
|
98
|
+
cellHighlight: (text) => tryStyle(mdTheme.heading, text) ?? text,
|
|
117
99
|
};
|
|
118
100
|
}
|
package/src/core/types/host.ts
CHANGED
|
@@ -20,18 +20,6 @@ export interface HostTheme {
|
|
|
20
20
|
bold(text: string): string;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
/**
|
|
24
|
-
* The background-capable theme slice the header band reads. Structural host shape: the host's live
|
|
25
|
-
* theme is a proxy over the active theme, so any object answering `bg` qualifies.
|
|
26
|
-
*
|
|
27
|
-
* Declared apart from {@link HostTheme} on purpose — that type is what every row renderer and every
|
|
28
|
-
* golden fixture constructs, so a background member there would break call sites that draw no band.
|
|
29
|
-
*/
|
|
30
|
-
export interface HostBackgroundTheme {
|
|
31
|
-
/** Wraps text in a background token's escape, e.g. bg("selectedBg", "x"). Required. */
|
|
32
|
-
bg(key: string, text: string): string;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
23
|
/** Live markdown-theme closures the content painter wraps. Structural host shape — no host import. */
|
|
36
24
|
export interface MarkdownTheme {
|
|
37
25
|
/** Horizontal-rule escape wrapper. Optional: a host without this token degrades to plain text. */
|
|
@@ -44,6 +32,8 @@ export interface MarkdownTheme {
|
|
|
44
32
|
code?(text: string): string;
|
|
45
33
|
/** Fenced-code-block border wrapper. Optional. */
|
|
46
34
|
codeBlockBorder?(text: string): string;
|
|
35
|
+
/** Heading wrapper (the theme's `mdHeading` ink); the table body's cell ink reads it. Optional. */
|
|
36
|
+
heading?(text: string): string;
|
|
47
37
|
/** Bold wrapper for `**strong**` spans — the host markdown theme's own `bold` closure. Optional. */
|
|
48
38
|
bold?(text: string): string;
|
|
49
39
|
/** Italic wrapper for `*em*` spans — the host markdown theme's own `italic` closure. Optional. */
|
|
@@ -175,10 +165,5 @@ export interface EventContext {
|
|
|
175
165
|
ui?: {
|
|
176
166
|
/** Collapses (false) / expands (true) every tool row. The collapse-first default rides this. Optional. */
|
|
177
167
|
setToolsExpanded?(expanded: boolean): void;
|
|
178
|
-
/**
|
|
179
|
-
* The live theme handle — a getter over the host's theme proxy, so one read sees every later
|
|
180
|
-
* switch. Optional: it is the band ink's only reach, and the extension API carries no theme.
|
|
181
|
-
*/
|
|
182
|
-
theme?: HostBackgroundTheme;
|
|
183
168
|
};
|
|
184
169
|
}
|
package/src/core/types/paint.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* paint.ts — the painting contract: the two role sets every renderer draws through
|
|
3
|
-
* reach the header band reads.
|
|
2
|
+
* paint.ts — the painting contract: the two role sets every renderer draws through.
|
|
4
3
|
*
|
|
5
4
|
* Boundary: region 1 is the tool row, region 2 is markdown content. Renderers call role methods
|
|
6
5
|
* and never emit an escape sequence themselves (AGENTS: escapes originate in core/paint.ts).
|
|
@@ -8,7 +7,7 @@
|
|
|
8
7
|
* shape: none — declaration-only module (no runtime unit), so no DSG-1 shape trigger applies.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
|
-
import type {
|
|
10
|
+
import type { HostTheme } from "./host.ts";
|
|
12
11
|
|
|
13
12
|
/** Region 1 — the tool-row roles. Text in, theme-derived escape-wrapped text out. */
|
|
14
13
|
export interface RowPaint {
|
|
@@ -49,19 +48,6 @@ export type RowPaintFactory = (
|
|
|
49
48
|
theme: HostTheme,
|
|
50
49
|
) => RowPaint;
|
|
51
50
|
|
|
52
|
-
/**
|
|
53
|
-
* The band's theme reach: one long-lived handle, fed from the per-event UI context and read per frame.
|
|
54
|
-
*
|
|
55
|
-
* The HANDLE is what travels, never a resolved colour: the host's theme is a proxy over the active
|
|
56
|
-
* theme, so a cached escape would freeze the band on the theme selected at load.
|
|
57
|
-
*/
|
|
58
|
-
export interface ThemeHolder {
|
|
59
|
-
/** Stores a live theme handle; a value that cannot paint a background is stored as no theme. */
|
|
60
|
-
set(theme: unknown): void;
|
|
61
|
-
/** The handle for this frame, or undefined when none was fed — the band's fallback state. */
|
|
62
|
-
get(): HostBackgroundTheme | undefined;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
51
|
/** Region 2 — the markdown-content roles. */
|
|
66
52
|
export interface ContentPaint {
|
|
67
53
|
/** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
|
|
@@ -77,14 +63,15 @@ export interface ContentPaint {
|
|
|
77
63
|
/** An `*em*` inline span, drawn in the theme's italic. Required. */
|
|
78
64
|
em(text: string): string;
|
|
79
65
|
/**
|
|
80
|
-
* One stamped header cell: the accent ink under bold. The ink is the
|
|
81
|
-
* (`mdCode`, identical to `accent` in both stock themes) and the
|
|
66
|
+
* One stamped header cell: the accent ink under bold — the table header's only ink. The ink is the
|
|
67
|
+
* markdown theme's `code` closure (`mdCode`, identical to `accent` in both stock themes) and the
|
|
68
|
+
* weight its `bold` closure. No background: the owner removed the band on review (#62). Required.
|
|
82
69
|
*/
|
|
83
70
|
header(text: string): string;
|
|
84
71
|
/**
|
|
85
|
-
* One
|
|
86
|
-
*
|
|
87
|
-
*
|
|
72
|
+
* One highlighted span inside a table BODY cell — a code span today: the theme's `heading` closure
|
|
73
|
+
* (`mdHeading`, purple in `dracula-soft`). The cell's regular text stays the default ink (#64).
|
|
74
|
+
* Degrades to plain text without the closure. Required.
|
|
88
75
|
*/
|
|
89
|
-
|
|
76
|
+
cellHighlight(text: string): string;
|
|
90
77
|
}
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
// ported from pi-pretty-tui/src/features/canvas/table.ts — survives because: the
|
|
2
|
-
//
|
|
3
|
-
// Only the escape source changed: frame and bars go through paint.rule,
|
|
4
|
-
// paint.
|
|
5
|
-
// originate outside core/paint.ts.
|
|
1
|
+
// ported from pi-pretty-tui/src/features/canvas/table.ts — survives because: the boxed layout, the
|
|
2
|
+
// column-width solver and the cell wrapping are the reading experience this requirement keeps.
|
|
3
|
+
// Only the escape source changed: frame and bars go through paint.rule, the header through
|
|
4
|
+
// paint.header, body cells through paint.cell; the predecessor's own bold/italic literals are dropped
|
|
5
|
+
// because no escape may originate outside core/paint.ts.
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* table.ts — the table surface: a markdown table becomes a
|
|
8
|
+
* table.ts — the table surface: a markdown table becomes a boxed box with inked text.
|
|
9
9
|
*
|
|
10
|
-
* The header row
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* through their own roles.
|
|
10
|
+
* The header row's cells take the header ink — bold over `mdCode`, over the whole cell, so a code span
|
|
11
|
+
* inside a header cell adds no ink of its own. A body cell's own text keeps the default ink; only a
|
|
12
|
+
* code span inside it takes the cell highlight — the theme's `mdHeading` purple (owner look-check,
|
|
13
|
+
* 2026-10-09, #64). Nothing paints a background: the band was removed the same day (#62). When the
|
|
14
|
+
* header row is empty (`| | |`), that row and its separator are dropped and the first column's labels
|
|
15
|
+
* take the header ink instead. `**strong**` and `*em*` cells route through their own roles.
|
|
16
16
|
*
|
|
17
17
|
* Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
|
|
18
18
|
* out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
|
|
@@ -86,11 +86,16 @@ function inlineText(token: InlineToken, paint: ContentPaint): string {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
/**
|
|
90
|
+
* The cell's text with its inline spans composed, `codeSpanInk` drawing each code span. The caller picks
|
|
91
|
+
* it per region: the identity where the cell's own role inks the whole text (header cells, key-column
|
|
92
|
+
* labels), the highlight role where the text stays default and only the code span is inked (#64).
|
|
93
|
+
*/
|
|
94
|
+
function cellText(cell: CellLike | undefined, paint: ContentPaint, codeSpanInk: (text: string) => string): string {
|
|
90
95
|
const tokens = cell?.tokens;
|
|
91
96
|
const text =
|
|
92
97
|
Array.isArray(tokens) && tokens.length > 0
|
|
93
|
-
? tokens.map((token) => inlineText(token, paint)).join("")
|
|
98
|
+
? tokens.map((token) => inlineText(token, { ...paint, code: codeSpanInk })).join("")
|
|
94
99
|
: (cell?.text ?? "");
|
|
95
100
|
// Soft wraps inside a cell are one logical line before the width solve.
|
|
96
101
|
return text.replace(/\s+/g, " ").trim();
|
|
@@ -103,8 +108,16 @@ function padCell(content: string, width: number): string {
|
|
|
103
108
|
|
|
104
109
|
/** shape: none — one greedy width solve plus a largest-remainder fill; a table is small and the rule is exact. */
|
|
105
110
|
function renderTable(token: TableToken, width: number, paint: ContentPaint): string[] {
|
|
106
|
-
|
|
107
|
-
const
|
|
111
|
+
/** A label cell (the header row, or a key-column table's first column): the whole text takes the header ink. */
|
|
112
|
+
const label = (cell: CellLike | undefined): string => cellText(cell, paint, (text) => text);
|
|
113
|
+
/** A body cell: its text stays default, and a code span inside it takes the highlight ink. */
|
|
114
|
+
const body = (cell: CellLike | undefined): string => cellText(cell, paint, (text) => paint.cellHighlight(text));
|
|
115
|
+
|
|
116
|
+
const header = token.header.map(label);
|
|
117
|
+
// An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
|
|
118
|
+
// header row and its separator are dropped, and those labels take the header ink instead.
|
|
119
|
+
const keyColumn = header.every((cell) => cell === "");
|
|
120
|
+
const rows = token.rows.map((row) => row.map((cell, index) => (keyColumn && index === 0 ? label(cell) : body(cell))));
|
|
108
121
|
const columns = Math.max(1, header.length);
|
|
109
122
|
const natural: number[] = [];
|
|
110
123
|
for (let index = 0; index < columns; index++) {
|
|
@@ -142,10 +155,6 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
142
155
|
}
|
|
143
156
|
}
|
|
144
157
|
|
|
145
|
-
// An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
|
|
146
|
-
// header row and its separator are dropped, and the band moves onto that first column instead.
|
|
147
|
-
const keyColumn = header.every((cell) => cell === "");
|
|
148
|
-
|
|
149
158
|
// A light box around and between everything: the frame is what keeps a wrapped cell visually
|
|
150
159
|
// inside the table instead of trailing off its right edge.
|
|
151
160
|
const ruleLine = (left: string, middle: string, right: string): string =>
|
|
@@ -153,26 +162,15 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
153
162
|
const bar = paint.rule("│");
|
|
154
163
|
const physical: string[] = [];
|
|
155
164
|
|
|
156
|
-
/** One logical row, wrapped into physical lines; `
|
|
157
|
-
const renderRow = (cells: string[],
|
|
165
|
+
/** One logical row, wrapped into physical lines; `ink` wraps each cell's text line by line. */
|
|
166
|
+
const renderRow = (cells: string[], ink: (text: string, index: number) => string): void => {
|
|
158
167
|
const wrapped = cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
|
|
159
168
|
const height = Math.max(1, ...wrapped.map((lines) => lines.length));
|
|
160
169
|
for (let line = 0; line < height; line++) {
|
|
161
|
-
const parts = wrapped.map(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
// unbroken bar; the last cell has no trailing separator, so its band ends at its own padding.
|
|
166
|
-
const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
|
|
167
|
-
return banded(index) ? paint.headerBand(unit) : unit;
|
|
168
|
-
});
|
|
169
|
-
// #57 (owner pick): the band reaches the outer frame where it abuts it — a banded first cell
|
|
170
|
-
// takes the left frame column into the band, a banded last cell the right one. The frame glyph
|
|
171
|
-
// joins the band's own style (dark-on-green), not the rule ink, so no unbanded sliver remains
|
|
172
|
-
// between the field and the border.
|
|
173
|
-
const left = banded(0) ? paint.headerBand("│") : bar;
|
|
174
|
-
const right = banded(cells.length - 1) ? paint.headerBand("│") : bar;
|
|
175
|
-
physical.push(`${left}${parts.join("")}${right}`);
|
|
170
|
+
const parts = wrapped.map(
|
|
171
|
+
(lines, index) => ` ${padCell(ink(lines[line] ?? "", index), widths[index] ?? MIN_COLUMN)} `,
|
|
172
|
+
);
|
|
173
|
+
physical.push(`${bar}${parts.join(bar)}${bar}`);
|
|
176
174
|
}
|
|
177
175
|
};
|
|
178
176
|
|
|
@@ -180,14 +178,14 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
180
178
|
if (keyColumn) {
|
|
181
179
|
rows.forEach((row, index) => {
|
|
182
180
|
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
183
|
-
renderRow(row, (
|
|
181
|
+
renderRow(row, (text, column) => (column === 0 ? paint.header(text) : text));
|
|
184
182
|
});
|
|
185
183
|
} else {
|
|
186
|
-
renderRow(header, () =>
|
|
184
|
+
renderRow(header, (text) => paint.header(text));
|
|
187
185
|
physical.push(ruleLine("├", "┼", "┤"));
|
|
188
186
|
rows.forEach((row, index) => {
|
|
189
187
|
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
190
|
-
renderRow(row, () =>
|
|
188
|
+
renderRow(row, (text) => text);
|
|
191
189
|
});
|
|
192
190
|
}
|
|
193
191
|
physical.push(ruleLine("└", "┴", "┘"));
|