From dc4ebfd6e37c8a5b238378b5c67e5f8fe95484a6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 2 Feb 2026 08:07:04 -0800 Subject: [PATCH 01/23] Use offset variants for some custom glyphs Part of #5668 --- addons/addon-webgl/src/CellColorResolver.ts | 7 +++++- addons/addon-webgl/src/TextureAtlas.ts | 3 ++- addons/addon-webgl/src/WebglRenderer.ts | 2 +- .../src/customGlyphs/CustomGlyphRasterizer.ts | 22 ++++++++++++++----- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index 6f61a704..afeb7245 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -42,7 +42,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, deviceCellWidth: number): void { + public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -63,6 +63,11 @@ export class CellColorResolver { const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15)); $variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2); } + if ($variantOffset === 0) { + if ((code >= 0x2591 && code <= 0x2593) || (code >= 0x1FB8C && code <= 0x1FB94)) { + $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); + } + } // Apply decorations on the bottom layer this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index aa0e2653..58f8da28 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -525,7 +525,8 @@ export class TextureAtlas implements ITextureAtlas { // Draw custom characters if applicable let customGlyph = false; if (this._config.customGlyphs !== false) { - customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css); + const variantOffset = this._workAttributeData.getUnderlineVariantOffset(); + customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset); } // Whether to clear pixels based on a threshold difference between the glyph color and the diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 05af6527..bd98ce60 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -478,7 +478,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.dimensions.device.cell.width); + this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index 6302757f..08928a96 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -22,14 +22,15 @@ export function tryDrawCustomGlyph( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, - backgroundColor?: string + backgroundColor?: string, + variantOffset: number = 0 ): boolean { const unifiedCharDefinition = customGlyphDefinitions[c]; if (unifiedCharDefinition) { // Normalize to array for uniform handling const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition]; for (const part of parts) { - drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor); + drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset); } return true; } @@ -48,7 +49,8 @@ function drawDefinitionPart( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, - backgroundColor?: string + backgroundColor?: string, + variantOffset: number = 0 ): void { // Handle scaleType - adjust dimensions and offset when scaling to character area let drawWidth = deviceCellWidth; @@ -74,7 +76,7 @@ function drawDefinitionPart( drawBlockVectorChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight); break; case CustomGlyphDefinitionType.BLOCK_PATTERN: - drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight); + drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset); break; case CustomGlyphDefinitionType.PATH_FUNCTION: drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth); @@ -437,7 +439,8 @@ function drawPatternChar( xOffset: number, yOffset: number, deviceCellWidth: number, - deviceCellHeight: number + deviceCellHeight: number, + variantOffset: number = 0 ): void { let patternSet = cachedPatterns.get(charDefinition); if (!patternSet) { @@ -486,6 +489,15 @@ function drawPatternChar( pattern = throwIfFalsy(ctx.createPattern(tmpCanvas, null)); patternSet.set(fillStyle, pattern); } + // Apply pattern offset to ensure seamless tiling across cells when cell dimensions are odd. + // variantOffset encodes: bit 1 = x pixel shift, bit 0 = y pixel shift. + const dx = (variantOffset >> 1) & 1; + const dy = variantOffset & 1; + if (dx !== 0 || dy !== 0) { + pattern.setTransform(new DOMMatrix().translateSelf(-dx, -dy)); + } else { + pattern.setTransform(new DOMMatrix()); + } ctx.fillStyle = pattern; ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight); } From 93c165044669c67ebaf52ab2d5a3b03c750bdf2e Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 3 Feb 2026 03:37:57 -0800 Subject: [PATCH 02/23] Move variant handling out of CellColorResolver.ts --- addons/addon-webgl/src/CellColorResolver.ts | 8 +------- addons/addon-webgl/src/WebglRenderer.ts | 10 ++++++++-- .../src/customGlyphs/CustomGlyphDefinitions.ts | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 9 deletions(-) diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index afeb7245..a6acb996 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -42,7 +42,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, deviceCellWidth: number, deviceCellHeight: 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; @@ -63,12 +63,6 @@ export class CellColorResolver { const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15)); $variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2); } - if ($variantOffset === 0) { - if ((code >= 0x2591 && code <= 0x2593) || (code >= 0x1FB8C && code <= 0x1FB94)) { - $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); - } - } - // Apply decorations on the bottom layer this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { if (d.backgroundColorRGB) { diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index bd98ce60..6f365cc9 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -13,7 +13,7 @@ import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeS import { CharData, IBufferLine, ICellData } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; -import { Attributes, Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Attributes, Content, ExtFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { Terminal } from '@xterm/xterm'; import { GlyphRenderer } from './GlyphRenderer'; @@ -26,6 +26,7 @@ import { Emitter, EventUtils } from 'common/Event'; import { addDisposableListener } from 'vs/base/browser/dom'; import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; +import { blockPatternCodepoints } from './customGlyphs/CustomGlyphDefinitions'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; @@ -478,7 +479,12 @@ 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.dimensions.device.cell.width, this.dimensions.device.cell.height); + this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width); + if ((this._cellColorResolver.result.ext & ExtFlags.VARIANT_OFFSET) === 0 && blockPatternCodepoints.has(code)) { + const variantOffset = ((x * this.dimensions.device.cell.width) % 2) * 2 + ((row * this.dimensions.device.cell.height) % 2); + this._cellColorResolver.result.ext &= ~ExtFlags.VARIANT_OFFSET; + this._cellColorResolver.result.ext |= (variantOffset << 29) & ExtFlags.VARIANT_OFFSET; + } // Override colors for cursor cell if (isCursorVisible && row === cursorY) { diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts index 79efc306..9998363c 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts @@ -820,6 +820,20 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi // #endregion }; +export const blockPatternCodepoints = new Set(); +for (const [char, definition] of Object.entries(customGlyphDefinitions)) { + if (!definition) { + continue; + } + const parts = Array.isArray(definition) ? definition : [definition]; + if (parts.some(part => part.type === CustomGlyphDefinitionType.BLOCK_PATTERN)) { + const codepoint = char.codePointAt(0); + if (codepoint !== undefined) { + blockPatternCodepoints.add(codepoint); + } + } +} + /** * Generates a drawing function for sextant characters. Sextants are a 2x3 grid where each cell * can be on or off. From 9a778f0c9877ab3a1fdf625e01c74435e5e46526 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:53:02 -0800 Subject: [PATCH 03/23] Move back to CellColorResolver --- addons/addon-webgl/src/CellColorResolver.ts | 6 +++++- addons/addon-webgl/src/WebglRenderer.ts | 10 ++-------- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/addons/addon-webgl/src/CellColorResolver.ts b/addons/addon-webgl/src/CellColorResolver.ts index a6acb996..3a4661cc 100644 --- a/addons/addon-webgl/src/CellColorResolver.ts +++ b/addons/addon-webgl/src/CellColorResolver.ts @@ -7,6 +7,7 @@ import { ICellData } from 'common/Types'; import { Terminal } from '@xterm/xterm'; import { rgba } from 'common/Color'; import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils'; +import { blockPatternCodepoints } from './customGlyphs/CustomGlyphDefinitions'; // Work variables to avoid garbage collection let $fg = 0; @@ -42,7 +43,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, deviceCellWidth: number): void { + public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void { this.result.bg = cell.bg; this.result.fg = cell.fg; this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0; @@ -63,6 +64,9 @@ export class CellColorResolver { const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15)); $variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2); } + if ($variantOffset === 0 && blockPatternCodepoints.has(code)) { + $variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2); + } // Apply decorations on the bottom layer this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => { if (d.backgroundColorRGB) { diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 6f365cc9..bd98ce60 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -13,7 +13,7 @@ import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeS import { CharData, IBufferLine, ICellData } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; -import { Attributes, Content, ExtFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; +import { Attributes, Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; import { Terminal } from '@xterm/xterm'; import { GlyphRenderer } from './GlyphRenderer'; @@ -26,7 +26,6 @@ import { Emitter, EventUtils } from 'common/Event'; import { addDisposableListener } from 'vs/base/browser/dom'; import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils'; -import { blockPatternCodepoints } from './customGlyphs/CustomGlyphDefinitions'; export class WebglRenderer extends Disposable implements IRenderer { private _renderLayers: IRenderLayer[]; @@ -479,12 +478,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.dimensions.device.cell.width); - if ((this._cellColorResolver.result.ext & ExtFlags.VARIANT_OFFSET) === 0 && blockPatternCodepoints.has(code)) { - const variantOffset = ((x * this.dimensions.device.cell.width) % 2) * 2 + ((row * this.dimensions.device.cell.height) % 2); - this._cellColorResolver.result.ext &= ~ExtFlags.VARIANT_OFFSET; - this._cellColorResolver.result.ext |= (variantOffset << 29) & ExtFlags.VARIANT_OFFSET; - } + this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height); // Override colors for cursor cell if (isCursorVisible && row === cursorY) { From db0226be5cebc075a18d7443acb945840d67039d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 3 Feb 2026 04:59:10 -0800 Subject: [PATCH 04/23] Add triangular shade chars fill tests --- demo/client/components/window/testWindow.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 6f2c6c07..2a5f8b4f 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -397,6 +397,13 @@ function customGlyphAlignmentHandler(term: Terminal): void { } } + term.write('\x1b[0mTriangular fill tests:\x1b[36m\n\r'); + term.write('1FB9C 1FB9D 1FB9E 1FB9F all\n\r'); + term.write('\u{1FB90}\u{1FB90}\u{1FB90}\u{1FB9C} \u{1FB9D}\u{1FB90}\u{1FB90}\u{1FB90} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r'); + term.write('\u{1FB90}\u{1FB90}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{1FB90}\u{1FB90} \u{00020}\u{00020}\u{1FB9E}\u{1FB90} \u{1FB90}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{1FB90}\u{1FB90}\u{1FB9F}\n\r'); + term.write('\u{1FB90}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{1FB90} \u{00020}\u{1FB9E}\u{1FB90}\u{1FB90} \u{1FB90}\u{1FB90}\u{1FB9F}\u{00020} \u{1FB9D}\u{1FB90}\u{1FB90}\u{1FB9C}\n\r'); + term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{1FB90}\u{1FB90}\u{1FB90} \u{1FB90}\u{1FB90}\u{1FB90}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r'); + term.write('\x1b[0mPowerline alignment tests:\n\r'); const powerlineLeftChars = ['\u{E0B2}', '\u{E0B3}', '\u{E0B6}', '\u{E0B7}', '\u{E0BA}', '\u{E0BB}', '\u{E0BE}', '\u{E0BF}', '\u{E0C2}', '\u{E0C3}', '\u{E0C5}', '\u{E0C7}', '\u{E0CA}', '\u{E0D4}']; const powerlineRightChars = ['\u{E0B0}', '\u{E0B1}', '\u{E0B4}', '\u{E0B5}', '\u{E0B8}', '\u{E0B9}', '\u{E0BC}', '\u{E0BD}', '\u{E0C0}', '\u{E0C1}', '\u{E0C4}', '\u{E0C6}', '\u{E0C8}', '\u{E0D2}', '\u{E0CC}', '\u{E0CD}', '\u{E0CE}', '\u{E0CF}', '\u{E0D0}', '\u{E0D1}']; From 8b0a6f97857483c000cf011aa8fec013b5f89c2d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 3 Feb 2026 05:04:03 -0800 Subject: [PATCH 05/23] Enumerate all block pattern codepoints --- .../customGlyphs/CustomGlyphDefinitions.ts | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts index 9998363c..75e53930 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts @@ -820,19 +820,26 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi // #endregion }; -export const blockPatternCodepoints = new Set(); -for (const [char, definition] of Object.entries(customGlyphDefinitions)) { - if (!definition) { - continue; - } - const parts = Array.isArray(definition) ? definition : [definition]; - if (parts.some(part => part.type === CustomGlyphDefinitionType.BLOCK_PATTERN)) { - const codepoint = char.codePointAt(0); - if (codepoint !== undefined) { - blockPatternCodepoints.add(codepoint); - } - } -} +export const blockPatternCodepoints = new Set([ + // Shade characters (2591-2593) + 0x2591, + 0x2592, + 0x2593, + // Rectangular shade characters (1FB8C-1FB94) + 0x1FB8C, + 0x1FB8D, + 0x1FB8E, + 0x1FB8F, + 0x1FB90, + 0x1FB91, + 0x1FB92, + 0x1FB94, + // Triangular shade characters (1FB9C-1FB9F) + 0x1FB9C, + 0x1FB9D, + 0x1FB9E, + 0x1FB9F +]); /** * Generates a drawing function for sextant characters. Sextants are a 2x3 grid where each cell From cba6e3d191cb1418dad76aaaa3d589996ed9f823 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 3 Feb 2026 15:49:07 -0800 Subject: [PATCH 06/23] Add api to expose screenElement --- src/browser/public/Terminal.ts | 1 + test/playwright/TestUtils.ts | 1 + typings/xterm.d.ts | 6 ++++++ 3 files changed, 8 insertions(+) diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts index 4a77fd4a..f25da387 100644 --- a/src/browser/public/Terminal.ts +++ b/src/browser/public/Terminal.ts @@ -83,6 +83,7 @@ export class Terminal extends Disposable implements ITerminalApi { public get onDimensionsChange(): IEvent { return this._core.onDimensionsChange; } public get element(): HTMLElement | undefined { return this._core.element; } + public get screenElement(): HTMLElement | undefined { return this._core.screenElement; } public get parser(): IParser { return this._parser ??= new ParserApi(this._core); } diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts index 59bcc1e8..f1569d41 100644 --- a/test/playwright/TestUtils.ts +++ b/test/playwright/TestUtils.ts @@ -115,6 +115,7 @@ type TerminalProxyAsyncMethodOverrides = 'hasSelection' | 'getSelection' | 'getS type TerminalProxyCustomOverrides = 'buffer' | 'dimensions' | ( // The below are not implemented yet 'element' | + 'screenElement' | 'textarea' | 'markers' | 'unicode' | diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 62de0ab2..5f645ee8 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -911,6 +911,12 @@ declare module '@xterm/xterm' { */ readonly element: HTMLElement | undefined; + /** + * The screen element containing the terminal's canvas rendering layers and decorations, + * excluding the viewport and the scrollbar. + */ + readonly screenElement: HTMLElement | undefined; + /** * The textarea that accepts input for the terminal. */ From 32e77c3b8111c05eda14ecb4e87b1ab6af0c7560 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 3 Feb 2026 23:24:57 -0800 Subject: [PATCH 07/23] Lint --- typings/xterm.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 5f645ee8..4bc4c148 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -912,8 +912,8 @@ declare module '@xterm/xterm' { readonly element: HTMLElement | undefined; /** - * The screen element containing the terminal's canvas rendering layers and decorations, - * excluding the viewport and the scrollbar. + * The screen element containing the terminal's canvas rendering layers + * and decorations, excluding the viewport and the scrollbar. */ readonly screenElement: HTMLElement | undefined; From b0d0bf81871541506f7279ecc03fdb64c19c75a2 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Wed, 4 Feb 2026 04:32:37 -0800 Subject: [PATCH 08/23] Update typings/xterm.d.ts --- typings/xterm.d.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 4bc4c148..39b0d592 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -912,9 +912,9 @@ declare module '@xterm/xterm' { readonly element: HTMLElement | undefined; /** - * The screen element containing the terminal's canvas rendering layers - * and decorations, excluding the viewport and the scrollbar. - */ + * The screen element containing the terminal's canvas rendering layers and + * decorations, excluding the viewport and the scrollbar. + */ readonly screenElement: HTMLElement | undefined; /** From 2e776c08202d2dda8225f683dcdb4c44037fff38 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 5 Feb 2026 06:40:50 -0800 Subject: [PATCH 09/23] Use ILogService in TaskQueue --- addons/addon-webgl/src/CharAtlasCache.ts | 4 +++- addons/addon-webgl/src/TextureAtlas.ts | 7 ++++--- src/browser/services/RenderService.ts | 7 +++++-- src/common/CoreTerminal.ts | 4 ++-- src/common/InputHandler.test.ts | 6 +++--- src/common/SortedList.test.ts | 5 +++-- src/common/SortedList.ts | 10 +++++++--- src/common/TaskQueue.ts | 12 +++++++++--- src/common/TestUtils.test.ts | 2 +- src/common/buffer/Buffer.test.ts | 14 +++++++------- src/common/buffer/Buffer.ts | 11 ++++++----- src/common/buffer/BufferSet.test.ts | 5 +++-- src/common/buffer/BufferSet.ts | 9 +++++---- src/common/services/BufferService.ts | 9 ++++++--- src/common/services/DecorationService.test.ts | 11 ++++++----- src/common/services/DecorationService.ts | 8 +++++--- src/common/services/OscLinkService.test.ts | 3 ++- 17 files changed, 77 insertions(+), 50 deletions(-) diff --git a/addons/addon-webgl/src/CharAtlasCache.ts b/addons/addon-webgl/src/CharAtlasCache.ts index 62ff4ff9..823a74cc 100644 --- a/addons/addon-webgl/src/CharAtlasCache.ts +++ b/addons/addon-webgl/src/CharAtlasCache.ts @@ -8,6 +8,7 @@ import { ITerminalOptions, Terminal } from '@xterm/xterm'; import { ITerminal, ReadonlyColorSet } from 'browser/Types'; import { ICharAtlasConfig, ITextureAtlas } from './Types'; import { generateConfig, configEquals } from './CharAtlasUtils'; +import type { ILogService } from 'common/services/Services'; interface ITextureAtlasCacheEntry { atlas: ITextureAtlas; @@ -67,8 +68,9 @@ export function acquireTextureAtlas( } const core: ITerminal = (terminal as any)._core; + const logService = (core as any)._logService as ILogService; const newEntry: ITextureAtlasCacheEntry = { - atlas: new TextureAtlas(document, newConfig, core.unicodeService), + atlas: new TextureAtlas(document, newConfig, core.unicodeService, logService), config: newConfig, ownedBy: [terminal] }; diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index aa0e2653..97046ba0 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -14,7 +14,7 @@ import { IdleTaskQueue } from 'common/TaskQueue'; import { IColor } from 'common/Types'; import { AttributeData } from 'common/buffer/AttributeData'; import { Attributes, DEFAULT_COLOR, DEFAULT_EXT, UnderlineStyle } from 'common/buffer/Constants'; -import { IUnicodeService } from 'common/services/Services'; +import { ILogService, IUnicodeService } from 'common/services/Services'; import { Emitter } from 'common/Event'; /** @@ -88,7 +88,8 @@ export class TextureAtlas implements ITextureAtlas { constructor( private readonly _document: Document, private readonly _config: ICharAtlasConfig, - private readonly _unicodeService: IUnicodeService + private readonly _unicodeService: IUnicodeService, + private readonly _logService: ILogService ) { this._createNewPage(); this._tmpCanvas = createCanvas( @@ -119,7 +120,7 @@ export class TextureAtlas implements ITextureAtlas { private _doWarmUp(): void { // Pre-fill with ASCII 33-126, this is not urgent and done in idle callbacks - const queue = new IdleTaskQueue(); + const queue = new IdleTaskQueue(this._logService); for (let i = 33; i < 126; i++) { queue.enqueue(() => { if (!this._cacheMap.get(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT)) { diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 63f83c5b..ada3df30 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types'; import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services'; import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import { DebouncedIdleTask } from 'common/TaskQueue'; -import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; interface ISelectionState { @@ -27,7 +27,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderer: MutableDisposable = this._register(new MutableDisposable()); private _renderDebouncer: IRenderDebouncerWithCallback; - private _pausedResizeTask = new DebouncedIdleTask(); + private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); private _isPaused: boolean = false; @@ -58,6 +58,7 @@ export class RenderService extends Disposable implements IRenderService { private _rowCount: number, screenElement: HTMLElement, @IOptionsService private readonly _optionsService: IOptionsService, + @ILogService private readonly _logService: ILogService, @ICharSizeService private readonly _charSizeService: ICharSizeService, @ICoreService private readonly _coreService: ICoreService, @IDecorationService decorationService: IDecorationService, @@ -67,6 +68,8 @@ export class RenderService extends Disposable implements IRenderService { ) { super(); + this._pausedResizeTask = new DebouncedIdleTask(this._logService); + this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService); this._register(this._renderDebouncer); diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 99d1411c..0ce19a4d 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -107,10 +107,10 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { this._instantiationService = new InstantiationService(); this.optionsService = this._register(new OptionsService(options)); this._instantiationService.setService(IOptionsService, this.optionsService); - this._bufferService = this._register(this._instantiationService.createInstance(BufferService)); - this._instantiationService.setService(IBufferService, this._bufferService); this._logService = this._register(this._instantiationService.createInstance(LogService)); this._instantiationService.setService(ILogService, this._logService); + this._bufferService = this._register(this._instantiationService.createInstance(BufferService)); + this._instantiationService.setService(IBufferService, this._bufferService); this.coreService = this._register(this._instantiationService.createInstance(CoreService)); this._instantiationService.setService(ICoreService, this.coreService); this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService)); diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index d7026e50..f36c28b4 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -65,7 +65,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService(); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); oscLinkService = new OscLinkService(bufferService); @@ -2458,7 +2458,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService({ vtExtensions: { kittyKeyboard: true } }); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService()); @@ -2508,7 +2508,7 @@ describe('InputHandler', () => { beforeEach(() => { optionsService = new MockOptionsService(); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); bufferService.resize(80, 30); coreService = new CoreService(bufferService, new MockLogService(), optionsService); coreService.onData(data => { console.log(data); }); diff --git a/src/common/SortedList.test.ts b/src/common/SortedList.test.ts index d2e01ba8..4718cf97 100644 --- a/src/common/SortedList.test.ts +++ b/src/common/SortedList.test.ts @@ -5,6 +5,7 @@ import { assert } from 'chai'; import { SortedList } from 'common/SortedList'; +import { MockLogService } from 'common/TestUtils.test'; const deepStrictEqual = assert.deepStrictEqual; @@ -15,7 +16,7 @@ describe('SortedList', () => { } beforeEach(() => { - list = new SortedList(e => e); + list = new SortedList(e => e, new MockLogService()); }); describe('insert', () => { @@ -90,7 +91,7 @@ describe('SortedList', () => { assertList([]); }); it('custom key', () => { - const customList = new SortedList<{ key: number }>(e => e.key); + const customList = new SortedList<{ key: number }>(e => e.key, new MockLogService()); customList.insert({ key: 5 }); customList.insert({ key: 2 }); customList.insert({ key: 10 }); diff --git a/src/common/SortedList.ts b/src/common/SortedList.ts index 82b6dfa6..c6dc6208 100644 --- a/src/common/SortedList.ts +++ b/src/common/SortedList.ts @@ -4,6 +4,7 @@ */ import { IdleTaskQueue } from 'common/TaskQueue'; +import type { ILogService } from 'common/services/Services'; // Work variables to avoid garbage collection. let i = 0; @@ -18,16 +19,19 @@ export class SortedList { private _array: T[] = []; private readonly _insertedValues: T[] = []; - private readonly _flushInsertedTask = new IdleTaskQueue(); + private readonly _flushInsertedTask: InstanceType; private _isFlushingInserted = false; private readonly _deletedIndices: number[] = []; - private readonly _flushDeletedTask = new IdleTaskQueue(); + private readonly _flushDeletedTask: InstanceType; private _isFlushingDeleted = false; constructor( - private readonly _getKey: (value: T) => number + private readonly _getKey: (value: T) => number, + logService: ILogService ) { + this._flushInsertedTask = new IdleTaskQueue(logService); + this._flushDeletedTask = new IdleTaskQueue(logService); } public clear(): void { diff --git a/src/common/TaskQueue.ts b/src/common/TaskQueue.ts index 40cddffd..508e5d46 100644 --- a/src/common/TaskQueue.ts +++ b/src/common/TaskQueue.ts @@ -4,6 +4,7 @@ */ import { isNode } from 'common/Platform'; +import type { ILogService } from 'common/services/Services'; interface ITaskQueue { /** @@ -34,6 +35,11 @@ abstract class TaskQueue implements ITaskQueue { private _tasks: (() => boolean | void)[] = []; private _idleCallback?: number; private _i = 0; + protected readonly _logService: ILogService; + + constructor(logService: ILogService) { + this._logService = logService; + } protected abstract _requestCallback(callback: CallbackWithDeadline): number; protected abstract _cancelCallback(identifier: number): void; @@ -90,7 +96,7 @@ abstract class TaskQueue implements ITaskQueue { // Warn when the time exceeding the deadline is over 20ms, if this happens in practice the // task should be split into sub-tasks to ensure the UI remains responsive. if (lastDeadlineRemaining - taskDuration < -20) { - console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); + this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`); } this._start(); return; @@ -151,8 +157,8 @@ export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? Idle export class DebouncedIdleTask { private _queue: ITaskQueue; - constructor() { - this._queue = new IdleTaskQueue(); + constructor(logService: ILogService) { + this._queue = new IdleTaskQueue(logService); } public set(task: () => boolean | void): void { diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 908cae0c..6e6f0b64 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -27,7 +27,7 @@ export class MockBufferService implements IBufferService { public rows: number, optionsService: IOptionsService = new MockOptionsService() ) { - this.buffers = new BufferSet(optionsService, this); + this.buffers = new BufferSet(optionsService, this, new MockLogService()); // Listen to buffer activation events and automatically fire scroll events this.buffers.onBufferActivate(e => { this._onScroll.fire(e.activeBuffer.ydisp); diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 39cffb49..f9e3e1b1 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { Buffer } from 'common/buffer/Buffer'; import { CircularList } from 'common/CircularList'; -import { MockOptionsService, MockBufferService } from 'common/TestUtils.test'; +import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; import { CellData } from 'common/buffer/CellData'; import { ExtendedAttrs } from 'common/buffer/AttributeData'; @@ -23,7 +23,7 @@ describe('Buffer', () => { beforeEach(() => { optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK }); bufferService = new MockBufferService(INIT_COLS, INIT_ROWS); - buffer = new Buffer(true, optionsService, bufferService); + buffer = new Buffer(true, optionsService, bufferService, new MockLogService()); }); describe('constructor', () => { @@ -151,7 +151,7 @@ describe('Buffer', () => { describe('no scrollback', () => { it('should trim from the top of the buffer when the cursor reaches the bottom', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); assert.equal(buffer.lines.maxLength, INIT_ROWS); buffer.y = INIT_ROWS - 1; buffer.fillViewportRows(); @@ -1054,7 +1054,7 @@ describe('Buffer', () => { describe('buffer marked to have no scrollback', () => { it('should always have a scrollback of 0', () => { // Test size on initialization - buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService); + buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.lines.maxLength, INIT_ROWS); // Test size on buffer increase @@ -1068,7 +1068,7 @@ describe('Buffer', () => { describe('addMarker', () => { it('should adjust a marker line when the buffer is trimmed', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); const marker = buffer.addMarker(buffer.lines.length - 1); assert.equal(marker.line, buffer.lines.length - 1); @@ -1076,7 +1076,7 @@ describe('Buffer', () => { assert.equal(marker.line, buffer.lines.length - 2); }); it('should dispose of a marker if it is trimmed off the buffer', () => { - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); @@ -1088,7 +1088,7 @@ describe('Buffer', () => { }); it('should call onDispose', () => { const eventStack: string[] = []; - buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService); + buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService()); buffer.fillViewportRows(); assert.equal(buffer.markers.length, 0); const marker = buffer.addMarker(0); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index b738ffef..8efefd60 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -14,7 +14,7 @@ import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, import { Marker } from 'common/buffer/Marker'; import { IBuffer } from 'common/buffer/Types'; import { DEFAULT_CHARSET } from 'common/data/Charsets'; -import { IBufferService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1 @@ -48,11 +48,14 @@ export class Buffer implements IBuffer { private _cols: number; private _rows: number; private _isClearing: boolean = false; + private _memoryCleanupQueue: InstanceType; + private _memoryCleanupPosition = 0; constructor( private _hasScrollback: boolean, private _optionsService: IOptionsService, - private _bufferService: IBufferService + private _bufferService: IBufferService, + private readonly _logService: ILogService ) { this._cols = this._bufferService.cols; this._rows = this._bufferService.rows; @@ -60,6 +63,7 @@ export class Buffer implements IBuffer { this.scrollTop = 0; this.scrollBottom = this._rows - 1; this.setupTabStops(); + this._memoryCleanupQueue = new IdleTaskQueue(this._logService); } public getNullCell(attr?: IAttributeData): ICellData { @@ -277,9 +281,6 @@ export class Buffer implements IBuffer { } } - private _memoryCleanupQueue = new IdleTaskQueue(); - private _memoryCleanupPosition = 0; - private _batchedMemoryCleanup(): boolean { let normalRun = true; if (this._memoryCleanupPosition >= this.lines.length) { diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 944b8c15..96737fee 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { BufferSet } from 'common/buffer/BufferSet'; import { Buffer } from 'common/buffer/Buffer'; -import { MockOptionsService, MockBufferService } from 'common/TestUtils.test'; +import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test'; describe('BufferSet', () => { let bufferSet: BufferSet; @@ -14,7 +14,8 @@ describe('BufferSet', () => { beforeEach(() => { bufferSet = new BufferSet( new MockOptionsService({ scrollback: 1000 }), - new MockBufferService(80, 24) + new MockBufferService(80, 24), + new MockLogService() ); }); diff --git a/src/common/buffer/BufferSet.ts b/src/common/buffer/BufferSet.ts index d83d3925..772a7644 100644 --- a/src/common/buffer/BufferSet.ts +++ b/src/common/buffer/BufferSet.ts @@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData } from 'common/Types'; import { Buffer } from 'common/buffer/Buffer'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferService, IOptionsService } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService } from 'common/services/Services'; import { Emitter } from 'common/Event'; /** @@ -27,7 +27,8 @@ export class BufferSet extends Disposable implements IBufferSet { */ constructor( private readonly _optionsService: IOptionsService, - private readonly _bufferService: IBufferService + private readonly _bufferService: IBufferService, + private readonly _logService: ILogService ) { super(); this.reset(); @@ -36,12 +37,12 @@ export class BufferSet extends Disposable implements IBufferSet { } public reset(): void { - this._normal = new Buffer(true, this._optionsService, this._bufferService); + this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService); this._normal.fillViewportRows(); // The alt buffer should never have scrollback. // See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer - this._alt = new Buffer(false, this._optionsService, this._bufferService); + this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService); this._activeBuffer = this._normal; this._onBufferActivate.fire({ activeBuffer: this._normal, diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 2e6227fe..6a7ce2b3 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle'; import { IAttributeData, IBufferLine } from 'common/Types'; import { BufferSet } from 'common/buffer/BufferSet'; import { IBuffer, IBufferSet } from 'common/buffer/Types'; -import { IBufferService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; +import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services'; import { Emitter } from 'common/Event'; export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars @@ -32,11 +32,14 @@ export class BufferService extends Disposable implements IBufferService { /** An IBufferline to clone/copy from for new blank lines */ private _cachedBlankLine: IBufferLine | undefined; - constructor(@IOptionsService optionsService: IOptionsService) { + constructor( + @IOptionsService optionsService: IOptionsService, + @ILogService logService: ILogService + ) { super(); this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS); this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS); - this.buffers = this._register(new BufferSet(optionsService, this)); + this.buffers = this._register(new BufferSet(optionsService, this, logService)); this._register(this.buffers.onBufferActivate(e => { this._onScroll.fire(e.activeBuffer.ydisp); })); diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 365be087..95c7c0e2 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -8,6 +8,7 @@ import { DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; +import { MockLogService } from 'common/TestUtils.test'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -22,7 +23,7 @@ const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -34,7 +35,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -47,7 +48,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -73,7 +74,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -102,7 +103,7 @@ describe('DecorationService', () => { describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(); + const service = new DecorationService(new MockLogService()); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 92e06089..133e1a98 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -5,7 +5,7 @@ import { css } from 'common/Color'; import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration } from 'common/services/Services'; +import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; import { IColor } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; @@ -25,7 +25,7 @@ export class DecorationService extends Disposable implements IDecorationService * while marker line values do change, they should all change by the same amount so this should * never become out of order. */ - private readonly _decorations: SortedList = new SortedList(e => e?.marker.line); + private readonly _decorations: SortedList; private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; @@ -34,9 +34,11 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor() { + constructor(@ILogService private readonly _logService: ILogService) { super(); + this._decorations = new SortedList(e => e?.marker.line, this._logService); + this._register(toDisposable(() => this.reset())); } diff --git a/src/common/services/OscLinkService.test.ts b/src/common/services/OscLinkService.test.ts index 5000e8e2..cc6c2228 100644 --- a/src/common/services/OscLinkService.test.ts +++ b/src/common/services/OscLinkService.test.ts @@ -9,6 +9,7 @@ import { BufferService } from 'common/services/BufferService'; import { OptionsService } from 'common/services/OptionsService'; import { OscLinkService } from 'common/services/OscLinkService'; import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; +import { MockLogService } from 'common/TestUtils.test'; describe('OscLinkService', () => { describe('constructor', () => { @@ -17,7 +18,7 @@ describe('OscLinkService', () => { let oscLinkService: IOscLinkService; beforeEach(() => { optionsService = new OptionsService({ rows: 3, cols: 10 }); - bufferService = new BufferService(optionsService); + bufferService = new BufferService(optionsService, new MockLogService()); oscLinkService = new OscLinkService(bufferService); }); From c2db7d4c8a777762f9388e81139dc99da1744e73 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 5 Feb 2026 07:20:23 -0800 Subject: [PATCH 10/23] Add scrollbar.showScrollbar Fixes #5676 --- addons/addon-fit/src/FitAddon.ts | 5 +- demo/client/client.ts | 2 +- .../client/components/window/optionsWindow.ts | 2 + src/browser/CoreBrowserTerminal.ts | 12 ++- src/browser/Viewport.ts | 12 ++- .../decorations/OverviewRulerRenderer.ts | 7 +- src/browser/scrollable/abstractScrollbar.ts | 3 +- src/browser/scrollable/scrollableElement.ts | 6 ++ .../scrollable/scrollableElementOptions.ts | 2 + src/browser/scrollable/scrollbarState.ts | 10 ++- src/browser/scrollable/verticalScrollbar.ts | 77 ++++++++++++++----- src/common/services/OptionsService.ts | 2 +- src/common/services/Services.ts | 1 + typings/xterm.d.ts | 6 ++ 14 files changed, 115 insertions(+), 32 deletions(-) diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 3b06fb73..c51cd63d 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -69,9 +69,10 @@ export class FitAddon implements ITerminalAddon, IFitApi { return undefined; } - const scrollbarWidth = (this._terminal.options.scrollback === 0 + const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true; + const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar ? 0 - : (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); + : (this._terminal.options.overviewRuler?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/demo/client/client.ts b/demo/client/client.ts index 1fcc6db0..fa249a9e 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -284,7 +284,7 @@ function createTerminal(): Terminal { const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0; term = new Terminal({ - scrollbar: { showArrows: true }, + scrollbar: { showScrollbar: true }, allowProposedApi: true, windowsPty: isWindows ? { // In a real scenario, these values should be verified on the backend diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index b97204d2..dfea4aed 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -123,6 +123,8 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'windowsPty', ]; const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [ + { label: 'scrollbar.showScrollbar', parent: 'scrollbar', prop: 'showScrollbar' }, + { label: 'scrollbar.showArrows', parent: 'scrollbar', prop: 'showArrows' }, { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' }, { label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' }, { label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' } diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index b7958570..902f5d35 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -616,11 +616,19 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { } this._register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e))); - if (this.options.overviewRuler.width) { + const showScrollbar = this.options.scrollbar?.showScrollbar ?? true; + if (showScrollbar && this.options.overviewRuler.width) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } this.optionsService.onSpecificOptionChange('overviewRuler', value => { - if (!this._overviewRulerRenderer && value && this._viewportElement && this.screenElement) { + const shouldShow = (this.options.scrollbar?.showScrollbar ?? true) && !!value?.width; + if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) { + this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); + } + }); + this.optionsService.onSpecificOptionChange('scrollbar', value => { + const shouldShow = (value?.showScrollbar ?? true) && !!this.options.overviewRuler.width; + if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } }); diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index f036f619..49e744cf 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -62,7 +62,8 @@ export class Viewport extends Disposable { this._register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', - 'overviewRuler' + 'overviewRuler', + 'scrollbar' ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); // Don't handle mouse wheel if wheel events are supported by the current mouse prototcol this._register(coreMouseService.onProtocolChange(type => { @@ -131,10 +132,17 @@ export class Viewport extends Disposable { } private _getChangeOptions(): IScrollableElementChangeOptions { + const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; + const showArrows = this._optionsService.rawOptions.scrollbar?.showArrows ?? false; + const verticalScrollbarSize = showScrollbar + ? (this._optionsService.rawOptions.overviewRuler?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH) + : 0; return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity, - verticalScrollbarSize: this._optionsService.rawOptions.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH + vertical: showScrollbar ? ScrollbarVisibility.AUTO : ScrollbarVisibility.HIDDEN, + verticalScrollbarSize, + verticalHasArrows: showArrows }; } diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index ea36cf76..576cdf6f 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -38,7 +38,11 @@ export class OverviewRulerRenderer extends Disposable { private readonly _ctx: CanvasRenderingContext2D; private readonly _colorZoneStore: IColorZoneStore = new ColorZoneStore(); private get _width(): number { - return this._optionsService.options.overviewRuler?.width || 0; + const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; + if (!showScrollbar) { + return 0; + } + return this._optionsService.rawOptions.overviewRuler?.width ?? 0; } private _animationFrame: number | undefined; @@ -96,6 +100,7 @@ export class OverviewRulerRenderer extends Disposable { this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true))); this._register(this._optionsService.onSpecificOptionChange('overviewRuler', () => this._queueRefresh(true))); + this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true))); this._register(this._themeService.onChangeColors(() => this._queueRefresh())); this._queueRefresh(true); } diff --git a/src/browser/scrollable/abstractScrollbar.ts b/src/browser/scrollable/abstractScrollbar.ts index 4870f665..64c25d9d 100644 --- a/src/browser/scrollable/abstractScrollbar.ts +++ b/src/browser/scrollable/abstractScrollbar.ts @@ -82,10 +82,11 @@ export abstract class AbstractScrollbar extends Widget { /** * Creates the dom node for an arrow & adds it to the container */ - protected _createArrow(opts: IScrollbarArrowOptions): void { + protected _createArrow(opts: IScrollbarArrowOptions): ScrollbarArrow { const arrow = this._register(new ScrollbarArrow(opts)); this.domNode.domNode.appendChild(arrow.bgDomNode); this.domNode.domNode.appendChild(arrow.domNode); + return arrow; } /** diff --git a/src/browser/scrollable/scrollableElement.ts b/src/browser/scrollable/scrollableElement.ts index 7014c845..e30b36fc 100644 --- a/src/browser/scrollable/scrollableElement.ts +++ b/src/browser/scrollable/scrollableElement.ts @@ -314,6 +314,12 @@ export class SmoothScrollableElement extends Widget { if (typeof newOptions.vertical !== 'undefined') { this._options.vertical = newOptions.vertical; } + if (typeof newOptions.horizontalHasArrows !== 'undefined') { + this._options.horizontalHasArrows = newOptions.horizontalHasArrows; + } + if (typeof newOptions.verticalHasArrows !== 'undefined') { + this._options.verticalHasArrows = newOptions.verticalHasArrows; + } if (typeof newOptions.horizontalScrollbarSize !== 'undefined') { this._options.horizontalScrollbarSize = newOptions.horizontalScrollbarSize; } diff --git a/src/browser/scrollable/scrollableElementOptions.ts b/src/browser/scrollable/scrollableElementOptions.ts index 8b241a60..037cd5c1 100644 --- a/src/browser/scrollable/scrollableElementOptions.ts +++ b/src/browser/scrollable/scrollableElementOptions.ts @@ -128,8 +128,10 @@ export interface IScrollableElementChangeOptions { scrollPredominantAxis?: boolean; horizontal?: ScrollbarVisibility; horizontalScrollbarSize?: number; + horizontalHasArrows?: boolean; vertical?: ScrollbarVisibility; verticalScrollbarSize?: number; + verticalHasArrows?: boolean; scrollByPage?: boolean; } diff --git a/src/browser/scrollable/scrollbarState.ts b/src/browser/scrollable/scrollbarState.ts index 9af66401..f9129f1b 100644 --- a/src/browser/scrollable/scrollbarState.ts +++ b/src/browser/scrollable/scrollbarState.ts @@ -35,7 +35,7 @@ export class ScrollbarState { * For the vertical scrollbar: the height of the scrollbar's arrows. * For the horizontal scrollbar: the width of the scrollbar's arrows. */ - private readonly _arrowSize: number; + private _arrowSize: number; // --- variables /** @@ -127,6 +127,14 @@ export class ScrollbarState { this._scrollbarSize = Math.round(scrollbarSize); } + public setArrowSize(arrowSize: number): void { + const iArrowSize = Math.round(arrowSize); + if (this._arrowSize !== iArrowSize) { + this._arrowSize = iArrowSize; + this._refreshComputedValues(); + } + } + public setOppositeScrollbarSize(oppositeScrollbarSize: number): void { this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize); } diff --git a/src/browser/scrollable/verticalScrollbar.ts b/src/browser/scrollable/verticalScrollbar.ts index 9efbe4d8..615f443a 100644 --- a/src/browser/scrollable/verticalScrollbar.ts +++ b/src/browser/scrollable/verticalScrollbar.ts @@ -7,17 +7,22 @@ import { AbstractScrollbar, ISimplifiedPointerEvent, IScrollbarHost } from './ab import { IScrollableElementResolvedOptions } from './scrollableElementOptions'; import { ScrollbarState } from './scrollbarState'; import { INewScrollPosition, Scrollable, ScrollbarVisibility, IScrollEvent } from './scrollable'; +import type { ScrollbarArrow } from './scrollbarArrow'; export class VerticalScrollbar extends AbstractScrollbar { + private _arrowUp: ScrollbarArrow | undefined; + private _arrowDown: ScrollbarArrow | undefined; + private _arrowScrollDelta: number = 0; constructor(scrollable: Scrollable, options: IScrollableElementResolvedOptions, host: IScrollbarHost) { const scrollDimensions = scrollable.getScrollDimensions(); const scrollPosition = scrollable.getCurrentScrollPosition(); + const hasArrows = options.verticalHasArrows; super({ lazyRender: options.lazyRender, host: host, scrollbarState: new ScrollbarState( - (options.verticalHasArrows ? options.verticalScrollbarSize : 0), + (hasArrows ? options.verticalScrollbarSize : 0), (options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize), 0, scrollDimensions.height, @@ -30,26 +35,7 @@ export class VerticalScrollbar extends AbstractScrollbar { scrollByPage: options.scrollByPage }); - if (options.verticalHasArrows) { - const arrowSize = options.verticalScrollbarSize; - const arrowDelta = 0; - this._createArrow({ - className: 'xterm-scra xterm-arrow-up', - top: arrowDelta, - left: arrowDelta, - bgWidth: options.verticalScrollbarSize, - bgHeight: arrowSize, - handleActivate: () => this._arrowScroll(-arrowSize) - }); - this._createArrow({ - className: 'xterm-scra xterm-arrow-down', - bottom: arrowDelta, - left: arrowDelta, - bgWidth: options.verticalScrollbarSize, - bgHeight: arrowSize, - handleActivate: () => this._arrowScroll(arrowSize) - }); - } + this._setArrows(hasArrows, options.verticalScrollbarSize); this._createSlider(0, Math.floor((options.verticalScrollbarSize - options.verticalSliderSize) / 2), options.verticalSliderSize, undefined); } @@ -98,7 +84,56 @@ export class VerticalScrollbar extends AbstractScrollbar { this._scrollable.setScrollPositionNow({ scrollTop: currentPosition.scrollTop + delta }); } + private _setArrows(showArrows: boolean, size: number): void { + this._arrowScrollDelta = size; + if (!this._arrowUp || !this._arrowDown) { + const arrowDelta = 0; + this._arrowUp = this._createArrow({ + className: 'xterm-scra xterm-arrow-up', + top: arrowDelta, + left: arrowDelta, + bgWidth: size, + bgHeight: size, + handleActivate: () => this._arrowScroll(-this._arrowScrollDelta) + }); + this._arrowDown = this._createArrow({ + className: 'xterm-scra xterm-arrow-down', + bottom: arrowDelta, + left: arrowDelta, + bgWidth: size, + bgHeight: size, + handleActivate: () => this._arrowScroll(this._arrowScrollDelta) + }); + } + + this._updateArrowSize(this._arrowUp, size); + this._updateArrowSize(this._arrowDown, size); + + if (!this._arrowUp || !this._arrowDown) { + return; + } + + const display = showArrows ? '' : 'none'; + this._arrowUp.bgDomNode.style.display = display; + this._arrowUp.domNode.style.display = display; + this._arrowDown.bgDomNode.style.display = display; + this._arrowDown.domNode.style.display = display; + } + + private _updateArrowSize(arrow: ScrollbarArrow | undefined, size: number): void { + if (!arrow) { + return; + } + arrow.bgDomNode.style.width = `${size}px`; + arrow.bgDomNode.style.height = `${size}px`; + arrow.domNode.style.width = `${size}px`; + arrow.domNode.style.height = `${size}px`; + } + public updateOptions(options: IScrollableElementResolvedOptions): void { + const arrowSize = options.verticalHasArrows ? options.verticalScrollbarSize : 0; + this._scrollbarState.setArrowSize(arrowSize); + this._setArrows(options.verticalHasArrows, options.verticalScrollbarSize); this.updateScrollbarSize(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize); this._scrollbarState.setOppositeScrollbarSize(0); this._visibilityController.setVisibility(options.vertical); diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 23c523d4..397825e9 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -32,7 +32,7 @@ export const DEFAULT_OPTIONS: Readonly> = { logLevel: 'info', logger: null, scrollback: 1000, - scrollbar: {}, + scrollbar: { showScrollbar: true }, scrollOnEraseInDisplay: false, scrollOnUserInput: true, scrollSensitivity: 1, diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 6a240560..85819960 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -311,6 +311,7 @@ export interface ITerminalQuirks { } export interface IScrollbarOptions { + showScrollbar?: boolean; showArrows?: boolean; } diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 39b0d592..28f02b68 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -716,6 +716,7 @@ declare module '@xterm/xterm' { /** * When defined, renders decorations in the overview ruler to the right of * the terminal. This must be set in order to see the overview ruler. + * This is ignored when {@link IScrollbarOptions.showScrollbar} is false. * @param color The color of the decoration. * @param position The position of the decoration. */ @@ -738,6 +739,11 @@ declare module '@xterm/xterm' { * Options for configuring the scrollbar. */ export interface IScrollbarOptions { + /** + * Whether to show the scrollbar. When false, this supersedes + * {@link IOverviewRulerOptions.width}. Defaults to true. + */ + showScrollbar?: boolean; /** * Whether to show arrows at the top and bottom of the scrollbar. Defaults * to false. From d155bb67674208ef998b68c833b4c41fd96c7eca Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:14:58 -0800 Subject: [PATCH 11/23] Move overviewRuler API under scrollbar Fixes #5678 --- addons/addon-fit/src/FitAddon.ts | 2 +- .../client/components/window/optionsWindow.ts | 1 - demo/client/components/window/testWindow.ts | 4 +- src/browser/CoreBrowserTerminal.ts | 11 ++--- src/browser/Viewport.ts | 3 +- .../decorations/OverviewRulerRenderer.ts | 10 ++--- src/common/services/OptionsService.ts | 1 - src/common/services/Services.ts | 3 +- test/playwright/Terminal.test.ts | 4 +- typings/xterm.d.ts | 40 +++++++++---------- 10 files changed, 36 insertions(+), 43 deletions(-) diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index c51cd63d..a1ef53e7 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -72,7 +72,7 @@ export class FitAddon implements ITerminalAddon, IFitApi { const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true; const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar ? 0 - : (this._terminal.options.overviewRuler?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); + : (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement); const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index dfea4aed..04adbec1 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -115,7 +115,6 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'documentOverride', 'linkHandler', 'logger', - 'overviewRuler', 'quirks', 'theme', 'vtExtensions', diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 6f2c6c07..acab7b3a 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -738,7 +738,7 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void { } function addDecoration(term: Terminal, dim: number = 1): void { - term.options['overviewRuler'] = { width: 14 }; + term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} }; const marker = term.registerMarker(1); const decoration = term.registerDecoration({ marker, @@ -755,7 +755,7 @@ function addDecoration(term: Terminal, dim: number = 1): void { } function addOverviewRuler(term: Terminal): void { - term.options['overviewRuler'] = { width: 14 }; + term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} }; term.registerDecoration({ marker: term.registerMarker(1), overviewRulerOptions: { color: '#ef2929' } }); term.registerDecoration({ marker: term.registerMarker(3), overviewRulerOptions: { color: '#8ae234' } }); term.registerDecoration({ marker: term.registerMarker(5), overviewRulerOptions: { color: '#729fcf' } }); diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 902f5d35..8ad8409f 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -617,17 +617,12 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { this._register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e))); const showScrollbar = this.options.scrollbar?.showScrollbar ?? true; - if (showScrollbar && this.options.overviewRuler.width) { + const overviewRulerWidth = this.options.scrollbar?.width; + if (showScrollbar && overviewRulerWidth) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } - this.optionsService.onSpecificOptionChange('overviewRuler', value => { - const shouldShow = (this.options.scrollbar?.showScrollbar ?? true) && !!value?.width; - if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) { - this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); - } - }); this.optionsService.onSpecificOptionChange('scrollbar', value => { - const shouldShow = (value?.showScrollbar ?? true) && !!this.options.overviewRuler.width; + const shouldShow = (value?.showScrollbar ?? true) && !!value?.width; if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) { this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement)); } diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts index 49e744cf..09864f7a 100644 --- a/src/browser/Viewport.ts +++ b/src/browser/Viewport.ts @@ -62,7 +62,6 @@ export class Viewport extends Disposable { this._register(this._optionsService.onMultipleOptionChange([ 'scrollSensitivity', 'fastScrollSensitivity', - 'overviewRuler', 'scrollbar' ], () => this._scrollableElement.updateOptions(this._getChangeOptions()))); // Don't handle mouse wheel if wheel events are supported by the current mouse prototcol @@ -135,7 +134,7 @@ export class Viewport extends Disposable { const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; const showArrows = this._optionsService.rawOptions.scrollbar?.showArrows ?? false; const verticalScrollbarSize = showScrollbar - ? (this._optionsService.rawOptions.overviewRuler?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH) + ? (this._optionsService.rawOptions.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH) : 0; return { mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity, diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index 576cdf6f..c5492f55 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -38,11 +38,12 @@ export class OverviewRulerRenderer extends Disposable { private readonly _ctx: CanvasRenderingContext2D; private readonly _colorZoneStore: IColorZoneStore = new ColorZoneStore(); private get _width(): number { - const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true; + const scrollbar = this._optionsService.rawOptions.scrollbar; + const showScrollbar = scrollbar?.showScrollbar ?? true; if (!showScrollbar) { return 0; } - return this._optionsService.rawOptions.overviewRuler?.width ?? 0; + return scrollbar?.width ?? 0; } private _animationFrame: number | undefined; @@ -99,7 +100,6 @@ export class OverviewRulerRenderer extends Disposable { })); this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true))); - this._register(this._optionsService.onSpecificOptionChange('overviewRuler', () => this._queueRefresh(true))); this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true))); this._register(this._themeService.onChangeColors(() => this._queueRefresh())); this._queueRefresh(true); @@ -181,10 +181,10 @@ export class OverviewRulerRenderer extends Disposable { private _renderRulerOutline(): void { this._ctx.fillStyle = this._themeService.colors.overviewRulerBorder.css; this._ctx.fillRect(0, 0, Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height); - if (this._optionsService.rawOptions.overviewRuler.showTopBorder) { + if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder) { this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, 0, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, Constants.OVERVIEW_RULER_BORDER_WIDTH); } - if (this._optionsService.rawOptions.overviewRuler.showBottomBorder) { + if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder) { this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height); } } diff --git a/src/common/services/OptionsService.ts b/src/common/services/OptionsService.ts index 397825e9..ec647aaa 100644 --- a/src/common/services/OptionsService.ts +++ b/src/common/services/OptionsService.ts @@ -55,7 +55,6 @@ export const DEFAULT_OPTIONS: Readonly> = { altClickMovesCursor: true, convertEol: false, termName: 'xterm', - overviewRuler: {}, quirks: {}, vtExtensions: {} }; diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index 85819960..b40f16fd 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -265,7 +265,6 @@ export interface ITerminalOptions { windowsPty?: IWindowsPty; windowOptions?: IWindowOptions; wordSeparator?: string; - overviewRuler?: IOverviewRulerOptions; quirks?: ITerminalQuirks; scrollbar?: IScrollbarOptions; scrollOnEraseInDisplay?: boolean; @@ -313,6 +312,8 @@ export interface ITerminalQuirks { export interface IScrollbarOptions { showScrollbar?: boolean; showArrows?: boolean; + width?: number; + overviewRuler?: IOverviewRulerOptions; } export interface IVtExtensions { diff --git a/test/playwright/Terminal.test.ts b/test/playwright/Terminal.test.ts index 9985fe67..2d1133cc 100644 --- a/test/playwright/Terminal.test.ts +++ b/test/playwright/Terminal.test.ts @@ -816,7 +816,7 @@ test.describe('API Integration Tests', () => { }); test.describe('overviewRulerDecorations', () => { test('should not add an overview ruler when width is not set', async () => { - await openTerminal(ctx); + await openTerminal(ctx, { scrollbar: { overviewRuler: {} } }); await ctx.page.evaluate(`window.marker1 = window.term.registerMarker(1)`); await ctx.page.evaluate(`window.marker2 = window.term.registerMarker(2)`); await ctx.page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); @@ -824,7 +824,7 @@ test.describe('API Integration Tests', () => { await pollFor(ctx.page, `document.querySelectorAll('.xterm-decoration-overview-ruler').length`, 0); }); test('should add an overview ruler when width is set', async () => { - await openTerminal(ctx, { overviewRuler: { width: 15 } }); + await openTerminal(ctx, { scrollbar: { width: 15, overviewRuler: {} } }); await ctx.page.evaluate(`window.marker1 = window.term.registerMarker(1)`); await ctx.page.evaluate(`window.marker2 = window.term.registerMarker(2)`); await ctx.page.evaluate(`window.term.registerDecoration({ marker: window.marker1, overviewRulerOptions: { color: 'red', position: 'full' } })`); diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 28f02b68..a850c7be 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -206,12 +206,6 @@ declare module '@xterm/xterm' { */ minimumContrastRatio?: number; - /** - * Controls the visibility and style of the overview ruler which visualizes - * decorations underneath the scroll bar. - */ - overviewRuler?: IOverviewRulerOptions; - /** * Control various quirks features that are either non-standard or standard * in but generally rejected in modern terminals. @@ -399,8 +393,8 @@ declare module '@xterm/xterm' { scrollbarSliderActiveBackground?: string; /** * The border color of the overview ruler. This visually separates the - * terminal from the scroll bar when {@link IOverviewRulerOptions.width} is - * set. When this is not set it defaults to black (`#000000`). + * terminal from the scroll bar when {@link IScrollbarOptions.width} is set. + * When this is not set it defaults to black (`#000000`). */ overviewRulerBorder?: string; /** ANSI black (eg. `\x1b[30m`) */ @@ -688,8 +682,8 @@ declare module '@xterm/xterm' { /** * When defined, renders the decoration in the overview ruler to the right - * of the terminal. {@link IOverviewRulerOptions.width} must be set in order - * to see the overview ruler. + * of the terminal. {@link IScrollbarOptions.width} must be set in order to + * see the overview ruler. * @param color The color of the decoration. * @param position The position of the decoration. */ @@ -712,16 +706,10 @@ declare module '@xterm/xterm' { tooMuchOutput: string; } + /** + * Options for configuring the overview ruler rendered beside the scrollbar. + */ export interface IOverviewRulerOptions { - /** - * When defined, renders decorations in the overview ruler to the right of - * the terminal. This must be set in order to see the overview ruler. - * This is ignored when {@link IScrollbarOptions.showScrollbar} is false. - * @param color The color of the decoration. - * @param position The position of the decoration. - */ - width?: number; - /** * Whether to show the top border of the overview ruler, which uses the * {@link ITheme.overviewRulerBorder} color. @@ -741,7 +729,7 @@ declare module '@xterm/xterm' { export interface IScrollbarOptions { /** * Whether to show the scrollbar. When false, this supersedes - * {@link IOverviewRulerOptions.width}. Defaults to true. + * {@link IScrollbarOptions.width}. Defaults to true. */ showScrollbar?: boolean; /** @@ -749,6 +737,18 @@ declare module '@xterm/xterm' { * to false. */ showArrows?: boolean; + + /** + * The width of the scrollbar and overview ruler in CSS pixels. When set, + * this enables the overview ruler. + */ + width?: number; + + /** + * Controls the visibility and style of the overview ruler which visualizes + * decorations underneath the scroll bar. + */ + overviewRuler?: IOverviewRulerOptions; } /** From aebbe812521f3b12ba4046841269b3c701c5e3c5 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 5 Feb 2026 09:19:26 -0800 Subject: [PATCH 12/23] Add overviewRuler options to demo --- .../client/components/window/optionsWindow.ts | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index 04adbec1..c24fb268 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -121,12 +121,14 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { 'windowOptions', 'windowsPty', ]; - const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [ - { label: 'scrollbar.showScrollbar', parent: 'scrollbar', prop: 'showScrollbar' }, - { label: 'scrollbar.showArrows', parent: 'scrollbar', prop: 'showArrows' }, - { label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' }, - { label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' }, - { label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' } + const nestedBooleanOptions: { label: string, path: string[], prop: string }[] = [ + { label: 'scrollbar.showScrollbar', path: ['scrollbar'], prop: 'showScrollbar' }, + { label: 'scrollbar.showArrows', path: ['scrollbar'], prop: 'showArrows' }, + { label: 'scrollbar.overviewRuler.showTopBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showTopBorder' }, + { label: 'scrollbar.overviewRuler.showBottomBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showBottomBorder' }, + { label: 'vtExtensions.kittyKeyboard', path: ['vtExtensions'], prop: 'kittyKeyboard' }, + { label: 'vtExtensions.kittySgrBoldFaintControl', path: ['vtExtensions'], prop: 'kittySgrBoldFaintControl' }, + { label: 'vtExtensions.win32InputMode', path: ['vtExtensions'], prop: 'win32InputMode' } ]; const stringOptions: { [key: string]: string[] | null } = { cursorStyle: ['block', 'underline', 'bar'], @@ -162,8 +164,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { booleanOptions.forEach(o => { html += `
`; }); - nestedBooleanOptions.forEach(({ label, parent, prop }) => { - const checked = (this._terminal.options as Record | undefined>)[parent]?.[prop] ?? false; + nestedBooleanOptions.forEach(({ label, path, prop }) => { + const options = this._terminal.options as Record; + const parent = path.reduce | undefined>((acc, key) => (acc as Record | undefined)?.[key] as Record | undefined, options); + const checked = (parent as Record | undefined)?.[prop] ?? false; html += `
`; }); html += '
'; @@ -197,11 +201,22 @@ export class OptionsWindow extends BaseWindow implements IControlWindow { } }); }); - nestedBooleanOptions.forEach(({ label, parent, prop }) => { + nestedBooleanOptions.forEach(({ label, path, prop }) => { const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement; addDomListener(input, 'change', () => { console.log('change', label, input.checked); - (this._terminal.options as Record)[parent] = { ...(this._terminal.options as Record | undefined>)[parent], [prop]: input.checked }; + const options = this._terminal.options as Record; + if (path.length === 1) { + const parentKey = path[0]; + options[parentKey] = { ...(options[parentKey] as Record | undefined), [prop]: input.checked }; + return; + } + if (path.length === 2) { + const [parentKey, childKey] = path; + const parent = (options[parentKey] as Record | undefined) ?? {}; + const child = (parent[childKey] as Record | undefined) ?? {}; + options[parentKey] = { ...parent, [childKey]: { ...child, [prop]: input.checked } }; + } }); }); numberOptions.forEach(o => { From f41e53a0f064edf478053967dc499eb585e7123d Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 6 Feb 2026 12:50:28 -0800 Subject: [PATCH 13/23] Use correct non-inverse shade char in testWindow --- demo/client/components/window/testWindow.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts index 2a5f8b4f..d7a2d315 100644 --- a/demo/client/components/window/testWindow.ts +++ b/demo/client/components/window/testWindow.ts @@ -399,10 +399,10 @@ function customGlyphAlignmentHandler(term: Terminal): void { term.write('\x1b[0mTriangular fill tests:\x1b[36m\n\r'); term.write('1FB9C 1FB9D 1FB9E 1FB9F all\n\r'); - term.write('\u{1FB90}\u{1FB90}\u{1FB90}\u{1FB9C} \u{1FB9D}\u{1FB90}\u{1FB90}\u{1FB90} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r'); - term.write('\u{1FB90}\u{1FB90}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{1FB90}\u{1FB90} \u{00020}\u{00020}\u{1FB9E}\u{1FB90} \u{1FB90}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{1FB90}\u{1FB90}\u{1FB9F}\n\r'); - term.write('\u{1FB90}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{1FB90} \u{00020}\u{1FB9E}\u{1FB90}\u{1FB90} \u{1FB90}\u{1FB90}\u{1FB9F}\u{00020} \u{1FB9D}\u{1FB90}\u{1FB90}\u{1FB9C}\n\r'); - term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{1FB90}\u{1FB90}\u{1FB90} \u{1FB90}\u{1FB90}\u{1FB90}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r'); + term.write('\u{02592}\u{02592}\u{02592}\u{1FB9C} \u{1FB9D}\u{02592}\u{02592}\u{02592} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r'); + term.write('\u{02592}\u{02592}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{02592}\u{02592} \u{00020}\u{00020}\u{1FB9E}\u{02592} \u{02592}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{02592}\u{02592}\u{1FB9F}\n\r'); + term.write('\u{02592}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{02592} \u{00020}\u{1FB9E}\u{02592}\u{02592} \u{02592}\u{02592}\u{1FB9F}\u{00020} \u{1FB9D}\u{02592}\u{02592}\u{1FB9C}\n\r'); + term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{02592}\u{02592}\u{02592} \u{02592}\u{02592}\u{02592}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r'); term.write('\x1b[0mPowerline alignment tests:\n\r'); const powerlineLeftChars = ['\u{E0B2}', '\u{E0B3}', '\u{E0B6}', '\u{E0B7}', '\u{E0BA}', '\u{E0BB}', '\u{E0BE}', '\u{E0BF}', '\u{E0C2}', '\u{E0C3}', '\u{E0C5}', '\u{E0C7}', '\u{E0CA}', '\u{E0D4}']; From cc8e6807908e717e9a79ce5136a8b64e8cdaf2af Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:20:31 -0800 Subject: [PATCH 14/23] Add attributesEqual and underline diff APIs Fixes #3440 --- .../src/SerializeAddon.test.ts | 2 +- addons/addon-serialize/src/SerializeAddon.ts | 56 +++++++++++----- .../test/SerializeAddon.test.ts | 43 ++++++++++++ .../renderer/dom/DomRendererRowFactory.ts | 2 +- src/browser/services/SelectionService.ts | 4 +- src/common/buffer/CellData.test.ts | 65 +++++++++++++++++++ src/common/buffer/CellData.ts | 60 ++++++++++++++++- src/common/public/BufferLineApiView.ts | 4 +- typings/xterm-headless.d.ts | 20 ++++++ typings/xterm.d.ts | 20 ++++++ 10 files changed, 253 insertions(+), 23 deletions(-) create mode 100644 src/common/buffer/CellData.test.ts diff --git a/addons/addon-serialize/src/SerializeAddon.test.ts b/addons/addon-serialize/src/SerializeAddon.test.ts index 6e8c2ad4..4f4fd546 100644 --- a/addons/addon-serialize/src/SerializeAddon.test.ts +++ b/addons/addon-serialize/src/SerializeAddon.test.ts @@ -116,7 +116,7 @@ describe('SerializeAddon', () => { describe('underline styles', () => { it('should serialize single underline with style', async () => { await writeP(terminal, sgr('4:1') + 'test' + sgr('24')); - assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m'); + assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m'); }); it('should serialize double underline', async () => { diff --git a/addons/addon-serialize/src/SerializeAddon.ts b/addons/addon-serialize/src/SerializeAddon.ts index fadeb5bb..4980c8ed 100644 --- a/addons/addon-serialize/src/SerializeAddon.ts +++ b/addons/addon-serialize/src/SerializeAddon.ts @@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell) if (!cell1.isUnderline() && !cell2.isUnderline()) { return true; } - const cell1Data = cell1 as unknown as IAttributeData; - const cell2Data = cell2 as unknown as IAttributeData; - return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle() - && cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor() - && cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode(); + if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) { + return false; + } + const cell1Default = cell1.isUnderlineColorDefault(); + const cell2Default = cell2.isUnderlineColorDefault(); + if (cell1Default && cell2Default) { + return true; + } + if (cell1Default !== cell2Default) { + return false; + } + return cell1.getUnderlineColor() === cell2.getUnderlineColor() + && cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode(); } function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { @@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo && cell1.isStrikethrough() === cell2.isStrikethrough(); } +function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean { + const cell1AsBufferCell = cell1 as IBufferCell; + if (typeof cell1AsBufferCell.attributesEquals === 'function') { + return cell1AsBufferCell.attributesEquals(cell2); + } + return equalFg(cell1, cell2) + && equalBg(cell1, cell2) + && equalFlags(cell1, cell2); +} + class StringSerializeHandler extends BaseSerializeHandler { private _rowIndex: number = 0; private _allRows: string[] = new Array(); @@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler { private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] { const sgrSeq: number[] = []; + if (attributesEquals(cell, oldCell)) { + return sgrSeq; + } const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); const flagsChanged = !equalFlags(cell, oldCell); @@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler { if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } if (!equalUnderline(cell, oldCell)) { - const cellData = cell as unknown as IAttributeData; - const style = cellData.getUnderlineStyle(); + const style = cell.getUnderlineStyle(); if (style === UnderlineStyle.NONE) { sgrSeq.push(24); + } else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) { + sgrSeq.push(4); } else { // Use SGR 4:X format for underline styles sgrSeq.push('4:' + style as unknown as number); // Handle underline color - if (!cellData.isUnderlineColorDefault()) { - const color = cellData.getUnderlineColor(); - if (cellData.isUnderlineColorRGB()) { + if (!cell.isUnderlineColorDefault()) { + const color = cell.getUnderlineColor(); + if (cell.isUnderlineColorRGB()) { sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number); } else { sgrSeq.push('58:5:' + color as unknown as number); @@ -675,12 +697,11 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { } private _getUnderlineColor(cell: IBufferCell): string | undefined { - const cellData = cell as unknown as IAttributeData; - if (cellData.isUnderlineColorDefault()) { + if (cell.isUnderlineColorDefault()) { return undefined; } - const color = cellData.getUnderlineColor(); - if (cellData.isUnderlineColorRGB()) { + const color = cell.getUnderlineColor(); + if (cell.isUnderlineColorRGB()) { const rgb = [ (color >> 16) & 255, (color >> 8) & 255, @@ -693,8 +714,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { } private _getUnderlineStyle(cell: IBufferCell): string { - const cellData = cell as unknown as IAttributeData; - switch (cellData.getUnderlineStyle()) { + switch (cell.getUnderlineStyle()) { case UnderlineStyle.SINGLE: return 'underline'; case UnderlineStyle.DOUBLE: @@ -713,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler { private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined { const content: string[] = []; + if (attributesEquals(cell, oldCell)) { + return undefined; + } + const fgChanged = !equalFg(cell, oldCell); const bgChanged = !equalBg(cell, oldCell); const flagsChanged = !equalFlags(cell, oldCell); diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index cf00b391..ee5355fa 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -203,6 +203,46 @@ test.describe('SerializeAddon', () => { strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n')); }); + test('buffer cell attributesEquals compares underline style and color', async () => { + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`); + const sameAttributes = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(sameAttributes, true); + + await ctx.page.evaluate(`window.term.reset()`); + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`); + const differentColor = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(differentColor, false); + + await ctx.page.evaluate(`window.term.reset()`); + await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`); + const differentStyle = await ctx.page.evaluate(`(() => { + const line = window.term.buffer.active.getLine(0); + const cellA = line?.getCell(0); + const cellB = line?.getCell(1); + if (!cellA || !cellB) { + return undefined; + } + return cellA.attributesEquals(cellB); + })()`); + strictEqual(differentStyle, false); + }); + test('serialize all rows of content with color256', async function(): Promise { const rows = 32; const cols = 10; @@ -602,6 +642,9 @@ const BOLD = '1'; const DIM = '2'; const ITALIC = '3'; const UNDERLINED = '4'; +const UNDERLINE_DOUBLE = '4:2'; +const UNDERLINE_COLOR_RED = '58;5;196'; +const UNDERLINE_COLOR_GREEN = '58;5;46'; const BLINK = '5'; const INVERSE = '7'; const INVISIBLE = '8'; diff --git a/src/browser/renderer/dom/DomRendererRowFactory.ts b/src/browser/renderer/dom/DomRendererRowFactory.ts index 1f276c78..0cff9c25 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.ts @@ -124,7 +124,7 @@ export class DomRendererRowFactory { // 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; + let cell: ICellData = this._workCell; if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) { const range = joinedRanges.shift()!; // If the ligature's selection state is not consistent, don't join it. This helps the diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index 95fa88cd..a5d1d759 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -11,7 +11,7 @@ import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services'; import { Disposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; -import { IBufferLine, IDisposable } from 'common/Types'; +import { IBufferLine, ICellData, IDisposable } from 'common/Types'; import { getRangeLength } from 'common/buffer/BufferRange'; import { CellData } from 'common/buffer/CellData'; import { IBuffer } from 'common/buffer/Types'; @@ -1021,7 +1021,7 @@ export class SelectionService extends Disposable implements ISelectionService { * word logic. * @param cell The cell to check. */ - private _isCharWordSeparator(cell: CellData): boolean { + private _isCharWordSeparator(cell: ICellData): boolean { // Zero width characters are never separators as they are always to the // right of wide characters if (cell.getWidth() === 0) { diff --git a/src/common/buffer/CellData.test.ts b/src/common/buffer/CellData.test.ts new file mode 100644 index 00000000..77d1ef9e --- /dev/null +++ b/src/common/buffer/CellData.test.ts @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ +import { Attributes, BgFlags, FgFlags, UnderlineStyle } from 'common/buffer/Constants'; +import { CellData } from 'common/buffer/CellData'; +import { assert } from 'chai'; + +function createStyledCell(char: string, underlineStyle: UnderlineStyle, underlineColor: number): CellData { + const cell = new CellData(); + const fg = Attributes.CM_P256 | 12 | FgFlags.BOLD | FgFlags.UNDERLINE; + cell.setFromCharData([fg, char, 1, char.charCodeAt(0)]); + cell.bg = Attributes.CM_P16 | 2 | BgFlags.ITALIC; + cell.extended.underlineStyle = underlineStyle; + cell.extended.underlineColor = Attributes.CM_P256 | underlineColor; + cell.updateExtended(); + return cell; +} + +describe('CellData', () => { + describe('attributesEquals', () => { + it('returns true for same attributes with different chars', () => { + const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.DOUBLE, 45); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + + it('detects underline style changes', () => { + const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + + assert.equal(cellA.attributesEquals(cellB), false); + }); + + it('detects underline color changes', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 46); + + assert.equal(cellA.attributesEquals(cellB), false); + }); + + it('ignores underline variant offsets', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + cellA.extended.underlineVariantOffset = 1; + cellB.extended.underlineVariantOffset = 3; + cellA.updateExtended(); + cellB.updateExtended(); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + + it('ignores url ids', () => { + const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45); + const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45); + cellA.extended.urlId = 1; + cellB.extended.urlId = 2; + cellA.updateExtended(); + cellB.updateExtended(); + + assert.equal(cellA.attributesEquals(cellB), true); + }); + }); +}); diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index 9454c553..c67dfe1b 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -3,10 +3,11 @@ * @license MIT */ -import { CharData, ICellData, IExtendedAttrs } from 'common/Types'; +import { CharData, IAttributeData, ICellData, IExtendedAttrs } from 'common/Types'; import { stringFromCodePoint } from 'common/input/TextDecoder'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants'; import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData'; +import type { IBufferCell as IBufferCellApi } from '@xterm/xterm'; /** * CellData - represents a single Cell in the terminal buffer. @@ -91,4 +92,61 @@ export class CellData extends AttributeData implements ICellData { public getAsCharData(): CharData { return [this.fg, this.getChars(), this.getWidth(), this.getCode()]; } + + public attributesEquals(other: IBufferCellApi): boolean { + if (this.getFgColorMode() !== other.getFgColorMode() || this.getFgColor() !== other.getFgColor()) { + return false; + } + if (this.getBgColorMode() !== other.getBgColorMode() || this.getBgColor() !== other.getBgColor()) { + return false; + } + if (this.isInverse() !== other.isInverse()) { + return false; + } + if (this.isBold() !== other.isBold()) { + return false; + } + if (this.isUnderline() !== other.isUnderline()) { + return false; + } + if (this.isUnderline()) { + const otherData = other as unknown as IAttributeData; + if (this.getUnderlineStyle() !== otherData.getUnderlineStyle()) { + return false; + } + const thisDefault = this.isUnderlineColorDefault(); + const otherDefault = otherData.isUnderlineColorDefault(); + if (!(thisDefault && otherDefault)) { + if (thisDefault !== otherDefault) { + return false; + } + if (this.getUnderlineColor() !== otherData.getUnderlineColor()) { + return false; + } + if (this.getUnderlineColorMode() !== otherData.getUnderlineColorMode()) { + return false; + } + } + } + if (this.isOverline() !== other.isOverline()) { + return false; + } + if (this.isBlink() !== other.isBlink()) { + return false; + } + if (this.isInvisible() !== other.isInvisible()) { + return false; + } + if (this.isItalic() !== other.isItalic()) { + return false; + } + if (this.isDim() !== other.isDim()) { + return false; + } + if (this.isStrikethrough() !== other.isStrikethrough()) { + return false; + } + return true; + } + } diff --git a/src/common/public/BufferLineApiView.ts b/src/common/public/BufferLineApiView.ts index 560dd0bf..0a747fa4 100644 --- a/src/common/public/BufferLineApiView.ts +++ b/src/common/public/BufferLineApiView.ts @@ -18,10 +18,10 @@ export class BufferLineApiView implements IBufferLineApi { } if (cell) { - this._line.loadCell(x, cell as ICellData); + this._line.loadCell(x, cell as unknown as ICellData); return cell; } - return this._line.loadCell(x, new CellData()); + return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi; } public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string { return this._line.translateToString(trimRight, startColumn, endColumn); diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index 9cd46c07..fd92d424 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1218,6 +1218,26 @@ declare module '@xterm/headless' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; + + /** Gets the underline style, see {@link UnderlineStyle}. */ + getUnderlineStyle(): number; + /** Gets the underline color number, following the same rules as foreground colors. */ + getUnderlineColor(): number; + /** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */ + getUnderlineColorMode(): number; + /** Whether the cell is using the RGB underline color mode. */ + isUnderlineColorRGB(): boolean; + /** Whether the cell is using the palette underline color mode. */ + isUnderlineColorPalette(): boolean; + /** Whether the cell is using the default underline color mode. */ + isUnderlineColorDefault(): boolean; + + /** + * Compares the cell's attributes (colors and styles) with another cell. + * This does not compare the cell's content and excludes URL ids and + * underline variant offsets. + */ + attributesEquals(other: IBufferCell): boolean; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index a850c7be..93b364da 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1853,6 +1853,26 @@ declare module '@xterm/xterm' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; + + /** Gets the underline style, see {@link UnderlineStyle}. */ + getUnderlineStyle(): number; + /** Gets the underline color number, following the same rules as foreground colors. */ + getUnderlineColor(): number; + /** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */ + getUnderlineColorMode(): number; + /** Whether the cell is using the RGB underline color mode. */ + isUnderlineColorRGB(): boolean; + /** Whether the cell is using the palette underline color mode. */ + isUnderlineColorPalette(): boolean; + /** Whether the cell is using the default underline color mode. */ + isUnderlineColorDefault(): boolean; + + /** + * Compares the cell's attributes (colors and styles) with another cell. + * This does not compare the cell's content and excludes URL ids and + * underline variant offsets. + */ + attributesEquals(other: IBufferCell): boolean; } /** From e0c59e68b24bb5e4ea8e7ead0381ddd82c160a5b Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 7 Feb 2026 06:12:47 -0800 Subject: [PATCH 15/23] Make 0x1FB98 and 0x1FB99 perfectly tile Fixes #5681 --- .../customGlyphs/CustomGlyphDefinitions.ts | 4 ++-- .../src/customGlyphs/CustomGlyphRasterizer.ts | 24 ++++++++++++------- 2 files changed, 18 insertions(+), 10 deletions(-) diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts index 75e53930..22ffdfa9 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts @@ -644,8 +644,8 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi ] }, // Diagonal fill characters (1FB98-1FB99) - '\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,0 L1,1 M0,.25 L.75,1 M0,.5 L.5,1 M0,.75 L.25,1 M.25,0 L1,.75 M.5,0 L1,.5 M.75,0 L1,.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL - '\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,.25 L.25,0 M0,.5 L.5,0 M0,.75 L.75,0 M0,1 L1,0 M.25,1 L1,.25 M.5,1 L1,.5 M.75,1 L1,.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL + '\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL + '\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL // Smooth mosaic terminal graphic characters (1FB9A-1FB9B) '\u{1FB9A}': { type: CustomGlyphDefinitionType.VECTOR_SHAPE, data: { d: 'M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0', type: CustomGlyphVectorType.FILL } }, // UPPER AND LOWER TRIANGULAR HALF BLOCK diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index 08928a96..ba892572 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -512,6 +512,11 @@ function drawPathFunctionCharacter( devicePixelRatio: number, strokeWidth?: number ): void { + ctx.save(); + ctx.beginPath(); + ctx.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight); + ctx.clip(); + ctx.beginPath(); let actualInstructions: string; if (typeof charDefinition === 'function') { @@ -538,7 +543,7 @@ function drawPathFunctionCharacter( if (!args[0] || !args[1]) { continue; } - f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio), state); + f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio, 0, 0, false), state); state.lastCommand = type; } if (strokeWidth !== undefined) { @@ -549,6 +554,7 @@ function drawPathFunctionCharacter( ctx.fill(); } ctx.closePath(); + ctx.restore(); } /** @@ -697,7 +703,7 @@ const svgToCanvasInstructionMap: { [index: string]: (ctx: CanvasRenderingContext } }; -function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0): number[] { +function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0, clampToCell: boolean = true): number[] { const result = args.map(e => parseFloat(e) || parseInt(e)); if (result.length < 2) { @@ -707,10 +713,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO for (let x = 0; x < result.length; x += 2) { // Translate from 0-1 to 0-cellWidth result[x] *= cellWidth - (leftPadding * devicePixelRatio) - (rightPadding * devicePixelRatio); - // Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp - // line at 100% devicePixelRatio + // Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally + // clamp to the cell bounds. if (doClamp && result[x] !== 0) { - result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0); + const rounded = Math.round(result[x] + 0.5) - 0.5; + result[x] = clampToCell ? clamp(rounded, cellWidth, 0) : rounded; } // Apply the cell's offset (ie. x*cellWidth) result[x] += xOffset + (leftPadding * devicePixelRatio); @@ -719,10 +726,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO for (let y = 1; y < result.length; y += 2) { // Translate from 0-1 to 0-cellHeight result[y] *= cellHeight; - // Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp - // line at 100% devicePixelRatio + // Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally + // clamp to the cell bounds. if (doClamp && result[y] !== 0) { - result[y] = clamp(Math.round(result[y] + 0.5) - 0.5, cellHeight, 0); + const rounded = Math.round(result[y] + 0.5) - 0.5; + result[y] = clampToCell ? clamp(rounded, cellHeight, 0) : rounded; } // Apply the cell's offset (ie. x*cellHeight) result[y] += yOffset; From 9eeb2799c1c405f3bd6da468791e96ade7c3f15f Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 7 Feb 2026 06:17:35 -0800 Subject: [PATCH 16/23] Remove internal details about underline cell APIs --- typings/xterm-headless.d.ts | 6 +++--- typings/xterm.d.ts | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index fd92d424..de8d06c4 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -1219,11 +1219,11 @@ declare module '@xterm/headless' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; - /** Gets the underline style, see {@link UnderlineStyle}. */ + /** Gets the underline style. */ getUnderlineStyle(): number; - /** Gets the underline color number, following the same rules as foreground colors. */ + /** Gets the underline color number. */ getUnderlineColor(): number; - /** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */ + /** Gets the underline color mode. */ getUnderlineColorMode(): number; /** Whether the cell is using the RGB underline color mode. */ isUnderlineColorRGB(): boolean; diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index 93b364da..c8e08f50 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -1854,11 +1854,11 @@ declare module '@xterm/xterm' { /** Whether the cell has the default attribute (no color or style). */ isAttributeDefault(): boolean; - /** Gets the underline style, see {@link UnderlineStyle}. */ + /** Gets the underline style. */ getUnderlineStyle(): number; - /** Gets the underline color number, following the same rules as foreground colors. */ + /** Gets the underline color number. */ getUnderlineColor(): number; - /** Gets the underline color mode, see {@link Attributes.CM_DEFAULT} etc. */ + /** Gets the underline color mode. */ getUnderlineColorMode(): number; /** Whether the cell is using the RGB underline color mode. */ isUnderlineColorRGB(): boolean; From 82333b9414a747740ce75616362951d93d0c81f6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Sat, 7 Feb 2026 06:29:33 -0800 Subject: [PATCH 17/23] Remove unnecessary cast --- src/common/buffer/CellData.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/common/buffer/CellData.ts b/src/common/buffer/CellData.ts index c67dfe1b..43c4c594 100644 --- a/src/common/buffer/CellData.ts +++ b/src/common/buffer/CellData.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { CharData, IAttributeData, ICellData, IExtendedAttrs } from 'common/Types'; +import { CharData, ICellData, IExtendedAttrs } from 'common/Types'; import { stringFromCodePoint } from 'common/input/TextDecoder'; import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants'; import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData'; @@ -110,20 +110,19 @@ export class CellData extends AttributeData implements ICellData { return false; } if (this.isUnderline()) { - const otherData = other as unknown as IAttributeData; - if (this.getUnderlineStyle() !== otherData.getUnderlineStyle()) { + if (this.getUnderlineStyle() !== other.getUnderlineStyle()) { return false; } const thisDefault = this.isUnderlineColorDefault(); - const otherDefault = otherData.isUnderlineColorDefault(); + const otherDefault = other.isUnderlineColorDefault(); if (!(thisDefault && otherDefault)) { if (thisDefault !== otherDefault) { return false; } - if (this.getUnderlineColor() !== otherData.getUnderlineColor()) { + if (this.getUnderlineColor() !== other.getUnderlineColor()) { return false; } - if (this.getUnderlineColorMode() !== otherData.getUnderlineColorMode()) { + if (this.getUnderlineColorMode() !== other.getUnderlineColorMode()) { return false; } } From b9fa53169f26456b1f6a0d3bbeaf180ff17589db Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Tue, 10 Feb 2026 01:17:40 -0800 Subject: [PATCH 18/23] Fix kitty modifier events when only flag 2 is used Fixes #5687 --- src/common/input/KittyKeyboard.test.ts | 16 +++++++++++++++- src/common/input/KittyKeyboard.ts | 2 +- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index af28a5b2..e495ac59 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -474,11 +474,25 @@ describe('KittyKeyboard', () => { }); it('modifier key release includes its own bit cleared', () => { - const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE); + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags | KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES, KittyKeyboardEventType.RELEASE); assert.strictEqual(result.key, '\x1b[57441;1:3u'); }); }); + describe('modifier-only reporting', () => { + const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES; + + it('does not report modifier press without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => { + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags); + assert.strictEqual(result.key, undefined); + }); + + it('does not report modifier release without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => { + const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE); + assert.strictEqual(result.key, undefined); + }); + }); + describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => { const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index c9db3dad..b296695c 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -418,7 +418,7 @@ export class KittyKeyboard { return result; } - if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) { + if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) { return result; } From a25db1c76db318498fc11697f19264750e9fa80f Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 10 Feb 2026 14:35:34 -0800 Subject: [PATCH 19/23] Update wasm to 0.3.0 changes in IIPHandler.ts --- addons/addon-image/package.json | 2 +- addons/addon-image/src/IIPHandler.ts | 38 ++-- package-lock.json | 290 +-------------------------- 3 files changed, 36 insertions(+), 294 deletions(-) diff --git a/addons/addon-image/package.json b/addons/addon-image/package.json index 10933a2e..5078a57f 100644 --- a/addons/addon-image/package.json +++ b/addons/addon-image/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.1.0" + "xterm-wasm-parts": "^0.3.0" } } diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index cca3f2ac..c0d28c00 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -5,12 +5,16 @@ import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; import { ImageRenderer } from './ImageRenderer'; import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; -import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; +import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; -// limit hold memory in base64 decoder +// limit hold memory in base64 decoder (encoded bytes) const KEEP_DATA = 4194304; +const INITIAL_DATA = 1048576; + +// Local mirror of const enum (esbuild can't inline const enums from external packages) +const DECODER_OK: DecodeStatus.OK = 0; // default IIP header values const DEFAULT_HEADER: IHeaderFields = { @@ -27,7 +31,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { private _aborted = false; private _hp = new HeaderParser(); private _header: IHeaderFields = DEFAULT_HEADER; - private _dec = new Base64Decoder(KEEP_DATA); + private _dec: Base64Decoder; private _metrics = UNSUPPORTED_TYPE; constructor( @@ -35,7 +39,11 @@ export class IIPHandler implements IOscHandler, IResetHandler { private readonly _renderer: ImageRenderer, private readonly _storage: ImageStorage, private readonly _coreTerminal: ITerminalExt - ) {} + ) { + const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3); + const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes); + this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes); + } public reset(): void {} @@ -50,7 +58,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { if (this._aborted) return; if (this._hp.state === HeaderState.END) { - if (this._dec.put(data, start, end)) { + if (this._dec.put(data.subarray(start, end)) !== DECODER_OK) { this._dec.release(); this._aborted = true; } @@ -66,8 +74,8 @@ export class IIPHandler implements IOscHandler, IResetHandler { this._aborted = true; return; } - this._dec.init(this._header.size); - if (this._dec.put(data, dataPos, end)) { + this._dec.init(); + if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) { this._dec.release(); this._aborted = true; } @@ -85,13 +93,15 @@ export class IIPHandler implements IOscHandler, IResetHandler { let cond: number | boolean = true; if (cond = success) { if (cond = !this._dec.end()) { - this._metrics = imageType(this._dec.data8); - if (cond = this._metrics.mime !== 'unsupported') { - w = this._metrics.width; - h = this._metrics.height; - if (cond = w && h && w * h < this._opts.pixelLimit) { - [w, h] = this._resize(w, h).map(Math.floor); - cond = w && h && w * h < this._opts.pixelLimit; + if (cond = this._dec.data8.length === this._header.size) { + this._metrics = imageType(this._dec.data8); + if (cond = this._metrics.mime !== 'unsupported') { + w = this._metrics.width; + h = this._metrics.height; + if (cond = w && h && w * h < this._opts.pixelLimit) { + [w, h] = this._resize(w, h).map(Math.floor); + cond = w && h && w * h < this._opts.pixelLimit; + } } } } diff --git a/package-lock.json b/package-lock.json index f20b41a3..791c0521 100644 --- a/package-lock.json +++ b/package-lock.json @@ -79,7 +79,7 @@ "license": "MIT", "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.1.0" + "xterm-wasm-parts": "^0.3.0" } }, "addons/addon-ligatures": { @@ -1278,102 +1278,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", - "dev": true, - "dependencies": { - "ansi-regex": "^6.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1546,16 +1450,6 @@ "pako": "^2.0.4" } }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@playwright/test": { "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", @@ -3511,12 +3405,6 @@ "node": ">= 0.4" } }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true - }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -4868,54 +4756,12 @@ "node": ">= 0.10" } }, - "node_modules/inwasm": { - "version": "0.0.13", - "resolved": "https://registry.npmjs.org/inwasm/-/inwasm-0.0.13.tgz", - "integrity": "sha512-gmULhw1wfF3tQ19y0TvcNH6A5jN7IuTD51kbZuy+ittUU59d+ZTQMb53wbGQuciMrledgagL3/ohnjUj5qJikQ==", + "node_modules/inwasm-runtime": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/inwasm-runtime/-/inwasm-runtime-0.1.2.tgz", + "integrity": "sha512-in+Lk4d7PGwQnG1zxRe6qHB2nvvc5LUOsI9ByoJRuaDONTY8xDlaF+CvapGoPiqreJ0cMHxRzKtS0SJBccpYfA==", "dev": true, - "dependencies": { - "acorn": "^8.8.2", - "acorn-walk": "^8.2.0", - "chokidar": "^3.5.3", - "colorette": "^2.0.20", - "glob": "^10.0.0", - "wabt": "^1.0.32" - }, - "bin": { - "inwasm": "lib/cli.js" - } - }, - "node_modules/inwasm/node_modules/acorn-walk": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", - "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", - "dev": true, - "dependencies": { - "acorn": "^8.11.0" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/inwasm/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "dev": true, - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -5415,21 +5261,6 @@ "node": ">=8" } }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, "node_modules/javascript-natural-sort": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/javascript-natural-sort/-/javascript-natural-sort-0.7.1.tgz", @@ -5878,15 +5709,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/mkdirp": { "version": "0.5.5", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.5.tgz", @@ -6424,12 +6246,6 @@ "node": ">=8" } }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true - }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -6517,28 +6333,6 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true - }, "node_modules/path-to-regexp": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", @@ -7532,21 +7326,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -7559,19 +7338,6 @@ "node": ">=8" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -8090,23 +7856,6 @@ "node": ">=18" } }, - "node_modules/wabt": { - "version": "1.0.39", - "resolved": "https://registry.npmjs.org/wabt/-/wabt-1.0.39.tgz", - "integrity": "sha512-ba+dRL/75VQQY7RkU/CgriGbkoWAfS8TDyUlJfJhJ8KhtXgMl5dhNvoPNUcQ9IWRhW8u41glMSuZeTvsYq2rRg==", - "dev": true, - "bin": { - "wasm-decompile": "bin/wasm-decompile", - "wasm-interp": "bin/wasm-interp", - "wasm-objdump": "bin/wasm-objdump", - "wasm-stats": "bin/wasm-stats", - "wasm-strip": "bin/wasm-strip", - "wasm-validate": "bin/wasm-validate", - "wasm2c": "bin/wasm2c", - "wasm2wat": "bin/wasm2wat", - "wat2wasm": "bin/wat2wasm" - } - }, "node_modules/watchpack": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.0.tgz", @@ -8450,24 +8199,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -8580,12 +8311,13 @@ } }, "node_modules/xterm-wasm-parts": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.1.0.tgz", - "integrity": "sha512-GFE8yNJfdkytGpcsOZhkL3B8XyUqkR/Du3SdxRyFbJg+BBCKm3raaaflgIM4TwNQ2AOLz01BEEuB5FZXx8aNTQ==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz", + "integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==", "dev": true, + "license": "MIT", "dependencies": { - "inwasm": "^0.0.13" + "inwasm-runtime": "^0.1.2" } }, "node_modules/y18n": { From d9b5d150aead1fc6dcbcc76e981c5d9be7d2d6b9 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 10 Feb 2026 16:31:17 -0800 Subject: [PATCH 20/23] Put the const integer into local const enum, save indirection time at runtime --- addons/addon-image/src/IIPHandler.ts | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index c0d28c00..9662303f 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -5,16 +5,19 @@ import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types'; import { ImageRenderer } from './ImageRenderer'; import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage'; -import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; +import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm'; import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; -// limit hold memory in base64 decoder (encoded bytes) -const KEEP_DATA = 4194304; -const INITIAL_DATA = 1048576; - -// Local mirror of const enum (esbuild can't inline const enums from external packages) -const DECODER_OK: DecodeStatus.OK = 0; +// Local const enum mirror - esbuild can't inline const enums from external packages +const enum DecoderConst { + // Limit held memory in base64 decoder (encoded bytes). + KEEP_DATA = 4194304, + // Initial buffer allocation for the decoder. + INITIAL_DATA = 1048576, + // Local mirror of const enum (esbuild can't inline const enums from external packages) + OK = 0 +} // default IIP header values const DEFAULT_HEADER: IHeaderFields = { @@ -41,8 +44,8 @@ export class IIPHandler implements IOscHandler, IResetHandler { private readonly _coreTerminal: ITerminalExt ) { const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3); - const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes); - this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes); + const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes); + this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes); } public reset(): void {} @@ -58,7 +61,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { if (this._aborted) return; if (this._hp.state === HeaderState.END) { - if (this._dec.put(data.subarray(start, end)) !== DECODER_OK) { + if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) { this._dec.release(); this._aborted = true; } @@ -75,7 +78,7 @@ export class IIPHandler implements IOscHandler, IResetHandler { return; } this._dec.init(); - if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) { + if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) { this._dec.release(); this._aborted = true; } From a0574f4e9c6f724f605e85c2187cac9864ad489b Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 10 Feb 2026 16:52:15 -0800 Subject: [PATCH 21/23] Pass pixel dimensions to node-pty in demo --- demo/client/client.ts | 10 +++++++--- demo/server/server.ts | 21 ++++++++++++++++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/demo/client/client.ts b/demo/client/client.ts index fa249a9e..77020680 100644 --- a/demo/client/client.ts +++ b/demo/client/client.ts @@ -328,7 +328,9 @@ function createTerminal(): Terminal { } const cols = size.cols; const rows = size.rows; - const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows; + const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0); + const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0); + const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight; fetch(url, { method: 'POST' }); }); @@ -379,7 +381,9 @@ function createTerminal(): Terminal { if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) { runFakeTerminal(); } else { - const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows, { method: 'POST' }); + const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0); + const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0); + const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight, { method: 'POST' }); const processId = await res.text(); pid = processId; socketURL += processId; @@ -626,7 +630,7 @@ function updateTerminalSize(): void { function getBox(width: number, height: number): any { return { string: '+', - style: 'font-size: 1px; padding: ' + Math.floor(height/2) + 'px ' + Math.floor(width/2) + 'px; line-height: ' + height + 'px;' + style: 'font-size: 1px; padding: ' + Math.floor(height / 2) + 'px ' + Math.floor(width / 2) + 'px; line-height: ' + height + 'px;' }; } if (source instanceof HTMLCanvasElement) { diff --git a/demo/server/server.ts b/demo/server/server.ts index 0f4a79b3..ef98eb84 100644 --- a/demo/server/server.ts +++ b/demo/server/server.ts @@ -65,6 +65,8 @@ function startServer(): void { } const cols = parseInt(req.query.cols); const rows = parseInt(req.query.rows); + const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0; + const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0; const isWindows = process.platform === 'win32'; const term = pty.spawn(isWindows ? 'powershell.exe' : 'bash', [], { name: 'xterm-256color', @@ -77,7 +79,13 @@ function startServer(): void { useConptyDll: isWindows, }); - console.log('Created terminal with PID: ' + term.pid); + // Set pixel dimensions immediately after spawn (pty.spawn doesn't support them) + if (pixelWidth > 0 && pixelHeight > 0) { + term.resize(cols, rows, { width: pixelWidth, height: pixelHeight }); + console.log('Created terminal with PID: ' + term.pid + ' (' + cols + 'x' + rows + ', ' + pixelWidth + 'px x ' + pixelHeight + 'px)'); + } else { + console.log('Created terminal with PID: ' + term.pid); + } terminals[term.pid] = term; unsentOutput[term.pid] = ''; temporaryDisposable[term.pid] = term.onData(function(data) { @@ -95,10 +103,17 @@ function startServer(): void { const pid = parseInt(req.params.pid); const cols = parseInt(req.query.cols); const rows = parseInt(req.query.rows); + const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0; + const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0; const term = terminals[pid]; - term.resize(cols, rows); - console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + if (pixelWidth > 0 && pixelHeight > 0) { + term.resize(cols, rows, { width: pixelWidth, height: pixelHeight }); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols, ' + rows + ' rows, ' + pixelWidth + 'px x ' + pixelHeight + 'px'); + } else { + term.resize(cols, rows); + console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.'); + } res.end(); }); From f7118b19c663c562eb9febc6feefbe45ecf42628 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 10 Feb 2026 19:08:12 -0800 Subject: [PATCH 22/23] Correct alphabetical ordering in demo/client/types.ts --- demo/client/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/client/types.ts b/demo/client/types.ts index 4790fd4f..c73f31e2 100644 --- a/demo/client/types.ts +++ b/demo/client/types.ts @@ -19,7 +19,7 @@ import type { WebFontsAddon } from '@xterm/addon-web-fonts'; import type { WebLinksAddon } from '@xterm/addon-web-links'; import type { WebglAddon } from '@xterm/addon-webgl'; -export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl' | 'ligatures'; +export type AddonType = 'attach' | 'clipboard' | 'fit' | 'image' | 'ligatures' | 'progress' | 'search' | 'serialize' | 'unicode11' | 'unicodeGraphemes' | 'webFonts' | 'webLinks' | 'webgl'; export interface IDemoAddon { name: T; From 008aa1bfb5eea7571b75ac4b30fb93b3020535d6 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Thu, 12 Feb 2026 07:00:56 -0800 Subject: [PATCH 23/23] Avoid forced layout in overview ruler impl Fixes #5696 --- src/browser/decorations/OverviewRulerRenderer.ts | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts index c5492f55..4302b342 100644 --- a/src/browser/decorations/OverviewRulerRenderer.ts +++ b/src/browser/decorations/OverviewRulerRenderer.ts @@ -51,8 +51,6 @@ export class OverviewRulerRenderer extends Disposable { private _shouldUpdateAnchor: boolean | undefined = true; private _lastKnownBufferLength: number = 0; - private _containerHeight: number | undefined; - constructor( private readonly _viewportElement: HTMLElement, private readonly _screenElement: HTMLElement, @@ -91,13 +89,7 @@ export class OverviewRulerRenderer extends Disposable { } })); - // Container height changed - this._register(this._renderService.onRender((): void => { - if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) { - this._queueRefresh(true); - this._containerHeight = this._screenElement.clientHeight; - } - })); + this._register(this._renderService.onDimensionsChange(() => this._queueRefresh(true))); this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true))); this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true))); @@ -144,10 +136,12 @@ export class OverviewRulerRenderer extends Disposable { } private _refreshCanvasDimensions(): void { + const cssCanvasHeight = this._renderService.dimensions.css.canvas.height; + const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height; this._canvas.style.width = `${this._width}px`; this._canvas.width = Math.round(this._width * this._coreBrowserService.dpr); - this._canvas.style.height = `${this._screenElement.clientHeight}px`; - this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowserService.dpr); + this._canvas.style.height = `${cssCanvasHeight}px`; + this._canvas.height = deviceCanvasHeight; this._refreshDrawConstants(); this._refreshColorZonePadding(); }