Merge remote-tracking branch 'upstream/master' into package_upgrades
This commit is contained in:
@@ -24,6 +24,7 @@ test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
.cursor/*.log
|
||||
|
||||
# Keep bundled code out of Git
|
||||
dist/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(',');
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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 }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(), '');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Vendored
+2
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user