Merge branch 'master' into clusters

This commit is contained in:
Daniel Imms
2023-08-02 11:41:22 -07:00
committed by GitHub
114 changed files with 7458 additions and 2412 deletions
+22 -2
View File
@@ -11,6 +11,7 @@ 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;
@@ -76,14 +77,13 @@ export class AccessibilityManager extends Disposable {
this._liveRegion.classList.add('live-region');
this._liveRegion.setAttribute('aria-live', 'assertive');
this._accessibilityContainer.appendChild(this._liveRegion);
this._liveRegionDebouncer = this.register(new TimeBasedDebouncer(this._announceCharacters.bind(this)));
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._liveRegionDebouncer);
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()));
@@ -167,6 +167,26 @@ export class AccessibilityManager extends Disposable {
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;
+5 -5
View File
@@ -4,7 +4,7 @@
*/
import { ISelectionService } from 'browser/services/Services';
import { ICoreService } from 'common/services/Services';
import { ICoreService, IOptionsService } from 'common/services/Services';
/**
* Prepares text to be pasted into the terminal by normalizing the line endings
@@ -40,17 +40,17 @@ export function copyHandler(ev: ClipboardEvent, selectionService: ISelectionServ
/**
* Redirect the clipboard's data to the terminal's input handler.
*/
export function handlePasteEvent(ev: ClipboardEvent, textarea: HTMLTextAreaElement, coreService: ICoreService): void {
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);
paste(text, textarea, coreService, optionsService);
}
}
export function paste(text: string, textarea: HTMLTextAreaElement, coreService: ICoreService): void {
export function paste(text: string, textarea: HTMLTextAreaElement, coreService: ICoreService, optionsService: IOptionsService): void {
text = prepareTextForTerminal(text);
text = bracketTextForPaste(text, coreService.decPrivateModes.bracketedPasteMode);
text = bracketTextForPaste(text, coreService.decPrivateModes.bracketedPasteMode && optionsService.rawOptions.ignoreBracketedPasteMode !== true);
coreService.triggerDataEvent(text, true);
textarea.value = '';
}
+2 -1
View File
@@ -8,7 +8,7 @@ import { MockViewport, MockCompositionHelper, MockRenderer, TestTerminal } from
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { MockUnicodeService } from 'common/TestUtils.test';
import { IMarker } from 'common/Types';
import { IMarker, ScrollSource } from 'common/Types';
import { ICoreService } from 'common/services/Services';
const INIT_COLS = 80;
@@ -29,6 +29,7 @@ describe('Terminal', () => {
term.refresh = () => { };
(term as any).renderer = new MockRenderer();
term.viewport = new MockViewport();
term.viewport.onRequestScrollLines(e => term.scrollLines(e.amount, e.suppressScrollEvent, ScrollSource.VIEWPORT));
(term as any)._compositionHelper = new MockCompositionHelper();
(term as any).element = {
classList: {
+17 -14
View File
@@ -35,7 +35,7 @@ import * as Strings from 'browser/LocalizableStrings';
import { AccessibilityManager } from './AccessibilityManager';
import { ITheme, IMarker, IDisposable, ILinkProvider, IDecorationOptions, IDecoration } from 'xterm';
import { DomRenderer } from 'browser/renderer/dom/DomRenderer';
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
import { KeyboardResultType, CoreMouseEventType, CoreMouseButton, CoreMouseAction, ITerminalOptions, ScrollSource, IColorEvent, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
import { evaluateKeyboardEvent } from 'common/input/Keyboard';
import { EventEmitter, IEvent, forwardEvent } from 'common/EventEmitter';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
@@ -203,15 +203,15 @@ export class Terminal extends CoreTerminal implements ITerminal {
let acc: 'foreground' | 'background' | 'cursor' | 'ansi';
let ident = '';
switch (req.index) {
case ColorIndex.FOREGROUND: // OSC 10 | 110
case SpecialColorIndex.FOREGROUND: // OSC 10 | 110
acc = 'foreground';
ident = '10';
break;
case ColorIndex.BACKGROUND: // OSC 11 | 111
case SpecialColorIndex.BACKGROUND: // OSC 11 | 111
acc = 'background';
ident = '11';
break;
case ColorIndex.CURSOR: // OSC 12 | 112
case SpecialColorIndex.CURSOR: // OSC 12 | 112
acc = 'cursor';
ident = '12';
break;
@@ -352,7 +352,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
}
copyHandler(event, this._selectionService!);
}));
const pasteHandlerWrapper = (event: ClipboardEvent): void => handlePasteEvent(event, this.textarea!, this.coreService);
const pasteHandlerWrapper = (event: ClipboardEvent): void => handlePasteEvent(event, this.textarea!, this.coreService, this.optionsService);
this.register(addDisposableDomListener(this.textarea!, 'paste', pasteHandlerWrapper));
this.register(addDisposableDomListener(this.element!, 'paste', pasteHandlerWrapper));
@@ -497,11 +497,8 @@ export class Terminal extends CoreTerminal implements ITerminal {
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
this.viewport = this._instantiationService.createInstance(Viewport,
(amount: number) => this.scrollLines(amount, true, ScrollSource.VIEWPORT),
this._viewportElement,
this._viewportScrollArea
);
this.viewport = this._instantiationService.createInstance(Viewport, this._viewportElement, this._viewportScrollArea);
this.viewport.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent, ScrollSource.VIEWPORT)),
this.register(this._inputHandler.onRequestSyncScrollBar(() => this.viewport!.syncScrollArea()));
this.register(this.viewport);
@@ -747,8 +744,10 @@ export class Terminal extends CoreTerminal implements ITerminal {
if (!(events & CoreMouseEventType.UP)) {
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
el.removeEventListener('mouseup', requestedEvents.mouseup!);
requestedEvents.mouseup = null;
} else if (!requestedEvents.mouseup) {
el.addEventListener('mouseup', eventListeners.mouseup);
requestedEvents.mouseup = eventListeners.mouseup;
}
@@ -870,12 +869,16 @@ export class Terminal extends CoreTerminal implements ITerminal {
}
public scrollLines(disp: number, suppressScrollEvent?: boolean, source = ScrollSource.TERMINAL): void {
super.scrollLines(disp, suppressScrollEvent, source);
this.refresh(0, this.rows - 1);
if (source === ScrollSource.VIEWPORT) {
super.scrollLines(disp, suppressScrollEvent, source);
this.refresh(0, this.rows - 1);
} else {
this.viewport?.scrollLines(disp);
}
}
public paste(data: string): void {
paste(data, this.textarea!, this.coreService);
paste(data, this.textarea!, this.coreService, this.optionsService);
}
/**
@@ -1003,7 +1006,7 @@ export class Terminal extends CoreTerminal implements ITerminal {
if (!shouldIgnoreComposition && !this._compositionHelper!.keydown(event)) {
if (this.options.scrollOnUserInput && this.buffer.ybase !== this.buffer.ydisp) {
this._bufferService.scrollToBottom();
this.scrollToBottom();
}
return false;
}
+5
View File
@@ -296,6 +296,8 @@ export class MockRenderer implements IRenderer {
}
export class MockViewport implements IViewport {
private readonly _onRequestScrollLines = new EventEmitter<{ amount: number, suppressScrollEvent: boolean }>();
public readonly onRequestScrollLines = this._onRequestScrollLines.event;
public dispose(): void {
throw new Error('Method not implemented.');
}
@@ -319,6 +321,9 @@ export class MockViewport implements IViewport {
public getBufferElements(startLine: number, endLine?: number | undefined): { bufferElements: HTMLElement[], cursorElement?: HTMLElement | undefined } {
throw new Error('Method not implemented.');
}
public scrollLines(disp: number): void {
this._onRequestScrollLines.fire({ amount: disp, suppressScrollEvent: false });
}
}
export class MockCompositionHelper implements ICompositionHelper {
+20
View File
@@ -69,10 +69,28 @@ export interface IPublicTerminal extends IDisposable {
selectAll(): void;
selectLines(start: number, end: number): void;
dispose(): void;
/**
* Scroll the display of the terminal
* @param amount The number of lines to scroll down (negative scroll up).
*/
scrollLines(amount: number): void;
/**
* Scroll the display of the terminal by a number of pages.
* @param pageCount The number of pages to scroll (negative scrolls up).
*/
scrollPages(pageCount: number): void;
/**
* Scrolls the display of the terminal to the top.
*/
scrollToTop(): void;
/**
* Scrolls the display of the terminal to the bottom.
*/
scrollToBottom(): void;
/**
* Scrolls to a line within the buffer.
* @param line The 0-based line index to scroll to.
*/
scrollToLine(line: number): void;
clear(): void;
write(data: string | Uint8Array, callback?: () => void): void;
@@ -142,12 +160,14 @@ export interface IPartialColorSet {
export interface IViewport extends IDisposable {
scrollBarWidth: number;
readonly onRequestScrollLines: IEvent<{ amount: number, suppressScrollEvent: boolean }>;
syncScrollArea(immediate?: 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?
}
export interface ILinkifierEvent {
+23 -3
View File
@@ -10,6 +10,7 @@ import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService }
import { IBufferService, IOptionsService } from 'common/services/Services';
import { IBuffer } from 'common/buffer/Types';
import { IRenderDimensions } from 'browser/renderer/shared/Types';
import { EventEmitter } from 'common/EventEmitter';
const FALLBACK_SCROLL_BAR_WIDTH = 15;
@@ -48,8 +49,10 @@ export class Viewport extends Disposable implements IViewport {
target: -1
};
private readonly _onRequestScrollLines = this.register(new EventEmitter<{ amount: number, suppressScrollEvent: boolean }>());
public readonly onRequestScrollLines = this._onRequestScrollLines.event;
constructor(
private readonly _scrollLines: (amount: number) => void,
private readonly _viewportElement: HTMLElement,
private readonly _scrollArea: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@@ -175,13 +178,13 @@ export class Viewport extends Disposable implements IViewport {
if (this._ignoreNextScrollEvent) {
this._ignoreNextScrollEvent = false;
// Still trigger the scroll so lines get refreshed
this._scrollLines(0);
this._onRequestScrollLines.fire({ amount: 0, suppressScrollEvent: true });
return;
}
const newRow = Math.round(this._lastScrollTop / this._currentRowHeight);
const diff = newRow - this._bufferService.buffer.ydisp;
this._scrollLines(diff);
this._onRequestScrollLines.fire({ amount: diff, suppressScrollEvent: true });
}
private _smoothScroll(): void {
@@ -263,6 +266,23 @@ export class Viewport extends Disposable implements IViewport {
return this._bubbleScroll(ev, amount);
}
public scrollLines(disp: number): void {
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) {
+126 -71
View File
@@ -3,7 +3,8 @@
* @license MIT
*/
import { BOLD_CLASS, CURSOR_BLINK_CLASS, CURSOR_CLASS, CURSOR_STYLE_BAR_CLASS, CURSOR_STYLE_BLOCK_CLASS, CURSOR_STYLE_UNDERLINE_CLASS, DIM_CLASS, DomRendererRowFactory, ITALIC_CLASS } from 'browser/renderer/dom/DomRendererRowFactory';
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';
@@ -13,6 +14,8 @@ import { color } from 'common/Color';
import { EventEmitter } from 'common/EventEmitter';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IInstantiationService, IOptionsService } from 'common/services/Services';
import { createStyle, IStyleSheet } from './StyleSheet';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
const ROW_CONTAINER_CLASS = 'xterm-rows';
@@ -23,6 +26,7 @@ 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
@@ -32,12 +36,12 @@ export class DomRenderer extends Disposable implements IRenderer {
private _rowFactory: DomRendererRowFactory;
private _terminalClass: number = nextTerminalId++;
private _themeStyleElement!: HTMLStyleElement;
private _dimensionsStyleElement!: HTMLStyleElement;
private _themeStyle!: IStyleSheet;
private _dimensionsStyle!: IStyleSheet;
private _rowContainer: HTMLElement;
private _rowElements: HTMLElement[] = [];
private _selectionContainer: HTMLElement;
private _cellToRowElements: Int16Array[] = [];
private _widthCache: WidthCache;
public dimensions: IRenderDimensions;
@@ -88,9 +92,19 @@ export class DomRenderer extends Disposable implements IRenderer {
// https://github.com/xtermjs/xterm.js/issues/2960
this._rowContainer.remove();
this._selectionContainer.remove();
this._themeStyleElement.remove();
this._dimensionsStyleElement.remove();
this._themeStyle.dispose();
this._dimensionsStyle.dispose();
this._widthCache.dispose();
}));
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 {
@@ -116,20 +130,18 @@ export class DomRenderer extends Disposable implements IRenderer {
element.style.overflow = 'hidden';
}
if (!this._dimensionsStyleElement) {
this._dimensionsStyleElement = document.createElement('style');
this._screenElement.appendChild(this._dimensionsStyleElement);
if (!this._dimensionsStyle) {
this._dimensionsStyle = createStyle(this._screenElement);
}
const styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
` display: inline-block;` +
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
` height: 100%;` +
` vertical-align: top;` +
` width: ${this.dimensions.css.cell.width}px` +
`}`;
this._dimensionsStyleElement.textContent = styles;
this._dimensionsStyle.setCss(styles);
this._selectionContainer.style.height = this._viewportElement.style.height;
this._screenElement.style.width = `${this.dimensions.css.canvas.width}px`;
@@ -137,9 +149,8 @@ export class DomRenderer extends Disposable implements IRenderer {
}
private _injectCss(colors: ReadonlyColorSet): void {
if (!this._themeStyleElement) {
this._themeStyleElement = document.createElement('style');
this._screenElement.appendChild(this._themeStyleElement);
if (!this._themeStyle) {
this._themeStyle = createStyle(this._screenElement);
}
// Base CSS
@@ -148,6 +159,8 @@ export class DomRenderer extends Disposable implements IRenderer {
` 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 {` +
@@ -155,20 +168,20 @@ export class DomRenderer extends Disposable implements IRenderer {
`}`;
// Text styles
styles +=
`${this._terminalSelector} span:not(.${BOLD_CLASS}) {` +
`${this._terminalSelector} span:not(.${RowCss.BOLD_CLASS}) {` +
` font-weight: ${this._optionsService.rawOptions.fontWeight};` +
`}` +
`${this._terminalSelector} span.${BOLD_CLASS} {` +
`${this._terminalSelector} span.${RowCss.BOLD_CLASS} {` +
` font-weight: ${this._optionsService.rawOptions.fontWeightBold};` +
`}` +
`${this._terminalSelector} span.${ITALIC_CLASS} {` +
`${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` +
` font-style: italic;` +
`}`;
// Blink animation
styles +=
`@keyframes blink_box_shadow` + `_` + this._terminalClass + ` {` +
` 50% {` +
` box-shadow: none;` +
` border-bottom-style: hidden;` +
` }` +
`}`;
styles +=
@@ -178,34 +191,36 @@ export class DomRenderer extends Disposable implements IRenderer {
` color: ${colors.cursorAccent.css};` +
` }` +
` 50% {` +
` background-color: ${colors.cursorAccent.css};` +
` background-color: inherit;` +
` color: ${colors.cursor.css};` +
` }` +
`}`;
// Cursor
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} ` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} ,` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}:not(.${FOCUS_CLASS}) .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} ` +
`{` +
` outline: 1px solid ${colors.cursor.css};` +
` outline-offset: -1px;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}:not(.${CURSOR_STYLE_BLOCK_CLASS}) {` +
`${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} .${CURSOR_CLASS}.${CURSOR_BLINK_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` +
`${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}.${FOCUS_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${CURSOR_CLASS}.${CURSOR_STYLE_BAR_CLASS} {` +
`${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} .${CURSOR_CLASS}.${CURSOR_STYLE_UNDERLINE_CLASS} {` +
` box-shadow: 0 -1px 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 +=
@@ -228,19 +243,36 @@ export class DomRenderer extends Disposable implements IRenderer {
for (const [i, c] of colors.ansi.entries()) {
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).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}.${DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).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;
this._themeStyle.setCss(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 {
@@ -263,6 +295,8 @@ export class DomRenderer extends Disposable implements IRenderer {
public handleCharSizeChanged(): void {
this._updateDimensions();
this._widthCache.clear();
this._setDefaultSpacing();
}
public handleBlur(): void {
@@ -275,10 +309,7 @@ export class DomRenderer extends Disposable implements IRenderer {
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
// Remove all selections
while (this._selectionContainer.children.length) {
this._selectionContainer.removeChild(this._selectionContainer.children[0]);
}
this._selectionContainer.replaceChildren();
this._rowFactory.handleSelectionChanged(start, end, columnSelectMode);
this.renderRows(0, this._bufferService.rows - 1);
@@ -348,6 +379,14 @@ export class DomRenderer extends Disposable implements IRenderer {
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 {
@@ -364,19 +403,33 @@ export class DomRenderer extends Disposable implements IRenderer {
}
public renderRows(start: number, end: number): void {
const cursorAbsoluteY = this._bufferService.buffer.ybase + this._bufferService.buffer.y;
const cursorX = Math.min(this._bufferService.buffer.x, this._bufferService.cols - 1);
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;
for (let y = start; y <= end; y++) {
const row = y + buffer.ydisp;
const rowElement = this._rowElements[y];
const row = y + this._bufferService.buffer.ydisp;
const lineData = this._bufferService.buffer.lines.get(row);
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
if (!this._cellToRowElements[y] || this._cellToRowElements[y].length !== this._bufferService.cols) {
this._cellToRowElements[y] = new Int16Array(this._bufferService.cols);
const lineData = buffer.lines.get(row);
if (!rowElement || !lineData) {
break;
}
rowElement.replaceChildren(this._rowFactory.createRow(lineData!, row, row === cursorAbsoluteY, cursorStyle, cursorX, cursorBlink, this.dimensions.css.cell.width, this._bufferService.cols, this._cellToRowElements[y]));
rowElement.replaceChildren(
...this._rowFactory.createRow(
lineData,
row,
row === cursorAbsoluteY,
cursorStyle,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
-1,
-1
)
);
}
}
@@ -408,40 +461,42 @@ export class DomRenderer extends Disposable implements IRenderer {
* - (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;
// avoid out-of-sync y-values, simply clamp into valid area
const maxY = this._cellToRowElements.length - 1;
const maxY = this._bufferService.rows - 1;
y = Math.max(Math.min(y, maxY), 0);
y2 = Math.max(Math.min(y2, maxY), 0);
const elemY = this._cellToRowElements[y];
const elemY2 = this._cellToRowElements[y2];
if (x >= elemY.length || x2 >= elemY2.length) {
// avoid out-of-sync x-values
// simply exit early, gets fixed by the next render update
return;
}
x = elemY[x];
x2 = elemY2[x2];
if (x === -1 || x2 === -1) {
return;
}
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;
while (x !== x2 || y !== y2) {
const row = this._rowElements[y];
if (!row) {
return;
}
const span = row.children[x] as HTMLElement;
if (span) {
span.style.textDecoration = enabled ? 'underline' : 'none';
}
if (++x >= cols) {
x = 0;
y++;
// 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,
cursorX,
cursorBlink,
this.dimensions.css.cell.width,
this._widthCache,
enabled ? (i === y ? x : 0) : -1,
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
)
);
}
}
}
@@ -11,10 +11,12 @@ import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test';
import { css } from 'common/Color';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
const EMPTY_WIDTH = new TestWidthCache(new jsdom.JSDOM('').window.document);
const EMPTY_ELEM_MAPPING = new Int16Array(1000);
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -37,54 +39,46 @@ describe('DomRendererRowFactory', () => {
describe('createRow', () => {
it('should not create anything for an empty row', () => {
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
''
);
});
it('should set correct attributes for double width characters', () => {
EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10];
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]));
// There should be no element for the following "empty" cell
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0]));
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
'<span style="width: 10px;">語</span>'
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>語</span>'
);
});
it('should add class for cursor and cursor style', () => {
for (const style of ['block', 'bar', 'underline']) {
const fragment = rowFactory.createRow(lineData, 0, true, style, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, true, style, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-cursor xterm-cursor-${style}"> </span>`
);
}
});
it('should add class for cursor blink', () => {
const fragment = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, true, 'block', 0, true, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-cursor xterm-cursor-blink xterm-cursor-block"> </span>`
);
});
it('should not render cells that go beyond the terminal\'s columns', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 1, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
'<span>a</span>'
);
});
describe('attributes', () => {
it('should add class for bold', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bold">a</span>'
);
});
@@ -93,8 +87,8 @@ describe('DomRendererRowFactory', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-italic">a</span>'
);
});
@@ -103,8 +97,8 @@ describe('DomRendererRowFactory', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-dim">a</span>'
);
});
@@ -116,8 +110,8 @@ describe('DomRendererRowFactory', () => {
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-1">a</span>'
);
});
@@ -127,8 +121,8 @@ describe('DomRendererRowFactory', () => {
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOUBLE;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-2">a</span>'
);
});
@@ -138,8 +132,8 @@ describe('DomRendererRowFactory', () => {
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.CURLY;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-3">a</span>'
);
});
@@ -149,8 +143,8 @@ describe('DomRendererRowFactory', () => {
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-4">a</span>'
);
});
@@ -160,8 +154,8 @@ describe('DomRendererRowFactory', () => {
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DASHED;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-underline-5">a</span>'
);
});
@@ -171,8 +165,8 @@ describe('DomRendererRowFactory', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-overline">a</span>'
);
});
@@ -181,8 +175,8 @@ describe('DomRendererRowFactory', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-strikethrough">a</span>'
);
});
@@ -194,8 +188,8 @@ describe('DomRendererRowFactory', () => {
cell.fg &= ~Attributes.PCOLOR_MASK;
cell.fg |= i;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-fg-${i}">a</span>`
);
}
@@ -208,8 +202,8 @@ describe('DomRendererRowFactory', () => {
cell.bg &= ~Attributes.PCOLOR_MASK;
cell.bg |= i;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-bg-${i}">a</span>`
);
}
@@ -220,8 +214,8 @@ describe('DomRendererRowFactory', () => {
cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-2 xterm-fg-1">a</span>'
);
});
@@ -231,8 +225,8 @@ describe('DomRendererRowFactory', () => {
cell.fg |= FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-257 xterm-fg-1">a</span>'
);
});
@@ -241,8 +235,8 @@ describe('DomRendererRowFactory', () => {
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1 xterm-fg-257">a</span>'
);
});
@@ -254,8 +248,8 @@ describe('DomRendererRowFactory', () => {
cell.fg &= ~Attributes.PCOLOR_MASK;
cell.fg |= i;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
`<span class="xterm-bold xterm-fg-${i + 8}">a</span>`
);
}
@@ -266,8 +260,8 @@ describe('DomRendererRowFactory', () => {
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span style="background-color:#040506;color:#010203;">a</span>'
);
});
@@ -277,8 +271,8 @@ describe('DomRendererRowFactory', () => {
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span style="background-color:#010203;color:#040506;">a</span>'
);
});
@@ -289,25 +283,179 @@ describe('DomRendererRowFactory', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
rowFactory.handleSelectionChanged([1, 0], [2, 0], false);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span class="xterm-decoration-top">b</span>'
);
});
it('should force whitespace cells to be rendered above the background', () => {
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
rowFactory.handleSelectionChanged([0, 0], [2, 0], false);
const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20, EMPTY_ELEM_MAPPING);
assert.equal(getFragmentHtml(fragment),
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-decoration-top"> </span><span class="xterm-decoration-top">a</span>'
);
});
});
});
function getFragmentHtml(fragment: DocumentFragment): string {
describe('createRow with merged spans', () => {
// for test purpose assume all in codepoints 0..255 are merging
// const ALL_MERGING = new Uint8Array(FontMetrics.MAX);
beforeEach(() => {
lineData = createEmptyLineData(10);
});
it('should not create anything for an empty row', () => {
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
''
);
});
it('can merge codepoints for equal spacing', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>abc</span>'
);
});
it('should not merge codepoints with different spacing', () => {
EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2];
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c</span>'
);
});
it('should not merge on FG change', () => {
const aColor1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]);
aColor1.fg |= Attributes.CM_P16 | 1;
const bColor2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]);
bColor2.fg |= Attributes.CM_P16 | 2;
lineData.setCell(0, aColor1);
lineData.setCell(1, aColor1);
lineData.setCell(2, bColor2);
lineData.setCell(3, bColor2);
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-fg-1">aa</span><span class="xterm-fg-2">bb</span>'
);
});
it('should not merge cursor cell', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)]));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, true, undefined, 2, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>aa</span><span class="xterm-cursor xterm-cursor-block">X</span><span>bb</span>'
);
});
it('should handle BCE correctly', () => {
const nullCell = lineData.loadCell(0, new CellData());
nullCell.bg = Attributes.CM_P16 | 1;
lineData.setCell(2, nullCell);
nullCell.bg = Attributes.CM_P16 | 2;
lineData.setCell(3, nullCell);
lineData.setCell(4, nullCell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span> </span><span class="xterm-bg-1"> </span><span class="xterm-bg-2"> </span>'
);
});
it('should handle BCE for multiple cells', () => {
const nullCell = lineData.loadCell(0, new CellData());
nullCell.bg = Attributes.CM_P16 | 1;
lineData.setCell(0, nullCell);
let spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(1, nullCell);
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(2, nullCell);
lineData.setCell(3, nullCell);
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span>'
);
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span class="xterm-bg-1"> </span><span>a</span>'
);
});
it('should apply correct positive or negative spacing', () => {
EMPTY_WIDTH.widths['€'] = [2, 2, 2, 2]; // too small, should add 3px
EMPTY_WIDTH.widths['語'] = [10, 10, 10, 10]; // exact match for its width, should merge
EMPTY_WIDTH.widths['𝄞'] = [7, 7, 7, 7]; // too wide, should subtract -2px
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -1, -1);
assert.equal(extractHtml(spans),
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c語</span><span style="letter-spacing: -2px;">𝄞</span>'
);
});
it('should not merge across link borders', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4);
assert.equal(extractHtml(spans),
'<span>aa</span><span style="text-decoration: underline;">xxx</span><span>bb</span>'
);
});
it('empty cells included in link underline', () => {
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, 2, 4);
assert.equal(extractHtml(spans),
'<span>aa</span><span style="text-decoration: underline;">x x</span>'
);
});
it('link range gets capped to actual line borders', () => {
for (let i = 0; i < 10; ++i) {
lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
}
const spans = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, EMPTY_WIDTH, -100, 100);
assert.equal(extractHtml(spans),
'<span style="text-decoration: underline;">aaaaaaaaaa</span>'
);
});
});
function extractHtml(spans: HTMLSpanElement[]): string {
const element = dom.window.document.createElement('div');
element.appendChild(fragment);
element.replaceChildren(...spans);
return element.innerHTML;
}
+163 -84
View File
@@ -1,11 +1,11 @@
/**
* Copyright (c) 2018 The xterm.js authors. All rights reserved.
* 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 { NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Attributes } from 'common/buffer/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';
@@ -13,18 +13,23 @@ import { ICharacterJoinerService, ICoreBrowserService, IThemeService } from 'bro
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';
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_BAR_CLASS = 'xterm-cursor-bar',
CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline'
}
export const BOLD_CLASS = 'xterm-bold';
export const DIM_CLASS = 'xterm-dim';
export const ITALIC_CLASS = 'xterm-italic';
export const UNDERLINE_CLASS = 'xterm-underline';
export const OVERLINE_CLASS = 'xterm-overline';
export const STRIKETHROUGH_CLASS = 'xterm-strikethrough';
export const CURSOR_CLASS = 'xterm-cursor';
export const CURSOR_BLINK_CLASS = 'xterm-cursor-blink';
export const CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block';
export const CURSOR_STYLE_BAR_CLASS = 'xterm-cursor-bar';
export const CURSOR_STYLE_UNDERLINE_CLASS = 'xterm-cursor-underline';
export class DomRendererRowFactory {
private _workCell: CellData = new CellData();
@@ -33,6 +38,8 @@ export class DomRendererRowFactory {
private _selectionEnd: [number, number] | undefined;
private _columnSelectMode: boolean = false;
public defaultSpacing = 0;
constructor(
private readonly _document: Document,
@ICharacterJoinerService private readonly _characterJoinerService: ICharacterJoinerService,
@@ -49,39 +56,47 @@ export class DomRendererRowFactory {
this._columnSelectMode = columnSelectMode;
}
public createRow(lineData: IBufferLine, row: number, isCursorRow: boolean, cursorStyle: string | undefined, cursorX: number, cursorBlink: boolean, cellWidth: number, cols: number, cellMap: Int16Array): DocumentFragment {
// NOTE: `cellMap` maps cell positions to a span element index in a row.
// All positions should be updated, even skipped ones after wide chars or left overs at the end,
// otherwise the mouse hover logic might mark the wrong elements as underlined.
const fragment = this._document.createDocumentFragment();
public createRow(
lineData: IBufferLine,
row: number,
isCursorRow: boolean,
cursorStyle: string | undefined,
cursorX: number,
cursorBlink: boolean,
cellWidth: number,
widthCache: WidthCache,
linkStart: number,
linkEnd: number
): HTMLSpanElement[] {
const elements: HTMLSpanElement[] = [];
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
// Find the line length first, this prevents the need to output a bunch of
// empty cells at the end. This cannot easily be integrated into the main
// loop below because of the colCount feature (which can be removed after we
// properly support reflow and disallow data to go beyond the right-side of
// the viewport).
let lineLength = 0;
for (let x = Math.min(lineData.length, cols) - 1; x >= 0; x--) {
if (lineData.loadCell(x, this._workCell).getCode() !== NULL_CELL_CODE || (isCursorRow && x === cursorX)) {
lineLength = x + 1;
break;
}
const colors = this._themeService.colors;
let lineLength = lineData.getNoBgTrimmedLength();
if (isCursorRow && lineLength < cursorX + 1) {
lineLength = cursorX + 1;
}
const colors = this._themeService.colors;
let elemIndex = -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 spacing = 0;
const classes: string[] = [];
let x = 0;
for (; x < lineLength; x++) {
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
// still have to update cellMap with current element index
if (width === 0) {
cellMap[x] = elemIndex;
continue;
}
@@ -112,16 +127,66 @@ export class DomRendererRowFactory {
width = cell.getWidth();
}
const charElement = this._document.createElement('span');
if (width > 1) {
charElement.style.width = `${cellWidth * width}px`;
const isInSelection = this._isCellInSelection(x, row);
const isCursorCell = isCursorRow && x === cursorX;
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
// get chars to render for this cell
let chars = cell.getChars() || WHITESPACE_CELL_CHAR;
if (chars === ' ' && (cell.isUnderline() || cell.isOverline())) {
chars = '\xa0';
}
if (isJoined) {
// Ligatures in the DOM renderer must use display inline, as they may not show with
// inline-block if they are outside the bounds of the element
charElement.style.display = 'inline';
// 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)
* - fg/bg/ul did not change
* - char not part of a selection
* - underline from hover state did not change
* - cell content renders to same letter-spacing
* - cell is not cursor
*/
if (
cellAmount
&& cell.bg === oldBg && cell.fg === oldFg && cell.extended.ext === oldExt
&& !isInSelection
&& isLinkHover === oldLinkHover
&& spacing === oldSpacing
&& !isCursorCell
&& !isJoined
) {
// 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;
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
@@ -130,48 +195,42 @@ export class DomRendererRowFactory {
}
}
if (!this._coreService.isCursorHidden && isCursorRow && x === cursorX) {
charElement.classList.add(CURSOR_CLASS);
if (!this._coreService.isCursorHidden && isCursorCell) {
classes.push(RowCss.CURSOR_CLASS);
if (cursorBlink) {
charElement.classList.add(CURSOR_BLINK_CLASS);
}
switch (cursorStyle) {
case 'bar':
charElement.classList.add(CURSOR_STYLE_BAR_CLASS);
break;
case 'underline':
charElement.classList.add(CURSOR_STYLE_UNDERLINE_CLASS);
break;
default:
charElement.classList.add(CURSOR_STYLE_BLOCK_CLASS);
break;
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
);
}
if (cell.isBold()) {
charElement.classList.add(BOLD_CLASS);
classes.push(RowCss.BOLD_CLASS);
}
if (cell.isItalic()) {
charElement.classList.add(ITALIC_CLASS);
classes.push(RowCss.ITALIC_CLASS);
}
if (cell.isDim()) {
charElement.classList.add(DIM_CLASS);
classes.push(RowCss.DIM_CLASS);
}
if (cell.isInvisible()) {
charElement.textContent = WHITESPACE_CELL_CHAR;
text = WHITESPACE_CELL_CHAR;
} else {
charElement.textContent = cell.getChars() || WHITESPACE_CELL_CHAR;
text = cell.getChars() || WHITESPACE_CELL_CHAR;
}
if (cell.isUnderline()) {
charElement.classList.add(`${UNDERLINE_CLASS}-${cell.extended.underlineStyle}`);
if (charElement.textContent === ' ') {
charElement.textContent = '\xa0'; // = &nbsp;
classes.push(`${RowCss.UNDERLINE_CLASS}-${cell.extended.underlineStyle}`);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
if (!cell.isUnderlineColorDefault()) {
if (cell.isUnderlineColorRGB()) {
@@ -187,14 +246,19 @@ export class DomRendererRowFactory {
}
if (cell.isOverline()) {
charElement.classList.add(OVERLINE_CLASS);
if (charElement.textContent === ' ') {
charElement.textContent = '\xa0'; // = &nbsp;
classes.push(RowCss.OVERLINE_CLASS);
if (text === ' ') {
text = '\xa0'; // = &nbsp;
}
}
if (cell.isStrikethrough()) {
charElement.classList.add(STRIKETHROUGH_CLASS);
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();
@@ -234,7 +298,6 @@ export class DomRendererRowFactory {
});
// Apply selection foreground if applicable
const isInSelection = this._isCellInSelection(x, row);
if (!isTop) {
if (colors.selectionForeground && isInSelection) {
fgColorMode = Attributes.CM_RGB;
@@ -252,7 +315,7 @@ export class DomRendererRowFactory {
// If it's a top decoration, render above the selection
if (isTop) {
charElement.classList.add(`xterm-decoration-top`);
classes.push('xterm-decoration-top');
}
// Background
@@ -261,7 +324,7 @@ export class DomRendererRowFactory {
case Attributes.CM_P16:
case Attributes.CM_P256:
resolvedBg = colors.ansi[bg];
charElement.classList.add(`xterm-bg-${bg}`);
classes.push(`xterm-bg-${bg}`);
break;
case Attributes.CM_RGB:
resolvedBg = rgba.toColor(bg >> 16, bg >> 8 & 0xFF, bg & 0xFF);
@@ -271,7 +334,7 @@ export class DomRendererRowFactory {
default:
if (isInverse) {
resolvedBg = colors.foreground;
charElement.classList.add(`xterm-bg-${INVERTED_DEFAULT_COLOR}`);
classes.push(`xterm-bg-${INVERTED_DEFAULT_COLOR}`);
} else {
resolvedBg = colors.background;
}
@@ -292,7 +355,7 @@ export class DomRendererRowFactory {
fg += 8;
}
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.ansi[fg], cell, bgOverride, undefined)) {
charElement.classList.add(`xterm-fg-${fg}`);
classes.push(`xterm-fg-${fg}`);
}
break;
case Attributes.CM_RGB:
@@ -309,24 +372,40 @@ export class DomRendererRowFactory {
default:
if (!this._applyMinimumContrast(charElement, resolvedBg, colors.foreground, cell, bgOverride, undefined)) {
if (isInverse) {
charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`);
classes.push(`xterm-fg-${INVERTED_DEFAULT_COLOR}`);
}
}
}
fragment.appendChild(charElement);
cellMap[x] = ++elemIndex;
// 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 && !isInSelection && !isJoined) {
cellAmount++;
} else {
charElement.textContent = text;
}
// apply letter-spacing rule
if (spacing !== this.defaultSpacing) {
charElement.style.letterSpacing = `${spacing}px`;
}
elements.push(charElement);
x = lastCharX;
}
// since the loop above might exit early not handling all cells,
// also set remaining cell positions to last element index
if (x < cols - 1) {
cellMap.subarray(x).fill(++elemIndex);
// postfix text of last merged span
if (charElement && cellAmount) {
charElement.textContent = text;
}
return fragment;
return elements;
}
private _applyMinimumContrast(element: HTMLElement, bg: IColor, fg: IColor, cell: ICellData, bgOverride: IColor | undefined, fgOverride: IColor | undefined): boolean {
+45
View File
@@ -0,0 +1,45 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
export interface IStyleSheet {
dispose: () => void;
setCss: (value: string) => void;
}
const createCssStyleSheet = (doc: Document): IStyleSheet => {
const sheet = new CSSStyleSheet();
doc.adoptedStyleSheets.push(sheet);
return {
dispose() {
const index = doc.adoptedStyleSheets.indexOf(sheet);
doc.adoptedStyleSheets.splice(index, 1);
},
setCss(css) {
sheet.replaceSync(css);
}
};
};
const createStyleElement = (parent: HTMLElement): IStyleSheet => {
const doc = parent.ownerDocument;
const element = doc.createElement('style');
parent.append(element);
return {
dispose() {
element.remove();
},
setCss(css) {
element.textContent = css;
}
};
};
export const createStyle = (parent: HTMLElement): IStyleSheet => {
try {
return createCssStyleSheet(parent.ownerDocument);
} catch {
return createStyleElement(parent);
}
};
+127
View File
@@ -0,0 +1,127 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import * as assert from 'assert';
import { WidthCache, WidthCacheSettings } from 'browser/renderer/dom/WidthCache';
import jsdom = require('jsdom');
export class TestWidthCache extends WidthCache {
public get flat(): Float32Array {
return (this as any)._flat;
}
public get holey(): Map<string, number> | undefined {
return (this as any)._holey;
}
public widths: {[key: string]: [number, number, number, number]} = {};
protected _measure(c: string, variant: number): number {
if (this.widths[c] !== undefined) {
return this.widths[c][variant];
}
return 5; // 5 is default width in tests in DomRendererRowFactory.test.ts
}
}
function castf32(v: number): number {
const buffer = new Float32Array(1);
buffer[0] = v;
return buffer[0];
}
describe('WidthCache', () => {
let wc: TestWidthCache;
beforeEach(() => {
wc = new TestWidthCache(new jsdom.JSDOM('').window.document);
wc.setFont('monospace', 15, 'normal', 'bold');
});
describe('cache invalidation', () => {
beforeEach(() => {
wc.flat.fill(1.23);
wc.holey?.set('a', 2.34);
});
it('can cache values', () => {
assert.deepStrictEqual(wc.flat[0], castf32(1.23));
assert.deepStrictEqual(wc.holey?.get('a'), 2.34);
assert.deepStrictEqual(wc.holey?.size, 1);
});
it('clear resets cache entries', () => {
wc.clear();
assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
assert.deepStrictEqual(wc.holey?.get('a'), undefined);
assert.deepStrictEqual(wc.holey?.size, 0);
});
it('setFont with changed font name', () => {
wc.setFont('Arial', 15, 'normal', 'bold');
assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
assert.deepStrictEqual(wc.holey?.get('a'), undefined);
assert.deepStrictEqual(wc.holey?.size, 0);
});
it('setFont with changed font size', () => {
wc.setFont('monospace', 14, 'normal', 'bold');
assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
assert.deepStrictEqual(wc.holey?.get('a'), undefined);
assert.deepStrictEqual(wc.holey?.size, 0);
});
it('setFont with changed weight', () => {
wc.setFont('monospace', 15, '100', 'bold');
assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
assert.deepStrictEqual(wc.holey?.get('a'), undefined);
assert.deepStrictEqual(wc.holey?.size, 0);
});
it('setFont with changed weightBold', () => {
wc.setFont('monospace', 15, 'normal', '900');
assert.deepStrictEqual(wc.flat[0], castf32(WidthCacheSettings.FLAT_UNSET));
assert.deepStrictEqual(wc.holey?.get('a'), undefined);
assert.deepStrictEqual(wc.holey?.size, 0);
});
it('setFont with unchanged settings does not cache entries', () => {
wc.setFont('monospace', 15, 'normal', 'bold');
assert.deepStrictEqual(wc.flat[0], castf32(1.23));
assert.deepStrictEqual(wc.holey?.get('a'), 2.34);
assert.deepStrictEqual(wc.holey?.size, 1);
});
});
describe('get', () => {
it('store regular < WidthCacheSettings.FLAT_SIZE in flat', () => {
for (let i = 0; i < WidthCacheSettings.FLAT_SIZE + 10; ++i) {
const width = wc.get(String.fromCharCode(i), false, false);
assert.deepStrictEqual(width, 5);
if (i < WidthCacheSettings.FLAT_SIZE) {
assert.deepStrictEqual(wc.flat[i], 5);
assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), undefined);
} else {
assert.deepStrictEqual(wc.holey?.get(String.fromCharCode(i)), 5);
}
}
});
it('stores bold & italic in holey', () => {
// bold
let width = wc.get('b', true, false);
assert.deepStrictEqual(width, 5);
assert.deepStrictEqual(wc.holey?.get('bB'), 5);
// italic
width = wc.get('i', false, true);
assert.deepStrictEqual(width, 5);
assert.deepStrictEqual(wc.holey?.get('iI'), 5);
// bold&italic
width = wc.get('x', true, true);
assert.deepStrictEqual(width, 5);
assert.deepStrictEqual(wc.holey?.get('xBI'), 5);
});
it('can store any string', () => {
// regular
let width = wc.get('foo', false, false);
assert.deepStrictEqual(width, 5);
assert.deepStrictEqual(wc.holey?.get('foo'), 5);
// bold&italic
width = wc.get('bar&baz', true, true);
assert.deepStrictEqual(width, 5);
assert.deepStrictEqual(wc.holey?.get('bar&bazBI'), 5);
});
});
});
+157
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;
}
}
@@ -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();
}
}
+14 -4
View File
@@ -7,7 +7,12 @@ import { IOptionsService } from 'common/services/Services';
import { EventEmitter } from 'common/EventEmitter';
import { ICharSizeService } from 'browser/services/Services';
import { Disposable } from 'common/Lifecycle';
import { ITerminalOptions } from 'common/Types';
const enum MeasureSettings {
REPEAT = 32
}
export class CharSizeService extends Disposable implements ICharSizeService {
public serviceBrand: undefined;
@@ -67,8 +72,10 @@ class DomMeasureStrategy implements IMeasureStrategy {
) {
this._measureElement = this._document.createElement('span');
this._measureElement.classList.add('xterm-char-measure-element');
this._measureElement.textContent = 'W';
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);
}
@@ -77,12 +84,15 @@ class DomMeasureStrategy implements IMeasureStrategy {
this._measureElement.style.fontSize = `${this._optionsService.rawOptions.fontSize}px`;
// Note that this triggers a synchronous layout
const geometry = this._measureElement.getBoundingClientRect();
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;
this._result.width = geometry.width / MeasureSettings.REPEAT;
this._result.height = Math.ceil(geometry.height);
}
+3 -9
View File
@@ -31,17 +31,11 @@ export class MouseService implements IMouseService {
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
const coords = getCoordsRelativeToElement(window, event, element);
// due to rounding issues in zoom states pixel values might be negative or overflow actual canvas
// ignore those events effectively narrowing mouse area a tiny bit at the edges
if (!this._charSizeService.hasValidSize
|| coords[0] < 0
|| coords[1] < 0
|| coords[0] >= this._renderService.dimensions.css.canvas.width
|| coords[1] >= this._renderService.dimensions.css.canvas.height) {
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),
+2 -3
View File
@@ -8,8 +8,7 @@ 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 { ColorIndex, IDisposable } from 'common/Types';
import { ITheme } from 'common/services/Services';
import { AllColorIndex, IDisposable } from 'common/Types';
export const ICharSizeService = createDecorator<ICharSizeService>('CharSizeService');
export interface ICharSizeService {
@@ -130,7 +129,7 @@ export interface IThemeService {
readonly onChangeColors: IEvent<ReadonlyColorSet>;
restoreColor(slot?: ColorIndex): void;
restoreColor(slot?: AllColorIndex): void;
/**
* Allows external modifying of colors in the theme, this is used instead of {@link colors} to
* prevent accidental writes.
+6 -6
View File
@@ -10,7 +10,7 @@ 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 { ColorIndex, IColor } from 'common/Types';
import { ColorIndex, SpecialColorIndex, IColor, AllColorIndex } from 'common/Types';
interface IRestoreColorSet {
foreground: IColor;
@@ -176,12 +176,12 @@ export class ThemeService extends Disposable implements IThemeService {
this._onChangeColors.fire(this.colors);
}
public restoreColor(slot?: ColorIndex): void {
public restoreColor(slot?: AllColorIndex): void {
this._restoreColor(slot);
this._onChangeColors.fire(this.colors);
}
private _restoreColor(slot: ColorIndex | undefined): void {
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) {
@@ -190,13 +190,13 @@ export class ThemeService extends Disposable implements IThemeService {
return;
}
switch (slot) {
case ColorIndex.FOREGROUND:
case SpecialColorIndex.FOREGROUND:
this._colors.foreground = this._restoreColors.foreground;
break;
case ColorIndex.BACKGROUND:
case SpecialColorIndex.BACKGROUND:
this._colors.background = this._restoreColors.background;
break;
case ColorIndex.CURSOR:
case SpecialColorIndex.CURSOR:
this._colors.cursor = this._restoreColors.cursor;
break;
default:
+11 -17
View File
@@ -195,38 +195,32 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
/**
* 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 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);
}
/**
* Scroll the display of the terminal by a number of pages.
* @param pageCount The number of pages to scroll (negative scrolls up).
*/
public scrollPages(pageCount: number): void {
this._bufferService.scrollPages(pageCount);
this.scrollLines(pageCount * (this.rows - 1));
}
/**
* Scrolls the display of the terminal to the top.
*/
public scrollToTop(): void {
this._bufferService.scrollToTop();
this.scrollLines(-this._bufferService.buffer.ydisp);
}
/**
* Scrolls the display of the terminal to the bottom.
*/
public scrollToBottom(): void {
this._bufferService.scrollToBottom();
this.scrollLines(this._bufferService.buffer.ybase - this._bufferService.buffer.ydisp);
}
public scrollToLine(line: number): void {
this._bufferService.scrollToLine(line);
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. */
+21 -21
View File
@@ -5,7 +5,7 @@
import { assert } from 'chai';
import { InputHandler } from 'common/InputHandler';
import { IBufferLine, IAttributeData, IColorEvent, ColorIndex, ColorRequestType } from 'common/Types';
import { IBufferLine, IAttributeData, IColorEvent, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants';
@@ -2002,87 +2002,87 @@ describe('InputHandler', () => {
inputHandler.onColor(ev => stack.push(ev));
// single foreground query --> color undefined
await inputHandler.parseP('\x1b]10;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.FOREGROUND }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.FOREGROUND }]]);
stack.length = 0;
// OSC with multiple values maps to OSC 10 & OSC 11 & OSC 12
await inputHandler.parseP('\x1b]10;?;?;?;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.FOREGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.FOREGROUND }], [{ type: ColorRequestType.REPORT, index: SpecialColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: SpecialColorIndex.CURSOR }]]);
stack.length = 0;
// set foreground color events
await inputHandler.parseP('\x1b]10;rgb:01/02/03\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [1, 2, 3] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.FOREGROUND, color: [1, 2, 3] }]]);
stack.length = 0;
await inputHandler.parseP('\x1b]10;#aabbcc\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [170, 187, 204] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.FOREGROUND, color: [170, 187, 204] }]]);
stack.length = 0;
// set FG, BG and cursor color at once
await inputHandler.parseP('\x1b]10;rgb:aa/bb/cc;#001122;rgb:12/34/56\x07');
assert.deepEqual(stack, [
[{ type: ColorRequestType.SET, index: ColorIndex.FOREGROUND, color: [170, 187, 204] }],
[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [0, 17, 34] }],
[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [18, 52, 86] }]
[{ type: ColorRequestType.SET, index: SpecialColorIndex.FOREGROUND, color: [170, 187, 204] }],
[{ type: ColorRequestType.SET, index: SpecialColorIndex.BACKGROUND, color: [0, 17, 34] }],
[{ type: ColorRequestType.SET, index: SpecialColorIndex.CURSOR, color: [18, 52, 86] }]
]);
});
it('110: restore FG color', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
await inputHandler.parseP('\x1b]110\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.FOREGROUND }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.FOREGROUND }]]);
});
it('11: BG set & query events', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
// single background query --> color undefined
await inputHandler.parseP('\x1b]11;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.BACKGROUND }]]);
stack.length = 0;
// OSC 11 with multiple values creates only BG and cursor event
await inputHandler.parseP('\x1b]11;?;?;?;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.BACKGROUND }], [{ type: ColorRequestType.REPORT, index: SpecialColorIndex.CURSOR }]]);
stack.length = 0;
// set background color events
await inputHandler.parseP('\x1b]11;rgb:01/02/03\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [1, 2, 3] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.BACKGROUND, color: [1, 2, 3] }]]);
stack.length = 0;
await inputHandler.parseP('\x1b]11;#aabbcc\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [170, 187, 204] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.BACKGROUND, color: [170, 187, 204] }]]);
stack.length = 0;
// set BG and cursor color at once
await inputHandler.parseP('\x1b]11;#001122;rgb:12/34/56\x07');
assert.deepEqual(stack, [
[{ type: ColorRequestType.SET, index: ColorIndex.BACKGROUND, color: [0, 17, 34] }],
[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [18, 52, 86] }]
[{ type: ColorRequestType.SET, index: SpecialColorIndex.BACKGROUND, color: [0, 17, 34] }],
[{ type: ColorRequestType.SET, index: SpecialColorIndex.CURSOR, color: [18, 52, 86] }]
]);
});
it('111: restore BG color', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
await inputHandler.parseP('\x1b]111\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.BACKGROUND }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.BACKGROUND }]]);
});
it('12: cursor color set & query events', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
// single cursor query --> color undefined
await inputHandler.parseP('\x1b]12;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.CURSOR }]]);
stack.length = 0;
// OSC 12 with multiple values creates only cursor event
await inputHandler.parseP('\x1b]12;?;?;?;?\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: ColorIndex.CURSOR }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.REPORT, index: SpecialColorIndex.CURSOR }]]);
stack.length = 0;
// set cursor color events
await inputHandler.parseP('\x1b]12;rgb:01/02/03\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [1, 2, 3] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.CURSOR, color: [1, 2, 3] }]]);
stack.length = 0;
await inputHandler.parseP('\x1b]12;#aabbcc\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: ColorIndex.CURSOR, color: [170, 187, 204] }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.SET, index: SpecialColorIndex.CURSOR, color: [170, 187, 204] }]]);
});
it('112: restore cursor color', async () => {
const stack: IColorEvent[] = [];
inputHandler.onColor(ev => stack.push(ev));
await inputHandler.parseP('\x1b]112\x07');
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: ColorIndex.CURSOR }]]);
assert.deepEqual(stack, [[{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.CURSOR }]]);
});
});
+11 -7
View File
@@ -4,7 +4,7 @@
* @license MIT
*/
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType } from 'common/Types';
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
import { C0, C1 } from 'common/data/EscapeSequences';
import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
@@ -2926,7 +2926,7 @@ export class InputHandler extends Disposable implements IInputHandler {
const spec = slots.shift() as string;
if (/^\d+$/.exec(idx)) {
const index = parseInt(idx);
if (0 <= index && index < 256) {
if (isValidColorIndex(index)) {
if (spec === '?') {
event.push({ type: ColorRequestType.REPORT, index });
} else {
@@ -2999,7 +2999,7 @@ export class InputHandler extends Disposable implements IInputHandler {
}
// special colors - OSC 10 | 11 | 12
private _specialColors = [ColorIndex.FOREGROUND, ColorIndex.BACKGROUND, ColorIndex.CURSOR];
private _specialColors = [SpecialColorIndex.FOREGROUND, SpecialColorIndex.BACKGROUND, SpecialColorIndex.CURSOR];
/**
* Apply colors requests for special colors in OSC 10 | 11 | 12.
@@ -3084,7 +3084,7 @@ export class InputHandler extends Disposable implements IInputHandler {
for (let i = 0; i < slots.length; ++i) {
if (/^\d+$/.exec(slots[i])) {
const index = parseInt(slots[i]);
if (0 <= index && index < 256) {
if (isValidColorIndex(index)) {
event.push({ type: ColorRequestType.RESTORE, index });
}
}
@@ -3101,7 +3101,7 @@ export class InputHandler extends Disposable implements IInputHandler {
* @vt: #Y OSC 110 "Restore default foreground color" "OSC 110 BEL" "Restore default foreground to themed color."
*/
public restoreFgColor(data: string): boolean {
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.FOREGROUND }]);
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.FOREGROUND }]);
return true;
}
@@ -3111,7 +3111,7 @@ export class InputHandler extends Disposable implements IInputHandler {
* @vt: #Y OSC 111 "Restore default background color" "OSC 111 BEL" "Restore default background to themed color."
*/
public restoreBgColor(data: string): boolean {
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.BACKGROUND }]);
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.BACKGROUND }]);
return true;
}
@@ -3121,7 +3121,7 @@ export class InputHandler extends Disposable implements IInputHandler {
* @vt: #Y OSC 112 "Restore default cursor color" "OSC 112 BEL" "Restore default cursor to themed color."
*/
public restoreCursorColor(data: string): boolean {
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: ColorIndex.CURSOR }]);
this._onColor.fire([{ type: ColorRequestType.RESTORE, index: SpecialColorIndex.CURSOR }]);
return true;
}
@@ -3440,3 +3440,7 @@ class DirtyRowTracker implements IDirtyRowTracker {
this.markRangeDirty(0, this._bufferService.rows - 1);
}
}
function isValidColorIndex(value: number): value is ColorIndex {
return 0 <= value && value < 256;
}
+14 -4
View File
@@ -242,6 +242,7 @@ export interface IBufferLine {
copyFrom(line: IBufferLine): void;
clone(): IBufferLine;
getTrimmedLength(): number;
getNoBgTrimmedLength(): number;
translateToString(trimRight?: boolean, startCol?: number, endCol?: number): string;
/* direct access to cell attrs */
@@ -413,23 +414,32 @@ export const enum ColorRequestType {
SET = 1,
RESTORE = 2
}
export const enum ColorIndex {
// 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: ColorIndex;
index: AllColorIndex;
}
export interface IColorSetRequest {
type: ColorRequestType.SET;
index: ColorIndex;
index: AllColorIndex;
color: IColorRGB;
}
export interface IColorRestoreRequest {
type: ColorRequestType.RESTORE;
index?: ColorIndex;
index?: AllColorIndex;
}
export type IColorEvent = (IColorReportRequest | IColorSetRequest | IColorRestoreRequest)[];
+10 -1
View File
@@ -5,7 +5,7 @@
import { CharData, IBufferLine, ICellData, IAttributeData, IExtendedAttrs } from 'common/Types';
import { stringFromCodePoint } from 'common/input/TextDecoder';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Content, BgFlags, FgFlags } from 'common/buffer/Constants';
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, WHITESPACE_CELL_CHAR, Content, BgFlags, FgFlags, Attributes } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
@@ -467,6 +467,15 @@ export class BufferLine implements IBufferLine {
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) {
-29
View File
@@ -147,33 +147,4 @@ export class BufferService extends Disposable implements IBufferService {
this._onScroll.fire(buffer.ydisp);
}
}
/**
* Scroll the display of the terminal by a number of pages.
* @param pageCount The number of pages to scroll (negative scrolls up).
*/
public scrollPages(pageCount: number): void {
this.scrollLines(pageCount * (this.rows - 1));
}
/**
* Scrolls the display of the terminal to the top.
*/
public scrollToTop(): void {
this.scrollLines(-this.buffer.ydisp);
}
/**
* Scrolls the display of the terminal to the bottom.
*/
public scrollToBottom(): void {
this.scrollLines(this.buffer.ybase - this.buffer.ydisp);
}
public scrollToLine(line: number): void {
const scrollAmount = line - this.buffer.ydisp;
if (scrollAmount !== 0) {
this.scrollLines(scrollAmount);
}
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IBufferService, ICoreService, ICoreMouseService } from 'common/services/Services';
import { EventEmitter, IEvent } from 'common/EventEmitter';
import { EventEmitter } from 'common/EventEmitter';
import { ICoreMouseProtocol, ICoreMouseEvent, CoreMouseEncoding, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
+1
View File
@@ -23,6 +23,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
fontSize: 15,
fontWeight: 'normal',
fontWeightBold: 'bold',
ignoreBracketedPasteMode: false,
lineHeight: 1.0,
letterSpacing: 0,
linkHandler: null,
+1 -4
View File
@@ -21,11 +21,7 @@ export interface IBufferService {
onResize: IEvent<{ cols: number, rows: number }>;
onScroll: IEvent<number>;
scroll(eraseAttr: IAttributeData, isWrapped?: boolean): void;
scrollToBottom(): void;
scrollToTop(): void;
scrollToLine(line: number): void;
scrollLines(disp: number, suppressScrollEvent?: boolean, source?: ScrollSource): void;
scrollPages(pageCount: number): void;
resize(cols: number, rows: number): void;
reset(): void;
}
@@ -225,6 +221,7 @@ export interface ITerminalOptions {
fontFamily?: string;
fontWeight?: FontWeight;
fontWeightBold?: FontWeight;
ignoreBracketedPasteMode?: boolean;
letterSpacing?: number;
lineHeight?: number;
linkHandler?: ILinkHandler | null;