blob: 72f36be41fc385a4f431d4541889cd013f30fc8c [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 Lin2649da22022-10-12 10:16:44 +110012// TODO(b/236205389): support option smoothScrollDuration?
13
Mike Frysinger75895da2022-10-04 00:42:28 +054514import {hterm, lib} from './deps_local.concat.js';
15
Jason Lin6a402a72022-08-25 16:07:02 +100016import {LitElement, css, html} from './lit.js';
Jason Linc48f7432022-10-13 17:28:30 +110017import {FontManager, ORIGINAL_URL, TERMINAL_EMULATORS, definePrefs,
18 delayedScheduler, fontManager, getOSInfo, sleep} from './terminal_common.js';
Jason Lin82ba86c2022-11-09 12:12:27 +110019import {TerminalContextMenu} from './terminal_context_menu.js';
Jason Lin6a402a72022-08-25 16:07:02 +100020import {ICON_COPY} from './terminal_icons.js';
Jason Lin83707c92022-09-20 19:09:41 +100021import {TerminalTooltip} from './terminal_tooltip.js';
Jason Linc2504ae2022-09-02 13:03:31 +100022import {Terminal, Unicode11Addon, WebLinksAddon, WebglAddon}
Jason Lin4de4f382022-09-01 14:10:18 +100023 from './xterm.js';
Jason Lin2649da22022-10-12 10:16:44 +110024import {XtermInternal} from './terminal_xterm_internal.js';
Jason Linca61ffb2022-08-03 19:37:12 +100025
Jason Lin5690e752022-08-30 15:36:45 +100026
27/** @enum {number} */
28export const Modifier = {
29 Shift: 1 << 0,
30 Alt: 1 << 1,
31 Ctrl: 1 << 2,
32 Meta: 1 << 3,
33};
34
35// This is just a static map from key names to key codes. It helps make the code
36// a bit more readable.
Jason Lin97a04282023-03-06 10:36:56 +110037export const keyCodes = hterm.Parser.identifiers.keyCodes;
Jason Lin5690e752022-08-30 15:36:45 +100038
39/**
40 * Encode a key combo (i.e. modifiers + a normal key) to an unique number.
41 *
42 * @param {number} modifiers
43 * @param {number} keyCode
44 * @return {number}
45 */
46export function encodeKeyCombo(modifiers, keyCode) {
47 return keyCode << 4 | modifiers;
48}
49
50const OS_DEFAULT_BINDINGS = [
51 // Submit feedback.
52 encodeKeyCombo(Modifier.Alt | Modifier.Shift, keyCodes.I),
53 // Toggle chromevox.
54 encodeKeyCombo(Modifier.Ctrl | Modifier.Alt, keyCodes.Z),
55 // Switch input method.
56 encodeKeyCombo(Modifier.Ctrl, keyCodes.SPACE),
57
58 // Dock window left/right.
59 encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_LEFT),
60 encodeKeyCombo(Modifier.Alt, keyCodes.BRACKET_RIGHT),
61
62 // Maximize/minimize window.
63 encodeKeyCombo(Modifier.Alt, keyCodes.EQUAL),
64 encodeKeyCombo(Modifier.Alt, keyCodes.MINUS),
65];
66
67
Jason Linca61ffb2022-08-03 19:37:12 +100068const ANSI_COLOR_NAMES = [
69 'black',
70 'red',
71 'green',
72 'yellow',
73 'blue',
74 'magenta',
75 'cyan',
76 'white',
77 'brightBlack',
78 'brightRed',
79 'brightGreen',
80 'brightYellow',
81 'brightBlue',
82 'brightMagenta',
83 'brightCyan',
84 'brightWhite',
85];
86
Jason Linca61ffb2022-08-03 19:37:12 +100087/**
Jason Lin97a04282023-03-06 10:36:56 +110088 * The value is the CSI code to send when no modifier keys are pressed.
89 *
90 * @type {!Map<number, string>}
91 */
92const ARROW_AND_SIX_PACK_KEYS = new Map([
93 [keyCodes.UP, '\x1b[A'],
94 [keyCodes.DOWN, '\x1b[B'],
95 [keyCodes.RIGHT, '\x1b[C'],
96 [keyCodes.LEFT, '\x1b[D'],
97 // 6-pack keys.
98 [keyCodes.INSERT, '\x1b[2~'],
99 [keyCodes.DEL, '\x1b[3~'],
100 [keyCodes.HOME, '\x1b[H'],
101 [keyCodes.END, '\x1b[F'],
102 [keyCodes.PAGE_UP, '\x1b[5~'],
103 [keyCodes.PAGE_DOWN, '\x1b[6~'],
104]);
105
106/**
Jason Linabad7562022-08-22 14:49:05 +1000107 * @typedef {{
108 * term: !Terminal,
109 * fontManager: !FontManager,
Jason Lin2649da22022-10-12 10:16:44 +1100110 * xtermInternal: !XtermInternal,
Jason Linabad7562022-08-22 14:49:05 +1000111 * }}
112 */
113export let XtermTerminalTestParams;
114
115/**
Jason Lin5690e752022-08-30 15:36:45 +1000116 * Compute a control character for a given character.
117 *
118 * @param {string} ch
119 * @return {string}
120 */
121function ctl(ch) {
122 return String.fromCharCode(ch.charCodeAt(0) - 64);
123}
124
125/**
Jason Lin21d854f2022-08-22 14:49:59 +1000126 * A "terminal io" class for xterm. We don't want the vanilla hterm.Terminal.IO
127 * because it always convert utf8 data to strings, which is not necessary for
128 * xterm.
129 */
130class XtermTerminalIO extends hterm.Terminal.IO {
131 /** @override */
132 writeUTF8(buffer) {
133 this.terminal_.write(new Uint8Array(buffer));
134 }
135
136 /** @override */
137 writelnUTF8(buffer) {
138 this.terminal_.writeln(new Uint8Array(buffer));
139 }
140
141 /** @override */
142 print(string) {
143 this.terminal_.write(string);
144 }
145
146 /** @override */
147 writeUTF16(string) {
148 this.print(string);
149 }
150
151 /** @override */
152 println(string) {
153 this.terminal_.writeln(string);
154 }
155
156 /** @override */
157 writelnUTF16(string) {
158 this.println(string);
159 }
160}
161
162/**
Jason Lin83707c92022-09-20 19:09:41 +1000163 * A custom link handler that:
164 *
165 * - Shows a tooltip with the url on a OSC 8 link. This is following what hterm
166 * is doing. Also, showing the tooltip is better for the security of the user
167 * because the link can have arbitrary text.
168 * - Uses our own way to open the window.
169 */
170class LinkHandler {
171 /**
172 * @param {!Terminal} term
173 */
174 constructor(term) {
175 this.term_ = term;
176 /** @type {?TerminalTooltip} */
177 this.tooltip_ = null;
178 }
179
180 /**
181 * @return {!TerminalTooltip}
182 */
183 getTooltip_() {
184 if (!this.tooltip_) {
185 this.tooltip_ = /** @type {!TerminalTooltip} */(
186 document.createElement('terminal-tooltip'));
187 this.tooltip_.classList.add('xterm-hover');
188 lib.notNull(this.term_.element).appendChild(this.tooltip_);
189 }
190 return this.tooltip_;
191 }
192
193 /**
194 * @param {!MouseEvent} ev
195 * @param {string} url
196 * @param {!Object} range
197 */
198 activate(ev, url, range) {
199 lib.f.openWindow(url, '_blank');
200 }
201
202 /**
203 * @param {!MouseEvent} ev
204 * @param {string} url
205 * @param {!Object} range
206 */
207 hover(ev, url, range) {
208 this.getTooltip_().show(url, {x: ev.clientX, y: ev.clientY});
209 }
210
211 /**
212 * @param {!MouseEvent} ev
213 * @param {string} url
214 * @param {!Object} range
215 */
216 leave(ev, url, range) {
217 this.getTooltip_().hide();
218 }
219}
220
Jason Linc7afb672022-10-11 15:54:17 +1100221class Bell {
222 constructor() {
223 this.showNotification = false;
224
225 /** @type {?Audio} */
226 this.audio_ = null;
227 /** @type {?Notification} */
228 this.notification_ = null;
229 this.coolDownUntil_ = 0;
230 }
231
232 /**
233 * Set whether a bell audio should be played.
234 *
235 * @param {boolean} value
236 */
237 set playAudio(value) {
238 this.audio_ = value ?
239 new Audio(lib.resource.getDataUrl('hterm/audio/bell')) : null;
240 }
241
242 ring() {
243 const now = Date.now();
244 if (now < this.coolDownUntil_) {
245 return;
246 }
247 this.coolDownUntil_ = now + 500;
248
249 this.audio_?.play();
250 if (this.showNotification && !document.hasFocus() && !this.notification_) {
251 this.notification_ = new Notification(
252 `\u266A ${document.title} \u266A`,
253 {icon: lib.resource.getDataUrl('hterm/images/icon-96')});
254 // Close the notification after a timeout. Note that this is different
255 // from hterm's behavior, but I think it makes more sense to do so.
256 setTimeout(() => {
257 this.notification_.close();
258 this.notification_ = null;
259 }, 5000);
260 }
261 }
262}
263
Jason Lind3aacef2022-10-12 19:03:37 +1100264const A11Y_BUTTON_STYLE = `
265position: fixed;
266z-index: 10;
267right: 16px;
268`;
269
Jason Lina8adea52022-10-25 13:14:14 +1100270// TODO: we should subscribe to the xterm.js onscroll event, and
271// disable/enable the buttons accordingly. However, xterm.js does not seem to
272// emit the onscroll event when the viewport is scrolled by the mouse. See
273// https://github.com/xtermjs/xterm.js/issues/3864
274export class A11yButtons {
Jason Lind3aacef2022-10-12 19:03:37 +1100275 /**
276 * @param {!Terminal} term
Jason Lina8adea52022-10-25 13:14:14 +1100277 * @param {!hterm.AccessibilityReader} htermA11yReader
Jason Lind3aacef2022-10-12 19:03:37 +1100278 */
Jason Linc0f14fe2022-10-25 15:31:29 +1100279 constructor(term, htermA11yReader) {
Jason Lina8adea52022-10-25 13:14:14 +1100280 this.term_ = term;
281 this.htermA11yReader_ = htermA11yReader;
Jason Linc0f14fe2022-10-25 15:31:29 +1100282 this.pageUpButton = document.createElement('button');
283 this.pageUpButton.style.cssText = A11Y_BUTTON_STYLE;
284 this.pageUpButton.textContent =
Jason Lind3aacef2022-10-12 19:03:37 +1100285 hterm.messageManager.get('HTERM_BUTTON_PAGE_UP');
Jason Linc0f14fe2022-10-25 15:31:29 +1100286 this.pageUpButton.addEventListener('click',
Jason Lina8adea52022-10-25 13:14:14 +1100287 () => this.scrollPages_(-1));
Jason Lind3aacef2022-10-12 19:03:37 +1100288
Jason Linc0f14fe2022-10-25 15:31:29 +1100289 this.pageDownButton = document.createElement('button');
290 this.pageDownButton.style.cssText = A11Y_BUTTON_STYLE;
291 this.pageDownButton.textContent =
Jason Lind3aacef2022-10-12 19:03:37 +1100292 hterm.messageManager.get('HTERM_BUTTON_PAGE_DOWN');
Jason Linc0f14fe2022-10-25 15:31:29 +1100293 this.pageDownButton.addEventListener('click',
Jason Lina8adea52022-10-25 13:14:14 +1100294 () => this.scrollPages_(1));
Jason Lind3aacef2022-10-12 19:03:37 +1100295
296 this.resetPos_();
Jason Lind3aacef2022-10-12 19:03:37 +1100297
298 this.onSelectionChange_ = this.onSelectionChange_.bind(this);
299 }
300
301 /**
Jason Lina8adea52022-10-25 13:14:14 +1100302 * @param {number} amount
303 */
304 scrollPages_(amount) {
305 this.term_.scrollPages(amount);
306 this.announceScreenContent_();
307 }
308
309 announceScreenContent_() {
310 const activeBuffer = this.term_.buffer.active;
311
312 let percentScrolled = 100;
313 if (activeBuffer.baseY !== 0) {
314 percentScrolled = Math.round(
315 100 * activeBuffer.viewportY / activeBuffer.baseY);
316 }
317
318 let currentScreenContent = hterm.messageManager.get(
319 'HTERM_ANNOUNCE_CURRENT_SCREEN_HEADER',
320 [percentScrolled],
321 '$1% scrolled,');
322
323 currentScreenContent += '\n';
324
325 const rowEnd = Math.min(activeBuffer.viewportY + this.term_.rows,
326 activeBuffer.length);
327 for (let i = activeBuffer.viewportY; i < rowEnd; ++i) {
328 currentScreenContent +=
329 activeBuffer.getLine(i).translateToString(true) + '\n';
330 }
331 currentScreenContent = currentScreenContent.trim();
332
333 this.htermA11yReader_.assertiveAnnounce(currentScreenContent);
334 }
335
336 /**
Jason Lind3aacef2022-10-12 19:03:37 +1100337 * @param {boolean} enabled
338 */
339 setEnabled(enabled) {
340 if (enabled) {
341 document.addEventListener('selectionchange', this.onSelectionChange_);
342 } else {
343 this.resetPos_();
344 document.removeEventListener('selectionchange', this.onSelectionChange_);
345 }
346 }
347
348 resetPos_() {
Jason Linc0f14fe2022-10-25 15:31:29 +1100349 this.pageUpButton.style.top = '-200px';
350 this.pageDownButton.style.bottom = '-200px';
Jason Lind3aacef2022-10-12 19:03:37 +1100351 }
352
353 onSelectionChange_() {
354 this.resetPos_();
355
Jason Lin36b9fce2022-11-10 16:56:40 +1100356 const selectedElement = document.getSelection().anchorNode?.parentElement;
Jason Linc0f14fe2022-10-25 15:31:29 +1100357 if (selectedElement === this.pageUpButton) {
358 this.pageUpButton.style.top = '16px';
359 } else if (selectedElement === this.pageDownButton) {
360 this.pageDownButton.style.bottom = '16px';
Jason Lind3aacef2022-10-12 19:03:37 +1100361 }
362 }
363}
364
Jason Linee0c1f72022-10-18 17:17:26 +1100365const BACKGROUND_IMAGE_KEY = 'background-image';
366
367class BackgroundImageWatcher {
368 /**
369 * @param {!hterm.PreferenceManager} prefs
370 * @param {function(string)} onChange This is called with the background image
371 * (could be empty) whenever it changes.
372 */
373 constructor(prefs, onChange) {
374 this.prefs_ = prefs;
375 this.onChange_ = onChange;
376 }
377
378 /**
379 * Call once to start watching for background image changes.
380 */
381 watch() {
382 window.addEventListener('storage', (e) => {
383 if (e.key === BACKGROUND_IMAGE_KEY) {
384 this.onChange_(this.getBackgroundImage());
385 }
386 });
387 this.prefs_.addObserver(BACKGROUND_IMAGE_KEY, () => {
388 this.onChange_(this.getBackgroundImage());
389 });
390 }
391
392 getBackgroundImage() {
393 const image = window.localStorage.getItem(BACKGROUND_IMAGE_KEY);
394 if (image) {
395 return `url(${image})`;
396 }
397
398 return this.prefs_.getString(BACKGROUND_IMAGE_KEY);
399 }
400}
401
Jason Linb8f380a2022-10-25 13:15:56 +1100402let xtermTerminalStringsLoaded = false;
403
Jason Lin83707c92022-09-20 19:09:41 +1000404/**
Jason Linca61ffb2022-08-03 19:37:12 +1000405 * A terminal class that 1) uses xterm.js and 2) behaves like a `hterm.Terminal`
406 * so that it can be used in existing code.
407 *
Jason Linca61ffb2022-08-03 19:37:12 +1000408 * @extends {hterm.Terminal}
409 * @unrestricted
410 */
Jason Linabad7562022-08-22 14:49:05 +1000411export class XtermTerminal {
Jason Linca61ffb2022-08-03 19:37:12 +1000412 /**
413 * @param {{
414 * storage: !lib.Storage,
415 * profileId: string,
416 * enableWebGL: boolean,
Jason Linabad7562022-08-22 14:49:05 +1000417 * testParams: (!XtermTerminalTestParams|undefined),
Jason Linca61ffb2022-08-03 19:37:12 +1000418 * }} args
419 */
Jason Linabad7562022-08-22 14:49:05 +1000420 constructor({storage, profileId, enableWebGL, testParams}) {
Jason Lin5690e752022-08-30 15:36:45 +1000421 this.ctrlCKeyDownHandler_ = this.ctrlCKeyDownHandler_.bind(this);
422 this.ctrlVKeyDownHandler_ = this.ctrlVKeyDownHandler_.bind(this);
423 this.zoomKeyDownHandler_ = this.zoomKeyDownHandler_.bind(this);
424
Jason Lin8de3d282022-09-01 21:29:05 +1000425 this.inited_ = false;
Jason Lin21d854f2022-08-22 14:49:59 +1000426 this.profileId_ = profileId;
Jason Linca61ffb2022-08-03 19:37:12 +1000427 /** @type {!hterm.PreferenceManager} */
428 this.prefs_ = new hterm.PreferenceManager(storage, profileId);
Jason Linc48f7432022-10-13 17:28:30 +1100429 definePrefs(this.prefs_);
Jason Linca61ffb2022-08-03 19:37:12 +1000430 this.enableWebGL_ = enableWebGL;
431
Jason Lin5690e752022-08-30 15:36:45 +1000432 // TODO: we should probably pass the initial prefs to the ctor.
Jason Linfc8a3722022-09-07 17:49:18 +1000433 this.term = testParams?.term || new Terminal({allowProposedApi: true});
Jason Lin2649da22022-10-12 10:16:44 +1100434 this.xtermInternal_ = testParams?.xtermInternal ||
435 new XtermInternal(this.term);
Jason Linabad7562022-08-22 14:49:05 +1000436 this.fontManager_ = testParams?.fontManager || fontManager;
Jason Linabad7562022-08-22 14:49:05 +1000437
Jason Linc2504ae2022-09-02 13:03:31 +1000438 /** @type {?Element} */
439 this.container_;
Jason Linc7afb672022-10-11 15:54:17 +1100440 this.bell_ = new Bell();
Jason Linc2504ae2022-09-02 13:03:31 +1000441 this.scheduleFit_ = delayedScheduler(() => this.fit_(),
Jason Linabad7562022-08-22 14:49:05 +1000442 testParams ? 0 : 250);
443
Jason Lin83707c92022-09-20 19:09:41 +1000444 this.term.loadAddon(
445 new WebLinksAddon((e, uri) => lib.f.openWindow(uri, '_blank')));
Jason Lin4de4f382022-09-01 14:10:18 +1000446 this.term.loadAddon(new Unicode11Addon());
447 this.term.unicode.activeVersion = '11';
448
Jason Linabad7562022-08-22 14:49:05 +1000449 this.pendingFont_ = null;
450 this.scheduleRefreshFont_ = delayedScheduler(
451 () => this.refreshFont_(), 100);
452 document.fonts.addEventListener('loadingdone',
453 () => this.onFontLoadingDone_());
Jason Linca61ffb2022-08-03 19:37:12 +1000454
455 this.installUnimplementedStubs_();
Jason Line9231bc2022-09-01 13:54:02 +1000456 this.installEscapeSequenceHandlers_();
Jason Linca61ffb2022-08-03 19:37:12 +1000457
Jason Lin34a45322022-10-12 19:10:52 +1100458 this.term.onResize(({cols, rows}) => {
459 this.io.onTerminalResize(cols, rows);
460 if (this.prefs_.get('enable-resize-status')) {
461 this.showOverlay(`${cols} × ${rows}`);
462 }
463 });
Jason Lin21d854f2022-08-22 14:49:59 +1000464 // We could also use `this.io.sendString()` except for the nassh exit
465 // prompt, which only listens to onVTKeystroke().
466 this.term.onData((data) => this.io.onVTKeystroke(data));
Jason Lin80e69132022-09-02 16:31:43 +1000467 this.term.onBinary((data) => this.io.onVTKeystroke(data));
Jason Lin2649da22022-10-12 10:16:44 +1100468 this.term.onTitleChange((title) => this.setWindowTitle(title));
Jason Lin83ef5ba2022-10-13 17:40:30 +1100469 this.term.onSelectionChange(() => {
470 if (this.prefs_.get('copy-on-select')) {
471 this.copySelection_();
472 }
473 });
Jason Linc7afb672022-10-11 15:54:17 +1100474 this.term.onBell(() => this.ringBell());
Jason Lin5690e752022-08-30 15:36:45 +1000475
476 /**
477 * A mapping from key combo (see encodeKeyCombo()) to a handler function.
478 *
479 * If a key combo is in the map:
480 *
481 * - The handler instead of xterm.js will handle the keydown event.
482 * - Keyup and keypress will be ignored by both us and xterm.js.
483 *
484 * We re-generate this map every time a relevant pref value is changed. This
485 * is ok because pref changes are rare.
486 *
487 * @type {!Map<number, function(!KeyboardEvent)>}
488 */
489 this.keyDownHandlers_ = new Map();
490 this.scheduleResetKeyDownHandlers_ =
491 delayedScheduler(() => this.resetKeyDownHandlers_(), 250);
492
Jason Lin97a04282023-03-06 10:36:56 +1100493 this.term.attachCustomKeyEventHandler((ev) => !this.handleKeyEvent_(ev));
Jason Linca61ffb2022-08-03 19:37:12 +1000494
Jason Lin21d854f2022-08-22 14:49:59 +1000495 this.io = new XtermTerminalIO(this);
496 this.notificationCenter_ = null;
Jason Lind3aacef2022-10-12 19:03:37 +1100497 this.htermA11yReader_ = null;
Jason Linc0f14fe2022-10-25 15:31:29 +1100498 this.a11yEnabled_ = false;
Jason Lind3aacef2022-10-12 19:03:37 +1100499 this.a11yButtons_ = null;
Jason Lin6a402a72022-08-25 16:07:02 +1000500 this.copyNotice_ = null;
Jason Lin446f3d92022-10-13 17:34:21 +1100501 this.scrollOnOutputListener_ = null;
Jason Linee0c1f72022-10-18 17:17:26 +1100502 this.backgroundImageWatcher_ = new BackgroundImageWatcher(this.prefs_,
503 this.setBackgroundImage.bind(this));
504 this.webglAddon_ = null;
Jason Lina63d8ba2022-11-02 17:42:38 +1100505 this.userCSSElement_ = null;
506 this.userCSSTextElement_ = null;
Jason Lin6a402a72022-08-25 16:07:02 +1000507
Jason Lin82ba86c2022-11-09 12:12:27 +1100508 this.contextMenu_ = /** @type {!TerminalContextMenu} */(
509 document.createElement('terminal-context-menu'));
510 this.contextMenu_.style.zIndex = 10;
511 this.contextMenu = {
512 setItems: (items) => this.contextMenu_.items = items,
513 };
514
Jason Lin83707c92022-09-20 19:09:41 +1000515 this.term.options.linkHandler = new LinkHandler(this.term);
Jason Lin6a402a72022-08-25 16:07:02 +1000516 this.term.options.theme = {
Jason Lin461ca562022-09-07 13:53:08 +1000517 // The webgl cursor layer also paints the character under the cursor with
518 // this `cursorAccent` color. We use a completely transparent color here
519 // to effectively disable that.
520 cursorAccent: 'rgba(0, 0, 0, 0)',
521 customGlyphs: true,
Jason Lin2edc25d2022-09-16 15:06:48 +1000522 selectionBackground: 'rgba(174, 203, 250, .6)',
523 selectionInactiveBackground: 'rgba(218, 220, 224, .6)',
Jason Lin6a402a72022-08-25 16:07:02 +1000524 selectionForeground: 'black',
Jason Lin6a402a72022-08-25 16:07:02 +1000525 };
526 this.observePrefs_();
Jason Linb8f380a2022-10-25 13:15:56 +1100527 if (!xtermTerminalStringsLoaded) {
528 xtermTerminalStringsLoaded = true;
529 Terminal.strings.promptLabel =
530 hterm.messageManager.get('TERMINAL_INPUT_LABEL');
531 Terminal.strings.tooMuchOutput =
532 hterm.messageManager.get('TERMINAL_TOO_MUCH_OUTPUT_MESSAGE');
533 }
Jason Linca61ffb2022-08-03 19:37:12 +1000534 }
535
Jason Linc7afb672022-10-11 15:54:17 +1100536 /** @override */
Jason Lin2649da22022-10-12 10:16:44 +1100537 setWindowTitle(title) {
538 document.title = title;
539 }
540
541 /** @override */
Jason Linc7afb672022-10-11 15:54:17 +1100542 ringBell() {
543 this.bell_.ring();
544 }
545
Jason Lin2649da22022-10-12 10:16:44 +1100546 /** @override */
547 print(str) {
548 this.xtermInternal_.print(str);
549 }
550
551 /** @override */
552 wipeContents() {
553 this.term.clear();
554 }
555
556 /** @override */
557 newLine() {
558 this.xtermInternal_.newLine();
559 }
560
561 /** @override */
562 cursorLeft(number) {
563 this.xtermInternal_.cursorLeft(number ?? 1);
564 }
565
Jason Lind3aacef2022-10-12 19:03:37 +1100566 /** @override */
567 setAccessibilityEnabled(enabled) {
Jason Linc0f14fe2022-10-25 15:31:29 +1100568 if (enabled === this.a11yEnabled_) {
569 return;
570 }
571 this.a11yEnabled_ = enabled;
572
Jason Lind3aacef2022-10-12 19:03:37 +1100573 this.a11yButtons_.setEnabled(enabled);
574 this.htermA11yReader_.setAccessibilityEnabled(enabled);
Jason Linc0f14fe2022-10-25 15:31:29 +1100575
576 if (enabled) {
577 this.xtermInternal_.enableA11y(this.a11yButtons_.pageUpButton,
578 this.a11yButtons_.pageDownButton);
579 } else {
580 this.xtermInternal_.disableA11y();
581 }
Jason Lind3aacef2022-10-12 19:03:37 +1100582 }
583
Jason Linee0c1f72022-10-18 17:17:26 +1100584 hasBackgroundImage() {
585 return !!this.container_.style.backgroundImage;
586 }
587
588 /** @override */
589 setBackgroundImage(image) {
590 this.container_.style.backgroundImage = image || '';
591 this.updateBackgroundColor_(this.prefs_.getString('background-color'));
592 }
593
Jason Linca61ffb2022-08-03 19:37:12 +1000594 /**
595 * Install stubs for stuff that we haven't implemented yet so that the code
596 * still runs.
597 */
598 installUnimplementedStubs_() {
599 this.keyboard = {
600 keyMap: {
601 keyDefs: [],
602 },
603 bindings: {
604 clear: () => {},
605 addBinding: () => {},
606 addBindings: () => {},
607 OsDefaults: {},
608 },
609 };
610 this.keyboard.keyMap.keyDefs[78] = {};
Joel Hockey965ea552023-02-19 22:08:04 -0800611 this.keyboard.keyMap.keyDefs[84] = {};
Jason Linca61ffb2022-08-03 19:37:12 +1000612
613 const methodNames = [
Joel Hockeyb89a9782022-10-16 22:00:12 -0700614 'eraseLine',
Joel Hockeyb89a9782022-10-16 22:00:12 -0700615 'setCursorColumn',
Jason Linca61ffb2022-08-03 19:37:12 +1000616 'setCursorPosition',
617 'setCursorVisible',
Joel Hockeyd78374f2022-11-02 23:05:53 -0700618 'uninstallKeyboard',
Jason Linca61ffb2022-08-03 19:37:12 +1000619 ];
620
621 for (const name of methodNames) {
622 this[name] = () => console.warn(`${name}() is not implemented`);
623 }
624
Jason Lin21d854f2022-08-22 14:49:59 +1000625 this.vt = {
626 resetParseState: () => {
627 console.warn('.vt.resetParseState() is not implemented');
628 },
629 };
Jason Linca61ffb2022-08-03 19:37:12 +1000630 }
631
Jason Line9231bc2022-09-01 13:54:02 +1000632 installEscapeSequenceHandlers_() {
633 // OSC 52 for copy.
634 this.term.parser.registerOscHandler(52, (args) => {
635 // Args comes in as a single 'clipboard;b64-data' string. The clipboard
636 // parameter is used to select which of the X clipboards to address. Since
637 // we're not integrating with X, we treat them all the same.
638 const parsedArgs = args.match(/^[cps01234567]*;(.*)/);
639 if (!parsedArgs) {
640 return true;
641 }
642
643 let data;
644 try {
645 data = window.atob(parsedArgs[1]);
646 } catch (e) {
647 // If the user sent us invalid base64 content, silently ignore it.
648 return true;
649 }
650 const decoder = new TextDecoder();
651 const bytes = lib.codec.stringToCodeUnitArray(data);
652 this.copyString_(decoder.decode(bytes));
653
654 return true;
655 });
Jason Lin2649da22022-10-12 10:16:44 +1100656
657 this.xtermInternal_.installTmuxControlModeHandler(
658 (data) => this.onTmuxControlModeLine(data));
659 this.xtermInternal_.installEscKHandler();
Jason Line9231bc2022-09-01 13:54:02 +1000660 }
661
Jason Linca61ffb2022-08-03 19:37:12 +1000662 /**
Jason Lin21d854f2022-08-22 14:49:59 +1000663 * Write data to the terminal.
664 *
665 * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
666 * UTF-8 data
Jason Lin2649da22022-10-12 10:16:44 +1100667 * @param {function()=} callback Optional callback that fires when the data
668 * was processed by the parser.
Jason Lin21d854f2022-08-22 14:49:59 +1000669 */
Jason Lin2649da22022-10-12 10:16:44 +1100670 write(data, callback) {
671 this.term.write(data, callback);
Jason Lin21d854f2022-08-22 14:49:59 +1000672 }
673
674 /**
675 * Like `this.write()` but also write a line break.
676 *
677 * @param {string|!Uint8Array} data
Jason Lin2649da22022-10-12 10:16:44 +1100678 * @param {function()=} callback Optional callback that fires when the data
679 * was processed by the parser.
Jason Lin21d854f2022-08-22 14:49:59 +1000680 */
Jason Lin2649da22022-10-12 10:16:44 +1100681 writeln(data, callback) {
682 this.term.writeln(data, callback);
Jason Lin21d854f2022-08-22 14:49:59 +1000683 }
684
Jason Linca61ffb2022-08-03 19:37:12 +1000685 get screenSize() {
686 return new hterm.Size(this.term.cols, this.term.rows);
687 }
688
689 /**
690 * Don't need to do anything.
691 *
692 * @override
693 */
694 installKeyboard() {}
695
696 /**
697 * @override
698 */
699 decorate(elem) {
Jason Linc2504ae2022-09-02 13:03:31 +1000700 this.container_ = elem;
Jason Linee0c1f72022-10-18 17:17:26 +1100701 elem.style.backgroundSize = '100% 100%';
702
Jason Lin8de3d282022-09-01 21:29:05 +1000703 (async () => {
704 await new Promise((resolve) => this.prefs_.readStorage(resolve));
705 // This will trigger all the observers to set the terminal options before
706 // we call `this.term.open()`.
707 this.prefs_.notifyAll();
708
Jason Linc2504ae2022-09-02 13:03:31 +1000709 const screenPaddingSize = /** @type {number} */(
710 this.prefs_.get('screen-padding-size'));
711 elem.style.paddingTop = elem.style.paddingLeft = `${screenPaddingSize}px`;
712
Jason Linee0c1f72022-10-18 17:17:26 +1100713 this.setBackgroundImage(
714 this.backgroundImageWatcher_.getBackgroundImage());
715 this.backgroundImageWatcher_.watch();
716
Jason Lin8de3d282022-09-01 21:29:05 +1000717 this.inited_ = true;
718 this.term.open(elem);
Jason Lin1be92f52023-01-23 23:50:00 +1100719 this.xtermInternal_.addDimensionsObserver(() => this.scheduleFit_());
Jason Lin8de3d282022-09-01 21:29:05 +1000720
Jason Lin8de3d282022-09-01 21:29:05 +1000721 if (this.enableWebGL_) {
Jason Linee0c1f72022-10-18 17:17:26 +1100722 this.reloadWebglAddon_();
Jason Lin8de3d282022-09-01 21:29:05 +1000723 }
724 this.term.focus();
725 (new ResizeObserver(() => this.scheduleFit_())).observe(elem);
Jason Lind3aacef2022-10-12 19:03:37 +1100726 this.htermA11yReader_ = new hterm.AccessibilityReader(elem);
727 this.notificationCenter_ = new hterm.NotificationCenter(document.body,
728 this.htermA11yReader_);
Jason Lin8de3d282022-09-01 21:29:05 +1000729
Jason Lin82ba86c2022-11-09 12:12:27 +1100730 elem.appendChild(this.contextMenu_);
731
Jason Lin932b7432022-12-07 16:51:54 +1100732 elem.addEventListener('dragover', (e) => e.preventDefault());
733 elem.addEventListener('drop',
734 (e) => this.onDrop_(/** @type {!DragEvent} */(e)));
735
Jason Lin82ba86c2022-11-09 12:12:27 +1100736 // Block the default context menu from popping up.
Emil Mikulic2a194d02022-09-29 14:30:59 +1000737 elem.addEventListener('contextmenu', (e) => e.preventDefault());
738
739 // Add a handler for pasting with the mouse.
Jason Lin932b7432022-12-07 16:51:54 +1100740 elem.addEventListener('mousedown',
741 (e) => this.onMouseDown_(/** @type {!MouseEvent} */(e)));
Emil Mikulic2a194d02022-09-29 14:30:59 +1000742
Jason Lin2649da22022-10-12 10:16:44 +1100743 await this.scheduleFit_();
Jason Linc0f14fe2022-10-25 15:31:29 +1100744 this.a11yButtons_ = new A11yButtons(this.term, this.htermA11yReader_);
Jason Lin23b4cef2023-03-06 10:25:29 +1100745 if (!this.prefs_.get('scrollbar-visible')) {
746 this.xtermInternal_.setScrollbarVisible(false);
747 }
Jason Lind3aacef2022-10-12 19:03:37 +1100748
Jason Lin8de3d282022-09-01 21:29:05 +1000749 this.onTerminalReady();
750 })();
Jason Lin21d854f2022-08-22 14:49:59 +1000751 }
752
753 /** @override */
754 showOverlay(msg, timeout = 1500) {
Jason Lin34a45322022-10-12 19:10:52 +1100755 this.notificationCenter_?.show(msg, {timeout});
Jason Lin21d854f2022-08-22 14:49:59 +1000756 }
757
758 /** @override */
759 hideOverlay() {
Jason Lin34a45322022-10-12 19:10:52 +1100760 this.notificationCenter_?.hide();
Jason Linca61ffb2022-08-03 19:37:12 +1000761 }
762
763 /** @override */
764 getPrefs() {
765 return this.prefs_;
766 }
767
768 /** @override */
769 getDocument() {
770 return window.document;
771 }
772
Jason Lin21d854f2022-08-22 14:49:59 +1000773 /** @override */
774 reset() {
775 this.term.reset();
Jason Linca61ffb2022-08-03 19:37:12 +1000776 }
777
778 /** @override */
Jason Lin21d854f2022-08-22 14:49:59 +1000779 setProfile(profileId, callback = undefined) {
780 this.prefs_.setProfile(profileId, callback);
Jason Linca61ffb2022-08-03 19:37:12 +1000781 }
782
Jason Lin21d854f2022-08-22 14:49:59 +1000783 /** @override */
784 interpret(string) {
785 this.term.write(string);
Jason Linca61ffb2022-08-03 19:37:12 +1000786 }
787
Jason Lin21d854f2022-08-22 14:49:59 +1000788 /** @override */
789 focus() {
790 this.term.focus();
791 }
Jason Linca61ffb2022-08-03 19:37:12 +1000792
793 /** @override */
794 onOpenOptionsPage() {}
795
796 /** @override */
797 onTerminalReady() {}
798
Jason Lind04bab32022-08-22 14:48:39 +1000799 observePrefs_() {
Jason Lin21d854f2022-08-22 14:49:59 +1000800 // This is for this.notificationCenter_.
801 const setHtermCSSVariable = (name, value) => {
802 document.body.style.setProperty(`--hterm-${name}`, value);
803 };
804
805 const setHtermColorCSSVariable = (name, color) => {
806 const css = lib.notNull(lib.colors.normalizeCSS(color));
807 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
808 setHtermCSSVariable(name, rgb);
809 };
810
811 this.prefs_.addObserver('font-size', (v) => {
Jason Lin1be92f52023-01-23 23:50:00 +1100812 this.term.options.fontSize = v;
Jason Lin21d854f2022-08-22 14:49:59 +1000813 setHtermCSSVariable('font-size', `${v}px`);
814 });
815
Jason Linda56aa92022-09-02 13:01:49 +1000816 // TODO(lxj): support option "lineHeight", "scrollback".
Jason Lind04bab32022-08-22 14:48:39 +1000817 this.prefs_.addObservers(null, {
Jason Linda56aa92022-09-02 13:01:49 +1000818 'audible-bell-sound': (v) => {
Jason Linc7afb672022-10-11 15:54:17 +1100819 this.bell_.playAudio = !!v;
820 },
821 'desktop-notification-bell': (v) => {
822 this.bell_.showNotification = v;
Jason Linda56aa92022-09-02 13:01:49 +1000823 },
Jason Lind04bab32022-08-22 14:48:39 +1000824 'background-color': (v) => {
Jason Linee0c1f72022-10-18 17:17:26 +1100825 this.updateBackgroundColor_(v);
Jason Lin21d854f2022-08-22 14:49:59 +1000826 setHtermColorCSSVariable('background-color', v);
Jason Lind04bab32022-08-22 14:48:39 +1000827 },
Jason Lind04bab32022-08-22 14:48:39 +1000828 'color-palette-overrides': (v) => {
829 if (!(v instanceof Array)) {
830 // For terminal, we always expect this to be an array.
831 console.warn('unexpected color palette: ', v);
832 return;
833 }
834 const colors = {};
835 for (let i = 0; i < v.length; ++i) {
836 colors[ANSI_COLOR_NAMES[i]] = v[i];
837 }
838 this.updateTheme_(colors);
839 },
Jason Lin1be92f52023-01-23 23:50:00 +1100840 'cursor-blink': (v) => {
841 this.term.options.cursorBlink = v;
842 },
Jason Linda56aa92022-09-02 13:01:49 +1000843 'cursor-color': (v) => this.updateTheme_({cursor: v}),
844 'cursor-shape': (v) => {
845 let shape;
846 if (v === 'BEAM') {
847 shape = 'bar';
848 } else {
849 shape = v.toLowerCase();
850 }
Jason Lin1be92f52023-01-23 23:50:00 +1100851 this.term.options.cursorStyle = shape;
Jason Linda56aa92022-09-02 13:01:49 +1000852 },
853 'font-family': (v) => this.updateFont_(v),
854 'foreground-color': (v) => {
Jason Lin461ca562022-09-07 13:53:08 +1000855 this.updateTheme_({foreground: v});
Jason Linda56aa92022-09-02 13:01:49 +1000856 setHtermColorCSSVariable('foreground-color', v);
857 },
Jason Lin1be92f52023-01-23 23:50:00 +1100858 'line-height': (v) => {
859 this.term.options.lineHeight = v;
860 },
Jason Lin471e1062022-12-08 15:39:15 +1100861 'scroll-on-keystroke': (v) => {
Jason Lin1be92f52023-01-23 23:50:00 +1100862 this.term.options.scrollOnUserInput = v;
Jason Lin471e1062022-12-08 15:39:15 +1100863 },
Jason Lin446f3d92022-10-13 17:34:21 +1100864 'scroll-on-output': (v) => {
865 if (!v) {
866 this.scrollOnOutputListener_?.dispose();
867 this.scrollOnOutputListener_ = null;
868 return;
869 }
870 if (!this.scrollOnOutputListener_) {
871 this.scrollOnOutputListener_ = this.term.onWriteParsed(
872 () => this.term.scrollToBottom());
873 }
874 },
Jason Lin23b4cef2023-03-06 10:25:29 +1100875 'scrollbar-visible': (v) => {
876 this.xtermInternal_.setScrollbarVisible(v);
877 },
Jason Lina63d8ba2022-11-02 17:42:38 +1100878 'user-css': (v) => {
879 if (this.userCSSElement_) {
880 this.userCSSElement_.remove();
881 }
882 if (v) {
883 this.userCSSElement_ = document.createElement('link');
884 this.userCSSElement_.setAttribute('rel', 'stylesheet');
885 this.userCSSElement_.setAttribute('href', v);
886 document.head.appendChild(this.userCSSElement_);
887 }
888 },
889 'user-css-text': (v) => {
890 if (!this.userCSSTextElement_) {
891 this.userCSSTextElement_ = document.createElement('style');
892 document.head.appendChild(this.userCSSTextElement_);
893 }
894 this.userCSSTextElement_.textContent = v;
895 },
Jason Lind04bab32022-08-22 14:48:39 +1000896 });
Jason Lin5690e752022-08-30 15:36:45 +1000897
898 for (const name of ['keybindings-os-defaults', 'pass-ctrl-n', 'pass-ctrl-t',
899 'pass-ctrl-w', 'pass-ctrl-tab', 'pass-ctrl-number', 'pass-alt-number',
900 'ctrl-plus-minus-zero-zoom', 'ctrl-c-copy', 'ctrl-v-paste']) {
901 this.prefs_.addObserver(name, this.scheduleResetKeyDownHandlers_);
902 }
Jason Lind04bab32022-08-22 14:48:39 +1000903 }
904
905 /**
Jason Linc2504ae2022-09-02 13:03:31 +1000906 * Fit the terminal to the containing HTML element.
907 */
908 fit_() {
909 if (!this.inited_) {
910 return;
911 }
912
913 const screenPaddingSize = /** @type {number} */(
914 this.prefs_.get('screen-padding-size'));
915
916 const calc = (size, cellSize) => {
917 return Math.floor((size - 2 * screenPaddingSize) / cellSize);
918 };
919
Jason Lin2649da22022-10-12 10:16:44 +1100920 const cellDimensions = this.xtermInternal_.getActualCellDimensions();
921 const cols = calc(this.container_.offsetWidth, cellDimensions.width);
922 const rows = calc(this.container_.offsetHeight, cellDimensions.height);
Jason Linc2504ae2022-09-02 13:03:31 +1000923 if (cols >= 0 && rows >= 0) {
924 this.term.resize(cols, rows);
925 }
926 }
927
Jason Linee0c1f72022-10-18 17:17:26 +1100928 reloadWebglAddon_() {
929 if (this.webglAddon_) {
930 this.webglAddon_.dispose();
931 }
932 this.webglAddon_ = new WebglAddon();
933 this.term.loadAddon(this.webglAddon_);
934 }
935
936 /**
937 * Update the background color. This will also adjust the transparency based
938 * on whether there is a background image.
939 *
940 * @param {string} color
941 */
942 updateBackgroundColor_(color) {
943 const hasBackgroundImage = this.hasBackgroundImage();
944
945 // We only set allowTransparency when it is necessary becuase 1) xterm.js
946 // documentation states that allowTransparency can affect performance; 2) I
947 // find that the rendering is better with allowTransparency being false.
948 // This could be a bug with xterm.js.
949 if (!!this.term.options.allowTransparency !== hasBackgroundImage) {
950 this.term.options.allowTransparency = hasBackgroundImage;
951 if (this.enableWebGL_ && this.inited_) {
952 // Setting allowTransparency in the middle messes up webgl rendering,
953 // so we need to reload it here.
954 this.reloadWebglAddon_();
955 }
956 }
957
958 if (this.hasBackgroundImage()) {
959 const css = lib.notNull(lib.colors.normalizeCSS(color));
960 const rgb = lib.colors.crackRGB(css).slice(0, 3).join(',');
961 // Note that we still want to set the RGB part correctly even though it is
962 // completely transparent. This is because the background color without
963 // the alpha channel is used in reverse video mode.
964 color = `rgba(${rgb}, 0)`;
965 }
966
967 this.updateTheme_({background: color});
968 }
969
Jason Linc2504ae2022-09-02 13:03:31 +1000970 /**
Jason Lind04bab32022-08-22 14:48:39 +1000971 * @param {!Object} theme
972 */
973 updateTheme_(theme) {
Jason Lin8de3d282022-09-01 21:29:05 +1000974 const updateTheme = (target) => {
975 for (const [key, value] of Object.entries(theme)) {
976 target[key] = lib.colors.normalizeCSS(value);
977 }
978 };
979
980 // Must use a new theme object to trigger re-render if we have initialized.
981 if (this.inited_) {
982 const newTheme = {...this.term.options.theme};
983 updateTheme(newTheme);
984 this.term.options.theme = newTheme;
985 return;
Jason Lind04bab32022-08-22 14:48:39 +1000986 }
Jason Lin8de3d282022-09-01 21:29:05 +1000987
988 updateTheme(this.term.options.theme);
Jason Lind04bab32022-08-22 14:48:39 +1000989 }
990
991 /**
Jason Linabad7562022-08-22 14:49:05 +1000992 * Called when there is a "fontloadingdone" event. We need this because
993 * `FontManager.loadFont()` does not guarantee loading all the font files.
994 */
995 async onFontLoadingDone_() {
996 // If there is a pending font, the font is going to be refresh soon, so we
997 // don't need to do anything.
Jason Lin8de3d282022-09-01 21:29:05 +1000998 if (this.inited_ && !this.pendingFont_) {
Jason Linabad7562022-08-22 14:49:05 +1000999 this.scheduleRefreshFont_();
1000 }
1001 }
1002
Jason Lin932b7432022-12-07 16:51:54 +11001003 /**
1004 * @param {!DragEvent} e
1005 */
1006 onDrop_(e) {
1007 e.preventDefault();
1008
1009 // If the shift key active, try to find a "rich" text source (but not plain
1010 // text). e.g. text/html is OK. This is the same behavior as hterm.
1011 if (e.shiftKey) {
1012 for (const type of e.dataTransfer.types) {
1013 if (type !== 'text/plain' && type.startsWith('text/')) {
1014 this.term.paste(e.dataTransfer.getData(type));
1015 return;
1016 }
1017 }
1018 }
1019
1020 this.term.paste(e.dataTransfer.getData('text/plain'));
1021 }
1022
1023 /**
1024 * @param {!MouseEvent} e
1025 */
Jason Lin97a04282023-03-06 10:36:56 +11001026 onMouseDown_(e) {
Jason Lin932b7432022-12-07 16:51:54 +11001027 if (this.term.modes.mouseTrackingMode !== 'none') {
1028 // xterm.js is in mouse mode and will handle the event.
1029 return;
1030 }
1031 const MIDDLE = 1;
1032 const RIGHT = 2;
1033
1034 if (e.button === RIGHT && e.ctrlKey) {
1035 this.contextMenu_.show({x: e.clientX, y: e.clientY});
1036 return;
1037 }
1038
1039 if (e.button === MIDDLE || (e.button === RIGHT &&
1040 this.prefs_.getBoolean('mouse-right-click-paste'))) {
Jason Lin97a04282023-03-06 10:36:56 +11001041 this.pasteFromClipboard_();
1042 }
1043 }
1044
1045 async pasteFromClipboard_() {
1046 const text = await navigator.clipboard?.readText?.();
1047 if (text) {
1048 this.term.paste(text);
Jason Lin932b7432022-12-07 16:51:54 +11001049 }
1050 }
1051
Jason Lin5690e752022-08-30 15:36:45 +10001052 copySelection_() {
Jason Line9231bc2022-09-01 13:54:02 +10001053 this.copyString_(this.term.getSelection());
1054 }
1055
1056 /** @param {string} data */
1057 copyString_(data) {
1058 if (!data) {
Jason Lin6a402a72022-08-25 16:07:02 +10001059 return;
1060 }
Jason Line9231bc2022-09-01 13:54:02 +10001061 navigator.clipboard?.writeText(data);
Jason Lin83ef5ba2022-10-13 17:40:30 +11001062
1063 if (this.prefs_.get('enable-clipboard-notice')) {
1064 if (!this.copyNotice_) {
1065 this.copyNotice_ = document.createElement('terminal-copy-notice');
1066 }
1067 setTimeout(() => this.showOverlay(lib.notNull(this.copyNotice_), 500),
1068 200);
Jason Lin6a402a72022-08-25 16:07:02 +10001069 }
Jason Lin6a402a72022-08-25 16:07:02 +10001070 }
1071
Jason Linabad7562022-08-22 14:49:05 +10001072 /**
1073 * Refresh xterm rendering for a font related event.
1074 */
1075 refreshFont_() {
1076 // We have to set the fontFamily option to a different string to trigger the
1077 // re-rendering. Appending a space at the end seems to be the easiest
1078 // solution. Note that `clearTextureAtlas()` and `refresh()` do not work for
1079 // us.
1080 //
1081 // TODO: Report a bug to xterm.js and ask for exposing a public function for
1082 // the refresh so that we don't need to do this hack.
1083 this.term.options.fontFamily += ' ';
1084 }
1085
1086 /**
1087 * Update a font.
1088 *
1089 * @param {string} cssFontFamily
1090 */
1091 async updateFont_(cssFontFamily) {
Jason Lin6a402a72022-08-25 16:07:02 +10001092 this.pendingFont_ = cssFontFamily;
1093 await this.fontManager_.loadFont(cssFontFamily);
1094 // Sleep a bit to wait for flushing fontloadingdone events. This is not
1095 // strictly necessary, but it should prevent `this.onFontLoadingDone_()`
1096 // to refresh font unnecessarily in some cases.
1097 await sleep(30);
Jason Linabad7562022-08-22 14:49:05 +10001098
Jason Lin6a402a72022-08-25 16:07:02 +10001099 if (this.pendingFont_ !== cssFontFamily) {
1100 // `updateFont_()` probably is called again. Abort what we are doing.
1101 console.log(`pendingFont_ (${this.pendingFont_}) is changed` +
1102 ` (expecting ${cssFontFamily})`);
1103 return;
1104 }
Jason Linabad7562022-08-22 14:49:05 +10001105
Jason Lin6a402a72022-08-25 16:07:02 +10001106 if (this.term.options.fontFamily !== cssFontFamily) {
1107 this.term.options.fontFamily = cssFontFamily;
1108 } else {
1109 // If the font is already the same, refresh font just to be safe.
1110 this.refreshFont_();
1111 }
1112 this.pendingFont_ = null;
Jason Linabad7562022-08-22 14:49:05 +10001113 }
Jason Lin5690e752022-08-30 15:36:45 +10001114
1115 /**
1116 * @param {!KeyboardEvent} ev
Jason Lin97a04282023-03-06 10:36:56 +11001117 * @return {boolean} Return true if the key event is handled.
Jason Lin5690e752022-08-30 15:36:45 +10001118 */
Jason Lin97a04282023-03-06 10:36:56 +11001119 handleKeyEvent_(ev) {
Jason Lin646dde02023-01-10 22:33:02 +11001120 // Without this, <alt-tab> (or <alt-shift-tab) is consumed by xterm.js
Jason Lin97a04282023-03-06 10:36:56 +11001121 // (instead of the OS) when terminal is full screen.
Jason Lin646dde02023-01-10 22:33:02 +11001122 if (ev.altKey && ev.keyCode === 9) {
Jason Lin97a04282023-03-06 10:36:56 +11001123 return true;
Jason Lin646dde02023-01-10 22:33:02 +11001124 }
1125
Jason Lin5690e752022-08-30 15:36:45 +10001126 const modifiers = (ev.shiftKey ? Modifier.Shift : 0) |
1127 (ev.altKey ? Modifier.Alt : 0) |
1128 (ev.ctrlKey ? Modifier.Ctrl : 0) |
1129 (ev.metaKey ? Modifier.Meta : 0);
Jason Lin97a04282023-03-06 10:36:56 +11001130
1131 if (this.handleArrowAndSixPackKeys_(ev, modifiers)) {
1132 ev.preventDefault();
1133 ev.stopPropagation();
1134 return true;
1135 }
1136
Jason Lin5690e752022-08-30 15:36:45 +10001137 const handler = this.keyDownHandlers_.get(
1138 encodeKeyCombo(modifiers, ev.keyCode));
1139 if (handler) {
1140 if (ev.type === 'keydown') {
1141 handler(ev);
1142 }
Jason Lin97a04282023-03-06 10:36:56 +11001143 return true;
1144 }
1145
1146 return false;
1147 }
1148
1149 /**
1150 * Handle arrow keys and the "six pack keys" (e.g. home, insert...) because
1151 * xterm.js does not always handle them correctly with modifier keys.
1152 *
1153 * The behavior here is mostly the same as hterm, but there are some
1154 * differences. For example, we send a code instead of scrolling the screen
1155 * with shift+up/down to follow the behavior of xterm and other popular
1156 * terminals (e.g. gnome-terminal).
1157 *
1158 * We don't use `this.keyDownHandlers_` for this because it needs one entry
1159 * per modifier combination.
1160 *
1161 * @param {!KeyboardEvent} ev
1162 * @param {number} modifiers
1163 * @return {boolean} Return true if the key event is handled.
1164 */
1165 handleArrowAndSixPackKeys_(ev, modifiers) {
1166 let code = ARROW_AND_SIX_PACK_KEYS.get(ev.keyCode);
1167 if (!code) {
Jason Lin5690e752022-08-30 15:36:45 +10001168 return false;
1169 }
1170
Jason Lin97a04282023-03-06 10:36:56 +11001171 // For this case, we need to consider the "application cursor mode". We will
1172 // just let xterm.js handle it.
1173 if (modifiers === 0) {
1174 return false;
1175 }
1176
1177 if (ev.type !== 'keydown') {
1178 // Do nothing for non-keydown event, and also don't let xterm.js handle
1179 // it.
1180 return true;
1181 }
1182
1183 // Special handling if only shift is depressed.
1184 if (modifiers === Modifier.Shift) {
1185 switch (ev.keyCode) {
1186 case keyCodes.INSERT:
1187 this.pasteFromClipboard_();
1188 return true;
1189 case keyCodes.PAGE_UP:
1190 this.term.scrollPages(-1);
1191 return true;
1192 case keyCodes.PAGE_DOWN:
1193 this.term.scrollPages(1);
1194 return true;
1195 case keyCodes.HOME:
1196 this.term.scrollToTop();
1197 return true;
1198 case keyCodes.END:
1199 this.term.scrollToBottom();
1200 return true;
1201 }
1202 }
1203
1204 const mod = `;${modifiers + 1}`;
1205 if (code.length === 3) {
1206 // Convert code from "CSI x" to "CSI 1 mod x";
1207 code = '\x1b[1' + mod + code[2];
1208 } else {
1209 // Convert code from "CSI ... ~" to "CSI ... mod ~";
1210 code = code.slice(0, -1) + mod + '~';
1211 }
1212 this.io.onVTKeystroke(code);
Jason Lin5690e752022-08-30 15:36:45 +10001213 return true;
1214 }
1215
1216 /**
1217 * A keydown handler for zoom-related keys.
1218 *
1219 * @param {!KeyboardEvent} ev
1220 */
1221 zoomKeyDownHandler_(ev) {
1222 ev.preventDefault();
1223
1224 if (this.prefs_.get('ctrl-plus-minus-zero-zoom') === ev.shiftKey) {
1225 // The only one with a control code.
1226 if (ev.keyCode === keyCodes.MINUS) {
1227 this.io.onVTKeystroke('\x1f');
1228 }
1229 return;
1230 }
1231
1232 let newFontSize;
1233 switch (ev.keyCode) {
1234 case keyCodes.ZERO:
1235 newFontSize = this.prefs_.get('font-size');
1236 break;
1237 case keyCodes.MINUS:
1238 newFontSize = this.term.options.fontSize - 1;
1239 break;
1240 default:
1241 newFontSize = this.term.options.fontSize + 1;
1242 break;
1243 }
1244
Jason Lin1be92f52023-01-23 23:50:00 +11001245 this.term.options.fontSize = Math.max(1, newFontSize);
Jason Lin5690e752022-08-30 15:36:45 +10001246 }
1247
1248 /** @param {!KeyboardEvent} ev */
1249 ctrlCKeyDownHandler_(ev) {
1250 ev.preventDefault();
1251 if (this.prefs_.get('ctrl-c-copy') !== ev.shiftKey &&
1252 this.term.hasSelection()) {
1253 this.copySelection_();
1254 return;
1255 }
1256
1257 this.io.onVTKeystroke('\x03');
1258 }
1259
1260 /** @param {!KeyboardEvent} ev */
1261 ctrlVKeyDownHandler_(ev) {
1262 if (this.prefs_.get('ctrl-v-paste') !== ev.shiftKey) {
1263 // Don't do anything and let the browser handles the key.
1264 return;
1265 }
1266
1267 ev.preventDefault();
1268 this.io.onVTKeystroke('\x16');
1269 }
1270
1271 resetKeyDownHandlers_() {
1272 this.keyDownHandlers_.clear();
1273
1274 /**
1275 * Don't do anything and let the browser handles the key.
1276 *
1277 * @param {!KeyboardEvent} ev
1278 */
1279 const noop = (ev) => {};
1280
1281 /**
1282 * @param {number} modifiers
1283 * @param {number} keyCode
1284 * @param {function(!KeyboardEvent)} func
1285 */
1286 const set = (modifiers, keyCode, func) => {
1287 this.keyDownHandlers_.set(encodeKeyCombo(modifiers, keyCode),
1288 func);
1289 };
1290
1291 /**
1292 * @param {number} modifiers
1293 * @param {number} keyCode
1294 * @param {function(!KeyboardEvent)} func
1295 */
1296 const setWithShiftVersion = (modifiers, keyCode, func) => {
1297 set(modifiers, keyCode, func);
1298 set(modifiers | Modifier.Shift, keyCode, func);
1299 };
1300
Jason Lin5690e752022-08-30 15:36:45 +10001301 // Ctrl+/
1302 set(Modifier.Ctrl, 191, (ev) => {
1303 ev.preventDefault();
1304 this.io.onVTKeystroke(ctl('_'));
1305 });
1306
1307 // Settings page.
1308 set(Modifier.Ctrl | Modifier.Shift, keyCodes.P, (ev) => {
1309 ev.preventDefault();
1310 chrome.terminalPrivate.openOptionsPage(() => {});
1311 });
1312
1313 if (this.prefs_.get('keybindings-os-defaults')) {
1314 for (const binding of OS_DEFAULT_BINDINGS) {
1315 this.keyDownHandlers_.set(binding, noop);
1316 }
1317 }
1318
1319 /** @param {!KeyboardEvent} ev */
1320 const newWindow = (ev) => {
1321 ev.preventDefault();
1322 chrome.terminalPrivate.openWindow();
1323 };
1324 set(Modifier.Ctrl | Modifier.Shift, keyCodes.N, newWindow);
1325 if (this.prefs_.get('pass-ctrl-n')) {
1326 set(Modifier.Ctrl, keyCodes.N, newWindow);
1327 }
1328
Joel Hockey965ea552023-02-19 22:08:04 -08001329 /** @param {!KeyboardEvent} ev */
1330 const newTab = (ev) => {
1331 ev.preventDefault();
1332 chrome.terminalPrivate.openWindow(
1333 {asTab: true, url: '/html/terminal.html'});
1334 };
Jason Lin5690e752022-08-30 15:36:45 +10001335 if (this.prefs_.get('pass-ctrl-t')) {
Joel Hockey965ea552023-02-19 22:08:04 -08001336 setWithShiftVersion(Modifier.Ctrl, keyCodes.T, newTab);
Jason Lin5690e752022-08-30 15:36:45 +10001337 }
1338
1339 if (this.prefs_.get('pass-ctrl-w')) {
1340 setWithShiftVersion(Modifier.Ctrl, keyCodes.W, noop);
1341 }
1342
1343 if (this.prefs_.get('pass-ctrl-tab')) {
1344 setWithShiftVersion(Modifier.Ctrl, keyCodes.TAB, noop);
1345 }
1346
1347 const passCtrlNumber = this.prefs_.get('pass-ctrl-number');
1348
1349 /**
1350 * Set a handler for the key combo ctrl+<number>.
1351 *
1352 * @param {number} number 1 to 9
1353 * @param {string} controlCode The control code to send if we don't want to
1354 * let the browser to handle it.
1355 */
1356 const setCtrlNumberHandler = (number, controlCode) => {
1357 let func = noop;
1358 if (!passCtrlNumber) {
1359 func = (ev) => {
1360 ev.preventDefault();
1361 this.io.onVTKeystroke(controlCode);
1362 };
1363 }
1364 set(Modifier.Ctrl, keyCodes.ZERO + number, func);
1365 };
1366
1367 setCtrlNumberHandler(1, '1');
1368 setCtrlNumberHandler(2, ctl('@'));
1369 setCtrlNumberHandler(3, ctl('['));
1370 setCtrlNumberHandler(4, ctl('\\'));
1371 setCtrlNumberHandler(5, ctl(']'));
1372 setCtrlNumberHandler(6, ctl('^'));
1373 setCtrlNumberHandler(7, ctl('_'));
1374 setCtrlNumberHandler(8, '\x7f');
1375 setCtrlNumberHandler(9, '9');
1376
1377 if (this.prefs_.get('pass-alt-number')) {
1378 for (let keyCode = keyCodes.ZERO; keyCode <= keyCodes.NINE; ++keyCode) {
1379 set(Modifier.Alt, keyCode, noop);
1380 }
1381 }
1382
1383 for (const keyCode of [keyCodes.ZERO, keyCodes.MINUS, keyCodes.EQUAL]) {
1384 setWithShiftVersion(Modifier.Ctrl, keyCode, this.zoomKeyDownHandler_);
1385 }
1386
1387 setWithShiftVersion(Modifier.Ctrl, keyCodes.C, this.ctrlCKeyDownHandler_);
1388 setWithShiftVersion(Modifier.Ctrl, keyCodes.V, this.ctrlVKeyDownHandler_);
1389 }
Jason Linee0c1f72022-10-18 17:17:26 +11001390
1391 handleOnTerminalReady() {}
Jason Linca61ffb2022-08-03 19:37:12 +10001392}
1393
Jason Lind66e6bf2022-08-22 14:47:10 +10001394class HtermTerminal extends hterm.Terminal {
1395 /** @override */
1396 decorate(div) {
1397 super.decorate(div);
1398
Jason Linc48f7432022-10-13 17:28:30 +11001399 definePrefs(this.getPrefs());
Jason Linee0c1f72022-10-18 17:17:26 +11001400 }
Jason Linc48f7432022-10-13 17:28:30 +11001401
Jason Linee0c1f72022-10-18 17:17:26 +11001402 /**
1403 * This needs to be called in the `onTerminalReady()` callback. This is
1404 * awkward, but it is temporary since we will drop support for hterm at some
1405 * point.
1406 */
1407 handleOnTerminalReady() {
Jason Lind66e6bf2022-08-22 14:47:10 +10001408 const fontManager = new FontManager(this.getDocument());
1409 fontManager.loadPowerlineCSS().then(() => {
1410 const prefs = this.getPrefs();
1411 fontManager.loadFont(/** @type {string} */(prefs.get('font-family')));
1412 prefs.addObserver(
1413 'font-family',
1414 (v) => fontManager.loadFont(/** @type {string} */(v)));
1415 });
Jason Linee0c1f72022-10-18 17:17:26 +11001416
1417 const backgroundImageWatcher = new BackgroundImageWatcher(this.getPrefs(),
1418 (image) => this.setBackgroundImage(image));
1419 this.setBackgroundImage(backgroundImageWatcher.getBackgroundImage());
1420 backgroundImageWatcher.watch();
Jason Lind66e6bf2022-08-22 14:47:10 +10001421 }
Jason Lin2649da22022-10-12 10:16:44 +11001422
1423 /**
1424 * Write data to the terminal.
1425 *
1426 * @param {string|!Uint8Array} data string for UTF-16 data, Uint8Array for
1427 * UTF-8 data
1428 * @param {function()=} callback Optional callback that fires when the data
1429 * was processed by the parser.
1430 */
1431 write(data, callback) {
1432 if (typeof data === 'string') {
1433 this.io.print(data);
1434 } else {
1435 this.io.writeUTF8(data);
1436 }
1437 // Hterm processes the data synchronously, so we can call the callback
1438 // immediately.
1439 if (callback) {
1440 setTimeout(callback);
1441 }
1442 }
Jason Lind66e6bf2022-08-22 14:47:10 +10001443}
1444
Jason Linca61ffb2022-08-03 19:37:12 +10001445/**
1446 * Constructs and returns a `hterm.Terminal` or a compatible one based on the
1447 * preference value.
1448 *
1449 * @param {{
1450 * storage: !lib.Storage,
1451 * profileId: string,
1452 * }} args
1453 * @return {!Promise<!hterm.Terminal>}
1454 */
1455export async function createEmulator({storage, profileId}) {
1456 let config = TERMINAL_EMULATORS.get('hterm');
1457
1458 if (getOSInfo().alternative_emulator) {
Jason Lin21d854f2022-08-22 14:49:59 +10001459 // TODO: remove the url param logic. This is temporary to make manual
1460 // testing a bit easier, which is also why this is not in
1461 // './js/terminal_info.js'.
Jason Line10d6c42022-11-11 16:04:32 +11001462 const emulator = ORIGINAL_URL.searchParams.get('emulator');
Jason Linca61ffb2022-08-03 19:37:12 +10001463 // Use the default (i.e. first) one if the pref is not set or invalid.
Jason Lin21d854f2022-08-22 14:49:59 +10001464 config = TERMINAL_EMULATORS.get(emulator) ||
Jason Linca61ffb2022-08-03 19:37:12 +10001465 TERMINAL_EMULATORS.values().next().value;
1466 console.log('Terminal emulator config: ', config);
1467 }
1468
1469 switch (config.lib) {
1470 case 'xterm.js':
1471 {
1472 const terminal = new XtermTerminal({
1473 storage,
1474 profileId,
1475 enableWebGL: config.webgl,
1476 });
Jason Linca61ffb2022-08-03 19:37:12 +10001477 return terminal;
1478 }
1479 case 'hterm':
Jason Lind66e6bf2022-08-22 14:47:10 +10001480 return new HtermTerminal({profileId, storage});
Jason Linca61ffb2022-08-03 19:37:12 +10001481 default:
1482 throw new Error('incorrect emulator config');
1483 }
1484}
1485
Jason Lin6a402a72022-08-25 16:07:02 +10001486class TerminalCopyNotice extends LitElement {
1487 /** @override */
1488 static get styles() {
1489 return css`
1490 :host {
1491 display: block;
1492 text-align: center;
1493 }
1494
1495 svg {
1496 fill: currentColor;
1497 }
1498 `;
1499 }
1500
1501 /** @override */
Jason Lind3aacef2022-10-12 19:03:37 +11001502 connectedCallback() {
1503 super.connectedCallback();
1504 if (!this.childNodes.length) {
1505 // This is not visible since we use shadow dom. But this will allow the
1506 // hterm.NotificationCenter to announce the the copy text.
1507 this.append(hterm.messageManager.get('HTERM_NOTIFY_COPY'));
1508 }
1509 }
1510
1511 /** @override */
Jason Lin6a402a72022-08-25 16:07:02 +10001512 render() {
1513 return html`
1514 ${ICON_COPY}
1515 <div>${hterm.messageManager.get('HTERM_NOTIFY_COPY')}</div>
1516 `;
1517 }
1518}
1519
1520customElements.define('terminal-copy-notice', TerminalCopyNotice);