@gajae-code/tui 0.18.10 → 0.18.12
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 +20 -0
- package/dist/types/chat/svg-figure.d.ts +26 -0
- package/dist/types/chat/svg-source.d.ts +32 -0
- package/dist/types/components/markdown.d.ts +20 -0
- package/package.json +3 -3
- package/src/chat/svg-figure.ts +405 -0
- package/src/chat/svg-source.ts +837 -0
- package/src/components/markdown.ts +160 -7
- package/src/tui.ts +85 -6
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.18.12] - 2026-10-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Render ```svg fenced code blocks as rasterized terminal images (Kitty, iTerm2, SIXEL). SVG figures stream inline with partial source repair, theme-aware token resolution through reserved `--gjc-<palette-key>` CSS variables, and automatic fallback to plain code blocks on rasterization failure. Ported from oh-my-pi.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- TUI no longer goes blank after stop/restart cycles: `#hardwareCursorRow` is now properly reset during `stop()` to prevent stale cursor position state from corrupting viewport calculations on the next `start()`.
|
|
14
|
+
|
|
15
|
+
- With `clearOnShrink` enabled, a live block that shrinks after the transcript has scrolled into history no longer leaves blank spacer rows above the pinned composer until the next resize. The contraction now clears the screen and history and replays the frame, so the viewport is filled again and no row appears twice in history.
|
|
16
|
+
|
|
17
|
+
- Scrub credentials from TUI render debug dumps and write them mode 0600 beside the debug log, without following a symlink at the dump path.
|
|
18
|
+
|
|
19
|
+
## [0.18.11] - 2026-10-12
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Fixed the viewport going blank above the status line and input box when a tall live block (task output, streaming tool output) collapsed with `clearOnShrink` off: the TUI now replays the frame when padding to the committed frontier would leave no transcript row on screen.
|
|
24
|
+
|
|
5
25
|
## [0.18.10] - 2026-10-12
|
|
6
26
|
|
|
7
27
|
### Fixed
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ImageTheme } from "../components/image";
|
|
2
|
+
import type { Component } from "../tui";
|
|
3
|
+
export interface SvgFigureOptions {
|
|
4
|
+
/**
|
|
5
|
+
* Theme for rendering the image fallback text.
|
|
6
|
+
*/
|
|
7
|
+
theme: ImageTheme;
|
|
8
|
+
/** CSS colors substituted for SVG theme variables and currentColor. */
|
|
9
|
+
palette: Readonly<Record<string, string>>;
|
|
10
|
+
/** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
|
|
11
|
+
onChange: () => void;
|
|
12
|
+
}
|
|
13
|
+
export declare class SvgFigure implements Component {
|
|
14
|
+
#private;
|
|
15
|
+
constructor(options: SvgFigureOptions);
|
|
16
|
+
/** Feed the fence body; `final` stops streaming, while `closed` records its closing marker. */
|
|
17
|
+
update(source: string, final: boolean, closed: boolean, palette?: Readonly<Record<string, string>>): void;
|
|
18
|
+
render(width: number): string[];
|
|
19
|
+
invalidate(): void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
debugState(): Record<string, unknown>;
|
|
22
|
+
get pending(): boolean;
|
|
23
|
+
get failed(): boolean;
|
|
24
|
+
/** Whether this failure may recover when terminal raster limits change. */
|
|
25
|
+
get retryable(): boolean;
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/** One run of an assistant text block: prose, or the body of a ```svg fence. */
|
|
2
|
+
export type FigureSegment = {
|
|
3
|
+
readonly kind: "markdown";
|
|
4
|
+
readonly text: string;
|
|
5
|
+
}
|
|
6
|
+
/** `closed` once the fence's closing line arrived. */
|
|
7
|
+
| {
|
|
8
|
+
readonly kind: "svg";
|
|
9
|
+
readonly source: string;
|
|
10
|
+
readonly closed: boolean;
|
|
11
|
+
};
|
|
12
|
+
/** Whether `markdown` holds a ```svg fence {@link splitSvgFences} would lift. */
|
|
13
|
+
export declare function hasSvgFence(markdown: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Split `markdown` into prose and ```svg fence bodies, in order. Only
|
|
16
|
+
* top-level fences lift; an svg fence nested in another fence, a list's
|
|
17
|
+
* indented block or a blockquote stays prose. Blank prose runs are dropped.
|
|
18
|
+
* An svg fence still open at the end is the last segment, `closed: false`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function splitSvgFences(markdown: string): FigureSegment[];
|
|
21
|
+
/** Whether a Marked SVG code token's raw source contains its closing fence. */
|
|
22
|
+
export declare function isClosedSvgFence(raw: string): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Turn a possibly truncated SVG source into a well-formed document: cut the
|
|
25
|
+
* trailing construct still being written (a tag without its `>`, an
|
|
26
|
+
* unterminated comment, CDATA section, processing instruction or declaration,
|
|
27
|
+
* a dangling `&entity`) and close every element left open, innermost first.
|
|
28
|
+
* A complete document passes through unchanged. Returns null until the root
|
|
29
|
+
* `<svg …>` start tag is complete — before that there is nothing to draw.
|
|
30
|
+
*/
|
|
31
|
+
export declare function closePartialSvg(source: string): string | null;
|
|
32
|
+
export declare function prepareSvg(svg: string, palette: Readonly<Record<string, string>>): string;
|
|
@@ -21,6 +21,7 @@ export declare function resetMarkdownHighlightCallCount(): void;
|
|
|
21
21
|
export declare const __markdownPerfCounters: {
|
|
22
22
|
lexerInvocations: number;
|
|
23
23
|
lexedBytes: number;
|
|
24
|
+
svgFenceDetectionCalls: number;
|
|
24
25
|
highlightAdmissionScans: number;
|
|
25
26
|
reset(): void;
|
|
26
27
|
};
|
|
@@ -74,8 +75,27 @@ export interface MarkdownTheme {
|
|
|
74
75
|
* Return null to fall back to fenced code rendering.
|
|
75
76
|
*/
|
|
76
77
|
resolveMermaidAscii?: (source: string) => string | null;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve an SVG figure component by fenced block source text. Components
|
|
80
|
+
* are retained by fence order. Return null for a stable fenced-code fallback
|
|
81
|
+
* that disposes a previous component; return undefined for a fallback that
|
|
82
|
+
* preserves the previous component and should be retried on the next render.
|
|
83
|
+
*/
|
|
84
|
+
resolveSvgFigure?: (source: string, context: SvgFigureResolveContext) => Component | null | undefined;
|
|
77
85
|
symbols: SymbolTheme;
|
|
78
86
|
}
|
|
87
|
+
export interface SvgFigureResolveContext {
|
|
88
|
+
/** Whether the containing message is still streaming. */
|
|
89
|
+
streaming: boolean;
|
|
90
|
+
/** The individual code fence has received its closing marker. */
|
|
91
|
+
closed: boolean;
|
|
92
|
+
/** Request a repaint after the figure's asynchronous output changes. */
|
|
93
|
+
onChange: () => void;
|
|
94
|
+
/** Available render width in terminal cells. */
|
|
95
|
+
width: number;
|
|
96
|
+
/** The component previously resolved for this fence position, if any. */
|
|
97
|
+
previous?: Component;
|
|
98
|
+
}
|
|
79
99
|
export declare class Markdown implements Component {
|
|
80
100
|
#private;
|
|
81
101
|
constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, codeBlockIndent?: number);
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.18.
|
|
4
|
+
"version": "0.18.12",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.18.
|
|
40
|
-
"@gajae-code/utils": "0.18.
|
|
39
|
+
"@gajae-code/natives": "0.18.12",
|
|
40
|
+
"@gajae-code/utils": "0.18.12",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ```svg fence drawn as an inline terminal image. The source is rasterized
|
|
3
|
+
* off the JS thread and shown through {@link Image}; while the fence streams,
|
|
4
|
+
* each throttled revision repairs the partial source and replaces the
|
|
5
|
+
* previous raster, which stays on screen until the next one lands.
|
|
6
|
+
*
|
|
7
|
+
* Sizing is natural rather than preview-capped: the figure takes as many columns as its
|
|
8
|
+
* drawing needs — up to the full width, and a viewport-relative height. The
|
|
9
|
+
* raster is drawn at the terminal's device pixels per unit, padded to whole
|
|
10
|
+
* cells, and redrawn when the room it is shown in changes, so the terminal
|
|
11
|
+
* places it 1:1: any resampling, even 0.99×, blurs every glyph edge.
|
|
12
|
+
*
|
|
13
|
+
* Source: oh-my-pi (https://github.com/can1357/oh-my-pi)
|
|
14
|
+
*/
|
|
15
|
+
import type { rasterizeSvg as rasterizeSvgFn } from "@gajae-code/natives";
|
|
16
|
+
import { logger } from "@gajae-code/utils";
|
|
17
|
+
import { Image, type ImageTheme } from "../components/image";
|
|
18
|
+
import { type CellDimensions, getCellDimensions, getImageDimensions, TERMINAL } from "../terminal-capabilities";
|
|
19
|
+
import type { Component } from "../tui";
|
|
20
|
+
import { closePartialSvg, prepareSvg } from "./svg-source";
|
|
21
|
+
|
|
22
|
+
type NativeRasterizeSvg = typeof rasterizeSvgFn;
|
|
23
|
+
let nativeRasterizeSvg: NativeRasterizeSvg | undefined;
|
|
24
|
+
|
|
25
|
+
function rasterizeSvg(
|
|
26
|
+
input: Uint8Array,
|
|
27
|
+
maxWidthPx: number,
|
|
28
|
+
maxHeightPx: number,
|
|
29
|
+
cellWidthPx: number,
|
|
30
|
+
cellHeightPx: number,
|
|
31
|
+
): Promise<Uint8Array> {
|
|
32
|
+
nativeRasterizeSvg ??= (require("@gajae-code/natives") as { rasterizeSvg: NativeRasterizeSvg }).rasterizeSvg;
|
|
33
|
+
return nativeRasterizeSvg(input, maxWidthPx, maxHeightPx, cellWidthPx, cellHeightPx);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Least time between rasters of a fence that is still streaming. */
|
|
37
|
+
const STREAM_INTERVAL_MS = 200;
|
|
38
|
+
/** Longest raster edge; the native rasterizer caps the surface at 4096² pixels. */
|
|
39
|
+
const MAX_EDGE_PX = 4096;
|
|
40
|
+
/** Share of the terminal's rows one figure may fill, so it fits on screen whole. */
|
|
41
|
+
const MAX_VIEWPORT_SHARE = 0.8;
|
|
42
|
+
|
|
43
|
+
/** Cells a figure may fill. */
|
|
44
|
+
interface Limits {
|
|
45
|
+
columns: number;
|
|
46
|
+
rows: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One rasterized revision and the inputs that produced it. */
|
|
50
|
+
interface Raster {
|
|
51
|
+
source: string;
|
|
52
|
+
/** Cell size the raster was padded to; its pixels are whole cells of it. */
|
|
53
|
+
cell: CellDimensions;
|
|
54
|
+
limits: Limits;
|
|
55
|
+
/** Base64 PNG. */
|
|
56
|
+
data: string;
|
|
57
|
+
widthPx: number;
|
|
58
|
+
heightPx: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Inputs of a raster attempt, successful or not. */
|
|
62
|
+
interface Attempt {
|
|
63
|
+
source: string;
|
|
64
|
+
palette: Readonly<Record<string, string>>;
|
|
65
|
+
cell: CellDimensions;
|
|
66
|
+
limits: Limits;
|
|
67
|
+
final: boolean;
|
|
68
|
+
closed: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Layout {
|
|
72
|
+
width: number;
|
|
73
|
+
cell: CellDimensions;
|
|
74
|
+
limits: Limits;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Cells a figure rendered at `width` may fill: the image's width, a share of the viewport, and the raster edge cap. */
|
|
78
|
+
function limitsFor(width: number, cell: CellDimensions): Limits {
|
|
79
|
+
const viewportRows = Math.floor((process.stdout.rows || 24) * MAX_VIEWPORT_SHARE);
|
|
80
|
+
return {
|
|
81
|
+
columns: Math.max(1, Math.min(Math.max(1, width - 2), Math.floor(MAX_EDGE_PX / cell.widthPx))),
|
|
82
|
+
rows: Math.max(1, Math.min(viewportRows, Math.floor(MAX_EDGE_PX / cell.heightPx))),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface SvgFigureOptions {
|
|
87
|
+
/**
|
|
88
|
+
* Theme for rendering the image fallback text.
|
|
89
|
+
*/
|
|
90
|
+
theme: ImageTheme;
|
|
91
|
+
/** CSS colors substituted for SVG theme variables and currentColor. */
|
|
92
|
+
palette: Readonly<Record<string, string>>;
|
|
93
|
+
/** The figure's rows changed outside a text update: a raster landed or rendering fell back to code. */
|
|
94
|
+
onChange: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export class SvgFigure implements Component {
|
|
98
|
+
readonly #options: SvgFigureOptions;
|
|
99
|
+
#palette: Readonly<Record<string, string>>;
|
|
100
|
+
#source = "";
|
|
101
|
+
/** No more text arrives for this fence: no throttling, and a failed raster falls back to code. */
|
|
102
|
+
#final = false;
|
|
103
|
+
/** Whether the fence had its closing marker; only open fences are repaired. */
|
|
104
|
+
#closed = false;
|
|
105
|
+
#raster: Raster | undefined;
|
|
106
|
+
#attempt: Attempt | undefined;
|
|
107
|
+
/** Final source that could not be drawn; rendered as plaintext. */
|
|
108
|
+
#failed: string | undefined;
|
|
109
|
+
/** Whether retrying the failed source at different pixel limits could succeed. */
|
|
110
|
+
#failedLayoutDependent = false;
|
|
111
|
+
#rasterizing = false;
|
|
112
|
+
#startedAt = Number.NEGATIVE_INFINITY;
|
|
113
|
+
#timer: NodeJS.Timeout | undefined;
|
|
114
|
+
/** A {@link #schedule} is queued for the end of the current task. */
|
|
115
|
+
#queued = false;
|
|
116
|
+
#disposed = false;
|
|
117
|
+
/** Last render width; rasters wait for the first render to know their room. */
|
|
118
|
+
#width: number | undefined;
|
|
119
|
+
#layout: Layout | undefined;
|
|
120
|
+
#image: { raster: Raster; cell: CellDimensions; columns: number; rows: number; component: Image } | undefined;
|
|
121
|
+
#graphicsUnavailableNotified = false;
|
|
122
|
+
|
|
123
|
+
constructor(options: SvgFigureOptions) {
|
|
124
|
+
this.#options = options;
|
|
125
|
+
this.#palette = options.palette;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Feed the fence body; `final` stops streaming, while `closed` records its closing marker. */
|
|
129
|
+
update(source: string, final: boolean, closed: boolean, palette = this.#palette): void {
|
|
130
|
+
const sourceChanged = source !== this.#source;
|
|
131
|
+
const paletteChanged = palette !== this.#palette;
|
|
132
|
+
const closedChanged = closed !== this.#closed;
|
|
133
|
+
if (!sourceChanged && !paletteChanged && final === this.#final && !closedChanged) return;
|
|
134
|
+
if (sourceChanged || paletteChanged || closedChanged) {
|
|
135
|
+
this.#failed = undefined;
|
|
136
|
+
this.#failedLayoutDependent = false;
|
|
137
|
+
}
|
|
138
|
+
this.#source = source;
|
|
139
|
+
this.#final = final;
|
|
140
|
+
this.#closed = closed;
|
|
141
|
+
this.#palette = palette;
|
|
142
|
+
// Deferred: a block built mid-stream is marked streaming right after it
|
|
143
|
+
// is mounted, when render() was called already (e.g. by diffing); a
|
|
144
|
+
// schedule() queued before then is dropped, so the next render() starts
|
|
145
|
+
// a new one.
|
|
146
|
+
if (!this.#queued) {
|
|
147
|
+
this.#queued = true;
|
|
148
|
+
queueMicrotask(() => {
|
|
149
|
+
this.#queued = false;
|
|
150
|
+
this.#schedule();
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
render(width: number): string[] {
|
|
156
|
+
if (!TERMINAL.imageProtocol) {
|
|
157
|
+
clearTimeout(this.#timer);
|
|
158
|
+
this.#timer = undefined;
|
|
159
|
+
if (!this.#graphicsUnavailableNotified) {
|
|
160
|
+
this.#graphicsUnavailableNotified = true;
|
|
161
|
+
this.#options.onChange();
|
|
162
|
+
}
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
// Source updates may arrive while graphics are unavailable, so a restored
|
|
166
|
+
// protocol must retry even when the visible layout did not change.
|
|
167
|
+
const graphicsRestored = this.#graphicsUnavailableNotified;
|
|
168
|
+
this.#graphicsUnavailableNotified = false;
|
|
169
|
+
const cell = getCellDimensions();
|
|
170
|
+
const limits = limitsFor(width, cell);
|
|
171
|
+
const previousLayout = this.#layout;
|
|
172
|
+
const cellChanged =
|
|
173
|
+
previousLayout?.cell.widthPx !== cell.widthPx || previousLayout.cell.heightPx !== cell.heightPx;
|
|
174
|
+
const layoutChanged =
|
|
175
|
+
previousLayout === undefined ||
|
|
176
|
+
previousLayout.width !== width ||
|
|
177
|
+
cellChanged ||
|
|
178
|
+
previousLayout.limits.columns !== limits.columns ||
|
|
179
|
+
previousLayout.limits.rows !== limits.rows;
|
|
180
|
+
if (layoutChanged) {
|
|
181
|
+
this.#width = width;
|
|
182
|
+
this.#layout = { width, cell, limits };
|
|
183
|
+
if (cellChanged) this.#image?.component.invalidate();
|
|
184
|
+
this.#schedule();
|
|
185
|
+
}
|
|
186
|
+
if (graphicsRestored && !layoutChanged) this.#schedule();
|
|
187
|
+
if (!this.#raster && !this.#failed) return [];
|
|
188
|
+
if (this.failed) return [];
|
|
189
|
+
if (this.#raster) {
|
|
190
|
+
const image = this.#imageFor(this.#raster, limits, cell);
|
|
191
|
+
return image.render(width);
|
|
192
|
+
}
|
|
193
|
+
return [];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
invalidate(): void {
|
|
197
|
+
this.#image?.component.invalidate();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
dispose(): void {
|
|
201
|
+
if (this.#disposed) return;
|
|
202
|
+
this.#disposed = true;
|
|
203
|
+
clearTimeout(this.#timer);
|
|
204
|
+
this.#timer = undefined;
|
|
205
|
+
this.#source = "";
|
|
206
|
+
this.#attempt = undefined;
|
|
207
|
+
this.#raster = undefined;
|
|
208
|
+
this.#failed = undefined;
|
|
209
|
+
this.#failedLayoutDependent = false;
|
|
210
|
+
this.#layout = undefined;
|
|
211
|
+
this.#width = undefined;
|
|
212
|
+
this.#image = undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
debugState(): Record<string, unknown> {
|
|
216
|
+
return {
|
|
217
|
+
final: this.#final,
|
|
218
|
+
failed: this.#failed === this.#source,
|
|
219
|
+
raster: this.#raster ? `${this.#raster.widthPx}x${this.#raster.heightPx}` : null,
|
|
220
|
+
current: this.#raster?.source === this.#source,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Whether the current source and cell size have not been attempted yet. */
|
|
225
|
+
#stale(width: number): boolean {
|
|
226
|
+
const attempt = this.#attempt;
|
|
227
|
+
const cell = getCellDimensions();
|
|
228
|
+
const limits = limitsFor(width, cell);
|
|
229
|
+
if (this.#failed === this.#source) {
|
|
230
|
+
if (!this.#failedLayoutDependent || !attempt) return false;
|
|
231
|
+
return (
|
|
232
|
+
attempt.cell.widthPx !== cell.widthPx ||
|
|
233
|
+
attempt.cell.heightPx !== cell.heightPx ||
|
|
234
|
+
attempt.limits.columns !== limits.columns ||
|
|
235
|
+
attempt.limits.rows !== limits.rows
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return (
|
|
239
|
+
attempt === undefined ||
|
|
240
|
+
attempt.source !== this.#source ||
|
|
241
|
+
attempt.palette !== this.#palette ||
|
|
242
|
+
attempt.closed !== this.#closed ||
|
|
243
|
+
attempt.cell.widthPx !== cell.widthPx ||
|
|
244
|
+
attempt.cell.heightPx !== cell.heightPx ||
|
|
245
|
+
// A streaming attempt that failed is retried once as final.
|
|
246
|
+
(this.#final && !attempt.final && this.#raster?.source !== this.#source) ||
|
|
247
|
+
((attempt.limits.columns !== limits.columns || attempt.limits.rows !== limits.rows) && this.#outgrown(limits))
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Whether the current raster would come out differently in `limits`. One
|
|
253
|
+
* below both of its limits drew at its natural size and redraws only once
|
|
254
|
+
* it no longer fits; one that filled a limit redraws whenever they change.
|
|
255
|
+
*/
|
|
256
|
+
#outgrown(limits: Limits): boolean {
|
|
257
|
+
const raster = this.#raster;
|
|
258
|
+
if (raster?.source !== this.#source) return false;
|
|
259
|
+
const columns = raster.widthPx / raster.cell.widthPx;
|
|
260
|
+
const rows = raster.heightPx / raster.cell.heightPx;
|
|
261
|
+
if (columns < raster.limits.columns && rows < raster.limits.rows) {
|
|
262
|
+
return columns > limits.columns || rows > limits.rows;
|
|
263
|
+
}
|
|
264
|
+
return raster.limits.columns !== limits.columns || raster.limits.rows !== limits.rows;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Start a raster when one is due: single-flight, throttled while streaming, immediate once final. */
|
|
268
|
+
#schedule(): void {
|
|
269
|
+
const width = this.#width;
|
|
270
|
+
if (this.#disposed || !TERMINAL.imageProtocol || this.#rasterizing || width === undefined) return;
|
|
271
|
+
if (this.#final && this.#timer) {
|
|
272
|
+
clearTimeout(this.#timer);
|
|
273
|
+
this.#timer = undefined;
|
|
274
|
+
}
|
|
275
|
+
if (this.#timer || !this.#stale(width)) return;
|
|
276
|
+
const wait = this.#final ? 0 : this.#startedAt + STREAM_INTERVAL_MS - performance.now();
|
|
277
|
+
if (wait > 0) {
|
|
278
|
+
this.#timer = setTimeout(() => {
|
|
279
|
+
this.#timer = undefined;
|
|
280
|
+
this.#schedule();
|
|
281
|
+
}, wait);
|
|
282
|
+
this.#timer.unref?.();
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
void this.#rasterize(width);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async #rasterize(width: number): Promise<void> {
|
|
289
|
+
const cell = getCellDimensions();
|
|
290
|
+
const attempt: Attempt = {
|
|
291
|
+
source: this.#source,
|
|
292
|
+
palette: this.#palette,
|
|
293
|
+
cell,
|
|
294
|
+
limits: limitsFor(width, cell),
|
|
295
|
+
final: this.#final,
|
|
296
|
+
closed: this.#closed,
|
|
297
|
+
};
|
|
298
|
+
this.#attempt = attempt;
|
|
299
|
+
const svg = attempt.closed ? attempt.source : closePartialSvg(attempt.source);
|
|
300
|
+
let png: Uint8Array | undefined;
|
|
301
|
+
let layoutDependentFailure = false;
|
|
302
|
+
if (svg !== null) {
|
|
303
|
+
this.#rasterizing = true;
|
|
304
|
+
this.#startedAt = performance.now();
|
|
305
|
+
try {
|
|
306
|
+
const prepared = prepareSvg(svg, attempt.palette);
|
|
307
|
+
try {
|
|
308
|
+
png = await rasterizeSvg(
|
|
309
|
+
new TextEncoder().encode(prepared),
|
|
310
|
+
attempt.limits.columns * cell.widthPx,
|
|
311
|
+
attempt.limits.rows * cell.heightPx,
|
|
312
|
+
cell.widthPx,
|
|
313
|
+
cell.heightPx,
|
|
314
|
+
);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
317
|
+
layoutDependentFailure = !message.includes("Failed to parse SVG:");
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
} catch (error) {
|
|
321
|
+
// A streaming prefix that does not parse yet keeps the previous raster.
|
|
322
|
+
if (attempt.final) logger.debug("SVG figure did not render", { error: String(error) });
|
|
323
|
+
} finally {
|
|
324
|
+
this.#rasterizing = false;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (this.#disposed) return;
|
|
328
|
+
const data = png ? Buffer.from(png).toString("base64") : undefined;
|
|
329
|
+
const size = data ? getImageDimensions(data, "image/png") : null;
|
|
330
|
+
if (data && size) {
|
|
331
|
+
this.#show(attempt, data, size.widthPx, size.heightPx);
|
|
332
|
+
} else if (
|
|
333
|
+
attempt.final &&
|
|
334
|
+
attempt.source === this.#source &&
|
|
335
|
+
attempt.palette === this.#palette &&
|
|
336
|
+
attempt.closed === this.#closed
|
|
337
|
+
) {
|
|
338
|
+
this.#failed = attempt.source;
|
|
339
|
+
this.#failedLayoutDependent = layoutDependentFailure;
|
|
340
|
+
// Clear the raster and image on terminal failure to keep preview only while a replacement is in flight.
|
|
341
|
+
this.#raster = undefined;
|
|
342
|
+
this.#image = undefined;
|
|
343
|
+
this.#options.onChange();
|
|
344
|
+
}
|
|
345
|
+
// The source, cell size, or room moved on while this raster ran.
|
|
346
|
+
this.#schedule();
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
#show(attempt: Attempt, data: string, widthPx: number, heightPx: number): void {
|
|
350
|
+
if (this.#failed === attempt.source) {
|
|
351
|
+
this.#failed = undefined;
|
|
352
|
+
this.#failedLayoutDependent = false;
|
|
353
|
+
}
|
|
354
|
+
this.#raster = {
|
|
355
|
+
source: attempt.source,
|
|
356
|
+
cell: attempt.cell,
|
|
357
|
+
limits: attempt.limits,
|
|
358
|
+
data,
|
|
359
|
+
widthPx,
|
|
360
|
+
heightPx,
|
|
361
|
+
};
|
|
362
|
+
this.#options.onChange();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* The image for `raster` over the whole cells it was padded to. At the
|
|
367
|
+
* cell size and room it was drawn for that box is its pixels exactly; a
|
|
368
|
+
* raster outdated by a resize is scaled by {@link Image} until its redraw lands.
|
|
369
|
+
*/
|
|
370
|
+
#imageFor(raster: Raster, limits: Limits, cell: CellDimensions): Image {
|
|
371
|
+
const current = this.#image;
|
|
372
|
+
const columns = Math.min(limits.columns, Math.ceil(raster.widthPx / cell.widthPx));
|
|
373
|
+
const rows = Math.min(limits.rows, Math.ceil(raster.heightPx / cell.heightPx));
|
|
374
|
+
if (
|
|
375
|
+
current?.raster === raster &&
|
|
376
|
+
current.cell.widthPx === cell.widthPx &&
|
|
377
|
+
current.cell.heightPx === cell.heightPx &&
|
|
378
|
+
current.columns === columns &&
|
|
379
|
+
current.rows === rows
|
|
380
|
+
) {
|
|
381
|
+
return current.component;
|
|
382
|
+
}
|
|
383
|
+
const component = new Image(raster.data, "image/png", this.#options.theme, {
|
|
384
|
+
maxWidthCells: columns,
|
|
385
|
+
maxHeightCells: rows,
|
|
386
|
+
filename: "svg",
|
|
387
|
+
refetch: () => raster.data,
|
|
388
|
+
});
|
|
389
|
+
this.#image = { raster, cell, columns, rows, component };
|
|
390
|
+
return component;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
get pending(): boolean {
|
|
394
|
+
return this.#rasterizing || (this.#final === false && this.#raster === undefined && this.#failed === undefined);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
get failed(): boolean {
|
|
398
|
+
return this.#failed === this.#source;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Whether this failure may recover when terminal raster limits change. */
|
|
402
|
+
get retryable(): boolean {
|
|
403
|
+
return this.failed && this.#failedLayoutDependent;
|
|
404
|
+
}
|
|
405
|
+
}
|