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/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..75ff03313 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,7 @@ 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. - [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-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 e34b387c7..7cacb88a0 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, SequenceType } from './IIPHeaderParser'; import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics'; @@ -37,6 +38,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; private _isMultipart = false; private _abortMulti = false; @@ -50,6 +52,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 {} @@ -165,27 +168,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 79b83a977..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); } @@ -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..f449f3186 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) { @@ -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..ac5acae77 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(); 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-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index f5be446f6..568d3a4f9 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; } @@ -364,7 +366,7 @@ 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 { diff --git a/addons/addon-webgl/src/RectangleRenderer.ts b/addons/addon-webgl/src/RectangleRenderer.ts index d22068bab..4ad8d66ff 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 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/WebglRenderer.ts b/addons/addon-webgl/src/WebglRenderer.ts index 83f20c9e7..91867ea26 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'; @@ -493,7 +493,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 +538,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 +553,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 +566,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/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/package-lock.json b/package-lock.json index 140ec6980..ebe3acbef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,7 +80,7 @@ "license": "MIT", "devDependencies": { "sixel": "^0.16.0", - "xterm-wasm-parts": "^0.3.0" + "xterm-wasm-parts": "^0.4.1" } }, "addons/addon-ligatures": { @@ -245,6 +245,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -558,6 +559,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" }, @@ -599,6 +601,7 @@ "url": "https://opencollective.com/csstools" } ], + "peer": true, "engines": { "node": ">=18" } @@ -1796,6 +1799,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz", "integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -2361,6 +2365,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2752,6 +2757,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3747,6 +3753,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7016,6 +7023,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -7856,6 +7864,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -8022,6 +8031,7 @@ "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz", "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -8070,6 +8080,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^1.2.0", @@ -8441,9 +8452,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..3f059d109 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\"", @@ -65,15 +66,14 @@ "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", 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/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/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/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 a5eea2e8e..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; })(); @@ -439,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); @@ -719,7 +746,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP } // 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: // Note: 0x20 (SP) is included, 0x7F (DEL) is excluded @@ -872,16 +902,18 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP 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: diff --git a/src/common/parser/Params.ts b/src/common/parser/Params.ts index 31872a0b2..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); @@ -159,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; } /** @@ -181,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]++; } @@ -237,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 cfa00c1a8..c04788f7e 100644 --- a/src/common/parser/Types.ts +++ b/src/common/parser/Types.ts @@ -220,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; @@ -252,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/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/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/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/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/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; } /**