blob: ccdec569777bf842c7c7b5439f6b07649bffc7df [file] [log] [blame]
Mike Frysinger598e8012022-09-07 08:38:34 -04001// Copyright 2022 The ChromiumOS Authors
Jason Lind66e6bf2022-08-22 14:47:10 +10002// 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
Emil Mikulic2a194d02022-09-29 14:30:59 +1000418 // Block right-click context menu from popping up.
419 elem.addEventListener('contextmenu', (e) => e.preventDefault());
420
421 // Add a handler for pasting with the mouse.
422 elem.addEventListener('mousedown', async (e) => {
423 if (this.term.modes.mouseTrackingMode !== 'none') {
424 // xterm.js is in mouse mode and will handle the event.
425 return;
426 }
427 const MIDDLE = 1;
428 const RIGHT = 2;
429 if (e.button === MIDDLE || (e.button === RIGHT &&
430 this.prefs_.getBoolean('mouse-right-click-paste'))) {
431 // Paste.
432 if (navigator.clipboard && navigator.clipboard.readText) {
433 const text = await navigator.clipboard.readText();
434 this.term.paste(text);
435 }
436 }
437 });
438
Jason Lin8de3d282022-09-01 21:29:05 +1000439 this.onTerminalReady();
440 })();
Jason Lin21d854f2022-08-22 14:49:59 +1000441 }
442
443 /** @override */
444 showOverlay(msg, timeout = 1500) {
445 if (this.notificationCenter_) {
446 this.notificationCenter_.show(msg, {timeout});
447 }
448 }
449
450 /** @override */
451 hideOverlay() {
452 if (this.notificationCenter_) {
453 this.notificationCenter_.hide();
454 }
Jason Linca61ffb2022-08-03 19:37:12 +1000455 }
456
457 /** @override */
458 getPrefs() {
459 return this.prefs_;
460 }
461
462 /** @override */
463 getDocument() {
464 return window.document;
465 }
466
Jason Lin21d854f2022-08-22 14:49:59 +1000467 /** @override */
468 reset() {
469 this.term.reset();
Jason Linca61ffb2022-08-03 19:37:12 +1000470 }
471
472 /** @override */
Jason Lin21d854f2022-08-22 14:49:59 +1000473 setProfile(profileId, callback = undefined) {
474 this.prefs_.setProfile(profileId, callback);
Jason Linca61ffb2022-08-03 19:37:12 +1000475 }
476
Jason Lin21d854f2022-08-22 14:49:59 +1000477 /** @override */
478 interpret(string) {
479 this.term.write(string);
Jason Linca61ffb2022-08-03 19:37:12 +1000480 }
481
Jason Lin21d854f2022-08-22 14:49:59 +1000482 /** @override */
483 focus() {
484 this.term.focus();
485 }
Jason Linca61ffb2022-08-03 19:37:12 +1000486
487 /** @override */
488 onOpenOptionsPage() {}
489
490 /** @override */
491 onTerminalReady() {}
492
Jason Lind04bab32022-08-22 14:48:39 +1000493 observePrefs_() {
Jason Lin21d854f2022-08-22 14:49:59 +1000494 // This is for this.notificationCenter_.
495 const setHtermCSSVariable = (name, value) => {
496 document.body.style.setProperty(`--hterm-${name}`, value);
497 };
498
499 const setHtermColorCSSVariable = (name, color) => {
500 const css = lib.notNull(lib.colors.normalizeCSS(color));
501 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
502 setHtermCSSVariable(name, rgb);
503 };
504
505 this.prefs_.addObserver('font-size', (v) => {
Jason Linda56aa92022-09-02 13:01:49 +1000506 this.updateOption_('fontSize', v, true);
Jason Lin21d854f2022-08-22 14:49:59 +1000507 setHtermCSSVariable('font-size', `${v}px`);
508 });
509
Jason Linda56aa92022-09-02 13:01:49 +1000510 // TODO(lxj): support option "lineHeight", "scrollback".
Jason Lind04bab32022-08-22 14:48:39 +1000511 this.prefs_.addObservers(null, {
Jason Linda56aa92022-09-02 13:01:49 +1000512 'audible-bell-sound': (v) => {
513 if (v) {
514 this.updateOption_('bellStyle', 'sound', false);
515 this.updateOption_('bellSound', lib.resource.getDataUrl(
516 'hterm/audio/bell'), false);
517 } else {
518 this.updateOption_('bellStyle', 'none', false);
519 }
520 },
Jason Lind04bab32022-08-22 14:48:39 +1000521 'background-color': (v) => {
522 this.updateTheme_({background: v});
Jason Lin21d854f2022-08-22 14:49:59 +1000523 setHtermColorCSSVariable('background-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000524 },
Jason Lind04bab32022-08-22 14:48:39 +1000525 'color-palette-overrides': (v) => {
526 if (!(v instanceof Array)) {
527 // For terminal, we always expect this to be an array.
528 console.warn('unexpected color palette: ', v);
529 return;
530 }
531 const colors = {};
532 for (let i = 0; i < v.length; ++i) {
533 colors[ANSI_COLOR_NAMES[i]] = v[i];
534 }
535 this.updateTheme_(colors);
536 },
Jason Linda56aa92022-09-02 13:01:49 +1000537 'cursor-blink': (v) => this.updateOption_('cursorBlink', v, false),
538 'cursor-color': (v) => this.updateTheme_({cursor: v}),
539 'cursor-shape': (v) => {
540 let shape;
541 if (v === 'BEAM') {
542 shape = 'bar';
543 } else {
544 shape = v.toLowerCase();
545 }
546 this.updateOption_('cursorStyle', shape, false);
547 },
548 'font-family': (v) => this.updateFont_(v),
549 'foreground-color': (v) => {
Jason Lin461ca562022-09-07 13:53:08 +1000550 this.updateTheme_({foreground: v});
Jason Linda56aa92022-09-02 13:01:49 +1000551 setHtermColorCSSVariable('foreground-color', v);
552 },
Jason Lind04bab32022-08-22 14:48:39 +1000553 });
Jason Lin5690e752022-08-30 15:36:45 +1000554
555 for (const name of ['keybindings-os-defaults', 'pass-ctrl-n', 'pass-ctrl-t',
556 'pass-ctrl-w', 'pass-ctrl-tab', 'pass-ctrl-number', 'pass-alt-number',
557 'ctrl-plus-minus-zero-zoom', 'ctrl-c-copy', 'ctrl-v-paste']) {
558 this.prefs_.addObserver(name, this.scheduleResetKeyDownHandlers_);
559 }
Jason Lind04bab32022-08-22 14:48:39 +1000560 }
561
562 /**
Jason Linc2504ae2022-09-02 13:03:31 +1000563 * Fit the terminal to the containing HTML element.
564 */
565 fit_() {
566 if (!this.inited_) {
567 return;
568 }
569
570 const screenPaddingSize = /** @type {number} */(
571 this.prefs_.get('screen-padding-size'));
572
573 const calc = (size, cellSize) => {
574 return Math.floor((size - 2 * screenPaddingSize) / cellSize);
575 };
576
577 // Unfortunately, it looks like we have to use private API from xterm.js.
578 // Maybe we should patch the FitAddon so that it works for us.
579 const dimensions = this.term._core._renderService.dimensions;
580 const cols = calc(this.container_.offsetWidth, dimensions.actualCellWidth);
581 const rows = calc(this.container_.offsetHeight,
582 dimensions.actualCellHeight);
583 if (cols >= 0 && rows >= 0) {
584 this.term.resize(cols, rows);
585 }
586 }
587
588 /**
Jason Lind04bab32022-08-22 14:48:39 +1000589 * @param {!Object} theme
590 */
591 updateTheme_(theme) {
Jason Lin8de3d282022-09-01 21:29:05 +1000592 const updateTheme = (target) => {
593 for (const [key, value] of Object.entries(theme)) {
594 target[key] = lib.colors.normalizeCSS(value);
595 }
596 };
597
598 // Must use a new theme object to trigger re-render if we have initialized.
599 if (this.inited_) {
600 const newTheme = {...this.term.options.theme};
601 updateTheme(newTheme);
602 this.term.options.theme = newTheme;
603 return;
Jason Lind04bab32022-08-22 14:48:39 +1000604 }
Jason Lin8de3d282022-09-01 21:29:05 +1000605
606 updateTheme(this.term.options.theme);
Jason Lind04bab32022-08-22 14:48:39 +1000607 }
608
609 /**
Jason Linda56aa92022-09-02 13:01:49 +1000610 * Update one xterm.js option. Use updateTheme_()/updateFont_() for
611 * theme/font.
Jason Lind04bab32022-08-22 14:48:39 +1000612 *
613 * @param {string} key
614 * @param {*} value
Jason Linda56aa92022-09-02 13:01:49 +1000615 * @param {boolean} scheduleFit
Jason Lind04bab32022-08-22 14:48:39 +1000616 */
Jason Linda56aa92022-09-02 13:01:49 +1000617 updateOption_(key, value, scheduleFit) {
Jason Lind04bab32022-08-22 14:48:39 +1000618 // TODO: xterm supports updating multiple options at the same time. We
619 // should probably do that.
620 this.term.options[key] = value;
Jason Linda56aa92022-09-02 13:01:49 +1000621 if (scheduleFit) {
622 this.scheduleFit_();
623 }
Jason Lind04bab32022-08-22 14:48:39 +1000624 }
Jason Linabad7562022-08-22 14:49:05 +1000625
626 /**
627 * Called when there is a "fontloadingdone" event. We need this because
628 * `FontManager.loadFont()` does not guarantee loading all the font files.
629 */
630 async onFontLoadingDone_() {
631 // If there is a pending font, the font is going to be refresh soon, so we
632 // don't need to do anything.
Jason Lin8de3d282022-09-01 21:29:05 +1000633 if (this.inited_ && !this.pendingFont_) {
Jason Linabad7562022-08-22 14:49:05 +1000634 this.scheduleRefreshFont_();
635 }
636 }
637
Jason Lin5690e752022-08-30 15:36:45 +1000638 copySelection_() {
Jason Line9231bc2022-09-01 13:54:02 +1000639 this.copyString_(this.term.getSelection());
640 }
641
642 /** @param {string} data */
643 copyString_(data) {
644 if (!data) {
Jason Lin6a402a72022-08-25 16:07:02 +1000645 return;
646 }
Jason Line9231bc2022-09-01 13:54:02 +1000647 navigator.clipboard?.writeText(data);
Jason Lin6a402a72022-08-25 16:07:02 +1000648 if (!this.copyNotice_) {
649 this.copyNotice_ = document.createElement('terminal-copy-notice');
650 }
651 setTimeout(() => this.showOverlay(lib.notNull(this.copyNotice_), 500), 200);
652 }
653
Jason Linabad7562022-08-22 14:49:05 +1000654 /**
655 * Refresh xterm rendering for a font related event.
656 */
657 refreshFont_() {
658 // We have to set the fontFamily option to a different string to trigger the
659 // re-rendering. Appending a space at the end seems to be the easiest
660 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
661 // us.
662 //
663 // TODO: Report a bug to xterm.js and ask for exposing a public function for
664 // the refresh so that we don't need to do this hack.
665 this.term.options.fontFamily += ' ';
666 }
667
668 /**
669 * Update a font.
670 *
671 * @param {string} cssFontFamily
672 */
673 async updateFont_(cssFontFamily) {
Jason Lin6a402a72022-08-25 16:07:02 +1000674 this.pendingFont_ = cssFontFamily;
675 await this.fontManager_.loadFont(cssFontFamily);
676 // Sleep a bit to wait for flushing fontloadingdone events. This is not
677 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
678 // to refresh font unnecessarily in some cases.
679 await sleep(30);
Jason Linabad7562022-08-22 14:49:05 +1000680
Jason Lin6a402a72022-08-25 16:07:02 +1000681 if (this.pendingFont_ !== cssFontFamily) {
682 // `updateFont_()` probably is called again. Abort what we are doing.
683 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
684 ` (expecting ${cssFontFamily})`);
685 return;
686 }
Jason Linabad7562022-08-22 14:49:05 +1000687
Jason Lin6a402a72022-08-25 16:07:02 +1000688 if (this.term.options.fontFamily !== cssFontFamily) {
689 this.term.options.fontFamily = cssFontFamily;
690 } else {
691 // If the font is already the same, refresh font just to be safe.
692 this.refreshFont_();
693 }
694 this.pendingFont_ = null;
695 this.scheduleFit_();
Jason Linabad7562022-08-22 14:49:05 +1000696 }
Jason Lin5690e752022-08-30 15:36:45 +1000697
698 /**
699 * @param {!KeyboardEvent} ev
700 * @return {boolean} Return false if xterm.js should not handle the key event.
701 */
702 customKeyEventHandler_(ev) {
703 const modifiers = (ev.shiftKey ? Modifier.Shift : 0) |
704 (ev.altKey ? Modifier.Alt : 0) |
705 (ev.ctrlKey ? Modifier.Ctrl : 0) |
706 (ev.metaKey ? Modifier.Meta : 0);
707 const handler = this.keyDownHandlers_.get(
708 encodeKeyCombo(modifiers, ev.keyCode));
709 if (handler) {
710 if (ev.type === 'keydown') {
711 handler(ev);
712 }
713 return false;
714 }
715
716 return true;
717 }
718
719 /**
720 * A keydown handler for zoom-related keys.
721 *
722 * @param {!KeyboardEvent} ev
723 */
724 zoomKeyDownHandler_(ev) {
725 ev.preventDefault();
726
727 if (this.prefs_.get('ctrl-plus-minus-zero-zoom') === ev.shiftKey) {
728 // The only one with a control code.
729 if (ev.keyCode === keyCodes.MINUS) {
730 this.io.onVTKeystroke('\x1f');
731 }
732 return;
733 }
734
735 let newFontSize;
736 switch (ev.keyCode) {
737 case keyCodes.ZERO:
738 newFontSize = this.prefs_.get('font-size');
739 break;
740 case keyCodes.MINUS:
741 newFontSize = this.term.options.fontSize - 1;
742 break;
743 default:
744 newFontSize = this.term.options.fontSize + 1;
745 break;
746 }
747
Jason Linda56aa92022-09-02 13:01:49 +1000748 this.updateOption_('fontSize', Math.max(1, newFontSize), true);
Jason Lin5690e752022-08-30 15:36:45 +1000749 }
750
751 /** @param {!KeyboardEvent} ev */
752 ctrlCKeyDownHandler_(ev) {
753 ev.preventDefault();
754 if (this.prefs_.get('ctrl-c-copy') !== ev.shiftKey &&
755 this.term.hasSelection()) {
756 this.copySelection_();
757 return;
758 }
759
760 this.io.onVTKeystroke('\x03');
761 }
762
763 /** @param {!KeyboardEvent} ev */
764 ctrlVKeyDownHandler_(ev) {
765 if (this.prefs_.get('ctrl-v-paste') !== ev.shiftKey) {
766 // Don't do anything and let the browser handles the key.
767 return;
768 }
769
770 ev.preventDefault();
771 this.io.onVTKeystroke('\x16');
772 }
773
774 resetKeyDownHandlers_() {
775 this.keyDownHandlers_.clear();
776
777 /**
778 * Don't do anything and let the browser handles the key.
779 *
780 * @param {!KeyboardEvent} ev
781 */
782 const noop = (ev) => {};
783
784 /**
785 * @param {number} modifiers
786 * @param {number} keyCode
787 * @param {function(!KeyboardEvent)} func
788 */
789 const set = (modifiers, keyCode, func) => {
790 this.keyDownHandlers_.set(encodeKeyCombo(modifiers, keyCode),
791 func);
792 };
793
794 /**
795 * @param {number} modifiers
796 * @param {number} keyCode
797 * @param {function(!KeyboardEvent)} func
798 */
799 const setWithShiftVersion = (modifiers, keyCode, func) => {
800 set(modifiers, keyCode, func);
801 set(modifiers | Modifier.Shift, keyCode, func);
802 };
803
Jason Linf51162f2022-09-01 15:21:55 +1000804 // Temporary shortcut to refresh the rendering in case of rendering errors.
805 // TODO(lxj): remove after this is fixed:
806 // https://github.com/xtermjs/xterm.js/issues/3878
807 set(Modifier.Ctrl | Modifier.Shift, keyCodes.L,
808 /** @suppress {missingProperties} */
809 () => {
810 this.scheduleRefreshFont_();
811 // Refresh the cursor layer.
812 if (this.enableWebGL_) {
813 this.term?._core?._renderService?._renderer?._renderLayers[1]
814 ?._clearAll();
815 }
816 },
817 );
Jason Lin5690e752022-08-30 15:36:45 +1000818
819 // Ctrl+/
820 set(Modifier.Ctrl, 191, (ev) => {
821 ev.preventDefault();
822 this.io.onVTKeystroke(ctl('_'));
823 });
824
825 // Settings page.
826 set(Modifier.Ctrl | Modifier.Shift, keyCodes.P, (ev) => {
827 ev.preventDefault();
828 chrome.terminalPrivate.openOptionsPage(() => {});
829 });
830
831 if (this.prefs_.get('keybindings-os-defaults')) {
832 for (const binding of OS_DEFAULT_BINDINGS) {
833 this.keyDownHandlers_.set(binding, noop);
834 }
835 }
836
837 /** @param {!KeyboardEvent} ev */
838 const newWindow = (ev) => {
839 ev.preventDefault();
840 chrome.terminalPrivate.openWindow();
841 };
842 set(Modifier.Ctrl | Modifier.Shift, keyCodes.N, newWindow);
843 if (this.prefs_.get('pass-ctrl-n')) {
844 set(Modifier.Ctrl, keyCodes.N, newWindow);
845 }
846
847 if (this.prefs_.get('pass-ctrl-t')) {
848 setWithShiftVersion(Modifier.Ctrl, keyCodes.T, noop);
849 }
850
851 if (this.prefs_.get('pass-ctrl-w')) {
852 setWithShiftVersion(Modifier.Ctrl, keyCodes.W, noop);
853 }
854
855 if (this.prefs_.get('pass-ctrl-tab')) {
856 setWithShiftVersion(Modifier.Ctrl, keyCodes.TAB, noop);
857 }
858
859 const passCtrlNumber = this.prefs_.get('pass-ctrl-number');
860
861 /**
862 * Set a handler for the key combo ctrl+<number>.
863 *
864 * @param {number} number 1 to 9
865 * @param {string} controlCode The control code to send if we don't want to
866 * let the browser to handle it.
867 */
868 const setCtrlNumberHandler = (number, controlCode) => {
869 let func = noop;
870 if (!passCtrlNumber) {
871 func = (ev) => {
872 ev.preventDefault();
873 this.io.onVTKeystroke(controlCode);
874 };
875 }
876 set(Modifier.Ctrl, keyCodes.ZERO + number, func);
877 };
878
879 setCtrlNumberHandler(1, '1');
880 setCtrlNumberHandler(2, ctl('@'));
881 setCtrlNumberHandler(3, ctl('['));
882 setCtrlNumberHandler(4, ctl('\\'));
883 setCtrlNumberHandler(5, ctl(']'));
884 setCtrlNumberHandler(6, ctl('^'));
885 setCtrlNumberHandler(7, ctl('_'));
886 setCtrlNumberHandler(8, '\x7f');
887 setCtrlNumberHandler(9, '9');
888
889 if (this.prefs_.get('pass-alt-number')) {
890 for (let keyCode = keyCodes.ZERO; keyCode <= keyCodes.NINE; ++keyCode) {
891 set(Modifier.Alt, keyCode, noop);
892 }
893 }
894
895 for (const keyCode of [keyCodes.ZERO, keyCodes.MINUS, keyCodes.EQUAL]) {
896 setWithShiftVersion(Modifier.Ctrl, keyCode, this.zoomKeyDownHandler_);
897 }
898
899 setWithShiftVersion(Modifier.Ctrl, keyCodes.C, this.ctrlCKeyDownHandler_);
900 setWithShiftVersion(Modifier.Ctrl, keyCodes.V, this.ctrlVKeyDownHandler_);
901 }
Jason Linca61ffb2022-08-03 19:37:12 +1000902}
903
Jason Lind66e6bf2022-08-22 14:47:10 +1000904class HtermTerminal extends hterm.Terminal {
905 /** @override */
906 decorate(div) {
907 super.decorate(div);
908
909 const fontManager = new FontManager(this.getDocument());
910 fontManager.loadPowerlineCSS().then(() => {
911 const prefs = this.getPrefs();
912 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
913 prefs.addObserver(
914 'font-family',
915 (v) => fontManager.loadFont(/** @type {string} */(v)));
916 });
917 }
918}
919
Jason Linca61ffb2022-08-03 19:37:12 +1000920/**
921 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
922 * preference value.
923 *
924 * @param {{
925 * storage: !lib.Storage,
926 * profileId: string,
927 * }} args
928 * @return {!Promise<!hterm.Terminal>}
929 */
930export async function createEmulator({storage, profileId}) {
931 let config = TERMINAL_EMULATORS.get('hterm');
932
933 if (getOSInfo().alternative_emulator) {
Jason Lin21d854f2022-08-22 14:49:59 +1000934 // TODO: remove the url param logic. This is temporary to make manual
935 // testing a bit easier, which is also why this is not in
936 // './js/terminal_info.js'.
937 const emulator = ORIGINAL_URL.searchParams.get('emulator') ||
938 await storage.getItem(`/hterm/profiles/${profileId}/terminal-emulator`);
Jason Linca61ffb2022-08-03 19:37:12 +1000939 // Use the default (i.e. first) one if the pref is not set or invalid.
Jason Lin21d854f2022-08-22 14:49:59 +1000940 config = TERMINAL_EMULATORS.get(emulator) ||
Jason Linca61ffb2022-08-03 19:37:12 +1000941 TERMINAL_EMULATORS.values().next().value;
942 console.log('Terminal emulator config: ', config);
943 }
944
945 switch (config.lib) {
946 case 'xterm.js':
947 {
948 const terminal = new XtermTerminal({
949 storage,
950 profileId,
951 enableWebGL: config.webgl,
952 });
Jason Linca61ffb2022-08-03 19:37:12 +1000953 return terminal;
954 }
955 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +1000956 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +1000957 default:
958 throw new Error('incorrect emulator config');
959 }
960}
961
Jason Lin6a402a72022-08-25 16:07:02 +1000962class TerminalCopyNotice extends LitElement {
963 /** @override */
964 static get styles() {
965 return css`
966 :host {
967 display: block;
968 text-align: center;
969 }
970
971 svg {
972 fill: currentColor;
973 }
974 `;
975 }
976
977 /** @override */
978 render() {
979 return html`
980 ${ICON_COPY}
981 <div>${hterm.messageManager.get('HTERM_NOTIFY_COPY')}</div>
982 `;
983 }
984}
985
986customElements.define('terminal-copy-notice', TerminalCopyNotice);