diff --git a/addons/xterm-addon-canvas/src/BaseRenderLayer.ts b/addons/xterm-addon-canvas/src/BaseRenderLayer.ts index 14dce8d6..a69968cc 100644 --- a/addons/xterm-addon-canvas/src/BaseRenderLayer.ts +++ b/addons/xterm-addon-canvas/src/BaseRenderLayer.ts @@ -191,14 +191,77 @@ export abstract class BaseRenderLayer implements IRenderLayer { * @param x The column to fill. * @param y The row to fill. */ - protected _fillBottomLineAtCells(x: number, y: number, width: number = 1): void { + protected _fillBottomLineAtCells(x: number, y: number, width: number = 1, pixelOffset: number = 0): void { this._ctx.fillRect( x * this._scaledCellWidth, - (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, + (y + 1) * this._scaledCellHeight + pixelOffset - window.devicePixelRatio - 1 /* Ensure it's drawn within the cell */, width * this._scaledCellWidth, window.devicePixelRatio); } + protected _curlyUnderlineAtCell(x: number, y: number, width: number = 1): void { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.strokeStyle = this._ctx.fillStyle; + this._ctx.lineWidth = window.devicePixelRatio; + for (let xOffset = 0; xOffset < width; xOffset++) { + const xLeft = (x + xOffset) * this._scaledCellWidth; + const xMid = (x + xOffset + 0.5) * this._scaledCellWidth; + const xRight = (x + xOffset + 1) * this._scaledCellWidth; + const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1; + const yMidBot = yMid - window.devicePixelRatio; + const yMidTop = yMid + window.devicePixelRatio; + this._ctx.moveTo(xLeft, yMid); + this._ctx.bezierCurveTo( + xLeft, yMidBot, + xMid, yMidBot, + xMid, yMid + ); + this._ctx.bezierCurveTo( + xMid, yMidTop, + xRight, yMidTop, + xRight, yMid + ); + } + this._ctx.stroke(); + this._ctx.restore(); + } + + protected _dottedUnderlineAtCell(x: number, y: number, width: number = 1): void { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.strokeStyle = this._ctx.fillStyle; + this._ctx.lineWidth = window.devicePixelRatio; + this._ctx.setLineDash([window.devicePixelRatio * 2, window.devicePixelRatio]); + const xLeft = x * this._scaledCellWidth; + const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1; + this._ctx.moveTo(xLeft, yMid); + for (let xOffset = 0; xOffset < width; xOffset++) { + // const xLeft = x * this._scaledCellWidth; + const xRight = (x + width + xOffset) * this._scaledCellWidth; + this._ctx.lineTo(xRight, yMid); + } + this._ctx.stroke(); + this._ctx.closePath(); + this._ctx.restore(); + } + + protected _dashedUnderlineAtCell(x: number, y: number, width: number = 1): void { + this._ctx.save(); + this._ctx.beginPath(); + this._ctx.strokeStyle = this._ctx.fillStyle; + this._ctx.lineWidth = window.devicePixelRatio; + this._ctx.setLineDash([window.devicePixelRatio * 4, window.devicePixelRatio * 3]); + const xLeft = x * this._scaledCellWidth; + const xRight = (x + width) * this._scaledCellWidth; + const yMid = (y + 1) * this._scaledCellHeight - window.devicePixelRatio - 1; + this._ctx.moveTo(xLeft, yMid); + this._ctx.lineTo(xRight, yMid); + this._ctx.stroke(); + this._ctx.closePath(); + this._ctx.restore(); + } + /** * Fills a 1px line (2px on HDPI) at the left of the cell. This uses the * existing fillStyle on the context. diff --git a/addons/xterm-addon-canvas/src/TextRenderLayer.ts b/addons/xterm-addon-canvas/src/TextRenderLayer.ts index 625bcce9..0308f125 100644 --- a/addons/xterm-addon-canvas/src/TextRenderLayer.ts +++ b/addons/xterm-addon-canvas/src/TextRenderLayer.ts @@ -8,7 +8,7 @@ import { CharData, ICellData } from 'common/Types'; import { GridCache } from './GridCache'; import { BaseRenderLayer } from './BaseRenderLayer'; import { AttributeData } from 'common/buffer/AttributeData'; -import { NULL_CELL_CODE, Content } from 'common/buffer/Constants'; +import { NULL_CELL_CODE, Content, UnderlineStyle } from 'common/buffer/Constants'; import { IColorSet } from 'browser/Types'; import { CellData } from 'common/buffer/CellData'; import { IOptionsService, IBufferService, IDecorationService } from 'common/services/Services'; @@ -269,7 +269,36 @@ export class TextRenderLayer extends BaseRenderLayer { this._fillMiddleLineAtCells(x, y, cell.getWidth()); } if (cell.isUnderline()) { - this._fillBottomLineAtCells(x, y, cell.getWidth()); + if (!cell.isUnderlineColorDefault()) { + if (cell.isUnderlineColorRGB()) { + this._ctx.fillStyle = `rgb(${AttributeData.toColorRGB(cell.getUnderlineColor()).join(',')})`; + } else { + let fg = cell.getUnderlineColor(); + if (this._optionsService.rawOptions.drawBoldTextInBrightColors && cell.isBold() && fg < 8) { + fg += 8; + } + this._ctx.fillStyle = this._colors.ansi[fg].css; + } + } + switch (cell.extended.underlineStyle) { + case UnderlineStyle.DOUBLE: + this._fillBottomLineAtCells(x, y, cell.getWidth(), -window.devicePixelRatio); + this._fillBottomLineAtCells(x, y, cell.getWidth(), window.devicePixelRatio); + break; + case UnderlineStyle.CURLY: + this._curlyUnderlineAtCell(x, y, cell.getWidth()); + break; + case UnderlineStyle.DOTTED: + this._dottedUnderlineAtCell(x, y, cell.getWidth()); + break; + case UnderlineStyle.DASHED: + this._dashedUnderlineAtCell(x, y, cell.getWidth()); + break; + case UnderlineStyle.SINGLE: + default: + this._fillBottomLineAtCells(x, y, cell.getWidth()); + break; + } } this._ctx.restore(); } diff --git a/addons/xterm-addon-webgl/src/GlyphRenderer.ts b/addons/xterm-addon-webgl/src/GlyphRenderer.ts index 9e60a7fc..6eff4ced 100644 --- a/addons/xterm-addon-webgl/src/GlyphRenderer.ts +++ b/addons/xterm-addon-webgl/src/GlyphRenderer.ts @@ -169,11 +169,11 @@ export class GlyphRenderer extends Disposable { return this._atlas ? this._atlas.beginFrame() : true; } - public updateCell(x: number, y: number, code: number, bg: number, fg: number, chars: string, lastBg: number): void { - this._updateCell(this._vertices.attributes, x, y, code, bg, fg, chars, lastBg); + public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, lastBg: number): void { + this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, lastBg); } - private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, chars: string, lastBg: number): void { + private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, lastBg: number): void { const terminal = this._terminal; const i = (y * terminal.cols + x) * INDICES_PER_CELL; @@ -185,16 +185,16 @@ export class GlyphRenderer extends Disposable { return; } - let rasterizedGlyph: IRasterizedGlyph; if (!this._atlas) { return; } // Get the glyph + let rasterizedGlyph: IRasterizedGlyph; if (chars && chars.length > 1) { - rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, bg, fg); + rasterizedGlyph = this._atlas.getRasterizedGlyphCombinedChar(chars, bg, fg, ext); } else { - rasterizedGlyph = this._atlas.getRasterizedGlyph(code, bg, fg); + rasterizedGlyph = this._atlas.getRasterizedGlyph(code, bg, fg, ext); } // Fill empty if no glyph was found diff --git a/addons/xterm-addon-webgl/src/RenderModel.ts b/addons/xterm-addon-webgl/src/RenderModel.ts index b93e1e85..2969a6d1 100644 --- a/addons/xterm-addon-webgl/src/RenderModel.ts +++ b/addons/xterm-addon-webgl/src/RenderModel.ts @@ -6,9 +6,10 @@ import { IRenderModel, ISelectionRenderModel } from './Types'; import { fill } from 'common/TypedArrayUtils'; -export const RENDER_MODEL_INDICIES_PER_CELL = 3; +export const RENDER_MODEL_INDICIES_PER_CELL = 4; export const RENDER_MODEL_BG_OFFSET = 1; export const RENDER_MODEL_FG_OFFSET = 2; +export const RENDER_MODEL_EXT_OFFSET = 3; export const COMBINED_CHAR_BIT_MASK = 0x80000000; diff --git a/addons/xterm-addon-webgl/src/Types.d.ts b/addons/xterm-addon-webgl/src/Types.d.ts index d8a27aa7..c803d3e4 100644 --- a/addons/xterm-addon-webgl/src/Types.d.ts +++ b/addons/xterm-addon-webgl/src/Types.d.ts @@ -4,7 +4,7 @@ */ export interface IRasterizedGlyphSet { - [bg: number]: { [fg: number]: IRasterizedGlyph } | undefined; + [bg: number]: { [fg: number]: { [ext: number]: IRasterizedGlyph } } | undefined; } /** diff --git a/addons/xterm-addon-webgl/src/WebglRenderer.ts b/addons/xterm-addon-webgl/src/WebglRenderer.ts index 74594590..d2d4f478 100644 --- a/addons/xterm-addon-webgl/src/WebglRenderer.ts +++ b/addons/xterm-addon-webgl/src/WebglRenderer.ts @@ -10,7 +10,7 @@ import { acquireCharAtlas, removeTerminalFromCache } from './atlas/CharAtlasCach import { WebglCharAtlas } from './atlas/WebglCharAtlas'; import { RectangleRenderer } from './RectangleRenderer'; import { IWebGL2RenderingContext } from './Types'; -import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; +import { RenderModel, COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { Attributes, BgFlags, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { Terminal, IEvent } from 'xterm'; @@ -34,7 +34,7 @@ export class WebglRenderer extends Disposable implements IRenderer { private _model: RenderModel = new RenderModel(); private _workCell: CellData = new CellData(); - private _workColors: { fg: number, bg: number } = { fg: 0, bg: 0 }; + private _workColors: { fg: number, bg: number, ext: number } = { fg: 0, bg: 0, ext: 0 }; private _canvas: HTMLCanvasElement; private _gl: IWebGL2RenderingContext; @@ -353,7 +353,8 @@ export class WebglRenderer extends Disposable implements IRenderer { // Nothing has changed, no updates needed if (this._model.cells[i] === code && this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._workColors.bg && - this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._workColors.fg) { + this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._workColors.fg && + this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._workColors.ext) { continue; } @@ -366,8 +367,9 @@ export class WebglRenderer extends Disposable implements IRenderer { this._model.cells[i] = code; this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._workColors.bg; this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._workColors.fg; + this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._workColors.ext; - this._glyphRenderer.updateCell(x, y, code, this._workColors.bg, this._workColors.fg, chars, lastBg); + this._glyphRenderer.updateCell(x, y, code, this._workColors.bg, this._workColors.fg, this._workColors.ext, chars, lastBg); if (isJoined) { // Restore work cell @@ -376,10 +378,11 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x < lastCharX; x++) { const j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; - this._glyphRenderer.updateCell(x, y, NULL_CELL_CODE, 0, 0, NULL_CELL_CHAR, 0); + this._glyphRenderer.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0); this._model.cells[j] = NULL_CELL_CODE; this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._workColors.bg; this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._workColors.fg; + this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._workColors.ext; } } } @@ -394,7 +397,7 @@ export class WebglRenderer extends Disposable implements IRenderer { private _loadColorsForCell(x: number, y: number): void { this._workColors.bg = this._workCell.bg; this._workColors.fg = this._workCell.fg; - + this._workColors.ext = this._workCell.bg & BgFlags.HAS_EXTENDED ? this._workCell.extended.ext : 0; // Get any foreground/background overrides, this happens on the model to avoid spreading // override logic throughout the different sub-renderers let bgOverride: number | undefined; diff --git a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts index f5b0dc1c..e5f0fe6c 100644 --- a/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts +++ b/addons/xterm-addon-webgl/src/atlas/WebglCharAtlas.ts @@ -6,7 +6,7 @@ import { ICharAtlasConfig } from './Types'; import { DIM_OPACITY, TEXT_BASELINE } from 'browser/renderer/Constants'; import { IRasterizedGlyph, IBoundingBox, IRasterizedGlyphSet } from '../Types'; -import { DEFAULT_COLOR, Attributes } from 'common/buffer/Constants'; +import { DEFAULT_COLOR, Attributes, DEFAULT_EXT, UnderlineStyle } from 'common/buffer/Constants'; import { throwIfFalsy } from '../WebglUtils'; import { IColor } from 'common/Types'; import { IDisposable } from 'xterm'; @@ -106,10 +106,12 @@ export class WebglCharAtlas implements IDisposable { private _doWarmUp(): void { // Pre-fill with ASCII 33-126 for (let i = 33; i < 126; i++) { - const rasterizedGlyph = this._drawToCache(i, DEFAULT_COLOR, DEFAULT_COLOR); + const rasterizedGlyph = this._drawToCache(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT); this._cacheMap[i] = { [DEFAULT_COLOR]: { - [DEFAULT_COLOR]: rasterizedGlyph + [DEFAULT_COLOR]: { + [DEFAULT_EXT]: rasterizedGlyph + } } }; } @@ -137,48 +139,50 @@ export class WebglCharAtlas implements IDisposable { this._didWarmUp = false; } - public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number): IRasterizedGlyph { - let rasterizedGlyphSet = this._cacheMapCombined[chars]; - if (!rasterizedGlyphSet) { - rasterizedGlyphSet = {}; - this._cacheMapCombined[chars] = rasterizedGlyphSet; - } - let rasterizedGlyph: IRasterizedGlyph | undefined; - const rasterizedGlyphSetBg = rasterizedGlyphSet[bg]; - if (rasterizedGlyphSetBg) { - rasterizedGlyph = rasterizedGlyphSetBg[fg]; - } - if (!rasterizedGlyph) { - rasterizedGlyph = this._drawToCache(chars, bg, fg); - if (!rasterizedGlyphSet[bg]) { - rasterizedGlyphSet[bg] = {}; - } - rasterizedGlyphSet[bg]![fg] = rasterizedGlyph; - } - return rasterizedGlyph; + public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number): IRasterizedGlyph { + return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext); + } + + public getRasterizedGlyph(code: number, bg: number, fg: number, ext: number): IRasterizedGlyph { + return this._getFromCacheMap(this._cacheMap, code, bg, fg, ext); } /** * Gets the glyphs texture coords, drawing the texture if it's not already */ - public getRasterizedGlyph(code: number, bg: number, fg: number): IRasterizedGlyph { - let rasterizedGlyphSet = this._cacheMap[code]; + private _getFromCacheMap( + cacheMap: { [key: string | number]: IRasterizedGlyphSet }, + key: string | number, + bg: number, + fg: number, + ext: number + ): IRasterizedGlyph { + let rasterizedGlyphSet = cacheMap[key]; if (!rasterizedGlyphSet) { rasterizedGlyphSet = {}; - this._cacheMap[code] = rasterizedGlyphSet; + cacheMap[key] = rasterizedGlyphSet; } + + let rasterizedGlyphSetBg = rasterizedGlyphSet[bg]; + if (!rasterizedGlyphSetBg) { + rasterizedGlyphSetBg = {}; + rasterizedGlyphSet[bg] = rasterizedGlyphSetBg; + } + let rasterizedGlyph: IRasterizedGlyph | undefined; - const rasterizedGlyphSetBg = rasterizedGlyphSet[bg]; - if (rasterizedGlyphSetBg) { - rasterizedGlyph = rasterizedGlyphSetBg[fg]; + let rasterizedGlyphSetFg = rasterizedGlyphSetBg[fg]; + if (!rasterizedGlyphSetFg) { + rasterizedGlyphSetFg = {}; + rasterizedGlyphSetBg[fg] = rasterizedGlyphSetFg; + } else { + rasterizedGlyph = rasterizedGlyphSetFg[ext]; } + if (!rasterizedGlyph) { - rasterizedGlyph = this._drawToCache(code, bg, fg); - if (!rasterizedGlyphSet[bg]) { - rasterizedGlyphSet[bg] = {}; - } - rasterizedGlyphSet[bg]![fg] = rasterizedGlyph; + rasterizedGlyph = this._drawToCache(key, bg, fg, ext); + rasterizedGlyphSetFg[ext] = rasterizedGlyph; } + return rasterizedGlyph; } @@ -334,9 +338,7 @@ export class WebglCharAtlas implements IDisposable { return color; } - private _drawToCache(code: number, bg: number, fg: number): IRasterizedGlyph; - private _drawToCache(chars: string, bg: number, fg: number): IRasterizedGlyph; - private _drawToCache(codeOrChars: number | string, bg: number, fg: number): IRasterizedGlyph { + private _drawToCache(codeOrChars: number | string, bg: number, fg: number, ext: number): IRasterizedGlyph { const chars = typeof codeOrChars === 'number' ? String.fromCharCode(codeOrChars) : codeOrChars; this.hasCanvasChanged = true; @@ -349,7 +351,7 @@ export class WebglCharAtlas implements IDisposable { this._tmpCanvas.width = allowedWidth; } // Include line height when drawing glyphs - const allowedHeight = this._config.scaledCellHeight + TMP_CANVAS_GLYPH_PADDING * 2; + const allowedHeight = this._config.scaledCellHeight + TMP_CANVAS_GLYPH_PADDING * 4; if (this._tmpCanvas.height < allowedHeight) { this._tmpCanvas.height = allowedHeight; } @@ -357,6 +359,7 @@ export class WebglCharAtlas implements IDisposable { this._workAttributeData.fg = fg; this._workAttributeData.bg = bg; + this._workAttributeData.extended.ext = ext; const invisible = !!this._workAttributeData.isInvisible(); if (invisible) { @@ -403,7 +406,7 @@ export class WebglCharAtlas implements IDisposable { this._tmpCtx.fillStyle = foregroundColor.css; // For powerline glyphs left/top padding is excluded (https://github.com/microsoft/vscode/issues/120129) - const padding = powerLineGlyph ? 0 : TMP_CANVAS_GLYPH_PADDING; + const padding = powerLineGlyph ? 0 : TMP_CANVAS_GLYPH_PADDING * 2; // Draw custom characters if applicable let drawSuccess = false; @@ -411,6 +414,115 @@ export class WebglCharAtlas implements IDisposable { drawSuccess = tryDrawCustomChar(this._tmpCtx, chars, padding, padding, this._config.scaledCellWidth, this._config.scaledCellHeight); } + // Whether to clear pixels based on a threshold difference between the glyph color and the + // background color. This should be disabled when the glyph contains multiple colors such as + // underline colors to prevent important colors could get cleared. + let enableClearThresholdCheck = true; + + // Draw underline + if (underline) { + this._tmpCtx.save(); + const lineWidth = Math.max(1, Math.floor(this._config.fontSize * window.devicePixelRatio / 10)); + const yOffset = this._tmpCtx.lineWidth % 2 === 1 ? 0.5 : 0; // When the width is odd, draw at 0.5 position + this._tmpCtx.lineWidth = lineWidth; + + // Underline color + if (this._workAttributeData.isUnderlineColorDefault()) { + this._tmpCtx.strokeStyle = this._tmpCtx.fillStyle; + } else if (this._workAttributeData.isUnderlineColorRGB()) { + enableClearThresholdCheck = false; + this._tmpCtx.strokeStyle = `rgb(${AttributeData.toColorRGB(this._workAttributeData.getUnderlineColor()).join(',')})`; + } else { + enableClearThresholdCheck = false; + let fg = this._workAttributeData.getUnderlineColor(); + if (this._config.drawBoldTextInBrightColors && this._workAttributeData.isBold() && fg < 8) { + fg += 8; + } + this._tmpCtx.strokeStyle = this._getColorFromAnsiIndex(fg).css; + } + + // Underline style/stroke + this._tmpCtx.beginPath(); + const xLeft = padding; + const xRight = padding + this._config.scaledCellWidth; + const yTop = Math.ceil(padding + this._config.scaledCharHeight - lineWidth) - yOffset; + const yMid = padding + this._config.scaledCharHeight - yOffset; + const yBot = Math.ceil(padding + this._config.scaledCharHeight + lineWidth) - yOffset; + switch (this._workAttributeData.extended.underlineStyle) { + case UnderlineStyle.DOUBLE: + this._tmpCtx.moveTo(xLeft, yTop); + this._tmpCtx.lineTo(xRight, yTop); + this._tmpCtx.moveTo(xLeft, yBot); + this._tmpCtx.lineTo(xRight, yBot); + break; + case UnderlineStyle.CURLY: + const xMid = padding + this._config.scaledCellWidth / 2; + // Choose the bezier top and bottom based on the device pixel ratio, the curly line is + // made taller when the line width is as otherwise it's not very clear otherwise. + const yCurlyBot = lineWidth <= 1 ? yBot : Math.ceil(padding + this._config.scaledCharHeight - lineWidth / 2) - yOffset; + const yCurlyTop = lineWidth <= 1 ? yTop : Math.ceil(padding + this._config.scaledCharHeight + lineWidth / 2) - yOffset; + // Clip the left and right edges of the underline such that it can be drawn just outside + // the edge of the cell to ensure a continuous stroke when there are multiple underlined + // glyphs adjacent to one another. + const clipRegion = new Path2D(); + clipRegion.rect(xLeft, yTop, this._config.scaledCellWidth, yBot - yTop); + this._tmpCtx.clip(clipRegion); + // Start 1/2 cell before and end 1/2 cells after to ensure a smooth curve with other cells + this._tmpCtx.moveTo(xLeft - this._config.scaledCellWidth / 2, yMid); + this._tmpCtx.bezierCurveTo( + xLeft - this._config.scaledCellWidth / 2, yCurlyTop, + xLeft, yCurlyTop, + xLeft, yMid + ); + this._tmpCtx.bezierCurveTo( + xLeft, yCurlyBot, + xMid, yCurlyBot, + xMid, yMid + ); + this._tmpCtx.bezierCurveTo( + xMid, yCurlyTop, + xRight, yCurlyTop, + xRight, yMid + ); + this._tmpCtx.bezierCurveTo( + xRight, yCurlyBot, + xRight + this._config.scaledCellWidth / 2, yCurlyBot, + xRight + this._config.scaledCellWidth / 2, yMid + ); + break; + case UnderlineStyle.DOTTED: + this._tmpCtx.setLineDash([window.devicePixelRatio * 2, window.devicePixelRatio]); + this._tmpCtx.moveTo(xLeft, yMid); + this._tmpCtx.lineTo(xRight, yMid); + break; + case UnderlineStyle.DASHED: + this._tmpCtx.setLineDash([window.devicePixelRatio * 4, window.devicePixelRatio * 3]); + this._tmpCtx.moveTo(xLeft, yMid); + this._tmpCtx.lineTo(xRight, yMid); + break; + case UnderlineStyle.SINGLE: + default: + this._tmpCtx.moveTo(xLeft, yMid); + this._tmpCtx.lineTo(xRight, yMid); + break; + } + this._tmpCtx.stroke(); + this._tmpCtx.restore(); + + // Draw stroke in the background color for non custom characters in order to give an outline + // between the text and the underline + if (!drawSuccess) { + // This only works when transparency is disabled because it's not clear how to clear stroked + // text + if (!this._config.allowTransparency && chars !== ' ') { + // This translates to 1/2 the line width in either direction + this._tmpCtx.lineWidth = window.devicePixelRatio * 3; + this._tmpCtx.strokeStyle = backgroundColor.css; + this._tmpCtx.strokeText(chars, padding, padding + this._config.scaledCharHeight); + } + } + } + // Draw the character if (!drawSuccess) { this._tmpCtx.fillText(chars, padding, padding + this._config.scaledCharHeight); @@ -419,12 +531,12 @@ export class WebglCharAtlas implements IDisposable { // If this charcater is underscore and beyond the cell bounds, shift it up until it is visible // even on the bottom row, try for a maximum of 5 pixels. if (chars === '_' && !this._config.allowTransparency) { - let isBeyondCellBounds = clearColor(this._tmpCtx.getImageData(padding, padding, this._config.scaledCellWidth, this._config.scaledCellHeight), backgroundColor, foregroundColor); + let isBeyondCellBounds = clearColor(this._tmpCtx.getImageData(padding, padding, this._config.scaledCellWidth, this._config.scaledCellHeight), backgroundColor, foregroundColor, enableClearThresholdCheck); if (isBeyondCellBounds) { for (let offset = 1; offset <= 5; offset++) { this._tmpCtx.clearRect(0, 0, this._tmpCanvas.width, this._tmpCanvas.height); this._tmpCtx.fillText(chars, padding, padding + this._config.scaledCharHeight - offset); - isBeyondCellBounds = clearColor(this._tmpCtx.getImageData(padding, padding, this._config.scaledCellWidth, this._config.scaledCellHeight), backgroundColor, foregroundColor); + isBeyondCellBounds = clearColor(this._tmpCtx.getImageData(padding, padding, this._config.scaledCellWidth, this._config.scaledCellHeight), backgroundColor, foregroundColor, enableClearThresholdCheck); if (!isBeyondCellBounds) { break; } @@ -432,23 +544,16 @@ export class WebglCharAtlas implements IDisposable { } } - // Draw underline and strikethrough - if (underline || strikethrough) { - const lineWidth = Math.max(1, Math.floor(this._config.fontSize / 10)); + // Draw strokethrough + if (strikethrough) { + const lineWidth = Math.max(1, Math.floor(this._config.fontSize * window.devicePixelRatio / 10)); const yOffset = this._tmpCtx.lineWidth % 2 === 1 ? 0.5 : 0; // When the width is odd, draw at 0.5 position this._tmpCtx.lineWidth = lineWidth; this._tmpCtx.strokeStyle = this._tmpCtx.fillStyle; this._tmpCtx.beginPath(); - if (underline) { - this._tmpCtx.moveTo(padding, padding + this._config.scaledCharHeight - yOffset); - this._tmpCtx.lineTo(padding + this._config.scaledCharWidth, padding + this._config.scaledCharHeight - yOffset); - } - if (strikethrough) { - this._tmpCtx.moveTo(padding, padding + Math.floor(this._config.scaledCharHeight / 2) - yOffset); - this._tmpCtx.lineTo(padding + this._config.scaledCharWidth, padding + Math.floor(this._config.scaledCharHeight / 2) - yOffset); - } + this._tmpCtx.moveTo(padding, padding + Math.floor(this._config.scaledCharHeight / 2) - yOffset); + this._tmpCtx.lineTo(padding + this._config.scaledCharWidth, padding + Math.floor(this._config.scaledCharHeight / 2) - yOffset); this._tmpCtx.stroke(); - this._tmpCtx.closePath(); } this._tmpCtx.restore(); @@ -462,7 +567,7 @@ export class WebglCharAtlas implements IDisposable { // Clear out the background color and determine if the glyph is empty. let isEmpty: boolean; if (!this._config.allowTransparency) { - isEmpty = clearColor(imageData, backgroundColor, foregroundColor); + isEmpty = clearColor(imageData, backgroundColor, foregroundColor, enableClearThresholdCheck); } else { isEmpty = checkCompletelyTransparent(imageData); } @@ -609,7 +714,7 @@ export class WebglCharAtlas implements IDisposable { * transparent. * @returns True if the result is "empty", meaning all pixels are fully transparent. */ -function clearColor(imageData: ImageData, bg: IColor, fg: IColor): boolean { +function clearColor(imageData: ImageData, bg: IColor, fg: IColor, enableThresholdCheck: boolean): boolean { // Get color channels const r = bg.rgba >>> 24; const g = bg.rgba >>> 16 & 0xFF; @@ -636,7 +741,8 @@ function clearColor(imageData: ImageData, bg: IColor, fg: IColor): boolean { imageData.data[offset + 3] = 0; } else { // Check the threshold based difference - if ((Math.abs(imageData.data[offset] - r) + + if (enableThresholdCheck && + (Math.abs(imageData.data[offset] - r) + Math.abs(imageData.data[offset + 1] - g) + Math.abs(imageData.data[offset + 2] - b)) < threshold) { imageData.data[offset + 3] = 0; diff --git a/css/xterm.css b/css/xterm.css index 95fc61ed..e9fd8153 100644 --- a/css/xterm.css +++ b/css/xterm.css @@ -163,9 +163,11 @@ opacity: 0.5; } -.xterm-underline { - text-decoration: underline; -} +.xterm-underline-1 { text-decoration: underline; } +.xterm-underline-2 { text-decoration: double underline; } +.xterm-underline-3 { text-decoration: wavy underline; } +.xterm-underline-4 { text-decoration: dotted underline; } +.xterm-underline-5 { text-decoration: dashed underline; } .xterm-strikethrough { text-decoration: line-through; diff --git a/demo/client.ts b/demo/client.ts index 41b1b085..1c01a38a 100644 --- a/demo/client.ts +++ b/demo/client.ts @@ -187,6 +187,7 @@ if (document.location.pathname === '/test') { document.getElementById('custom-glyph').addEventListener('click', writeCustomGlyphHandler); document.getElementById('load-test').addEventListener('click', loadTest); document.getElementById('powerline-symbol-test').addEventListener('click', powerlineSymbolTest); + document.getElementById('underline-test').addEventListener('click', underlineTest); document.getElementById('add-decoration').addEventListener('click', addDecoration); document.getElementById('add-overview-ruler').addEventListener('click', addOverviewRuler); } @@ -200,7 +201,6 @@ function createTerminal(): void { const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; term = new Terminal({ allowProposedApi: true, - allowTransparency: true, windowsMode: isWindows, fontFamily: 'Fira Code, courier-new, courier, monospace', theme: xtermjsTheme @@ -212,6 +212,7 @@ function createTerminal(): void { addons.serialize.instance = new SerializeAddon(); addons.fit.instance = new FitAddon(); addons.unicode11.instance = new Unicode11Addon(); + addons.webgl.instance = new WebglAddon(); // TODO: Remove arguments when link provider API is the default addons['web-links'].instance = new WebLinksAddon(undefined, undefined, true); typedTerm.loadAddon(addons.fit.instance); @@ -236,6 +237,10 @@ function createTerminal(): void { term.open(terminalContainer); addons.fit.instance!.fit(); + typedTerm.loadAddon(addons.webgl.instance); + setTimeout(() => { + document.body.appendChild(addons.webgl.instance.textureAtlas); + }, 0); term.focus(); addDomListener(paddingElement, 'change', setPadding); @@ -738,6 +743,52 @@ function powerlineSymbolTest() { term.writeln('nf-mdi-github_face (\\uFbd9) \ufbd9'); } +function underlineTest() { + function u(style: number): string { + return `\x1b[4:${style}m`; + } + function c(color: string): string { + return `\x1b[58:${color}m`; + } + term.write('\n\n\r'); + term.writeln('Underline styles:'); + term.writeln(''); + term.writeln(`${u(0)}4:0m - No underline`); + term.writeln(`${u(1)}4:1m - Straight`); + term.writeln(`${u(2)}4:2m - Double`); + term.writeln(`${u(3)}4:3m - Curly`); + term.writeln(`${u(4)}4:4m - Dotted`); + term.writeln(`${u(5)}4:5m - Dashed\x1b[0m`); + term.writeln(''); + term.writeln(`Underline colors (256 color mode):`); + term.writeln(''); + for (let i = 0; i < 256; i++) { + term.write((i !== 0 ? '\x1b[0m, ' : '') + u(1 + i % 5) + c('5:' + i) + i); + } + term.writeln(`\x1b[0m\n\n\rUnderline colors (true color mode):`); + term.writeln(''); + for (let i = 0; i < 80; i++) { + const v = Math.round(i / 79 * 255); + term.write(u(1) + c(`2:0:${v}:${v}:${v}`) + (i < 4 ? 'grey'[i] : ' ')); + } + term.write('\n\r'); + for (let i = 0; i < 80; i++) { + const v = Math.round(i / 79 * 255); + term.write(u(1) + c(`2:0:${v}:${0}:${0}`) + (i < 3 ? 'red'[i] : ' ')); + } + term.write('\n\r'); + for (let i = 0; i < 80; i++) { + const v = Math.round(i / 79 * 255); + term.write(u(1) + c(`2:0:${0}:${v}:${0}`) + (i < 5 ? 'green'[i] : ' ')); + } + term.write('\n\r'); + for (let i = 0; i < 80; i++) { + const v = Math.round(i / 79 * 255); + term.write(u(1) + c(`2:0:${0}:${0}:${v}`) + (i < 4 ? 'blue'[i] : ' ')); + } + term.write('\x1b[0m\n\r'); +} + function addDecoration() { term.options['overviewRulerWidth'] = 15; const marker = term.registerMarker(1); diff --git a/demo/index.html b/demo/index.html index 23f78aa6..bff5b4b2 100644 --- a/demo/index.html +++ b/demo/index.html @@ -77,6 +77,7 @@
Styles
+
Decorations
diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 55a7b776..df359dac 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -6,7 +6,7 @@ import jsdom = require('jsdom'); import { assert } from 'chai'; import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory'; -import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes } from 'common/buffer/Constants'; +import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { IBufferLine } from 'common/Types'; import { CellData } from 'common/buffer/CellData'; @@ -130,14 +130,62 @@ describe('DomRendererRowFactory', () => { ); }); - it('should add class for underline', () => { - const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); - cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; - lineData.setCell(0, cell); - const fragment = rowFactory.createRow(lineData, 0, false, undefined, 0, false, 5, 20); - assert.equal(getFragmentHtml(fragment), - 'a' - ); + describe('underline', () => { + it('should add class for straight underline style', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + 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); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add class for double underline style', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + 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); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add class for curly underline style', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + 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); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add class for double dotted style', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + 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); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); + it('should add class for dashed underline style', () => { + const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]); + cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE; + 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); + assert.equal(getFragmentHtml(fragment), + 'a' + ); + }); }); it('should add class for strikethrough', () => { diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 634ef47b..cf4b3680 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -13,6 +13,7 @@ import { IColorSet } from 'browser/Types'; import { ICharacterJoinerService, ICoreBrowserService } from 'browser/services/Services'; import { JoinedCellData } from 'browser/services/CharacterJoinerService'; import { excludeFromContrastRatioDemands } from 'browser/renderer/RendererUtils'; +import { AttributeData } from 'common/buffer/AttributeData'; export const BOLD_CLASS = 'xterm-bold'; export const DIM_CLASS = 'xterm-dim'; @@ -156,16 +157,30 @@ export class DomRendererRowFactory { charElement.classList.add(DIM_CLASS); } - if (cell.isUnderline()) { - charElement.classList.add(UNDERLINE_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.innerHTML = ' '; + } + 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 = this._colors.ansi[fg].css; + } + } + } + if (cell.isStrikethrough()) { charElement.classList.add(STRIKETHROUGH_CLASS); } diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index ed9a7124..56815da0 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -113,6 +113,7 @@ export interface IColor { export type IColorRGB = [number, number, number]; export interface IExtendedAttrs { + ext: number; underlineStyle: number; underlineColor: number; clone(): IExtendedAttrs; diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index 43d378ea..6878069a 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -4,7 +4,7 @@ */ import { IAttributeData, IColorRGB, IExtendedAttrs } from 'common/Types'; -import { Attributes, FgFlags, BgFlags, UnderlineStyle } from 'common/buffer/Constants'; +import { Attributes, FgFlags, BgFlags, UnderlineStyle, ExtFlags } from 'common/buffer/Constants'; export class AttributeData implements IAttributeData { public static toColorRGB(value: number): IColorRGB { @@ -30,7 +30,7 @@ export class AttributeData implements IAttributeData { // data public fg = 0; public bg = 0; - public extended = new ExtendedAttrs(); + public extended: IExtendedAttrs = new ExtendedAttrs(); // flags public isInverse(): number { return this.fg & FgFlags.INVERSE; } @@ -127,12 +127,33 @@ export class AttributeData implements IAttributeData { * Holds information about different underline styles and color. */ export class ExtendedAttrs implements IExtendedAttrs { + private _ext: number = 0; + public get ext(): number { return this._ext; } + public set ext(value: number) { this._ext = value; } + + public get underlineStyle(): UnderlineStyle { + return (this._ext & ExtFlags.UNDERLINE_STYLE) >> 26; + } + public set underlineStyle(value: UnderlineStyle) { + this._ext &= ~ExtFlags.UNDERLINE_STYLE; + this._ext |= (value << 26) & ExtFlags.UNDERLINE_STYLE; + } + + public get underlineColor(): number { + return this._ext & (Attributes.CM_MASK | Attributes.RGB_MASK); + } + public set underlineColor(value: number) { + this._ext &= ~(Attributes.CM_MASK | Attributes.RGB_MASK); + this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); + } + constructor( - // underline style, NONE is empty - public underlineStyle: UnderlineStyle = UnderlineStyle.NONE, - // underline color, -1 is empty (same as FG) - public underlineColor: number = -1 - ) {} + underlineStyle: UnderlineStyle = UnderlineStyle.NONE, + underlineColor: number = -1 + ) { + this.underlineStyle = underlineStyle; + this.underlineColor = underlineColor; + } public clone(): IExtendedAttrs { return new ExtendedAttrs(this.underlineStyle, this.underlineColor); diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index fa15a854..111aae03 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -45,7 +45,7 @@ describe('AttributeData', () => { assert.equal(attrs.getUnderlineColor(), 45); // should use FG color if underlineColor holds no value - attrs.extended.underlineColor = -1; + attrs.extended.underlineColor = 0; attrs.fg |= Attributes.CM_P256 | 123; assert.equal(attrs.getUnderlineColor(), 123); }); @@ -62,7 +62,7 @@ describe('AttributeData', () => { assert.equal(attrs.getUnderlineColor(), (1 << 16) | (2 << 8) | 3); // should use FG color if underlineColor holds no value - attrs.extended.underlineColor = -1; + attrs.extended.underlineColor = 0; attrs.fg |= Attributes.CM_P256 | 123; assert.equal(attrs.getUnderlineColor(), 123); }); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index f0bf4fcb..6d2a442f 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -55,7 +55,7 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); export class BufferLine implements IBufferLine { protected _data: Uint32Array; protected _combined: {[index: number]: string} = {}; - protected _extendedAttrs: {[index: number]: ExtendedAttrs} = {}; + protected _extendedAttrs: {[index: number]: IExtendedAttrs} = {}; public length: number; constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) { diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index a2c1b884..0dfa86fd 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -5,6 +5,7 @@ export const DEFAULT_COLOR = 256; export const DEFAULT_ATTR = (0 << 18) | (DEFAULT_COLOR << 9) | (256 << 0); +export const DEFAULT_EXT = 0; export const CHAR_DATA_ATTR_INDEX = 0; export const CHAR_DATA_CHAR_INDEX = 1; @@ -129,6 +130,13 @@ export const enum BgFlags { HAS_EXTENDED = 0x10000000 } +export const enum ExtFlags { + /** + * bit 27..32 (upper 3 unused) + */ + UNDERLINE_STYLE = 0x1C000000 +} + export const enum UnderlineStyle { NONE = 0, SINGLE = 1,