terminal: add custom key handler for xterm.js
Bug: b/236205389
Change-Id: I43f690614ccf98d265a29935533c1b39b38b8837
Reviewed-on: https://chromium-review.googlesource.com/c/apps/libapps/+/3864725
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
index b763c0e..4568fdc 100644
--- a/terminal/js/terminal_emulator.js
+++ b/terminal/js/terminal_emulator.js
@@ -15,6 +15,48 @@
import {ICON_COPY} from './terminal_icons.js';
import {Terminal, FitAddon, WebglAddon} from './xterm.js';
+
+/** @enum {number} */
+export const Modifier = {
+ Shift: 1 << 0,
+ Alt: 1 << 1,
+ Ctrl: 1 << 2,
+ Meta: 1 << 3,
+};
+
+// This is just a static map from key names to key codes. It helps make the code
+// a bit more readable.
+const keyCodes = hterm.Parser.identifiers.keyCodes;
+
+/**
+ * Encode a key combo (i.e. modifiers + a normal key) to an unique number.
+ *
+ * @param {number} modifiers
+ * @param {number} keyCode
+ * @return {number}
+ */
+export function encodeKeyCombo(modifiers, keyCode) {
+ return keyCode << 4 | modifiers;
+}
+
+const OS_DEFAULT_BINDINGS = [
+ // Submit feedback.
+ encodeKeyCombo(Modifier.Alt | Modifier.Shift, keyCodes.I),
+ // Toggle chromevox.
+ encodeKeyCombo(Modifier.Ctrl | Modifier.Alt, keyCodes.Z),
+ // Switch input method.
+ encodeKeyCombo(Modifier.Ctrl, keyCodes.SPACE),
+
+ // Dock window left/right.
+ encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_LEFT),
+ encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_RIGHT),
+
+ // Maximize/minimize window.
+ encodeKeyCombo(Modifier.Alt, keyCodes.EQUAL),
+ encodeKeyCombo(Modifier.Alt, keyCodes.MINUS),
+];
+
+
const ANSI_COLOR_NAMES = [
'black',
'red',
@@ -48,6 +90,16 @@
export let XtermTerminalTestParams;
/**
+ * Compute a control character for a given character.
+ *
+ * @param {string} ch
+ * @return {string}
+ */
+function ctl(ch) {
+ return String.fromCharCode(ch.charCodeAt(0) - 64);
+}
+
+/**
* A "terminal io" class for xterm. We don't want the vanilla hterm.Terminal.IO
* because it always convert utf8 data to strings, which is not necessary for
* xterm.
@@ -101,11 +153,16 @@
* }} args
*/
constructor({storage, profileId, enableWebGL, testParams}) {
+ this.ctrlCKeyDownHandler_ = this.ctrlCKeyDownHandler_.bind(this);
+ this.ctrlVKeyDownHandler_ = this.ctrlVKeyDownHandler_.bind(this);
+ this.zoomKeyDownHandler_ = this.zoomKeyDownHandler_.bind(this);
+
this.profileId_ = profileId;
/** @type {!hterm.PreferenceManager} */
this.prefs_ = new hterm.PreferenceManager(storage, profileId);
this.enableWebGL_ = enableWebGL;
+ // TODO: we should probably pass the initial prefs to the ctor.
this.term = testParams?.term || new Terminal();
this.fontManager_ = testParams?.fontManager || fontManager;
this.fitAddon = testParams?.fitAddon || new FitAddon();
@@ -127,7 +184,27 @@
// prompt, which only listens to onVTKeystroke().
this.term.onData((data) => this.io.onVTKeystroke(data));
this.term.onTitleChange((title) => document.title = title);
- this.term.onSelectionChange(() => this.onSelectionChange_());
+ this.term.onSelectionChange(() => this.copySelection_());
+
+ /**
+ * A mapping from key combo (see encodeKeyCombo()) to a handler function.
+ *
+ * If a key combo is in the map:
+ *
+ * - The handler instead of xterm.js will handle the keydown event.
+ * - Keyup and keypress will be ignored by both us and xterm.js.
+ *
+ * We re-generate this map every time a relevant pref value is changed. This
+ * is ok because pref changes are rare.
+ *
+ * @type {!Map<number, function(!KeyboardEvent)>}
+ */
+ this.keyDownHandlers_ = new Map();
+ this.scheduleResetKeyDownHandlers_ =
+ delayedScheduler(() => this.resetKeyDownHandlers_(), 250);
+
+ this.term.attachCustomKeyEventHandler(
+ this.customKeyEventHandler_.bind(this));
this.io = new XtermTerminalIO(this);
this.notificationCenter_ = null;
@@ -337,6 +414,12 @@
this.updateTheme_(colors);
},
});
+
+ for (const name of ['keybindings-os-defaults', 'pass-ctrl-n', 'pass-ctrl-t',
+ 'pass-ctrl-w', 'pass-ctrl-tab', 'pass-ctrl-number', 'pass-alt-number',
+ 'ctrl-plus-minus-zero-zoom', 'ctrl-c-copy', 'ctrl-v-paste']) {
+ this.prefs_.addObserver(name, this.scheduleResetKeyDownHandlers_);
+ }
}
/**
@@ -379,7 +462,7 @@
}
}
- onSelectionChange_() {
+ copySelection_() {
const selection = this.term.getSelection();
if (!selection) {
return;
@@ -434,6 +517,197 @@
this.pendingFont_ = null;
this.scheduleFit_();
}
+
+ /**
+ * @param {!KeyboardEvent} ev
+ * @return {boolean} Return false if xterm.js should not handle the key event.
+ */
+ customKeyEventHandler_(ev) {
+ const modifiers = (ev.shiftKey ? Modifier.Shift : 0) |
+ (ev.altKey ? Modifier.Alt : 0) |
+ (ev.ctrlKey ? Modifier.Ctrl : 0) |
+ (ev.metaKey ? Modifier.Meta : 0);
+ const handler = this.keyDownHandlers_.get(
+ encodeKeyCombo(modifiers, ev.keyCode));
+ if (handler) {
+ if (ev.type === 'keydown') {
+ handler(ev);
+ }
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * A keydown handler for zoom-related keys.
+ *
+ * @param {!KeyboardEvent} ev
+ */
+ zoomKeyDownHandler_(ev) {
+ ev.preventDefault();
+
+ if (this.prefs_.get('ctrl-plus-minus-zero-zoom') === ev.shiftKey) {
+ // The only one with a control code.
+ if (ev.keyCode === keyCodes.MINUS) {
+ this.io.onVTKeystroke('\x1f');
+ }
+ return;
+ }
+
+ let newFontSize;
+ switch (ev.keyCode) {
+ case keyCodes.ZERO:
+ newFontSize = this.prefs_.get('font-size');
+ break;
+ case keyCodes.MINUS:
+ newFontSize = this.term.options.fontSize - 1;
+ break;
+ default:
+ newFontSize = this.term.options.fontSize + 1;
+ break;
+ }
+
+ this.updateOption_('fontSize', Math.max(1, newFontSize));
+ }
+
+ /** @param {!KeyboardEvent} ev */
+ ctrlCKeyDownHandler_(ev) {
+ ev.preventDefault();
+ if (this.prefs_.get('ctrl-c-copy') !== ev.shiftKey &&
+ this.term.hasSelection()) {
+ this.copySelection_();
+ return;
+ }
+
+ this.io.onVTKeystroke('\x03');
+ }
+
+ /** @param {!KeyboardEvent} ev */
+ ctrlVKeyDownHandler_(ev) {
+ if (this.prefs_.get('ctrl-v-paste') !== ev.shiftKey) {
+ // Don't do anything and let the browser handles the key.
+ return;
+ }
+
+ ev.preventDefault();
+ this.io.onVTKeystroke('\x16');
+ }
+
+ resetKeyDownHandlers_() {
+ this.keyDownHandlers_.clear();
+
+ /**
+ * Don't do anything and let the browser handles the key.
+ *
+ * @param {!KeyboardEvent} ev
+ */
+ const noop = (ev) => {};
+
+ /**
+ * @param {number} modifiers
+ * @param {number} keyCode
+ * @param {function(!KeyboardEvent)} func
+ */
+ const set = (modifiers, keyCode, func) => {
+ this.keyDownHandlers_.set(encodeKeyCombo(modifiers, keyCode),
+ func);
+ };
+
+ /**
+ * @param {number} modifiers
+ * @param {number} keyCode
+ * @param {function(!KeyboardEvent)} func
+ */
+ const setWithShiftVersion = (modifiers, keyCode, func) => {
+ set(modifiers, keyCode, func);
+ set(modifiers | Modifier.Shift, keyCode, func);
+ };
+
+
+ // Ctrl+/
+ set(Modifier.Ctrl, 191, (ev) => {
+ ev.preventDefault();
+ this.io.onVTKeystroke(ctl('_'));
+ });
+
+ // Settings page.
+ set(Modifier.Ctrl | Modifier.Shift, keyCodes.P, (ev) => {
+ ev.preventDefault();
+ chrome.terminalPrivate.openOptionsPage(() => {});
+ });
+
+ if (this.prefs_.get('keybindings-os-defaults')) {
+ for (const binding of OS_DEFAULT_BINDINGS) {
+ this.keyDownHandlers_.set(binding, noop);
+ }
+ }
+
+ /** @param {!KeyboardEvent} ev */
+ const newWindow = (ev) => {
+ ev.preventDefault();
+ chrome.terminalPrivate.openWindow();
+ };
+ set(Modifier.Ctrl | Modifier.Shift, keyCodes.N, newWindow);
+ if (this.prefs_.get('pass-ctrl-n')) {
+ set(Modifier.Ctrl, keyCodes.N, newWindow);
+ }
+
+ if (this.prefs_.get('pass-ctrl-t')) {
+ setWithShiftVersion(Modifier.Ctrl, keyCodes.T, noop);
+ }
+
+ if (this.prefs_.get('pass-ctrl-w')) {
+ setWithShiftVersion(Modifier.Ctrl, keyCodes.W, noop);
+ }
+
+ if (this.prefs_.get('pass-ctrl-tab')) {
+ setWithShiftVersion(Modifier.Ctrl, keyCodes.TAB, noop);
+ }
+
+ const passCtrlNumber = this.prefs_.get('pass-ctrl-number');
+
+ /**
+ * Set a handler for the key combo ctrl+<number>.
+ *
+ * @param {number} number 1 to 9
+ * @param {string} controlCode The control code to send if we don't want to
+ * let the browser to handle it.
+ */
+ const setCtrlNumberHandler = (number, controlCode) => {
+ let func = noop;
+ if (!passCtrlNumber) {
+ func = (ev) => {
+ ev.preventDefault();
+ this.io.onVTKeystroke(controlCode);
+ };
+ }
+ set(Modifier.Ctrl, keyCodes.ZERO + number, func);
+ };
+
+ setCtrlNumberHandler(1, '1');
+ setCtrlNumberHandler(2, ctl('@'));
+ setCtrlNumberHandler(3, ctl('['));
+ setCtrlNumberHandler(4, ctl('\\'));
+ setCtrlNumberHandler(5, ctl(']'));
+ setCtrlNumberHandler(6, ctl('^'));
+ setCtrlNumberHandler(7, ctl('_'));
+ setCtrlNumberHandler(8, '\x7f');
+ setCtrlNumberHandler(9, '9');
+
+ if (this.prefs_.get('pass-alt-number')) {
+ for (let keyCode = keyCodes.ZERO; keyCode <= keyCodes.NINE; ++keyCode) {
+ set(Modifier.Alt, keyCode, noop);
+ }
+ }
+
+ for (const keyCode of [keyCodes.ZERO, keyCodes.MINUS, keyCodes.EQUAL]) {
+ setWithShiftVersion(Modifier.Ctrl, keyCode, this.zoomKeyDownHandler_);
+ }
+
+ setWithShiftVersion(Modifier.Ctrl, keyCodes.C, this.ctrlCKeyDownHandler_);
+ setWithShiftVersion(Modifier.Ctrl, keyCodes.V, this.ctrlVKeyDownHandler_);
+ }
}
class HtermTerminal extends hterm.Terminal {