blob: aead32802b46ffd39a71a5acd619a84995b43336 [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
Jason Linc7afb672022-10-11 15:54:17 +1100195class Bell {
196 constructor() {
197 this.showNotification = false;
198
199 /** @type {?Audio} */
200 this.audio_ = null;
201 /** @type {?Notification} */
202 this.notification_ = null;
203 this.coolDownUntil_ = 0;
204 }
205
206 /**
207 * Set whether a bell audio should be played.
208 *
209 * @param {boolean} value
210 */
211 set playAudio(value) {
212 this.audio_ = value ?
213 new Audio(lib.resource.getDataUrl('hterm/audio/bell')) : null;
214 }
215
216 ring() {
217 const now = Date.now();
218 if (now < this.coolDownUntil_) {
219 return;
220 }
221 this.coolDownUntil_ = now + 500;
222
223 this.audio_?.play();
224 if (this.showNotification && !document.hasFocus() && !this.notification_) {
225 this.notification_ = new Notification(
226 `\u266A ${document.title} \u266A`,
227 {icon: lib.resource.getDataUrl('hterm/images/icon-96')});
228 // Close the notification after a timeout. Note that this is different
229 // from hterm's behavior, but I think it makes more sense to do so.
230 setTimeout(() => {
231 this.notification_.close();
232 this.notification_ = null;
233 }, 5000);
234 }
235 }
236}
237
Jason Lin83707c92022-09-20 19:09:41 +1000238/**
Jason Linca61ffb2022-08-03 19:37:12 +1000239 * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
240 * so that it can be used in existing code.
241 *
Jason Linca61ffb2022-08-03 19:37:12 +1000242 * @extends {hterm.Terminal}
243 * @unrestricted
244 */
Jason Linabad7562022-08-22 14:49:05 +1000245export class XtermTerminal {
Jason Linca61ffb2022-08-03 19:37:12 +1000246 /**
247 * @param {{
248 * storage: !lib.Storage,
249 * profileId: string,
250 * enableWebGL: boolean,
Jason Linabad7562022-08-22 14:49:05 +1000251 * testParams: (!XtermTerminalTestParams|undefined),
Jason Linca61ffb2022-08-03 19:37:12 +1000252 * }} args
253 */
Jason Linabad7562022-08-22 14:49:05 +1000254 constructor({storage, profileId, enableWebGL, testParams}) {
Jason Lin5690e752022-08-30 15:36:45 +1000255 this.ctrlCKeyDownHandler_ = this.ctrlCKeyDownHandler_.bind(this);
256 this.ctrlVKeyDownHandler_ = this.ctrlVKeyDownHandler_.bind(this);
257 this.zoomKeyDownHandler_ = this.zoomKeyDownHandler_.bind(this);
258
Jason Lin8de3d282022-09-01 21:29:05 +1000259 this.inited_ = false;
Jason Lin21d854f2022-08-22 14:49:59 +1000260 this.profileId_ = profileId;
Jason Linca61ffb2022-08-03 19:37:12 +1000261 /** @type {!hterm.PreferenceManager} */
262 this.prefs_ = new hterm.PreferenceManager(storage, profileId);
263 this.enableWebGL_ = enableWebGL;
264
Jason Lin5690e752022-08-30 15:36:45 +1000265 // TODO: we should probably pass the initial prefs to the ctor.
Jason Linfc8a3722022-09-07 17:49:18 +1000266 this.term = testParams?.term || new Terminal({allowProposedApi: true});
Jason Linabad7562022-08-22 14:49:05 +1000267 this.fontManager_ = testParams?.fontManager || fontManager;
Jason Linabad7562022-08-22 14:49:05 +1000268
Jason Linc2504ae2022-09-02 13:03:31 +1000269 /** @type {?Element} */
270 this.container_;
Jason Linc7afb672022-10-11 15:54:17 +1100271 this.bell_ = new Bell();
Jason Linc2504ae2022-09-02 13:03:31 +1000272 this.scheduleFit_ = delayedScheduler(() => this.fit_(),
Jason Linabad7562022-08-22 14:49:05 +1000273 testParams ? 0 : 250);
274
Jason Lin83707c92022-09-20 19:09:41 +1000275 this.term.loadAddon(
276 new WebLinksAddon((e, uri) => lib.f.openWindow(uri, '_blank')));
Jason Lin4de4f382022-09-01 14:10:18 +1000277 this.term.loadAddon(new Unicode11Addon());
278 this.term.unicode.activeVersion = '11';
279
Jason Linabad7562022-08-22 14:49:05 +1000280 this.pendingFont_ = null;
281 this.scheduleRefreshFont_ = delayedScheduler(
282 () => this.refreshFont_(), 100);
283 document.fonts.addEventListener('loadingdone',
284 () => this.onFontLoadingDone_());
Jason Linca61ffb2022-08-03 19:37:12 +1000285
286 this.installUnimplementedStubs_();
Jason Line9231bc2022-09-01 13:54:02 +1000287 this.installEscapeSequenceHandlers_();
Jason Linca61ffb2022-08-03 19:37:12 +1000288
Jason Lin21d854f2022-08-22 14:49:59 +1000289 this.term.onResize(({cols, rows}) => this.io.onTerminalResize(cols, rows));
290 // We could also use `this.io.sendString()` except for the nassh exit
291 // prompt, which only listens to onVTKeystroke().
292 this.term.onData((data) => this.io.onVTKeystroke(data));
Jason Lin80e69132022-09-02 16:31:43 +1000293 this.term.onBinary((data) => this.io.onVTKeystroke(data));
Jason Lin6a402a72022-08-25 16:07:02 +1000294 this.term.onTitleChange((title) => document.title = title);
Jason Lin5690e752022-08-30 15:36:45 +1000295 this.term.onSelectionChange(() => this.copySelection_());
Jason Linc7afb672022-10-11 15:54:17 +1100296 this.term.onBell(() => this.ringBell());
Jason Lin5690e752022-08-30 15:36:45 +1000297
298 /**
299 * A mapping from key combo (see encodeKeyCombo()) to a handler function.
300 *
301 * If a key combo is in the map:
302 *
303 * - The handler instead of xterm.js will handle the keydown event.
304 * - Keyup and keypress will be ignored by both us and xterm.js.
305 *
306 * We re-generate this map every time a relevant pref value is changed. This
307 * is ok because pref changes are rare.
308 *
309 * @type {!Map<number, function(!KeyboardEvent)>}
310 */
311 this.keyDownHandlers_ = new Map();
312 this.scheduleResetKeyDownHandlers_ =
313 delayedScheduler(() => this.resetKeyDownHandlers_(), 250);
314
315 this.term.attachCustomKeyEventHandler(
316 this.customKeyEventHandler_.bind(this));
Jason Linca61ffb2022-08-03 19:37:12 +1000317
Jason Lin21d854f2022-08-22 14:49:59 +1000318 this.io = new XtermTerminalIO(this);
319 this.notificationCenter_ = null;
Jason Lin6a402a72022-08-25 16:07:02 +1000320
321 this.copyNotice_ = null;
322
Jason Lin83707c92022-09-20 19:09:41 +1000323 this.term.options.linkHandler = new LinkHandler(this.term);
Jason Lin6a402a72022-08-25 16:07:02 +1000324 this.term.options.theme = {
Jason Lin461ca562022-09-07 13:53:08 +1000325 // The webgl cursor layer also paints the character under the cursor with
326 // this `cursorAccent` color. We use a completely transparent color here
327 // to effectively disable that.
328 cursorAccent: 'rgba(0, 0, 0, 0)',
329 customGlyphs: true,
Jason Lin2edc25d2022-09-16 15:06:48 +1000330 selectionBackground: 'rgba(174, 203, 250, .6)',
331 selectionInactiveBackground: 'rgba(218, 220, 224, .6)',
Jason Lin6a402a72022-08-25 16:07:02 +1000332 selectionForeground: 'black',
Jason Lin6a402a72022-08-25 16:07:02 +1000333 };
334 this.observePrefs_();
Jason Linca61ffb2022-08-03 19:37:12 +1000335 }
336
Jason Linc7afb672022-10-11 15:54:17 +1100337 /** @override */
338 ringBell() {
339 this.bell_.ring();
340 }
341
Jason Linca61ffb2022-08-03 19:37:12 +1000342 /**
343 * Install stubs for stuff that we haven't implemented yet so that the code
344 * still runs.
345 */
346 installUnimplementedStubs_() {
347 this.keyboard = {
348 keyMap: {
349 keyDefs: [],
350 },
351 bindings: {
352 clear: () => {},
353 addBinding: () => {},
354 addBindings: () => {},
355 OsDefaults: {},
356 },
357 };
358 this.keyboard.keyMap.keyDefs[78] = {};
359
360 const methodNames = [
Jason Linca61ffb2022-08-03 19:37:12 +1000361 'setAccessibilityEnabled',
362 'setBackgroundImage',
363 'setCursorPosition',
364 'setCursorVisible',
Jason Linca61ffb2022-08-03 19:37:12 +1000365 ];
366
367 for (const name of methodNames) {
368 this[name] = () => console.warn(`${name}() is not implemented`);
369 }
370
371 this.contextMenu = {
372 setItems: () => {
373 console.warn('.contextMenu.setItems() is not implemented');
374 },
375 };
Jason Lin21d854f2022-08-22 14:49:59 +1000376
377 this.vt = {
378 resetParseState: () => {
379 console.warn('.vt.resetParseState() is not implemented');
380 },
381 };
Jason Linca61ffb2022-08-03 19:37:12 +1000382 }
383
Jason Line9231bc2022-09-01 13:54:02 +1000384 installEscapeSequenceHandlers_() {
385 // OSC 52 for copy.
386 this.term.parser.registerOscHandler(52, (args) => {
387 // Args comes in as a single 'clipboard;b64-data' string. The clipboard
388 // parameter is used to select which of the X clipboards to address. Since
389 // we're not integrating with X, we treat them all the same.
390 const parsedArgs = args.match(/^[cps01234567]*;(.*)/);
391 if (!parsedArgs) {
392 return true;
393 }
394
395 let data;
396 try {
397 data = window.atob(parsedArgs[1]);
398 } catch (e) {
399 // If the user sent us invalid base64 content, silently ignore it.
400 return true;
401 }
402 const decoder = new TextDecoder();
403 const bytes = lib.codec.stringToCodeUnitArray(data);
404 this.copyString_(decoder.decode(bytes));
405
406 return true;
407 });
408 }
409
Jason Linca61ffb2022-08-03 19:37:12 +1000410 /**
Jason Lin21d854f2022-08-22 14:49:59 +1000411 * Write data to the terminal.
412 *
413 * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
414 * UTF-8 data
415 */
416 write(data) {
417 this.term.write(data);
418 }
419
420 /**
421 * Like `this.write()` but also write a line break.
422 *
423 * @param {string|!Uint8Array} data
424 */
425 writeln(data) {
426 this.term.writeln(data);
427 }
428
Jason Linca61ffb2022-08-03 19:37:12 +1000429 get screenSize() {
430 return new hterm.Size(this.term.cols, this.term.rows);
431 }
432
433 /**
434 * Don't need to do anything.
435 *
436 * @override
437 */
438 installKeyboard() {}
439
440 /**
441 * @override
442 */
443 decorate(elem) {
Jason Linc2504ae2022-09-02 13:03:31 +1000444 this.container_ = elem;
Jason Lin8de3d282022-09-01 21:29:05 +1000445 (async () => {
446 await new Promise((resolve) => this.prefs_.readStorage(resolve));
447 // This will trigger all the observers to set the terminal options before
448 // we call `this.term.open()`.
449 this.prefs_.notifyAll();
450
Jason Linc2504ae2022-09-02 13:03:31 +1000451 const screenPaddingSize = /** @type {number} */(
452 this.prefs_.get('screen-padding-size'));
453 elem.style.paddingTop = elem.style.paddingLeft = `${screenPaddingSize}px`;
454
Jason Lin8de3d282022-09-01 21:29:05 +1000455 this.inited_ = true;
456 this.term.open(elem);
457
458 this.scheduleFit_();
459 if (this.enableWebGL_) {
460 this.term.loadAddon(new WebglAddon());
461 }
462 this.term.focus();
463 (new ResizeObserver(() => this.scheduleFit_())).observe(elem);
464 // TODO: Make a11y work. Maybe we can just use hterm.AccessibilityReader.
465 this.notificationCenter_ = new hterm.NotificationCenter(document.body);
466
Emil Mikulic2a194d02022-09-29 14:30:59 +1000467 // Block right-click context menu from popping up.
468 elem.addEventListener('contextmenu', (e) => e.preventDefault());
469
470 // Add a handler for pasting with the mouse.
471 elem.addEventListener('mousedown', async (e) => {
472 if (this.term.modes.mouseTrackingMode !== 'none') {
473 // xterm.js is in mouse mode and will handle the event.
474 return;
475 }
476 const MIDDLE = 1;
477 const RIGHT = 2;
478 if (e.button === MIDDLE || (e.button === RIGHT &&
479 this.prefs_.getBoolean('mouse-right-click-paste'))) {
480 // Paste.
481 if (navigator.clipboard && navigator.clipboard.readText) {
482 const text = await navigator.clipboard.readText();
483 this.term.paste(text);
484 }
485 }
486 });
487
Jason Lin8de3d282022-09-01 21:29:05 +1000488 this.onTerminalReady();
489 })();
Jason Lin21d854f2022-08-22 14:49:59 +1000490 }
491
492 /** @override */
493 showOverlay(msg, timeout = 1500) {
494 if (this.notificationCenter_) {
495 this.notificationCenter_.show(msg, {timeout});
496 }
497 }
498
499 /** @override */
500 hideOverlay() {
501 if (this.notificationCenter_) {
502 this.notificationCenter_.hide();
503 }
Jason Linca61ffb2022-08-03 19:37:12 +1000504 }
505
506 /** @override */
507 getPrefs() {
508 return this.prefs_;
509 }
510
511 /** @override */
512 getDocument() {
513 return window.document;
514 }
515
Jason Lin21d854f2022-08-22 14:49:59 +1000516 /** @override */
517 reset() {
518 this.term.reset();
Jason Linca61ffb2022-08-03 19:37:12 +1000519 }
520
521 /** @override */
Jason Lin21d854f2022-08-22 14:49:59 +1000522 setProfile(profileId, callback = undefined) {
523 this.prefs_.setProfile(profileId, callback);
Jason Linca61ffb2022-08-03 19:37:12 +1000524 }
525
Jason Lin21d854f2022-08-22 14:49:59 +1000526 /** @override */
527 interpret(string) {
528 this.term.write(string);
Jason Linca61ffb2022-08-03 19:37:12 +1000529 }
530
Jason Lin21d854f2022-08-22 14:49:59 +1000531 /** @override */
532 focus() {
533 this.term.focus();
534 }
Jason Linca61ffb2022-08-03 19:37:12 +1000535
536 /** @override */
537 onOpenOptionsPage() {}
538
539 /** @override */
540 onTerminalReady() {}
541
Jason Lind04bab32022-08-22 14:48:39 +1000542 observePrefs_() {
Jason Lin21d854f2022-08-22 14:49:59 +1000543 // This is for this.notificationCenter_.
544 const setHtermCSSVariable = (name, value) => {
545 document.body.style.setProperty(`--hterm-${name}`, value);
546 };
547
548 const setHtermColorCSSVariable = (name, color) => {
549 const css = lib.notNull(lib.colors.normalizeCSS(color));
550 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
551 setHtermCSSVariable(name, rgb);
552 };
553
554 this.prefs_.addObserver('font-size', (v) => {
Jason Linda56aa92022-09-02 13:01:49 +1000555 this.updateOption_('fontSize', v, true);
Jason Lin21d854f2022-08-22 14:49:59 +1000556 setHtermCSSVariable('font-size', `${v}px`);
557 });
558
Jason Linda56aa92022-09-02 13:01:49 +1000559 // TODO(lxj): support option "lineHeight", "scrollback".
Jason Lind04bab32022-08-22 14:48:39 +1000560 this.prefs_.addObservers(null, {
Jason Linda56aa92022-09-02 13:01:49 +1000561 'audible-bell-sound': (v) => {
Jason Linc7afb672022-10-11 15:54:17 +1100562 this.bell_.playAudio = !!v;
563 },
564 'desktop-notification-bell': (v) => {
565 this.bell_.showNotification = v;
Jason Linda56aa92022-09-02 13:01:49 +1000566 },
Jason Lind04bab32022-08-22 14:48:39 +1000567 'background-color': (v) => {
568 this.updateTheme_({background: v});
Jason Lin21d854f2022-08-22 14:49:59 +1000569 setHtermColorCSSVariable('background-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000570 },
Jason Lind04bab32022-08-22 14:48:39 +1000571 'color-palette-overrides': (v) => {
572 if (!(v instanceof Array)) {
573 // For terminal, we always expect this to be an array.
574 console.warn('unexpected color palette: ', v);
575 return;
576 }
577 const colors = {};
578 for (let i = 0; i < v.length; ++i) {
579 colors[ANSI_COLOR_NAMES[i]] = v[i];
580 }
581 this.updateTheme_(colors);
582 },
Jason Linda56aa92022-09-02 13:01:49 +1000583 'cursor-blink': (v) => this.updateOption_('cursorBlink', v, false),
584 'cursor-color': (v) => this.updateTheme_({cursor: v}),
585 'cursor-shape': (v) => {
586 let shape;
587 if (v === 'BEAM') {
588 shape = 'bar';
589 } else {
590 shape = v.toLowerCase();
591 }
592 this.updateOption_('cursorStyle', shape, false);
593 },
594 'font-family': (v) => this.updateFont_(v),
595 'foreground-color': (v) => {
Jason Lin461ca562022-09-07 13:53:08 +1000596 this.updateTheme_({foreground: v});
Jason Linda56aa92022-09-02 13:01:49 +1000597 setHtermColorCSSVariable('foreground-color', v);
598 },
Jason Lind04bab32022-08-22 14:48:39 +1000599 });
Jason Lin5690e752022-08-30 15:36:45 +1000600
601 for (const name of ['keybindings-os-defaults', 'pass-ctrl-n', 'pass-ctrl-t',
602 'pass-ctrl-w', 'pass-ctrl-tab', 'pass-ctrl-number', 'pass-alt-number',
603 'ctrl-plus-minus-zero-zoom', 'ctrl-c-copy', 'ctrl-v-paste']) {
604 this.prefs_.addObserver(name, this.scheduleResetKeyDownHandlers_);
605 }
Jason Lind04bab32022-08-22 14:48:39 +1000606 }
607
608 /**
Jason Linc2504ae2022-09-02 13:03:31 +1000609 * Fit the terminal to the containing HTML element.
610 */
611 fit_() {
612 if (!this.inited_) {
613 return;
614 }
615
616 const screenPaddingSize = /** @type {number} */(
617 this.prefs_.get('screen-padding-size'));
618
619 const calc = (size, cellSize) => {
620 return Math.floor((size - 2 * screenPaddingSize) / cellSize);
621 };
622
623 // Unfortunately, it looks like we have to use private API from xterm.js.
624 // Maybe we should patch the FitAddon so that it works for us.
625 const dimensions = this.term._core._renderService.dimensions;
626 const cols = calc(this.container_.offsetWidth, dimensions.actualCellWidth);
627 const rows = calc(this.container_.offsetHeight,
628 dimensions.actualCellHeight);
629 if (cols >= 0 && rows >= 0) {
630 this.term.resize(cols, rows);
631 }
632 }
633
634 /**
Jason Lind04bab32022-08-22 14:48:39 +1000635 * @param {!Object} theme
636 */
637 updateTheme_(theme) {
Jason Lin8de3d282022-09-01 21:29:05 +1000638 const updateTheme = (target) => {
639 for (const [key, value] of Object.entries(theme)) {
640 target[key] = lib.colors.normalizeCSS(value);
641 }
642 };
643
644 // Must use a new theme object to trigger re-render if we have initialized.
645 if (this.inited_) {
646 const newTheme = {...this.term.options.theme};
647 updateTheme(newTheme);
648 this.term.options.theme = newTheme;
649 return;
Jason Lind04bab32022-08-22 14:48:39 +1000650 }
Jason Lin8de3d282022-09-01 21:29:05 +1000651
652 updateTheme(this.term.options.theme);
Jason Lind04bab32022-08-22 14:48:39 +1000653 }
654
655 /**
Jason Linda56aa92022-09-02 13:01:49 +1000656 * Update one xterm.js option. Use updateTheme_()/updateFont_() for
657 * theme/font.
Jason Lind04bab32022-08-22 14:48:39 +1000658 *
659 * @param {string} key
660 * @param {*} value
Jason Linda56aa92022-09-02 13:01:49 +1000661 * @param {boolean} scheduleFit
Jason Lind04bab32022-08-22 14:48:39 +1000662 */
Jason Linda56aa92022-09-02 13:01:49 +1000663 updateOption_(key, value, scheduleFit) {
Jason Lind04bab32022-08-22 14:48:39 +1000664 // TODO: xterm supports updating multiple options at the same time. We
665 // should probably do that.
666 this.term.options[key] = value;
Jason Linda56aa92022-09-02 13:01:49 +1000667 if (scheduleFit) {
668 this.scheduleFit_();
669 }
Jason Lind04bab32022-08-22 14:48:39 +1000670 }
Jason Linabad7562022-08-22 14:49:05 +1000671
672 /**
673 * Called when there is a "fontloadingdone" event. We need this because
674 * `FontManager.loadFont()` does not guarantee loading all the font files.
675 */
676 async onFontLoadingDone_() {
677 // If there is a pending font, the font is going to be refresh soon, so we
678 // don't need to do anything.
Jason Lin8de3d282022-09-01 21:29:05 +1000679 if (this.inited_ && !this.pendingFont_) {
Jason Linabad7562022-08-22 14:49:05 +1000680 this.scheduleRefreshFont_();
681 }
682 }
683
Jason Lin5690e752022-08-30 15:36:45 +1000684 copySelection_() {
Jason Line9231bc2022-09-01 13:54:02 +1000685 this.copyString_(this.term.getSelection());
686 }
687
688 /** @param {string} data */
689 copyString_(data) {
690 if (!data) {
Jason Lin6a402a72022-08-25 16:07:02 +1000691 return;
692 }
Jason Line9231bc2022-09-01 13:54:02 +1000693 navigator.clipboard?.writeText(data);
Jason Lin6a402a72022-08-25 16:07:02 +1000694 if (!this.copyNotice_) {
695 this.copyNotice_ = document.createElement('terminal-copy-notice');
696 }
697 setTimeout(() => this.showOverlay(lib.notNull(this.copyNotice_), 500), 200);
698 }
699
Jason Linabad7562022-08-22 14:49:05 +1000700 /**
701 * Refresh xterm rendering for a font related event.
702 */
703 refreshFont_() {
704 // We have to set the fontFamily option to a different string to trigger the
705 // re-rendering. Appending a space at the end seems to be the easiest
706 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
707 // us.
708 //
709 // TODO: Report a bug to xterm.js and ask for exposing a public function for
710 // the refresh so that we don't need to do this hack.
711 this.term.options.fontFamily += ' ';
712 }
713
714 /**
715 * Update a font.
716 *
717 * @param {string} cssFontFamily
718 */
719 async updateFont_(cssFontFamily) {
Jason Lin6a402a72022-08-25 16:07:02 +1000720 this.pendingFont_ = cssFontFamily;
721 await this.fontManager_.loadFont(cssFontFamily);
722 // Sleep a bit to wait for flushing fontloadingdone events. This is not
723 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
724 // to refresh font unnecessarily in some cases.
725 await sleep(30);
Jason Linabad7562022-08-22 14:49:05 +1000726
Jason Lin6a402a72022-08-25 16:07:02 +1000727 if (this.pendingFont_ !== cssFontFamily) {
728 // `updateFont_()` probably is called again. Abort what we are doing.
729 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
730 ` (expecting ${cssFontFamily})`);
731 return;
732 }
Jason Linabad7562022-08-22 14:49:05 +1000733
Jason Lin6a402a72022-08-25 16:07:02 +1000734 if (this.term.options.fontFamily !== cssFontFamily) {
735 this.term.options.fontFamily = cssFontFamily;
736 } else {
737 // If the font is already the same, refresh font just to be safe.
738 this.refreshFont_();
739 }
740 this.pendingFont_ = null;
741 this.scheduleFit_();
Jason Linabad7562022-08-22 14:49:05 +1000742 }
Jason Lin5690e752022-08-30 15:36:45 +1000743
744 /**
745 * @param {!KeyboardEvent} ev
746 * @return {boolean} Return false if xterm.js should not handle the key event.
747 */
748 customKeyEventHandler_(ev) {
749 const modifiers = (ev.shiftKey ? Modifier.Shift : 0) |
750 (ev.altKey ? Modifier.Alt : 0) |
751 (ev.ctrlKey ? Modifier.Ctrl : 0) |
752 (ev.metaKey ? Modifier.Meta : 0);
753 const handler = this.keyDownHandlers_.get(
754 encodeKeyCombo(modifiers, ev.keyCode));
755 if (handler) {
756 if (ev.type === 'keydown') {
757 handler(ev);
758 }
759 return false;
760 }
761
762 return true;
763 }
764
765 /**
766 * A keydown handler for zoom-related keys.
767 *
768 * @param {!KeyboardEvent} ev
769 */
770 zoomKeyDownHandler_(ev) {
771 ev.preventDefault();
772
773 if (this.prefs_.get('ctrl-plus-minus-zero-zoom') === ev.shiftKey) {
774 // The only one with a control code.
775 if (ev.keyCode === keyCodes.MINUS) {
776 this.io.onVTKeystroke('\x1f');
777 }
778 return;
779 }
780
781 let newFontSize;
782 switch (ev.keyCode) {
783 case keyCodes.ZERO:
784 newFontSize = this.prefs_.get('font-size');
785 break;
786 case keyCodes.MINUS:
787 newFontSize = this.term.options.fontSize - 1;
788 break;
789 default:
790 newFontSize = this.term.options.fontSize + 1;
791 break;
792 }
793
Jason Linda56aa92022-09-02 13:01:49 +1000794 this.updateOption_('fontSize', Math.max(1, newFontSize), true);
Jason Lin5690e752022-08-30 15:36:45 +1000795 }
796
797 /** @param {!KeyboardEvent} ev */
798 ctrlCKeyDownHandler_(ev) {
799 ev.preventDefault();
800 if (this.prefs_.get('ctrl-c-copy') !== ev.shiftKey &&
801 this.term.hasSelection()) {
802 this.copySelection_();
803 return;
804 }
805
806 this.io.onVTKeystroke('\x03');
807 }
808
809 /** @param {!KeyboardEvent} ev */
810 ctrlVKeyDownHandler_(ev) {
811 if (this.prefs_.get('ctrl-v-paste') !== ev.shiftKey) {
812 // Don't do anything and let the browser handles the key.
813 return;
814 }
815
816 ev.preventDefault();
817 this.io.onVTKeystroke('\x16');
818 }
819
820 resetKeyDownHandlers_() {
821 this.keyDownHandlers_.clear();
822
823 /**
824 * Don't do anything and let the browser handles the key.
825 *
826 * @param {!KeyboardEvent} ev
827 */
828 const noop = (ev) => {};
829
830 /**
831 * @param {number} modifiers
832 * @param {number} keyCode
833 * @param {function(!KeyboardEvent)} func
834 */
835 const set = (modifiers, keyCode, func) => {
836 this.keyDownHandlers_.set(encodeKeyCombo(modifiers, keyCode),
837 func);
838 };
839
840 /**
841 * @param {number} modifiers
842 * @param {number} keyCode
843 * @param {function(!KeyboardEvent)} func
844 */
845 const setWithShiftVersion = (modifiers, keyCode, func) => {
846 set(modifiers, keyCode, func);
847 set(modifiers | Modifier.Shift, keyCode, func);
848 };
849
Jason Linf51162f2022-09-01 15:21:55 +1000850 // Temporary shortcut to refresh the rendering in case of rendering errors.
851 // TODO(lxj): remove after this is fixed:
852 // https://github.com/xtermjs/xterm.js/issues/3878
853 set(Modifier.Ctrl | Modifier.Shift, keyCodes.L,
854 /** @suppress {missingProperties} */
855 () => {
856 this.scheduleRefreshFont_();
857 // Refresh the cursor layer.
858 if (this.enableWebGL_) {
859 this.term?._core?._renderService?._renderer?._renderLayers[1]
860 ?._clearAll();
861 }
862 },
863 );
Jason Lin5690e752022-08-30 15:36:45 +1000864
865 // Ctrl+/
866 set(Modifier.Ctrl, 191, (ev) => {
867 ev.preventDefault();
868 this.io.onVTKeystroke(ctl('_'));
869 });
870
871 // Settings page.
872 set(Modifier.Ctrl | Modifier.Shift, keyCodes.P, (ev) => {
873 ev.preventDefault();
874 chrome.terminalPrivate.openOptionsPage(() => {});
875 });
876
877 if (this.prefs_.get('keybindings-os-defaults')) {
878 for (const binding of OS_DEFAULT_BINDINGS) {
879 this.keyDownHandlers_.set(binding, noop);
880 }
881 }
882
883 /** @param {!KeyboardEvent} ev */
884 const newWindow = (ev) => {
885 ev.preventDefault();
886 chrome.terminalPrivate.openWindow();
887 };
888 set(Modifier.Ctrl | Modifier.Shift, keyCodes.N, newWindow);
889 if (this.prefs_.get('pass-ctrl-n')) {
890 set(Modifier.Ctrl, keyCodes.N, newWindow);
891 }
892
893 if (this.prefs_.get('pass-ctrl-t')) {
894 setWithShiftVersion(Modifier.Ctrl, keyCodes.T, noop);
895 }
896
897 if (this.prefs_.get('pass-ctrl-w')) {
898 setWithShiftVersion(Modifier.Ctrl, keyCodes.W, noop);
899 }
900
901 if (this.prefs_.get('pass-ctrl-tab')) {
902 setWithShiftVersion(Modifier.Ctrl, keyCodes.TAB, noop);
903 }
904
905 const passCtrlNumber = this.prefs_.get('pass-ctrl-number');
906
907 /**
908 * Set a handler for the key combo ctrl+<number>.
909 *
910 * @param {number} number 1 to 9
911 * @param {string} controlCode The control code to send if we don't want to
912 * let the browser to handle it.
913 */
914 const setCtrlNumberHandler = (number, controlCode) => {
915 let func = noop;
916 if (!passCtrlNumber) {
917 func = (ev) => {
918 ev.preventDefault();
919 this.io.onVTKeystroke(controlCode);
920 };
921 }
922 set(Modifier.Ctrl, keyCodes.ZERO + number, func);
923 };
924
925 setCtrlNumberHandler(1, '1');
926 setCtrlNumberHandler(2, ctl('@'));
927 setCtrlNumberHandler(3, ctl('['));
928 setCtrlNumberHandler(4, ctl('\\'));
929 setCtrlNumberHandler(5, ctl(']'));
930 setCtrlNumberHandler(6, ctl('^'));
931 setCtrlNumberHandler(7, ctl('_'));
932 setCtrlNumberHandler(8, '\x7f');
933 setCtrlNumberHandler(9, '9');
934
935 if (this.prefs_.get('pass-alt-number')) {
936 for (let keyCode = keyCodes.ZERO; keyCode <= keyCodes.NINE; ++keyCode) {
937 set(Modifier.Alt, keyCode, noop);
938 }
939 }
940
941 for (const keyCode of [keyCodes.ZERO, keyCodes.MINUS, keyCodes.EQUAL]) {
942 setWithShiftVersion(Modifier.Ctrl, keyCode, this.zoomKeyDownHandler_);
943 }
944
945 setWithShiftVersion(Modifier.Ctrl, keyCodes.C, this.ctrlCKeyDownHandler_);
946 setWithShiftVersion(Modifier.Ctrl, keyCodes.V, this.ctrlVKeyDownHandler_);
947 }
Jason Linca61ffb2022-08-03 19:37:12 +1000948}
949
Jason Lind66e6bf2022-08-22 14:47:10 +1000950class HtermTerminal extends hterm.Terminal {
951 /** @override */
952 decorate(div) {
953 super.decorate(div);
954
955 const fontManager = new FontManager(this.getDocument());
956 fontManager.loadPowerlineCSS().then(() => {
957 const prefs = this.getPrefs();
958 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
959 prefs.addObserver(
960 'font-family',
961 (v) => fontManager.loadFont(/** @type {string} */(v)));
962 });
963 }
964}
965
Jason Linca61ffb2022-08-03 19:37:12 +1000966/**
967 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
968 * preference value.
969 *
970 * @param {{
971 * storage: !lib.Storage,
972 * profileId: string,
973 * }} args
974 * @return {!Promise<!hterm.Terminal>}
975 */
976export async function createEmulator({storage, profileId}) {
977 let config = TERMINAL_EMULATORS.get('hterm');
978
979 if (getOSInfo().alternative_emulator) {
Jason Lin21d854f2022-08-22 14:49:59 +1000980 // TODO: remove the url param logic. This is temporary to make manual
981 // testing a bit easier, which is also why this is not in
982 // './js/terminal_info.js'.
983 const emulator = ORIGINAL_URL.searchParams.get('emulator') ||
984 await storage.getItem(`/hterm/profiles/${profileId}/terminal-emulator`);
Jason Linca61ffb2022-08-03 19:37:12 +1000985 // Use the default (i.e. first) one if the pref is not set or invalid.
Jason Lin21d854f2022-08-22 14:49:59 +1000986 config = TERMINAL_EMULATORS.get(emulator) ||
Jason Linca61ffb2022-08-03 19:37:12 +1000987 TERMINAL_EMULATORS.values().next().value;
988 console.log('Terminal emulator config: ', config);
989 }
990
991 switch (config.lib) {
992 case 'xterm.js':
993 {
994 const terminal = new XtermTerminal({
995 storage,
996 profileId,
997 enableWebGL: config.webgl,
998 });
Jason Linca61ffb2022-08-03 19:37:12 +1000999 return terminal;
1000 }
1001 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +10001002 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +10001003 default:
1004 throw new Error('incorrect emulator config');
1005 }
1006}
1007
Jason Lin6a402a72022-08-25 16:07:02 +10001008class TerminalCopyNotice extends LitElement {
1009 /** @override */
1010 static get styles() {
1011 return css`
1012 :host {
1013 display: block;
1014 text-align: center;
1015 }
1016
1017 svg {
1018 fill: currentColor;
1019 }
1020 `;
1021 }
1022
1023 /** @override */
1024 render() {
1025 return html`
1026 ${ICON_COPY}
1027 <div>${hterm.messageManager.get('HTERM_NOTIFY_COPY')}</div>
1028 `;
1029 }
1030}
1031
1032customElements.define('terminal-copy-notice', TerminalCopyNotice);