blob: 00f3f49ef80c3d32b1e8141b725d7a3949d76bad [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 Linabad7562022-08-22 14:49:05 +100013import {FontManager, TERMINAL_EMULATORS, delayedScheduler, fontManager,
14 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',
37 'font-size': 'fontSize',
38};
39
40/**
Jason Linabad7562022-08-22 14:49:05 +100041 * @typedef {{
42 * term: !Terminal,
43 * fontManager: !FontManager,
44 * fitAddon: !FitAddon,
45 * }}
46 */
47export let XtermTerminalTestParams;
48
49/**
Jason Linca61ffb2022-08-03 19:37:12 +100050 * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
51 * so that it can be used in existing code.
52 *
53 * TODO: Currently, this also behaves like a `hterm.Terminal.IO` object, which
54 * kind of works but it is weird. We might want to just use the real
55 * `hterm.Terminal.IO`.
56 *
57 * @extends {hterm.Terminal}
58 * @unrestricted
59 */
Jason Linabad7562022-08-22 14:49:05 +100060export class XtermTerminal {
Jason Linca61ffb2022-08-03 19:37:12 +100061 /**
62 * @param {{
63 * storage: !lib.Storage,
64 * profileId: string,
65 * enableWebGL: boolean,
Jason Linabad7562022-08-22 14:49:05 +100066 * testParams: (!XtermTerminalTestParams|undefined),
Jason Linca61ffb2022-08-03 19:37:12 +100067 * }} args
68 */
Jason Linabad7562022-08-22 14:49:05 +100069 constructor({storage, profileId, enableWebGL, testParams}) {
Jason Linca61ffb2022-08-03 19:37:12 +100070 /** @type {!hterm.PreferenceManager} */
71 this.prefs_ = new hterm.PreferenceManager(storage, profileId);
72 this.enableWebGL_ = enableWebGL;
73
Jason Linabad7562022-08-22 14:49:05 +100074 this.term = testParams?.term || new Terminal();
75 this.fontManager_ = testParams?.fontManager || fontManager;
76 this.fitAddon = testParams?.fitAddon || new FitAddon();
77
Jason Linca61ffb2022-08-03 19:37:12 +100078 this.term.loadAddon(this.fitAddon);
Jason Linabad7562022-08-22 14:49:05 +100079 this.scheduleFit_ = delayedScheduler(() => this.fitAddon.fit(),
80 testParams ? 0 : 250);
81
82 this.pendingFont_ = null;
83 this.scheduleRefreshFont_ = delayedScheduler(
84 () => this.refreshFont_(), 100);
85 document.fonts.addEventListener('loadingdone',
86 () => this.onFontLoadingDone_());
Jason Linca61ffb2022-08-03 19:37:12 +100087
88 this.installUnimplementedStubs_();
89
Jason Lind04bab32022-08-22 14:48:39 +100090 this.observePrefs_();
Jason Linca61ffb2022-08-03 19:37:12 +100091
92 this.term.onResize(({cols, rows}) => this.onTerminalResize(cols, rows));
93 this.term.onData((data) => this.sendString(data));
94
95 // Also pretends to be a `hterm.Terminal.IO` object.
96 this.io = this;
97 this.terminal_ = this;
98 }
99
100 /**
101 * Install stubs for stuff that we haven't implemented yet so that the code
102 * still runs.
103 */
104 installUnimplementedStubs_() {
105 this.keyboard = {
106 keyMap: {
107 keyDefs: [],
108 },
109 bindings: {
110 clear: () => {},
111 addBinding: () => {},
112 addBindings: () => {},
113 OsDefaults: {},
114 },
115 };
116 this.keyboard.keyMap.keyDefs[78] = {};
117
118 const methodNames = [
119 'hideOverlay',
120 'setAccessibilityEnabled',
121 'setBackgroundImage',
122 'setCursorPosition',
123 'setCursorVisible',
124 'setTerminalProfile',
125 'showOverlay',
126
127 // This two are for `hterm.Terminal.IO`.
128 'push',
129 'pop',
130 ];
131
132 for (const name of methodNames) {
133 this[name] = () => console.warn(`${name}() is not implemented`);
134 }
135
136 this.contextMenu = {
137 setItems: () => {
138 console.warn('.contextMenu.setItems() is not implemented');
139 },
140 };
141 }
142
143 /**
144 * One-time initialization at the beginning.
145 */
146 async init() {
147 await new Promise((resolve) => this.prefs_.readStorage(resolve));
148 this.prefs_.notifyAll();
149 this.onTerminalReady();
150 }
151
152 get screenSize() {
153 return new hterm.Size(this.term.cols, this.term.rows);
154 }
155
156 /**
157 * Don't need to do anything.
158 *
159 * @override
160 */
161 installKeyboard() {}
162
163 /**
164 * @override
165 */
166 decorate(elem) {
167 this.term.open(elem);
Jason Lind04bab32022-08-22 14:48:39 +1000168 this.scheduleFit_();
Jason Linca61ffb2022-08-03 19:37:12 +1000169 if (this.enableWebGL_) {
170 this.term.loadAddon(new WebglAddon());
171 }
Jason Lind04bab32022-08-22 14:48:39 +1000172 (new ResizeObserver(() => this.scheduleFit_())).observe(elem);
Jason Linca61ffb2022-08-03 19:37:12 +1000173 }
174
175 /** @override */
176 getPrefs() {
177 return this.prefs_;
178 }
179
180 /** @override */
181 getDocument() {
182 return window.document;
183 }
184
185 /**
186 * This is a method from `hterm.Terminal.IO`.
187 *
188 * @param {!ArrayBuffer|!Array<number>} buffer The UTF-8 data to print.
189 */
190 writeUTF8(buffer) {
191 this.term.write(new Uint8Array(buffer));
192 }
193
194 /** @override */
195 print(data) {
196 this.term.write(data);
197 }
198
199 /**
200 * This is a method from `hterm.Terminal.IO`.
201 *
202 * @param {string} data
203 */
204 println(data) {
205 this.term.writeln(data);
206 }
207
208 /**
209 * This is a method from `hterm.Terminal.IO`.
210 *
211 * @param {number} width
212 * @param {number} height
213 */
214 onTerminalResize(width, height) {}
215
216 /** @override */
217 onOpenOptionsPage() {}
218
219 /** @override */
220 onTerminalReady() {}
221
222 /**
223 * This is a method from `hterm.Terminal.IO`.
224 *
225 * @param {string} v
226 */
227 onVTKeystoke(v) {}
228
229 /**
230 * This is a method from `hterm.Terminal.IO`.
231 *
232 * @param {string} v
233 */
234 sendString(v) {}
Jason Lind04bab32022-08-22 14:48:39 +1000235
236 observePrefs_() {
237 for (const pref in PrefToXtermOptions) {
238 this.prefs_.addObserver(pref, (v) => {
239 this.updateOption_(PrefToXtermOptions[pref], v);
240 });
241 }
242
243 // Theme-related preference items.
244 this.prefs_.addObservers(null, {
245 'background-color': (v) => {
246 this.updateTheme_({background: v});
247 },
248 'foreground-color': (v) => {
249 this.updateTheme_({foreground: v});
250 },
251 'cursor-color': (v) => {
252 this.updateTheme_({cursor: v});
253 },
254 'color-palette-overrides': (v) => {
255 if (!(v instanceof Array)) {
256 // For terminal, we always expect this to be an array.
257 console.warn('unexpected color palette: ', v);
258 return;
259 }
260 const colors = {};
261 for (let i = 0; i < v.length; ++i) {
262 colors[ANSI_COLOR_NAMES[i]] = v[i];
263 }
264 this.updateTheme_(colors);
265 },
266 });
267 }
268
269 /**
270 * @param {!Object} theme
271 */
272 updateTheme_(theme) {
273 const newTheme = {...this.term.options.theme};
274 for (const key in theme) {
275 newTheme[key] = lib.colors.normalizeCSS(theme[key]);
276 }
277 this.updateOption_('theme', newTheme);
278 }
279
280 /**
281 * Update one xterm.js option.
282 *
283 * @param {string} key
284 * @param {*} value
285 */
286 updateOption_(key, value) {
Jason Linabad7562022-08-22 14:49:05 +1000287 if (key === 'fontFamily') {
288 this.updateFont_(/** @type {string} */(value));
289 return;
290 }
Jason Lind04bab32022-08-22 14:48:39 +1000291 // TODO: xterm supports updating multiple options at the same time. We
292 // should probably do that.
293 this.term.options[key] = value;
294 this.scheduleFit_();
295 }
Jason Linabad7562022-08-22 14:49:05 +1000296
297 /**
298 * Called when there is a "fontloadingdone" event. We need this because
299 * `FontManager.loadFont()` does not guarantee loading all the font files.
300 */
301 async onFontLoadingDone_() {
302 // If there is a pending font, the font is going to be refresh soon, so we
303 // don't need to do anything.
304 if (!this.pendingFont_) {
305 this.scheduleRefreshFont_();
306 }
307 }
308
309 /**
310 * Refresh xterm rendering for a font related event.
311 */
312 refreshFont_() {
313 // We have to set the fontFamily option to a different string to trigger the
314 // re-rendering. Appending a space at the end seems to be the easiest
315 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
316 // us.
317 //
318 // TODO: Report a bug to xterm.js and ask for exposing a public function for
319 // the refresh so that we don't need to do this hack.
320 this.term.options.fontFamily += ' ';
321 }
322
323 /**
324 * Update a font.
325 *
326 * @param {string} cssFontFamily
327 */
328 async updateFont_(cssFontFamily) {
329 this.pendingFont_ = cssFontFamily;
330 await this.fontManager_.loadFont(cssFontFamily);
331 // Sleep a bit to wait for flushing fontloadingdone events. This is not
332 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
333 // to refresh font unnecessarily in some cases.
334 await sleep(30);
335
336 if (this.pendingFont_ !== cssFontFamily) {
337 // `updateFont_()` probably is called again. Abort what we are doing.
338 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
339 ` (expecting ${cssFontFamily})`);
340 return;
341 }
342
343 if (this.term.options.fontFamily !== cssFontFamily) {
344 this.term.options.fontFamily = cssFontFamily;
345 } else {
346 // If the font is already the same, refresh font just to be safe.
347 this.refreshFont_();
348 }
349 this.pendingFont_ = null;
350 this.scheduleFit_();
351 }
Jason Linca61ffb2022-08-03 19:37:12 +1000352}
353
Jason Lind66e6bf2022-08-22 14:47:10 +1000354class HtermTerminal extends hterm.Terminal {
355 /** @override */
356 decorate(div) {
357 super.decorate(div);
358
359 const fontManager = new FontManager(this.getDocument());
360 fontManager.loadPowerlineCSS().then(() => {
361 const prefs = this.getPrefs();
362 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
363 prefs.addObserver(
364 'font-family',
365 (v) => fontManager.loadFont(/** @type {string} */(v)));
366 });
367 }
368}
369
Jason Linca61ffb2022-08-03 19:37:12 +1000370/**
371 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
372 * preference value.
373 *
374 * @param {{
375 * storage: !lib.Storage,
376 * profileId: string,
377 * }} args
378 * @return {!Promise<!hterm.Terminal>}
379 */
380export async function createEmulator({storage, profileId}) {
381 let config = TERMINAL_EMULATORS.get('hterm');
382
383 if (getOSInfo().alternative_emulator) {
384 const prefKey = `/hterm/profiles/${profileId}/terminal-emulator`;
385 // Use the default (i.e. first) one if the pref is not set or invalid.
386 config = TERMINAL_EMULATORS.get(await storage.getItem(prefKey)) ||
387 TERMINAL_EMULATORS.values().next().value;
388 console.log('Terminal emulator config: ', config);
389 }
390
391 switch (config.lib) {
392 case 'xterm.js':
393 {
394 const terminal = new XtermTerminal({
395 storage,
396 profileId,
397 enableWebGL: config.webgl,
398 });
399 // Don't await it so that the caller can override
400 // `terminal.onTerminalReady()` before the terminal is ready.
401 terminal.init();
402 return terminal;
403 }
404 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +1000405 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +1000406 default:
407 throw new Error('incorrect emulator config');
408 }
409}
410