Merge branch 'master' into fix/addon-ligatures-prepublish-only

This commit is contained in:
Daniel Imms
2026-05-26 12:26:23 -07:00
committed by GitHub
85 changed files with 3043 additions and 1790 deletions
@@ -1,7 +1,11 @@
---
applyTo: '**/*.benchmark.ts'
name: benchmark
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
---
# Benchmark run instructions
# Benchmark Run Instructions
## Running Benchmarks
- Full suite: `npm run benchmark`
- Single benchmark file:
@@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts'
- Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Benchmark Case Selection
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
Notes:
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script).
@@ -1,7 +1,11 @@
---
applyTo: '**/*.test.ts'
name: unit-test
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
---
When writing unit tests follow these rules:
# Unit Test Instructions
When writing unit tests, follow these rules:
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
-10
View File
@@ -1,10 +0,0 @@
{
"hooks": {
"SessionStart": [
{
"type": "command",
"command": "npm run agent:setup-repo"
}
]
}
}
+13 -13
View File
@@ -207,31 +207,31 @@ jobs:
- name: Build demo server
run: npm run esbuild-demo-server
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
- name: Integration tests (addon-attach)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
- name: Integration tests (addon-clipboard)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
- name: Integration tests (addon-fit)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
- name: Integration tests (addon-serialize)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
- name: Integration tests (addon-unicode-graphemes)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
- name: Integration tests (addon-web-fonts)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl)
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
release-dry-run:
needs: build
+3
View File
@@ -22,6 +22,9 @@ build/
yarn.lock
test-results/
# Tracing/debug files
.cursor/nal-trace/
# Keep bundled code out of Git
dist/
demo/dist/
@@ -57,6 +57,13 @@ export class MyAddon implements ITerminalAddon {
- Proposed APIs require `allowProposedApi: true` option
- Constructor-only options (cols, rows) cannot be changed after instantiation
**Disposable Management**:
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
**TypeScript Constants**:
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
**Testing Utilities**: Use `TestUtils.ts` helpers:
- `openTerminal(ctx, options)` for setup
- `pollFor(page, fn, expectedValue)` for async assertions
+2
View File
@@ -252,6 +252,8 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
-3
View File
@@ -21,8 +21,5 @@
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"dependencies": {
"js-base64": "^3.7.5"
}
}
+10 -18
View File
@@ -4,8 +4,7 @@
*/
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
import { Base64 as JSBase64 } from 'js-base64';
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
export class ClipboardAddon implements ITerminalAddon {
private _terminal?: Terminal;
@@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose();
}
private _readText(sel: ClipboardSelectionType, data: string): void {
private _readText(sel: string, data: string): void {
const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
}
@@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true;
}
const pc = args[0] as ClipboardSelectionType;
const pc = args[0];
const pd = args[1];
if (pd === '?') {
const text = this._provider.readText(pc);
@@ -70,30 +69,23 @@ export class ClipboardAddon implements ITerminalAddon {
}
export class BrowserClipboardProvider implements IClipboardProvider {
public async readText(selection: ClipboardSelectionType): Promise<string> {
if (selection !== 'c') {
return Promise.resolve('');
}
public readText(selection: string): Promise<string> {
return navigator.clipboard.readText();
}
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
if (selection !== 'c') {
return Promise.resolve();
}
public writeText(selection: string, text: string): Promise<void> {
return navigator.clipboard.writeText(text);
}
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
return JSBase64.encode(data);
return btoa(data);
}
public decodeText(data: string): string {
const text = JSBase64.decode(data);
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return '';
}
return text;
try {
return atob(data);
} catch {}
return '';
}
}
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test doesn't work since the migration to @playwright/test
if (ctx.browser.browserType().name() !== 'chromium') {
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
@@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('primary selection', async () => {
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('empty selection (default)', async () => {
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
});
test('invalid base64 string', async () => {
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
@@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => {
});
test.describe('read data', async function (): Promise<any> {
test('simple string', async () => {
test.beforeAll(async () => {
await ctx.page.evaluate(`
window.data = [];
window.term.onData(e => data.push(e));
`);
});
test('simple string', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;c;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
});
test('primary selection', async () => {
await ctx.page.evaluate('window.data.length = 0;');
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
await ctx.proxy.write(`\x1b]52;p;?\x07`);
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
});
test('clear clipboard', async () => {
await ctx.proxy.write(`\x1b]52;c;!\x07`);
await ctx.proxy.write(`\x1b]52;c;?\x07`);
+12 -21
View File
@@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' {
public dispose(): void
}
/**
* Clipboard selection type. This is used to specify which selection buffer to
* read or write to.
* - SYSTEM `c`: The system clipboard.
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
* with Linux X11.
*/
export const enum ClipboardSelectionType {
SYSTEM = 'c',
PRIMARY = 'p',
}
export interface IBase64 {
/**
* Converts a utf-8 string to a base64 string.
@@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider {
/**
* Gets the clipboard content.
* @param selection The clipboard selection to read.
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
* @returns A promise that resolves with clipboard selection data.
*/
readText(selection: ClipboardSelectionType): string | Promise<string>;
readText(selection: string): string | Promise<string>;
/**
* Sets the clipboard content.
* @param selection The clipboard selection to set.
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
* @param data The clipboard text to write.
*/
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
writeText(selection: string, text: string): void | Promise<void>;
}
/**
* The clipboard provider interface that enables xterm.js to access the system clipboard.
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
*
* Note this this clipboard provider does not implement own cut buffers as xterm does,
* but redirect the selection parameter always to navigator.clipboard.
*/
export class BrowserClipboardProvider implements IClipboardProvider{
/**
* Reads text from the clipboard.
* @param selection The selection type to read from.
* @param selection The selection type to read from (always refers to navigator.clipboard).
* @returns A promise that resolves with the text from the clipboard.
*/
public readText(selection: ClipboardSelectionType): Promise<string>;
public readText(selection: string): Promise<string>;
/**
* Writes text to the clipboard.
* @param selection The selection type to write to.
* @param selection The selection type to write to (always refers to navigator.clipboard).
* @param data The text to write to the clipboard.
* @returns A promise that resolves when the text has been written to the clipboard.
*/
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
public writeText(selection: string, data: string): Promise<void>;
}
}
+6 -4
View File
@@ -19,8 +19,10 @@ interface ITerminalDimensions {
cols: number;
}
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
const enum Constants {
MINIMUM_COLS = 2,
MINIMUM_ROWS = 1
}
function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) {
@@ -86,8 +88,8 @@ export class FitAddon implements ITerminalAddon, IFitApi {
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
cols: Math.max(Constants.MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(Constants.MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
return geometry;
}
+4 -4
View File
@@ -25,14 +25,14 @@ const customSettings: IImageAddonOptions = {
pixelLimit: 16777216, // max. pixel size of a single image
sixelSupport: true, // enable sixel support
sixelScrolling: true, // whether to scroll on image output
sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 25000000, // size limit of a single sixel sequence
sixelPaletteLimit: 4096, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support
iipSizeLimit: 20000000, // size limit of a single IIP sequence
iipSizeLimit: 33554432, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
}
// initialization
+1 -1
View File
@@ -25,6 +25,6 @@
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.3.0"
"xterm-wasm-parts": "^0.4.1"
}
}
+23 -20
View File
@@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
@@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
private _hp = new HeaderParser();
private _header: IHeaderFields = DEFAULT_HEADER;
private _dec: Base64Decoder;
private _qoiDec: QoiDecoder;
private _metrics = UNSUPPORTED_TYPE;
constructor(
@@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
}
public reset(): void {}
@@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
this._dec.release();
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<boolean>(r => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, w, h);
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
this._storage.addImage(canvas);
r(true);
});
img.src = url;
// sanity measure to avoid terminal blocking from dangling promise
// happens from corrupt data (onload never gets fired)
setTimeout(() => r(true), 1000);
});
let blob: Blob | ImageData;
if (this._metrics.mime === 'image/qoi') {
const data = this._qoiDec.decode(this._dec.data8);
blob = new ImageData(
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
this._qoiDec.width,
this._qoiDec.height
);
this._qoiDec.release();
if (w === this._qoiDec.width && h === this._qoiDec.height) {
// use fast-path if we don't need to rescale
this._dec.release();
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
canvas.getContext('2d')?.putImageData(blob, 0, 0);
this._storage.addImage(canvas);
return true;
}
} else {
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
}
this._dec.release();
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
this._storage.addImage(bm);
+9 -1
View File
@@ -4,7 +4,7 @@
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
@@ -45,6 +45,14 @@ export function imageType(d: Uint8Array): IMetrics {
height: d[9] << 8 | d[8]
};
}
// QOI: qoif
if (d32[0] === 0x66696F71) {
return {
mime: 'image/qoi',
width: d[4] << 24 | d[5] << 16 | d[6] << 8 | d[7],
height: d[8] << 24 | d[9] << 16 | d[10] << 8 | d[11]
};
}
return UNSUPPORTED_TYPE;
}
+46 -5
View File
@@ -16,20 +16,61 @@ import { SixelImageStorage } from './SixelImageStorage';
import { IIPImageStorage } from './IIPImageStorage';
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
/**
* Document VT features provided by this addon.
*
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
*
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
* - max. palette size of 4096 colors
* - max. pixel width of 16K
* - max. 25 MB per sixel sequence
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
*
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
* the following limitations:
* - sequence:
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
* no separator bytes allowed)
* - supported params: size, name, width, height, preserveAspectRatio
* - image formats: PNG, JPEG and GIF
* - no animation support (renders first image of a GIF)
* - no multipart support
* - VT340 cursor positioning (begin of last sixel data row)
*
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
*
*
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
*
* Kitty graphics support is provided by the addon @xterm/addon-image.
* Note that while basic image output already works, this is still work in progress.
*/
// default values of addon ctor options
const DEFAULT_OPTIONS: IImageAddonOptions = {
enableSizeReports: true,
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 256,
sixelSizeLimit: 25000000,
sixelPaletteLimit: 4096,
sixelSizeLimit: 33554432,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 20000000
kittySizeLimit: 33554432
};
// max palette size supported by the sixel lib (compile time setting)
@@ -166,7 +207,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
this._disposeLater(
kittyStorage,
kittyHandler,
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
}
}
+9 -7
View File
@@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const PLACEHOLDER_LENGTH = 4096;
const PLACEHOLDER_HEIGHT = 24;
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
/**
* ImageRenderer - terminal frontend extension:
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
public showPlaceholder(value: boolean): void {
if (value) {
if (!this._placeholder && this.cellSize.height !== -1) {
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
@@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
@@ -358,7 +360,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
}
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
return;
@@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
return this._layers.has(layer);
}
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
@@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
ctx.putImageData(imgData, 0, 0);
// create placeholder line, width aligned to blueprint width
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
@@ -16,24 +16,23 @@ import {
IKittyCommand,
IPendingTransmission,
IKittyImageData,
BYTES_PER_PIXEL_RGB,
BYTES_PER_PIXEL_RGBA,
ALPHA_OPAQUE,
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
// Memory limit for base64 decoder (4MB, same as IIPHandler)
const DECODER_KEEP_DATA = 4194304;
const DECODER_INITIAL_DATA = 4194304; // 4MB
const enum Constants {
// Memory limit for base64 decoder (4MB, same as IIPHandler)
DECODER_KEEP_DATA = 4194304,
DECODER_INITIAL_DATA = 4194304, // 4MB
// Local mirror of const enum (esbuild can't inline const enums from external packages)
DECODER_OK = 0,
// Maximum control data size
MAX_CONTROL_DATA_SIZE = 512,
// Semicolon codepoint
SEMICOLON = 0x3B
}
// Local mirror of const enum (esbuild can't inline const enums from external packages)
const DECODER_OK: DecodeStatus.OK = 0;
// Maximum control data size
const MAX_CONTROL_DATA_SIZE = 512;
// Semicolon codepoint
const SEMICOLON = 0x3B;
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
// Kitty graphics protocol handler with streaming base64 decoding.
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
@@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private _inControlData = true;
// Buffer for control data.
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
private _controlLength = 0;
// Pre-calculated encoded size limit
@@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Convert decoded size limit -> max encoded bytes.
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
// ensure we preallocate more than configured limit while using 4mb initial size.
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
}
public reset(): void {
@@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Scan for semicolon
let controlEnd = end;
for (let i = start; i < end; i++) {
if (data[i] === SEMICOLON) {
if (data[i] === Constants.SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
@@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
this._aborted = true;
return;
}
@@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
@@ -484,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
@@ -534,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
const isOk = message === 'OK';
if (isOk && quiet === 1) return;
if (!isOk && quiet === 2) return;
if (isOk && quiet >= 1) return;
if (!isOk && quiet >= 2) return;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
@@ -723,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
throw new Error('Width and height required for raw pixel data');
}
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
@@ -734,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
if (image.format === KittyFormat.RGBA) {
// RGBA: use bytes directly — no copy needed
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height));
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
}
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
// Assumes little-endian (all modern browsers/Node.js).
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
const dst32 = new Uint32Array(data.buffer);
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
@@ -759,15 +758,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
}
// Handle remaining 1–3 pixels
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
for (let i = alignedPixels; i < pixelCount; i++) {
data[dstByte] = bytes[srcByte];
data[dstByte + 1] = bytes[srcByte + 1];
data[dstByte + 2] = bytes[srcByte + 2];
data[dstByte + 3] = ALPHA_OPAQUE;
srcByte += BYTES_PER_PIXEL_RGB;
dstByte += BYTES_PER_PIXEL_RGBA;
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
@@ -82,9 +82,11 @@ export const enum KittyKey {
}
// Pixel format constants
export const BYTES_PER_PIXEL_RGB = 3;
export const BYTES_PER_PIXEL_RGBA = 4;
export const ALPHA_OPAQUE = 255;
export const enum KittyPixelConstants {
BYTES_PER_PIXEL_RGB = 3,
BYTES_PER_PIXEL_RGBA = 4,
ALPHA_OPAQUE = 255
}
// Parsed Kitty graphics command.
export interface IKittyCommand {
+74 -8
View File
@@ -8,6 +8,7 @@ import { readFileSync } from 'fs';
import { FINALIZER, introducer, sixelEncode } from 'sixel';
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
import { deepStrictEqual, ok, strictEqual } from 'assert';
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
/**
* Plugin ctor options.
@@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
];
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
const qoiEnc = new QoiEncoder(1024*1024);
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
let ctx: ITestContext;
test.beforeAll(async ({ browser }) => {
@@ -85,11 +90,6 @@ test.afterAll(async () => await ctx.page.close());
test.describe('ImageAddon', () => {
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(`
window.term.reset()
window.imageAddon?.dispose();
@@ -132,13 +132,13 @@ test.describe('ImageAddon', () => {
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 25000000,
sixelSizeLimit: 33554432,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 20000000,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 20000000
kittySizeLimit: 33554432
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -312,6 +312,68 @@ test.describe('ImageAddon', () => {
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
});
});
test.describe('IIP - QOI support', () => {
test('palette should yield same bytes from PNG and QOI', async () => {
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
deepStrictEqual(await getOrigSize(1), [640, 80]);
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
deepStrictEqual(await getOrigSize(2), [640, 80]);
const pngScrape = await getImageAtBufferCell(0, 0);
const qoiScrape = await getImageAtBufferCell(0, 11);
deepStrictEqual(qoiScrape, pngScrape);
});
});
test.describe('IIP - resizing', () => {
/**
* The correct resize behavior is wonky and needs to be tested against iTerm2,
* especially for missing params to derive correct default behavior.
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
* ref: https://iterm2.com/documentation-images.html
* imgcat: https://iterm2.com/utilities/imgcat
*
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
*/
const images = [
['palette.png', 525, PALETTE_PNG_BASE64],
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
];
for (const [name, size, payload] of images) {
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
// cell based resize
const header = 'width=20;height=5;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
});
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
// pixel based resize
const header = 'width=320px;height=160px;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
deepStrictEqual(await getOrigSize(1), [320, 160]);
});
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
// % of viewport resize
const header = 'width=50%;height=30%;preserveAspectRatio=0';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
});
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
// width provided in percent
const header = 'width=50%';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
const dim = await getDimensions();
const width = Math.floor(dim.width * 0.5);
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
// height provided in pixel
const header2 = 'height=200px';
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
});
}
});
});
/**
@@ -345,3 +407,7 @@ async function getOrigSize(id: number): Promise<[number, number]> {
window.imageAddon._storage._images.get(${id}).orig.height
]`);
}
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
}
+52 -5
View File
@@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => {
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
test.beforeEach(async ({}, testInfo) => {
// DEBT: This test never worked on webkit
if (ctx.browser.browserType().name() === 'webkit') {
testInfo.skip();
return;
}
await ctx.page.evaluate(`
window.term.reset()
window.imageAddon?.dispose();
@@ -622,6 +617,18 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses OK response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('suppresses error response when q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
@@ -802,6 +809,30 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=t OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
test('a=T OK suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
});
});
test.describe('Transmission medium rejection', () => {
@@ -1151,6 +1182,22 @@ test.describe('Kitty Graphics Protocol', () => {
strictEqual(await getImageStorageLength(), 1);
});
test('OK response suppressed by q=2', async () => {
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
await timeout(100);
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
});
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
await timeout(100);
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
strictEqual(await getImageStorageLength(), 1);
});
test('ENOENT error suppressed by q=2', async () => {
await ctx.page.evaluate(() => {
(window as any).kittyGotResponse = false;
+1 -1
View File
@@ -37,7 +37,7 @@
"devDependencies": {
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.6.0",
"axios": "^1.15.2",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^3.2.1"
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
for (let i = 0; i < buffer.length; i++) {
// Do this intentionally to get content of underlining source
const bufferLine = buffer.getLine(i)._line;
lines.push(JSON.stringify(bufferLine));
lines.push(JSON.stringify(bufferLine, (key, value) => {
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
}
return {
x: buffer.cursorX,
@@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => {
test.describe('font loading at runtime', () => {
test('loadFonts (JS)', async () => {
await ctx.page.evaluate(`
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
loadFonts([ff1, ff2]);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
});
test('loadFonts (CSS, unquoted)', async () => {
await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
loadFonts(['Kongtext', 'BPdots']);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
@@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => {
test('loadFonts (CSS, quoted)', async ({ browser }) => {
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
loadFonts(['Kongtext', 'BPdots']);
`);
if (browser.browserType().name() === 'firefox') {
@@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => {
test('FontFace hashing', async () => {
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
await ctx.page.evaluate(`
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
loadFonts([ff1, ff2]);
loadFonts([ff1, ff2]);
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
@@ -76,7 +76,7 @@ test.describe('WebFontsAddon', () => {
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
const data = await ctx.page.evaluate(`
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
@@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => {
// safari loads the font, firefox & chrome dont
if (browser.browserType().name() === 'webkit') {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
try {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
} catch (e) {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
} else {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
}
@@ -103,7 +107,10 @@ test.describe('WebFontsAddon', () => {
window.helperTerm.open(term.element);
`);
await timeout(100);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
if (browser.browserType().name() !== 'webkit') {
// safari sometimes reports 'unloadeed'?
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
// cleanup this messy test case
await ctx.page.evaluate(`
+25 -17
View File
@@ -78,9 +78,11 @@ void main() {
}`);
}
const INDICES_PER_CELL = 11;
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
const CELL_POSITION_INDICES = 2;
const enum Constants {
INDICES_PER_CELL = 11,
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
CELL_POSITION_INDICES = 2
}
// Work variables to avoid garbage collection
let $i = 0;
@@ -160,22 +162,22 @@ export class GlyphRenderer extends Disposable {
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0);
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
// Setup static uniforms
@@ -222,12 +224,12 @@ export class GlyphRenderer extends Disposable {
}
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
$i = (y * this._terminal.cols + x) * INDICES_PER_CELL;
$i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL;
// Exit early if this is a null character, allow space character to continue as it may have
// underline/strikethrough styles
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
return;
}
@@ -288,7 +290,7 @@ export class GlyphRenderer extends Disposable {
public clear(): void {
const terminal = this._terminal;
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
// Clear vertices
if (this._vertices.count !== newCount) {
@@ -310,7 +312,7 @@ export class GlyphRenderer extends Disposable {
for (let x = 0; x < terminal.cols; x++) {
this._vertices.attributes[i + 9] = x / terminal.cols;
this._vertices.attributes[i + 10] = y / terminal.rows;
i += INDICES_PER_CELL;
i += Constants.INDICES_PER_CELL;
}
}
}
@@ -346,8 +348,8 @@ export class GlyphRenderer extends Disposable {
// - So we don't send vertices for all the line-ending whitespace to the GPU
let bufferLength = 0;
for (let y = 0; y < renderModel.lineLengths.length; y++) {
const si = y * this._terminal.cols * INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
const si = y * this._terminal.cols * Constants.INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
activeBuffer.set(sub, bufferLength);
bufferLength += sub.length;
}
@@ -356,7 +358,9 @@ export class GlyphRenderer extends Disposable {
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
// Bind the atlas page texture if they have changed
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
// monotonic, so a page object swap at the same index (which happens after a page merge)
// is detected by the same comparison.
for (let i = 0; i < this._atlas.pages.length; i++) {
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
this._bindAtlasPageTexture(gl, this._atlas, i);
@@ -364,11 +368,15 @@ export class GlyphRenderer extends Disposable {
}
// Draw the viewport
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
}
public setAtlas(atlas: ITextureAtlas): void {
this._atlas = atlas;
this.invalidateAtlasTextures();
}
public invalidateAtlasTextures(): void {
for (const glTexture of this._atlasTextures) {
glTexture.version = -1;
}
+7 -6
View File
@@ -10,7 +10,7 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IColor } from 'common/Types';
import { Terminal } from '@xterm/xterm';
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
import { RenderModelConstants } from './RenderModel';
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
@@ -217,9 +217,9 @@ export class RectangleRenderer extends Disposable {
currentFg = 0;
currentInverse = false;
for (x = 0; x < terminal.cols; x++) {
modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET];
fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET];
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
inverse = !!(fg & FgFlags.INVERSE);
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
// A rectangle needs to be drawn if going from non-default to another color
@@ -336,8 +336,9 @@ export class RectangleRenderer extends Disposable {
}
}
if (vertices.attributes.length < offset + 4) {
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
// +1 for the viewport-clear rectangle at offset 0.
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
}
$x1 = startX * this._dimensions.device.cell.width;
$y1 = y * this._dimensions.device.cell.height;
+7 -5
View File
@@ -7,10 +7,12 @@ import { ICursorRenderModel, IRenderModel } from './Types';
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
export const RENDER_MODEL_INDICIES_PER_CELL = 4;
export const RENDER_MODEL_BG_OFFSET = 1;
export const RENDER_MODEL_FG_OFFSET = 2;
export const RENDER_MODEL_EXT_OFFSET = 3;
export const enum RenderModelConstants {
INDICIES_PER_CELL = 4,
BG_OFFSET = 1,
FG_OFFSET = 2,
EXT_OFFSET = 3
}
export const COMBINED_CHAR_BIT_MASK = 0x80000000;
@@ -27,7 +29,7 @@ export class RenderModel implements IRenderModel {
}
public resize(cols: number, rows: number): void {
const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL;
const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL;
if (indexCount !== this.cells.length) {
this.cells = new Uint32Array(indexCount);
this.lineLengths = new Uint32Array(rows);
+13 -7
View File
@@ -133,7 +133,9 @@ export class TextureAtlas implements ITextureAtlas {
private _requestClearModel = false;
public beginFrame(): boolean {
return this._requestClearModel;
const result = this._requestClearModel;
this._requestClearModel = false;
return result;
}
public clearTexture(): void {
@@ -193,7 +195,7 @@ export class TextureAtlas implements ITextureAtlas {
// Merge into the new page
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
mergedPage.version++;
mergedPage.version = ++AtlasPage.nextVersion;
// Delete the pages, shifting glyph texture pages as needed
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
@@ -251,7 +253,7 @@ export class TextureAtlas implements ITextureAtlas {
for (const g of adjustingPage.glyphs) {
g.texturePage--;
}
adjustingPage.version++;
adjustingPage.version = ++AtlasPage.nextVersion;
}
}
@@ -937,7 +939,7 @@ export class TextureAtlas implements ITextureAtlas {
rasterizedGlyph.size.y
);
activePage.addGlyph(rasterizedGlyph);
activePage.version++;
activePage.version = ++AtlasPage.nextVersion;
return rasterizedGlyph;
}
@@ -1047,9 +1049,13 @@ class AtlasPage {
}
/**
* Used to check whether the canvas of the atlas page has changed.
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
* unit at a given index needs to be re-uploaded — both for content changes within the same
* page and for a page object swap at the same index (which happens after a page merge,
* where a per-page counter could coincide with the previously-bound page's value).
*/
public version = 0;
public static nextVersion: number = 0;
public version = ++AtlasPage.nextVersion;
// Texture atlas current positioning data. The texture packing strategy used is to fill from
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
@@ -1092,7 +1098,7 @@ class AtlasPage {
this.currentRow.y = 0;
this.currentRow.height = 0;
this.fixedRows.length = 0;
this.version++;
this.version = ++AtlasPage.nextVersion;
}
}
+29 -12
View File
@@ -19,7 +19,7 @@ import { ICoreService, IDecorationService, IOptionsService } from 'common/servic
import { Terminal } from '@xterm/xterm';
import { GlyphRenderer } from './GlyphRenderer';
import { RectangleRenderer } from './RectangleRenderer';
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel';
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
import { IRenderLayer } from './renderLayer/Types';
@@ -28,6 +28,10 @@ import { addDisposableListener } from 'browser/Dom';
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
const enum Constants {
MERGE_RETRY_LIMIT = 32
}
export class WebglRenderer extends Disposable implements IRenderer {
private _renderLayers: IRenderLayer[];
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
@@ -375,6 +379,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
this._updateModel(start, end);
}
// A mid-update atlas page merge invalidates vertex data and may not bump the host
// page's version, so re-run the update and force a full texture rebind.
let merged = false;
let mergeRetries = 0;
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
merged = true;
this._clearModel(true);
this._updateModel(0, this._terminal.rows - 1);
}
if (merged) {
this._glyphRenderer.value.invalidateAtlasTextures();
}
// Render
this._rectangleRenderer.value.renderBackgrounds();
this._glyphRenderer.value.render(this._model);
@@ -493,7 +510,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
chars = cell.getChars();
code = cell.getCode();
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
if (!rowHasBlinkingCells && cell.isBlink()) {
rowHasBlinkingCells = true;
@@ -538,9 +555,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Nothing has changed, no updates needed
if (this._model.cells[i] === code &&
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg &&
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg &&
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) {
this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg &&
this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg &&
this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) {
continue;
}
@@ -553,9 +570,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Cache the results in the model
this._model.cells[i] = code;
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
width = cell.getWidth();
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
@@ -566,14 +583,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Null out non-first cells
for (x++; x <= lastCharX; x++) {
j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
this._model.cells[j] = NULL_CELL_CODE;
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
// supported
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
}
x--; // Go back to the previous update cell for next iteration
}
+10 -5
View File
@@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => {
await openTerminal(ctx);
ctxWrapper.value = ctx;
await ctx.page.evaluate(`
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
`);
});
test.afterAll(async () => await ctx.page.close());
@@ -24,15 +26,18 @@ test.afterAll(async () => await ctx.page.close());
test.describe('WebGL Renderer Integration Tests', async () => {
// HACK: webgl2 is often not supported in headless firefox on Linux
// https://github.com/microsoft/playwright/issues/11566
// also disable safari due to #5852
if (platform() === 'linux') {
test.skip(({ browserName }) => browserName === 'firefox');
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
}
injectSharedRendererTests(ctxWrapper);
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
await ctx.page.evaluate(`
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
`);
});
});
+4 -1
View File
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
{
name: 'Chromium',
use: {
browserName: 'chromium'
browserName: 'chromium',
launchOptions: {
args: ['--enable-unsafe-swiftshader']
}
}
},
{
+32 -1
View File
@@ -57,6 +57,9 @@ xterm.js version: {{version}}
{{#CSI.length}}
- [CSI](#csi)
{{/CSI.length}}
{{#APC.length}}
- [APC](#apc)
{{/APC.length}}
{{#DCS.length}}
- [DCS](#dcs)
{{/DCS.length}}
@@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit)
Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
any sequence of these types will be silently ignored. They are also not hookable by the API.
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
@@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does:
{{/CSI.length}}
{{#APC.length}}
## APC
| Mnemonic | Name | Sequence | Short Description | Support |
| -------- | ---- | -------- | ----------------- | ------- |
{{#APC}}
| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} |
{{/APC}}
{{#APC.hasLongDescriptions}}
{{#APC}}
{{#longDescription.length}}
<section class="sequence-details">
### {{name}}
{{#longDescription}}
{{{.}}}
{{/longDescription}}
</section>
{{/longDescription.length}}
{{/APC}}
{{/APC.hasLongDescriptions}}
{{/APC.length}}
{{#DCS.length}}
## DCS
@@ -252,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
input.value = 'auto';
this._handlers.updateTerminalSize();
}
const screenElement = document.querySelector('.xterm-screen');
if (screenElement instanceof HTMLElement) {
screenElement.style.outline = this._autoResize ? ''
: 'solid thin red';
}
} else if (o === 'theme') {
value = this._getTheme();
}
+30 -718
View File
File diff suppressed because it is too large Load Diff
+3 -11
View File
@@ -29,6 +29,7 @@
"scripts": {
"presetup": "npm run build",
"setup": "npm run esbuild",
"setup-full": "node bin/setup-full.mjs",
"postsetup": "npm run esbuild-demo-server",
"start": "node demo/start",
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
@@ -55,7 +56,6 @@
"test-unit": "node ./bin/test_unit.js",
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
"test-integration": "node ./bin/test_integration.js --workers=75%",
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
@@ -65,30 +65,25 @@
"benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
"benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
"clean": "rm -rf lib out addons/*/lib addons/*/out",
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
"vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts",
"prepackage": "npm run build",
"package": "webpack",
"postpackage": "npm run esbuild-package",
"prepackage-headless": "npm run esbuild-package-headless-only",
"package-headless": "webpack --config ./webpack.config.headless.js",
"postpackage-headless": "node ./bin/package_headless.js",
"prepublishOnly": "npm run package",
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
"prepublishOnly": "npm run package"
},
"devDependencies": {
"@lunapaint/png-codec": "^0.2.0",
"@playwright/test": "^1.57.0",
"@stylistic/eslint-plugin": "^4.4.1",
"@types/chai": "^4.2.22",
"@types/debug": "^4.1.7",
"@types/deep-equal": "^1.0.1",
"@types/express": "4",
"@types/express-ws": "^3.0.1",
"@types/jsdom": "^27.0.0",
"@types/mocha": "^9.0.0",
"@types/node": "^22.19.3",
"@types/trusted-types": "^1.0.6",
"@types/utf8": "^3.0.0",
"@types/webpack": "^5.28.0",
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
@@ -96,8 +91,6 @@
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
"deep-equal": "^2.0.5",
"esbuild": "~0.25.2",
"eslint": "^9.39.2",
"eslint-plugin-jsdoc": "^50.8.0",
@@ -113,7 +106,6 @@
"ts-loader": "^9.3.1",
"typescript": "^5.9.3",
"typescript-eslint": "^8.50.1",
"utf8": "^3.0.0",
"webpack": "^5.61.0",
"webpack-cli": "^4.9.1",
"ws": "^8.2.3",
+5 -3
View File
@@ -12,7 +12,9 @@ import { IBuffer } from 'common/buffer/Types';
import { IInstantiationService } from 'common/services/Services';
import { addDisposableListener } from 'browser/Dom';
const MAX_ROWS_TO_READ = 20;
const enum Constants {
MAX_ROWS_TO_READ = 20
}
const enum BoundaryPosition {
TOP,
@@ -137,7 +139,7 @@ export class AccessibilityManager extends Disposable {
}
private _handleChar(char: string): void {
if (this._liveRegionLineCount < MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) {
if (this._charsToConsume.length > 0) {
// Have the screen reader ignore the char if it was just input
const shiftedChar = this._charsToConsume.shift();
@@ -150,7 +152,7 @@ export class AccessibilityManager extends Disposable {
if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent = Strings.tooMuchOutput.get();
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal {
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
throw new Error('Method not implemented.');
}
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
throw new Error('Method not implemented.');
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
+41 -39
View File
@@ -19,13 +19,15 @@ import { Emitter } from 'common/Event';
import { addDisposableListener } from 'browser/Dom';
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
const ROW_CONTAINER_CLASS = 'xterm-rows';
const FG_CLASS_PREFIX = 'xterm-fg-';
const BG_CLASS_PREFIX = 'xterm-bg-';
const FOCUS_CLASS = 'xterm-focus';
const SELECTION_CLASS = 'xterm-selection';
const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle';
const enum Constants {
TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-',
ROW_CONTAINER_CLASS = 'xterm-rows',
FG_CLASS_PREFIX = 'xterm-fg-',
BG_CLASS_PREFIX = 'xterm-bg-',
FOCUS_CLASS = 'xterm-focus',
SELECTION_CLASS = 'xterm-selection',
CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'
}
let nextTerminalId = 1;
@@ -76,12 +78,12 @@ export class DomRenderer extends Disposable implements IRenderer {
) {
super();
this._rowContainer = this._document.createElement('div');
this._rowContainer.classList.add(ROW_CONTAINER_CLASS);
this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS);
this._rowContainer.style.lineHeight = 'normal';
this._rowContainer.setAttribute('aria-hidden', 'true');
this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
this._selectionContainer = this._document.createElement('div');
this._selectionContainer.classList.add(SELECTION_CLASS);
this._selectionContainer.classList.add(Constants.SELECTION_CLASS);
this._selectionContainer.setAttribute('aria-hidden', 'true');
this.dimensions = createRenderDimensions();
@@ -93,7 +95,7 @@ export class DomRenderer extends Disposable implements IRenderer {
this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);
this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
this._screenElement.appendChild(this._rowContainer);
this._screenElement.appendChild(this._selectionContainer);
@@ -110,7 +112,7 @@ export class DomRenderer extends Disposable implements IRenderer {
));
this._register(toDisposable(() => {
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
// Outside influences such as React unmounts may manipulate the DOM before our disposal.
// https://github.com/xtermjs/xterm.js/issues/2960
@@ -160,7 +162,7 @@ export class DomRenderer extends Disposable implements IRenderer {
}
const styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
` height: 100%;` +
` vertical-align: top;` +
@@ -181,7 +183,7 @@ export class DomRenderer extends Disposable implements IRenderer {
// Base CSS
let styles =
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` +
// Disabling pointer events circumvents a browser behavior that prevents `click` events from
// being delivered if the target element is replaced during the click. This happened due to
// refresh() being called during the mousedown handler to start a selection.
@@ -189,14 +191,14 @@ export class DomRenderer extends Disposable implements IRenderer {
` color: ${colors.foreground.css};` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
` font-kerning: none;` +
` white-space: pre` +
`}`;
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` +
` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
`}`;
// Text styles
@@ -242,70 +244,70 @@ export class DomRenderer extends Disposable implements IRenderer {
`}`;
// Cursor
styles +=
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` animation: ${blinkAnimationBarId} 1s step-end infinite;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
`}` +
// Disable cursor blinking when idle
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
` animation: none !important;` +
`}` +
// !important helps fix an issue where the cursor will not render on top of the selection,
// however it's very hard to fix this issue and retain the blink animation without the use of
// !important. So this edge case fails when cursor blink is on.
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
` background-color: ${colors.cursor.css};` +
` color: ${colors.cursorAccent.css};` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
` background-color: ${colors.cursor.css} !important;` +
` color: ${colors.cursorAccent.css} !important;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
` outline: 1px solid ${colors.cursor.css};` +
` outline-offset: -1px;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
`}` +
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
` border-bottom: 1px ${colors.cursor.css};` +
` border-bottom-style: solid;` +
` height: calc(100% - 1px);` +
`}`;
// Selection
styles +=
`${this._terminalSelector} .${SELECTION_CLASS} {` +
`${this._terminalSelector} .${Constants.SELECTION_CLASS} {` +
` position: absolute;` +
` top: 0;` +
` left: 0;` +
` z-index: 1;` +
` pointer-events: none;` +
`}` +
`${this._terminalSelector}.focus .${SELECTION_CLASS} div {` +
`${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionBackgroundOpaque.css};` +
`}` +
`${this._terminalSelector} .${SELECTION_CLASS} div {` +
`${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` +
` position: absolute;` +
` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
`}`;
// Colors
for (const [i, c] of colors.ansi.entries()) {
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
}
styles +=
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
this._themeStyleElement.textContent = styles;
}
@@ -361,13 +363,13 @@ export class DomRenderer extends Disposable implements IRenderer {
}
public handleBlur(): void {
this._rowContainer.classList.remove(FOCUS_CLASS);
this._rowContainer.classList.remove(Constants.FOCUS_CLASS);
this._cursorBlinkStateManager.pause();
this.renderRows(0, this._bufferService.rows - 1);
}
public handleFocus(): void {
this._rowContainer.classList.add(FOCUS_CLASS);
this._rowContainer.classList.add(Constants.FOCUS_CLASS);
this._cursorBlinkStateManager.resume();
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
}
@@ -561,7 +563,7 @@ export class DomRenderer extends Disposable implements IRenderer {
}
private get _terminalSelector(): string {
return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
}
private _handleLinkHover(e: ILinkifierEvent): void {
@@ -667,7 +669,7 @@ class CursorBlinkStateManager {
public restartBlinkAnimation(): void {
if (this._isIdlePaused) {
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
}
this._resetIdleTimer();
}
@@ -679,7 +681,7 @@ class CursorBlinkStateManager {
public resume(): void {
this._isIdlePaused = false;
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
this._resetIdleTimer();
}
@@ -699,7 +701,7 @@ class CursorBlinkStateManager {
}
private _stopBlinkingDueToIdle(): void {
this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS);
this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS);
this._isIdlePaused = true;
this._idleTimeout = undefined;
}
@@ -8,14 +8,14 @@ import { assert } from 'chai';
import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory';
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
const dom = new jsdom.JSDOM('');
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('DomRendererRowFactory', () => {
let dom: jsdom.JSDOM;
@@ -517,7 +517,7 @@ describe('DomRendererRowFactory', () => {
}
function createEmptyLineData(cols: number): IBufferLine {
const lineData = new BufferLine(cols);
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
for (let i = 0; i < cols; i++) {
lineData.setCell(i, NULL_CELL_DATA);
}
+7 -5
View File
@@ -18,8 +18,10 @@ import * as platform from 'common/Platform';
import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable';
// import 'vs/css!./media/scrollbars';
const HIDE_TIMEOUT = 500;
const SCROLL_WHEEL_SENSITIVITY = 50;
const enum Constants {
HIDE_TIMEOUT = 500,
SCROLL_WHEEL_SENSITIVITY = 50
}
class MouseWheelClassifierItem {
public timestamp: number;
@@ -404,12 +406,12 @@ export class SmoothScrollableElement extends Widget {
let desiredScrollPosition: INewScrollPosition = {};
if (deltaY) {
const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY;
const deltaScrollTop = Constants.SCROLL_WHEEL_SENSITIVITY * deltaY;
const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop));
this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop);
}
if (deltaX) {
const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX;
const deltaScrollLeft = Constants.SCROLL_WHEEL_SENSITIVITY * deltaX;
const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft));
this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft);
}
@@ -533,7 +535,7 @@ export class SmoothScrollableElement extends Widget {
private _scheduleHide(): void {
if (!this._mouseIsOver && !this._isDragging) {
this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT);
this._hideTimeout.cancelAndSet(() => this._hide(), Constants.HIDE_TIMEOUT);
}
}
}
@@ -7,10 +7,13 @@ import { assert } from 'chai';
import { ICharacterJoinerService } from 'browser/services/Services';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
import { MockBufferService, createCellData } from 'common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService;
@@ -22,7 +25,7 @@ describe('CharacterJoinerService', () => {
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
lines.set(3, lineData([['no joined ranges']]));
lines.set(4, new BufferLine(0));
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]);
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
@@ -267,7 +270,7 @@ describe('CharacterJoinerService', () => {
type IPartialLineData = ([string] | [string, number]);
function lineData(data: IPartialLineData[]): IBufferLine {
const tline = new BufferLine(0);
const tline = new BufferLine(TEST_STRING_CACHE, 0);
for (let i = 0; i < data.length; ++i) {
const line = data[i][0];
const attr = (data[i][1] || 0) as number;
+1 -1
View File
@@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService {
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
return getCoords(
window,
getWindow(element),
event,
element,
colCount,
+6 -1
View File
@@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService {
private _renderDebouncer: IRenderDebouncerWithCallback;
private _pausedResizeTask: DebouncedIdleTask;
private _observerDisposable = this._register(new MutableDisposable());
private _intersectionObserver: IntersectionObserver | undefined;
private _isPaused: boolean = false;
private _needsFullRefresh: boolean = false;
@@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService {
// and resume based on terminal visibility if so
if ('IntersectionObserver' in w) {
const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
this._observerDisposable.value = toDisposable(() => {
this._intersectionObserver?.disconnect();
this._intersectionObserver = undefined;
});
this._intersectionObserver = observer;
observer.observe(screenElement);
this._observerDisposable.value = toDisposable(() => observer.disconnect());
}
}
@@ -9,12 +9,15 @@ import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { IBufferService, IOptionsService } from 'common/services/Services';
import { MockCoreBrowserService, MockMouseService, MockRenderService } from 'browser/TestUtils.test';
import { CellData } from 'common/buffer/CellData';
import { IBuffer } from 'common/buffer/Types';
import { IRenderService } from 'browser/services/Services';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestSelectionService extends SelectionService {
constructor(
bufferService: IBufferService,
@@ -55,7 +58,7 @@ describe('SelectionService', () => {
});
function stringToRow(text: string): IBufferLine {
const result = new BufferLine(text.length);
const result = new BufferLine(TEST_STRING_CACHE, text.length);
for (let i = 0; i < text.length; i++) {
result.setCell(i, createCellData(0, text.charAt(i), 1));
}
@@ -63,7 +66,7 @@ describe('SelectionService', () => {
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(chars.length);
const line = new BufferLine(TEST_STRING_CACHE, chars.length);
chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line;
}
@@ -118,7 +121,7 @@ describe('SelectionService', () => {
[0, 'o', 1, 'o'.charCodeAt(0)],
[0, 'o', 1, 'o'.charCodeAt(0)]
];
const line = new BufferLine(data.length);
const line = new BufferLine(TEST_STRING_CACHE, data.length);
for (let i = 0; i < data.length; ++i) line.setCell(i, CellData.fromCharData(data[i]));
buffer.lines.set(0, line);
// Ensure wide characters take up 2 columns
+26 -27
View File
@@ -18,27 +18,26 @@ import { IBuffer } from 'common/buffer/Types';
import { IBufferService, ICoreService, IOptionsService } from 'common/services/Services';
import { Emitter } from 'common/Event';
/**
* The number of pixels the mouse needs to be above or below the viewport in
* order to scroll at the maximum speed.
*/
const DRAG_SCROLL_MAX_THRESHOLD = 50;
/**
* The maximum scrolling speed
*/
const DRAG_SCROLL_MAX_SPEED = 15;
/**
* The number of milliseconds between drag scroll updates.
*/
const DRAG_SCROLL_INTERVAL = 50;
/**
* The maximum amount of time that can have elapsed for an alt click to move the
* cursor.
*/
const ALT_CLICK_MOVE_CURSOR_TIME = 500;
const enum Constants {
/**
* The number of pixels the mouse needs to be above or below the viewport in
* order to scroll at the maximum speed.
*/
DRAG_SCROLL_MAX_THRESHOLD = 50,
/**
* The maximum scrolling speed
*/
DRAG_SCROLL_MAX_SPEED = 15,
/**
* The number of milliseconds between drag scroll updates.
*/
DRAG_SCROLL_INTERVAL = 50,
/**
* The maximum amount of time that can have elapsed for an alt click to move the
* cursor.
*/
ALT_CLICK_MOVE_CURSOR_TIME = 500
}
const NON_BREAKING_SPACE_CHAR = String.fromCharCode(160);
const ALL_NON_BREAKING_SPACE_REGEX = new RegExp(NON_BREAKING_SPACE_CHAR, 'g');
@@ -424,9 +423,9 @@ export class SelectionService extends Disposable implements ISelectionService {
offset -= terminalHeight;
}
offset = Math.min(Math.max(offset, -DRAG_SCROLL_MAX_THRESHOLD), DRAG_SCROLL_MAX_THRESHOLD);
offset /= DRAG_SCROLL_MAX_THRESHOLD;
return (offset / Math.abs(offset)) + Math.round(offset * (DRAG_SCROLL_MAX_SPEED - 1));
offset = Math.min(Math.max(offset, -Constants.DRAG_SCROLL_MAX_THRESHOLD), Constants.DRAG_SCROLL_MAX_THRESHOLD);
offset /= Constants.DRAG_SCROLL_MAX_THRESHOLD;
return (offset / Math.abs(offset)) + Math.round(offset * (Constants.DRAG_SCROLL_MAX_SPEED - 1));
}
/**
@@ -500,7 +499,7 @@ export class SelectionService extends Disposable implements ISelectionService {
this._screenElement.ownerDocument.addEventListener('mousemove', this._mouseMoveListener);
this._screenElement.ownerDocument.addEventListener('mouseup', this._mouseUpListener);
}
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), DRAG_SCROLL_INTERVAL);
this._dragScrollIntervalTimer = this._coreBrowserService.window.setInterval(() => this._dragScroll(), Constants.DRAG_SCROLL_INTERVAL);
}
/**
@@ -675,7 +674,7 @@ export class SelectionService extends Disposable implements ISelectionService {
}
/**
* The callback that occurs every DRAG_SCROLL_INTERVAL ms that does the
* The callback that occurs every Constants.DRAG_SCROLL_INTERVAL ms that does the
* scrolling of the viewport.
*/
private _dragScroll(): void {
@@ -713,7 +712,7 @@ export class SelectionService extends Disposable implements ISelectionService {
this._removeMouseDownListeners();
if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
if (this.selectionText.length <= 1 && timeElapsed < Constants.ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) {
const coordinates = this._mouseCoordsService.getCoords(
event,
+36
View File
@@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable {
}
}
/**
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
* run yet.
*/
export class MicrotaskTimer implements IDisposable {
private _isScheduled = false;
private _isDisposed = false;
public dispose(): void {
this.cancel();
this._isDisposed = true;
}
public cancel(): void {
this._isScheduled = false;
}
public set(runner: () => void): void {
if (this._isDisposed) {
throw new Error('Calling set on a disposed MicrotaskTimer');
}
if (this._isScheduled) {
return;
}
this._isScheduled = true;
queueMicrotask(() => {
if (!this._isScheduled) {
return;
}
this._isScheduled = false;
runner();
});
}
}
export class IntervalTimer implements IDisposable {
private _disposable: IDisposable | undefined;
private _isDisposed = false;
+5 -5
View File
@@ -24,7 +24,7 @@
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
import { InstantiationService } from 'common/services/InstantiationService';
import { LogService } from 'common/services/LogService';
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
import { BufferService, BufferServiceConstants } from 'common/services/BufferService';
import { OptionsService } from 'common/services/OptionsService';
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types';
import { CoreService } from 'common/services/CoreService';
@@ -173,8 +173,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
return;
}
x = Math.max(x, MINIMUM_COLS);
y = Math.max(y, MINIMUM_ROWS);
x = Math.max(x, BufferServiceConstants.MINIMUM_COLS);
y = Math.max(y, BufferServiceConstants.MINIMUM_ROWS);
// Flush pending writes before resize to avoid race conditions where async
// writes are processed with incorrect dimensions
@@ -243,8 +243,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
}
/** Add handler for APC escape sequence. See xterm.d.ts for details. */
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerApcHandler(ident, callback);
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._inputHandler.registerApcHandler(id, callback);
}
protected _setup(): void {
+30 -48
View File
@@ -30,28 +30,9 @@ import { XTERM_VERSION } from 'common/Version';
*/
const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2 };
/**
* VT commands done by the parser - FIXME: move this to the parser?
*/
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
/**
* Document xterm VT features here that are currently unsupported
*/
// @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
// @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps \| Pt ST" "Definitions for user-defined keys."
// @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String."
// @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data."
@@ -60,12 +41,13 @@ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-':
/**
* Max length of the UTF32 input buffer. Real memory consumption is 4 times higher.
*/
const MAX_PARSEBUFFER_LENGTH = 131072;
/**
* Limit length of title and icon name stacks.
*/
const STACK_LIMIT = 10;
const enum Constants {
MAX_PARSEBUFFER_LENGTH = 131072,
/** Limit length of title and icon name stacks. */
STACK_LIMIT = 10,
// create a warning log if an async handler takes longer than the limit (in ms)
SLOW_ASYNC_LIMIT = 5000
}
// map params to window option
function paramToWindowOption(n: number, opts: IWindowOptions): boolean {
@@ -104,9 +86,6 @@ export enum WindowsOptionsReportType {
GET_CELL_SIZE_PIXELS = 1
}
// create a warning log if an async handler takes longer than the limit (in ms)
const SLOW_ASYNC_LIMIT = 5000;
// Work variables to avoid garbage collection
let $temp = 0;
@@ -211,6 +190,9 @@ export class InputHandler extends Disposable implements IInputHandler {
}
this._logService.debug('Unknown DCS code: ', { identifier: this._parser.identToString(ident), action, payload });
});
this._parser.setApcHandlerFallback((ident, action, payload) => {
this._logService.debug('Unknown APC code: ', { identifier: this._parser.identToString(ident), action, payload });
});
/**
* print handler
@@ -409,7 +391,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (this._logService.logLevel <= LogLevelEnum.WARN) {
let slowTimeout: ReturnType<typeof setTimeout> | undefined;
const slowPromise = new Promise<never>((_res, rej) => {
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), SLOW_ASYNC_LIMIT);
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), Constants.SLOW_ASYNC_LIMIT);
});
Promise.race([p, slowPromise])
.then(() => {
@@ -423,7 +405,7 @@ export class InputHandler extends Disposable implements IInputHandler {
if (err !== '#SLOW_TIMEOUT') {
throw err;
}
console.warn(`async parser handler taking longer than ${SLOW_ASYNC_LIMIT} ms`);
console.warn(`async parser handler taking longer than ${Constants.SLOW_ASYNC_LIMIT} ms`);
});
}
}
@@ -461,8 +443,8 @@ export class InputHandler extends Disposable implements IInputHandler {
cursorStartX = this._parseStack.cursorStartX;
cursorStartY = this._parseStack.cursorStartY;
this._parseStack.paused = false;
if (data.length > MAX_PARSEBUFFER_LENGTH) {
start = this._parseStack.position + MAX_PARSEBUFFER_LENGTH;
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
start = this._parseStack.position + Constants.MAX_PARSEBUFFER_LENGTH;
}
}
@@ -479,8 +461,8 @@ export class InputHandler extends Disposable implements IInputHandler {
// resize input buffer if needed
if (this._parseBuffer.length < data.length) {
if (this._parseBuffer.length < MAX_PARSEBUFFER_LENGTH) {
this._parseBuffer = new Uint32Array(Math.min(data.length, MAX_PARSEBUFFER_LENGTH));
if (this._parseBuffer.length < Constants.MAX_PARSEBUFFER_LENGTH) {
this._parseBuffer = new Uint32Array(Math.min(data.length, Constants.MAX_PARSEBUFFER_LENGTH));
}
}
@@ -491,9 +473,9 @@ export class InputHandler extends Disposable implements IInputHandler {
}
// process big data in smaller chunks
if (data.length > MAX_PARSEBUFFER_LENGTH) {
for (let i = start; i < data.length; i += MAX_PARSEBUFFER_LENGTH) {
const end = i + MAX_PARSEBUFFER_LENGTH < data.length ? i + MAX_PARSEBUFFER_LENGTH : data.length;
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
for (let i = start; i < data.length; i += Constants.MAX_PARSEBUFFER_LENGTH) {
const end = i + Constants.MAX_PARSEBUFFER_LENGTH < data.length ? i + Constants.MAX_PARSEBUFFER_LENGTH : data.length;
const len = (typeof data === 'string')
? this._stringDecoder.decode(data.substring(i, end), this._parseBuffer)
: this._utf8Decoder.decode(data.subarray(i, end), this._parseBuffer);
@@ -729,8 +711,8 @@ export class InputHandler extends Disposable implements IInputHandler {
/**
* Forward registerApcHandler from parser.
*/
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._parser.registerApcHandler(ident, new ApcHandler(callback));
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._parser.registerApcHandler(id, new ApcHandler(callback));
}
/**
@@ -2415,7 +2397,7 @@ export class InputHandler extends Disposable implements IInputHandler {
color &= ~Attributes.RGB_MASK;
color |= AttributeData.fromColorRGB([c1, c2, c3]);
} else if (mode === 5) {
color &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
color &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
color |= Attributes.CM_P256 | (c1 & 0xff);
}
return color;
@@ -2628,19 +2610,19 @@ export class InputHandler extends Disposable implements IInputHandler {
p = params.params[i];
if (p >= 30 && p <= 37) {
// fg color 8
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= Attributes.CM_P16 | (p - 30);
} else if (p >= 40 && p <= 47) {
// bg color 8
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= Attributes.CM_P16 | (p - 40);
} else if (p >= 90 && p <= 97) {
// fg color 16
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= Attributes.CM_P16 | (p - 90) | 8;
} else if (p >= 100 && p <= 107) {
// bg color 16
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= Attributes.CM_P16 | (p - 100) | 8;
} else if (p === 0) {
// default
@@ -2700,11 +2682,11 @@ export class InputHandler extends Disposable implements IInputHandler {
} else if (p === 39) {
// reset fg
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
attr.fg |= DEFAULT_ATTR_DATA.fg & Attributes.RGB_MASK;
} else if (p === 49) {
// reset bg
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
attr.bg |= DEFAULT_ATTR_DATA.bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
attr.bg |= DEFAULT_ATTR_DATA.bg & Attributes.RGB_MASK;
} else if (p === 38 || p === 48 || p === 58) {
// fg color 256 and RGB
i += this._extractColor(params, i, attr);
@@ -2971,13 +2953,13 @@ export class InputHandler extends Disposable implements IInputHandler {
case 22: // PushTitle
if (second === 0 || second === 2) {
this._windowTitleStack.push(this._windowTitle);
if (this._windowTitleStack.length > STACK_LIMIT) {
if (this._windowTitleStack.length > Constants.STACK_LIMIT) {
this._windowTitleStack.shift();
}
}
if (second === 0 || second === 1) {
this._iconNameStack.push(this._iconName);
if (this._iconNameStack.length > STACK_LIMIT) {
if (this._iconNameStack.length > Constants.STACK_LIMIT) {
this._iconNameStack.shift();
}
}
+2 -2
View File
@@ -22,7 +22,7 @@ export interface ICoreTerminal {
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
}
export interface IDisposable {
@@ -476,7 +476,7 @@ export interface IInputHandler {
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
/** C0 BEL */ bell(): boolean;
/** C0 LF */ lineFeed(): boolean;
+118 -13
View File
@@ -8,22 +8,34 @@ import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
const INIT_COLS = 80;
const INIT_ROWS = 24;
const INIT_SCROLLBACK = 1000;
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBuffer extends Buffer {
public getStringCache(): BufferLineStringCache {
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
}
public getStringCacheClearTimeout(): unknown {
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
}
}
describe('Buffer', () => {
let optionsService: MockOptionsService;
let bufferService: MockBufferService;
let buffer: Buffer;
let buffer: TestBuffer;
beforeEach(() => {
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
});
describe('constructor', () => {
@@ -151,7 +163,7 @@ describe('Buffer', () => {
describe('no scrollback', () => {
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
assert.equal(buffer.lines.maxLength, INIT_ROWS);
buffer.y = INIT_ROWS - 1;
buffer.fillViewportRows();
@@ -1054,7 +1066,7 @@ describe('Buffer', () => {
describe('buffer marked to have no scrollback', () => {
it('should always have a scrollback of 0', () => {
// Test size on initialization
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.lines.maxLength, INIT_ROWS);
// Test size on buffer increase
@@ -1068,7 +1080,7 @@ describe('Buffer', () => {
describe('addMarker', () => {
it('should adjust a marker line when the buffer is trimmed', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
const marker = buffer.addMarker(buffer.lines.length - 1);
assert.equal(marker.line, buffer.lines.length - 1);
@@ -1076,7 +1088,7 @@ describe('Buffer', () => {
assert.equal(marker.line, buffer.lines.length - 2);
});
it('should dispose of a marker if it is trimmed off the buffer', () => {
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1088,7 +1100,7 @@ describe('Buffer', () => {
});
it('should call onDispose', () => {
const eventStack: string[] = [];
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
buffer.fillViewportRows();
assert.equal(buffer.markers.length, 0);
const marker = buffer.addMarker(0);
@@ -1104,7 +1116,7 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.setCell(0, createCellData(0, 'a', 1));
line.setCell(1, createCellData(0, 'b', 1));
line.setCell(2, createCellData(0, 'c', 1));
@@ -1116,7 +1128,7 @@ describe('Buffer', () => {
});
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1127,7 +1139,7 @@ describe('Buffer', () => {
});
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(3);
const line = new BufferLine(TEST_STRING_CACHE, 3);
line.setCell(0, createCellData(0, '語', 2));
line.setCell(1, createCellData(0, '', 0));
line.setCell(2, createCellData(0, 'a', 1));
@@ -1144,7 +1156,7 @@ describe('Buffer', () => {
});
it('should handle single width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 1));
line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
@@ -1157,7 +1169,7 @@ describe('Buffer', () => {
});
it('should handle double width emojis', () => {
const line = new BufferLine(2);
const line = new BufferLine(TEST_STRING_CACHE, 2);
line.setCell(0, createCellData(0, '😁', 2));
line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line);
@@ -1168,7 +1180,7 @@ describe('Buffer', () => {
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
assert.equal(str2, '😁');
const line2 = new BufferLine(3);
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
line2.setCell(0, createCellData(0, '😁', 2));
line2.setCell(1, createCellData(0, '', 0));
line2.setCell(2, createCellData(0, 'a', 1));
@@ -1179,6 +1191,99 @@ describe('Buffer', () => {
});
});
describe('line string cache cleanup', () => {
it('should clear shared cache entries with a single timer', () => {
const originalSetTimeout = globalThis.setTimeout;
const originalClearTimeout = globalThis.clearTimeout;
const originalDateNow = Date.now;
let timeoutId = 0;
let now = 0;
const clearedTimeouts: number[] = [];
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
const id = ++timeoutId;
scheduledTimeouts.set(id, {
delay: timeout ?? 0,
fire: () => {
scheduledTimeouts.delete(id);
handler();
}
});
return id as ReturnType<typeof setTimeout>;
}) as typeof setTimeout;
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
const numericId = id as unknown as number;
clearedTimeouts.push(numericId);
scheduledTimeouts.delete(numericId);
}) as typeof clearTimeout;
Date.now = () => now;
try {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 2);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
const initialTimerCreationCount = timeoutId;
now = 5000;
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(timeoutId, initialTimerCreationCount);
assert.equal(scheduledTimeouts.size, 1);
assert.deepEqual(clearedTimeouts, []);
now = 15000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(timeoutId, initialTimerCreationCount + 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
assert.equal(scheduledTimeouts.size, 1);
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
now = 20000;
[...scheduledTimeouts.values()][0].fire();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
assert.equal(cache.entries.size, 1);
} finally {
Date.now = originalDateNow;
globalThis.setTimeout = originalSetTimeout;
globalThis.clearTimeout = originalClearTimeout;
}
});
it('should reset line string cache state on clear and resize', () => {
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
buffer.translateBufferLineToString(0, false);
const cache = buffer.getStringCache();
assert.equal(cache.entries.size, 1);
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
buffer.clear();
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
buffer.fillViewportRows();
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
buffer.translateBufferLineToString(0, false);
assert.equal(cache.entries.size, 1);
buffer.resize(INIT_COLS - 1, INIT_ROWS);
assert.equal(cache.entries.size, 0);
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
});
});
describe('memory cleanup after shrinking', () => {
it('should realign memory from idle task execution', async () => {
buffer.fillViewportRows();
+14 -5
View File
@@ -4,10 +4,12 @@
*/
import { CircularList, IInsertEvent } from 'common/CircularList';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IdleTaskQueue } from 'common/TaskQueue';
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
import { CellData } from 'common/buffer/CellData';
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
@@ -25,7 +27,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
* - cursor position
* - scroll position
*/
export class Buffer implements IBuffer {
export class Buffer extends Disposable implements IBuffer {
public lines: CircularList<IBufferLine>;
public ydisp: number = 0;
public ybase: number = 0;
@@ -50,6 +52,7 @@ export class Buffer implements IBuffer {
private _isClearing: boolean = false;
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
private _memoryCleanupPosition = 0;
private readonly _stringCache: BufferLineStringCache;
constructor(
private _hasScrollback: boolean,
@@ -57,6 +60,7 @@ export class Buffer implements IBuffer {
private _bufferService: IBufferService,
private readonly _logService: ILogService
) {
super();
this._cols = this._bufferService.cols;
this._rows = this._bufferService.rows;
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
@@ -64,6 +68,9 @@ export class Buffer implements IBuffer {
this.scrollBottom = this._rows - 1;
this.setupTabStops();
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
this._register(toDisposable(() => this.clearAllMarkers()));
this._stringCache = this._register(new BufferLineStringCache());
}
public getNullCell(attr?: IAttributeData): ICellData {
@@ -93,7 +100,7 @@ export class Buffer implements IBuffer {
}
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
}
public get hasScrollback(): boolean {
@@ -138,6 +145,7 @@ export class Buffer implements IBuffer {
* Clears the buffer to it's initial state, discarding all previous data.
*/
public clear(): void {
this._stringCache.clear();
this.ydisp = 0;
this.ybase = 0;
this.y = 0;
@@ -156,6 +164,7 @@ export class Buffer implements IBuffer {
public resize(newCols: number, newRows: number): void {
// store reference to null cell with default attrs
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
this._stringCache.clear();
// count bufferlines with overly big memory to be cleaned afterwards
let dirtyMemoryLines = 0;
@@ -190,7 +199,7 @@ export class Buffer implements IBuffer {
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
// Just add the new missing rows on Windows as conpty reprints the screen with it's
// view of the world. Once a line enters scrollback for conpty it remains there
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
} else {
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
// There is room above the buffer and there are no empty elements below the line,
@@ -204,7 +213,7 @@ export class Buffer implements IBuffer {
} else {
// Add a blank line if there is no buffer left at the top to scroll to, or if there
// are blank lines after the cursor
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
}
}
@@ -345,7 +354,7 @@ export class Buffer implements IBuffer {
}
if (this.lines.length < newRows) {
// Add an extra row at the bottom of the viewport
this.lines.push(new BufferLine(newCols, nullCell));
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
}
} else {
if (this.ydisp === this.ybase) {
+100 -1
View File
@@ -4,18 +4,41 @@
*/
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
import { BufferLine } from 'common/buffer//BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { CellData } from 'common/buffer/CellData';
import { CharData, IBufferLine } from '../Types';
import { CharData, IBufferLine, ICellData } from '../Types';
import { assert } from 'chai';
import { AttributeData } from 'common/buffer/AttributeData';
import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestUtils.test';
const TEST_STRING_CACHE = new BufferLineStringCache();
class TestBufferLine extends BufferLine {
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
}
public get combined(): {[index: number]: string} {
return this._combined;
}
public get cachedString(): string | undefined {
return this._getStringCacheEntry(false)?.value;
}
public set cachedString(value: string | undefined) {
this._getStringCacheEntry(true)!.value = value;
}
public get isCachedStringTrimmed(): boolean {
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
}
public set isCachedStringTrimmed(value: boolean) {
this._getStringCacheEntry(true)!.isTrimmed = value;
}
public toArray(): CharData[] {
const result = [];
for (let i = 0; i < this.length; ++i) {
@@ -807,5 +830,81 @@ describe('BufferLine', function(): void {
assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
});
it('should cache canonical string translations', () => {
const line = new TestBufferLine(5);
line.setCell(0, createCellData(1, 'a', 1));
line.setCell(1, createCellData(1, 'b', 1));
line.setCell(2, createCellData(1, 'c', 1));
// Trimmed-only canonical request should cache the trimmed value.
const trimmed = line.translateToString(true, undefined, undefined, undefined);
assert.equal(trimmed, 'abc');
assert.equal(line.cachedString, 'abc');
assert.equal(line.isCachedStringTrimmed, true);
// Non-trimmed canonical request should refresh cache with the full value.
const translated = line.translateToString(false, undefined, undefined, undefined);
assert.equal(translated, 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Once non-trimmed is cached, trimmed should be derived via trimEnd().
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
line.cachedString = 'cached-non-trimmed ';
line.isCachedStringTrimmed = false;
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed ');
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed');
line.cachedString = 'cached-trimmed';
line.isCachedStringTrimmed = true;
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed');
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc ');
assert.equal(line.cachedString, 'abc ');
assert.equal(line.isCachedStringTrimmed, false);
// Any optional translation argument should bypass cache.
assert.equal(line.translateToString(false, 0, 2, undefined), 'ab');
assert.equal(line.translateToString(true, 0, 2, undefined), 'ab');
});
it('should invalidate cached canonical strings on line mutations', () => {
const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => {
const line = new TestBufferLine(5);
line.fill(createCellData(1, 'a', 1));
line.translateToString(true, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, true);
line.translateToString(false, undefined, undefined, undefined);
assert.equal(line.cachedString, 'aaaaa');
assert.equal(line.isCachedStringTrimmed, false);
mutate(line);
assert.equal(line.cachedString, undefined);
assert.equal(line.isCachedStringTrimmed, false);
};
assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)]));
assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0));
assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1)));
assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1)));
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyFrom(src);
});
assertCacheInvalidated(line => {
const src = new TestBufferLine(5);
src.fill(createCellData(1, 'x', 1));
line.copyCellsFrom(src, 0, 0, 2, false);
});
});
});
});
+128 -59
View File
@@ -9,25 +9,26 @@ 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';
/**
* buffer memory layout:
*
* | uint32_t | uint32_t | uint32_t |
* | `content` | `FG` | `BG` |
* | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) |
*/
// Buffer memory layout:
//
// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21)
// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8)
// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8)
/** typed array slots taken by one cell */
const CELL_SIZE = 3;
const enum Constants {
/** The number of 32 bit array indices taken by one cell. */
CELL_INDICIES = 3,
/** Factor when to cleanup underlying array buffer after shrinking. */
CLEANUP_THRESHOLD = 2
}
/**
* Cell member indices.
*
* Direct access:
* `content = data[column * CELL_SIZE + Cell.CONTENT];`
* `fg = data[column * CELL_SIZE + Cell.FG];`
* `bg = data[column * CELL_SIZE + Cell.BG];`
* `content = data[column * Constants.CELL_INDICIES + Cell.CONTENT];`
* `fg = data[column * Constants.CELL_INDICIES + Cell.FG];`
* `bg = data[column * Constants.CELL_INDICIES + Cell.BG];`
*/
const enum Cell {
CONTENT = 0,
@@ -41,8 +42,17 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
let $startIndex = 0;
const $workCell = new CellData();
/** Factor when to cleanup underlying array buffer after shrinking. */
const CLEANUP_THRESHOLD = 2;
export interface IBufferLineStringCacheEntry {
value: string | undefined;
isTrimmed: boolean;
generation: number;
}
export interface IBufferLineStringCache {
generation: number;
allocateEntry(): IBufferLineStringCacheEntry;
touch?(): void;
}
/**
* Typed array based bufferline implementation.
@@ -63,10 +73,16 @@ export class BufferLine implements IBufferLine {
protected _data: Uint32Array;
protected _combined: {[index: number]: string} = {};
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
public length: number;
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
this._data = new Uint32Array(cols * CELL_SIZE);
constructor(
protected readonly _stringCache: IBufferLineStringCache,
cols: number,
fillCellData?: ICellData,
public isWrapped: boolean = false
) {
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
for (let i = 0; i < cols; ++i) {
this.setCell(i, cell);
@@ -79,10 +95,10 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public get(index: number): CharData {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
const cp = content & Content.CODEPOINT_MASK;
return [
this._data[index * CELL_SIZE + Cell.FG],
this._data[index * Constants.CELL_INDICIES + Cell.FG],
(content & Content.IS_COMBINED_MASK)
? this._combined[index]
: (cp) ? stringFromCodePoint(cp) : '',
@@ -98,12 +114,13 @@ export class BufferLine implements IBufferLine {
* @deprecated
*/
public set(index: number, value: CharData): void {
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
this._invalidateStringCache();
this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
this._combined[index] = value[1];
this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
} else {
this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
}
}
@@ -112,22 +129,22 @@ export class BufferLine implements IBufferLine {
* use these when only one value is needed, otherwise use `loadCell`
*/
public getWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT;
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT;
}
/** Test whether content has width. */
public hasWidth(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK;
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.WIDTH_MASK;
}
/** Get FG cell component. */
public getFg(index: number): number {
return this._data[index * CELL_SIZE + Cell.FG];
return this._data[index * Constants.CELL_INDICIES + Cell.FG];
}
/** Get BG cell component. */
public getBg(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG];
return this._data[index * Constants.CELL_INDICIES + Cell.BG];
}
/**
@@ -136,7 +153,7 @@ export class BufferLine implements IBufferLine {
* from real empty cells.
*/
public hasContent(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
}
/**
@@ -145,7 +162,7 @@ export class BufferLine implements IBufferLine {
* a single UTF32 codepoint or the last codepoint of a combined string.
*/
public getCodePoint(index: number): number {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index].charCodeAt(this._combined[index].length - 1);
}
@@ -154,12 +171,12 @@ export class BufferLine implements IBufferLine {
/** Test whether the cell contains a combined string. */
public isCombined(index: number): number {
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK;
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK;
}
/** Returns the string content of the cell. */
public getString(index: number): string {
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
return this._combined[index];
}
@@ -172,7 +189,7 @@ export class BufferLine implements IBufferLine {
/** Get state of protected flag. */
public isProtected(index: number): number {
return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED;
return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED;
}
/**
@@ -180,7 +197,7 @@ export class BufferLine implements IBufferLine {
* to GC as it significantly reduced the amount of new objects/references needed.
*/
public loadCell(index: number, cell: ICellData): ICellData {
$startIndex = index * CELL_SIZE;
$startIndex = index * Constants.CELL_INDICIES;
cell.content = this._data[$startIndex + Cell.CONTENT];
cell.fg = this._data[$startIndex + Cell.FG];
cell.bg = this._data[$startIndex + Cell.BG];
@@ -197,15 +214,16 @@ export class BufferLine implements IBufferLine {
* Set data at `index` to `cell`.
*/
public setCell(index: number, cell: ICellData): void {
this._invalidateStringCache();
if (cell.content & Content.IS_COMBINED_MASK) {
this._combined[index] = cell.combinedData;
}
if (cell.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = cell.extended;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content;
this._data[index * CELL_SIZE + Cell.FG] = cell.fg;
this._data[index * CELL_SIZE + Cell.BG] = cell.bg;
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content;
this._data[index * Constants.CELL_INDICIES + Cell.FG] = cell.fg;
this._data[index * Constants.CELL_INDICIES + Cell.BG] = cell.bg;
}
/**
@@ -214,12 +232,13 @@ export class BufferLine implements IBufferLine {
* it gets an optimized access method.
*/
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
this._invalidateStringCache();
if (attrs.bg & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[index] = attrs.extended;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * CELL_SIZE + Cell.FG] = attrs.fg;
this._data[index * CELL_SIZE + Cell.BG] = attrs.bg;
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg;
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg;
}
/**
@@ -229,7 +248,8 @@ export class BufferLine implements IBufferLine {
* by the previous `setDataFromCodePoint` call, we can omit it here.
*/
public addCodepointToCell(index: number, codePoint: number, width: number): void {
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
this._invalidateStringCache();
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
if (content & Content.IS_COMBINED_MASK) {
// we already have a combined string, simply add
this._combined[index] += stringFromCodePoint(codePoint);
@@ -251,10 +271,11 @@ export class BufferLine implements IBufferLine {
content &= ~Content.WIDTH_MASK;
content |= width << Content.WIDTH_SHIFT;
}
this._data[index * CELL_SIZE + Cell.CONTENT] = content;
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content;
}
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
@@ -282,6 +303,7 @@ export class BufferLine implements IBufferLine {
}
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
this._invalidateStringCache();
pos %= this.length;
if (n < this.length - pos) {
for (let i = 0; i < this.length - pos - n; ++i) {
@@ -308,6 +330,7 @@ export class BufferLine implements IBufferLine {
}
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
@@ -344,13 +367,14 @@ export class BufferLine implements IBufferLine {
* The underlying array buffer will not change if there is still enough space
* to hold the new buffer line data.
* Returns a boolean indicating, whether a `cleanupMemory` call would free
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
*/
public resize(cols: number, fillCellData: ICellData): boolean {
this._invalidateStringCache();
if (cols === this.length) {
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
const uint32Cells = cols * CELL_SIZE;
const uint32Cells = cols * Constants.CELL_INDICIES;
if (cols > this.length) {
if (this._data.buffer.byteLength >= uint32Cells * 4) {
// optimization: avoid alloc and data copy if buffer has enough room
@@ -385,17 +409,17 @@ export class BufferLine implements IBufferLine {
}
}
this.length = cols;
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
}
/**
* Cleanup underlying array buffer.
* A cleanup will be triggered if the array buffer exceeds the actual used
* memory by a factor of CLEANUP_THRESHOLD.
* memory by a factor of Constants.CLEANUP_THRESHOLD.
* Returns 0 or 1 indicating whether a cleanup happened.
*/
public cleanupMemory(): number {
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
if (this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
const data = new Uint32Array(this._data.length);
data.set(this._data);
this._data = data;
@@ -406,6 +430,7 @@ export class BufferLine implements IBufferLine {
/** fill a line with fillCharData */
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
this._invalidateStringCache();
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
if (respectProtect) {
for (let i = 0; i < this.length; ++i) {
@@ -424,6 +449,7 @@ export class BufferLine implements IBufferLine {
/** alter to a full copy of line */
public copyFrom(line: BufferLine): void {
this._invalidateStringCache();
if (this.length !== line.length) {
this._data = new Uint32Array(line._data);
} else {
@@ -444,7 +470,7 @@ export class BufferLine implements IBufferLine {
/** create a new clone */
public clone(): IBufferLine {
const newLine = new BufferLine(0);
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
newLine._data = new Uint32Array(this._data);
newLine.length = this.length;
for (const el in this._combined) {
@@ -459,8 +485,8 @@ export class BufferLine implements IBufferLine {
public getTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
@@ -468,30 +494,31 @@ export class BufferLine implements IBufferLine {
public getNoBgTrimmedLength(): number {
for (let i = this.length - 1; i >= 0; --i) {
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK)) {
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
}
}
return 0;
}
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
this._invalidateStringCache();
const srcData = src._data;
if (applyInReverse) {
for (let cell = length - 1; cell >= 0; cell--) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
}
}
} else {
for (let cell = 0; cell < length; cell++) {
for (let i = 0; i < CELL_SIZE; i++) {
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
}
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
}
}
@@ -508,7 +535,8 @@ export class BufferLine implements IBufferLine {
}
/**
* Translates the buffer line to a string.
* Translates the buffer line to a string. Caching only applies to canonical full-line translation
* requests (regardless of `trimRight` value).
*
* @param trimRight Whether to trim any empty cells on the right.
* @param startCol The column to start the string (0-based inclusive).
@@ -521,6 +549,19 @@ export class BufferLine implements IBufferLine {
* returned string, the corresponding entries in `outColumns` will have the same column number.
*/
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
if (isCanonicalRequest) {
this._stringCache.touch?.();
}
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
if (trimRight) {
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
}
if (!stringCacheEntry.isTrimmed) {
return stringCacheEntry.value;
}
}
startCol = startCol ?? 0;
endCol = endCol ?? this.length;
if (trimRight) {
@@ -531,7 +572,7 @@ export class BufferLine implements IBufferLine {
}
let result = '';
while (startCol < endCol) {
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
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;
@@ -545,6 +586,34 @@ export class BufferLine implements IBufferLine {
if (outColumns) {
outColumns.push(startCol);
}
if (isCanonicalRequest) {
const cacheEntry = this._getStringCacheEntry(true)!;
cacheEntry.value = result;
cacheEntry.isTrimmed = !!trimRight;
}
return result;
}
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
const cachedEntry = this._stringCacheEntryRef?.deref();
if (cachedEntry) {
if (cachedEntry.generation === this._stringCache.generation) {
return cachedEntry;
}
}
if (!createIfNeeded) {
return undefined;
}
const cacheEntry = this._stringCache.allocateEntry();
this._stringCacheEntryRef = new WeakRef(cacheEntry);
return cacheEntry;
}
private _invalidateStringCache(): void {
const cacheEntry = this._getStringCacheEntry(false);
if (cacheEntry) {
cacheEntry.value = undefined;
cacheEntry.isTrimmed = false;
}
}
}
@@ -0,0 +1,69 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine';
import { disposableTimeout } from 'common/Async';
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle';
const enum Constants {
CACHE_TTL_MS = 15000
}
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
public generation: number = 0;
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
private _lastAccessTimestamp: number = 0;
constructor() {
super();
this._register(toDisposable(() => this.entries.clear()));
}
public touch(): void {
this._scheduleClear();
}
public allocateEntry(): IBufferLineStringCacheEntry {
const entry: IBufferLineStringCacheEntry = {
value: undefined,
isTrimmed: false,
generation: this.generation
};
this.entries.add(entry);
this._scheduleClear();
return entry;
}
public clear(): void {
this._clearTimeout.clear();
this._lastAccessTimestamp = 0;
this.generation++;
for (const entry of this.entries) {
entry.value = undefined;
entry.isTrimmed = false;
}
this.entries.clear();
}
private _scheduleClear(): void {
this._lastAccessTimestamp = Date.now();
if (this._clearTimeout.value) {
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
}
private _scheduleClearTimeout(timeoutMs: number): void {
this._clearTimeout.value = disposableTimeout(() => {
const elapsed = Date.now() - this._lastAccessTimestamp;
if (elapsed >= Constants.CACHE_TTL_MS) {
this.clear();
return;
}
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
}, timeoutMs);
}
}
+9 -6
View File
@@ -4,13 +4,16 @@
*/
import { assert } from 'chai';
import { BufferLine } from 'common/buffer/BufferLine';
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE } from 'common/buffer/Constants';
import { reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
const TEST_STRING_CACHE = new BufferLineStringCache();
describe('BufferReflow', () => {
describe('reflowSmallerGetNewLineLengths', () => {
it('should return correct line lengths for a small line with wide characters', () => {
const line = new BufferLine(4);
const line = new BufferLine(TEST_STRING_CACHE, 4);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -20,7 +23,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 4, 2), [2, 2], 'line: 汉, 语');
});
it('should return correct line lengths for a large line with wide characters', () => {
const line = new BufferLine(12);
const line = new BufferLine(TEST_STRING_CACHE, 12);
for (let i = 0; i < 12; i += 4) {
line.set(i, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(i + 2, [0, '语', 2, '语'.charCodeAt(0)]);
@@ -42,7 +45,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 12, 2), [2, 2, 2, 2, 2, 2], 'line: 汉, 语, 汉, 语, 汉, 语');
});
it('should return correct line lengths for a string with wide and single characters', () => {
const line = new BufferLine(6);
const line = new BufferLine(TEST_STRING_CACHE, 6);
line.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(2, [0, '', 0, 0]);
@@ -56,14 +59,14 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line], 6, 2), [1, 2, 2, 1], 'line: a, 汉, 语, b');
});
it('should return correct line lengths for a wrapped line with wide and single characters', () => {
const line1 = new BufferLine(6);
const line1 = new BufferLine(TEST_STRING_CACHE, 6);
line1.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line1.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line1.set(2, [0, '', 0, 0]);
line1.set(3, [0, '语', 2, '语'.charCodeAt(0)]);
line1.set(4, [0, '', 0, 0]);
line1.set(5, [0, 'b', 1, 'b'.charCodeAt(0)]);
const line2 = new BufferLine(6, undefined, true);
const line2 = new BufferLine(TEST_STRING_CACHE, 6, undefined, true);
line2.set(0, [0, 'a', 1, 'a'.charCodeAt(0)]);
line2.set(1, [0, '汉', 2, '汉'.charCodeAt(0)]);
line2.set(2, [0, '', 0, 0]);
@@ -78,7 +81,7 @@ describe('BufferReflow', () => {
assert.deepEqual(reflowSmallerGetNewLineLengths([line1, line2], 6, 2), [1, 2, 2, 2, 2, 2, 1], 'lines: a, 汉, 语, ba, 汉, 语, b');
});
it('should work on lines ending in null space', () => {
const line = new BufferLine(5);
const line = new BufferLine(TEST_STRING_CACHE, 5);
line.set(0, [0, '汉', 2, '汉'.charCodeAt(0)]);
line.set(1, [0, '', 0, 0]);
line.set(2, [0, '语', 2, '语'.charCodeAt(0)]);
+42 -1
View File
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { BufferSet } from 'common/buffer/BufferSet';
import { Buffer } from 'common/buffer/Buffer';
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
describe('BufferSet', () => {
let bufferSet: BufferSet;
@@ -82,4 +82,45 @@ describe('BufferSet', () => {
assert.equal(bufferSet.alt.markers.length, 0);
});
});
describe('lifecycle', () => {
it('should dispose previous buffers on reset', () => {
const oldNormal = bufferSet.normal as any;
oldNormal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
oldNormal.translateBufferLineToString(0, false);
const oldCache = oldNormal._stringCache;
assert.equal(oldCache.entries.size, 1);
assert.notEqual(oldCache._clearTimeout.value, undefined);
bufferSet.reset();
assert.notEqual(bufferSet.normal, oldNormal);
assert.equal(oldCache.entries.size, 0);
assert.equal(oldCache._clearTimeout.value, undefined);
});
it('should dispose both buffers when disposed', () => {
const normal = bufferSet.normal as any;
normal.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
normal.translateBufferLineToString(0, false);
bufferSet.activateAltBuffer();
const alt = bufferSet.alt as any;
alt.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
alt.translateBufferLineToString(0, false);
const normalCache = normal._stringCache;
const altCache = alt._stringCache;
assert.notEqual(normalCache._clearTimeout.value, undefined);
assert.notEqual(altCache._clearTimeout.value, undefined);
bufferSet.dispose();
assert.equal(normalCache.entries.size, 0);
assert.equal(altCache.entries.size, 0);
assert.equal(normalCache._clearTimeout.value, undefined);
assert.equal(altCache._clearTimeout.value, undefined);
});
});
});
+5 -1
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Disposable } from 'common/Lifecycle';
import { Disposable, MutableDisposable } from 'common/Lifecycle';
import { IAttributeData } from 'common/Types';
import { Buffer } from 'common/buffer/Buffer';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
@@ -18,6 +18,8 @@ export class BufferSet extends Disposable implements IBufferSet {
private _normal!: Buffer;
private _alt!: Buffer;
private _activeBuffer!: Buffer;
private readonly _normalBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _altBuffer = this._register(new MutableDisposable<Buffer>());
private readonly _onBufferActivate = this._register(new Emitter<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>());
public readonly onBufferActivate = this._onBufferActivate.event;
@@ -38,11 +40,13 @@ export class BufferSet extends Disposable implements IBufferSet {
public reset(): void {
this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService);
this._normalBuffer.value = this._normal;
this._normal.fillViewportRows();
// The alt buffer should never have scrollback.
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService);
this._altBuffer.value = this._alt;
this._activeBuffer = this._normal;
this._onBufferActivate.fire({
activeBuffer: this._normal,
+135 -5
View File
@@ -453,19 +453,59 @@ describe('KittyKeyboard', () => {
describe('event types (press/repeat/release)', () => {
const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('press event (default, no suffix)', () => {
it('UTF-8 text press event', () => {
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97u');
assert.strictEqual(result.key, 'a');
});
it('press event explicit :1 when modifiers present', () => {
it('Escape key press event (default, no suffix)', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[27u');
});
it('Enter key press event → legacy \\r', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\r');
});
it('Tab key press event → legacy \\t', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\t');
});
it('Backspace key press event → legacy \\x7f', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x7f');
});
it('press event when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97;5u');
});
it('repeat event → :2 suffix', () => {
it('UTF-8 text repeat event', () => {
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[97;1:2u');
assert.strictEqual(result.key, 'a');
});
it('Escape key repeat event → :2 suffix', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[27;1:2u');
});
it('Enter key repeat event → legacy \\r', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\r');
});
it('Tab key repeat event → legacy \\t', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\t');
});
it('Backspace key repeat event → legacy \\x7f', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x7f');
});
it('release event → :3 suffix', () => {
@@ -473,6 +513,26 @@ describe('KittyKeyboard', () => {
assert.strictEqual(result.key, '\x1b[97;1:3u');
});
it('Escape key release event → :3 suffix', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[27;1:3u');
});
it('Enter key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('Tab key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('Backspace key release event is not reported', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, undefined);
});
it('release with modifier → mod:3', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[97;5:3u');
@@ -494,6 +554,27 @@ describe('KittyKeyboard', () => {
});
});
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense and
// isn't specified in the spec, but press and repeat events shouldn't get swallowed.
describe('REPORT_EVENT_TYPES flag without DISAMBIGUATE_ESCAPE_CODES', () => {
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('press event is not swallowed when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[97;5u');
});
it('repeat event is not swallowed when modifiers present', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[97;5:2u');
});
it('release event is reported as CSI u sequence', () => {
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[97;5:3u');
});
});
describe('modifier-only reporting', () => {
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
@@ -563,6 +644,55 @@ describe('KittyKeyboard', () => {
});
});
describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag with REPORT_EVENT_TYPES', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
it('Enter key press event → CSI 13 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[13u');
});
it('Tab key press event → CSI 9 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[9u');
});
it('Backspace key press event → CSI 127 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
assert.strictEqual(result.key, '\x1b[127u');
});
it('Enter key repeat event → CSI 13;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[13;1:2u');
});
it('Tab key repeat event → CSI 9;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[9;1:2u');
});
it('Backspace key repeat event → CSI 127;1:2 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
assert.strictEqual(result.key, '\x1b[127;1:2u');
});
it('Enter key release event → CSI 13;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[13;1:3u');
});
it('Tab key release event → CSI 9;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[9;1:3u');
});
it('Backspace key release event → CSI 127;1:3 u', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
assert.strictEqual(result.key, '\x1b[127;1:3u');
});
});
describe('REPORT_ASSOCIATED_TEXT flag', () => {
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT;
+28 -21
View File
@@ -471,29 +471,36 @@ export class KittyKeyboard {
return result;
}
// Special handling for Enter/Tab/Backspace.
const specialKey = keyCode === 13 || keyCode === 9 || keyCode === 127;
// Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as escape
// codes" is also set.
if (specialKey && eventType === KittyKeyboardEventType.RELEASE && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) {
return result;
}
const isFunc = this._functionalKeyCodes[ev.key] !== undefined || this._getNumpadKeyCode(ev) !== undefined;
let useCsiU = false;
if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) {
useCsiU = true;
} else if (reportEventTypes) {
useCsiU = true;
} else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) {
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy
// mode" and consider space to be a text-generating key, so these skip the isFunc fast-path
// and only get CSI u when modifiers are present (handled below).
const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127;
if (isFunc && !isDisambiguateLegacy) {
useCsiU = true;
} else if (modifiers > 0) {
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) {
useCsiU = false;
} else {
useCsiU = true;
}
}
}
const useCsiU = !!(
flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES ||
(reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) ||
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense, so
// just make REPORT_EVENT_TYPES imply DISAMBIGUATE_ESCAPE_CODES here for simplicity.
// See: https://github.com/kovidgoyal/kitty/issues/9999
((flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES || reportEventTypes) &&
(
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and
// consider space to be a text-generating key, so these skip the isFunc fast-path and only
// get CSI u when modifiers are present (handled below).
(isFunc && !specialKey) ||
(
(modifiers > 0 && ev.key.length !== 1) ||
modifiers - 1 > KittyKeyboardModifiers.SHIFT
)
)
)
);
if (useCsiU) {
result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);
+33 -5
View File
@@ -5,7 +5,6 @@
import { assert } from 'chai';
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32, utf32ToString } from 'common/input/TextDecoder';
import { encode } from 'utf8';
// convert UTF32 codepoints to string
@@ -29,6 +28,35 @@ function fromByteString(s: string): Uint8Array {
return result;
}
function stringToUtf8Bytes(s: string): Uint8Array {
const bytes: number[] = [];
for (let i = 0; i < s.length; i++) {
let cp = s.charCodeAt(i);
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.length) {
const next = s.charCodeAt(i + 1);
if (next >= 0xDC00 && next <= 0xDFFF) {
cp = 0x10000 + ((cp - 0xD800) << 10) + (next - 0xDC00);
i++;
}
}
if (cp < 0x80) {
bytes.push(cp);
} else if (cp < 0x800) {
bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F));
} else if (cp < 0x10000) {
bytes.push(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
} else {
bytes.push(
0xF0 | (cp >> 18),
0x80 | ((cp >> 12) & 0x3F),
0x80 | ((cp >> 6) & 0x3F),
0x80 | (cp & 0x3F)
);
}
}
return new Uint8Array(bytes);
}
function assertDecodedRange(
min: number,
max: number,
@@ -172,7 +200,7 @@ describe('text encodings', () => {
max,
(i) => (i >= 0xD800 && i <= 0xDFFF) || i === 0xFEFF,
(i) => String.fromCharCode(i),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(data, length) => toString(data, length)
);
});
@@ -187,7 +215,7 @@ describe('text encodings', () => {
max,
() => false,
(i) => stringFromCodePoint(i),
(input, target) => decoder.decode(fromByteString(encode(input)), target),
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
(data, length) => toString(data, length)
);
});
@@ -196,7 +224,7 @@ describe('text encodings', () => {
it('0xFEFF(BOM)', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(5);
const utf8Data = fromByteString(encode(String.fromCharCode(0xFEFF)));
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
const length = decoder.decode(utf8Data, target);
assert.equal(length, 0);
decoder.clear();
@@ -207,7 +235,7 @@ describe('text encodings', () => {
const decoder = new Utf8ToUtf32();
const target = new Uint32Array(500);
for (let i = 0; i < TEST_STRINGS.length; ++i) {
const utf8Data = fromByteString(encode(TEST_STRINGS[i]));
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
const length = decoder.decode(utf8Data, target);
assert.equal(toString(target, length), TEST_STRINGS[i]);
decoder.clear();
+30 -30
View File
@@ -9,30 +9,30 @@ import { Emitter } from 'common/Event';
declare const setTimeout: (handler: () => void, timeout?: number) => void;
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
* Enable flow control to avoid this limit and make sure that your backend correctly
* propagates this to the underlying pty. (see docs for further instructions)
* Since this limit is meant as a safety parachute to prevent browser crashs,
* it is set to a very high number. Typically xterm.js gets unresponsive with
* a 100 times lower number (>500 kB).
*/
const DISCARD_WATERMARK = 50000000; // ~50 MB
/**
* The max number of ms to spend on writes before allowing the renderer to
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
* depends on the time it takes for the renderer to draw the frame.
*/
const WRITE_TIMEOUT_MS = 12;
/**
* Threshold of max held chunks in the write buffer, that were already processed.
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
* memory consumption by data thats not used anymore.
*/
const WRITE_BUFFER_LENGTH_THRESHOLD = 50;
const enum Constants {
/**
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
* Enable flow control to avoid this limit and make sure that your backend correctly
* propagates this to the underlying pty. (see docs for further instructions)
* Since this limit is meant as a safety parachute to prevent browser crashs,
* it is set to a very high number. Typically xterm.js gets unresponsive with
* a 100 times lower number (>500 kB).
*/
DISCARD_WATERMARK = 50000000, // ~50 MB
/**
* The max number of ms to spend on writes before allowing the renderer to
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
* depends on the time it takes for the renderer to draw the frame.
*/
WRITE_TIMEOUT_MS = 12,
/**
* Threshold of max held chunks in the write buffer, that were already processed.
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
* memory consumption by data thats not used anymore.
*/
WRITE_BUFFER_LENGTH_THRESHOLD = 50
}
export class WriteBuffer extends Disposable {
private _writeBuffer: (string | Uint8Array)[] = [];
@@ -133,7 +133,7 @@ export class WriteBuffer extends Disposable {
}
public write(data: string | Uint8Array, callback?: () => void): void {
if (this._pendingData > DISCARD_WATERMARK) {
if (this._pendingData > Constants.DISCARD_WATERMARK) {
throw new Error('write data discarded, use flow control to avoid losing data');
}
@@ -169,7 +169,7 @@ export class WriteBuffer extends Disposable {
* effectively lowering the redrawing needs, schematically:
*
* macroTask _innerWrite:
* if (performance.now() - (lastTime | 0) < WRITE_TIMEOUT_MS):
* if (performance.now() - (lastTime | 0) < Constants.WRITE_TIMEOUT_MS):
* schedule microTask _innerWrite(lastTime)
* else:
* schedule macroTask _innerWrite(0)
@@ -218,7 +218,7 @@ 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 >= WRITE_TIMEOUT_MS
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS
? setTimeout(() => this._innerWrite(0, r))
: this._innerWrite(startTime, r);
@@ -235,7 +235,7 @@ export class WriteBuffer extends Disposable {
* current microtask queue (executed before setTimeout).
*/
// const continuation: (r: boolean) => void = performance.now() - startTime >=
// WRITE_TIMEOUT_MS
// Constants.WRITE_TIMEOUT_MS
// ? r => setTimeout(() => this._innerWrite(0, r))
// : r => this._innerWrite(startTime, r);
@@ -255,14 +255,14 @@ export class WriteBuffer extends Disposable {
this._bufferOffset++;
this._pendingData -= data.length;
if (performance.now() - startTime >= WRITE_TIMEOUT_MS) {
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
break;
}
}
if (this._writeBuffer.length > this._bufferOffset) {
// Allow renderer to catch up before processing the next batch
// trim already processed chunks if we are above threshold
if (this._bufferOffset > WRITE_BUFFER_LENGTH_THRESHOLD) {
if (this._bufferOffset > Constants.WRITE_BUFFER_LENGTH_THRESHOLD) {
this._writeBuffer = this._writeBuffer.slice(this._bufferOffset);
this._callbacks = this._callbacks.slice(this._bufferOffset);
this._bufferOffset = 0;
+354 -255
View File
@@ -5,7 +5,7 @@
import { assert } from 'chai';
import { ApcParser, ApcHandler } from 'common/parser/ApcParser';
import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder';
import { IApcHandler } from 'common/parser/Types';
import { IApcHandler, IFunctionIdentifier } from 'common/parser/Types';
function toUtf32(s: string): Uint32Array {
const utf32 = new Uint32Array(s.length);
@@ -14,31 +14,53 @@ function toUtf32(s: string): Uint32Array {
return utf32.subarray(0, length);
}
class TestHandler implements IApcHandler {
public id: number;
public output: [string, number, string, (boolean | string)?][];
public msg: string;
public returnFalse: boolean;
constructor(
id: number,
output: [string, number, string, (boolean | string)?][],
msg: string,
returnFalse: boolean = false
) {
this.id = id;
this.output = output;
this.msg = msg;
this.returnFalse = returnFalse;
function identifier(id: IFunctionIdentifier): number {
let res = 0;
if (id.prefix) {
if (id.prefix.length > 1) {
throw new Error('only one byte as prefix supported');
}
res = id.prefix.charCodeAt(0);
if (res && 0x3c > res || res > 0x3f) {
throw new Error('prefix must be in range 0x3c .. 0x3f');
}
}
if (id.intermediates) {
if (id.intermediates.length > 2) {
throw new Error('only two bytes as intermediates are supported');
}
for (let i = 0; i < id.intermediates.length; ++i) {
const intermediate = id.intermediates.charCodeAt(i);
if (0x20 > intermediate || intermediate > 0x2f) {
throw new Error('intermediate must be in range 0x20 .. 0x2f');
}
res <<= 8;
res |= intermediate;
}
}
if (id.final.length !== 1) {
throw new Error('final must be a single byte');
}
const finalCode = id.final.charCodeAt(0);
if (0x40 > finalCode || finalCode > 0x7e) {
throw new Error('final must be in range 0x40 .. 0x7e');
}
res <<= 8;
res |= finalCode;
return res;
}
class TestHandler implements IApcHandler {
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
public start(): void {
this.output.push([this.msg, this.id, 'START']);
this.output.push([this.msg, 'START']);
}
public put(data: Uint32Array, start: number, end: number): void {
this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]);
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
}
public end(success: boolean): boolean {
this.output.push([this.msg, this.id, 'END', success]);
this.output.push([this.msg, 'END', success]);
if (this.returnFalse) {
return false;
}
@@ -48,173 +70,105 @@ class TestHandler implements IApcHandler {
describe('ApcParser', () => {
let parser: ApcParser;
let reports: [number, string, (boolean | string | undefined)?][] = [];
let reports: any[] = [];
beforeEach(() => {
reports = [];
parser = new ApcParser();
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
reports.push([id, action, data]);
});
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
});
describe('identifier parsing', () => {
it('single character identifier', () => {
parser.start();
const data = toUtf32('Gf=100,a=T;payload');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined], // 0x47 = 'G'
[0x47, 'PUT', 'f=100,a=T;payload'],
[0x47, 'END', true]
]);
});
it('identifier with no payload', () => {
parser.start();
const data = toUtf32('G');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined],
[0x47, 'END', true]
]);
});
it('identifier with chunked payload', () => {
parser.start();
let data = toUtf32('Gf=100');
parser.put(data, 0, data.length);
data = toUtf32(',a=T');
parser.put(data, 0, data.length);
data = toUtf32(';payload');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [
[0x47, 'START', undefined],
[0x47, 'PUT', 'f=100'],
[0x47, 'PUT', ',a=T'],
[0x47, 'PUT', ';payload'],
[0x47, 'END', true]
]);
});
it('empty APC sequence', () => {
parser.start();
parser.end(true);
assert.deepEqual(reports, []);
});
});
describe('handler registration', () => {
let handlerReports: [string, number, string, (boolean | string)?][];
beforeEach(() => {
handlerReports = [];
});
it('registerHandler for specific identifier', () => {
const G_CODE = 0x47; // 'G'
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Gf=100,a=T;imagedata');
it('setApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['kitty', G_CODE, 'START'],
['kitty', G_CODE, 'PUT', 'f=100,a=T;imagedata'],
['kitty', G_CODE, 'END', true]
]);
assert.deepEqual(reports, []);
});
it('unregistered identifier falls back', () => {
const G_CODE = 0x47; // 'G'
const X_CODE = 0x58; // 'X'
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Xsome data');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, []);
assert.deepEqual(reports, [
[X_CODE, 'START', undefined],
[X_CODE, 'PUT', 'some data'],
[X_CODE, 'END', true]
// messages from TestHandler
['th', 'START'],
['th', 'PUT', 'Here comes'],
['th', 'PUT', 'the mouse!'],
['th', 'END', true]
]);
});
it('clearHandler removes handler', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.clearHandler(G_CODE);
parser.start();
const data = toUtf32('Gf=100');
it('clearApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
parser.clearHandler(identifier({intermediates: '+', final: 'p'}));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, []);
assert.deepEqual(reports, [
[G_CODE, 'START', undefined],
[G_CODE, 'PUT', 'f=100'],
[G_CODE, 'END', true]
// messages from fallback handler
[identifier({intermediates: '+', final: 'p'}), 'START', undefined],
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'Here comes'],
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'the mouse!'],
[identifier({intermediates: '+', final: 'p'}), 'END', true]
]);
});
it('multiple handlers for same identifier', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
parser.start();
const data = toUtf32('Gdata');
it('addApcHandler', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2'));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler2', G_CODE, 'START'],
['handler1', G_CODE, 'START'],
['handler2', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'PUT', 'data'],
['handler2', G_CODE, 'END', true],
['handler1', G_CODE, 'END', false]
assert.deepEqual(reports, [
['th2', 'START'],
['th1', 'START'],
['th2', 'PUT', 'Here comes'],
['th1', 'PUT', 'Here comes'],
['th2', 'PUT', 'the mouse!'],
['th1', 'PUT', 'the mouse!'],
['th2', 'END', true],
['th1', 'END', false] // false due being already handled by th2!
]);
});
it('handler returning false allows fallthrough', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true));
parser.start();
const data = toUtf32('Gdata');
it('addApcHandler with return false', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler2', G_CODE, 'START'],
['handler1', G_CODE, 'START'],
['handler2', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'PUT', 'data'],
['handler2', G_CODE, 'END', true],
['handler1', G_CODE, 'END', true]
assert.deepEqual(reports, [
['th2', 'START'],
['th1', 'START'],
['th2', 'PUT', 'Here comes'],
['th1', 'PUT', 'Here comes'],
['th2', 'PUT', 'the mouse!'],
['th1', 'PUT', 'the mouse!'],
['th2', 'END', true],
['th1', 'END', true] // true since th2 indicated to keep bubbling
]);
});
it('dispose removes handler', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
disposable.dispose();
parser.start();
const data = toUtf32('Gdata');
it('dispose handlers', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(handlerReports, [
['handler1', G_CODE, 'START'],
['handler1', G_CODE, 'PUT', 'data'],
['handler1', G_CODE, 'END', true]
assert.deepEqual(reports, [
['th1', 'START'],
['th1', 'PUT', 'Here comes'],
['th1', 'PUT', 'the mouse!'],
['th1', 'END', true]
]);
});
});
describe('ApcHandler convenience class', () => {
describe('ApcHandlerFactory', () => {
const TEST_PAYLOAD_LIMIT = 100;
const CHUNK_SIZE = 10;
let originalPayloadLimit: number;
@@ -231,133 +185,278 @@ describe('ApcParser', () => {
});
it('should be called once on end(true)', () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('Gf=100');
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(',a=T;payload');
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(results, [[G_CODE, 'f=100,a=T;payload']]);
assert.deepEqual(reports, ['Here comes the mouse!']);
});
it('should not be called on end(false)', () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
const data = toUtf32('Gf=100,a=T;payload');
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(false);
assert.deepEqual(results, []);
assert.deepEqual(reports, []);
});
it('should handle payload up to limit', function(): void {
this.timeout(30000);
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('G');
it('should be disposable', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('A'.repeat(CHUNK_SIZE));
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
data = toUtf32('some other');
parser.put(data, 0, data.length);
data = toUtf32(' data');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
});
it('should respect return false', () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return false; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
});
it('should work up to payload limit', function(): void {
this.timeout(30000);
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
const data = toUtf32('A'.repeat(CHUNK_SIZE));
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
parser.put(data, 0, data.length);
}
parser.end(true);
assert.deepEqual(results, [[G_CODE, 'A'.repeat(TEST_PAYLOAD_LIMIT)]]);
assert.deepEqual(reports, ['A'.repeat(TEST_PAYLOAD_LIMIT)]);
});
it('should abort for payload over limit', function(): void {
it('should abort for payload limit +1', function(): void {
this.timeout(30000);
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
results.push([G_CODE, data]);
return true;
}));
parser.start();
let data = toUtf32('G');
parser.put(data, 0, data.length);
data = toUtf32('A'.repeat(CHUNK_SIZE));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('A'.repeat(CHUNK_SIZE));
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
parser.put(data, 0, data.length);
}
data = toUtf32('A');
parser.put(data, 0, data.length);
parser.end(true);
assert.deepEqual(results, []);
});
});
describe('reset behavior', () => {
let handlerReports: [string, number, string, (boolean | string)?][];
beforeEach(() => {
handlerReports = [];
});
it('reset during payload cleans up handlers', () => {
const G_CODE = 0x47;
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
parser.start();
const data = toUtf32('Gf=100');
parser.put(data, 0, data.length);
parser.reset();
assert.deepEqual(handlerReports, [
['kitty', G_CODE, 'START'],
['kitty', G_CODE, 'PUT', 'f=100'],
['kitty', G_CODE, 'END', false]
]);
assert.deepEqual(reports, []);
});
});
});
class TestHandlerAsync implements IApcHandler {
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
public start(): void {
this.output.push([this.msg, 'START']);
}
public put(data: Uint32Array, start: number, end: number): void {
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
}
public async end(success: boolean): Promise<boolean> {
// simple sleep to check in tests whether ordering gets messed up
await Promise.resolve();
this.output.push([this.msg, 'END', success]);
if (this.returnFalse) {
return false;
}
return true;
}
}
async function unhookP(parser: ApcParser, success: boolean): Promise<void> {
let result: void | Promise<boolean>;
let prev: boolean | undefined;
while (result = parser.end(success, prev)) {
prev = await result;
}
}
describe('ApcParser - async tests', () => {
let parser: ApcParser;
let reports: [number, string, (boolean | string | undefined)?][] = [];
let reports: any[] = [];
beforeEach(() => {
reports = [];
parser = new ApcParser();
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
reports.push([id, action, data]);
});
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
});
async function endP(parser: ApcParser, success: boolean): Promise<void> {
let result: void | Promise<boolean>;
let prev: boolean | undefined;
while (result = parser.end(success, prev)) {
prev = await result;
}
}
describe('async ApcHandler', () => {
it('should handle async handler', async () => {
const G_CODE = 0x47;
const results: [number, string][] = [];
parser.registerHandler(G_CODE, new ApcHandler(async (data: string) => {
await new Promise(res => setTimeout(res, 10));
results.push([G_CODE, data]);
return true;
}));
parser.start();
const data = toUtf32('Gf=100,a=T');
parser.put(data, 0, data.length);
await endP(parser, true);
assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]);
describe('sync and async mixed', () => {
describe('sync | async | sync', () => {
it('first should run, cleanup action for others', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', false));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['s2', 'START'],
['a1', 'START'],
['s1', 'START'],
['s2', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['s2', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['s2', 'END', true],
['a1', 'END', false], // important: a1 before s1
['s1', 'END', false]
]);
});
it('all should run', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['s2', 'START'],
['a1', 'START'],
['s1', 'START'],
['s2', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['s2', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['s2', 'END', true],
['a1', 'END', true], // important: a1 before s1
['s1', 'END', true]
]);
});
});
describe('async | sync | async', () => {
it('first should run, cleanup action for others', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', false));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['a2', 'START'],
['s1', 'START'],
['a1', 'START'],
['a2', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['a2', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['a2', 'END', true],
['s1', 'END', false], // important: s1 between a2 .. a1
['a1', 'END', false]
]);
});
it('all should run', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', true));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32('the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [
// messages from TestHandler
['a2', 'START'],
['s1', 'START'],
['a1', 'START'],
['a2', 'PUT', 'Here comes'],
['s1', 'PUT', 'Here comes'],
['a1', 'PUT', 'Here comes'],
['a2', 'PUT', 'the mouse!'],
['s1', 'PUT', 'the mouse!'],
['a1', 'PUT', 'the mouse!'],
['a2', 'END', true],
['s1', 'END', true], // important: s1 between a2 .. a1
['a1', 'END', true]
]);
});
});
describe('ApcHandlerFactory', () => {
it('should be called once on end(true)', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, ['Here comes the mouse!']);
});
it('should not be called on end(false)', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, false);
assert.deepEqual(reports, []);
});
it('should be disposable', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return true; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
dispo.dispose();
parser.start(identifier({intermediates: '+', final: 'p'}));
data = toUtf32('some other');
parser.put(data, 0, data.length);
data = toUtf32(' data');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
});
it('should respect return false', async () => {
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return false; }));
parser.start(identifier({intermediates: '+', final: 'p'}));
let data = toUtf32('Here comes');
parser.put(data, 0, data.length);
data = toUtf32(' the mouse!');
parser.put(data, 0, data.length);
await unhookP(parser, true);
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
});
});
});
});
+43 -91
View File
@@ -4,7 +4,7 @@
*/
import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, ISubParserStackState } from 'common/parser/Types';
import { ApcState, ParserConstants } from 'common/parser/Constants';
import { ParserConstants } from 'common/parser/Constants';
import { utf32ToString } from 'common/input/TextDecoder';
import { IDisposable } from 'common/Types';
@@ -19,10 +19,9 @@ const EMPTY_HANDLERS: IApcHandler[] = [];
* The identifier is the character code of the first byte after ESC _.
*/
export class ApcParser implements IApcParser {
private _state = ApcState.START;
private _active = EMPTY_HANDLERS;
private _id = -1;
private _handlers: IHandlerCollection<IApcHandler> = Object.create(null);
private _active = EMPTY_HANDLERS;
private _ident: number = 0;
private _handlerFb: ApcFallbackHandlerType = () => { };
private _stack: ISubParserStackState = {
paused: false,
@@ -64,22 +63,24 @@ export class ApcParser implements IApcParser {
}
public reset(): void {
// force cleanup handlers if payload was already sent
if (this._state === ApcState.PAYLOAD) {
// force cleanup handlers
if (this._active.length) {
for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) {
this._active[j].end(false);
}
}
this._stack.paused = false;
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
this._ident = 0;
}
private _start(): void {
this._active = this._handlers[this._id] || EMPTY_HANDLERS;
public start(ident: number): void {
// always reset leftover handlers
this.reset();
this._ident = ident;
this._active = this._handlers[ident] || EMPTY_HANDLERS;
if (!this._active.length) {
this._handlerFb(this._id, 'START');
this._handlerFb(this._ident, 'START');
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].start();
@@ -87,9 +88,9 @@ export class ApcParser implements IApcParser {
}
}
private _put(data: Uint32Array, start: number, end: number): void {
public put(data: Uint32Array, start: number, end: number): void {
if (!this._active.length) {
this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end));
this._handlerFb(this._ident, 'PUT', utf32ToString(data, start, end));
} else {
for (let j = this._active.length - 1; j >= 0; j--) {
this._active[j].put(data, start, end);
@@ -97,100 +98,51 @@ export class ApcParser implements IApcParser {
}
}
public start(): void {
// always reset leftover handlers
this.reset();
this._state = ApcState.ID;
}
/**
* Put data to current APC command.
* For APC, the first character is used as the identifier.
* Format: ESC _ <identifier><payload> ESC \
* Example: ESC _ G f=100,a=T;... ESC \ (Kitty graphics, identifier='G')
*/
public put(data: Uint32Array, start: number, end: number): void {
if (this._state === ApcState.ABORT) {
return;
}
if (this._state === ApcState.ID) {
// The first character is the identifier
if (start < end) {
this._id = data[start++];
this._state = ApcState.PAYLOAD;
this._start();
}
}
if (this._state === ApcState.PAYLOAD && end - start > 0) {
this._put(data, start, end);
}
}
/**
* Indicates end of an APC command.
* Whether the APC got aborted or finished normally
* is indicated by `success`.
*/
public end(success: boolean, promiseResult: boolean = true): void | Promise<boolean> {
if (this._state === ApcState.START) {
return;
}
// do nothing if command was faulty
if (this._state !== ApcState.ABORT) {
// if we are still in ID state and get an early end
// means we got an empty APC sequence with no identifier,
// which is invalid - just reset and return
if (this._state === ApcState.ID) {
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
return;
if (!this._active.length) {
this._handlerFb(this._ident, 'END', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!this._active.length) {
this._handlerFb(this._id, 'END', success);
} else {
let handlerResult: boolean | Promise<boolean> = false;
let j = this._active.length - 1;
let fallThrough = false;
if (this._stack.paused) {
j = this._stack.loopPosition - 1;
handlerResult = promiseResult;
fallThrough = this._stack.fallThrough;
this._stack.paused = false;
}
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].end(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers
// we always have to call .end for proper cleanup,
// here we use `success` to indicate whether a handler should execute
if (!fallThrough && handlerResult === false) {
for (; j >= 0; j--) {
handlerResult = this._active[j].end(false);
if (handlerResult instanceof Promise) {
handlerResult = this._active[j].end(success);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
this._stack.fallThrough = false;
return handlerResult;
}
}
j--;
}
// cleanup left over handlers (fallThrough for async)
for (; j >= 0; j--) {
handlerResult = this._active[j].end(false);
if (handlerResult instanceof Promise) {
this._stack.paused = true;
this._stack.loopPosition = j;
this._stack.fallThrough = true;
return handlerResult;
}
}
}
this._active = EMPTY_HANDLERS;
this._id = -1;
this._state = ApcState.START;
this._ident = 0;
}
}
+4 -12
View File
@@ -21,9 +21,11 @@ export const enum ParserState {
DCS_IGNORE = 11,
DCS_INTERMEDIATE = 12,
DCS_PASSTHROUGH = 13,
APC_STRING = 14,
APC_ENTRY = 14,
APC_INTERMEDIATE = 15,
APC_PASSTHROUGH = 16,
// Number of states, meaning LAST_STATE + 1.
STATE_LENGTH = 15
STATE_LENGTH = 17
}
/**
@@ -60,16 +62,6 @@ export const enum OscState {
ABORT = 3
}
/**
* Internal states of ApcParser.
*/
export const enum ApcState {
START = 0,
ID = 1,
PAYLOAD = 2,
ABORT = 3
}
// payload limit for OSC and DCS
export const enum ParserConstants {
PAYLOAD_LIMIT = 10000000
+440 -42
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types';
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType } from 'common/parser/Types';
import { EscapeSequenceParser, TransitionTable, VT500_TRANSITION_TABLE } from 'common/parser/EscapeSequenceParser';
import { assert } from 'chai';
import { StringToUtf32, stringFromCodePoint, utf32ToString } from 'common/input/TextDecoder';
@@ -12,6 +12,7 @@ import { Params } from 'common/parser/Params';
import { OscHandler } from 'common/parser/OscParser';
import { IDisposable } from 'common/Types';
import { DcsHandler } from 'common/parser/DcsParser';
import { ApcHandler } from 'common/parser/ApcParser';
function r(a: number, b: number): string[] {
@@ -145,6 +146,15 @@ const testTerminal: any = {
},
actionDCSUnhook(success: boolean): void {
this.calls.push(['dcs unhook', success]);
},
actionAPCStart(): void {
this.calls.push(['apc start']);
},
actionAPCPut(s: string): void {
this.calls.push(['apc put', s]);
},
actionAPCEnd(success: boolean): void {
this.calls.push(['apc end', success]);
}
};
@@ -163,7 +173,9 @@ const states: number[] = [
ParserState.DCS_IGNORE,
ParserState.DCS_INTERMEDIATE,
ParserState.DCS_PASSTHROUGH,
ParserState.APC_STRING
ParserState.APC_ENTRY,
ParserState.APC_INTERMEDIATE,
ParserState.APC_PASSTHROUGH
];
let state: any;
@@ -198,6 +210,18 @@ testParser.setDcsHandlerFallback((collectAndFlag, action, payload) => {
testTerminal.actionDCSUnhook(payload);
}
});
testParser.setApcHandlerFallback((collectAndFlag, action, payload) => {
switch (action) {
case 'START':
testTerminal.actionAPCStart(payload);
break;
case 'PUT':
testTerminal.actionAPCPut(payload);
break;
case 'END':
testTerminal.actionAPCEnd(payload);
}
});
// translate string based parse calls into typed array based
@@ -278,7 +302,7 @@ describe('EscapeSequenceParser', () => {
const exceptions: { [key: number]: { [key: string]: any[] } } = {
8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING
13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH
14: { '\x18': [], '\x1a': [] } // abort APC_STRING
16: { '\x18': [['apc end', false]], '\x1a': [['apc end', false]] } // abort APC_PASSTHROUGH
};
parser.reset();
testTerminal.clear();
@@ -725,20 +749,6 @@ describe('EscapeSequenceParser', () => {
}
}
});
it('trans ANYWHERE/ESCAPE --> APC_STRING', () => {
parser.reset();
// C0 (ESC _)
parse(parser, '\x1b_');
assert.equal(parser.currentState, ParserState.APC_STRING);
parser.reset();
// C1
for (state in states) {
parser.currentState = state;
parse(parser, '\x9f');
assert.equal(parser.currentState, ParserState.APC_STRING);
parser.reset();
}
});
it('state SOS_PM_STRING ignore rules', () => {
parser.reset();
let ignored = r(0x00, 0x18);
@@ -1035,6 +1045,134 @@ describe('EscapeSequenceParser', () => {
parser.reset();
testTerminal.clear();
});
it('state APC_ENTRY', () => {
parser.reset();
// C0
parse(parser, '\x1b_');
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
// C1
for (state in states) {
parser.currentState = state;
parse(parser, '\x9f');
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
}
});
it('state APC_ENTRY ignore rules', () => {
parser.reset();
const ignored = [
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
for (let i = 0; i < ignored.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, ignored[i]);
assert.equal(parser.currentState, ParserState.APC_ENTRY);
parser.reset();
}
});
it('trans APC_ENTRY --> APC_INTERMEDIATE with collect action', () => {
parser.reset();
const collect = r(0x20, 0x30);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
assert.equal(parser.collect, collect[i]);
parser.reset();
}
});
it('trans APC_ENTRY --> APC_PASSTHROUGH with start', () => {
parser.reset();
testTerminal.clear();
const collect = r(0x30, 0x7f);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_ENTRY;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc start']]);
parser.reset();
testTerminal.clear();
}
});
it('trans APC_INTERMEDIATE --> APC_PASSTHROUGH with start', () => {
parser.reset();
testTerminal.clear();
const collect = r(0x30, 0x7f);
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_INTERMEDIATE;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc start']]);
parser.reset();
testTerminal.clear();
}
});
it('state APC_INTERMEDIATE ignore rules', () => {
parser.reset();
const ignored = [
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
for (let i = 0; i < ignored.length; ++i) {
parser.currentState = ParserState.APC_INTERMEDIATE;
parse(parser, ignored[i]);
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
parser.reset();
}
});
it('state APC_PASSTHROUGH put action', () => {
parser.reset();
testTerminal.clear();
let puts = r(0x08, 0x0e);
puts = puts.concat(r(0x20, 0x7f));
for (let i = 0; i < puts.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, puts[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([['apc put', puts[i]]]);
parser.reset();
testTerminal.clear();
}
});
it('state APC_PASSTHROUGH ignore rules', () => {
parser.reset();
testTerminal.clear();
let puts = r(0x00, 0x08);
puts = puts.concat(r(0x0e, 0x18));
puts.push('\x19');
puts = puts.concat(r(0x1c, 0x20));
puts.push('\x7f');
for (let i = 0; i < puts.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, puts[i]);
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
testTerminal.compare([]);
parser.reset();
testTerminal.clear();
}
});
it('trans APC_PASSTHROUGH --> GROUND|ESCAPE with end action', () => {
parser.reset();
testTerminal.clear();
const collect = ['\x9c', '\x18', '\x1a']; // ST - true, CAN & SUB - false
for (let i = 0; i < collect.length; ++i) {
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, collect[i]);
assert.equal(parser.currentState, ParserState.GROUND);
testTerminal.compare([['apc end', collect[i] === '\x9c']]);
parser.reset();
testTerminal.clear();
}
// ESC end
parser.currentState = ParserState.APC_PASSTHROUGH;
parse(parser, '\x1b');
assert.equal(parser.currentState, ParserState.ESCAPE);
testTerminal.compare([['apc end', true]]);
parser.reset();
testTerminal.clear();
});
});
function test(s: string, value: any, noReset: any): void {
@@ -1100,6 +1238,42 @@ describe('EscapeSequenceParser', () => {
['print', 'defg']
], null);
});
it('single APC', () => {
test('\x1b_X3+$aäbc;däe\x9c', [
['apc start'],
['apc put', '3+$aäbc;däe'],
['apc end', true]
], null);
});
it('multi APC', () => {
test('\x1b_Xabc;de', [
['apc start'],
['apc put', 'abc;de']
], null);
testTerminal.clear();
test('abc\x9c', [
['apc put', 'abc'],
['apc end', true]
], true);
});
it('print + DCS(C1) + print', () => {
test('abc\x9fAbc;de\x9cxyz', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', true],
['print', 'xyz']
], null);
});
it('print + DCS(C0) + print', () => {
test('abc\x1b_Abc;de\x1b\\xyz', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', true],
['print', 'xyz']
], null);
});
it('error recovery', () => {
test('\x1b[1€abcdefg\x9b<;c', [
['print', 'abcdefg'],
@@ -1146,6 +1320,22 @@ describe('EscapeSequenceParser', () => {
['dcs unhook', false] // false for abort
], null);
});
it('CAN should abort APC', () => {
test('abc\x9fXbc;de\x18', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', false] // false for abort
], null);
});
it('SUB should abort APC', () => {
test('abc\x9fXbc;de\x1a', [
['print', 'abc'],
['apc start'],
['apc put', 'bc;de'],
['apc end', false] // false for abort
], null);
});
it('CAN should abort OSC', () => {
test('\x1b]0;abc123€öäü\x18', [
['osc', '0;abc123€öäü, success: false']
@@ -1210,6 +1400,7 @@ describe('EscapeSequenceParser', () => {
const exe: string[] = [];
const osc: [number, string][] = [];
const dcs: ([string] | [string, string] | [string, string, ParamsArray, number])[] = [];
const apc: [string, string][] = [];
function clearAccu(): void {
print = '';
esc.length = 0;
@@ -1217,6 +1408,7 @@ describe('EscapeSequenceParser', () => {
exe.length = 0;
osc.length = 0;
dcs.length = 0;
apc.length = 0;
}
beforeEach(() => {
parser2 = new TestEscapeSequenceParser();
@@ -1574,6 +1766,95 @@ describe('EscapeSequenceParser', () => {
assert.deepEqual(dcsCustom, [['A', [1, 2, 3], 'abc']]);
});
});
it('APC handler', () => {
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, {
start: function (): void {
apc.push(['start', '']);
},
put: function (data: Uint32Array, start: number, end: number): void {
let s = '';
for (let i = start; i < end; ++i) {
s += stringFromCodePoint(data[i]);
}
apc.push(['put', s]);
},
end: function (success): boolean {
apc.push(['end', success ? '1' : '0']);
return true;
}
});
parse(parser2, '\x1b_+pabc');
parse(parser2, ';de\x9c');
assert.deepEqual(apc, [
['start', ''],
['put', 'abc'], ['put', ';de'],
['end', '1']
]);
parser2.clearApcHandler({ intermediates: '+', final: 'p' });
parser2.clearApcHandler({ intermediates: '+', final: 'p' }); // should not throw
clearAccu();
parse(parser2, '\x1b_+pabc');
parse(parser2, ';de\x9c');
assert.deepEqual(apc, []);
});
describe('APC custom handlers', () => {
const APC_INPUT = '\x1b_+pabc\x1b\\';
it('Prevent fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['B', 'abc']]);
});
it('Allow fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['B', 'abc'], ['A', 'abc']]);
});
it('Multiple custom handlers fallback once', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['C', 'abc'], ['B', 'abc']]);
});
it('Multiple custom handlers no fallback', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return true; }));
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['C', 'abc']]);
});
it('Execution order should go from latest handler down to the original', () => {
const order: number[] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(1); return true; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(2); return false; }));
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(3); return false; }));
parse(parser2, APC_INPUT);
assert.deepEqual(order, [3, 2, 1]);
});
it('Dispose should work', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
dispo.dispose();
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['A', 'abc']]);
});
it('Should not corrupt the parser when dispose is called twice', () => {
const apcCustom: [string, string][] = [];
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
dispo.dispose();
dispo.dispose();
parse(parser2, APC_INPUT);
assert.deepEqual(apcCustom, [['A', 'abc']]);
});
});
it('ERROR handler', () => {
let errorState: IParsingState | null = null;
parser2.setErrorHandler(function (state: IParsingState): IParsingState {
@@ -1625,15 +1906,22 @@ describe('EscapeSequenceParser', () => {
assert.throws(() => { parser.identifier({ final: '\x7f' }); }, 'final must be in range 64 .. 126');
assert.throws(() => { parser.identifier({ final: 'zz' }); }, 'final must be a single byte');
});
it('final ESC range 0x30 .. 0x7e, one byte', () => {
it('final ESC + APC range 0x30 .. 0x7e, one byte', () => {
for (let i = 0x30; i <= 0x7e; ++i) {
const final = String.fromCharCode(i);
let handler: IDisposable | undefined;
assert.doesNotThrow(() => { handler = parser.registerEscHandler({ final }, () => true); }, 'final must be in range 48 .. 126');
if (handler) handler.dispose();
assert.doesNotThrow(
() => { handler = parser.registerApcHandler({ final }, {start: () => {}, put: ()=>{}, end: ()=>true}); },
'final must be in range 48 .. 126'
);
if (handler) handler.dispose();
}
assert.throws(() => { parser.registerEscHandler({ final: '\x2f' }, () => true); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerEscHandler({ final: '\x7f' }, () => true); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerApcHandler({ final: '\x2f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
assert.throws(() => { parser.registerApcHandler({ final: '\x7f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
});
it('id calculation - should stacking prefix -> intermediate -> final', () => {
assert.equal(parser.identToString(parser.identifier({ final: 'z' })), 'z');
@@ -1705,6 +1993,25 @@ describe('EscapeSequenceParser', () => {
]
);
});
it('APC', () => {
const callstack: any[] = [];
const h1 = parser.registerApcHandler({ final: 'z' }, new ApcHandler(data => { callstack.push(['z', data]); return true; }));
const h2 = parser.registerApcHandler({ intermediates: '!', final: 'z' }, new ApcHandler(data => { callstack.push(['!z', data]); return true; }));
const h3 = parser.registerApcHandler({ intermediates: '!!', final: 'z' }, new ApcHandler(data => { callstack.push(['!!z', data]); return true; }));
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
h1.dispose();
h2.dispose();
h3.dispose();
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
assert.deepEqual(
callstack,
[
['z', 'AB'],
['!z', 'AB'],
['!!z', 'AB'],
]
);
});
});
});
// TODO: error conditions and error recovery (not implemented yet in parser)
@@ -1758,9 +2065,9 @@ async function throwsAsync(fn: () => Promise<any>, message?: string | undefined)
}
describe('EscapeSequenceParser - async', () => {
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | FIN
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\FIN';
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | APC X abc ST | FIN
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1, APC X
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\\x1b_Xabc\x1b\\FIN';
let RESULT: any[];
let parser: TestEscapeSequenceParser;
const callstack: any[] = [];
@@ -1782,6 +2089,7 @@ describe('EscapeSequenceParser - async', () => {
['PRINT', '$>'],
['DCS a', ['xyz', [1, 2]]],
['OSC 1', 'foo=bar'],
['APC X', 'abc'],
['PRINT', 'FIN']
];
parser = new TestEscapeSequenceParser();
@@ -1805,6 +2113,7 @@ describe('EscapeSequenceParser - async', () => {
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
parser.registerOscHandler(1, new OscHandler(data => { callstack.push(['OSC 1', data]); return true; }));
parser.registerDcsHandler({ final: 'a' }, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => { callstack.push(['APC X', data]); return true; }));
});
it('sync handlers keep being parsed in sync mode', () => {
@@ -1840,6 +2149,7 @@ describe('EscapeSequenceParser - async', () => {
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
parser.registerOscHandler(1, new OscHandler(async data => { callstack.push(['OSC 1', data]); return true; }));
parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['APC X', data]); return true; }));
});
it('sync parse call does not work anymore', () => {
@@ -1876,7 +2186,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('correct result on chunked awaited parse calls', async () => {
@@ -1903,6 +2214,7 @@ describe('EscapeSequenceParser - async', () => {
['PRINT', '>'],
['DCS a', ['xyz', [1, 2]]],
['OSC 1', 'foo=bar'],
['APC X', 'abc'],
['PRINT', 'F'],
['PRINT', 'I'],
['PRINT', 'N']
@@ -1921,7 +2233,8 @@ describe('EscapeSequenceParser - async', () => {
[0, ParserStackType.ESC, 0],
[0, ParserStackType.CSI, 0],
[0, ParserStackType.DCS, 0],
[0, ParserStackType.OSC, 0]
[0, ParserStackType.OSC, 0],
[0, ParserStackType.APC, 0],
]);
});
it('multiple async SGR handlers', async () => {
@@ -1941,7 +2254,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -1954,7 +2268,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -1972,7 +2287,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 1],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -1985,7 +2301,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('multiple async ESC handlers', async () => {
@@ -2003,7 +2320,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2016,7 +2334,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2033,7 +2352,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2046,7 +2366,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('sync/async SGR mixed', async () => {
@@ -2071,7 +2392,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 2],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// dispose SGR2 (sync one)
@@ -2092,7 +2414,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 1],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// dispose SGR3 (async one)
@@ -2105,7 +2428,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
});
it('multiple async OSC handlers', async () => {
@@ -2123,7 +2447,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2136,7 +2461,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2153,7 +2479,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2166,7 +2493,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
@@ -2185,7 +2513,8 @@ describe('EscapeSequenceParser - async', () => {
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2198,7 +2527,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
@@ -2215,7 +2545,8 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
@@ -2228,7 +2559,74 @@ describe('EscapeSequenceParser - async', () => {
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0]
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
it('multiple async APC handlers', async () => {
// register with fallback
const APC2 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return false; }));
await parseP(parser, INPUT);
for (let i = 0; i < callstack.length; ++i) {
const entry = callstack[i];
if (entry[0] === '2# APC X') assert.equal(callstack[i + 1][0], 'APC a', 'Should fallback to original handler');
}
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
APC2.dispose();
await parseP(parser, INPUT);
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// register without fallback
const APC22 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return true; }));
await parseP(parser, INPUT);
for (let i = 0; i < callstack.length; ++i) {
const entry = callstack[i];
if (entry[0] === '2# APC X') assert.notEqual(callstack[i + 1][0], 'APC X', 'Should fallback to original handler');
}
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
// after dispose we should be back to RESULT
APC22.dispose();
await parseP(parser, INPUT);
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
evalStackSaves(parser.trackedStack, [
[6, ParserStackType.CSI, 0],
[15, ParserStackType.ESC, 0],
[20, ParserStackType.ESC, 0],
[27, ParserStackType.CSI, 0],
[41, ParserStackType.DCS, 0],
[54, ParserStackType.OSC, 0],
[62, ParserStackType.APC, 0],
]);
clearAccu();
});
+151 -62
View File
@@ -12,6 +12,24 @@ import { OscParser } from 'common/parser/OscParser';
import { DcsParser } from 'common/parser/DcsParser';
import { ApcParser } from 'common/parser/ApcParser';
/**
* VT commands done by the parser
*/
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
/**
* Table values are generated like this:
* index: currentState << TableValue.INDEX_STATE_SHIFT | charCode
@@ -77,7 +95,9 @@ const NON_ASCII_PRINTABLE = 0xA0;
* Taken from https://vt100.net/emu/dec_ansi_parser.
*/
export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
const table: TransitionTable = new TransitionTable(4095);
// table size:
// (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1
const table: TransitionTable = new TransitionTable(4257);
// range macro for byte
const BYTE_VALUES = 256;
@@ -105,7 +125,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC
table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC
table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // SOS, PM
table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC
table.add(0x9f, state, ParserAction.CLEAR, ParserState.APC_ENTRY); // APC
table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI
table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS
}
@@ -136,11 +156,20 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x9c, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.GROUND);
table.add(0x7f, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING);
// apc
table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING);
table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND);
table.add(0x5f, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.APC_ENTRY);
table.addMany(EXECUTABLES, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
table.add(0x7f, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
table.addMany(r(0x20, 0x30), ParserState.APC_ENTRY, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
table.addMany(r(0x30, 0x7f), ParserState.APC_ENTRY, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
table.addMany(r(0x30, 0x7f), ParserState.APC_INTERMEDIATE, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
table.addMany(EXECUTABLES, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.APC_INTERMEDIATE, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
table.add(0x7f, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
table.addMany(PRINTABLES, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
table.addMany(EXECUTABLES, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
table.addMany(r(0x08, 0x0e), ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
table.add(0x7f, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_PASSTHROUGH, ParserAction.APC_END, ParserState.GROUND);
// csi entries
table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY);
table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND);
@@ -169,22 +198,18 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY);
table.addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
table.addMany(r(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM);
table.addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
table.addMany(r(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM);
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
table.addMany(r(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.addMany(r(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
@@ -200,7 +225,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
table.add(NON_ASCII_PRINTABLE, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
table.add(NON_ASCII_PRINTABLE, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
return table;
})();
@@ -247,6 +272,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
// handler lookup containers
protected _printHandler: PrintHandlerType;
protected _executeHandlers: { [flag: number]: ExecuteHandlerType };
// fast path for EXE bytes < 0x18
protected _executeHandlersArr: (ExecuteHandlerType | undefined)[];
protected _csiHandlers: IHandlerCollection<CsiHandlerType>;
protected _escHandlers: IHandlerCollection<EscHandlerType>;
protected readonly _oscParser: IOscParser;
@@ -290,11 +317,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
this._printHandler = this._printHandlerFb;
this._executeHandlers = Object.create(null);
this._executeHandlersArr = new Array(0x18).fill(undefined);
this._csiHandlers = Object.create(null);
this._escHandlers = Object.create(null);
this._register(toDisposable(() => {
this._csiHandlers = Object.create(null);
this._executeHandlers = Object.create(null);
this._executeHandlersArr = new Array(0x18).fill(undefined);
this._escHandlers = Object.create(null);
}));
this._oscParser = this._register(new OscParser());
@@ -381,10 +410,14 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
}
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
this._executeHandlers[flag.charCodeAt(0)] = handler;
const code = flag.charCodeAt(0);
this._executeHandlers[code] = handler;
if (code < 0x18) this._executeHandlersArr[code] = handler;
}
public clearExecuteHandler(flag: string): void {
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
const code = flag.charCodeAt(0);
if (this._executeHandlers[code]) delete this._executeHandlers[code];
if (code < 0x18) this._executeHandlersArr[code] = undefined;
}
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
this._executeHandlerFb = handler;
@@ -431,11 +464,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._oscParser.setHandlerFallback(handler);
}
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
return this._apcParser.registerHandler(ident, handler);
public registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable {
id.prefix = undefined; // APC does not support prefix byte
return this._apcParser.registerHandler(this._identifier(id, [0x30, 0x7e]), handler);
}
public clearApcHandler(ident: number): void {
this._apcParser.clearHandler(ident);
public clearApcHandler(id: IFunctionIdentifier): void {
id.prefix = undefined; // APC does not support prefix byte
this._apcParser.clearHandler(this._identifier(id, [0x30, 0x7e]));
}
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
this._apcParser.setHandlerFallback(handler);
@@ -462,8 +497,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this._oscParser.reset();
this._dcsParser.reset();
this._apcParser.reset();
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
// abort pending continuation from async handler
@@ -506,6 +540,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
* - OSC_STRING:OSC_PUT
* - DCS_PASSTHROUGH:DCS_PUT
*
* Additionally the following fast paths exist before the table lookup:
* - EXE bytes < 0x18 in non-payload states (avoids table lookup entirely)
* - 7-bit CSI sequences without intermediates (ESC [ params final)
*
* Note on asynchronous handler support:
* Any handler returning a promise will be treated as asynchronous.
* To keep the in-band blocking working for async handlers, `parse` pauses execution,
@@ -608,8 +646,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserStackType.OSC:
@@ -619,8 +656,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserStackType.APC:
@@ -630,8 +666,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
}
@@ -649,34 +684,90 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
for (let i = start; i < length; ++i) {
code = data[i];
// EXE fast-path: common control bytes (0x00-0x17) in non-payload states
if (code < 0x18 && this.currentState <= ParserState.CSI_IGNORE) {
(this._executeHandlersArr[code] ?? this._executeHandlerFb)(code);
this.precedingJoinState = 0;
continue;
}
// CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop
if (code === 0x1b
&& this.currentState < ParserState.OSC_STRING
&& i + 2 < length && data[i + 1] === 0x5b
) {
this._params.resetZdm();
this._collect = 0;
let k = i + 2;
let ch = data[k];
if (ch >= 0x3c && ch <= 0x3f) {
this._collect = ch;
k++;
}
let csiDone = false;
for (; k < length; k++) {
ch = data[k];
if (ch >= 0x30 && ch <= 0x39) {
this._params.addDigit(ch - 48);
} else if (ch === 0x3b) {
this._params.addParam(0);
} else if (ch === 0x3a) {
this._params.addSubParam(-1);
} else if (ch >= 0x40 && ch <= 0x7e) {
const handlers = this._csiHandlers[this._collect << 8 | ch];
let j = handlers ? handlers.length - 1 : -1;
for (; j >= 0; j--) {
handlerResult = handlers[j](this._params);
if (handlerResult === true) {
break;
} else if (handlerResult instanceof Promise) {
transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND;
this._preserveStack(ParserStackType.CSI, handlers, j, transition, k);
return handlerResult;
}
}
if (j < 0) {
this._csiHandlerFb(this._collect << 8 | ch, this._params);
}
this.precedingJoinState = 0;
i = k;
this.currentState = ParserState.GROUND;
csiDone = true;
break;
} else {
break;
}
}
if (!csiDone) {
i = k - 1;
this.currentState = ParserState.CSI_PARAM;
}
continue;
}
// normal transition & action lookup
transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)];
transition = this._transitions.table[
this.currentState << TableAccess.INDEX_STATE_SHIFT |
(code < NON_ASCII_PRINTABLE ? code : NON_ASCII_PRINTABLE)
];
switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) {
case ParserAction.PRINT:
// read ahead with loop unrolling
// Note: 0x20 (SP) is included, 0x7F (DEL) is excluded
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
}
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
this._printHandler(data, i, j);
i = j - 1;
break;
let c = i;
const l4 = length - 4;
while (c < l4
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
) {}
if (c >= l4) {
while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) {
c++;
}
}
this._printHandler(data, i, c);
i = c - 1;
break;
case ParserAction.EXECUTE:
if (this._executeHandlers[code]) this._executeHandlers[code]();
@@ -758,8 +849,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
this.precedingJoinState = 0;
break;
case ParserAction.CLEAR:
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
break;
case ParserAction.DCS_HOOK:
@@ -783,8 +873,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
@@ -808,22 +897,23 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
case ParserAction.APC_START:
this._apcParser.start();
this._apcParser.start(this._collect << 8 | code);
break;
case ParserAction.APC_PUT:
// inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c
// allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE
for (let j = i + 1; ; ++j) {
if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) {
this._apcParser.put(data, i, j);
i = j - 1;
break;
}
if (j < length && (
(data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE
)) continue;
this._apcParser.put(data, i, j);
i = j - 1;
break;
}
break;
case ParserAction.APC_END:
@@ -833,8 +923,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
return handlerResult;
}
if (code === 0x1b) transition |= ParserState.ESCAPE;
this._params.reset();
this._params.addParam(0); // ZDM
this._params.resetZdm();
this._collect = 0;
this.precedingJoinState = 0;
break;
+27 -8
View File
@@ -4,10 +4,16 @@
*/
import { IParams, ParamsArray } from 'common/parser/Types';
// max value supported for a single param/subparam (clamped to positive int32 range)
const MAX_VALUE = 0x7FFFFFFF;
// max allowed subparams for a single sequence (hardcoded limitation)
const MAX_SUBPARAMS = 256;
const enum Constants {
/**
* Max value supported for a single param/subparam (clamped to positive int32 range)
*/
MAX_VALUE = 0x7FFFFFFF,
/**
* Max allowed subparams for a single sequence (hardcoded limitation)
*/
MAX_SUBPARAMS = 256
}
/**
* Params storage class.
@@ -70,7 +76,7 @@ export class Params implements IParams {
* @param maxSubParamsLength max length of storable sub parameters
*/
constructor(public maxLength: number = 32, public maxSubParamsLength: number = 32) {
if (maxSubParamsLength > MAX_SUBPARAMS) {
if (maxSubParamsLength > Constants.MAX_SUBPARAMS) {
throw new Error('maxSubParamsLength must not be greater than 256');
}
this.params = new Int32Array(maxLength);
@@ -129,6 +135,19 @@ export class Params implements IParams {
this._digitIsSub = false;
}
/**
* Reset and add 0 as first param (ZDM).
*/
public resetZdm(): void {
this.length = 1;
this._subParamsLength = 0;
this._rejectDigits = false;
this._rejectSubDigits = false;
this._digitIsSub = false;
this._subParamsIdx[0] = 0;
this.params[0] = 0;
}
/**
* Add a parameter value.
* `Params` only stores up to `maxLength` parameters, any later
@@ -146,7 +165,7 @@ export class Params implements IParams {
throw new Error('values lesser than -1 are not allowed');
}
this._subParamsIdx[this.length] = this._subParamsLength << 8 | this._subParamsLength;
this.params[this.length++] = value > MAX_VALUE ? MAX_VALUE : value;
this.params[this.length++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
}
/**
@@ -168,7 +187,7 @@ export class Params implements IParams {
if (value < -1) {
throw new Error('values lesser than -1 are not allowed');
}
this._subParams[this._subParamsLength++] = value > MAX_VALUE ? MAX_VALUE : value;
this._subParams[this._subParamsLength++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
this._subParamsIdx[this.length - 1]++;
}
@@ -224,6 +243,6 @@ export class Params implements IParams {
const store = this._digitIsSub ? this._subParams : this.params;
const cur = store[length - 1];
store[length - 1] = ~cur ? Math.min(cur * 10 + value, MAX_VALUE) : value;
store[length - 1] = ~cur ? Math.min(cur * 10 + value, Constants.MAX_VALUE) : value;
}
}
+5 -4
View File
@@ -32,6 +32,7 @@ export interface IParams {
clone(): IParams;
toArray(): ParamsArray;
reset(): void;
resetZdm(): void;
addParam(value: number): void;
addSubParam(value: number): void;
hasSubParams(idx: number): boolean;
@@ -107,7 +108,7 @@ export type EscFallbackHandlerType = (identifier: number) => void;
/**
* EXECUTE handler types.
*/
export type ExecuteHandlerType = () => boolean;
export type ExecuteHandlerType = (ident?: number) => boolean;
export type ExecuteFallbackHandlerType = (ident: number) => void;
/**
@@ -219,8 +220,8 @@ export interface IEscapeSequenceParser extends IDisposable {
clearOscHandler(ident: number): void;
setOscHandlerFallback(handler: OscFallbackHandlerType): void;
registerApcHandler(ident: number, handler: IApcHandler): IDisposable;
clearApcHandler(ident: number): void;
registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable;
clearApcHandler(id: IFunctionIdentifier): void;
setApcHandlerFallback(handler: ApcFallbackHandlerType): void;
setErrorHandler(handler: (state: IParsingState) => IParsingState): void;
@@ -251,7 +252,7 @@ export interface IDcsParser extends ISubParser<IDcsHandler, DcsFallbackHandlerTy
}
export interface IApcParser extends ISubParser<IApcHandler, ApcFallbackHandlerType> {
start(): void;
start(ident: number): void;
end(success: boolean, promiseResult?: boolean): void | Promise<boolean>;
}
+2 -2
View File
@@ -34,7 +34,7 @@ export class ParserApi implements IParser {
public addOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this.registerOscHandler(ident, callback);
}
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._core.registerApcHandler(ident, callback);
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
return this._core.registerApcHandler(id, callback);
}
}
+6 -4
View File
@@ -10,8 +10,10 @@ import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services';
import { Emitter } from 'common/Event';
export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars
export const MINIMUM_ROWS = 1;
export const enum BufferServiceConstants {
MINIMUM_COLS = 2, // Less than 2 can mess with wide chars
MINIMUM_ROWS = 1
}
export class BufferService extends Disposable implements IBufferService {
public serviceBrand: any;
@@ -37,8 +39,8 @@ export class BufferService extends Disposable implements IBufferService {
@ILogService logService: ILogService
) {
super();
this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS);
this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
this.cols = Math.max(optionsService.rawOptions.cols || 0, BufferServiceConstants.MINIMUM_COLS);
this.rows = Math.max(optionsService.rawOptions.rows || 0, BufferServiceConstants.MINIMUM_ROWS);
this.buffers = this._register(new BufferSet(optionsService, this, logService));
this._register(this.buffers.onBufferActivate(e => {
this._onScroll.fire(e.activeBuffer.ydisp);
+107 -7
View File
@@ -4,11 +4,13 @@
*/
import { assert } from 'chai';
import { DecorationService } from './DecorationService';
import { DecorationLineCache, DecorationService } from './DecorationService';
import { IMarker } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
import { MockLogService } from 'common/TestUtils.test';
import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
import { Buffer } from 'common/buffer/Buffer';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
function createFakeMarker(line: number): IMarker {
return Object.freeze(new class extends Disposable {
@@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker {
}());
}
function createDecorationService(): DecorationService {
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
return new DecorationService(new MockLogService(), bufferService);
}
const fakeMarker: IMarker = createFakeMarker(1);
describe('DecorationService', () => {
it('should set isDisposed to true after dispose', () => {
const service = new DecorationService(new MockLogService());
const service = createDecorationService();
const decoration = service.registerDecoration({
marker: fakeMarker
});
@@ -35,7 +42,7 @@ describe('DecorationService', () => {
describe('forEachDecorationAtCell', () => {
it('should find decoration at its marker line', () => {
const service = new DecorationService(new MockLogService());
const service = createDecorationService();
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10
@@ -48,7 +55,7 @@ describe('DecorationService', () => {
});
it('should find decoration with height > 1 on subsequent lines', () => {
const service = new DecorationService(new MockLogService());
const service = createDecorationService();
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10,
@@ -74,7 +81,7 @@ describe('DecorationService', () => {
});
it('should not find decoration outside its x range', () => {
const service = new DecorationService(new MockLogService());
const service = createDecorationService();
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
x: 5,
@@ -99,11 +106,35 @@ describe('DecorationService', () => {
service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d));
assert.strictEqual(foundAtX8.length, 0);
});
it('should find multi-line decoration when single-line decorations exist on other lines', () => {
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
for (let i = 0; i < 100; i++) {
serviceWithBuffer.registerDecoration({
marker: buffer.addMarker(i),
width: 5
});
}
const multiLine = serviceWithBuffer.registerDecoration({
marker: buffer.addMarker(10),
width: 10,
height: 3
});
assert.ok(multiLine);
const found: typeof multiLine[] = [];
serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d));
assert.include(found, multiLine);
});
});
describe('getDecorationsAtCell', () => {
it('should find decoration with height > 1 on subsequent lines', () => {
const service = new DecorationService(new MockLogService());
const service = createDecorationService();
const decoration = service.registerDecoration({
marker: createFakeMarker(5),
width: 10,
@@ -117,4 +148,73 @@ describe('DecorationService', () => {
assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0);
});
});
describe('DecorationLineCache', () => {
it('should return undefined for lines with no indexed decorations', () => {
const cache = new DecorationLineCache();
assert.isUndefined(cache.getDecorationsOnLine(0));
});
});
describe('line index maintenance', () => {
it('should keep lookups correct after buffer trim', () => {
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(buffer.lines.length - 1);
const decoration = service.registerDecoration({ marker, width: 10 });
assert.ok(decoration);
buffer.lines.onTrimEmitter.fire(1);
const found: typeof decoration[] = [];
service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d));
assert.strictEqual(found.length, 1);
});
it('should remove decoration from line index when marker is trimmed off buffer', () => {
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(0);
const decoration = service.registerDecoration({ marker, width: 10 });
assert.ok(decoration);
buffer.lines.onTrimEmitter.fire(1);
assert.isTrue(marker.isDisposed);
assert.isTrue(decoration!.isDisposed);
const found: typeof decoration[] = [];
service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d));
assert.strictEqual(found.length, 0);
});
it('should keep multi-line decoration indexed after line insert', async () => {
const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 }));
const service = new DecorationService(new MockLogService(), bufferService);
const buffer = bufferService.buffer;
(buffer as Buffer).fillViewportRows();
const marker = buffer.addMarker(3);
const decoration = service.registerDecoration({ marker, width: 10, height: 3 });
assert.ok(decoration);
buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA));
await new Promise<void>(resolve => queueMicrotask(resolve));
const foundOnSpan: typeof decoration[] = [];
for (let line = marker.line; line < marker.line + 3; line++) {
service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d));
}
assert.include(foundOnSpan, decoration);
const foundOutsideSpan: typeof decoration[] = [];
service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d));
assert.strictEqual(foundOutsideSpan.length, 0);
});
});
});
+254 -20
View File
@@ -3,19 +3,19 @@
* @license MIT
*/
import type { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { MicrotaskTimer } from 'common/Async';
import { css } from 'common/Color';
import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle';
import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
import { SortedList } from 'common/SortedList';
import { IColor } from 'common/Types';
import { IColor, ICircularList } from 'common/Types';
import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm';
import { Emitter } from 'common/Event';
// Work variables to avoid garbage collection
let $xmin = 0;
let $xmax = 0;
let $ymin = 0;
let $ymax = 0;
export class DecorationService extends Disposable implements IDecorationService {
public serviceBrand: any;
@@ -27,6 +27,8 @@ export class DecorationService extends Disposable implements IDecorationService
*/
private readonly _decorations: SortedList<IInternalDecoration>;
private readonly _lineCache = this._register(new DecorationLineCache());
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
private readonly _onDecorationRemoved = this._register(new Emitter<IInternalDecoration>());
@@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
constructor(@ILogService private readonly _logService: ILogService) {
constructor(
@ILogService private readonly _logService: ILogService,
@IBufferService private readonly _bufferService: IBufferService
) {
super();
this._decorations = new SortedList(e => e?.marker.line, this._logService);
this._register(toDisposable(() => this.reset()));
this._register(this._bufferService.buffers.onBufferActivate(() => {
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
}));
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
}
public registerDecoration(options: IDecorationOptions): IDecoration | undefined {
@@ -53,12 +62,14 @@ export class DecorationService extends Disposable implements IDecorationService
listener.dispose();
if (decoration) {
if (this._decorations.delete(decoration)) {
this._lineCache.remove(decoration);
this._onDecorationRemoved.fire(decoration);
}
markerDispose.dispose();
}
});
this._decorations.insert(decoration);
this._lineCache.add(decoration);
this._onDecorationRegistered.fire(decoration);
}
return decoration;
@@ -69,19 +80,17 @@ export class DecorationService extends Disposable implements IDecorationService
d.dispose();
}
this._decorations.clear();
this._lineCache.clear();
}
public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator<IInternalDecoration> {
const bucket = this._lineCache.getDecorationsOnLine(line);
if (!bucket) {
return;
}
let xmin = 0;
let xmax = 0;
let ymin = 0;
let ymax = 0;
for (const d of this._decorations.values()) {
ymin = d.marker.line;
ymax = ymin + (d.options.height ?? 1);
if (line < ymin || line >= ymax) {
continue;
}
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,235 @@ 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;
}
const existing = newMap.get(newLine);
if (existing) {
existing.push(...bucket);
} else {
newMap.set(newLine, bucket.slice());
}
}
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) {
existing.push(...bucket);
} 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 +377,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';
}
+9 -7
View File
@@ -11,13 +11,15 @@
import { IServiceIdentifier } from 'common/services/Services';
const DI_TARGET = 'di$target';
const DI_DEPENDENCIES = 'di$dependencies';
const enum Constants {
DI_TARGET = 'di$target',
DI_DEPENDENCIES = 'di$dependencies'
}
export const serviceRegistry: Map<string, IServiceIdentifier<any>> = new Map();
export function getServiceDependencies(ctor: any): { id: IServiceIdentifier<any>, index: number, optional: boolean }[] {
return ctor[DI_DEPENDENCIES] || [];
return ctor[Constants.DI_DEPENDENCIES] || [];
}
export function createDecorator<T>(id: string): IServiceIdentifier<T> {
@@ -40,10 +42,10 @@ export function createDecorator<T>(id: string): IServiceIdentifier<T> {
}
function storeServiceDependency(id: Function, target: Function, index: number): void {
if ((target as any)[DI_TARGET] === target) {
(target as any)[DI_DEPENDENCIES].push({ id, index });
if ((target as any)[Constants.DI_TARGET] === target) {
(target as any)[Constants.DI_DEPENDENCIES].push({ id, index });
} else {
(target as any)[DI_DEPENDENCIES] = [{ id, index }];
(target as any)[DI_TARGET] = target;
(target as any)[Constants.DI_DEPENDENCIES] = [{ id, index }];
(target as any)[Constants.DI_TARGET] = target;
}
}
+2
View File
@@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration {
readonly backgroundColorRGB: IColor | undefined;
readonly foregroundColorRGB: IColor | undefined;
readonly onRenderEmitter: Emitter<HTMLElement>;
/** @internal Start line for line-index removal; kept in sync on buffer line shifts. */
_indexedStartLine: number;
}
+3 -1
View File
@@ -3,7 +3,9 @@
"compilerOptions": {
"lib": [
"es2015",
"es2016.Array.Include"
"es2016.Array.Include",
"es2019.string",
"es2021.weakref"
],
"outDir": "../../out",
"types": [
+2 -1
View File
@@ -3,7 +3,8 @@
"compilerOptions": {
"lib": [
"es2015",
"es2016.Array.Include"
"es2016.Array.Include",
"es2021.weakref"
],
"outDir": "../../out",
"types": [
@@ -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();
});
});
@@ -6,9 +6,10 @@ import { perfContext, before, beforeEach, ThroughputRuntimeCase } from 'xterm-be
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
import { C0, C1 } from 'common/data/EscapeSequences';
import { IDcsHandler, IOscHandler, IParams } from 'common/parser/Types';
import { IDcsHandler, IOscHandler, IApcHandler, IParams } from 'common/parser/Types';
import { OscHandler } from 'common/parser/OscParser';
import { DcsHandler } from '../../out/common/parser/DcsParser';
import { DcsHandler } from 'common/parser/DcsParser';
import { ApcHandler } from 'common/parser/ApcParser';
const SIZE = 5000000;
@@ -32,6 +33,12 @@ class FastOscHandler implements IOscHandler {
public end(success: boolean): boolean { return true; }
}
class FastApcHandler implements IApcHandler {
public start(): void {}
public put(data: Uint32Array, start: number, end: number): void {}
public end(success: boolean): boolean { return true; }
}
perfContext('Parser throughput - 50MB data', () => {
let parsed: Uint32Array;
@@ -108,6 +115,8 @@ perfContext('Parser throughput - 50MB data', () => {
parser.registerEscHandler({ intermediates: '%', final: 'G' }, () => true);
parser.registerDcsHandler({ final: 'p' }, new DcsHandler(data => true));
parser.registerDcsHandler({ final: 'q' }, new FastDcsHandler());
parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => true));
parser.registerApcHandler({ final: 'Y' }, new FastApcHandler());
});
perfContext('PRINT - a', () => {
@@ -349,4 +358,64 @@ perfContext('Parser throughput - 50MB data', () => {
return { payloadSize: parsed.length };
}, { fork: true }).showAverageThroughput();
});
perfContext('APC string interface (short seq)', () => {
before(() => {
const data = '\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\';
let content = '';
while (content.length < SIZE) {
content += data;
}
parsed = toUtf32(content);
});
new ThroughputRuntimeCase('', async () => {
parser.parse(parsed, parsed.length);
return { payloadSize: parsed.length };
}, { fork: true }).showAverageThroughput();
});
perfContext('APC string interface (long seq)', () => {
before(() => {
const data = '\x1b_XLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\';
let content = '';
while (content.length < SIZE) {
content += data;
}
parsed = toUtf32(content);
});
new ThroughputRuntimeCase('', async () => {
parser.parse(parsed, parsed.length);
return { payloadSize: parsed.length };
}, { fork: true }).showAverageThroughput();
});
perfContext('APC class interface (short seq)', () => {
before(() => {
const data = '\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\';
let content = '';
while (content.length < SIZE) {
content += data;
}
parsed = toUtf32(content);
});
new ThroughputRuntimeCase('', async () => {
parser.parse(parsed, parsed.length);
return { payloadSize: parsed.length };
}, { fork: true }).showAverageThroughput();
});
perfContext('APC class interface (long seq)', () => {
before(() => {
const data = '\x1b_YLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\';
let content = '';
while (content.length < SIZE) {
content += data;
}
parsed = toUtf32(content);
});
new ThroughputRuntimeCase('', async () => {
parser.parse(parsed, parsed.length);
return { payloadSize: parsed.length };
}, { fork: true }).showAverageThroughput();
});
});
+3 -3
View File
@@ -44,7 +44,7 @@ perfContext('Terminal: ls -lR /usr/lib', () => {
}
});
perfContext('write/string/async', () => {
perfContext('write/string', () => {
let terminal: CoreBrowserTerminal;
before(() => {
terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 });
@@ -55,13 +55,13 @@ perfContext('Terminal: ls -lR /usr/lib', () => {
}, { fork: false }).showAverageThroughput();
});
perfContext('write/Utf8/async', () => {
perfContext('write/utf8', () => {
let terminal: CoreBrowserTerminal;
before(() => {
terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 });
});
new ThroughputRuntimeCase('', async () => {
await new Promise<void>(res => terminal.write(content, res));
await new Promise<void>(res => terminal.write(contentUtf8, res));
return { payloadSize: contentUtf8.length };
}, { fork: false }).showAverageThroughput();
});
+10 -10
View File
@@ -248,7 +248,7 @@ test.describe('Parser Integration Tests', () => {
await ctx.proxy.evaluate(([term]) => {
window.customApcHandlerCallStack = [];
// APC uses first character as identifier (e.g., 0x41 = 'A')
window.disposable = term.parser.registerApcHandler(0x41, data => {
window.disposable = term.parser.registerApcHandler({ final: 'A' }, data => {
window.customApcHandlerCallStack!.push(['handler', data]);
return true;
});
@@ -263,7 +263,7 @@ test.describe('Parser Integration Tests', () => {
test('should handle short data', async () => {
await ctx.proxy.evaluate(([term]) => {
window.customApcHandlerCallStack = [];
window.disposable = term.parser.registerApcHandler(0x42, data => {
window.disposable = term.parser.registerApcHandler({ final: 'B' }, data => {
window.customApcHandlerCallStack!.push(['handler', data]);
return true;
});
@@ -278,15 +278,15 @@ test.describe('Parser Integration Tests', () => {
await ctx.proxy.evaluate(([term]) => {
window.customApcHandlerCallStack = [];
window.disposables = [
term.parser.registerApcHandler(0x43, data => {
term.parser.registerApcHandler({ final: 'C' }, data => {
window.customApcHandlerCallStack!.push(['A', data]);
return false;
}),
term.parser.registerApcHandler(0x43, data => {
term.parser.registerApcHandler({ final: 'C' }, data => {
window.customApcHandlerCallStack!.push(['B', data]);
return true;
}),
term.parser.registerApcHandler(0x43, data => {
term.parser.registerApcHandler({ final: 'C' }, data => {
window.customApcHandlerCallStack!.push(['C', data]);
return false;
})
@@ -302,17 +302,17 @@ test.describe('Parser Integration Tests', () => {
await ctx.proxy.evaluate(([term]) => {
window.customApcHandlerCallStack = [];
window.disposables = [
term.parser.registerApcHandler(0x44, data => {
term.parser.registerApcHandler({ final: 'D' }, data => {
window.customApcHandlerCallStack!.push(['A', data]);
return false;
}),
term.parser.registerApcHandler(0x44, data => {
term.parser.registerApcHandler({ final: 'D' }, data => {
return new Promise(res => setTimeout(res, 50)).then(() => {
window.customApcHandlerCallStack!.push(['B', data]);
return false;
});
}),
term.parser.registerApcHandler(0x44, data => {
term.parser.registerApcHandler({ final: 'D' }, data => {
window.customApcHandlerCallStack!.push(['C', data]);
return false;
})
@@ -330,11 +330,11 @@ test.describe('Parser Integration Tests', () => {
await ctx.proxy.evaluate(([term]) => {
window.customApcHandlerCallStack = [];
window.disposables = [
term.parser.registerApcHandler(0x46, data => { // 'F'
term.parser.registerApcHandler({ final: 'F' }, data => {
window.customApcHandlerCallStack!.push(['F', data]);
return true;
}),
term.parser.registerApcHandler(0x58, data => { // 'X'
term.parser.registerApcHandler({ final: 'X' }, data => {
window.customApcHandlerCallStack!.push(['X', data]);
return true;
})
+10 -5
View File
@@ -169,7 +169,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 1), [0, 0, 0, 255]);
});
test('background 0-15 invisible', async () => {
// due to #5852
test.skip('background 0-15 invisible', async () => {
const theme: ITheme = {
black: '#010203',
red: '#040506',
@@ -335,7 +336,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
}
});
test('background 16-255 invisible', async () => {
// due to #5852
test.skip('background 16-255 invisible', async () => {
let data = '';
for (let y = 0; y < 240 / 16; y++) {
for (let x = 0; x < 16; x++) {
@@ -715,7 +717,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
}
});
test('background true color grey invisible', async () => {
// due to #5852
test.skip('background true color grey invisible', async () => {
let data = '';
for (let y = 0; y < 16; y++) {
for (let x = 0; x < 16; x++) {
@@ -872,7 +875,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 2), [64, 64, 64, 255]);
});
test('should enforce half the contrast for dim cells', async () => {
// due to #5852
test.skip('should enforce half the contrast for dim cells', async () => {
const theme: ITheme = {
background: '#ffffff',
black: '#2e3436',
@@ -1177,7 +1181,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 3), [0, 0, 255, 255]);
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 3), [0, 0, 255, 255]);
});
test('#4758: multiple invisible text characters without SGR change should not be rendered', async () => {
// due to #5852
test.skip('#4758: multiple invisible text characters without SGR change should not be rendered', async () => {
// Regression test: #4758 when multiple invisible characters are used
await ctx.value.proxy.writeln(`■\x1b[8m■■`);
// Full refresh as the before result is the same as after
+5 -5
View File
@@ -1274,12 +1274,12 @@ declare module '@xterm/headless' {
prefix?: string;
/**
* Optional intermediate bytes, must be in range \x20 .. \x2f.
* Usable in CSI, DCS and ESC.
* Usable in CSI, DCS, ESC and APC.
*/
intermediates?: string;
/**
* Final byte, must be in range \x40 .. \x7e for CSI and DCS,
* \x30 .. \x7e for ESC.
* \x30 .. \x7e for ESC and APC.
*/
final: string;
}
@@ -1354,8 +1354,8 @@ declare module '@xterm/headless' {
/**
* Adds a handler for APC escape sequences.
* @param ident The identifier (first character) of the sequence as a
* character code, e.g. 71 for 'G' (Kitty graphics protocol).
* @param id Specifies the function identifier under which the callback
* gets registered, e.g. {final: 'G'} for Kitty graphics protocol.
* @param callback The function to handle the sequence. Note that the
* function will only be called once if the sequence finished successfully.
* There is currently no way to intercept smaller data chunks, data chunks
@@ -1368,7 +1368,7 @@ declare module '@xterm/headless' {
* added handler is tried first.
* @returns An IDisposable you can call to remove this handler.
*/
registerApcHandler(ident: number, callback: (data: string) => boolean): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean): IDisposable;
}
/**
+5 -5
View File
@@ -1909,12 +1909,12 @@ declare module '@xterm/xterm' {
prefix?: string;
/**
* Optional intermediate bytes, must be in range \x20 .. \x2f.
* Usable in CSI, DCS and ESC.
* Usable in CSI, DCS, ESC and APC.
*/
intermediates?: string;
/**
* Final byte, must be in range \x40 .. \x7e for CSI and DCS,
* \x30 .. \x7e for ESC.
* \x30 .. \x7e for ESC and APC.
*/
final: string;
}
@@ -2002,8 +2002,8 @@ declare module '@xterm/xterm' {
/**
* Adds a handler for APC escape sequences.
* @param ident The identifier (first character) of the sequence as a
* character code, e.g. 71 for 'G' (Kitty graphics protocol).
* @param id Specifies the function identifier under which the callback
* gets registered, e.g. {final: 'G'} for Kitty graphics protocol.
* @param callback The function to handle the sequence. Note that the
* function will only be called once if the sequence finished successfully.
* There is currently no way to intercept smaller data chunks, data chunks
@@ -2016,7 +2016,7 @@ declare module '@xterm/xterm' {
* handler. The most recently added handler is tried first.
* @returns An IDisposable you can call to remove this handler.
*/
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
}
/**