@zenodinh/pi-render 0.1.7 → 0.1.9
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 +10 -27
- package/src/core/types/host.ts +2 -17
- package/src/core/types/paint.ts +12 -23
- package/src/renderers/content/table.ts +28 -27
- package/src/renderers/tool/runtime.ts +48 -17
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`,
|
|
@@ -60,7 +60,9 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
60
60
|
accent: (text) => fgOrPlain(theme, "accent", text),
|
|
61
61
|
error: (text) => fgOrPlain(theme, "error", text),
|
|
62
62
|
warning: (text) => fgOrPlain(theme, "warning", text),
|
|
63
|
-
|
|
63
|
+
// The settled ink is `muted` by owner pick (#58): the theme's green read too bright. The role
|
|
64
|
+
// keeps its name, so the three state call sites stay one expression.
|
|
65
|
+
success: (text) => fgOrPlain(theme, "muted", text),
|
|
64
66
|
gutter: (text) => fgOrPlain(theme, "muted", text),
|
|
65
67
|
match: (text) => matchOrAccent(theme, text),
|
|
66
68
|
diffAdded: (text) => fgOrPlain(theme, "toolDiffAdded", text),
|
|
@@ -69,33 +71,14 @@ export function createRowPaint(theme: HostTheme): RowPaint {
|
|
|
69
71
|
};
|
|
70
72
|
}
|
|
71
73
|
|
|
72
|
-
/**
|
|
73
|
-
* The header band: the header ink with its cell's foreground and background swapped — SGR reverse video.
|
|
74
|
-
* Reversal is what makes the band safe on any theme: it preserves the theme's own ink-on-background
|
|
75
|
-
* contrast ratio instead of pairing an ink with a fill that was picked for something else, and it keeps
|
|
76
|
-
* the band theme-driven, because the header ink is the theme's own. No token, no construction-time
|
|
77
|
-
* choice (owner ruling 2026-10-09; issue #52).
|
|
78
|
-
*/
|
|
79
|
-
/**
|
|
80
|
-
* SGR reverse video, on and off — the ONE escape pair that originates here rather than in a theme.
|
|
81
|
-
* Reverse video needs no token, so it works on any theme and on a frame rendered before one arrives.
|
|
82
|
-
*/
|
|
83
|
-
const REVERSE_ON = "\x1b[7m";
|
|
84
|
-
const REVERSE_OFF = "\x1b[27m";
|
|
85
|
-
|
|
86
|
-
/** The band: reverse video over the header ink — the cell's own ink and background swapped. */
|
|
87
|
-
function reverseBand(text: string): string {
|
|
88
|
-
return `${REVERSE_ON}${text}${REVERSE_OFF}`;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
74
|
/**
|
|
92
75
|
* Region 2 — the content factory. `rule` degrades hr → quoteBorder → plain; every role ends in plain.
|
|
93
76
|
*
|
|
94
|
-
* The markdown theme's own closures supply the
|
|
95
|
-
* `
|
|
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 the table body's cells, `bold`/`italic` for spans.
|
|
96
79
|
*/
|
|
97
80
|
// shape: closure returning an object literal — trigger #4, eight stateless content roles over the
|
|
98
|
-
// captured markdown theme
|
|
81
|
+
// captured markdown theme.
|
|
99
82
|
export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
100
83
|
// The header ink: the theme's `code` closure under its `bold` closure. Each link is optional and
|
|
101
84
|
// degrades on its own, so a theme missing one still stamps whatever the other gives.
|
|
@@ -111,6 +94,6 @@ export function createContentPaint(mdTheme: MarkdownTheme): ContentPaint {
|
|
|
111
94
|
strong: (text) => tryStyle(mdTheme.bold, text) ?? text,
|
|
112
95
|
em: (text) => tryStyle(mdTheme.italic, text) ?? text,
|
|
113
96
|
header,
|
|
114
|
-
|
|
97
|
+
cell: (text) => tryStyle(mdTheme.heading, text) ?? text,
|
|
115
98
|
};
|
|
116
99
|
}
|
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 {
|
|
@@ -26,7 +25,10 @@ export interface RowPaint {
|
|
|
26
25
|
error(text: string): string;
|
|
27
26
|
/** Warning text for a degraded or partial result. Required. */
|
|
28
27
|
warning(text: string): string;
|
|
29
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* The settled ink: a call that did not fail, drawn in the `muted` token (thinking's gray) by owner
|
|
30
|
+
* pick (#58). The role keeps its name so the state call sites stay one expression. Required.
|
|
31
|
+
*/
|
|
30
32
|
success(text: string): string;
|
|
31
33
|
/** The row's left gutter marker. Required. */
|
|
32
34
|
gutter(text: string): string;
|
|
@@ -46,19 +48,6 @@ export type RowPaintFactory = (
|
|
|
46
48
|
theme: HostTheme,
|
|
47
49
|
) => RowPaint;
|
|
48
50
|
|
|
49
|
-
/**
|
|
50
|
-
* The band's theme reach: one long-lived handle, fed from the per-event UI context and read per frame.
|
|
51
|
-
*
|
|
52
|
-
* The HANDLE is what travels, never a resolved colour: the host's theme is a proxy over the active
|
|
53
|
-
* theme, so a cached escape would freeze the band on the theme selected at load.
|
|
54
|
-
*/
|
|
55
|
-
export interface ThemeHolder {
|
|
56
|
-
/** Stores a live theme handle; a value that cannot paint a background is stored as no theme. */
|
|
57
|
-
set(theme: unknown): void;
|
|
58
|
-
/** The handle for this frame, or undefined when none was fed — the band's fallback state. */
|
|
59
|
-
get(): HostBackgroundTheme | undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
51
|
/** Region 2 — the markdown-content roles. */
|
|
63
52
|
export interface ContentPaint {
|
|
64
53
|
/** Horizontal rule; falls back hr → quoteBorder → plain rather than throw. Required. */
|
|
@@ -74,14 +63,14 @@ export interface ContentPaint {
|
|
|
74
63
|
/** An `*em*` inline span, drawn in the theme's italic. Required. */
|
|
75
64
|
em(text: string): string;
|
|
76
65
|
/**
|
|
77
|
-
* One stamped header cell: the accent ink under bold. The ink is the
|
|
78
|
-
* (`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.
|
|
79
69
|
*/
|
|
80
70
|
header(text: string): string;
|
|
81
71
|
/**
|
|
82
|
-
* One
|
|
83
|
-
*
|
|
84
|
-
* frame with no theme reachable. Required.
|
|
72
|
+
* One table body cell: the theme's `heading` closure (`mdHeading`) wrapped around the whole cell, so
|
|
73
|
+
* a code span inside adds no ink of its own. Degrades to plain text without the closure. Required.
|
|
85
74
|
*/
|
|
86
|
-
|
|
75
|
+
cell(text: string): string;
|
|
87
76
|
}
|
|
@@ -1,17 +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
|
-
*
|
|
10
|
+
* The header row's cells take the header ink — bold over `mdCode`; the body cells take the cell ink,
|
|
11
|
+
* the theme's `mdHeading` purple. Each cell's whole text carries that one ink, so a code span inside a
|
|
12
|
+
* cell adds no green or gray of its own. Nothing paints a background: the owner removed the full-bleed
|
|
13
|
+
* band on a live look-check (2026-10-09, #62). When the header row is empty (`| | |`), that row and its
|
|
14
|
+
* separator are dropped and the first column's labels take the header ink instead. `**strong**` and
|
|
15
|
+
* `*em*` cells route through their own roles.
|
|
15
16
|
*
|
|
16
17
|
* Boundary: lane-local. The transformer passes markdown in and splices the returned markdown back
|
|
17
18
|
* out, so every escape in the output originates in the injected ContentPaint (T-08) — the #22 fix.
|
|
@@ -85,11 +86,16 @@ function inlineText(token: InlineToken, paint: ContentPaint): string {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
/**
|
|
90
|
+
* The cell's text with its inline spans composed. A code span adds no ink: its wrapper is the
|
|
91
|
+
* identity, so the cell's own role wraps the whole composed text (owner ruling, #62).
|
|
92
|
+
*/
|
|
88
93
|
function cellText(cell: CellLike | undefined, paint: ContentPaint): string {
|
|
89
94
|
const tokens = cell?.tokens;
|
|
95
|
+
const uninked: ContentPaint = { ...paint, code: (text) => text };
|
|
90
96
|
const text =
|
|
91
97
|
Array.isArray(tokens) && tokens.length > 0
|
|
92
|
-
? tokens.map((token) => inlineText(token,
|
|
98
|
+
? tokens.map((token) => inlineText(token, uninked)).join("")
|
|
93
99
|
: (cell?.text ?? "");
|
|
94
100
|
// Soft wraps inside a cell are one logical line before the width solve.
|
|
95
101
|
return text.replace(/\s+/g, " ").trim();
|
|
@@ -142,7 +148,7 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
142
148
|
}
|
|
143
149
|
|
|
144
150
|
// An all-empty header (`| | |`) means the table carries its labels in the first column: the blank
|
|
145
|
-
// header row and its separator are dropped, and
|
|
151
|
+
// header row and its separator are dropped, and those labels take the header ink instead.
|
|
146
152
|
const keyColumn = header.every((cell) => cell === "");
|
|
147
153
|
|
|
148
154
|
// A light box around and between everything: the frame is what keeps a wrapped cell visually
|
|
@@ -152,20 +158,15 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
152
158
|
const bar = paint.rule("│");
|
|
153
159
|
const physical: string[] = [];
|
|
154
160
|
|
|
155
|
-
/** One logical row, wrapped into physical lines; `
|
|
156
|
-
const renderRow = (cells: string[],
|
|
161
|
+
/** One logical row, wrapped into physical lines; `ink` picks the role each cell's text is drawn in. */
|
|
162
|
+
const renderRow = (cells: string[], ink: (text: string, index: number) => string): void => {
|
|
157
163
|
const wrapped = cells.map((cell, index) => wrapTextWithAnsi(cell, widths[index] ?? MIN_COLUMN));
|
|
158
164
|
const height = Math.max(1, ...wrapped.map((lines) => lines.length));
|
|
159
165
|
for (let line = 0; line < height; line++) {
|
|
160
|
-
const parts = wrapped.map(
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
// unbroken bar; the last cell has no trailing separator, so its band ends at its own padding.
|
|
165
|
-
const unit = index < cells.length - 1 ? ` ${padded} ${bar}` : ` ${padded} `;
|
|
166
|
-
return banded(index) ? paint.headerBand(unit) : unit;
|
|
167
|
-
});
|
|
168
|
-
physical.push(`${bar}${parts.join("")}${bar}`);
|
|
166
|
+
const parts = wrapped.map(
|
|
167
|
+
(lines, index) => ` ${padCell(ink(lines[line] ?? "", index), widths[index] ?? MIN_COLUMN)} `,
|
|
168
|
+
);
|
|
169
|
+
physical.push(`${bar}${parts.join(bar)}${bar}`);
|
|
169
170
|
}
|
|
170
171
|
};
|
|
171
172
|
|
|
@@ -173,14 +174,14 @@ function renderTable(token: TableToken, width: number, paint: ContentPaint): str
|
|
|
173
174
|
if (keyColumn) {
|
|
174
175
|
rows.forEach((row, index) => {
|
|
175
176
|
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
176
|
-
renderRow(row, (
|
|
177
|
+
renderRow(row, (text, column) => (column === 0 ? paint.header(text) : paint.cell(text)));
|
|
177
178
|
});
|
|
178
179
|
} else {
|
|
179
|
-
renderRow(header, () =>
|
|
180
|
+
renderRow(header, (text) => paint.header(text));
|
|
180
181
|
physical.push(ruleLine("├", "┼", "┤"));
|
|
181
182
|
rows.forEach((row, index) => {
|
|
182
183
|
if (index > 0) physical.push(ruleLine("├", "┼", "┤"));
|
|
183
|
-
renderRow(row, () =>
|
|
184
|
+
renderRow(row, (text) => paint.cell(text));
|
|
184
185
|
});
|
|
185
186
|
}
|
|
186
187
|
physical.push(ruleLine("└", "┴", "┘"));
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* renderCall/renderResult methods, with no subclassing.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
18
19
|
import type {
|
|
19
20
|
HostTheme,
|
|
20
21
|
RenderContext,
|
|
@@ -166,6 +167,11 @@ interface PaddedText extends UiComponent {
|
|
|
166
167
|
setText(body: string): void;
|
|
167
168
|
/** Re-reads the row's indent, so a host padding change lands on the next frame. Required. */
|
|
168
169
|
setPad(pad: number): void;
|
|
170
|
+
/**
|
|
171
|
+
* Sets how an over-wide line is spent: false cuts it to the budget with an ellipsis, true reflows it.
|
|
172
|
+
* Required: one component serves both row states, so the mode must follow the frame.
|
|
173
|
+
*/
|
|
174
|
+
setWrap(wrap: boolean): void;
|
|
169
175
|
}
|
|
170
176
|
|
|
171
177
|
/** `in`-narrowed, never cast: a recorded component is the host's until it answers this protocol. */
|
|
@@ -180,7 +186,8 @@ function textComponent(body: string): UiComponent {
|
|
|
180
186
|
render(_width: number): string[] {
|
|
181
187
|
// One entry per line; an empty body still yields one line, so a degraded row never vanishes.
|
|
182
188
|
// Width is the component's own: renderResult receives none, so nothing here is width-keyed.
|
|
183
|
-
// The offer minus the indent is handed down by the pad wrapper
|
|
189
|
+
// The offer minus the indent is handed down by the pad wrapper, which also spends what comes back —
|
|
190
|
+
// cut when collapsed, reflowed when expanded.
|
|
184
191
|
return current.split("\n");
|
|
185
192
|
},
|
|
186
193
|
invalidate(): void {
|
|
@@ -193,22 +200,39 @@ function textComponent(body: string): UiComponent {
|
|
|
193
200
|
}
|
|
194
201
|
|
|
195
202
|
/**
|
|
196
|
-
* The pad wrapper:
|
|
197
|
-
* the
|
|
203
|
+
* The pad wrapper: the inner component gets the offer minus the indent, every line it draws takes the
|
|
204
|
+
* indent, and every line is fitted to the budget — an inner is told the budget, not obliged to spend it.
|
|
198
205
|
*
|
|
199
206
|
* Both halves are ours because a row asks the host for the "self" shell (#51), and that shell skips the
|
|
200
207
|
* Box(1,1) which used to indent each line and render its child at `width - paddingX * 2` (pi-tui
|
|
201
208
|
* box.js:83 → :91 → :115). The budget half is what a reused host `Text` needs: it wraps at the width it
|
|
202
209
|
* is given (pi-tui text.js:53-56), so a full-width title would wrap at the whole offer and then be
|
|
203
|
-
* pushed one column past it by the indent.
|
|
210
|
+
* pushed one column past it by the indent. Spending is the other half of the same duty (#56): our own
|
|
211
|
+
* text component ignores the width it is handed, so an over-wide title used to run past the offer.
|
|
204
212
|
*
|
|
205
|
-
*
|
|
213
|
+
* How an over-wide line is spent follows the row's state — the owner's review on #59: an expansion must
|
|
214
|
+
* show the FULL request and result. Collapsed cuts to the budget, ellipsis-marked: one logical line is the
|
|
215
|
+
* collapse contract. Expanded reflows with pi-tui's `wrapTextWithAnsi` — the call the table and code-panel
|
|
216
|
+
* surfaces already spend their cells with — while truncation stays only as the backstop for a slice that
|
|
217
|
+
* no width can hold (table.ts:187's rule). A line within budget passes through untouched either way.
|
|
218
|
+
*
|
|
219
|
+
* shape: closure returning an object literal — trigger #4: one stored pad and one spending mode behind
|
|
220
|
+
* render/setPad/setWrap.
|
|
206
221
|
*/
|
|
207
|
-
function padComponent(inner: UiComponent, pad: number): PaddedText {
|
|
222
|
+
function padComponent(inner: UiComponent, pad: number, wrap: boolean): PaddedText {
|
|
208
223
|
let indent = pad;
|
|
224
|
+
let wrapping = wrap;
|
|
209
225
|
return {
|
|
210
226
|
render(width: number): string[] {
|
|
211
|
-
const
|
|
227
|
+
const budget = Math.max(0, width - indent);
|
|
228
|
+
const lines = inner.render(budget).flatMap((line) => {
|
|
229
|
+
if (visibleWidth(line) <= budget) return [line];
|
|
230
|
+
// Collapsed cuts, expanded reflows (see the block comment): only an over-wide line is spent.
|
|
231
|
+
const spent = wrapping && budget > 0 ? wrapTextWithAnsi(line, budget) : [truncateToWidth(line, budget, "…")];
|
|
232
|
+
// Backstop: a wrapped slice can still exceed the budget (a wide grapheme), and a line no
|
|
233
|
+
// width can hold is cut rather than left to run past the edge (#56).
|
|
234
|
+
return spent.map((piece) => (visibleWidth(piece) > budget ? truncateToWidth(piece, budget, "…") : piece));
|
|
235
|
+
});
|
|
212
236
|
if (indent === 0) return lines;
|
|
213
237
|
const spaces = " ".repeat(indent);
|
|
214
238
|
return lines.map((line) => spaces + line);
|
|
@@ -222,10 +246,13 @@ function padComponent(inner: UiComponent, pad: number): PaddedText {
|
|
|
222
246
|
setPad(value: number): void {
|
|
223
247
|
indent = value;
|
|
224
248
|
},
|
|
249
|
+
setWrap(value: boolean): void {
|
|
250
|
+
wrapping = value;
|
|
251
|
+
},
|
|
225
252
|
};
|
|
226
253
|
}
|
|
227
254
|
|
|
228
|
-
function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
255
|
+
function textFor(ctx: RenderContext, body: string, wrap: boolean): UiComponent {
|
|
229
256
|
const pad = padOf(ctx);
|
|
230
257
|
const reuse = ctx.lastComponent;
|
|
231
258
|
// Only a component this runtime wrapped can be told a new pad, so it is the only one it reuses. A
|
|
@@ -234,6 +261,7 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
|
234
261
|
if (reuse !== undefined && isPaddedText(reuse)) {
|
|
235
262
|
reuse.setText(body);
|
|
236
263
|
reuse.setPad(pad);
|
|
264
|
+
reuse.setWrap(wrap);
|
|
237
265
|
return reuse;
|
|
238
266
|
}
|
|
239
267
|
// A host text component in the slot is still worth mutating in place: it caches its own frame, and
|
|
@@ -241,9 +269,9 @@ function textFor(ctx: RenderContext, body: string): UiComponent {
|
|
|
241
269
|
const setText = reuse?.setText;
|
|
242
270
|
if (reuse !== undefined && setText !== undefined) {
|
|
243
271
|
setText.call(reuse, body);
|
|
244
|
-
return padComponent(reuse, pad);
|
|
272
|
+
return padComponent(reuse, pad, wrap);
|
|
245
273
|
}
|
|
246
|
-
return padComponent(textComponent(body), pad);
|
|
274
|
+
return padComponent(textComponent(body), pad, wrap);
|
|
247
275
|
}
|
|
248
276
|
|
|
249
277
|
/**
|
|
@@ -374,11 +402,13 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
|
|
|
374
402
|
const ink = ctx.isError === true ? paint.error : ctx.isPartial === true ? paint.dim : paint.success;
|
|
375
403
|
const title = ink(model.title);
|
|
376
404
|
// Collapsed rows are one line: the summary the result slot settled rides this title line.
|
|
377
|
-
|
|
378
|
-
|
|
405
|
+
// Expanded reflows, so a long title stays readable in full; collapsed cuts to its one line.
|
|
406
|
+
const expanded = ctx.expanded === true;
|
|
407
|
+
const summary = expanded ? undefined : readSummary(ctx);
|
|
408
|
+
return textFor(ctx, summary === undefined ? title : `${title} · ${summary}`, expanded);
|
|
379
409
|
} catch (err) {
|
|
380
410
|
logFailure(deps.log, ctx, err);
|
|
381
|
-
return textFor(ctx, "");
|
|
411
|
+
return textFor(ctx, "", ctx.expanded === true);
|
|
382
412
|
}
|
|
383
413
|
};
|
|
384
414
|
|
|
@@ -391,10 +421,11 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
|
|
|
391
421
|
try {
|
|
392
422
|
const paint = deps.paint(theme);
|
|
393
423
|
const model = modelFor(result, ctx);
|
|
394
|
-
// A degraded row keeps
|
|
395
|
-
|
|
424
|
+
// A degraded row keeps the raw one-line body and settles no summary for the call slot to adopt;
|
|
425
|
+
// an expanded frame spends that line by the same reflow rule as any other.
|
|
426
|
+
if (model === UNKNOWN) return textFor(ctx, rawBody(result, paint), options.expanded === true);
|
|
396
427
|
const body = spec.project(model, viewOf(options, ctx), paint);
|
|
397
|
-
if (options.expanded) return textFor(ctx, body);
|
|
428
|
+
if (options.expanded) return textFor(ctx, body, true);
|
|
398
429
|
// Collapsed: hand the one-line summary to the call slot through the shared state bag and draw no
|
|
399
430
|
// line here. Redraw only when it changed — an unchanged summary is not a new frame.
|
|
400
431
|
if (readSummary(ctx) !== body) {
|
|
@@ -405,7 +436,7 @@ export function createRowRenderers(spec: RowSpec, deps: { paint: RowPaintFactory
|
|
|
405
436
|
} catch (err) {
|
|
406
437
|
// The paint may itself be the thrower, so the degraded row is built without it.
|
|
407
438
|
logFailure(deps.log, ctx, err);
|
|
408
|
-
return textFor(ctx, rawBody(result, undefined));
|
|
439
|
+
return textFor(ctx, rawBody(result, undefined), options.expanded === true);
|
|
409
440
|
}
|
|
410
441
|
};
|
|
411
442
|
|