blob: f4504ba9939e0d815937ede1a03a372c6428f835 [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
Jason Lin6a402a72022-08-25 16:07:02 +100012import {LitElement, css, html} from './lit.js';
Jason Lin21d854f2022-08-22 14:49:59 +100013import {FontManager, ORIGINAL_URL, TERMINAL_EMULATORS, delayedScheduler,
14 fontManager, getOSInfo, sleep} from './terminal_common.js';
Jason Lin6a402a72022-08-25 16:07:02 +100015import {ICON_COPY} from './terminal_icons.js';
Jason Lin83707c92022-09-20 19:09:41 +100016import {TerminalTooltip} from './terminal_tooltip.js';
Jason Linc2504ae2022-09-02 13:03:31 +100017import {Terminal, Unicode11Addon, WebLinksAddon, WebglAddon}
Jason Lin4de4f382022-09-01 14:10:18 +100018 from './xterm.js';
Jason Linca61ffb2022-08-03 19:37:12 +100019
Jason Lin5690e752022-08-30 15:36:45 +100020
21/** @enum {number} */
22export const Modifier = {
23 Shift: 1 << 0,
24 Alt: 1 << 1,
25 Ctrl: 1 << 2,
26 Meta: 1 << 3,
27};
28
29// This is just a static map from key names to key codes. It helps make the code
30// a bit more readable.
31const keyCodes = hterm.Parser.identifiers.keyCodes;
32
33/**
34 * Encode a key combo (i.e. modifiers + a normal key) to an unique number.
35 *
36 * @param {number} modifiers
37 * @param {number} keyCode
38 * @return {number}
39 */
40export function encodeKeyCombo(modifiers, keyCode) {
41 return keyCode << 4 | modifiers;
42}
43
44const OS_DEFAULT_BINDINGS = [
45 // Submit feedback.
46 encodeKeyCombo(Modifier.Alt | Modifier.Shift, keyCodes.I),
47 // Toggle chromevox.
48 encodeKeyCombo(Modifier.Ctrl | Modifier.Alt, keyCodes.Z),
49 // Switch input method.
50 encodeKeyCombo(Modifier.Ctrl, keyCodes.SPACE),
51
52 // Dock window left/right.
53 encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_LEFT),
54 encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_RIGHT),
55
56 // Maximize/minimize window.
57 encodeKeyCombo(Modifier.Alt, keyCodes.EQUAL),
58 encodeKeyCombo(Modifier.Alt, keyCodes.MINUS),
59];
60
61
Jason Linca61ffb2022-08-03 19:37:12 +100062const ANSI_COLOR_NAMES = [
63 'black',
64 'red',
65 'green',
66 'yellow',
67 'blue',
68 'magenta',
69 'cyan',
70 'white',
71 'brightBlack',
72 'brightRed',
73 'brightGreen',
74 'brightYellow',
75 'brightBlue',
76 'brightMagenta',
77 'brightCyan',
78 'brightWhite',
79];
80
Jason Linca61ffb2022-08-03 19:37:12 +100081/**
Jason Linabad7562022-08-22 14:49:05 +100082 * @typedef {{
83 * term: !Terminal,
84 * fontManager: !FontManager,
Jason Linabad7562022-08-22 14:49:05 +100085 * }}
86 */
87export let XtermTerminalTestParams;
88
89/**
Jason Lin5690e752022-08-30 15:36:45 +100090 * Compute a control character for a given character.
91 *
92 * @param {string} ch
93 * @return {string}
94 */
95function ctl(ch) {
96 return String.fromCharCode(ch.charCodeAt(0) - 64);
97}
98
99/**
Jason Lin21d854f2022-08-22 14:49:59 +1000100 * A "terminal io" class for xterm. We don't want the vanilla hterm.Terminal.IO
101 * because it always convert utf8 data to strings, which is not necessary for
102 * xterm.
103 */
104class XtermTerminalIO extends hterm.Terminal.IO {
105 /** @override */
106 writeUTF8(buffer) {
107 this.terminal_.write(new Uint8Array(buffer));
108 }
109
110 /** @override */
111 writelnUTF8(buffer) {
112 this.terminal_.writeln(new Uint8Array(buffer));
113 }
114
115 /** @override */
116 print(string) {
117 this.terminal_.write(string);
118 }
119
120 /** @override */
121 writeUTF16(string) {
122 this.print(string);
123 }
124
125 /** @override */
126 println(string) {
127 this.terminal_.writeln(string);
128 }
129
130 /** @override */
131 writelnUTF16(string) {
132 this.println(string);
133 }
134}
135
136/**
Jason Lin83707c92022-09-20 19:09:41 +1000137 * A custom link handler that:
138 *
139 * - Shows a tooltip with the url on a OSC 8 link. This is following what hterm
140 * is doing. Also, showing the tooltip is better for the security of the user
141 * because the link can have arbitrary text.
142 * - Uses our own way to open the window.
143 */
144class LinkHandler {
145 /**
146 * @param {!Terminal} term
147 */
148 constructor(term) {
149 this.term_ = term;
150 /** @type {?TerminalTooltip} */
151 this.tooltip_ = null;
152 }
153
154 /**
155 * @return {!TerminalTooltip}
156 */
157 getTooltip_() {
158 if (!this.tooltip_) {
159 this.tooltip_ = /** @type {!TerminalTooltip} */(
160 document.createElement('terminal-tooltip'));
161 this.tooltip_.classList.add('xterm-hover');
162 lib.notNull(this.term_.element).appendChild(this.tooltip_);
163 }
164 return this.tooltip_;
165 }
166
167 /**
168 * @param {!MouseEvent} ev
169 * @param {string} url
170 * @param {!Object} range
171 */
172 activate(ev, url, range) {
173 lib.f.openWindow(url, '_blank');
174 }
175
176 /**
177 * @param {!MouseEvent} ev
178 * @param {string} url
179 * @param {!Object} range
180 */
181 hover(ev, url, range) {
182 this.getTooltip_().show(url, {x: ev.clientX, y: ev.clientY});
183 }
184
185 /**
186 * @param {!MouseEvent} ev
187 * @param {string} url
188 * @param {!Object} range
189 */
190 leave(ev, url, range) {
191 this.getTooltip_().hide();
192 }
193}
194
195/**
Jason Linca61ffb2022-08-03 19:37:12 +1000196 * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
197 * so that it can be used in existing code.
198 *
Jason Linca61ffb2022-08-03 19:37:12 +1000199 * @extends {hterm.Terminal}
200 * @unrestricted
201 */
Jason Linabad7562022-08-22 14:49:05 +1000202export class XtermTerminal {
Jason Linca61ffb2022-08-03 19:37:12 +1000203 /**
204 * @param {{
205 * storage: !lib.Storage,
206 * profileId: string,
207 * enableWebGL: boolean,
Jason Linabad7562022-08-22 14:49:05 +1000208 * testParams: (!XtermTerminalTestParams|undefined),
Jason Linca61ffb2022-08-03 19:37:12 +1000209 * }} args
210 */
Jason Linabad7562022-08-22 14:49:05 +1000211 constructor({storage, profileId, enableWebGL, testParams}) {
Jason Lin5690e752022-08-30 15:36:45 +1000212 this.ctrlCKeyDownHandler_ = this.ctrlCKeyDownHandler_.bind(this);
213 this.ctrlVKeyDownHandler_ = this.ctrlVKeyDownHandler_.bind(this);
214 this.zoomKeyDownHandler_ = this.zoomKeyDownHandler_.bind(this);
215
Jason Lin8de3d282022-09-01 21:29:05 +1000216 this.inited_ = false;
Jason Lin21d854f2022-08-22 14:49:59 +1000217 this.profileId_ = profileId;
Jason Linca61ffb2022-08-03 19:37:12 +1000218 /** @type {!hterm.PreferenceManager} */
219 this.prefs_ = new hterm.PreferenceManager(storage, profileId);
220 this.enableWebGL_ = enableWebGL;
221
Jason Lin5690e752022-08-30 15:36:45 +1000222 // TODO: we should probably pass the initial prefs to the ctor.
Jason Linfc8a3722022-09-07 17:49:18 +1000223 this.term = testParams?.term || new Terminal({allowProposedApi: true});
Jason Linabad7562022-08-22 14:49:05 +1000224 this.fontManager_ = testParams?.fontManager || fontManager;
Jason Linabad7562022-08-22 14:49:05 +1000225
Jason Linc2504ae2022-09-02 13:03:31 +1000226 /** @type {?Element} */
227 this.container_;
228
229 this.scheduleFit_ = delayedScheduler(() => this.fit_(),
Jason Linabad7562022-08-22 14:49:05 +1000230 testParams ? 0 : 250);
231
Jason Lin83707c92022-09-20 19:09:41 +1000232 this.term.loadAddon(
233 new WebLinksAddon((e, uri) => lib.f.openWindow(uri, '_blank')));
Jason Lin4de4f382022-09-01 14:10:18 +1000234 this.term.loadAddon(new Unicode11Addon());
235 this.term.unicode.activeVersion = '11';
236
Jason Linabad7562022-08-22 14:49:05 +1000237 this.pendingFont_ = null;
238 this.scheduleRefreshFont_ = delayedScheduler(
239 () => this.refreshFont_(), 100);
240 document.fonts.addEventListener('loadingdone',
241 () => this.onFontLoadingDone_());
Jason Linca61ffb2022-08-03 19:37:12 +1000242
243 this.installUnimplementedStubs_();
Jason Line9231bc2022-09-01 13:54:02 +1000244 this.installEscapeSequenceHandlers_();
Jason Linca61ffb2022-08-03 19:37:12 +1000245
Jason Lin21d854f2022-08-22 14:49:59 +1000246 this.term.onResize(({cols, rows}) => this.io.onTerminalResize(cols, rows));
247 // We could also use `this.io.sendString()` except for the nassh exit
248 // prompt, which only listens to onVTKeystroke().
249 this.term.onData((data) => this.io.onVTKeystroke(data));
Jason Lin80e69132022-09-02 16:31:43 +1000250 this.term.onBinary((data) => this.io.onVTKeystroke(data));
Jason Lin6a402a72022-08-25 16:07:02 +1000251 this.term.onTitleChange((title) => document.title = title);
Jason Lin5690e752022-08-30 15:36:45 +1000252 this.term.onSelectionChange(() => this.copySelection_());
253
254 /**
255 * A mapping from key combo (see encodeKeyCombo()) to a handler function.
256 *
257 * If a key combo is in the map:
258 *
259 * - The handler instead of xterm.js will handle the keydown event.
260 * - Keyup and keypress will be ignored by both us and xterm.js.
261 *
262 * We re-generate this map every time a relevant pref value is changed. This
263 * is ok because pref changes are rare.
264 *
265 * @type {!Map<number, function(!KeyboardEvent)>}
266 */
267 this.keyDownHandlers_ = new Map();
268 this.scheduleResetKeyDownHandlers_ =
269 delayedScheduler(() => this.resetKeyDownHandlers_(), 250);
270
271 this.term.attachCustomKeyEventHandler(
272 this.customKeyEventHandler_.bind(this));
Jason Linca61ffb2022-08-03 19:37:12 +1000273
Jason Lin21d854f2022-08-22 14:49:59 +1000274 this.io = new XtermTerminalIO(this);
275 this.notificationCenter_ = null;
Jason Lin6a402a72022-08-25 16:07:02 +1000276
277 this.copyNotice_ = null;
278
Jason Lin83707c92022-09-20 19:09:41 +1000279 this.term.options.linkHandler = new LinkHandler(this.term);
Jason Lin6a402a72022-08-25 16:07:02 +1000280 this.term.options.theme = {
Jason Lin461ca562022-09-07 13:53:08 +1000281 // The webgl cursor layer also paints the character under the cursor with
282 // this `cursorAccent` color. We use a completely transparent color here
283 // to effectively disable that.
284 cursorAccent: 'rgba(0, 0, 0, 0)',
285 customGlyphs: true,
Jason Lin2edc25d2022-09-16 15:06:48 +1000286 selectionBackground: 'rgba(174, 203, 250, .6)',
287 selectionInactiveBackground: 'rgba(218, 220, 224, .6)',
Jason Lin6a402a72022-08-25 16:07:02 +1000288 selectionForeground: 'black',
Jason Lin6a402a72022-08-25 16:07:02 +1000289 };
290 this.observePrefs_();
Jason Linca61ffb2022-08-03 19:37:12 +1000291 }
292
293 /**
294 * Install stubs for stuff that we haven't implemented yet so that the code
295 * still runs.
296 */
297 installUnimplementedStubs_() {
298 this.keyboard = {
299 keyMap: {
300 keyDefs: [],
301 },
302 bindings: {
303 clear: () => {},
304 addBinding: () => {},
305 addBindings: () => {},
306 OsDefaults: {},
307 },
308 };
309 this.keyboard.keyMap.keyDefs[78] = {};
310
311 const methodNames = [
Jason Linca61ffb2022-08-03 19:37:12 +1000312 'setAccessibilityEnabled',
313 'setBackgroundImage',
314 'setCursorPosition',
315 'setCursorVisible',
Jason Linca61ffb2022-08-03 19:37:12 +1000316 ];
317
318 for (const name of methodNames) {
319 this[name] = () => console.warn(`${name}() is not implemented`);
320 }
321
322 this.contextMenu = {
323 setItems: () => {
324 console.warn('.contextMenu.setItems() is not implemented');
325 },
326 };
Jason Lin21d854f2022-08-22 14:49:59 +1000327
328 this.vt = {
329 resetParseState: () => {
330 console.warn('.vt.resetParseState() is not implemented');
331 },
332 };
Jason Linca61ffb2022-08-03 19:37:12 +1000333 }
334
Jason Line9231bc2022-09-01 13:54:02 +1000335 installEscapeSequenceHandlers_() {
336 // OSC 52 for copy.
337 this.term.parser.registerOscHandler(52, (args) => {
338 // Args comes in as a single 'clipboard;b64-data' string. The clipboard
339 // parameter is used to select which of the X clipboards to address. Since
340 // we're not integrating with X, we treat them all the same.
341 const parsedArgs = args.match(/^[cps01234567]*;(.*)/);
342 if (!parsedArgs) {
343 return true;
344 }
345
346 let data;
347 try {
348 data = window.atob(parsedArgs[1]);
349 } catch (e) {
350 // If the user sent us invalid base64 content, silently ignore it.
351 return true;
352 }
353 const decoder = new TextDecoder();
354 const bytes = lib.codec.stringToCodeUnitArray(data);
355 this.copyString_(decoder.decode(bytes));
356
357 return true;
358 });
359 }
360
Jason Linca61ffb2022-08-03 19:37:12 +1000361 /**
Jason Lin21d854f2022-08-22 14:49:59 +1000362 * Write data to the terminal.
363 *
364 * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
365 * UTF-8 data
366 */
367 write(data) {
368 this.term.write(data);
369 }
370
371 /**
372 * Like `this.write()` but also write a line break.
373 *
374 * @param {string|!Uint8Array} data
375 */
376 writeln(data) {
377 this.term.writeln(data);
378 }
379
Jason Linca61ffb2022-08-03 19:37:12 +1000380 get screenSize() {
381 return new hterm.Size(this.term.cols, this.term.rows);
382 }
383
384 /**
385 * Don't need to do anything.
386 *
387 * @override
388 */
389 installKeyboard() {}
390
391 /**
392 * @override
393 */
394 decorate(elem) {
Jason Linc2504ae2022-09-02 13:03:31 +1000395 this.container_ = elem;
Jason Lin8de3d282022-09-01 21:29:05 +1000396 (async () => {
397 await new Promise((resolve) => this.prefs_.readStorage(resolve));
398 // This will trigger all the observers to set the terminal options before
399 // we call `this.term.open()`.
400 this.prefs_.notifyAll();
401
Jason Linc2504ae2022-09-02 13:03:31 +1000402 const screenPaddingSize = /** @type {number} */(
403 this.prefs_.get('screen-padding-size'));
404 elem.style.paddingTop = elem.style.paddingLeft = `${screenPaddingSize}px`;
405
Jason Lin8de3d282022-09-01 21:29:05 +1000406 this.inited_ = true;
407 this.term.open(elem);
408
409 this.scheduleFit_();
410 if (this.enableWebGL_) {
411 this.term.loadAddon(new WebglAddon());
412 }
413 this.term.focus();
414 (new ResizeObserver(() => this.scheduleFit_())).observe(elem);
415 // TODO: Make a11y work. Maybe we can just use hterm.AccessibilityReader.
416 this.notificationCenter_ = new hterm.NotificationCenter(document.body);
417
418 this.onTerminalReady();
419 })();
Jason Lin21d854f2022-08-22 14:49:59 +1000420 }
421
422 /** @override */
423 showOverlay(msg, timeout = 1500) {
424 if (this.notificationCenter_) {
425 this.notificationCenter_.show(msg, {timeout});
426 }
427 }
428
429 /** @override */
430 hideOverlay() {
431 if (this.notificationCenter_) {
432 this.notificationCenter_.hide();
433 }
Jason Linca61ffb2022-08-03 19:37:12 +1000434 }
435
436 /** @override */
437 getPrefs() {
438 return this.prefs_;
439 }
440
441 /** @override */
442 getDocument() {
443 return window.document;
444 }
445
Jason Lin21d854f2022-08-22 14:49:59 +1000446 /** @override */
447 reset() {
448 this.term.reset();
Jason Linca61ffb2022-08-03 19:37:12 +1000449 }
450
451 /** @override */
Jason Lin21d854f2022-08-22 14:49:59 +1000452 setProfile(profileId, callback = undefined) {
453 this.prefs_.setProfile(profileId, callback);
Jason Linca61ffb2022-08-03 19:37:12 +1000454 }
455
Jason Lin21d854f2022-08-22 14:49:59 +1000456 /** @override */
457 interpret(string) {
458 this.term.write(string);
Jason Linca61ffb2022-08-03 19:37:12 +1000459 }
460
Jason Lin21d854f2022-08-22 14:49:59 +1000461 /** @override */
462 focus() {
463 this.term.focus();
464 }
Jason Linca61ffb2022-08-03 19:37:12 +1000465
466 /** @override */
467 onOpenOptionsPage() {}
468
469 /** @override */
470 onTerminalReady() {}
471
Jason Lind04bab32022-08-22 14:48:39 +1000472 observePrefs_() {
Jason Lin21d854f2022-08-22 14:49:59 +1000473 // This is for this.notificationCenter_.
474 const setHtermCSSVariable = (name, value) => {
475 document.body.style.setProperty(`--hterm-${name}`, value);
476 };
477
478 const setHtermColorCSSVariable = (name, color) => {
479 const css = lib.notNull(lib.colors.normalizeCSS(color));
480 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
481 setHtermCSSVariable(name, rgb);
482 };
483
484 this.prefs_.addObserver('font-size', (v) => {
Jason Linda56aa92022-09-02 13:01:49 +1000485 this.updateOption_('fontSize', v, true);
Jason Lin21d854f2022-08-22 14:49:59 +1000486 setHtermCSSVariable('font-size', `${v}px`);
487 });
488
Jason Linda56aa92022-09-02 13:01:49 +1000489 // TODO(lxj): support option "lineHeight", "scrollback".
Jason Lind04bab32022-08-22 14:48:39 +1000490 this.prefs_.addObservers(null, {
Jason Linda56aa92022-09-02 13:01:49 +1000491 'audible-bell-sound': (v) => {
492 if (v) {
493 this.updateOption_('bellStyle', 'sound', false);
494 this.updateOption_('bellSound', lib.resource.getDataUrl(
495 'hterm/audio/bell'), false);
496 } else {
497 this.updateOption_('bellStyle', 'none', false);
498 }
499 },
Jason Lind04bab32022-08-22 14:48:39 +1000500 'background-color': (v) => {
501 this.updateTheme_({background: v});
Jason Lin21d854f2022-08-22 14:49:59 +1000502 setHtermColorCSSVariable('background-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000503 },
Jason Lind04bab32022-08-22 14:48:39 +1000504 'color-palette-overrides': (v) => {
505 if (!(v instanceof Array)) {
506 // For terminal, we always expect this to be an array.
507 console.warn('unexpected color palette: ', v);
508 return;
509 }
510 const colors = {};
511 for (let i = 0; i < v.length; ++i) {
512 colors[ANSI_COLOR_NAMES[i]] = v[i];
513 }
514 this.updateTheme_(colors);
515 },
Jason Linda56aa92022-09-02 13:01:49 +1000516 'cursor-blink': (v) => this.updateOption_('cursorBlink', v, false),
517 'cursor-color': (v) => this.updateTheme_({cursor: v}),
518 'cursor-shape': (v) => {
519 let shape;
520 if (v === 'BEAM') {
521 shape = 'bar';
522 } else {
523 shape = v.toLowerCase();
524 }
525 this.updateOption_('cursorStyle', shape, false);
526 },
527 'font-family': (v) => this.updateFont_(v),
528 'foreground-color': (v) => {
Jason Lin461ca562022-09-07 13:53:08 +1000529 this.updateTheme_({foreground: v});
Jason Linda56aa92022-09-02 13:01:49 +1000530 setHtermColorCSSVariable('foreground-color', v);
531 },
Jason Lind04bab32022-08-22 14:48:39 +1000532 });
Jason Lin5690e752022-08-30 15:36:45 +1000533
534 for (const name of ['keybindings-os-defaults', 'pass-ctrl-n', 'pass-ctrl-t',
535 'pass-ctrl-w', 'pass-ctrl-tab', 'pass-ctrl-number', 'pass-alt-number',
536 'ctrl-plus-minus-zero-zoom', 'ctrl-c-copy', 'ctrl-v-paste']) {
537 this.prefs_.addObserver(name, this.scheduleResetKeyDownHandlers_);
538 }
Jason Lind04bab32022-08-22 14:48:39 +1000539 }
540
541 /**
Jason Linc2504ae2022-09-02 13:03:31 +1000542 * Fit the terminal to the containing HTML element.
543 */
544 fit_() {
545 if (!this.inited_) {
546 return;
547 }
548
549 const screenPaddingSize = /** @type {number} */(
550 this.prefs_.get('screen-padding-size'));
551
552 const calc = (size, cellSize) => {
553 return Math.floor((size - 2 * screenPaddingSize) / cellSize);
554 };
555
556 // Unfortunately, it looks like we have to use private API from xterm.js.
557 // Maybe we should patch the FitAddon so that it works for us.
558 const dimensions = this.term._core._renderService.dimensions;
559 const cols = calc(this.container_.offsetWidth, dimensions.actualCellWidth);
560 const rows = calc(this.container_.offsetHeight,
561 dimensions.actualCellHeight);
562 if (cols >= 0 && rows >= 0) {
563 this.term.resize(cols, rows);
564 }
565 }
566
567 /**
Jason Lind04bab32022-08-22 14:48:39 +1000568 * @param {!Object} theme
569 */
570 updateTheme_(theme) {
Jason Lin8de3d282022-09-01 21:29:05 +1000571 const updateTheme = (target) => {
572 for (const [key, value] of Object.entries(theme)) {
573 target[key] = lib.colors.normalizeCSS(value);
574 }
575 };
576
577 // Must use a new theme object to trigger re-render if we have initialized.
578 if (this.inited_) {
579 const newTheme = {...this.term.options.theme};
580 updateTheme(newTheme);
581 this.term.options.theme = newTheme;
582 return;
Jason Lind04bab32022-08-22 14:48:39 +1000583 }
Jason Lin8de3d282022-09-01 21:29:05 +1000584
585 updateTheme(this.term.options.theme);
Jason Lind04bab32022-08-22 14:48:39 +1000586 }
587
588 /**
Jason Linda56aa92022-09-02 13:01:49 +1000589 * Update one xterm.js option. Use updateTheme_()/updateFont_() for
590 * theme/font.
Jason Lind04bab32022-08-22 14:48:39 +1000591 *
592 * @param {string} key
593 * @param {*} value
Jason Linda56aa92022-09-02 13:01:49 +1000594 * @param {boolean} scheduleFit
Jason Lind04bab32022-08-22 14:48:39 +1000595 */
Jason Linda56aa92022-09-02 13:01:49 +1000596 updateOption_(key, value, scheduleFit) {
Jason Lind04bab32022-08-22 14:48:39 +1000597 // TODO: xterm supports updating multiple options at the same time. We
598 // should probably do that.
599 this.term.options[key] = value;
Jason Linda56aa92022-09-02 13:01:49 +1000600 if (scheduleFit) {
601 this.scheduleFit_();
602 }
Jason Lind04bab32022-08-22 14:48:39 +1000603 }
Jason Linabad7562022-08-22 14:49:05 +1000604
605 /**
606 * Called when there is a "fontloadingdone" event. We need this because
607 * `FontManager.loadFont()` does not guarantee loading all the font files.
608 */
609 async onFontLoadingDone_() {
610 // If there is a pending font, the font is going to be refresh soon, so we
611 // don't need to do anything.
Jason Lin8de3d282022-09-01 21:29:05 +1000612 if (this.inited_ && !this.pendingFont_) {
Jason Linabad7562022-08-22 14:49:05 +1000613 this.scheduleRefreshFont_();
614 }
615 }
616
Jason Lin5690e752022-08-30 15:36:45 +1000617 copySelection_() {
Jason Line9231bc2022-09-01 13:54:02 +1000618 this.copyString_(this.term.getSelection());
619 }
620
621 /** @param {string} data */
622 copyString_(data) {
623 if (!data) {
Jason Lin6a402a72022-08-25 16:07:02 +1000624 return;
625 }
Jason Line9231bc2022-09-01 13:54:02 +1000626 navigator.clipboard?.writeText(data);
Jason Lin6a402a72022-08-25 16:07:02 +1000627 if (!this.copyNotice_) {
628 this.copyNotice_ = document.createElement('terminal-copy-notice');
629 }
630 setTimeout(() => this.showOverlay(lib.notNull(this.copyNotice_), 500), 200);
631 }
632
Jason Linabad7562022-08-22 14:49:05 +1000633 /**
634 * Refresh xterm rendering for a font related event.
635 */
636 refreshFont_() {
637 // We have to set the fontFamily option to a different string to trigger the
638 // re-rendering. Appending a space at the end seems to be the easiest
639 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
640 // us.
641 //
642 // TODO: Report a bug to xterm.js and ask for exposing a public function for
643 // the refresh so that we don't need to do this hack.
644 this.term.options.fontFamily += ' ';
645 }
646
647 /**
648 * Update a font.
649 *
650 * @param {string} cssFontFamily
651 */
652 async updateFont_(cssFontFamily) {
Jason Lin6a402a72022-08-25 16:07:02 +1000653 this.pendingFont_ = cssFontFamily;
654 await this.fontManager_.loadFont(cssFontFamily);
655 // Sleep a bit to wait for flushing fontloadingdone events. This is not
656 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
657 // to refresh font unnecessarily in some cases.
658 await sleep(30);
Jason Linabad7562022-08-22 14:49:05 +1000659
Jason Lin6a402a72022-08-25 16:07:02 +1000660 if (this.pendingFont_ !== cssFontFamily) {
661 // `updateFont_()` probably is called again. Abort what we are doing.
662 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
663 ` (expecting ${cssFontFamily})`);
664 return;
665 }
Jason Linabad7562022-08-22 14:49:05 +1000666
Jason Lin6a402a72022-08-25 16:07:02 +1000667 if (this.term.options.fontFamily !== cssFontFamily) {
668 this.term.options.fontFamily = cssFontFamily;
669 } else {
670 // If the font is already the same, refresh font just to be safe.
671 this.refreshFont_();
672 }
673 this.pendingFont_ = null;
674 this.scheduleFit_();
Jason Linabad7562022-08-22 14:49:05 +1000675 }
Jason Lin5690e752022-08-30 15:36:45 +1000676
677 /**
678 * @param {!KeyboardEvent} ev
679 * @return {boolean} Return false if xterm.js should not handle the key event.
680 */
681 customKeyEventHandler_(ev) {
682 const modifiers = (ev.shiftKey ? Modifier.Shift : 0) |
683 (ev.altKey ? Modifier.Alt : 0) |
684 (ev.ctrlKey ? Modifier.Ctrl : 0) |
685 (ev.metaKey ? Modifier.Meta : 0);
686 const handler = this.keyDownHandlers_.get(
687 encodeKeyCombo(modifiers, ev.keyCode));
688 if (handler) {
689 if (ev.type === 'keydown') {
690 handler(ev);
691 }
692 return false;
693 }
694
695 return true;
696 }
697
698 /**
699 * A keydown handler for zoom-related keys.
700 *
701 * @param {!KeyboardEvent} ev
702 */
703 zoomKeyDownHandler_(ev) {
704 ev.preventDefault();
705
706 if (this.prefs_.get('ctrl-plus-minus-zero-zoom') === ev.shiftKey) {
707 // The only one with a control code.
708 if (ev.keyCode === keyCodes.MINUS) {
709 this.io.onVTKeystroke('\x1f');
710 }
711 return;
712 }
713
714 let newFontSize;
715 switch (ev.keyCode) {
716 case keyCodes.ZERO:
717 newFontSize = this.prefs_.get('font-size');
718 break;
719 case keyCodes.MINUS:
720 newFontSize = this.term.options.fontSize - 1;
721 break;
722 default:
723 newFontSize = this.term.options.fontSize + 1;
724 break;
725 }
726
Jason Linda56aa92022-09-02 13:01:49 +1000727 this.updateOption_('fontSize', Math.max(1, newFontSize), true);
Jason Lin5690e752022-08-30 15:36:45 +1000728 }
729
730 /** @param {!KeyboardEvent} ev */
731 ctrlCKeyDownHandler_(ev) {
732 ev.preventDefault();
733 if (this.prefs_.get('ctrl-c-copy') !== ev.shiftKey &&
734 this.term.hasSelection()) {
735 this.copySelection_();
736 return;
737 }
738
739 this.io.onVTKeystroke('\x03');
740 }
741
742 /** @param {!KeyboardEvent} ev */
743 ctrlVKeyDownHandler_(ev) {
744 if (this.prefs_.get('ctrl-v-paste') !== ev.shiftKey) {
745 // Don't do anything and let the browser handles the key.
746 return;
747 }
748
749 ev.preventDefault();
750 this.io.onVTKeystroke('\x16');
751 }
752
753 resetKeyDownHandlers_() {
754 this.keyDownHandlers_.clear();
755
756 /**
757 * Don't do anything and let the browser handles the key.
758 *
759 * @param {!KeyboardEvent} ev
760 */
761 const noop = (ev) => {};
762
763 /**
764 * @param {number} modifiers
765 * @param {number} keyCode
766 * @param {function(!KeyboardEvent)} func
767 */
768 const set = (modifiers, keyCode, func) => {
769 this.keyDownHandlers_.set(encodeKeyCombo(modifiers, keyCode),
770 func);
771 };
772
773 /**
774 * @param {number} modifiers
775 * @param {number} keyCode
776 * @param {function(!KeyboardEvent)} func
777 */
778 const setWithShiftVersion = (modifiers, keyCode, func) => {
779 set(modifiers, keyCode, func);
780 set(modifiers | Modifier.Shift, keyCode, func);
781 };
782
Jason Linf51162f2022-09-01 15:21:55 +1000783 // Temporary shortcut to refresh the rendering in case of rendering errors.
784 // TODO(lxj): remove after this is fixed:
785 // https://github.com/xtermjs/xterm.js/issues/3878
786 set(Modifier.Ctrl | Modifier.Shift, keyCodes.L,
787 /** @suppress {missingProperties} */
788 () => {
789 this.scheduleRefreshFont_();
790 // Refresh the cursor layer.
791 if (this.enableWebGL_) {
792 this.term?._core?._renderService?._renderer?._renderLayers[1]
793 ?._clearAll();
794 }
795 },
796 );
Jason Lin5690e752022-08-30 15:36:45 +1000797
798 // Ctrl+/
799 set(Modifier.Ctrl, 191, (ev) => {
800 ev.preventDefault();
801 this.io.onVTKeystroke(ctl('_'));
802 });
803
804 // Settings page.
805 set(Modifier.Ctrl | Modifier.Shift, keyCodes.P, (ev) => {
806 ev.preventDefault();
807 chrome.terminalPrivate.openOptionsPage(() => {});
808 });
809
810 if (this.prefs_.get('keybindings-os-defaults')) {
811 for (const binding of OS_DEFAULT_BINDINGS) {
812 this.keyDownHandlers_.set(binding, noop);
813 }
814 }
815
816 /** @param {!KeyboardEvent} ev */
817 const newWindow = (ev) => {
818 ev.preventDefault();
819 chrome.terminalPrivate.openWindow();
820 };
821 set(Modifier.Ctrl | Modifier.Shift, keyCodes.N, newWindow);
822 if (this.prefs_.get('pass-ctrl-n')) {
823 set(Modifier.Ctrl, keyCodes.N, newWindow);
824 }
825
826 if (this.prefs_.get('pass-ctrl-t')) {
827 setWithShiftVersion(Modifier.Ctrl, keyCodes.T, noop);
828 }
829
830 if (this.prefs_.get('pass-ctrl-w')) {
831 setWithShiftVersion(Modifier.Ctrl, keyCodes.W, noop);
832 }
833
834 if (this.prefs_.get('pass-ctrl-tab')) {
835 setWithShiftVersion(Modifier.Ctrl, keyCodes.TAB, noop);
836 }
837
838 const passCtrlNumber = this.prefs_.get('pass-ctrl-number');
839
840 /**
841 * Set a handler for the key combo ctrl+<number>.
842 *
843 * @param {number} number 1 to 9
844 * @param {string} controlCode The control code to send if we don't want to
845 * let the browser to handle it.
846 */
847 const setCtrlNumberHandler = (number, controlCode) => {
848 let func = noop;
849 if (!passCtrlNumber) {
850 func = (ev) => {
851 ev.preventDefault();
852 this.io.onVTKeystroke(controlCode);
853 };
854 }
855 set(Modifier.Ctrl, keyCodes.ZERO + number, func);
856 };
857
858 setCtrlNumberHandler(1, '1');
859 setCtrlNumberHandler(2, ctl('@'));
860 setCtrlNumberHandler(3, ctl('['));
861 setCtrlNumberHandler(4, ctl('\\'));
862 setCtrlNumberHandler(5, ctl(']'));
863 setCtrlNumberHandler(6, ctl('^'));
864 setCtrlNumberHandler(7, ctl('_'));
865 setCtrlNumberHandler(8, '\x7f');
866 setCtrlNumberHandler(9, '9');
867
868 if (this.prefs_.get('pass-alt-number')) {
869 for (let keyCode = keyCodes.ZERO; keyCode <= keyCodes.NINE; ++keyCode) {
870 set(Modifier.Alt, keyCode, noop);
871 }
872 }
873
874 for (const keyCode of [keyCodes.ZERO, keyCodes.MINUS, keyCodes.EQUAL]) {
875 setWithShiftVersion(Modifier.Ctrl, keyCode, this.zoomKeyDownHandler_);
876 }
877
878 setWithShiftVersion(Modifier.Ctrl, keyCodes.C, this.ctrlCKeyDownHandler_);
879 setWithShiftVersion(Modifier.Ctrl, keyCodes.V, this.ctrlVKeyDownHandler_);
880 }
Jason Linca61ffb2022-08-03 19:37:12 +1000881}
882
Jason Lind66e6bf2022-08-22 14:47:10 +1000883class HtermTerminal extends hterm.Terminal {
884 /** @override */
885 decorate(div) {
886 super.decorate(div);
887
888 const fontManager = new FontManager(this.getDocument());
889 fontManager.loadPowerlineCSS().then(() => {
890 const prefs = this.getPrefs();
891 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
892 prefs.addObserver(
893 'font-family',
894 (v) => fontManager.loadFont(/** @type {string} */(v)));
895 });
896 }
897}
898
Jason Linca61ffb2022-08-03 19:37:12 +1000899/**
900 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
901 * preference value.
902 *
903 * @param {{
904 * storage: !lib.Storage,
905 * profileId: string,
906 * }} args
907 * @return {!Promise<!hterm.Terminal>}
908 */
909export async function createEmulator({storage, profileId}) {
910 let config = TERMINAL_EMULATORS.get('hterm');
911
912 if (getOSInfo().alternative_emulator) {
Jason Lin21d854f2022-08-22 14:49:59 +1000913 // TODO: remove the url param logic. This is temporary to make manual
914 // testing a bit easier, which is also why this is not in
915 // './js/terminal_info.js'.
916 const emulator = ORIGINAL_URL.searchParams.get('emulator') ||
917 await storage.getItem(`/hterm/profiles/${profileId}/terminal-emulator`);
Jason Linca61ffb2022-08-03 19:37:12 +1000918 // Use the default (i.e. first) one if the pref is not set or invalid.
Jason Lin21d854f2022-08-22 14:49:59 +1000919 config = TERMINAL_EMULATORS.get(emulator) ||
Jason Linca61ffb2022-08-03 19:37:12 +1000920 TERMINAL_EMULATORS.values().next().value;
921 console.log('Terminal emulator config: ', config);
922 }
923
924 switch (config.lib) {
925 case 'xterm.js':
926 {
927 const terminal = new XtermTerminal({
928 storage,
929 profileId,
930 enableWebGL: config.webgl,
931 });
Jason Linca61ffb2022-08-03 19:37:12 +1000932 return terminal;
933 }
934 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +1000935 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +1000936 default:
937 throw new Error('incorrect emulator config');
938 }
939}
940
Jason Lin6a402a72022-08-25 16:07:02 +1000941class TerminalCopyNotice extends LitElement {
942 /** @override */
943 static get styles() {
944 return css`
945 :host {
946 display: block;
947 text-align: center;
948 }
949
950 svg {
951 fill: currentColor;
952 }
953 `;
954 }
955
956 /** @override */
957 render() {
958 return html`
959 ${ICON_COPY}
960 <div>${hterm.messageManager.get('HTERM_NOTIFY_COPY')}</div>
961 `;
962 }
963}
964
965customElements.define('terminal-copy-notice', TerminalCopyNotice);