diff --git a/addons/addon-canvas/src/BaseRenderLayer.ts b/addons/addon-canvas/src/BaseRenderLayer.ts index c2ec1c363d..e7e234003e 100644 --- a/addons/addon-canvas/src/BaseRenderLayer.ts +++ b/addons/addon-canvas/src/BaseRenderLayer.ts @@ -58,7 +58,7 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer protected readonly _coreBrowserService: ICoreBrowserService ) { super(); - this._cellColorResolver = new CellColorResolver(this._terminal, this._selectionModel, this._decorationService, this._coreBrowserService, this._themeService); + this._cellColorResolver = new CellColorResolver(this._terminal, this._optionsService, this._selectionModel, this._decorationService, this._coreBrowserService, this._themeService); this._canvas = this._coreBrowserService.mainDocument.createElement('canvas'); this._canvas.classList.add(`xterm-${id}-layer`); this._canvas.style.zIndex = zIndex.toString(); @@ -365,7 +365,7 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer */ protected _drawChars(cell: ICellData, x: number, y: number): void { const chars = cell.getChars(); - this._cellColorResolver.resolve(cell, x, this._bufferService.buffer.ydisp + y); + this._cellColorResolver.resolve(cell, x, this._bufferService.buffer.ydisp + y, this._deviceCellWidth); if (!this._charAtlas) { return; diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts index 8b3891ee0a..f9b6eef68f 100644 --- a/addons/addon-image/src/ImageStorage.ts +++ b/addons/addon-image/src/ImageStorage.ts @@ -53,6 +53,18 @@ class ExtendedAttrsImage implements IExtendedAttrsImage { this._ext |= value & (Attributes.CM_MASK | Attributes.RGB_MASK); } + public get underlineVariantOffset(): number { + const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29; + if (val < 0) { + return val ^ 0xFFFFFFF8; + } + return val; + } + public set underlineVariantOffset(value: number) { + this._ext &= ~ExtFlags.VARIANT_OFFSET; + this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET; + } + private _urlId: number = 0; public get urlId(): number { return this._urlId; diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index d50d730115..bb17baa84c 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -75,7 +75,7 @@ export class WebglRenderer extends Disposable implements IRenderer { this.register(this._themeService.onChangeColors(() => this._handleColorChange())); - this._cellColorResolver = new CellColorResolver(this._terminal, this._model.selection, this._decorationService, this._coreBrowserService, this._themeService); + this._cellColorResolver = new CellColorResolver(this._terminal, this._optionsService, this._model.selection, this._decorationService, this._coreBrowserService, this._themeService); this._core = (this._terminal as any)._core; @@ -442,7 +442,7 @@ export class WebglRenderer extends Disposable implements IRenderer { i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; // Load colors/resolve overrides into work colors - this._cellColorResolver.resolve(cell, x, row); + this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { diff --git a/src/browser/renderer/shared/CellColorResolver.ts b/src/browser/renderer/shared/CellColorResolver.ts index 72a9fa3038..5837a675b2 100644 --- a/src/browser/renderer/shared/CellColorResolver.ts +++ b/src/browser/renderer/shared/CellColorResolver.ts @@ -1,8 +1,8 @@ import { ISelectionRenderModel } from 'browser/renderer/shared/Types'; import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; import { ReadonlyColorSet } from 'browser/Types'; -import { Attributes, BgFlags, FgFlags } from 'common/buffer/Constants'; -import { IDecorationService } from 'common/services/Services'; +import { Attributes, BgFlags, ExtFlags, FgFlags, NULL_CELL_CODE, UnderlineStyle } from 'common/buffer/Constants'; +import { IDecorationService, IOptionsService } from 'common/services/Services'; import { ICellData } from 'common/Types'; import { Terminal } from '@xterm/xterm'; @@ -13,6 +13,7 @@ let $hasFg = false; let $hasBg = false; let $isSelected = false; let $colors: ReadonlyColorSet | undefined; +let $variantOffset = 0; export class CellColorResolver { /** @@ -27,6 +28,7 @@ export class CellColorResolver { constructor( private readonly _terminal: Terminal, + private readonly _optionService: IOptionsService, private readonly _selectionRenderModel: ISelectionRenderModel, private readonly _decorationService: IDecorationService, private readonly _coreBrowserService: ICoreBrowserService, @@ -38,7 +40,7 @@ export class CellColorResolver { * Resolves colors for the cell, putting the result into the shared {@link result}. This resolves * overrides, inverse and selection for the cell which can then be used to feed into the renderer. */ - public resolve(cell: ICellData, x: number, y: number): void { + public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -52,6 +54,13 @@ export class CellColorResolver { $hasFg = false; $isSelected = false; $colors = this._themeService.colors; + $variantOffset = 0; + + const code = cell.getCode(); + if (code !== NULL_CELL_CODE && cell.extended.underlineStyle === UnderlineStyle.DOTTED) { + const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15)); + $variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2); + } // Apply decorations on the bottom layer this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { @@ -133,5 +142,9 @@ export class CellColorResolver { // Use the override if it exists this.result.bg = $hasBg ? $bg : this.result.bg; this.result.fg = $hasFg ? $fg : this.result.fg; + + // Reset overrides variantOffset + this.result.ext &= ~ExtFlags.VARIANT_OFFSET; + this.result.ext |= ($variantOffset << 29) & ExtFlags.VARIANT_OFFSET; } } diff --git a/src/browser/renderer/shared/RendererUtils.test.ts b/src/browser/renderer/shared/RendererUtils.test.ts new file mode 100644 index 0000000000..a050e8a94f --- /dev/null +++ b/src/browser/renderer/shared/RendererUtils.test.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2023 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { computeNextVariantOffset } from 'browser/renderer/shared/RendererUtils'; +import { assert } from 'chai'; + +describe('RendererUtils', () => { + it('computeNextVariantOffset', () => { + const cellWidth = 11; + const doubleCellWidth = 22; + let line = 1; + let variantOffset = 0; + + // should line 1 + // =,_,=_,=_, + let cells = [cellWidth, cellWidth, doubleCellWidth, doubleCellWidth]; + let result = [1, 0, 0, 0]; + for (let index = 0; index < cells.length; index++) { + const cell = cells[index]; + variantOffset = computeNextVariantOffset(cell, line, variantOffset); + assert.equal(variantOffset, result[index]); + } + + // should line 2 + // ==__==__==_,_==__==__==,__==__==__==__==__==__,==__==__==__==__==__==, + line = 2; + variantOffset = 0; + cells = [cellWidth, cellWidth, doubleCellWidth, doubleCellWidth]; + result = [3, 2, 0 ,2]; + for (let index = 0; index < cells.length; index++) { + const cell = cells[index]; + variantOffset = computeNextVariantOffset(cell, line, variantOffset); + assert.equal(variantOffset, result[index]); + } + + // should line 3 + // ===___===__,_===___===_,__===___===___===___==,=___===___===___===___, + line = 3; + variantOffset = 0; + cells = [cellWidth, cellWidth, doubleCellWidth, doubleCellWidth]; + result = [5, 4, 2, 0]; + for (let index = 0; index < cells.length; index++) { + const cell = cells[index]; + variantOffset = computeNextVariantOffset(cell, line, variantOffset); + assert.equal(variantOffset, result[index]); + } + }); +}); diff --git a/src/browser/renderer/shared/RendererUtils.ts b/src/browser/renderer/shared/RendererUtils.ts index 70c9ad86ca..59b87b0e30 100644 --- a/src/browser/renderer/shared/RendererUtils.ts +++ b/src/browser/renderer/shared/RendererUtils.ts @@ -56,3 +56,7 @@ function createDimension(): IDimensions { height: 0 }; } + +export function computeNextVariantOffset(cellWidth: number, lineWidth: number, currentOffset: number = 0): number { + return (cellWidth - (Math.round(lineWidth) * 2 - currentOffset)) % (Math.round(lineWidth) * 2); +} diff --git a/src/browser/renderer/shared/TextureAtlas.ts b/src/browser/renderer/shared/TextureAtlas.ts index 56c77b6835..d7f65d0034 100644 --- a/src/browser/renderer/shared/TextureAtlas.ts +++ b/src/browser/renderer/shared/TextureAtlas.ts @@ -6,7 +6,7 @@ import { IColorContrastCache } from 'browser/Types'; import { DIM_OPACITY, TEXT_BASELINE } from 'browser/renderer/shared/Constants'; import { tryDrawCustomChar } from 'browser/renderer/shared/CustomGlyphs'; -import { excludeFromContrastRatioDemands, isPowerlineGlyph, isRestrictedPowerlineGlyph, throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import { computeNextVariantOffset, excludeFromContrastRatioDemands, isPowerlineGlyph, isRestrictedPowerlineGlyph, throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; import { IBoundingBox, ICharAtlasConfig, IRasterizedGlyph, ITextureAtlas } from 'browser/renderer/shared/Types'; import { NULL_COLOR, color, rgba } from 'common/Color'; import { EventEmitter } from 'common/EventEmitter'; @@ -545,6 +545,7 @@ export class TextureAtlas implements ITextureAtlas { const yTop = Math.ceil(padding + this._config.deviceCharHeight) - yOffset - (restrictToCellHeight ? lineWidth * 2 : 0); const yMid = yTop + lineWidth; const yBot = yTop + lineWidth * 2; + let nextOffset = this._workAttributeData.getUnderlineVariantOffset(); for (let i = 0; i < chWidth; i++) { this._tmpCtx.save(); @@ -594,9 +595,22 @@ export class TextureAtlas implements ITextureAtlas { ); break; case UnderlineStyle.DOTTED: - this._tmpCtx.setLineDash([Math.round(lineWidth), Math.round(lineWidth)]); - this._tmpCtx.moveTo(xChLeft, yTop); - this._tmpCtx.lineTo(xChRight, yTop); + const offsetWidth = nextOffset === 0 ? 0 : + (nextOffset >= lineWidth ? lineWidth * 2 - nextOffset : lineWidth - nextOffset); + // a line and a gap. + const isLineStart = nextOffset >= lineWidth ? false : true; + if (isLineStart === false || offsetWidth === 0) { + this._tmpCtx.setLineDash([Math.round(lineWidth), Math.round(lineWidth)]); + this._tmpCtx.moveTo(xChLeft + offsetWidth, yTop); + this._tmpCtx.lineTo(xChRight, yTop); + } else { + this._tmpCtx.setLineDash([Math.round(lineWidth), Math.round(lineWidth)]); + this._tmpCtx.moveTo(xChLeft, yTop); + this._tmpCtx.lineTo(xChLeft + offsetWidth, yTop); + this._tmpCtx.moveTo(xChLeft + offsetWidth + lineWidth, yTop); + this._tmpCtx.lineTo(xChRight, yTop); + } + nextOffset = computeNextVariantOffset(xChRight - xChLeft, lineWidth, nextOffset); break; case UnderlineStyle.DASHED: this._tmpCtx.setLineDash([this._config.devicePixelRatio * 4, this._config.devicePixelRatio * 3]); diff --git a/src/common/Types.d.ts b/src/common/Types.d.ts index 14dee97212..bbe1c74114 100644 --- a/src/common/Types.d.ts +++ b/src/common/Types.d.ts @@ -119,6 +119,7 @@ export interface IExtendedAttrs { ext: number; underlineStyle: UnderlineStyle; underlineColor: number; + underlineVariantOffset: number; urlId: number; clone(): IExtendedAttrs; isEmpty(): boolean; @@ -209,6 +210,7 @@ export interface IAttributeData { isUnderlineColorPalette(): boolean; isUnderlineColorDefault(): boolean; getUnderlineStyle(): number; + getUnderlineVariantOffset(): number; } /** Cell data */ diff --git a/src/common/buffer/AttributeData.ts b/src/common/buffer/AttributeData.ts index f4d12c2bcc..6221fb81d2 100644 --- a/src/common/buffer/AttributeData.ts +++ b/src/common/buffer/AttributeData.ts @@ -126,6 +126,9 @@ export class AttributeData implements IAttributeData { ? (this.bg & BgFlags.HAS_EXTENDED ? this.extended.underlineStyle : UnderlineStyle.SINGLE) : UnderlineStyle.NONE; } + public getUnderlineVariantOffset(): number { + return this.extended.underlineVariantOffset; + } } @@ -174,6 +177,18 @@ export class ExtendedAttrs implements IExtendedAttrs { this._urlId = value; } + public get underlineVariantOffset(): number { + const val = (this._ext & ExtFlags.VARIANT_OFFSET) >> 29; + if (val < 0) { + return val ^ 0xFFFFFFF8; + } + return val; + } + public set underlineVariantOffset(value: number) { + this._ext &= ~ExtFlags.VARIANT_OFFSET; + this._ext |= (value << 29) & ExtFlags.VARIANT_OFFSET; + } + constructor( ext: number = 0, urlId: number = 0 diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index f2819aa8c4..8b9ec63e46 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -119,6 +119,18 @@ describe('AttributeData', () => { attrs.fg &= ~FgFlags.UNDERLINE; assert.equal(attrs.getUnderlineStyle(), UnderlineStyle.NONE); }); + it('getUnderlineVariantOffset', () => { + const attrs = new AttributeData(); + + // defaults to no offset + assert.equal(attrs.getUnderlineVariantOffset(), 0); + + // should return 0 - 7 + for (let i = 0; i < 8; ++i) { + attrs.extended.underlineVariantOffset = i; + assert.equal(attrs.getUnderlineVariantOffset(), i); + } + }); }); }); diff --git a/src/common/buffer/Constants.ts b/src/common/buffer/Constants.ts index f6a31be7b9..5ce075cf78 100644 --- a/src/common/buffer/Constants.ts +++ b/src/common/buffer/Constants.ts @@ -134,9 +134,17 @@ export const enum BgFlags { export const enum ExtFlags { /** - * bit 27..32 (upper 3 unused) + * bit 27..29 */ - UNDERLINE_STYLE = 0x1C000000 + UNDERLINE_STYLE = 0x1C000000, + + /** + * bit 30..32 + * + * An optional variant for the glyph, this can be used for example to offset underlines by a + * number of pixels to create a perfect pattern. + */ + VARIANT_OFFSET = 0xE0000000 } export const enum UnderlineStyle {