@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/virtual.ts ADDED
@@ -0,0 +1,185 @@
1
+ import type {
2
+ AcquiredState, CapabilityOverrides, Disposable, InputEvent, Size,
3
+ TerminalAdapter, TerminalCapabilities, TerminalSessionOptions,
4
+ } from '@textui/core';
5
+ import { FULL_CAPABILITIES, toDisposable } from '@textui/core';
6
+ import { createDecoder, type InputDecoder } from './input.js';
7
+
8
+ /**
9
+ * A terminal with no terminal behind it.
10
+ *
11
+ * The same adapter serves three cases that turn out to be one case: the test
12
+ * harness, an application embedded in a host that owns the real tty, and a
13
+ * browser-hosted terminal such as xterm.js where output goes to a callback
14
+ * rather than a file descriptor.
15
+ *
16
+ * Because it is embedded by default, `acquire` records nothing it did not do -
17
+ * and it does nothing to anyone's terminal.
18
+ */
19
+
20
+ export interface VirtualAdapterOptions {
21
+ width?: number;
22
+ height?: number;
23
+ capabilities?: CapabilityOverrides;
24
+ /** Where encoded output goes. Omit to only accumulate it. */
25
+ onWrite?(data: string): void;
26
+ /** Keep every chunk written, for assertions. */
27
+ record?: boolean;
28
+ /** True when this adapter owns terminal setup. Defaults to false. */
29
+ managed?: boolean;
30
+ }
31
+
32
+ const NOTHING_ACQUIRED: AcquiredState = {
33
+ altScreen: false, mouse: false, wheel: false, focusEvents: false,
34
+ paste: false, cursorHidden: false, enhancedKeys: false, rawMode: false,
35
+ titleSet: false,
36
+ };
37
+
38
+ export class VirtualTerminalAdapter implements TerminalAdapter {
39
+ readonly id = 'virtual';
40
+
41
+ private caps: TerminalCapabilities;
42
+ private state: AcquiredState | null = null;
43
+ private currentSize: Size;
44
+ private pending: string[] = [];
45
+ private written: string[] = [];
46
+ private inputListeners = new Set<(event: InputEvent) => void>();
47
+ private resizeListeners = new Set<(size: Size) => void>();
48
+ private decoder: InputDecoder;
49
+ private title: string | null = null;
50
+ private clipboard: string | null = null;
51
+
52
+ constructor(private options: VirtualAdapterOptions = {}) {
53
+ this.currentSize = {
54
+ width: options.width ?? 80,
55
+ height: options.height ?? 24,
56
+ };
57
+ this.caps = { ...FULL_CAPABILITIES, ...options.capabilities };
58
+ this.decoder = createDecoder((event) => this.dispatch(event), { escapeTimeoutMs: 0 });
59
+ }
60
+
61
+ get acquired(): AcquiredState | null {
62
+ return this.state;
63
+ }
64
+
65
+ size(): Size {
66
+ return this.currentSize;
67
+ }
68
+
69
+ capabilities(): TerminalCapabilities {
70
+ return this.caps;
71
+ }
72
+
73
+ setCapabilityOverrides(overrides: CapabilityOverrides): void {
74
+ this.caps = { ...this.caps, ...overrides };
75
+ }
76
+
77
+ acquire(options: TerminalSessionOptions): AcquiredState {
78
+ const managed = options.managed ?? this.options.managed ?? false;
79
+ this.state = managed
80
+ ? {
81
+ ...NOTHING_ACQUIRED,
82
+ altScreen: (options.altScreen ?? true) && this.caps.altScreen,
83
+ mouse: (options.mouse ?? false) && this.caps.mouse,
84
+ wheel: (options.wheel ?? true) && this.caps.wheel,
85
+ focusEvents: (options.focusEvents ?? false) && this.caps.focusEvents,
86
+ paste: (options.paste ?? true) && this.caps.paste,
87
+ cursorHidden: (options.hideCursor ?? true) && this.caps.cursor,
88
+ }
89
+ : { ...NOTHING_ACQUIRED };
90
+ return this.state;
91
+ }
92
+
93
+ release(): void {
94
+ this.state = null;
95
+ this.decoder.reset();
96
+ }
97
+
98
+ write(data: string): void {
99
+ if (data === '') return;
100
+ this.pending.push(data);
101
+ }
102
+
103
+ flush(): void {
104
+ if (this.pending.length === 0) return;
105
+ const data = this.pending.join('');
106
+ this.pending = [];
107
+ if (this.options.record !== false) this.written.push(data);
108
+ this.options.onWrite?.(data);
109
+ }
110
+
111
+ onInput(fn: (event: InputEvent) => void): Disposable {
112
+ this.inputListeners.add(fn);
113
+ return toDisposable(() => this.inputListeners.delete(fn));
114
+ }
115
+
116
+ onResize(fn: (size: Size) => void): Disposable {
117
+ this.resizeListeners.add(fn);
118
+ return toDisposable(() => this.resizeListeners.delete(fn));
119
+ }
120
+
121
+ writeClipboard(text: string): void {
122
+ this.clipboard = text;
123
+ }
124
+
125
+ setTitle(title: string): void {
126
+ this.title = title;
127
+ }
128
+
129
+ // ----------------------------------------------------------- test surface
130
+
131
+ /** Feed raw bytes, exactly as a terminal would deliver them. */
132
+ feed(data: string): void {
133
+ this.decoder.feed(data);
134
+ }
135
+
136
+ /** Deliver an already-decoded event. */
137
+ send(event: InputEvent): void {
138
+ this.dispatch(event);
139
+ }
140
+
141
+ resize(width: number, height: number): void {
142
+ if (width === this.currentSize.width && height === this.currentSize.height) return;
143
+ this.currentSize = { width, height };
144
+ for (const fn of [...this.resizeListeners]) fn(this.currentSize);
145
+ }
146
+
147
+ /** Everything written since the last `clearOutput`. */
148
+ output(): string {
149
+ return this.written.join('');
150
+ }
151
+
152
+ chunks(): string[] {
153
+ return [...this.written];
154
+ }
155
+
156
+ clearOutput(): void {
157
+ this.written = [];
158
+ }
159
+
160
+ currentTitle(): string | null {
161
+ return this.title;
162
+ }
163
+
164
+ clipboardContents(): string | null {
165
+ return this.clipboard;
166
+ }
167
+
168
+ private dispatch(event: InputEvent): void {
169
+ if (event.type === 'resize') {
170
+ this.resize(event.width, event.height);
171
+ return;
172
+ }
173
+ for (const fn of [...this.inputListeners]) fn(event);
174
+ }
175
+
176
+ dispose(): void {
177
+ this.release();
178
+ this.inputListeners.clear();
179
+ this.resizeListeners.clear();
180
+ }
181
+ }
182
+
183
+ export function createVirtualTerminal(options?: VirtualAdapterOptions): VirtualTerminalAdapter {
184
+ return new VirtualTerminalAdapter(options);
185
+ }
package/src/writer.ts ADDED
@@ -0,0 +1,202 @@
1
+ import type { Frame, Run } from '@textui/core';
2
+ import type { TerminalCapabilities } from '@textui/core';
3
+ import { COLOR_DEFAULT, downsample, isRgb, unpackRgb } from '@textui/core';
4
+ import {
5
+ ATTR_BLINK, ATTR_BOLD, ATTR_DIM, ATTR_HIDDEN, ATTR_INVERSE,
6
+ ATTR_ITALIC, ATTR_STRIKE, ATTR_UNDERLINE,
7
+ } from '@textui/core';
8
+ import * as ansi from './ansi.js';
9
+
10
+ /**
11
+ * Frame to bytes.
12
+ *
13
+ * The writer holds the terminal's current SGR state across runs and emits only
14
+ * the difference, because a full reset per run is what makes a redraw flicker
15
+ * on a slow link. Colour is reduced here, against the terminal's real depth -
16
+ * no component ever picked a fallback.
17
+ */
18
+ interface WriterState {
19
+ fg: number;
20
+ bg: number;
21
+ attrs: number;
22
+ link: string | undefined;
23
+ x: number;
24
+ y: number;
25
+ valid: boolean;
26
+ }
27
+
28
+ function freshState(): WriterState {
29
+ return { fg: COLOR_DEFAULT, bg: COLOR_DEFAULT, attrs: 0, link: undefined, x: -1, y: -1, valid: false };
30
+ }
31
+
32
+ export function fgSequence(color: number, depth: TerminalCapabilities['colorDepth']): string {
33
+ const c = downsample(color, depth);
34
+ if (c === COLOR_DEFAULT) return `${ansi.SGR.fgDefault}`;
35
+ if (isRgb(c)) {
36
+ const [r, g, b] = unpackRgb(c);
37
+ return `38;2;${r};${g};${b}`;
38
+ }
39
+ if (c < 8) return `${30 + c}`;
40
+ if (c < 16) return `${90 + (c - 8)}`;
41
+ return `38;5;${c}`;
42
+ }
43
+
44
+ export function bgSequence(color: number, depth: TerminalCapabilities['colorDepth']): string {
45
+ const c = downsample(color, depth);
46
+ if (c === COLOR_DEFAULT) return `${ansi.SGR.bgDefault}`;
47
+ if (isRgb(c)) {
48
+ const [r, g, b] = unpackRgb(c);
49
+ return `48;2;${r};${g};${b}`;
50
+ }
51
+ if (c < 8) return `${40 + c}`;
52
+ if (c < 16) return `${100 + (c - 8)}`;
53
+ return `48;5;${c}`;
54
+ }
55
+
56
+ export const ATTR_ON: [number, number][] = [
57
+ [ATTR_BOLD, SGRon(ansi.SGR.bold)],
58
+ [ATTR_DIM, SGRon(ansi.SGR.dim)],
59
+ [ATTR_ITALIC, SGRon(ansi.SGR.italic)],
60
+ [ATTR_UNDERLINE, SGRon(ansi.SGR.underline)],
61
+ [ATTR_BLINK, SGRon(ansi.SGR.blink)],
62
+ [ATTR_INVERSE, SGRon(ansi.SGR.inverse)],
63
+ [ATTR_HIDDEN, SGRon(ansi.SGR.hidden)],
64
+ [ATTR_STRIKE, SGRon(ansi.SGR.strike)],
65
+ ];
66
+
67
+ function SGRon(code: number): number {
68
+ return code;
69
+ }
70
+
71
+ export class Writer {
72
+ private state = freshState();
73
+
74
+ constructor(private capabilities: TerminalCapabilities) {}
75
+
76
+ setCapabilities(capabilities: TerminalCapabilities): void {
77
+ this.capabilities = capabilities;
78
+ this.invalidate();
79
+ }
80
+
81
+ /** Forget what we believe the terminal's state to be. After a resize. */
82
+ invalidate(): void {
83
+ this.state = freshState();
84
+ }
85
+
86
+ /** Encode a frame. Returns an empty string when nothing changed. */
87
+ write(frame: Frame): string {
88
+ if (frame.runs.length === 0 && !frame.cursor) return '';
89
+
90
+ const out: string[] = [];
91
+ const sync = this.capabilities.synchronizedOutput;
92
+ if (sync) out.push(ansi.syncStart);
93
+
94
+ // A frame is painted with the cursor hidden; showing it once at the end
95
+ // is the difference between a steady caret and one that streaks.
96
+ const hideForPaint = this.capabilities.cursor && frame.runs.length > 0;
97
+ if (hideForPaint) out.push(ansi.cursorHide);
98
+
99
+ for (const run of frame.runs) out.push(this.encodeRun(run));
100
+
101
+ if (this.state.link !== undefined) {
102
+ out.push(ansi.linkClose);
103
+ this.state.link = undefined;
104
+ }
105
+
106
+ if (frame.cursor && this.capabilities.cursor) {
107
+ out.push(ansi.cursorTo(frame.cursor.x, frame.cursor.y));
108
+ this.state.x = frame.cursor.x;
109
+ this.state.y = frame.cursor.y;
110
+ if (frame.cursor.visible) out.push(ansi.cursorShow);
111
+ }
112
+
113
+ if (sync) out.push(ansi.syncEnd);
114
+ return out.join('');
115
+ }
116
+
117
+ private encodeRun(run: Run): string {
118
+ const out: string[] = [];
119
+ const s = this.state;
120
+
121
+ // Move only when we are not already where this run starts.
122
+ if (!s.valid || s.y !== run.y || s.x !== run.x) {
123
+ out.push(
124
+ s.valid && s.y === run.y ? ansi.cursorColumn(run.x) : ansi.cursorTo(run.x, run.y),
125
+ );
126
+ }
127
+
128
+ out.push(this.encodeStyle(run));
129
+
130
+ if (this.capabilities.hyperlinks && run.link !== s.link) {
131
+ out.push(run.link === undefined ? ansi.linkClose : ansi.linkOpen(run.link));
132
+ s.link = run.link;
133
+ }
134
+
135
+ out.push(run.text);
136
+
137
+ s.x = run.x + [...run.text].length;
138
+ s.y = run.y;
139
+ s.valid = true;
140
+ return out.join('');
141
+ }
142
+
143
+ private encodeStyle(run: Run): string {
144
+ const s = this.state;
145
+ const codes: (string | number)[] = [];
146
+
147
+ const attrsChanged = s.attrs !== run.attrs;
148
+ if (attrsChanged) {
149
+ const removed = s.attrs & ~run.attrs;
150
+ // Turning several attributes off individually costs more than one reset
151
+ // followed by re-stating what is still on.
152
+ if (removed !== 0 && popcount(removed) > 1) {
153
+ codes.push(ansi.SGR.reset);
154
+ s.fg = COLOR_DEFAULT;
155
+ s.bg = COLOR_DEFAULT;
156
+ s.attrs = 0;
157
+ } else if (removed !== 0) {
158
+ if (removed & ATTR_BOLD) codes.push(ansi.SGR.noBold);
159
+ if (removed & ATTR_DIM) codes.push(ansi.SGR.noBold);
160
+ if (removed & ATTR_ITALIC) codes.push(ansi.SGR.noItalic);
161
+ if (removed & ATTR_UNDERLINE) codes.push(ansi.SGR.noUnderline);
162
+ if (removed & ATTR_BLINK) codes.push(ansi.SGR.noBlink);
163
+ if (removed & ATTR_INVERSE) codes.push(ansi.SGR.noInverse);
164
+ if (removed & ATTR_HIDDEN) codes.push(ansi.SGR.noHidden);
165
+ if (removed & ATTR_STRIKE) codes.push(ansi.SGR.noStrike);
166
+ }
167
+
168
+ const added = run.attrs & ~s.attrs;
169
+ for (const [bit, code] of ATTR_ON) {
170
+ if (added & bit) codes.push(code);
171
+ }
172
+ s.attrs = run.attrs;
173
+ }
174
+
175
+ if (this.capabilities.colorDepth > 0) {
176
+ if (s.fg !== run.fg) {
177
+ codes.push(fgSequence(run.fg, this.capabilities.colorDepth));
178
+ s.fg = run.fg;
179
+ }
180
+ if (s.bg !== run.bg) {
181
+ codes.push(bgSequence(run.bg, this.capabilities.colorDepth));
182
+ s.bg = run.bg;
183
+ }
184
+ }
185
+
186
+ return codes.length === 0 ? '' : `${ansi.CSI}${codes.join(';')}m`;
187
+ }
188
+ }
189
+
190
+ function popcount(n: number): number {
191
+ let count = 0;
192
+ let v = n;
193
+ while (v) {
194
+ v &= v - 1;
195
+ count++;
196
+ }
197
+ return count;
198
+ }
199
+
200
+ export function createWriter(capabilities: TerminalCapabilities): Writer {
201
+ return new Writer(capabilities);
202
+ }