@textui/terminal 0.1.0

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/src/node.ts ADDED
@@ -0,0 +1,329 @@
1
+ import type {
2
+ AcquiredState, CapabilityOverrides, Disposable, InputEvent, Size,
3
+ TerminalAdapter, TerminalCapabilities, TerminalSessionOptions,
4
+ } from '@textui/core';
5
+ import { toDisposable } from '@textui/core';
6
+ import * as ansi from './ansi.js';
7
+ import { applyOverrides, detectCapabilities, describeEnvironment } from './capabilities.js';
8
+ import { createDecoder, type InputDecoder } from './input.js';
9
+
10
+ /**
11
+ * The Node TTY adapter.
12
+ *
13
+ * Ownership is the whole point of this file. `acquire` records exactly what it
14
+ * turned on and `release` undoes exactly that - so an application that owns
15
+ * the terminal gets a clean alternate screen, and one embedded in a host that
16
+ * already set raw mode does not tear the host's state down on the way out.
17
+ *
18
+ * A terminal left in raw mode with the cursor hidden is a broken shell, so
19
+ * release also runs on exit and on a fatal signal, not only on a tidy stop.
20
+ */
21
+
22
+ /**
23
+ * What this adapter needs from a stream, rather than which stream it is.
24
+ *
25
+ * `process.stdin` and `process.stdout` satisfy these, so nothing changes for
26
+ * a caller - but the published types stop naming Node's stream interfaces,
27
+ * which come from a package this one does not depend on. It compiled here
28
+ * only because those types happened to be installed at the root; a consumer
29
+ * without them got `Cannot find namespace` out of a package that advertises
30
+ * no dependencies.
31
+ *
32
+ * Saying it structurally also makes the requirement legible - "something with
33
+ * `setRawMode` and `columns`" is a contract, where "Node's stream" is a shrug
34
+ * - and makes it true of the runtimes whose streams are not Node's. Bun and
35
+ * Deno satisfy these by shape rather than by luck.
36
+ */
37
+ export interface TerminalInput {
38
+ isTTY?: boolean;
39
+ /** True while the terminal is delivering keys rather than lines. */
40
+ isRaw?: boolean;
41
+ setRawMode?(raw: boolean): void;
42
+ setEncoding(encoding: 'utf8'): void;
43
+ on(event: 'data', listener: (chunk: string) => void): void;
44
+ off(event: 'data', listener: (chunk: string) => void): void;
45
+ pause(): void;
46
+ resume(): void;
47
+ }
48
+
49
+ export interface TerminalOutput {
50
+ isTTY?: boolean;
51
+ columns?: number;
52
+ rows?: number;
53
+ write(data: string): void;
54
+ on(event: 'resize', listener: () => void): void;
55
+ off(event: 'resize', listener: () => void): void;
56
+ }
57
+
58
+ /** The three this adapter installs handlers for. */
59
+ export type TerminalSignal = 'SIGINT' | 'SIGTERM' | 'SIGHUP';
60
+
61
+ export interface NodeAdapterOptions {
62
+ stdin?: TerminalInput;
63
+ stdout?: TerminalOutput;
64
+ env?: Record<string, string | undefined>;
65
+ capabilities?: CapabilityOverrides;
66
+ /** Install exit and signal handlers that release the terminal. */
67
+ installExitHandlers?: boolean;
68
+ escapeTimeoutMs?: number;
69
+ }
70
+
71
+ const NOTHING_ACQUIRED: AcquiredState = {
72
+ altScreen: false, mouse: false, wheel: false, focusEvents: false,
73
+ paste: false, cursorHidden: false, enhancedKeys: false, rawMode: false,
74
+ titleSet: false,
75
+ };
76
+
77
+ export class NodeTerminalAdapter implements TerminalAdapter {
78
+ readonly id = 'node';
79
+
80
+ private stdin: TerminalInput;
81
+ private stdout: TerminalOutput;
82
+ private env: Record<string, string | undefined>;
83
+ private overrides: CapabilityOverrides;
84
+
85
+ private caps: TerminalCapabilities;
86
+ private state: AcquiredState | null = null;
87
+
88
+ private decoder: InputDecoder | null = null;
89
+ private inputListeners = new Set<(event: InputEvent) => void>();
90
+ private resizeListeners = new Set<(size: Size) => void>();
91
+
92
+ private pending: string[] = [];
93
+ private onStdinData = (chunk: Buffer | string): void => {
94
+ this.decoder?.feed(typeof chunk === 'string' ? chunk : chunk.toString('utf8'));
95
+ };
96
+ private handleResize = (): void => {
97
+ const size = this.size();
98
+ for (const fn of [...this.resizeListeners]) fn(size);
99
+ };
100
+ private exitHandler: (() => void) | null = null;
101
+ private signalHandler: ((signal: TerminalSignal) => void) | null = null;
102
+
103
+ constructor(private options: NodeAdapterOptions = {}) {
104
+ this.stdin = options.stdin ?? process.stdin;
105
+ this.stdout = options.stdout ?? process.stdout;
106
+ this.env = options.env ?? process.env;
107
+ this.overrides = options.capabilities ?? {};
108
+ this.caps = this.detect();
109
+ }
110
+
111
+ private detect(): TerminalCapabilities {
112
+ return applyOverrides(
113
+ detectCapabilities({
114
+ env: this.env,
115
+ isTTY: Boolean(this.stdout.isTTY),
116
+ columns: this.stdout.columns,
117
+ rows: this.stdout.rows,
118
+ platform: process.platform,
119
+ }),
120
+ this.overrides,
121
+ );
122
+ }
123
+
124
+ get acquired(): AcquiredState | null {
125
+ return this.state;
126
+ }
127
+
128
+ size(): Size {
129
+ return {
130
+ width: this.stdout.columns ?? 80,
131
+ height: this.stdout.rows ?? 24,
132
+ };
133
+ }
134
+
135
+ capabilities(): TerminalCapabilities {
136
+ return this.caps;
137
+ }
138
+
139
+ setCapabilityOverrides(overrides: CapabilityOverrides): void {
140
+ this.overrides = { ...this.overrides, ...overrides };
141
+ this.caps = this.detect();
142
+ }
143
+
144
+ environment(): Record<string, string> {
145
+ return describeEnvironment({
146
+ env: this.env,
147
+ isTTY: Boolean(this.stdout.isTTY),
148
+ columns: this.stdout.columns,
149
+ rows: this.stdout.rows,
150
+ });
151
+ }
152
+
153
+ acquire(options: TerminalSessionOptions): AcquiredState {
154
+ if (this.state) return this.state;
155
+
156
+ const caps = this.caps;
157
+ const managed = options.managed ?? true;
158
+ const state: AcquiredState = { ...NOTHING_ACQUIRED };
159
+ const out: string[] = [];
160
+
161
+ if (this.stdin.isTTY && typeof this.stdin.setRawMode === 'function' && !this.stdin.isRaw) {
162
+ this.stdin.setRawMode(true);
163
+ state.rawMode = true;
164
+ }
165
+
166
+ if (managed && (options.altScreen ?? true) && caps.altScreen) {
167
+ out.push(ansi.altScreenEnter, ansi.eraseScreen, ansi.cursorHome);
168
+ state.altScreen = true;
169
+ }
170
+
171
+ if ((options.hideCursor ?? true) && caps.cursor) {
172
+ out.push(ansi.cursorHide);
173
+ state.cursorHidden = true;
174
+ }
175
+
176
+ if ((options.mouse ?? false) && caps.mouse) {
177
+ out.push((options.wheel ?? true) ? ansi.mouseOn : ansi.mouseButtonsOn);
178
+ state.mouse = true;
179
+ state.wheel = options.wheel ?? true;
180
+ }
181
+
182
+ if ((options.focusEvents ?? false) && caps.focusEvents) {
183
+ out.push(ansi.focusEventsOn);
184
+ state.focusEvents = true;
185
+ }
186
+
187
+ if ((options.paste ?? true) && caps.paste) {
188
+ out.push(ansi.bracketedPasteOn);
189
+ state.paste = true;
190
+ }
191
+
192
+ if ((options.enhancedKeys ?? false) && caps.kittyKeyboard) {
193
+ out.push(ansi.kittyKeyboardPush);
194
+ state.enhancedKeys = true;
195
+ }
196
+
197
+ if (options.title && caps.title) {
198
+ out.push(ansi.setTitle(options.title));
199
+ state.titleSet = true;
200
+ }
201
+
202
+ this.stdout.write(out.join(''));
203
+
204
+ this.decoder = createDecoder(
205
+ (event) => {
206
+ for (const fn of [...this.inputListeners]) fn(event);
207
+ },
208
+ { escapeTimeoutMs: this.options.escapeTimeoutMs },
209
+ );
210
+
211
+ this.stdin.setEncoding('utf8');
212
+ this.stdin.on('data', this.onStdinData);
213
+ this.stdin.resume();
214
+ this.stdout.on('resize', this.handleResize);
215
+
216
+ if (this.options.installExitHandlers ?? true) this.installExitHandlers();
217
+
218
+ this.state = state;
219
+ return state;
220
+ }
221
+
222
+ /**
223
+ * Undo exactly what was acquired, in reverse. Anything this adapter did not
224
+ * turn on is left alone - that is what makes an embedded session safe.
225
+ */
226
+ release(): void {
227
+ const state = this.state;
228
+ if (!state) return;
229
+ this.state = null;
230
+
231
+ const out: string[] = [];
232
+ if (state.enhancedKeys) out.push(ansi.kittyKeyboardPop);
233
+ if (state.paste) out.push(ansi.bracketedPasteOff);
234
+ if (state.focusEvents) out.push(ansi.focusEventsOff);
235
+ if (state.mouse) out.push(state.wheel ? ansi.mouseOff : ansi.mouseButtonsOff);
236
+ if (state.altScreen) out.push(ansi.altScreenLeave);
237
+ if (state.cursorHidden) out.push(ansi.cursorShow);
238
+ out.push(ansi.reset);
239
+
240
+ try {
241
+ this.stdout.write(out.join(''));
242
+ } catch {
243
+ // The stream may already be closed during an abrupt exit.
244
+ }
245
+
246
+ this.stdin.off('data', this.onStdinData);
247
+ this.stdout.off('resize', this.handleResize);
248
+ this.decoder?.reset();
249
+ this.decoder = null;
250
+
251
+ if (state.rawMode && this.stdin.isTTY && typeof this.stdin.setRawMode === 'function') {
252
+ try {
253
+ this.stdin.setRawMode(false);
254
+ } catch {
255
+ /* best effort */
256
+ }
257
+ }
258
+ this.stdin.pause();
259
+ this.removeExitHandlers();
260
+ }
261
+
262
+ write(data: string): void {
263
+ if (data === '') return;
264
+ this.pending.push(data);
265
+ }
266
+
267
+ flush(): void {
268
+ if (this.pending.length === 0) return;
269
+ const data = this.pending.join('');
270
+ this.pending = [];
271
+ this.stdout.write(data);
272
+ }
273
+
274
+ onInput(fn: (event: InputEvent) => void): Disposable {
275
+ this.inputListeners.add(fn);
276
+ return toDisposable(() => this.inputListeners.delete(fn));
277
+ }
278
+
279
+ onResize(fn: (size: Size) => void): Disposable {
280
+ this.resizeListeners.add(fn);
281
+ return toDisposable(() => this.resizeListeners.delete(fn));
282
+ }
283
+
284
+ writeClipboard(text: string): void {
285
+ if (!this.caps.clipboard) return;
286
+ this.stdout.write(ansi.clipboardWrite(text));
287
+ }
288
+
289
+ setTitle(title: string): void {
290
+ if (!this.caps.title) return;
291
+ this.stdout.write(ansi.setTitle(title));
292
+ }
293
+
294
+ private installExitHandlers(): void {
295
+ this.exitHandler = () => this.release();
296
+ process.on('exit', this.exitHandler);
297
+
298
+ this.signalHandler = (signal: TerminalSignal) => {
299
+ this.release();
300
+ process.exit(signal === 'SIGINT' ? 130 : 143);
301
+ };
302
+ process.on('SIGINT', this.signalHandler);
303
+ process.on('SIGTERM', this.signalHandler);
304
+ process.on('SIGHUP', this.signalHandler);
305
+ }
306
+
307
+ private removeExitHandlers(): void {
308
+ if (this.exitHandler) {
309
+ process.off('exit', this.exitHandler);
310
+ this.exitHandler = null;
311
+ }
312
+ if (this.signalHandler) {
313
+ process.off('SIGINT', this.signalHandler);
314
+ process.off('SIGTERM', this.signalHandler);
315
+ process.off('SIGHUP', this.signalHandler);
316
+ this.signalHandler = null;
317
+ }
318
+ }
319
+
320
+ dispose(): void {
321
+ this.release();
322
+ this.inputListeners.clear();
323
+ this.resizeListeners.clear();
324
+ }
325
+ }
326
+
327
+ export function createNodeTerminal(options?: NodeAdapterOptions): NodeTerminalAdapter {
328
+ return new NodeTerminalAdapter(options);
329
+ }
package/src/svg.ts ADDED
@@ -0,0 +1,352 @@
1
+ import type { CellBuffer, Color, ColorDepth } from '@textui/core';
2
+ import {
3
+ ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE, ATTR_ITALIC, ATTR_STRIKE,
4
+ ATTR_UNDERLINE, COLOR_DEFAULT, downsample, mix, packColor, toHex,
5
+ } from '@textui/core';
6
+
7
+ /**
8
+ * A frame, as a picture.
9
+ *
10
+ * `captureBuffer` writes a frame you can `cat`. This writes one you can put in
11
+ * a README, and the difference matters more than it sounds: an `.ans` file is
12
+ * only a screenshot on a terminal, so the place a terminal application most
13
+ * needs to show what it looks like - a repository page, a docs site, a pull
14
+ * request - is the one place it cannot.
15
+ *
16
+ * The output is one self-contained SVG with no external anything: no font file,
17
+ * no stylesheet, no script. That is what lets it survive GitHub, which serves
18
+ * markdown images from a sanitising proxy that fetches nothing on the page's
19
+ * behalf.
20
+ *
21
+ * It is also **text**, which is the part worth having. A committed SVG diffs:
22
+ * a change that moves a column or recolours a token shows up as a changed line
23
+ * in review, so a screenshot in the docs can be checked by CI rather than
24
+ * re-taken by hand and trusted.
25
+ */
26
+ export interface SvgOptions {
27
+ /**
28
+ * Cell size in pixels. The defaults are close to a 13px monospace face at a
29
+ * normal line height, which is what makes the picture look like a terminal
30
+ * rather than like text that happens to be in a grid.
31
+ */
32
+ cellWidth?: number;
33
+ cellHeight?: number;
34
+ fontSize?: number;
35
+ /**
36
+ * The font stack. No web font by default and none recommended: a font that
37
+ * has to be fetched is a font that is missing exactly where this file is
38
+ * most useful, and a missing monospace font takes the column alignment with
39
+ * it.
40
+ */
41
+ fontFamily?: string;
42
+ /**
43
+ * Where the baseline sits inside the cell, as a fraction of its height.
44
+ *
45
+ * A number rather than `dominant-baseline`, which renderers disagree about
46
+ * enough that the text lands a pixel or two off between two viewers of the
47
+ * same file.
48
+ */
49
+ baseline?: number;
50
+ /** Space around the grid, in pixels. */
51
+ padding?: number;
52
+ /** Corner radius on the backdrop. Zero is a square edge. */
53
+ radius?: number;
54
+ /**
55
+ * What a cell left at the terminal's own colours becomes.
56
+ *
57
+ * A terminal has no answer for this - "default" means whatever the emulator
58
+ * is configured with - so a picture has to choose, and the honest choice is
59
+ * the caller's. These are what the theme would call `background` and `text`.
60
+ *
61
+ * Passing `default` here is the one thing that cannot work, and a theme can
62
+ * hand it over without meaning to: `mono` is *made* of `default`, so
63
+ * `theme.colors.canvas` and `theme.colors.text` are both it. Either one is
64
+ * replaced with the exporter's own, because a picture with no ink is a
65
+ * rectangle.
66
+ */
67
+ background?: Color;
68
+ foreground?: Color;
69
+ /**
70
+ * Reduce colour first, to show what a shallower terminal would have shown.
71
+ * Left off, nothing is reduced: an SVG has no colour limit of its own, so
72
+ * downsampling by default would be inventing a constraint.
73
+ */
74
+ colorDepth?: ColorDepth;
75
+ /** An accessible name, emitted as `<title>`. */
76
+ title?: string;
77
+ }
78
+
79
+ interface Run {
80
+ /** Column the run starts at, and how many columns it covers. */
81
+ x: number;
82
+ columns: number;
83
+ text: string;
84
+ fg: number;
85
+ attrs: number;
86
+ /** Drawn with the box and block glyphs rather than with letters. */
87
+ graphic: boolean;
88
+ }
89
+
90
+ const DEFAULTS = {
91
+ cellWidth: 8,
92
+ cellHeight: 17,
93
+ fontSize: 13,
94
+ fontFamily:
95
+ "ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'DejaVu Sans Mono', monospace",
96
+ baseline: 0.78,
97
+ padding: 10,
98
+ radius: 6,
99
+ background: '#0d1117',
100
+ foreground: '#c9d1d9',
101
+ } as const;
102
+
103
+ export function bufferToSvg(buffer: CellBuffer, options: SvgOptions = {}): string {
104
+ const cw = options.cellWidth ?? DEFAULTS.cellWidth;
105
+ const ch = options.cellHeight ?? DEFAULTS.cellHeight;
106
+ const fontSize = options.fontSize ?? DEFAULTS.fontSize;
107
+ const fontFamily = options.fontFamily ?? DEFAULTS.fontFamily;
108
+ const baseline = options.baseline ?? DEFAULTS.baseline;
109
+ const pad = options.padding ?? DEFAULTS.padding;
110
+ const radius = options.radius ?? DEFAULTS.radius;
111
+ const depth = options.colorDepth;
112
+
113
+ const width = buffer.width * cw + pad * 2;
114
+ const height = buffer.height * ch + pad * 2;
115
+
116
+ // Packed, so `inverse` and `dim` are worked out in the same space as every
117
+ // other colour rather than as a special case per attribute. `Color` rather
118
+ // than a hex string because that is what a theme holds: `theme.colors.canvas`
119
+ // goes straight in.
120
+ const paperPacked = given(options.background, DEFAULTS.background);
121
+ const inkPacked = given(options.foreground, DEFAULTS.foreground);
122
+ const paper = toHex(paperPacked);
123
+ const reduce = (c: number): number => (depth === undefined ? c : downsample(c, depth));
124
+
125
+ const out: string[] = [];
126
+ out.push(
127
+ `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" `
128
+ + `viewBox="0 0 ${width} ${height}" font-family="${escape(fontFamily)}" `
129
+ + `font-size="${fontSize}px">`,
130
+ );
131
+ if (options.title !== undefined) out.push(`<title>${escape(options.title)}</title>`);
132
+ out.push(
133
+ `<rect width="${width}" height="${height}"`
134
+ + `${radius > 0 ? ` rx="${radius}"` : ''} fill="${paper}"/>`,
135
+ );
136
+
137
+ for (let y = 0; y < buffer.height; y++) {
138
+ // Backgrounds first and for the whole row, because a glyph drawn over a
139
+ // rect emitted later would be painted out: SVG has no z-index, only
140
+ // document order.
141
+ for (const run of backgroundRuns(buffer, y, paperPacked, inkPacked, reduce)) {
142
+ out.push(
143
+ `<rect x="${pad + run.x * cw}" y="${pad + y * ch}" `
144
+ + `width="${run.columns * cw}" height="${ch}" fill="${toHex(run.fg)}"/>`,
145
+ );
146
+ }
147
+ }
148
+
149
+ const textY = (y: number): number => Math.round(pad + y * ch + ch * baseline);
150
+
151
+ for (let y = 0; y < buffer.height; y++) {
152
+ for (const run of glyphRuns(buffer, y, paperPacked, inkPacked, reduce)) {
153
+ const decoration = [
154
+ (run.attrs & ATTR_UNDERLINE) !== 0 ? 'underline' : '',
155
+ (run.attrs & ATTR_STRIKE) !== 0 ? 'line-through' : '',
156
+ ].filter(Boolean).join(' ');
157
+
158
+ out.push(
159
+ `<text x="${pad + run.x * cw}" y="${textY(y)}" fill="${toHex(run.fg)}"`
160
+ + ((run.attrs & ATTR_BOLD) !== 0 ? ' font-weight="bold"' : '')
161
+ + ((run.attrs & ATTR_ITALIC) !== 0 ? ' font-style="italic"' : '')
162
+ + (decoration !== '' ? ` text-decoration="${decoration}"` : '')
163
+ // The run is told how wide it is, in columns, so the grid holds even
164
+ // where the reader's monospace font has a different advance width from
165
+ // the one `cellWidth` was picked for.
166
+ //
167
+ // Which of the two adjustments only matters when the font is already
168
+ // wrong - where the run's natural width is the right one, both are no
169
+ // ops - and then the two kinds of run want opposite things. Letters
170
+ // want `spacing`: looser tracking is readable and squashed letterforms
171
+ // are not. A bar or a border wants `spacingAndGlyphs`: spacing a run of
172
+ // blocks apart is how a solid bar comes out striped and a box's sides
173
+ // stop meeting its corners, and a block stretched to its cell is still
174
+ // a block.
175
+ + ` textLength="${run.columns * cw}"`
176
+ + ` lengthAdjust="${run.graphic ? 'spacingAndGlyphs' : 'spacing'}"`
177
+ // Leading spaces inside a run are part of the picture, and the default
178
+ // is to collapse them.
179
+ + ` xml:space="preserve">${escape(run.text)}</text>`,
180
+ );
181
+ }
182
+ }
183
+
184
+ out.push('</svg>');
185
+ return out.join('\n');
186
+ }
187
+
188
+ /**
189
+ * A colour the picture can actually use.
190
+ *
191
+ * `toHex` has nothing to return for `default` and returns black, so a `default`
192
+ * that reaches the file makes paper and ink the same colour and the whole
193
+ * frame disappears into the backdrop. That is not a colour the caller chose; it
194
+ * is the caller having no colour to give, which is what the fallback is for.
195
+ */
196
+ function given(color: Color | undefined, fallback: Color): number {
197
+ const packed = packColor(color ?? fallback);
198
+ return packed === COLOR_DEFAULT ? packColor(fallback) : packed;
199
+ }
200
+
201
+ /**
202
+ * The colours a cell actually paints with.
203
+ *
204
+ * `inverse` is resolved here rather than at either end: it swaps the pair, and
205
+ * a cell that inverts while leaving one side at the terminal default is
206
+ * swapping *that* default in - which is only knowable once both have been
207
+ * filled in. Doing it later would invert a colour against itself.
208
+ */
209
+ function colorsOf(
210
+ fg: number, bg: number, attrs: number, paper: number, ink: number,
211
+ ): { fg: number; bg: number } {
212
+ let front = fg === COLOR_DEFAULT ? ink : fg;
213
+ let back = bg === COLOR_DEFAULT ? paper : bg;
214
+ if ((attrs & ATTR_INVERSE) !== 0) [front, back] = [back, front];
215
+ // Dim is a reduced intensity, which on a picture is a colour part of the way
216
+ // to the one behind it - there is no "half as bright" for an arbitrary hex.
217
+ if ((attrs & ATTR_DIM) !== 0) front = mix(front, back, 0.5);
218
+ return { fg: front, bg: back };
219
+ }
220
+
221
+ /** Background rects for one row, coalesced, skipping the paper colour. */
222
+ function backgroundRuns(
223
+ buffer: CellBuffer, y: number, paper: number, ink: number,
224
+ reduce: (c: number) => number,
225
+ ): Run[] {
226
+ const runs: Run[] = [];
227
+ let open: Run | null = null;
228
+
229
+ for (let x = 0; x < buffer.width; x++) {
230
+ const cell = buffer.get(x, y);
231
+ // A continuation cell has no colours of its own - it is the right half of
232
+ // the glyph before it, and that cell's run already covers this column.
233
+ if (cell?.continuation === true) {
234
+ if (open) open.columns += 1;
235
+ continue;
236
+ }
237
+
238
+ // Reduced first, resolved after. A shallower terminal shrinks the palette
239
+ // it was *given*; a cell left at the terminal's own colour has nothing to
240
+ // shrink and still comes out as the emulator's. The other order asks
241
+ // `downsample` for the nearest palette entry to paper and ink, and at depth
242
+ // zero it hands back `default` again - which is how a picture ends up as
243
+ // one black rectangle.
244
+ const back = cell
245
+ ? colorsOf(reduce(packColor(cell.fg)), reduce(packColor(cell.bg)), cell.attrs, paper, ink).bg
246
+ : paper;
247
+
248
+ // Nothing to draw where the cell is the same colour as the backdrop, which
249
+ // is most of most screens.
250
+ if (back === paper) { open = null; continue; }
251
+ if (open && open.fg === back && open.x + open.columns === x) { open.columns += 1; continue; }
252
+ open = { x, columns: 1, text: '', fg: back, attrs: 0, graphic: false };
253
+ runs.push(open);
254
+ }
255
+
256
+ return runs;
257
+ }
258
+
259
+ /**
260
+ * Whether a cell is drawn with the drawing characters rather than with letters.
261
+ *
262
+ * The split is where a monospace font stops being certain to have the glyph.
263
+ * Below U+2000 is text - ASCII, Latin, the punctuation any font ships with -
264
+ * and above it is the box drawing, the block elements, the braille the charts
265
+ * are made of, the arrows and the geometric shapes. A font that lacks those
266
+ * substitutes another one for them, and a substitute is under no obligation to
267
+ * use the same advance width, which is the whole reason a run must not span
268
+ * both: `textLength` corrects a run as a unit, so one wrong-width glyph in a
269
+ * run of letters pushes every letter after it off the grid.
270
+ *
271
+ * The `mono` theme is where this shows, because it is where it is worst. Every
272
+ * other theme colours its bars and its borders differently from its text, and
273
+ * a colour change already ends a run - so the split was there by accident.
274
+ * Give a whole screen one colour and each row coalesces into a single run of
275
+ * letters and blocks together, corrected as one.
276
+ */
277
+ function isGraphic(char: string): boolean {
278
+ return (char.codePointAt(0) ?? 0x20) >= 0x2000;
279
+ }
280
+
281
+ /** Text runs for one row, coalesced by colour, attributes, and kind. */
282
+ function glyphRuns(
283
+ buffer: CellBuffer, y: number, paper: number, ink: number,
284
+ reduce: (c: number) => number,
285
+ ): Run[] {
286
+ const runs: Run[] = [];
287
+ let open: Run | null = null;
288
+
289
+ for (let x = 0; x < buffer.width; x++) {
290
+ const cell = buffer.get(x, y);
291
+ if (cell?.continuation === true) {
292
+ if (open) open.columns += 1;
293
+ continue;
294
+ }
295
+ if (!cell) { open = null; continue; }
296
+
297
+ const { fg: front } = colorsOf(
298
+ reduce(packColor(cell.fg)), reduce(packColor(cell.bg)), cell.attrs, paper, ink,
299
+ );
300
+ // `hidden` is a cell whose glyph is not drawn - a masked field. Its
301
+ // background still is, which is why this is here and not in `colorsOf`.
302
+ const char = (cell.attrs & ATTR_HIDDEN) !== 0 ? ' ' : cell.char;
303
+ const graphic = isGraphic(char);
304
+ // Only the attributes that change how a glyph is drawn. `blink` is not one
305
+ // of them: a still frame cannot blink, and an SMIL animation would make
306
+ // the file un-diffable for a flourish nobody asked for.
307
+ const attrs = cell.attrs & (ATTR_BOLD | ATTR_ITALIC | ATTR_UNDERLINE | ATTR_STRIKE);
308
+
309
+ if (open && open.fg === front && open.attrs === attrs && open.graphic === graphic
310
+ && open.x + open.columns === x) {
311
+ open.text += char;
312
+ open.columns += 1;
313
+ continue;
314
+ }
315
+ open = { x, columns: 1, text: char, fg: front, attrs, graphic };
316
+ runs.push(open);
317
+ }
318
+
319
+ // The row's own trailing blanks. They coalesce onto the last run because
320
+ // they share its colour, and then every row in the file carries spaces out
321
+ // to the right edge - bytes for nothing, and a `textLength` claiming columns
322
+ // the text does not occupy.
323
+ //
324
+ // Only the tail, and only where nothing is drawn through it: a blank in the
325
+ // middle of a row is between two things, and an underlined one is a line.
326
+ const last = runs[runs.length - 1];
327
+ if (last && (last.attrs & (ATTR_UNDERLINE | ATTR_STRIKE)) === 0) {
328
+ const trimmed = last.text.replace(/\s+$/, '');
329
+ last.columns -= last.text.length - trimmed.length;
330
+ last.text = trimmed;
331
+ }
332
+
333
+ // A run of blank cells paints nothing - unless it was underlined or struck
334
+ // through, where the line is the whole point.
335
+ return runs.filter((run) => run.text.trim() !== ''
336
+ || (run.attrs & (ATTR_UNDERLINE | ATTR_STRIKE)) !== 0);
337
+ }
338
+
339
+ /**
340
+ * The five XML entities, and no more than that.
341
+ *
342
+ * A frame can hold anything somebody typed, and an unescaped `<` in a filename
343
+ * is the difference between a picture and a file no parser will open.
344
+ */
345
+ function escape(text: string): string {
346
+ return text
347
+ .replace(/&/g, '&amp;')
348
+ .replace(/</g, '&lt;')
349
+ .replace(/>/g, '&gt;')
350
+ .replace(/"/g, '&quot;')
351
+ .replace(/'/g, '&apos;');
352
+ }