diff --git a/.gitignore b/.gitignore index 81fe7a147..aa46b451f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ test-results/ # Tracing/debug files .cursor/nal-trace/ +.cursor/*.log # Keep bundled code out of Git dist/ diff --git a/addons/addon-image/src/SixelHandler.ts b/addons/addon-image/src/SixelHandler.ts index 2f12fbe19..1af2d85bc 100644 --- a/addons/addon-image/src/SixelHandler.ts +++ b/addons/addon-image/src/SixelHandler.ts @@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler { const height = this._dec.height; // partial fix for https://github.com/jerch/xterm-addon-image/issues/37 - if (!width || ! height) { + if (!width || !height) { if (height) { this._storage.advanceCursor(height); } diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index be4876fba..d85c5fa7a 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -20,7 +20,7 @@ "package": "webpack", "pretest": "npm run build", "test": "nyc mocha out/**/*.test.js", - "prepublish": "npm run package" + "prepublishOnly": "npm run package" }, "keywords": [ "font", diff --git a/addons/addon-progress/test/ProgressAddon.test.ts b/addons/addon-progress/test/ProgressAddon.test.ts index 792c0445d..12ff7a38f 100644 --- a/addons/addon-progress/test/ProgressAddon.test.ts +++ b/addons/addon-progress/test/ProgressAddon.test.ts @@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla let ctx: ITestContext; -test.beforeAll(async ({ browser }) => { - ctx = await createTestContext(browser); - ctx.page.setViewportSize({ width: 1024, height: 768 }); - await openTerminal(ctx); -}); -test.afterAll(async () => await ctx.page.close()); - test.describe('ProgressAddon', () => { + test.describe.configure({ mode: 'serial' }); + + test.beforeAll(async ({ browser }) => { + ctx = await createTestContext(browser); + await openTerminal(ctx); + }); + test.afterAll(async () => { + await ctx?.page?.close(); + }); + test.beforeEach(async function(): Promise { await ctx.page.evaluate(` window.progressStack = []; diff --git a/addons/addon-webgl/src/CharAtlasUtils.test.ts b/addons/addon-webgl/src/CharAtlasUtils.test.ts new file mode 100644 index 000000000..0a135746e --- /dev/null +++ b/addons/addon-webgl/src/CharAtlasUtils.test.ts @@ -0,0 +1,87 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { configEquals } from './CharAtlasUtils'; +import { ICharAtlasConfig } from './Types'; +import { NULL_COLOR } from 'common/Color'; +import { IColor } from 'common/Types'; + +function createTestConfig(overrides: Partial = {}): ICharAtlasConfig { + const color: IColor = { css: '#ffffff', rgba: 0xffffffff }; + const contrastCache = { + clear: () => {}, + setCss: () => {}, + getCss: () => undefined, + setColor: () => {}, + getColor: () => undefined + }; + const colors = { + foreground: color, + background: color, + cursor: NULL_COLOR, + cursorAccent: NULL_COLOR, + selectionForeground: undefined, + selectionBackgroundTransparent: NULL_COLOR, + selectionBackgroundOpaque: NULL_COLOR, + selectionInactiveBackgroundTransparent: NULL_COLOR, + selectionInactiveBackgroundOpaque: NULL_COLOR, + overviewRulerBorder: NULL_COLOR, + scrollbarSliderBackground: NULL_COLOR, + scrollbarSliderHoverBackground: NULL_COLOR, + scrollbarSliderActiveBackground: NULL_COLOR, + ansi: new Array(256).fill(color), + contrastCache, + halfContrastCache: contrastCache + }; + return { + customGlyphs: true, + devicePixelRatio: 1, + deviceMaxTextureSize: 4096, + letterSpacing: 0, + lineHeight: 1, + fontSize: 15, + fontFamily: 'monospace', + fontWeight: 'normal', + fontWeightBold: 'bold', + deviceCellWidth: 10, + deviceCellHeight: 20, + deviceCharWidth: 8, + deviceCharHeight: 16, + allowTransparency: false, + drawBoldTextInBrightColors: true, + minimumContrastRatio: 1, + colors, + ...overrides + }; +} + +describe('CharAtlasUtils', () => { + describe('configEquals', () => { + it('should return true for identical configs', () => { + const a = createTestConfig(); + const b = createTestConfig(); + assert.ok(configEquals(a, b)); + }); + + it('should return false when deviceMaxTextureSize differs', () => { + const a = createTestConfig(); + const b = createTestConfig({ deviceMaxTextureSize: 8192 }); + assert.ok(!configEquals(a, b)); + }); + + it('should return false when deviceCellWidth differs', () => { + const a = createTestConfig(); + const b = createTestConfig({ deviceCellWidth: 11 }); + assert.ok(!configEquals(a, b)); + }); + + it('should return false when deviceCellHeight differs', () => { + const a = createTestConfig(); + const b = createTestConfig({ deviceCellHeight: 21 }); + assert.ok(!configEquals(a, b)); + }); + }); +}); diff --git a/addons/addon-webgl/src/CharAtlasUtils.ts b/addons/addon-webgl/src/CharAtlasUtils.ts index 4ce103e98..d9ca69b39 100644 --- a/addons/addon-webgl/src/CharAtlasUtils.ts +++ b/addons/addon-webgl/src/CharAtlasUtils.ts @@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean } } return a.devicePixelRatio === b.devicePixelRatio && + a.deviceMaxTextureSize === b.deviceMaxTextureSize && a.customGlyphs === b.customGlyphs && a.lineHeight === b.lineHeight && a.letterSpacing === b.letterSpacing && @@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean a.fontWeight === b.fontWeight && a.fontWeightBold === b.fontWeightBold && a.allowTransparency === b.allowTransparency && + a.deviceCellWidth === b.deviceCellWidth && + a.deviceCellHeight === b.deviceCellHeight && a.deviceCharWidth === b.deviceCharWidth && a.deviceCharHeight === b.deviceCharHeight && a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors && diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 46b8efeb9..650fc451a 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle'; import { Terminal } from '@xterm/xterm'; import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types'; import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils'; -import type { IOptionsService } from 'common/services/Services'; +import type { ILogService, IOptionsService } from 'common/services/Services'; import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; interface IVertices { @@ -114,7 +114,8 @@ export class GlyphRenderer extends Disposable { private readonly _terminal: Terminal, private readonly _gl: IWebGL2RenderingContext, private _dimensions: IRenderDimensions, - private readonly _optionsService: IOptionsService + private readonly _optionsService: IOptionsService, + private readonly _logService: ILogService ) { super(); @@ -127,7 +128,7 @@ export class GlyphRenderer extends Disposable { TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null); } - this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages))); + this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService)); this._register(toDisposable(() => gl.deleteProgram(this._program))); // Uniform locations @@ -136,8 +137,9 @@ export class GlyphRenderer extends Disposable { this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture')); // Create and set the vertex array object - this._vertexArrayObject = gl.createVertexArray(); - gl.bindVertexArray(this._vertexArrayObject); + const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray(); + this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject))); + gl.bindVertexArray(vertexArrayObject); // Setup a_unitquad, this defines the 4 vertices of a rectangle const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); diff --git a/addons/addon-webgl/src/RectangleRenderer.ts b/addons/addon-webgl/src/RectangleRenderer.ts index 81e129e5c..eebfda3b3 100644 --- a/addons/addon-webgl/src/RectangleRenderer.ts +++ b/addons/addon-webgl/src/RectangleRenderer.ts @@ -14,6 +14,7 @@ import { RenderModelConstants } from './RenderModel'; import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types'; import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils'; import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import type { ILogService } from 'common/services/Services'; const enum VertexAttribLocations { POSITION = 0, @@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable { private _terminal: Terminal, private _gl: IWebGL2RenderingContext, private _dimensions: IRenderDimensions, - private readonly _themeService: IThemeService + private readonly _themeService: IThemeService, + private readonly _logService: ILogService ) { super(); const gl = this._gl; - this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource)); + this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService)); this._register(toDisposable(() => gl.deleteProgram(this._program))); // Uniform locations this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection')); // Create and set the vertex array object - this._vertexArrayObject = gl.createVertexArray(); - gl.bindVertexArray(this._vertexArrayObject); + const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray(); + this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject))); + gl.bindVertexArray(vertexArrayObject); // Setup a_unitquad, this defines the 4 vertices of a rectangle const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 34188235c..dab0b1c85 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas { page.canvas.remove(); } this._onAddTextureAtlasCanvas.dispose(); + this._onRemoveTextureAtlasCanvas.dispose(); } public warmUp(): void { @@ -529,7 +530,7 @@ export class TextureAtlas implements ITextureAtlas { let customGlyph = false; if (this._config.customGlyphs !== false) { 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); + 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, this._logService, 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/Types.ts b/addons/addon-webgl/src/Types.ts index 67c9408d6..294582e37 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -28,6 +28,7 @@ export interface ICursorRenderModel { export interface IWebGL2RenderingContext extends WebGLRenderingContext { vertexAttribDivisor(index: number, divisor: number): void; createVertexArray(): IWebGLVertexArrayObject; + deleteVertexArray(vao: IWebGLVertexArrayObject): void; bindVertexArray(vao: IWebGLVertexArrayObject): void; drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void; } diff --git a/addons/addon-webgl/src/WebglAddon.ts b/addons/addon-webgl/src/WebglAddon.ts index f21fb704e..624c9ea38 100644 --- a/addons/addon-webgl/src/WebglAddon.ts +++ b/addons/addon-webgl/src/WebglAddon.ts @@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender import { ITerminal } from 'browser/Types'; import { Disposable, toDisposable } from 'common/Lifecycle'; import { getSafariVersion, isSafari } from 'common/Platform'; -import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services'; import { IWebGL2RenderingContext } from './Types'; import { WebglRenderer } from './WebglRenderer'; import { Emitter, EventUtils } from 'common/Event'; @@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi const charSizeService: ICharSizeService = unsafeCore._charSizeService; const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService; const decorationService: IDecorationService = unsafeCore._decorationService; + const logService: ILogService = unsafeCore._logService; const themeService: IThemeService = unsafeCore._themeService; this._renderer = this._register(new WebglRenderer( @@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi coreBrowserService, coreService, decorationService, + logService, optionsService, themeService, this._customGlyphs, diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index fdd0ad964..9c2f459df 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -15,7 +15,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants'; import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager'; -import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services'; +import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services'; import { Terminal } from '@xterm/xterm'; import { GlyphRenderer } from './GlyphRenderer'; import { RectangleRenderer } from './RectangleRenderer'; @@ -77,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer { private readonly _coreBrowserService: ICoreBrowserService, private readonly _coreService: ICoreService, private readonly _decorationService: IDecorationService, + private readonly _logService: ILogService, private readonly _optionsService: IOptionsService, private readonly _themeService: IThemeService, private readonly _customGlyphs: boolean = true, @@ -122,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer { this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE); this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => { - console.log('webglcontextlost event received'); + this._logService.debug('webglcontextlost event received'); // Prevent the default behavior in order to enable WebGL context restoration. e.preventDefault(); // Wait a few seconds to see if the 'webglcontextrestored' event is fired. // If not, dispatch the onContextLoss notification to observers. this._contextRestorationTimeout = setTimeout(() => { this._contextRestorationTimeout = undefined; - console.warn('webgl context not restored; firing onContextLoss'); + this._logService.warn('webgl context not restored; firing onContextLoss'); this._onContextLoss.fire(e); }, 3000 /* ms */); })); this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => { - console.warn('webglcontextrestored event received'); + this._logService.warn('webglcontextrestored event received'); clearTimeout(this._contextRestorationTimeout); this._contextRestorationTimeout = undefined; // The texture atlas and glyph renderer must be fully reinitialized @@ -158,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer { this._isAttached = this._core.screenElement!.isConnected; this._register(toDisposable(() => { + clearTimeout(this._contextRestorationTimeout); + this._contextRestorationTimeout = undefined; for (const l of this._renderLayers) { l.dispose(); } @@ -274,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer { * Initializes members dependent on WebGL context state. */ private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] { - this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService); - this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService); + this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService); + this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService); // Update dimensions and acquire char atlas this.handleCharSizeChanged(); diff --git a/addons/addon-webgl/src/WebglUtils.ts b/addons/addon-webgl/src/WebglUtils.ts index da765c802..9944c6bd3 100644 --- a/addons/addon-webgl/src/WebglUtils.ts +++ b/addons/addon-webgl/src/WebglUtils.ts @@ -4,6 +4,7 @@ */ import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import type { ILogService } from 'common/services/Services'; /** * A matrix that when multiplies will translate 0-1 coordinates (left to right, @@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([ -1, 1, 0, 1 ]); -export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined { +export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined { const program = throwIfFalsy(gl.createProgram()); - gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource))); - gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource))); + gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService))); + gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService))); gl.linkProgram(program); const success = gl.getProgramParameter(program, gl.LINK_STATUS); if (success) { return program; } - console.error(gl.getProgramInfoLog(program)); + logService.error(gl.getProgramInfoLog(program)); gl.deleteProgram(program); } -export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined { +export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined { const shader = throwIfFalsy(gl.createShader(type)); gl.shaderSource(shader, source); gl.compileShader(shader); @@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st return shader; } - console.error(gl.getShaderInfoLog(shader)); + logService.error(gl.getShaderInfoLog(shader)); gl.deleteShader(shader); } diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts index ba8925727..357d512d2 100644 --- a/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts +++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphRasterizer.ts @@ -4,6 +4,7 @@ */ import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils'; +import type { ILogService } from 'common/services/Services'; import { customGlyphDefinitions } from './CustomGlyphDefinitions'; import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types'; @@ -22,6 +23,7 @@ export function tryDrawCustomGlyph( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, + logService: ILogService, backgroundColor?: string, variantOffset: number = 0 ): boolean { @@ -30,7 +32,7 @@ export function tryDrawCustomGlyph( // 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, variantOffset); + drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset); } return true; } @@ -49,6 +51,7 @@ function drawDefinitionPart( deviceCharHeight: number, fontSize: number, devicePixelRatio: number, + logService: ILogService, backgroundColor?: string, variantOffset: number = 0 ): void { @@ -79,7 +82,7 @@ function drawDefinitionPart( 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); + drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth); break; case CustomGlyphDefinitionType.PATH: drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth); @@ -88,7 +91,7 @@ function drawDefinitionPart( drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor); break; case CustomGlyphDefinitionType.VECTOR_SHAPE: - drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio); + drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService); break; case CustomGlyphDefinitionType.BRAILLE: drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight); @@ -510,6 +513,7 @@ function drawPathFunctionCharacter( deviceCellWidth: number, deviceCellHeight: number, devicePixelRatio: number, + logService: ILogService, strokeWidth?: number ): void { ctx.save(); @@ -536,7 +540,7 @@ function drawPathFunctionCharacter( } const f = svgToCanvasInstructionMap[type]; if (!f) { - console.error(`Could not find drawing instructions for "${type}"`); + logService.error(`Could not find drawing instructions for "${type}"`); continue; } const args: string[] = instruction.substring(1).split(','); @@ -598,7 +602,8 @@ function drawVectorShape( deviceCellWidth: number, deviceCellHeight: number, fontSize: number, - devicePixelRatio: number + devicePixelRatio: number, + logService: ILogService ): void { // Clip the cell to make sure drawing doesn't occur beyond bounds const clipRegion = new Path2D(); @@ -619,7 +624,7 @@ function drawVectorShape( } const f = svgToCanvasInstructionMap[type]; if (!f) { - console.error(`Could not find drawing instructions for "${type}"`); + logService.error(`Could not find drawing instructions for "${type}"`); continue; } const args: string[] = instruction.substring(1).split(','); diff --git a/eslint.config.mjs b/eslint.config.mjs index 2b502098f..a5d8a6f91 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,6 +43,7 @@ export default tseslint.config( singleline: { delimiter: 'comma', requireLast: false } }], '@stylistic/type-annotation-spacing': 'warn', + '@stylistic/space-unary-ops': 'warn', '@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }], '@typescript-eslint/consistent-type-assertions': 'warn', @@ -85,6 +86,7 @@ export default tseslint.config( 'eqeqeq': ['warn', 'always'], 'jsdoc/check-alignment': 'warn', 'jsdoc/check-param-names': 'warn', + 'jsdoc/multiline-blocks': 'warn', 'jsdoc/no-multi-asterisks': 'warn', 'keyword-spacing': 'warn', 'max-len': ['warn', { diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 65c77e694..62c4ae31c 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -563,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { try { this._onWillOpen.fire(this.element); + } catch (e) { + this._logService.error('onWillOpen handler threw an exception', e); } - catch { /* fails to load addon for some reason */ } if (!this._renderService.hasRenderer()) { this._renderService.setRenderer(this._createRenderer()); } diff --git a/src/browser/Linkifier.test.ts b/src/browser/Linkifier.test.ts index 4294ef105..685c404f4 100644 --- a/src/browser/Linkifier.test.ts +++ b/src/browser/Linkifier.test.ts @@ -43,6 +43,20 @@ describe('Linkifier2', () => { }, activate: () => { } }; + const multiLineLink: ILink = { + text: 'foo', + range: { + start: { + x: 2, + y: 1 + }, + end: { + x: 4, + y: 2 + } + }, + activate: () => { } + }; beforeEach(() => { const dom = new jsdom.JSDOM(); @@ -86,4 +100,30 @@ describe('Linkifier2', () => { linkifier.linkLeave({ classList: { add: () => { } } } as any, link, {} as any); }); + it('onShowLinkUnderline event range is correct for wrapped links', done => { + linkifier.onShowLinkUnderline(e => { + assert.equal(multiLineLink.range.start.x - 1, e.x1); + assert.equal(multiLineLink.range.start.y - 1, e.y1); + assert.equal(multiLineLink.range.end.x, e.x2); + assert.equal(multiLineLink.range.end.y - 1, e.y2); + + done(); + }); + + linkifier.linkHover({ classList: { add: () => { } } } as any, multiLineLink, {} as any); + }); + + it('onHideLinkUnderline event range is correct for wrapped links', done => { + linkifier.onHideLinkUnderline(e => { + assert.equal(multiLineLink.range.start.x - 1, e.x1); + assert.equal(multiLineLink.range.start.y - 1, e.y1); + assert.equal(multiLineLink.range.end.x, e.x2); + assert.equal(multiLineLink.range.end.y - 1, e.y2); + + done(); + }); + + linkifier.linkLeave({ classList: { add: () => { } } } as any, multiLineLink, {} as any); + }); + }); diff --git a/src/browser/OscLinkProvider.test.ts b/src/browser/OscLinkProvider.test.ts new file mode 100644 index 000000000..9e844cd16 --- /dev/null +++ b/src/browser/OscLinkProvider.test.ts @@ -0,0 +1,105 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { OscLinkProvider } from 'browser/OscLinkProvider'; +import { ILink } from 'browser/Types'; +import { createCellData, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { IBufferService, IOscLinkService } from 'common/services/Services'; +import { IBufferLine, IOscLinkData } from 'common/Types'; + +class TestOscLinkService implements IOscLinkService { + public serviceBrand: any; + public registerLink(_linkData: IOscLinkData): number { return 0; } + public addLineToLink(_linkId: number, _y: number): void { } + public getLinkData(linkId: number): IOscLinkData | undefined { + return { uri: `https://example.com/${linkId}` }; + } +} + +function setText(line: IBufferLine | undefined, x: number, text: string): void { + if (!line) { + throw new Error('Missing buffer line'); + } + for (let i = 0; i < text.length; i++) { + line.setCell(x + i, createCellData(0, text[i], 1)); + } +} + +function setUrl(line: IBufferLine | undefined, x: number, text: string, linkId: number): void { + if (!line) { + throw new Error('Missing buffer line'); + } + for (let i = 0; i < text.length; i++) { + const cell = createCellData(0, text[i], 1); + cell.extended.urlId = linkId; + cell.updateExtended(); + line.setCell(x + i, cell); + } +} + +function getLinks(provider: OscLinkProvider, y: number): Promise { + return new Promise(resolve => provider.provideLinks(y, links => resolve(links ?? []))); +} + +describe('OscLinkProvider', () => { + let bufferService: IBufferService; + let provider: OscLinkProvider; + + beforeEach(() => { + const optionsService = new MockOptionsService(); + bufferService = new MockBufferService(5, 5, optionsService); + provider = new OscLinkProvider(bufferService, optionsService, new TestOscLinkService()); + }); + + it('expands a wrapped link range backward to the previous line', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setText(line1, 0, 'aa'); + setUrl(line1, 2, 'bbb', 1); + setUrl(line2, 0, 'cccc', 1); + setText(line2, 4, 'x'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 2); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 3, y: 1 }, + end: { x: 4, y: 2 } + }); + }); + + it('expands a wrapped link range forward when a link ends at line boundary', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setUrl(line1, 0, 'aaaaa', 1); + setUrl(line2, 0, 'bb', 1); + setText(line2, 2, 'ccc'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 1); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 1, y: 1 }, + end: { x: 2, y: 2 } + }); + }); + + it('does not merge wrapped links with different url ids', async () => { + const line1 = bufferService.buffer.lines.get(0); + const line2 = bufferService.buffer.lines.get(1); + setUrl(line1, 0, 'aaaaa', 1); + setUrl(line2, 0, 'bbb', 2); + setText(line2, 3, 'cc'); + line2!.isWrapped = true; + + const links = await getLinks(provider, 1); + assert.lengthOf(links, 1); + assert.deepEqual(links[0].range, { + start: { x: 1, y: 1 }, + end: { x: 5, y: 1 } + }); + }); +}); diff --git a/src/browser/OscLinkProvider.ts b/src/browser/OscLinkProvider.ts index b6a3cce0a..d01a20b35 100644 --- a/src/browser/OscLinkProvider.ts +++ b/src/browser/OscLinkProvider.ts @@ -6,6 +6,7 @@ import { IBufferRange, ILink } from 'browser/Types'; import { ILinkProvider } from 'browser/services/Services'; import { CellData } from 'common/buffer/CellData'; +import { IBufferLine } from 'common/Types'; import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services'; export class OscLinkProvider implements ILinkProvider { @@ -57,19 +58,8 @@ export class OscLinkProvider implements ILinkProvider { if (finishLink || (currentStart !== -1 && x === lineLength - 1)) { const text = this._oscLinkService.getLinkData(currentLinkId)?.uri; if (text) { - // These ranges are 1-based - const range: IBufferRange = { - start: { - x: currentStart + 1, - y - }, - end: { - // Offset end x if it's a link that ends on the last cell in the line - x: x + (!finishLink && x === lineLength - 1 ? 1 : 0), - y - } - }; - + const endX = x + (!finishLink && x === lineLength - 1 ? 1 : 0); + const range = this._getRangeWithLineWrap(y, currentStart, endX, currentLinkId); let ignoreLink = false; if (!linkHandler?.allowNonHttpProtocols) { try { @@ -111,6 +101,82 @@ export class OscLinkProvider implements ILinkProvider { // id callback(result); } + + /** + * Expand a single-line OSC 8 range to a contiguous wrapped range for the same link id. + */ + private _getRangeWithLineWrap(y: number, startX: number, endX: number, linkId: number): IBufferRange { + let startY = y; + let finalStartX = startX; + let endY = y; + let finalEndX = endX; + + // Expand upward only when this segment starts at column 0 and the current line is wrapped. + while (finalStartX === 0) { + const currentLine = this._bufferService.buffer.lines.get(startY - 1); + if (!currentLine?.isWrapped) { + break; + } + const previousLine = this._bufferService.buffer.lines.get(startY - 2); + if (!previousLine) { + break; + } + const previousLineLength = previousLine.getTrimmedLength(); + if (previousLineLength === 0 || !this._hasUrlId(previousLine, previousLineLength - 1, linkId)) { + break; + } + let previousStartX = previousLineLength - 1; + while (previousStartX > 0 && this._hasUrlId(previousLine, previousStartX - 1, linkId)) { + previousStartX--; + } + startY--; + finalStartX = previousStartX; + } + + // Expand downward only when this segment reaches trimmed EOL and the next line is wrapped. + while (true) { + const currentLine = this._bufferService.buffer.lines.get(endY - 1); + if (!currentLine) { + break; + } + const currentLineLength = currentLine.getTrimmedLength(); + if (finalEndX !== currentLineLength) { + break; + } + const nextLine = this._bufferService.buffer.lines.get(endY); + if (!nextLine?.isWrapped) { + break; + } + const nextLineLength = nextLine.getTrimmedLength(); + if (nextLineLength === 0 || !this._hasUrlId(nextLine, 0, linkId)) { + break; + } + let nextEndX = 1; + while (nextEndX < nextLineLength && this._hasUrlId(nextLine, nextEndX, linkId)) { + nextEndX++; + } + endY++; + finalEndX = nextEndX; + } + + // IBufferRange uses 1-based coordinates. + return { + start: { + x: finalStartX + 1, + y: startY + }, + end: { + x: finalEndX, + y: endY + } + }; + } + + private _hasUrlId(line: IBufferLine, x: number, linkId: number): boolean { + const cell = this._workCell; + line.loadCell(x, cell); + return !!cell.hasExtendedAttrs() && cell.extended.urlId === linkId; + } } function defaultActivate(e: MouseEvent, uri: string): void { diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts index e2455fd2b..60f0a6ab9 100644 --- a/src/browser/services/MouseCoordsService.ts +++ b/src/browser/services/MouseCoordsService.ts @@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService { public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { return getCoords( - window, + getWindow(element), event, element, colCount, diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 83e3d3379..e999b7eac 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); + private _intersectionObserver: IntersectionObserver | undefined; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService { // and resume based on terminal visibility if so if ('IntersectionObserver' in w) { const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 }); + this._observerDisposable.value = toDisposable(() => { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = undefined; + }); + this._intersectionObserver = observer; observer.observe(screenElement); - this._observerDisposable.value = toDisposable(() => observer.disconnect()); } } diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index e4a06ad0c..6968feb7e 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -9,7 +9,7 @@ import { moveToCellSequence } from 'browser/input/MoveToCell'; import { SelectionModel } from 'browser/selection/SelectionModel'; import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types'; import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services'; -import { Disposable, toDisposable } from 'common/Lifecycle'; +import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle'; import * as Browser from 'common/Platform'; import { IBufferLine, ICellData, IDisposable } from 'common/Types'; import { getRangeLength } from 'common/buffer/BufferRange'; @@ -102,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService { private _mouseMoveListener: EventListener; private _mouseUpListener: EventListener; - private _trimListener: IDisposable; + private readonly _trimListener = this._register(new MutableDisposable()); private _workCell: CellData = new CellData(); private _mouseDownTimeStamp: number = 0; @@ -140,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService { this.clearSelection(); } }); - this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount)); + this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount)); this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e))); this.enable(); @@ -769,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService { // reverseIndex) and delete in a splice is only ever used when the same // number of elements was just added. Given this is could actually be // beneficial to leave the selection as is for these cases. - this._trimListener.dispose(); - this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount)); + this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount)); } /** diff --git a/src/common/Async.ts b/src/common/Async.ts index 7345f19da..276532fb8 100644 --- a/src/common/Async.ts +++ b/src/common/Async.ts @@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable { } } +/** + * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled + * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not + * run yet. + */ +export class MicrotaskTimer implements IDisposable { + private _isScheduled = false; + private _isDisposed = false; + + public dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + public cancel(): void { + this._isScheduled = false; + } + + public set(runner: () => void): void { + if (this._isDisposed) { + throw new Error('Calling set on a disposed MicrotaskTimer'); + } + if (this._isScheduled) { + return; + } + this._isScheduled = true; + queueMicrotask(() => { + if (!this._isScheduled) { + return; + } + this._isScheduled = false; + runner(); + }); + } +} + export class IntervalTimer implements IDisposable { private _disposable: IDisposable | undefined; private _isDisposed = false; diff --git a/src/common/Clone.test.ts b/src/common/Clone.test.ts deleted file mode 100644 index 5fbe4bf9b..000000000 --- a/src/common/Clone.test.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -import { assert } from 'chai'; -import { clone } from 'common/Clone'; - -describe('clone', () => { - it('should clone simple objects', () => { - const test = { - a: 1, - b: 2 - }; - - assert.deepEqual(clone(test), { a: 1, b: 2 }); - }); - - it('should clone nested objects', () => { - const test = { - bar: { - a: 1, - b: 2, - c: { - foo: 'bar' - } - } - }; - - assert.deepEqual(clone(test), { - bar: { - a: 1, - b: 2, - c: { - foo: 'bar' - } - } - }); - }); - - it('should clone array values', () => { - const test = { - a: [1, 2, 3], - b: [1, null, 'test', { foo: 'bar' }] - }; - - assert.deepEqual(clone(test), { - a: [1, 2, 3], - b: [1, null, 'test', { foo: 'bar' }] - }); - }); - - it('should stop mutation from occuring on the original object', () => { - const test = { - a: 1, - b: 2, - c: { - foo: 'bar' - } - }; - - const cloned = clone(test); - - test.a = 5; - test.c.foo = 'barbaz'; - - assert.deepEqual(cloned, { - a: 1, - b: 2, - c: { - foo: 'bar' - } - }); - }); - - it('should clone to a maximum depth of 5 by default', () => { - const test = { - a: { - b: { - c: { - d: { - e: { - f: 'foo' - } - } - } - } - } - }; - - const cloned = clone(test); - - test.a.b.c.d.e.f = 'bar'; - - // The values at a greater depth then 5 should not be cloned - assert.equal((cloned as any).a.b.c.d.e.f, 'bar'); - }); - - it('should allow an optional maximum depth to be set', () => { - const test = { - a: { - b: { - c: 'foo' - } - } - }; - - const cloned = clone(test, 2); - - test.a.b.c = 'bar'; - - // The values at a greater depth then 2 should not be cloned - assert.equal((cloned as any).a.b.c, 'bar'); - }); - - it('should not throw when cloning a recursive reference', () => { - const test = { - a: { - b: { - c: {} - } - } - }; - - test.a.b.c = test; - - assert.doesNotThrow(() => clone(test)); - }); -}); diff --git a/src/common/Clone.ts b/src/common/Clone.ts deleted file mode 100644 index 37821fe02..000000000 --- a/src/common/Clone.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2016 The xterm.js authors. All rights reserved. - * @license MIT - */ - -/* - * A simple utility for cloning values - */ -export function clone(val: T, depth: number = 5): T { - if (typeof val !== 'object') { - return val; - } - - // If we're cloning an array, use an array as the base, otherwise use an object - const clonedObject: any = Array.isArray(val) ? [] : {}; - - for (const key in val) { - // Recursively clone eack item unless we're at the maximum depth - clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1)); - } - - return clonedObject as T; -} diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index 9bd7faaf5..80168aa65 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -263,7 +263,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal { private _handleWindowsPtyOptionChange(): void { let value = false; const windowsPty = this.optionsService.rawOptions.windowsPty; - if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) { + if (windowsPty && windowsPty.backend !== undefined && windowsPty.buildNumber !== undefined) { value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376); } if (value) { diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index f9da81a25..5c59b062b 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -14,7 +14,6 @@ import { Params } from 'common/parser/Params'; import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test'; import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; -import { clone } from 'common/Clone'; import { BufferService } from 'common/services/BufferService'; import { CoreService } from 'common/services/CoreService'; import { OscLinkService } from 'common/services/OscLinkService'; @@ -227,32 +226,32 @@ describe('InputHandler', () => { assert.equal(coreService.decPrivateModes.cursorStyle, undefined); assert.equal(coreService.decPrivateModes.cursorBlink, undefined); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([1])); assert.equal(coreService.decPrivateModes.cursorStyle, 'block'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([2])); assert.equal(coreService.decPrivateModes.cursorStyle, 'block'); assert.equal(coreService.decPrivateModes.cursorBlink, false); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([3])); assert.equal(coreService.decPrivateModes.cursorStyle, 'underline'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([4])); assert.equal(coreService.decPrivateModes.cursorStyle, 'underline'); assert.equal(coreService.decPrivateModes.cursorBlink, false); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([5])); assert.equal(coreService.decPrivateModes.cursorStyle, 'bar'); assert.equal(coreService.decPrivateModes.cursorBlink, true); - optionsService.options = clone(DEFAULT_OPTIONS); + optionsService.options = structuredClone(DEFAULT_OPTIONS); inputHandler.setCursorStyle(Params.fromArray([6])); assert.equal(coreService.decPrivateModes.cursorStyle, 'bar'); assert.equal(coreService.decPrivateModes.cursorBlink, false); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 2b7142dd0..fd1fdb8a1 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -567,8 +567,9 @@ export class InputHandler extends Disposable implements IInputHandler { if (screenReaderMode) { this._onA11yChar.fire(stringFromCodePoint(code)); } - if (this._getCurrentLinkId()) { - this._oscLinkService.addLineToLink(this._getCurrentLinkId(), this._activeBuffer.ybase + this._activeBuffer.y); + const linkId = this._getCurrentLinkId(); + if (linkId) { + this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y); } // goto next line if ch would overflow @@ -2590,10 +2591,6 @@ export class InputHandler extends Disposable implements IInputHandler { * | 3 | CMY color. | #N | * | 4 | CMYK color. | #N | * | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y | - * - * - * FIXME: blinking is implemented in attrs, but not working in renderers? - * FIXME: remove dead branch for p=100 */ public charAttributes(params: IParams): boolean { // Optimize a single SGR0. diff --git a/src/common/StringBuilder.test.ts b/src/common/StringBuilder.test.ts new file mode 100644 index 000000000..1794de0f7 --- /dev/null +++ b/src/common/StringBuilder.test.ts @@ -0,0 +1,143 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { assert } from 'chai'; +import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder'; + +describe('StringBuilder', () => { + it('should start empty', () => { + const builder = new StringBuilder(); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should append a single chunk', () => { + const builder = new StringBuilder(); + builder.append('hello'); + assert.strictEqual(builder.length, 5); + assert.strictEqual(builder.toString(), 'hello'); + }); + + it('should join multiple chunks in order', () => { + const builder = new StringBuilder(); + builder.append('foo'); + builder.append('bar'); + builder.append('baz'); + assert.strictEqual(builder.length, 9); + assert.strictEqual(builder.toString(), 'foobarbaz'); + }); + + it('should handle empty chunks', () => { + const builder = new StringBuilder(); + builder.append(''); + builder.append('a'); + builder.append(''); + assert.strictEqual(builder.length, 1); + assert.strictEqual(builder.toString(), 'a'); + }); + + it('should reset accumulated data', () => { + const builder = new StringBuilder(); + builder.append('hello'); + builder.reset(); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should allow appending after reset', () => { + const builder = new StringBuilder(); + builder.append('old'); + builder.reset(); + builder.append('new'); + assert.strictEqual(builder.toString(), 'new'); + }); + + it('should accumulate many small chunks without quadratic concatenation', () => { + const builder = new StringBuilder(); + const chunk = 'x'; + const count = 10000; + for (let i = 0; i < count; i++) { + builder.append(chunk); + } + assert.strictEqual(builder.length, count); + assert.strictEqual(builder.toString(), 'x'.repeat(count)); + }); +}); + +describe('LimitedStringBuilder', () => { + it('should expose the configured limit', () => { + const builder = new LimitedStringBuilder(42); + assert.strictEqual(builder.limit, 42); + }); + + it('should start empty', () => { + const builder = new LimitedStringBuilder(10); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should accept data up to the limit', () => { + const builder = new LimitedStringBuilder(10); + assert.strictEqual(builder.append('12345'), false); + assert.strictEqual(builder.append('67890'), false); + assert.strictEqual(builder.length, 10); + assert.strictEqual(builder.toString(), '1234567890'); + }); + + it('should accept a single chunk exactly at the limit', () => { + const builder = new LimitedStringBuilder(5); + assert.strictEqual(builder.append('abcde'), false); + assert.strictEqual(builder.length, 5); + assert.strictEqual(builder.toString(), 'abcde'); + }); + + it('should reject data exceeding the limit and clear the buffer', () => { + const builder = new LimitedStringBuilder(5); + builder.append('abc'); + assert.strictEqual(builder.append('def'), true); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should reject a single chunk larger than the limit', () => { + const builder = new LimitedStringBuilder(3); + assert.strictEqual(builder.append('toolong'), true); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); + + it('should allow appending again after reset following a limit breach', () => { + const builder = new LimitedStringBuilder(3); + assert.strictEqual(builder.append('abcd'), true); + builder.reset(); + assert.strictEqual(builder.append('ab'), false); + assert.strictEqual(builder.toString(), 'ab'); + }); + + it('should accumulate many chunks before hitting the limit', () => { + const limit = 100; + const builder = new LimitedStringBuilder(limit); + const chunk = 'A'; + for (let i = 0; i < limit; i++) { + assert.strictEqual(builder.append(chunk), false); + } + assert.strictEqual(builder.toString(), 'A'.repeat(limit)); + assert.strictEqual(builder.append('B'), true); + assert.strictEqual(builder.toString(), ''); + }); + + it('should reject when limit is zero and any data is appended', () => { + const builder = new LimitedStringBuilder(0); + assert.strictEqual(builder.append('a'), true); + assert.strictEqual(builder.length, 0); + }); + + it('should allow zero-length appends at the limit', () => { + const builder = new LimitedStringBuilder(0); + assert.strictEqual(builder.append(''), false); + assert.strictEqual(builder.length, 0); + assert.strictEqual(builder.toString(), ''); + }); +}); diff --git a/src/common/StringBuilder.ts b/src/common/StringBuilder.ts new file mode 100644 index 000000000..d9184ce31 --- /dev/null +++ b/src/common/StringBuilder.ts @@ -0,0 +1,67 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +/** + * Accumulates string data from multiple chunks without O(n²) string concatenation. + */ +export class StringBuilder { + private _chunks: string[] = []; + private _length = 0; + + public get length(): number { + return this._length; + } + + public reset(): void { + this._chunks.length = 0; + this._length = 0; + } + + public append(chunk: string): void { + this._chunks.push(chunk); + this._length += chunk.length; + } + + public toString(): string { + return this._chunks.join(''); + } +} + +/** + * String builder that rejects payloads larger than a fixed limit. + */ +export class LimitedStringBuilder { + private readonly _builder = new StringBuilder(); + + constructor(private readonly _limit: number) { } + + public get length(): number { + return this._builder.length; + } + + public get limit(): number { + return this._limit; + } + + public reset(): void { + this._builder.reset(); + } + + /** + * @returns true if the limit was exceeded (buffer is cleared in that case) + */ + public append(chunk: string): boolean { + this._builder.append(chunk); + if (this._builder.length > this._limit) { + this._builder.reset(); + return true; + } + return false; + } + + public toString(): string { + return this._builder.toString(); + } +} diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts index 5940fa659..69c2d499d 100644 --- a/src/common/TestUtils.test.ts +++ b/src/common/TestUtils.test.ts @@ -5,7 +5,6 @@ import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services'; import { UnicodeService } from 'common/services/UnicodeService'; -import { clone } from 'common/Clone'; import { DEFAULT_OPTIONS } from 'common/services/OptionsService'; import { IBufferSet, IBuffer } from 'common/buffer/Types'; import { BufferSet } from 'common/buffer/BufferSet'; @@ -157,7 +156,7 @@ export class MockLogService implements ILogService { export class MockOptionsService implements IOptionsService { public serviceBrand: any; - public readonly rawOptions: Required = clone(DEFAULT_OPTIONS); + public readonly rawOptions: Required = structuredClone(DEFAULT_OPTIONS); public options: Required = this.rawOptions; public onOptionChange: IEvent = new Emitter().event; constructor(testOptions?: Partial) { diff --git a/src/common/Types.ts b/src/common/Types.ts index 5edf63637..07aebbe8f 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -528,8 +528,8 @@ export interface IInputHandler { /** CSI ' } */ insertColumns(params: IParams): boolean; /** CSI ' ~ */ deleteColumns(params: IParams): boolean; - /** OSC 0 - OSC 2 */ setTitle(data: string): boolean; + /** OSC 0 */ + /** OSC 2 */ setTitle(data: string): boolean; /** OSC 4 */ setOrReportIndexedColor(data: string): boolean; /** OSC 10 */ setOrReportFgColor(data: string): boolean; /** OSC 11 */ setOrReportBgColor(data: string): boolean; @@ -542,24 +542,24 @@ export interface IInputHandler { /** ESC E */ nextLine(): boolean; /** ESC = */ keypadApplicationMode(): boolean; /** ESC > */ keypadNumericMode(): boolean; - /** ESC % G - ESC % @ */ selectDefaultCharset(): boolean; - /** ESC ( C - ESC ) C - ESC * C - ESC + C - ESC - C - ESC . C - ESC / C */ selectCharset(collectAndFlag: string): boolean; + /** ESC % G */ + /** ESC % @ */ selectDefaultCharset(): boolean; + /** ESC ( C */ + /** ESC ) C */ + /** ESC * C */ + /** ESC + C */ + /** ESC - C */ + /** ESC . C */ + /** ESC / C */ selectCharset(collectAndFlag: string): boolean; /** ESC D */ index(): boolean; /** ESC H */ tabSet(): boolean; /** ESC M */ reverseIndex(): boolean; /** ESC c */ fullReset(): boolean; - /** ESC n - ESC o - ESC | - ESC } - ESC ~ */ setgLevel(level: number): boolean; + /** ESC n */ + /** ESC o */ + /** ESC | */ + /** ESC } */ + /** ESC ~ */ setgLevel(level: number): boolean; /** ESC # 8 */ screenAlignmentPattern(): boolean; } diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index be200f112..e24a000c9 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -8,6 +8,7 @@ import { AttributeData } from 'common/buffer/AttributeData'; import { CellData } from 'common/buffer/CellData'; import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants'; import { stringFromCodePoint } from 'common/input/TextDecoder'; +import { StringBuilder } from 'common/StringBuilder'; // Buffer memory layout: // @@ -41,6 +42,7 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData()); // Work variables to avoid garbage collection let $startIndex = 0; const $workCell = new CellData(); +const $translateToStringBuilder = new StringBuilder(); export interface IBufferLineStringCacheEntry { value: string | undefined; @@ -570,12 +572,12 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.length = 0; } - let result = ''; + $translateToStringBuilder.reset(); while (startCol < endCol) { const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT]; const cp = content & Content.CODEPOINT_MASK; const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR; - result += chars; + $translateToStringBuilder.append(chars); if (outColumns) { for (let i = 0; i < chars.length; ++i) { outColumns.push(startCol); @@ -586,6 +588,8 @@ export class BufferLine implements IBufferLine { if (outColumns) { outColumns.push(startCol); } + const result = $translateToStringBuilder.toString(); + $translateToStringBuilder.reset(); if (isCanonicalRequest) { const cacheEntry = this._getStringCacheEntry(true)!; cacheEntry.value = result; diff --git a/src/common/buffer/BufferReflow.ts b/src/common/buffer/BufferReflow.ts index 44aa0976f..45a194711 100644 --- a/src/common/buffer/BufferReflow.ts +++ b/src/common/buffer/BufferReflow.ts @@ -178,7 +178,10 @@ export function reflowLargerApplyNewLayout(lines: CircularList, new */ export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] { const newLineLengths: number[] = []; - const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c); + let cellsNeeded = 0; + for (let i = 0; i < wrappedLines.length; i++) { + cellsNeeded += getWrappedLineTrimmedLength(wrappedLines, i, oldCols); + } // Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and // linesNeeded diff --git a/src/common/input/WriteBuffer.test.ts b/src/common/input/WriteBuffer.test.ts index f366a6320..8d8c81fc2 100644 --- a/src/common/input/WriteBuffer.test.ts +++ b/src/common/input/WriteBuffer.test.ts @@ -125,5 +125,86 @@ describe('WriteBuffer', () => { assert.deepEqual(stack, []); assert.deepEqual(cbStack, []); }); + it('flushSync fires onWriteParsed', () => { + let parsed = 0; + wb.onWriteParsed(() => parsed++); + wb.write('a'); + wb.write('b'); + assert.equal(parsed, 0); + wb.flushSync(); + assert.equal(parsed, 1); + }); + it('flushSync with no pending writes does not fire onWriteParsed', () => { + let parsed = 0; + wb.onWriteParsed(() => parsed++); + wb.flushSync(); + assert.equal(parsed, 0); + }); + it('dispose cancels scheduled innerWrite', done => { + wb.write('a'); + wb.dispose(); + setTimeout(() => { + assert.deepEqual(stack, []); + done(); + }, 20); + }); + it('dispose does not fire onWriteParsed for pending writes', done => { + let parsed = 0; + wb.onWriteParsed(() => parsed++); + wb.write('a'); + wb.dispose(); + setTimeout(() => { + assert.equal(parsed, 0); + done(); + }, 20); + }); + it('write after dispose is a no-op', done => { + wb.dispose(); + wb.write('a'); + setTimeout(() => { + assert.deepEqual(stack, []); + done(); + }, 20); + }); + it('dispose is idempotent', done => { + wb.write('a'); + wb.dispose(); + wb.dispose(); + setTimeout(() => { + assert.deepEqual(stack, []); + done(); + }, 20); + }); + it('async handler continuation is skipped after dispose', done => { + let resolve!: (value: boolean) => void; + const pending = new Promise(r => { resolve = r; }); + wb = new WriteBuffer(() => pending); + wb.write('a'); + wb.dispose(); + resolve(true); + setTimeout(() => { + assert.deepEqual(stack, []); + done(); + }, 20); + }); + it('handleUserInput still processes first chunk synchronously', () => { + wb.handleUserInput(); + wb.write('a'); + assert.deepEqual(stack, ['a']); + }); + it('flushSync after dispose is a no-op', done => { + wb.write('a'); + wb.dispose(); + wb.flushSync(); + setTimeout(() => { + assert.deepEqual(stack, []); + done(); + }, 20); + }); + it('writeSync after dispose is a no-op', () => { + wb.dispose(); + wb.writeSync('a'); + assert.deepEqual(stack, []); + }); }); }); diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index b936c9dbf..7ecab58ad 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -4,11 +4,10 @@ * @license MIT */ -import { Disposable } from 'common/Lifecycle'; +import { TimeoutTimer } from 'common/Async'; +import { Disposable, toDisposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -declare const setTimeout: (handler: () => void, timeout?: number) => void; - const enum Constants { /** * Safety watermark to avoid memory exhaustion and browser engine crash on fast data input. @@ -43,11 +42,18 @@ export class WriteBuffer extends Disposable { private _syncCalls = 0; private _didUserInput = false; + private readonly _innerWriteTimer = this._register(new TimeoutTimer()); private readonly _onWriteParsed = this._register(new Emitter()); public readonly onWriteParsed = this._onWriteParsed.event; constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise) { super(); + this._register(toDisposable(() => { + this._writeBuffer.length = 0; + this._callbacks.length = 0; + this._pendingData = 0; + this._bufferOffset = 0; + })); } public handleUserInput(): void { @@ -63,6 +69,9 @@ export class WriteBuffer extends Disposable { * promises to resolve. */ public flushSync(): void { + if (this._store.isDisposed) { + return; + } // exit early if another sync write loop is active if (this._isSyncWriting) { return; @@ -71,7 +80,9 @@ export class WriteBuffer extends Disposable { // Process all pending chunks synchronously let chunk: string | Uint8Array | undefined; + let didProcess = false; while (chunk = this._writeBuffer.shift()) { + didProcess = true; this._action(chunk); const cb = this._callbacks.shift(); if (cb) cb(); @@ -84,12 +95,18 @@ export class WriteBuffer extends Disposable { this._callbacks.length = 0; this._isSyncWriting = false; + if (didProcess) { + this._onWriteParsed.fire(); + } } /** * @deprecated Unreliable, to be removed soon. */ public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void { + if (this._store.isDisposed) { + return; + } // stop writeSync recursions with maxSubsequentCalls argument // This is dangerous to use as it will lose the current data chunk // and return immediately. @@ -133,6 +150,9 @@ export class WriteBuffer extends Disposable { } public write(data: string | Uint8Array, callback?: () => void): void { + if (this._store.isDisposed) { + return; + } if (this._pendingData > Constants.DISCARD_WATERMARK) { throw new Error('write data discarded, use flow control to avoid losing data'); } @@ -153,7 +173,7 @@ export class WriteBuffer extends Disposable { return; } - setTimeout(() => this._innerWrite()); + this._scheduleInnerWrite(); } this._pendingData += data.length; @@ -189,7 +209,17 @@ export class WriteBuffer extends Disposable { * * Note, for pure sync code `lastTime` and `promiseResult` have no meaning. */ + private _scheduleInnerWrite(lastTime: number = 0, promiseResult: boolean = true): void { + if (this._store.isDisposed) { + return; + } + this._innerWriteTimer.cancelAndSet(() => this._innerWrite(lastTime, promiseResult), 0); + } + protected _innerWrite(lastTime: number = 0, promiseResult: boolean = true): void { + if (this._store.isDisposed) { + return; + } const startTime = lastTime || performance.now(); while (this._writeBuffer.length > this._bufferOffset) { const data = this._writeBuffer[this._bufferOffset]; @@ -218,9 +248,16 @@ export class WriteBuffer extends Disposable { * responsibility to slice hard work), but we can at least schedule a screen update as we * gain control. */ - const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS - ? setTimeout(() => this._innerWrite(0, r)) - : this._innerWrite(startTime, r); + const continuation: (r: boolean) => void = (r: boolean) => { + if (this._store.isDisposed) { + return; + } + if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) { + this._scheduleInnerWrite(0, r); + } else { + this._innerWrite(startTime, r); + } + }; /** * Optimization considerations: @@ -267,7 +304,7 @@ export class WriteBuffer extends Disposable { this._callbacks = this._callbacks.slice(this._bufferOffset); this._bufferOffset = 0; } - setTimeout(() => this._innerWrite()); + this._scheduleInnerWrite(); } else { this._writeBuffer.length = 0; this._callbacks.length = 0; diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index 8f921f8cb..4e647d037 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -7,6 +7,7 @@ import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, IS import { ParserConstants } from 'common/parser/Constants'; import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IApcHandler[] = []; @@ -153,13 +154,13 @@ export class ApcParser implements IApcParser { export class ApcHandler implements IApcHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(ApcHandler._payloadLimit); private _hitLimit: boolean = false; constructor(private _handler: (data: string) => boolean | Promise) { } public start(): void { - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -167,9 +168,7 @@ export class ApcHandler implements IApcHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > ApcHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -179,18 +178,18 @@ export class ApcHandler implements IApcHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data); + ret = this._handler(this._data.toString()); if (ret instanceof Promise) { // need to hold data until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; } diff --git a/src/common/parser/DcsParser.ts b/src/common/parser/DcsParser.ts index 692200230..25c22b8dd 100644 --- a/src/common/parser/DcsParser.ts +++ b/src/common/parser/DcsParser.ts @@ -8,6 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle import { utf32ToString } from 'common/input/TextDecoder'; import { Params } from 'common/parser/Params'; import { ParserConstants } from 'common/parser/Constants'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IDcsHandler[] = []; @@ -140,7 +141,7 @@ EMPTY_PARAMS.addParam(0); export class DcsHandler implements IDcsHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(DcsHandler._payloadLimit); private _params: IParams = EMPTY_PARAMS; private _hitLimit: boolean = false; @@ -152,7 +153,7 @@ export class DcsHandler implements IDcsHandler { // perf optimization: // clone only, if we have non empty params, otherwise stick with default this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -160,9 +161,7 @@ export class DcsHandler implements IDcsHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > DcsHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -172,20 +171,20 @@ export class DcsHandler implements IDcsHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data, this._params); + ret = this._handler(this._data.toString(), this._params); if (ret instanceof Promise) { // need to hold data and params until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { this._params = EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } this._params = EMPTY_PARAMS; - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; } diff --git a/src/common/parser/OscParser.ts b/src/common/parser/OscParser.ts index 1f0c8cb23..3e05f2298 100644 --- a/src/common/parser/OscParser.ts +++ b/src/common/parser/OscParser.ts @@ -7,6 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS import { OscState, ParserConstants } from 'common/parser/Constants'; import { utf32ToString } from 'common/input/TextDecoder'; import { IDisposable } from 'common/Types'; +import { LimitedStringBuilder } from 'common/StringBuilder'; const EMPTY_HANDLERS: IOscHandler[] = []; @@ -194,13 +195,13 @@ export class OscParser implements IOscParser { export class OscHandler implements IOscHandler { private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT; - private _data = ''; + private _data = new LimitedStringBuilder(OscHandler._payloadLimit); private _hitLimit: boolean = false; constructor(private _handler: (data: string) => boolean | Promise) { } public start(): void { - this._data = ''; + this._data.reset(); this._hitLimit = false; } @@ -208,9 +209,7 @@ export class OscHandler implements IOscHandler { if (this._hitLimit) { return; } - this._data += utf32ToString(data, start, end); - if (this._data.length > OscHandler._payloadLimit) { - this._data = ''; + if (this._data.append(utf32ToString(data, start, end))) { this._hitLimit = true; } } @@ -220,18 +219,18 @@ export class OscHandler implements IOscHandler { if (this._hitLimit) { ret = false; } else if (success) { - ret = this._handler(this._data); + ret = this._handler(this._data.toString()); if (ret instanceof Promise) { // need to hold data until `ret` got resolved // dont care for errors, data will be freed anyway on next start return ret.then(res => { - this._data = ''; + this._data.reset(); this._hitLimit = false; return res; }); } } - this._data = ''; + this._data.reset(); this._hitLimit = false; return ret; } diff --git a/src/common/public/BufferNamespaceApi.ts b/src/common/public/BufferNamespaceApi.ts index 425b1798c..b9744f943 100644 --- a/src/common/public/BufferNamespaceApi.ts +++ b/src/common/public/BufferNamespaceApi.ts @@ -20,7 +20,7 @@ export class BufferNamespaceApi extends Disposable implements IBufferNamespaceAp super(); this._normal = new BufferApiView(this._core.buffers.normal, 'normal'); this._alternate = new BufferApiView(this._core.buffers.alt, 'alternate'); - this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active)); + this._register(this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active))); } public get active(): IBufferApi { if (this._core.buffers.active === this._core.buffers.normal) { return this.normal; } diff --git a/src/common/services/CoreService.ts b/src/common/services/CoreService.ts index d8962ec88..f5094f5a9 100644 --- a/src/common/services/CoreService.ts +++ b/src/common/services/CoreService.ts @@ -3,7 +3,6 @@ * @license MIT */ -import { clone } from 'common/Clone'; import { Disposable } from 'common/Lifecycle'; import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types'; import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services'; @@ -61,14 +60,14 @@ export class CoreService extends Disposable implements ICoreService { ) { super(); this.isCursorInitialized = _optionsService.rawOptions.showCursorImmediately ?? false; - this.modes = clone(DEFAULT_MODES); - this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.modes = structuredClone(DEFAULT_MODES); + this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES); this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); } public reset(): void { - this.modes = clone(DEFAULT_MODES); - this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES); + this.modes = structuredClone(DEFAULT_MODES); + this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES); this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE(); } diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e24..e3a6c2f04 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -4,11 +4,13 @@ */ import { assert } from 'chai'; -import { DecorationService } from './DecorationService'; +import { DecorationLineCache, DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { Buffer } from 'common/buffer/Buffer'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker { }()); } +function createDecorationService(): DecorationService { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + return new DecorationService(new MockLogService(), bufferService); +} + const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -35,7 +42,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -48,7 +55,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -74,7 +81,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -99,11 +106,35 @@ describe('DecorationService', () => { service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d)); assert.strictEqual(foundAtX8.length, 0); }); + + it('should find multi-line decoration when single-line decorations exist on other lines', () => { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + for (let i = 0; i < 100; i++) { + serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(i), + width: 5 + }); + } + const multiLine = serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(10), + width: 10, + height: 3 + }); + assert.ok(multiLine); + + const found: typeof multiLine[] = []; + serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d)); + assert.include(found, multiLine); + }); }); describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -117,4 +148,73 @@ describe('DecorationService', () => { assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0); }); }); + + describe('DecorationLineCache', () => { + it('should return undefined for lines with no indexed decorations', () => { + const cache = new DecorationLineCache(); + assert.isUndefined(cache.getDecorationsOnLine(0)); + }); + }); + + describe('line index maintenance', () => { + it('should keep lookups correct after buffer trim', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(buffer.lines.length - 1); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d)); + assert.strictEqual(found.length, 1); + }); + + it('should remove decoration from line index when marker is trimmed off buffer', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(0); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + assert.isTrue(marker.isDisposed); + assert.isTrue(decoration!.isDisposed); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d)); + assert.strictEqual(found.length, 0); + }); + + it('should keep multi-line decoration indexed after line insert', async () => { + const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(3); + const decoration = service.registerDecoration({ marker, width: 10, height: 3 }); + assert.ok(decoration); + + buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); + await new Promise(resolve => queueMicrotask(resolve)); + + const foundOnSpan: typeof decoration[] = []; + for (let line = marker.line; line < marker.line + 3; line++) { + service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d)); + } + assert.include(foundOnSpan, decoration); + + const foundOutsideSpan: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d)); + assert.strictEqual(foundOutsideSpan.length, 0); + }); + }); }); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 375daa20b..95875e54a 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -3,19 +3,19 @@ * @license MIT */ +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { MicrotaskTimer } from 'common/Async'; import { css } from 'common/Color'; -import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, ICircularList } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; // Work variables to avoid garbage collection let $xmin = 0; let $xmax = 0; -let $ymin = 0; -let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; @@ -27,6 +27,8 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; + private readonly _lineCache = this._register(new DecorationLineCache()); + private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); @@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor(@ILogService private readonly _logService: ILogService) { + constructor( + @ILogService private readonly _logService: ILogService, + @IBufferService private readonly _bufferService: IBufferService + ) { super(); this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); + this._register(this._bufferService.buffers.onBufferActivate(() => { + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); + })); + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -53,12 +62,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { + this._lineCache.remove(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); + this._lineCache.add(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -69,19 +80,17 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); + this._lineCache.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { - let xmin; - let xmax; - let ymin; - let ymax; - for (const d of this._decorations.values()) { - ymin = d.marker.line; - ymax = ymin + (d.options.height ?? 1); - if (line < ymin || line >= ymax) { - continue; - } + const bucket = this._lineCache.getDecorationsOnLine(line); + if (!bucket) { + return; + } + let xmin = 0; + let xmax = 0; + for (const d of bucket) { xmin = d.options.x ?? 0; xmax = xmin + (d.options.width ?? 1); if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -91,12 +100,11 @@ export class DecorationService extends Disposable implements IDecorationService } public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void { - for (const d of this._decorations.values()) { - $ymin = d.marker.line; - $ymax = $ymin + (d.options.height ?? 1); - if (line < $ymin || line >= $ymax) { - continue; - } + const bucket = this._lineCache.getDecorationsOnLine(line); + if (!bucket) { + return; + } + for (const d of bucket) { $xmin = d.options.x ?? 0; $xmax = $xmin + (d.options.width ?? 1); if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) { @@ -106,10 +114,232 @@ export class DecorationService extends Disposable implements IDecorationService } } +/** + * Per-logical-line index of decorations for fast cell lookup. + * + * Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots. + * Multi-line decorations appear in every line bucket they span. The index is kept aligned + * with marker.line updates via buffer line trim/insert/delete events. + */ +export class DecorationLineCache extends Disposable { + private readonly _decorationsByLine: Map = new Map(); + private readonly _decorations = new Set(); + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer()); + private _lineIndexSyncCallbacks: (() => void)[] = []; + + public clear(): void { + this._lineIndexSyncCallbacks.length = 0; + this._lineIndexSyncTimer.cancel(); + this._decorationsByLine.clear(); + this._decorations.clear(); + } + + public add(decoration: IInternalDecoration): void { + this._decorations.add(decoration); + this._addToLineBuckets(decoration); + } + + public remove(decoration: IInternalDecoration): void { + this._decorations.delete(decoration); + this._removeFromLineBuckets(decoration); + } + + public getDecorationsOnLine(line: number): ReadonlyArray | undefined { + return this._decorationsByLine.get(line); + } + + public attachToBufferLines(lines: ICircularList): void { + const store = new DisposableStore(); + this._bufferLineListeners.value = store; + store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); + store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); + store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); + } + + private _getDecorationHeight(decoration: IInternalDecoration): number { + return decoration.options.height ?? 1; + } + + private _addToLineBuckets(decoration: IInternalDecoration): void { + const start = decoration.marker.line; + if (start < 0) { + return; + } + decoration._indexedStartLine = start; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + let bucket = this._decorationsByLine.get(line); + if (!bucket) { + bucket = []; + this._decorationsByLine.set(line, bucket); + } + bucket.push(decoration); + } + } + + private _removeFromLineBuckets(decoration: IInternalDecoration): void { + const start = decoration._indexedStartLine; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + continue; + } + const index = bucket.indexOf(decoration); + if (index !== -1) { + bucket.splice(index, 1); + } + if (bucket.length === 0) { + this._decorationsByLine.delete(line); + } + } + } + + private _reindexDecoration(decoration: IInternalDecoration): void { + this._removeFromLineBuckets(decoration); + if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { + this._addToLineBuckets(decoration); + } + } + + /** Re-index after marker line updates (buffer listeners may run before markers). */ + private _scheduleLineIndexSync(callback: () => void): void { + this._lineIndexSyncCallbacks.push(callback); + this._lineIndexSyncTimer.set(() => { + const callbacks = this._lineIndexSyncCallbacks; + this._lineIndexSyncCallbacks = []; + for (const cb of callbacks) { + cb(); + } + }); + } + + private _handleBufferLinesTrim(amount: number): void { + if (amount <= 0) { + return; + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line - amount; + if (newLine < 0) { + continue; + } + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations) { + if (!d.marker.isDisposed) { + d._indexedStartLine -= amount; + } + } + } + + private _handleBufferLinesInsert(event: IInsertEvent): void { + this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event)); + } + + private _handleBufferLinesDelete(event: IDeleteEvent): void { + this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event)); + } + + private _mergeLineBucket(newMap: Map, line: number, bucket: IInternalDecoration[]): void { + const existing = newMap.get(line); + if (existing) { + for (let i = 0, len = bucket.length; i < len; i++) { + existing.push(bucket[i]); + } + } else { + newMap.set(line, bucket.slice()); + } + } + + /** + * Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count). + * Decorations that span the insert point are re-indexed individually (rare vs single-line hits). + */ + private _applyBufferLinesInsert(event: IInsertEvent): void { + const { index, amount } = event; + const spanCrossers: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + if (start < index && start + this._getDecorationHeight(d) > index) { + spanCrossers.push(d); + this._removeFromLineBuckets(d); + } + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line >= index ? line + amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + if (d._indexedStartLine >= index) { + d._indexedStartLine = d.marker.line; + } + } + for (const d of spanCrossers) { + this._addToLineBuckets(d); + } + } + + /** + * Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a + * multi-line decoration spans across the deleted range but survives. + */ + private _applyBufferLinesDelete(event: IDeleteEvent): void { + const deleteEnd = event.index + event.amount; + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + if (line >= event.index && line < deleteEnd) { + continue; + } + const newLine = line >= deleteEnd ? line - event.amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + const toReindex: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + const height = this._getDecorationHeight(d); + if (start >= deleteEnd) { + d._indexedStartLine = d.marker.line; + } else if (start < event.index && start + height > deleteEnd) { + toReindex.push(d); + } + } + for (const d of toReindex) { + this._reindexDecoration(d); + } + } +} + class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + /** Start line used for line-index removal when marker.line is cleared on dispose. */ + public _indexedStartLine: number; + public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; private readonly _onDispose = this.add(new Emitter()); @@ -144,6 +374,7 @@ class Decoration extends DisposableStore implements IInternalDecoration { ) { super(); this.marker = options.marker; + this._indexedStartLine = options.marker.line; if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) { this.options.overviewRulerOptions.position = 'full'; } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc..a858193c3 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration { readonly backgroundColorRGB: IColor | undefined; readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; } diff --git a/src/headless/Terminal.ts b/src/headless/Terminal.ts index cc8b152b2..a348ae824 100644 --- a/src/headless/Terminal.ts +++ b/src/headless/Terminal.ts @@ -69,12 +69,7 @@ export class Terminal extends CoreTerminal { return this.buffer.markers; } - public addMarker(cursorYOffset: number): IMarker | undefined { - // Disallow markers on the alt buffer - if (this.buffer !== this.buffers.normal) { - return; - } - + public registerMarker(cursorYOffset: number): IMarker { return this.buffer.addMarker(this.buffer.ybase + this.buffer.y + cursorYOffset); } @@ -108,6 +103,7 @@ export class Terminal extends CoreTerminal { // Don't clear if it's already clear return; } + this.buffer.clearAllMarkers(); this.buffer.lines.set(0, this.buffer.lines.get(this.buffer.ybase + this.buffer.y)!); this.buffer.lines.length = 1; this.buffer.ydisp = 0; diff --git a/src/headless/public/Terminal.test.ts b/src/headless/public/Terminal.test.ts index 0b35fb0e7..73e2f4865 100644 --- a/src/headless/public/Terminal.test.ts +++ b/src/headless/public/Terminal.test.ts @@ -114,6 +114,29 @@ describe('Headless API Tests', function (): void { } }); + it('clear disposes markers', async () => { + term = new Terminal({ rows: 5, allowProposedApi: true }); + for (let i = 0; i < 10; i++) { + await writeSync('\n\rtest' + i); + } + const markers = [ + term.registerMarker(1), + term.registerMarker(2), + term.registerMarker(3), + term.registerMarker(4) + ]; + let disposeCount = 0; + for (const marker of markers) { + marker.onDispose(() => disposeCount++); + } + term.clear(); + strictEqual(disposeCount, markers.length); + for (const marker of markers) { + strictEqual(marker.isDisposed, true); + } + strictEqual(term.markers.length, 0); + }); + describe('options', () => { const termOptions = { cols: 80, @@ -396,6 +419,15 @@ describe('Headless API Tests', function (): void { strictEqual(term.buffer.normal.getLine(0)!.translateToString(), 'norm '); strictEqual(term.buffer.alternate.getLine(0), undefined); }); + + it('registerMarker on alternate buffer', async () => { + term = new Terminal({ cols: 5, allowProposedApi: true }); + await writeSync('\x1b[?47h'); + const marker = term.registerMarker(0); + strictEqual(term.buffer.active.type, 'alternate'); + strictEqual(term.markers.length, 1); + strictEqual(term.markers[0], marker); + }); }); describe('modes', () => { diff --git a/src/headless/public/Terminal.ts b/src/headless/public/Terminal.ts index 0d4e6e93b..d593dea15 100644 --- a/src/headless/public/Terminal.ts +++ b/src/headless/public/Terminal.ts @@ -40,15 +40,6 @@ export class Terminal extends Disposable implements ITerminalApi { }; for (const propName in this._core.options) { - Object.defineProperty(this._publicOptions, propName, { - get: () => { - return this._core.options[propName]; - }, - set: (value: any) => { - this._checkReadonlyOptions(propName); - this._core.options[propName] = value; - } - }); const desc = { get: getter.bind(this, propName), set: setter.bind(this, propName) @@ -139,11 +130,11 @@ export class Terminal extends Disposable implements ITerminalApi { this._verifyIntegers(columns, rows); this._core.resize(columns, rows); } - public registerMarker(cursorYOffset: number = 0): IMarker | undefined { + public registerMarker(cursorYOffset: number = 0): IMarker { this._verifyIntegers(cursorYOffset); - return this._core.addMarker(cursorYOffset); + return this._core.registerMarker(cursorYOffset); } - public addMarker(cursorYOffset: number): IMarker | undefined { + public addMarker(cursorYOffset: number): IMarker { return this.registerMarker(cursorYOffset); } public dispose(): void { diff --git a/test/benchmark/DecorationService.benchmark.ts b/test/benchmark/DecorationService.benchmark.ts new file mode 100644 index 000000000..1a2033de1 --- /dev/null +++ b/test/benchmark/DecorationService.benchmark.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { DecorationService } from 'common/services/DecorationService'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +const enum Constants { + COLS = 80, + ROWS = 30, + SINGLE_LINE_DECORATION_COUNT = 20_000, + MULTI_LINE_DECORATION_COUNT = 19_999, + MULTI_LINE_HEIGHT = 2, + VIEWPORT_SCAN_ITERATIONS = 20 +} + +function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void { + const buffer = bufferService.buffer; + for (let i = 0; i < count; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } +} + +function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void { + const buffer = bufferService.buffer; + for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } + const multiLineMarker = buffer.addMarker(10); + service.registerDecoration({ + marker: multiLineMarker, + width: Constants.COLS, + height: Constants.MULTI_LINE_HEIGHT + }); +} + +function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number { + let hitCount = 0; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + service.forEachDecorationAtCell(col, row, undefined, () => { + hitCount++; + }); + } + } + return hitCount; +} + +perfContext('DecorationService.forEachDecorationAtCell', () => { + perfContext('single-line dense / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 0, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('mixed single-line + multi-line / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerMixedDecorations(service, bufferService); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 10, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('viewport grid scan', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let totalHits = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits }; + }, { fork: false }).showAverageRuntime(); + }); +}); diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index d3b62346d..d51dc096d 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -825,12 +825,11 @@ declare module '@xterm/headless' { resize(columns: number, rows: number): void; /** - * Adds a marker to the normal buffer and returns it. If the alt buffer is - * active, undefined is returned. + * Adds a marker to the normal buffer and returns it. * @param cursorYOffset The y position offset of the marker from the cursor. * @returns The new marker or undefined. */ - registerMarker(cursorYOffset?: number): IMarker | undefined; + registerMarker(cursorYOffset?: number): IMarker; /* * Disposes of the terminal, detaching it from the DOM and removing any