UNPKG

xterm

Version:

Full xterm terminal, in your browser

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