@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/LICENSE +21 -0
- package/README.md +83 -0
- package/dist/ansi.d.ts +84 -0
- package/dist/ansi.d.ts.map +1 -0
- package/dist/ansi.js +102 -0
- package/dist/capabilities.d.ts +22 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +130 -0
- package/dist/capture.d.ts +24 -0
- package/dist/capture.d.ts.map +1 -0
- package/dist/capture.js +72 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/input.d.ts +28 -0
- package/dist/input.d.ts.map +1 -0
- package/dist/input.js +357 -0
- package/dist/node.d.ts +99 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +233 -0
- package/dist/svg.d.ts +74 -0
- package/dist/svg.d.ts.map +1 -0
- package/dist/svg.js +235 -0
- package/dist/virtual.d.ts +65 -0
- package/dist/virtual.d.ts.map +1 -0
- package/dist/virtual.js +137 -0
- package/dist/writer.d.ts +19 -0
- package/dist/writer.d.ts.map +1 -0
- package/dist/writer.js +173 -0
- package/package.json +62 -0
- package/src/ansi.ts +120 -0
- package/src/capabilities.ts +140 -0
- package/src/capture.ts +101 -0
- package/src/index.ts +17 -0
- package/src/input.ts +394 -0
- package/src/node.ts +329 -0
- package/src/svg.ts +352 -0
- package/src/virtual.ts +185 -0
- package/src/writer.ts +202 -0
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, '&')
|
|
348
|
+
.replace(/</g, '<')
|
|
349
|
+
.replace(/>/g, '>')
|
|
350
|
+
.replace(/"/g, '"')
|
|
351
|
+
.replace(/'/g, ''');
|
|
352
|
+
}
|