diff --git a/addons/addon-canvas/src/BaseRenderLayer.ts b/addons/addon-canvas/src/BaseRenderLayer.ts index e7e234003e..0868ba8eae 100644 --- a/addons/addon-canvas/src/BaseRenderLayer.ts +++ b/addons/addon-canvas/src/BaseRenderLayer.ts @@ -8,7 +8,7 @@ import { CellColorResolver } from 'browser/renderer/shared/CellColorResolver'; import { acquireTextureAtlas } from 'browser/renderer/shared/CharAtlasCache'; import { TEXT_BASELINE } from 'browser/renderer/shared/Constants'; import { tryDrawCustomChar } from 'browser/renderer/shared/CustomGlyphs'; -import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import { isEmoji, throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel'; import { IRasterizedGlyph, IRenderDimensions, ISelectionRenderModel, ITextureAtlas } from 'browser/renderer/shared/Types'; import { ICoreBrowserService, IThemeService } from 'browser/services/Services'; @@ -365,6 +365,8 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer */ protected _drawChars(cell: ICellData, x: number, y: number): void { const chars = cell.getChars(); + const code = cell.getCode(); + const width = cell.getWidth(); this._cellColorResolver.resolve(cell, x, this._bufferService.buffer.ydisp + y, this._deviceCellWidth); if (!this._charAtlas) { @@ -400,6 +402,23 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer this._bitmapGenerator[glyph.texturePage]!.refresh(); this._bitmapGenerator[glyph.texturePage]!.version = this._charAtlas.pages[glyph.texturePage].version; } + + // Reduce scale horizontally for wide glyphs printed in cells that would overlap with the + // following cell (ie. the width is not 2). + let renderWidth = glyph.size.x; + if (this._optionsService.rawOptions.rescaleOverlappingGlyphs) { + if ( + // Is single cell width + width === 1 && + // Glyph exceeds cell bounds, + 1 to avoid hurting readability + glyph.size.x > this._deviceCellWidth + 1 && + // Never rescale emoji + code && !isEmoji(code) + ) { + renderWidth = this._deviceCellWidth - 1; // - 1 to improve readability + } + } + this._ctx.drawImage( this._bitmapGenerator[glyph.texturePage]?.bitmap || this._charAtlas!.pages[glyph.texturePage].canvas, glyph.texturePosition.x, @@ -408,7 +427,7 @@ export abstract class BaseRenderLayer extends Disposable implements IRenderLayer glyph.size.y, x * this._deviceCellWidth + this._deviceCharLeft - glyph.offset.x, y * this._deviceCellHeight + this._deviceCharTop - glyph.offset.y, - glyph.size.x, + renderWidth, glyph.size.y ); this._ctx.restore(); diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 1fb0e18c54..7ac89084f1 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import { isEmoji, throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; import { TextureAtlas } from 'browser/renderer/shared/TextureAtlas'; import { IRasterizedGlyph, IRenderDimensions, ITextureAtlas } from 'browser/renderer/shared/Types'; import { NULL_CELL_CODE } from 'common/buffer/Constants'; @@ -11,6 +11,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle'; import { Terminal } from '@xterm/xterm'; import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types'; import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils'; +import type { IOptionsService } from 'common/services/Services'; interface IVertices { attributes: Float32Array; @@ -111,7 +112,8 @@ export class GlyphRenderer extends Disposable { constructor( private readonly _terminal: Terminal, private readonly _gl: IWebGL2RenderingContext, - private _dimensions: IRenderDimensions + private _dimensions: IRenderDimensions, + private readonly _optionsService: IOptionsService ) { super(); @@ -212,15 +214,15 @@ export class GlyphRenderer extends Disposable { return this._atlas ? this._atlas.beginFrame() : true; } - public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, lastBg: number): void { + public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void { // Since this function is called for every cell (`rows*cols`), it must be very optimized. It // should not instantiate any variables unless a new glyph is drawn to the cache where the // slight slowdown is acceptable for the developer ergonomics provided as it's a once of for // each glyph. - this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, lastBg); + this._updateCell(this._vertices.attributes, x, y, code, bg, fg, ext, chars, width, lastBg); } - private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: 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, width: number, lastBg: number): void { $i = (y * this._terminal.cols + x) * INDICES_PER_CELL; // Exit early if this is a null character, allow space character to continue as it may have @@ -275,6 +277,21 @@ export class GlyphRenderer extends Disposable { array[$i + 8] = $glyph.sizeClipSpace.y; } // a_cellpos only changes on resize + + // Reduce scale horizontally for wide glyphs printed in cells that would overlap with the + // following cell (ie. the width is not 2). + if (this._optionsService.rawOptions.rescaleOverlappingGlyphs) { + if ( + // Is single cell width + width === 1 && + // Glyph exceeds cell bounds, + 1 to avoid hurting readability + $glyph.size.x > this._dimensions.device.cell.width + 1 && + // Never rescale emoji + code && !isEmoji(code) + ) { + array[$i + 2] = (this._dimensions.device.cell.width - 1) / this._dimensions.device.canvas.width; // - 1 to improve readability + } + } } public clear(): void { diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 3a01e244b9..fa17865229 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -36,7 +36,8 @@ export class WebglRenderer extends Disposable implements IRenderer { private _observerDisposable = this.register(new MutableDisposable()); private _model: RenderModel = new RenderModel(); - private _workCell: CellData = new CellData(); + private _workCell: ICellData = new CellData(); + private _workCell2: ICellData = new CellData(); private _cellColorResolver: CellColorResolver; private _canvas: HTMLCanvasElement; @@ -245,7 +246,7 @@ export class WebglRenderer extends Disposable implements IRenderer { */ private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] { this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService); - this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions); + this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService); // Update dimensions and acquire char atlas this.handleCharSizeChanged(); @@ -388,6 +389,7 @@ export class WebglRenderer extends Disposable implements IRenderer { let range: [number, number]; let chars: string; let code: number; + let width: number; let i: number; let x: number; let j: number; @@ -500,7 +502,8 @@ export class WebglRenderer extends Disposable implements IRenderer { this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext; - this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, lastBg); + width = cell.getWidth(); + this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg); if (isJoined) { // Restore work cell @@ -509,7 +512,7 @@ export class WebglRenderer extends Disposable implements IRenderer { // Null out non-first cells for (x++; x < lastCharX; x++) { j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL; - this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0); + this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0); this._model.cells[j] = NULL_CELL_CODE; this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg; this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg; diff --git a/src/browser/renderer/shared/RendererUtils.ts b/src/browser/renderer/shared/RendererUtils.ts index 9a4bffe000..b9fc731241 100644 --- a/src/browser/renderer/shared/RendererUtils.ts +++ b/src/browser/renderer/shared/RendererUtils.ts @@ -27,6 +27,19 @@ function isBoxOrBlockGlyph(codepoint: number): boolean { return 0x2500 <= codepoint && codepoint <= 0x259F; } +export function isEmoji(codepoint: number): boolean { + return ( + codepoint >= 0x1F600 && codepoint <= 0x1F64F || // Emoticons + codepoint >= 0x1F300 && codepoint <= 0x1F5FF || // Misc Symbols and Pictographs + codepoint >= 0x1F680 && codepoint <= 0x1F6FF || // Transport and Map + codepoint >= 0x2600 && codepoint <= 0x26FF || // Misc symbols + codepoint >= 0x2700 && codepoint <= 0x27BF || // Dingbats + codepoint >= 0xFE00 && codepoint <= 0xFE0F || // Variation Selectors + codepoint >= 0x1F900 && codepoint <= 0x1F9FF || // Supplemental Symbols and Pictographs + codepoint >= 0x1F1E6 && codepoint <= 0x1F1FF + ); +} + export function treatGlyphAsBackgroundColor(codepoint: number): boolean { return isPowerlineGlyph(codepoint) || isBoxOrBlockGlyph(codepoint); } diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index a184641bb6..d4f2be4650 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -87,7 +87,8 @@ export class RenderService extends Disposable implements IRenderService { 'fontSize', 'fontWeight', 'fontWeightBold', - 'minimumContrastRatio' + 'minimumContrastRatio', + 'rescaleOverlappingGlyphs' ], () => { this.clear(); this.handleResize(bufferService.cols, bufferService.rows); diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index ba92992e9f..0375f6addb 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -44,6 +44,7 @@ export const DEFAULT_OPTIONS: Readonly> = { allowTransparency: false, tabStopWidth: 8, theme: {}, + rescaleOverlappingGlyphs: false, rightClickSelectsWord: isMac, windowOptions: {}, windowsMode: false, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 304e8cbb4c..210a0afb08 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -234,6 +234,7 @@ export interface ITerminalOptions { macOptionIsMeta?: boolean; macOptionClickForcesSelection?: boolean; minimumContrastRatio?: number; + rescaleOverlappingGlyphs?: boolean; rightClickSelectsWord?: boolean; rows?: number; screenReaderMode?: boolean; diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 4003460729..9c1feaea72 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -140,6 +140,17 @@ declare module '@xterm/headless' { */ minimumContrastRatio?: number; + /** + * Whether to rescale glyphs horizontally that are a single cell wide but + * have glyphs that would overlap following cell(s). This typically happens + * for ambiguous width characters (eg. the roman numeral characters U+2160+) + * which aren't featured in monospace fonts. Emoji glyphs are never + * rescaled. This is an important feature for achieving GB18030 compliance. + * + * Note that this doesn't work with the DOM renderer. The default is false. + */ + rescaleOverlappingGlyphs?: boolean; + /** * Whether to select the word under the cursor on right click, this is * standard behavior in a lot of macOS applications. diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a46db4be53..b4e01d84e4 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -209,6 +209,17 @@ declare module '@xterm/xterm' { */ minimumContrastRatio?: number; + /** + * Whether to rescale glyphs horizontally that are a single cell wide but + * have glyphs that would overlap following cell(s). This typically happens + * for ambiguous width characters (eg. the roman numeral characters U+2160+) + * which aren't featured in monospace fonts. Emoji glyphs are never + * rescaled. This is an important feature for achieving GB18030 compliance. + * + * Note that this doesn't work with the DOM renderer. The default is false. + */ + rescaleOverlappingGlyphs?: boolean; + /** * Whether to select the word under the cursor on right click, this is * standard behavior in a lot of macOS applications.