Files
xterm.js/src/browser/CoreBrowserTerminal.ts
T

1170 lines
46 KiB
TypeScript

/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* @license MIT
*
* Originally forked from (with the author's permission):
* Fabrice Bellard's javascript vt100 for jslinux:
* http://bellard.org/jslinux/
* Copyright (c) 2011 Fabrice Bellard
* The original design remains. The terminal itself
* has been extended to include xterm CSI codes, among
* other features.
*
* Terminal Emulation References:
* http://vt100.net/
* http://invisible-island.net/xterm/ctlseqs/ctlseqs.txt
* http://invisible-island.net/xterm/ctlseqs/ctlseqs.html
* http://invisible-island.net/vttest/
* http://www.inwap.com/pdp10/ansicode.txt
* http://linux.die.net/man/4/console_codes
* http://linux.die.net/man/7/urxvt
*/
import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker, IRenderDimensions as IRenderDimensionsApi } from '@xterm/xterm';
import { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from 'browser/Clipboard';
import * as Strings from 'browser/LocalizableStrings';
import { OscLinkProvider } from 'browser/OscLinkProvider';
import { CharacterJoinerHandler, CustomKeyEventHandler, CustomWheelEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal } from 'browser/Types';
import { Viewport } from 'browser/Viewport';
import { BufferDecorationRenderer } from 'browser/decorations/BufferDecorationRenderer';
import { OverviewRulerRenderer } from 'browser/decorations/OverviewRulerRenderer';
import { CompositionHelper } from 'browser/input/CompositionHelper';
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
import { IRenderer } from 'browser/renderer/shared/Types';
import { CharSizeService } from 'browser/services/CharSizeService';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
import { LinkProviderService } from 'browser/services/LinkProviderService';
import { MouseCoordsService } from 'browser/services/MouseCoordsService';
import { MouseService } from 'browser/services/MouseService';
import { RenderService } from 'browser/services/RenderService';
import { SelectionService } from 'browser/services/SelectionService';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { ThemeService } from 'browser/services/ThemeService';
import { KeyboardService } from 'browser/services/KeyboardService';
import { channels, color, rgb } from 'common/Color';
import { CoreTerminal } from 'common/CoreTerminal';
import * as Browser from 'common/Platform';
import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { IBuffer } from 'common/buffer/Types';
import { C0, C1ESCAPED } from 'common/data/EscapeSequences';
import { toRgbString } from 'common/input/XParseColor';
import { DecorationService } from 'common/services/DecorationService';
import { IDecorationService } from 'common/services/Services';
import { WindowsOptionsReportType } from '../common/InputHandler';
import { AccessibilityManager } from './AccessibilityManager';
import { Linkifier } from './Linkifier';
import { Emitter, EventUtils, type IEvent } from 'common/Event';
import { addDisposableListener } from 'browser/Dom';
import { MutableDisposable, toDisposable } from 'common/Lifecycle';
export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
public textarea: HTMLTextAreaElement | undefined;
public element: HTMLElement | undefined;
public screenElement: HTMLElement | undefined;
private _document: Document | undefined;
private _viewportElement: HTMLElement | undefined;
private _helperContainer: HTMLElement | undefined;
private _compositionView: HTMLElement | undefined;
private readonly _linkifier: MutableDisposable<ILinkifier2> = this._register(new MutableDisposable());
public get linkifier(): ILinkifier2 | undefined { return this._linkifier.value; }
private _overviewRulerRenderer: OverviewRulerRenderer | undefined;
private _viewport: Viewport | undefined;
public browser: IBrowser = Browser as any;
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
// Browser services
private readonly _decorationService: DecorationService;
private readonly _keyboardService: IKeyboardService;
private readonly _linkProviderService: ILinkProviderService;
// Optional browser services
private _charSizeService: ICharSizeService | undefined;
private _coreBrowserService: ICoreBrowserService | undefined;
private _mouseCoordsService: IMouseCoordsService | undefined;
private _mouseService: IMouseService | undefined;
private _renderService: IRenderService | undefined;
private _themeService: IThemeService | undefined;
private _characterJoinerService: ICharacterJoinerService | undefined;
private _selectionService: ISelectionService | undefined;
/**
* Records whether the keydown event has already been handled and triggered a data event, if so
* the keypress event should not trigger a data event but should still print to the textarea so
* screen readers will announce it.
*/
private _keyDownHandled: boolean = false;
/**
* Records whether a keydown event has occured since the last keyup event, i.e. whether a key
* is currently "pressed".
*/
private _keyDownSeen: boolean = false;
/**
* Records whether the keypress event has already been handled and triggered a data event, if so
* the input event should not trigger a data event but should still print to the textarea so
* screen readers will announce it.
*/
private _keyPressHandled: boolean = false;
/**
* Records whether there has been a keydown event for a dead key without a corresponding keydown
* event for the composed/alternative character. If we cancel the keydown event for the dead key,
* no events will be emitted for the final character.
*/
private _unprocessedDeadKey: boolean = false;
private _compositionHelper: ICompositionHelper | undefined;
private _accessibilityManager: MutableDisposable<AccessibilityManager> = this._register(new MutableDisposable());
private readonly _onCursorMove = this._register(new Emitter<void>());
public readonly onCursorMove = this._onCursorMove.event;
private readonly _onKey = this._register(new Emitter<{ key: string, domEvent: KeyboardEvent }>());
public readonly onKey = this._onKey.event;
private readonly _onSelectionChange = this._register(new Emitter<void>());
public readonly onSelectionChange = this._onSelectionChange.event;
private readonly _onTitleChange = this._register(new Emitter<string>());
public readonly onTitleChange = this._onTitleChange.event;
private readonly _onBell = this._register(new Emitter<void>());
public readonly onBell = this._onBell.event;
private _onFocus = this._register(new Emitter<void>());
public get onFocus(): IEvent<void> { return this._onFocus.event; }
private _onBlur = this._register(new Emitter<void>());
public get onBlur(): IEvent<void> { return this._onBlur.event; }
private _onA11yCharEmitter = this._register(new Emitter<string>());
public get onA11yChar(): IEvent<string> { return this._onA11yCharEmitter.event; }
private _onA11yTabEmitter = this._register(new Emitter<number>());
public get onA11yTab(): IEvent<number> { return this._onA11yTabEmitter.event; }
private _onWillOpen = this._register(new Emitter<HTMLElement>());
public get onWillOpen(): IEvent<HTMLElement> { return this._onWillOpen.event; }
private readonly _onDimensionsChange = this._register(new Emitter<IRenderDimensionsApi>());
public readonly onDimensionsChange = this._onDimensionsChange.event;
public get dimensions(): IRenderDimensionsApi | undefined {
if (!this._renderService) {
return undefined;
}
const dimensions = this._renderService.dimensions;
return {
css: {
canvas: { ...dimensions.css.canvas },
cell: { ...dimensions.css.cell }
},
device: {
canvas: { ...dimensions.device.canvas },
cell: { ...dimensions.device.cell },
char: { ...dimensions.device.char }
}
};
}
constructor(
options: Partial<ITerminalOptions> = {}
) {
super(options);
this._setup();
this._decorationService = this._instantiationService.createInstance(DecorationService);
this._instantiationService.setService(IDecorationService, this._decorationService);
this._keyboardService = this._instantiationService.createInstance(KeyboardService);
this._instantiationService.setService(IKeyboardService, this._keyboardService);
this._linkProviderService = this._instantiationService.createInstance(LinkProviderService);
this._instantiationService.setService(ILinkProviderService, this._linkProviderService);
this._linkProviderService.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider));
// Setup InputHandler listeners
this._register(this._inputHandler.onRequestBell(() => this._onBell.fire()));
this._register(this._inputHandler.onRequestRefreshRows((e) => this.refresh(e?.start ?? 0, e?.end ?? (this.rows - 1))));
this._register(this._inputHandler.onRequestSendFocus(() => this._reportFocus()));
this._register(this._inputHandler.onRequestReset(() => this.reset()));
this._register(this._inputHandler.onRequestWindowsOptionsReport(type => this._reportWindowsOptions(type)));
this._register(this._inputHandler.onColor((event) => this._handleColorEvent(event)));
this._register(EventUtils.forward(this._inputHandler.onCursorMove, this._onCursorMove));
this._register(EventUtils.forward(this._inputHandler.onTitleChange, this._onTitleChange));
this._register(EventUtils.forward(this._inputHandler.onA11yChar, this._onA11yCharEmitter));
this._register(EventUtils.forward(this._inputHandler.onA11yTab, this._onA11yTabEmitter));
// Setup listeners
this._register(this._bufferService.onResize(e => this._afterResize(e.cols, e.rows)));
this._register(toDisposable(() => {
this._customKeyEventHandler = undefined;
this.element?.parentNode?.removeChild(this.element);
}));
}
/**
* Handle color event from inputhandler for OSC 4|104 | 10|110 | 11|111 | 12|112.
* An event from OSC 4|104 may contain multiple set or report requests, and multiple
* or none restore requests (resetting all),
* while an event from OSC 10|110 | 11|111 | 12|112 always contains a single request.
*/
private _handleColorEvent(event: IColorEvent): void {
if (!this._themeService) return;
for (const req of event) {
let acc: 'foreground' | 'background' | 'cursor' | 'ansi';
let ident = '';
switch (req.index) {
case SpecialColorIndex.FOREGROUND: // OSC 10 | 110
acc = 'foreground';
ident = '10';
break;
case SpecialColorIndex.BACKGROUND: // OSC 11 | 111
acc = 'background';
ident = '11';
break;
case SpecialColorIndex.CURSOR: // OSC 12 | 112
acc = 'cursor';
ident = '12';
break;
default: // OSC 4 | 104
// we can skip the [0..255] range check here (already done in inputhandler)
acc = 'ansi';
ident = '4;' + req.index;
}
switch (req.type) {
case ColorRequestType.REPORT:
const colorRgb = color.toColorRGB(acc === 'ansi'
? this._themeService.colors.ansi[req.index]
: this._themeService.colors[acc]);
this.coreService.triggerDataEvent(`${C0.ESC}]${ident};${toRgbString(colorRgb)}${C1ESCAPED.ST}`);
break;
case ColorRequestType.SET:
if (acc === 'ansi') {
this._themeService.modifyColors(colors => colors.ansi[req.index] = channels.toColor(...req.color));
} else {
const narrowedAcc = acc;
this._themeService.modifyColors(colors => colors[narrowedAcc] = channels.toColor(...req.color));
}
break;
case ColorRequestType.RESTORE:
this._themeService.restoreColor(req.index);
break;
}
}
}
/**
* Reports the current color scheme (dark or light) based on the relative luminance
* of the background and foreground theme colors.
* Sends CSI ? 997 ; 1 n for dark mode or CSI ? 997 ; 2 n for light mode.
*/
private _reportColorScheme(): void {
if (!this._themeService) return;
const bgLuminance = rgb.relativeLuminance(this._themeService.colors.background.rgba >> 8);
const fgLuminance = rgb.relativeLuminance(this._themeService.colors.foreground.rgba >> 8);
// Dark mode = background is darker than foreground (lower luminance)
const colorSchemeMode = bgLuminance < fgLuminance ? 1 : 2;
this.coreService.triggerDataEvent(`${C0.ESC}[?997;${colorSchemeMode}n`);
}
protected _setup(): void {
super._setup();
this._customKeyEventHandler = undefined;
}
/**
* Convenience property to active buffer.
*/
public get buffer(): IBuffer {
return this.buffers.active;
}
/**
* Focus the terminal. Delegates focus handling to the terminal's DOM element.
*/
public focus(): void {
if (this.textarea) {
this.textarea.focus({ preventScroll: true });
}
}
private _handleScreenReaderModeOptionChange(value: boolean): void {
if (value) {
if (!this._accessibilityManager.value && this._renderService) {
this._accessibilityManager.value = this._instantiationService.createInstance(AccessibilityManager, this);
}
} else {
this._accessibilityManager.clear();
}
}
/**
* Binds the desired focus behavior on a given terminal object.
*/
private _handleTextAreaFocus(ev: FocusEvent): void {
if (this.coreService.decPrivateModes.sendFocus) {
this.coreService.triggerDataEvent(C0.ESC + '[I');
}
this.element!.classList.add('focus');
this._showCursor();
this._onFocus.fire();
}
/**
* Blur the terminal, calling the blur function on the terminal's underlying
* textarea.
*/
public blur(): void {
return this.textarea?.blur();
}
/**
* Binds the desired blur behavior on a given terminal object.
*/
private _handleTextAreaBlur(): void {
// Text can safely be removed on blur. Doing it earlier could interfere with
// screen readers reading it out.
this.textarea!.value = '';
this.refresh(this.buffer.y, this.buffer.y);
if (this.coreService.decPrivateModes.sendFocus) {
this.coreService.triggerDataEvent(C0.ESC + '[O');
}
this.element!.classList.remove('focus');
this._onBlur.fire();
}
private _syncTextArea(): void {
if (!this.textarea || !this.buffer.isCursorInViewport || this._compositionHelper!.isComposing || !this._renderService) {
return;
}
const cursorY = this.buffer.ybase + this.buffer.y;
const bufferLine = this.buffer.lines.get(cursorY);
if (!bufferLine) {
return;
}
const cursorX = Math.min(this.buffer.x, this.cols - 1);
const cellHeight = this._renderService.dimensions.css.cell.height;
const width = bufferLine.getWidth(cursorX);
const cellWidth = this._renderService.dimensions.css.cell.width * width;
const cursorTop = this.buffer.y * this._renderService.dimensions.css.cell.height;
const cursorLeft = cursorX * this._renderService.dimensions.css.cell.width;
// Sync the textarea to the exact position of the composition view so the IME knows where the
// text is.
this.textarea.style.left = cursorLeft + 'px';
this.textarea.style.top = cursorTop + 'px';
this.textarea.style.width = cellWidth + 'px';
this.textarea.style.height = cellHeight + 'px';
this.textarea.style.lineHeight = cellHeight + 'px';
this.textarea.style.zIndex = '-5';
}
/**
* Initialize default behavior
*/
private _initGlobal(): void {
this._bindKeys();
// Bind clipboard functionality
this._register(addDisposableListener(this.element!, 'copy', (event: ClipboardEvent) => {
// If mouse events are active it means the selection manager is disabled and
// copy should be handled by the host program.
if (!this.hasSelection()) {
return;
}
copyHandler(event, this._selectionService!);
}));
const pasteHandlerWrapper = (event: ClipboardEvent): void => handlePasteEvent(event, this.textarea!, this.coreService, this.optionsService);
this._register(addDisposableListener(this.textarea!, 'paste', pasteHandlerWrapper));
this._register(addDisposableListener(this.element!, 'paste', pasteHandlerWrapper));
// Handle right click context menus
if (Browser.isFirefox) {
// Firefox doesn't appear to fire the contextmenu event on right click
this._register(addDisposableListener(this.element!, 'mousedown', (event: MouseEvent) => {
if (event.button === 2) {
rightClickHandler(event, this.textarea!, this.screenElement!, this._selectionService!, this.options.rightClickSelectsWord);
}
}));
} else {
this._register(addDisposableListener(this.element!, 'contextmenu', (event: MouseEvent) => {
rightClickHandler(event, this.textarea!, this.screenElement!, this._selectionService!, this.options.rightClickSelectsWord);
}));
}
// Move the textarea under the cursor when middle clicking on Linux to ensure
// middle click to paste selection works. This only appears to work in Chrome
// at the time is writing.
if (Browser.isLinux) {
// Use auxclick event over mousedown the latter doesn't seem to work. Note
// that the regular click event doesn't fire for the middle mouse button.
this._register(addDisposableListener(this.element!, 'auxclick', (event: MouseEvent) => {
if (event.button === 1) {
moveTextAreaUnderMouseCursor(event, this.textarea!, this.screenElement!);
}
}));
}
}
/**
* Apply key handling to the terminal
*/
private _bindKeys(): void {
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => {
// Ensure the textarea is synced to the latest cursor location before composition begins. This
// is to workaround a problem where highly dynamic TUIs like agentic CLIs reprint agressively
// would cause the IME to appear in the wrong position. The theory is that when the IME is
// triggered during a partial render the textarea position becomes locked and will not move
// until it is hidden and a custom move occurs.
this._syncTextArea();
this._compositionHelper!.compositionstart();
this._compositionHelper!.updateCompositionElements();
}));
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
this._register(this.onRender(() => this._compositionHelper!.updateCompositionElements()));
}
/**
* Opens the terminal within an element.
*
* @param parent The element to create the terminal within.
*/
public open(parent: HTMLElement): void {
if (!parent) {
throw new Error('Terminal requires a parent element.');
}
if (!parent.isConnected) {
this._logService.debug('Terminal.open was called on an element that was not attached to the DOM');
}
// If the terminal is already opened
if (this.element?.ownerDocument.defaultView && this._coreBrowserService) {
// Adjust the window if needed
if (this.element.ownerDocument.defaultView !== this._coreBrowserService.window) {
this._coreBrowserService.window = this.element.ownerDocument.defaultView;
}
return;
}
this._document = parent.ownerDocument;
if (this.options.documentOverride && this.options.documentOverride instanceof Document) {
this._document = this.optionsService.rawOptions.documentOverride as Document;
}
// Create main element container
this.element = this._document.createElement('div');
this.element.dir = 'ltr'; // xterm.css assumes LTR
this.element.classList.add('terminal');
this.element.classList.add('xterm');
this.element.classList.toggle('allow-transparency', this.options.allowTransparency);
this._register(this.optionsService.onSpecificOptionChange('allowTransparency', value => this.element!.classList.toggle('allow-transparency', value)));
parent.appendChild(this.element);
// Performance: Use a document fragment to build the terminal
// viewport and helper elements detached from the DOM
const fragment = this._document.createDocumentFragment();
this._viewportElement = this._document.createElement('div');
this._viewportElement.classList.add('xterm-viewport');
fragment.appendChild(this._viewportElement);
this.screenElement = this._document.createElement('div');
this.screenElement.classList.add('xterm-screen');
this._register(addDisposableListener(this.screenElement, 'mousemove', (ev: MouseEvent) => this.updateCursorStyle(ev)));
// Create the container that will hold helpers like the textarea for
// capturing DOM Events. Then produce the helpers.
this._helperContainer = this._document.createElement('div');
this._helperContainer.classList.add('xterm-helpers');
this.screenElement.appendChild(this._helperContainer);
fragment.appendChild(this.screenElement);
const textarea = this.textarea = this._document.createElement('textarea');
this.textarea.classList.add('xterm-helper-textarea');
this.textarea.setAttribute('aria-label', Strings.promptLabel.get());
if (!Browser.isChromeOS) {
// ChromeVox on ChromeOS does not like this. See
// https://issuetracker.google.com/issues/260170397
this.textarea.setAttribute('aria-multiline', 'false');
}
this.textarea.setAttribute('autocorrect', 'off');
this.textarea.setAttribute('autocapitalize', 'off');
this.textarea.setAttribute('spellcheck', 'false');
this.textarea.tabIndex = 0;
this._register(this.optionsService.onSpecificOptionChange('disableStdin', () => textarea.readOnly = this.optionsService.rawOptions.disableStdin));
this.textarea.readOnly = this.optionsService.rawOptions.disableStdin;
// Register the core browser service before the generic textarea handlers are registered so it
// handles them first. Otherwise the renderers may use the wrong focus state.
this._coreBrowserService = this._register(this._instantiationService.createInstance(CoreBrowserService,
this.textarea,
parent.ownerDocument.defaultView ?? window,
// Force unsafe null in node.js environment for tests
this._document ?? (typeof window !== 'undefined') ? window.document : null as any
));
this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService);
this._register(addDisposableListener(this.textarea, 'focus', (ev: FocusEvent) => this._handleTextAreaFocus(ev)));
this._register(addDisposableListener(this.textarea, 'blur', () => this._handleTextAreaBlur()));
this._helperContainer.appendChild(this.textarea);
this._charSizeService = this._instantiationService.createInstance(CharSizeService, this._document, this._helperContainer);
this._instantiationService.setService(ICharSizeService, this._charSizeService);
this._themeService = this._instantiationService.createInstance(ThemeService);
this._instantiationService.setService(IThemeService, this._themeService);
// CSI ? 996 n - color scheme query (https://contour-terminal.org/vt-extensions/color-palette-update-notifications/)
this._register(this._inputHandler.onRequestColorSchemeQuery(() => this._reportColorScheme()));
// Emit unsolicited color scheme notification on theme change when DECSET 2031 is enabled
this._register(this._themeService.onChangeColors(() => {
if (this.coreService.decPrivateModes.colorSchemeUpdates) {
this._reportColorScheme();
}
}));
this._characterJoinerService = this._instantiationService.createInstance(CharacterJoinerService);
this._instantiationService.setService(ICharacterJoinerService, this._characterJoinerService);
this._renderService = this._register(this._instantiationService.createInstance(RenderService, this.rows, this.screenElement));
this._instantiationService.setService(IRenderService, this._renderService);
this._register(this._renderService.onRenderedViewportChange(e => this._onRender.fire(e)));
this._register(this._renderService.onDimensionsChange(e => this._onDimensionsChange.fire({
css: {
canvas: { ...e.css.canvas },
cell: { ...e.css.cell }
},
device: {
canvas: { ...e.device.canvas },
cell: { ...e.device.cell },
char: { ...e.device.char }
}
})));
this.onResize(e => this._renderService!.resize(e.cols, e.rows));
this._compositionView = this._document.createElement('div');
this._compositionView.classList.add('composition-view');
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
this._helperContainer.appendChild(this._compositionView);
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
// Performance: Add viewport and helper elements from the fragment
this.element.appendChild(fragment);
try {
this._onWillOpen.fire(this.element);
} catch (e) {
this._logService.error('onWillOpen handler threw an exception', e);
}
if (!this._renderService.hasRenderer()) {
this._renderService.setRenderer(this._createRenderer());
}
this._register(this.onCursorMove(() => {
this._renderService!.handleCursorMove();
this._syncTextArea();
}));
this._register(this.onResize(() => {
this._renderService!.handleResize(this.cols, this.rows);
this._syncTextArea();
}));
this._register(this.onBlur(() => this._renderService!.handleBlur()));
this._register(this.onFocus(() => this._renderService!.handleFocus()));
this._viewport = this._register(this._instantiationService.createInstance(Viewport, this.element, this.screenElement));
this._register(this._viewport.onRequestScrollLines(e => {
super.scrollLines(e, false);
this.refresh(0, this.rows - 1);
}));
this._selectionService = this._register(this._instantiationService.createInstance(SelectionService,
this.element,
this.screenElement,
linkifier
));
this._instantiationService.setService(ISelectionService, this._selectionService);
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
this._register(this._selectionService.onLinuxMouseSelection(text => {
// If there's a new selection, put it into the textarea, focus and select it
// in order to register it as a selection on the OS. This event is fired
// only on Linux to enable middle click to paste selection.
this.textarea!.value = text;
this.textarea!.focus();
this.textarea!.select();
}));
this._register(EventUtils.any(
this._onScroll.event,
this._inputHandler.onScroll
)(() => {
this._selectionService!.refresh();
this._viewport?.queueSync();
}));
this._register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement));
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
// apply mouse event classes set by escape codes before terminal was attached
if (this.mouseStateService.areMouseEventsActive) {
this._selectionService.disable();
this.element.classList.add('enable-mouse-events');
} else {
this._selectionService.enable();
}
if (this.options.screenReaderMode) {
// Note that this must be done *after* the renderer is created in order to
// ensure the correct order of the dprchange event
this._accessibilityManager.value = this._instantiationService.createInstance(AccessibilityManager, this);
}
this._register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e)));
const showScrollbar = this.options.scrollbar?.showScrollbar ?? true;
const overviewRulerWidth = this.options.scrollbar?.width;
if (showScrollbar && overviewRulerWidth) {
this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement));
}
this.optionsService.onSpecificOptionChange('scrollbar', value => {
const shouldShow = (value?.showScrollbar ?? true) && !!value?.width;
if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) {
this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement));
}
});
// Measure the character size
this._charSizeService.measure();
// Setup loop that draws to screen
this.refresh(0, this.rows - 1);
// Initialize global actions that need to be taken on the document.
this._initGlobal();
// Listen for mouse events and translate
// them into terminal mouse protocols.
this._mouseService.bindMouse({
element: this.element!,
screenElement: this.screenElement!,
document: this._document!,
handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
}, disposable => this._register(disposable), () => this.focus());
}
private _createRenderer(): IRenderer {
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
}
/**
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
* opportunity.
* @param start The row to start from (between 0 and this.rows - 1).
* @param end The row to end at (between start and this.rows - 1).
*/
public refresh(start: number, end: number, sync: boolean = false): void {
this._renderService?.refreshRows(start, end, sync);
}
/**
* Change the cursor style for different selection modes
*/
public updateCursorStyle(ev: KeyboardEvent | MouseEvent): void {
if (this._selectionService?.shouldColumnSelect(ev)) {
this.element!.classList.add('column-select');
} else {
this.element!.classList.remove('column-select');
}
}
/**
* Display the cursor element
*/
private _showCursor(): void {
if (!this.coreService.isCursorInitialized) {
this.coreService.isCursorInitialized = true;
this.refresh(this.buffer.y, this.buffer.y);
}
}
public scrollLines(disp: number, suppressScrollEvent?: boolean): void {
// All scrollLines methods need to go via the viewport in order to support smooth scroll
if (this._viewport) {
this._viewport.scrollLines(disp);
} else {
super.scrollLines(disp, suppressScrollEvent);
}
this.refresh(0, this.rows - 1);
}
public scrollPages(pageCount: number): void {
this.scrollLines(pageCount * (this.rows - 1));
}
public scrollToTop(): void {
this.scrollLines(-this._bufferService.buffer.ydisp);
}
public scrollToBottom(disableSmoothScroll?: boolean): void {
if (disableSmoothScroll && this._viewport) {
this._viewport.scrollToLine(this.buffer.ybase, true);
} else {
this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp);
}
}
public scrollToLine(line: number): void {
const scrollAmount = line - this._bufferService.buffer.ydisp;
if (scrollAmount !== 0) {
this.scrollLines(scrollAmount);
}
}
public paste(data: string): void {
paste(data, this.textarea!, this.coreService, this.optionsService);
}
public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void {
this._customKeyEventHandler = customKeyEventHandler;
}
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
return this._linkProviderService.registerLinkProvider(linkProvider);
}
public registerCharacterJoiner(handler: CharacterJoinerHandler): number {
if (!this._characterJoinerService) {
throw new Error('Terminal must be opened first');
}
const joinerId = this._characterJoinerService.register(handler);
this.refresh(0, this.rows - 1);
return joinerId;
}
public deregisterCharacterJoiner(joinerId: number): void {
if (!this._characterJoinerService) {
throw new Error('Terminal must be opened first');
}
if (this._characterJoinerService.deregister(joinerId)) {
this.refresh(0, this.rows - 1);
}
}
public get markers(): IMarker[] {
return this.buffer.markers;
}
public registerMarker(cursorYOffset: number): IMarker {
return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset);
}
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
return this._decorationService.registerDecoration(decorationOptions);
}
/**
* Gets whether the terminal has an active selection.
*/
public hasSelection(): boolean {
return this._selectionService ? this._selectionService.hasSelection : false;
}
/**
* Selects text within the terminal.
* @param column The column the selection starts at..
* @param row The row the selection starts at.
* @param length The length of the selection.
*/
public select(column: number, row: number, length: number): void {
this._selectionService!.setSelection(column, row, length);
}
/**
* Gets the terminal's current selection, this is useful for implementing copy
* behavior outside of xterm.js.
*/
public getSelection(): string {
return this._selectionService ? this._selectionService.selectionText : '';
}
public getSelectionPosition(): IBufferRange | undefined {
if (!this._selectionService || !this._selectionService.hasSelection) {
return undefined;
}
return {
start: {
x: this._selectionService.selectionStart![0],
y: this._selectionService.selectionStart![1]
},
end: {
x: this._selectionService.selectionEnd![0],
y: this._selectionService.selectionEnd![1]
}
};
}
/**
* Clears the current terminal selection.
*/
public clearSelection(): void {
this._selectionService?.clearSelection();
}
/**
* Selects all text within the terminal.
*/
public selectAll(): void {
this._selectionService?.selectAll();
}
public selectLines(start: number, end: number): void {
this._selectionService?.selectLines(start, end);
}
/**
* Handle a keydown [KeyboardEvent].
*
* [KeyboardEvent]: https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent
*/
protected _keyDown(event: KeyboardEvent): boolean | undefined {
this._keyDownHandled = false;
this._keyDownSeen = true;
if (this._customKeyEventHandler && this._customKeyEventHandler(event) === false) {
return false;
}
// Ignore composing with Alt key on Mac when macOptionIsMeta is enabled
const shouldIgnoreComposition = this.browser.isMac && this.options.macOptionIsMeta && event.altKey;
if (!shouldIgnoreComposition && !this._compositionHelper!.keydown(event)) {
if (this.options.scrollOnUserInput && this.buffer.ybase !== this.buffer.ydisp) {
this.scrollToBottom(true);
}
return false;
}
if (!shouldIgnoreComposition && (event.key === 'Dead' || event.key === 'AltGraph')) {
this._unprocessedDeadKey = true;
}
const result = this._keyboardService.evaluateKeyDown(event);
this.updateCursorStyle(event);
if (result.type === KeyboardResultType.PAGE_DOWN || result.type === KeyboardResultType.PAGE_UP) {
const scrollCount = this.rows - 1;
this.scrollLines(result.type === KeyboardResultType.PAGE_UP ? -scrollCount : scrollCount);
event.preventDefault();
event.stopPropagation();
return false;
}
if (result.type === KeyboardResultType.SELECT_ALL) {
this.selectAll();
}
if (this._isThirdLevelShift(this.browser, event)) {
return true;
}
if (result.cancel) {
// The event is canceled at the end already, is this necessary?
event.preventDefault();
event.stopPropagation();
}
if (!result.key) {
return true;
}
// HACK: Process A-Z in the keypress event to fix an issue with macOS IMEs where lower case
// letters cannot be input while caps lock is on. Skip this hack when using kitty protocol
// or Win32 input mode as they need to send proper sequences for all key events.
if (!this._keyboardService.useKitty && !this._keyboardService.useWin32InputMode && event.key && !event.ctrlKey && !event.altKey && !event.metaKey && event.key.length === 1) {
if (event.key.charCodeAt(0) >= 65 && event.key.charCodeAt(0) <= 90) {
return true;
}
}
if (this._unprocessedDeadKey) {
this._unprocessedDeadKey = false;
return true;
}
// If ctrl+c or enter is being sent, clear out the textarea. This is done so that screen readers
// will announce deleted characters. This will not work 100% of the time but it should cover
// most scenarios.
if (result.key === C0.ETX || result.key === C0.CR) {
this.textarea!.value = '';
}
const wasModifierOnly = this._keyboardService.useWin32InputMode && wasModifierKeyOnlyEvent(event);
this._onKey.fire({ key: result.key, domEvent: event });
this._showCursor();
this.coreService.triggerDataEvent(result.key, !wasModifierOnly);
// Cancel events when not in screen reader mode so events don't get bubbled up and handled by
// other listeners. When screen reader mode is enabled, we don't cancel them (unless ctrl or alt
// is also depressed) so that the cursor textarea can be updated, which triggers the screen
// reader to read it.
if (!this.optionsService.rawOptions.screenReaderMode || event.altKey || event.ctrlKey) {
event.preventDefault();
event.stopPropagation();
return false;
}
this._keyDownHandled = true;
}
private _isThirdLevelShift(browser: IBrowser, ev: KeyboardEvent): boolean {
const thirdLevelKey =
(browser.isMac && !this.options.macOptionIsMeta && ev.altKey && !ev.ctrlKey && !ev.metaKey) ||
(browser.isWindows && ev.altKey && ev.ctrlKey && !ev.metaKey) ||
(browser.isWindows && ev.getModifierState('AltGraph'));
if (ev.type === 'keypress') {
return thirdLevelKey;
}
// Don't invoke for arrows, pageDown, home, backspace, etc. (on non-keypress events)
return thirdLevelKey && (!ev.keyCode || ev.keyCode > 47);
}
protected _keyUp(ev: KeyboardEvent): void {
this._keyDownSeen = false;
if (this._customKeyEventHandler && this._customKeyEventHandler(ev) === false) {
return;
}
if (!wasModifierKeyOnlyEvent(ev)) {
this.focus();
}
// Handle key release for Kitty keyboard protocol
const result = this._keyboardService.evaluateKeyUp(ev);
if (result?.key) {
const wasModifierOnly = this._keyboardService.useWin32InputMode && wasModifierKeyOnlyEvent(ev);
this.coreService.triggerDataEvent(result.key, !wasModifierOnly);
}
this.updateCursorStyle(ev);
this._keyPressHandled = false;
}
/**
* Handle a keypress event.
* Key Resources:
* - https://developer.mozilla.org/en-US/docs/DOM/KeyboardEvent
* @param ev The keypress event to be handled.
*/
protected _keyPress(ev: KeyboardEvent): boolean {
let key;
this._keyPressHandled = false;
if (this._keyDownHandled) {
return false;
}
if (this._customKeyEventHandler && this._customKeyEventHandler(ev) === false) {
return false;
}
if (ev.charCode) {
key = ev.charCode;
} else if (ev.which === null || ev.which === undefined) {
key = ev.keyCode;
} else if (ev.which !== 0 && ev.charCode !== 0) {
key = ev.which;
} else {
return false;
}
if (!key || (
(ev.altKey || ev.ctrlKey || ev.metaKey) && !this._isThirdLevelShift(this.browser, ev)
)) {
return false;
}
key = String.fromCharCode(key);
this._onKey.fire({ key, domEvent: ev });
this._showCursor();
this.coreService.triggerDataEvent(key, true);
this._keyPressHandled = true;
// The key was handled so clear the dead key state, otherwise certain keystrokes like arrow
// keys could be ignored
this._unprocessedDeadKey = false;
return true;
}
/**
* Handle an input event.
* Key Resources:
* - https://developer.mozilla.org/en-US/docs/Web/API/InputEvent
* @param ev The input event to be handled.
*/
protected _inputEvent(ev: InputEvent): boolean {
// Only support emoji IMEs when screen reader mode is disabled as the event must bubble up to
// support reading out character input which can doubling up input characters
// Based on these event traces: https://github.com/xtermjs/xterm.js/issues/3679
if (ev.data && ev.inputType === 'insertText' && (!ev.composed || !this._keyDownSeen) && !this.optionsService.rawOptions.screenReaderMode) {
if (this._keyPressHandled) {
return false;
}
// The key was handled so clear the dead key state, otherwise certain keystrokes like arrow
// keys could be ignored
this._unprocessedDeadKey = false;
const text = ev.data;
this.coreService.triggerDataEvent(text, true);
return true;
}
return false;
}
/**
* Resizes the terminal.
*
* @param x The number of columns to resize to.
* @param y The number of rows to resize to.
*/
public resize(x: number, y: number): void {
if (x === this.cols && y === this.rows) {
// Check if we still need to measure the char size (fixes #785).
if (this._charSizeService && !this._charSizeService.hasValidSize) {
this._charSizeService.measure();
}
return;
}
super.resize(x, y);
}
private _afterResize(x: number, y: number): void {
this._charSizeService?.measure();
}
/**
* Clear the entire buffer, making the prompt line the new first line.
*/
public clear(): void {
if (this.buffer.ybase === 0 && this.buffer.y === 0) {
// Don't clear if it's already clear
return;
}
this.buffer.clearAllMarkers();
this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!);
this.buffer.lines.length = 1;
this.buffer.ydisp = 0;
this.buffer.ybase = 0;
this.buffer.y = 0;
for (let i = 1; i < this.rows; i++) {
this.buffer.lines.push(this.buffer.getBlankLine(DEFAULT_ATTR_DATA));
}
// IMPORTANT: Fire scroll event before viewport is reset. This ensures embedders get the clear
// scroll event and that the viewport's state will be valid for immediate writes.
this._onScroll.fire({ position: this.buffer.ydisp });
this.refresh(0, this.rows - 1);
}
/**
* Reset terminal.
* Note: Calling this directly from JS is synchronous but does not clear
* input buffers and does not reset the parser, thus the terminal will
* continue to apply pending input data.
* If you need in band reset (synchronous with input data) consider
* using DECSTR (soft reset, CSI ! p) or RIS instead (hard reset, ESC c).
*/
public reset(): void {
/**
* Since _setup handles a full terminal creation, we have to carry forward
* a few things that should not reset.
*/
this.options.rows = this.rows;
this.options.cols = this.cols;
const customKeyEventHandler = this._customKeyEventHandler;
this._setup();
super.reset();
this._mouseService?.reset();
this._selectionService?.reset();
this._decorationService.reset();
// reattach
this._customKeyEventHandler = customKeyEventHandler;
// do a full screen refresh
this.refresh(0, this.rows - 1, true);
}
public clearTextureAtlas(): void {
this._renderService?.clearTextureAtlas();
}
private _reportFocus(): void {
if (this.element?.classList.contains('focus')) {
this.coreService.triggerDataEvent(C0.ESC + '[I');
} else {
this.coreService.triggerDataEvent(C0.ESC + '[O');
}
}
private _reportWindowsOptions(type: WindowsOptionsReportType): void {
if (!this._renderService) {
return;
}
switch (type) {
case WindowsOptionsReportType.GET_WIN_SIZE_PIXELS:
const canvasWidth = this._renderService.dimensions.css.canvas.width.toFixed(0);
const canvasHeight = this._renderService.dimensions.css.canvas.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[4;${canvasHeight};${canvasWidth}t`);
break;
case WindowsOptionsReportType.GET_CELL_SIZE_PIXELS:
const cellWidth = this._renderService.dimensions.css.cell.width.toFixed(0);
const cellHeight = this._renderService.dimensions.css.cell.height.toFixed(0);
this.coreService.triggerDataEvent(`${C0.ESC}[6;${cellHeight};${cellWidth}t`);
break;
}
}
}
/**
* Helpers
*/
function wasModifierKeyOnlyEvent(ev: KeyboardEvent): boolean {
return ev.keyCode === 16 || // Shift
ev.keyCode === 17 || // Ctrl
ev.keyCode === 18 || // Alt
ev.keyCode === 91 || // Meta (Left)
ev.keyCode === 92 || // Meta (Right)
ev.keyCode === 93 || // Meta (Menu)
ev.keyCode === 224 || // Meta (Firefox)
ev.key === 'Meta';
}