blob: 1e9f9f2d9f3002c58b148e309039159fcf2e9f1a [file] [log] [blame]
Jason Lind66e6bf2022-08-22 14:47:10 +10001// Copyright 2022 The Chromium OS Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
Jason Linca61ffb2022-08-03 19:37:12 +10005/**
6 * @fileoverview For supporting xterm.js and the terminal emulator.
7 */
8
9// TODO(b/236205389): add tests. For example, we should enable the test in
10// terminal_tests.js for XtermTerminal.
11
12import {Terminal, FitAddon, WebglAddon} from './xterm.js';
Jason Lin21d854f2022-08-22 14:49:59 +100013import {FontManager, ORIGINAL_URL, TERMINAL_EMULATORS, delayedScheduler,
14 fontManager, getOSInfo, sleep} from './terminal_common.js';
Jason Linca61ffb2022-08-03 19:37:12 +100015
16const ANSI_COLOR_NAMES = [
17 'black',
18 'red',
19 'green',
20 'yellow',
21 'blue',
22 'magenta',
23 'cyan',
24 'white',
25 'brightBlack',
26 'brightRed',
27 'brightGreen',
28 'brightYellow',
29 'brightBlue',
30 'brightMagenta',
31 'brightCyan',
32 'brightWhite',
33];
34
35const PrefToXtermOptions = {
36 'font-family': 'fontFamily',
Jason Linca61ffb2022-08-03 19:37:12 +100037};
38
39/**
Jason Linabad7562022-08-22 14:49:05 +100040 * @typedef {{
41 * term: !Terminal,
42 * fontManager: !FontManager,
43 * fitAddon: !FitAddon,
44 * }}
45 */
46export let XtermTerminalTestParams;
47
48/**
Jason Lin21d854f2022-08-22 14:49:59 +100049 * A "terminal io" class for xterm. We don't want the vanilla hterm.Terminal.IO
50 * because it always convert utf8 data to strings, which is not necessary for
51 * xterm.
52 */
53class XtermTerminalIO extends hterm.Terminal.IO {
54 /** @override */
55 writeUTF8(buffer) {
56 this.terminal_.write(new Uint8Array(buffer));
57 }
58
59 /** @override */
60 writelnUTF8(buffer) {
61 this.terminal_.writeln(new Uint8Array(buffer));
62 }
63
64 /** @override */
65 print(string) {
66 this.terminal_.write(string);
67 }
68
69 /** @override */
70 writeUTF16(string) {
71 this.print(string);
72 }
73
74 /** @override */
75 println(string) {
76 this.terminal_.writeln(string);
77 }
78
79 /** @override */
80 writelnUTF16(string) {
81 this.println(string);
82 }
83}
84
85/**
Jason Linca61ffb2022-08-03 19:37:12 +100086 * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
87 * so that it can be used in existing code.
88 *
Jason Linca61ffb2022-08-03 19:37:12 +100089 * @extends {hterm.Terminal}
90 * @unrestricted
91 */
Jason Linabad7562022-08-22 14:49:05 +100092export class XtermTerminal {
Jason Linca61ffb2022-08-03 19:37:12 +100093 /**
94 * @param {{
95 * storage: !lib.Storage,
96 * profileId: string,
97 * enableWebGL: boolean,
Jason Linabad7562022-08-22 14:49:05 +100098 * testParams: (!XtermTerminalTestParams|undefined),
Jason Linca61ffb2022-08-03 19:37:12 +100099 * }} args
100 */
Jason Linabad7562022-08-22 14:49:05 +1000101 constructor({storage, profileId, enableWebGL, testParams}) {
Jason Lin21d854f2022-08-22 14:49:59 +1000102 this.profileId_ = profileId;
Jason Linca61ffb2022-08-03 19:37:12 +1000103 /** @type {!hterm.PreferenceManager} */
104 this.prefs_ = new hterm.PreferenceManager(storage, profileId);
105 this.enableWebGL_ = enableWebGL;
106
Jason Linabad7562022-08-22 14:49:05 +1000107 this.term = testParams?.term || new Terminal();
108 this.fontManager_ = testParams?.fontManager || fontManager;
109 this.fitAddon = testParams?.fitAddon || new FitAddon();
110
Jason Linca61ffb2022-08-03 19:37:12 +1000111 this.term.loadAddon(this.fitAddon);
Jason Linabad7562022-08-22 14:49:05 +1000112 this.scheduleFit_ = delayedScheduler(() => this.fitAddon.fit(),
113 testParams ? 0 : 250);
114
115 this.pendingFont_ = null;
116 this.scheduleRefreshFont_ = delayedScheduler(
117 () => this.refreshFont_(), 100);
118 document.fonts.addEventListener('loadingdone',
119 () => this.onFontLoadingDone_());
Jason Linca61ffb2022-08-03 19:37:12 +1000120
121 this.installUnimplementedStubs_();
122
Jason Lind04bab32022-08-22 14:48:39 +1000123 this.observePrefs_();
Jason Linca61ffb2022-08-03 19:37:12 +1000124
Jason Lin21d854f2022-08-22 14:49:59 +1000125 this.term.onResize(({cols, rows}) => this.io.onTerminalResize(cols, rows));
126 // We could also use `this.io.sendString()` except for the nassh exit
127 // prompt, which only listens to onVTKeystroke().
128 this.term.onData((data) => this.io.onVTKeystroke(data));
Jason Linca61ffb2022-08-03 19:37:12 +1000129
Jason Lin21d854f2022-08-22 14:49:59 +1000130 this.io = new XtermTerminalIO(this);
131 this.notificationCenter_ = null;
Jason Linca61ffb2022-08-03 19:37:12 +1000132 }
133
134 /**
135 * Install stubs for stuff that we haven't implemented yet so that the code
136 * still runs.
137 */
138 installUnimplementedStubs_() {
139 this.keyboard = {
140 keyMap: {
141 keyDefs: [],
142 },
143 bindings: {
144 clear: () => {},
145 addBinding: () => {},
146 addBindings: () => {},
147 OsDefaults: {},
148 },
149 };
150 this.keyboard.keyMap.keyDefs[78] = {};
151
152 const methodNames = [
Jason Linca61ffb2022-08-03 19:37:12 +1000153 'setAccessibilityEnabled',
154 'setBackgroundImage',
155 'setCursorPosition',
156 'setCursorVisible',
Jason Linca61ffb2022-08-03 19:37:12 +1000157 ];
158
159 for (const name of methodNames) {
160 this[name] = () => console.warn(`${name}() is not implemented`);
161 }
162
163 this.contextMenu = {
164 setItems: () => {
165 console.warn('.contextMenu.setItems() is not implemented');
166 },
167 };
Jason Lin21d854f2022-08-22 14:49:59 +1000168
169 this.vt = {
170 resetParseState: () => {
171 console.warn('.vt.resetParseState() is not implemented');
172 },
173 };
Jason Linca61ffb2022-08-03 19:37:12 +1000174 }
175
176 /**
177 * One-time initialization at the beginning.
178 */
179 async init() {
180 await new Promise((resolve) => this.prefs_.readStorage(resolve));
181 this.prefs_.notifyAll();
182 this.onTerminalReady();
183 }
184
Jason Lin21d854f2022-08-22 14:49:59 +1000185 /**
186 * Write data to the terminal.
187 *
188 * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
189 * UTF-8 data
190 */
191 write(data) {
192 this.term.write(data);
193 }
194
195 /**
196 * Like `this.write()` but also write a line break.
197 *
198 * @param {string|!Uint8Array} data
199 */
200 writeln(data) {
201 this.term.writeln(data);
202 }
203
Jason Linca61ffb2022-08-03 19:37:12 +1000204 get screenSize() {
205 return new hterm.Size(this.term.cols, this.term.rows);
206 }
207
208 /**
209 * Don't need to do anything.
210 *
211 * @override
212 */
213 installKeyboard() {}
214
215 /**
216 * @override
217 */
218 decorate(elem) {
219 this.term.open(elem);
Jason Lind04bab32022-08-22 14:48:39 +1000220 this.scheduleFit_();
Jason Linca61ffb2022-08-03 19:37:12 +1000221 if (this.enableWebGL_) {
222 this.term.loadAddon(new WebglAddon());
223 }
Jason Lin21d854f2022-08-22 14:49:59 +1000224 this.term.focus();
Jason Lind04bab32022-08-22 14:48:39 +1000225 (new ResizeObserver(() => this.scheduleFit_())).observe(elem);
Jason Lin21d854f2022-08-22 14:49:59 +1000226 // TODO: Make a11y work. Maybe we can just use `hterm.AccessibilityReader`.
227 this.notificationCenter_ = new hterm.NotificationCenter(document.body);
228 }
229
230 /** @override */
231 showOverlay(msg, timeout = 1500) {
232 if (this.notificationCenter_) {
233 this.notificationCenter_.show(msg, {timeout});
234 }
235 }
236
237 /** @override */
238 hideOverlay() {
239 if (this.notificationCenter_) {
240 this.notificationCenter_.hide();
241 }
Jason Linca61ffb2022-08-03 19:37:12 +1000242 }
243
244 /** @override */
245 getPrefs() {
246 return this.prefs_;
247 }
248
249 /** @override */
250 getDocument() {
251 return window.document;
252 }
253
Jason Lin21d854f2022-08-22 14:49:59 +1000254 /** @override */
255 reset() {
256 this.term.reset();
Jason Linca61ffb2022-08-03 19:37:12 +1000257 }
258
259 /** @override */
Jason Lin21d854f2022-08-22 14:49:59 +1000260 setProfile(profileId, callback = undefined) {
261 this.prefs_.setProfile(profileId, callback);
Jason Linca61ffb2022-08-03 19:37:12 +1000262 }
263
Jason Lin21d854f2022-08-22 14:49:59 +1000264 /** @override */
265 interpret(string) {
266 this.term.write(string);
Jason Linca61ffb2022-08-03 19:37:12 +1000267 }
268
Jason Lin21d854f2022-08-22 14:49:59 +1000269 /** @override */
270 focus() {
271 this.term.focus();
272 }
Jason Linca61ffb2022-08-03 19:37:12 +1000273
274 /** @override */
275 onOpenOptionsPage() {}
276
277 /** @override */
278 onTerminalReady() {}
279
Jason Lind04bab32022-08-22 14:48:39 +1000280 observePrefs_() {
281 for (const pref in PrefToXtermOptions) {
282 this.prefs_.addObserver(pref, (v) => {
283 this.updateOption_(PrefToXtermOptions[pref], v);
284 });
285 }
286
Jason Lin21d854f2022-08-22 14:49:59 +1000287 // This is for this.notificationCenter_.
288 const setHtermCSSVariable = (name, value) => {
289 document.body.style.setProperty(`--hterm-${name}`, value);
290 };
291
292 const setHtermColorCSSVariable = (name, color) => {
293 const css = lib.notNull(lib.colors.normalizeCSS(color));
294 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
295 setHtermCSSVariable(name, rgb);
296 };
297
298 this.prefs_.addObserver('font-size', (v) => {
299 this.updateOption_('fontSize', v);
300 setHtermCSSVariable('font-size', `${v}px`);
301 });
302
Jason Lind04bab32022-08-22 14:48:39 +1000303 // Theme-related preference items.
304 this.prefs_.addObservers(null, {
305 'background-color': (v) => {
306 this.updateTheme_({background: v});
Jason Lin21d854f2022-08-22 14:49:59 +1000307 setHtermColorCSSVariable('background-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000308 },
309 'foreground-color': (v) => {
310 this.updateTheme_({foreground: v});
Jason Lin21d854f2022-08-22 14:49:59 +1000311 setHtermColorCSSVariable('foreground-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000312 },
313 'cursor-color': (v) => {
314 this.updateTheme_({cursor: v});
315 },
316 'color-palette-overrides': (v) => {
317 if (!(v instanceof Array)) {
318 // For terminal, we always expect this to be an array.
319 console.warn('unexpected color palette: ', v);
320 return;
321 }
322 const colors = {};
323 for (let i = 0; i < v.length; ++i) {
324 colors[ANSI_COLOR_NAMES[i]] = v[i];
325 }
326 this.updateTheme_(colors);
327 },
328 });
329 }
330
331 /**
332 * @param {!Object} theme
333 */
334 updateTheme_(theme) {
335 const newTheme = {...this.term.options.theme};
336 for (const key in theme) {
337 newTheme[key] = lib.colors.normalizeCSS(theme[key]);
338 }
339 this.updateOption_('theme', newTheme);
340 }
341
342 /**
343 * Update one xterm.js option.
344 *
345 * @param {string} key
346 * @param {*} value
347 */
348 updateOption_(key, value) {
Jason Linabad7562022-08-22 14:49:05 +1000349 if (key === 'fontFamily') {
350 this.updateFont_(/** @type {string} */(value));
351 return;
352 }
Jason Lind04bab32022-08-22 14:48:39 +1000353 // TODO: xterm supports updating multiple options at the same time. We
354 // should probably do that.
355 this.term.options[key] = value;
356 this.scheduleFit_();
357 }
Jason Linabad7562022-08-22 14:49:05 +1000358
359 /**
360 * Called when there is a "fontloadingdone" event. We need this because
361 * `FontManager.loadFont()` does not guarantee loading all the font files.
362 */
363 async onFontLoadingDone_() {
364 // If there is a pending font, the font is going to be refresh soon, so we
365 // don't need to do anything.
366 if (!this.pendingFont_) {
367 this.scheduleRefreshFont_();
368 }
369 }
370
371 /**
372 * Refresh xterm rendering for a font related event.
373 */
374 refreshFont_() {
375 // We have to set the fontFamily option to a different string to trigger the
376 // re-rendering. Appending a space at the end seems to be the easiest
377 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
378 // us.
379 //
380 // TODO: Report a bug to xterm.js and ask for exposing a public function for
381 // the refresh so that we don't need to do this hack.
382 this.term.options.fontFamily += ' ';
383 }
384
385 /**
386 * Update a font.
387 *
388 * @param {string} cssFontFamily
389 */
390 async updateFont_(cssFontFamily) {
391 this.pendingFont_ = cssFontFamily;
392 await this.fontManager_.loadFont(cssFontFamily);
393 // Sleep a bit to wait for flushing fontloadingdone events. This is not
394 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
395 // to refresh font unnecessarily in some cases.
396 await sleep(30);
397
398 if (this.pendingFont_ !== cssFontFamily) {
399 // `updateFont_()` probably is called again. Abort what we are doing.
400 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
401 ` (expecting ${cssFontFamily})`);
402 return;
403 }
404
405 if (this.term.options.fontFamily !== cssFontFamily) {
406 this.term.options.fontFamily = cssFontFamily;
407 } else {
408 // If the font is already the same, refresh font just to be safe.
409 this.refreshFont_();
410 }
411 this.pendingFont_ = null;
412 this.scheduleFit_();
413 }
Jason Linca61ffb2022-08-03 19:37:12 +1000414}
415
Jason Lind66e6bf2022-08-22 14:47:10 +1000416class HtermTerminal extends hterm.Terminal {
417 /** @override */
418 decorate(div) {
419 super.decorate(div);
420
421 const fontManager = new FontManager(this.getDocument());
422 fontManager.loadPowerlineCSS().then(() => {
423 const prefs = this.getPrefs();
424 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
425 prefs.addObserver(
426 'font-family',
427 (v) => fontManager.loadFont(/** @type {string} */(v)));
428 });
429 }
430}
431
Jason Linca61ffb2022-08-03 19:37:12 +1000432/**
433 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
434 * preference value.
435 *
436 * @param {{
437 * storage: !lib.Storage,
438 * profileId: string,
439 * }} args
440 * @return {!Promise<!hterm.Terminal>}
441 */
442export async function createEmulator({storage, profileId}) {
443 let config = TERMINAL_EMULATORS.get('hterm');
444
445 if (getOSInfo().alternative_emulator) {
Jason Lin21d854f2022-08-22 14:49:59 +1000446 // TODO: remove the url param logic. This is temporary to make manual
447 // testing a bit easier, which is also why this is not in
448 // './js/terminal_info.js'.
449 const emulator = ORIGINAL_URL.searchParams.get('emulator') ||
450 await storage.getItem(`/hterm/profiles/${profileId}/terminal-emulator`);
Jason Linca61ffb2022-08-03 19:37:12 +1000451 // Use the default (i.e. first) one if the pref is not set or invalid.
Jason Lin21d854f2022-08-22 14:49:59 +1000452 config = TERMINAL_EMULATORS.get(emulator) ||
Jason Linca61ffb2022-08-03 19:37:12 +1000453 TERMINAL_EMULATORS.values().next().value;
454 console.log('Terminal emulator config: ', config);
455 }
456
457 switch (config.lib) {
458 case 'xterm.js':
459 {
460 const terminal = new XtermTerminal({
461 storage,
462 profileId,
463 enableWebGL: config.webgl,
464 });
465 // Don't await it so that the caller can override
466 // `terminal.onTerminalReady()` before the terminal is ready.
467 terminal.init();
468 return terminal;
469 }
470 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +1000471 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +1000472 default:
473 throw new Error('incorrect emulator config');
474 }
475}
476