terminal: preliminary support for xterm.js
Bug: b/236205389
Change-Id: If9bf6a57084357b801ef8e8a190121755e79bd4e
Reviewed-on: https://chromium-review.googlesource.com/c/apps/libapps/+/3805985
Reviewed-by: Joel Hockey <joelhockey@chromium.org>
Tested-by: kokoro <noreply+kokoro@google.com>
diff --git a/terminal/js/terminal_emulator.js b/terminal/js/terminal_emulator.js
new file mode 100644
index 0000000..ae48a48
--- /dev/null
+++ b/terminal/js/terminal_emulator.js
@@ -0,0 +1,309 @@
+/**
+ * @fileoverview For supporting xterm.js and the terminal emulator.
+ */
+
+// TODO(b/236205389): add tests. For example, we should enable the test in
+// terminal_tests.js for XtermTerminal.
+
+import {Terminal, FitAddon, WebglAddon} from './xterm.js';
+import {TERMINAL_EMULATORS, getOSInfo} from './terminal_common.js';
+
+const ANSI_COLOR_NAMES = [
+ 'black',
+ 'red',
+ 'green',
+ 'yellow',
+ 'blue',
+ 'magenta',
+ 'cyan',
+ 'white',
+ 'brightBlack',
+ 'brightRed',
+ 'brightGreen',
+ 'brightYellow',
+ 'brightBlue',
+ 'brightMagenta',
+ 'brightCyan',
+ 'brightWhite',
+];
+
+const PrefToXtermOptions = {
+ 'font-family': 'fontFamily',
+ 'font-size': 'fontSize',
+};
+
+/**
+ * This class observers a PreferenceManager and sets the corresponding options
+ * on the xterm.js terminal when necessary.
+ */
+class PrefReflector {
+ /**
+ * @param {!hterm.PreferenceManager} prefs
+ * @param {!Terminal} xtermTerminal
+ */
+ constructor(prefs, xtermTerminal) {
+ this.xtermTerminal_ = xtermTerminal;
+ this.theme_ = {};
+
+ for (const pref in PrefToXtermOptions) {
+ prefs.addObserver(pref, (v) => {
+ this.xtermTerminal_.options[PrefToXtermOptions[pref]] = v;
+ });
+ }
+
+ // Theme-related preference items.
+ prefs.addObservers(null, {
+ 'background-color': (v) => {
+ this.updateTheme_({background: v});
+ },
+ 'foreground-color': (v) => {
+ this.updateTheme_({foreground: v});
+ },
+ 'cursor-color': (v) => {
+ this.updateTheme_({cursor: v});
+ },
+ 'color-palette-overrides': (v) => {
+ if (!(v instanceof Array)) {
+ // For terminal, we always expect this to be an array.
+ console.warn('unexpected color palette: ', v);
+ return;
+ }
+ const colors = {};
+ for (let i = 0; i < v.length; ++i) {
+ colors[ANSI_COLOR_NAMES[i]] = v[i];
+ }
+ this.updateTheme_(colors);
+ },
+ });
+ }
+
+ /**
+ * @param {!Object} theme
+ */
+ updateTheme_(theme) {
+ for (const key in theme) {
+ this.theme_[key] = lib.colors.normalizeCSS(theme[key]);
+ }
+ // Must copy, otherwise, xterm.js will not detect the change.
+ this.xtermTerminal_.options.theme = Object.assign({}, this.theme_);
+ }
+}
+
+/**
+ * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
+ * so that it can be used in existing code.
+ *
+ * TODO: Currently, this also behaves like a `hterm.Terminal.IO` object, which
+ * kind of works but it is weird. We might want to just use the real
+ * `hterm.Terminal.IO`.
+ *
+ * @extends {hterm.Terminal}
+ * @unrestricted
+ */
+class XtermTerminal {
+ /**
+ * @param {{
+ * storage: !lib.Storage,
+ * profileId: string,
+ * enableWebGL: boolean,
+ * }} args
+ */
+ constructor({storage, profileId, enableWebGL}) {
+ /** @type {!hterm.PreferenceManager} */
+ this.prefs_ = new hterm.PreferenceManager(storage, profileId);
+ this.enableWebGL_ = enableWebGL;
+
+ this.term = new Terminal();
+ this.fitAddon = new FitAddon();
+ this.term.loadAddon(this.fitAddon);
+
+ this.installUnimplementedStubs_();
+
+ this.prefReflector_ = new PrefReflector(this.prefs_, this.term);
+
+ this.term.onResize(({cols, rows}) => this.onTerminalResize(cols, rows));
+ this.term.onData((data) => this.sendString(data));
+
+ // Also pretends to be a `hterm.Terminal.IO` object.
+ this.io = this;
+ this.terminal_ = this;
+ }
+
+ /**
+ * Install stubs for stuff that we haven't implemented yet so that the code
+ * still runs.
+ */
+ installUnimplementedStubs_() {
+ this.keyboard = {
+ keyMap: {
+ keyDefs: [],
+ },
+ bindings: {
+ clear: () => {},
+ addBinding: () => {},
+ addBindings: () => {},
+ OsDefaults: {},
+ },
+ };
+ this.keyboard.keyMap.keyDefs[78] = {};
+
+ const methodNames = [
+ 'hideOverlay',
+ 'setAccessibilityEnabled',
+ 'setBackgroundImage',
+ 'setCursorPosition',
+ 'setCursorVisible',
+ 'setTerminalProfile',
+ 'showOverlay',
+
+ // This two are for `hterm.Terminal.IO`.
+ 'push',
+ 'pop',
+ ];
+
+ for (const name of methodNames) {
+ this[name] = () => console.warn(`${name}() is not implemented`);
+ }
+
+ this.contextMenu = {
+ setItems: () => {
+ console.warn('.contextMenu.setItems() is not implemented');
+ },
+ };
+ }
+
+ /**
+ * One-time initialization at the beginning.
+ */
+ async init() {
+ await new Promise((resolve) => this.prefs_.readStorage(resolve));
+ this.prefs_.notifyAll();
+ this.onTerminalReady();
+ }
+
+ get screenSize() {
+ return new hterm.Size(this.term.cols, this.term.rows);
+ }
+
+ /**
+ * Don't need to do anything.
+ *
+ * @override
+ */
+ installKeyboard() {}
+
+ /**
+ * @override
+ */
+ decorate(elem) {
+ this.term.open(elem);
+ this.fitAddon.fit();
+ if (this.enableWebGL_) {
+ this.term.loadAddon(new WebglAddon());
+ }
+ (new ResizeObserver(() => this.fitAddon.fit())).observe(elem);
+ }
+
+ /** @override */
+ getPrefs() {
+ return this.prefs_;
+ }
+
+ /** @override */
+ getDocument() {
+ return window.document;
+ }
+
+ /**
+ * This is a method from `hterm.Terminal.IO`.
+ *
+ * @param {!ArrayBuffer|!Array<number>} buffer The UTF-8 data to print.
+ */
+ writeUTF8(buffer) {
+ this.term.write(new Uint8Array(buffer));
+ }
+
+ /** @override */
+ print(data) {
+ this.term.write(data);
+ }
+
+ /**
+ * This is a method from `hterm.Terminal.IO`.
+ *
+ * @param {string} data
+ */
+ println(data) {
+ this.term.writeln(data);
+ }
+
+ /**
+ * This is a method from `hterm.Terminal.IO`.
+ *
+ * @param {number} width
+ * @param {number} height
+ */
+ onTerminalResize(width, height) {}
+
+ /** @override */
+ onOpenOptionsPage() {}
+
+ /** @override */
+ onTerminalReady() {}
+
+ /**
+ * This is a method from `hterm.Terminal.IO`.
+ *
+ * @param {string} v
+ */
+ onVTKeystoke(v) {}
+
+ /**
+ * This is a method from `hterm.Terminal.IO`.
+ *
+ * @param {string} v
+ */
+ sendString(v) {}
+}
+
+/**
+ * Constructs and returns a `hterm.Terminal` or a compatible one based on the
+ * preference value.
+ *
+ * @param {{
+ * storage: !lib.Storage,
+ * profileId: string,
+ * }} args
+ * @return {!Promise<!hterm.Terminal>}
+ */
+export async function createEmulator({storage, profileId}) {
+ let config = TERMINAL_EMULATORS.get('hterm');
+
+ if (getOSInfo().alternative_emulator) {
+ const prefKey = `/hterm/profiles/${profileId}/terminal-emulator`;
+ // Use the default (i.e. first) one if the pref is not set or invalid.
+ config = TERMINAL_EMULATORS.get(await storage.getItem(prefKey)) ||
+ TERMINAL_EMULATORS.values().next().value;
+ console.log('Terminal emulator config: ', config);
+ }
+
+ switch (config.lib) {
+ case 'xterm.js':
+ {
+ const terminal = new XtermTerminal({
+ storage,
+ profileId,
+ enableWebGL: config.webgl,
+ });
+ // Don't await it so that the caller can override
+ // `terminal.onTerminalReady()` before the terminal is ready.
+ terminal.init();
+ return terminal;
+ }
+ case 'hterm':
+ return new hterm.Terminal({profileId, storage});
+ default:
+ throw new Error('incorrect emulator config');
+ }
+}
+