UNPKG

xterm

Version:

Full xterm terminal, in your browser

1,255 lines (1,089 loc) • 51.6 kB
/** * 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 { copyHandler, handlePasteEvent, moveTextAreaUnderMouseCursor, paste, rightClickHandler } from 'browser/Clipboard'; import { addDisposableDomListener } from 'browser/Lifecycle'; import { Linkifier2 } from 'browser/Linkifier2'; import * as Strings from 'browser/LocalizableStrings'; import { OscLinkProvider } from 'browser/OscLinkProvider'; import { CharacterJoinerHandler, CustomKeyEventHandler, IBrowser, IBufferRange, ICompositionHelper, ILinkifier2, ITerminal, IViewport } 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 { MouseService } from 'browser/services/MouseService'; import { RenderService } from 'browser/services/RenderService'; import { SelectionService } from 'browser/services/SelectionService'; import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services'; import { ThemeService } from 'browser/services/ThemeService'; import { color, rgba } from 'common/Color'; import { CoreTerminal } from 'common/CoreTerminal'; import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter'; import { MutableDisposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, ScrollSource, SpecialColorIndex } from 'common/Types'; import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBuffer } from 'common/buffer/Types'; import { C0, C1_ESCAPED } from 'common/data/EscapeSequences'; import { evaluateKeyboardEvent } from 'common/input/Keyboard'; import { toRgbString } from 'common/input/XParseColor'; import { DecorationService } from 'common/services/DecorationService'; import { IDecorationService } from 'common/services/Services'; import { IDecoration, IDecorationOptions, IDisposable, ILinkProvider, IMarker } from 'xterm'; import { WindowsOptionsReportType } from '../common/InputHandler'; import { AccessibilityManager } from './AccessibilityManager'; // Let it work inside Node.js for automated testing purposes. const document: Document = (typeof window !== 'undefined') ? window.document : null as any; export class Terminal extends CoreTerminal implements ITerminal { public textarea: HTMLTextAreaElement | undefined; public element: HTMLElement | undefined; public screenElement: HTMLElement | undefined; private _document: Document | undefined; private _viewportScrollArea: HTMLElement | undefined; private _viewportElement: HTMLElement | undefined; private _helperContainer: HTMLElement | undefined; private _compositionView: HTMLElement | undefined; private _overviewRulerRenderer: OverviewRulerRenderer | undefined; public browser: IBrowser = Browser as any; private _customKeyEventHandler: CustomKeyEventHandler | undefined; // browser services private _decorationService: DecorationService; private _charSizeService: ICharSizeService | undefined; private _coreBrowserService: ICoreBrowserService | 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; public linkifier2: ILinkifier2; public viewport: IViewport | undefined; private _compositionHelper: ICompositionHelper | undefined; private _accessibilityManager: MutableDisposable<AccessibilityManager> = this.register(new MutableDisposable()); private readonly _onCursorMove = this.register(new EventEmitter<void>()); public readonly onCursorMove = this._onCursorMove.event; private readonly _onKey = this.register(new EventEmitter<{ key: string, domEvent: KeyboardEvent }>()); public readonly onKey = this._onKey.event; private readonly _onRender = this.register(new EventEmitter<{ start: number, end: number }>()); public readonly onRender = this._onRender.event; private readonly _onSelectionChange = this.register(new EventEmitter<void>()); public readonly onSelectionChange = this._onSelectionChange.event; private readonly _onTitleChange = this.register(new EventEmitter<string>()); public readonly onTitleChange = this._onTitleChange.event; private readonly _onBell = this.register(new EventEmitter<void>()); public readonly onBell = this._onBell.event; private _onFocus = this.register(new EventEmitter<void>()); public get onFocus(): IEvent<void> { return this._onFocus.event; } private _onBlur = this.register(new EventEmitter<void>()); public get onBlur(): IEvent<void> { return this._onBlur.event; } private _onA11yCharEmitter = this.register(new EventEmitter<string>()); public get onA11yChar(): IEvent<string> { return this._onA11yCharEmitter.event; } private _onA11yTabEmitter = this.register(new EventEmitter<number>()); public get onA11yTab(): IEvent<number> { return this._onA11yTabEmitter.event; } private _onWillOpen = this.register(new EventEmitter<HTMLElement>()); public get onWillOpen(): IEvent<HTMLElement> { return this._onWillOpen.event; } constructor( options: Partial<ITerminalOptions> = {} ) { super(options); this._setup(); this.linkifier2 = this.register(this._instantiationService.createInstance(Linkifier2)); this.linkifier2.registerLinkProvider(this._instantiationService.createInstance(OscLinkProvider)); this._decorationService = this._instantiationService.createInstance(DecorationService); this._instantiationService.setService(IDecorationService, this._decorationService); // Setup InputHandler listeners this.register(this._inputHandler.onRequestBell(() => this._onBell.fire())); this.register(this._inputHandler.onRequestRefreshRows((start, end) => this.refresh(start, end))); 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(forwardEvent(this._inputHandler.onCursorMove, this._onCursorMove)); this.register(forwardEvent(this._inputHandler.onTitleChange, this._onTitleChange)); this.register(forwardEvent(this._inputHandler.onA11yChar, this._onA11yCharEmitter)); this.register(forwardEvent(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 channels = color.toColorRGB(acc === 'ansi' ? this._themeService.colors.ansi[req.index] : this._themeService.colors[acc]); this.coreService.triggerDataEvent(`${C0.ESC}]${ident};${toRgbString(channels)}${C1_ESCAPED.ST}`); break; case ColorRequestType.SET: if (acc === 'ansi') { this._themeService.modifyColors(colors => colors.ansi[req.index] = rgba.toColor(...req.color)); } else { const narrowedAcc = acc; this._themeService.modifyColors(colors => colors[narrowedAcc] = rgba.toColor(...req.color)); } break; case ColorRequestType.RESTORE: this._themeService.restoreColor(req.index); break; } } } 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: KeyboardEvent): void { if (this.coreService.decPrivateModes.sendFocus) { this.coreService.triggerDataEvent(C0.ESC + '[I'); } this.updateCursorStyle(ev); 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(addDisposableDomListener(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(addDisposableDomListener(this.textarea!, 'paste', pasteHandlerWrapper)); this.register(addDisposableDomListener(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(addDisposableDomListener(this.element!, 'mousedown', (event: MouseEvent) => { if (event.button === 2) { rightClickHandler(event, this.textarea!, this.screenElement!, this._selectionService!, this.options.rightClickSelectsWord); } })); } else { this.register(addDisposableDomListener(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(addDisposableDomListener(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(addDisposableDomListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true)); this.register(addDisposableDomListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true)); this.register(addDisposableDomListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true)); this.register(addDisposableDomListener(this.textarea!, 'compositionstart', () => this._compositionHelper!.compositionstart())); this.register(addDisposableDomListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e))); this.register(addDisposableDomListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend())); this.register(addDisposableDomListener(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'); } this._document = parent.ownerDocument!; // 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'); parent.appendChild(this.element); // Performance: Use a document fragment to build the terminal // viewport and helper elements detached from the DOM const fragment = document.createDocumentFragment(); this._viewportElement = document.createElement('div'); this._viewportElement.classList.add('xterm-viewport'); fragment.appendChild(this._viewportElement); this._viewportScrollArea = document.createElement('div'); this._viewportScrollArea.classList.add('xterm-scroll-area'); this._viewportElement.appendChild(this._viewportScrollArea); this.screenElement = document.createElement('div'); this.screenElement.classList.add('xterm-screen'); // Create the container that will hold helpers like the textarea for // capturing DOM Events. Then produce the helpers. this._helperContainer = document.createElement('div'); this._helperContainer.classList.add('xterm-helpers'); this.screenElement.appendChild(this._helperContainer); fragment.appendChild(this.screenElement); this.textarea = document.createElement('textarea'); this.textarea.classList.add('xterm-helper-textarea'); this.textarea.setAttribute('aria-label', Strings.promptLabel); 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; // 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._instantiationService.createInstance(CoreBrowserService, this.textarea, this._document.defaultView ?? window); this._instantiationService.setService(ICoreBrowserService, this._coreBrowserService); this.register(addDisposableDomListener(this.textarea, 'focus', (ev: KeyboardEvent) => this._handleTextAreaFocus(ev))); this.register(addDisposableDomListener(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); 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.onResize(e => this._renderService!.resize(e.cols, e.rows)); this._compositionView = document.createElement('div'); this._compositionView.classList.add('composition-view'); this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView); this._helperContainer.appendChild(this._compositionView); // Performance: Add viewport and helper elements from the fragment this.element.appendChild(fragment); try { this._onWillOpen.fire(this.element); } catch { /* fails to load addon for some reason */ } if (!this._renderService.hasRenderer()) { this._renderService.setRenderer(this._createRenderer()); } this._mouseService = this._instantiationService.createInstance(MouseService); this._instantiationService.setService(IMouseService, this._mouseService); this.viewport = this._instantiationService.createInstance(Viewport, this._viewportElement, this._viewportScrollArea); this.viewport.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent, ScrollSource.VIEWPORT)), this.register(this._inputHandler.onRequestSyncScrollBar(() => this.viewport!.syncScrollArea())); this.register(this.viewport); this.register(this.onCursorMove(() => { this._renderService!.handleCursorMove(); this._syncTextArea(); })); this.register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows))); this.register(this.onBlur(() => this._renderService!.handleBlur())); this.register(this.onFocus(() => this._renderService!.handleFocus())); this.register(this._renderService.onDimensionsChange(() => this.viewport!.syncScrollArea())); this._selectionService = this.register(this._instantiationService.createInstance(SelectionService, this.element, this.screenElement, this.linkifier2 )); this._instantiationService.setService(ISelectionService, this._selectionService); 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(this._onScroll.event(ev => { this.viewport!.syncScrollArea(); this._selectionService!.refresh(); })); this.register(addDisposableDomListener(this._viewportElement, 'scroll', () => this._selectionService!.refresh())); this.linkifier2.attachToDom(this.screenElement, this._mouseService, this._renderService); this.register(this._instantiationService.createInstance(BufferDecorationRenderer, this.screenElement)); this.register(addDisposableDomListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e))); // apply mouse event classes set by escape codes before terminal was attached if (this.coreMouseService.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))); if (this.options.overviewRulerWidth) { this._overviewRulerRenderer = this.register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } this.optionsService.onSpecificOptionChange('overviewRulerWidth', value => { if (!this._overviewRulerRenderer && value && 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.bindMouse(); } private _createRenderer(): IRenderer { return this._instantiationService.createInstance(DomRenderer, this.element!, this.screenElement!, this._viewportElement!, this.linkifier2); } /** * Bind certain mouse events to the terminal. * By default only 3 button + wheel up/down is ativated. For higher buttons * no mouse report will be created. Typically the standard actions will be active. * * There are several reasons not to enable support for higher buttons/wheel: * - Button 4 and 5 are typically used for history back and forward navigation, * there is no straight forward way to supress/intercept those standard actions. * - Support for higher buttons does not work in some platform/browser combinations. * - Left/right wheel was not tested. * - Emulators vary in mouse button support, typically only 3 buttons and * wheel up/down work reliable. * * TODO: Move mouse event code into its own file. */ public bindMouse(): void { const self = this; const el = this.element!; // send event to CoreMouseService function sendEvent(ev: MouseEvent | WheelEvent): boolean { // get mouse coordinates const pos = self._mouseService!.getMouseReportCoords(ev, self.screenElement!); if (!pos) { return false; } let but: CoreMouseButton; let action: CoreMouseAction | undefined; switch ((ev as any).overrideType || ev.type) { case 'mousemove': action = CoreMouseAction.MOVE; if (ev.buttons === undefined) { // buttons is not supported on macOS, try to get a value from button instead but = CoreMouseButton.NONE; if (ev.button !== undefined) { but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; } } else { // according to MDN buttons only reports up to button 5 (AUX2) but = ev.buttons & 1 ? CoreMouseButton.LEFT : ev.buttons & 4 ? CoreMouseButton.MIDDLE : ev.buttons & 2 ? CoreMouseButton.RIGHT : CoreMouseButton.NONE; // fallback to NONE } break; case 'mouseup': action = CoreMouseAction.UP; but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; break; case 'mousedown': action = CoreMouseAction.DOWN; but = ev.button < 3 ? ev.button : CoreMouseButton.NONE; break; case 'wheel': const amount = self.viewport!.getLinesScrolled(ev as WheelEvent); if (amount === 0) { return false; } action = (ev as WheelEvent).deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN; but = CoreMouseButton.WHEEL; break; default: // dont handle other event types by accident return false; } // exit if we cannot determine valid button/action values // do nothing for higher buttons than wheel if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) { return false; } return self.coreMouseService.triggerMouseEvent({ col: pos.col, row: pos.row, x: pos.x, y: pos.y, button: but, action, ctrl: ev.ctrlKey, alt: ev.altKey, shift: ev.shiftKey }); } /** * Event listener state handling. * We listen to the onProtocolChange event of CoreMouseService and put * requested listeners in `requestedEvents`. With this the listeners * have all bits to do the event listener juggling. * Note: 'mousedown' currently is "always on" and not managed * by onProtocolChange. */ const requestedEvents: { [key: string]: ((ev: Event) => void) | null } = { mouseup: null, wheel: null, mousedrag: null, mousemove: null }; const eventListeners: { [key: string]: (ev: any) => void | boolean } = { mouseup: (ev: MouseEvent) => { sendEvent(ev); if (!ev.buttons) { // if no other button is held remove global handlers this._document!.removeEventListener('mouseup', requestedEvents.mouseup!); if (requestedEvents.mousedrag) { this._document!.removeEventListener('mousemove', requestedEvents.mousedrag); } } return this.cancel(ev); }, wheel: (ev: WheelEvent) => { sendEvent(ev); return this.cancel(ev, true); }, mousedrag: (ev: MouseEvent) => { // deal only with move while a button is held if (ev.buttons) { sendEvent(ev); } }, mousemove: (ev: MouseEvent) => { // deal only with move without any button if (!ev.buttons) { sendEvent(ev); } } }; this.register(this.coreMouseService.onProtocolChange(events => { // apply global changes on events if (events) { if (this.optionsService.rawOptions.logLevel === 'debug') { this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events)); } this.element!.classList.add('enable-mouse-events'); this._selectionService!.disable(); } else { this._logService.debug('Unbinding from mouse events.'); this.element!.classList.remove('enable-mouse-events'); this._selectionService!.enable(); } // add/remove handlers from requestedEvents if (!(events & CoreMouseEventType.MOVE)) { el.removeEventListener('mousemove', requestedEvents.mousemove!); requestedEvents.mousemove = null; } else if (!requestedEvents.mousemove) { el.addEventListener('mousemove', eventListeners.mousemove); requestedEvents.mousemove = eventListeners.mousemove; } if (!(events & CoreMouseEventType.WHEEL)) { el.removeEventListener('wheel', requestedEvents.wheel!); requestedEvents.wheel = null; } else if (!requestedEvents.wheel) { el.addEventListener('wheel', eventListeners.wheel, { passive: false }); requestedEvents.wheel = eventListeners.wheel; } if (!(events & CoreMouseEventType.UP)) { this._document!.removeEventListener('mouseup', requestedEvents.mouseup!); el.removeEventListener('mouseup', requestedEvents.mouseup!); requestedEvents.mouseup = null; } else if (!requestedEvents.mouseup) { el.addEventListener('mouseup', eventListeners.mouseup); requestedEvents.mouseup = eventListeners.mouseup; } if (!(events & CoreMouseEventType.DRAG)) { this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!); requestedEvents.mousedrag = null; } else if (!requestedEvents.mousedrag) { requestedEvents.mousedrag = eventListeners.mousedrag; } })); // force initial onProtocolChange so we dont miss early mouse requests this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol; /** * "Always on" event listeners. */ this.register(addDisposableDomListener(el, 'mousedown', (ev: MouseEvent) => { ev.preventDefault(); this.focus(); // Don't send the mouse button to the pty if mouse events are disabled or // if the selection manager is having selection forced (ie. a modifier is // held). if (!this.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) { return; } sendEvent(ev); // Register additional global handlers which should keep reporting outside // of the terminal element. // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. if (requestedEvents.mouseup) { this._document!.addEventListener('mouseup', requestedEvents.mouseup); } if (requestedEvents.mousedrag) { this._document!.addEventListener('mousemove', requestedEvents.mousedrag); } return this.cancel(ev); })); this.register(addDisposableDomListener(el, 'wheel', (ev: WheelEvent) => { // do nothing, if app side handles wheel itself if (requestedEvents.wheel) return; if (!this.buffer.hasScrollback) { // Convert wheel events into up/down events when the buffer does not have scrollback, this // enables scrolling in apps hosted in the alt buffer such as vim or tmux. const amount = this.viewport!.getLinesScrolled(ev); // Do nothing if there's no vertical scroll if (amount === 0) { return; } // Construct and send sequences const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B'); let data = ''; for (let i = 0; i < Math.abs(amount); i++) { data += sequence; } this.coreService.triggerDataEvent(data, true); return this.cancel(ev, true); } // normal viewport scrolling // conditionally stop event, if the viewport still had rows to scroll within if (this.viewport!.handleWheel(ev)) { return this.cancel(ev); } }, { passive: false })); this.register(addDisposableDomListener(el, 'touchstart', (ev: TouchEvent) => { if (this.coreMouseService.areMouseEventsActive) return; this.viewport!.handleTouchStart(ev); return this.cancel(ev); }, { passive: true })); this.register(addDisposableDomListener(el, 'touchmove', (ev: TouchEvent) => { if (this.coreMouseService.areMouseEventsActive) return; if (!this.viewport!.handleTouchMove(ev)) { return this.cancel(ev); } }, { passive: false })); } /** * 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): void { this._renderService?.refreshRows(start, end); } /** * Change the cursor style for different selection modes */ public updateCursorStyle(ev: KeyboardEvent): 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, source = ScrollSource.TERMINAL): void { if (source === ScrollSource.VIEWPORT) { super.scrollLines(disp, suppressScrollEvent, source); this.refresh(0, this.rows - 1); } else { this.viewport?.scrollLines(disp); } } public paste(data: string): void { paste(data, this.textarea!, this.coreService, this.optionsService); } /** * Attaches a custom key event handler which is run before keys are processed, * giving consumers of xterm.js ultimate control as to what keys should be * processed by the terminal and what keys should not. * @param customKeyEventHandler The custom KeyboardEvent handler to attach. * This is a function that takes a KeyboardEvent, allowing consumers to stop * propagation and/or prevent the default action. The function returns whether * the event should be processed by xterm.js. */ public attachCustomKeyEventHandler(customKeyEventHandler: CustomKeyEventHandler): void { this._customKeyEventHandler = customKeyEventHandler; } public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { return this.linkifier2.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(); } return false; } if (!shouldIgnoreComposition && (event.key === 'Dead' || event.key === 'AltGraph')) { this._unprocessedDeadKey = true; } const result = evaluateKeyboardEvent(event, this.coreService.decPrivateModes.applicationCursorKeys, this.browser.isMac, this.options.macOptionIsMeta); 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); return this.cancel(event, true); } 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? this.cancel(event, true); } 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. if (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 = ''; } this._onKey.fire({ key: result.key, domEvent: event }); this._showCursor(); this.coreService.triggerDataEvent(result.key, true); // 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) { return this.cancel(event, true); } 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(); } 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; } this.cancel(ev); 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); this.cancel(ev); 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(); // Sync the scroll area to make sure scroll events don't fire and scroll the viewport to an // invalid location this.viewport?.syncScrollArea(true); } /** * 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, source: ScrollSource.TERMINAL }); this.viewport?.reset(); 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._selectionService?.reset(); this._decorationService.reset(); this.viewport?.reset(); // reattach this._customKeyEventHandler = customKeyEventHandler; // do a full screen refresh this.refresh(0, this.rows - 1); }