terminal: make XtermTerminal support tmux integration
Bug: b/236205389
Change-Id: I05572d59ac03d144ffcdf91cee862b783687248b
Reviewed-on: https://chromium-review.googlesource.com/c/apps/libapps/+/3944190
Tested-by: kokoro <noreply+kokoro@google.com>
Reviewed-by: Joel Hockey <joelhockey@chromium.org>
diff --git a/terminal/js/terminal_common.js b/terminal/js/terminal_common.js
index 5b04cb2..4860b5c 100644
--- a/terminal/js/terminal_common.js
+++ b/terminal/js/terminal_common.js
@@ -463,7 +463,8 @@
/**
* Return a new "scheduler" function. When the function is called, it will
- * schedule the `callback` to be called after `delay` time. The function does
+ * schedule the `callback` to be called after `delay` time. It also returns a
+ * promise, which is fulfilled after the callback is called. The function does
* nothing if it is called again and the last one hasn't timed out yet.
*
* TODO: This can probably replace some other existing scheduling code (search
@@ -471,17 +472,20 @@
*
* @param {function()} callback
* @param {number} delay
- * @return {function()} The schedule function.
+ * @return {function(): !Promise<void>} The schedule function.
*/
export function delayedScheduler(callback, delay) {
- let pending = false;
+ let donePromise = null;
return () => {
- if (!pending) {
- pending = true;
- setTimeout(() => {
- pending = false;
- callback();
- }, delay);
+ if (!donePromise) {
+ donePromise = new Promise((resolve) => {
+ setTimeout(() => {
+ donePromise = null;
+ callback();
+ resolve();
+ }, delay);
+ });
}
+ return donePromise;
};
}
diff --git a/terminal/js/terminal_common_tests.js b/terminal/js/terminal_common_tests.js
index 67b5c02..d88ee33 100644
--- a/terminal/js/terminal_common_tests.js
+++ b/terminal/js/terminal_common_tests.js
@@ -9,7 +9,7 @@
import {DEFAULT_BACKGROUND_COLOR, SUPPORTED_FONT_FAMILIES,
SUPPORTED_FONT_FAMILIES_MINIMAL, delayedScheduler, definePrefs,
fontFamilyToCSS, getSupportedFontFamilies, normalizeCSSFontFamily,
- normalizePrefsInPlace, sleep} from './terminal_common.js';
+ normalizePrefsInPlace} from './terminal_common.js';
describe('terminal_common_tests.js', () => {
beforeEach(function() {
@@ -63,13 +63,14 @@
it('delayedScheduler', async function() {
let counter = 0;
- const schedule = delayedScheduler(() => ++counter, 0);
+ const schedule = delayedScheduler(() => ++counter, 50);
+ const promise = schedule();
for (let i = 0; i < 10; ++i) {
schedule();
}
assert.equal(counter, 0);
- await sleep(0);
+ await promise;
assert.equal(counter, 1);
});
diff --git a/terminal/js/terminal_emulator.js b/terminal/js/terminal_emulator.js
index aead328..d25a2af 100644
--- a/terminal/js/terminal_emulator.js
+++ b/terminal/js/terminal_emulator.js
@@ -9,6 +9,8 @@
// TODO(b/236205389): add tests. For example, we should enable the test in
// terminal_tests.js for XtermTerminal.
+// TODO(b/236205389): support option smoothScrollDuration?
+
import {LitElement, css, html} from './lit.js';
import {FontManager, ORIGINAL_URL, TERMINAL_EMULATORS, delayedScheduler,
fontManager, getOSInfo, sleep} from './terminal_common.js';
@@ -16,6 +18,7 @@
import {TerminalTooltip} from './terminal_tooltip.js';
import {Terminal, Unicode11Addon, WebLinksAddon, WebglAddon}
from './xterm.js';
+import {XtermInternal} from './terminal_xterm_internal.js';
/** @enum {number} */
@@ -82,6 +85,7 @@
* @typedef {{
* term: !Terminal,
* fontManager: !FontManager,
+ * xtermInternal: !XtermInternal,
* }}
*/
export let XtermTerminalTestParams;
@@ -264,6 +268,8 @@
// TODO: we should probably pass the initial prefs to the ctor.
this.term = testParams?.term || new Terminal({allowProposedApi: true});
+ this.xtermInternal_ = testParams?.xtermInternal ||
+ new XtermInternal(this.term);
this.fontManager_ = testParams?.fontManager || fontManager;
/** @type {?Element} */
@@ -291,7 +297,7 @@
// prompt, which only listens to onVTKeystroke().
this.term.onData((data) => this.io.onVTKeystroke(data));
this.term.onBinary((data) => this.io.onVTKeystroke(data));
- this.term.onTitleChange((title) => document.title = title);
+ this.term.onTitleChange((title) => this.setWindowTitle(title));
this.term.onSelectionChange(() => this.copySelection_());
this.term.onBell(() => this.ringBell());
@@ -335,10 +341,35 @@
}
/** @override */
+ setWindowTitle(title) {
+ document.title = title;
+ }
+
+ /** @override */
ringBell() {
this.bell_.ring();
}
+ /** @override */
+ print(str) {
+ this.xtermInternal_.print(str);
+ }
+
+ /** @override */
+ wipeContents() {
+ this.term.clear();
+ }
+
+ /** @override */
+ newLine() {
+ this.xtermInternal_.newLine();
+ }
+
+ /** @override */
+ cursorLeft(number) {
+ this.xtermInternal_.cursorLeft(number ?? 1);
+ }
+
/**
* Install stubs for stuff that we haven't implemented yet so that the code
* still runs.
@@ -405,6 +436,10 @@
return true;
});
+
+ this.xtermInternal_.installTmuxControlModeHandler(
+ (data) => this.onTmuxControlModeLine(data));
+ this.xtermInternal_.installEscKHandler();
}
/**
@@ -412,18 +447,22 @@
*
* @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
* UTF-8 data
+ * @param {function()=} callback Optional callback that fires when the data
+ * was processed by the parser.
*/
- write(data) {
- this.term.write(data);
+ write(data, callback) {
+ this.term.write(data, callback);
}
/**
* Like `this.write()` but also write a line break.
*
* @param {string|!Uint8Array} data
+ * @param {function()=} callback Optional callback that fires when the data
+ * was processed by the parser.
*/
- writeln(data) {
- this.term.writeln(data);
+ writeln(data, callback) {
+ this.term.writeln(data, callback);
}
get screenSize() {
@@ -455,7 +494,6 @@
this.inited_ = true;
this.term.open(elem);
- this.scheduleFit_();
if (this.enableWebGL_) {
this.term.loadAddon(new WebglAddon());
}
@@ -485,6 +523,7 @@
}
});
+ await this.scheduleFit_();
this.onTerminalReady();
})();
}
@@ -620,12 +659,9 @@
return Math.floor((size - 2 * screenPaddingSize) / cellSize);
};
- // Unfortunately, it looks like we have to use private API from xterm.js.
- // Maybe we should patch the FitAddon so that it works for us.
- const dimensions = this.term._core._renderService.dimensions;
- const cols = calc(this.container_.offsetWidth, dimensions.actualCellWidth);
- const rows = calc(this.container_.offsetHeight,
- dimensions.actualCellHeight);
+ const cellDimensions = this.xtermInternal_.getActualCellDimensions();
+ const cols = calc(this.container_.offsetWidth, cellDimensions.width);
+ const rows = calc(this.container_.offsetHeight, cellDimensions.height);
if (cols >= 0 && rows >= 0) {
this.term.resize(cols, rows);
}
@@ -847,21 +883,6 @@
set(modifiers | Modifier.Shift, keyCode, func);
};
- // Temporary shortcut to refresh the rendering in case of rendering errors.
- // TODO(lxj): remove after this is fixed:
- // https://github.com/xtermjs/xterm.js/issues/3878
- set(Modifier.Ctrl | Modifier.Shift, keyCodes.L,
- /** @suppress {missingProperties} */
- () => {
- this.scheduleRefreshFont_();
- // Refresh the cursor layer.
- if (this.enableWebGL_) {
- this.term?._core?._renderService?._renderer?._renderLayers[1]
- ?._clearAll();
- }
- },
- );
-
// Ctrl+/
set(Modifier.Ctrl, 191, (ev) => {
ev.preventDefault();
@@ -961,6 +982,27 @@
(v) => fontManager.loadFont(/** @type {string} */(v)));
});
}
+
+ /**
+ * Write data to the terminal.
+ *
+ * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
+ * UTF-8 data
+ * @param {function()=} callback Optional callback that fires when the data
+ * was processed by the parser.
+ */
+ write(data, callback) {
+ if (typeof data === 'string') {
+ this.io.print(data);
+ } else {
+ this.io.writeUTF8(data);
+ }
+ // Hterm processes the data synchronously, so we can call the callback
+ // immediately.
+ if (callback) {
+ setTimeout(callback);
+ }
+ }
}
/**
diff --git a/terminal/js/terminal_emulator_tests.js b/terminal/js/terminal_emulator_tests.js
index 7949af0..3c2d980 100644
--- a/terminal/js/terminal_emulator_tests.js
+++ b/terminal/js/terminal_emulator_tests.js
@@ -20,16 +20,11 @@
parser: {
registerOscHandler: () => {},
},
- _core: {
- _renderService: {
- dimensions: {
- actualCellWidth: 10.5,
- actualCellHeight: 20.5,
- },
- },
- },
}),
fontManager: new MockObject(),
+ xtermInternal: new MockObject({
+ getActualCellDimensions: () => ({width: 9, height: 22}),
+ }),
};
const testParams = {};
for (const prop in this.mocks) {
diff --git a/terminal/js/terminal_xterm_internal.js b/terminal/js/terminal_xterm_internal.js
new file mode 100644
index 0000000..21347a1
--- /dev/null
+++ b/terminal/js/terminal_xterm_internal.js
@@ -0,0 +1,192 @@
+// Copyright 2022 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Implements XtermInternal, which interacts with the internal of
+ * xterm.js to provide extra functionalities. Unlike the public APIs, the
+ * internal of xterm.js is not stable, so we should try to minimize this file
+ * and have good test coverage.
+ */
+
+import {delayedScheduler} from './terminal_common.js';
+import {Terminal} from './xterm.js';
+
+const BUFFER_SIZE = 4096;
+
+/**
+ * @typedef {{
+ * params: !Int32Array,
+ * length: number,
+ * }}
+ */
+let IParams;
+
+/**
+ * A handler for tmux's DCS P sequence.
+ *
+ * Also see IDcsHandler at
+ * https://github.com/xtermjs/xterm.js/blob/2659de229173acf883f58401257d64aecc4138e1/src/common/parser/Types.d.ts#L79
+ */
+class TmuxDcsPHandler {
+ /**
+ * @param {function(?string)} onTmuxControlModeLine See
+ * `hterm.Terminal.onTmuxControlModeLine`.
+ */
+ constructor(onTmuxControlModeLine) {
+ this.onTmuxControlModeLine_ = onTmuxControlModeLine;
+ /**
+ * The buffer to hold an incomplete tmux line. It is set to null if tmux
+ * control mode is not active.
+ *
+ * @type {?string}
+ */
+ this.buffer_ = null;
+ }
+
+ /** @param {!IParams} params */
+ hook(params) {
+ if (params.length === 1 && params.params[0] === 1000) {
+ this.buffer_ = '';
+ return;
+ }
+ console.warn('Unknown DCS P sequence. Params:',
+ params.params.slice(0, params.length));
+ }
+
+ /**
+ * @param {!Uint32Array} data
+ * @param {number} start
+ * @param {number} end
+ */
+ put(data, start, end) {
+ data = data.subarray(start, end);
+ if (this.buffer_ === null) {
+ return;
+ }
+
+ for (const code of data) {
+ const c = String.fromCodePoint(code);
+ if (c === '\n' && this.buffer_.slice(-1) === '\r') {
+ this.onTmuxControlModeLine_(this.buffer_.slice(0, -1));
+ this.buffer_ = '';
+ continue;
+ }
+ this.buffer_ += String.fromCodePoint(code);
+ }
+ }
+
+ /** @param {boolean} success */
+ unhook(success) {
+ if (this.buffer_ !== null) {
+ if (this.buffer_) {
+ console.warn('Unexpected tmux data before ST', {data: this.buffer_});
+ }
+ this.onTmuxControlModeLine_(null);
+ }
+ this.buffer_ = null;
+ }
+}
+
+
+export class XtermInternal {
+ /**
+ * @param {!Terminal} terminal
+ * @suppress {missingProperties}
+ */
+ constructor(terminal) {
+ this.terminal_ = terminal;
+
+ this.core_ = /** @type {{
+ _renderService: {
+ dimensions: {
+ actualCellHeight: number,
+ actualCellWidth: number,
+ },
+ },
+ _inputHandler: {
+ nextLine: function(),
+ print: function(!Uint32Array, number, number),
+ _moveCursor: function(number, number),
+ _parser: {
+ registerDcsHandler: function(!Object, !TmuxDcsPHandler),
+ _transitions: {
+ add: function(number, number, number, number),
+ },
+ }
+ },
+ }} */(this.terminal_._core);
+
+ this.encodeBuffer_ = new Uint32Array(BUFFER_SIZE);
+ this.scheduleFullRefresh_ = delayedScheduler(
+ () => this.terminal_.refresh(0, this.terminal_.rows), 10);
+ }
+
+ /**
+ * @return {{width: number, height: number}}
+ */
+ getActualCellDimensions() {
+ const dimensions = this.core_._renderService.dimensions;
+ return {
+ width: dimensions.actualCellWidth,
+ height: dimensions.actualCellHeight,
+ };
+ }
+
+ /**
+ * See hterm.Terminal.print.
+ *
+ * @param {string} str
+ */
+ print(str) {
+ let bufferLength = 0;
+ for (const c of str) {
+ this.encodeBuffer_[bufferLength++] = c.codePointAt(0);
+ if (bufferLength === BUFFER_SIZE) {
+ // The buffer is full. Let's send the data now.
+ this.core_._inputHandler.print(this.encodeBuffer_, 0, bufferLength);
+ bufferLength = 0;
+ }
+ }
+ this.core_._inputHandler.print(this.encodeBuffer_, 0, bufferLength);
+ this.scheduleFullRefresh_();
+ }
+
+ newLine() {
+ this.core_._inputHandler.nextLine();
+ }
+
+ /**
+ * @param {number} number
+ */
+ cursorLeft(number) {
+ this.core_._inputHandler._moveCursor(-number, 0);
+ this.scheduleFullRefresh_();
+ }
+
+ /**
+ * Install a ESC k (set window name) handler for tmux. The data in the
+ * sequence is ignored.
+ */
+ installEscKHandler() {
+ this.core_._inputHandler._parser._transitions.add(
+ // k
+ 0x6b,
+ // ParserState.ESCAPE,
+ 1,
+ // ParserAction.IGNORE
+ 0,
+ // ParserState.DCS_IGNORE
+ 11,
+ );
+ }
+
+ /**
+ * @param {function(?string)} onTmuxControlModeLine See
+ * `hterm.Terminal.onTmuxControlModeLine`.
+ */
+ installTmuxControlModeHandler(onTmuxControlModeLine) {
+ this.core_._inputHandler._parser.registerDcsHandler({final: 'p'},
+ new TmuxDcsPHandler(onTmuxControlModeLine));
+ }
+}
diff --git a/terminal/js/terminal_xterm_internal_tests.js b/terminal/js/terminal_xterm_internal_tests.js
new file mode 100644
index 0000000..00d2c2b
--- /dev/null
+++ b/terminal/js/terminal_xterm_internal_tests.js
@@ -0,0 +1,112 @@
+// Copyright 2022 The ChromiumOS Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/**
+ * @fileoverview Tests for terminal_xterm_internal.js
+ */
+
+import {Terminal} from './xterm.js';
+import {XtermInternal} from './terminal_xterm_internal.js';
+
+const COLS = 80;
+const ROWS = 24;
+
+describe('terminal_xterm_internal.js', function() {
+ beforeEach(function() {
+ this.elem = document.createElement('div');
+ this.elem.style.height = '500px';
+ this.elem.style.width = '500px';
+ document.body.appendChild(this.elem);
+
+ this.terminal = new Terminal({cols: COLS, rows: ROWS,
+ allowProposedApi: true});
+ this.terminal.open(this.elem);
+ this.terminal.options.fontFamily = '"Noto Sans Mono"';
+ this.terminal.options.fontSize = 16;
+ this.xtermInternal = new XtermInternal(this.terminal);
+
+ this.write = async (content) => {
+ return new Promise((resolve) => this.terminal.write(content, resolve));
+ };
+ });
+
+ afterEach(function() {
+ this.terminal.dispose();
+ document.body.removeChild(this.elem);
+ });
+
+ it('getActualCellDimensions()', async function() {
+ const {width, height} = this.xtermInternal.getActualCellDimensions();
+ assert.isAbove(width, 0);
+ assert.isAbove(height, 0);
+ });
+
+ it('print()', async function() {
+ this.xtermInternal.print('hello world');
+ assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+ 'hello world');
+ });
+
+ it('newLine()', async function() {
+ await this.write('012');
+ const buffer = this.terminal.buffer.active;
+ assert.equal(buffer.cursorX, 3);
+ assert.equal(buffer.cursorY, 0);
+ this.xtermInternal.newLine();
+ assert.equal(buffer.cursorX, 0);
+ assert.equal(buffer.cursorY, 1);
+ });
+
+ it('cursorLeft()', async function() {
+ await this.write('012');
+ const buffer = this.terminal.buffer.active;
+ assert.equal(buffer.cursorX, 3);
+ assert.equal(buffer.cursorY, 0);
+ this.xtermInternal.cursorLeft(1);
+ assert.equal(this.terminal.buffer.active.cursorX, 2);
+ assert.equal(buffer.cursorY, 0);
+ });
+
+ it('installEscKHandler()', async function() {
+ await this.write('\x1bkhello world\x1b\\');
+ assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+ 'hello world',
+ 'before installing the handler, the string will be printed');
+
+ this.xtermInternal.installEscKHandler();
+ await this.write('abc\x1bk1234\x1b\\def');
+ assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+ 'hello worldabcdef',
+ 'after installing the handler, the string should be ignored');
+ });
+
+ it('installTmuxControlModeHandler()', async function() {
+ const tmuxLines = [];
+
+ this.xtermInternal.installTmuxControlModeHandler((line) => {
+ tmuxLines.push(line);
+ });
+
+ for (const input of [
+ 'hello world\x1bP',
+ '1000phello ',
+ 'tmux\r',
+ '\nhello',
+ ' again\r\nbye',
+ ]) {
+ await this.write(input);
+ }
+
+ assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+ 'hello world');
+ assert.deepEqual(tmuxLines, ['hello tmux', 'hello again']);
+ tmuxLines.length = 0;
+
+ await this.write(' tmux\r\n\x1b\\abcd');
+ assert.deepEqual(tmuxLines, ['bye tmux', null]);
+
+ assert.equal(this.terminal.buffer.active.getLine(0).translateToString(true),
+ 'hello worldabcd');
+ });
+});