added xterm to public

This commit is contained in:
2024-06-02 21:54:57 +03:00
parent 8e56811220
commit 67350e3159
109 changed files with 26229 additions and 0 deletions

View File

@@ -0,0 +1,300 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import * as Strings from 'browser/LocalizableStrings';
import { ITerminal, IRenderDebouncer } from 'browser/Types';
import { isMac } from 'common/Platform';
import { TimeBasedDebouncer } from 'browser/TimeBasedDebouncer';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
import { IRenderService } from 'browser/services/Services';
import { addDisposableDomListener } from 'browser/Lifecycle';
import { IBuffer } from 'common/buffer/Types';
const MAX_ROWS_TO_READ = 20;
const enum BoundaryPosition {
TOP,
BOTTOM
}
export class AccessibilityManager extends Disposable {
private _accessibilityContainer: HTMLElement;
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[];
private _liveRegion: HTMLElement;
private _liveRegionLineCount: number = 0;
private _liveRegionDebouncer: IRenderDebouncer;
private _screenDprMonitor: ScreenDprMonitor;
private _topBoundaryFocusListener: (e: FocusEvent) => void;
private _bottomBoundaryFocusListener: (e: FocusEvent) => void;
/**
* This queue has a character pushed to it for keys that are pressed, if the
* next character added to the terminal is equal to the key char then it is
* not announced (added to live region) because it has already been announced
* by the textarea event (which cannot be canceled). There are some race
* condition cases if there is typing while data is streaming, but this covers
* the main case of typing into the prompt and inputting the answer to a
* question (Y/N, etc.).
*/
private _charsToConsume: string[] = [];
private _charsToAnnounce: string = '';
constructor(
private readonly _terminal: ITerminal,
@IRenderService private readonly _renderService: IRenderService
) {
super();
this._accessibilityContainer = document.createElement('div');
this._accessibilityContainer.classList.add('xterm-accessibility');
this._rowContainer = document.createElement('div');
this._rowContainer.setAttribute('role', 'list');
this._rowContainer.classList.add('xterm-accessibility-tree');
this._rowElements = [];
for (let i = 0; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}
this._topBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.TOP);
this._bottomBoundaryFocusListener = e => this._handleBoundaryFocus(e, BoundaryPosition.BOTTOM);
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
this._refreshRowsDimensions();
this._accessibilityContainer.appendChild(this._rowContainer);
this._liveRegion = document.createElement('div');
this._liveRegion.classList.add('live-region');
this._liveRegion.setAttribute('aria-live', 'assertive');
this._accessibilityContainer.appendChild(this._liveRegion);
this._liveRegionDebouncer = this.register(new TimeBasedDebouncer(this._renderRows.bind(this)));
if (!this._terminal.element) {
throw new Error('Cannot enable accessibility before Terminal.open');
}
this._terminal.element.insertAdjacentElement('afterbegin', this._accessibilityContainer);
this.register(this._terminal.onResize(e => this._handleResize(e.rows)));
this.register(this._terminal.onRender(e => this._refreshRows(e.start, e.end)));
this.register(this._terminal.onScroll(() => this._refreshRows()));
// Line feed is an issue as the prompt won't be read out after a command is run
this.register(this._terminal.onA11yChar(char => this._handleChar(char)));
this.register(this._terminal.onLineFeed(() => this._handleChar('\n')));
this.register(this._terminal.onA11yTab(spaceCount => this._handleTab(spaceCount)));
this.register(this._terminal.onKey(e => this._handleKey(e.key)));
this.register(this._terminal.onBlur(() => this._clearLiveRegion()));
this.register(this._renderService.onDimensionsChange(() => this._refreshRowsDimensions()));
this._screenDprMonitor = new ScreenDprMonitor(window);
this.register(this._screenDprMonitor);
this._screenDprMonitor.setListener(() => this._refreshRowsDimensions());
// This shouldn't be needed on modern browsers but is present in case the
// media query that drives the ScreenDprMonitor isn't supported
this.register(addDisposableDomListener(window, 'resize', () => this._refreshRowsDimensions()));
this._refreshRows();
this.register(toDisposable(() => {
this._accessibilityContainer.remove();
this._rowElements.length = 0;
}));
}
private _handleTab(spaceCount: number): void {
for (let i = 0; i < spaceCount; i++) {
this._handleChar(' ');
}
}
private _handleChar(char: string): void {
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
if (this._charsToConsume.length > 0) {
// Have the screen reader ignore the char if it was just input
const shiftedChar = this._charsToConsume.shift();
if (shiftedChar !== char) {
this._charsToAnnounce += char;
}
} else {
this._charsToAnnounce += char;
}
if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent += Strings.tooMuchOutput;
}
}
// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
if (this._liveRegion.textContent && this._liveRegion.textContent.length > 0 && !this._liveRegion.parentNode) {
setTimeout(() => {
this._accessibilityContainer.appendChild(this._liveRegion);
}, 0);
}
}
}
}
private _clearLiveRegion(): void {
this._liveRegion.textContent = '';
this._liveRegionLineCount = 0;
// Only detach/attach on mac as otherwise messages can go unaccounced
if (isMac) {
this._liveRegion.remove();
}
}
private _handleKey(keyChar: string): void {
this._clearLiveRegion();
// Only add the char if there is no control character.
if (!/\p{Control}/u.test(keyChar)) {
this._charsToConsume.push(keyChar);
}
}
private _refreshRows(start?: number, end?: number): void {
this._liveRegionDebouncer.refresh(start, end, this._terminal.rows);
}
private _renderRows(start: number, end: number): void {
const buffer: IBuffer = this._terminal.buffer;
const setSize = buffer.lines.length.toString();
for (let i = start; i <= end; i++) {
const lineData = buffer.translateBufferLineToString(buffer.ydisp + i, true);
const posInSet = (buffer.ydisp + i + 1).toString();
const element = this._rowElements[i];
if (element) {
if (lineData.length === 0) {
element.innerText = '\u00a0';
} else {
element.textContent = lineData;
}
element.setAttribute('aria-posinset', posInSet);
element.setAttribute('aria-setsize', setSize);
}
}
this._announceCharacters();
}
private _announceCharacters(): void {
if (this._charsToAnnounce.length === 0) {
return;
}
this._liveRegion.textContent += this._charsToAnnounce;
this._charsToAnnounce = '';
}
private _handleBoundaryFocus(e: FocusEvent, position: BoundaryPosition): void {
const boundaryElement = e.target as HTMLElement;
const beforeBoundaryElement = this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2];
// Don't scroll if the buffer top has reached the end in that direction
const posInSet = boundaryElement.getAttribute('aria-posinset');
const lastRowPos = position === BoundaryPosition.TOP ? '1' : `${this._terminal.buffer.lines.length}`;
if (posInSet === lastRowPos) {
return;
}
// Don't scroll when the last focused item was not the second row (focus is going the other
// direction)
if (e.relatedTarget !== beforeBoundaryElement) {
return;
}
// Remove old boundary element from array
let topBoundaryElement: HTMLElement;
let bottomBoundaryElement: HTMLElement;
if (position === BoundaryPosition.TOP) {
topBoundaryElement = boundaryElement;
bottomBoundaryElement = this._rowElements.pop()!;
this._rowContainer.removeChild(bottomBoundaryElement);
} else {
topBoundaryElement = this._rowElements.shift()!;
bottomBoundaryElement = boundaryElement;
this._rowContainer.removeChild(topBoundaryElement);
}
// Remove listeners from old boundary elements
topBoundaryElement.removeEventListener('focus', this._topBoundaryFocusListener);
bottomBoundaryElement.removeEventListener('focus', this._bottomBoundaryFocusListener);
// Add new element to array/DOM
if (position === BoundaryPosition.TOP) {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.unshift(newElement);
this._rowContainer.insertAdjacentElement('afterbegin', newElement);
} else {
const newElement = this._createAccessibilityTreeNode();
this._rowElements.push(newElement);
this._rowContainer.appendChild(newElement);
}
// Add listeners to new boundary elements
this._rowElements[0].addEventListener('focus', this._topBoundaryFocusListener);
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
// Scroll up
this._terminal.scrollLines(position === BoundaryPosition.TOP ? -1 : 1);
// Focus new boundary before element
this._rowElements[position === BoundaryPosition.TOP ? 1 : this._rowElements.length - 2].focus();
// Prevent the standard behavior
e.preventDefault();
e.stopImmediatePropagation();
}
private _handleResize(rows: number): void {
// Remove bottom boundary listener
this._rowElements[this._rowElements.length - 1].removeEventListener('focus', this._bottomBoundaryFocusListener);
// Grow rows as required
for (let i = this._rowContainer.children.length; i < this._terminal.rows; i++) {
this._rowElements[i] = this._createAccessibilityTreeNode();
this._rowContainer.appendChild(this._rowElements[i]);
}
// Shrink rows as required
while (this._rowElements.length > rows) {
this._rowContainer.removeChild(this._rowElements.pop()!);
}
// Add bottom boundary listener
this._rowElements[this._rowElements.length - 1].addEventListener('focus', this._bottomBoundaryFocusListener);
this._refreshRowsDimensions();
}
private _createAccessibilityTreeNode(): HTMLElement {
const element = document.createElement('div');
element.setAttribute('role', 'listitem');
element.tabIndex = -1;
this._refreshRowDimensions(element);
return element;
}
private _refreshRowsDimensions(): void {
if (!this._renderService.dimensions.css.cell.height) {
return;
}
this._accessibilityContainer.style.width = `${this._renderService.dimensions.css.canvas.width}px`;
if (this._rowElements.length !== this._terminal.rows) {
this._handleResize(this._terminal.rows);
}
for (let i = 0; i < this._terminal.rows; i++) {
this._refreshRowDimensions(this._rowElements[i]);
}
}
private _refreshRowDimensions(element: HTMLElement): void {
element.style.height = `${this._renderService.dimensions.css.cell.height}px`;
}
}

View File

@@ -0,0 +1,93 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ISelectionService } from 'browser/services/Services';
import { ICoreService, IOptionsService } from 'common/services/Services';
/**
* Prepares text to be pasted into the terminal by normalizing the line endings
* @param text The pasted text that needs processing before inserting into the terminal
*/
export function prepareTextForTerminal(text: string): string {
return text.replace(/\r?\n/g, '\r');
}
/**
* Bracket text for paste, if necessary, as per https://cirw.in/blog/bracketed-paste
* @param text The pasted text to bracket
*/
export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): string {
if (bracketedPasteMode) {
return '\x1b[200~' + text + '\x1b[201~';
}
return text;
}
/**
* Binds copy functionality to the given terminal.
* @param ev The original copy event to be handled
*/
export function copyHandler(ev: ClipboardEvent, selectionService: ISelectionService): void {
if (ev.clipboardData) {
ev.clipboardData.setData('text/plain', selectionService.selectionText);
}
// Prevent or the original text will be copied.
ev.preventDefault();
}
/**
* Redirect the clipboard's data to the terminal's input handler.
*/
export function handlePasteEvent(ev: ClipboardEvent, textarea: HTMLTextAreaElement, coreService: ICoreService, optionsService: IOptionsService): void {
ev.stopPropagation();
if (ev.clipboardData) {
const text = ev.clipboardData.getData('text/plain');
paste(text, textarea, coreService, optionsService);
}
}
export function paste(text: string, textarea: HTMLTextAreaElement, coreService: ICoreService, optionsService: IOptionsService): void {
text = prepareTextForTerminal(text);
text = bracketTextForPaste(text, coreService.decPrivateModes.bracketedPasteMode && optionsService.rawOptions.ignoreBracketedPasteMode !== true);
coreService.triggerDataEvent(text, true);
textarea.value = '';
}
/**
* Moves the textarea under the mouse cursor and focuses it.
* @param ev The original right click event to be handled.
* @param textarea The terminal's textarea.
*/
export function moveTextAreaUnderMouseCursor(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement): void {
// Calculate textarea position relative to the screen element
const pos = screenElement.getBoundingClientRect();
const left = ev.clientX - pos.left - 10;
const top = ev.clientY - pos.top - 10;
// Bring textarea at the cursor position
textarea.style.width = '20px';
textarea.style.height = '20px';
textarea.style.left = `${left}px`;
textarea.style.top = `${top}px`;
textarea.style.zIndex = '1000';
textarea.focus();
}
/**
* Bind to right-click event and allow right-click copy and paste.
*/
export function rightClickHandler(ev: MouseEvent, textarea: HTMLTextAreaElement, screenElement: HTMLElement, selectionService: ISelectionService, shouldSelectWord: boolean): void {
moveTextAreaUnderMouseCursor(ev, textarea, screenElement);
if (shouldSelectWord) {
selectionService.rightClickSelect(ev);
}
// Get textarea ready to copy from the context menu
textarea.value = selectionService.selectionText;
textarea.select();
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IColorContrastCache } from 'browser/Types';
import { IColor } from 'common/Types';
import { TwoKeyMap } from 'common/MultiKeyMap';
export class ColorContrastCache implements IColorContrastCache {
private _color: TwoKeyMap</* bg */number, /* fg */number, IColor | null> = new TwoKeyMap();
private _css: TwoKeyMap</* bg */number, /* fg */number, string | null> = new TwoKeyMap();
public setCss(bg: number, fg: number, value: string | null): void {
this._css.set(bg, fg, value);
}
public getCss(bg: number, fg: number): string | null | undefined {
return this._css.get(bg, fg);
}
public setColor(bg: number, fg: number, value: IColor | null): void {
this._color.set(bg, fg, value);
}
public getColor(bg: number, fg: number): IColor | null | undefined {
return this._color.get(bg, fg);
}
public clear(): void {
this._color.clear();
this._css.clear();
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
/**
* Adds a disposable listener to a node in the DOM, returning the disposable.
* @param node The node to add a listener to.
* @param type The event type.
* @param handler The handler for the listener.
* @param options The boolean or options object to pass on to the event
* listener.
*/
export function addDisposableDomListener(
node: Element | Window | Document,
type: string,
handler: (e: any) => void,
options?: boolean | AddEventListenerOptions
): IDisposable {
node.addEventListener(type, handler, options);
let disposed = false;
return {
dispose: () => {
if (disposed) {
return;
}
disposed = true;
node.removeEventListener(type, handler, options);
}
};
}

View File

@@ -0,0 +1,416 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { addDisposableDomListener } from 'browser/Lifecycle';
import { IBufferCellPosition, ILink, ILinkDecorations, ILinkProvider, ILinkWithState, ILinkifier2, ILinkifierEvent } from 'browser/Types';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable, disposeArray, getDisposeArrayDisposable, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { IBufferService } from 'common/services/Services';
import { IMouseService, IRenderService } from './services/Services';
export class Linkifier2 extends Disposable implements ILinkifier2 {
private _element: HTMLElement | undefined;
private _mouseService: IMouseService | undefined;
private _renderService: IRenderService | undefined;
private _linkProviders: ILinkProvider[] = [];
public get currentLink(): ILinkWithState | undefined { return this._currentLink; }
protected _currentLink: ILinkWithState | undefined;
private _mouseDownLink: ILinkWithState | undefined;
private _lastMouseEvent: MouseEvent | undefined;
private _linkCacheDisposables: IDisposable[] = [];
private _lastBufferCell: IBufferCellPosition | undefined;
private _isMouseOut: boolean = true;
private _wasResized: boolean = false;
private _activeProviderReplies: Map<Number, ILinkWithState[] | undefined> | undefined;
private _activeLine: number = -1;
private readonly _onShowLinkUnderline = this.register(new EventEmitter<ILinkifierEvent>());
public readonly onShowLinkUnderline = this._onShowLinkUnderline.event;
private readonly _onHideLinkUnderline = this.register(new EventEmitter<ILinkifierEvent>());
public readonly onHideLinkUnderline = this._onHideLinkUnderline.event;
constructor(
@IBufferService private readonly _bufferService: IBufferService
) {
super();
this.register(getDisposeArrayDisposable(this._linkCacheDisposables));
this.register(toDisposable(() => {
this._lastMouseEvent = undefined;
}));
// Listen to resize to catch the case where it's resized and the cursor is out of the viewport.
this.register(this._bufferService.onResize(() => {
this._clearCurrentLink();
this._wasResized = true;
}));
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
this._linkProviders.push(linkProvider);
return {
dispose: () => {
// Remove the link provider from the list
const providerIndex = this._linkProviders.indexOf(linkProvider);
if (providerIndex !== -1) {
this._linkProviders.splice(providerIndex, 1);
}
}
};
}
public attachToDom(element: HTMLElement, mouseService: IMouseService, renderService: IRenderService): void {
this._element = element;
this._mouseService = mouseService;
this._renderService = renderService;
this.register(addDisposableDomListener(this._element, 'mouseleave', () => {
this._isMouseOut = true;
this._clearCurrentLink();
}));
this.register(addDisposableDomListener(this._element, 'mousemove', this._handleMouseMove.bind(this)));
this.register(addDisposableDomListener(this._element, 'mousedown', this._handleMouseDown.bind(this)));
this.register(addDisposableDomListener(this._element, 'mouseup', this._handleMouseUp.bind(this)));
}
private _handleMouseMove(event: MouseEvent): void {
this._lastMouseEvent = event;
if (!this._element || !this._mouseService) {
return;
}
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
if (!position) {
return;
}
this._isMouseOut = false;
// Ignore the event if it's an embedder created hover widget
const composedPath = event.composedPath() as HTMLElement[];
for (let i = 0; i < composedPath.length; i++) {
const target = composedPath[i];
// Hit Terminal.element, break and continue
if (target.classList.contains('xterm')) {
break;
}
// It's a hover, don't respect hover event
if (target.classList.contains('xterm-hover')) {
return;
}
}
if (!this._lastBufferCell || (position.x !== this._lastBufferCell.x || position.y !== this._lastBufferCell.y)) {
this._handleHover(position);
this._lastBufferCell = position;
}
}
private _handleHover(position: IBufferCellPosition): void {
// TODO: This currently does not cache link provider results across wrapped lines, activeLine
// should be something like `activeRange: {startY, endY}`
// Check if we need to clear the link
if (this._activeLine !== position.y || this._wasResized) {
this._clearCurrentLink();
this._askForLink(position, false);
this._wasResized = false;
return;
}
// Check the if the link is in the mouse position
const isCurrentLinkInPosition = this._currentLink && this._linkAtPosition(this._currentLink.link, position);
if (!isCurrentLinkInPosition) {
this._clearCurrentLink();
this._askForLink(position, true);
}
}
private _askForLink(position: IBufferCellPosition, useLineCache: boolean): void {
if (!this._activeProviderReplies || !useLineCache) {
this._activeProviderReplies?.forEach(reply => {
reply?.forEach(linkWithState => {
if (linkWithState.link.dispose) {
linkWithState.link.dispose();
}
});
});
this._activeProviderReplies = new Map();
this._activeLine = position.y;
}
let linkProvided = false;
// There is no link cached, so ask for one
for (const [i, linkProvider] of this._linkProviders.entries()) {
if (useLineCache) {
const existingReply = this._activeProviderReplies?.get(i);
// If there isn't a reply, the provider hasn't responded yet.
// TODO: If there isn't a reply yet it means that the provider is still resolving. Ensuring
// provideLinks isn't triggered again saves ILink.hover firing twice though. This probably
// needs promises to get fixed
if (existingReply) {
linkProvided = this._checkLinkProviderResult(i, position, linkProvided);
}
} else {
linkProvider.provideLinks(position.y, (links: ILink[] | undefined) => {
if (this._isMouseOut) {
return;
}
const linksWithState: ILinkWithState[] | undefined = links?.map(link => ({ link }));
this._activeProviderReplies?.set(i, linksWithState);
linkProvided = this._checkLinkProviderResult(i, position, linkProvided);
// If all providers have responded, remove lower priority links that intersect ranges of
// higher priority links
if (this._activeProviderReplies?.size === this._linkProviders.length) {
this._removeIntersectingLinks(position.y, this._activeProviderReplies);
}
});
}
}
}
private _removeIntersectingLinks(y: number, replies: Map<Number, ILinkWithState[] | undefined>): void {
const occupiedCells = new Set<number>();
for (let i = 0; i < replies.size; i++) {
const providerReply = replies.get(i);
if (!providerReply) {
continue;
}
for (let i = 0; i < providerReply.length; i++) {
const linkWithState = providerReply[i];
const startX = linkWithState.link.range.start.y < y ? 0 : linkWithState.link.range.start.x;
const endX = linkWithState.link.range.end.y > y ? this._bufferService.cols : linkWithState.link.range.end.x;
for (let x = startX; x <= endX; x++) {
if (occupiedCells.has(x)) {
providerReply.splice(i--, 1);
break;
}
occupiedCells.add(x);
}
}
}
}
private _checkLinkProviderResult(index: number, position: IBufferCellPosition, linkProvided: boolean): boolean {
if (!this._activeProviderReplies) {
return linkProvided;
}
const links = this._activeProviderReplies.get(index);
// Check if every provider before this one has come back undefined
let hasLinkBefore = false;
for (let j = 0; j < index; j++) {
if (!this._activeProviderReplies.has(j) || this._activeProviderReplies.get(j)) {
hasLinkBefore = true;
}
}
// If all providers with higher priority came back undefined, then this provider's link for
// the position should be used
if (!hasLinkBefore && links) {
const linkAtPosition = links.find(link => this._linkAtPosition(link.link, position));
if (linkAtPosition) {
linkProvided = true;
this._handleNewLink(linkAtPosition);
}
}
// Check if all the providers have responded
if (this._activeProviderReplies.size === this._linkProviders.length && !linkProvided) {
// Respect the order of the link providers
for (let j = 0; j < this._activeProviderReplies.size; j++) {
const currentLink = this._activeProviderReplies.get(j)?.find(link => this._linkAtPosition(link.link, position));
if (currentLink) {
linkProvided = true;
this._handleNewLink(currentLink);
break;
}
}
}
return linkProvided;
}
private _handleMouseDown(): void {
this._mouseDownLink = this._currentLink;
}
private _handleMouseUp(event: MouseEvent): void {
if (!this._element || !this._mouseService || !this._currentLink) {
return;
}
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
if (!position) {
return;
}
if (this._mouseDownLink === this._currentLink && this._linkAtPosition(this._currentLink.link, position)) {
this._currentLink.link.activate(event, this._currentLink.link.text);
}
}
private _clearCurrentLink(startRow?: number, endRow?: number): void {
if (!this._element || !this._currentLink || !this._lastMouseEvent) {
return;
}
// If we have a start and end row, check that the link is within it
if (!startRow || !endRow || (this._currentLink.link.range.start.y >= startRow && this._currentLink.link.range.end.y <= endRow)) {
this._linkLeave(this._element, this._currentLink.link, this._lastMouseEvent);
this._currentLink = undefined;
disposeArray(this._linkCacheDisposables);
}
}
private _handleNewLink(linkWithState: ILinkWithState): void {
if (!this._element || !this._lastMouseEvent || !this._mouseService) {
return;
}
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
if (!position) {
return;
}
// Trigger hover if the we have a link at the position
if (this._linkAtPosition(linkWithState.link, position)) {
this._currentLink = linkWithState;
this._currentLink.state = {
decorations: {
underline: linkWithState.link.decorations === undefined ? true : linkWithState.link.decorations.underline,
pointerCursor: linkWithState.link.decorations === undefined ? true : linkWithState.link.decorations.pointerCursor
},
isHovered: true
};
this._linkHover(this._element, linkWithState.link, this._lastMouseEvent);
// Add listener for tracking decorations changes
linkWithState.link.decorations = {} as ILinkDecorations;
Object.defineProperties(linkWithState.link.decorations, {
pointerCursor: {
get: () => this._currentLink?.state?.decorations.pointerCursor,
set: v => {
if (this._currentLink?.state && this._currentLink.state.decorations.pointerCursor !== v) {
this._currentLink.state.decorations.pointerCursor = v;
if (this._currentLink.state.isHovered) {
this._element?.classList.toggle('xterm-cursor-pointer', v);
}
}
}
},
underline: {
get: () => this._currentLink?.state?.decorations.underline,
set: v => {
if (this._currentLink?.state && this._currentLink?.state?.decorations.underline !== v) {
this._currentLink.state.decorations.underline = v;
if (this._currentLink.state.isHovered) {
this._fireUnderlineEvent(linkWithState.link, v);
}
}
}
}
});
// Listen to viewport changes to re-render the link under the cursor (only when the line the
// link is on changes)
if (this._renderService) {
this._linkCacheDisposables.push(this._renderService.onRenderedViewportChange(e => {
// Sanity check, this shouldn't happen in practice as this listener would be disposed
if (!this._currentLink) {
return;
}
// When start is 0 a scroll most likely occurred, make sure links above the fold also get
// cleared.
const start = e.start === 0 ? 0 : e.start + 1 + this._bufferService.buffer.ydisp;
const end = this._bufferService.buffer.ydisp + 1 + e.end;
// Only clear the link if the viewport change happened on this line
if (this._currentLink.link.range.start.y >= start && this._currentLink.link.range.end.y <= end) {
this._clearCurrentLink(start, end);
if (this._lastMouseEvent && this._element) {
// re-eval previously active link after changes
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!);
if (position) {
this._askForLink(position, false);
}
}
}
}));
}
}
}
protected _linkHover(element: HTMLElement, link: ILink, event: MouseEvent): void {
if (this._currentLink?.state) {
this._currentLink.state.isHovered = true;
if (this._currentLink.state.decorations.underline) {
this._fireUnderlineEvent(link, true);
}
if (this._currentLink.state.decorations.pointerCursor) {
element.classList.add('xterm-cursor-pointer');
}
}
if (link.hover) {
link.hover(event, link.text);
}
}
private _fireUnderlineEvent(link: ILink, showEvent: boolean): void {
const range = link.range;
const scrollOffset = this._bufferService.buffer.ydisp;
const event = this._createLinkUnderlineEvent(range.start.x - 1, range.start.y - scrollOffset - 1, range.end.x, range.end.y - scrollOffset - 1, undefined);
const emitter = showEvent ? this._onShowLinkUnderline : this._onHideLinkUnderline;
emitter.fire(event);
}
protected _linkLeave(element: HTMLElement, link: ILink, event: MouseEvent): void {
if (this._currentLink?.state) {
this._currentLink.state.isHovered = false;
if (this._currentLink.state.decorations.underline) {
this._fireUnderlineEvent(link, false);
}
if (this._currentLink.state.decorations.pointerCursor) {
element.classList.remove('xterm-cursor-pointer');
}
}
if (link.leave) {
link.leave(event, link.text);
}
}
/**
* Check if the buffer position is within the link
* @param link
* @param position
*/
private _linkAtPosition(link: ILink, position: IBufferCellPosition): boolean {
const lower = link.range.start.y * this._bufferService.cols + link.range.start.x;
const upper = link.range.end.y * this._bufferService.cols + link.range.end.x;
const current = position.y * this._bufferService.cols + position.x;
return (lower <= current && current <= upper);
}
/**
* Get the buffer position from a mouse event
* @param event
*/
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined {
const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
if (!coords) {
return;
}
return { x: coords[0], y: coords[1] + this._bufferService.buffer.ydisp };
}
private _createLinkUnderlineEvent(x1: number, y1: number, x2: number, y2: number, fg: number | undefined): ILinkifierEvent {
return { x1, y1, x2, y2, cols: this._bufferService.cols, fg };
}
}

View File

@@ -0,0 +1,12 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
// This file contains strings that get exported in the API so they can be localized
// eslint-disable-next-line prefer-const
export let promptLabel = 'Terminal input';
// eslint-disable-next-line prefer-const
export let tooMuchOutput = 'Too much output to announce, navigate to rows manually to read';

View File

@@ -0,0 +1,128 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferRange, ILink, ILinkProvider } from 'browser/Types';
import { CellData } from 'common/buffer/CellData';
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
export class OscLinkProvider implements ILinkProvider {
constructor(
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IOscLinkService private readonly _oscLinkService: IOscLinkService
) {
}
public provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void {
const line = this._bufferService.buffer.lines.get(y - 1);
if (!line) {
callback(undefined);
return;
}
const result: ILink[] = [];
const linkHandler = this._optionsService.rawOptions.linkHandler;
const cell = new CellData();
const lineLength = line.getTrimmedLength();
let currentLinkId = -1;
let currentStart = -1;
let finishLink = false;
for (let x = 0; x < lineLength; x++) {
// Minor optimization, only check for content if there isn't a link in case the link ends with
// a null cell
if (currentStart === -1 && !line.hasContent(x)) {
continue;
}
line.loadCell(x, cell);
if (cell.hasExtendedAttrs() && cell.extended.urlId) {
if (currentStart === -1) {
currentStart = x;
currentLinkId = cell.extended.urlId;
continue;
} else {
finishLink = cell.extended.urlId !== currentLinkId;
}
} else {
if (currentStart !== -1) {
finishLink = true;
}
}
if (finishLink || (currentStart !== -1 && x === lineLength - 1)) {
const text = this._oscLinkService.getLinkData(currentLinkId)?.uri;
if (text) {
// These ranges are 1-based
const range: IBufferRange = {
start: {
x: currentStart + 1,
y
},
end: {
// Offset end x if it's a link that ends on the last cell in the line
x: x + (!finishLink && x === lineLength - 1 ? 1 : 0),
y
}
};
let ignoreLink = false;
if (!linkHandler?.allowNonHttpProtocols) {
try {
const parsed = new URL(text);
if (!['http:', 'https:'].includes(parsed.protocol)) {
ignoreLink = true;
}
} catch (e) {
// Ignore invalid URLs to prevent unexpected behaviors
ignoreLink = true;
}
}
if (!ignoreLink) {
// OSC links always use underline and pointer decorations
result.push({
text,
range,
activate: (e, text) => (linkHandler ? linkHandler.activate(e, text, range) : defaultActivate(e, text)),
hover: (e, text) => linkHandler?.hover?.(e, text, range),
leave: (e, text) => linkHandler?.leave?.(e, text, range)
});
}
}
finishLink = false;
// Clear link or start a new link if one starts immediately
if (cell.hasExtendedAttrs() && cell.extended.urlId) {
currentStart = x;
currentLinkId = cell.extended.urlId;
} else {
currentStart = -1;
currentLinkId = -1;
}
}
}
// TODO: Handle fetching and returning other link ranges to underline other links with the same
// id
callback(result);
}
}
function defaultActivate(e: MouseEvent, uri: string): void {
const answer = confirm(`Do you want to navigate to ${uri}?\n\nWARNING: This link could potentially be dangerous`);
if (answer) {
const newWindow = window.open();
if (newWindow) {
try {
newWindow.opener = null;
} catch {
// no-op, Electron can throw
}
newWindow.location.href = uri;
} else {
console.warn('Opening link blocked as opener could not be cleared');
}
}
}

View File

@@ -0,0 +1,83 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IRenderDebouncerWithCallback } from 'browser/Types';
/**
* Debounces calls to render terminal rows using animation frames.
*/
export class RenderDebouncer implements IRenderDebouncerWithCallback {
private _rowStart: number | undefined;
private _rowEnd: number | undefined;
private _rowCount: number | undefined;
private _animationFrame: number | undefined;
private _refreshCallbacks: FrameRequestCallback[] = [];
constructor(
private _parentWindow: Window,
private _renderCallback: (start: number, end: number) => void
) {
}
public dispose(): void {
if (this._animationFrame) {
this._parentWindow.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
}
public addRefreshCallback(callback: FrameRequestCallback): number {
this._refreshCallbacks.push(callback);
if (!this._animationFrame) {
this._animationFrame = this._parentWindow.requestAnimationFrame(() => this._innerRefresh());
}
return this._animationFrame;
}
public refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void {
this._rowCount = rowCount;
// Get the min/max row start/end for the arg values
rowStart = rowStart !== undefined ? rowStart : 0;
rowEnd = rowEnd !== undefined ? rowEnd : this._rowCount - 1;
// Set the properties to the updated values
this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart;
this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd;
if (this._animationFrame) {
return;
}
this._animationFrame = this._parentWindow.requestAnimationFrame(() => this._innerRefresh());
}
private _innerRefresh(): void {
this._animationFrame = undefined;
// Make sure values are set
if (this._rowStart === undefined || this._rowEnd === undefined || this._rowCount === undefined) {
this._runRefreshCallbacks();
return;
}
// Clamp values
const start = Math.max(this._rowStart, 0);
const end = Math.min(this._rowEnd, this._rowCount - 1);
// Reset debouncer (this happens before render callback as the render could trigger it again)
this._rowStart = undefined;
this._rowEnd = undefined;
// Run render callback
this._renderCallback(start, end);
this._runRefreshCallbacks();
}
private _runRefreshCallbacks(): void {
for (const callback of this._refreshCallbacks) {
callback(0);
}
this._refreshCallbacks = [];
}
}

View File

@@ -0,0 +1,72 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Disposable, toDisposable } from 'common/Lifecycle';
export type ScreenDprListener = (newDevicePixelRatio?: number, oldDevicePixelRatio?: number) => void;
/**
* The screen device pixel ratio monitor allows listening for when the
* window.devicePixelRatio value changes. This is done not with polling but with
* the use of window.matchMedia to watch media queries. When the event fires,
* the listener will be reattached using a different media query to ensure that
* any further changes will register.
*
* The listener should fire on both window zoom changes and switching to a
* monitor with a different DPI.
*/
export class ScreenDprMonitor extends Disposable {
private _currentDevicePixelRatio: number;
private _outerListener: ((this: MediaQueryList, ev: MediaQueryListEvent) => any) | undefined;
private _listener: ScreenDprListener | undefined;
private _resolutionMediaMatchList: MediaQueryList | undefined;
constructor(private _parentWindow: Window) {
super();
this._currentDevicePixelRatio = this._parentWindow.devicePixelRatio;
this.register(toDisposable(() => {
this.clearListener();
}));
}
public setListener(listener: ScreenDprListener): void {
if (this._listener) {
this.clearListener();
}
this._listener = listener;
this._outerListener = () => {
if (!this._listener) {
return;
}
this._listener(this._parentWindow.devicePixelRatio, this._currentDevicePixelRatio);
this._updateDpr();
};
this._updateDpr();
}
private _updateDpr(): void {
if (!this._outerListener) {
return;
}
// Clear listeners for old DPR
this._resolutionMediaMatchList?.removeListener(this._outerListener);
// Add listeners for new DPR
this._currentDevicePixelRatio = this._parentWindow.devicePixelRatio;
this._resolutionMediaMatchList = this._parentWindow.matchMedia(`screen and (resolution: ${this._parentWindow.devicePixelRatio}dppx)`);
this._resolutionMediaMatchList.addListener(this._outerListener);
}
public clearListener(): void {
if (!this._resolutionMediaMatchList || !this._listener || !this._outerListener) {
return;
}
this._resolutionMediaMatchList.removeListener(this._outerListener);
this._resolutionMediaMatchList = undefined;
this._listener = undefined;
this._outerListener = undefined;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
const RENDER_DEBOUNCE_THRESHOLD_MS = 1000; // 1 Second
import { IRenderDebouncer } from 'browser/Types';
/**
* Debounces calls to update screen readers to update at most once configurable interval of time.
*/
export class TimeBasedDebouncer implements IRenderDebouncer {
private _rowStart: number | undefined;
private _rowEnd: number | undefined;
private _rowCount: number | undefined;
// The last moment that the Terminal was refreshed at
private _lastRefreshMs = 0;
// Whether a trailing refresh should be triggered due to a refresh request that was throttled
private _additionalRefreshRequested = false;
private _refreshTimeoutID: number | undefined;
constructor(
private _renderCallback: (start: number, end: number) => void,
private readonly _debounceThresholdMS = RENDER_DEBOUNCE_THRESHOLD_MS
) {
}
public dispose(): void {
if (this._refreshTimeoutID) {
clearTimeout(this._refreshTimeoutID);
}
}
public refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void {
this._rowCount = rowCount;
// Get the min/max row start/end for the arg values
rowStart = rowStart !== undefined ? rowStart : 0;
rowEnd = rowEnd !== undefined ? rowEnd : this._rowCount - 1;
// Set the properties to the updated values
this._rowStart = this._rowStart !== undefined ? Math.min(this._rowStart, rowStart) : rowStart;
this._rowEnd = this._rowEnd !== undefined ? Math.max(this._rowEnd, rowEnd) : rowEnd;
// Only refresh if the time since last refresh is above a threshold, otherwise wait for
// enough time to pass before refreshing again.
const refreshRequestTime: number = Date.now();
if (refreshRequestTime - this._lastRefreshMs >= this._debounceThresholdMS) {
// Enough time has lapsed since the last refresh; refresh immediately
this._lastRefreshMs = refreshRequestTime;
this._innerRefresh();
} else if (!this._additionalRefreshRequested) {
// This is the first additional request throttled; set up trailing refresh
const elapsed = refreshRequestTime - this._lastRefreshMs;
const waitPeriodBeforeTrailingRefresh = this._debounceThresholdMS - elapsed;
this._additionalRefreshRequested = true;
this._refreshTimeoutID = window.setTimeout(() => {
this._lastRefreshMs = Date.now();
this._innerRefresh();
this._additionalRefreshRequested = false;
this._refreshTimeoutID = undefined; // No longer need to clear the timeout
}, waitPeriodBeforeTrailingRefresh);
}
}
private _innerRefresh(): void {
// Make sure values are set
if (this._rowStart === undefined || this._rowEnd === undefined || this._rowCount === undefined) {
return;
}
// Clamp values
const start = Math.max(this._rowStart, 0);
const end = Math.min(this._rowEnd, this._rowCount - 1);
// Reset debouncer (this happens before render callback as the render could trigger it again)
this._rowStart = undefined;
this._rowEnd = undefined;
// Run render callback
this._renderCallback(start, end);
}
}

181
public/xterm/src/browser/Types.d.ts vendored Normal file
View File

@@ -0,0 +1,181 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IEvent } from 'common/EventEmitter';
import { CharData, IColor, ICoreTerminal, ITerminalOptions } from 'common/Types';
import { IBuffer } from 'common/buffer/Types';
import { IDisposable, Terminal as ITerminalApi } from 'xterm';
import { IMouseService, IRenderService } from './services/Services';
/**
* A portion of the public API that are implemented identially internally and simply passed through.
*/
type InternalPassthroughApis = Omit<ITerminalApi, 'buffer' | 'parser' | 'unicode' | 'modes' | 'writeln' | 'loadAddon'>;
export interface ITerminal extends InternalPassthroughApis, ICoreTerminal {
screenElement: HTMLElement | undefined;
browser: IBrowser;
buffer: IBuffer;
viewport: IViewport | undefined;
options: Required<ITerminalOptions>;
linkifier2: ILinkifier2;
onBlur: IEvent<void>;
onFocus: IEvent<void>;
onA11yChar: IEvent<string>;
onA11yTab: IEvent<number>;
onWillOpen: IEvent<HTMLElement>;
cancel(ev: Event, force?: boolean): boolean | void;
}
export type CustomKeyEventHandler = (event: KeyboardEvent) => boolean;
export type LineData = CharData[];
export interface ICompositionHelper {
readonly isComposing: boolean;
compositionstart(): void;
compositionupdate(ev: CompositionEvent): void;
compositionend(): void;
updateCompositionElements(dontRecurse?: boolean): void;
keydown(ev: KeyboardEvent): boolean;
}
export interface IBrowser {
isNode: boolean;
userAgent: string;
platform: string;
isFirefox: boolean;
isMac: boolean;
isIpad: boolean;
isIphone: boolean;
isWindows: boolean;
}
export interface IColorSet {
foreground: IColor;
background: IColor;
cursor: IColor;
cursorAccent: IColor;
selectionForeground: IColor | undefined;
selectionBackgroundTransparent: IColor;
/** The selection blended on top of background. */
selectionBackgroundOpaque: IColor;
selectionInactiveBackgroundTransparent: IColor;
selectionInactiveBackgroundOpaque: IColor;
ansi: IColor[];
/** Maps original colors to colors that respect minimum contrast ratio. */
contrastCache: IColorContrastCache;
/** Maps original colors to colors that respect _half_ of the minimum contrast ratio. */
halfContrastCache: IColorContrastCache;
}
export type ReadonlyColorSet = Readonly<Omit<IColorSet, 'ansi'>> & { ansi: Readonly<Pick<IColorSet, 'ansi'>['ansi']> };
export interface IColorContrastCache {
clear(): void;
setCss(bg: number, fg: number, value: string | null): void;
getCss(bg: number, fg: number): string | null | undefined;
setColor(bg: number, fg: number, value: IColor | null): void;
getColor(bg: number, fg: number): IColor | null | undefined;
}
export interface IPartialColorSet {
foreground: IColor;
background: IColor;
cursor?: IColor;
cursorAccent?: IColor;
selectionBackground?: IColor;
ansi: IColor[];
}
export interface IViewport extends IDisposable {
scrollBarWidth: number;
readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;
syncScrollArea(immediate?: boolean, force?: boolean): void;
getLinesScrolled(ev: WheelEvent): number;
getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement };
handleWheel(ev: WheelEvent): boolean;
handleTouchStart(ev: TouchEvent): void;
handleTouchMove(ev: TouchEvent): boolean;
scrollLines(disp: number): void; // todo api name?
reset(): void;
}
export interface ILinkifierEvent {
x1: number;
y1: number;
x2: number;
y2: number;
cols: number;
fg: number | undefined;
}
interface ILinkState {
decorations: ILinkDecorations;
isHovered: boolean;
}
export interface ILinkWithState {
link: ILink;
state?: ILinkState;
}
export interface ILinkifier2 extends IDisposable {
onShowLinkUnderline: IEvent<ILinkifierEvent>;
onHideLinkUnderline: IEvent<ILinkifierEvent>;
readonly currentLink: ILinkWithState | undefined;
attachToDom(element: HTMLElement, mouseService: IMouseService, renderService: IRenderService): void;
registerLinkProvider(linkProvider: ILinkProvider): IDisposable;
}
interface ILinkProvider {
provideLinks(y: number, callback: (links: ILink[] | undefined) => void): void;
}
interface ILink {
range: IBufferRange;
text: string;
decorations?: ILinkDecorations;
activate(event: MouseEvent, text: string): void;
hover?(event: MouseEvent, text: string): void;
leave?(event: MouseEvent, text: string): void;
dispose?(): void;
}
interface ILinkDecorations {
pointerCursor: boolean;
underline: boolean;
}
interface IBufferRange {
start: IBufferCellPosition;
end: IBufferCellPosition;
}
interface IBufferCellPosition {
x: number;
y: number;
}
export type CharacterJoinerHandler = (text: string) => [number, number][];
export interface ICharacterJoiner {
id: number;
handler: CharacterJoinerHandler;
}
export interface IRenderDebouncer extends IDisposable {
refresh(rowStart: number | undefined, rowEnd: number | undefined, rowCount: number): void;
}
export interface IRenderDebouncerWithCallback extends IRenderDebouncer {
addRefreshCallback(callback: FrameRequestCallback): number;
}
export interface IBufferElementProvider {
provideBufferElements(): DocumentFragment | HTMLElement;
}

View File

@@ -0,0 +1,401 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { addDisposableDomListener } from 'browser/Lifecycle';
import { IViewport, ReadonlyColorSet } from 'browser/Types';
import { IRenderDimensions } from 'browser/renderer/shared/Types';
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { IBuffer } from 'common/buffer/Types';
import { IBufferService, IOptionsService } from 'common/services/Services';
const FALLBACK_SCROLL_BAR_WIDTH = 15;
interface ISmoothScrollState {
startTime: number;
origin: number;
target: number;
}
/**
* Represents the viewport of a terminal, the visible area within the larger buffer of output.
* Logic for the virtual scroll bar is included in this object.
*/
export class Viewport extends Disposable implements IViewport {
public scrollBarWidth: number = 0;
private _currentRowHeight: number = 0;
private _currentDeviceCellHeight: number = 0;
private _lastRecordedBufferLength: number = 0;
private _lastRecordedViewportHeight: number = 0;
private _lastRecordedBufferHeight: number = 0;
private _lastTouchY: number = 0;
private _lastScrollTop: number = 0;
private _activeBuffer: IBuffer;
private _renderDimensions: IRenderDimensions;
// Stores a partial line amount when scrolling, this is used to keep track of how much of a line
// is scrolled so we can "scroll" over partial lines and feel natural on touchpads. This is a
// quick fix and could have a more robust solution in place that reset the value when needed.
private _wheelPartialScroll: number = 0;
private _refreshAnimationFrame: number | null = null;
private _ignoreNextScrollEvent: boolean = false;
private _smoothScrollState: ISmoothScrollState = {
startTime: 0,
origin: -1,
target: -1
};
private readonly _onRequestScrollLines = this.register(new EventEmitter<{ amount: number, suppressScrollEvent: boolean }>());
public readonly onRequestScrollLines = this._onRequestScrollLines.event;
constructor(
private readonly _viewportElement: HTMLElement,
private readonly _scrollArea: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@IRenderService private readonly _renderService: IRenderService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
@IThemeService themeService: IThemeService
) {
super();
// Measure the width of the scrollbar. If it is 0 we can assume it's an OSX overlay scrollbar.
// Unfortunately the overlay scrollbar would be hidden underneath the screen element in that
// case, therefore we account for a standard amount to make it visible
this.scrollBarWidth = (this._viewportElement.offsetWidth - this._scrollArea.offsetWidth) || FALLBACK_SCROLL_BAR_WIDTH;
this.register(addDisposableDomListener(this._viewportElement, 'scroll', this._handleScroll.bind(this)));
// Track properties used in performance critical code manually to avoid using slow getters
this._activeBuffer = this._bufferService.buffer;
this.register(this._bufferService.buffers.onBufferActivate(e => this._activeBuffer = e.activeBuffer));
this._renderDimensions = this._renderService.dimensions;
this.register(this._renderService.onDimensionsChange(e => this._renderDimensions = e));
this._handleThemeChange(themeService.colors);
this.register(themeService.onChangeColors(e => this._handleThemeChange(e)));
this.register(this._optionsService.onSpecificOptionChange('scrollback', () => this.syncScrollArea()));
// Perform this async to ensure the ICharSizeService is ready.
setTimeout(() => this.syncScrollArea());
}
private _handleThemeChange(colors: ReadonlyColorSet): void {
this._viewportElement.style.backgroundColor = colors.background.css;
}
public reset(): void {
this._currentRowHeight = 0;
this._currentDeviceCellHeight = 0;
this._lastRecordedBufferLength = 0;
this._lastRecordedViewportHeight = 0;
this._lastRecordedBufferHeight = 0;
this._lastTouchY = 0;
this._lastScrollTop = 0;
// Sync on next animation frame to ensure the new terminal state is used
this._coreBrowserService.window.requestAnimationFrame(() => this.syncScrollArea());
}
/**
* Refreshes row height, setting line-height, viewport height and scroll area height if
* necessary.
*/
private _refresh(immediate: boolean): void {
if (immediate) {
this._innerRefresh();
if (this._refreshAnimationFrame !== null) {
this._coreBrowserService.window.cancelAnimationFrame(this._refreshAnimationFrame);
}
return;
}
if (this._refreshAnimationFrame === null) {
this._refreshAnimationFrame = this._coreBrowserService.window.requestAnimationFrame(() => this._innerRefresh());
}
}
private _innerRefresh(): void {
if (this._charSizeService.height > 0) {
this._currentRowHeight = this._renderService.dimensions.device.cell.height / this._coreBrowserService.dpr;
this._currentDeviceCellHeight = this._renderService.dimensions.device.cell.height;
this._lastRecordedViewportHeight = this._viewportElement.offsetHeight;
const newBufferHeight = Math.round(this._currentRowHeight * this._lastRecordedBufferLength) + (this._lastRecordedViewportHeight - this._renderService.dimensions.css.canvas.height);
if (this._lastRecordedBufferHeight !== newBufferHeight) {
this._lastRecordedBufferHeight = newBufferHeight;
this._scrollArea.style.height = this._lastRecordedBufferHeight + 'px';
}
}
// Sync scrollTop
const scrollTop = this._bufferService.buffer.ydisp * this._currentRowHeight;
if (this._viewportElement.scrollTop !== scrollTop) {
// Ignore the next scroll event which will be triggered by setting the scrollTop as we do not
// want this event to scroll the terminal
this._ignoreNextScrollEvent = true;
this._viewportElement.scrollTop = scrollTop;
}
this._refreshAnimationFrame = null;
}
/**
* Updates dimensions and synchronizes the scroll area if necessary.
*/
public syncScrollArea(immediate: boolean = false): void {
// If buffer height changed
if (this._lastRecordedBufferLength !== this._bufferService.buffer.lines.length) {
this._lastRecordedBufferLength = this._bufferService.buffer.lines.length;
this._refresh(immediate);
return;
}
// If viewport height changed
if (this._lastRecordedViewportHeight !== this._renderService.dimensions.css.canvas.height) {
this._refresh(immediate);
return;
}
// If the buffer position doesn't match last scroll top
if (this._lastScrollTop !== this._activeBuffer.ydisp * this._currentRowHeight) {
this._refresh(immediate);
return;
}
// If row height changed
if (this._renderDimensions.device.cell.height !== this._currentDeviceCellHeight) {
this._refresh(immediate);
return;
}
}
/**
* Handles scroll events on the viewport, calculating the new viewport and requesting the
* terminal to scroll to it.
* @param ev The scroll event.
*/
private _handleScroll(ev: Event): void {
// Record current scroll top position
this._lastScrollTop = this._viewportElement.scrollTop;
// Don't attempt to scroll if the element is not visible, otherwise scrollTop will be corrupt
// which causes the terminal to scroll the buffer to the top
if (!this._viewportElement.offsetParent) {
return;
}
// Ignore the event if it was flagged to ignore (when the source of the event is from Viewport)
if (this._ignoreNextScrollEvent) {
this._ignoreNextScrollEvent = false;
// Still trigger the scroll so lines get refreshed
this._onRequestScrollLines.fire({ amount: 0, suppressScrollEvent: true });
return;
}
const newRow = Math.round(this._lastScrollTop / this._currentRowHeight);
const diff = newRow - this._bufferService.buffer.ydisp;
this._onRequestScrollLines.fire({ amount: diff, suppressScrollEvent: true });
}
private _smoothScroll(): void {
// Check valid state
if (this._isDisposed || this._smoothScrollState.origin === -1 || this._smoothScrollState.target === -1) {
return;
}
// Calculate position complete
const percent = this._smoothScrollPercent();
this._viewportElement.scrollTop = this._smoothScrollState.origin + Math.round(percent * (this._smoothScrollState.target - this._smoothScrollState.origin));
// Continue or finish smooth scroll
if (percent < 1) {
this._coreBrowserService.window.requestAnimationFrame(() => this._smoothScroll());
} else {
this._clearSmoothScrollState();
}
}
private _smoothScrollPercent(): number {
if (!this._optionsService.rawOptions.smoothScrollDuration || !this._smoothScrollState.startTime) {
return 1;
}
return Math.max(Math.min((Date.now() - this._smoothScrollState.startTime) / this._optionsService.rawOptions.smoothScrollDuration, 1), 0);
}
private _clearSmoothScrollState(): void {
this._smoothScrollState.startTime = 0;
this._smoothScrollState.origin = -1;
this._smoothScrollState.target = -1;
}
/**
* Handles bubbling of scroll event in case the viewport has reached top or bottom
* @param ev The scroll event.
* @param amount The amount scrolled
*/
private _bubbleScroll(ev: Event, amount: number): boolean {
const scrollPosFromTop = this._viewportElement.scrollTop + this._lastRecordedViewportHeight;
if ((amount < 0 && this._viewportElement.scrollTop !== 0) ||
(amount > 0 && scrollPosFromTop < this._lastRecordedBufferHeight)) {
if (ev.cancelable) {
ev.preventDefault();
}
return false;
}
return true;
}
/**
* Handles mouse wheel events by adjusting the viewport's scrollTop and delegating the actual
* scrolling to `onScroll`, this event needs to be attached manually by the consumer of
* `Viewport`.
* @param ev The mouse wheel event.
*/
public handleWheel(ev: WheelEvent): boolean {
const amount = this._getPixelsScrolled(ev);
if (amount === 0) {
return false;
}
if (!this._optionsService.rawOptions.smoothScrollDuration) {
this._viewportElement.scrollTop += amount;
} else {
this._smoothScrollState.startTime = Date.now();
if (this._smoothScrollPercent() < 1) {
this._smoothScrollState.origin = this._viewportElement.scrollTop;
if (this._smoothScrollState.target === -1) {
this._smoothScrollState.target = this._viewportElement.scrollTop + amount;
} else {
this._smoothScrollState.target += amount;
}
this._smoothScrollState.target = Math.max(Math.min(this._smoothScrollState.target, this._viewportElement.scrollHeight), 0);
this._smoothScroll();
} else {
this._clearSmoothScrollState();
}
}
return this._bubbleScroll(ev, amount);
}
public scrollLines(disp: number): void {
if (disp === 0) {
return;
}
if (!this._optionsService.rawOptions.smoothScrollDuration) {
this._onRequestScrollLines.fire({ amount: disp, suppressScrollEvent: false });
} else {
const amount = disp * this._currentRowHeight;
this._smoothScrollState.startTime = Date.now();
if (this._smoothScrollPercent() < 1) {
this._smoothScrollState.origin = this._viewportElement.scrollTop;
this._smoothScrollState.target = this._smoothScrollState.origin + amount;
this._smoothScrollState.target = Math.max(Math.min(this._smoothScrollState.target, this._viewportElement.scrollHeight), 0);
this._smoothScroll();
} else {
this._clearSmoothScrollState();
}
}
}
private _getPixelsScrolled(ev: WheelEvent): number {
// Do nothing if it's not a vertical scroll event
if (ev.deltaY === 0 || ev.shiftKey) {
return 0;
}
// Fallback to WheelEvent.DOM_DELTA_PIXEL
let amount = this._applyScrollModifier(ev.deltaY, ev);
if (ev.deltaMode === WheelEvent.DOM_DELTA_LINE) {
amount *= this._currentRowHeight;
} else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
amount *= this._currentRowHeight * this._bufferService.rows;
}
return amount;
}
public getBufferElements(startLine: number, endLine?: number): { bufferElements: HTMLElement[], cursorElement?: HTMLElement } {
let currentLine: string = '';
let cursorElement: HTMLElement | undefined;
const bufferElements: HTMLElement[] = [];
const end = endLine ?? this._bufferService.buffer.lines.length;
const lines = this._bufferService.buffer.lines;
for (let i = startLine; i < end; i++) {
const line = lines.get(i);
if (!line) {
continue;
}
const isWrapped = lines.get(i + 1)?.isWrapped;
currentLine += line.translateToString(!isWrapped);
if (!isWrapped || i === lines.length - 1) {
const div = document.createElement('div');
div.textContent = currentLine;
bufferElements.push(div);
if (currentLine.length > 0) {
cursorElement = div;
}
currentLine = '';
}
}
return { bufferElements, cursorElement };
}
/**
* Gets the number of pixels scrolled by the mouse event taking into account what type of delta
* is being used.
* @param ev The mouse wheel event.
*/
public getLinesScrolled(ev: WheelEvent): number {
// Do nothing if it's not a vertical scroll event
if (ev.deltaY === 0 || ev.shiftKey) {
return 0;
}
// Fallback to WheelEvent.DOM_DELTA_LINE
let amount = this._applyScrollModifier(ev.deltaY, ev);
if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
amount /= this._currentRowHeight + 0.0; // Prevent integer division
this._wheelPartialScroll += amount;
amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1);
this._wheelPartialScroll %= 1;
} else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
amount *= this._bufferService.rows;
}
return amount;
}
private _applyScrollModifier(amount: number, ev: WheelEvent): number {
const modifier = this._optionsService.rawOptions.fastScrollModifier;
// Multiply the scroll speed when the modifier is down
if ((modifier === 'alt' && ev.altKey) ||
(modifier === 'ctrl' && ev.ctrlKey) ||
(modifier === 'shift' && ev.shiftKey)) {
return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity;
}
return amount * this._optionsService.rawOptions.scrollSensitivity;
}
/**
* Handles the touchstart event, recording the touch occurred.
* @param ev The touch event.
*/
public handleTouchStart(ev: TouchEvent): void {
this._lastTouchY = ev.touches[0].pageY;
}
/**
* Handles the touchmove event, scrolling the viewport if the position shifted.
* @param ev The touch event.
*/
public handleTouchMove(ev: TouchEvent): boolean {
const deltaY = this._lastTouchY - ev.touches[0].pageY;
this._lastTouchY = ev.touches[0].pageY;
if (deltaY === 0) {
return false;
}
this._viewportElement.scrollTop += deltaY;
return this._bubbleScroll(ev, deltaY);
}
}

View File

@@ -0,0 +1,134 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { addDisposableDomListener } from 'browser/Lifecycle';
import { IRenderService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IDecorationService, IInternalDecoration } from 'common/services/Services';
export class BufferDecorationRenderer extends Disposable {
private readonly _container: HTMLElement;
private readonly _decorationElements: Map<IInternalDecoration, HTMLElement> = new Map();
private _animationFrame: number | undefined;
private _altBufferIsActive: boolean = false;
private _dimensionsChanged: boolean = false;
constructor(
private readonly _screenElement: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@IDecorationService private readonly _decorationService: IDecorationService,
@IRenderService private readonly _renderService: IRenderService
) {
super();
this._container = document.createElement('div');
this._container.classList.add('xterm-decoration-container');
this._screenElement.appendChild(this._container);
this.register(this._renderService.onRenderedViewportChange(() => this._doRefreshDecorations()));
this.register(this._renderService.onDimensionsChange(() => {
this._dimensionsChanged = true;
this._queueRefresh();
}));
this.register(addDisposableDomListener(window, 'resize', () => this._queueRefresh()));
this.register(this._bufferService.buffers.onBufferActivate(() => {
this._altBufferIsActive = this._bufferService.buffer === this._bufferService.buffers.alt;
}));
this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh()));
this.register(this._decorationService.onDecorationRemoved(decoration => this._removeDecoration(decoration)));
this.register(toDisposable(() => {
this._container.remove();
this._decorationElements.clear();
}));
}
private _queueRefresh(): void {
if (this._animationFrame !== undefined) {
return;
}
this._animationFrame = this._renderService.addRefreshCallback(() => {
this._doRefreshDecorations();
this._animationFrame = undefined;
});
}
private _doRefreshDecorations(): void {
for (const decoration of this._decorationService.decorations) {
this._renderDecoration(decoration);
}
this._dimensionsChanged = false;
}
private _renderDecoration(decoration: IInternalDecoration): void {
this._refreshStyle(decoration);
if (this._dimensionsChanged) {
this._refreshXPosition(decoration);
}
}
private _createElement(decoration: IInternalDecoration): HTMLElement {
const element = document.createElement('div');
element.classList.add('xterm-decoration');
element.classList.toggle('xterm-decoration-top-layer', decoration?.options?.layer === 'top');
element.style.width = `${Math.round((decoration.options.width || 1) * this._renderService.dimensions.css.cell.width)}px`;
element.style.height = `${(decoration.options.height || 1) * this._renderService.dimensions.css.cell.height}px`;
element.style.top = `${(decoration.marker.line - this._bufferService.buffers.active.ydisp) * this._renderService.dimensions.css.cell.height}px`;
element.style.lineHeight = `${this._renderService.dimensions.css.cell.height}px`;
const x = decoration.options.x ?? 0;
if (x && x > this._bufferService.cols) {
// exceeded the container width, so hide
element.style.display = 'none';
}
this._refreshXPosition(decoration, element);
return element;
}
private _refreshStyle(decoration: IInternalDecoration): void {
const line = decoration.marker.line - this._bufferService.buffers.active.ydisp;
if (line < 0 || line >= this._bufferService.rows) {
// outside of viewport
if (decoration.element) {
decoration.element.style.display = 'none';
decoration.onRenderEmitter.fire(decoration.element);
}
} else {
let element = this._decorationElements.get(decoration);
if (!element) {
element = this._createElement(decoration);
decoration.element = element;
this._decorationElements.set(decoration, element);
this._container.appendChild(element);
decoration.onDispose(() => {
this._decorationElements.delete(decoration);
element!.remove();
});
}
element.style.top = `${line * this._renderService.dimensions.css.cell.height}px`;
element.style.display = this._altBufferIsActive ? 'none' : 'block';
decoration.onRenderEmitter.fire(element);
}
}
private _refreshXPosition(decoration: IInternalDecoration, element: HTMLElement | undefined = decoration.element): void {
if (!element) {
return;
}
const x = decoration.options.x ?? 0;
if ((decoration.options.anchor || 'left') === 'right') {
element.style.right = x ? `${x * this._renderService.dimensions.css.cell.width}px` : '';
} else {
element.style.left = x ? `${x * this._renderService.dimensions.css.cell.width}px` : '';
}
}
private _removeDecoration(decoration: IInternalDecoration): void {
this._decorationElements.get(decoration)?.remove();
this._decorationElements.delete(decoration);
decoration.dispose();
}
}

View File

@@ -0,0 +1,117 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IInternalDecoration } from 'common/services/Services';
export interface IColorZoneStore {
readonly zones: IColorZone[];
clear(): void;
addDecoration(decoration: IInternalDecoration): void;
/**
* Sets the amount of padding in lines that will be added between zones, if new lines intersect
* the padding they will be merged into the same zone.
*/
setPadding(padding: { [position: string]: number }): void;
}
export interface IColorZone {
/** Color in a format supported by canvas' fillStyle. */
color: string;
position: 'full' | 'left' | 'center' | 'right' | undefined;
startBufferLine: number;
endBufferLine: number;
}
interface IMinimalDecorationForColorZone {
marker: Pick<IInternalDecoration['marker'], 'line'>;
options: Pick<IInternalDecoration['options'], 'overviewRulerOptions'>;
}
export class ColorZoneStore implements IColorZoneStore {
private _zones: IColorZone[] = [];
// The zone pool is used to keep zone objects from being freed between clearing the color zone
// store and fetching the zones. This helps reduce GC pressure since the color zones are
// accumulated on potentially every scroll event.
private _zonePool: IColorZone[] = [];
private _zonePoolIndex = 0;
private _linePadding: { [position: string]: number } = {
full: 0,
left: 0,
center: 0,
right: 0
};
public get zones(): IColorZone[] {
// Trim the zone pool to free unused memory
this._zonePool.length = Math.min(this._zonePool.length, this._zones.length);
return this._zones;
}
public clear(): void {
this._zones.length = 0;
this._zonePoolIndex = 0;
}
public addDecoration(decoration: IMinimalDecorationForColorZone): void {
if (!decoration.options.overviewRulerOptions) {
return;
}
for (const z of this._zones) {
if (z.color === decoration.options.overviewRulerOptions.color &&
z.position === decoration.options.overviewRulerOptions.position) {
if (this._lineIntersectsZone(z, decoration.marker.line)) {
return;
}
if (this._lineAdjacentToZone(z, decoration.marker.line, decoration.options.overviewRulerOptions.position)) {
this._addLineToZone(z, decoration.marker.line);
return;
}
}
}
// Create using zone pool if possible
if (this._zonePoolIndex < this._zonePool.length) {
this._zonePool[this._zonePoolIndex].color = decoration.options.overviewRulerOptions.color;
this._zonePool[this._zonePoolIndex].position = decoration.options.overviewRulerOptions.position;
this._zonePool[this._zonePoolIndex].startBufferLine = decoration.marker.line;
this._zonePool[this._zonePoolIndex].endBufferLine = decoration.marker.line;
this._zones.push(this._zonePool[this._zonePoolIndex++]);
return;
}
// Create
this._zones.push({
color: decoration.options.overviewRulerOptions.color,
position: decoration.options.overviewRulerOptions.position,
startBufferLine: decoration.marker.line,
endBufferLine: decoration.marker.line
});
this._zonePool.push(this._zones[this._zones.length - 1]);
this._zonePoolIndex++;
}
public setPadding(padding: { [position: string]: number }): void {
this._linePadding = padding;
}
private _lineIntersectsZone(zone: IColorZone, line: number): boolean {
return (
line >= zone.startBufferLine &&
line <= zone.endBufferLine
);
}
private _lineAdjacentToZone(zone: IColorZone, line: number, position: IColorZone['position']): boolean {
return (
(line >= zone.startBufferLine - this._linePadding[position || 'full']) &&
(line <= zone.endBufferLine + this._linePadding[position || 'full'])
);
}
private _addLineToZone(zone: IColorZone, line: number): void {
zone.startBufferLine = Math.min(zone.startBufferLine, line);
zone.endBufferLine = Math.max(zone.endBufferLine, line);
}
}

View File

@@ -0,0 +1,219 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { ColorZoneStore, IColorZone, IColorZoneStore } from 'browser/decorations/ColorZoneStore';
import { addDisposableDomListener } from 'browser/Lifecycle';
import { ICoreBrowserService, IRenderService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
// Helper objects to avoid excessive calculation and garbage collection during rendering. These are
// static values for each render and can be accessed using the decoration position as the key.
const drawHeight = {
full: 0,
left: 0,
center: 0,
right: 0
};
const drawWidth = {
full: 0,
left: 0,
center: 0,
right: 0
};
const drawX = {
full: 0,
left: 0,
center: 0,
right: 0
};
export class OverviewRulerRenderer extends Disposable {
private readonly _canvas: HTMLCanvasElement;
private readonly _ctx: CanvasRenderingContext2D;
private readonly _colorZoneStore: IColorZoneStore = new ColorZoneStore();
private get _width(): number {
return this._optionsService.options.overviewRulerWidth || 0;
}
private _animationFrame: number | undefined;
private _shouldUpdateDimensions: boolean | undefined = true;
private _shouldUpdateAnchor: boolean | undefined = true;
private _lastKnownBufferLength: number = 0;
private _containerHeight: number | undefined;
constructor(
private readonly _viewportElement: HTMLElement,
private readonly _screenElement: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@IDecorationService private readonly _decorationService: IDecorationService,
@IRenderService private readonly _renderService: IRenderService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICoreBrowserService private readonly _coreBrowseService: ICoreBrowserService
) {
super();
this._canvas = document.createElement('canvas');
this._canvas.classList.add('xterm-decoration-overview-ruler');
this._refreshCanvasDimensions();
this._viewportElement.parentElement?.insertBefore(this._canvas, this._viewportElement);
const ctx = this._canvas.getContext('2d');
if (!ctx) {
throw new Error('Ctx cannot be null');
} else {
this._ctx = ctx;
}
this._registerDecorationListeners();
this._registerBufferChangeListeners();
this._registerDimensionChangeListeners();
this.register(toDisposable(() => {
this._canvas?.remove();
}));
}
/**
* On decoration add or remove, redraw
*/
private _registerDecorationListeners(): void {
this.register(this._decorationService.onDecorationRegistered(() => this._queueRefresh(undefined, true)));
this.register(this._decorationService.onDecorationRemoved(() => this._queueRefresh(undefined, true)));
}
/**
* On buffer change, redraw
* and hide the canvas if the alt buffer is active
*/
private _registerBufferChangeListeners(): void {
this.register(this._renderService.onRenderedViewportChange(() => this._queueRefresh()));
this.register(this._bufferService.buffers.onBufferActivate(() => {
this._canvas!.style.display = this._bufferService.buffer === this._bufferService.buffers.alt ? 'none' : 'block';
}));
this.register(this._bufferService.onScroll(() => {
if (this._lastKnownBufferLength !== this._bufferService.buffers.normal.lines.length) {
this._refreshDrawHeightConstants();
this._refreshColorZonePadding();
}
}));
}
/**
* On dimension change, update canvas dimensions
* and then redraw
*/
private _registerDimensionChangeListeners(): void {
// container height changed
this.register(this._renderService.onRender((): void => {
if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) {
this._queueRefresh(true);
this._containerHeight = this._screenElement.clientHeight;
}
}));
// overview ruler width changed
this.register(this._optionsService.onSpecificOptionChange('overviewRulerWidth', () => this._queueRefresh(true)));
// device pixel ratio changed
this.register(addDisposableDomListener(this._coreBrowseService.window, 'resize', () => this._queueRefresh(true)));
// set the canvas dimensions
this._queueRefresh(true);
}
private _refreshDrawConstants(): void {
// width
const outerWidth = Math.floor(this._canvas.width / 3);
const innerWidth = Math.ceil(this._canvas.width / 3);
drawWidth.full = this._canvas.width;
drawWidth.left = outerWidth;
drawWidth.center = innerWidth;
drawWidth.right = outerWidth;
// height
this._refreshDrawHeightConstants();
// x
drawX.full = 0;
drawX.left = 0;
drawX.center = drawWidth.left;
drawX.right = drawWidth.left + drawWidth.center;
}
private _refreshDrawHeightConstants(): void {
drawHeight.full = Math.round(2 * this._coreBrowseService.dpr);
// Calculate actual pixels per line
const pixelsPerLine = this._canvas.height / this._bufferService.buffer.lines.length;
// Clamp actual pixels within a range
const nonFullHeight = Math.round(Math.max(Math.min(pixelsPerLine, 12), 6) * this._coreBrowseService.dpr);
drawHeight.left = nonFullHeight;
drawHeight.center = nonFullHeight;
drawHeight.right = nonFullHeight;
}
private _refreshColorZonePadding(): void {
this._colorZoneStore.setPadding({
full: Math.floor(this._bufferService.buffers.active.lines.length / (this._canvas.height - 1) * drawHeight.full),
left: Math.floor(this._bufferService.buffers.active.lines.length / (this._canvas.height - 1) * drawHeight.left),
center: Math.floor(this._bufferService.buffers.active.lines.length / (this._canvas.height - 1) * drawHeight.center),
right: Math.floor(this._bufferService.buffers.active.lines.length / (this._canvas.height - 1) * drawHeight.right)
});
this._lastKnownBufferLength = this._bufferService.buffers.normal.lines.length;
}
private _refreshCanvasDimensions(): void {
this._canvas.style.width = `${this._width}px`;
this._canvas.width = Math.round(this._width * this._coreBrowseService.dpr);
this._canvas.style.height = `${this._screenElement.clientHeight}px`;
this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowseService.dpr);
this._refreshDrawConstants();
this._refreshColorZonePadding();
}
private _refreshDecorations(): void {
if (this._shouldUpdateDimensions) {
this._refreshCanvasDimensions();
}
this._ctx.clearRect(0, 0, this._canvas.width, this._canvas.height);
this._colorZoneStore.clear();
for (const decoration of this._decorationService.decorations) {
this._colorZoneStore.addDecoration(decoration);
}
this._ctx.lineWidth = 1;
const zones = this._colorZoneStore.zones;
for (const zone of zones) {
if (zone.position !== 'full') {
this._renderColorZone(zone);
}
}
for (const zone of zones) {
if (zone.position === 'full') {
this._renderColorZone(zone);
}
}
this._shouldUpdateDimensions = false;
this._shouldUpdateAnchor = false;
}
private _renderColorZone(zone: IColorZone): void {
this._ctx.fillStyle = zone.color;
this._ctx.fillRect(
/* x */ drawX[zone.position || 'full'],
/* y */ Math.round(
(this._canvas.height - 1) * // -1 to ensure at least 2px are allowed for decoration on last line
(zone.startBufferLine / this._bufferService.buffers.active.lines.length) - drawHeight[zone.position || 'full'] / 2
),
/* w */ drawWidth[zone.position || 'full'],
/* h */ Math.round(
(this._canvas.height - 1) * // -1 to ensure at least 2px are allowed for decoration on last line
((zone.endBufferLine - zone.startBufferLine) / this._bufferService.buffers.active.lines.length) + drawHeight[zone.position || 'full']
)
);
}
private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void {
this._shouldUpdateDimensions = updateCanvasDimensions || this._shouldUpdateDimensions;
this._shouldUpdateAnchor = updateAnchor || this._shouldUpdateAnchor;
if (this._animationFrame !== undefined) {
return;
}
this._animationFrame = this._coreBrowseService.window.requestAnimationFrame(() => {
this._refreshDecorations();
this._animationFrame = undefined;
});
}
}

View File

@@ -0,0 +1,246 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IRenderService } from 'browser/services/Services';
import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
import { C0 } from 'common/data/EscapeSequences';
interface IPosition {
start: number;
end: number;
}
/**
* Encapsulates the logic for handling compositionstart, compositionupdate and compositionend
* events, displaying the in-progress composition to the UI and forwarding the final composition
* to the handler.
*/
export class CompositionHelper {
/**
* Whether input composition is currently happening, eg. via a mobile keyboard, speech input or
* IME. This variable determines whether the compositionText should be displayed on the UI.
*/
private _isComposing: boolean;
public get isComposing(): boolean { return this._isComposing; }
/**
* The position within the input textarea's value of the current composition.
*/
private _compositionPosition: IPosition;
/**
* Whether a composition is in the process of being sent, setting this to false will cancel any
* in-progress composition.
*/
private _isSendingComposition: boolean;
/**
* Data already sent due to keydown event.
*/
private _dataAlreadySent: string;
constructor(
private readonly _textarea: HTMLTextAreaElement,
private readonly _compositionView: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICoreService private readonly _coreService: ICoreService,
@IRenderService private readonly _renderService: IRenderService
) {
this._isComposing = false;
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._dataAlreadySent = '';
}
/**
* Handles the compositionstart event, activating the composition view.
*/
public compositionstart(): void {
this._isComposing = true;
this._compositionPosition.start = this._textarea.value.length;
this._compositionView.textContent = '';
this._dataAlreadySent = '';
this._compositionView.classList.add('active');
}
/**
* Handles the compositionupdate event, updating the composition view.
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
this._compositionView.textContent = ev.data;
this.updateCompositionElements();
setTimeout(() => {
this._compositionPosition.end = this._textarea.value.length;
}, 0);
}
/**
* Handles the compositionend event, hiding the composition view and sending the composition to
* the handler.
*/
public compositionend(): void {
this._finalizeComposition(true);
}
/**
* Handles the keydown event, routing any necessary events to the CompositionHelper functions.
* @param ev The keydown event.
* @returns Whether the Terminal should continue processing the keydown event.
*/
public keydown(ev: KeyboardEvent): boolean {
if (this._isComposing || this._isSendingComposition) {
if (ev.keyCode === 229) {
// Continue composing if the keyCode is the "composition character"
return false;
}
if (ev.keyCode === 16 || ev.keyCode === 17 || ev.keyCode === 18) {
// Continue composing if the keyCode is a modifier key
return false;
}
// Finish composition immediately. This is mainly here for the case where enter is
// pressed and the handler needs to be triggered before the command is executed.
this._finalizeComposition(false);
}
if (ev.keyCode === 229) {
// If the "composition character" is used but gets to this point it means a non-composition
// character (eg. numbers and punctuation) was pressed when the IME was active.
this._handleAnyTextareaChanges();
return false;
}
return true;
}
/**
* Finalizes the composition, resuming regular input actions. This is called when a composition
* is ending.
* @param waitForPropagation Whether to wait for events to propagate before sending
* the input. This should be false if a non-composition keystroke is entered before the
* compositionend event is triggered, such as enter, so that the composition is sent before
* the command is executed.
*/
private _finalizeComposition(waitForPropagation: boolean): void {
this._compositionView.classList.remove('active');
this._isComposing = false;
if (!waitForPropagation) {
// Cancel any delayed composition send requests and send the input immediately.
this._isSendingComposition = false;
const input = this._textarea.value.substring(this._compositionPosition.start, this._compositionPosition.end);
this._coreService.triggerDataEvent(input, true);
} else {
// Make a deep copy of the composition position here as a new compositionstart event may
// fire before the setTimeout executes.
const currentCompositionPosition = {
start: this._compositionPosition.start,
end: this._compositionPosition.end
};
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
// complete. This ensures the correct character is retrieved.
// This solution was used because:
// - The compositionend event's data property is unreliable, at least on Chromium
// - The last compositionupdate event's data property does not always accurately describe
// the character, a counter example being Korean where an ending consonsant can move to
// the following character if the following input is a vowel.
this._isSendingComposition = true;
setTimeout(() => {
// Ensure that the input has not already been sent
if (this._isSendingComposition) {
this._isSendingComposition = false;
let input;
// Add length of data already sent due to keydown event,
// otherwise input characters can be duplicated. (Issue #3191)
currentCompositionPosition.start += this._dataAlreadySent.length;
if (this._isComposing) {
// Use the end position to get the string if a new composition has started.
input = this._textarea.value.substring(currentCompositionPosition.start, currentCompositionPosition.end);
} else {
// Don't use the end position here in order to pick up any characters after the
// composition has finished, for example when typing a non-composition character
// (eg. 2) after a composition character.
input = this._textarea.value.substring(currentCompositionPosition.start);
}
if (input.length > 0) {
this._coreService.triggerDataEvent(input, true);
}
}
}, 0);
}
}
/**
* Apply any changes made to the textarea after the current event chain is allowed to complete.
* This should be called when not currently composing but a keydown event with the "composition
* character" (229) is triggered, in order to allow non-composition text to be entered when an
* IME is active.
*/
private _handleAnyTextareaChanges(): void {
const oldValue = this._textarea.value;
setTimeout(() => {
// Ignore if a composition has started since the timeout
if (!this._isComposing) {
const newValue = this._textarea.value;
const diff = newValue.replace(oldValue, '');
this._dataAlreadySent = diff;
if (newValue.length > oldValue.length) {
this._coreService.triggerDataEvent(diff, true);
} else if (newValue.length < oldValue.length) {
this._coreService.triggerDataEvent(`${C0.DEL}`, true);
} else if ((newValue.length === oldValue.length) && (newValue !== oldValue)) {
this._coreService.triggerDataEvent(newValue, true);
}
}
}, 0);
}
/**
* Positions the composition view on top of the cursor and the textarea just below it (so the
* IME helper dialog is positioned correctly).
* @param dontRecurse Whether to use setTimeout to recursively trigger another update, this is
* necessary as the IME events across browsers are not consistently triggered.
*/
public updateCompositionElements(dontRecurse?: boolean): void {
if (!this._isComposing) {
return;
}
if (this._bufferService.buffer.isCursorInViewport) {
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
const cellHeight = this._renderService.dimensions.css.cell.height;
const cursorTop = this._bufferService.buffer.y * this._renderService.dimensions.css.cell.height;
const cursorLeft = cursorX * this._renderService.dimensions.css.cell.width;
this._compositionView.style.left = cursorLeft + 'px';
this._compositionView.style.top = cursorTop + 'px';
this._compositionView.style.height = cellHeight + 'px';
this._compositionView.style.lineHeight = cellHeight + 'px';
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
// Sync the textarea to the exact position of the composition view so the IME knows where the
// text is.
const compositionViewBounds = this._compositionView.getBoundingClientRect();
this._textarea.style.left = cursorLeft + 'px';
this._textarea.style.top = cursorTop + 'px';
// Ensure the text area is at least 1x1, otherwise certain IMEs may break
this._textarea.style.width = Math.max(compositionViewBounds.width, 1) + 'px';
this._textarea.style.height = Math.max(compositionViewBounds.height, 1) + 'px';
this._textarea.style.lineHeight = compositionViewBounds.height + 'px';
}
if (!dontRecurse) {
setTimeout(() => this.updateCompositionElements(true), 0);
}
}
}

View File

@@ -0,0 +1,54 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
export function getCoordsRelativeToElement(window: Pick<Window, 'getComputedStyle'>, event: {clientX: number, clientY: number}, element: HTMLElement): [number, number] {
const rect = element.getBoundingClientRect();
const elementStyle = window.getComputedStyle(element);
const leftPadding = parseInt(elementStyle.getPropertyValue('padding-left'));
const topPadding = parseInt(elementStyle.getPropertyValue('padding-top'));
return [
event.clientX - rect.left - leftPadding,
event.clientY - rect.top - topPadding
];
}
/**
* Gets coordinates within the terminal for a particular mouse event. The result
* is returned as an array in the form [x, y] instead of an object as it's a
* little faster and this function is used in some low level code.
* @param window The window object the element belongs to.
* @param event The mouse event.
* @param element The terminal's container element.
* @param colCount The number of columns in the terminal.
* @param rowCount The number of rows n the terminal.
* @param hasValidCharSize Whether there is a valid character size available.
* @param cssCellWidth The cell width device pixel render dimensions.
* @param cssCellHeight The cell height device pixel render dimensions.
* @param isSelection Whether the request is for the selection or not. This will
* apply an offset to the x value such that the left half of the cell will
* select that cell and the right half will select the next cell.
*/
export function getCoords(window: Pick<Window, 'getComputedStyle'>, event: Pick<MouseEvent, 'clientX' | 'clientY'>, element: HTMLElement, colCount: number, rowCount: number, hasValidCharSize: boolean, cssCellWidth: number, cssCellHeight: number, isSelection?: boolean): [number, number] | undefined {
// Coordinates cannot be measured if there are no valid
if (!hasValidCharSize) {
return undefined;
}
const coords = getCoordsRelativeToElement(window, event, element);
if (!coords) {
return undefined;
}
coords[0] = Math.ceil((coords[0] + (isSelection ? cssCellWidth / 2 : 0)) / cssCellWidth);
coords[1] = Math.ceil(coords[1] / cssCellHeight);
// Ensure coordinates are within the terminal viewport. Note that selections
// need an addition point of precision to cover the end point (as characters
// cover half of one char and half of the next).
coords[0] = Math.min(Math.max(coords[0], 1), colCount + (isSelection ? 1 : 0));
coords[1] = Math.min(Math.max(coords[1], 1), rowCount);
return coords;
}

View File

@@ -0,0 +1,249 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { C0 } from 'common/data/EscapeSequences';
import { IBufferService } from 'common/services/Services';
const enum Direction {
UP = 'A',
DOWN = 'B',
RIGHT = 'C',
LEFT = 'D'
}
/**
* Concatenates all the arrow sequences together.
* Resets the starting row to an unwrapped row, moves to the requested row,
* then moves to requested col.
*/
export function moveToCellSequence(targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string {
const startX = bufferService.buffer.x;
const startY = bufferService.buffer.y;
// The alt buffer should try to navigate between rows
if (!bufferService.buffer.hasScrollback) {
return resetStartingRow(startX, startY, targetX, targetY, bufferService, applicationCursor) +
moveToRequestedRow(startY, targetY, bufferService, applicationCursor) +
moveToRequestedCol(startX, startY, targetX, targetY, bufferService, applicationCursor);
}
// Only move horizontally for the normal buffer
let direction;
if (startY === targetY) {
direction = startX > targetX ? Direction.LEFT : Direction.RIGHT;
return repeat(Math.abs(startX - targetX), sequence(direction, applicationCursor));
}
direction = startY > targetY ? Direction.LEFT : Direction.RIGHT;
const rowDifference = Math.abs(startY - targetY);
const cellsToMove = colsFromRowEnd(startY > targetY ? targetX : startX, bufferService) +
(rowDifference - 1) * bufferService.cols + 1 /* wrap around 1 row */ +
colsFromRowBeginning(startY > targetY ? startX : targetX, bufferService);
return repeat(cellsToMove, sequence(direction, applicationCursor));
}
/**
* Find the number of cols from a row beginning to a col.
*/
function colsFromRowBeginning(currX: number, bufferService: IBufferService): number {
return currX - 1;
}
/**
* Find the number of cols from a col to row end.
*/
function colsFromRowEnd(currX: number, bufferService: IBufferService): number {
return bufferService.cols - currX;
}
/**
* If the initial position of the cursor is on a row that is wrapped, move the
* cursor up to the first row that is not wrapped to have accurate vertical
* positioning.
*/
function resetStartingRow(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string {
if (moveToRequestedRow(startY, targetY, bufferService, applicationCursor).length === 0) {
return '';
}
return repeat(bufferLine(
startX, startY, startX,
startY - wrappedRowsForRow(startY, bufferService), false, bufferService
).length, sequence(Direction.LEFT, applicationCursor));
}
/**
* Using the reset starting and ending row, move to the requested row,
* ignoring wrapped rows
*/
function moveToRequestedRow(startY: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string {
const startRow = startY - wrappedRowsForRow(startY, bufferService);
const endRow = targetY - wrappedRowsForRow(targetY, bufferService);
const rowsToMove = Math.abs(startRow - endRow) - wrappedRowsCount(startY, targetY, bufferService);
return repeat(rowsToMove, sequence(verticalDirection(startY, targetY), applicationCursor));
}
/**
* Move to the requested col on the ending row
*/
function moveToRequestedCol(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): string {
let startRow;
if (moveToRequestedRow(startY, targetY, bufferService, applicationCursor).length > 0) {
startRow = targetY - wrappedRowsForRow(targetY, bufferService);
} else {
startRow = startY;
}
const endRow = targetY;
const direction = horizontalDirection(startX, startY, targetX, targetY, bufferService, applicationCursor);
return repeat(bufferLine(
startX, startRow, targetX, endRow,
direction === Direction.RIGHT, bufferService
).length, sequence(direction, applicationCursor));
}
/**
* Utility functions
*/
/**
* Calculates the number of wrapped rows between the unwrapped starting and
* ending rows. These rows need to ignored since the cursor skips over them.
*/
function wrappedRowsCount(startY: number, targetY: number, bufferService: IBufferService): number {
let wrappedRows = 0;
const startRow = startY - wrappedRowsForRow(startY, bufferService);
const endRow = targetY - wrappedRowsForRow(targetY, bufferService);
for (let i = 0; i < Math.abs(startRow - endRow); i++) {
const direction = verticalDirection(startY, targetY) === Direction.UP ? -1 : 1;
const line = bufferService.buffer.lines.get(startRow + (direction * i));
if (line?.isWrapped) {
wrappedRows++;
}
}
return wrappedRows;
}
/**
* Calculates the number of wrapped rows that make up a given row.
* @param currentRow The row to determine how many wrapped rows make it up
*/
function wrappedRowsForRow(currentRow: number, bufferService: IBufferService): number {
let rowCount = 0;
let line = bufferService.buffer.lines.get(currentRow);
let lineWraps = line?.isWrapped;
while (lineWraps && currentRow >= 0 && currentRow < bufferService.rows) {
rowCount++;
line = bufferService.buffer.lines.get(--currentRow);
lineWraps = line?.isWrapped;
}
return rowCount;
}
/**
* Direction determiners
*/
/**
* Determines if the right or left arrow is needed
*/
function horizontalDirection(startX: number, startY: number, targetX: number, targetY: number, bufferService: IBufferService, applicationCursor: boolean): Direction {
let startRow;
if (moveToRequestedRow(targetX, targetY, bufferService, applicationCursor).length > 0) {
startRow = targetY - wrappedRowsForRow(targetY, bufferService);
} else {
startRow = startY;
}
if ((startX < targetX &&
startRow <= targetY) || // down/right or same y/right
(startX >= targetX &&
startRow < targetY)) { // down/left or same y/left
return Direction.RIGHT;
}
return Direction.LEFT;
}
/**
* Determines if the up or down arrow is needed
*/
function verticalDirection(startY: number, targetY: number): Direction {
return startY > targetY ? Direction.UP : Direction.DOWN;
}
/**
* Constructs the string of chars in the buffer from a starting row and col
* to an ending row and col
* @param startCol The starting column position
* @param startRow The starting row position
* @param endCol The ending column position
* @param endRow The ending row position
* @param forward Direction to move
*/
function bufferLine(
startCol: number,
startRow: number,
endCol: number,
endRow: number,
forward: boolean,
bufferService: IBufferService
): string {
let currentCol = startCol;
let currentRow = startRow;
let bufferStr = '';
while (currentCol !== endCol || currentRow !== endRow) {
currentCol += forward ? 1 : -1;
if (forward && currentCol > bufferService.cols - 1) {
bufferStr += bufferService.buffer.translateBufferLineToString(
currentRow, false, startCol, currentCol
);
currentCol = 0;
startCol = 0;
currentRow++;
} else if (!forward && currentCol < 0) {
bufferStr += bufferService.buffer.translateBufferLineToString(
currentRow, false, 0, startCol + 1
);
currentCol = bufferService.cols - 1;
startCol = currentCol;
currentRow--;
}
}
return bufferStr + bufferService.buffer.translateBufferLineToString(
currentRow, false, startCol, currentCol
);
}
/**
* Constructs the escape sequence for clicking an arrow
* @param direction The direction to move
*/
function sequence(direction: Direction, applicationCursor: boolean): string {
const mod = applicationCursor ? 'O' : '[';
return C0.ESC + mod + direction;
}
/**
* Returns a string repeated a given number of times
* Polyfill from https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/repeat
* @param count The number of times to repeat the string
* @param str The string that is to be repeated
*/
function repeat(count: number, str: string): string {
count = Math.floor(count);
let rpt = '';
for (let i = 0; i < count; i++) {
rpt += str;
}
return rpt;
}

View File

@@ -0,0 +1,260 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import * as Strings from 'browser/LocalizableStrings';
import { Terminal as TerminalCore } from 'browser/Terminal';
import { IBufferRange, ITerminal } from 'browser/Types';
import { IEvent } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { ITerminalOptions } from 'common/Types';
import { AddonManager } from 'common/public/AddonManager';
import { BufferNamespaceApi } from 'common/public/BufferNamespaceApi';
import { ParserApi } from 'common/public/ParserApi';
import { UnicodeApi } from 'common/public/UnicodeApi';
import { IBufferNamespace as IBufferNamespaceApi, IDecoration, IDecorationOptions, IDisposable, ILinkProvider, ILocalizableStrings, IMarker, IModes, IParser, ITerminalAddon, Terminal as ITerminalApi, ITerminalInitOnlyOptions, IUnicodeHandling } from 'xterm';
/**
* The set of options that only have an effect when set in the Terminal constructor.
*/
const CONSTRUCTOR_ONLY_OPTIONS = ['cols', 'rows'];
export class Terminal extends Disposable implements ITerminalApi {
private _core: ITerminal;
private _addonManager: AddonManager;
private _parser: IParser | undefined;
private _buffer: BufferNamespaceApi | undefined;
private _publicOptions: Required<ITerminalOptions>;
constructor(options?: ITerminalOptions & ITerminalInitOnlyOptions) {
super();
this._core = this.register(new TerminalCore(options));
this._addonManager = this.register(new AddonManager());
this._publicOptions = { ... this._core.options };
const getter = (propName: string): any => {
return this._core.options[propName];
};
const setter = (propName: string, value: any): void => {
this._checkReadonlyOptions(propName);
this._core.options[propName] = value;
};
for (const propName in this._core.options) {
const desc = {
get: getter.bind(this, propName),
set: setter.bind(this, propName)
};
Object.defineProperty(this._publicOptions, propName, desc);
}
}
private _checkReadonlyOptions(propName: string): void {
// Throw an error if any constructor only option is modified
// from terminal.options
// Modifications from anywhere else are allowed
if (CONSTRUCTOR_ONLY_OPTIONS.includes(propName)) {
throw new Error(`Option "${propName}" can only be set in the constructor`);
}
}
private _checkProposedApi(): void {
if (!this._core.optionsService.rawOptions.allowProposedApi) {
throw new Error('You must set the allowProposedApi option to true to use proposed API');
}
}
public get onBell(): IEvent<void> { return this._core.onBell; }
public get onBinary(): IEvent<string> { return this._core.onBinary; }
public get onCursorMove(): IEvent<void> { return this._core.onCursorMove; }
public get onData(): IEvent<string> { return this._core.onData; }
public get onKey(): IEvent<{ key: string, domEvent: KeyboardEvent }> { return this._core.onKey; }
public get onLineFeed(): IEvent<void> { return this._core.onLineFeed; }
public get onRender(): IEvent<{ start: number, end: number }> { return this._core.onRender; }
public get onResize(): IEvent<{ cols: number, rows: number }> { return this._core.onResize; }
public get onScroll(): IEvent<number> { return this._core.onScroll; }
public get onSelectionChange(): IEvent<void> { return this._core.onSelectionChange; }
public get onTitleChange(): IEvent<string> { return this._core.onTitleChange; }
public get onWriteParsed(): IEvent<void> { return this._core.onWriteParsed; }
public get element(): HTMLElement | undefined { return this._core.element; }
public get parser(): IParser {
if (!this._parser) {
this._parser = new ParserApi(this._core);
}
return this._parser;
}
public get unicode(): IUnicodeHandling {
this._checkProposedApi();
return new UnicodeApi(this._core);
}
public get textarea(): HTMLTextAreaElement | undefined { return this._core.textarea; }
public get rows(): number { return this._core.rows; }
public get cols(): number { return this._core.cols; }
public get buffer(): IBufferNamespaceApi {
if (!this._buffer) {
this._buffer = this.register(new BufferNamespaceApi(this._core));
}
return this._buffer;
}
public get markers(): ReadonlyArray<IMarker> {
this._checkProposedApi();
return this._core.markers;
}
public get modes(): IModes {
const m = this._core.coreService.decPrivateModes;
let mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' = 'none';
switch (this._core.coreMouseService.activeProtocol) {
case 'X10': mouseTrackingMode = 'x10'; break;
case 'VT200': mouseTrackingMode = 'vt200'; break;
case 'DRAG': mouseTrackingMode = 'drag'; break;
case 'ANY': mouseTrackingMode = 'any'; break;
}
return {
applicationCursorKeysMode: m.applicationCursorKeys,
applicationKeypadMode: m.applicationKeypad,
bracketedPasteMode: m.bracketedPasteMode,
insertMode: this._core.coreService.modes.insertMode,
mouseTrackingMode: mouseTrackingMode,
originMode: m.origin,
reverseWraparoundMode: m.reverseWraparound,
sendFocusMode: m.sendFocus,
wraparoundMode: m.wraparound
};
}
public get options(): Required<ITerminalOptions> {
return this._publicOptions;
}
public set options(options: ITerminalOptions) {
for (const propName in options) {
this._publicOptions[propName] = options[propName];
}
}
public blur(): void {
this._core.blur();
}
public focus(): void {
this._core.focus();
}
public resize(columns: number, rows: number): void {
this._verifyIntegers(columns, rows);
this._core.resize(columns, rows);
}
public open(parent: HTMLElement): void {
this._core.open(parent);
}
public attachCustomKeyEventHandler(customKeyEventHandler: (event: KeyboardEvent) => boolean): void {
this._core.attachCustomKeyEventHandler(customKeyEventHandler);
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
return this._core.registerLinkProvider(linkProvider);
}
public registerCharacterJoiner(handler: (text: string) => [number, number][]): number {
this._checkProposedApi();
return this._core.registerCharacterJoiner(handler);
}
public deregisterCharacterJoiner(joinerId: number): void {
this._checkProposedApi();
this._core.deregisterCharacterJoiner(joinerId);
}
public registerMarker(cursorYOffset: number = 0): IMarker {
this._verifyIntegers(cursorYOffset);
return this._core.registerMarker(cursorYOffset);
}
public registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined {
this._checkProposedApi();
this._verifyPositiveIntegers(decorationOptions.x ?? 0, decorationOptions.width ?? 0, decorationOptions.height ?? 0);
return this._core.registerDecoration(decorationOptions);
}
public hasSelection(): boolean {
return this._core.hasSelection();
}
public select(column: number, row: number, length: number): void {
this._verifyIntegers(column, row, length);
this._core.select(column, row, length);
}
public getSelection(): string {
return this._core.getSelection();
}
public getSelectionPosition(): IBufferRange | undefined {
return this._core.getSelectionPosition();
}
public clearSelection(): void {
this._core.clearSelection();
}
public selectAll(): void {
this._core.selectAll();
}
public selectLines(start: number, end: number): void {
this._verifyIntegers(start, end);
this._core.selectLines(start, end);
}
public dispose(): void {
super.dispose();
}
public scrollLines(amount: number): void {
this._verifyIntegers(amount);
this._core.scrollLines(amount);
}
public scrollPages(pageCount: number): void {
this._verifyIntegers(pageCount);
this._core.scrollPages(pageCount);
}
public scrollToTop(): void {
this._core.scrollToTop();
}
public scrollToBottom(): void {
this._core.scrollToBottom();
}
public scrollToLine(line: number): void {
this._verifyIntegers(line);
this._core.scrollToLine(line);
}
public clear(): void {
this._core.clear();
}
public write(data: string | Uint8Array, callback?: () => void): void {
this._core.write(data, callback);
}
public writeln(data: string | Uint8Array, callback?: () => void): void {
this._core.write(data);
this._core.write('\r\n', callback);
}
public paste(data: string): void {
this._core.paste(data);
}
public refresh(start: number, end: number): void {
this._verifyIntegers(start, end);
this._core.refresh(start, end);
}
public reset(): void {
this._core.reset();
}
public clearTextureAtlas(): void {
this._core.clearTextureAtlas();
}
public loadAddon(addon: ITerminalAddon): void {
this._addonManager.loadAddon(this, addon);
}
public static get strings(): ILocalizableStrings {
return Strings;
}
private _verifyIntegers(...values: number[]): void {
for (const value of values) {
if (value === Infinity || isNaN(value) || value % 1 !== 0) {
throw new Error('This API only accepts integers');
}
}
}
private _verifyPositiveIntegers(...values: number[]): void {
for (const value of values) {
if (value && (value === Infinity || isNaN(value) || value % 1 !== 0 || value < 0)) {
throw new Error('This API only accepts positive integers');
}
}
}
}

View File

@@ -0,0 +1,506 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { DomRendererRowFactory, RowCss } from 'browser/renderer/dom/DomRendererRowFactory';
import { WidthCache } from 'browser/renderer/dom/WidthCache';
import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants';
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
import { IRenderDimensions, IRenderer, IRequestRedrawEvent } from 'browser/renderer/shared/Types';
import { ICharSizeService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
import { ILinkifier2, ILinkifierEvent, ReadonlyColorSet } from 'browser/Types';
import { color } from 'common/Color';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IInstantiationService, IOptionsService } from 'common/services/Services';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
const ROW_CONTAINER_CLASS = 'xterm-rows';
const FG_CLASS_PREFIX = 'xterm-fg-';
const BG_CLASS_PREFIX = 'xterm-bg-';
const FOCUS_CLASS = 'xterm-focus';
const SELECTION_CLASS = 'xterm-selection';
let nextTerminalId = 1;
/**
* A fallback renderer for when canvas is slow. This is not meant to be
* particularly fast or feature complete, more just stable and usable for when
* canvas is not an option.
*/
export class DomRenderer extends Disposable implements IRenderer {
private _rowFactory: DomRendererRowFactory;
private _terminalClass: number = nextTerminalId++;
private _themeStyleElement!: HTMLStyleElement;
private _dimensionsStyleElement!: HTMLStyleElement;
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[] = [];
private _selectionContainer: HTMLElement;
private _widthCache: WidthCache;
public dimensions: IRenderDimensions;
public readonly onRequestRedraw = this.register(new EventEmitter<IRequestRedrawEvent>()).event;
constructor(
private readonly _element: HTMLElement,
private readonly _screenElement: HTMLElement,
private readonly _viewportElement: HTMLElement,
private readonly _linkifier2: ILinkifier2,
@IInstantiationService instantiationService: IInstantiationService,
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
@IThemeService private readonly _themeService: IThemeService
) {
super();
this._rowContainer = document.createElement('div');
this._rowContainer.classList.add(ROW_CONTAINER_CLASS);
this._rowContainer.style.lineHeight = 'normal';
this._rowContainer.setAttribute('aria-hidden', 'true');
this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
this._selectionContainer = document.createElement('div');
this._selectionContainer.classList.add(SELECTION_CLASS);
this._selectionContainer.setAttribute('aria-hidden', 'true');
this.dimensions = createRenderDimensions();
this._updateDimensions();
this.register(this._optionsService.onOptionChange(() => this._handleOptionsChanged()));
this.register(this._themeService.onChangeColors(e => this._injectCss(e)));
this._injectCss(this._themeService.colors);
this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);
this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
this._screenElement.appendChild(this._rowContainer);
this._screenElement.appendChild(this._selectionContainer);
this.register(this._linkifier2.onShowLinkUnderline(e => this._handleLinkHover(e)));
this.register(this._linkifier2.onHideLinkUnderline(e => this._handleLinkLeave(e)));
this.register(toDisposable(() => {
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
// Outside influences such as React unmounts may manipulate the DOM before our disposal.
// https://github.com/xtermjs/xterm.js/issues/2960
this._rowContainer.remove();
this._selectionContainer.remove();
this._widthCache.dispose();
this._themeStyleElement.remove();
this._dimensionsStyleElement.remove();
}));
this._widthCache = new WidthCache(document);
this._widthCache.setFont(
this._optionsService.rawOptions.fontFamily,
this._optionsService.rawOptions.fontSize,
this._optionsService.rawOptions.fontWeight,
this._optionsService.rawOptions.fontWeightBold
);
this._setDefaultSpacing();
}
private _updateDimensions(): void {
const dpr = this._coreBrowserService.dpr;
this.dimensions.device.char.width = this._charSizeService.width * dpr;
this.dimensions.device.char.height = Math.ceil(this._charSizeService.height * dpr);
this.dimensions.device.cell.width = this.dimensions.device.char.width + Math.round(this._optionsService.rawOptions.letterSpacing);
this.dimensions.device.cell.height = Math.floor(this.dimensions.device.char.height * this._optionsService.rawOptions.lineHeight);
this.dimensions.device.char.left = 0;
this.dimensions.device.char.top = 0;
this.dimensions.device.canvas.width = this.dimensions.device.cell.width * this._bufferService.cols;
this.dimensions.device.canvas.height = this.dimensions.device.cell.height * this._bufferService.rows;
this.dimensions.css.canvas.width = Math.round(this.dimensions.device.canvas.width / dpr);
this.dimensions.css.canvas.height = Math.round(this.dimensions.device.canvas.height / dpr);
this.dimensions.css.cell.width = this.dimensions.css.canvas.width / this._bufferService.cols;
this.dimensions.css.cell.height = this.dimensions.css.canvas.height / this._bufferService.rows;
for (const element of this._rowElements) {
element.style.width = `${this.dimensions.css.canvas.width}px`;
element.style.height = `${this.dimensions.css.cell.height}px`;
element.style.lineHeight = `${this.dimensions.css.cell.height}px`;
// Make sure rows don't overflow onto following row
element.style.overflow = 'hidden';
}
if (!this._dimensionsStyleElement) {
this._dimensionsStyleElement = document.createElement('style');
this._screenElement.appendChild(this._dimensionsStyleElement);
}
const styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
` height: 100%;` +
` vertical-align: top;` +
`}`;
this._dimensionsStyleElement.textContent = styles;
this._selectionContainer.style.height = this._viewportElement.style.height;
this._screenElement.style.width = `${this.dimensions.css.canvas.width}px`;
this._screenElement.style.height = `${this.dimensions.css.canvas.height}px`;
}
private _injectCss(colors: ReadonlyColorSet): void {
if (!this._themeStyleElement) {
this._themeStyleElement = document.createElement('style');
this._screenElement.appendChild(this._themeStyleElement);
}
// Base CSS
let styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` +
` color: ${colors.foreground.css};` +
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
` font-kerning: none;` +
` white-space: pre` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
`}`;
// Text styles
styles +=
`${this._terminalSelector} span:not(.${RowCss.BOLD_CLASS}) {` +
` font-weight: ${this._optionsService.rawOptions.fontWeight};` +
`}` +
`${this._terminalSelector} span.${RowCss.BOLD_CLASS} {` +
` font-weight: ${this._optionsService.rawOptions.fontWeightBold};` +
`}` +
`${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` +
` font-style: italic;` +
`}`;
// Blink animation
styles +=
`@keyframes blink_box_shadow` + `_` + this._terminalClass + ` {` +
` 50% {` +
` border-bottom-style: hidden;` +
` }` +
`}`;
styles +=
`@keyframes blink_block` + `_` + this._terminalClass + ` {` +
` 0% {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
` }` +
` 50% {` +
` background-color: inherit;` +
` color: ${colors.cursor.css};` +
` }` +
`}`;
// Cursor
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}:not(.${RowCss.CURSOR_STYLE_BLOCK_CLASS}) {` +
` animation: blink_box_shadow` + `_` + this._terminalClass + ` 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` animation: blink_block` + `_` + this._terminalClass + ` 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
` outline: 1px solid ${colors.cursor.css};` +
` outline-offset: -1px;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` border-bottom: 1px ${colors.cursor.css};` +
` border-bottom-style: solid;` +
` height: calc(100% - 1px);` +
`}`;
// Selection
styles +=
`${this._terminalSelector} .${SELECTION_CLASS} {` +
` position: absolute;` +
` top: 0;` +
` left: 0;` +
` z-index: 1;` +
` pointer-events: none;` +
`}` +
`${this._terminalSelector}.focus .${SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionBackgroundOpaque.css};` +
`}` +
`${this._terminalSelector} .${SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
`}`;
// Colors
for (const [i, c] of colors.ansi.entries()) {
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
}
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
this._themeStyleElement.textContent = styles;
}
/**
* default letter spacing
* Due to rounding issues in dimensions dpr calc glyph might render
* slightly too wide or too narrow. The method corrects the stacking offsets
* by applying a default letter-spacing for all chars.
* The value gets passed to the row factory to avoid setting this value again
* (render speedup is roughly 10%).
*/
private _setDefaultSpacing(): void {
// measure same char as in CharSizeService to get the base deviation
const spacing = this.dimensions.css.cell.width - this._widthCache.get('W', false, false);
this._rowContainer.style.letterSpacing = `${spacing}px`;
this._rowFactory.defaultSpacing = spacing;
}
public handleDevicePixelRatioChange(): void {
this._updateDimensions();
this._widthCache.clear();
this._setDefaultSpacing();
}
private _refreshRowElements(cols: number, rows: number): void {
// Add missing elements
for (let i = this._rowElements.length; i <= rows; i++) {
const row = document.createElement('div');
this._rowContainer.appendChild(row);
this._rowElements.push(row);
}
// Remove excess elements
while (this._rowElements.length > rows) {
this._rowContainer.removeChild(this._rowElements.pop()!);
}
}
public handleResize(cols: number, rows: number): void {
this._refreshRowElements(cols, rows);
this._updateDimensions();
}
public handleCharSizeChanged(): void {
this._updateDimensions();
this._widthCache.clear();
this._setDefaultSpacing();
}
public handleBlur(): void {
this._rowContainer.classList.remove(FOCUS_CLASS);
}
public handleFocus(): void {
this._rowContainer.classList.add(FOCUS_CLASS);
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
// Remove all selections
this._selectionContainer.replaceChildren();
this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);
this.renderRows(0, this._bufferService.rows - 1);
// Selection does not exist
if (!start || !end) {
return;
}
// Translate from buffer position to viewport position
const viewportStartRow = start[1] - this._bufferService.buffer.ydisp;
const viewportEndRow = end[1] - this._bufferService.buffer.ydisp;
const viewportCappedStartRow = Math.max(viewportStartRow, 0);
const viewportCappedEndRow = Math.min(viewportEndRow, this._bufferService.rows - 1);
// No need to draw the selection
if (viewportCappedStartRow >= this._bufferService.rows || viewportCappedEndRow < 0) {
return;
}
// Create the selections
const documentFragment = document.createDocumentFragment();
if (columnSelectMode) {
const isXFlipped = start[0] > end[0];
documentFragment.appendChild(
this._createSelectionElement(viewportCappedStartRow, isXFlipped ? end[0] : start[0], isXFlipped ? start[0] : end[0], viewportCappedEndRow - viewportCappedStartRow + 1)
);
} else {
// Draw first row
const startCol = viewportStartRow === viewportCappedStartRow ? start[0] : 0;
const endCol = viewportCappedStartRow === viewportEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow, startCol, endCol));
// Draw middle rows
const middleRowsCount = viewportCappedEndRow - viewportCappedStartRow - 1;
documentFragment.appendChild(this._createSelectionElement(viewportCappedStartRow + 1, 0, this._bufferService.cols, middleRowsCount));
// Draw final row
if (viewportCappedStartRow !== viewportCappedEndRow) {
// Only draw viewportEndRow if it's not the same as viewporttartRow
const endCol = viewportEndRow === viewportCappedEndRow ? end[0] : this._bufferService.cols;
documentFragment.appendChild(this._createSelectionElement(viewportCappedEndRow, 0, endCol));
}
}
this._selectionContainer.appendChild(documentFragment);
}
/**
* Creates a selection element at the specified position.
* @param row The row of the selection.
* @param colStart The start column.
* @param colEnd The end columns.
*/
private _createSelectionElement(row: number, colStart: number, colEnd: number, rowCount: number = 1): HTMLElement {
const element = document.createElement('div');
element.style.height = `${rowCount * this.dimensions.css.cell.height}px`;
element.style.top = `${row * this.dimensions.css.cell.height}px`;
element.style.left = `${colStart * this.dimensions.css.cell.width}px`;
element.style.width = `${this.dimensions.css.cell.width * (colEnd - colStart)}px`;
return element;
}
public handleCursorMove(): void {
// No-op, the cursor is drawn when rows are drawn
}
private _handleOptionsChanged(): void {
// Force a refresh
this._updateDimensions();
// Refresh CSS
this._injectCss(this._themeService.colors);
// update spacing cache
this._widthCache.setFont(
this._optionsService.rawOptions.fontFamily,
this._optionsService.rawOptions.fontSize,
this._optionsService.rawOptions.fontWeight,
this._optionsService.rawOptions.fontWeightBold
);
this._setDefaultSpacing();
}
public clear(): void {
for (const e of this._rowElements) {
/**
* NOTE: This used to be `e.innerText = '';` but that doesn't work when using `jsdom` and
* `@testing-library/react`
*
* references:
* - https://github.com/testing-library/react-testing-library/issues/1146
* - https://github.com/jsdom/jsdom/issues/1245
*/
e.replaceChildren();
}
}
public renderRows(start: number, end: number): void {
const buffer = this._bufferService.buffer;
const cursorAbsoluteY = buffer.ybase + buffer.y;
const cursorX = Math.min(buffer.x, this._bufferService.cols - 1);
const cursorBlink = this._optionsService.rawOptions.cursorBlink;
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
for (let y = start; y <= end; y++) {
const row = y + buffer.ydisp;
const rowElement = this._rowElements[y];
const lineData = buffer.lines.get(row);
if (!rowElement || !lineData) {
break;
}
rowElement.replaceChildren(
...this._rowFactory.createRow(
lineData,
row,
row === cursorAbsoluteY,
cursorStyle,
cursorInactiveStyle,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
-1,
-1
)
);
}
}
private get _terminalSelector(): string {
return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
}
private _handleLinkHover(e: ILinkifierEvent): void {
this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, true);
}
private _handleLinkLeave(e: ILinkifierEvent): void {
this._setCellUnderline(e.x1, e.x2, e.y1, e.y2, e.cols, false);
}
private _setCellUnderline(x: number, x2: number, y: number, y2: number, cols: number, enabled: boolean): void {
/**
* NOTE: The linkifier may send out of viewport y-values if:
* - negative y-value: the link started at a higher line
* - y-value >= maxY: the link ends at a line below viewport
*
* For negative y-values we can simply adjust x = 0,
* as higher up link start means, that everything from
* (0,0) is a link under top-down-left-right char progression
*
* Additionally there might be a small chance of out-of-sync x|y-values
* from a race condition of render updates vs. link event handler execution:
* - (sync) resize: chances terminal buffer in sync, schedules render update async
* - (async) link handler race condition: new buffer metrics, but still on old render state
* - (async) render update: brings term metrics and render state back in sync
*/
// clip coords into viewport
if (y < 0) x = 0;
if (y2 < 0) x2 = 0;
const maxY = this._bufferService.rows - 1;
y = Math.max(Math.min(y, maxY), 0);
y2 = Math.max(Math.min(y2, maxY), 0);
cols = Math.min(cols, this._bufferService.cols);
const buffer = this._bufferService.buffer;
const cursorAbsoluteY = buffer.ybase + buffer.y;
const cursorX = Math.min(buffer.x, cols - 1);
const cursorBlink = this._optionsService.rawOptions.cursorBlink;
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
// refresh rows within link range
for (let i = y; i <= y2; ++i) {
const row = i + buffer.ydisp;
const rowElement = this._rowElements[i];
const bufferline = buffer.lines.get(row);
if (!rowElement || !bufferline) {
break;
}
rowElement.replaceChildren(
...this._rowFactory.createRow(
bufferline,
row,
row === cursorAbsoluteY,
cursorStyle,
cursorInactiveStyle,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
enabled ? (i === y ? x : 0) : -1,
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
)
);
}
}
}

View File

@@ -0,0 +1,522 @@
/**
* Copyright (c) 2018, 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferLine, ICellData, IColor } from 'common/Types';
import { INVERTED_DEFAULT_COLOR } from 'browser/renderer/shared/Constants';
import { WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
import { color, rgba } from 'common/Color';
import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
import { JoinedCellData } from 'browser/services/CharacterJoinerService';
import { excludeFromContrastRatioDemands } from 'browser/renderer/shared/RendererUtils';
import { AttributeData } from 'common/buffer/AttributeData';
import { WidthCache } from 'browser/renderer/dom/WidthCache';
import { IColorContrastCache } from 'browser/Types';
export const enum RowCss {
BOLD_CLASS = 'xterm-bold',
DIM_CLASS = 'xterm-dim',
ITALIC_CLASS = 'xterm-italic',
UNDERLINE_CLASS = 'xterm-underline',
OVERLINE_CLASS = 'xterm-overline',
STRIKETHROUGH_CLASS = 'xterm-strikethrough',
CURSOR_CLASS = 'xterm-cursor',
CURSOR_BLINK_CLASS = 'xterm-cursor-blink',
CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block',
CURSOR_STYLE_OUTLINE_CLASS = 'xterm-cursor-outline',
CURSOR_STYLE_BAR_CLASS = 'xterm-cursor-bar',
CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline'
}
export class DomRendererRowFactory {
private _workCell: CellData = new CellData();
private _selectionStart: [number, number] | undefined;
private _selectionEnd: [number, number] | undefined;
private _columnSelectMode: boolean = false;
public defaultSpacing = 0;
constructor(
private readonly _document: Document,
@ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService,
@IOptionsService private readonly _optionsService: IOptionsService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService,
@ICoreService private readonly _coreService: ICoreService,
@IDecorationService private readonly _decorationService: IDecorationService,
@IThemeService private readonly _themeService: IThemeService
) {}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
this._selectionStart = start;
this._selectionEnd = end;
this._columnSelectMode = columnSelectMode;
}
public createRow(
lineData: IBufferLine,
row: number,
isCursorRow: boolean,
cursorStyle: string | undefined,
cursorInactiveStyle: string | undefined,
cursorX: number,
cursorBlink: boolean,
cellWidth: number,
widthCache: WidthCache,
linkStart: number,
linkEnd: number
): HTMLSpanElement[] {
const elements: HTMLSpanElement[] = [];
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
const colors = this._themeService.colors;
let lineLength = lineData.getNoBgTrimmedLength();
if (isCursorRow && lineLength < cursorX + 1) {
lineLength = cursorX + 1;
}
let charElement: HTMLSpanElement | undefined;
let cellAmount = 0;
let text = '';
let oldBg = 0;
let oldFg = 0;
let oldExt = 0;
let oldLinkHover: number | boolean = false;
let oldSpacing = 0;
let oldIsInSelection: boolean = false;
let spacing = 0;
const classes: string[] = [];
const hasHover = linkStart !== -1 && linkEnd !== -1;
for (let x = 0; x < lineLength; x++) {
lineData.loadCell(x, this._workCell);
let width = this._workCell.getWidth();
// The character to the left is a wide character, drawing is owned by the char at x-1
if (width === 0) {
continue;
}
// If true, indicates that the current character(s) to draw were joined.
let isJoined = false;
let lastCharX = x;
// Process any joined character ranges as needed. Because of how the
// ranges are produced, we know that they are valid for the characters
// and attributes of our input.
let cell = this._workCell;
if (joinedRanges.length > 0 && x === joinedRanges[0][0]) {
isJoined = true;
const range = joinedRanges.shift()!;
// We already know the exact start and end column of the joined range,
// so we get the string and width representing it directly
cell = new JoinedCellData(
this._workCell,
lineData.translateToString(true, range[0], range[1]),
range[1] - range[0]
);
// Skip over the cells occupied by this range in the loop
lastCharX = range[1] - 1;
// Recalculate width
width = cell.getWidth();
}
const isInSelection = this._isCellInSelection(x, row);
const isCursorCell = isCursorRow && x === cursorX;
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
let isDecorated = false;
this._decorationService.forEachDecorationAtCell(x, row, undefined, d => {
isDecorated = true;
});
// get chars to render for this cell
let chars = cell.getChars() || WHITESPACE_CELL_CHAR;
if (chars === ' ' && (cell.isUnderline() || cell.isOverline())) {
chars = '\xa0';
}
// lookup char render width and calc spacing
spacing = width * cellWidth - widthCache.get(chars, cell.isBold(), cell.isItalic());
if (!charElement) {
charElement = this._document.createElement('span');
} else {
/**
* chars can only be merged on existing span if:
* - existing span only contains mergeable chars (cellAmount != 0)
* - bg did not change (or both are in selection)
* - fg did not change (or both are in selection and selection fg is set)
* - ext did not change
* - underline from hover state did not change
* - cell content renders to same letter-spacing
* - cell is not cursor
*/
if (
cellAmount
&& (
(isInSelection && oldIsInSelection)
|| (!isInSelection && !oldIsInSelection && cell.bg === oldBg)
)
&& (
(isInSelection && oldIsInSelection && colors.selectionForeground)
|| cell.fg === oldFg
)
&& cell.extended.ext === oldExt
&& isLinkHover === oldLinkHover
&& spacing === oldSpacing
&& !isCursorCell
&& !isJoined
&& !isDecorated
) {
// no span alterations, thus only account chars skipping all code below
text += chars;
cellAmount++;
continue;
} else {
/**
* cannot merge:
* - apply left-over text to old span
* - create new span, reset state holders cellAmount & text
*/
if (cellAmount) {
charElement.textContent = text;
}
charElement = this._document.createElement('span');
cellAmount = 0;
text = '';
}
}
// preserve conditions for next merger eval round
oldBg = cell.bg;
oldFg = cell.fg;
oldExt = cell.extended.ext;
oldLinkHover = isLinkHover;
oldSpacing = spacing;
oldIsInSelection = isInSelection;
if (isJoined) {
// The DOM renderer colors the background of the cursor but for ligatures all cells are
// joined. The workaround here is to show a cursor around the whole ligature so it shows up,
// the cursor looks the same when on any character of the ligature though
if (cursorX >= x && cursorX <= lastCharX) {
cursorX = x;
}
}
if (!this._coreService.isCursorHidden && isCursorCell) {
classes.push(RowCss.CURSOR_CLASS);
if (this._coreBrowserService.isFocused) {
if (cursorBlink) {
classes.push(RowCss.CURSOR_BLINK_CLASS);
}
classes.push(
cursorStyle === 'bar'
? RowCss.CURSOR_STYLE_BAR_CLASS
: cursorStyle === 'underline'
? RowCss.CURSOR_STYLE_UNDERLINE_CLASS
: RowCss.CURSOR_STYLE_BLOCK_CLASS
);
} else {
if (cursorInactiveStyle) {
switch (cursorInactiveStyle) {
case 'outline':
classes.push(RowCss.CURSOR_STYLE_OUTLINE_CLASS);
break;
case 'block':
classes.push(RowCss.CURSOR_STYLE_BLOCK_CLASS);
break;
case 'bar':
classes.push(RowCss.CURSOR_STYLE_BAR_CLASS);
break;
case 'underline':
classes.push(RowCss.CURSOR_STYLE_UNDERLINE_CLASS);
break;
default:
break;
}
}
}
}
if (cell.isBold()) {
classes.push(RowCss.BOLD_CLASS);
}
if (cell.isItalic()) {
classes.push(RowCss.ITALIC_CLASS);
}
if (cell.isDim()) {
classes.push(RowCss.DIM_CLASS);
}
if (cell.isInvisible()) {
text = WHITESPACE_CELL_CHAR;
} else {
text = cell.getChars() || WHITESPACE_CELL_CHAR;
}
if (cell.isUnderline()) {
classes.push(`${RowCss.UNDERLINE_CLASS}-${cell.extended.underlineStyle}`);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
if (!cell.isUnderlineColorDefault()) {
if (cell.isUnderlineColorRGB()) {
charElement.style.textDecorationColor = `rgb(${AttributeData.toColorRGB(cell.getUnderlineColor()).join(',')})`;
} else {
let fg = cell.getUnderlineColor();
if (this._optionsService.rawOptions.drawBoldTextInBrightColors && cell.isBold() && fg < 8) {
fg += 8;
}
charElement.style.textDecorationColor = colors.ansi[fg].css;
}
}
}
if (cell.isOverline()) {
classes.push(RowCss.OVERLINE_CLASS);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
}
if (cell.isStrikethrough()) {
classes.push(RowCss.STRIKETHROUGH_CLASS);
}
// apply link hover underline late, effectively overrides any previous text-decoration
// settings
if (isLinkHover) {
charElement.style.textDecoration = 'underline';
}
let fg = cell.getFgColor();
let fgColorMode = cell.getFgColorMode();
let bg = cell.getBgColor();
let bgColorMode = cell.getBgColorMode();
const isInverse = !!cell.isInverse();
if (isInverse) {
const temp = fg;
fg = bg;
bg = temp;
const temp2 = fgColorMode;
fgColorMode = bgColorMode;
bgColorMode = temp2;
}
// Apply any decoration foreground/background overrides, this must happen after inverse has
// been applied
let bgOverride: IColor | undefined;
let fgOverride: IColor | undefined;
let isTop = false;
this._decorationService.forEachDecorationAtCell(x, row, undefined, d => {
if (d.options.layer !== 'top' && isTop) {
return;
}
if (d.backgroundColorRGB) {
bgColorMode = Attributes.CM_RGB;
bg = d.backgroundColorRGB.rgba >> 8 & 0xFFFFFF;
bgOverride = d.backgroundColorRGB;
}
if (d.foregroundColorRGB) {
fgColorMode = Attributes.CM_RGB;
fg = d.foregroundColorRGB.rgba >> 8 & 0xFFFFFF;
fgOverride = d.foregroundColorRGB;
}
isTop = d.options.layer === 'top';
});
// Apply selection
if (!isTop && isInSelection) {
// If in the selection, force the element to be above the selection to improve contrast and
// support opaque selections. The applies background is not actually needed here as
// selection is drawn in a seperate container, the main purpose of this to ensuring minimum
// contrast ratio
bgOverride = this._coreBrowserService.isFocused ? colors.selectionBackgroundOpaque : colors.selectionInactiveBackgroundOpaque;
bg = bgOverride.rgba >> 8 & 0xFFFFFF;
bgColorMode = Attributes.CM_RGB;
// Since an opaque selection is being rendered, the selection pretends to be a decoration to
// ensure text is drawn above the selection.
isTop = true;
// Apply selection foreground if applicable
if (colors.selectionForeground) {
fgColorMode = Attributes.CM_RGB;
fg = colors.selectionForeground.rgba >> 8 & 0xFFFFFF;
fgOverride = colors.selectionForeground;
}
}
// If it's a top decoration, render above the selection
if (isTop) {
classes.push('xterm-decoration-top');
}
// Background
let resolvedBg: IColor;
switch (bgColorMode) {
case Attributes.CM_P16:
case Attributes.CM_P256:
resolvedBg = colors.ansi[bg];
classes.push(`xterm-bg-${bg}`);
break;
case Attributes.CM_RGB:
resolvedBg = rgba.toColor(bg >> 16, bg >> 8 & 0xFF, bg & 0xFF);
this._addStyle(charElement, `background-color:#${padStart((bg >>> 0).toString(16), '0', 6)}`);
break;
case Attributes.CM_DEFAULT:
default:
if (isInverse) {
resolvedBg = colors.foreground;
classes.push(`xterm-bg-${INVERTED_DEFAULT_COLOR}`);
} else {
resolvedBg = colors.background;
}
}
// If there is no background override by now it's the original color, so apply dim if needed
if (!bgOverride) {
if (cell.isDim()) {
bgOverride = color.multiplyOpacity(resolvedBg, 0.5);
}
}
// Foreground
switch (fgColorMode) {
case Attributes.CM_P16:
case Attributes.CM_P256:
if (cell.isBold() && fg < 8 && this._optionsService.rawOptions.drawBoldTextInBrightColors) {
fg += 8;
}
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.ansi[fg], cell, bgOverride, undefined)) {
classes.push(`xterm-fg-${fg}`);
}
break;
case Attributes.CM_RGB:
const color = rgba.toColor(
(fg >> 16) & 0xFF,
(fg >> 8) & 0xFF,
(fg ) & 0xFF
);
if (!this._applyMinimumContrast(charElement, resolvedBg, color, cell, bgOverride, fgOverride)) {
this._addStyle(charElement, `color:#${padStart(fg.toString(16), '0', 6)}`);
}
break;
case Attributes.CM_DEFAULT:
default:
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.foreground, cell, bgOverride, undefined)) {
if (isInverse) {
classes.push(`xterm-fg-${INVERTED_DEFAULT_COLOR}`);
}
}
}
// apply CSS classes
// slightly faster than using classList by omitting
// checks for doubled entries (code above should not have doublets)
if (classes.length) {
charElement.className = classes.join(' ');
classes.length = 0;
}
// exclude conditions for cell merging - never merge these
if (!isCursorCell && !isJoined && !isDecorated) {
cellAmount++;
} else {
charElement.textContent = text;
}
// apply letter-spacing rule
if (spacing !== this.defaultSpacing) {
charElement.style.letterSpacing = `${spacing}px`;
}
elements.push(charElement);
x = lastCharX;
}
// postfix text of last merged span
if (charElement && cellAmount) {
charElement.textContent = text;
}
return elements;
}
private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor, cell: ICellData, bgOverride: IColor | undefined, fgOverride: IColor | undefined): boolean {
if (this._optionsService.rawOptions.minimumContrastRatio === 1 || excludeFromContrastRatioDemands(cell.getCode())) {
return false;
}
// Try get from cache first, only use the cache when there are no decoration overrides
const cache = this._getContrastCache(cell);
let adjustedColor: IColor | undefined | null = undefined;
if (!bgOverride && !fgOverride) {
adjustedColor = cache.getColor(bg.rgba, fg.rgba);
}
// Calculate and store in cache
if (adjustedColor === undefined) {
// Dim cells only require half the contrast, otherwise they wouldn't be distinguishable from
// non-dim cells
const ratio = this._optionsService.rawOptions.minimumContrastRatio / (cell.isDim() ? 2 : 1);
adjustedColor = color.ensureContrastRatio(bgOverride || bg, fgOverride || fg, ratio);
cache.setColor((bgOverride || bg).rgba, (fgOverride || fg).rgba, adjustedColor ?? null);
}
if (adjustedColor) {
this._addStyle(element, `color:${adjustedColor.css}`);
return true;
}
return false;
}
private _getContrastCache(cell: ICellData): IColorContrastCache {
if (cell.isDim()) {
return this._themeService.colors.halfContrastCache;
}
return this._themeService.colors.contrastCache;
}
private _addStyle(element: HTMLElement, style: string): void {
element.setAttribute('style', `${element.getAttribute('style') || ''}${style};`);
}
private _isCellInSelection(x: number, y: number): boolean {
const start = this._selectionStart;
const end = this._selectionEnd;
if (!start || !end) {
return false;
}
if (this._columnSelectMode) {
if (start[0] <= end[0]) {
return x >= start[0] && y >= start[1] &&
x < end[0] && y <= end[1];
}
return x < start[0] && y >= start[1] &&
x >= end[0] && y <= end[1];
}
return (y > start[1] && y < end[1]) ||
(start[1] === end[1] && y === start[1] && x >= start[0] && x < end[0]) ||
(start[1] < end[1] && y === end[1] && x < end[0]) ||
(start[1] < end[1] && y === start[1] && x >= start[0]);
}
}
function padStart(text: string, padChar: string, length: number): string {
while (text.length < length) {
text = padChar + text;
}
return text;
}

View File

@@ -0,0 +1,157 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
import { FontWeight } from 'common/services/Services';
export const enum WidthCacheSettings {
/** sentinel for unset values in flat cache */
FLAT_UNSET = -9999,
/** size of flat cache, size-1 equals highest codepoint handled by flat */
FLAT_SIZE = 256,
/** char repeat for measuring */
REPEAT = 32
}
const enum FontVariant {
REGULAR = 0,
BOLD = 1,
ITALIC = 2,
BOLD_ITALIC = 3
}
export class WidthCache implements IDisposable {
// flat cache for regular variant up to CacheSettings.FLAT_SIZE
// NOTE: ~4x faster access than holey (serving >>80% of terminal content)
// It has a small memory footprint (only 1MB for full BMP caching),
// still the sweet spot is not reached before touching 32k different codepoints,
// thus we store the remaining <<20% of terminal data in a holey structure.
protected _flat = new Float32Array(WidthCacheSettings.FLAT_SIZE);
// holey cache for bold, italic and bold&italic for any string
// FIXME: can grow really big over time (~8.5 MB for full BMP caching),
// so a shared API across terminals is needed
protected _holey: Map<string, number> | undefined;
private _font = '';
private _fontSize = 0;
private _weight: FontWeight = 'normal';
private _weightBold: FontWeight = 'bold';
private _container: HTMLDivElement;
private _measureElements: HTMLSpanElement[] = [];
constructor(_document: Document) {
this._container = _document.createElement('div');
this._container.style.position = 'absolute';
this._container.style.top = '-50000px';
this._container.style.width = '50000px';
// SP should stack in spans
this._container.style.whiteSpace = 'pre';
// avoid undercuts in non-monospace fonts from kerning
this._container.style.fontKerning = 'none';
const regular = _document.createElement('span');
const bold = _document.createElement('span');
bold.style.fontWeight = 'bold';
const italic = _document.createElement('span');
italic.style.fontStyle = 'italic';
const boldItalic = _document.createElement('span');
boldItalic.style.fontWeight = 'bold';
boldItalic.style.fontStyle = 'italic';
// NOTE: must be in order of FontVariant
this._measureElements = [regular, bold, italic, boldItalic];
this._container.appendChild(regular);
this._container.appendChild(bold);
this._container.appendChild(italic);
this._container.appendChild(boldItalic);
_document.body.appendChild(this._container);
this.clear();
}
public dispose(): void {
this._container.remove(); // remove elements from DOM
this._measureElements.length = 0; // release element refs
this._holey = undefined; // free cache memory via GC
}
/**
* Clear the width cache.
*/
public clear(): void {
this._flat.fill(WidthCacheSettings.FLAT_UNSET);
// .clear() has some overhead, re-assign instead (>3 times faster)
this._holey = new Map<string, number>();
}
/**
* Set the font for measuring.
* Must be called for any changes on font settings.
* Also clears the cache.
*/
public setFont(font: string, fontSize: number, weight: FontWeight, weightBold: FontWeight): void {
// skip if nothing changed
if (font === this._font
&& fontSize === this._fontSize
&& weight === this._weight
&& weightBold === this._weightBold
) {
return;
}
this._font = font;
this._fontSize = fontSize;
this._weight = weight;
this._weightBold = weightBold;
this._container.style.fontFamily = this._font;
this._container.style.fontSize = `${this._fontSize}px`;
this._measureElements[FontVariant.REGULAR].style.fontWeight = `${weight}`;
this._measureElements[FontVariant.BOLD].style.fontWeight = `${weightBold}`;
this._measureElements[FontVariant.ITALIC].style.fontWeight = `${weight}`;
this._measureElements[FontVariant.BOLD_ITALIC].style.fontWeight = `${weightBold}`;
this.clear();
}
/**
* Get the render width for cell content `c` with current font settings.
* `variant` denotes the font variant to be used.
*/
public get(c: string, bold: boolean | number, italic: boolean | number): number {
let cp = 0;
if (!bold && !italic && c.length === 1 && (cp = c.charCodeAt(0)) < WidthCacheSettings.FLAT_SIZE) {
return this._flat[cp] !== WidthCacheSettings.FLAT_UNSET
? this._flat[cp]
: (this._flat[cp] = this._measure(c, 0));
}
let key = c;
if (bold) key += 'B';
if (italic) key += 'I';
let width = this._holey!.get(key);
if (width === undefined) {
let variant = 0;
if (bold) variant |= FontVariant.BOLD;
if (italic) variant |= FontVariant.ITALIC;
width = this._measure(c, variant);
this._holey!.set(key, width);
}
return width;
}
protected _measure(c: string, variant: FontVariant): number {
const el = this._measureElements[variant];
el.textContent = c.repeat(WidthCacheSettings.REPEAT);
return el.offsetWidth / WidthCacheSettings.REPEAT;
}
}

View File

@@ -0,0 +1,137 @@
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
import { ICoreBrowserService, IThemeService } from 'browser/services/Services';
import { ReadonlyColorSet } from 'browser/Types';
import { Attributes, BgFlags, FgFlags } from 'common/buffer/Constants';
import { IDecorationService } from 'common/services/Services';
import { ICellData } from 'common/Types';
import { Terminal } from 'xterm';
// Work variables to avoid garbage collection
let $fg = 0;
let $bg = 0;
let $hasFg = false;
let $hasBg = false;
let $isSelected = false;
let $colors: ReadonlyColorSet | undefined;
export class CellColorResolver {
/**
* The shared result of the {@link resolve} call. This is only safe to use immediately after as
* any other calls will share object.
*/
public readonly result: { fg: number, bg: number, ext: number } = {
fg: 0,
bg: 0,
ext: 0
};
constructor(
private readonly _terminal: Terminal,
private readonly _selectionRenderModel: ISelectionRenderModel,
private readonly _decorationService: IDecorationService,
private readonly _coreBrowserService: ICoreBrowserService,
private readonly _themeService: IThemeService
) {
}
/**
* Resolves colors for the cell, putting the result into the shared {@link result}. This resolves
* overrides, inverse and selection for the cell which can then be used to feed into the renderer.
*/
public resolve(cell: ICellData, x: number, y: number): void {
this.result.bg = cell.bg;
this.result.fg = cell.fg;
this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0;
// Get any foreground/background overrides, this happens on the model to avoid spreading
// override logic throughout the different sub-renderers
// Reset overrides work variables
$bg = 0;
$fg = 0;
$hasBg = false;
$hasFg = false;
$isSelected = false;
$colors = this._themeService.colors;
// Apply decorations on the bottom layer
this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => {
if (d.backgroundColorRGB) {
$bg = d.backgroundColorRGB.rgba >> 8 & 0xFFFFFF;
$hasBg = true;
}
if (d.foregroundColorRGB) {
$fg = d.foregroundColorRGB.rgba >> 8 & 0xFFFFFF;
$hasFg = true;
}
});
// Apply the selection color if needed
$isSelected = this._selectionRenderModel.isCellSelected(this._terminal, x, y);
if ($isSelected) {
$bg = (this._coreBrowserService.isFocused ? $colors.selectionBackgroundOpaque : $colors.selectionInactiveBackgroundOpaque).rgba >> 8 & 0xFFFFFF;
$hasBg = true;
if ($colors.selectionForeground) {
$fg = $colors.selectionForeground.rgba >> 8 & 0xFFFFFF;
$hasFg = true;
}
}
// Apply decorations on the top layer
this._decorationService.forEachDecorationAtCell(x, y, 'top', d => {
if (d.backgroundColorRGB) {
$bg = d.backgroundColorRGB.rgba >> 8 & 0xFFFFFF;
$hasBg = true;
}
if (d.foregroundColorRGB) {
$fg = d.foregroundColorRGB.rgba >> 8 & 0xFFFFFF;
$hasFg = true;
}
});
// Convert any overrides from rgba to the fg/bg packed format. This resolves the inverse flag
// ahead of time in order to use the correct cache key
if ($hasBg) {
if ($isSelected) {
// Non-RGB attributes from model + force non-dim + override + force RGB color mode
$bg = (cell.bg & ~Attributes.RGB_MASK & ~BgFlags.DIM) | $bg | Attributes.CM_RGB;
} else {
// Non-RGB attributes from model + override + force RGB color mode
$bg = (cell.bg & ~Attributes.RGB_MASK) | $bg | Attributes.CM_RGB;
}
}
if ($hasFg) {
// Non-RGB attributes from model + force disable inverse + override + force RGB color mode
$fg = (cell.fg & ~Attributes.RGB_MASK & ~FgFlags.INVERSE) | $fg | Attributes.CM_RGB;
}
// Handle case where inverse was specified by only one of bg override or fg override was set,
// resolving the other inverse color and setting the inverse flag if needed.
if (this.result.fg & FgFlags.INVERSE) {
if ($hasBg && !$hasFg) {
// Resolve bg color type (default color has a different meaning in fg vs bg)
if ((this.result.bg & Attributes.CM_MASK) === Attributes.CM_DEFAULT) {
$fg = (this.result.fg & ~(Attributes.RGB_MASK | FgFlags.INVERSE | Attributes.CM_MASK)) | (($colors.background.rgba >> 8 & 0xFFFFFF) & Attributes.RGB_MASK) | Attributes.CM_RGB;
} else {
$fg = (this.result.fg & ~(Attributes.RGB_MASK | FgFlags.INVERSE | Attributes.CM_MASK)) | this.result.bg & (Attributes.RGB_MASK | Attributes.CM_MASK);
}
$hasFg = true;
}
if (!$hasBg && $hasFg) {
// Resolve bg color type (default color has a different meaning in fg vs bg)
if ((this.result.fg & Attributes.CM_MASK) === Attributes.CM_DEFAULT) {
$bg = (this.result.bg & ~(Attributes.RGB_MASK | Attributes.CM_MASK)) | (($colors.foreground.rgba >> 8 & 0xFFFFFF) & Attributes.RGB_MASK) | Attributes.CM_RGB;
} else {
$bg = (this.result.bg & ~(Attributes.RGB_MASK | Attributes.CM_MASK)) | this.result.fg & (Attributes.RGB_MASK | Attributes.CM_MASK);
}
$hasBg = true;
}
}
// Release object
$colors = undefined;
// Use the override if it exists
this.result.bg = $hasBg ? $bg : this.result.bg;
this.result.fg = $hasFg ? $fg : this.result.fg;
}
}

View File

@@ -0,0 +1,96 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { TextureAtlas } from 'browser/renderer/shared/TextureAtlas';
import { ITerminalOptions, Terminal } from 'xterm';
import { ITerminal, ReadonlyColorSet } from 'browser/Types';
import { ICharAtlasConfig, ITextureAtlas } from 'browser/renderer/shared/Types';
import { generateConfig, configEquals } from 'browser/renderer/shared/CharAtlasUtils';
interface ITextureAtlasCacheEntry {
atlas: ITextureAtlas;
config: ICharAtlasConfig;
// N.B. This implementation potentially holds onto copies of the terminal forever, so
// this may cause memory leaks.
ownedBy: Terminal[];
}
const charAtlasCache: ITextureAtlasCacheEntry[] = [];
/**
* Acquires a char atlas, either generating a new one or returning an existing
* one that is in use by another terminal.
*/
export function acquireTextureAtlas(
terminal: Terminal,
options: Required<ITerminalOptions>,
colors: ReadonlyColorSet,
deviceCellWidth: number,
deviceCellHeight: number,
deviceCharWidth: number,
deviceCharHeight: number,
devicePixelRatio: number
): ITextureAtlas {
const newConfig = generateConfig(deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, options, colors, devicePixelRatio);
// Check to see if the terminal already owns this config
for (let i = 0; i < charAtlasCache.length; i++) {
const entry = charAtlasCache[i];
const ownedByIndex = entry.ownedBy.indexOf(terminal);
if (ownedByIndex >= 0) {
if (configEquals(entry.config, newConfig)) {
return entry.atlas;
}
// The configs differ, release the terminal from the entry
if (entry.ownedBy.length === 1) {
entry.atlas.dispose();
charAtlasCache.splice(i, 1);
} else {
entry.ownedBy.splice(ownedByIndex, 1);
}
break;
}
}
// Try match a char atlas from the cache
for (let i = 0; i < charAtlasCache.length; i++) {
const entry = charAtlasCache[i];
if (configEquals(entry.config, newConfig)) {
// Add the terminal to the cache entry and return
entry.ownedBy.push(terminal);
return entry.atlas;
}
}
const core: ITerminal = (terminal as any)._core;
const newEntry: ITextureAtlasCacheEntry = {
atlas: new TextureAtlas(document, newConfig, core.unicodeService),
config: newConfig,
ownedBy: [terminal]
};
charAtlasCache.push(newEntry);
return newEntry.atlas;
}
/**
* Removes a terminal reference from the cache, allowing its memory to be freed.
* @param terminal The terminal to remove.
*/
export function removeTerminalFromCache(terminal: Terminal): void {
for (let i = 0; i < charAtlasCache.length; i++) {
const index = charAtlasCache[i].ownedBy.indexOf(terminal);
if (index !== -1) {
if (charAtlasCache[i].ownedBy.length === 1) {
// Remove the cache entry if it's the only terminal
charAtlasCache[i].atlas.dispose();
charAtlasCache.splice(i, 1);
} else {
// Remove the reference from the cache entry
charAtlasCache[i].ownedBy.splice(index, 1);
}
break;
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICharAtlasConfig } from './Types';
import { Attributes } from 'common/buffer/Constants';
import { ITerminalOptions } from 'xterm';
import { IColorSet, ReadonlyColorSet } from 'browser/Types';
import { NULL_COLOR } from 'common/Color';
export function generateConfig(deviceCellWidth: number, deviceCellHeight: number, deviceCharWidth: number, deviceCharHeight: number, options: Required<ITerminalOptions>, colors: ReadonlyColorSet, devicePixelRatio: number): ICharAtlasConfig {
// null out some fields that don't matter
const clonedColors: IColorSet = {
foreground: colors.foreground,
background: colors.background,
cursor: NULL_COLOR,
cursorAccent: NULL_COLOR,
selectionForeground: NULL_COLOR,
selectionBackgroundTransparent: NULL_COLOR,
selectionBackgroundOpaque: NULL_COLOR,
selectionInactiveBackgroundTransparent: NULL_COLOR,
selectionInactiveBackgroundOpaque: NULL_COLOR,
// For the static char atlas, we only use the first 16 colors, but we need all 256 for the
// dynamic character atlas.
ansi: colors.ansi.slice(),
contrastCache: colors.contrastCache,
halfContrastCache: colors.halfContrastCache
};
return {
customGlyphs: options.customGlyphs,
devicePixelRatio,
letterSpacing: options.letterSpacing,
lineHeight: options.lineHeight,
deviceCellWidth: deviceCellWidth,
deviceCellHeight: deviceCellHeight,
deviceCharWidth: deviceCharWidth,
deviceCharHeight: deviceCharHeight,
fontFamily: options.fontFamily,
fontSize: options.fontSize,
fontWeight: options.fontWeight,
fontWeightBold: options.fontWeightBold,
allowTransparency: options.allowTransparency,
drawBoldTextInBrightColors: options.drawBoldTextInBrightColors,
minimumContrastRatio: options.minimumContrastRatio,
colors: clonedColors
};
}
export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean {
for (let i = 0; i < a.colors.ansi.length; i++) {
if (a.colors.ansi[i].rgba !== b.colors.ansi[i].rgba) {
return false;
}
}
return a.devicePixelRatio === b.devicePixelRatio &&
a.customGlyphs === b.customGlyphs &&
a.lineHeight === b.lineHeight &&
a.letterSpacing === b.letterSpacing &&
a.fontFamily === b.fontFamily &&
a.fontSize === b.fontSize &&
a.fontWeight === b.fontWeight &&
a.fontWeightBold === b.fontWeightBold &&
a.allowTransparency === b.allowTransparency &&
a.deviceCharWidth === b.deviceCharWidth &&
a.deviceCharHeight === b.deviceCharHeight &&
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
a.minimumContrastRatio === b.minimumContrastRatio &&
a.colors.foreground.rgba === b.colors.foreground.rgba &&
a.colors.background.rgba === b.colors.background.rgba;
}
export function is256Color(colorCode: number): boolean {
return (colorCode & Attributes.CM_MASK) === Attributes.CM_P16 || (colorCode & Attributes.CM_MASK) === Attributes.CM_P256;
}

View File

@@ -0,0 +1,14 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { isFirefox, isLegacyEdge } from 'common/Platform';
export const INVERTED_DEFAULT_COLOR = 257;
export const DIM_OPACITY = 0.5;
// The text baseline is set conditionally by browser. Using 'ideographic' for Firefox or Legacy Edge
// would result in truncated text (Issue 3353). Using 'bottom' for Chrome would result in slightly
// unaligned Powerline fonts (PR 3356#issuecomment-850928179).
export const TEXT_BASELINE: CanvasTextBaseline = isFirefox || isLegacyEdge ? 'bottom' : 'ideographic';

View File

@@ -0,0 +1,146 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICoreBrowserService } from 'browser/services/Services';
/**
* The time between cursor blinks.
*/
const BLINK_INTERVAL = 600;
export class CursorBlinkStateManager {
public isCursorVisible: boolean;
private _animationFrame: number | undefined;
private _blinkStartTimeout: number | undefined;
private _blinkInterval: number | undefined;
/**
* The time at which the animation frame was restarted, this is used on the
* next render to restart the timers so they don't need to restart the timers
* multiple times over a short period.
*/
private _animationTimeRestarted: number | undefined;
constructor(
private _renderCallback: () => void,
private _coreBrowserService: ICoreBrowserService
) {
this.isCursorVisible = true;
if (this._coreBrowserService.isFocused) {
this._restartInterval();
}
}
public get isPaused(): boolean { return !(this._blinkStartTimeout || this._blinkInterval); }
public dispose(): void {
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
this._blinkInterval = undefined;
}
if (this._blinkStartTimeout) {
this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout);
this._blinkStartTimeout = undefined;
}
if (this._animationFrame) {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
}
public restartBlinkAnimation(): void {
if (this.isPaused) {
return;
}
// Save a timestamp so that the restart can be done on the next interval
this._animationTimeRestarted = Date.now();
// Force a cursor render to ensure it's visible and in the correct position
this.isCursorVisible = true;
if (!this._animationFrame) {
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
this._renderCallback();
this._animationFrame = undefined;
});
}
}
private _restartInterval(timeToStart: number = BLINK_INTERVAL): void {
// Clear any existing interval
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
this._blinkInterval = undefined;
}
// Setup the initial timeout which will hide the cursor, this is done before
// the regular interval is setup in order to support restarting the blink
// animation in a lightweight way (without thrashing clearInterval and
// setInterval).
this._blinkStartTimeout = this._coreBrowserService.window.setTimeout(() => {
// Check if another animation restart was requested while this was being
// started
if (this._animationTimeRestarted) {
const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
this._animationTimeRestarted = undefined;
if (time > 0) {
this._restartInterval(time);
return;
}
}
// Hide the cursor
this.isCursorVisible = false;
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
this._renderCallback();
this._animationFrame = undefined;
});
// Setup the blink interval
this._blinkInterval = this._coreBrowserService.window.setInterval(() => {
// Adjust the animation time if it was restarted
if (this._animationTimeRestarted) {
// calc time diff
// Make restart interval do a setTimeout initially?
const time = BLINK_INTERVAL - (Date.now() - this._animationTimeRestarted);
this._animationTimeRestarted = undefined;
this._restartInterval(time);
return;
}
// Invert visibility and render
this.isCursorVisible = !this.isCursorVisible;
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
this._renderCallback();
this._animationFrame = undefined;
});
}, BLINK_INTERVAL);
}, timeToStart);
}
public pause(): void {
this.isCursorVisible = true;
if (this._blinkInterval) {
this._coreBrowserService.window.clearInterval(this._blinkInterval);
this._blinkInterval = undefined;
}
if (this._blinkStartTimeout) {
this._coreBrowserService.window.clearTimeout(this._blinkStartTimeout);
this._blinkStartTimeout = undefined;
}
if (this._animationFrame) {
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
this._animationFrame = undefined;
}
}
public resume(): void {
// Clear out any existing timers just in case
this.pause();
this._animationTimeRestarted = undefined;
this._restartInterval();
this.restartBlinkAnimation();
}
}

View File

@@ -0,0 +1,687 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
interface IBlockVector {
x: number;
y: number;
w: number;
h: number;
}
export const blockElementDefinitions: { [index: string]: IBlockVector[] | undefined } = {
// Block elements (0x2580-0x2590)
'▀': [{ x: 0, y: 0, w: 8, h: 4 }], // UPPER HALF BLOCK
'▁': [{ x: 0, y: 7, w: 8, h: 1 }], // LOWER ONE EIGHTH BLOCK
'▂': [{ x: 0, y: 6, w: 8, h: 2 }], // LOWER ONE QUARTER BLOCK
'▃': [{ x: 0, y: 5, w: 8, h: 3 }], // LOWER THREE EIGHTHS BLOCK
'▄': [{ x: 0, y: 4, w: 8, h: 4 }], // LOWER HALF BLOCK
'▅': [{ x: 0, y: 3, w: 8, h: 5 }], // LOWER FIVE EIGHTHS BLOCK
'▆': [{ x: 0, y: 2, w: 8, h: 6 }], // LOWER THREE QUARTERS BLOCK
'▇': [{ x: 0, y: 1, w: 8, h: 7 }], // LOWER SEVEN EIGHTHS BLOCK
'█': [{ x: 0, y: 0, w: 8, h: 8 }], // FULL BLOCK
'▉': [{ x: 0, y: 0, w: 7, h: 8 }], // LEFT SEVEN EIGHTHS BLOCK
'▊': [{ x: 0, y: 0, w: 6, h: 8 }], // LEFT THREE QUARTERS BLOCK
'▋': [{ x: 0, y: 0, w: 5, h: 8 }], // LEFT FIVE EIGHTHS BLOCK
'▌': [{ x: 0, y: 0, w: 4, h: 8 }], // LEFT HALF BLOCK
'▍': [{ x: 0, y: 0, w: 3, h: 8 }], // LEFT THREE EIGHTHS BLOCK
'▎': [{ x: 0, y: 0, w: 2, h: 8 }], // LEFT ONE QUARTER BLOCK
'▏': [{ x: 0, y: 0, w: 1, h: 8 }], // LEFT ONE EIGHTH BLOCK
'▐': [{ x: 4, y: 0, w: 4, h: 8 }], // RIGHT HALF BLOCK
// Block elements (0x2594-0x2595)
'▔': [{ x: 0, y: 0, w: 8, h: 1 }], // UPPER ONE EIGHTH BLOCK
'▕': [{ x: 7, y: 0, w: 1, h: 8 }], // RIGHT ONE EIGHTH BLOCK
// Terminal graphic characters (0x2596-0x259F)
'▖': [{ x: 0, y: 4, w: 4, h: 4 }], // QUADRANT LOWER LEFT
'▗': [{ x: 4, y: 4, w: 4, h: 4 }], // QUADRANT LOWER RIGHT
'▘': [{ x: 0, y: 0, w: 4, h: 4 }], // QUADRANT UPPER LEFT
'▙': [{ x: 0, y: 0, w: 4, h: 8 }, { x: 0, y: 4, w: 8, h: 4 }], // QUADRANT UPPER LEFT AND LOWER LEFT AND LOWER RIGHT
'▚': [{ x: 0, y: 0, w: 4, h: 4 }, { x: 4, y: 4, w: 4, h: 4 }], // QUADRANT UPPER LEFT AND LOWER RIGHT
'▛': [{ x: 0, y: 0, w: 4, h: 8 }, { x: 4, y: 0, w: 4, h: 4 }], // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER LEFT
'▜': [{ x: 0, y: 0, w: 8, h: 4 }, { x: 4, y: 0, w: 4, h: 8 }], // QUADRANT UPPER LEFT AND UPPER RIGHT AND LOWER RIGHT
'▝': [{ x: 4, y: 0, w: 4, h: 4 }], // QUADRANT UPPER RIGHT
'▞': [{ x: 4, y: 0, w: 4, h: 4 }, { x: 0, y: 4, w: 4, h: 4 }], // QUADRANT UPPER RIGHT AND LOWER LEFT
'▟': [{ x: 4, y: 0, w: 4, h: 8 }, { x: 0, y: 4, w: 8, h: 4 }], // QUADRANT UPPER RIGHT AND LOWER LEFT AND LOWER RIGHT
// VERTICAL ONE EIGHTH BLOCK-2 through VERTICAL ONE EIGHTH BLOCK-7
'\u{1FB70}': [{ x: 1, y: 0, w: 1, h: 8 }],
'\u{1FB71}': [{ x: 2, y: 0, w: 1, h: 8 }],
'\u{1FB72}': [{ x: 3, y: 0, w: 1, h: 8 }],
'\u{1FB73}': [{ x: 4, y: 0, w: 1, h: 8 }],
'\u{1FB74}': [{ x: 5, y: 0, w: 1, h: 8 }],
'\u{1FB75}': [{ x: 6, y: 0, w: 1, h: 8 }],
// HORIZONTAL ONE EIGHTH BLOCK-2 through HORIZONTAL ONE EIGHTH BLOCK-7
'\u{1FB76}': [{ x: 0, y: 1, w: 8, h: 1 }],
'\u{1FB77}': [{ x: 0, y: 2, w: 8, h: 1 }],
'\u{1FB78}': [{ x: 0, y: 3, w: 8, h: 1 }],
'\u{1FB79}': [{ x: 0, y: 4, w: 8, h: 1 }],
'\u{1FB7A}': [{ x: 0, y: 5, w: 8, h: 1 }],
'\u{1FB7B}': [{ x: 0, y: 6, w: 8, h: 1 }],
// LEFT AND LOWER ONE EIGHTH BLOCK
'\u{1FB7C}': [{ x: 0, y: 0, w: 1, h: 8 }, { x: 0, y: 7, w: 8, h: 1 }],
// LEFT AND UPPER ONE EIGHTH BLOCK
'\u{1FB7D}': [{ x: 0, y: 0, w: 1, h: 8 }, { x: 0, y: 0, w: 8, h: 1 }],
// RIGHT AND UPPER ONE EIGHTH BLOCK
'\u{1FB7E}': [{ x: 7, y: 0, w: 1, h: 8 }, { x: 0, y: 0, w: 8, h: 1 }],
// RIGHT AND LOWER ONE EIGHTH BLOCK
'\u{1FB7F}': [{ x: 7, y: 0, w: 1, h: 8 }, { x: 0, y: 7, w: 8, h: 1 }],
// UPPER AND LOWER ONE EIGHTH BLOCK
'\u{1FB80}': [{ x: 0, y: 0, w: 8, h: 1 }, { x: 0, y: 7, w: 8, h: 1 }],
// HORIZONTAL ONE EIGHTH BLOCK-1358
'\u{1FB81}': [{ x: 0, y: 0, w: 8, h: 1 }, { x: 0, y: 2, w: 8, h: 1 }, { x: 0, y: 4, w: 8, h: 1 }, { x: 0, y: 7, w: 8, h: 1 }],
// UPPER ONE QUARTER BLOCK
'\u{1FB82}': [{ x: 0, y: 0, w: 8, h: 2 }],
// UPPER THREE EIGHTHS BLOCK
'\u{1FB83}': [{ x: 0, y: 0, w: 8, h: 3 }],
// UPPER FIVE EIGHTHS BLOCK
'\u{1FB84}': [{ x: 0, y: 0, w: 8, h: 5 }],
// UPPER THREE QUARTERS BLOCK
'\u{1FB85}': [{ x: 0, y: 0, w: 8, h: 6 }],
// UPPER SEVEN EIGHTHS BLOCK
'\u{1FB86}': [{ x: 0, y: 0, w: 8, h: 7 }],
// RIGHT ONE QUARTER BLOCK
'\u{1FB87}': [{ x: 6, y: 0, w: 2, h: 8 }],
// RIGHT THREE EIGHTHS B0OCK
'\u{1FB88}': [{ x: 5, y: 0, w: 3, h: 8 }],
// RIGHT FIVE EIGHTHS BL0CK
'\u{1FB89}': [{ x: 3, y: 0, w: 5, h: 8 }],
// RIGHT THREE QUARTERS 0LOCK
'\u{1FB8A}': [{ x: 2, y: 0, w: 6, h: 8 }],
// RIGHT SEVEN EIGHTHS B0OCK
'\u{1FB8B}': [{ x: 1, y: 0, w: 7, h: 8 }],
// CHECKER BOARD FILL
'\u{1FB95}': [
{ x: 0, y: 0, w: 2, h: 2 }, { x: 4, y: 0, w: 2, h: 2 },
{ x: 2, y: 2, w: 2, h: 2 }, { x: 6, y: 2, w: 2, h: 2 },
{ x: 0, y: 4, w: 2, h: 2 }, { x: 4, y: 4, w: 2, h: 2 },
{ x: 2, y: 6, w: 2, h: 2 }, { x: 6, y: 6, w: 2, h: 2 }
],
// INVERSE CHECKER BOARD FILL
'\u{1FB96}': [
{ x: 2, y: 0, w: 2, h: 2 }, { x: 6, y: 0, w: 2, h: 2 },
{ x: 0, y: 2, w: 2, h: 2 }, { x: 4, y: 2, w: 2, h: 2 },
{ x: 2, y: 4, w: 2, h: 2 }, { x: 6, y: 4, w: 2, h: 2 },
{ x: 0, y: 6, w: 2, h: 2 }, { x: 4, y: 6, w: 2, h: 2 }
],
// HEAVY HORIZONTAL FILL (upper middle and lower one quarter block)
'\u{1FB97}': [{ x: 0, y: 2, w: 8, h: 2 }, { x: 0, y: 6, w: 8, h: 2 }]
};
type PatternDefinition = number[][];
/**
* Defines the repeating pattern used by special characters, the pattern is made up of a 2d array of
* pixel values to be filled (1) or not filled (0).
*/
const patternCharacterDefinitions: { [key: string]: PatternDefinition | undefined } = {
// Shade characters (0x2591-0x2593)
'░': [ // LIGHT SHADE (25%)
[1, 0, 0, 0],
[0, 0, 0, 0],
[0, 0, 1, 0],
[0, 0, 0, 0]
],
'▒': [ // MEDIUM SHADE (50%)
[1, 0],
[0, 0],
[0, 1],
[0, 0]
],
'▓': [ // DARK SHADE (75%)
[0, 1],
[1, 1],
[1, 0],
[1, 1]
]
};
const enum Shapes {
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
/** ─ */ LEFT_TO_RIGHT = 'M0,.5 L1,.5',
/** └ */ TOP_TO_RIGHT = 'M.5,0 L.5,.5 L1,.5',
/** ┘ */ TOP_TO_LEFT = 'M.5,0 L.5,.5 L0,.5',
/** ┐ */ LEFT_TO_BOTTOM = 'M0,.5 L.5,.5 L.5,1',
/** ┌ */ RIGHT_TO_BOTTOM = 'M0.5,1 L.5,.5 L1,.5',
/** ╵ */ MIDDLE_TO_TOP = 'M.5,.5 L.5,0',
/** ╴ */ MIDDLE_TO_LEFT = 'M.5,.5 L0,.5',
/** ╶ */ MIDDLE_TO_RIGHT = 'M.5,.5 L1,.5',
/** ╷ */ MIDDLE_TO_BOTTOM = 'M.5,.5 L.5,1',
/** ┴ */ T_TOP = 'M0,.5 L1,.5 M.5,.5 L.5,0',
/** ┤ */ T_LEFT = 'M.5,0 L.5,1 M.5,.5 L0,.5',
/** ├ */ T_RIGHT = 'M.5,0 L.5,1 M.5,.5 L1,.5',
/** ┬ */ T_BOTTOM = 'M0,.5 L1,.5 M.5,.5 L.5,1',
/** ┼ */ CROSS = 'M0,.5 L1,.5 M.5,0 L.5,1',
/** ╌ */ TWO_DASHES_HORIZONTAL = 'M.1,.5 L.4,.5 M.6,.5 L.9,.5', // .2 empty, .3 filled
/** ┄ */ THREE_DASHES_HORIZONTAL = 'M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5', // .1333 empty, .2 filled
/** ┉ */ FOUR_DASHES_HORIZONTAL = 'M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5', // .1 empty, .15 filled
/** ╎ */ TWO_DASHES_VERTICAL = 'M.5,.1 L.5,.4 M.5,.6 L.5,.9',
/** ┆ */ THREE_DASHES_VERTICAL = 'M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333',
/** ┊ */ FOUR_DASHES_VERTICAL = 'M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95',
}
const enum Style {
NORMAL = 1,
BOLD = 3
}
/**
* @param xp The percentage of 15% of the x axis.
* @param yp The percentage of 15% of the x axis on the y axis.
*/
type DrawFunctionDefinition = (xp: number, yp: number) => string;
/**
* This contains the definitions of all box drawing characters in the format of SVG paths (ie. the
* svg d attribute).
*/
export const boxDrawingDefinitions: { [character: string]: { [fontWeight: number]: string | DrawFunctionDefinition } | undefined } = {
// Uniform normal and bold
'─': { [Style.NORMAL]: Shapes.LEFT_TO_RIGHT },
'━': { [Style.BOLD]: Shapes.LEFT_TO_RIGHT },
'│': { [Style.NORMAL]: Shapes.TOP_TO_BOTTOM },
'┃': { [Style.BOLD]: Shapes.TOP_TO_BOTTOM },
'┌': { [Style.NORMAL]: Shapes.RIGHT_TO_BOTTOM },
'┏': { [Style.BOLD]: Shapes.RIGHT_TO_BOTTOM },
'┐': { [Style.NORMAL]: Shapes.LEFT_TO_BOTTOM },
'┓': { [Style.BOLD]: Shapes.LEFT_TO_BOTTOM },
'└': { [Style.NORMAL]: Shapes.TOP_TO_RIGHT },
'┗': { [Style.BOLD]: Shapes.TOP_TO_RIGHT },
'┘': { [Style.NORMAL]: Shapes.TOP_TO_LEFT },
'┛': { [Style.BOLD]: Shapes.TOP_TO_LEFT },
'├': { [Style.NORMAL]: Shapes.T_RIGHT },
'┣': { [Style.BOLD]: Shapes.T_RIGHT },
'┤': { [Style.NORMAL]: Shapes.T_LEFT },
'┫': { [Style.BOLD]: Shapes.T_LEFT },
'┬': { [Style.NORMAL]: Shapes.T_BOTTOM },
'┳': { [Style.BOLD]: Shapes.T_BOTTOM },
'┴': { [Style.NORMAL]: Shapes.T_TOP },
'┻': { [Style.BOLD]: Shapes.T_TOP },
'┼': { [Style.NORMAL]: Shapes.CROSS },
'╋': { [Style.BOLD]: Shapes.CROSS },
'╴': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT },
'╸': { [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'╵': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP },
'╹': { [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'╶': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT },
'╺': { [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'╷': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM },
'╻': { [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
// Double border
'═': { [Style.NORMAL]: (xp, yp) => `M0,${.5 - yp} L1,${.5 - yp} M0,${.5 + yp} L1,${.5 + yp}` },
'║': { [Style.NORMAL]: (xp, yp) => `M${.5 - xp},0 L${.5 - xp},1 M${.5 + xp},0 L${.5 + xp},1` },
'╒': { [Style.NORMAL]: (xp, yp) => `M.5,1 L.5,${.5 - yp} L1,${.5 - yp} M.5,${.5 + yp} L1,${.5 + yp}` },
'╓': { [Style.NORMAL]: (xp, yp) => `M${.5 - xp},1 L${.5 - xp},.5 L1,.5 M${.5 + xp},.5 L${.5 + xp},1` },
'╔': { [Style.NORMAL]: (xp, yp) => `M1,${.5 - yp} L${.5 - xp},${.5 - yp} L${.5 - xp},1 M1,${.5 + yp} L${.5 + xp},${.5 + yp} L${.5 + xp},1` },
'╕': { [Style.NORMAL]: (xp, yp) => `M0,${.5 - yp} L.5,${.5 - yp} L.5,1 M0,${.5 + yp} L.5,${.5 + yp}` },
'╖': { [Style.NORMAL]: (xp, yp) => `M${.5 + xp},1 L${.5 + xp},.5 L0,.5 M${.5 - xp},.5 L${.5 - xp},1` },
'╗': { [Style.NORMAL]: (xp, yp) => `M0,${.5 + yp} L${.5 - xp},${.5 + yp} L${.5 - xp},1 M0,${.5 - yp} L${.5 + xp},${.5 - yp} L${.5 + xp},1` },
'╘': { [Style.NORMAL]: (xp, yp) => `M.5,0 L.5,${.5 + yp} L1,${.5 + yp} M.5,${.5 - yp} L1,${.5 - yp}` },
'╙': { [Style.NORMAL]: (xp, yp) => `M1,.5 L${.5 - xp},.5 L${.5 - xp},0 M${.5 + xp},.5 L${.5 + xp},0` },
'╚': { [Style.NORMAL]: (xp, yp) => `M1,${.5 - yp} L${.5 + xp},${.5 - yp} L${.5 + xp},0 M1,${.5 + yp} L${.5 - xp},${.5 + yp} L${.5 - xp},0` },
'╛': { [Style.NORMAL]: (xp, yp) => `M0,${.5 + yp} L.5,${.5 + yp} L.5,0 M0,${.5 - yp} L.5,${.5 - yp}` },
'╜': { [Style.NORMAL]: (xp, yp) => `M0,.5 L${.5 + xp},.5 L${.5 + xp},0 M${.5 - xp},.5 L${.5 - xp},0` },
'╝': { [Style.NORMAL]: (xp, yp) => `M0,${.5 - yp} L${.5 - xp},${.5 - yp} L${.5 - xp},0 M0,${.5 + yp} L${.5 + xp},${.5 + yp} L${.5 + xp},0` },
'╞': { [Style.NORMAL]: (xp, yp) => `${Shapes.TOP_TO_BOTTOM} M.5,${.5 - yp} L1,${.5 - yp} M.5,${.5 + yp} L1,${.5 + yp}` },
'╟': { [Style.NORMAL]: (xp, yp) => `M${.5 - xp},0 L${.5 - xp},1 M${.5 + xp},0 L${.5 + xp},1 M${.5 + xp},.5 L1,.5` },
'╠': { [Style.NORMAL]: (xp, yp) => `M${.5 - xp},0 L${.5 - xp},1 M1,${.5 + yp} L${.5 + xp},${.5 + yp} L${.5 + xp},1 M1,${.5 - yp} L${.5 + xp},${.5 - yp} L${.5 + xp},0` },
'╡': { [Style.NORMAL]: (xp, yp) => `${Shapes.TOP_TO_BOTTOM} M0,${.5 - yp} L.5,${.5 - yp} M0,${.5 + yp} L.5,${.5 + yp}` },
'╢': { [Style.NORMAL]: (xp, yp) => `M0,.5 L${.5 - xp},.5 M${.5 - xp},0 L${.5 - xp},1 M${.5 + xp},0 L${.5 + xp},1` },
'╣': { [Style.NORMAL]: (xp, yp) => `M${.5 + xp},0 L${.5 + xp},1 M0,${.5 + yp} L${.5 - xp},${.5 + yp} L${.5 - xp},1 M0,${.5 - yp} L${.5 - xp},${.5 - yp} L${.5 - xp},0` },
'╤': { [Style.NORMAL]: (xp, yp) => `M0,${.5 - yp} L1,${.5 - yp} M0,${.5 + yp} L1,${.5 + yp} M.5,${.5 + yp} L.5,1` },
'╥': { [Style.NORMAL]: (xp, yp) => `${Shapes.LEFT_TO_RIGHT} M${.5 - xp},.5 L${.5 - xp},1 M${.5 + xp},.5 L${.5 + xp},1` },
'╦': { [Style.NORMAL]: (xp, yp) => `M0,${.5 - yp} L1,${.5 - yp} M0,${.5 + yp} L${.5 - xp},${.5 + yp} L${.5 - xp},1 M1,${.5 + yp} L${.5 + xp},${.5 + yp} L${.5 + xp},1` },
'╧': { [Style.NORMAL]: (xp, yp) => `M.5,0 L.5,${.5 - yp} M0,${.5 - yp} L1,${.5 - yp} M0,${.5 + yp} L1,${.5 + yp}` },
'╨': { [Style.NORMAL]: (xp, yp) => `${Shapes.LEFT_TO_RIGHT} M${.5 - xp},.5 L${.5 - xp},0 M${.5 + xp},.5 L${.5 + xp},0` },
'╩': { [Style.NORMAL]: (xp, yp) => `M0,${.5 + yp} L1,${.5 + yp} M0,${.5 - yp} L${.5 - xp},${.5 - yp} L${.5 - xp},0 M1,${.5 - yp} L${.5 + xp},${.5 - yp} L${.5 + xp},0` },
'╪': { [Style.NORMAL]: (xp, yp) => `${Shapes.TOP_TO_BOTTOM} M0,${.5 - yp} L1,${.5 - yp} M0,${.5 + yp} L1,${.5 + yp}` },
'╫': { [Style.NORMAL]: (xp, yp) => `${Shapes.LEFT_TO_RIGHT} M${.5 - xp},0 L${.5 - xp},1 M${.5 + xp},0 L${.5 + xp},1` },
'╬': { [Style.NORMAL]: (xp, yp) => `M0,${.5 + yp} L${.5 - xp},${.5 + yp} L${.5 - xp},1 M1,${.5 + yp} L${.5 + xp},${.5 + yp} L${.5 + xp},1 M0,${.5 - yp} L${.5 - xp},${.5 - yp} L${.5 - xp},0 M1,${.5 - yp} L${.5 + xp},${.5 - yp} L${.5 + xp},0` },
// Diagonal
'': { [Style.NORMAL]: 'M1,0 L0,1' },
'╲': { [Style.NORMAL]: 'M0,0 L1,1' },
'': { [Style.NORMAL]: 'M1,0 L0,1 M0,0 L1,1' },
// Mixed weight
'╼': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'╽': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'╾': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'╿': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┍': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┎': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'┑': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┒': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'┕': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┖': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┙': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┚': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┝': { [Style.NORMAL]: Shapes.TOP_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┞': { [Style.NORMAL]: Shapes.RIGHT_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┟': { [Style.NORMAL]: Shapes.TOP_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'┠': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.TOP_TO_BOTTOM },
'┡': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.TOP_TO_RIGHT },
'┢': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.RIGHT_TO_BOTTOM },
'┥': { [Style.NORMAL]: Shapes.TOP_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┦': { [Style.NORMAL]: Shapes.LEFT_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┧': { [Style.NORMAL]: Shapes.TOP_TO_LEFT, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'┨': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.TOP_TO_BOTTOM },
'┩': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.TOP_TO_LEFT },
'┪': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.LEFT_TO_BOTTOM },
'┭': { [Style.NORMAL]: Shapes.RIGHT_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┮': { [Style.NORMAL]: Shapes.LEFT_TO_BOTTOM, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┯': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: Shapes.LEFT_TO_RIGHT },
'┰': { [Style.NORMAL]: Shapes.LEFT_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'┱': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.LEFT_TO_BOTTOM },
'┲': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.RIGHT_TO_BOTTOM },
'┵': { [Style.NORMAL]: Shapes.TOP_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┶': { [Style.NORMAL]: Shapes.TOP_TO_LEFT, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┷': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: Shapes.LEFT_TO_RIGHT },
'┸': { [Style.NORMAL]: Shapes.LEFT_TO_RIGHT, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'┹': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: Shapes.TOP_TO_LEFT },
'┺': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: Shapes.TOP_TO_RIGHT },
'┽': { [Style.NORMAL]: `${Shapes.TOP_TO_BOTTOM} ${Shapes.MIDDLE_TO_RIGHT}`, [Style.BOLD]: Shapes.MIDDLE_TO_LEFT },
'┾': { [Style.NORMAL]: `${Shapes.TOP_TO_BOTTOM} ${Shapes.MIDDLE_TO_LEFT}`, [Style.BOLD]: Shapes.MIDDLE_TO_RIGHT },
'┿': { [Style.NORMAL]: Shapes.TOP_TO_BOTTOM, [Style.BOLD]: Shapes.LEFT_TO_RIGHT },
'╀': { [Style.NORMAL]: `${Shapes.LEFT_TO_RIGHT} ${Shapes.MIDDLE_TO_BOTTOM}`, [Style.BOLD]: Shapes.MIDDLE_TO_TOP },
'╁': { [Style.NORMAL]: `${Shapes.MIDDLE_TO_TOP} ${Shapes.LEFT_TO_RIGHT}`, [Style.BOLD]: Shapes.MIDDLE_TO_BOTTOM },
'╂': { [Style.NORMAL]: Shapes.LEFT_TO_RIGHT, [Style.BOLD]: Shapes.TOP_TO_BOTTOM },
'╃': { [Style.NORMAL]: Shapes.RIGHT_TO_BOTTOM, [Style.BOLD]: Shapes.TOP_TO_LEFT },
'╄': { [Style.NORMAL]: Shapes.LEFT_TO_BOTTOM, [Style.BOLD]: Shapes.TOP_TO_RIGHT },
'╅': { [Style.NORMAL]: Shapes.TOP_TO_RIGHT, [Style.BOLD]: Shapes.LEFT_TO_BOTTOM },
'╆': { [Style.NORMAL]: Shapes.TOP_TO_LEFT, [Style.BOLD]: Shapes.RIGHT_TO_BOTTOM },
'╇': { [Style.NORMAL]: Shapes.MIDDLE_TO_BOTTOM, [Style.BOLD]: `${Shapes.MIDDLE_TO_TOP} ${Shapes.LEFT_TO_RIGHT}` },
'╈': { [Style.NORMAL]: Shapes.MIDDLE_TO_TOP, [Style.BOLD]: `${Shapes.LEFT_TO_RIGHT} ${Shapes.MIDDLE_TO_BOTTOM}` },
'╉': { [Style.NORMAL]: Shapes.MIDDLE_TO_RIGHT, [Style.BOLD]: `${Shapes.TOP_TO_BOTTOM} ${Shapes.MIDDLE_TO_LEFT}` },
'╊': { [Style.NORMAL]: Shapes.MIDDLE_TO_LEFT, [Style.BOLD]: `${Shapes.TOP_TO_BOTTOM} ${Shapes.MIDDLE_TO_RIGHT}` },
// Dashed
'╌': { [Style.NORMAL]: Shapes.TWO_DASHES_HORIZONTAL },
'╍': { [Style.BOLD]: Shapes.TWO_DASHES_HORIZONTAL },
'┄': { [Style.NORMAL]: Shapes.THREE_DASHES_HORIZONTAL },
'┅': { [Style.BOLD]: Shapes.THREE_DASHES_HORIZONTAL },
'┈': { [Style.NORMAL]: Shapes.FOUR_DASHES_HORIZONTAL },
'┉': { [Style.BOLD]: Shapes.FOUR_DASHES_HORIZONTAL },
'╎': { [Style.NORMAL]: Shapes.TWO_DASHES_VERTICAL },
'╏': { [Style.BOLD]: Shapes.TWO_DASHES_VERTICAL },
'┆': { [Style.NORMAL]: Shapes.THREE_DASHES_VERTICAL },
'┇': { [Style.BOLD]: Shapes.THREE_DASHES_VERTICAL },
'┊': { [Style.NORMAL]: Shapes.FOUR_DASHES_VERTICAL },
'┋': { [Style.BOLD]: Shapes.FOUR_DASHES_VERTICAL },
// Curved
'╭': { [Style.NORMAL]: (xp, yp) => `M.5,1 L.5,${.5 + (yp / .15 * .5)} C.5,${.5 + (yp / .15 * .5)},.5,.5,1,.5` },
'╮': { [Style.NORMAL]: (xp, yp) => `M.5,1 L.5,${.5 + (yp / .15 * .5)} C.5,${.5 + (yp / .15 * .5)},.5,.5,0,.5` },
'╯': { [Style.NORMAL]: (xp, yp) => `M.5,0 L.5,${.5 - (yp / .15 * .5)} C.5,${.5 - (yp / .15 * .5)},.5,.5,0,.5` },
'╰': { [Style.NORMAL]: (xp, yp) => `M.5,0 L.5,${.5 - (yp / .15 * .5)} C.5,${.5 - (yp / .15 * .5)},.5,.5,1,.5` }
};
interface IVectorShape {
d: string;
type: VectorType;
leftPadding?: number;
rightPadding?: number;
}
const enum VectorType {
FILL,
STROKE
}
/**
* This contains the definitions of the primarily used box drawing characters as vector shapes. The
* reason these characters are defined specially is to avoid common problems if a user's font has
* not been patched with powerline characters and also to get pixel perfect rendering as rendering
* issues can occur around AA/SPAA.
*
* The line variants draw beyond the cell and get clipped to ensure the end of the line is not
* visible.
*
* Original symbols defined in https://github.com/powerline/fontpatcher
*/
export const powerlineDefinitions: { [index: string]: IVectorShape } = {
// Right triangle solid
'\u{E0B0}': { d: 'M0,0 L1,.5 L0,1', type: VectorType.FILL, rightPadding: 2 },
// Right triangle line
'\u{E0B1}': { d: 'M-1,-.5 L1,.5 L-1,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Left triangle solid
'\u{E0B2}': { d: 'M1,0 L0,.5 L1,1', type: VectorType.FILL, leftPadding: 2 },
// Left triangle line
'\u{E0B3}': { d: 'M2,-.5 L0,.5 L2,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Right semi-circle solid
'\u{E0B4}': { d: 'M0,0 L0,1 C0.552,1,1,0.776,1,.5 C1,0.224,0.552,0,0,0', type: VectorType.FILL, rightPadding: 1 },
// Right semi-circle line
'\u{E0B5}': { d: 'M.2,1 C.422,1,.8,.826,.78,.5 C.8,.174,0.422,0,.2,0', type: VectorType.STROKE, rightPadding: 1 },
// Left semi-circle solid
'\u{E0B6}': { d: 'M1,0 L1,1 C0.448,1,0,0.776,0,.5 C0,0.224,0.448,0,1,0', type: VectorType.FILL, leftPadding: 1 },
// Left semi-circle line
'\u{E0B7}': { d: 'M.8,1 C0.578,1,0.2,.826,.22,.5 C0.2,0.174,0.578,0,0.8,0', type: VectorType.STROKE, leftPadding: 1 },
// Lower left triangle
'\u{E0B8}': { d: 'M-.5,-.5 L1.5,1.5 L-.5,1.5', type: VectorType.FILL },
// Backslash separator
'\u{E0B9}': { d: 'M-.5,-.5 L1.5,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Lower right triangle
'\u{E0BA}': { d: 'M1.5,-.5 L-.5,1.5 L1.5,1.5', type: VectorType.FILL },
// Upper left triangle
'\u{E0BC}': { d: 'M1.5,-.5 L-.5,1.5 L-.5,-.5', type: VectorType.FILL },
// Forward slash separator
'\u{E0BD}': { d: 'M1.5,-.5 L-.5,1.5', type: VectorType.STROKE, leftPadding: 1, rightPadding: 1 },
// Upper right triangle
'\u{E0BE}': { d: 'M-.5,-.5 L1.5,1.5 L1.5,-.5', type: VectorType.FILL }
};
// Forward slash separator redundant
powerlineDefinitions['\u{E0BB}'] = powerlineDefinitions['\u{E0BD}'];
// Backslash separator redundant
powerlineDefinitions['\u{E0BF}'] = powerlineDefinitions['\u{E0B9}'];
/**
* Try drawing a custom block element or box drawing character, returning whether it was
* successfully drawn.
*/
export function tryDrawCustomChar(
ctx: CanvasRenderingContext2D,
c: string,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
fontSize: number,
devicePixelRatio: number
): boolean {
const blockElementDefinition = blockElementDefinitions[c];
if (blockElementDefinition) {
drawBlockElementChar(ctx, blockElementDefinition, xOffset, yOffset, deviceCellWidth, deviceCellHeight);
return true;
}
const patternDefinition = patternCharacterDefinitions[c];
if (patternDefinition) {
drawPatternChar(ctx, patternDefinition, xOffset, yOffset, deviceCellWidth, deviceCellHeight);
return true;
}
const boxDrawingDefinition = boxDrawingDefinitions[c];
if (boxDrawingDefinition) {
drawBoxDrawingChar(ctx, boxDrawingDefinition, xOffset, yOffset, deviceCellWidth, deviceCellHeight, devicePixelRatio);
return true;
}
const powerlineDefinition = powerlineDefinitions[c];
if (powerlineDefinition) {
drawPowerlineChar(ctx, powerlineDefinition, xOffset, yOffset, deviceCellWidth, deviceCellHeight, fontSize, devicePixelRatio);
return true;
}
return false;
}
function drawBlockElementChar(
ctx: CanvasRenderingContext2D,
charDefinition: IBlockVector[],
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
for (let i = 0; i < charDefinition.length; i++) {
const box = charDefinition[i];
const xEighth = deviceCellWidth / 8;
const yEighth = deviceCellHeight / 8;
ctx.fillRect(
xOffset + box.x * xEighth,
yOffset + box.y * yEighth,
box.w * xEighth,
box.h * yEighth
);
}
}
const cachedPatterns: Map<PatternDefinition, Map</* fillStyle */string, CanvasPattern>> = new Map();
function drawPatternChar(
ctx: CanvasRenderingContext2D,
charDefinition: number[][],
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number
): void {
let patternSet = cachedPatterns.get(charDefinition);
if (!patternSet) {
patternSet = new Map();
cachedPatterns.set(charDefinition, patternSet);
}
const fillStyle = ctx.fillStyle;
if (typeof fillStyle !== 'string') {
throw new Error(`Unexpected fillStyle type "${fillStyle}"`);
}
let pattern = patternSet.get(fillStyle);
if (!pattern) {
const width = charDefinition[0].length;
const height = charDefinition.length;
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = width;
tmpCanvas.height = height;
const tmpCtx = throwIfFalsy(tmpCanvas.getContext('2d'));
const imageData = new ImageData(width, height);
// Extract rgba from fillStyle
let r: number;
let g: number;
let b: number;
let a: number;
if (fillStyle.startsWith('#')) {
r = parseInt(fillStyle.slice(1, 3), 16);
g = parseInt(fillStyle.slice(3, 5), 16);
b = parseInt(fillStyle.slice(5, 7), 16);
a = fillStyle.length > 7 && parseInt(fillStyle.slice(7, 9), 16) || 1;
} else if (fillStyle.startsWith('rgba')) {
([r, g, b, a] = fillStyle.substring(5, fillStyle.length - 1).split(',').map(e => parseFloat(e)));
} else {
throw new Error(`Unexpected fillStyle color format "${fillStyle}" when drawing pattern glyph`);
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
imageData.data[(y * width + x) * 4 ] = r;
imageData.data[(y * width + x) * 4 + 1] = g;
imageData.data[(y * width + x) * 4 + 2] = b;
imageData.data[(y * width + x) * 4 + 3] = charDefinition[y][x] * (a * 255);
}
}
tmpCtx.putImageData(imageData, 0, 0);
pattern = throwIfFalsy(ctx.createPattern(tmpCanvas, null));
patternSet.set(fillStyle, pattern);
}
ctx.fillStyle = pattern;
ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
}
/**
* Draws the following box drawing characters by mapping a subset of SVG d attribute instructions to
* canvas draw calls.
*
* Box styles: ┎┰┒┍┯┑╓╥╖╒╤╕ ┏┳┓┌┲┓┌┬┐┏┱┐
* ┌─┬─┐ ┏━┳━┓ ╔═╦═╗ ┠╂┨┝┿┥╟╫╢╞╪╡ ┡╇┩├╊┫┢╈┪┣╉┤
* │ │ │ ┃ ┃ ┃ ║ ║ ║ ┖┸┚┕┷┙╙╨╜╘╧╛ └┴┘└┺┛┗┻┛┗┹┘
* ├─┼─┤ ┣━╋━┫ ╠═╬═╣ ┏┱┐┌┲┓┌┬┐┌┬┐ ┏┳┓┌┮┓┌┬┐┏┭┐
* │ │ │ ┃ ┃ ┃ ║ ║ ║ ┡╃┤├╄┩├╆┪┢╅┤ ┞╀┦├┾┫┟╁┧┣┽┤
* └─┴─┘ ┗━┻━┛ ╚═╩═╝ └┴┘└┴┘└┺┛┗┹┘ └┴┘└┶┛┗┻┛┗┵┘
*
* Other:
* ╭─╮ ╲ ╷╻╎╏┆┇┊┋ ╺╾╴ ╌╌╌ ┄┄┄ ┈┈┈
* │ │ ╽╿╎╏┆┇┊┋ ╶╼╸ ╍╍╍ ┅┅┅ ┉┉┉
* ╰─╯ ╲ ╹╵╎╏┆┇┊┋
*
* All box drawing characters:
* ─ ━ │ ┃ ┄ ┅ ┆ ┇ ┈ ┉ ┊ ┋ ┌ ┍ ┎ ┏
* ┐ ┑ ┒ ┓ └ ┕ ┖ ┗ ┘ ┙ ┚ ┛ ├ ┝ ┞ ┟
* ┠ ┡ ┢ ┣ ┤ ┥ ┦ ┧ ┨ ┩ ┪ ┫ ┬ ┭ ┮ ┯
* ┰ ┱ ┲ ┳ ┴ ┵ ┶ ┷ ┸ ┹ ┺ ┻ ┼ ┽ ┾ ┿
* ╀ ╁ ╂ ╃ ╄ ╅ ╆ ╇ ╈ ╉ ╊ ╋ ╌ ╍ ╎ ╏
* ═ ║ ╒ ╓ ╔ ╕ ╖ ╗ ╘ ╙ ╚ ╛ ╜ ╝ ╞ ╟
* ╠ ╡ ╢ ╣ ╤ ╥ ╦ ╧ ╨ ╩ ╪ ╫ ╬ ╭ ╮ ╯
* ╰ ╴ ╵ ╶ ╷ ╸ ╹ ╺ ╻ ╼ ╽ ╾ ╿
*
* ---
*
* Box drawing alignment tests: █
* ▉
* ╔══╦══╗ ┌──┬──┐ ╭──┬──╮ ╭──┬──╮ ┏━━┳━━┓ ┎┒┏┑ ╷ ╻ ┏┯┓ ┌┰┐ ▊ ╱╲╱╲╳╳╳
* ║┌─╨─┐║ │╔═╧═╗│ │╒═╪═╕│ │╓─╁─╖│ ┃┌─╂─┐┃ ┗╃╄┙ ╶┼╴╺╋╸┠┼┨ ┝╋┥ ▋ ╲╱╲╱╳╳╳
* ║│╲ ╱│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╿ │┃ ┍╅╆┓ ╵ ╹ ┗┷┛ └┸┘ ▌ ╱╲╱╲╳╳╳
* ╠╡ ╞╣ ├╢ ╟┤ ├┼─┼─┼┤ ├╫─╂─╫┤ ┣┿╾┼╼┿┫ ┕┛┖┚ ┌┄┄┐ ╎ ┏┅┅┓ ┋ ▍ ╲╱╲╱╳╳╳
* ║│╱ ╲│║ │║ ║│ ││ │ ││ │║ ┃ ║│ ┃│ ╽ │┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▎
* ║└─╥─┘║ │╚═╤═╝│ │╘═╪═╛│ │╙─╀─╜│ ┃└─╂─┘┃ ░░▒▒▓▓██ ┊ ┆ ╎ ╏ ┇ ┋ ▏
* ╚══╩══╝ └──┴──┘ ╰──┴──╯ ╰──┴──╯ ┗━━┻━━┛ └╌╌┘ ╎ ┗╍╍┛ ┋ ▁▂▃▄▅▆▇█
*
* Source: https://www.w3.org/2001/06/utf-8-test/UTF-8-demo.html
*/
function drawBoxDrawingChar(
ctx: CanvasRenderingContext2D,
charDefinition: { [fontWeight: number]: string | ((xp: number, yp: number) => string) },
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
devicePixelRatio: number
): void {
ctx.strokeStyle = ctx.fillStyle;
for (const [fontWeight, instructions] of Object.entries(charDefinition)) {
ctx.beginPath();
ctx.lineWidth = devicePixelRatio * Number.parseInt(fontWeight);
let actualInstructions: string;
if (typeof instructions === 'function') {
const xp = .15;
const yp = .15 / deviceCellHeight * deviceCellWidth;
actualInstructions = instructions(xp, yp);
} else {
actualInstructions = instructions;
}
for (const instruction of actualInstructions.split(' ')) {
const type = instruction[0];
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
continue;
}
f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio));
}
ctx.stroke();
ctx.closePath();
}
}
function drawPowerlineChar(
ctx: CanvasRenderingContext2D,
charDefinition: IVectorShape,
xOffset: number,
yOffset: number,
deviceCellWidth: number,
deviceCellHeight: number,
fontSize: number,
devicePixelRatio: number
): void {
// Clip the cell to make sure drawing doesn't occur beyond bounds
const clipRegion = new Path2D();
clipRegion.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
ctx.clip(clipRegion);
ctx.beginPath();
// Scale the stroke with DPR and font size
const cssLineWidth = fontSize / 12;
ctx.lineWidth = devicePixelRatio * cssLineWidth;
for (const instruction of charDefinition.d.split(' ')) {
const type = instruction[0];
const f = svgToCanvasInstructionMap[type];
if (!f) {
console.error(`Could not find drawing instructions for "${type}"`);
continue;
}
const args: string[] = instruction.substring(1).split(',');
if (!args[0] || !args[1]) {
continue;
}
f(ctx, translateArgs(
args,
deviceCellWidth,
deviceCellHeight,
xOffset,
yOffset,
false,
devicePixelRatio,
(charDefinition.leftPadding ?? 0) * (cssLineWidth / 2),
(charDefinition.rightPadding ?? 0) * (cssLineWidth / 2)
));
}
if (charDefinition.type === VectorType.STROKE) {
ctx.strokeStyle = ctx.fillStyle;
ctx.stroke();
} else {
ctx.fill();
}
ctx.closePath();
}
function clamp(value: number, max: number, min: number = 0): number {
return Math.max(Math.min(value, max), min);
}
const svgToCanvasInstructionMap: { [index: string]: any } = {
'C': (ctx: CanvasRenderingContext2D, args: number[]) => ctx.bezierCurveTo(args[0], args[1], args[2], args[3], args[4], args[5]),
'L': (ctx: CanvasRenderingContext2D, args: number[]) => ctx.lineTo(args[0], args[1]),
'M': (ctx: CanvasRenderingContext2D, args: number[]) => ctx.moveTo(args[0], args[1])
};
function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0): number[] {
const result = args.map(e => parseFloat(e) || parseInt(e));
if (result.length < 2) {
throw new Error('Too few arguments for instruction');
}
for (let x = 0; x < result.length; x += 2) {
// Translate from 0-1 to 0-cellWidth
result[x] *= cellWidth - (leftPadding * devicePixelRatio) - (rightPadding * devicePixelRatio);
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
// line at 100% devicePixelRatio
if (doClamp && result[x] !== 0) {
result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0);
}
// Apply the cell's offset (ie. x*cellWidth)
result[x] += xOffset + (leftPadding * devicePixelRatio);
}
for (let y = 1; y < result.length; y += 2) {
// Translate from 0-1 to 0-cellHeight
result[y] *= cellHeight;
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
// line at 100% devicePixelRatio
if (doClamp && result[y] !== 0) {
result[y] = clamp(Math.round(result[y] + 0.5) - 0.5, cellHeight, 0);
}
// Apply the cell's offset (ie. x*cellHeight)
result[y] += yOffset;
}
return result;
}

View File

@@ -0,0 +1,41 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
export function observeDevicePixelDimensions(element: HTMLElement, parentWindow: Window & typeof globalThis, callback: (deviceWidth: number, deviceHeight: number) => void): IDisposable {
// Observe any resizes to the element and extract the actual pixel size of the element if the
// devicePixelContentBoxSize API is supported. This allows correcting rounding errors when
// converting between CSS pixels and device pixels which causes blurry rendering when device
// pixel ratio is not a round number.
let observer: ResizeObserver | undefined = new parentWindow.ResizeObserver((entries) => {
const entry = entries.find((entry) => entry.target === element);
if (!entry) {
return;
}
// Disconnect if devicePixelContentBoxSize isn't supported by the browser
if (!('devicePixelContentBoxSize' in entry)) {
observer?.disconnect();
observer = undefined;
return;
}
// Fire the callback, ignore events where the dimensions are 0x0 as the canvas is likely hidden
const width = entry.devicePixelContentBoxSize[0].inlineSize;
const height = entry.devicePixelContentBoxSize[0].blockSize;
if (width > 0 && height > 0) {
callback(width, height);
}
});
try {
observer.observe(element, { box: ['device-pixel-content-box'] } as any);
} catch {
observer.disconnect();
observer = undefined;
}
return toDisposable(() => observer?.disconnect());
}

View File

@@ -0,0 +1 @@
This folder contains files that are shared between the renderer addons, but not necessarily bundled into the `xterm` module.

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDimensions, IRenderDimensions } from 'browser/renderer/shared/Types';
export function throwIfFalsy<T>(value: T | undefined | null): T {
if (!value) {
throw new Error('value must not be falsy');
}
return value;
}
export function isPowerlineGlyph(codepoint: number): boolean {
// Only return true for Powerline symbols which require
// different padding and should be excluded from minimum contrast
// ratio standards
return 0xE0A4 <= codepoint && codepoint <= 0xE0D6;
}
export function isRestrictedPowerlineGlyph(codepoint: number): boolean {
return 0xE0B0 <= codepoint && codepoint <= 0xE0B7;
}
function isBoxOrBlockGlyph(codepoint: number): boolean {
return 0x2500 <= codepoint && codepoint <= 0x259F;
}
export function excludeFromContrastRatioDemands(codepoint: number): boolean {
return isPowerlineGlyph(codepoint) || isBoxOrBlockGlyph(codepoint);
}
export function createRenderDimensions(): IRenderDimensions {
return {
css: {
canvas: createDimension(),
cell: createDimension()
},
device: {
canvas: createDimension(),
cell: createDimension(),
char: {
width: 0,
height: 0,
left: 0,
top: 0
}
}
};
}
function createDimension(): IDimensions {
return {
width: 0,
height: 0
};
}

View File

@@ -0,0 +1,91 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
import { Terminal } from 'xterm';
class SelectionRenderModel implements ISelectionRenderModel {
public hasSelection!: boolean;
public columnSelectMode!: boolean;
public viewportStartRow!: number;
public viewportEndRow!: number;
public viewportCappedStartRow!: number;
public viewportCappedEndRow!: number;
public startCol!: number;
public endCol!: number;
public selectionStart: [number, number] | undefined;
public selectionEnd: [number, number] | undefined;
constructor() {
this.clear();
}
public clear(): void {
this.hasSelection = false;
this.columnSelectMode = false;
this.viewportStartRow = 0;
this.viewportEndRow = 0;
this.viewportCappedStartRow = 0;
this.viewportCappedEndRow = 0;
this.startCol = 0;
this.endCol = 0;
this.selectionStart = undefined;
this.selectionEnd = undefined;
}
public update(terminal: Terminal, start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean = false): void {
this.selectionStart = start;
this.selectionEnd = end;
// Selection does not exist
if (!start || !end || (start[0] === end[0] && start[1] === end[1])) {
this.clear();
return;
}
// Translate from buffer position to viewport position
const viewportStartRow = start[1] - terminal.buffer.active.viewportY;
const viewportEndRow = end[1] - terminal.buffer.active.viewportY;
const viewportCappedStartRow = Math.max(viewportStartRow, 0);
const viewportCappedEndRow = Math.min(viewportEndRow, terminal.rows - 1);
// No need to draw the selection
if (viewportCappedStartRow >= terminal.rows || viewportCappedEndRow < 0) {
this.clear();
return;
}
this.hasSelection = true;
this.columnSelectMode = columnSelectMode;
this.viewportStartRow = viewportStartRow;
this.viewportEndRow = viewportEndRow;
this.viewportCappedStartRow = viewportCappedStartRow;
this.viewportCappedEndRow = viewportCappedEndRow;
this.startCol = start[0];
this.endCol = end[0];
}
public isCellSelected(terminal: Terminal, x: number, y: number): boolean {
if (!this.hasSelection) {
return false;
}
y -= terminal.buffer.active.viewportY;
if (this.columnSelectMode) {
if (this.startCol <= this.endCol) {
return x >= this.startCol && y >= this.viewportCappedStartRow &&
x < this.endCol && y <= this.viewportCappedEndRow;
}
return x < this.startCol && y >= this.viewportCappedStartRow &&
x >= this.endCol && y <= this.viewportCappedEndRow;
}
return (y > this.viewportStartRow && y < this.viewportEndRow) ||
(this.viewportStartRow === this.viewportEndRow && y === this.viewportStartRow && x >= this.startCol && x < this.endCol) ||
(this.viewportStartRow < this.viewportEndRow && y === this.viewportEndRow && x < this.endCol) ||
(this.viewportStartRow < this.viewportEndRow && y === this.viewportStartRow && x >= this.startCol);
}
}
export function createSelectionRenderModel(): ISelectionRenderModel {
return new SelectionRenderModel();
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,173 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { FontWeight, Terminal } from 'xterm';
import { IColorSet } from 'browser/Types';
import { IDisposable } from 'common/Types';
import { IEvent } from 'common/EventEmitter';
export interface ICharAtlasConfig {
customGlyphs: boolean;
devicePixelRatio: number;
letterSpacing: number;
lineHeight: number;
fontSize: number;
fontFamily: string;
fontWeight: FontWeight;
fontWeightBold: FontWeight;
deviceCellWidth: number;
deviceCellHeight: number;
deviceCharWidth: number;
deviceCharHeight: number;
allowTransparency: boolean;
drawBoldTextInBrightColors: boolean;
minimumContrastRatio: number;
colors: IColorSet;
}
export interface IDimensions {
width: number;
height: number;
}
export interface IOffset {
top: number;
left: number;
}
export interface IRenderDimensions {
/**
* Dimensions measured in CSS pixels (ie. device pixels / device pixel ratio).
*/
css: {
canvas: IDimensions;
cell: IDimensions;
};
/**
* Dimensions measured in actual pixels as rendered to the device.
*/
device: {
canvas: IDimensions;
cell: IDimensions;
char: IDimensions & IOffset;
};
}
export interface IRequestRedrawEvent {
start: number;
end: number;
}
/**
* Note that IRenderer implementations should emit the refresh event after
* rendering rows to the screen.
*/
export interface IRenderer extends IDisposable {
readonly dimensions: IRenderDimensions;
/**
* Fires when the renderer is requesting to be redrawn on the next animation
* frame but is _not_ a result of content changing (eg. selection changes).
*/
readonly onRequestRedraw: IEvent<IRequestRedrawEvent>;
dispose(): void;
handleDevicePixelRatioChange(): void;
handleResize(cols: number, rows: number): void;
handleCharSizeChanged(): void;
handleBlur(): void;
handleFocus(): void;
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
handleCursorMove(): void;
clear(): void;
renderRows(start: number, end: number): void;
clearTextureAtlas?(): void;
}
export interface ITextureAtlas extends IDisposable {
readonly pages: { canvas: HTMLCanvasElement, version: number }[];
onAddTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
onRemoveTextureAtlasCanvas: IEvent<HTMLCanvasElement>;
/**
* Warm up the texture atlas, adding common glyphs to avoid slowing early frame.
*/
warmUp(): void;
/**
* Call when a frame is being drawn, this will return true if the atlas was cleared to make room
* for a new set of glyphs.
*/
beginFrame(): boolean;
/**
* Clear all glyphs from the texture atlas.
*/
clearTexture(): void;
getRasterizedGlyph(code: number, bg: number, fg: number, ext: number, restrictToCellHeight: boolean): IRasterizedGlyph;
getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean): IRasterizedGlyph;
}
/**
* Represents a rasterized glyph within a texture atlas. Some numbers are
* tracked in CSS pixels as well in order to reduce calculations during the
* render loop.
*/
export interface IRasterizedGlyph {
/**
* The x and y offset between the glyph's top/left and the top/left of a cell
* in pixels.
*/
offset: IVector;
/**
* The index of the texture page that the glyph is on.
*/
texturePage: number;
/**
* the x and y position of the glyph in the texture in pixels.
*/
texturePosition: IVector;
/**
* the x and y position of the glyph in the texture in clip space coordinates.
*/
texturePositionClipSpace: IVector;
/**
* The width and height of the glyph in the texture in pixels.
*/
size: IVector;
/**
* The width and height of the glyph in the texture in clip space coordinates.
*/
sizeClipSpace: IVector;
}
export interface IVector {
x: number;
y: number;
}
export interface IBoundingBox {
top: number;
left: number;
right: number;
bottom: number;
}
export interface ISelectionRenderModel {
readonly hasSelection: boolean;
readonly columnSelectMode: boolean;
readonly viewportStartRow: number;
readonly viewportEndRow: number;
readonly viewportCappedStartRow: number;
readonly viewportCappedEndRow: number;
readonly startCol: number;
readonly endCol: number;
readonly selectionStart: [number, number] | undefined;
readonly selectionEnd: [number, number] | undefined;
clear(): void;
update(terminal: Terminal, start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode?: boolean): void;
isCellSelected(terminal: Terminal, x: number, y: number): boolean;
}

View File

@@ -0,0 +1,144 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferService } from 'common/services/Services';
/**
* Represents a selection within the buffer. This model only cares about column
* and row coordinates, not wide characters.
*/
export class SelectionModel {
/**
* Whether select all is currently active.
*/
public isSelectAllActive: boolean = false;
/**
* The minimal length of the selection from the start position. When double
* clicking on a word, the word will be selected which makes the selection
* start at the start of the word and makes this variable the length.
*/
public selectionStartLength: number = 0;
/**
* The [x, y] position the selection starts at.
*/
public selectionStart: [number, number] | undefined;
/**
* The [x, y] position the selection ends at.
*/
public selectionEnd: [number, number] | undefined;
constructor(
private _bufferService: IBufferService
) {
}
/**
* Clears the current selection.
*/
public clearSelection(): void {
this.selectionStart = undefined;
this.selectionEnd = undefined;
this.isSelectAllActive = false;
this.selectionStartLength = 0;
}
/**
* The final selection start, taking into consideration select all.
*/
public get finalSelectionStart(): [number, number] | undefined {
if (this.isSelectAllActive) {
return [0, 0];
}
if (!this.selectionEnd || !this.selectionStart) {
return this.selectionStart;
}
return this.areSelectionValuesReversed() ? this.selectionEnd : this.selectionStart;
}
/**
* The final selection end, taking into consideration select all, double click
* word selection and triple click line selection.
*/
public get finalSelectionEnd(): [number, number] | undefined {
if (this.isSelectAllActive) {
return [this._bufferService.cols, this._bufferService.buffer.ybase + this._bufferService.rows - 1];
}
if (!this.selectionStart) {
return undefined;
}
// Use the selection start + length if the end doesn't exist or they're reversed
if (!this.selectionEnd || this.areSelectionValuesReversed()) {
const startPlusLength = this.selectionStart[0] + this.selectionStartLength;
if (startPlusLength > this._bufferService.cols) {
// Ensure the trailing EOL isn't included when the selection ends on the right edge
if (startPlusLength % this._bufferService.cols === 0) {
return [this._bufferService.cols, this.selectionStart[1] + Math.floor(startPlusLength / this._bufferService.cols) - 1];
}
return [startPlusLength % this._bufferService.cols, this.selectionStart[1] + Math.floor(startPlusLength / this._bufferService.cols)];
}
return [startPlusLength, this.selectionStart[1]];
}
// Ensure the the word/line is selected after a double/triple click
if (this.selectionStartLength) {
// Select the larger of the two when start and end are on the same line
if (this.selectionEnd[1] === this.selectionStart[1]) {
// Keep the whole wrapped word/line selected if the content wraps multiple lines
const startPlusLength = this.selectionStart[0] + this.selectionStartLength;
if (startPlusLength > this._bufferService.cols) {
return [startPlusLength % this._bufferService.cols, this.selectionStart[1] + Math.floor(startPlusLength / this._bufferService.cols)];
}
return [Math.max(startPlusLength, this.selectionEnd[0]), this.selectionEnd[1]];
}
}
return this.selectionEnd;
}
/**
* Returns whether the selection start and end are reversed.
*/
public areSelectionValuesReversed(): boolean {
const start = this.selectionStart;
const end = this.selectionEnd;
if (!start || !end) {
return false;
}
return start[1] > end[1] || (start[1] === end[1] && start[0] > end[0]);
}
/**
* Handle the buffer being trimmed, adjust the selection position.
* @param amount The amount the buffer is being trimmed.
* @returns Whether a refresh is necessary.
*/
public handleTrim(amount: number): boolean {
// Adjust the selection position based on the trimmed amount.
if (this.selectionStart) {
this.selectionStart[1] -= amount;
}
if (this.selectionEnd) {
this.selectionEnd[1] -= amount;
}
// The selection has moved off the buffer, clear it.
if (this.selectionEnd && this.selectionEnd[1] < 0) {
this.clearSelection();
return true;
}
// If the selection start is trimmed, ensure the start column is 0.
if (this.selectionStart && this.selectionStart[1] < 0) {
this.selectionStart[1] = 0;
}
return false;
}
}

View File

@@ -0,0 +1,15 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
export interface ISelectionRedrawRequestEvent {
start: [number, number] | undefined;
end: [number, number] | undefined;
columnSelectMode: boolean;
}
export interface ISelectionRequestScrollLinesEvent {
amount: number;
suppressScrollEvent: boolean;
}

View File

@@ -0,0 +1,102 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IOptionsService } from 'common/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { ICharSizeService } from 'browser/services/Services';
import { Disposable } from 'common/Lifecycle';
const enum MeasureSettings {
REPEAT = 32
}
export class CharSizeService extends Disposable implements ICharSizeService {
public serviceBrand: undefined;
public width: number = 0;
public height: number = 0;
private _measureStrategy: IMeasureStrategy;
public get hasValidSize(): boolean { return this.width > 0 && this.height > 0; }
private readonly _onCharSizeChange = this.register(new EventEmitter<void>());
public readonly onCharSizeChange = this._onCharSizeChange.event;
constructor(
document: Document,
parentElement: HTMLElement,
@IOptionsService private readonly _optionsService: IOptionsService
) {
super();
this._measureStrategy = new DomMeasureStrategy(document, parentElement, this._optionsService);
this.register(this._optionsService.onMultipleOptionChange(['fontFamily', 'fontSize'], () => this.measure()));
}
public measure(): void {
const result = this._measureStrategy.measure();
if (result.width !== this.width || result.height !== this.height) {
this.width = result.width;
this.height = result.height;
this._onCharSizeChange.fire();
}
}
}
interface IMeasureStrategy {
measure(): IReadonlyMeasureResult;
}
interface IReadonlyMeasureResult {
readonly width: number;
readonly height: number;
}
interface IMeasureResult {
width: number;
height: number;
}
// TODO: For supporting browsers we should also provide a CanvasCharDimensionsProvider that uses
// ctx.measureText
class DomMeasureStrategy implements IMeasureStrategy {
private _result: IMeasureResult = { width: 0, height: 0 };
private _measureElement: HTMLElement;
constructor(
private _document: Document,
private _parentElement: HTMLElement,
private _optionsService: IOptionsService
) {
this._measureElement = this._document.createElement('span');
this._measureElement.classList.add('xterm-char-measure-element');
this._measureElement.textContent = 'W'.repeat(MeasureSettings.REPEAT);
this._measureElement.setAttribute('aria-hidden', 'true');
this._measureElement.style.whiteSpace = 'pre';
this._measureElement.style.fontKerning = 'none';
this._parentElement.appendChild(this._measureElement);
}
public measure(): IReadonlyMeasureResult {
this._measureElement.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._measureElement.style.fontSize = `${this._optionsService.rawOptions.fontSize}px`;
// Note that this triggers a synchronous layout
const geometry = {
height: Number(this._measureElement.offsetHeight),
width: Number(this._measureElement.offsetWidth)
};
// If values are 0 then the element is likely currently display:none, in which case we should
// retain the previous value.
if (geometry.width !== 0 && geometry.height !== 0) {
this._result.width = geometry.width / MeasureSettings.REPEAT;
this._result.height = Math.ceil(geometry.height);
}
return this._result;
}
}

View File

@@ -0,0 +1,339 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferLine, ICellData, CharData } from 'common/Types';
import { ICharacterJoiner } from 'browser/Types';
import { AttributeData } from 'common/buffer/AttributeData';
import { WHITESPACE_CELL_CHAR, Content } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { IBufferService } from 'common/services/Services';
import { ICharacterJoinerService } from 'browser/services/Services';
export class JoinedCellData extends AttributeData implements ICellData {
private _width: number;
// .content carries no meaning for joined CellData, simply nullify it
// thus we have to overload all other .content accessors
public content: number = 0;
public fg: number;
public bg: number;
public combinedData: string = '';
constructor(firstCell: ICellData, chars: string, width: number) {
super();
this.fg = firstCell.fg;
this.bg = firstCell.bg;
this.combinedData = chars;
this._width = width;
}
public isCombined(): number {
// always mark joined cell data as combined
return Content.IS_COMBINED_MASK;
}
public getWidth(): number {
return this._width;
}
public getChars(): string {
return this.combinedData;
}
public getCode(): number {
// code always gets the highest possible fake codepoint (read as -1)
// this is needed as code is used by caches as identifier
return 0x1FFFFF;
}
public setFromCharData(value: CharData): void {
throw new Error('not implemented');
}
public getAsCharData(): CharData {
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
}
}
export class CharacterJoinerService implements ICharacterJoinerService {
public serviceBrand: undefined;
private _characterJoiners: ICharacterJoiner[] = [];
private _nextCharacterJoinerId: number = 0;
private _workCell: CellData = new CellData();
constructor(
@IBufferService private _bufferService: IBufferService
) { }
public register(handler: (text: string) => [number, number][]): number {
const joiner: ICharacterJoiner = {
id: this._nextCharacterJoinerId++,
handler
};
this._characterJoiners.push(joiner);
return joiner.id;
}
public deregister(joinerId: number): boolean {
for (let i = 0; i < this._characterJoiners.length; i++) {
if (this._characterJoiners[i].id === joinerId) {
this._characterJoiners.splice(i, 1);
return true;
}
}
return false;
}
public getJoinedCharacters(row: number): [number, number][] {
if (this._characterJoiners.length === 0) {
return [];
}
const line = this._bufferService.buffer.lines.get(row);
if (!line || line.length === 0) {
return [];
}
const ranges: [number, number][] = [];
const lineStr = line.translateToString(true);
// Because some cells can be represented by multiple javascript characters,
// we track the cell and the string indexes separately. This allows us to
// translate the string ranges we get from the joiners back into cell ranges
// for use when rendering
let rangeStartColumn = 0;
let currentStringIndex = 0;
let rangeStartStringIndex = 0;
let rangeAttrFG = line.getFg(0);
let rangeAttrBG = line.getBg(0);
for (let x = 0; x < line.getTrimmedLength(); x++) {
line.loadCell(x, this._workCell);
if (this._workCell.getWidth() === 0) {
// If this character is of width 0, skip it.
continue;
}
// End of range
if (this._workCell.fg !== rangeAttrFG || this._workCell.bg !== rangeAttrBG) {
// If we ended up with a sequence of more than one character,
// look for ranges to join.
if (x - rangeStartColumn > 1) {
const joinedRanges = this._getJoinedRanges(
lineStr,
rangeStartStringIndex,
currentStringIndex,
line,
rangeStartColumn
);
for (let i = 0; i < joinedRanges.length; i++) {
ranges.push(joinedRanges[i]);
}
}
// Reset our markers for a new range.
rangeStartColumn = x;
rangeStartStringIndex = currentStringIndex;
rangeAttrFG = this._workCell.fg;
rangeAttrBG = this._workCell.bg;
}
currentStringIndex += this._workCell.getChars().length || WHITESPACE_CELL_CHAR.length;
}
// Process any trailing ranges.
if (this._bufferService.cols - rangeStartColumn > 1) {
const joinedRanges = this._getJoinedRanges(
lineStr,
rangeStartStringIndex,
currentStringIndex,
line,
rangeStartColumn
);
for (let i = 0; i < joinedRanges.length; i++) {
ranges.push(joinedRanges[i]);
}
}
return ranges;
}
/**
* Given a segment of a line of text, find all ranges of text that should be
* joined in a single rendering unit. Ranges are internally converted to
* column ranges, rather than string ranges.
* @param line String representation of the full line of text
* @param startIndex Start position of the range to search in the string (inclusive)
* @param endIndex End position of the range to search in the string (exclusive)
*/
private _getJoinedRanges(line: string, startIndex: number, endIndex: number, lineData: IBufferLine, startCol: number): [number, number][] {
const text = line.substring(startIndex, endIndex);
// At this point we already know that there is at least one joiner so
// we can just pull its value and assign it directly rather than
// merging it into an empty array, which incurs unnecessary writes.
let allJoinedRanges: [number, number][] = [];
try {
allJoinedRanges = this._characterJoiners[0].handler(text);
} catch (error) {
console.error(error);
}
for (let i = 1; i < this._characterJoiners.length; i++) {
// We merge any overlapping ranges across the different joiners
try {
const joinerRanges = this._characterJoiners[i].handler(text);
for (let j = 0; j < joinerRanges.length; j++) {
CharacterJoinerService._mergeRanges(allJoinedRanges, joinerRanges[j]);
}
} catch (error) {
console.error(error);
}
}
this._stringRangesToCellRanges(allJoinedRanges, lineData, startCol);
return allJoinedRanges;
}
/**
* Modifies the provided ranges in-place to adjust for variations between
* string length and cell width so that the range represents a cell range,
* rather than the string range the joiner provides.
* @param ranges String ranges containing start (inclusive) and end (exclusive) index
* @param line Cell data for the relevant line in the terminal
* @param startCol Offset within the line to start from
*/
private _stringRangesToCellRanges(ranges: [number, number][], line: IBufferLine, startCol: number): void {
let currentRangeIndex = 0;
let currentRangeStarted = false;
let currentStringIndex = 0;
let currentRange = ranges[currentRangeIndex];
// If we got through all of the ranges, stop searching
if (!currentRange) {
return;
}
for (let x = startCol; x < this._bufferService.cols; x++) {
const width = line.getWidth(x);
const length = line.getString(x).length || WHITESPACE_CELL_CHAR.length;
// We skip zero-width characters when creating the string to join the text
// so we do the same here
if (width === 0) {
continue;
}
// Adjust the start of the range
if (!currentRangeStarted && currentRange[0] <= currentStringIndex) {
currentRange[0] = x;
currentRangeStarted = true;
}
// Adjust the end of the range
if (currentRange[1] <= currentStringIndex) {
currentRange[1] = x;
// We're finished with this range, so we move to the next one
currentRange = ranges[++currentRangeIndex];
// If there are no more ranges left, stop searching
if (!currentRange) {
break;
}
// Ranges can be on adjacent characters. Because the end index of the
// ranges are exclusive, this means that the index for the start of a
// range can be the same as the end index of the previous range. To
// account for the start of the next range, we check here just in case.
if (currentRange[0] <= currentStringIndex) {
currentRange[0] = x;
currentRangeStarted = true;
} else {
currentRangeStarted = false;
}
}
// Adjust the string index based on the character length to line up with
// the column adjustment
currentStringIndex += length;
}
// If there is still a range left at the end, it must extend all the way to
// the end of the line.
if (currentRange) {
currentRange[1] = this._bufferService.cols;
}
}
/**
* Merges the range defined by the provided start and end into the list of
* existing ranges. The merge is done in place on the existing range for
* performance and is also returned.
* @param ranges Existing range list
* @param newRange Tuple of two numbers representing the new range to merge in.
* @returns The ranges input with the new range merged in place
*/
private static _mergeRanges(ranges: [number, number][], newRange: [number, number]): [number, number][] {
let inRange = false;
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
if (!inRange) {
if (newRange[1] <= range[0]) {
// Case 1: New range is before the search range
ranges.splice(i, 0, newRange);
return ranges;
}
if (newRange[1] <= range[1]) {
// Case 2: New range is either wholly contained within the
// search range or overlaps with the front of it
range[0] = Math.min(newRange[0], range[0]);
return ranges;
}
if (newRange[0] < range[1]) {
// Case 3: New range either wholly contains the search range
// or overlaps with the end of it
range[0] = Math.min(newRange[0], range[0]);
inRange = true;
}
// Case 4: New range starts after the search range
continue;
} else {
if (newRange[1] <= range[0]) {
// Case 5: New range extends from previous range but doesn't
// reach the current one
ranges[i - 1][1] = newRange[1];
return ranges;
}
if (newRange[1] <= range[1]) {
// Case 6: New range extends from prvious range into the
// current range
ranges[i - 1][1] = Math.max(newRange[1], range[1]);
ranges.splice(i, 1);
return ranges;
}
// Case 7: New range extends from previous range past the
// end of the current range
ranges.splice(i, 1);
i--;
}
}
if (inRange) {
// Case 8: New range extends past the last existing range
ranges[ranges.length - 1][1] = newRange[1];
} else {
// Case 9: New range starts after the last existing range
ranges.push(newRange);
}
return ranges;
}
}

View File

@@ -0,0 +1,33 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICoreBrowserService } from './Services';
export class CoreBrowserService implements ICoreBrowserService {
public serviceBrand: undefined;
private _isFocused = false;
private _cachedIsFocused: boolean | undefined = undefined;
constructor(
private _textarea: HTMLTextAreaElement,
public readonly window: Window & typeof globalThis
) {
this._textarea.addEventListener('focus', () => this._isFocused = true);
this._textarea.addEventListener('blur', () => this._isFocused = false);
}
public get dpr(): number {
return this.window.devicePixelRatio;
}
public get isFocused(): boolean {
if (this._cachedIsFocused === undefined) {
this._cachedIsFocused = this._isFocused && this._textarea.ownerDocument.hasFocus();
queueMicrotask(() => this._cachedIsFocused = undefined);
}
return this._cachedIsFocused;
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICharSizeService, IRenderService, IMouseService } from './Services';
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
export class MouseService implements IMouseService {
public serviceBrand: undefined;
constructor(
@IRenderService private readonly _renderService: IRenderService,
@ICharSizeService private readonly _charSizeService: ICharSizeService
) {
}
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
window,
event,
element,
colCount,
rowCount,
this._charSizeService.hasValidSize,
this._renderService.dimensions.css.cell.width,
this._renderService.dimensions.css.cell.height,
isSelection
);
}
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
const coords = getCoordsRelativeToElement(window, event, element);
if (!this._charSizeService.hasValidSize) {
return undefined;
}
coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
return {
col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
x: Math.floor(coords[0]),
y: Math.floor(coords[1])
};
}
}

View File

@@ -0,0 +1,284 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { addDisposableDomListener } from 'browser/Lifecycle';
import { RenderDebouncer } from 'browser/RenderDebouncer';
import { ScreenDprMonitor } from 'browser/ScreenDprMonitor';
import { IRenderDebouncerWithCallback } from 'browser/Types';
import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable, MutableDisposable } from 'common/Lifecycle';
import { DebouncedIdleTask } from 'common/TaskQueue';
import { IBufferService, IDecorationService, IOptionsService } from 'common/services/Services';
interface ISelectionState {
start: [number, number] | undefined;
end: [number, number] | undefined;
columnSelectMode: boolean;
}
export class RenderService extends Disposable implements IRenderService {
public serviceBrand: undefined;
private _renderer: MutableDisposable<IRenderer> = this.register(new MutableDisposable());
private _renderDebouncer: IRenderDebouncerWithCallback;
private _screenDprMonitor: ScreenDprMonitor;
private _pausedResizeTask = new DebouncedIdleTask();
private _isPaused: boolean = false;
private _needsFullRefresh: boolean = false;
private _isNextRenderRedrawOnly: boolean = true;
private _needsSelectionRefresh: boolean = false;
private _canvasWidth: number = 0;
private _canvasHeight: number = 0;
private _selectionState: ISelectionState = {
start: undefined,
end: undefined,
columnSelectMode: false
};
private readonly _onDimensionsChange = this.register(new EventEmitter<IRenderDimensions>());
public readonly onDimensionsChange = this._onDimensionsChange.event;
private readonly _onRenderedViewportChange = this.register(new EventEmitter<{ start: number, end: number }>());
public readonly onRenderedViewportChange = this._onRenderedViewportChange.event;
private readonly _onRender = this.register(new EventEmitter<{ start: number, end: number }>());
public readonly onRender = this._onRender.event;
private readonly _onRefreshRequest = this.register(new EventEmitter<{ start: number, end: number }>());
public readonly onRefreshRequest = this._onRefreshRequest.event;
public get dimensions(): IRenderDimensions { return this._renderer.value!.dimensions; }
constructor(
private _rowCount: number,
screenElement: HTMLElement,
@IOptionsService optionsService: IOptionsService,
@ICharSizeService private readonly _charSizeService: ICharSizeService,
@IDecorationService decorationService: IDecorationService,
@IBufferService bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
@IThemeService themeService: IThemeService
) {
super();
this._renderDebouncer = new RenderDebouncer(coreBrowserService.window, (start, end) => this._renderRows(start, end));
this.register(this._renderDebouncer);
this._screenDprMonitor = new ScreenDprMonitor(coreBrowserService.window);
this._screenDprMonitor.setListener(() => this.handleDevicePixelRatioChange());
this.register(this._screenDprMonitor);
this.register(bufferService.onResize(() => this._fullRefresh()));
this.register(bufferService.buffers.onBufferActivate(() => this._renderer.value?.clear()));
this.register(optionsService.onOptionChange(() => this._handleOptionsChanged()));
this.register(this._charSizeService.onCharSizeChange(() => this.handleCharSizeChanged()));
// Do a full refresh whenever any decoration is added or removed. This may not actually result
// in changes but since decorations should be used sparingly or added/removed all in the same
// frame this should have minimal performance impact.
this.register(decorationService.onDecorationRegistered(() => this._fullRefresh()));
this.register(decorationService.onDecorationRemoved(() => this._fullRefresh()));
// Clear the renderer when the a change that could affect glyphs occurs
this.register(optionsService.onMultipleOptionChange([
'customGlyphs',
'drawBoldTextInBrightColors',
'letterSpacing',
'lineHeight',
'fontFamily',
'fontSize',
'fontWeight',
'fontWeightBold',
'minimumContrastRatio'
], () => {
this.clear();
this.handleResize(bufferService.cols, bufferService.rows);
this._fullRefresh();
}));
// Refresh the cursor line when the cursor changes
this.register(optionsService.onMultipleOptionChange([
'cursorBlink',
'cursorStyle'
], () => this.refreshRows(bufferService.buffer.y, bufferService.buffer.y, true)));
// dprchange should handle this case, we need this as well for browsers that don't support the
// matchMedia query.
this.register(addDisposableDomListener(coreBrowserService.window, 'resize', () => this.handleDevicePixelRatioChange()));
this.register(themeService.onChangeColors(() => this._fullRefresh()));
// Detect whether IntersectionObserver is detected and enable renderer pause
// and resume based on terminal visibility if so
if ('IntersectionObserver' in coreBrowserService.window) {
const observer = new coreBrowserService.window.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
observer.observe(screenElement);
this.register({ dispose: () => observer.disconnect() });
}
}
private _handleIntersectionChange(entry: IntersectionObserverEntry): void {
this._isPaused = entry.isIntersecting === undefined ? (entry.intersectionRatio === 0) : !entry.isIntersecting;
// Terminal was hidden on open
if (!this._isPaused && !this._charSizeService.hasValidSize) {
this._charSizeService.measure();
}
if (!this._isPaused && this._needsFullRefresh) {
this._pausedResizeTask.flush();
this.refreshRows(0, this._rowCount - 1);
this._needsFullRefresh = false;
}
}
public refreshRows(start: number, end: number, isRedrawOnly: boolean = false): void {
if (this._isPaused) {
this._needsFullRefresh = true;
return;
}
if (!isRedrawOnly) {
this._isNextRenderRedrawOnly = false;
}
this._renderDebouncer.refresh(start, end, this._rowCount);
}
private _renderRows(start: number, end: number): void {
if (!this._renderer.value) {
return;
}
// Since this is debounced, a resize event could have happened between the time a refresh was
// requested and when this triggers. Clamp the values of start and end to ensure they're valid
// given the current viewport state.
start = Math.min(start, this._rowCount - 1);
end = Math.min(end, this._rowCount - 1);
// Render
this._renderer.value.renderRows(start, end);
// Update selection if needed
if (this._needsSelectionRefresh) {
this._renderer.value.handleSelectionChanged(this._selectionState.start, this._selectionState.end, this._selectionState.columnSelectMode);
this._needsSelectionRefresh = false;
}
// Fire render event only if it was not a redraw
if (!this._isNextRenderRedrawOnly) {
this._onRenderedViewportChange.fire({ start, end });
}
this._onRender.fire({ start, end });
this._isNextRenderRedrawOnly = true;
}
public resize(cols: number, rows: number): void {
this._rowCount = rows;
this._fireOnCanvasResize();
}
private _handleOptionsChanged(): void {
if (!this._renderer.value) {
return;
}
this.refreshRows(0, this._rowCount - 1);
this._fireOnCanvasResize();
}
private _fireOnCanvasResize(): void {
if (!this._renderer.value) {
return;
}
// Don't fire the event if the dimensions haven't changed
if (this._renderer.value.dimensions.css.canvas.width === this._canvasWidth && this._renderer.value.dimensions.css.canvas.height === this._canvasHeight) {
return;
}
this._onDimensionsChange.fire(this._renderer.value.dimensions);
}
public hasRenderer(): boolean {
return !!this._renderer.value;
}
public setRenderer(renderer: IRenderer): void {
this._renderer.value = renderer;
this._renderer.value.onRequestRedraw(e => this.refreshRows(e.start, e.end, true));
// Force a refresh
this._needsSelectionRefresh = true;
this._fullRefresh();
}
public addRefreshCallback(callback: FrameRequestCallback): number {
return this._renderDebouncer.addRefreshCallback(callback);
}
private _fullRefresh(): void {
if (this._isPaused) {
this._needsFullRefresh = true;
} else {
this.refreshRows(0, this._rowCount - 1);
}
}
public clearTextureAtlas(): void {
if (!this._renderer.value) {
return;
}
this._renderer.value.clearTextureAtlas?.();
this._fullRefresh();
}
public handleDevicePixelRatioChange(): void {
// Force char size measurement as DomMeasureStrategy(getBoundingClientRect) is not stable
// when devicePixelRatio changes
this._charSizeService.measure();
if (!this._renderer.value) {
return;
}
this._renderer.value.handleDevicePixelRatioChange();
this.refreshRows(0, this._rowCount - 1);
}
public handleResize(cols: number, rows: number): void {
if (!this._renderer.value) {
return;
}
if (this._isPaused) {
this._pausedResizeTask.set(() => this._renderer.value!.handleResize(cols, rows));
} else {
this._renderer.value.handleResize(cols, rows);
}
this._fullRefresh();
}
// TODO: Is this useful when we have onResize?
public handleCharSizeChanged(): void {
this._renderer.value?.handleCharSizeChanged();
}
public handleBlur(): void {
this._renderer.value?.handleBlur();
}
public handleFocus(): void {
this._renderer.value?.handleFocus();
}
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
this._selectionState.start = start;
this._selectionState.end = end;
this._selectionState.columnSelectMode = columnSelectMode;
this._renderer.value?.handleSelectionChanged(start, end, columnSelectMode);
}
public handleCursorMove(): void {
this._renderer.value?.handleCursorMove();
}
public clear(): void {
this._renderer.value?.clear();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IEvent } from 'common/EventEmitter';
import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
import { IColorSet, ReadonlyColorSet } from 'browser/Types';
import { ISelectionRedrawRequestEvent as ISelectionRequestRedrawEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
import { createDecorator } from 'common/services/ServiceRegistry';
import { AllColorIndex, IDisposable } from 'common/Types';
export const ICharSizeService = createDecorator<ICharSizeService>('CharSizeService');
export interface ICharSizeService {
serviceBrand: undefined;
readonly width: number;
readonly height: number;
readonly hasValidSize: boolean;
readonly onCharSizeChange: IEvent<void>;
measure(): void;
}
export const ICoreBrowserService = createDecorator<ICoreBrowserService>('CoreBrowserService');
export interface ICoreBrowserService {
serviceBrand: undefined;
readonly isFocused: boolean;
/**
* Parent window that the terminal is rendered into. DOM and rendering APIs
* (e.g. requestAnimationFrame) should be invoked in the context of this
* window.
*/
readonly window: Window & typeof globalThis;
/**
* Helper for getting the devicePixelRatio of the parent window.
*/
readonly dpr: number;
}
export const IMouseService = createDecorator<IMouseService>('MouseService');
export interface IMouseService {
serviceBrand: undefined;
getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined;
getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined;
}
export const IRenderService = createDecorator<IRenderService>('RenderService');
export interface IRenderService extends IDisposable {
serviceBrand: undefined;
onDimensionsChange: IEvent<IRenderDimensions>;
/**
* Fires when buffer changes are rendered. This does not fire when only cursor
* or selections are rendered.
*/
onRenderedViewportChange: IEvent<{ start: number, end: number }>;
/**
* Fires on render
*/
onRender: IEvent<{ start: number, end: number }>;
onRefreshRequest: IEvent<{ start: number, end: number }>;
dimensions: IRenderDimensions;
addRefreshCallback(callback: FrameRequestCallback): number;
refreshRows(start: number, end: number): void;
clearTextureAtlas(): void;
resize(cols: number, rows: number): void;
hasRenderer(): boolean;
setRenderer(renderer: IRenderer): void;
handleDevicePixelRatioChange(): void;
handleResize(cols: number, rows: number): void;
handleCharSizeChanged(): void;
handleBlur(): void;
handleFocus(): void;
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
handleCursorMove(): void;
clear(): void;
}
export const ISelectionService = createDecorator<ISelectionService>('SelectionService');
export interface ISelectionService {
serviceBrand: undefined;
readonly selectionText: string;
readonly hasSelection: boolean;
readonly selectionStart: [number, number] | undefined;
readonly selectionEnd: [number, number] | undefined;
readonly onLinuxMouseSelection: IEvent<string>;
readonly onRequestRedraw: IEvent<ISelectionRequestRedrawEvent>;
readonly onRequestScrollLines: IEvent<ISelectionRequestScrollLinesEvent>;
readonly onSelectionChange: IEvent<void>;
disable(): void;
enable(): void;
reset(): void;
setSelection(row: number, col: number, length: number): void;
selectAll(): void;
selectLines(start: number, end: number): void;
clearSelection(): void;
rightClickSelect(event: MouseEvent): void;
shouldColumnSelect(event: KeyboardEvent | MouseEvent): boolean;
shouldForceSelection(event: MouseEvent): boolean;
refresh(isLinuxMouseSelection?: boolean): void;
handleMouseDown(event: MouseEvent): void;
isCellInSelection(x: number, y: number): boolean;
}
export const ICharacterJoinerService = createDecorator<ICharacterJoinerService>('CharacterJoinerService');
export interface ICharacterJoinerService {
serviceBrand: undefined;
register(handler: (text: string) => [number, number][]): number;
deregister(joinerId: number): boolean;
getJoinedCharacters(row: number): [number, number][];
}
export const IThemeService = createDecorator<IThemeService>('ThemeService');
export interface IThemeService {
serviceBrand: undefined;
readonly colors: ReadonlyColorSet;
readonly onChangeColors: IEvent<ReadonlyColorSet>;
restoreColor(slot?: AllColorIndex): void;
/**
* Allows external modifying of colors in the theme, this is used instead of {@link colors} to
* prevent accidental writes.
*/
modifyColors(callback: (colors: IColorSet) => void): void;
}

View File

@@ -0,0 +1,237 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ColorContrastCache } from 'browser/ColorContrastCache';
import { IThemeService } from 'browser/services/Services';
import { IColorContrastCache, IColorSet, ReadonlyColorSet } from 'browser/Types';
import { channels, color, css, NULL_COLOR } from 'common/Color';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { IOptionsService, ITheme } from 'common/services/Services';
import { AllColorIndex, IColor, SpecialColorIndex } from 'common/Types';
interface IRestoreColorSet {
foreground: IColor;
background: IColor;
cursor: IColor;
ansi: IColor[];
}
const DEFAULT_FOREGROUND = css.toColor('#ffffff');
const DEFAULT_BACKGROUND = css.toColor('#000000');
const DEFAULT_CURSOR = css.toColor('#ffffff');
const DEFAULT_CURSOR_ACCENT = css.toColor('#000000');
const DEFAULT_SELECTION = {
css: 'rgba(255, 255, 255, 0.3)',
rgba: 0xFFFFFF4D
};
// An IIFE to generate DEFAULT_ANSI_COLORS.
export const DEFAULT_ANSI_COLORS = Object.freeze((() => {
const colors = [
// dark:
css.toColor('#2e3436'),
css.toColor('#cc0000'),
css.toColor('#4e9a06'),
css.toColor('#c4a000'),
css.toColor('#3465a4'),
css.toColor('#75507b'),
css.toColor('#06989a'),
css.toColor('#d3d7cf'),
// bright:
css.toColor('#555753'),
css.toColor('#ef2929'),
css.toColor('#8ae234'),
css.toColor('#fce94f'),
css.toColor('#729fcf'),
css.toColor('#ad7fa8'),
css.toColor('#34e2e2'),
css.toColor('#eeeeec')
];
// Fill in the remaining 240 ANSI colors.
// Generate colors (16-231)
const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff];
for (let i = 0; i < 216; i++) {
const r = v[(i / 36) % 6 | 0];
const g = v[(i / 6) % 6 | 0];
const b = v[i % 6];
colors.push({
css: channels.toCss(r, g, b),
rgba: channels.toRgba(r, g, b)
});
}
// Generate greys (232-255)
for (let i = 0; i < 24; i++) {
const c = 8 + i * 10;
colors.push({
css: channels.toCss(c, c, c),
rgba: channels.toRgba(c, c, c)
});
}
return colors;
})());
export class ThemeService extends Disposable implements IThemeService {
public serviceBrand: undefined;
private _colors: IColorSet;
private _contrastCache: IColorContrastCache = new ColorContrastCache();
private _halfContrastCache: IColorContrastCache = new ColorContrastCache();
private _restoreColors!: IRestoreColorSet;
public get colors(): ReadonlyColorSet { return this._colors; }
private readonly _onChangeColors = this.register(new EventEmitter<ReadonlyColorSet>());
public readonly onChangeColors = this._onChangeColors.event;
constructor(
@IOptionsService private readonly _optionsService: IOptionsService
) {
super();
this._colors = {
foreground: DEFAULT_FOREGROUND,
background: DEFAULT_BACKGROUND,
cursor: DEFAULT_CURSOR,
cursorAccent: DEFAULT_CURSOR_ACCENT,
selectionForeground: undefined,
selectionBackgroundTransparent: DEFAULT_SELECTION,
selectionBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION),
selectionInactiveBackgroundTransparent: DEFAULT_SELECTION,
selectionInactiveBackgroundOpaque: color.blend(DEFAULT_BACKGROUND, DEFAULT_SELECTION),
ansi: DEFAULT_ANSI_COLORS.slice(),
contrastCache: this._contrastCache,
halfContrastCache: this._halfContrastCache
};
this._updateRestoreColors();
this._setTheme(this._optionsService.rawOptions.theme);
this.register(this._optionsService.onSpecificOptionChange('minimumContrastRatio', () => this._contrastCache.clear()));
this.register(this._optionsService.onSpecificOptionChange('theme', () => this._setTheme(this._optionsService.rawOptions.theme)));
}
/**
* Sets the terminal's theme.
* @param theme The theme to use. If a partial theme is provided then default
* colors will be used where colors are not defined.
*/
private _setTheme(theme: ITheme = {}): void {
const colors = this._colors;
colors.foreground = parseColor(theme.foreground, DEFAULT_FOREGROUND);
colors.background = parseColor(theme.background, DEFAULT_BACKGROUND);
colors.cursor = parseColor(theme.cursor, DEFAULT_CURSOR);
colors.cursorAccent = parseColor(theme.cursorAccent, DEFAULT_CURSOR_ACCENT);
colors.selectionBackgroundTransparent = parseColor(theme.selectionBackground, DEFAULT_SELECTION);
colors.selectionBackgroundOpaque = color.blend(colors.background, colors.selectionBackgroundTransparent);
colors.selectionInactiveBackgroundTransparent = parseColor(theme.selectionInactiveBackground, colors.selectionBackgroundTransparent);
colors.selectionInactiveBackgroundOpaque = color.blend(colors.background, colors.selectionInactiveBackgroundTransparent);
colors.selectionForeground = theme.selectionForeground ? parseColor(theme.selectionForeground, NULL_COLOR) : undefined;
if (colors.selectionForeground === NULL_COLOR) {
colors.selectionForeground = undefined;
}
/**
* If selection color is opaque, blend it with background with 0.3 opacity
* Issue #2737
*/
if (color.isOpaque(colors.selectionBackgroundTransparent)) {
const opacity = 0.3;
colors.selectionBackgroundTransparent = color.opacity(colors.selectionBackgroundTransparent, opacity);
}
if (color.isOpaque(colors.selectionInactiveBackgroundTransparent)) {
const opacity = 0.3;
colors.selectionInactiveBackgroundTransparent = color.opacity(colors.selectionInactiveBackgroundTransparent, opacity);
}
colors.ansi = DEFAULT_ANSI_COLORS.slice();
colors.ansi[0] = parseColor(theme.black, DEFAULT_ANSI_COLORS[0]);
colors.ansi[1] = parseColor(theme.red, DEFAULT_ANSI_COLORS[1]);
colors.ansi[2] = parseColor(theme.green, DEFAULT_ANSI_COLORS[2]);
colors.ansi[3] = parseColor(theme.yellow, DEFAULT_ANSI_COLORS[3]);
colors.ansi[4] = parseColor(theme.blue, DEFAULT_ANSI_COLORS[4]);
colors.ansi[5] = parseColor(theme.magenta, DEFAULT_ANSI_COLORS[5]);
colors.ansi[6] = parseColor(theme.cyan, DEFAULT_ANSI_COLORS[6]);
colors.ansi[7] = parseColor(theme.white, DEFAULT_ANSI_COLORS[7]);
colors.ansi[8] = parseColor(theme.brightBlack, DEFAULT_ANSI_COLORS[8]);
colors.ansi[9] = parseColor(theme.brightRed, DEFAULT_ANSI_COLORS[9]);
colors.ansi[10] = parseColor(theme.brightGreen, DEFAULT_ANSI_COLORS[10]);
colors.ansi[11] = parseColor(theme.brightYellow, DEFAULT_ANSI_COLORS[11]);
colors.ansi[12] = parseColor(theme.brightBlue, DEFAULT_ANSI_COLORS[12]);
colors.ansi[13] = parseColor(theme.brightMagenta, DEFAULT_ANSI_COLORS[13]);
colors.ansi[14] = parseColor(theme.brightCyan, DEFAULT_ANSI_COLORS[14]);
colors.ansi[15] = parseColor(theme.brightWhite, DEFAULT_ANSI_COLORS[15]);
if (theme.extendedAnsi) {
const colorCount = Math.min(colors.ansi.length - 16, theme.extendedAnsi.length);
for (let i = 0; i < colorCount; i++) {
colors.ansi[i + 16] = parseColor(theme.extendedAnsi[i], DEFAULT_ANSI_COLORS[i + 16]);
}
}
// Clear our the cache
this._contrastCache.clear();
this._halfContrastCache.clear();
this._updateRestoreColors();
this._onChangeColors.fire(this.colors);
}
public restoreColor(slot?: AllColorIndex): void {
this._restoreColor(slot);
this._onChangeColors.fire(this.colors);
}
private _restoreColor(slot: AllColorIndex | undefined): void {
// unset slot restores all ansi colors
if (slot === undefined) {
for (let i = 0; i < this._restoreColors.ansi.length; ++i) {
this._colors.ansi[i] = this._restoreColors.ansi[i];
}
return;
}
switch (slot) {
case SpecialColorIndex.FOREGROUND:
this._colors.foreground = this._restoreColors.foreground;
break;
case SpecialColorIndex.BACKGROUND:
this._colors.background = this._restoreColors.background;
break;
case SpecialColorIndex.CURSOR:
this._colors.cursor = this._restoreColors.cursor;
break;
default:
this._colors.ansi[slot] = this._restoreColors.ansi[slot];
}
}
public modifyColors(callback: (colors: IColorSet) => void): void {
callback(this._colors);
// Assume the change happened
this._onChangeColors.fire(this.colors);
}
private _updateRestoreColors(): void {
this._restoreColors = {
foreground: this._colors.foreground,
background: this._colors.background,
cursor: this._colors.cursor,
ansi: this._colors.ansi.slice()
};
}
}
function parseColor(
cssString: string | undefined,
fallback: IColor
): IColor {
if (cssString !== undefined) {
try {
return css.toColor(cssString);
} catch {
// no-op
}
}
return fallback;
}

View File

@@ -0,0 +1,241 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICircularList } from 'common/Types';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
export interface IInsertEvent {
index: number;
amount: number;
}
export interface IDeleteEvent {
index: number;
amount: number;
}
/**
* Represents a circular list; a list with a maximum size that wraps around when push is called,
* overriding values at the start of the list.
*/
export class CircularList<T> extends Disposable implements ICircularList<T> {
protected _array: (T | undefined)[];
private _startIndex: number;
private _length: number;
public readonly onDeleteEmitter = this.register(new EventEmitter<IDeleteEvent>());
public readonly onDelete = this.onDeleteEmitter.event;
public readonly onInsertEmitter = this.register(new EventEmitter<IInsertEvent>());
public readonly onInsert = this.onInsertEmitter.event;
public readonly onTrimEmitter = this.register(new EventEmitter<number>());
public readonly onTrim = this.onTrimEmitter.event;
constructor(
private _maxLength: number
) {
super();
this._array = new Array<T>(this._maxLength);
this._startIndex = 0;
this._length = 0;
}
public get maxLength(): number {
return this._maxLength;
}
public set maxLength(newMaxLength: number) {
// There was no change in maxLength, return early.
if (this._maxLength === newMaxLength) {
return;
}
// Reconstruct array, starting at index 0. Only transfer values from the
// indexes 0 to length.
const newArray = new Array<T | undefined>(newMaxLength);
for (let i = 0; i < Math.min(newMaxLength, this.length); i++) {
newArray[i] = this._array[this._getCyclicIndex(i)];
}
this._array = newArray;
this._maxLength = newMaxLength;
this._startIndex = 0;
}
public get length(): number {
return this._length;
}
public set length(newLength: number) {
if (newLength > this._length) {
for (let i = this._length; i < newLength; i++) {
this._array[i] = undefined;
}
}
this._length = newLength;
}
/**
* Gets the value at an index.
*
* Note that for performance reasons there is no bounds checking here, the index reference is
* circular so this should always return a value and never throw.
* @param index The index of the value to get.
* @returns The value corresponding to the index.
*/
public get(index: number): T | undefined {
return this._array[this._getCyclicIndex(index)];
}
/**
* Sets the value at an index.
*
* Note that for performance reasons there is no bounds checking here, the index reference is
* circular so this should always return a value and never throw.
* @param index The index to set.
* @param value The value to set.
*/
public set(index: number, value: T | undefined): void {
this._array[this._getCyclicIndex(index)] = value;
}
/**
* Pushes a new value onto the list, wrapping around to the start of the array, overriding index 0
* if the maximum length is reached.
* @param value The value to push onto the list.
*/
public push(value: T): void {
this._array[this._getCyclicIndex(this._length)] = value;
if (this._length === this._maxLength) {
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
} else {
this._length++;
}
}
/**
* Advance ringbuffer index and return current element for recycling.
* Note: The buffer must be full for this method to work.
* @throws When the buffer is not full.
*/
public recycle(): T {
if (this._length !== this._maxLength) {
throw new Error('Can only recycle when the buffer is full');
}
this._startIndex = ++this._startIndex % this._maxLength;
this.onTrimEmitter.fire(1);
return this._array[this._getCyclicIndex(this._length - 1)]!;
}
/**
* Ringbuffer is at max length.
*/
public get isFull(): boolean {
return this._length === this._maxLength;
}
/**
* Removes and returns the last value on the list.
* @returns The popped value.
*/
public pop(): T | undefined {
return this._array[this._getCyclicIndex(this._length-- - 1)];
}
/**
* Deletes and/or inserts items at a particular index (in that order). Unlike
* Array.prototype.splice, this operation does not return the deleted items as a new array in
* order to save creating a new array. Note that this operation may shift all values in the list
* in the worst case.
* @param start The index to delete and/or insert.
* @param deleteCount The number of elements to delete.
* @param items The items to insert.
*/
public splice(start: number, deleteCount: number, ...items: T[]): void {
// Delete items
if (deleteCount) {
for (let i = start; i < this._length - deleteCount; i++) {
this._array[this._getCyclicIndex(i)] = this._array[this._getCyclicIndex(i + deleteCount)];
}
this._length -= deleteCount;
this.onDeleteEmitter.fire({ index: start, amount: deleteCount });
}
// Add items
for (let i = this._length - 1; i >= start; i--) {
this._array[this._getCyclicIndex(i + items.length)] = this._array[this._getCyclicIndex(i)];
}
for (let i = 0; i < items.length; i++) {
this._array[this._getCyclicIndex(start + i)] = items[i];
}
if (items.length) {
this.onInsertEmitter.fire({ index: start, amount: items.length });
}
// Adjust length as needed
if (this._length + items.length > this._maxLength) {
const countToTrim = (this._length + items.length) - this._maxLength;
this._startIndex += countToTrim;
this._length = this._maxLength;
this.onTrimEmitter.fire(countToTrim);
} else {
this._length += items.length;
}
}
/**
* Trims a number of items from the start of the list.
* @param count The number of items to remove.
*/
public trimStart(count: number): void {
if (count > this._length) {
count = this._length;
}
this._startIndex += count;
this._length -= count;
this.onTrimEmitter.fire(count);
}
public shiftElements(start: number, count: number, offset: number): void {
if (count <= 0) {
return;
}
if (start < 0 || start >= this._length) {
throw new Error('start argument out of range');
}
if (start + offset < 0) {
throw new Error('Cannot shift elements in list beyond index 0');
}
if (offset > 0) {
for (let i = count - 1; i >= 0; i--) {
this.set(start + i + offset, this.get(start + i));
}
const expandListBy = (start + count + offset) - this._length;
if (expandListBy > 0) {
this._length += expandListBy;
while (this._length > this._maxLength) {
this._length--;
this._startIndex++;
this.onTrimEmitter.fire(1);
}
}
} else {
for (let i = 0; i < count; i++) {
this.set(start + i + offset, this.get(start + i));
}
}
}
/**
* Gets the cyclic index for the specified regular index. The cyclic index can then be used on the
* backing array to get the element associated with the regular index.
* @param index The regular index.
* @returns The cyclic index.
*/
private _getCyclicIndex(index: number): number {
return (this._startIndex + index) % this._maxLength;
}
}

View File

@@ -0,0 +1,23 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
/*
* A simple utility for cloning values
*/
export function clone<T>(val: T, depth: number = 5): T {
if (typeof val !== 'object') {
return val;
}
// If we're cloning an array, use an array as the base, otherwise use an object
const clonedObject: any = Array.isArray(val) ? [] : {};
for (const key in val) {
// Recursively clone eack item unless we're at the maximum depth
clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1));
}
return clonedObject as T;
}

View File

@@ -0,0 +1,356 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { isNode } from 'common/Platform';
import { IColor, IColorRGB } from 'common/Types';
let $r = 0;
let $g = 0;
let $b = 0;
let $a = 0;
export const NULL_COLOR: IColor = {
css: '#00000000',
rgba: 0
};
/**
* Helper functions where the source type is "channels" (individual color channels as numbers).
*/
export namespace channels {
export function toCss(r: number, g: number, b: number, a?: number): string {
if (a !== undefined) {
return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}${toPaddedHex(a)}`;
}
return `#${toPaddedHex(r)}${toPaddedHex(g)}${toPaddedHex(b)}`;
}
export function toRgba(r: number, g: number, b: number, a: number = 0xFF): number {
// Note: The aggregated number is RGBA32 (BE), thus needs to be converted to ABGR32
// on LE systems, before it can be used for direct 32-bit buffer writes.
// >>> 0 forces an unsigned int
return (r << 24 | g << 16 | b << 8 | a) >>> 0;
}
}
/**
* Helper functions where the source type is `IColor`.
*/
export namespace color {
export function blend(bg: IColor, fg: IColor): IColor {
$a = (fg.rgba & 0xFF) / 255;
if ($a === 1) {
return {
css: fg.css,
rgba: fg.rgba
};
}
const fgR = (fg.rgba >> 24) & 0xFF;
const fgG = (fg.rgba >> 16) & 0xFF;
const fgB = (fg.rgba >> 8) & 0xFF;
const bgR = (bg.rgba >> 24) & 0xFF;
const bgG = (bg.rgba >> 16) & 0xFF;
const bgB = (bg.rgba >> 8) & 0xFF;
$r = bgR + Math.round((fgR - bgR) * $a);
$g = bgG + Math.round((fgG - bgG) * $a);
$b = bgB + Math.round((fgB - bgB) * $a);
const css = channels.toCss($r, $g, $b);
const rgba = channels.toRgba($r, $g, $b);
return { css, rgba };
}
export function isOpaque(color: IColor): boolean {
return (color.rgba & 0xFF) === 0xFF;
}
export function ensureContrastRatio(bg: IColor, fg: IColor, ratio: number): IColor | undefined {
const result = rgba.ensureContrastRatio(bg.rgba, fg.rgba, ratio);
if (!result) {
return undefined;
}
return rgba.toColor(
(result >> 24 & 0xFF),
(result >> 16 & 0xFF),
(result >> 8 & 0xFF)
);
}
export function opaque(color: IColor): IColor {
const rgbaColor = (color.rgba | 0xFF) >>> 0;
[$r, $g, $b] = rgba.toChannels(rgbaColor);
return {
css: channels.toCss($r, $g, $b),
rgba: rgbaColor
};
}
export function opacity(color: IColor, opacity: number): IColor {
$a = Math.round(opacity * 0xFF);
[$r, $g, $b] = rgba.toChannels(color.rgba);
return {
css: channels.toCss($r, $g, $b, $a),
rgba: channels.toRgba($r, $g, $b, $a)
};
}
export function multiplyOpacity(color: IColor, factor: number): IColor {
$a = color.rgba & 0xFF;
return opacity(color, ($a * factor) / 0xFF);
}
export function toColorRGB(color: IColor): IColorRGB {
return [(color.rgba >> 24) & 0xFF, (color.rgba >> 16) & 0xFF, (color.rgba >> 8) & 0xFF];
}
}
/**
* Helper functions where the source type is "css" (string: '#rgb', '#rgba', '#rrggbb',
* '#rrggbbaa').
*/
export namespace css {
let $ctx: CanvasRenderingContext2D | undefined;
let $litmusColor: CanvasGradient | undefined;
if (!isNode) {
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const ctx = canvas.getContext('2d', {
willReadFrequently: true
});
if (ctx) {
$ctx = ctx;
$ctx.globalCompositeOperation = 'copy';
$litmusColor = $ctx.createLinearGradient(0, 0, 1, 1);
}
}
/**
* Converts a css string to an IColor, this should handle all valid CSS color strings and will
* throw if it's invalid. The ideal format to use is `#rrggbb[aa]` as it's the fastest to parse.
*
* Only `#rgb[a]`, `#rrggbb[aa]`, `rgb()` and `rgba()` formats are supported when run in a Node
* environment.
*/
export function toColor(css: string): IColor {
// Formats: #rgb[a] and #rrggbb[aa]
if (css.match(/#[\da-f]{3,8}/i)) {
switch (css.length) {
case 4: { // #rgb
$r = parseInt(css.slice(1, 2).repeat(2), 16);
$g = parseInt(css.slice(2, 3).repeat(2), 16);
$b = parseInt(css.slice(3, 4).repeat(2), 16);
return rgba.toColor($r, $g, $b);
}
case 5: { // #rgba
$r = parseInt(css.slice(1, 2).repeat(2), 16);
$g = parseInt(css.slice(2, 3).repeat(2), 16);
$b = parseInt(css.slice(3, 4).repeat(2), 16);
$a = parseInt(css.slice(4, 5).repeat(2), 16);
return rgba.toColor($r, $g, $b, $a);
}
case 7: // #rrggbb
return {
css,
rgba: (parseInt(css.slice(1), 16) << 8 | 0xFF) >>> 0
};
case 9: // #rrggbbaa
return {
css,
rgba: parseInt(css.slice(1), 16) >>> 0
};
}
}
// Formats: rgb() or rgba()
const rgbaMatch = css.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);
if (rgbaMatch) {
$r = parseInt(rgbaMatch[1]);
$g = parseInt(rgbaMatch[2]);
$b = parseInt(rgbaMatch[3]);
$a = Math.round((rgbaMatch[5] === undefined ? 1 : parseFloat(rgbaMatch[5])) * 0xFF);
return rgba.toColor($r, $g, $b, $a);
}
// Validate the context is available for canvas-based color parsing
if (!$ctx || !$litmusColor) {
throw new Error('css.toColor: Unsupported css format');
}
// Validate the color using canvas fillStyle
// See https://html.spec.whatwg.org/multipage/canvas.html#fill-and-stroke-styles
$ctx.fillStyle = $litmusColor;
$ctx.fillStyle = css;
if (typeof $ctx.fillStyle !== 'string') {
throw new Error('css.toColor: Unsupported css format');
}
$ctx.fillRect(0, 0, 1, 1);
[$r, $g, $b, $a] = $ctx.getImageData(0, 0, 1, 1).data;
// Validate the color is non-transparent as color hue gets lost when drawn to the canvas
if ($a !== 0xFF) {
throw new Error('css.toColor: Unsupported css format');
}
// Extract the color from the canvas' fillStyle property which exposes the color value in rgba()
// format
// See https://html.spec.whatwg.org/multipage/canvas.html#serialisation-of-a-color
return {
rgba: channels.toRgba($r, $g, $b, $a),
css
};
}
}
/**
* Helper functions where the source type is "rgb" (number: 0xrrggbb).
*/
export namespace rgb {
/**
* Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio
* between two colors.
* @param rgb The color to use.
* @see https://www.w3.org/TR/WCAG20/#relativeluminancedef
*/
export function relativeLuminance(rgb: number): number {
return relativeLuminance2(
(rgb >> 16) & 0xFF,
(rgb >> 8 ) & 0xFF,
(rgb ) & 0xFF);
}
/**
* Gets the relative luminance of an RGB color, this is useful in determining the contrast ratio
* between two colors.
* @param r The red channel (0x00 to 0xFF).
* @param g The green channel (0x00 to 0xFF).
* @param b The blue channel (0x00 to 0xFF).
* @see https://www.w3.org/TR/WCAG20/#relativeluminancedef
*/
export function relativeLuminance2(r: number, g: number, b: number): number {
const rs = r / 255;
const gs = g / 255;
const bs = b / 255;
const rr = rs <= 0.03928 ? rs / 12.92 : Math.pow((rs + 0.055) / 1.055, 2.4);
const rg = gs <= 0.03928 ? gs / 12.92 : Math.pow((gs + 0.055) / 1.055, 2.4);
const rb = bs <= 0.03928 ? bs / 12.92 : Math.pow((bs + 0.055) / 1.055, 2.4);
return rr * 0.2126 + rg * 0.7152 + rb * 0.0722;
}
}
/**
* Helper functions where the source type is "rgba" (number: 0xrrggbbaa).
*/
export namespace rgba {
/**
* Given a foreground color and a background color, either increase or reduce the luminance of the
* foreground color until the specified contrast ratio is met. If pure white or black is hit
* without the contrast ratio being met, go the other direction using the background color as the
* foreground color and take either the first or second result depending on which has the higher
* contrast ratio.
*
* `undefined` will be returned if the contrast ratio is already met.
*
* @param bgRgba The background color in rgba format.
* @param fgRgba The foreground color in rgba format.
* @param ratio The contrast ratio to achieve.
*/
export function ensureContrastRatio(bgRgba: number, fgRgba: number, ratio: number): number | undefined {
const bgL = rgb.relativeLuminance(bgRgba >> 8);
const fgL = rgb.relativeLuminance(fgRgba >> 8);
const cr = contrastRatio(bgL, fgL);
if (cr < ratio) {
if (fgL < bgL) {
const resultA = reduceLuminance(bgRgba, fgRgba, ratio);
const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));
if (resultARatio < ratio) {
const resultB = increaseLuminance(bgRgba, fgRgba, ratio);
const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));
return resultARatio > resultBRatio ? resultA : resultB;
}
return resultA;
}
const resultA = increaseLuminance(bgRgba, fgRgba, ratio);
const resultARatio = contrastRatio(bgL, rgb.relativeLuminance(resultA >> 8));
if (resultARatio < ratio) {
const resultB = reduceLuminance(bgRgba, fgRgba, ratio);
const resultBRatio = contrastRatio(bgL, rgb.relativeLuminance(resultB >> 8));
return resultARatio > resultBRatio ? resultA : resultB;
}
return resultA;
}
return undefined;
}
export function reduceLuminance(bgRgba: number, fgRgba: number, ratio: number): number {
// This is a naive but fast approach to reducing luminance as converting to
// HSL and back is expensive
const bgR = (bgRgba >> 24) & 0xFF;
const bgG = (bgRgba >> 16) & 0xFF;
const bgB = (bgRgba >> 8) & 0xFF;
let fgR = (fgRgba >> 24) & 0xFF;
let fgG = (fgRgba >> 16) & 0xFF;
let fgB = (fgRgba >> 8) & 0xFF;
let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));
while (cr < ratio && (fgR > 0 || fgG > 0 || fgB > 0)) {
// Reduce by 10% until the ratio is hit
fgR -= Math.max(0, Math.ceil(fgR * 0.1));
fgG -= Math.max(0, Math.ceil(fgG * 0.1));
fgB -= Math.max(0, Math.ceil(fgB * 0.1));
cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));
}
return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;
}
export function increaseLuminance(bgRgba: number, fgRgba: number, ratio: number): number {
// This is a naive but fast approach to increasing luminance as converting to
// HSL and back is expensive
const bgR = (bgRgba >> 24) & 0xFF;
const bgG = (bgRgba >> 16) & 0xFF;
const bgB = (bgRgba >> 8) & 0xFF;
let fgR = (fgRgba >> 24) & 0xFF;
let fgG = (fgRgba >> 16) & 0xFF;
let fgB = (fgRgba >> 8) & 0xFF;
let cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));
while (cr < ratio && (fgR < 0xFF || fgG < 0xFF || fgB < 0xFF)) {
// Increase by 10% until the ratio is hit
fgR = Math.min(0xFF, fgR + Math.ceil((255 - fgR) * 0.1));
fgG = Math.min(0xFF, fgG + Math.ceil((255 - fgG) * 0.1));
fgB = Math.min(0xFF, fgB + Math.ceil((255 - fgB) * 0.1));
cr = contrastRatio(rgb.relativeLuminance2(fgR, fgG, fgB), rgb.relativeLuminance2(bgR, bgG, bgB));
}
return (fgR << 24 | fgG << 16 | fgB << 8 | 0xFF) >>> 0;
}
// FIXME: Move this to channels NS?
export function toChannels(value: number): [number, number, number, number] {
return [(value >> 24) & 0xFF, (value >> 16) & 0xFF, (value >> 8) & 0xFF, value & 0xFF];
}
export function toColor(r: number, g: number, b: number, a?: number): IColor {
return {
css: channels.toCss(r, g, b, a),
rgba: channels.toRgba(r, g, b, a)
};
}
}
export function toPaddedHex(c: number): string {
const s = c.toString(16);
return s.length < 2 ? '0' + s : s;
}
/**
* Gets the contrast ratio between two relative luminance values.
* @param l1 The first relative luminance.
* @param l2 The first relative luminance.
* @see https://www.w3.org/TR/WCAG20/#contrast-ratiodef
*/
export function contrastRatio(l1: number, l2: number): number {
if (l1 < l2) {
return (l2 + 0.05) / (l1 + 0.05);
}
return (l1 + 0.05) / (l2 + 0.05);
}

View File

@@ -0,0 +1,284 @@
/**
* Copyright (c) 2014-2020 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 { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
import { InstantiationService } from 'common/services/InstantiationService';
import { LogService } from 'common/services/LogService';
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
import { OptionsService } from 'common/services/OptionsService';
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent, ScrollSource } from 'common/Types';
import { CoreService } from 'common/services/CoreService';
import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter';
import { CoreMouseService } from 'common/services/CoreMouseService';
import { UnicodeService } from 'common/services/UnicodeService';
import { CharsetService } from 'common/services/CharsetService';
import { updateWindowsModeWrappedState } from 'common/WindowsMode';
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
import { IBufferSet } from 'common/buffer/Types';
import { InputHandler } from 'common/InputHandler';
import { WriteBuffer } from 'common/input/WriteBuffer';
import { OscLinkService } from 'common/services/OscLinkService';
// Only trigger this warning a single time per session
let hasWriteSyncWarnHappened = false;
export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
protected readonly _instantiationService: IInstantiationService;
protected readonly _bufferService: IBufferService;
protected readonly _logService: ILogService;
protected readonly _charsetService: ICharsetService;
protected readonly _oscLinkService: IOscLinkService;
public readonly coreMouseService: ICoreMouseService;
public readonly coreService: ICoreService;
public readonly unicodeService: IUnicodeService;
public readonly optionsService: IOptionsService;
protected _inputHandler: InputHandler;
private _writeBuffer: WriteBuffer;
private _windowsWrappingHeuristics = this.register(new MutableDisposable());
private readonly _onBinary = this.register(new EventEmitter<string>());
public readonly onBinary = this._onBinary.event;
private readonly _onData = this.register(new EventEmitter<string>());
public readonly onData = this._onData.event;
protected _onLineFeed = this.register(new EventEmitter<void>());
public readonly onLineFeed = this._onLineFeed.event;
private readonly _onResize = this.register(new EventEmitter<{ cols: number, rows: number }>());
public readonly onResize = this._onResize.event;
protected readonly _onWriteParsed = this.register(new EventEmitter<void>());
public readonly onWriteParsed = this._onWriteParsed.event;
/**
* Internally we track the source of the scroll but this is meaningless outside the library so
* it's filtered out.
*/
protected _onScrollApi?: EventEmitter<number, void>;
protected _onScroll = this.register(new EventEmitter<IScrollEvent, void>());
public get onScroll(): IEvent<number, void> {
if (!this._onScrollApi) {
this._onScrollApi = this.register(new EventEmitter<number, void>());
this._onScroll.event(ev => {
this._onScrollApi?.fire(ev.position);
});
}
return this._onScrollApi.event;
}
public get cols(): number { return this._bufferService.cols; }
public get rows(): number { return this._bufferService.rows; }
public get buffers(): IBufferSet { return this._bufferService.buffers; }
public get options(): Required<ITerminalOptions> { return this.optionsService.options; }
public set options(options: ITerminalOptions) {
for (const key in options) {
this.optionsService.options[key] = options[key];
}
}
constructor(
options: Partial<ITerminalOptions>
) {
super();
// Setup and initialize services
this._instantiationService = new InstantiationService();
this.optionsService = this.register(new OptionsService(options));
this._instantiationService.setService(IOptionsService, this.optionsService);
this._bufferService = this.register(this._instantiationService.createInstance(BufferService));
this._instantiationService.setService(IBufferService, this._bufferService);
this._logService = this.register(this._instantiationService.createInstance(LogService));
this._instantiationService.setService(ILogService, this._logService);
this.coreService = this.register(this._instantiationService.createInstance(CoreService));
this._instantiationService.setService(ICoreService, this.coreService);
this.coreMouseService = this.register(this._instantiationService.createInstance(CoreMouseService));
this._instantiationService.setService(ICoreMouseService, this.coreMouseService);
this.unicodeService = this.register(this._instantiationService.createInstance(UnicodeService));
this._instantiationService.setService(IUnicodeService, this.unicodeService);
this._charsetService = this._instantiationService.createInstance(CharsetService);
this._instantiationService.setService(ICharsetService, this._charsetService);
this._oscLinkService = this._instantiationService.createInstance(OscLinkService);
this._instantiationService.setService(IOscLinkService, this._oscLinkService);
// Register input handler and handle/forward events
this._inputHandler = this.register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService));
this.register(forwardEvent(this._inputHandler.onLineFeed, this._onLineFeed));
this.register(this._inputHandler);
// Setup listeners
this.register(forwardEvent(this._bufferService.onResize, this._onResize));
this.register(forwardEvent(this.coreService.onData, this._onData));
this.register(forwardEvent(this.coreService.onBinary, this._onBinary));
this.register(this.coreService.onRequestScrollToBottom(() => this.scrollToBottom()));
this.register(this.coreService.onUserInput(() => this._writeBuffer.handleUserInput()));
this.register(this.optionsService.onMultipleOptionChange(['windowsMode', 'windowsPty'], () => this._handleWindowsPtyOptionChange()));
this.register(this._bufferService.onScroll(event => {
this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL });
this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom);
}));
this.register(this._inputHandler.onScroll(event => {
this._onScroll.fire({ position: this._bufferService.buffer.ydisp, source: ScrollSource.TERMINAL });
this._inputHandler.markRangeDirty(this._bufferService.buffer.scrollTop, this._bufferService.buffer.scrollBottom);
}));
// Setup WriteBuffer
this._writeBuffer = this.register(new WriteBuffer((data, promiseResult) => this._inputHandler.parse(data, promiseResult)));
this.register(forwardEvent(this._writeBuffer.onWriteParsed, this._onWriteParsed));
}
public write(data: string | Uint8Array, callback?: () => void): void {
this._writeBuffer.write(data, callback);
}
/**
* Write data to terminal synchonously.
*
* This method is unreliable with async parser handlers, thus should not
* be used anymore. If you need blocking semantics on data input consider
* `write` with a callback instead.
*
* @deprecated Unreliable, will be removed soon.
*/
public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void {
if (this._logService.logLevel <= LogLevelEnum.WARN && !hasWriteSyncWarnHappened) {
this._logService.warn('writeSync is unreliable and will be removed soon.');
hasWriteSyncWarnHappened = true;
}
this._writeBuffer.writeSync(data, maxSubsequentCalls);
}
public resize(x: number, y: number): void {
if (isNaN(x) || isNaN(y)) {
return;
}
x = Math.max(x, MINIMUM_COLS);
y = Math.max(y, MINIMUM_ROWS);
this._bufferService.resize(x, y);
}
/**
* Scroll the terminal down 1 row, creating a blank line.
* @param eraseAttr The attribute data to use the for blank line.
* @param isWrapped Whether the new line is wrapped from the previous line.
*/
public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void {
this._bufferService.scroll(eraseAttr, isWrapped);
}
/**
* Scroll the display of the terminal
* @param disp The number of lines to scroll down (negative scroll up).
* @param suppressScrollEvent Don't emit the scroll event as scrollLines. This is used to avoid
* unwanted events being handled by the viewport when the event was triggered from the viewport
* originally.
* @param source Which component the event came from.
*/
public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void {
this._bufferService.scrollLines(disp, suppressScrollEvent, source);
}
public scrollPages(pageCount: number): void {
this.scrollLines(pageCount * (this.rows - 1));
}
public scrollToTop(): void {
this.scrollLines(-this._bufferService.buffer.ydisp);
}
public scrollToBottom(): void {
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);
}
}
/** Add handler for ESC escape sequence. See xterm.d.ts for details. */
public registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerEscHandler(id, callback);
}
/** Add handler for DCS escape sequence. See xterm.d.ts for details. */
public registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerDcsHandler(id, callback);
}
/** Add handler for CSI escape sequence. See xterm.d.ts for details. */
public registerCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerCsiHandler(id, callback);
}
/** Add handler for OSC escape sequence. See xterm.d.ts for details. */
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerOscHandler(ident, callback);
}
protected _setup(): void {
this._handleWindowsPtyOptionChange();
}
public reset(): void {
this._inputHandler.reset();
this._bufferService.reset();
this._charsetService.reset();
this.coreService.reset();
this.coreMouseService.reset();
}
private _handleWindowsPtyOptionChange(): void {
let value = false;
const windowsPty = this.optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) {
value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376);
} else if (this.optionsService.rawOptions.windowsMode) {
value = true;
}
if (value) {
this._enableWindowsWrappingHeuristics();
} else {
this._windowsWrappingHeuristics.clear();
}
}
protected _enableWindowsWrappingHeuristics(): void {
if (!this._windowsWrappingHeuristics.value) {
const disposables: IDisposable[] = [];
disposables.push(this.onLineFeed(updateWindowsModeWrappedState.bind(null, this._bufferService)));
disposables.push(this.registerCsiHandler({ final: 'H' }, () => {
updateWindowsModeWrappedState(this._bufferService);
return false;
}));
this._windowsWrappingHeuristics.value = toDisposable(() => {
for (const d of disposables) {
d.dispose();
}
});
}
}
}

View File

@@ -0,0 +1,73 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
interface IListener<T, U = void> {
(arg1: T, arg2: U): void;
}
export interface IEvent<T, U = void> {
(listener: (arg1: T, arg2: U) => any): IDisposable;
}
export interface IEventEmitter<T, U = void> {
event: IEvent<T, U>;
fire(arg1: T, arg2: U): void;
dispose(): void;
}
export class EventEmitter<T, U = void> implements IEventEmitter<T, U> {
private _listeners: IListener<T, U>[] = [];
private _event?: IEvent<T, U>;
private _disposed: boolean = false;
public get event(): IEvent<T, U> {
if (!this._event) {
this._event = (listener: (arg1: T, arg2: U) => any) => {
this._listeners.push(listener);
const disposable = {
dispose: () => {
if (!this._disposed) {
for (let i = 0; i < this._listeners.length; i++) {
if (this._listeners[i] === listener) {
this._listeners.splice(i, 1);
return;
}
}
}
}
};
return disposable;
};
}
return this._event;
}
public fire(arg1: T, arg2: U): void {
const queue: IListener<T, U>[] = [];
for (let i = 0; i < this._listeners.length; i++) {
queue.push(this._listeners[i]);
}
for (let i = 0; i < queue.length; i++) {
queue[i].call(undefined, arg1, arg2);
}
}
public dispose(): void {
this.clearListeners();
this._disposed = true;
}
public clearListeners(): void {
if (this._listeners) {
this._listeners.length = 0;
}
}
}
export function forwardEvent<T>(from: IEvent<T>, to: IEventEmitter<T>): IDisposable {
return from(e => to.fire(e));
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,108 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
/**
* A base class that can be extended to provide convenience methods for managing the lifecycle of an
* object and its components.
*/
export abstract class Disposable implements IDisposable {
protected _disposables: IDisposable[] = [];
protected _isDisposed: boolean = false;
/**
* Disposes the object, triggering the `dispose` method on all registered IDisposables.
*/
public dispose(): void {
this._isDisposed = true;
for (const d of this._disposables) {
d.dispose();
}
this._disposables.length = 0;
}
/**
* Registers a disposable object.
* @param d The disposable to register.
* @returns The disposable.
*/
public register<T extends IDisposable>(d: T): T {
this._disposables.push(d);
return d;
}
/**
* Unregisters a disposable object if it has been registered, if not do
* nothing.
* @param d The disposable to unregister.
*/
public unregister<T extends IDisposable>(d: T): void {
const index = this._disposables.indexOf(d);
if (index !== -1) {
this._disposables.splice(index, 1);
}
}
}
export class MutableDisposable<T extends IDisposable> implements IDisposable {
private _value?: T;
private _isDisposed = false;
/**
* Gets the value if it exists.
*/
public get value(): T | undefined {
return this._isDisposed ? undefined : this._value;
}
/**
* Sets the value, disposing of the old value if it exists.
*/
public set value(value: T | undefined) {
if (this._isDisposed || value === this._value) {
return;
}
this._value?.dispose();
this._value = value;
}
/**
* Resets the stored value and disposes of the previously stored value.
*/
public clear(): void {
this.value = undefined;
}
public dispose(): void {
this._isDisposed = true;
this._value?.dispose();
this._value = undefined;
}
}
/**
* Wrap a function in a disposable.
*/
export function toDisposable(f: () => void): IDisposable {
return { dispose: f };
}
/**
* Dispose of all disposables in an array and set its length to 0.
*/
export function disposeArray(disposables: IDisposable[]): void {
for (const d of disposables) {
d.dispose();
}
disposables.length = 0;
}
/**
* Creates a disposable that will dispose of an array of disposables when disposed.
*/
export function getDisposeArrayDisposable(array: IDisposable[]): IDisposable {
return { dispose: () => disposeArray(array) };
}

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
export class TwoKeyMap<TFirst extends string | number, TSecond extends string | number, TValue> {
private _data: { [bg: string | number]: { [fg: string | number]: TValue | undefined } | undefined } = {};
public set(first: TFirst, second: TSecond, value: TValue): void {
if (!this._data[first]) {
this._data[first] = {};
}
this._data[first as string | number]![second] = value;
}
public get(first: TFirst, second: TSecond): TValue | undefined {
return this._data[first as string | number] ? this._data[first as string | number]![second] : undefined;
}
public clear(): void {
this._data = {};
}
}
export class FourKeyMap<TFirst extends string | number, TSecond extends string | number, TThird extends string | number, TFourth extends string | number, TValue> {
private _data: TwoKeyMap<TFirst, TSecond, TwoKeyMap<TThird, TFourth, TValue>> = new TwoKeyMap();
public set(first: TFirst, second: TSecond, third: TThird, fourth: TFourth, value: TValue): void {
if (!this._data.get(first, second)) {
this._data.set(first, second, new TwoKeyMap());
}
this._data.get(first, second)!.set(third, fourth, value);
}
public get(first: TFirst, second: TSecond, third: TThird, fourth: TFourth): TValue | undefined {
return this._data.get(first, second)?.get(third, fourth);
}
public clear(): void {
this._data.clear();
}
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
interface INavigator {
userAgent: string;
language: string;
platform: string;
}
// We're declaring a navigator global here as we expect it in all runtimes (node and browser), but
// we want this module to live in common.
declare const navigator: INavigator;
export const isNode = (typeof navigator === 'undefined') ? true : false;
const userAgent = (isNode) ? 'node' : navigator.userAgent;
const platform = (isNode) ? 'node' : navigator.platform;
export const isFirefox = userAgent.includes('Firefox');
export const isLegacyEdge = userAgent.includes('Edge');
export const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
export function getSafariVersion(): number {
if (!isSafari) {
return 0;
}
const majorVersion = userAgent.match(/Version\/(\d+)/);
if (majorVersion === null || majorVersion.length < 2) {
return 0;
}
return parseInt(majorVersion[1]);
}
// Find the users platform. We use this to interpret the meta key
// and ISO third level shifts.
// http://stackoverflow.com/q/19877924/577598
export const isMac = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K'].includes(platform);
export const isIpad = platform === 'iPad';
export const isIphone = platform === 'iPhone';
export const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].includes(platform);
export const isLinux = platform.indexOf('Linux') >= 0;
// Note that when this is true, isLinux will also be true.
export const isChromeOS = /\bCrOS\b/.test(userAgent);

View File

@@ -0,0 +1,118 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
// Work variables to avoid garbage collection.
let i = 0;
/**
* A generic list that is maintained in sorted order and allows values with duplicate keys. This
* list is based on binary search and as such locating a key will take O(log n) amortized, this
* includes the by key iterator.
*/
export class SortedList<T> {
private readonly _array: T[] = [];
constructor(
private readonly _getKey: (value: T) => number
) {
}
public clear(): void {
this._array.length = 0;
}
public insert(value: T): void {
if (this._array.length === 0) {
this._array.push(value);
return;
}
i = this._search(this._getKey(value));
this._array.splice(i, 0, value);
}
public delete(value: T): boolean {
if (this._array.length === 0) {
return false;
}
const key = this._getKey(value);
if (key === undefined) {
return false;
}
i = this._search(key);
if (i === -1) {
return false;
}
if (this._getKey(this._array[i]) !== key) {
return false;
}
do {
if (this._array[i] === value) {
this._array.splice(i, 1);
return true;
}
} while (++i < this._array.length && this._getKey(this._array[i]) === key);
return false;
}
public *getKeyIterator(key: number): IterableIterator<T> {
if (this._array.length === 0) {
return;
}
i = this._search(key);
if (i < 0 || i >= this._array.length) {
return;
}
if (this._getKey(this._array[i]) !== key) {
return;
}
do {
yield this._array[i];
} while (++i < this._array.length && this._getKey(this._array[i]) === key);
}
public forEachByKey(key: number, callback: (value: T) => void): void {
if (this._array.length === 0) {
return;
}
i = this._search(key);
if (i < 0 || i >= this._array.length) {
return;
}
if (this._getKey(this._array[i]) !== key) {
return;
}
do {
callback(this._array[i]);
} while (++i < this._array.length && this._getKey(this._array[i]) === key);
}
public values(): IterableIterator<T> {
// Duplicate the array to avoid issues when _array changes while iterating
return [...this._array].values();
}
private _search(key: number): number {
let min = 0;
let max = this._array.length - 1;
while (max >= min) {
let mid = (min + max) >> 1;
const midKey = this._getKey(this._array[mid]);
if (midKey > key) {
max = mid - 1;
} else if (midKey < key) {
min = mid + 1;
} else {
// key in list, walk to lowest duplicate
while (mid > 0 && this._getKey(this._array[mid - 1]) === key) {
mid--;
}
return mid;
}
}
// key not in list
// still return closest min (also used as insert position)
return min;
}
}

View File

@@ -0,0 +1,166 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { isNode } from 'common/Platform';
interface ITaskQueue {
/**
* Adds a task to the queue which will run in a future idle callback.
* To avoid perceivable stalls on the mainthread, tasks with heavy workload
* should split their work into smaller pieces and return `true` to get
* called again until the work is done (on falsy return value).
*/
enqueue(task: () => boolean | void): void;
/**
* Flushes the queue, running all remaining tasks synchronously.
*/
flush(): void;
/**
* Clears any remaining tasks from the queue, these will not be run.
*/
clear(): void;
}
interface ITaskDeadline {
timeRemaining(): number;
}
type CallbackWithDeadline = (deadline: ITaskDeadline) => void;
abstract class TaskQueue implements ITaskQueue {
private _tasks: (() => boolean | void)[] = [];
private _idleCallback?: number;
private _i = 0;
protected abstract _requestCallback(callback: CallbackWithDeadline): number;
protected abstract _cancelCallback(identifier: number): void;
public enqueue(task: () => boolean | void): void {
this._tasks.push(task);
this._start();
}
public flush(): void {
while (this._i < this._tasks.length) {
if (!this._tasks[this._i]()) {
this._i++;
}
}
this.clear();
}
public clear(): void {
if (this._idleCallback) {
this._cancelCallback(this._idleCallback);
this._idleCallback = undefined;
}
this._i = 0;
this._tasks.length = 0;
}
private _start(): void {
if (!this._idleCallback) {
this._idleCallback = this._requestCallback(this._process.bind(this));
}
}
private _process(deadline: ITaskDeadline): void {
this._idleCallback = undefined;
let taskDuration = 0;
let longestTask = 0;
let lastDeadlineRemaining = deadline.timeRemaining();
let deadlineRemaining = 0;
while (this._i < this._tasks.length) {
taskDuration = Date.now();
if (!this._tasks[this._i]()) {
this._i++;
}
// other than performance.now, Date.now might not be stable (changes on wall clock changes),
// this is not an issue here as a clock change during a short running task is very unlikely
// in case it still happened and leads to negative duration, simply assume 1 msec
taskDuration = Math.max(1, Date.now() - taskDuration);
longestTask = Math.max(taskDuration, longestTask);
// Guess the following task will take a similar time to the longest task in this batch, allow
// additional room to try avoid exceeding the deadline
deadlineRemaining = deadline.timeRemaining();
if (longestTask * 1.5 > deadlineRemaining) {
// Warn when the time exceeding the deadline is over 20ms, if this happens in practice the
// task should be split into sub-tasks to ensure the UI remains responsive.
if (lastDeadlineRemaining - taskDuration < -20) {
console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`);
}
this._start();
return;
}
lastDeadlineRemaining = deadlineRemaining;
}
this.clear();
}
}
/**
* A queue of that runs tasks over several tasks via setTimeout, trying to maintain above 60 frames
* per second. The tasks will run in the order they are enqueued, but they will run some time later,
* and care should be taken to ensure they're non-urgent and will not introduce race conditions.
*/
export class PriorityTaskQueue extends TaskQueue {
protected _requestCallback(callback: CallbackWithDeadline): number {
return setTimeout(() => callback(this._createDeadline(16)));
}
protected _cancelCallback(identifier: number): void {
clearTimeout(identifier);
}
private _createDeadline(duration: number): ITaskDeadline {
const end = Date.now() + duration;
return {
timeRemaining: () => Math.max(0, end - Date.now())
};
}
}
class IdleTaskQueueInternal extends TaskQueue {
protected _requestCallback(callback: IdleRequestCallback): number {
return requestIdleCallback(callback);
}
protected _cancelCallback(identifier: number): void {
cancelIdleCallback(identifier);
}
}
/**
* A queue of that runs tasks over several idle callbacks, trying to respect the idle callback's
* deadline given by the environment. The tasks will run in the order they are enqueued, but they
* will run some time later, and care should be taken to ensure they're non-urgent and will not
* introduce race conditions.
*
* This reverts to a {@link PriorityTaskQueue} if the environment does not support idle callbacks.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? IdleTaskQueueInternal : PriorityTaskQueue;
/**
* An object that tracks a single debounced task that will run on the next idle frame. When called
* multiple times, only the last set task will run.
*/
export class DebouncedIdleTask {
private _queue: ITaskQueue;
constructor() {
this._queue = new IdleTaskQueue();
}
public set(task: () => boolean | void): void {
this._queue.clear();
this._queue.enqueue(task);
}
public flush(): void {
this._queue.flush();
}
}

View File

@@ -0,0 +1,17 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
export type TypedArray = Uint8Array | Uint16Array | Uint32Array | Uint8ClampedArray | Int8Array | Int16Array | Int32Array | Float32Array | Float64Array;
/**
* Concat two typed arrays `a` and `b`.
* Returns a new typed array.
*/
export function concat<T extends TypedArray>(a: T, b: T): T {
const result = new (a.constructor as any)(a.length + b.length);
result.set(a);
result.set(b, a.length);
return result;
}

553
public/xterm/src/common/Types.d.ts vendored Normal file
View File

@@ -0,0 +1,553 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { IEvent, IEventEmitter } from 'common/EventEmitter';
import { Attributes, UnderlineStyle } from 'common/buffer/Constants'; // eslint-disable-line no-unused-vars
import { IBufferSet } from 'common/buffer/Types';
import { IParams } from 'common/parser/Types';
import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from 'xterm';
export interface ICoreTerminal {
coreMouseService: ICoreMouseService;
coreService: ICoreService;
optionsService: IOptionsService;
unicodeService: IUnicodeService;
buffers: IBufferSet;
options: Required<ITerminalOptions>;
registerCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean | Promise<boolean>): IDisposable;
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
}
export interface IDisposable {
dispose(): void;
}
// TODO: The options that are not in the public API should be reviewed
export interface ITerminalOptions extends IPublicTerminalOptions {
[key: string]: any;
cancelEvents?: boolean;
convertEol?: boolean;
termName?: string;
}
export type CursorStyle = 'block' | 'underline' | 'bar';
export type CursorInactiveStyle = 'outline' | 'block' | 'bar' | 'underline' | 'none';
export type XtermListener = (...args: any[]) => void;
/**
* A keyboard event interface which does not depend on the DOM, KeyboardEvent implicitly extends
* this event.
*/
export interface IKeyboardEvent {
altKey: boolean;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
/** @deprecated See KeyboardEvent.keyCode */
keyCode: number;
key: string;
type: string;
code: string;
}
export interface IScrollEvent {
position: number;
source: ScrollSource;
}
export const enum ScrollSource {
TERMINAL,
VIEWPORT,
}
export interface ICircularList<T> {
length: number;
maxLength: number;
isFull: boolean;
onDeleteEmitter: IEventEmitter<IDeleteEvent>;
onDelete: IEvent<IDeleteEvent>;
onInsertEmitter: IEventEmitter<IInsertEvent>;
onInsert: IEvent<IInsertEvent>;
onTrimEmitter: IEventEmitter<number>;
onTrim: IEvent<number>;
get(index: number): T | undefined;
set(index: number, value: T): void;
push(value: T): void;
recycle(): T;
pop(): T | undefined;
splice(start: number, deleteCount: number, ...items: T[]): void;
trimStart(count: number): void;
shiftElements(start: number, count: number, offset: number): void;
}
export const enum KeyboardResultType {
SEND_KEY,
SELECT_ALL,
PAGE_UP,
PAGE_DOWN
}
export interface IKeyboardResult {
type: KeyboardResultType;
cancel: boolean;
key: string | undefined;
}
export interface ICharset {
[key: string]: string | undefined;
}
export type CharData = [number, string, number, number];
export interface IColor {
css: string;
rgba: number; // 32-bit int with rgba in each byte
}
export type IColorRGB = [number, number, number];
export interface IExtendedAttrs {
ext: number;
underlineStyle: UnderlineStyle;
underlineColor: number;
urlId: number;
clone(): IExtendedAttrs;
isEmpty(): boolean;
}
/**
* Tracks the current hyperlink. Since these are treated as extended attirbutes, these get passed on
* to the linkifier when anything is printed. Doing it this way ensures that even when the cursor
* moves around unexpectedly the link is tracked, as opposed to using a start position and
* finalizing it at the end.
*/
export interface IOscLinkData {
id?: string;
uri: string;
}
/**
* An object that represents all attributes of a cell.
*/
export interface IAttributeData {
/**
* "fg" is a 32-bit unsigned integer that stores the foreground color of the cell in the 24 least
* significant bits and additional flags in the remaining 8 bits.
*/
fg: number;
/**
* "bg" is a 32-bit unsigned integer that stores the background color of the cell in the 24 least
* significant bits and additional flags in the remaining 8 bits.
*/
bg: number;
/**
* "extended", aka "ext", stores extended attributes beyond those available in fg and bg. This
* data is optional on a cell and encodes less common data.
*/
extended: IExtendedAttrs;
clone(): IAttributeData;
// flags
isInverse(): number;
isBold(): number;
isUnderline(): number;
isBlink(): number;
isInvisible(): number;
isItalic(): number;
isDim(): number;
isStrikethrough(): number;
isProtected(): number;
isOverline(): number;
/**
* The color mode of the foreground color which determines how to decode {@link getFgColor},
* possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16},
* {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}.
*/
getFgColorMode(): number;
/**
* The color mode of the background color which determines how to decode {@link getBgColor},
* possible values include {@link Attributes.CM_DEFAULT}, {@link Attributes.CM_P16},
* {@link Attributes.CM_P256} and {@link Attributes.CM_RGB}.
*/
getBgColorMode(): number;
isFgRGB(): boolean;
isBgRGB(): boolean;
isFgPalette(): boolean;
isBgPalette(): boolean;
isFgDefault(): boolean;
isBgDefault(): boolean;
isAttributeDefault(): boolean;
/**
* Gets an integer representation of the foreground color, how to decode the color depends on the
* color mode {@link getFgColorMode}.
*/
getFgColor(): number;
/**
* Gets an integer representation of the background color, how to decode the color depends on the
* color mode {@link getBgColorMode}.
*/
getBgColor(): number;
// extended attrs
hasExtendedAttrs(): number;
updateExtended(): void;
getUnderlineColor(): number;
getUnderlineColorMode(): number;
isUnderlineColorRGB(): boolean;
isUnderlineColorPalette(): boolean;
isUnderlineColorDefault(): boolean;
getUnderlineStyle(): number;
}
/** Cell data */
export interface ICellData extends IAttributeData {
content: number;
combinedData: string;
isCombined(): number;
getWidth(): number;
getChars(): string;
getCode(): number;
setFromCharData(value: CharData): void;
getAsCharData(): CharData;
}
/**
* Interface for a line in the terminal buffer.
*/
export interface IBufferLine {
length: number;
isWrapped: boolean;
get(index: number): CharData;
set(index: number, value: CharData): void;
loadCell(index: number, cell: ICellData): ICellData;
setCell(index: number, cell: ICellData): void;
setCellFromCodePoint(index: number, codePoint: number, width: number, fg: number, bg: number, eAttrs: IExtendedAttrs): void;
addCodepointToCell(index: number, codePoint: number): void;
insertCells(pos: number, n: number, ch: ICellData, eraseAttr?: IAttributeData): void;
deleteCells(pos: number, n: number, fill: ICellData, eraseAttr?: IAttributeData): void;
replaceCells(start: number, end: number, fill: ICellData, eraseAttr?: IAttributeData, respectProtect?: boolean): void;
resize(cols: number, fill: ICellData): boolean;
cleanupMemory(): number;
fill(fillCellData: ICellData, respectProtect?: boolean): void;
copyFrom(line: IBufferLine): void;
clone(): IBufferLine;
getTrimmedLength(): number;
getNoBgTrimmedLength(): number;
translateToString(trimRight?: boolean, startCol?: number, endCol?: number): string;
/* direct access to cell attrs */
getWidth(index: number): number;
hasWidth(index: number): number;
getFg(index: number): number;
getBg(index: number): number;
hasContent(index: number): number;
getCodePoint(index: number): number;
isCombined(index: number): number;
getString(index: number): string;
}
export interface IMarker extends IDisposable {
readonly id: number;
readonly isDisposed: boolean;
readonly line: number;
onDispose: IEvent<void>;
}
export interface IModes {
insertMode: boolean;
}
export interface IDecPrivateModes {
applicationCursorKeys: boolean;
applicationKeypad: boolean;
bracketedPasteMode: boolean;
origin: boolean;
reverseWraparound: boolean;
sendFocus: boolean;
wraparound: boolean; // defaults: xterm - true, vt100 - false
}
export interface IRowRange {
start: number;
end: number;
}
/**
* Interface for mouse events in the core.
*/
export const enum CoreMouseButton {
LEFT = 0,
MIDDLE = 1,
RIGHT = 2,
NONE = 3,
WHEEL = 4,
// additional buttons 1..8
// untested!
AUX1 = 8,
AUX2 = 9,
AUX3 = 10,
AUX4 = 11,
AUX5 = 12,
AUX6 = 13,
AUX7 = 14,
AUX8 = 15
}
export const enum CoreMouseAction {
UP = 0, // buttons, wheel
DOWN = 1, // buttons, wheel
LEFT = 2, // wheel only
RIGHT = 3, // wheel only
MOVE = 32 // buttons only
}
export interface ICoreMouseEvent {
/** column (zero based). */
col: number;
/** row (zero based). */
row: number;
/** xy pixel positions. */
x: number;
y: number;
/**
* Button the action occured. Due to restrictions of the tracking protocols
* it is not possible to report multiple buttons at once.
* Wheel is treated as a button.
* There are invalid combinations of buttons and actions possible
* (like move + wheel), those are silently ignored by the CoreMouseService.
*/
button: CoreMouseButton;
action: CoreMouseAction;
/**
* Modifier states.
* Protocols will add/ignore those based on specific restrictions.
*/
ctrl?: boolean;
alt?: boolean;
shift?: boolean;
}
/**
* CoreMouseEventType
* To be reported to the browser component which events a mouse
* protocol wants to be catched and forwarded as an ICoreMouseEvent
* to CoreMouseService.
*/
export const enum CoreMouseEventType {
NONE = 0,
/** any mousedown event */
DOWN = 1,
/** any mouseup event */
UP = 2,
/** any mousemove event while a button is held */
DRAG = 4,
/** any mousemove event without a button */
MOVE = 8,
/** any wheel event */
WHEEL = 16
}
/**
* Mouse protocol interface.
* A mouse protocol can be registered and activated at the CoreMouseService.
* `events` should contain a list of needed events as a hint for the browser component
* to install/remove the appropriate event handlers.
* `restrict` applies further protocol specific restrictions like not allowed
* modifiers or filtering invalid event types.
*/
export interface ICoreMouseProtocol {
events: CoreMouseEventType;
restrict: (e: ICoreMouseEvent) => boolean;
}
/**
* CoreMouseEncoding
* The tracking encoding can be registered and activated at the CoreMouseService.
* If a ICoreMouseEvent passes all procotol restrictions it will be encoded
* with the active encoding and sent out.
* Note: Returning an empty string will supress sending a mouse report,
* which can be used to skip creating falsey reports in limited encodings
* (DEFAULT only supports up to 223 1-based as coord value).
*/
export type CoreMouseEncoding = (event: ICoreMouseEvent) => string;
/**
* windowOptions
*/
export interface IWindowOptions {
restoreWin?: boolean;
minimizeWin?: boolean;
setWinPosition?: boolean;
setWinSizePixels?: boolean;
raiseWin?: boolean;
lowerWin?: boolean;
refreshWin?: boolean;
setWinSizeChars?: boolean;
maximizeWin?: boolean;
fullscreenWin?: boolean;
getWinState?: boolean;
getWinPosition?: boolean;
getWinSizePixels?: boolean;
getScreenSizePixels?: boolean;
getCellSizePixels?: boolean;
getWinSizeChars?: boolean;
getScreenSizeChars?: boolean;
getIconTitle?: boolean;
getWinTitle?: boolean;
pushTitle?: boolean;
popTitle?: boolean;
setWinLines?: boolean;
}
// color events from common, used for OSC 4/10/11/12 and 104/110/111/112
export const enum ColorRequestType {
REPORT = 0,
SET = 1,
RESTORE = 2
}
// IntRange from https://stackoverflow.com/a/39495173
type Enumerate<N extends number, Acc extends number[] = []> = Acc['length'] extends N
? Acc[number]
: Enumerate<N, [...Acc, Acc['length']]>;
type IntRange<F extends number, T extends number> = Exclude<Enumerate<T>, Enumerate<F>>;
type ColorIndex = IntRange<0, 256>; // number from 0 to 255
type AllColorIndex = ColorIndex | SpecialColorIndex;
export const enum SpecialColorIndex {
FOREGROUND = 256,
BACKGROUND = 257,
CURSOR = 258
}
export interface IColorReportRequest {
type: ColorRequestType.REPORT;
index: AllColorIndex;
}
export interface IColorSetRequest {
type: ColorRequestType.SET;
index: AllColorIndex;
color: IColorRGB;
}
export interface IColorRestoreRequest {
type: ColorRequestType.RESTORE;
index?: AllColorIndex;
}
export type IColorEvent = (IColorReportRequest | IColorSetRequest | IColorRestoreRequest)[];
/**
* Calls the parser and handles actions generated by the parser.
*/
export interface IInputHandler {
onTitleChange: IEvent<string>;
parse(data: string | Uint8Array, promiseResult?: boolean): void | Promise<boolean>;
print(data: Uint32Array, start: number, end: number): void;
registerCsiHandler(id: IFunctionIdentifier, callback: (params: IParams) => boolean | Promise<boolean>): IDisposable;
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
/** C0 BEL */ bell(): boolean;
/** C0 LF */ lineFeed(): boolean;
/** C0 CR */ carriageReturn(): boolean;
/** C0 BS */ backspace(): boolean;
/** C0 HT */ tab(): boolean;
/** C0 SO */ shiftOut(): boolean;
/** C0 SI */ shiftIn(): boolean;
/** CSI @ */ insertChars(params: IParams): boolean;
/** CSI SP @ */ scrollLeft(params: IParams): boolean;
/** CSI A */ cursorUp(params: IParams): boolean;
/** CSI SP A */ scrollRight(params: IParams): boolean;
/** CSI B */ cursorDown(params: IParams): boolean;
/** CSI C */ cursorForward(params: IParams): boolean;
/** CSI D */ cursorBackward(params: IParams): boolean;
/** CSI E */ cursorNextLine(params: IParams): boolean;
/** CSI F */ cursorPrecedingLine(params: IParams): boolean;
/** CSI G */ cursorCharAbsolute(params: IParams): boolean;
/** CSI H */ cursorPosition(params: IParams): boolean;
/** CSI I */ cursorForwardTab(params: IParams): boolean;
/** CSI J */ eraseInDisplay(params: IParams): boolean;
/** CSI K */ eraseInLine(params: IParams): boolean;
/** CSI L */ insertLines(params: IParams): boolean;
/** CSI M */ deleteLines(params: IParams): boolean;
/** CSI P */ deleteChars(params: IParams): boolean;
/** CSI S */ scrollUp(params: IParams): boolean;
/** CSI T */ scrollDown(params: IParams, collect?: string): boolean;
/** CSI X */ eraseChars(params: IParams): boolean;
/** CSI Z */ cursorBackwardTab(params: IParams): boolean;
/** CSI ` */ charPosAbsolute(params: IParams): boolean;
/** CSI a */ hPositionRelative(params: IParams): boolean;
/** CSI b */ repeatPrecedingCharacter(params: IParams): boolean;
/** CSI c */ sendDeviceAttributesPrimary(params: IParams): boolean;
/** CSI > c */ sendDeviceAttributesSecondary(params: IParams): boolean;
/** CSI d */ linePosAbsolute(params: IParams): boolean;
/** CSI e */ vPositionRelative(params: IParams): boolean;
/** CSI f */ hVPosition(params: IParams): boolean;
/** CSI g */ tabClear(params: IParams): boolean;
/** CSI h */ setMode(params: IParams, collect?: string): boolean;
/** CSI l */ resetMode(params: IParams, collect?: string): boolean;
/** CSI m */ charAttributes(params: IParams): boolean;
/** CSI n */ deviceStatus(params: IParams, collect?: string): boolean;
/** CSI p */ softReset(params: IParams, collect?: string): boolean;
/** CSI q */ setCursorStyle(params: IParams, collect?: string): boolean;
/** CSI r */ setScrollRegion(params: IParams, collect?: string): boolean;
/** CSI s */ saveCursor(params: IParams): boolean;
/** CSI u */ restoreCursor(params: IParams): boolean;
/** CSI ' } */ insertColumns(params: IParams): boolean;
/** CSI ' ~ */ deleteColumns(params: IParams): boolean;
/** OSC 0
OSC 2 */ setTitle(data: string): boolean;
/** OSC 4 */ setOrReportIndexedColor(data: string): boolean;
/** OSC 10 */ setOrReportFgColor(data: string): boolean;
/** OSC 11 */ setOrReportBgColor(data: string): boolean;
/** OSC 12 */ setOrReportCursorColor(data: string): boolean;
/** OSC 104 */ restoreIndexedColor(data: string): boolean;
/** OSC 110 */ restoreFgColor(data: string): boolean;
/** OSC 111 */ restoreBgColor(data: string): boolean;
/** OSC 112 */ restoreCursorColor(data: string): boolean;
/** ESC E */ nextLine(): boolean;
/** ESC = */ keypadApplicationMode(): boolean;
/** ESC > */ keypadNumericMode(): boolean;
/** ESC % G
ESC % @ */ selectDefaultCharset(): boolean;
/** ESC ( C
ESC ) C
ESC * C
ESC + C
ESC - C
ESC . C
ESC / C */ selectCharset(collectAndFlag: string): boolean;
/** ESC D */ index(): boolean;
/** ESC H */ tabSet(): boolean;
/** ESC M */ reverseIndex(): boolean;
/** ESC c */ fullReset(): boolean;
/** ESC n
ESC o
ESC |
ESC }
ESC ~ */ setgLevel(level: number): boolean;
/** ESC # 8 */ screenAlignmentPattern(): boolean;
}
export interface IParseStack {
paused: boolean;
cursorStartX: number;
cursorStartY: number;
decodedLength: number;
position: number;
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CHAR_DATA_CODE_INDEX, NULL_CELL_CODE, WHITESPACE_CELL_CODE } from 'common/buffer/Constants';
import { IBufferService } from 'common/services/Services';
export function updateWindowsModeWrappedState(bufferService: IBufferService): void {
// Winpty does not support wraparound mode which means that lines will never
// be marked as wrapped. This causes issues for things like copying a line
// retaining the wrapped new line characters or if consumers are listening
// in on the data stream.
//
// The workaround for this is to listen to every incoming line feed and mark
// the line as wrapped if the last character in the previous line is not a
// space. This is certainly not without its problems, but generally on
// Windows when text reaches the end of the terminal it's likely going to be
// wrapped.
const line = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y - 1);
const lastChar = line?.get(bufferService.cols - 1);
const nextLine = bufferService.buffer.lines.get(bufferService.buffer.ybase + bufferService.buffer.y);
if (nextLine && lastChar) {
nextLine.isWrapped = (lastChar[CHAR_DATA_CODE_INDEX] !== NULL_CELL_CODE && lastChar[CHAR_DATA_CODE_INDEX] !== WHITESPACE_CELL_CODE);
}
}

View File

@@ -0,0 +1,196 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IAttributeData, IColorRGB, IExtendedAttrs } from 'common/Types';
import { Attributes, FgFlags, BgFlags, UnderlineStyle, ExtFlags } from 'common/buffer/Constants';
export class AttributeData implements IAttributeData {
public static toColorRGB(value: number): IColorRGB {
return [
value >>> Attributes.RED_SHIFT & 255,
value >>> Attributes.GREEN_SHIFT & 255,
value & 255
];
}
public static fromColorRGB(value: IColorRGB): number {
return (value[0] & 255) << Attributes.RED_SHIFT | (value[1] & 255) << Attributes.GREEN_SHIFT | value[2] & 255;
}
public clone(): IAttributeData {
const newObj = new AttributeData();
newObj.fg = this.fg;
newObj.bg = this.bg;
newObj.extended = this.extended.clone();
return newObj;
}
// data
public fg = 0;
public bg = 0;
public extended: IExtendedAttrs = new ExtendedAttrs();
// flags
public isInverse(): number { return this.fg & FgFlags.INVERSE; }
public isBold(): number { return this.fg & FgFlags.BOLD; }
public isUnderline(): number {
if (this.hasExtendedAttrs() && this.extended.underlineStyle !== UnderlineStyle.NONE) {
return 1;
}
return this.fg & FgFlags.UNDERLINE;
}
public isBlink(): number { return this.fg & FgFlags.BLINK; }
public isInvisible(): number { return this.fg & FgFlags.INVISIBLE; }
public isItalic(): number { return this.bg & BgFlags.ITALIC; }
public isDim(): number { return this.bg & BgFlags.DIM; }
public isStrikethrough(): number { return this.fg & FgFlags.STRIKETHROUGH; }
public isProtected(): number { return this.bg & BgFlags.PROTECTED; }
public isOverline(): number { return this.bg & BgFlags.OVERLINE; }
// color modes
public getFgColorMode(): number { return this.fg & Attributes.CM_MASK; }
public getBgColorMode(): number { return this.bg & Attributes.CM_MASK; }
public isFgRGB(): boolean { return (this.fg & Attributes.CM_MASK) === Attributes.CM_RGB; }
public isBgRGB(): boolean { return (this.bg & Attributes.CM_MASK) === Attributes.CM_RGB; }
public isFgPalette(): boolean { return (this.fg & Attributes.CM_MASK) === Attributes.CM_P16 || (this.fg & Attributes.CM_MASK) === Attributes.CM_P256; }
public isBgPalette(): boolean { return (this.bg & Attributes.CM_MASK) === Attributes.CM_P16 || (this.bg & Attributes.CM_MASK) === Attributes.CM_P256; }
public isFgDefault(): boolean { return (this.fg & Attributes.CM_MASK) === 0; }
public isBgDefault(): boolean { return (this.bg & Attributes.CM_MASK) === 0; }
public isAttributeDefault(): boolean { return this.fg === 0 && this.bg === 0; }
// colors
public getFgColor(): number {
switch (this.fg & Attributes.CM_MASK) {
case Attributes.CM_P16:
case Attributes.CM_P256: return this.fg & Attributes.PCOLOR_MASK;
case Attributes.CM_RGB: return this.fg & Attributes.RGB_MASK;
default: return -1; // CM_DEFAULT defaults to -1
}
}
public getBgColor(): number {
switch (this.bg & Attributes.CM_MASK) {
case Attributes.CM_P16:
case Attributes.CM_P256: return this.bg & Attributes.PCOLOR_MASK;
case Attributes.CM_RGB: return this.bg & Attributes.RGB_MASK;
default: return -1; // CM_DEFAULT defaults to -1
}
}
// extended attrs
public hasExtendedAttrs(): number {
return this.bg & BgFlags.HAS_EXTENDED;
}
public updateExtended(): void {
if (this.extended.isEmpty()) {
this.bg &= ~BgFlags.HAS_EXTENDED;
} else {
this.bg |= BgFlags.HAS_EXTENDED;
}
}
public getUnderlineColor(): number {
if ((this.bg & BgFlags.HAS_EXTENDED) && ~this.extended.underlineColor) {
switch (this.extended.underlineColor & Attributes.CM_MASK) {
case Attributes.CM_P16:
case Attributes.CM_P256: return this.extended.underlineColor & Attributes.PCOLOR_MASK;
case Attributes.CM_RGB: return this.extended.underlineColor & Attributes.RGB_MASK;
default: return this.getFgColor();
}
}
return this.getFgColor();
}
public getUnderlineColorMode(): number {
return (this.bg & BgFlags.HAS_EXTENDED) && ~this.extended.underlineColor
? this.extended.underlineColor & Attributes.CM_MASK
: this.getFgColorMode();
}
public isUnderlineColorRGB(): boolean {
return (this.bg & BgFlags.HAS_EXTENDED) && ~this.extended.underlineColor
? (this.extended.underlineColor & Attributes.CM_MASK) === Attributes.CM_RGB
: this.isFgRGB();
}
public isUnderlineColorPalette(): boolean {
return (this.bg & BgFlags.HAS_EXTENDED) && ~this.extended.underlineColor
? (this.extended.underlineColor & Attributes.CM_MASK) === Attributes.CM_P16
|| (this.extended.underlineColor & Attributes.CM_MASK) === Attributes.CM_P256
: this.isFgPalette();
}
public isUnderlineColorDefault(): boolean {
return (this.bg & BgFlags.HAS_EXTENDED) && ~this.extended.underlineColor
? (this.extended.underlineColor & Attributes.CM_MASK) === 0
: this.isFgDefault();
}
public getUnderlineStyle(): UnderlineStyle {
return this.fg & FgFlags.UNDERLINE
? (this.bg & BgFlags.HAS_EXTENDED ? this.extended.underlineStyle : UnderlineStyle.SINGLE)
: UnderlineStyle.NONE;
}
}
/**
* Extended attributes for a cell.
* Holds information about different underline styles and color.
*/
export class ExtendedAttrs implements IExtendedAttrs {
private _ext: number = 0;
public get ext(): number {
if (this._urlId) {
return (
(this._ext & ~ExtFlags.UNDERLINE_STYLE) |
(this.underlineStyle << 26)
);
}
return this._ext;
}
public set ext(value: number) { this._ext = value; }
public get underlineStyle(): UnderlineStyle {
// Always return the URL style if it has one
if (this._urlId) {
return UnderlineStyle.DASHED;
}
return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26;
}
public set underlineStyle(value: UnderlineStyle) {
this._ext &= ~ExtFlags.UNDERLINE_STYLE;
this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE;
}
public get underlineColor(): number {
return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK);
}
public set underlineColor(value: number) {
this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK);
}
private _urlId: number = 0;
public get urlId(): number {
return this._urlId;
}
public set urlId(value: number) {
this._urlId = value;
}
constructor(
ext: number = 0,
urlId: number = 0
) {
this._ext = ext;
this._urlId = urlId;
}
public clone(): IExtendedAttrs {
return new ExtendedAttrs(this._ext, this._urlId);
}
/**
* Convenient method to indicate whether the object holds no additional information,
* that needs to be persistant in the buffer.
*/
public isEmpty(): boolean {
return this.underlineStyle === UnderlineStyle.NONE && this._urlId === 0;
}
}

View File

@@ -0,0 +1,654 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
import { Marker } from 'common/buffer/Marker';
import { IBuffer } from 'common/buffer/Types';
import { DEFAULT_CHARSET } from 'common/data/Charsets';
import { IBufferService, IOptionsService } from 'common/services/Services';
export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
/**
* This class represents a terminal buffer (an internal state of the terminal), where the
* following information is stored (in high-level):
* - text content of this particular buffer
* - cursor position
* - scroll position
*/
export class Buffer implements IBuffer {
public lines: CircularList<IBufferLine>;
public ydisp: number = 0;
public ybase: number = 0;
public y: number = 0;
public x: number = 0;
public scrollBottom: number;
public scrollTop: number;
public tabs: { [column: number]: boolean | undefined } = {};
public savedY: number = 0;
public savedX: number = 0;
public savedCurAttrData = DEFAULT_ATTR_DATA.clone();
public savedCharset: ICharset | undefined = DEFAULT_CHARSET;
public markers: Marker[] = [];
private _nullCell: ICellData = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
private _whitespaceCell: ICellData = CellData.fromCharData([0, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_WIDTH, WHITESPACE_CELL_CODE]);
private _cols: number;
private _rows: number;
private _isClearing: boolean = false;
constructor(
private _hasScrollback: boolean,
private _optionsService: IOptionsService,
private _bufferService: IBufferService
) {
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
}
public getNullCell(attr?: IAttributeData): ICellData {
if (attr) {
this._nullCell.fg = attr.fg;
this._nullCell.bg = attr.bg;
this._nullCell.extended = attr.extended;
} else {
this._nullCell.fg = 0;
this._nullCell.bg = 0;
this._nullCell.extended = new ExtendedAttrs();
}
return this._nullCell;
}
public getWhitespaceCell(attr?: IAttributeData): ICellData {
if (attr) {
this._whitespaceCell.fg = attr.fg;
this._whitespaceCell.bg = attr.bg;
this._whitespaceCell.extended = attr.extended;
} else {
this._whitespaceCell.fg = 0;
this._whitespaceCell.bg = 0;
this._whitespaceCell.extended = new ExtendedAttrs();
}
return this._whitespaceCell;
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
}
public get hasScrollback(): boolean {
return this._hasScrollback && this.lines.maxLength > this._rows;
}
public get isCursorInViewport(): boolean {
const absoluteY = this.ybase + this.y;
const relativeY = absoluteY - this.ydisp;
return (relativeY >= 0 && relativeY < this._rows);
}
/**
* Gets the correct buffer length based on the rows provided, the terminal's
* scrollback and whether this buffer is flagged to have scrollback or not.
* @param rows The terminal rows to use in the calculation.
*/
private _getCorrectBufferLength(rows: number): number {
if (!this._hasScrollback) {
return rows;
}
const correctBufferLength = rows + this._optionsService.rawOptions.scrollback;
return correctBufferLength > MAX_BUFFER_SIZE ? MAX_BUFFER_SIZE : correctBufferLength;
}
/**
* Fills the buffer's viewport with blank lines.
*/
public fillViewportRows(fillAttr?: IAttributeData): void {
if (this.lines.length === 0) {
if (fillAttr === undefined) {
fillAttr = DEFAULT_ATTR_DATA;
}
let i = this._rows;
while (i--) {
this.lines.push(this.getBlankLine(fillAttr));
}
}
}
/**
* Clears the buffer to it's initial state, discarding all previous data.
*/
public clear(): void {
this.ydisp = 0;
this.ybase = 0;
this.y = 0;
this.x = 0;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
this.scrollTop = 0;
this.scrollBottom = this._rows - 1;
this.setupTabStops();
}
/**
* Resizes the buffer, adjusting its data accordingly.
* @param newCols The new number of columns.
* @param newRows The new number of rows.
*/
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
// Increase max length if needed before adjustments to allow space to fill
// as required.
const newMaxLength = this._getCorrectBufferLength(newRows);
if (newMaxLength > this.lines.maxLength) {
this.lines.maxLength = newMaxLength;
}
// The following adjustments should only happen if the buffer has been
// initialized/filled.
if (this.lines.length > 0) {
// Deal with columns increasing (reducing needs to happen after reflow)
if (this._cols < newCols) {
for (let i = 0; i < this.lines.length; i++) {
// +boolean for fast 0 or 1 conversion
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
}
}
// Resize rows in both directions as needed
let addToY = 0;
if (this._rows < newRows) {
for (let y = this._rows; y < newRows; y++) {
if (this.lines.length < newRows + this.ybase) {
if (this._optionsService.rawOptions.windowsMode || this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
// Just add the new missing rows on Windows as conpty reprints the screen with it's
// view of the world. Once a line enters scrollback for conpty it remains there
this.lines.push(new BufferLine(newCols, nullCell));
} else {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
// There is room above the buffer and there are no empty elements below the line,
// scroll up
this.ybase--;
addToY++;
if (this.ydisp > 0) {
// Viewport is at the top of the buffer, must increase downwards
this.ydisp--;
}
} else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there
// are blank lines after the cursor
this.lines.push(new BufferLine(newCols, nullCell));
}
}
}
}
} else { // (this._rows >= newRows)
for (let y = this._rows; y > newRows; y--) {
if (this.lines.length > newRows + this.ybase) {
if (this.lines.length > this.ybase + this.y + 1) {
// The line is a blank line below the cursor, remove it
this.lines.pop();
} else {
// The line is the cursor, scroll down
this.ybase++;
this.ydisp++;
}
}
}
}
// Reduce max length if needed after adjustments, this is done after as it
// would otherwise cut data from the bottom of the buffer.
if (newMaxLength < this.lines.maxLength) {
// Trim from the top of the buffer and adjust ybase and ydisp.
const amountToTrim = this.lines.length - newMaxLength;
if (amountToTrim > 0) {
this.lines.trimStart(amountToTrim);
this.ybase = Math.max(this.ybase - amountToTrim, 0);
this.ydisp = Math.max(this.ydisp - amountToTrim, 0);
this.savedY = Math.max(this.savedY - amountToTrim, 0);
}
this.lines.maxLength = newMaxLength;
}
// Make sure that the cursor stays on screen
this.x = Math.min(this.x, newCols - 1);
this.y = Math.min(this.y, newRows - 1);
if (addToY) {
this.y += addToY;
}
this.savedX = Math.min(this.savedX, newCols - 1);
this.scrollTop = 0;
}
this.scrollBottom = newRows - 1;
if (this._isReflowEnabled) {
this._reflow(newCols, newRows);
// Trim the end of the line off if cols shrunk
if (this._cols > newCols) {
for (let i = 0; i < this.lines.length; i++) {
// +boolean for fast 0 or 1 conversion
dirtyMemoryLines += +this.lines.get(i)!.resize(newCols, nullCell);
}
}
}
this._cols = newCols;
this._rows = newRows;
this._memoryCleanupQueue.clear();
// schedule memory cleanup only, if more than 10% of the lines are affected
if (dirtyMemoryLines > 0.1 * this.lines.length) {
this._memoryCleanupPosition = 0;
this._memoryCleanupQueue.enqueue(() => this._batchedMemoryCleanup());
}
}
private _memoryCleanupQueue = new IdleTaskQueue();
private _memoryCleanupPosition = 0;
private _batchedMemoryCleanup(): boolean {
let normalRun = true;
if (this._memoryCleanupPosition >= this.lines.length) {
// cleanup made it once through all lines, thus rescan in loop below to also catch shifted
// lines, which should finish rather quick if there are no more cleanups pending
this._memoryCleanupPosition = 0;
normalRun = false;
}
let counted = 0;
while (this._memoryCleanupPosition < this.lines.length) {
counted += this.lines.get(this._memoryCleanupPosition++)!.cleanupMemory();
// cleanup max 100 lines per batch
if (counted > 100) {
return true;
}
}
// normal runs always need another rescan afterwards
// if we made it here with normalRun=false, we are in a final run
// and can end the cleanup task for sure
return normalRun;
}
private get _isReflowEnabled(): boolean {
const windowsPty = this._optionsService.rawOptions.windowsPty;
if (windowsPty && windowsPty.buildNumber) {
return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376;
}
return this._hasScrollback && !this._optionsService.rawOptions.windowsMode;
}
private _reflow(newCols: number, newRows: number): void {
if (this._cols === newCols) {
return;
}
// Iterate through rows, ignore the last one as it cannot be wrapped
if (newCols > this._cols) {
this._reflowLarger(newCols, newRows);
} else {
this._reflowSmaller(newCols, newRows);
}
}
private _reflowLarger(newCols: number, newRows: number): void {
const toRemove: number[] = reflowLargerGetLinesToRemove(this.lines, this._cols, newCols, this.ybase + this.y, this.getNullCell(DEFAULT_ATTR_DATA));
if (toRemove.length > 0) {
const newLayoutResult = reflowLargerCreateNewLayout(this.lines, toRemove);
reflowLargerApplyNewLayout(this.lines, newLayoutResult.layout);
this._reflowLargerAdjustViewport(newCols, newRows, newLayoutResult.countRemoved);
}
}
private _reflowLargerAdjustViewport(newCols: number, newRows: number, countRemoved: number): void {
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
// Adjust viewport based on number of items removed
let viewportAdjustments = countRemoved;
while (viewportAdjustments-- > 0) {
if (this.ybase === 0) {
if (this.y > 0) {
this.y--;
}
if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(newCols, nullCell));
}
} else {
if (this.ydisp === this.ybase) {
this.ydisp--;
}
this.ybase--;
}
}
this.savedY = Math.max(this.savedY - countRemoved, 0);
}
private _reflowSmaller(newCols: number, newRows: number): void {
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
// Gather all BufferLines that need to be inserted into the Buffer here so that they can be
// batched up and only committed once
const toInsert = [];
let countToInsert = 0;
// Go backwards as many lines may be trimmed and this will avoid considering them
for (let y = this.lines.length - 1; y >= 0; y--) {
// Check whether this line is a problem
let nextLine = this.lines.get(y) as BufferLine;
if (!nextLine || !nextLine.isWrapped && nextLine.getTrimmedLength() <= newCols) {
continue;
}
// Gather wrapped lines and adjust y to be the starting line
const wrappedLines: BufferLine[] = [nextLine];
while (nextLine.isWrapped && y > 0) {
nextLine = this.lines.get(--y) as BufferLine;
wrappedLines.unshift(nextLine);
}
// If these lines contain the cursor don't touch them, the program will handle fixing up
// wrapped lines with the cursor
const absoluteY = this.ybase + this.y;
if (absoluteY >= y && absoluteY < y + wrappedLines.length) {
continue;
}
const lastLineLength = wrappedLines[wrappedLines.length - 1].getTrimmedLength();
const destLineLengths = reflowSmallerGetNewLineLengths(wrappedLines, this._cols, newCols);
const linesToAdd = destLineLengths.length - wrappedLines.length;
let trimmedLines: number;
if (this.ybase === 0 && this.y !== this.lines.length - 1) {
// If the top section of the buffer is not yet filled
trimmedLines = Math.max(0, this.y - this.lines.maxLength + linesToAdd);
} else {
trimmedLines = Math.max(0, this.lines.length - this.lines.maxLength + linesToAdd);
}
// Add the new lines
const newLines: BufferLine[] = [];
for (let i = 0; i < linesToAdd; i++) {
const newLine = this.getBlankLine(DEFAULT_ATTR_DATA, true) as BufferLine;
newLines.push(newLine);
}
if (newLines.length > 0) {
toInsert.push({
// countToInsert here gets the actual index, taking into account other inserted items.
// using this we can iterate through the list forwards
start: y + wrappedLines.length + countToInsert,
newLines
});
countToInsert += newLines.length;
}
wrappedLines.push(...newLines);
// Copy buffer data to new locations, this needs to happen backwards to do in-place
let destLineIndex = destLineLengths.length - 1; // Math.floor(cellsNeeded / newCols);
let destCol = destLineLengths[destLineIndex]; // cellsNeeded % newCols;
if (destCol === 0) {
destLineIndex--;
destCol = destLineLengths[destLineIndex];
}
let srcLineIndex = wrappedLines.length - linesToAdd - 1;
let srcCol = lastLineLength;
while (srcLineIndex >= 0) {
const cellsToCopy = Math.min(srcCol, destCol);
if (wrappedLines[destLineIndex] === undefined) {
// Sanity check that the line exists, this has been known to fail for an unknown reason
// which would stop the reflow from happening if an exception would throw.
break;
}
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true);
destCol -= cellsToCopy;
if (destCol === 0) {
destLineIndex--;
destCol = destLineLengths[destLineIndex];
}
srcCol -= cellsToCopy;
if (srcCol === 0) {
srcLineIndex--;
const wrappedLinesIndex = Math.max(srcLineIndex, 0);
srcCol = getWrappedLineTrimmedLength(wrappedLines, wrappedLinesIndex, this._cols);
}
}
// Null out the end of the line ends if a wide character wrapped to the following line
for (let i = 0; i < wrappedLines.length; i++) {
if (destLineLengths[i] < newCols) {
wrappedLines[i].setCell(destLineLengths[i], nullCell);
}
}
// Adjust viewport as needed
let viewportAdjustments = linesToAdd - trimmedLines;
while (viewportAdjustments-- > 0) {
if (this.ybase === 0) {
if (this.y < newRows - 1) {
this.y++;
this.lines.pop();
} else {
this.ybase++;
this.ydisp++;
}
} else {
// Ensure ybase does not exceed its maximum value
if (this.ybase < Math.min(this.lines.maxLength, this.lines.length + countToInsert) - newRows) {
if (this.ybase === this.ydisp) {
this.ydisp++;
}
this.ybase++;
}
}
}
this.savedY = Math.min(this.savedY + linesToAdd, this.ybase + newRows - 1);
}
// Rearrange lines in the buffer if there are any insertions, this is done at the end rather
// than earlier so that it's a single O(n) pass through the buffer, instead of O(n^2) from many
// costly calls to CircularList.splice.
if (toInsert.length > 0) {
// Record buffer insert events and then play them back backwards so that the indexes are
// correct
const insertEvents: IInsertEvent[] = [];
// Record original lines so they don't get overridden when we rearrange the list
const originalLines: BufferLine[] = [];
for (let i = 0; i < this.lines.length; i++) {
originalLines.push(this.lines.get(i) as BufferLine);
}
const originalLinesLength = this.lines.length;
let originalLineIndex = originalLinesLength - 1;
let nextToInsertIndex = 0;
let nextToInsert = toInsert[nextToInsertIndex];
this.lines.length = Math.min(this.lines.maxLength, this.lines.length + countToInsert);
let countInsertedSoFar = 0;
for (let i = Math.min(this.lines.maxLength - 1, originalLinesLength + countToInsert - 1); i >= 0; i--) {
if (nextToInsert && nextToInsert.start > originalLineIndex + countInsertedSoFar) {
// Insert extra lines here, adjusting i as needed
for (let nextI = nextToInsert.newLines.length - 1; nextI >= 0; nextI--) {
this.lines.set(i--, nextToInsert.newLines[nextI]);
}
i++;
// Create insert events for later
insertEvents.push({
index: originalLineIndex + 1,
amount: nextToInsert.newLines.length
});
countInsertedSoFar += nextToInsert.newLines.length;
nextToInsert = toInsert[++nextToInsertIndex];
} else {
this.lines.set(i, originalLines[originalLineIndex--]);
}
}
// Update markers
let insertCountEmitted = 0;
for (let i = insertEvents.length - 1; i >= 0; i--) {
insertEvents[i].index += insertCountEmitted;
this.lines.onInsertEmitter.fire(insertEvents[i]);
insertCountEmitted += insertEvents[i].amount;
}
const amountToTrim = Math.max(0, originalLinesLength + countToInsert - this.lines.maxLength);
if (amountToTrim > 0) {
this.lines.onTrimEmitter.fire(amountToTrim);
}
}
}
/**
* Translates a buffer line to a string, with optional start and end columns.
* Wide characters will count as two columns in the resulting string. This
* function is useful for getting the actual text underneath the raw selection
* position.
* @param lineIndex The absolute index of the line being translated.
* @param trimRight Whether to trim whitespace to the right.
* @param startCol The column to start at.
* @param endCol The column to end at.
*/
public translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol: number = 0, endCol?: number): string {
const line = this.lines.get(lineIndex);
if (!line) {
return '';
}
return line.translateToString(trimRight, startCol, endCol);
}
public getWrappedRangeForLine(y: number): { first: number, last: number } {
let first = y;
let last = y;
// Scan upwards for wrapped lines
while (first > 0 && this.lines.get(first)!.isWrapped) {
first--;
}
// Scan downwards for wrapped lines
while (last + 1 < this.lines.length && this.lines.get(last + 1)!.isWrapped) {
last++;
}
return { first, last };
}
/**
* Setup the tab stops.
* @param i The index to start setting up tab stops from.
*/
public setupTabStops(i?: number): void {
if (i !== null && i !== undefined) {
if (!this.tabs[i]) {
i = this.prevStop(i);
}
} else {
this.tabs = {};
i = 0;
}
for (; i < this._cols; i += this._optionsService.rawOptions.tabStopWidth) {
this.tabs[i] = true;
}
}
/**
* Move the cursor to the previous tab stop from the given position (default is current).
* @param x The position to move the cursor to the previous tab stop.
*/
public prevStop(x?: number): number {
if (x === null || x === undefined) {
x = this.x;
}
while (!this.tabs[--x] && x > 0);
return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x;
}
/**
* Move the cursor one tab stop forward from the given position (default is current).
* @param x The position to move the cursor one tab stop forward.
*/
public nextStop(x?: number): number {
if (x === null || x === undefined) {
x = this.x;
}
while (!this.tabs[++x] && x < this._cols);
return x >= this._cols ? this._cols - 1 : x < 0 ? 0 : x;
}
/**
* Clears markers on single line.
* @param y The line to clear.
*/
public clearMarkers(y: number): void {
this._isClearing = true;
for (let i = 0; i < this.markers.length; i++) {
if (this.markers[i].line === y) {
this.markers[i].dispose();
this.markers.splice(i--, 1);
}
}
this._isClearing = false;
}
/**
* Clears markers on all lines
*/
public clearAllMarkers(): void {
this._isClearing = true;
for (let i = 0; i < this.markers.length; i++) {
this.markers[i].dispose();
this.markers.splice(i--, 1);
}
this._isClearing = false;
}
public addMarker(y: number): Marker {
const marker = new Marker(y);
this.markers.push(marker);
marker.register(this.lines.onTrim(amount => {
marker.line -= amount;
// The marker should be disposed when the line is trimmed from the buffer
if (marker.line < 0) {
marker.dispose();
}
}));
marker.register(this.lines.onInsert(event => {
if (marker.line >= event.index) {
marker.line += event.amount;
}
}));
marker.register(this.lines.onDelete(event => {
// Delete the marker if it's within the range
if (marker.line >= event.index && marker.line < event.index + event.amount) {
marker.dispose();
}
// Shift the marker if it's after the deleted range
if (marker.line > event.index) {
marker.line -= event.amount;
}
}));
marker.register(marker.onDispose(() => this._removeMarker(marker)));
return marker;
}
private _removeMarker(marker: Marker): void {
if (!this._isClearing) {
this.markers.splice(this.markers.indexOf(marker), 1);
}
}
}

View File

@@ -0,0 +1,520 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
import { stringFromCodePoint } from 'common/input/TextDecoder';
/**
* buffer memory layout:
*
* | uint32_t | uint32_t | uint32_t |
* | `content` | `FG` | `BG` |
* | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) |
*/
/** typed array slots taken by one cell */
const CELL_SIZE = 3;
/**
* Cell member indices.
*
* Direct access:
* `content = data[column * CELL_SIZE + Cell.CONTENT];`
* `fg = data[column * CELL_SIZE + Cell.FG];`
* `bg = data[column * CELL_SIZE + Cell.BG];`
*/
const enum Cell {
CONTENT = 0,
FG = 1, // currently simply holds all known attrs
BG = 2 // currently unused
}
export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
// Work variables to avoid garbage collection
let $startIndex = 0;
/** Factor when to cleanup underlying array buffer after shrinking. */
const CLEANUP_THRESHOLD = 2;
/**
* Typed array based bufferline implementation.
*
* There are 2 ways to insert data into the cell buffer:
* - `setCellFromCodepoint` + `addCodepointToCell`
* Use these for data that is already UTF32.
* Used during normal input in `InputHandler` for faster buffer access.
* - `setCell`
* This method takes a CellData object and stores the data in the buffer.
* Use `CellData.fromCharData` to create the CellData object (e.g. from JS string).
*
* To retrieve data from the buffer use either one of the primitive methods
* (if only one particular value is needed) or `loadCell`. For `loadCell` in a loop
* memory allocs / GC pressure can be greatly reduced by reusing the CellData object.
*/
export class BufferLine implements IBufferLine {
protected _data: Uint32Array;
protected _combined: {[index: number]: string} = {};
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
public length: number;
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
this._data = new Uint32Array(cols * CELL_SIZE);
const cell = fillCellData || CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
for (let i = 0; i < cols; ++i) {
this.setCell(i, cell);
}
this.length = cols;
}
/**
* Get cell data CharData.
* @deprecated
*/
public get(index: number): CharData {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
return [
this._data[index * CELL_SIZE + Cell.FG],
(content & Content.IS_COMBINED_MASK)
? this._combined[index]
: (cp) ? stringFromCodePoint(cp) : '',
content >> Content.WIDTH_SHIFT,
(content & Content.IS_COMBINED_MASK)
? this._combined[index].charCodeAt(this._combined[index].length - 1)
: cp
];
}
/**
* Set cell data from CharData.
* @deprecated
*/
public set(index: number, value: CharData): void {
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
} else {
this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
}
/**
* primitive getters
* use these when only one value is needed, otherwise use `loadCell`
*/
public getWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT;
}
/** Test whether content has width. */
public hasWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK;
}
/** Get FG cell component. */
public getFg(index: number): number {
return this._data[index * CELL_SIZE + Cell.FG];
}
/** Get BG cell component. */
public getBg(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG];
}
/**
* Test whether contains any chars.
* Basically an empty has no content, but other cells might differ in FG/BG
* from real empty cells.
*/
public hasContent(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
}
/**
* Get codepoint of the cell.
* To be in line with `code` in CharData this either returns
* a single UTF32 codepoint or the last codepoint of a combined string.
*/
public getCodePoint(index: number): number {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index].charCodeAt(this._combined[index].length - 1);
}
return content & Content.CODEPOINT_MASK;
}
/** Test whether the cell contains a combined string. */
public isCombined(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK;
}
/** Returns the string content of the cell. */
public getString(index: number): string {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index];
}
if (content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
}
// return empty string for empty cells
return '';
}
/** Get state of protected flag. */
public isProtected(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED;
}
/**
* Load data at `index` into `cell`. This is used to access cells in a way that's more friendly
* to GC as it significantly reduced the amount of new objects/references needed.
*/
public loadCell(index: number, cell: ICellData): ICellData {
$startIndex = index * CELL_SIZE;
cell.content = this._data[$startIndex + Cell.CONTENT];
cell.fg = this._data[$startIndex + Cell.FG];
cell.bg = this._data[$startIndex + Cell.BG];
if (cell.content & Content.IS_COMBINED_MASK) {
cell.combinedData = this._combined[index];
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
cell.extended = this._extendedAttrs[index]!;
}
return cell;
}
/**
* Set data at `index` to `cell`.
*/
public setCell(index: number, cell: ICellData): void {
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = cell.extended;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content;
this._data[index * CELL_SIZE + Cell.FG] = cell.fg;
this._data[index * CELL_SIZE + Cell.BG] = cell.bg;
}
/**
* Set cell data from input handler.
* Since the input handler see the incoming chars as UTF32 codepoints,
* it gets an optimized access method.
*/
public setCellFromCodePoint(index: number, codePoint: number, width: number, fg: number, bg: number, eAttrs: IExtendedAttrs): void {
if (bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = eAttrs;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * CELL_SIZE + Cell.FG] = fg;
this._data[index * CELL_SIZE + Cell.BG] = bg;
}
/**
* Add a codepoint to a cell from input handler.
* During input stage combining chars with a width of 0 follow and stack
* onto a leading char. Since we already set the attrs
* by the previous `setDataFromCodePoint` call, we can omit it here.
*/
public addCodepointToCell(index: number, codePoint: number): void {
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
this._combined[index] += stringFromCodePoint(codePoint);
} else {
if (content & Content.CODEPOINT_MASK) {
// normal case for combining chars:
// - move current leading char + new one into combined string
// - set combined flag
this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0
content |= Content.IS_COMBINED_MASK;
} else {
// should not happen - we actually have no data in the cell yet
// simply set the data in the cell buffer with a width of 1
content = codePoint | (1 << Content.WIDTH_SHIFT);
}
this._data[index * CELL_SIZE + Cell.CONTENT] = content;
}
}
public insertCells(pos: number, n: number, fillCellData: ICellData, eraseAttr?: IAttributeData): void {
pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodePoint(pos - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
if (n < this.length - pos) {
const cell = new CellData();
for (let i = this.length - pos - n - 1; i >= 0; --i) {
this.setCell(pos + n + i, this.loadCell(pos + i, cell));
}
for (let i = 0; i < n; ++i) {
this.setCell(pos + i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
// handle fullwidth at line end: reset last cell if it is first cell of a wide char
if (this.getWidth(this.length - 1) === 2) {
this.setCellFromCodePoint(this.length - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
}
public deleteCells(pos: number, n: number, fillCellData: ICellData, eraseAttr?: IAttributeData): void {
pos %= this.length;
if (n < this.length - pos) {
const cell = new CellData();
for (let i = 0; i < this.length - pos - n; ++i) {
this.setCell(pos + i, this.loadCell(pos + n + i, cell));
}
for (let i = this.length - n; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
} else {
for (let i = pos; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
// handle fullwidth at pos:
// - reset pos-1 if wide char
// - reset pos if width==0 (previous second cell of a wide char)
if (pos && this.getWidth(pos - 1) === 2) {
this.setCellFromCodePoint(pos - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
if (this.getWidth(pos) === 0 && !this.hasContent(pos)) {
this.setCellFromCodePoint(pos, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
}
public replaceCells(start: number, end: number, fillCellData: ICellData, eraseAttr?: IAttributeData, respectProtect: boolean = false): void {
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
this.setCellFromCodePoint(start - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) {
this.setCellFromCodePoint(end, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
while (start < end && start < this.length) {
if (!this.isProtected(start)) {
this.setCell(start, fillCellData);
}
start++;
}
return;
}
// handle fullwidth at start: reset cell one to the left if start is second cell of a wide char
if (start && this.getWidth(start - 1) === 2) {
this.setCellFromCodePoint(start - 1, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
// handle fullwidth at last cell + 1: reset to empty cell if it is second part of a wide char
if (end < this.length && this.getWidth(end - 1) === 2) {
this.setCellFromCodePoint(end, 0, 1, eraseAttr?.fg || 0, eraseAttr?.bg || 0, eraseAttr?.extended || new ExtendedAttrs());
}
while (start < end && start < this.length) {
this.setCell(start++, fillCellData);
}
}
/**
* Resize BufferLine to `cols` filling excess cells with `fillCellData`.
* The underlying array buffer will not change if there is still enough space
* to hold the new buffer line data.
* Returns a boolean indicating, whether a `cleanupMemory` call would free
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
if (cols === this.length) {
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * CELL_SIZE;
if (cols > this.length) {
if (this._data.buffer.byteLength >= uint32Cells * 4) {
// optimization: avoid alloc and data copy if buffer has enough room
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
} else {
// slow path: new alloc and full data copy
const data = new Uint32Array(uint32Cells);
data.set(this._data);
this._data = data;
}
for (let i = this.length; i < cols; ++i) {
this.setCell(i, fillCellData);
}
} else {
// optimization: just shrink the view on existing buffer
this._data = this._data.subarray(0, uint32Cells);
// Remove any cut off combined data
const keys = Object.keys(this._combined);
for (let i = 0; i < keys.length; i++) {
const key = parseInt(keys[i], 10);
if (key >= cols) {
delete this._combined[key];
}
}
// remove any cut off extended attributes
const extKeys = Object.keys(this._extendedAttrs);
for (let i = 0; i < extKeys.length; i++) {
const key = parseInt(extKeys[i], 10);
if (key >= cols) {
delete this._extendedAttrs[key];
}
}
}
this.length = cols;
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
/**
* Cleanup underlying array buffer.
* A cleanup will be triggered if the array buffer exceeds the actual used
* memory by a factor of CLEANUP_THRESHOLD.
* Returns 0 or 1 indicating whether a cleanup happened.
*/
public cleanupMemory(): number {
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
const data = new Uint32Array(this._data.length);
data.set(this._data);
this._data = data;
return 1;
}
return 0;
}
/** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
if (!this.isProtected(i)) {
this.setCell(i, fillCellData);
}
}
return;
}
this._combined = {};
this._extendedAttrs = {};
for (let i = 0; i < this.length; ++i) {
this.setCell(i, fillCellData);
}
}
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
} else {
// use high speed copy if lengths are equal
this._data.set(line._data);
}
this.length = line.length;
this._combined = {};
for (const el in line._combined) {
this._combined[el] = line._combined[el];
}
this._extendedAttrs = {};
for (const el in line._extendedAttrs) {
this._extendedAttrs[el] = line._extendedAttrs[el];
}
this.isWrapped = line.isWrapped;
}
/** create a new clone */
public clone(): IBufferLine {
const newLine = new BufferLine(0);
newLine._data = new Uint32Array(this._data);
newLine.length = this.length;
for (const el in this._combined) {
newLine._combined[el] = this._combined[el];
}
for (const el in this._extendedAttrs) {
newLine._extendedAttrs[el] = this._extendedAttrs[el];
}
newLine.isWrapped = this.isWrapped;
return newLine;
}
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
const srcData = src._data;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
}
}
} else {
for (let cell = 0; cell < length; cell++) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
}
}
}
// Move any combined data over as needed, FIXME: repeat for extended attrs
const srcCombinedKeys = Object.keys(src._combined);
for (let i = 0; i < srcCombinedKeys.length; i++) {
const key = parseInt(srcCombinedKeys[i], 10);
if (key >= srcCol) {
this._combined[key - srcCol + destCol] = src._combined[key];
}
}
}
public translateToString(trimRight: boolean = false, startCol: number = 0, endCol: number = this.length): string {
if (trimRight) {
endCol = Math.min(endCol, this.getTrimmedLength());
}
let result = '';
while (startCol < endCol) {
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
result += (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
startCol += (content >> Content.WIDTH_SHIFT) || 1; // always advance by 1
}
return result;
}
}

View File

@@ -0,0 +1,13 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferRange } from 'xterm';
export function getRangeLength(range: IBufferRange, bufferCols: number): number {
if (range.start.y > range.end.y) {
throw new Error(`Buffer range end (${range.end.x}, ${range.end.y}) cannot be before start (${range.start.x}, ${range.start.y})`);
}
return bufferCols * (range.end.y - range.start.y) + (range.end.x - range.start.x + 1);
}

View File

@@ -0,0 +1,223 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { BufferLine } from 'common/buffer/BufferLine';
import { CircularList } from 'common/CircularList';
import { IBufferLine, ICellData } from 'common/Types';
export interface INewLayoutResult {
layout: number[];
countRemoved: number;
}
/**
* Evaluates and returns indexes to be removed after a reflow larger occurs. Lines will be removed
* when a wrapped line unwraps.
* @param lines The buffer lines.
* @param oldCols The columns before resize
* @param newCols The columns after resize.
* @param bufferAbsoluteY The absolute y position of the cursor (baseY + cursorY).
* @param nullCell The cell data to use when filling in empty cells.
*/
export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData): number[] {
// Gather all BufferLines that need to be removed from the Buffer here so that they can be
// batched up and only committed once
const toRemove: number[] = [];
for (let y = 0; y < lines.length - 1; y++) {
// Check if this row is wrapped
let i = y;
let nextLine = lines.get(++i) as BufferLine;
if (!nextLine.isWrapped) {
continue;
}
// Check how many lines it's wrapped for
const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine];
while (i < lines.length && nextLine.isWrapped) {
wrappedLines.push(nextLine);
nextLine = lines.get(++i) as BufferLine;
}
// If these lines contain the cursor don't touch them, the program will handle fixing up wrapped
// lines with the cursor
if (bufferAbsoluteY >= y && bufferAbsoluteY < i) {
y += wrappedLines.length - 1;
continue;
}
// Copy buffer data to new locations
let destLineIndex = 0;
let destCol = getWrappedLineTrimmedLength(wrappedLines, destLineIndex, oldCols);
let srcLineIndex = 1;
let srcCol = 0;
while (srcLineIndex < wrappedLines.length) {
const srcTrimmedTineLength = getWrappedLineTrimmedLength(wrappedLines, srcLineIndex, oldCols);
const srcRemainingCells = srcTrimmedTineLength - srcCol;
const destRemainingCells = newCols - destCol;
const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells);
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false);
destCol += cellsToCopy;
if (destCol === newCols) {
destLineIndex++;
destCol = 0;
}
srcCol += cellsToCopy;
if (srcCol === srcTrimmedTineLength) {
srcLineIndex++;
srcCol = 0;
}
// Make sure the last cell isn't wide, if it is copy it to the current dest
if (destCol === 0 && destLineIndex !== 0) {
if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) {
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false);
// Null out the end of the last row
wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell);
}
}
}
// Clear out remaining cells or fragments could remain;
wrappedLines[destLineIndex].replaceCells(destCol, newCols, nullCell);
// Work backwards and remove any rows at the end that only contain null cells
let countToRemove = 0;
for (let i = wrappedLines.length - 1; i > 0; i--) {
if (i > destLineIndex || wrappedLines[i].getTrimmedLength() === 0) {
countToRemove++;
} else {
break;
}
}
if (countToRemove > 0) {
toRemove.push(y + wrappedLines.length - countToRemove); // index
toRemove.push(countToRemove);
}
y += wrappedLines.length - 1;
}
return toRemove;
}
/**
* Creates and return the new layout for lines given an array of indexes to be removed.
* @param lines The buffer lines.
* @param toRemove The indexes to remove.
*/
export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
const layout: number[] = [];
// First iterate through the list and get the actual indexes to use for rows
let nextToRemoveIndex = 0;
let nextToRemoveStart = toRemove[nextToRemoveIndex];
let countRemovedSoFar = 0;
for (let i = 0; i < lines.length; i++) {
if (nextToRemoveStart === i) {
const countToRemove = toRemove[++nextToRemoveIndex];
// Tell markers that there was a deletion
lines.onDeleteEmitter.fire({
index: i - countRemovedSoFar,
amount: countToRemove
});
i += countToRemove - 1;
countRemovedSoFar += countToRemove;
nextToRemoveStart = toRemove[++nextToRemoveIndex];
} else {
layout.push(i);
}
}
return {
layout,
countRemoved: countRemovedSoFar
};
}
/**
* Applies a new layout to the buffer. This essentially does the same as many splice calls but it's
* done all at once in a single iteration through the list since splice is very expensive.
* @param lines The buffer lines.
* @param newLayout The new layout to apply.
*/
export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, newLayout: number[]): void {
// Record original lines so they don't get overridden when we rearrange the list
const newLayoutLines: BufferLine[] = [];
for (let i = 0; i < newLayout.length; i++) {
newLayoutLines.push(lines.get(newLayout[i]) as BufferLine);
}
// Rearrange the list
for (let i = 0; i < newLayoutLines.length; i++) {
lines.set(i, newLayoutLines[i]);
}
lines.length = newLayout.length;
}
/**
* Gets the new line lengths for a given wrapped line. The purpose of this function it to pre-
* compute the wrapping points since wide characters may need to be wrapped onto the following line.
* This function will return an array of numbers of where each line wraps to, the resulting array
* will only contain the values `newCols` (when the line does not end with a wide character) and
* `newCols - 1` (when the line does end with a wide character), except for the last value which
* will contain the remaining items to fill the line.
*
* Calling this with a `newCols` value of `1` will lock up.
*
* @param wrappedLines The wrapped lines to evaluate.
* @param oldCols The columns before resize.
* @param newCols The columns after resize.
*/
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
const newLineLengths: number[] = [];
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
// Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and
// linesNeeded
let srcCol = 0;
let srcLine = 0;
let cellsAvailable = 0;
while (cellsAvailable < cellsNeeded) {
if (cellsNeeded - cellsAvailable < newCols) {
// Add the final line and exit the loop
newLineLengths.push(cellsNeeded - cellsAvailable);
break;
}
srcCol += newCols;
const oldTrimmedLength = getWrappedLineTrimmedLength(wrappedLines, srcLine, oldCols);
if (srcCol > oldTrimmedLength) {
srcCol -= oldTrimmedLength;
srcLine++;
}
const endsWithWide = wrappedLines[srcLine].getWidth(srcCol - 1) === 2;
if (endsWithWide) {
srcCol--;
}
const lineLength = endsWithWide ? newCols - 1 : newCols;
newLineLengths.push(lineLength);
cellsAvailable += lineLength;
}
return newLineLengths;
}
export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number {
// If this is the last row in the wrapped line, get the actual trimmed length
if (i === lines.length - 1) {
return lines[i].getTrimmedLength();
}
// Detect whether the following line starts with a wide character and the end of the current line
// is null, if so then we can be pretty sure the null character should be excluded from the line
// length]
const endsInNull = !(lines[i].hasContent(cols - 1)) && lines[i].getWidth(cols - 1) === 1;
const followingLineStartsWithWide = lines[i + 1].getWidth(0) === 2;
if (endsInNull && followingLineStartsWithWide) {
return cols - 1;
}
return cols;
}

View File

@@ -0,0 +1,134 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { IAttributeData } from 'common/Types';
import { Buffer } from 'common/buffer/Buffer';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { IBufferService, IOptionsService } from 'common/services/Services';
/**
* The BufferSet represents the set of two buffers used by xterm terminals (normal and alt) and
* provides also utilities for working with them.
*/
export class BufferSet extends Disposable implements IBufferSet {
private _normal!: Buffer;
private _alt!: Buffer;
private _activeBuffer!: Buffer;
private readonly _onBufferActivate = this.register(new EventEmitter<{activeBuffer: IBuffer, inactiveBuffer: IBuffer}>());
public readonly onBufferActivate = this._onBufferActivate.event;
/**
* Create a new BufferSet for the given terminal.
*/
constructor(
private readonly _optionsService: IOptionsService,
private readonly _bufferService: IBufferService
) {
super();
this.reset();
this.register(this._optionsService.onSpecificOptionChange('scrollback', () => this.resize(this._bufferService.cols, this._bufferService.rows)));
this.register(this._optionsService.onSpecificOptionChange('tabStopWidth', () => this.setupTabStops()));
}
public reset(): void {
this._normal = new Buffer(true, this._optionsService, this._bufferService);
this._normal.fillViewportRows();
// The alt buffer should never have scrollback.
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
this._alt = new Buffer(false, this._optionsService, this._bufferService);
this._activeBuffer = this._normal;
this._onBufferActivate.fire({
activeBuffer: this._normal,
inactiveBuffer: this._alt
});
this.setupTabStops();
}
/**
* Returns the alt Buffer of the BufferSet
*/
public get alt(): Buffer {
return this._alt;
}
/**
* Returns the currently active Buffer of the BufferSet
*/
public get active(): Buffer {
return this._activeBuffer;
}
/**
* Returns the normal Buffer of the BufferSet
*/
public get normal(): Buffer {
return this._normal;
}
/**
* Sets the normal Buffer of the BufferSet as its currently active Buffer
*/
public activateNormalBuffer(): void {
if (this._activeBuffer === this._normal) {
return;
}
this._normal.x = this._alt.x;
this._normal.y = this._alt.y;
// The alt buffer should always be cleared when we switch to the normal
// buffer. This frees up memory since the alt buffer should always be new
// when activated.
this._alt.clearAllMarkers();
this._alt.clear();
this._activeBuffer = this._normal;
this._onBufferActivate.fire({
activeBuffer: this._normal,
inactiveBuffer: this._alt
});
}
/**
* Sets the alt Buffer of the BufferSet as its currently active Buffer
*/
public activateAltBuffer(fillAttr?: IAttributeData): void {
if (this._activeBuffer === this._alt) {
return;
}
// Since the alt buffer is always cleared when the normal buffer is
// activated, we want to fill it when switching to it.
this._alt.fillViewportRows(fillAttr);
this._alt.x = this._normal.x;
this._alt.y = this._normal.y;
this._activeBuffer = this._alt;
this._onBufferActivate.fire({
activeBuffer: this._alt,
inactiveBuffer: this._normal
});
}
/**
* Resizes both normal and alt buffers, adjusting their data accordingly.
* @param newCols The new number of columns.
* @param newRows The new number of rows.
*/
public resize(newCols: number, newRows: number): void {
this._normal.resize(newCols, newRows);
this._alt.resize(newCols, newRows);
this.setupTabStops(newCols);
}
/**
* Setup the tab stops.
* @param i The index to start setting up tab stops from.
*/
public setupTabStops(i?: number): void {
this._normal.setupTabStops(i);
this._alt.setupTabStops(i);
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CharData, ICellData, IExtendedAttrs } from 'common/Types';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
/**
* CellData - represents a single Cell in the terminal buffer.
*/
export class CellData extends AttributeData implements ICellData {
/** Helper to create CellData from CharData. */
public static fromCharData(value: CharData): CellData {
const obj = new CellData();
obj.setFromCharData(value);
return obj;
}
/** Primitives from terminal buffer. */
public content = 0;
public fg = 0;
public bg = 0;
public extended: IExtendedAttrs = new ExtendedAttrs();
public combinedData = '';
/** Whether cell contains a combined string. */
public isCombined(): number {
return this.content & Content.IS_COMBINED_MASK;
}
/** Width of the cell. */
public getWidth(): number {
return this.content >> Content.WIDTH_SHIFT;
}
/** JS string of the content. */
public getChars(): string {
if (this.content & Content.IS_COMBINED_MASK) {
return this.combinedData;
}
if (this.content & Content.CODEPOINT_MASK) {
return stringFromCodePoint(this.content & Content.CODEPOINT_MASK);
}
return '';
}
/**
* Codepoint of cell
* Note this returns the UTF32 codepoint of single chars,
* if content is a combined string it returns the codepoint
* of the last char in string to be in line with code in CharData.
*/
public getCode(): number {
return (this.isCombined())
? this.combinedData.charCodeAt(this.combinedData.length - 1)
: this.content & Content.CODEPOINT_MASK;
}
/** Set data from CharData */
public setFromCharData(value: CharData): void {
this.fg = value[CHAR_DATA_ATTR_INDEX];
this.bg = 0;
let combined = false;
// surrogates and combined strings need special treatment
if (value[CHAR_DATA_CHAR_INDEX].length > 2) {
combined = true;
}
else if (value[CHAR_DATA_CHAR_INDEX].length === 2) {
const code = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0);
// if the 2-char string is a surrogate create single codepoint
// everything else is combined
if (0xD800 <= code && code <= 0xDBFF) {
const second = value[CHAR_DATA_CHAR_INDEX].charCodeAt(1);
if (0xDC00 <= second && second <= 0xDFFF) {
this.content = ((code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
else {
combined = true;
}
}
else {
combined = true;
}
}
else {
this.content = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
if (combined) {
this.combinedData = value[CHAR_DATA_CHAR_INDEX];
this.content = Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
}
/** Get data as CharData. */
public getAsCharData(): CharData {
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
}
}

View File

@@ -0,0 +1,149 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
export const DEFAULT_COLOR = 0;
export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0);
export const DEFAULT_EXT = 0;
export const CHAR_DATA_ATTR_INDEX = 0;
export const CHAR_DATA_CHAR_INDEX = 1;
export const CHAR_DATA_WIDTH_INDEX = 2;
export const CHAR_DATA_CODE_INDEX = 3;
/**
* Null cell - a real empty cell (containing nothing).
* Note that code should always be 0 for a null cell as
* several test condition of the buffer line rely on this.
*/
export const NULL_CELL_CHAR = '';
export const NULL_CELL_WIDTH = 1;
export const NULL_CELL_CODE = 0;
/**
* Whitespace cell.
* This is meant as a replacement for empty cells when needed
* during rendering lines to preserve correct aligment.
*/
export const WHITESPACE_CELL_CHAR = ' ';
export const WHITESPACE_CELL_WIDTH = 1;
export const WHITESPACE_CELL_CODE = 32;
/**
* Bitmasks for accessing data in `content`.
*/
export const enum Content {
/**
* bit 1..21 codepoint, max allowed in UTF32 is 0x10FFFF (21 bits taken)
* read: `codepoint = content & Content.codepointMask;`
* write: `content |= codepoint & Content.codepointMask;`
* shortcut if precondition `codepoint <= 0x10FFFF` is met:
* `content |= codepoint;`
*/
CODEPOINT_MASK = 0x1FFFFF,
/**
* bit 22 flag indication whether a cell contains combined content
* read: `isCombined = content & Content.isCombined;`
* set: `content |= Content.isCombined;`
* clear: `content &= ~Content.isCombined;`
*/
IS_COMBINED_MASK = 0x200000, // 1 << 21
/**
* bit 1..22 mask to check whether a cell contains any string data
* we need to check for codepoint and isCombined bits to see
* whether a cell contains anything
* read: `isEmpty = !(content & Content.hasContent)`
*/
HAS_CONTENT_MASK = 0x3FFFFF,
/**
* bit 23..24 wcwidth value of cell, takes 2 bits (ranges from 0..2)
* read: `width = (content & Content.widthMask) >> Content.widthShift;`
* `hasWidth = content & Content.widthMask;`
* as long as wcwidth is highest value in `content`:
* `width = content >> Content.widthShift;`
* write: `content |= (width << Content.widthShift) & Content.widthMask;`
* shortcut if precondition `0 <= width <= 3` is met:
* `content |= width << Content.widthShift;`
*/
WIDTH_MASK = 0xC00000, // 3 << 22
WIDTH_SHIFT = 22
}
export const enum Attributes {
/**
* bit 1..8 blue in RGB, color in P256 and P16
*/
BLUE_MASK = 0xFF,
BLUE_SHIFT = 0,
PCOLOR_MASK = 0xFF,
PCOLOR_SHIFT = 0,
/**
* bit 9..16 green in RGB
*/
GREEN_MASK = 0xFF00,
GREEN_SHIFT = 8,
/**
* bit 17..24 red in RGB
*/
RED_MASK = 0xFF0000,
RED_SHIFT = 16,
/**
* bit 25..26 color mode: DEFAULT (0) | P16 (1) | P256 (2) | RGB (3)
*/
CM_MASK = 0x3000000,
CM_DEFAULT = 0,
CM_P16 = 0x1000000,
CM_P256 = 0x2000000,
CM_RGB = 0x3000000,
/**
* bit 1..24 RGB room
*/
RGB_MASK = 0xFFFFFF
}
export const enum FgFlags {
/**
* bit 27..32
*/
INVERSE = 0x4000000,
BOLD = 0x8000000,
UNDERLINE = 0x10000000,
BLINK = 0x20000000,
INVISIBLE = 0x40000000,
STRIKETHROUGH = 0x80000000,
}
export const enum BgFlags {
/**
* bit 27..32 (upper 2 unused)
*/
ITALIC = 0x4000000,
DIM = 0x8000000,
HAS_EXTENDED = 0x10000000,
PROTECTED = 0x20000000,
OVERLINE = 0x40000000
}
export const enum ExtFlags {
/**
* bit 27..32 (upper 3 unused)
*/
UNDERLINE_STYLE = 0x1C000000
}
export const enum UnderlineStyle {
NONE = 0,
SINGLE = 1,
DOUBLE = 2,
CURLY = 3,
DOTTED = 4,
DASHED = 5
}

View File

@@ -0,0 +1,43 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { disposeArray } from 'common/Lifecycle';
import { IDisposable, IMarker } from 'common/Types';
export class Marker implements IMarker {
private static _nextId = 1;
public isDisposed: boolean = false;
private readonly _disposables: IDisposable[] = [];
private readonly _id: number = Marker._nextId++;
public get id(): number { return this._id; }
private readonly _onDispose = this.register(new EventEmitter<void>());
public readonly onDispose = this._onDispose.event;
constructor(
public line: number
) {
}
public dispose(): void {
if (this.isDisposed) {
return;
}
this.isDisposed = true;
this.line = -1;
// Emit before super.dispose such that dispose listeners get a change to react
this._onDispose.fire();
disposeArray(this._disposables);
this._disposables.length = 0;
}
public register<T extends IDisposable>(disposable: T): T {
this._disposables.push(disposable);
return disposable;
}
}

View File

@@ -0,0 +1,52 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IAttributeData, ICircularList, IBufferLine, ICellData, IMarker, ICharset, IDisposable } from 'common/Types';
import { IEvent } from 'common/EventEmitter';
// BufferIndex denotes a position in the buffer: [rowIndex, colIndex]
export type BufferIndex = [number, number];
export interface IBuffer {
readonly lines: ICircularList<IBufferLine>;
ydisp: number;
ybase: number;
y: number;
x: number;
tabs: any;
scrollBottom: number;
scrollTop: number;
hasScrollback: boolean;
savedY: number;
savedX: number;
savedCharset: ICharset | undefined;
savedCurAttrData: IAttributeData;
isCursorInViewport: boolean;
markers: IMarker[];
translateBufferLineToString(lineIndex: number, trimRight: boolean, startCol?: number, endCol?: number): string;
getWrappedRangeForLine(y: number): { first: number, last: number };
nextStop(x?: number): number;
prevStop(x?: number): number;
getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine;
getNullCell(attr?: IAttributeData): ICellData;
getWhitespaceCell(attr?: IAttributeData): ICellData;
addMarker(y: number): IMarker;
clearMarkers(y: number): void;
clearAllMarkers(): void;
}
export interface IBufferSet extends IDisposable {
alt: IBuffer;
normal: IBuffer;
active: IBuffer;
onBufferActivate: IEvent<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>;
activateNormalBuffer(): void;
activateAltBuffer(fillAttr?: IAttributeData): void;
reset(): void;
resize(newCols: number, newRows: number): void;
setupTabStops(i?: number): void;
}

View File

@@ -0,0 +1,256 @@
/**
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICharset } from 'common/Types';
/**
* The character sets supported by the terminal. These enable several languages
* to be represented within the terminal with only 8-bit encoding. See ISO 2022
* for a discussion on character sets. Only VT100 character sets are supported.
*/
export const CHARSETS: { [key: string]: ICharset | undefined } = {};
/**
* The default character set, US.
*/
export const DEFAULT_CHARSET: ICharset | undefined = CHARSETS['B'];
/**
* DEC Special Character and Line Drawing Set.
* Reference: http://vt100.net/docs/vt102-ug/table5-13.html
* A lot of curses apps use this if they see TERM=xterm.
* testing: echo -e '\e(0a\e(B'
* The xterm output sometimes seems to conflict with the
* reference above. xterm seems in line with the reference
* when running vttest however.
* The table below now uses xterm's output from vttest.
*/
CHARSETS['0'] = {
'`': '\u25c6', // '◆'
'a': '\u2592', // '▒'
'b': '\u2409', // '␉' (HT)
'c': '\u240c', // '␌' (FF)
'd': '\u240d', // '␍' (CR)
'e': '\u240a', // '␊' (LF)
'f': '\u00b0', // '°'
'g': '\u00b1', // '±'
'h': '\u2424', // '␤' (NL)
'i': '\u240b', // '␋' (VT)
'j': '\u2518', // '┘'
'k': '\u2510', // '┐'
'l': '\u250c', // '┌'
'm': '\u2514', // '└'
'n': '\u253c', // '┼'
'o': '\u23ba', // '⎺'
'p': '\u23bb', // '⎻'
'q': '\u2500', // '─'
'r': '\u23bc', // '⎼'
's': '\u23bd', // '⎽'
't': '\u251c', // '├'
'u': '\u2524', // '┤'
'v': '\u2534', // '┴'
'w': '\u252c', // '┬'
'x': '\u2502', // '│'
'y': '\u2264', // '≤'
'z': '\u2265', // '≥'
'{': '\u03c0', // 'π'
'|': '\u2260', // '≠'
'}': '\u00a3', // '£'
'~': '\u00b7' // '·'
};
/**
* British character set
* ESC (A
* Reference: http://vt100.net/docs/vt220-rm/table2-5.html
*/
CHARSETS['A'] = {
'#': '£'
};
/**
* United States character set
* ESC (B
*/
CHARSETS['B'] = undefined;
/**
* Dutch character set
* ESC (4
* Reference: http://vt100.net/docs/vt220-rm/table2-6.html
*/
CHARSETS['4'] = {
'#': '£',
'@': '¾',
'[': 'ij',
'\\': '½',
']': '|',
'{': '¨',
'|': 'f',
'}': '¼',
'~': '´'
};
/**
* Finnish character set
* ESC (C or ESC (5
* Reference: http://vt100.net/docs/vt220-rm/table2-7.html
*/
CHARSETS['C'] =
CHARSETS['5'] = {
'[': 'Ä',
'\\': 'Ö',
']': 'Å',
'^': 'Ü',
'`': 'é',
'{': 'ä',
'|': 'ö',
'}': 'å',
'~': 'ü'
};
/**
* French character set
* ESC (R
* Reference: http://vt100.net/docs/vt220-rm/table2-8.html
*/
CHARSETS['R'] = {
'#': '£',
'@': 'à',
'[': '°',
'\\': 'ç',
']': '§',
'{': 'é',
'|': 'ù',
'}': 'è',
'~': '¨'
};
/**
* French Canadian character set
* ESC (Q
* Reference: http://vt100.net/docs/vt220-rm/table2-9.html
*/
CHARSETS['Q'] = {
'@': 'à',
'[': 'â',
'\\': 'ç',
']': 'ê',
'^': 'î',
'`': 'ô',
'{': 'é',
'|': 'ù',
'}': 'è',
'~': 'û'
};
/**
* German character set
* ESC (K
* Reference: http://vt100.net/docs/vt220-rm/table2-10.html
*/
CHARSETS['K'] = {
'@': '§',
'[': 'Ä',
'\\': 'Ö',
']': 'Ü',
'{': 'ä',
'|': 'ö',
'}': 'ü',
'~': 'ß'
};
/**
* Italian character set
* ESC (Y
* Reference: http://vt100.net/docs/vt220-rm/table2-11.html
*/
CHARSETS['Y'] = {
'#': '£',
'@': '§',
'[': '°',
'\\': 'ç',
']': 'é',
'`': 'ù',
'{': 'à',
'|': 'ò',
'}': 'è',
'~': 'ì'
};
/**
* Norwegian/Danish character set
* ESC (E or ESC (6
* Reference: http://vt100.net/docs/vt220-rm/table2-12.html
*/
CHARSETS['E'] =
CHARSETS['6'] = {
'@': 'Ä',
'[': 'Æ',
'\\': 'Ø',
']': 'Å',
'^': 'Ü',
'`': 'ä',
'{': 'æ',
'|': 'ø',
'}': 'å',
'~': 'ü'
};
/**
* Spanish character set
* ESC (Z
* Reference: http://vt100.net/docs/vt220-rm/table2-13.html
*/
CHARSETS['Z'] = {
'#': '£',
'@': '§',
'[': '¡',
'\\': 'Ñ',
']': '¿',
'{': '°',
'|': 'ñ',
'}': 'ç'
};
/**
* Swedish character set
* ESC (H or ESC (7
* Reference: http://vt100.net/docs/vt220-rm/table2-14.html
*/
CHARSETS['H'] =
CHARSETS['7'] = {
'@': 'É',
'[': 'Ä',
'\\': 'Ö',
']': 'Å',
'^': 'Ü',
'`': 'é',
'{': 'ä',
'|': 'ö',
'}': 'å',
'~': 'ü'
};
/**
* Swiss character set
* ESC (=
* Reference: http://vt100.net/docs/vt220-rm/table2-15.html
*/
CHARSETS['='] = {
'#': 'ù',
'@': 'à',
'[': 'é',
'\\': 'ç',
']': 'ê',
'^': 'î',
// eslint-disable-next-line @typescript-eslint/naming-convention
'_': 'è',
'`': 'ô',
'{': 'ä',
'|': 'ö',
'}': 'ü',
'~': 'û'
};

View File

@@ -0,0 +1,153 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* C0 control codes
* See = https://en.wikipedia.org/wiki/C0_and_C1_control_codes
*/
export namespace C0 {
/** Null (Caret = ^@, C = \0) */
export const NUL = '\x00';
/** Start of Heading (Caret = ^A) */
export const SOH = '\x01';
/** Start of Text (Caret = ^B) */
export const STX = '\x02';
/** End of Text (Caret = ^C) */
export const ETX = '\x03';
/** End of Transmission (Caret = ^D) */
export const EOT = '\x04';
/** Enquiry (Caret = ^E) */
export const ENQ = '\x05';
/** Acknowledge (Caret = ^F) */
export const ACK = '\x06';
/** Bell (Caret = ^G, C = \a) */
export const BEL = '\x07';
/** Backspace (Caret = ^H, C = \b) */
export const BS = '\x08';
/** Character Tabulation, Horizontal Tabulation (Caret = ^I, C = \t) */
export const HT = '\x09';
/** Line Feed (Caret = ^J, C = \n) */
export const LF = '\x0a';
/** Line Tabulation, Vertical Tabulation (Caret = ^K, C = \v) */
export const VT = '\x0b';
/** Form Feed (Caret = ^L, C = \f) */
export const FF = '\x0c';
/** Carriage Return (Caret = ^M, C = \r) */
export const CR = '\x0d';
/** Shift Out (Caret = ^N) */
export const SO = '\x0e';
/** Shift In (Caret = ^O) */
export const SI = '\x0f';
/** Data Link Escape (Caret = ^P) */
export const DLE = '\x10';
/** Device Control One (XON) (Caret = ^Q) */
export const DC1 = '\x11';
/** Device Control Two (Caret = ^R) */
export const DC2 = '\x12';
/** Device Control Three (XOFF) (Caret = ^S) */
export const DC3 = '\x13';
/** Device Control Four (Caret = ^T) */
export const DC4 = '\x14';
/** Negative Acknowledge (Caret = ^U) */
export const NAK = '\x15';
/** Synchronous Idle (Caret = ^V) */
export const SYN = '\x16';
/** End of Transmission Block (Caret = ^W) */
export const ETB = '\x17';
/** Cancel (Caret = ^X) */
export const CAN = '\x18';
/** End of Medium (Caret = ^Y) */
export const EM = '\x19';
/** Substitute (Caret = ^Z) */
export const SUB = '\x1a';
/** Escape (Caret = ^[, C = \e) */
export const ESC = '\x1b';
/** File Separator (Caret = ^\) */
export const FS = '\x1c';
/** Group Separator (Caret = ^]) */
export const GS = '\x1d';
/** Record Separator (Caret = ^^) */
export const RS = '\x1e';
/** Unit Separator (Caret = ^_) */
export const US = '\x1f';
/** Space */
export const SP = '\x20';
/** Delete (Caret = ^?) */
export const DEL = '\x7f';
}
/**
* C1 control codes
* See = https://en.wikipedia.org/wiki/C0_and_C1_control_codes
*/
export namespace C1 {
/** padding character */
export const PAD = '\x80';
/** High Octet Preset */
export const HOP = '\x81';
/** Break Permitted Here */
export const BPH = '\x82';
/** No Break Here */
export const NBH = '\x83';
/** Index */
export const IND = '\x84';
/** Next Line */
export const NEL = '\x85';
/** Start of Selected Area */
export const SSA = '\x86';
/** End of Selected Area */
export const ESA = '\x87';
/** Horizontal Tabulation Set */
export const HTS = '\x88';
/** Horizontal Tabulation With Justification */
export const HTJ = '\x89';
/** Vertical Tabulation Set */
export const VTS = '\x8a';
/** Partial Line Down */
export const PLD = '\x8b';
/** Partial Line Up */
export const PLU = '\x8c';
/** Reverse Index */
export const RI = '\x8d';
/** Single-Shift 2 */
export const SS2 = '\x8e';
/** Single-Shift 3 */
export const SS3 = '\x8f';
/** Device Control String */
export const DCS = '\x90';
/** Private Use 1 */
export const PU1 = '\x91';
/** Private Use 2 */
export const PU2 = '\x92';
/** Set Transmit State */
export const STS = '\x93';
/** Destructive backspace, intended to eliminate ambiguity about meaning of BS. */
export const CCH = '\x94';
/** Message Waiting */
export const MW = '\x95';
/** Start of Protected Area */
export const SPA = '\x96';
/** End of Protected Area */
export const EPA = '\x97';
/** Start of String */
export const SOS = '\x98';
/** Single Graphic Character Introducer */
export const SGCI = '\x99';
/** Single Character Introducer */
export const SCI = '\x9a';
/** Control Sequence Introducer */
export const CSI = '\x9b';
/** String Terminator */
export const ST = '\x9c';
/** Operating System Command */
export const OSC = '\x9d';
/** Privacy Message */
export const PM = '\x9e';
/** Application Program Command */
export const APC = '\x9f';
}
export namespace C1_ESCAPED {
export const ST = `${C0.ESC}\\`;
}

View File

@@ -0,0 +1,398 @@
/**
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
* @license MIT
*/
import { IKeyboardEvent, IKeyboardResult, KeyboardResultType } from 'common/Types';
import { C0 } from 'common/data/EscapeSequences';
// reg + shift key mappings for digits and special chars
const KEYCODE_KEY_MAPPINGS: { [key: number]: [string, string]} = {
// digits 0-9
48: ['0', ')'],
49: ['1', '!'],
50: ['2', '@'],
51: ['3', '#'],
52: ['4', '$'],
53: ['5', '%'],
54: ['6', '^'],
55: ['7', '&'],
56: ['8', '*'],
57: ['9', '('],
// special chars
186: [';', ':'],
187: ['=', '+'],
188: [',', '<'],
189: ['-', '_'],
190: ['.', '>'],
191: ['/', '?'],
192: ['`', '~'],
219: ['[', '{'],
220: ['\\', '|'],
221: [']', '}'],
222: ['\'', '"']
};
export function evaluateKeyboardEvent(
ev: IKeyboardEvent,
applicationCursorMode: boolean,
isMac: boolean,
macOptionIsMeta: boolean
): IKeyboardResult {
const result: IKeyboardResult = {
type: KeyboardResultType.SEND_KEY,
// Whether to cancel event propagation (NOTE: this may not be needed since the event is
// canceled at the end of keyDown
cancel: false,
// The new key even to emit
key: undefined
};
const modifiers = (ev.shiftKey ? 1 : 0) | (ev.altKey ? 2 : 0) | (ev.ctrlKey ? 4 : 0) | (ev.metaKey ? 8 : 0);
switch (ev.keyCode) {
case 0:
if (ev.key === 'UIKeyInputUpArrow') {
if (applicationCursorMode) {
result.key = C0.ESC + 'OA';
} else {
result.key = C0.ESC + '[A';
}
}
else if (ev.key === 'UIKeyInputLeftArrow') {
if (applicationCursorMode) {
result.key = C0.ESC + 'OD';
} else {
result.key = C0.ESC + '[D';
}
}
else if (ev.key === 'UIKeyInputRightArrow') {
if (applicationCursorMode) {
result.key = C0.ESC + 'OC';
} else {
result.key = C0.ESC + '[C';
}
}
else if (ev.key === 'UIKeyInputDownArrow') {
if (applicationCursorMode) {
result.key = C0.ESC + 'OB';
} else {
result.key = C0.ESC + '[B';
}
}
break;
case 8:
// backspace
if (ev.altKey) {
result.key = C0.ESC + C0.DEL; // \e ^?
break;
}
result.key = C0.DEL; // ^?
break;
case 9:
// tab
if (ev.shiftKey) {
result.key = C0.ESC + '[Z';
break;
}
result.key = C0.HT;
result.cancel = true;
break;
case 13:
// return/enter
result.key = ev.altKey ? C0.ESC + C0.CR : C0.CR;
result.cancel = true;
break;
case 27:
// escape
result.key = C0.ESC;
if (ev.altKey) {
result.key = C0.ESC + C0.ESC;
}
result.cancel = true;
break;
case 37:
// left-arrow
if (ev.metaKey) {
break;
}
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'D';
// HACK: Make Alt + left-arrow behave like Ctrl + left-arrow: move one word backwards
// http://unix.stackexchange.com/a/108106
// macOS uses different escape sequences than linux
if (result.key === C0.ESC + '[1;3D') {
result.key = C0.ESC + (isMac ? 'b' : '[1;5D');
}
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OD';
} else {
result.key = C0.ESC + '[D';
}
break;
case 39:
// right-arrow
if (ev.metaKey) {
break;
}
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'C';
// HACK: Make Alt + right-arrow behave like Ctrl + right-arrow: move one word forward
// http://unix.stackexchange.com/a/108106
// macOS uses different escape sequences than linux
if (result.key === C0.ESC + '[1;3C') {
result.key = C0.ESC + (isMac ? 'f' : '[1;5C');
}
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OC';
} else {
result.key = C0.ESC + '[C';
}
break;
case 38:
// up-arrow
if (ev.metaKey) {
break;
}
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'A';
// HACK: Make Alt + up-arrow behave like Ctrl + up-arrow
// http://unix.stackexchange.com/a/108106
// macOS uses different escape sequences than linux
if (!isMac && result.key === C0.ESC + '[1;3A') {
result.key = C0.ESC + '[1;5A';
}
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OA';
} else {
result.key = C0.ESC + '[A';
}
break;
case 40:
// down-arrow
if (ev.metaKey) {
break;
}
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'B';
// HACK: Make Alt + down-arrow behave like Ctrl + down-arrow
// http://unix.stackexchange.com/a/108106
// macOS uses different escape sequences than linux
if (!isMac && result.key === C0.ESC + '[1;3B') {
result.key = C0.ESC + '[1;5B';
}
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OB';
} else {
result.key = C0.ESC + '[B';
}
break;
case 45:
// insert
if (!ev.shiftKey && !ev.ctrlKey) {
// <Ctrl> or <Shift> + <Insert> are used to
// copy-paste on some systems.
result.key = C0.ESC + '[2~';
}
break;
case 46:
// delete
if (modifiers) {
result.key = C0.ESC + '[3;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[3~';
}
break;
case 36:
// home
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'H';
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OH';
} else {
result.key = C0.ESC + '[H';
}
break;
case 35:
// end
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'F';
} else if (applicationCursorMode) {
result.key = C0.ESC + 'OF';
} else {
result.key = C0.ESC + '[F';
}
break;
case 33:
// page up
if (ev.shiftKey) {
result.type = KeyboardResultType.PAGE_UP;
} else if (ev.ctrlKey) {
result.key = C0.ESC + '[5;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[5~';
}
break;
case 34:
// page down
if (ev.shiftKey) {
result.type = KeyboardResultType.PAGE_DOWN;
} else if (ev.ctrlKey) {
result.key = C0.ESC + '[6;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[6~';
}
break;
case 112:
// F1-F12
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'P';
} else {
result.key = C0.ESC + 'OP';
}
break;
case 113:
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'Q';
} else {
result.key = C0.ESC + 'OQ';
}
break;
case 114:
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'R';
} else {
result.key = C0.ESC + 'OR';
}
break;
case 115:
if (modifiers) {
result.key = C0.ESC + '[1;' + (modifiers + 1) + 'S';
} else {
result.key = C0.ESC + 'OS';
}
break;
case 116:
if (modifiers) {
result.key = C0.ESC + '[15;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[15~';
}
break;
case 117:
if (modifiers) {
result.key = C0.ESC + '[17;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[17~';
}
break;
case 118:
if (modifiers) {
result.key = C0.ESC + '[18;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[18~';
}
break;
case 119:
if (modifiers) {
result.key = C0.ESC + '[19;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[19~';
}
break;
case 120:
if (modifiers) {
result.key = C0.ESC + '[20;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[20~';
}
break;
case 121:
if (modifiers) {
result.key = C0.ESC + '[21;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[21~';
}
break;
case 122:
if (modifiers) {
result.key = C0.ESC + '[23;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[23~';
}
break;
case 123:
if (modifiers) {
result.key = C0.ESC + '[24;' + (modifiers + 1) + '~';
} else {
result.key = C0.ESC + '[24~';
}
break;
default:
// a-z and space
if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey) {
if (ev.keyCode >= 65 && ev.keyCode <= 90) {
result.key = String.fromCharCode(ev.keyCode - 64);
} else if (ev.keyCode === 32) {
result.key = C0.NUL;
} else if (ev.keyCode >= 51 && ev.keyCode <= 55) {
// escape, file sep, group sep, record sep, unit sep
result.key = String.fromCharCode(ev.keyCode - 51 + 27);
} else if (ev.keyCode === 56) {
result.key = C0.DEL;
} else if (ev.keyCode === 219) {
result.key = C0.ESC;
} else if (ev.keyCode === 220) {
result.key = C0.FS;
} else if (ev.keyCode === 221) {
result.key = C0.GS;
}
} else if ((!isMac || macOptionIsMeta) && ev.altKey && !ev.metaKey) {
// On macOS this is a third level shift when !macOptionIsMeta. Use <Esc> instead.
const keyMapping = KEYCODE_KEY_MAPPINGS[ev.keyCode];
const key = keyMapping?.[!ev.shiftKey ? 0 : 1];
if (key) {
result.key = C0.ESC + key;
} else if (ev.keyCode >= 65 && ev.keyCode <= 90) {
const keyCode = ev.ctrlKey ? ev.keyCode - 64 : ev.keyCode + 32;
let keyString = String.fromCharCode(keyCode);
if (ev.shiftKey) {
keyString = keyString.toUpperCase();
}
result.key = C0.ESC + keyString;
} else if (ev.keyCode === 32) {
result.key = C0.ESC + (ev.ctrlKey ? C0.NUL : ' ');
} else if (ev.key === 'Dead' && ev.code.startsWith('Key')) {
// Reference: https://github.com/xtermjs/xterm.js/issues/3725
// Alt will produce a "dead key" (initate composition) with some
// of the letters in US layout (e.g. N/E/U).
// It's safe to match against Key* since no other `code` values begin with "Key".
// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code/code_values#code_values_on_mac
let keyString = ev.code.slice(3, 4);
if (!ev.shiftKey) {
keyString = keyString.toLowerCase();
}
result.key = C0.ESC + keyString;
result.cancel = true;
}
} else if (isMac && !ev.altKey && !ev.ctrlKey && !ev.shiftKey && ev.metaKey) {
if (ev.keyCode === 65) { // cmd + a
result.type = KeyboardResultType.SELECT_ALL;
}
} else if (ev.key && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.keyCode >= 48 && ev.key.length === 1) {
// Include only keys that that result in a _single_ character; don't include num lock,
// volume up, etc.
result.key = ev.key;
} else if (ev.key && ev.ctrlKey) {
if (ev.key === '_') { // ^_
result.key = C0.US;
}
if (ev.key === '@') { // ^ + shift + 2 = ^ + @
result.key = C0.NUL;
}
}
break;
}
return result;
}

View File

@@ -0,0 +1,346 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* Polyfill - Convert UTF32 codepoint into JS string.
* Note: The built-in String.fromCodePoint happens to be much slower
* due to additional sanity checks. We can avoid them since
* we always operate on legal UTF32 (granted by the input decoders)
* and use this faster version instead.
*/
export function stringFromCodePoint(codePoint: number): string {
if (codePoint > 0xFFFF) {
codePoint -= 0x10000;
return String.fromCharCode((codePoint >> 10) + 0xD800) + String.fromCharCode((codePoint % 0x400) + 0xDC00);
}
return String.fromCharCode(codePoint);
}
/**
* Convert UTF32 char codes into JS string.
* Basically the same as `stringFromCodePoint` but for multiple codepoints
* in a loop (which is a lot faster).
*/
export function utf32ToString(data: Uint32Array, start: number = 0, end: number = data.length): string {
let result = '';
for (let i = start; i < end; ++i) {
let codepoint = data[i];
if (codepoint > 0xFFFF) {
// JS strings are encoded as UTF16, thus a non BMP codepoint gets converted into a surrogate
// pair conversion rules:
// - subtract 0x10000 from code point, leaving a 20 bit number
// - add high 10 bits to 0xD800 --> first surrogate
// - add low 10 bits to 0xDC00 --> second surrogate
codepoint -= 0x10000;
result += String.fromCharCode((codepoint >> 10) + 0xD800) + String.fromCharCode((codepoint % 0x400) + 0xDC00);
} else {
result += String.fromCharCode(codepoint);
}
}
return result;
}
/**
* StringToUtf32 - decodes UTF16 sequences into UTF32 codepoints.
* To keep the decoder in line with JS strings it handles single surrogates as UCS2.
*/
export class StringToUtf32 {
private _interim: number = 0;
/**
* Clears interim and resets decoder to clean state.
*/
public clear(): void {
this._interim = 0;
}
/**
* Decode JS string to UTF32 codepoints.
* The methods assumes stream input and will store partly transmitted
* surrogate pairs and decode them with the next data chunk.
* Note: The method does no bound checks for target, therefore make sure
* the provided input data does not exceed the size of `target`.
* Returns the number of written codepoints in `target`.
*/
public decode(input: string, target: Uint32Array): number {
const length = input.length;
if (!length) {
return 0;
}
let size = 0;
let startPos = 0;
// handle leftover surrogate high
if (this._interim) {
const second = input.charCodeAt(startPos++);
if (0xDC00 <= second && second <= 0xDFFF) {
target[size++] = (this._interim - 0xD800) * 0x400 + second - 0xDC00 + 0x10000;
} else {
// illegal codepoint (USC2 handling)
target[size++] = this._interim;
target[size++] = second;
}
this._interim = 0;
}
for (let i = startPos; i < length; ++i) {
const code = input.charCodeAt(i);
// surrogate pair first
if (0xD800 <= code && code <= 0xDBFF) {
if (++i >= length) {
this._interim = code;
return size;
}
const second = input.charCodeAt(i);
if (0xDC00 <= second && second <= 0xDFFF) {
target[size++] = (code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000;
} else {
// illegal codepoint (USC2 handling)
target[size++] = code;
target[size++] = second;
}
continue;
}
if (code === 0xFEFF) {
// BOM
continue;
}
target[size++] = code;
}
return size;
}
}
/**
* Utf8Decoder - decodes UTF8 byte sequences into UTF32 codepoints.
*/
export class Utf8ToUtf32 {
public interim: Uint8Array = new Uint8Array(3);
/**
* Clears interim bytes and resets decoder to clean state.
*/
public clear(): void {
this.interim.fill(0);
}
/**
* Decodes UTF8 byte sequences in `input` to UTF32 codepoints in `target`.
* The methods assumes stream input and will store partly transmitted bytes
* and decode them with the next data chunk.
* Note: The method does no bound checks for target, therefore make sure
* the provided data chunk does not exceed the size of `target`.
* Returns the number of written codepoints in `target`.
*/
public decode(input: Uint8Array, target: Uint32Array): number {
const length = input.length;
if (!length) {
return 0;
}
let size = 0;
let byte1: number;
let byte2: number;
let byte3: number;
let byte4: number;
let codepoint = 0;
let startPos = 0;
// handle leftover bytes
if (this.interim[0]) {
let discardInterim = false;
let cp = this.interim[0];
cp &= ((((cp & 0xE0) === 0xC0)) ? 0x1F : (((cp & 0xF0) === 0xE0)) ? 0x0F : 0x07);
let pos = 0;
let tmp: number;
while ((tmp = this.interim[++pos] & 0x3F) && pos < 4) {
cp <<= 6;
cp |= tmp;
}
// missing bytes - read ahead from input
const type = (((this.interim[0] & 0xE0) === 0xC0)) ? 2 : (((this.interim[0] & 0xF0) === 0xE0)) ? 3 : 4;
const missing = type - pos;
while (startPos < missing) {
if (startPos >= length) {
return 0;
}
tmp = input[startPos++];
if ((tmp & 0xC0) !== 0x80) {
// wrong continuation, discard interim bytes completely
startPos--;
discardInterim = true;
break;
} else {
// need to save so we can continue short inputs in next call
this.interim[pos++] = tmp;
cp <<= 6;
cp |= tmp & 0x3F;
}
}
if (!discardInterim) {
// final test is type dependent
if (type === 2) {
if (cp < 0x80) {
// wrong starter byte
startPos--;
} else {
target[size++] = cp;
}
} else if (type === 3) {
if (cp < 0x0800 || (cp >= 0xD800 && cp <= 0xDFFF) || cp === 0xFEFF) {
// illegal codepoint or BOM
} else {
target[size++] = cp;
}
} else {
if (cp < 0x010000 || cp > 0x10FFFF) {
// illegal codepoint
} else {
target[size++] = cp;
}
}
}
this.interim.fill(0);
}
// loop through input
const fourStop = length - 4;
let i = startPos;
while (i < length) {
/**
* ASCII shortcut with loop unrolled to 4 consecutive ASCII chars.
* This is a compromise between speed gain for ASCII
* and penalty for non ASCII:
* For best ASCII performance the char should be stored directly into target,
* but even a single attempt to write to target and compare afterwards
* penalizes non ASCII really bad (-50%), thus we load the char into byteX first,
* which reduces ASCII performance by ~15%.
* This trial for ASCII reduces non ASCII performance by ~10% which seems acceptible
* compared to the gains.
* Note that this optimization only takes place for 4 consecutive ASCII chars,
* for any shorter it bails out. Worst case - all 4 bytes being read but
* thrown away due to the last being a non ASCII char (-10% performance).
*/
while (i < fourStop
&& !((byte1 = input[i]) & 0x80)
&& !((byte2 = input[i + 1]) & 0x80)
&& !((byte3 = input[i + 2]) & 0x80)
&& !((byte4 = input[i + 3]) & 0x80))
{
target[size++] = byte1;
target[size++] = byte2;
target[size++] = byte3;
target[size++] = byte4;
i += 4;
}
// reread byte1
byte1 = input[i++];
// 1 byte
if (byte1 < 0x80) {
target[size++] = byte1;
// 2 bytes
} else if ((byte1 & 0xE0) === 0xC0) {
if (i >= length) {
this.interim[0] = byte1;
return size;
}
byte2 = input[i++];
if ((byte2 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
codepoint = (byte1 & 0x1F) << 6 | (byte2 & 0x3F);
if (codepoint < 0x80) {
// wrong starter byte
i--;
continue;
}
target[size++] = codepoint;
// 3 bytes
} else if ((byte1 & 0xF0) === 0xE0) {
if (i >= length) {
this.interim[0] = byte1;
return size;
}
byte2 = input[i++];
if ((byte2 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
if (i >= length) {
this.interim[0] = byte1;
this.interim[1] = byte2;
return size;
}
byte3 = input[i++];
if ((byte3 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
codepoint = (byte1 & 0x0F) << 12 | (byte2 & 0x3F) << 6 | (byte3 & 0x3F);
if (codepoint < 0x0800 || (codepoint >= 0xD800 && codepoint <= 0xDFFF) || codepoint === 0xFEFF) {
// illegal codepoint or BOM, no i-- here
continue;
}
target[size++] = codepoint;
// 4 bytes
} else if ((byte1 & 0xF8) === 0xF0) {
if (i >= length) {
this.interim[0] = byte1;
return size;
}
byte2 = input[i++];
if ((byte2 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
if (i >= length) {
this.interim[0] = byte1;
this.interim[1] = byte2;
return size;
}
byte3 = input[i++];
if ((byte3 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
if (i >= length) {
this.interim[0] = byte1;
this.interim[1] = byte2;
this.interim[2] = byte3;
return size;
}
byte4 = input[i++];
if ((byte4 & 0xC0) !== 0x80) {
// wrong continuation
i--;
continue;
}
codepoint = (byte1 & 0x07) << 18 | (byte2 & 0x3F) << 12 | (byte3 & 0x3F) << 6 | (byte4 & 0x3F);
if (codepoint < 0x010000 || codepoint > 0x10FFFF) {
// illegal codepoint, no i-- here
continue;
}
target[size++] = codepoint;
} else {
// illegal byte, just skip
}
}
return size;
}
}

View File

@@ -0,0 +1,132 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IUnicodeVersionProvider } from 'common/services/Services';
type CharWidth = 0 | 1 | 2;
const BMP_COMBINING = [
[0x0300, 0x036F], [0x0483, 0x0486], [0x0488, 0x0489],
[0x0591, 0x05BD], [0x05BF, 0x05BF], [0x05C1, 0x05C2],
[0x05C4, 0x05C5], [0x05C7, 0x05C7], [0x0600, 0x0603],
[0x0610, 0x0615], [0x064B, 0x065E], [0x0670, 0x0670],
[0x06D6, 0x06E4], [0x06E7, 0x06E8], [0x06EA, 0x06ED],
[0x070F, 0x070F], [0x0711, 0x0711], [0x0730, 0x074A],
[0x07A6, 0x07B0], [0x07EB, 0x07F3], [0x0901, 0x0902],
[0x093C, 0x093C], [0x0941, 0x0948], [0x094D, 0x094D],
[0x0951, 0x0954], [0x0962, 0x0963], [0x0981, 0x0981],
[0x09BC, 0x09BC], [0x09C1, 0x09C4], [0x09CD, 0x09CD],
[0x09E2, 0x09E3], [0x0A01, 0x0A02], [0x0A3C, 0x0A3C],
[0x0A41, 0x0A42], [0x0A47, 0x0A48], [0x0A4B, 0x0A4D],
[0x0A70, 0x0A71], [0x0A81, 0x0A82], [0x0ABC, 0x0ABC],
[0x0AC1, 0x0AC5], [0x0AC7, 0x0AC8], [0x0ACD, 0x0ACD],
[0x0AE2, 0x0AE3], [0x0B01, 0x0B01], [0x0B3C, 0x0B3C],
[0x0B3F, 0x0B3F], [0x0B41, 0x0B43], [0x0B4D, 0x0B4D],
[0x0B56, 0x0B56], [0x0B82, 0x0B82], [0x0BC0, 0x0BC0],
[0x0BCD, 0x0BCD], [0x0C3E, 0x0C40], [0x0C46, 0x0C48],
[0x0C4A, 0x0C4D], [0x0C55, 0x0C56], [0x0CBC, 0x0CBC],
[0x0CBF, 0x0CBF], [0x0CC6, 0x0CC6], [0x0CCC, 0x0CCD],
[0x0CE2, 0x0CE3], [0x0D41, 0x0D43], [0x0D4D, 0x0D4D],
[0x0DCA, 0x0DCA], [0x0DD2, 0x0DD4], [0x0DD6, 0x0DD6],
[0x0E31, 0x0E31], [0x0E34, 0x0E3A], [0x0E47, 0x0E4E],
[0x0EB1, 0x0EB1], [0x0EB4, 0x0EB9], [0x0EBB, 0x0EBC],
[0x0EC8, 0x0ECD], [0x0F18, 0x0F19], [0x0F35, 0x0F35],
[0x0F37, 0x0F37], [0x0F39, 0x0F39], [0x0F71, 0x0F7E],
[0x0F80, 0x0F84], [0x0F86, 0x0F87], [0x0F90, 0x0F97],
[0x0F99, 0x0FBC], [0x0FC6, 0x0FC6], [0x102D, 0x1030],
[0x1032, 0x1032], [0x1036, 0x1037], [0x1039, 0x1039],
[0x1058, 0x1059], [0x1160, 0x11FF], [0x135F, 0x135F],
[0x1712, 0x1714], [0x1732, 0x1734], [0x1752, 0x1753],
[0x1772, 0x1773], [0x17B4, 0x17B5], [0x17B7, 0x17BD],
[0x17C6, 0x17C6], [0x17C9, 0x17D3], [0x17DD, 0x17DD],
[0x180B, 0x180D], [0x18A9, 0x18A9], [0x1920, 0x1922],
[0x1927, 0x1928], [0x1932, 0x1932], [0x1939, 0x193B],
[0x1A17, 0x1A18], [0x1B00, 0x1B03], [0x1B34, 0x1B34],
[0x1B36, 0x1B3A], [0x1B3C, 0x1B3C], [0x1B42, 0x1B42],
[0x1B6B, 0x1B73], [0x1DC0, 0x1DCA], [0x1DFE, 0x1DFF],
[0x200B, 0x200F], [0x202A, 0x202E], [0x2060, 0x2063],
[0x206A, 0x206F], [0x20D0, 0x20EF], [0x302A, 0x302F],
[0x3099, 0x309A], [0xA806, 0xA806], [0xA80B, 0xA80B],
[0xA825, 0xA826], [0xFB1E, 0xFB1E], [0xFE00, 0xFE0F],
[0xFE20, 0xFE23], [0xFEFF, 0xFEFF], [0xFFF9, 0xFFFB]
];
const HIGH_COMBINING = [
[0x10A01, 0x10A03], [0x10A05, 0x10A06], [0x10A0C, 0x10A0F],
[0x10A38, 0x10A3A], [0x10A3F, 0x10A3F], [0x1D167, 0x1D169],
[0x1D173, 0x1D182], [0x1D185, 0x1D18B], [0x1D1AA, 0x1D1AD],
[0x1D242, 0x1D244], [0xE0001, 0xE0001], [0xE0020, 0xE007F],
[0xE0100, 0xE01EF]
];
// BMP lookup table, lazy initialized during first addon loading
let table: Uint8Array;
function bisearch(ucs: number, data: number[][]): boolean {
let min = 0;
let max = data.length - 1;
let mid;
if (ucs < data[0][0] || ucs > data[max][1]) {
return false;
}
while (max >= min) {
mid = (min + max) >> 1;
if (ucs > data[mid][1]) {
min = mid + 1;
} else if (ucs < data[mid][0]) {
max = mid - 1;
} else {
return true;
}
}
return false;
}
export class UnicodeV6 implements IUnicodeVersionProvider {
public readonly version = '6';
constructor() {
// init lookup table once
if (!table) {
table = new Uint8Array(65536);
table.fill(1);
table[0] = 0;
// control chars
table.fill(0, 1, 32);
table.fill(0, 0x7f, 0xa0);
// apply wide char rules first
// wide chars
table.fill(2, 0x1100, 0x1160);
table[0x2329] = 2;
table[0x232a] = 2;
table.fill(2, 0x2e80, 0xa4d0);
table[0x303f] = 1; // wrongly in last line
table.fill(2, 0xac00, 0xd7a4);
table.fill(2, 0xf900, 0xfb00);
table.fill(2, 0xfe10, 0xfe1a);
table.fill(2, 0xfe30, 0xfe70);
table.fill(2, 0xff00, 0xff61);
table.fill(2, 0xffe0, 0xffe7);
// apply combining last to ensure we overwrite
// wrongly wide set chars:
// the original algo evals combining first and falls
// through to wide check so we simply do here the opposite
// combining 0
for (let r = 0; r < BMP_COMBINING.length; ++r) {
table.fill(0, BMP_COMBINING[r][0], BMP_COMBINING[r][1] + 1);
}
}
}
public wcwidth(num: number): CharWidth {
if (num < 32) return 0;
if (num < 127) return 1;
if (num < 65536) return table[num] as CharWidth;
if (bisearch(num, HIGH_COMBINING)) return 0;
if ((num >= 0x20000 && num <= 0x2fffd) || (num >= 0x30000 && num <= 0x3fffd)) return 2;
return 1;
}
}

View File

@@ -0,0 +1,246 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
declare const setTimeout: (handler: () => void, timeout?: number) => void;
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
* Enable flow control to avoid this limit and make sure that your backend correctly
* propagates this to the underlying pty. (see docs for further instructions)
* Since this limit is meant as a safety parachute to prevent browser crashs,
* it is set to a very high number. Typically xterm.js gets unresponsive with
* a 100 times lower number (>500 kB).
*/
const DISCARD_WATERMARK = 50000000; // ~50 MB
/**
* The max number of ms to spend on writes before allowing the renderer to
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
* depends on the time it takes for the renderer to draw the frame.
*/
const WRITE_TIMEOUT_MS = 12;
/**
* Threshold of max held chunks in the write buffer, that were already processed.
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
* memory consumption by data thats not used anymore.
*/
const WRITE_BUFFER_LENGTH_THRESHOLD = 50;
export class WriteBuffer extends Disposable {
private _writeBuffer: (string | Uint8Array)[] = [];
private _callbacks: ((() => void) | undefined)[] = [];
private _pendingData = 0;
private _bufferOffset = 0;
private _isSyncWriting = false;
private _syncCalls = 0;
private _didUserInput = false;
private readonly _onWriteParsed = this.register(new EventEmitter<void>());
public readonly onWriteParsed = this._onWriteParsed.event;
constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise<boolean>) {
super();
}
public handleUserInput(): void {
this._didUserInput = true;
}
/**
* @deprecated Unreliable, to be removed soon.
*/
public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void {
// stop writeSync recursions with maxSubsequentCalls argument
// This is dangerous to use as it will lose the current data chunk
// and return immediately.
if (maxSubsequentCalls !== undefined && this._syncCalls > maxSubsequentCalls) {
// comment next line if a whole loop block should only contain x `writeSync` calls
// (total flat vs. deep nested limit)
this._syncCalls = 0;
return;
}
// append chunk to buffer
this._pendingData += data.length;
this._writeBuffer.push(data);
this._callbacks.push(undefined);
// increase recursion counter
this._syncCalls++;
// exit early if another writeSync loop is active
if (this._isSyncWriting) {
return;
}
this._isSyncWriting = true;
// force sync processing on pending data chunks to avoid in-band data scrambling
// does the same as innerWrite but without event loop
// we have to do it here as single loop steps to not corrupt loop subject
// by another writeSync call triggered from _action
let chunk: string | Uint8Array | undefined;
while (chunk = this._writeBuffer.shift()) {
this._action(chunk);
const cb = this._callbacks.shift();
if (cb) cb();
}
// reset to avoid reprocessing of chunks with scheduled innerWrite call
// stopping scheduled innerWrite by offset > length condition
this._pendingData = 0;
this._bufferOffset = 0x7FFFFFFF;
// allow another writeSync to loop
this._isSyncWriting = false;
this._syncCalls = 0;
}
public write(data: string | Uint8Array, callback?: () => void): void {
if (this._pendingData > DISCARD_WATERMARK) {
throw new Error('write data discarded, use flow control to avoid losing data');
}
// schedule chunk processing for next event loop run
if (!this._writeBuffer.length) {
this._bufferOffset = 0;
// If this is the first write call after the user has done some input,
// parse it immediately to minimize input latency,
// otherwise schedule for the next event
if (this._didUserInput) {
this._didUserInput = false;
this._pendingData += data.length;
this._writeBuffer.push(data);
this._callbacks.push(callback);
this._innerWrite();
return;
}
setTimeout(() => this._innerWrite());
}
this._pendingData += data.length;
this._writeBuffer.push(data);
this._callbacks.push(callback);
}
/**
* Inner write call, that enters the sliced chunk processing by timing.
*
* `lastTime` indicates, when the last _innerWrite call had started.
* It is used to aggregate async handler execution under a timeout constraint
* effectively lowering the redrawing needs, schematically:
*
* macroTask _innerWrite:
* if (Date.now() - (lastTime | 0) < WRITE_TIMEOUT_MS):
* schedule microTask _innerWrite(lastTime)
* else:
* schedule macroTask _innerWrite(0)
*
* overall execution order on task queues:
*
* macrotasks: [...] --> _innerWrite(0) --> [...] --> screenUpdate --> [...]
* m t: |
* i a: [...]
* c s: |
* r k: while < timeout:
* o s: _innerWrite(timeout)
*
* `promiseResult` depicts the promise resolve value of an async handler.
* This value gets carried forward through all saved stack states of the
* paused parser for proper continuation.
*
* Note, for pure sync code `lastTime` and `promiseResult` have no meaning.
*/
protected _innerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
const startTime = lastTime || Date.now();
while (this._writeBuffer.length > this._bufferOffset) {
const data = this._writeBuffer[this._bufferOffset];
const result = this._action(data, promiseResult);
if (result) {
/**
* If we get a promise as return value, we re-schedule the continuation
* as thenable on the promise and exit right away.
*
* The exit here means, that we block input processing at the current active chunk,
* the exact execution position within the chunk is preserved by the saved
* stack content in InputHandler and EscapeSequenceParser.
*
* Resuming happens automatically from that saved stack state.
* Also the resolved promise value is passed along the callstack to
* `EscapeSequenceParser.parse` to correctly resume the stopped handler loop.
*
* Exceptions on async handlers will be logged to console async, but do not interrupt
* the input processing (continues with next handler at the current input position).
*/
/**
* If a promise takes long to resolve, we should schedule continuation behind setTimeout.
* This might already be too late, if our .then enters really late (executor + prev thens
* took very long). This cannot be solved here for the handler itself (it is the handlers
* responsibility to slice hard work), but we can at least schedule a screen update as we
* gain control.
*/
const continuation: (r: boolean) => void = (r: boolean) => Date.now() - startTime >= WRITE_TIMEOUT_MS
? setTimeout(() => this._innerWrite(0, r))
: this._innerWrite(startTime, r);
/**
* Optimization considerations:
* The continuation above favors FPS over throughput by eval'ing `startTime` on resolve.
* This might schedule too many screen updates with bad throughput drops (in case a slow
* resolving handler sliced its work properly behind setTimeout calls). We cannot spot
* this condition here, also the renderer has no way to spot nonsense updates either.
* FIXME: A proper fix for this would track the FPS at the renderer entry level separately.
*
* If favoring of FPS shows bad throughtput impact, use the following instead. It favors
* throughput by eval'ing `startTime` upfront pulling at least one more chunk into the
* current microtask queue (executed before setTimeout).
*/
// const continuation: (r: boolean) => void = Date.now() - startTime >= WRITE_TIMEOUT_MS
// ? r => setTimeout(() => this._innerWrite(0, r))
// : r => this._innerWrite(startTime, r);
// Handle exceptions synchronously to current band position, idea:
// 1. spawn a single microtask which we allow to throw hard
// 2. spawn a promise immediately resolving to `true`
// (executed on the same queue, thus properly aligned before continuation happens)
result.catch(err => {
queueMicrotask(() => {throw err;});
return Promise.resolve(false);
}).then(continuation);
return;
}
const cb = this._callbacks[this._bufferOffset];
if (cb) cb();
this._bufferOffset++;
this._pendingData -= data.length;
if (Date.now() - startTime >= WRITE_TIMEOUT_MS) {
break;
}
}
if (this._writeBuffer.length > this._bufferOffset) {
// Allow renderer to catch up before processing the next batch
// trim already processed chunks if we are above threshold
if (this._bufferOffset > WRITE_BUFFER_LENGTH_THRESHOLD) {
this._writeBuffer = this._writeBuffer.slice(this._bufferOffset);
this._callbacks = this._callbacks.slice(this._bufferOffset);
this._bufferOffset = 0;
}
setTimeout(() => this._innerWrite());
} else {
this._writeBuffer.length = 0;
this._callbacks.length = 0;
this._pendingData = 0;
this._bufferOffset = 0;
}
this._onWriteParsed.fire();
}
}

View File

@@ -0,0 +1,80 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
// 'rgb:' rule - matching: r/g/b | rr/gg/bb | rrr/ggg/bbb | rrrr/gggg/bbbb (hex digits)
const RGB_REX = /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/;
// '#...' rule - matching any hex digits
const HASH_REX = /^[\da-f]+$/;
/**
* Parse color spec to RGB values (8 bit per channel).
* See `man xparsecolor` for details about certain format specifications.
*
* Supported formats:
* - rgb:<red>/<green>/<blue> with <red>, <green>, <blue> in h | hh | hhh | hhhh
* - #RGB, #RRGGBB, #RRRGGGBBB, #RRRRGGGGBBBB
*
* All other formats like rgbi: or device-independent string specifications
* with float numbering are not supported.
*/
export function parseColor(data: string): [number, number, number] | undefined {
if (!data) return;
// also handle uppercases
let low = data.toLowerCase();
if (low.indexOf('rgb:') === 0) {
// 'rgb:' specifier
low = low.slice(4);
const m = RGB_REX.exec(low);
if (m) {
const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535;
return [
Math.round(parseInt(m[1] || m[4] || m[7] || m[10], 16) / base * 255),
Math.round(parseInt(m[2] || m[5] || m[8] || m[11], 16) / base * 255),
Math.round(parseInt(m[3] || m[6] || m[9] || m[12], 16) / base * 255)
];
}
} else if (low.indexOf('#') === 0) {
// '#' specifier
low = low.slice(1);
if (HASH_REX.exec(low) && [3, 6, 9, 12].includes(low.length)) {
const adv = low.length / 3;
const result: [number, number, number] = [0, 0, 0];
for (let i = 0; i < 3; ++i) {
const c = parseInt(low.slice(adv * i, adv * i + adv), 16);
result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8;
}
return result;
}
}
// Named colors are currently not supported due to the large addition to the xterm.js bundle size
// they would add. In order to support named colors, we would need some way of optionally loading
// additional payloads so startup/download time is not bloated (see #3530).
}
// pad hex output to requested bit width
function pad(n: number, bits: number): string {
const s = n.toString(16);
const s2 = s.length < 2 ? '0' + s : s;
switch (bits) {
case 4:
return s[0];
case 8:
return s2;
case 12:
return (s2 + s2).slice(0, 3);
default:
return s2 + s2;
}
}
/**
* Convert a given color to rgb:../../.. string of `bits` depth.
*/
export function toRgbString(color: [number, number, number], bits: number = 16): string {
const [r, g, b] = color;
return `rgb:${pad(r, bits)}/${pad(g, bits)}/${pad(b, bits)}`;
}

View File

@@ -0,0 +1,58 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
/**
* Internal states of EscapeSequenceParser.
*/
export const enum ParserState {
GROUND = 0,
ESCAPE = 1,
ESCAPE_INTERMEDIATE = 2,
CSI_ENTRY = 3,
CSI_PARAM = 4,
CSI_INTERMEDIATE = 5,
CSI_IGNORE = 6,
SOS_PM_APC_STRING = 7,
OSC_STRING = 8,
DCS_ENTRY = 9,
DCS_PARAM = 10,
DCS_IGNORE = 11,
DCS_INTERMEDIATE = 12,
DCS_PASSTHROUGH = 13
}
/**
* Internal actions of EscapeSequenceParser.
*/
export const enum ParserAction {
IGNORE = 0,
ERROR = 1,
PRINT = 2,
EXECUTE = 3,
OSC_START = 4,
OSC_PUT = 5,
OSC_END = 6,
CSI_DISPATCH = 7,
PARAM = 8,
COLLECT = 9,
ESC_DISPATCH = 10,
CLEAR = 11,
DCS_HOOK = 12,
DCS_PUT = 13,
DCS_UNHOOK = 14
}
/**
* Internal states of OscParser.
*/
export const enum OscState {
START = 0,
ID = 1,
PAYLOAD = 2,
ABORT = 3
}
// payload limit for OSC and DCS
export const PAYLOAD_LIMIT = 10000000;

View File

@@ -0,0 +1,192 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandlerType, ISubParserStackState } from 'common/parser/Types';
import { utf32ToString } from 'common/input/TextDecoder';
import { Params } from 'common/parser/Params';
import { PAYLOAD_LIMIT } from 'common/parser/Constants';
const EMPTY_HANDLERS: IDcsHandler[] = [];
export class DcsParser implements IDcsParser {
private _handlers: IHandlerCollection<IDcsHandler> = Object.create(null);
private _active: IDcsHandler[] = EMPTY_HANDLERS;
private _ident: number = 0;
private _handlerFb: DcsFallbackHandlerType = () => { };
private _stack: ISubParserStackState = {
paused: false,
loopPosition: 0,
fallThrough: false
};
public dispose(): void {
this._handlers = Object.create(null);
this._handlerFb = () => { };
this._active = EMPTY_HANDLERS;
}
public registerHandler(ident: number, handler: IDcsHandler): IDisposable {
if (this._handlers[ident] === undefined) {
this._handlers[ident] = [];
}
const handlerList = this._handlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearHandler(ident: number): void {
if (this._handlers[ident]) delete this._handlers[ident];
}
public setHandlerFallback(handler: DcsFallbackHandlerType): void {
this._handlerFb = handler;
}
public reset(): void {
// force cleanup leftover handlers
if (this._active.length) {
for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) {
this._active[j].unhook(false);
}
}
this._stack.paused = false;
this._active = EMPTY_HANDLERS;
this._ident = 0;
}
public hook(ident: number, params: IParams): void {
// always reset leftover handlers
this.reset();
this._ident = ident;
this._active = this._handlers[ident] || EMPTY_HANDLERS;
if (!this._active.length) {
this._handlerFb(this._ident, 'HOOK', params);
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].hook(params);
}
}
}
public put(data: Uint32Array, start: number, end: number): void {
if (!this._active.length) {
this._handlerFb(this._ident, 'PUT', utf32ToString(data, start, end));
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].put(data, start, end);
}
}
}
public unhook(success: boolean, promiseResult: boolean = true): void | Promise<boolean> {
if (!this._active.length) {
this._handlerFb(this._ident, 'UNHOOK', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].unhook(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers (fallThrough for async)
for (; j >= 0; j--) {
handlerResult = this._active[j].unhook(false);
if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
return handlerResult;
}
}
}
this._active = EMPTY_HANDLERS;
this._ident = 0;
}
}
// predefine empty params as [0] (ZDM)
const EMPTY_PARAMS = new Params();
EMPTY_PARAMS.addParam(0);
/**
* Convenient class to create a DCS handler from a single callback function.
* Note: The payload is currently limited to 50 MB (hardcoded).
*/
export class DcsHandler implements IDcsHandler {
private _data = '';
private _params: IParams = EMPTY_PARAMS;
private _hitLimit: boolean = false;
constructor(private _handler: (data: string, params: IParams) => boolean | Promise<boolean>) { }
public hook(params: IParams): void {
// since we need to preserve params until `unhook`, we have to clone it
// (only borrowed from parser and spans multiple parser states)
// perf optimization:
// clone only, if we have non empty params, otherwise stick with default
this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS;
this._data = '';
this._hitLimit = false;
}
public put(data: Uint32Array, start: number, end: number): void {
if (this._hitLimit) {
return;
}
this._data += utf32ToString(data, start, end);
if (this._data.length > PAYLOAD_LIMIT) {
this._data = '';
this._hitLimit = true;
}
}
public unhook(success: boolean): boolean | Promise<boolean> {
let ret: boolean | Promise<boolean> = false;
if (this._hitLimit) {
ret = false;
} else if (success) {
ret = this._handler(this._data, this._params);
if (ret instanceof Promise) {
// need to hold data and params until `ret` got resolved
// dont care for errors, data will be freed anyway on next start
return ret.then(res => {
this._params = EMPTY_PARAMS;
this._data = '';
this._hitLimit = false;
return res;
});
}
}
this._params = EMPTY_PARAMS;
this._data = '';
this._hitLimit = false;
return ret;
}
}

View File

@@ -0,0 +1,792 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, IOscParser, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types';
import { ParserState, ParserAction } from 'common/parser/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { Params } from 'common/parser/Params';
import { OscParser } from 'common/parser/OscParser';
import { DcsParser } from 'common/parser/DcsParser';
/**
* Table values are generated like this:
* index: currentState << TableValue.INDEX_STATE_SHIFT | charCode
* value: action << TableValue.TRANSITION_ACTION_SHIFT | nextState
*/
const enum TableAccess {
TRANSITION_ACTION_SHIFT = 4,
TRANSITION_STATE_MASK = 15,
INDEX_STATE_SHIFT = 8
}
/**
* Transition table for EscapeSequenceParser.
*/
export class TransitionTable {
public table: Uint8Array;
constructor(length: number) {
this.table = new Uint8Array(length);
}
/**
* Set default transition.
* @param action default action
* @param next default next state
*/
public setDefault(action: ParserAction, next: ParserState): void {
this.table.fill(action << TableAccess.TRANSITION_ACTION_SHIFT | next);
}
/**
* Add a transition to the transition table.
* @param code input character code
* @param state current parser state
* @param action parser action to be done
* @param next next parser state
*/
public add(code: number, state: ParserState, action: ParserAction, next: ParserState): void {
this.table[state << TableAccess.INDEX_STATE_SHIFT | code] = action << TableAccess.TRANSITION_ACTION_SHIFT | next;
}
/**
* Add transitions for multiple input character codes.
* @param codes input character code array
* @param state current parser state
* @param action parser action to be done
* @param next next parser state
*/
public addMany(codes: number[], state: ParserState, action: ParserAction, next: ParserState): void {
for (let i = 0; i < codes.length; i++) {
this.table[state << TableAccess.INDEX_STATE_SHIFT | codes[i]] = action << TableAccess.TRANSITION_ACTION_SHIFT | next;
}
}
}
// Pseudo-character placeholder for printable non-ascii characters (unicode).
const NON_ASCII_PRINTABLE = 0xA0;
/**
* VT500 compatible transition table.
* Taken from https://vt100.net/emu/dec_ansi_parser.
*/
export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
const table: TransitionTable = new TransitionTable(4095);
// range macro for byte
const BYTE_VALUES = 256;
const blueprint = Array.apply(null, Array(BYTE_VALUES)).map((unused: any, i: number) => i);
const r = (start: number, end: number): number[] => blueprint.slice(start, end);
// Default definitions.
const PRINTABLES = r(0x20, 0x7f); // 0x20 (SP) included, 0x7F (DEL) excluded
const EXECUTABLES = r(0x00, 0x18);
EXECUTABLES.push(0x19);
EXECUTABLES.push.apply(EXECUTABLES, r(0x1c, 0x20));
const states: number[] = r(ParserState.GROUND, ParserState.DCS_PASSTHROUGH + 1);
let state: any;
// set default transition
table.setDefault(ParserAction.ERROR, ParserState.GROUND);
// printables
table.addMany(PRINTABLES, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND);
// global anywhere rules
for (state in states) {
table.addMany([0x18, 0x1a, 0x99, 0x9a], state, ParserAction.EXECUTE, ParserState.GROUND);
table.addMany(r(0x80, 0x90), state, ParserAction.EXECUTE, ParserState.GROUND);
table.addMany(r(0x90, 0x98), state, ParserAction.EXECUTE, ParserState.GROUND);
table.add(0x9c, state, ParserAction.IGNORE, ParserState.GROUND); // ST as terminator
table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC
table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC
table.addMany([0x98, 0x9e, 0x9f], state, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING);
table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI
table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS
}
// rules for executables and 7f
table.addMany(EXECUTABLES, ParserState.GROUND, ParserAction.EXECUTE, ParserState.GROUND);
table.addMany(EXECUTABLES, ParserState.ESCAPE, ParserAction.EXECUTE, ParserState.ESCAPE);
table.add(0x7f, ParserState.ESCAPE, ParserAction.IGNORE, ParserState.ESCAPE);
table.addMany(EXECUTABLES, ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING);
table.addMany(EXECUTABLES, ParserState.CSI_ENTRY, ParserAction.EXECUTE, ParserState.CSI_ENTRY);
table.add(0x7f, ParserState.CSI_ENTRY, ParserAction.IGNORE, ParserState.CSI_ENTRY);
table.addMany(EXECUTABLES, ParserState.CSI_PARAM, ParserAction.EXECUTE, ParserState.CSI_PARAM);
table.add(0x7f, ParserState.CSI_PARAM, ParserAction.IGNORE, ParserState.CSI_PARAM);
table.addMany(EXECUTABLES, ParserState.CSI_IGNORE, ParserAction.EXECUTE, ParserState.CSI_IGNORE);
table.addMany(EXECUTABLES, ParserState.CSI_INTERMEDIATE, ParserAction.EXECUTE, ParserState.CSI_INTERMEDIATE);
table.add(0x7f, ParserState.CSI_INTERMEDIATE, ParserAction.IGNORE, ParserState.CSI_INTERMEDIATE);
table.addMany(EXECUTABLES, ParserState.ESCAPE_INTERMEDIATE, ParserAction.EXECUTE, ParserState.ESCAPE_INTERMEDIATE);
table.add(0x7f, ParserState.ESCAPE_INTERMEDIATE, ParserAction.IGNORE, ParserState.ESCAPE_INTERMEDIATE);
// osc
table.add(0x5d, ParserState.ESCAPE, ParserAction.OSC_START, ParserState.OSC_STRING);
table.addMany(PRINTABLES, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING);
table.add(0x7f, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING);
table.addMany([0x9c, 0x1b, 0x18, 0x1a, 0x07], ParserState.OSC_STRING, ParserAction.OSC_END, ParserState.GROUND);
table.addMany(r(0x1c, 0x20), ParserState.OSC_STRING, ParserAction.IGNORE, ParserState.OSC_STRING);
// sos/pm/apc does nothing
table.addMany([0x58, 0x5e, 0x5f], ParserState.ESCAPE, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING);
table.addMany(PRINTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING);
table.addMany(EXECUTABLES, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING);
table.add(0x9c, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.GROUND);
table.add(0x7f, ParserState.SOS_PM_APC_STRING, ParserAction.IGNORE, ParserState.SOS_PM_APC_STRING);
// csi entries
table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY);
table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND);
table.addMany(r(0x30, 0x3c), ParserState.CSI_ENTRY, ParserAction.PARAM, ParserState.CSI_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.CSI_ENTRY, ParserAction.COLLECT, ParserState.CSI_PARAM);
table.addMany(r(0x30, 0x3c), ParserState.CSI_PARAM, ParserAction.PARAM, ParserState.CSI_PARAM);
table.addMany(r(0x40, 0x7f), ParserState.CSI_PARAM, ParserAction.CSI_DISPATCH, ParserState.GROUND);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.CSI_PARAM, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.addMany(r(0x20, 0x40), ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.add(0x7f, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.addMany(r(0x40, 0x7f), ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.GROUND);
table.addMany(r(0x20, 0x30), ParserState.CSI_ENTRY, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.CSI_INTERMEDIATE, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE);
table.addMany(r(0x30, 0x40), ParserState.CSI_INTERMEDIATE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.addMany(r(0x40, 0x7f), ParserState.CSI_INTERMEDIATE, ParserAction.CSI_DISPATCH, ParserState.GROUND);
table.addMany(r(0x20, 0x30), ParserState.CSI_PARAM, ParserAction.COLLECT, ParserState.CSI_INTERMEDIATE);
// esc_intermediate
table.addMany(r(0x20, 0x30), ParserState.ESCAPE, ParserAction.COLLECT, ParserState.ESCAPE_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.ESCAPE_INTERMEDIATE, ParserAction.COLLECT, ParserState.ESCAPE_INTERMEDIATE);
table.addMany(r(0x30, 0x7f), ParserState.ESCAPE_INTERMEDIATE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
table.addMany(r(0x30, 0x50), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
table.addMany(r(0x51, 0x58), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
table.addMany([0x59, 0x5a, 0x5c], ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
table.addMany(r(0x60, 0x7f), ParserState.ESCAPE, ParserAction.ESC_DISPATCH, ParserState.GROUND);
// dcs entry
table.add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY);
table.addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM);
table.addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
table.addMany(r(0x40, 0x7f), ParserState.DCS_PARAM, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
table.addMany(r(0x40, 0x7f), ParserState.DCS_ENTRY, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
table.addMany(EXECUTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
table.addMany(PRINTABLES, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
table.add(0x7f, ParserState.DCS_PASSTHROUGH, ParserAction.IGNORE, ParserState.DCS_PASSTHROUGH);
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.DCS_PASSTHROUGH, ParserAction.DCS_UNHOOK, ParserState.GROUND);
// special handling of unicode chars
table.add(NON_ASCII_PRINTABLE, ParserState.GROUND, ParserAction.PRINT, ParserState.GROUND);
table.add(NON_ASCII_PRINTABLE, ParserState.OSC_STRING, ParserAction.OSC_PUT, ParserState.OSC_STRING);
table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
return table;
})();
/**
* EscapeSequenceParser.
* This class implements the ANSI/DEC compatible parser described by
* Paul Williams (https://vt100.net/emu/dec_ansi_parser).
*
* To implement custom ANSI compliant escape sequences it is not needed to
* alter this parser, instead consider registering a custom handler.
* For non ANSI compliant sequences change the transition table with
* the optional `transitions` constructor argument and
* reimplement the `parse` method.
*
* This parser is currently hardcoded to operate in ZDM (Zero Default Mode)
* as suggested by the original parser, thus empty parameters are set to 0.
* This this is not in line with the latest ECMA-48 specification
* (ZDM was part of the early specs and got completely removed later on).
*
* Other than the original parser from vt100.net this parser supports
* sub parameters in digital parameters separated by colons. Empty sub parameters
* are set to -1 (no ZDM for sub parameters).
*
* About prefix and intermediate bytes:
* This parser follows the assumptions of the vt100.net parser with these restrictions:
* - only one prefix byte is allowed as first parameter byte, byte range 0x3c .. 0x3f
* - max. two intermediates are respected, byte range 0x20 .. 0x2f
* Note that this is not in line with ECMA-48 which does not limit either of those.
* Furthermore ECMA-48 allows the prefix byte range at any param byte position. Currently
* there are no known sequences that follow the broader definition of the specification.
*
* TODO: implement error recovery hook via error handler return values
*/
export class EscapeSequenceParser extends Disposable implements IEscapeSequenceParser {
public initialState: number;
public currentState: number;
public precedingCodepoint: number;
// buffers over several parse calls
protected _params: Params;
protected _collect: number;
// handler lookup containers
protected _printHandler: PrintHandlerType;
protected _executeHandlers: { [flag: number]: ExecuteHandlerType };
protected _csiHandlers: IHandlerCollection<CsiHandlerType>;
protected _escHandlers: IHandlerCollection<EscHandlerType>;
protected readonly _oscParser: IOscParser;
protected readonly _dcsParser: IDcsParser;
protected _errorHandler: (state: IParsingState) => IParsingState;
// fallback handlers
protected _printHandlerFb: PrintFallbackHandlerType;
protected _executeHandlerFb: ExecuteFallbackHandlerType;
protected _csiHandlerFb: CsiFallbackHandlerType;
protected _escHandlerFb: EscFallbackHandlerType;
protected _errorHandlerFb: (state: IParsingState) => IParsingState;
// parser stack save for async handler support
protected _parseStack: IParserStackState = {
state: ParserStackType.NONE,
handlers: [],
handlerPos: 0,
transition: 0,
chunkPos: 0
};
constructor(
protected readonly _transitions: TransitionTable = VT500_TRANSITION_TABLE
) {
super();
this.initialState = ParserState.GROUND;
this.currentState = this.initialState;
this._params = new Params(); // defaults to 32 storable params/subparams
this._params.addParam(0); // ZDM
this._collect = 0;
this.precedingCodepoint = 0;
// set default fallback handlers and handler lookup containers
this._printHandlerFb = (data, start, end): void => { };
this._executeHandlerFb = (code: number): void => { };
this._csiHandlerFb = (ident: number, params: IParams): void => { };
this._escHandlerFb = (ident: number): void => { };
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
this._printHandler = this._printHandlerFb;
this._executeHandlers = Object.create(null);
this._csiHandlers = Object.create(null);
this._escHandlers = Object.create(null);
this.register(toDisposable(() => {
this._csiHandlers = Object.create(null);
this._executeHandlers = Object.create(null);
this._escHandlers = Object.create(null);
}));
this._oscParser = this.register(new OscParser());
this._dcsParser = this.register(new DcsParser());
this._errorHandler = this._errorHandlerFb;
// swallow 7bit ST (ESC+\)
this.registerEscHandler({ final: '\\' }, () => true);
}
protected _identifier(id: IFunctionIdentifier, finalRange: number[] = [0x40, 0x7e]): number {
let res = 0;
if (id.prefix) {
if (id.prefix.length > 1) {
throw new Error('only one byte as prefix supported');
}
res = id.prefix.charCodeAt(0);
if (res && 0x3c > res || res > 0x3f) {
throw new Error('prefix must be in range 0x3c .. 0x3f');
}
}
if (id.intermediates) {
if (id.intermediates.length > 2) {
throw new Error('only two bytes as intermediates are supported');
}
for (let i = 0; i < id.intermediates.length; ++i) {
const intermediate = id.intermediates.charCodeAt(i);
if (0x20 > intermediate || intermediate > 0x2f) {
throw new Error('intermediate must be in range 0x20 .. 0x2f');
}
res <<= 8;
res |= intermediate;
}
}
if (id.final.length !== 1) {
throw new Error('final must be a single byte');
}
const finalCode = id.final.charCodeAt(0);
if (finalRange[0] > finalCode || finalCode > finalRange[1]) {
throw new Error(`final must be in range ${finalRange[0]} .. ${finalRange[1]}`);
}
res <<= 8;
res |= finalCode;
return res;
}
public identToString(ident: number): string {
const res: string[] = [];
while (ident) {
res.push(String.fromCharCode(ident & 0xFF));
ident >>= 8;
}
return res.reverse().join('');
}
public setPrintHandler(handler: PrintHandlerType): void {
this._printHandler = handler;
}
public clearPrintHandler(): void {
this._printHandler = this._printHandlerFb;
}
public registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable {
const ident = this._identifier(id, [0x30, 0x7e]);
if (this._escHandlers[ident] === undefined) {
this._escHandlers[ident] = [];
}
const handlerList = this._escHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearEscHandler(id: IFunctionIdentifier): void {
if (this._escHandlers[this._identifier(id, [0x30, 0x7e])]) delete this._escHandlers[this._identifier(id, [0x30, 0x7e])];
}
public setEscHandlerFallback(handler: EscFallbackHandlerType): void {
this._escHandlerFb = handler;
}
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
this._executeHandlers[flag.charCodeAt(0)] = handler;
}
public clearExecuteHandler(flag: string): void {
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
}
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
this._executeHandlerFb = handler;
}
public registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable {
const ident = this._identifier(id);
if (this._csiHandlers[ident] === undefined) {
this._csiHandlers[ident] = [];
}
const handlerList = this._csiHandlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearCsiHandler(id: IFunctionIdentifier): void {
if (this._csiHandlers[this._identifier(id)]) delete this._csiHandlers[this._identifier(id)];
}
public setCsiHandlerFallback(callback: (ident: number, params: IParams) => void): void {
this._csiHandlerFb = callback;
}
public registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable {
return this._dcsParser.registerHandler(this._identifier(id), handler);
}
public clearDcsHandler(id: IFunctionIdentifier): void {
this._dcsParser.clearHandler(this._identifier(id));
}
public setDcsHandlerFallback(handler: DcsFallbackHandlerType): void {
this._dcsParser.setHandlerFallback(handler);
}
public registerOscHandler(ident: number, handler: IOscHandler): IDisposable {
return this._oscParser.registerHandler(ident, handler);
}
public clearOscHandler(ident: number): void {
this._oscParser.clearHandler(ident);
}
public setOscHandlerFallback(handler: OscFallbackHandlerType): void {
this._oscParser.setHandlerFallback(handler);
}
public setErrorHandler(callback: (state: IParsingState) => IParsingState): void {
this._errorHandler = callback;
}
public clearErrorHandler(): void {
this._errorHandler = this._errorHandlerFb;
}
/**
* Reset parser to initial values.
*
* This can also be used to lift the improper continuation error condition
* when dealing with async handlers. Use this only as a last resort to silence
* that error when the terminal has no pending data to be processed. Note that
* the interrupted async handler might continue its work in the future messing
* up the terminal state even further.
*/
public reset(): void {
this.currentState = this.initialState;
this._oscParser.reset();
this._dcsParser.reset();
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
this.precedingCodepoint = 0;
// abort pending continuation from async handler
// Here the RESET type indicates, that the next parse call will
// ignore any saved stack, instead continues sync with next codepoint from GROUND
if (this._parseStack.state !== ParserStackType.NONE) {
this._parseStack.state = ParserStackType.RESET;
this._parseStack.handlers = []; // also release handlers ref
}
}
/**
* Async parse support.
*/
protected _preserveStack(
state: ParserStackType,
handlers: ResumableHandlersType,
handlerPos: number,
transition: number,
chunkPos: number
): void {
this._parseStack.state = state;
this._parseStack.handlers = handlers;
this._parseStack.handlerPos = handlerPos;
this._parseStack.transition = transition;
this._parseStack.chunkPos = chunkPos;
}
/**
* Parse UTF32 codepoints in `data` up to `length`.
*
* Note: For several actions with high data load the parsing is optimized
* by using local read ahead loops with hardcoded conditions to
* avoid costly table lookups. Make sure that any change of table values
* will be reflected in the loop conditions as well and vice versa.
* Affected states/actions:
* - GROUND:PRINT
* - CSI_PARAM:PARAM
* - DCS_PARAM:PARAM
* - OSC_STRING:OSC_PUT
* - DCS_PASSTHROUGH:DCS_PUT
*
* Note on asynchronous handler support:
* Any handler returning a promise will be treated as asynchronous.
* To keep the in-band blocking working for async handlers, `parse` pauses execution,
* creates a stack save and returns the promise to the caller.
* For proper continuation of the paused state it is important
* to await the promise resolving. On resolve the parse must be repeated
* with the same chunk of data and the resolved value in `promiseResult`
* until no promise is returned.
*
* Important: With only sync handlers defined, parsing is completely synchronous as well.
* As soon as an async handler is involved, synchronous parsing is not possible anymore.
*
* Boilerplate for proper parsing of multiple chunks with async handlers:
*
* ```typescript
* async function parseMultipleChunks(chunks: Uint32Array[]): Promise<void> {
* for (const chunk of chunks) {
* let result: void | Promise<boolean>;
* let prev: boolean | undefined;
* while (result = parser.parse(chunk, chunk.length, prev)) {
* prev = await result;
* }
* }
* // finished parsing all chunks...
* }
* ```
*/
public parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise<boolean> {
let code = 0;
let transition = 0;
let start = 0;
let handlerResult: void | boolean | Promise<boolean>;
// resume from async handler
if (this._parseStack.state) {
// allow sync parser reset even in continuation mode
// Note: can be used to recover parser from improper continuation error below
if (this._parseStack.state === ParserStackType.RESET) {
this._parseStack.state = ParserStackType.NONE;
start = this._parseStack.chunkPos + 1; // continue with next codepoint in GROUND
} else {
if (promiseResult === undefined || this._parseStack.state === ParserStackType.FAIL) {
/**
* Reject further parsing on improper continuation after pausing. This is a really bad
* condition with screwed up execution order and prolly messed up terminal state,
* therefore we exit hard with an exception and reject any further parsing.
*
* Note: With `Terminal.write` usage this exception should never occur, as the top level
* calls are guaranteed to handle async conditions properly. If you ever encounter this
* exception in your terminal integration it indicates, that you injected data chunks to
* `InputHandler.parse` or `EscapeSequenceParser.parse` synchronously without waiting for
* continuation of a running async handler.
*
* It is possible to get rid of this error by calling `reset`. But dont rely on that, as
* the pending async handler still might mess up the terminal later. Instead fix the
* faulty async handling, so this error will not be thrown anymore.
*/
this._parseStack.state = ParserStackType.FAIL;
throw new Error('improper continuation due to previous async handler, giving up parsing');
}
// we have to resume the old handler loop if:
// - return value of the promise was `false`
// - handlers are not exhausted yet
const handlers = this._parseStack.handlers;
let handlerPos = this._parseStack.handlerPos - 1;
switch (this._parseStack.state) {
case ParserStackType.CSI:
if (promiseResult === false && handlerPos > -1) {
for (; handlerPos >= 0; handlerPos--) {
handlerResult = (handlers as CsiHandlerType[])[handlerPos](this._params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
}
this._parseStack.handlers = [];
break;
case ParserStackType.ESC:
if (promiseResult === false && handlerPos > -1) {
for (; handlerPos >= 0; handlerPos--) {
handlerResult = (handlers as EscHandlerType[])[handlerPos]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._parseStack.handlerPos = handlerPos;
return handlerResult;
}
}
}
this._parseStack.handlers = [];
break;
case ParserStackType.DCS:
code = data[this._parseStack.chunkPos];
handlerResult = this._dcsParser.unhook(code !== 0x18 && code !== 0x1a, promiseResult);
if (handlerResult) {
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
break;
case ParserStackType.OSC:
code = data[this._parseStack.chunkPos];
handlerResult = this._oscParser.end(code !== 0x18 && code !== 0x1a, promiseResult);
if (handlerResult) {
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
break;
}
// cleanup before continuing with the main sync loop
this._parseStack.state = ParserStackType.NONE;
start = this._parseStack.chunkPos + 1;
this.precedingCodepoint = 0;
this.currentState = this._parseStack.transition & TableAccess.TRANSITION_STATE_MASK;
}
}
// continue with main sync loop
// process input string
for (let i = start; i < length; ++i) {
code = data[i];
// normal transition & action lookup
transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)];
switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) {
case ParserAction.PRINT:
// read ahead with loop unrolling
// Note: 0x20 (SP) is included, 0x7F (DEL) is excluded
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
}
break;
case ParserAction.EXECUTE:
if (this._executeHandlers[code]) this._executeHandlers[code]();
else this._executeHandlerFb(code);
this.precedingCodepoint = 0;
break;
case ParserAction.IGNORE:
break;
case ParserAction.ERROR:
const inject: IParsingState = this._errorHandler(
{
position: i,
code,
currentState: this.currentState,
collect: this._collect,
params: this._params,
abort: false
});
if (inject.abort) return;
// inject values: currently not implemented
break;
case ParserAction.CSI_DISPATCH:
// Trigger CSI Handler
const handlers = this._csiHandlers[this._collect << 8 | code];
let j = handlers ? handlers.length - 1 : -1;
for (; j >= 0; j--) {
// true means success and to stop bubbling
// a promise indicates an async handler that needs to finish before progressing
handlerResult = handlers[j](this._params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._preserveStack(ParserStackType.CSI, handlers, j, transition, i);
return handlerResult;
}
}
if (j < 0) {
this._csiHandlerFb(this._collect << 8 | code, this._params);
}
this.precedingCodepoint = 0;
break;
case ParserAction.PARAM:
// inner loop: digits (0x30 - 0x39) and ; (0x3b) and : (0x3a)
do {
switch (code) {
case 0x3b:
this._params.addParam(0); // ZDM
break;
case 0x3a:
this._params.addSubParam(-1);
break;
default: // 0x30 - 0x39
this._params.addDigit(code - 48);
}
} while (++i < length && (code = data[i]) > 0x2f && code < 0x3c);
i--;
break;
case ParserAction.COLLECT:
this._collect <<= 8;
this._collect |= code;
break;
case ParserAction.ESC_DISPATCH:
const handlersEsc = this._escHandlers[this._collect << 8 | code];
let jj = handlersEsc ? handlersEsc.length - 1 : -1;
for (; jj >= 0; jj--) {
// true means success and to stop bubbling
// a promise indicates an async handler that needs to finish before progressing
handlerResult = handlersEsc[jj]();
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._preserveStack(ParserStackType.ESC, handlersEsc, jj, transition, i);
return handlerResult;
}
}
if (jj < 0) {
this._escHandlerFb(this._collect << 8 | code);
}
this.precedingCodepoint = 0;
break;
case ParserAction.CLEAR:
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
break;
case ParserAction.DCS_HOOK:
this._dcsParser.hook(this._collect << 8 | code, this._params);
break;
case ParserAction.DCS_PUT:
// inner loop - exit DCS_PUT: 0x18, 0x1a, 0x1b, 0x7f, 0x80 - 0x9f
// unhook triggered by: 0x1b, 0x9c (success) and 0x18, 0x1a (abort)
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || (code > 0x7f && code < NON_ASCII_PRINTABLE)) {
this._dcsParser.put(data, i, j);
i = j - 1;
break;
}
}
break;
case ParserAction.DCS_UNHOOK:
handlerResult = this._dcsParser.unhook(code !== 0x18 && code !== 0x1a);
if (handlerResult) {
this._preserveStack(ParserStackType.DCS, [], 0, transition, i);
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
this.precedingCodepoint = 0;
break;
case ParserAction.OSC_START:
this._oscParser.start();
break;
case ParserAction.OSC_PUT:
// inner loop: 0x20 (SP) included, 0x7F (DEL) included
for (let j = i + 1; ; j++) {
if (j >= length || (code = data[j]) < 0x20 || (code > 0x7f && code < NON_ASCII_PRINTABLE)) {
this._oscParser.put(data, i, j);
i = j - 1;
break;
}
}
break;
case ParserAction.OSC_END:
handlerResult = this._oscParser.end(code !== 0x18 && code !== 0x1a);
if (handlerResult) {
this._preserveStack(ParserStackType.OSC, [], 0, transition, i);
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._collect = 0;
this.precedingCodepoint = 0;
break;
}
this.currentState = transition & TableAccess.TRANSITION_STATE_MASK;
}
}
}

View File

@@ -0,0 +1,238 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, ISubParserStackState } from 'common/parser/Types';
import { OscState, PAYLOAD_LIMIT } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
const EMPTY_HANDLERS: IOscHandler[] = [];
export class OscParser implements IOscParser {
private _state = OscState.START;
private _active = EMPTY_HANDLERS;
private _id = -1;
private _handlers: IHandlerCollection<IOscHandler> = Object.create(null);
private _handlerFb: OscFallbackHandlerType = () => { };
private _stack: ISubParserStackState = {
paused: false,
loopPosition: 0,
fallThrough: false
};
public registerHandler(ident: number, handler: IOscHandler): IDisposable {
if (this._handlers[ident] === undefined) {
this._handlers[ident] = [];
}
const handlerList = this._handlers[ident];
handlerList.push(handler);
return {
dispose: () => {
const handlerIndex = handlerList.indexOf(handler);
if (handlerIndex !== -1) {
handlerList.splice(handlerIndex, 1);
}
}
};
}
public clearHandler(ident: number): void {
if (this._handlers[ident]) delete this._handlers[ident];
}
public setHandlerFallback(handler: OscFallbackHandlerType): void {
this._handlerFb = handler;
}
public dispose(): void {
this._handlers = Object.create(null);
this._handlerFb = () => { };
this._active = EMPTY_HANDLERS;
}
public reset(): void {
// force cleanup handlers if payload was already sent
if (this._state === OscState.PAYLOAD) {
for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) {
this._active[j].end(false);
}
}
this._stack.paused = false;
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = OscState.START;
}
private _start(): void {
this._active = this._handlers[this._id] || EMPTY_HANDLERS;
if (!this._active.length) {
this._handlerFb(this._id, 'START');
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].start();
}
}
}
private _put(data: Uint32Array, start: number, end: number): void {
if (!this._active.length) {
this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end));
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].put(data, start, end);
}
}
}
public start(): void {
// always reset leftover handlers
this.reset();
this._state = OscState.ID;
}
/**
* Put data to current OSC command.
* Expects the identifier of the OSC command in the form
* OSC id ; payload ST/BEL
* Payload chunks are not further processed and get
* directly passed to the handlers.
*/
public put(data: Uint32Array, start: number, end: number): void {
if (this._state === OscState.ABORT) {
return;
}
if (this._state === OscState.ID) {
while (start < end) {
const code = data[start++];
if (code === 0x3b) {
this._state = OscState.PAYLOAD;
this._start();
break;
}
if (code < 0x30 || 0x39 < code) {
this._state = OscState.ABORT;
return;
}
if (this._id === -1) {
this._id = 0;
}
this._id = this._id * 10 + code - 48;
}
}
if (this._state === OscState.PAYLOAD && end - start > 0) {
this._put(data, start, end);
}
}
/**
* Indicates end of an OSC command.
* Whether the OSC got aborted or finished normally
* is indicated by `success`.
*/
public end(success: boolean, promiseResult: boolean = true): void | Promise<boolean> {
if (this._state === OscState.START) {
return;
}
// do nothing if command was faulty
if (this._state !== OscState.ABORT) {
// if we are still in ID state and get an early end
// means that the command has no payload thus we still have
// to announce START and send END right after
if (this._state === OscState.ID) {
this._start();
}
if (!this._active.length) {
this._handlerFb(this._id, 'END', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].end(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers
// we always have to call .end for proper cleanup,
// here we use `success` to indicate whether a handler should execute
for (; j >= 0; j--) {
handlerResult = this._active[j].end(false);
if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
return handlerResult;
}
}
}
}
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = OscState.START;
}
}
/**
* Convenient class to allow attaching string based handler functions
* as OSC handlers.
*/
export class OscHandler implements IOscHandler {
private _data = '';
private _hitLimit: boolean = false;
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
public start(): void {
this._data = '';
this._hitLimit = false;
}
public put(data: Uint32Array, start: number, end: number): void {
if (this._hitLimit) {
return;
}
this._data += utf32ToString(data, start, end);
if (this._data.length > PAYLOAD_LIMIT) {
this._data = '';
this._hitLimit = true;
}
}
public end(success: boolean): boolean | Promise<boolean> {
let ret: boolean | Promise<boolean> = false;
if (this._hitLimit) {
ret = false;
} else if (success) {
ret = this._handler(this._data);
if (ret instanceof Promise) {
// need to hold data until `ret` got resolved
// dont care for errors, data will be freed anyway on next start
return ret.then(res => {
this._data = '';
this._hitLimit = false;
return res;
});
}
}
this._data = '';
this._hitLimit = false;
return ret;
}
}

View File

@@ -0,0 +1,229 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IParams, ParamsArray } from 'common/parser/Types';
// max value supported for a single param/subparam (clamped to positive int32 range)
const MAX_VALUE = 0x7FFFFFFF;
// max allowed subparams for a single sequence (hardcoded limitation)
const MAX_SUBPARAMS = 256;
/**
* Params storage class.
* This type is used by the parser to accumulate sequence parameters and sub parameters
* and transmit them to the input handler actions.
*
* NOTES:
* - params object for action handlers is borrowed, use `.toArray` or `.clone` to get a copy
* - never read beyond `params.length - 1` (likely to contain arbitrary data)
* - `.getSubParams` returns a borrowed typed array, use `.getSubParamsAll` for cloned sub params
* - hardcoded limitations:
* - max. value for a single (sub) param is 2^31 - 1 (greater values are clamped to that)
* - max. 256 sub params possible
* - negative values are not allowed beside -1 (placeholder for default value)
*
* About ZDM (Zero Default Mode):
* ZDM is not orchestrated by this class. If the parser is in ZDM,
* it should add 0 for empty params, otherwise -1. This does not apply
* to subparams, empty subparams should always be added with -1.
*/
export class Params implements IParams {
// params store and length
public params: Int32Array;
public length: number;
// sub params store and length
protected _subParams: Int32Array;
protected _subParamsLength: number;
// sub params offsets from param: param idx --> [start, end] offset
private _subParamsIdx: Uint16Array;
private _rejectDigits: boolean;
private _rejectSubDigits: boolean;
private _digitIsSub: boolean;
/**
* Create a `Params` type from JS array representation.
*/
public static fromArray(values: ParamsArray): Params {
const params = new Params();
if (!values.length) {
return params;
}
// skip leading sub params
for (let i = (Array.isArray(values[0])) ? 1 : 0; i < values.length; ++i) {
const value = values[i];
if (Array.isArray(value)) {
for (let k = 0; k < value.length; ++k) {
params.addSubParam(value[k]);
}
} else {
params.addParam(value);
}
}
return params;
}
/**
* @param maxLength max length of storable parameters
* @param maxSubParamsLength max length of storable sub parameters
*/
constructor(public maxLength: number = 32, public maxSubParamsLength: number = 32) {
if (maxSubParamsLength > MAX_SUBPARAMS) {
throw new Error('maxSubParamsLength must not be greater than 256');
}
this.params = new Int32Array(maxLength);
this.length = 0;
this._subParams = new Int32Array(maxSubParamsLength);
this._subParamsLength = 0;
this._subParamsIdx = new Uint16Array(maxLength);
this._rejectDigits = false;
this._rejectSubDigits = false;
this._digitIsSub = false;
}
/**
* Clone object.
*/
public clone(): Params {
const newParams = new Params(this.maxLength, this.maxSubParamsLength);
newParams.params.set(this.params);
newParams.length = this.length;
newParams._subParams.set(this._subParams);
newParams._subParamsLength = this._subParamsLength;
newParams._subParamsIdx.set(this._subParamsIdx);
newParams._rejectDigits = this._rejectDigits;
newParams._rejectSubDigits = this._rejectSubDigits;
newParams._digitIsSub = this._digitIsSub;
return newParams;
}
/**
* Get a JS array representation of the current parameters and sub parameters.
* The array is structured as follows:
* sequence: "1;2:3:4;5::6"
* array : [1, 2, [3, 4], 5, [-1, 6]]
*/
public toArray(): ParamsArray {
const res: ParamsArray = [];
for (let i = 0; i < this.length; ++i) {
res.push(this.params[i]);
const start = this._subParamsIdx[i] >> 8;
const end = this._subParamsIdx[i] & 0xFF;
if (end - start > 0) {
res.push(Array.prototype.slice.call(this._subParams, start, end));
}
}
return res;
}
/**
* Reset to initial empty state.
*/
public reset(): void {
this.length = 0;
this._subParamsLength = 0;
this._rejectDigits = false;
this._rejectSubDigits = false;
this._digitIsSub = false;
}
/**
* Add a parameter value.
* `Params` only stores up to `maxLength` parameters, any later
* parameter will be ignored.
* Note: VT devices only stored up to 16 values, xterm seems to
* store up to 30.
*/
public addParam(value: number): void {
this._digitIsSub = false;
if (this.length >= this.maxLength) {
this._rejectDigits = true;
return;
}
if (value < -1) {
throw new Error('values lesser than -1 are not allowed');
}
this._subParamsIdx[this.length] = this._subParamsLength << 8 | this._subParamsLength;
this.params[this.length++] = value > MAX_VALUE ? MAX_VALUE : value;
}
/**
* Add a sub parameter value.
* The sub parameter is automatically associated with the last parameter value.
* Thus it is not possible to add a subparameter without any parameter added yet.
* `Params` only stores up to `subParamsLength` sub parameters, any later
* sub parameter will be ignored.
*/
public addSubParam(value: number): void {
this._digitIsSub = true;
if (!this.length) {
return;
}
if (this._rejectDigits || this._subParamsLength >= this.maxSubParamsLength) {
this._rejectSubDigits = true;
return;
}
if (value < -1) {
throw new Error('values lesser than -1 are not allowed');
}
this._subParams[this._subParamsLength++] = value > MAX_VALUE ? MAX_VALUE : value;
this._subParamsIdx[this.length - 1]++;
}
/**
* Whether parameter at index `idx` has sub parameters.
*/
public hasSubParams(idx: number): boolean {
return ((this._subParamsIdx[idx] & 0xFF) - (this._subParamsIdx[idx] >> 8) > 0);
}
/**
* Return sub parameters for parameter at index `idx`.
* Note: The values are borrowed, thus you need to copy
* the values if you need to hold them in nonlocal scope.
*/
public getSubParams(idx: number): Int32Array | null {
const start = this._subParamsIdx[idx] >> 8;
const end = this._subParamsIdx[idx] & 0xFF;
if (end - start > 0) {
return this._subParams.subarray(start, end);
}
return null;
}
/**
* Return all sub parameters as {idx: subparams} mapping.
* Note: The values are not borrowed.
*/
public getSubParamsAll(): {[idx: number]: Int32Array} {
const result: {[idx: number]: Int32Array} = {};
for (let i = 0; i < this.length; ++i) {
const start = this._subParamsIdx[i] >> 8;
const end = this._subParamsIdx[i] & 0xFF;
if (end - start > 0) {
result[i] = this._subParams.slice(start, end);
}
}
return result;
}
/**
* Add a single digit value to current parameter.
* This is used by the parser to account digits on a char by char basis.
*/
public addDigit(value: number): void {
let length;
if (this._rejectDigits
|| !(length = this._digitIsSub ? this._subParamsLength : this.length)
|| (this._digitIsSub && this._rejectSubDigits)
) {
return;
}
const store = this._digitIsSub ? this._subParams : this.params;
const cur = store[length - 1];
store[length - 1] = ~cur ? Math.min(cur * 10 + value, MAX_VALUE) : value;
}
}

View File

@@ -0,0 +1,274 @@
/**
* Copyright (c) 2017 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from 'common/Types';
import { ParserState } from 'common/parser/Constants';
/** sequence params serialized to js arrays */
export type ParamsArray = (number | number[])[];
/** Params constructor type. */
export interface IParamsConstructor {
new(maxLength: number, maxSubParamsLength: number): IParams;
/** create params from ParamsArray */
fromArray(values: ParamsArray): IParams;
}
/** Interface of Params storage class. */
export interface IParams {
/** from ctor */
maxLength: number;
maxSubParamsLength: number;
/** param values and its length */
params: Int32Array;
length: number;
/** methods */
clone(): IParams;
toArray(): ParamsArray;
reset(): void;
addParam(value: number): void;
addSubParam(value: number): void;
hasSubParams(idx: number): boolean;
getSubParams(idx: number): Int32Array | null;
getSubParamsAll(): {[idx: number]: Int32Array};
}
/**
* Internal state of EscapeSequenceParser.
* Used as argument of the error handler to allow
* introspection at runtime on parse errors.
* Return it with altered values to recover from
* faulty states (not yet supported).
* Set `abort` to `true` to abort the current parsing.
*/
export interface IParsingState {
// position in parse string
position: number;
// actual character code
code: number;
// current parser state
currentState: ParserState;
// collect buffer with intermediate characters
collect: number;
// params buffer
params: IParams;
// should abort (default: false)
abort: boolean;
}
/**
* Command handler interfaces.
*/
/**
* CSI handler types.
* Note: `params` is borrowed.
*/
export type CsiHandlerType = (params: IParams) => boolean | Promise<boolean>;
export type CsiFallbackHandlerType = (ident: number, params: IParams) => void;
/**
* DCS handler types.
*/
export interface IDcsHandler {
/**
* Called when a DCS command starts.
* Prepare needed data structures here.
* Note: `params` is borrowed.
*/
hook(params: IParams): void;
/**
* Incoming payload chunk.
* Note: `params` is borrowed.
*/
put(data: Uint32Array, start: number, end: number): void;
/**
* End of DCS command. `success` indicates whether the
* command finished normally or got aborted, thus final
* execution of the command should depend on `success`.
* To save memory also cleanup data structures here.
*/
unhook(success: boolean): boolean | Promise<boolean>;
}
export type DcsFallbackHandlerType = (ident: number, action: 'HOOK' | 'PUT' | 'UNHOOK', payload?: any) => void;
/**
* ESC handler types.
*/
export type EscHandlerType = () => boolean | Promise<boolean>;
export type EscFallbackHandlerType = (identifier: number) => void;
/**
* EXECUTE handler types.
*/
export type ExecuteHandlerType = () => boolean;
export type ExecuteFallbackHandlerType = (ident: number) => void;
/**
* OSC handler types.
*/
export interface IOscHandler {
/**
* Announces start of this OSC command.
* Prepare needed data structures here.
*/
start(): void;
/**
* Incoming data chunk.
* Note: Data is borrowed.
*/
put(data: Uint32Array, start: number, end: number): void;
/**
* End of OSC command. `success` indicates whether the
* command finished normally or got aborted, thus final
* execution of the command should depend on `success`.
* To save memory also cleanup data structures here.
*/
end(success: boolean): boolean | Promise<boolean>;
}
export type OscFallbackHandlerType = (ident: number, action: 'START' | 'PUT' | 'END', payload?: any) => void;
/**
* PRINT handler types.
*/
export type PrintHandlerType = (data: Uint32Array, start: number, end: number) => void;
export type PrintFallbackHandlerType = PrintHandlerType;
/**
* EscapeSequenceParser interface.
*/
export interface IEscapeSequenceParser extends IDisposable {
/**
* Preceding codepoint to get REP working correctly.
* This must be set by the print handler as last action.
* It gets reset by the parser for any valid sequence beside REP itself.
*/
precedingCodepoint: number;
/**
* Reset the parser to its initial state (handlers are kept).
*/
reset(): void;
/**
* Parse UTF32 codepoints in `data` up to `length`.
* @param data The data to parse.
*/
parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise<boolean>;
/**
* Get string from numercial function identifier `ident`.
* Useful in fallback handlers which expose the low level
* numcerical function identifier for debugging purposes.
* Note: A full back translation to `IFunctionIdentifier`
* is not implemented.
*/
identToString(ident: number): string;
setPrintHandler(handler: PrintHandlerType): void;
clearPrintHandler(): void;
registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable;
clearEscHandler(id: IFunctionIdentifier): void;
setEscHandlerFallback(handler: EscFallbackHandlerType): void;
setExecuteHandler(flag: string, handler: ExecuteHandlerType): void;
clearExecuteHandler(flag: string): void;
setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void;
registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable;
clearCsiHandler(id: IFunctionIdentifier): void;
setCsiHandlerFallback(callback: CsiFallbackHandlerType): void;
registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable;
clearDcsHandler(id: IFunctionIdentifier): void;
setDcsHandlerFallback(handler: DcsFallbackHandlerType): void;
registerOscHandler(ident: number, handler: IOscHandler): IDisposable;
clearOscHandler(ident: number): void;
setOscHandlerFallback(handler: OscFallbackHandlerType): void;
setErrorHandler(handler: (state: IParsingState) => IParsingState): void;
clearErrorHandler(): void;
}
/**
* Subparser interfaces.
* The subparsers are instantiated in `EscapeSequenceParser` and
* called during `EscapeSequenceParser.parse`.
*/
export interface ISubParser<T, U> extends IDisposable {
reset(): void;
registerHandler(ident: number, handler: T): IDisposable;
clearHandler(ident: number): void;
setHandlerFallback(handler: U): void;
put(data: Uint32Array, start: number, end: number): void;
}
export interface IOscParser extends ISubParser<IOscHandler, OscFallbackHandlerType> {
start(): void;
end(success: boolean, promiseResult?: boolean): void | Promise<boolean>;
}
export interface IDcsParser extends ISubParser<IDcsHandler, DcsFallbackHandlerType> {
hook(ident: number, params: IParams): void;
unhook(success: boolean, promiseResult?: boolean): void | Promise<boolean>;
}
/**
* Interface to denote a specific ESC, CSI or DCS handler slot.
* The values are used to create an integer respresentation during handler
* regristation before passed to the subparsers as `ident`.
* The integer translation is made to allow a faster handler access
* in `EscapeSequenceParser.parse`.
*/
export interface IFunctionIdentifier {
prefix?: string;
intermediates?: string;
final: string;
}
export interface IHandlerCollection<T> {
[key: string]: T[];
}
/**
* Types for async parser support.
*/
// type of saved stack state in parser
export const enum ParserStackType {
NONE = 0,
FAIL,
RESET,
CSI,
ESC,
OSC,
DCS
}
// aggregate of resumable handler lists
export type ResumableHandlersType = CsiHandlerType[] | EscHandlerType[];
// saved stack state of the parser
export interface IParserStackState {
state: ParserStackType;
handlers: ResumableHandlersType;
handlerPos: number;
transition: number;
chunkPos: number;
}
// saved stack state of subparser (OSC and DCS)
export interface ISubParserStackState {
paused: boolean;
loopPosition: number;
fallThrough: boolean;
}

View File

@@ -0,0 +1,53 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ITerminalAddon, IDisposable, Terminal } from 'xterm';
export interface ILoadedAddon {
instance: ITerminalAddon;
dispose: () => void;
isDisposed: boolean;
}
export class AddonManager implements IDisposable {
protected _addons: ILoadedAddon[] = [];
public dispose(): void {
for (let i = this._addons.length - 1; i >= 0; i--) {
this._addons[i].instance.dispose();
}
}
public loadAddon(terminal: Terminal, instance: ITerminalAddon): void {
const loadedAddon: ILoadedAddon = {
instance,
dispose: instance.dispose,
isDisposed: false
};
this._addons.push(loadedAddon);
instance.dispose = () => this._wrappedAddonDispose(loadedAddon);
instance.activate(terminal as any);
}
private _wrappedAddonDispose(loadedAddon: ILoadedAddon): void {
if (loadedAddon.isDisposed) {
// Do nothing if already disposed
return;
}
let index = -1;
for (let i = 0; i < this._addons.length; i++) {
if (this._addons[i] === loadedAddon) {
index = i;
break;
}
}
if (index === -1) {
throw new Error('Could not dispose an addon that has not been loaded');
}
loadedAddon.isDisposed = true;
loadedAddon.dispose.apply(loadedAddon.instance);
this._addons.splice(index, 1);
}
}

View File

@@ -0,0 +1,35 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBuffer as IBufferApi, IBufferLine as IBufferLineApi, IBufferCell as IBufferCellApi } from 'xterm';
import { IBuffer } from 'common/buffer/Types';
import { BufferLineApiView } from 'common/public/BufferLineApiView';
import { CellData } from 'common/buffer/CellData';
export class BufferApiView implements IBufferApi {
constructor(
private _buffer: IBuffer,
public readonly type: 'normal' | 'alternate'
) { }
public init(buffer: IBuffer): BufferApiView {
this._buffer = buffer;
return this;
}
public get cursorY(): number { return this._buffer.y; }
public get cursorX(): number { return this._buffer.x; }
public get viewportY(): number { return this._buffer.ydisp; }
public get baseY(): number { return this._buffer.ybase; }
public get length(): number { return this._buffer.lines.length; }
public getLine(y: number): IBufferLineApi | undefined {
const line = this._buffer.lines.get(y);
if (!line) {
return undefined;
}
return new BufferLineApiView(line);
}
public getNullCell(): IBufferCellApi { return new CellData(); }
}

View File

@@ -0,0 +1,29 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { CellData } from 'common/buffer/CellData';
import { IBufferLine, ICellData } from 'common/Types';
import { IBufferCell as IBufferCellApi, IBufferLine as IBufferLineApi } from 'xterm';
export class BufferLineApiView implements IBufferLineApi {
constructor(private _line: IBufferLine) { }
public get isWrapped(): boolean { return this._line.isWrapped; }
public get length(): number { return this._line.length; }
public getCell(x: number, cell?: IBufferCellApi): IBufferCellApi | undefined {
if (x < 0 || x >= this._line.length) {
return undefined;
}
if (cell) {
this._line.loadCell(x, cell as ICellData);
return cell;
}
return this._line.loadCell(x, new CellData());
}
public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string {
return this._line.translateToString(trimRight, startColumn, endColumn);
}
}

View File

@@ -0,0 +1,36 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBuffer as IBufferApi, IBufferNamespace as IBufferNamespaceApi } from 'xterm';
import { BufferApiView } from 'common/public/BufferApiView';
import { EventEmitter } from 'common/EventEmitter';
import { ICoreTerminal } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
export class BufferNamespaceApi extends Disposable implements IBufferNamespaceApi {
private _normal: BufferApiView;
private _alternate: BufferApiView;
private readonly _onBufferChange = this.register(new EventEmitter<IBufferApi>());
public readonly onBufferChange = this._onBufferChange.event;
constructor(private _core: ICoreTerminal) {
super();
this._normal = new BufferApiView(this._core.buffers.normal, 'normal');
this._alternate = new BufferApiView(this._core.buffers.alt, 'alternate');
this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active));
}
public get active(): IBufferApi {
if (this._core.buffers.active === this._core.buffers.normal) { return this.normal; }
if (this._core.buffers.active === this._core.buffers.alt) { return this.alternate; }
throw new Error('Active buffer is neither normal nor alternate');
}
public get normal(): IBufferApi {
return this._normal.init(this._core.buffers.normal);
}
public get alternate(): IBufferApi {
return this._alternate.init(this._core.buffers.alt);
}
}

View File

@@ -0,0 +1,37 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IParams } from 'common/parser/Types';
import { IDisposable, IFunctionIdentifier, IParser } from 'xterm';
import { ICoreTerminal } from 'common/Types';
export class ParserApi implements IParser {
constructor(private _core: ICoreTerminal) { }
public registerCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean | Promise<boolean>): IDisposable {
return this._core.registerCsiHandler(id, (params: IParams) => callback(params.toArray()));
}
public addCsiHandler(id: IFunctionIdentifier, callback: (params: (number | number[])[]) => boolean | Promise<boolean>): IDisposable {
return this.registerCsiHandler(id, callback);
}
public registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean | Promise<boolean>): IDisposable {
return this._core.registerDcsHandler(id, (data: string, params: IParams) => callback(data, params.toArray()));
}
public addDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: (number | number[])[]) => boolean | Promise<boolean>): IDisposable {
return this.registerDcsHandler(id, callback);
}
public registerEscHandler(id: IFunctionIdentifier, handler: () => boolean | Promise<boolean>): IDisposable {
return this._core.registerEscHandler(id, handler);
}
public addEscHandler(id: IFunctionIdentifier, handler: () => boolean | Promise<boolean>): IDisposable {
return this.registerEscHandler(id, handler);
}
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._core.registerOscHandler(ident, callback);
}
public addOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this.registerOscHandler(ident, callback);
}
}

View File

@@ -0,0 +1,27 @@
/**
* Copyright (c) 2021 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICoreTerminal } from 'common/Types';
import { IUnicodeHandling, IUnicodeVersionProvider } from 'xterm';
export class UnicodeApi implements IUnicodeHandling {
constructor(private _core: ICoreTerminal) { }
public register(provider: IUnicodeVersionProvider): void {
this._core.unicodeService.register(provider);
}
public get versions(): string[] {
return this._core.unicodeService.versions;
}
public get activeVersion(): string {
return this._core.unicodeService.activeVersion;
}
public set activeVersion(version: string) {
this._core.unicodeService.activeVersion = version;
}
}

View File

@@ -0,0 +1,151 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { IAttributeData, IBufferLine, ScrollSource } from 'common/Types';
import { BufferSet } from 'common/buffer/BufferSet';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { IBufferService, IOptionsService } from 'common/services/Services';
export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars
export const MINIMUM_ROWS = 1;
export class BufferService extends Disposable implements IBufferService {
public serviceBrand: any;
public cols: number;
public rows: number;
public buffers: IBufferSet;
/** Whether the user is scrolling (locks the scroll position) */
public isUserScrolling: boolean = false;
private readonly _onResize = this.register(new EventEmitter<{ cols: number, rows: number }>());
public readonly onResize = this._onResize.event;
private readonly _onScroll = this.register(new EventEmitter<number>());
public readonly onScroll = this._onScroll.event;
public get buffer(): IBuffer { return this.buffers.active; }
/** An IBufferline to clone/copy from for new blank lines */
private _cachedBlankLine: IBufferLine | undefined;
constructor(@IOptionsService optionsService: IOptionsService) {
super();
this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS);
this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
this.buffers = this.register(new BufferSet(optionsService, this));
}
public resize(cols: number, rows: number): void {
this.cols = cols;
this.rows = rows;
this.buffers.resize(cols, rows);
// TODO: This doesn't fire when scrollback changes - add a resize event to BufferSet and forward
// event
this._onResize.fire({ cols, rows });
}
public reset(): void {
this.buffers.reset();
this.isUserScrolling = false;
}
/**
* Scroll the terminal down 1 row, creating a blank line.
* @param eraseAttr The attribute data to use the for blank line.
* @param isWrapped Whether the new line is wrapped from the previous line.
*/
public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void {
const buffer = this.buffer;
let newLine: IBufferLine | undefined;
newLine = this._cachedBlankLine;
if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) {
newLine = buffer.getBlankLine(eraseAttr, isWrapped);
this._cachedBlankLine = newLine;
}
newLine.isWrapped = isWrapped;
const topRow = buffer.ybase + buffer.scrollTop;
const bottomRow = buffer.ybase + buffer.scrollBottom;
if (buffer.scrollTop === 0) {
// Determine whether the buffer is going to be trimmed after insertion.
const willBufferBeTrimmed = buffer.lines.isFull;
// Insert the line using the fastest method
if (bottomRow === buffer.lines.length - 1) {
if (willBufferBeTrimmed) {
buffer.lines.recycle().copyFrom(newLine);
} else {
buffer.lines.push(newLine.clone());
}
} else {
buffer.lines.splice(bottomRow + 1, 0, newLine.clone());
}
// Only adjust ybase and ydisp when the buffer is not trimmed
if (!willBufferBeTrimmed) {
buffer.ybase++;
// Only scroll the ydisp with ybase if the user has not scrolled up
if (!this.isUserScrolling) {
buffer.ydisp++;
}
} else {
// When the buffer is full and the user has scrolled up, keep the text
// stable unless ydisp is right at the top
if (this.isUserScrolling) {
buffer.ydisp = Math.max(buffer.ydisp - 1, 0);
}
}
} else {
// scrollTop is non-zero which means no line will be going to the
// scrollback, instead we can just shift them in-place.
const scrollRegionHeight = bottomRow - topRow + 1 /* as it's zero-based */;
buffer.lines.shiftElements(topRow + 1, scrollRegionHeight - 1, -1);
buffer.lines.set(bottomRow, newLine.clone());
}
// Move the viewport to the bottom of the buffer unless the user is
// scrolling.
if (!this.isUserScrolling) {
buffer.ydisp = buffer.ybase;
}
this._onScroll.fire(buffer.ydisp);
}
/**
* Scroll the display of the terminal
* @param disp The number of lines to scroll down (negative scroll up).
* @param suppressScrollEvent Don't emit the scroll event as scrollLines. This is used
* to avoid unwanted events being handled by the viewport when the event was triggered from the
* viewport originally.
*/
public scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void {
const buffer = this.buffer;
if (disp < 0) {
if (buffer.ydisp === 0) {
return;
}
this.isUserScrolling = true;
} else if (disp + buffer.ydisp >= buffer.ybase) {
this.isUserScrolling = false;
}
const oldYdisp = buffer.ydisp;
buffer.ydisp = Math.max(Math.min(buffer.ydisp + disp, buffer.ybase), 0);
// No change occurred, don't trigger scroll/refresh
if (oldYdisp === buffer.ydisp) {
return;
}
if (!suppressScrollEvent) {
this._onScroll.fire(buffer.ydisp);
}
}
}

View File

@@ -0,0 +1,34 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ICharsetService } from 'common/services/Services';
import { ICharset } from 'common/Types';
export class CharsetService implements ICharsetService {
public serviceBrand: any;
public charset: ICharset | undefined;
public glevel: number = 0;
private _charsets: (ICharset | undefined)[] = [];
public reset(): void {
this.charset = undefined;
this._charsets = [];
this.glevel = 0;
}
public setgLevel(g: number): void {
this.glevel = g;
this.charset = this._charsets[g];
}
public setgCharset(g: number, charset: ICharset | undefined): void {
this._charsets[g] = charset;
if (this.glevel === g) {
this.charset = charset;
}
}
}

View File

@@ -0,0 +1,318 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferService, ICoreService, ICoreMouseService } from 'common/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { ICoreMouseProtocol, ICoreMouseEvent, CoreMouseEncoding, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
/**
* Supported default protocols.
*/
const DEFAULT_PROTOCOLS: { [key: string]: ICoreMouseProtocol } = {
/**
* NONE
* Events: none
* Modifiers: none
*/
NONE: {
events: CoreMouseEventType.NONE,
restrict: () => false
},
/**
* X10
* Events: mousedown
* Modifiers: none
*/
X10: {
events: CoreMouseEventType.DOWN,
restrict: (e: ICoreMouseEvent) => {
// no wheel, no move, no up
if (e.button === CoreMouseButton.WHEEL || e.action !== CoreMouseAction.DOWN) {
return false;
}
// no modifiers
e.ctrl = false;
e.alt = false;
e.shift = false;
return true;
}
},
/**
* VT200
* Events: mousedown / mouseup / wheel
* Modifiers: all
*/
VT200: {
events: CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL,
restrict: (e: ICoreMouseEvent) => {
// no move
if (e.action === CoreMouseAction.MOVE) {
return false;
}
return true;
}
},
/**
* DRAG
* Events: mousedown / mouseup / wheel / mousedrag
* Modifiers: all
*/
DRAG: {
events: CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL | CoreMouseEventType.DRAG,
restrict: (e: ICoreMouseEvent) => {
// no move without button
if (e.action === CoreMouseAction.MOVE && e.button === CoreMouseButton.NONE) {
return false;
}
return true;
}
},
/**
* ANY
* Events: all mouse related events
* Modifiers: all
*/
ANY: {
events:
CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL
| CoreMouseEventType.DRAG | CoreMouseEventType.MOVE,
restrict: (e: ICoreMouseEvent) => true
}
};
const enum Modifiers {
SHIFT = 4,
ALT = 8,
CTRL = 16
}
// helper for default encoders to generate the event code.
function eventCode(e: ICoreMouseEvent, isSGR: boolean): number {
let code = (e.ctrl ? Modifiers.CTRL : 0) | (e.shift ? Modifiers.SHIFT : 0) | (e.alt ? Modifiers.ALT : 0);
if (e.button === CoreMouseButton.WHEEL) {
code |= 64;
code |= e.action;
} else {
code |= e.button & 3;
if (e.button & 4) {
code |= 64;
}
if (e.button & 8) {
code |= 128;
}
if (e.action === CoreMouseAction.MOVE) {
code |= CoreMouseAction.MOVE;
} else if (e.action === CoreMouseAction.UP && !isSGR) {
// special case - only SGR can report button on release
// all others have to go with NONE
code |= CoreMouseButton.NONE;
}
}
return code;
}
const S = String.fromCharCode;
/**
* Supported default encodings.
*/
const DEFAULT_ENCODINGS: { [key: string]: CoreMouseEncoding } = {
/**
* DEFAULT - CSI M Pb Px Py
* Single byte encoding for coords and event code.
* Can encode values up to 223 (1-based).
*/
DEFAULT: (e: ICoreMouseEvent) => {
const params = [eventCode(e, false) + 32, e.col + 32, e.row + 32];
// supress mouse report if we exceed addressible range
// Note this is handled differently by emulators
// - xterm: sends 0;0 coords instead
// - vte, konsole: no report
if (params[0] > 255 || params[1] > 255 || params[2] > 255) {
return '';
}
return `\x1b[M${S(params[0])}${S(params[1])}${S(params[2])}`;
},
/**
* SGR - CSI < Pb ; Px ; Py M|m
* No encoding limitation.
* Can report button on release and works with a well formed sequence.
*/
SGR: (e: ICoreMouseEvent) => {
const final = (e.action === CoreMouseAction.UP && e.button !== CoreMouseButton.WHEEL) ? 'm' : 'M';
return `\x1b[<${eventCode(e, true)};${e.col};${e.row}${final}`;
},
SGR_PIXELS: (e: ICoreMouseEvent) => {
const final = (e.action === CoreMouseAction.UP && e.button !== CoreMouseButton.WHEEL) ? 'm' : 'M';
return `\x1b[<${eventCode(e, true)};${e.x};${e.y}${final}`;
}
};
/**
* CoreMouseService
*
* Provides mouse tracking reports with different protocols and encodings.
* - protocols: NONE (default), X10, VT200, DRAG, ANY
* - encodings: DEFAULT, SGR (UTF8, URXVT removed in #2507)
*
* Custom protocols/encodings can be added by `addProtocol` / `addEncoding`.
* To activate a protocol/encoding, set `activeProtocol` / `activeEncoding`.
* Switching a protocol will send a notification event `onProtocolChange`
* with a list of needed events to track.
*
* The service handles the mouse tracking state and decides whether to send
* a tracking report to the backend based on protocol and encoding limitations.
* To send a mouse event call `triggerMouseEvent`.
*/
export class CoreMouseService extends Disposable implements ICoreMouseService {
private _protocols: { [name: string]: ICoreMouseProtocol } = {};
private _encodings: { [name: string]: CoreMouseEncoding } = {};
private _activeProtocol: string = '';
private _activeEncoding: string = '';
private _lastEvent: ICoreMouseEvent | null = null;
private readonly _onProtocolChange = this.register(new EventEmitter<CoreMouseEventType>());
public readonly onProtocolChange = this._onProtocolChange.event;
constructor(
@IBufferService private readonly _bufferService: IBufferService,
@ICoreService private readonly _coreService: ICoreService
) {
super();
// register default protocols and encodings
for (const name of Object.keys(DEFAULT_PROTOCOLS)) this.addProtocol(name, DEFAULT_PROTOCOLS[name]);
for (const name of Object.keys(DEFAULT_ENCODINGS)) this.addEncoding(name, DEFAULT_ENCODINGS[name]);
// call reset to set defaults
this.reset();
}
public addProtocol(name: string, protocol: ICoreMouseProtocol): void {
this._protocols[name] = protocol;
}
public addEncoding(name: string, encoding: CoreMouseEncoding): void {
this._encodings[name] = encoding;
}
public get activeProtocol(): string {
return this._activeProtocol;
}
public get areMouseEventsActive(): boolean {
return this._protocols[this._activeProtocol].events !== 0;
}
public set activeProtocol(name: string) {
if (!this._protocols[name]) {
throw new Error(`unknown protocol "${name}"`);
}
this._activeProtocol = name;
this._onProtocolChange.fire(this._protocols[name].events);
}
public get activeEncoding(): string {
return this._activeEncoding;
}
public set activeEncoding(name: string) {
if (!this._encodings[name]) {
throw new Error(`unknown encoding "${name}"`);
}
this._activeEncoding = name;
}
public reset(): void {
this.activeProtocol = 'NONE';
this.activeEncoding = 'DEFAULT';
this._lastEvent = null;
}
/**
* Triggers a mouse event to be sent.
*
* Returns true if the event passed all protocol restrictions and a report
* was sent, otherwise false. The return value may be used to decide whether
* the default event action in the bowser component should be omitted.
*
* Note: The method will change values of the given event object
* to fullfill protocol and encoding restrictions.
*/
public triggerMouseEvent(e: ICoreMouseEvent): boolean {
// range check for col/row
if (e.col < 0 || e.col >= this._bufferService.cols
|| e.row < 0 || e.row >= this._bufferService.rows) {
return false;
}
// filter nonsense combinations of button + action
if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) {
return false;
}
if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) {
return false;
}
if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) {
return false;
}
// report 1-based coords
e.col++;
e.row++;
// debounce move events at grid or pixel level
if (e.action === CoreMouseAction.MOVE
&& this._lastEvent
&& this._equalEvents(this._lastEvent, e, this._activeEncoding === 'SGR_PIXELS')
) {
return false;
}
// apply protocol restrictions
if (!this._protocols[this._activeProtocol].restrict(e)) {
return false;
}
// encode report and send
const report = this._encodings[this._activeEncoding](e);
if (report) {
// always send DEFAULT as binary data
if (this._activeEncoding === 'DEFAULT') {
this._coreService.triggerBinaryEvent(report);
} else {
this._coreService.triggerDataEvent(report, true);
}
}
this._lastEvent = e;
return true;
}
public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
return {
down: !!(events & CoreMouseEventType.DOWN),
up: !!(events & CoreMouseEventType.UP),
drag: !!(events & CoreMouseEventType.DRAG),
move: !!(events & CoreMouseEventType.MOVE),
wheel: !!(events & CoreMouseEventType.WHEEL)
};
}
private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean {
if (pixels) {
if (e1.x !== e2.x) return false;
if (e1.y !== e2.y) return false;
} else {
if (e1.col !== e2.col) return false;
if (e1.row !== e2.row) return false;
}
if (e1.button !== e2.button) return false;
if (e1.action !== e2.action) return false;
if (e1.ctrl !== e2.ctrl) return false;
if (e1.alt !== e2.alt) return false;
if (e1.shift !== e2.shift) return false;
return true;
}
}

View File

@@ -0,0 +1,87 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { clone } from 'common/Clone';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { IDecPrivateModes, IModes } from 'common/Types';
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
const DEFAULT_MODES: IModes = Object.freeze({
insertMode: false
});
const DEFAULT_DEC_PRIVATE_MODES: IDecPrivateModes = Object.freeze({
applicationCursorKeys: false,
applicationKeypad: false,
bracketedPasteMode: false,
origin: false,
reverseWraparound: false,
sendFocus: false,
wraparound: true // defaults: xterm - true, vt100 - false
});
export class CoreService extends Disposable implements ICoreService {
public serviceBrand: any;
public isCursorInitialized: boolean = false;
public isCursorHidden: boolean = false;
public modes: IModes;
public decPrivateModes: IDecPrivateModes;
private readonly _onData = this.register(new EventEmitter<string>());
public readonly onData = this._onData.event;
private readonly _onUserInput = this.register(new EventEmitter<void>());
public readonly onUserInput = this._onUserInput.event;
private readonly _onBinary = this.register(new EventEmitter<string>());
public readonly onBinary = this._onBinary.event;
private readonly _onRequestScrollToBottom = this.register(new EventEmitter<void>());
public readonly onRequestScrollToBottom = this._onRequestScrollToBottom.event;
constructor(
@IBufferService private readonly _bufferService: IBufferService,
@ILogService private readonly _logService: ILogService,
@IOptionsService private readonly _optionsService: IOptionsService
) {
super();
this.modes = clone(DEFAULT_MODES);
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
}
public reset(): void {
this.modes = clone(DEFAULT_MODES);
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
}
public triggerDataEvent(data: string, wasUserInput: boolean = false): void {
// Prevents all events to pty process if stdin is disabled
if (this._optionsService.rawOptions.disableStdin) {
return;
}
// Input is being sent to the terminal, the terminal should focus the prompt.
const buffer = this._bufferService.buffer;
if (wasUserInput && this._optionsService.rawOptions.scrollOnUserInput && buffer.ybase !== buffer.ydisp) {
this._onRequestScrollToBottom.fire();
}
// Fire onUserInput so listeners can react as well (eg. clear selection)
if (wasUserInput) {
this._onUserInput.fire();
}
// Fire onData API
this._logService.debug(`sending data "${data}"`, () => data.split('').map(e => e.charCodeAt(0)));
this._onData.fire(data);
}
public triggerBinaryEvent(data: string): void {
if (this._optionsService.rawOptions.disableStdin) {
return;
}
this._logService.debug(`sending binary "${data}"`, () => data.split('').map(e => e.charCodeAt(0)));
this._onBinary.fire(data);
}
}

View File

@@ -0,0 +1,140 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { css } from 'common/Color';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IDecorationService, IInternalDecoration } from 'common/services/Services';
import { SortedList } from 'common/SortedList';
import { IColor } from 'common/Types';
import { IDecoration, IDecorationOptions, IMarker } from 'xterm';
// Work variables to avoid garbage collection
let $xmin = 0;
let $xmax = 0;
export class DecorationService extends Disposable implements IDecorationService {
public serviceBrand: any;
/**
* A list of all decorations, sorted by the marker's line value. This relies on the fact that
* while marker line values do change, they should all change by the same amount so this should
* never become out of order.
*/
private readonly _decorations: SortedList<IInternalDecoration> = new SortedList(e => e?.marker.line);
private readonly _onDecorationRegistered = this.register(new EventEmitter<IInternalDecoration>());
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
private readonly _onDecorationRemoved = this.register(new EventEmitter<IInternalDecoration>());
public readonly onDecorationRemoved = this._onDecorationRemoved.event;
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
constructor() {
super();
this.register(toDisposable(() => this.reset()));
}
public registerDecoration(options: IDecorationOptions): IDecoration | undefined {
if (options.marker.isDisposed) {
return undefined;
}
const decoration = new Decoration(options);
if (decoration) {
const markerDispose = decoration.marker.onDispose(() => decoration.dispose());
decoration.onDispose(() => {
if (decoration) {
if (this._decorations.delete(decoration)) {
this._onDecorationRemoved.fire(decoration);
}
markerDispose.dispose();
}
});
this._decorations.insert(decoration);
this._onDecorationRegistered.fire(decoration);
}
return decoration;
}
public reset(): void {
for (const d of this._decorations.values()) {
d.dispose();
}
this._decorations.clear();
}
public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator<IInternalDecoration> {
let xmin = 0;
let xmax = 0;
for (const d of this._decorations.getKeyIterator(line)) {
xmin = d.options.x ?? 0;
xmax = xmin + (d.options.width ?? 1);
if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
yield d;
}
}
}
public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void {
this._decorations.forEachByKey(line, d => {
$xmin = d.options.x ?? 0;
$xmax = $xmin + (d.options.width ?? 1);
if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
callback(d);
}
});
}
}
class Decoration extends Disposable implements IInternalDecoration {
public readonly marker: IMarker;
public element: HTMLElement | undefined;
public get isDisposed(): boolean { return this._isDisposed; }
public readonly onRenderEmitter = this.register(new EventEmitter<HTMLElement>());
public readonly onRender = this.onRenderEmitter.event;
private readonly _onDispose = this.register(new EventEmitter<void>());
public readonly onDispose = this._onDispose.event;
private _cachedBg: IColor | undefined | null = null;
public get backgroundColorRGB(): IColor | undefined {
if (this._cachedBg === null) {
if (this.options.backgroundColor) {
this._cachedBg = css.toColor(this.options.backgroundColor);
} else {
this._cachedBg = undefined;
}
}
return this._cachedBg;
}
private _cachedFg: IColor | undefined | null = null;
public get foregroundColorRGB(): IColor | undefined {
if (this._cachedFg === null) {
if (this.options.foregroundColor) {
this._cachedFg = css.toColor(this.options.foregroundColor);
} else {
this._cachedFg = undefined;
}
}
return this._cachedFg;
}
constructor(
public readonly options: IDecorationOptions
) {
super();
this.marker = options.marker;
if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) {
this.options.overviewRulerOptions.position = 'full';
}
}
public override dispose(): void {
this._onDispose.fire();
super.dispose();
}
}

View File

@@ -0,0 +1,85 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*
* This was heavily inspired from microsoft/vscode's dependency injection system (MIT).
*/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IInstantiationService, IServiceIdentifier } from 'common/services/Services';
import { getServiceDependencies } from 'common/services/ServiceRegistry';
export class ServiceCollection {
private _entries = new Map<IServiceIdentifier<any>, any>();
constructor(...entries: [IServiceIdentifier<any>, any][]) {
for (const [id, service] of entries) {
this.set(id, service);
}
}
public set<T>(id: IServiceIdentifier<T>, instance: T): T {
const result = this._entries.get(id);
this._entries.set(id, instance);
return result;
}
public forEach(callback: (id: IServiceIdentifier<any>, instance: any) => any): void {
for (const [key, value] of this._entries.entries()) {
callback(key, value);
}
}
public has(id: IServiceIdentifier<any>): boolean {
return this._entries.has(id);
}
public get<T>(id: IServiceIdentifier<T>): T | undefined {
return this._entries.get(id);
}
}
export class InstantiationService implements IInstantiationService {
public serviceBrand: undefined;
private readonly _services: ServiceCollection = new ServiceCollection();
constructor() {
this._services.set(IInstantiationService, this);
}
public setService<T>(id: IServiceIdentifier<T>, instance: T): void {
this._services.set(id, instance);
}
public getService<T>(id: IServiceIdentifier<T>): T | undefined {
return this._services.get(id);
}
public createInstance<T>(ctor: any, ...args: any[]): T {
const serviceDependencies = getServiceDependencies(ctor).sort((a, b) => a.index - b.index);
const serviceArgs: any[] = [];
for (const dependency of serviceDependencies) {
const service = this._services.get(dependency.id);
if (!service) {
throw new Error(`[createInstance] ${ctor.name} depends on UNKNOWN service ${dependency.id}.`);
}
serviceArgs.push(service);
}
const firstServiceArgPos = serviceDependencies.length > 0 ? serviceDependencies[0].index : args.length;
// check for argument mismatches, adjust static args if needed
if (args.length !== firstServiceArgPos) {
throw new Error(`[createInstance] First service dependency of ${ctor.name} at position ${firstServiceArgPos + 1} conflicts with ${args.length} static arguments`);
}
// now create the instance
return new ctor(...[...args, ...serviceArgs]);
}
}

View File

@@ -0,0 +1,124 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { ILogService, IOptionsService, LogLevelEnum } from 'common/services/Services';
type LogType = (message?: any, ...optionalParams: any[]) => void;
interface IConsole {
log: LogType;
error: LogType;
info: LogType;
trace: LogType;
warn: LogType;
}
// console is available on both node.js and browser contexts but the common
// module doesn't depend on them so we need to explicitly declare it.
declare const console: IConsole;
const optionsKeyToLogLevel: { [key: string]: LogLevelEnum } = {
trace: LogLevelEnum.TRACE,
debug: LogLevelEnum.DEBUG,
info: LogLevelEnum.INFO,
warn: LogLevelEnum.WARN,
error: LogLevelEnum.ERROR,
off: LogLevelEnum.OFF
};
const LOG_PREFIX = 'xterm.js: ';
export class LogService extends Disposable implements ILogService {
public serviceBrand: any;
private _logLevel: LogLevelEnum = LogLevelEnum.OFF;
public get logLevel(): LogLevelEnum { return this._logLevel; }
constructor(
@IOptionsService private readonly _optionsService: IOptionsService
) {
super();
this._updateLogLevel();
this.register(this._optionsService.onSpecificOptionChange('logLevel', () => this._updateLogLevel()));
// For trace logging, assume the latest created log service is valid
traceLogger = this;
}
private _updateLogLevel(): void {
this._logLevel = optionsKeyToLogLevel[this._optionsService.rawOptions.logLevel];
}
private _evalLazyOptionalParams(optionalParams: any[]): void {
for (let i = 0; i < optionalParams.length; i++) {
if (typeof optionalParams[i] === 'function') {
optionalParams[i] = optionalParams[i]();
}
}
}
private _log(type: LogType, message: string, optionalParams: any[]): void {
this._evalLazyOptionalParams(optionalParams);
type.call(console, (this._optionsService.options.logger ? '' : LOG_PREFIX) + message, ...optionalParams);
}
public trace(message: string, ...optionalParams: any[]): void {
if (this._logLevel <= LogLevelEnum.TRACE) {
this._log(this._optionsService.options.logger?.trace.bind(this._optionsService.options.logger) ?? console.log, message, optionalParams);
}
}
public debug(message: string, ...optionalParams: any[]): void {
if (this._logLevel <= LogLevelEnum.DEBUG) {
this._log(this._optionsService.options.logger?.debug.bind(this._optionsService.options.logger) ?? console.log, message, optionalParams);
}
}
public info(message: string, ...optionalParams: any[]): void {
if (this._logLevel <= LogLevelEnum.INFO) {
this._log(this._optionsService.options.logger?.info.bind(this._optionsService.options.logger) ?? console.info, message, optionalParams);
}
}
public warn(message: string, ...optionalParams: any[]): void {
if (this._logLevel <= LogLevelEnum.WARN) {
this._log(this._optionsService.options.logger?.warn.bind(this._optionsService.options.logger) ?? console.warn, message, optionalParams);
}
}
public error(message: string, ...optionalParams: any[]): void {
if (this._logLevel <= LogLevelEnum.ERROR) {
this._log(this._optionsService.options.logger?.error.bind(this._optionsService.options.logger) ?? console.error, message, optionalParams);
}
}
}
let traceLogger: ILogService;
export function setTraceLogger(logger: ILogService): void {
traceLogger = logger;
}
/**
* A decorator that can be used to automatically log trace calls to the decorated function.
*/
export function traceCall(_target: any, key: string, descriptor: any): any {
if (typeof descriptor.value !== 'function') {
throw new Error('not supported');
}
const fnKey = 'value';
const fn = descriptor.value;
descriptor[fnKey] = function (...args: any[]) {
// Early exit
if (traceLogger.logLevel !== LogLevelEnum.TRACE) {
return fn.apply(this, args);
}
traceLogger.trace(`GlyphRenderer#${fn.name}(${args.map(e => JSON.stringify(e)).join(', ')})`);
const result = fn.apply(this, args);
traceLogger.trace(`GlyphRenderer#${fn.name} return`, result);
return result;
};
}

View File

@@ -0,0 +1,201 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { Disposable } from 'common/Lifecycle';
import { isMac } from 'common/Platform';
import { CursorStyle, IDisposable } from 'common/Types';
import { FontWeight, IOptionsService, ITerminalOptions } from 'common/services/Services';
export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
cols: 80,
rows: 24,
cursorBlink: false,
cursorStyle: 'block',
cursorWidth: 1,
cursorInactiveStyle: 'outline',
customGlyphs: true,
drawBoldTextInBrightColors: true,
fastScrollModifier: 'alt',
fastScrollSensitivity: 5,
fontFamily: 'courier-new, courier, monospace',
fontSize: 15,
fontWeight: 'normal',
fontWeightBold: 'bold',
ignoreBracketedPasteMode: false,
lineHeight: 1.0,
letterSpacing: 0,
linkHandler: null,
logLevel: 'info',
logger: null,
scrollback: 1000,
scrollOnUserInput: true,
scrollSensitivity: 1,
screenReaderMode: false,
smoothScrollDuration: 0,
macOptionIsMeta: false,
macOptionClickForcesSelection: false,
minimumContrastRatio: 1,
disableStdin: false,
allowProposedApi: false,
allowTransparency: false,
tabStopWidth: 8,
theme: {},
rightClickSelectsWord: isMac,
windowOptions: {},
windowsMode: false,
windowsPty: {},
wordSeparator: ' ()[]{}\',"`',
altClickMovesCursor: true,
convertEol: false,
termName: 'xterm',
cancelEvents: false,
overviewRulerWidth: 0
};
const FONT_WEIGHT_OPTIONS: Extract<FontWeight, string>[] = ['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900'];
export class OptionsService extends Disposable implements IOptionsService {
public serviceBrand: any;
public readonly rawOptions: Required<ITerminalOptions>;
public options: Required<ITerminalOptions>;
private readonly _onOptionChange = this.register(new EventEmitter<keyof ITerminalOptions>());
public readonly onOptionChange = this._onOptionChange.event;
constructor(options: Partial<ITerminalOptions>) {
super();
// set the default value of each option
const defaultOptions = { ...DEFAULT_OPTIONS };
for (const key in options) {
if (key in defaultOptions) {
try {
const newValue = options[key];
defaultOptions[key] = this._sanitizeAndValidateOption(key, newValue);
} catch (e) {
console.error(e);
}
}
}
// set up getters and setters for each option
this.rawOptions = defaultOptions;
this.options = { ... defaultOptions };
this._setupOptions();
}
// eslint-disable-next-line @typescript-eslint/naming-convention
public onSpecificOptionChange<T extends keyof ITerminalOptions>(key: T, listener: (value: ITerminalOptions[T]) => any): IDisposable {
return this.onOptionChange(eventKey => {
if (eventKey === key) {
listener(this.rawOptions[key]);
}
});
}
// eslint-disable-next-line @typescript-eslint/naming-convention
public onMultipleOptionChange(keys: (keyof ITerminalOptions)[], listener: () => any): IDisposable {
return this.onOptionChange(eventKey => {
if (keys.indexOf(eventKey) !== -1) {
listener();
}
});
}
private _setupOptions(): void {
const getter = (propName: string): any => {
if (!(propName in DEFAULT_OPTIONS)) {
throw new Error(`No option with key "${propName}"`);
}
return this.rawOptions[propName];
};
const setter = (propName: string, value: any): void => {
if (!(propName in DEFAULT_OPTIONS)) {
throw new Error(`No option with key "${propName}"`);
}
value = this._sanitizeAndValidateOption(propName, value);
// Don't fire an option change event if they didn't change
if (this.rawOptions[propName] !== value) {
this.rawOptions[propName] = value;
this._onOptionChange.fire(propName);
}
};
for (const propName in this.rawOptions) {
const desc = {
get: getter.bind(this, propName),
set: setter.bind(this, propName)
};
Object.defineProperty(this.options, propName, desc);
}
}
private _sanitizeAndValidateOption(key: string, value: any): any {
switch (key) {
case 'cursorStyle':
if (!value) {
value = DEFAULT_OPTIONS[key];
}
if (!isCursorStyle(value)) {
throw new Error(`"${value}" is not a valid value for ${key}`);
}
break;
case 'wordSeparator':
if (!value) {
value = DEFAULT_OPTIONS[key];
}
break;
case 'fontWeight':
case 'fontWeightBold':
if (typeof value === 'number' && 1 <= value && value <= 1000) {
// already valid numeric value
break;
}
value = FONT_WEIGHT_OPTIONS.includes(value) ? value : DEFAULT_OPTIONS[key];
break;
case 'cursorWidth':
value = Math.floor(value);
// Fall through for bounds check
case 'lineHeight':
case 'tabStopWidth':
if (value < 1) {
throw new Error(`${key} cannot be less than 1, value: ${value}`);
}
break;
case 'minimumContrastRatio':
value = Math.max(1, Math.min(21, Math.round(value * 10) / 10));
break;
case 'scrollback':
value = Math.min(value, 4294967295);
if (value < 0) {
throw new Error(`${key} cannot be less than 0, value: ${value}`);
}
break;
case 'fastScrollSensitivity':
case 'scrollSensitivity':
if (value <= 0) {
throw new Error(`${key} cannot be less than or equal to 0, value: ${value}`);
}
break;
case 'rows':
case 'cols':
if (!value && value !== 0) {
throw new Error(`${key} must be numeric, value: ${value}`);
}
break;
case 'windowsPty':
value = value ?? {};
break;
}
return value;
}
}
function isCursorStyle(value: unknown): value is CursorStyle {
return value === 'block' || value === 'underline' || value === 'bar';
}

View File

@@ -0,0 +1,115 @@
/**
* Copyright (c) 2022 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IBufferService, IOscLinkService } from 'common/services/Services';
import { IMarker, IOscLinkData } from 'common/Types';
export class OscLinkService implements IOscLinkService {
public serviceBrand: any;
private _nextId = 1;
/**
* A map of the link key to link entry. This is used to add additional lines to links with ids.
*/
private _entriesWithId: Map<string, IOscLinkEntryWithId> = new Map();
/**
* A map of the link id to the link entry. The "link id" (number) which is the numberic
* representation of a unique link should not be confused with "id" (string) which comes in with
* `id=` in the OSC link's properties.
*/
private _dataByLinkId: Map<number, IOscLinkEntryNoId | IOscLinkEntryWithId> = new Map();
constructor(
@IBufferService private readonly _bufferService: IBufferService
) {
}
public registerLink(data: IOscLinkData): number {
const buffer = this._bufferService.buffer;
// Links with no id will only ever be registered a single time
if (data.id === undefined) {
const marker = buffer.addMarker(buffer.ybase + buffer.y);
const entry: IOscLinkEntryNoId = {
data,
id: this._nextId++,
lines: [marker]
};
marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
this._dataByLinkId.set(entry.id, entry);
return entry.id;
}
// Add the line to the link if it already exists
const castData = data as Required<IOscLinkData>;
const key = this._getEntryIdKey(castData);
const match = this._entriesWithId.get(key);
if (match) {
this.addLineToLink(match.id, buffer.ybase + buffer.y);
return match.id;
}
// Create the link
const marker = buffer.addMarker(buffer.ybase + buffer.y);
const entry: IOscLinkEntryWithId = {
id: this._nextId++,
key: this._getEntryIdKey(castData),
data: castData,
lines: [marker]
};
marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
this._entriesWithId.set(entry.key, entry);
this._dataByLinkId.set(entry.id, entry);
return entry.id;
}
public addLineToLink(linkId: number, y: number): void {
const entry = this._dataByLinkId.get(linkId);
if (!entry) {
return;
}
if (entry.lines.every(e => e.line !== y)) {
const marker = this._bufferService.buffer.addMarker(y);
entry.lines.push(marker);
marker.onDispose(() => this._removeMarkerFromLink(entry, marker));
}
}
public getLinkData(linkId: number): IOscLinkData | undefined {
return this._dataByLinkId.get(linkId)?.data;
}
private _getEntryIdKey(linkData: Required<IOscLinkData>): string {
return `${linkData.id};;${linkData.uri}`;
}
private _removeMarkerFromLink(entry: IOscLinkEntryNoId | IOscLinkEntryWithId, marker: IMarker): void {
const index = entry.lines.indexOf(marker);
if (index === -1) {
return;
}
entry.lines.splice(index, 1);
if (entry.lines.length === 0) {
if (entry.data.id !== undefined) {
this._entriesWithId.delete((entry as IOscLinkEntryWithId).key);
}
this._dataByLinkId.delete(entry.id);
}
}
}
interface IOscLinkEntry<T extends IOscLinkData> {
data: T;
id: number;
lines: IMarker[];
}
interface IOscLinkEntryNoId extends IOscLinkEntry<IOscLinkData> {
}
interface IOscLinkEntryWithId extends IOscLinkEntry<Required<IOscLinkData>> {
key: string;
}

View File

@@ -0,0 +1,49 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*
* This was heavily inspired from microsoft/vscode's dependency injection system (MIT).
*/
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { IServiceIdentifier } from 'common/services/Services';
const DI_TARGET = 'di$target';
const DI_DEPENDENCIES = 'di$dependencies';
export const serviceRegistry: Map<string, IServiceIdentifier<any>> = new Map();
export function getServiceDependencies(ctor: any): { id: IServiceIdentifier<any>, index: number, optional: boolean }[] {
return ctor[DI_DEPENDENCIES] || [];
}
export function createDecorator<T>(id: string): IServiceIdentifier<T> {
if (serviceRegistry.has(id)) {
return serviceRegistry.get(id)!;
}
const decorator: any = function (target: Function, key: string, index: number): any {
if (arguments.length !== 3) {
throw new Error('@IServiceName-decorator can only be used to decorate a parameter');
}
storeServiceDependency(decorator, target, index);
};
decorator.toString = () => id;
serviceRegistry.set(id, decorator);
return decorator;
}
function storeServiceDependency(id: Function, target: Function, index: number): void {
if ((target as any)[DI_TARGET] === target) {
(target as any)[DI_DEPENDENCIES].push({ id, index });
} else {
(target as any)[DI_DEPENDENCIES] = [{ id, index }];
(target as any)[DI_TARGET] = target;
}
}

View File

@@ -0,0 +1,342 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IEvent, IEventEmitter } from 'common/EventEmitter';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEncoding, ICoreMouseProtocol, CoreMouseEventType, ICharset, IWindowOptions, IModes, IAttributeData, ScrollSource, IDisposable, IColor, CursorStyle, CursorInactiveStyle, IOscLinkData } from 'common/Types';
import { createDecorator } from 'common/services/ServiceRegistry';
import { IDecorationOptions, IDecoration, ILinkHandler, IWindowsPty, ILogger } from 'xterm';
export const IBufferService = createDecorator<IBufferService>('BufferService');
export interface IBufferService {
serviceBrand: undefined;
readonly cols: number;
readonly rows: number;
readonly buffer: IBuffer;
readonly buffers: IBufferSet;
isUserScrolling: boolean;
onResize: IEvent<{ cols: number, rows: number }>;
onScroll: IEvent<number>;
scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void;
scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void;
resize(cols: number, rows: number): void;
reset(): void;
}
export const ICoreMouseService = createDecorator<ICoreMouseService>('CoreMouseService');
export interface ICoreMouseService {
activeProtocol: string;
activeEncoding: string;
areMouseEventsActive: boolean;
addProtocol(name: string, protocol: ICoreMouseProtocol): void;
addEncoding(name: string, encoding: CoreMouseEncoding): void;
reset(): void;
/**
* Triggers a mouse event to be sent.
*
* Returns true if the event passed all protocol restrictions and a report
* was sent, otherwise false. The return value may be used to decide whether
* the default event action in the bowser component should be omitted.
*
* Note: The method will change values of the given event object
* to fullfill protocol and encoding restrictions.
*/
triggerMouseEvent(event: ICoreMouseEvent): boolean;
/**
* Event to announce changes in mouse tracking.
*/
onProtocolChange: IEvent<CoreMouseEventType>;
/**
* Human readable version of mouse events.
*/
explainEvents(events: CoreMouseEventType): { [event: string]: boolean };
}
export const ICoreService = createDecorator<ICoreService>('CoreService');
export interface ICoreService {
serviceBrand: undefined;
/**
* Initially the cursor will not be visible until the first time the terminal
* is focused.
*/
isCursorInitialized: boolean;
isCursorHidden: boolean;
readonly modes: IModes;
readonly decPrivateModes: IDecPrivateModes;
readonly onData: IEvent<string>;
readonly onUserInput: IEvent<void>;
readonly onBinary: IEvent<string>;
readonly onRequestScrollToBottom: IEvent<void>;
reset(): void;
/**
* Triggers the onData event in the public API.
* @param data The data that is being emitted.
* @param wasUserInput Whether the data originated from the user (as opposed to
* resulting from parsing incoming data). When true this will also:
* - Scroll to the bottom of the buffer if option scrollOnUserInput is true.
* - Fire the `onUserInput` event (so selection can be cleared).
*/
triggerDataEvent(data: string, wasUserInput?: boolean): void;
/**
* Triggers the onBinary event in the public API.
* @param data The data that is being emitted.
*/
triggerBinaryEvent(data: string): void;
}
export const ICharsetService = createDecorator<ICharsetService>('CharsetService');
export interface ICharsetService {
serviceBrand: undefined;
charset: ICharset | undefined;
readonly glevel: number;
reset(): void;
/**
* Set the G level of the terminal.
* @param g
*/
setgLevel(g: number): void;
/**
* Set the charset for the given G level of the terminal.
* @param g
* @param charset
*/
setgCharset(g: number, charset: ICharset | undefined): void;
}
export interface IServiceIdentifier<T> {
(...args: any[]): void;
type: T;
}
export interface IBrandedService {
serviceBrand: undefined;
}
type GetLeadingNonServiceArgs<TArgs extends any[]> = TArgs extends [] ? []
: TArgs extends [...infer TFirst, infer TLast] ? TLast extends IBrandedService ? GetLeadingNonServiceArgs<TFirst> : TArgs
: never;
export const IInstantiationService = createDecorator<IInstantiationService>('InstantiationService');
export interface IInstantiationService {
serviceBrand: undefined;
setService<T>(id: IServiceIdentifier<T>, instance: T): void;
getService<T>(id: IServiceIdentifier<T>): T | undefined;
createInstance<Ctor extends new (...args: any[]) => any, R extends InstanceType<Ctor>>(t: Ctor, ...args: GetLeadingNonServiceArgs<ConstructorParameters<Ctor>>): R;
}
export enum LogLevelEnum {
TRACE = 0,
DEBUG = 1,
INFO = 2,
WARN = 3,
ERROR = 4,
OFF = 5
}
export const ILogService = createDecorator<ILogService>('LogService');
export interface ILogService {
serviceBrand: undefined;
readonly logLevel: LogLevelEnum;
trace(message: any, ...optionalParams: any[]): void;
debug(message: any, ...optionalParams: any[]): void;
info(message: any, ...optionalParams: any[]): void;
warn(message: any, ...optionalParams: any[]): void;
error(message: any, ...optionalParams: any[]): void;
}
export const IOptionsService = createDecorator<IOptionsService>('OptionsService');
export interface IOptionsService {
serviceBrand: undefined;
/**
* Read only access to the raw options object, this is an internal-only fast path for accessing
* single options without any validation as we trust TypeScript to enforce correct usage
* internally.
*/
readonly rawOptions: Required<ITerminalOptions>;
/**
* Options as exposed through the public API, this property uses getters and setters with
* validation which makes it safer but slower. {@link rawOptions} should be used for pretty much
* all internal usage for performance reasons.
*/
readonly options: Required<ITerminalOptions>;
/**
* Adds an event listener for when any option changes.
*/
readonly onOptionChange: IEvent<keyof ITerminalOptions>;
/**
* Adds an event listener for when a specific option changes, this is a convenience method that is
* preferred over {@link onOptionChange} when only a single option is being listened to.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
onSpecificOptionChange<T extends keyof ITerminalOptions>(key: T, listener: (arg1: Required<ITerminalOptions>[T]) => any): IDisposable;
/**
* Adds an event listener for when a set of specific options change, this is a convenience method
* that is preferred over {@link onOptionChange} when multiple options are being listened to and
* handled the same way.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
onMultipleOptionChange(keys: (keyof ITerminalOptions)[], listener: () => any): IDisposable;
}
export type FontWeight = 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900' | number;
export type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'off';
export interface ITerminalOptions {
allowProposedApi?: boolean;
allowTransparency?: boolean;
altClickMovesCursor?: boolean;
cols?: number;
convertEol?: boolean;
cursorBlink?: boolean;
cursorStyle?: CursorStyle;
cursorWidth?: number;
cursorInactiveStyle?: CursorInactiveStyle;
customGlyphs?: boolean;
disableStdin?: boolean;
drawBoldTextInBrightColors?: boolean;
fastScrollModifier?: 'none' | 'alt' | 'ctrl' | 'shift';
fastScrollSensitivity?: number;
fontSize?: number;
fontFamily?: string;
fontWeight?: FontWeight;
fontWeightBold?: FontWeight;
ignoreBracketedPasteMode?: boolean;
letterSpacing?: number;
lineHeight?: number;
linkHandler?: ILinkHandler | null;
logLevel?: LogLevel;
logger?: ILogger | null;
macOptionIsMeta?: boolean;
macOptionClickForcesSelection?: boolean;
minimumContrastRatio?: number;
rightClickSelectsWord?: boolean;
rows?: number;
screenReaderMode?: boolean;
scrollback?: number;
scrollOnUserInput?: boolean;
scrollSensitivity?: number;
smoothScrollDuration?: number;
tabStopWidth?: number;
theme?: ITheme;
windowsMode?: boolean;
windowsPty?: IWindowsPty;
windowOptions?: IWindowOptions;
wordSeparator?: string;
overviewRulerWidth?: number;
[key: string]: any;
cancelEvents: boolean;
termName: string;
}
export interface ITheme {
foreground?: string;
background?: string;
cursor?: string;
cursorAccent?: string;
selectionForeground?: string;
selectionBackground?: string;
selectionInactiveBackground?: string;
black?: string;
red?: string;
green?: string;
yellow?: string;
blue?: string;
magenta?: string;
cyan?: string;
white?: string;
brightBlack?: string;
brightRed?: string;
brightGreen?: string;
brightYellow?: string;
brightBlue?: string;
brightMagenta?: string;
brightCyan?: string;
brightWhite?: string;
extendedAnsi?: string[];
}
export const IOscLinkService = createDecorator<IOscLinkService>('OscLinkService');
export interface IOscLinkService {
serviceBrand: undefined;
/**
* Registers a link to the service, returning the link ID. The link data is managed by this
* service and will be freed when this current cursor position is trimmed off the buffer.
*/
registerLink(linkData: IOscLinkData): number;
/**
* Adds a line to a link if needed.
*/
addLineToLink(linkId: number, y: number): void;
/** Get the link data associated with a link ID. */
getLinkData(linkId: number): IOscLinkData | undefined;
}
export const IUnicodeService = createDecorator<IUnicodeService>('UnicodeService');
export interface IUnicodeService {
serviceBrand: undefined;
/** Register an Unicode version provider. */
register(provider: IUnicodeVersionProvider): void;
/** Registered Unicode versions. */
readonly versions: string[];
/** Currently active version. */
activeVersion: string;
/** Event triggered, when activate version changed. */
readonly onChange: IEvent<string>;
/**
* Unicode version dependent
*/
wcwidth(codepoint: number): number;
getStringCellWidth(s: string): number;
}
export interface IUnicodeVersionProvider {
readonly version: string;
wcwidth(ucs: number): 0 | 1 | 2;
}
export const IDecorationService = createDecorator<IDecorationService>('DecorationService');
export interface IDecorationService extends IDisposable {
serviceBrand: undefined;
readonly decorations: IterableIterator<IInternalDecoration>;
readonly onDecorationRegistered: IEvent<IInternalDecoration>;
readonly onDecorationRemoved: IEvent<IInternalDecoration>;
registerDecoration(decorationOptions: IDecorationOptions): IDecoration | undefined;
reset(): void;
/**
* Trigger a callback over the decoration at a cell (in no particular order). This uses a callback
* instead of an iterator as it's typically used in hot code paths.
*/
forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void;
}
export interface IInternalDecoration extends IDecoration {
readonly options: IDecorationOptions;
readonly backgroundColorRGB: IColor | undefined;
readonly foregroundColorRGB: IColor | undefined;
readonly onRenderEmitter: IEventEmitter<HTMLElement>;
}

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { EventEmitter } from 'common/EventEmitter';
import { UnicodeV6 } from 'common/input/UnicodeV6';
import { IUnicodeService, IUnicodeVersionProvider } from 'common/services/Services';
export class UnicodeService implements IUnicodeService {
public serviceBrand: any;
private _providers: {[key: string]: IUnicodeVersionProvider} = Object.create(null);
private _active: string = '';
private _activeProvider: IUnicodeVersionProvider;
private readonly _onChange = new EventEmitter<string>();
public readonly onChange = this._onChange.event;
constructor() {
const defaultProvider = new UnicodeV6();
this.register(defaultProvider);
this._active = defaultProvider.version;
this._activeProvider = defaultProvider;
}
public dispose(): void {
this._onChange.dispose();
}
public get versions(): string[] {
return Object.keys(this._providers);
}
public get activeVersion(): string {
return this._active;
}
public set activeVersion(version: string) {
if (!this._providers[version]) {
throw new Error(`unknown Unicode version "${version}"`);
}
this._active = version;
this._activeProvider = this._providers[version];
this._onChange.fire(version);
}
public register(provider: IUnicodeVersionProvider): void {
this._providers[provider.version] = provider;
}
/**
* Unicode version dependent interface.
*/
public wcwidth(num: number): number {
return this._activeProvider.wcwidth(num);
}
public getStringCellWidth(s: string): number {
let result = 0;
const length = s.length;
for (let i = 0; i < length; ++i) {
let code = s.charCodeAt(i);
// surrogate pair first
if (0xD800 <= code && code <= 0xDBFF) {
if (++i >= length) {
// this should not happen with strings retrieved from
// Buffer.translateToString as it converts from UTF-32
// and therefore always should contain the second part
// for any other string we still have to handle it somehow:
// simply treat the lonely surrogate first as a single char (UCS-2 behavior)
return result + this.wcwidth(code);
}
const second = s.charCodeAt(i);
// convert surrogate pair to high codepoint only for valid second part (UTF-16)
// otherwise treat them independently (UCS-2 behavior)
if (0xDC00 <= second && second <= 0xDFFF) {
code = (code - 0xD800) * 0x400 + second - 0xDC00 + 0x10000;
} else {
result += this.wcwidth(second);
}
}
result += this.wcwidth(code);
}
return result;
}
}

Some files were not shown because too many files have changed in this diff Show More