/** * Copyright (c) 2018 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 { 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'; 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(); private _selectionStart: [number, number] | undefined; private _selectionEnd: [number, number] | undefined; private _columnSelectMode: boolean = false; 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, 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(); 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 elemIndex = -1; let x = 0; for (; 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; } // 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 charElement = this._document.createElement('span'); if (width > 1) { charElement.style.width = `${cellWidth * width}px`; } 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'; // 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 && isCursorRow && x === cursorX) { charElement.classList.add(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; } } if (cell.isBold()) { charElement.classList.add(BOLD_CLASS); } if (cell.isItalic()) { charElement.classList.add(ITALIC_CLASS); } if (cell.isDim()) { charElement.classList.add(DIM_CLASS); } if (cell.isInvisible()) { charElement.textContent = WHITESPACE_CELL_CHAR; } else { charElement.textContent = cell.getChars() || WHITESPACE_CELL_CHAR; } if (cell.isUnderline()) { charElement.classList.add(`${UNDERLINE_CLASS}-${cell.extended.underlineStyle}`); if (charElement.textContent === ' ') { charElement.textContent = '\xa0'; // =   } 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()) { charElement.classList.add(OVERLINE_CLASS); if (charElement.textContent === ' ') { charElement.textContent = '\xa0'; // =   } } if (cell.isStrikethrough()) { charElement.classList.add(STRIKETHROUGH_CLASS); } 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 foreground if applicable const isInSelection = this._isCellInSelection(x, row); if (!isTop) { if (colors.selectionForeground && isInSelection) { fgColorMode = Attributes.CM_RGB; fg = colors.selectionForeground.rgba >> 8 & 0xFFFFFF; fgOverride = colors.selectionForeground; } } // If in the selection, force the element to be above the selection to improve contrast and // support opaque selections if (isInSelection) { bgOverride = this._coreBrowserService.isFocused ? colors.selectionBackgroundOpaque : colors.selectionInactiveBackgroundOpaque; isTop = true; } // If it's a top decoration, render above the selection if (isTop) { charElement.classList.add(`xterm-decoration-top`); } // Background let resolvedBg: IColor; switch (bgColorMode) { case Attributes.CM_P16: case Attributes.CM_P256: resolvedBg = colors.ansi[bg]; charElement.classList.add(`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; charElement.classList.add(`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)) { charElement.classList.add(`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) { charElement.classList.add(`xterm-fg-${INVERTED_DEFAULT_COLOR}`); } } } fragment.appendChild(charElement); cellMap[x] = ++elemIndex; 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); } return fragment; } 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 let adjustedColor: IColor | undefined | null = undefined; if (!bgOverride && !fgOverride) { adjustedColor = this._themeService.colors.contrastCache.getColor(bg.rgba, fg.rgba); } // Calculate and store in cache if (adjustedColor === undefined) { adjustedColor = color.ensureContrastRatio(bgOverride || bg, fgOverride || fg, this._optionsService.rawOptions.minimumContrastRatio); this._themeService.colors.contrastCache.setColor((bgOverride || bg).rgba, (fgOverride || fg).rgba, adjustedColor ?? null); } if (adjustedColor) { this._addStyle(element, `color:${adjustedColor.css}`); return true; } return false; } 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; }