Merge remote-tracking branch 'upstream/master' into package_upgrades

This commit is contained in:
Daniel Imms
2026-05-27 19:23:43 -07:00
49 changed files with 1334 additions and 334 deletions
+1
View File
@@ -24,6 +24,7 @@ test-results/
# Tracing/debug files
.cursor/nal-trace/
.cursor/*.log
# Keep bundled code out of Git
dist/
+1 -1
View File
@@ -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);
}
+1 -1
View File
@@ -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",
@@ -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<any> {
await ctx.page.evaluate(`
window.progressStack = [];
@@ -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> = {}): 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));
});
});
});
+3
View File
@@ -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 &&
+7 -5
View File
@@ -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]);
+7 -4
View File
@@ -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]);
+2 -1
View File
@@ -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
+1
View File
@@ -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;
}
+3 -1
View File
@@ -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,
+9 -6
View File
@@ -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();
+7 -6
View File
@@ -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);
}
@@ -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(',');
+2
View File
@@ -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', {
+2 -1
View File
@@ -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());
}
+40
View File
@@ -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);
});
});
+105
View File
@@ -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<ILink[]> {
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 }
});
});
});
+79 -13
View File
@@ -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 {
+1 -1
View File
@@ -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,
+6 -1
View File
@@ -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());
}
}
+4 -5
View File
@@ -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<IDisposable>());
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));
}
/**
+36
View File
@@ -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;
-129
View File
@@ -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));
});
});
-23
View File
@@ -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<T>(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;
}
+1 -1
View File
@@ -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) {
+6 -7
View File
@@ -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);
+3 -6
View File
@@ -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.
+143
View File
@@ -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(), '');
});
});
+67
View File
@@ -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();
}
}
+1 -2
View File
@@ -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<ITerminalOptions> = clone(DEFAULT_OPTIONS);
public readonly rawOptions: Required<ITerminalOptions> = structuredClone(DEFAULT_OPTIONS);
public options: Required<ITerminalOptions> = this.rawOptions;
public onOptionChange: IEvent<keyof ITerminalOptions> = new Emitter<keyof ITerminalOptions>().event;
constructor(testOptions?: Partial<ITerminalOptions>) {
+16 -16
View File
@@ -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;
}
+6 -2
View File
@@ -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;
+4 -1
View File
@@ -178,7 +178,10 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, 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
+81
View File
@@ -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<boolean>(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, []);
});
});
});
+45 -8
View File
@@ -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<void>());
public readonly onWriteParsed = this._onWriteParsed.event;
constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise<boolean>) {
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;
+7 -8
View File
@@ -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<boolean>) { }
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;
}
+7 -8
View File
@@ -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;
}
+7 -8
View File
@@ -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<boolean>) { }
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;
}
+1 -1
View File
@@ -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; }
+4 -5
View File
@@ -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();
}
+107 -7
View File
@@ -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<void>(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);
});
});
});
+253 -22
View File
@@ -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<IInternalDecoration>;
private readonly _lineCache = this._register(new DecorationLineCache());
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
private readonly _onDecorationRemoved = this._register(new Emitter<IInternalDecoration>());
@@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService
public get decorations(): IterableIterator<IInternalDecoration> { 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<IInternalDecoration> {
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<number, IInternalDecoration[]> = new Map();
private readonly _decorations = new Set<IInternalDecoration>();
private readonly _bufferLineListeners = this._register(new MutableDisposable<DisposableStore>());
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<IInternalDecoration> | undefined {
return this._decorationsByLine.get(line);
}
public attachToBufferLines(lines: ICircularList<unknown>): 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<number, IInternalDecoration[]>();
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<number, IInternalDecoration[]>, 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<number, IInternalDecoration[]>();
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<number, IInternalDecoration[]>();
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<HTMLElement>());
public readonly onRender = this.onRenderEmitter.event;
private readonly _onDispose = this.add(new Emitter<void>());
@@ -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';
}
+2
View File
@@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration {
readonly backgroundColorRGB: IColor | undefined;
readonly foregroundColorRGB: IColor | undefined;
readonly onRenderEmitter: Emitter<HTMLElement>;
/** @internal Start line for line-index removal; kept in sync on buffer line shifts. */
_indexedStartLine: number;
}
+2 -6
View File
@@ -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;
+32
View File
@@ -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', () => {
+3 -12
View File
@@ -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 {
@@ -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();
});
});
+2 -3
View File
@@ -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