diff --git a/.github/instructions/benchmark.instructions.md b/.agents/skills/benchmark/SKILL.md similarity index 51% rename from .github/instructions/benchmark.instructions.md rename to .agents/skills/benchmark/SKILL.md index 2e586708d..e87c285d9 100644 --- a/.github/instructions/benchmark.instructions.md +++ b/.agents/skills/benchmark/SKILL.md @@ -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 "" 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). diff --git a/.github/instructions/unit-test.instructions.md b/.agents/skills/unit-test/SKILL.md similarity index 51% rename from .github/instructions/unit-test.instructions.md rename to .agents/skills/unit-test/SKILL.md index f139e8223..a285983b5 100644 --- a/.github/instructions/unit-test.instructions.md +++ b/.agents/skills/unit-test/SKILL.md @@ -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. diff --git a/.github/hooks/setupRepo.json b/.github/hooks/setupRepo.json deleted file mode 100644 index 5dd0c6560..000000000 --- a/.github/hooks/setupRepo.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "hooks": { - "SessionStart": [ - { - "type": "command", - "command": "npm run agent:setup-repo" - } - ] - } -} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c40e86ff2..204faf12a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.gitignore b/.gitignore index e48470eff..81fe7a147 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ build/ yarn.lock test-results/ +# Tracing/debug files +.cursor/nal-trace/ + # Keep bundled code out of Git dist/ demo/dist/ diff --git a/.github/copilot-instructions.md b/AGENTS.md similarity index 86% rename from .github/copilot-instructions.md rename to AGENTS.md index c9954ed7e..0d5d03588 100644 --- a/.github/copilot-instructions.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index a6fecc191..7107314b6 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/addons/addon-clipboard/package.json b/addons/addon-clipboard/package.json index 176fa861a..eb207679d 100644 --- a/addons/addon-clipboard/package.json +++ b/addons/addon-clipboard/package.json @@ -21,8 +21,5 @@ "package": "../../node_modules/.bin/webpack", "prepublishOnly": "npm run package", "start": "node ../../demo/start" - }, - "dependencies": { - "js-base64": "^3.7.5" } } diff --git a/addons/addon-clipboard/src/ClipboardAddon.ts b/addons/addon-clipboard/src/ClipboardAddon.ts index 58425924f..5e194880c 100644 --- a/addons/addon-clipboard/src/ClipboardAddon.ts +++ b/addons/addon-clipboard/src/ClipboardAddon.ts @@ -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 { - if (selection !== 'c') { - return Promise.resolve(''); - } + public readText(selection: string): Promise { return navigator.clipboard.readText(); } - public async writeText(selection: ClipboardSelectionType, text: string): Promise { - if (selection !== 'c') { - return Promise.resolve(); - } + public writeText(selection: string, text: string): Promise { 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 ''; } } diff --git a/addons/addon-clipboard/test/ClipboardAddon.test.ts b/addons/addon-clipboard/test/ClipboardAddon.test.ts index d4d86d46b..2c04e619b 100644 --- a/addons/addon-clipboard/test/ClipboardAddon.test.ts +++ b/addons/addon-clipboard/test/ClipboardAddon.test.ts @@ -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 { - 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`); diff --git a/addons/addon-clipboard/typings/addon-clipboard.d.ts b/addons/addon-clipboard/typings/addon-clipboard.d.ts index 4ef12e0f7..f8e024ac3 100644 --- a/addons/addon-clipboard/typings/addon-clipboard.d.ts +++ b/addons/addon-clipboard/typings/addon-clipboard.d.ts @@ -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; + readText(selection: string): string | Promise; /** * 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; + writeText(selection: string, text: string): void | Promise; } /** - * 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; + public readText(selection: string): Promise; /** * 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; + public writeText(selection: string, data: string): Promise; } } diff --git a/addons/addon-fit/src/FitAddon.ts b/addons/addon-fit/src/FitAddon.ts index 87b21e99e..3db988acd 100644 --- a/addons/addon-fit/src/FitAddon.ts +++ b/addons/addon-fit/src/FitAddon.ts @@ -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; } diff --git a/addons/addon-image/README.md b/addons/addon-image/README.md index dc225d13b..a18ce18bb 100644 --- a/addons/addon-image/README.md +++ b/addons/addon-image/README.md @@ -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 diff --git a/addons/addon-image/package.json b/addons/addon-image/package.json index 3d5fa37f6..fc6864a0e 100644 --- a/addons/addon-image/package.json +++ b/addons/addon-image/package.json @@ -25,6 +25,6 @@ }, "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } } diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts index 76492c405..6f94ef8ed 100644 --- a/addons/addon-image/src/IIPHandler.ts +++ b/addons/addon-image/src/IIPHandler.ts @@ -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], { type: this._metrics.mime }); - this._dec.release(); - - if (!window.createImageBitmap) { - const url = URL.createObjectURL(blob); - const img = new Image(); - return new Promise(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); diff --git a/addons/addon-image/src/IIPMetrics.ts b/addons/addon-image/src/IIPMetrics.ts index d5470466f..b377cf061 100644 --- a/addons/addon-image/src/IIPMetrics.ts +++ b/addons/addon-image/src/IIPMetrics.ts @@ -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; } diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts index 7af0e519c..7e0f29d3f 100644 --- a/addons/addon-image/src/ImageAddon.ts +++ b/addons/addon-image/src/ImageAddon.ts @@ -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= ; ... : 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) ); } } diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts index 53a0a9c9b..b36cdd004 100644 --- a/addons/addon-image/src/ImageRenderer.ts +++ b/addons/addon-image/src/ImageRenderer.ts @@ -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) { diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts index 17cd5ed9f..de889dfff 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts @@ -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)); diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts index 82b2b0a9a..448ebcc05 100644 --- a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts +++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts @@ -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 { diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts index 67769204f..dc729ec63 100644 --- a/addons/addon-image/test/ImageAddon.test.ts +++ b/addons/addon-image/test/ImageAddon.test.ts @@ -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 { + return ctx.page.evaluate(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`); +} diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts index 315538aa1..a9b5d6845 100644 --- a/addons/addon-image/test/KittyGraphics.test.ts +++ b/addons/addon-image/test/KittyGraphics.test.ts @@ -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; diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json index cd70ada2b..18c82584e 100644 --- a/addons/addon-ligatures/package.json +++ b/addons/addon-ligatures/package.json @@ -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" diff --git a/addons/addon-serialize/test/SerializeAddon.test.ts b/addons/addon-serialize/test/SerializeAddon.test.ts index ee5355fa6..fb42d807d 100644 --- a/addons/addon-serialize/test/SerializeAddon.test.ts +++ b/addons/addon-serialize/test/SerializeAddon.test.ts @@ -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, diff --git a/addons/addon-web-fonts/test/WebFontsAddon.test.ts b/addons/addon-web-fonts/test/WebFontsAddon.test.ts index 1315b1ee6..c94bff84d 100644 --- a/addons/addon-web-fonts/test/WebFontsAddon.test.ts +++ b/addons/addon-web-fonts/test/WebFontsAddon.test.ts @@ -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(` diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index f5be446f6..46b8efeb9 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -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; } diff --git a/addons/addon-webgl/src/RectangleRenderer.ts b/addons/addon-webgl/src/RectangleRenderer.ts index d22068bab..81e129e5c 100644 --- a/addons/addon-webgl/src/RectangleRenderer.ts +++ b/addons/addon-webgl/src/RectangleRenderer.ts @@ -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; diff --git a/addons/addon-webgl/src/RenderModel.ts b/addons/addon-webgl/src/RenderModel.ts index a94f317ee..6113a9bdc 100644 --- a/addons/addon-webgl/src/RenderModel.ts +++ b/addons/addon-webgl/src/RenderModel.ts @@ -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); diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 5629508f2..34188235c 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -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; } } diff --git a/addons/addon-webgl/src/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 83f20c9e7..500308bd1 100644 --- a/addons/addon-webgl/src/WebglRenderer.ts +++ b/addons/addon-webgl/src/WebglRenderer.ts @@ -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 = 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 } diff --git a/addons/addon-webgl/test/WebglRenderer.test.ts b/addons/addon-webgl/test/WebglRenderer.test.ts index af61992ce..c425fa000 100644 --- a/addons/addon-webgl/test/WebglRenderer.test.ts +++ b/addons/addon-webgl/test/WebglRenderer.test.ts @@ -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) {} `); }); }); diff --git a/addons/addon-webgl/test/playwright.config.ts b/addons/addon-webgl/test/playwright.config.ts index e2fe314e5..8200a4a8f 100644 --- a/addons/addon-webgl/test/playwright.config.ts +++ b/addons/addon-webgl/test/playwright.config.ts @@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = { { name: 'Chromium', use: { - browserName: 'chromium' + browserName: 'chromium', + launchOptions: { + args: ['--enable-unsafe-swiftshader'] + } } }, { diff --git a/bin/extract_vtfeatures.js b/bin/extract_vtfeatures.js index 60979cce4..1fde261bb 100644 --- a/bin/extract_vtfeatures.js +++ b/bin/extract_vtfeatures.js @@ -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}} +
+ +### {{name}} +{{#longDescription}} +{{{.}}} +{{/longDescription}} + +
+{{/longDescription.length}} +{{/APC}} +{{/APC.hasLongDescriptions}} + +{{/APC.length}} + + {{#DCS.length}} ## DCS diff --git a/bin/agent/setup-repo.mjs b/bin/setup-full.mjs similarity index 100% rename from bin/agent/setup-repo.mjs rename to bin/setup-full.mjs diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts index 18ab9254d..31521a871 100644 --- a/demo/client/components/window/optionsWindow.ts +++ b/demo/client/components/window/optionsWindow.ts @@ -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(); } diff --git a/package-lock.json b/package-lock.json index 6130135fd..f101299ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,15 +16,11 @@ "@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", @@ -32,8 +28,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", @@ -49,7 +43,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", @@ -64,10 +57,7 @@ "addons/addon-clipboard": { "name": "@xterm/addon-clipboard", "version": "0.2.0", - "license": "MIT", - "dependencies": { - "js-base64": "^3.7.5" - } + "license": "MIT" }, "addons/addon-fit": { "name": "@xterm/addon-fit", @@ -80,7 +70,7 @@ "license": "MIT", "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } }, "addons/addon-ligatures": { @@ -94,7 +84,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" @@ -1525,21 +1515,6 @@ "@types/node": "*" } }, - "node_modules/@types/debug": { - "version": "4.1.12", - "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", - "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", - "dev": true, - "dependencies": { - "@types/ms": "*" - } - }, - "node_modules/@types/deep-equal": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@types/deep-equal/-/deep-equal-1.0.4.tgz", - "integrity": "sha512-tqdiS4otQP4KmY0PR3u6KbZ5EWvhNdUoS/jc93UuK23C220lOZ/9TvjfxdPcKvqwwDVtmtSCrnr0p/2dirAxkA==", - "dev": true - }, "node_modules/@types/eslint": { "version": "9.6.1", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", @@ -1652,12 +1627,6 @@ "integrity": "sha512-Z61JK7DKDtdKTWwLeElSEBcWGRLY8g95ic5FoQqI9CMx0ns/Ghep3B4DfcEimiKMvtamNVULVNKEsiwV3aQmXw==", "dev": true }, - "node_modules/@types/ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", - "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", - "dev": true - }, "node_modules/@types/node": { "version": "22.19.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz", @@ -1731,18 +1700,6 @@ "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", "dev": true }, - "node_modules/@types/trusted-types": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-1.0.6.tgz", - "integrity": "sha512-230RC8sFeHoT6sSUlRO6a8cAnclO06eeiq1QDfiv2FGCLWFvvERWgwIQD4FWqD9A69BN7Lzee4OXwoMVnnsWDw==", - "dev": true - }, - "node_modules/@types/utf8": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/utf8/-/utf8-3.0.3.tgz", - "integrity": "sha512-+lqLGxWZsEe4Z6OrzBI7Ym4SMUTaMS5yOrHZ0/IL0bpIye1Qbs4PpobJL2mLDbftUXlPFZR7fu6d1yM+bHLX1w==", - "dev": true - }, "node_modules/@types/webpack": { "version": "5.28.5", "resolved": "https://registry.npmjs.org/@types/webpack/-/webpack-5.28.5.tgz", @@ -2567,22 +2524,6 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "dev": true }, - "node_modules/array-buffer-byte-length": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", - "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "is-array-buffer": "^3.0.5" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -2604,30 +2545,16 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "dev": true }, - "node_modules/available-typed-arrays": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dev": true, - "dependencies": { - "possible-typed-array-names": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/axios": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", - "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", "dev": true, + "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -2804,24 +2731,6 @@ "node": ">=8" } }, - "node_modules/call-bind": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", - "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", - "dev": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.0", - "es-define-property": "^1.0.0", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3260,24 +3169,6 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "dev": true }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -3376,38 +3267,6 @@ "node": ">=6" } }, - "node_modules/deep-equal": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", - "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", - "dev": true, - "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.5", - "es-get-iterator": "^1.1.3", - "get-intrinsic": "^1.2.2", - "is-arguments": "^1.1.1", - "is-array-buffer": "^3.0.2", - "is-date-object": "^1.0.5", - "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", - "isarray": "^2.0.5", - "object-is": "^1.1.5", - "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "side-channel": "^1.0.4", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.13" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3441,40 +3300,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/define-properties": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", - "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3608,26 +3433,6 @@ "node": ">= 0.4" } }, - "node_modules/es-get-iterator": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", - "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", - "has-symbols": "^1.0.3", - "is-arguments": "^1.1.1", - "is-map": "^2.0.2", - "is-set": "^2.0.2", - "is-string": "^1.0.7", - "isarray": "^2.0.5", - "stop-iteration-iterator": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/es-module-lexer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", @@ -4090,9 +3895,9 @@ "dev": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "dev": true, "funding": [ { @@ -4103,7 +3908,8 @@ "type": "opencollective", "url": "https://opencollective.com/fastify" } - ] + ], + "license": "BSD-3-Clause" }, "node_modules/fastest-levenshtein": { "version": "1.0.16", @@ -4251,9 +4057,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -4261,6 +4067,7 @@ "url": "https://github.com/sponsors/RubenVerborgh" } ], + "license": "MIT", "engines": { "node": ">=4.0" }, @@ -4284,21 +4091,6 @@ "node": ">8.0.0" } }, - "node_modules/for-each": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dev": true, - "dependencies": { - "is-callable": "^1.2.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -4423,15 +4215,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/functions-have-names": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", - "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4596,18 +4379,6 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "dev": true }, - "node_modules/has-bigints": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", - "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -4617,18 +4388,6 @@ "node": ">=8" } }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dev": true, - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4848,20 +4607,6 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "dev": true }, - "node_modules/internal-slot": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", - "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "hasown": "^2.0.2", - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/interpret": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz", @@ -4887,54 +4632,6 @@ "node": ">= 0.10" } }, - "node_modules/is-arguments": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", - "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-array-buffer": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", - "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-bigint": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", - "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", - "dev": true, - "dependencies": { - "has-bigints": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -4947,34 +4644,6 @@ "node": ">=8" } }, - "node_modules/is-boolean-object": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", - "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-callable": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4990,22 +4659,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-date-object": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", - "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5036,18 +4689,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-map": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", - "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -5057,22 +4698,6 @@ "node": ">=0.12.0" } }, - "node_modules/is-number-object": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", - "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-plain-obj": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", @@ -5100,51 +4725,6 @@ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", "dev": true }, - "node_modules/is-regex": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", - "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-set": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", - "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-shared-array-buffer": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", - "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -5157,39 +4737,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-string": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", - "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-symbol": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", - "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "has-symbols": "^1.1.0", - "safe-regex-test": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-typedarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", @@ -5208,34 +4755,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-weakmap": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", - "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-weakset": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", - "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.3", - "get-intrinsic": "^1.2.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-windows": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", @@ -5245,12 +4764,6 @@ "node": ">=0.10.0" } }, - "node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5411,11 +4924,6 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/js-base64": { - "version": "3.7.8", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", - "integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -6205,51 +5713,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/object-is": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", - "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object-keys": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", - "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.assign": { - "version": "4.1.7", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", - "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0", - "has-symbols": "^1.1.0", - "object-keys": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -6601,15 +6064,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/possible-typed-array-names": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", - "dev": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6655,10 +6109,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", @@ -6755,26 +6213,6 @@ "node": ">= 0.10" } }, - "node_modules/regexp.prototype.flags": { - "version": "1.5.4", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", - "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.8", - "define-properties": "^1.2.1", - "es-errors": "^1.3.0", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "set-function-name": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/release-zalgo": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/release-zalgo/-/release-zalgo-1.0.0.tgz", @@ -6951,23 +6389,6 @@ } ] }, - "node_modules/safe-regex-test": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", - "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", - "dev": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "is-regex": "^1.2.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -7126,38 +6547,6 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "dev": true }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/set-function-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", - "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", - "dev": true, - "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -7422,19 +6811,6 @@ "node": ">= 0.8" } }, - "node_modules/stop-iteration-iterator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", - "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", - "dev": true, - "dependencies": { - "es-errors": "^1.3.0", - "internal-slot": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7935,12 +7311,6 @@ "punycode": "^2.1.0" } }, - "node_modules/utf8": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/utf8/-/utf8-3.0.0.tgz", - "integrity": "sha512-E8VjFIQ/TyQgp+TZfS6l8yp/xWppSAHzidGiRrqe4bK4XP9pTRyKFgGJpO3SN7zdX4DeomTrwaseCHovfpFcqQ==", - "dev": true - }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -8221,70 +7591,12 @@ "node": ">= 8" } }, - "node_modules/which-boxed-primitive": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", - "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", - "dev": true, - "dependencies": { - "is-bigint": "^1.1.0", - "is-boolean-object": "^1.2.1", - "is-number-object": "^1.1.1", - "is-string": "^1.1.1", - "is-symbol": "^1.1.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-collection": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", - "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "dependencies": { - "is-map": "^2.0.3", - "is-set": "^2.0.3", - "is-weakmap": "^2.0.2", - "is-weakset": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/which-module": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", "dev": true }, - "node_modules/which-typed-array": { - "version": "1.1.19", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.19.tgz", - "integrity": "sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw==", - "dev": true, - "dependencies": { - "available-typed-arrays": "^1.0.7", - "call-bind": "^1.0.8", - "call-bound": "^1.0.4", - "for-each": "^0.3.5", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-tostringtag": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/wildcard": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", @@ -8435,9 +7747,9 @@ } }, "node_modules/xterm-wasm-parts": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz", - "integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.4.1.tgz", + "integrity": "sha512-/qwoasrSyrzs90WHTN4wpISAEz1u9sOpLsDhkd9a2h27l1pz/KZd9h4p+X2pUR7OqyXvDsOdj0dV3906s3wh8g==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 151566544..9590e8a12 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/browser/AccessibilityManager.ts b/src/browser/AccessibilityManager.ts index 1d3c44ae1..6fc421337 100644 --- a/src/browser/AccessibilityManager.ts +++ b/src/browser/AccessibilityManager.ts @@ -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(); } } diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts index 485900e41..65a18da72 100644 --- a/src/browser/TestUtils.test.ts +++ b/src/browser/TestUtils.test.ts @@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal { public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { throw new Error('Method not implemented.'); } - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { throw new Error('Method not implemented.'); } public registerLinkProvider(linkProvider: ILinkProvider): IDisposable { diff --git a/src/browser/renderer/dom/DomRenderer.ts b/src/browser/renderer/dom/DomRenderer.ts index 08b5093df..6f873af7b 100644 --- a/src/browser/renderer/dom/DomRenderer.ts +++ b/src/browser/renderer/dom/DomRenderer.ts @@ -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; } diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts index 05fc8000c..6ca84569a 100644 --- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts +++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts @@ -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); } diff --git a/src/browser/scrollable/scrollableElement.ts b/src/browser/scrollable/scrollableElement.ts index e30b36fc3..4039d0c4e 100644 --- a/src/browser/scrollable/scrollableElement.ts +++ b/src/browser/scrollable/scrollableElement.ts @@ -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); } } } diff --git a/src/browser/services/CharacterJoinerService.test.ts b/src/browser/services/CharacterJoinerService.test.ts index a0ccc67d6..06730c7ac 100644 --- a/src/browser/services/CharacterJoinerService.test.ts +++ b/src/browser/services/CharacterJoinerService.test.ts @@ -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; diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts index e2455fd2b..60f0a6ab9 100644 --- a/src/browser/services/MouseCoordsService.ts +++ b/src/browser/services/MouseCoordsService.ts @@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService { public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined { return getCoords( - window, + getWindow(element), event, element, colCount, diff --git a/src/browser/services/RenderService.ts b/src/browser/services/RenderService.ts index 83e3d3379..e999b7eac 100644 --- a/src/browser/services/RenderService.ts +++ b/src/browser/services/RenderService.ts @@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService { private _renderDebouncer: IRenderDebouncerWithCallback; private _pausedResizeTask: DebouncedIdleTask; private _observerDisposable = this._register(new MutableDisposable()); + private _intersectionObserver: IntersectionObserver | undefined; private _isPaused: boolean = false; private _needsFullRefresh: boolean = false; @@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService { // and resume based on terminal visibility if so if ('IntersectionObserver' in w) { const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 }); + this._observerDisposable.value = toDisposable(() => { + this._intersectionObserver?.disconnect(); + this._intersectionObserver = undefined; + }); + this._intersectionObserver = observer; observer.observe(screenElement); - this._observerDisposable.value = toDisposable(() => observer.disconnect()); } } diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts index 8deedb751..be62ccc6b 100644 --- a/src/browser/services/SelectionService.test.ts +++ b/src/browser/services/SelectionService.test.ts @@ -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 diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts index e1b8510e9..39692a871 100644 --- a/src/browser/services/SelectionService.ts +++ b/src/browser/services/SelectionService.ts @@ -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, diff --git a/src/common/Async.ts b/src/common/Async.ts index 7345f19da..276532fb8 100644 --- a/src/common/Async.ts +++ b/src/common/Async.ts @@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable { } } +/** + * Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled + * microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not + * run yet. + */ +export class MicrotaskTimer implements IDisposable { + private _isScheduled = false; + private _isDisposed = false; + + public dispose(): void { + this.cancel(); + this._isDisposed = true; + } + + public cancel(): void { + this._isScheduled = false; + } + + public set(runner: () => void): void { + if (this._isDisposed) { + throw new Error('Calling set on a disposed MicrotaskTimer'); + } + if (this._isScheduled) { + return; + } + this._isScheduled = true; + queueMicrotask(() => { + if (!this._isScheduled) { + return; + } + this._isScheduled = false; + runner(); + }); + } +} + export class IntervalTimer implements IDisposable { private _disposable: IDisposable | undefined; private _isDisposed = false; diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts index e2a3adf72..9bd7faaf5 100644 --- a/src/common/CoreTerminal.ts +++ b/src/common/CoreTerminal.ts @@ -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): IDisposable { - return this._inputHandler.registerApcHandler(ident, callback); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { + return this._inputHandler.registerApcHandler(id, callback); } protected _setup(): void { diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 68265c5df..2b7142dd0 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -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 | undefined; const slowPromise = new Promise((_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): IDisposable { - return this._parser.registerApcHandler(ident, new ApcHandler(callback)); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): 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(); } } diff --git a/src/common/Types.ts b/src/common/Types.ts index 0a9c89a1a..5edf63637 100644 --- a/src/common/Types.ts +++ b/src/common/Types.ts @@ -22,7 +22,7 @@ export interface ICoreTerminal { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; - registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; } export interface IDisposable { @@ -476,7 +476,7 @@ export interface IInputHandler { registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise): IDisposable; registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise): IDisposable; registerOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; - registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; /** C0 BEL */ bell(): boolean; /** C0 LF */ lineFeed(): boolean; diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts index 9c013c5de..abfea7b06 100644 --- a/src/common/buffer/Buffer.test.ts +++ b/src/common/buffer/Buffer.test.ts @@ -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 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; + }) as typeof setTimeout; + (globalThis as any).clearTimeout = ((id: ReturnType) => { + 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(); diff --git a/src/common/buffer/Buffer.ts b/src/common/buffer/Buffer.ts index 8efefd60a..34ef02f6a 100644 --- a/src/common/buffer/Buffer.ts +++ b/src/common/buffer/Buffer.ts @@ -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; public ydisp: number = 0; public ybase: number = 0; @@ -50,6 +52,7 @@ export class Buffer implements IBuffer { private _isClearing: boolean = false; private _memoryCleanupQueue: InstanceType; 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(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) { diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts index dce68ebbf..3aec28872 100644 --- a/src/common/buffer/BufferLine.test.ts +++ b/src/common/buffer/BufferLine.test.ts @@ -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); + }); + }); }); }); diff --git a/src/common/buffer/BufferLine.ts b/src/common/buffer/BufferLine.ts index e415851a0..be200f112 100644 --- a/src/common/buffer/BufferLine.ts +++ b/src/common/buffer/BufferLine.ts @@ -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 | 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; + } + } } diff --git a/src/common/buffer/BufferLineStringCache.ts b/src/common/buffer/BufferLineStringCache.ts new file mode 100644 index 000000000..b365eef70 --- /dev/null +++ b/src/common/buffer/BufferLineStringCache.ts @@ -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 = new Set(); + private readonly _clearTimeout = this._register(new MutableDisposable()); + 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); + } +} diff --git a/src/common/buffer/BufferReflow.test.ts b/src/common/buffer/BufferReflow.test.ts index b351b89c4..d71186ddd 100644 --- a/src/common/buffer/BufferReflow.test.ts +++ b/src/common/buffer/BufferReflow.test.ts @@ -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)]); diff --git a/src/common/buffer/BufferSet.test.ts b/src/common/buffer/BufferSet.test.ts index 96737fee9..8809a3cb6 100644 --- a/src/common/buffer/BufferSet.test.ts +++ b/src/common/buffer/BufferSet.test.ts @@ -6,7 +6,7 @@ import { assert } from 'chai'; import { BufferSet } from 'common/buffer/BufferSet'; import { Buffer } from 'common/buffer/Buffer'; -import { MockOptionsService, MockBufferService, 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); + }); + }); }); diff --git a/src/common/buffer/BufferSet.ts b/src/common/buffer/BufferSet.ts index 772a76442..32a4bf3b4 100644 --- a/src/common/buffer/BufferSet.ts +++ b/src/common/buffer/BufferSet.ts @@ -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()); + private readonly _altBuffer = this._register(new MutableDisposable()); 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, diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts index b5aa9d643..2a832d75f 100644 --- a/src/common/input/KittyKeyboard.test.ts +++ b/src/common/input/KittyKeyboard.test.ts @@ -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; diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts index e34f47070..ff57b4f89 100644 --- a/src/common/input/KittyKeyboard.ts +++ b/src/common/input/KittyKeyboard.ts @@ -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); diff --git a/src/common/input/TextDecoder.test.ts b/src/common/input/TextDecoder.test.ts index a0a4a3230..1959e1591 100644 --- a/src/common/input/TextDecoder.test.ts +++ b/src/common/input/TextDecoder.test.ts @@ -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(); diff --git a/src/common/input/WriteBuffer.ts b/src/common/input/WriteBuffer.ts index c9db076d6..b936c9dbf 100644 --- a/src/common/input/WriteBuffer.ts +++ b/src/common/input/WriteBuffer.ts @@ -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; diff --git a/src/common/parser/ApcParser.test.ts b/src/common/parser/ApcParser.test.ts index 9021fb38f..c2200d918 100644 --- a/src/common/parser/ApcParser.test.ts +++ b/src/common/parser/ApcParser.test.ts @@ -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 { + // 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 { + let result: void | Promise; + 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 { - let result: void | Promise; - 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!']]); + }); }); }); }); diff --git a/src/common/parser/ApcParser.ts b/src/common/parser/ApcParser.ts index dbcf0cced..8f921f8cb 100644 --- a/src/common/parser/ApcParser.ts +++ b/src/common/parser/ApcParser.ts @@ -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 = 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 _ 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 { - 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 = 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 = 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; } } diff --git a/src/common/parser/Constants.ts b/src/common/parser/Constants.ts index 828d7ad62..cf596c65e 100644 --- a/src/common/parser/Constants.ts +++ b/src/common/parser/Constants.ts @@ -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 diff --git a/src/common/parser/EscapeSequenceParser.test.ts b/src/common/parser/EscapeSequenceParser.test.ts index 08d47f06e..2d5a04df7 100644 --- a/src/common/parser/EscapeSequenceParser.test.ts +++ b/src/common/parser/EscapeSequenceParser.test.ts @@ -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, 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(); }); diff --git a/src/common/parser/EscapeSequenceParser.ts b/src/common/parser/EscapeSequenceParser.ts index febb4878c..d944112db 100644 --- a/src/common/parser/EscapeSequenceParser.ts +++ b/src/common/parser/EscapeSequenceParser.ts @@ -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; protected _escHandlers: IHandlerCollection; 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; diff --git a/src/common/parser/Params.ts b/src/common/parser/Params.ts index 7071453d0..8a77c78a4 100644 --- a/src/common/parser/Params.ts +++ b/src/common/parser/Params.ts @@ -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; } } diff --git a/src/common/parser/Types.ts b/src/common/parser/Types.ts index 2405dcb88..c04788f7e 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -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 { - start(): void; + start(ident: number): void; end(success: boolean, promiseResult?: boolean): void | Promise; } diff --git a/src/common/public/ParserApi.ts b/src/common/public/ParserApi.ts index a9b971886..4c482d7ca 100644 --- a/src/common/public/ParserApi.ts +++ b/src/common/public/ParserApi.ts @@ -34,7 +34,7 @@ export class ParserApi implements IParser { public addOscHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { return this.registerOscHandler(ident, callback); } - public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise): IDisposable { - return this._core.registerApcHandler(ident, callback); + public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable { + return this._core.registerApcHandler(id, callback); } } diff --git a/src/common/services/BufferService.ts b/src/common/services/BufferService.ts index 6a7ce2b34..06ac6b10a 100644 --- a/src/common/services/BufferService.ts +++ b/src/common/services/BufferService.ts @@ -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); diff --git a/src/common/services/DecorationService.test.ts b/src/common/services/DecorationService.test.ts index 95c7c0e24..e3a6c2f04 100644 --- a/src/common/services/DecorationService.test.ts +++ b/src/common/services/DecorationService.test.ts @@ -4,11 +4,13 @@ */ import { assert } from 'chai'; -import { DecorationService } from './DecorationService'; +import { DecorationLineCache, DecorationService } from './DecorationService'; import { IMarker } from 'common/Types'; import { Disposable } from 'common/Lifecycle'; import { Emitter } from 'common/Event'; -import { MockLogService } from 'common/TestUtils.test'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +import { Buffer } from 'common/buffer/Buffer'; +import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine'; function createFakeMarker(line: number): IMarker { return Object.freeze(new class extends Disposable { @@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker { }()); } +function createDecorationService(): DecorationService { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + return new DecorationService(new MockLogService(), bufferService); +} + const fakeMarker: IMarker = createFakeMarker(1); describe('DecorationService', () => { it('should set isDisposed to true after dispose', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: fakeMarker }); @@ -35,7 +42,7 @@ describe('DecorationService', () => { describe('forEachDecorationAtCell', () => { it('should find decoration at its marker line', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10 @@ -48,7 +55,7 @@ describe('DecorationService', () => { }); it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -74,7 +81,7 @@ describe('DecorationService', () => { }); it('should not find decoration outside its x range', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), x: 5, @@ -99,11 +106,35 @@ describe('DecorationService', () => { service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d)); assert.strictEqual(foundAtX8.length, 0); }); + + it('should find multi-line decoration when single-line decorations exist on other lines', () => { + const bufferService = new MockBufferService(80, 24, new MockOptionsService()); + const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + for (let i = 0; i < 100; i++) { + serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(i), + width: 5 + }); + } + const multiLine = serviceWithBuffer.registerDecoration({ + marker: buffer.addMarker(10), + width: 10, + height: 3 + }); + assert.ok(multiLine); + + const found: typeof multiLine[] = []; + serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d)); + assert.include(found, multiLine); + }); }); describe('getDecorationsAtCell', () => { it('should find decoration with height > 1 on subsequent lines', () => { - const service = new DecorationService(new MockLogService()); + const service = createDecorationService(); const decoration = service.registerDecoration({ marker: createFakeMarker(5), width: 10, @@ -117,4 +148,73 @@ describe('DecorationService', () => { assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0); }); }); + + describe('DecorationLineCache', () => { + it('should return undefined for lines with no indexed decorations', () => { + const cache = new DecorationLineCache(); + assert.isUndefined(cache.getDecorationsOnLine(0)); + }); + }); + + describe('line index maintenance', () => { + it('should keep lookups correct after buffer trim', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(buffer.lines.length - 1); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d)); + assert.strictEqual(found.length, 1); + }); + + it('should remove decoration from line index when marker is trimmed off buffer', () => { + const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(0); + const decoration = service.registerDecoration({ marker, width: 10 }); + assert.ok(decoration); + + buffer.lines.onTrimEmitter.fire(1); + assert.isTrue(marker.isDisposed); + assert.isTrue(decoration!.isDisposed); + + const found: typeof decoration[] = []; + service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d)); + assert.strictEqual(found.length, 0); + }); + + it('should keep multi-line decoration indexed after line insert', async () => { + const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 })); + const service = new DecorationService(new MockLogService(), bufferService); + const buffer = bufferService.buffer; + (buffer as Buffer).fillViewportRows(); + + const marker = buffer.addMarker(3); + const decoration = service.registerDecoration({ marker, width: 10, height: 3 }); + assert.ok(decoration); + + buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA)); + await new Promise(resolve => queueMicrotask(resolve)); + + const foundOnSpan: typeof decoration[] = []; + for (let line = marker.line; line < marker.line + 3; line++) { + service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d)); + } + assert.include(foundOnSpan, decoration); + + const foundOutsideSpan: typeof decoration[] = []; + service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d)); + assert.strictEqual(foundOutsideSpan.length, 0); + }); + }); }); diff --git a/src/common/services/DecorationService.ts b/src/common/services/DecorationService.ts index 133e1a985..a582c18af 100644 --- a/src/common/services/DecorationService.ts +++ b/src/common/services/DecorationService.ts @@ -3,19 +3,19 @@ * @license MIT */ +import type { IDeleteEvent, IInsertEvent } from 'common/CircularList'; +import { MicrotaskTimer } from 'common/Async'; import { css } from 'common/Color'; -import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle'; -import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle'; +import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services'; import { SortedList } from 'common/SortedList'; -import { IColor } from 'common/Types'; +import { IColor, ICircularList } from 'common/Types'; import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm'; import { Emitter } from 'common/Event'; // Work variables to avoid garbage collection let $xmin = 0; let $xmax = 0; -let $ymin = 0; -let $ymax = 0; export class DecorationService extends Disposable implements IDecorationService { public serviceBrand: any; @@ -27,6 +27,8 @@ export class DecorationService extends Disposable implements IDecorationService */ private readonly _decorations: SortedList; + private readonly _lineCache = this._register(new DecorationLineCache()); + private readonly _onDecorationRegistered = this._register(new Emitter()); public readonly onDecorationRegistered = this._onDecorationRegistered.event; private readonly _onDecorationRemoved = this._register(new Emitter()); @@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService public get decorations(): IterableIterator { return this._decorations.values(); } - constructor(@ILogService private readonly _logService: ILogService) { + constructor( + @ILogService private readonly _logService: ILogService, + @IBufferService private readonly _bufferService: IBufferService + ) { super(); this._decorations = new SortedList(e => e?.marker.line, this._logService); this._register(toDisposable(() => this.reset())); + this._register(this._bufferService.buffers.onBufferActivate(() => { + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); + })); + this._lineCache.attachToBufferLines(this._bufferService.buffer.lines); } public registerDecoration(options: IDecorationOptions): IDecoration | undefined { @@ -53,12 +62,14 @@ export class DecorationService extends Disposable implements IDecorationService listener.dispose(); if (decoration) { if (this._decorations.delete(decoration)) { + this._lineCache.remove(decoration); this._onDecorationRemoved.fire(decoration); } markerDispose.dispose(); } }); this._decorations.insert(decoration); + this._lineCache.add(decoration); this._onDecorationRegistered.fire(decoration); } return decoration; @@ -69,19 +80,17 @@ export class DecorationService extends Disposable implements IDecorationService d.dispose(); } this._decorations.clear(); + this._lineCache.clear(); } public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator { + 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 = new Map(); + private readonly _decorations = new Set(); + private readonly _bufferLineListeners = this._register(new MutableDisposable()); + private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer()); + private _lineIndexSyncCallbacks: (() => void)[] = []; + + public clear(): void { + this._lineIndexSyncCallbacks.length = 0; + this._lineIndexSyncTimer.cancel(); + this._decorationsByLine.clear(); + this._decorations.clear(); + } + + public add(decoration: IInternalDecoration): void { + this._decorations.add(decoration); + this._addToLineBuckets(decoration); + } + + public remove(decoration: IInternalDecoration): void { + this._decorations.delete(decoration); + this._removeFromLineBuckets(decoration); + } + + public getDecorationsOnLine(line: number): ReadonlyArray | undefined { + return this._decorationsByLine.get(line); + } + + public attachToBufferLines(lines: ICircularList): void { + const store = new DisposableStore(); + this._bufferLineListeners.value = store; + store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount))); + store.add(lines.onInsert(event => this._handleBufferLinesInsert(event))); + store.add(lines.onDelete(event => this._handleBufferLinesDelete(event))); + } + + private _getDecorationHeight(decoration: IInternalDecoration): number { + return decoration.options.height ?? 1; + } + + private _addToLineBuckets(decoration: IInternalDecoration): void { + const start = decoration.marker.line; + if (start < 0) { + return; + } + decoration._indexedStartLine = start; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + let bucket = this._decorationsByLine.get(line); + if (!bucket) { + bucket = []; + this._decorationsByLine.set(line, bucket); + } + bucket.push(decoration); + } + } + + private _removeFromLineBuckets(decoration: IInternalDecoration): void { + const start = decoration._indexedStartLine; + const height = this._getDecorationHeight(decoration); + for (let line = start; line < start + height; line++) { + const bucket = this._decorationsByLine.get(line); + if (!bucket) { + continue; + } + const index = bucket.indexOf(decoration); + if (index !== -1) { + bucket.splice(index, 1); + } + if (bucket.length === 0) { + this._decorationsByLine.delete(line); + } + } + } + + private _reindexDecoration(decoration: IInternalDecoration): void { + this._removeFromLineBuckets(decoration); + if (!decoration.marker.isDisposed && decoration.marker.line >= 0) { + this._addToLineBuckets(decoration); + } + } + + /** Re-index after marker line updates (buffer listeners may run before markers). */ + private _scheduleLineIndexSync(callback: () => void): void { + this._lineIndexSyncCallbacks.push(callback); + this._lineIndexSyncTimer.set(() => { + const callbacks = this._lineIndexSyncCallbacks; + this._lineIndexSyncCallbacks = []; + for (const cb of callbacks) { + cb(); + } + }); + } + + private _handleBufferLinesTrim(amount: number): void { + if (amount <= 0) { + return; + } + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line - amount; + if (newLine < 0) { + continue; + } + 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, 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(); + for (const [line, bucket] of this._decorationsByLine) { + const newLine = line >= index ? line + amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + if (d._indexedStartLine >= index) { + d._indexedStartLine = d.marker.line; + } + } + for (const d of spanCrossers) { + this._addToLineBuckets(d); + } + } + + /** + * Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a + * multi-line decoration spans across the deleted range but survives. + */ + private _applyBufferLinesDelete(event: IDeleteEvent): void { + const deleteEnd = event.index + event.amount; + const newMap = new Map(); + for (const [line, bucket] of this._decorationsByLine) { + if (line >= event.index && line < deleteEnd) { + continue; + } + const newLine = line >= deleteEnd ? line - event.amount : line; + this._mergeLineBucket(newMap, newLine, bucket); + } + this._decorationsByLine.clear(); + for (const [line, bucket] of newMap) { + this._decorationsByLine.set(line, bucket); + } + const toReindex: IInternalDecoration[] = []; + for (const d of this._decorations) { + if (d.marker.isDisposed) { + continue; + } + const start = d._indexedStartLine; + const height = this._getDecorationHeight(d); + if (start >= deleteEnd) { + d._indexedStartLine = d.marker.line; + } else if (start < event.index && start + height > deleteEnd) { + toReindex.push(d); + } + } + for (const d of toReindex) { + this._reindexDecoration(d); + } + } +} + class Decoration extends DisposableStore implements IInternalDecoration { public readonly marker: IMarker; public element: HTMLElement | undefined; + /** Start line used for line-index removal when marker.line is cleared on dispose. */ + public _indexedStartLine: number; + public readonly onRenderEmitter = this.add(new Emitter()); public readonly onRender = this.onRenderEmitter.event; private readonly _onDispose = this.add(new Emitter()); @@ -144,6 +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'; } diff --git a/src/common/services/ServiceRegistry.ts b/src/common/services/ServiceRegistry.ts index 7d887bc61..af51d1641 100644 --- a/src/common/services/ServiceRegistry.ts +++ b/src/common/services/ServiceRegistry.ts @@ -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> = new Map(); export function getServiceDependencies(ctor: any): { id: IServiceIdentifier, index: number, optional: boolean }[] { - return ctor[DI_DEPENDENCIES] || []; + return ctor[Constants.DI_DEPENDENCIES] || []; } export function createDecorator(id: string): IServiceIdentifier { @@ -40,10 +42,10 @@ export function createDecorator(id: string): IServiceIdentifier { } 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; } } diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts index a15dc23dc..a858193c3 100644 --- a/src/common/services/Services.ts +++ b/src/common/services/Services.ts @@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration { readonly backgroundColorRGB: IColor | undefined; readonly foregroundColorRGB: IColor | undefined; readonly onRenderEmitter: Emitter; + /** @internal Start line for line-index removal; kept in sync on buffer line shifts. */ + _indexedStartLine: number; } diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index 22501b9cf..31a893756 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -3,7 +3,9 @@ "compilerOptions": { "lib": [ "es2015", - "es2016.Array.Include" + "es2016.Array.Include", + "es2019.string", + "es2021.weakref" ], "outDir": "../../out", "types": [ diff --git a/src/headless/tsconfig.json b/src/headless/tsconfig.json index 6ae7b1dc9..0c445bcfc 100644 --- a/src/headless/tsconfig.json +++ b/src/headless/tsconfig.json @@ -3,7 +3,8 @@ "compilerOptions": { "lib": [ "es2015", - "es2016.Array.Include" + "es2016.Array.Include", + "es2021.weakref" ], "outDir": "../../out", "types": [ diff --git a/test/benchmark/DecorationService.benchmark.ts b/test/benchmark/DecorationService.benchmark.ts new file mode 100644 index 000000000..1a2033de1 --- /dev/null +++ b/test/benchmark/DecorationService.benchmark.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2026 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { perfContext, before, RuntimeCase } from 'xterm-benchmark'; +import { DecorationService } from 'common/services/DecorationService'; +import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test'; +const enum Constants { + COLS = 80, + ROWS = 30, + SINGLE_LINE_DECORATION_COUNT = 20_000, + MULTI_LINE_DECORATION_COUNT = 19_999, + MULTI_LINE_HEIGHT = 2, + VIEWPORT_SCAN_ITERATIONS = 20 +} + +function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void { + const buffer = bufferService.buffer; + for (let i = 0; i < count; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } +} + +function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void { + const buffer = bufferService.buffer; + for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) { + const line = i % 5000; + const marker = buffer.addMarker(line); + service.registerDecoration({ marker, width: Constants.COLS }); + } + const multiLineMarker = buffer.addMarker(10); + service.registerDecoration({ + marker: multiLineMarker, + width: Constants.COLS, + height: Constants.MULTI_LINE_HEIGHT + }); +} + +function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number { + let hitCount = 0; + for (let row = 0; row < rows; row++) { + for (let col = 0; col < cols; col++) { + service.forEachDecorationAtCell(col, row, undefined, () => { + hitCount++; + }); + } + } + return hitCount; +} + +perfContext('DecorationService.forEachDecorationAtCell', () => { + perfContext('single-line dense / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 0, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('mixed single-line + multi-line / sparse line hit', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerMixedDecorations(service, bufferService); + }); + new RuntimeCase('', () => { + let hitCount = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + service.forEachDecorationAtCell(0, 10, undefined, () => { + hitCount++; + }); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount }; + }, { fork: false }).showAverageRuntime(); + }); + + perfContext('viewport grid scan', () => { + let service: DecorationService; + let bufferService: MockBufferService; + before(() => { + bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService()); + service = new DecorationService(new MockLogService(), bufferService); + registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT); + }); + new RuntimeCase('', () => { + let totalHits = 0; + for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) { + totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS); + } + return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits }; + }, { fork: false }).showAverageRuntime(); + }); +}); diff --git a/test/benchmark/EscapeSequenceParser.benchmark.ts b/test/benchmark/EscapeSequenceParser.benchmark.ts index 10c447973..5c9f372fb 100644 --- a/test/benchmark/EscapeSequenceParser.benchmark.ts +++ b/test/benchmark/EscapeSequenceParser.benchmark.ts @@ -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(); + }); }); diff --git a/test/benchmark/Terminal.benchmark.ts b/test/benchmark/Terminal.benchmark.ts index 4944495b8..fb06b429e 100644 --- a/test/benchmark/Terminal.benchmark.ts +++ b/test/benchmark/Terminal.benchmark.ts @@ -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(res => terminal.write(content, res)); + await new Promise(res => terminal.write(contentUtf8, res)); return { payloadSize: contentUtf8.length }; }, { fork: false }).showAverageThroughput(); }); diff --git a/test/playwright/Parser.test.ts b/test/playwright/Parser.test.ts index 989d7fd38..6f61fb8c9 100644 --- a/test/playwright/Parser.test.ts +++ b/test/playwright/Parser.test.ts @@ -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; }) diff --git a/test/playwright/SharedRendererTests.ts b/test/playwright/SharedRendererTests.ts index 6754dc6f9..db5c4c073 100644 --- a/test/playwright/SharedRendererTests.ts +++ b/test/playwright/SharedRendererTests.ts @@ -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 diff --git a/typings/xterm-headless.d.ts b/typings/xterm-headless.d.ts index de8d06c42..d3b62346d 100644 --- a/typings/xterm-headless.d.ts +++ b/typings/xterm-headless.d.ts @@ -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; } /** diff --git a/typings/xterm.d.ts b/typings/xterm.d.ts index c8e08f509..d1ce877b9 100644 --- a/typings/xterm.d.ts +++ b/typings/xterm.d.ts @@ -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): IDisposable; + registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise): IDisposable; } /**