Merge branch 'master' into fix/addon-ligatures-prepublish-only
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
---
|
||||
applyTo: '**/*.benchmark.ts'
|
||||
name: benchmark
|
||||
description: Run and author benchmark tests in xterm.js. Use when editing benchmark files, working in `*.benchmark.ts`, or when asked how to run the full benchmark suite, a single benchmark file, or a single benchmark case.
|
||||
---
|
||||
# Benchmark run instructions
|
||||
|
||||
# Benchmark Run Instructions
|
||||
|
||||
## Running Benchmarks
|
||||
|
||||
- Full suite: `npm run benchmark`
|
||||
- Single benchmark file:
|
||||
@@ -11,8 +15,14 @@ applyTo: '**/*.benchmark.ts'
|
||||
- Use `-t` to get the path, then:
|
||||
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
|
||||
|
||||
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||
## Benchmark Case Selection
|
||||
|
||||
When writing benchmark instructions:
|
||||
|
||||
- Use `RuntimeCase` to measure pure runtime in ms.
|
||||
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
|
||||
|
||||
## Notes
|
||||
|
||||
Notes:
|
||||
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
|
||||
- Keep `NODE_PATH=./out` (handled by the npm script).
|
||||
@@ -1,7 +1,11 @@
|
||||
---
|
||||
applyTo: '**/*.test.ts'
|
||||
name: unit-test
|
||||
description: Write and review xterm.js unit tests with project conventions. Use when editing `*.test.ts` files or when asked for test authoring guidelines.
|
||||
---
|
||||
When writing unit tests follow these rules:
|
||||
|
||||
# Unit Test Instructions
|
||||
|
||||
When writing unit tests, follow these rules:
|
||||
|
||||
- When writing unit tests for addons, always create a real xterm.js instance instead of mocking it.
|
||||
- Prefer `assert.ok` over `assert.notStrictEqual` when checking something is undefined or not.
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npm run agent:setup-repo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+13
-13
@@ -207,31 +207,31 @@ jobs:
|
||||
- name: Build demo server
|
||||
run: npm run esbuild-demo-server
|
||||
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
|
||||
- name: Integration tests (addon-attach)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
|
||||
- name: Integration tests (addon-clipboard)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
|
||||
- name: Integration tests (addon-fit)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
|
||||
- name: Integration tests (addon-image)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
|
||||
- name: Integration tests (addon-progress)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
|
||||
- name: Integration tests (addon-search)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
|
||||
- name: Integration tests (addon-serialize)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
|
||||
- name: Integration tests (addon-unicode-graphemes)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
|
||||
- name: Integration tests (addon-unicode11)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
|
||||
- name: Integration tests (addon-web-fonts)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
|
||||
- name: Integration tests (addon-web-links)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
|
||||
- name: Integration tests (addon-webgl)
|
||||
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
|
||||
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
|
||||
|
||||
release-dry-run:
|
||||
needs: build
|
||||
|
||||
@@ -22,6 +22,9 @@ build/
|
||||
yarn.lock
|
||||
test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
|
||||
# Keep bundled code out of Git
|
||||
dist/
|
||||
demo/dist/
|
||||
|
||||
@@ -57,6 +57,13 @@ export class MyAddon implements ITerminalAddon {
|
||||
- Proposed APIs require `allowProposedApi: true` option
|
||||
- Constructor-only options (cols, rows) cannot be changed after instantiation
|
||||
|
||||
**Disposable Management**:
|
||||
- When a disposable object can be replaced over time, prefer a registered `MutableDisposable` over manual dispose/reassign logic.
|
||||
- Register it on the owning class (for example, `this._register(new MutableDisposable())`) and assign through `.value`; this automatically disposes the previous value and avoids accidentally leaking resources.
|
||||
|
||||
**TypeScript Constants**:
|
||||
- Prefer `const enum` over top-level `const` declarations for primitive constants when appropriate, since values are inlined and avoid runtime property lookups.
|
||||
|
||||
**Testing Utilities**: Use `TestUtils.ts` helpers:
|
||||
- `openTerminal(ctx, options)` for setup
|
||||
- `pollFor(page, fn, expectedValue)` for async assertions
|
||||
@@ -252,6 +252,8 @@ Xterm.js is used in many world-class applications to provide great terminal expe
|
||||
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
|
||||
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
|
||||
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
|
||||
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
|
||||
- [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer.
|
||||
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
|
||||
|
||||
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
|
||||
|
||||
@@ -21,8 +21,5 @@
|
||||
"package": "../../node_modules/.bin/webpack",
|
||||
"prepublishOnly": "npm run package",
|
||||
"start": "node ../../demo/start"
|
||||
},
|
||||
"dependencies": {
|
||||
"js-base64": "^3.7.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
*/
|
||||
|
||||
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
|
||||
import { Base64 as JSBase64 } from 'js-base64';
|
||||
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
|
||||
|
||||
export class ClipboardAddon implements ITerminalAddon {
|
||||
private _terminal?: Terminal;
|
||||
@@ -25,7 +24,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
return this._disposable?.dispose();
|
||||
}
|
||||
|
||||
private _readText(sel: ClipboardSelectionType, data: string): void {
|
||||
private _readText(sel: string, data: string): void {
|
||||
const b64 = this._base64.encodeText(data);
|
||||
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
|
||||
}
|
||||
@@ -36,7 +35,7 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pc = args[0] as ClipboardSelectionType;
|
||||
const pc = args[0];
|
||||
const pd = args[1];
|
||||
if (pd === '?') {
|
||||
const text = this._provider.readText(pc);
|
||||
@@ -70,30 +69,23 @@ export class ClipboardAddon implements ITerminalAddon {
|
||||
}
|
||||
|
||||
export class BrowserClipboardProvider implements IClipboardProvider {
|
||||
public async readText(selection: ClipboardSelectionType): Promise<string> {
|
||||
if (selection !== 'c') {
|
||||
return Promise.resolve('');
|
||||
}
|
||||
public readText(selection: string): Promise<string> {
|
||||
return navigator.clipboard.readText();
|
||||
}
|
||||
|
||||
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
|
||||
if (selection !== 'c') {
|
||||
return Promise.resolve();
|
||||
}
|
||||
public writeText(selection: string, text: string): Promise<void> {
|
||||
return navigator.clipboard.writeText(text);
|
||||
}
|
||||
}
|
||||
|
||||
export class Base64 implements IBase64 {
|
||||
public encodeText(data: string): string {
|
||||
return JSBase64.encode(data);
|
||||
return btoa(data);
|
||||
}
|
||||
public decodeText(data: string): string {
|
||||
const text = JSBase64.decode(data);
|
||||
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
|
||||
return '';
|
||||
}
|
||||
return text;
|
||||
try {
|
||||
return atob(data);
|
||||
} catch {}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ test.describe('ClipboardAddon', () => {
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// DEBT: This test doesn't work since the migration to @playwright/test
|
||||
if (ctx.browser.browserType().name() !== 'chromium') {
|
||||
if (ctx.browser.browserType().name() === 'webkit') {
|
||||
testInfo.skip();
|
||||
return;
|
||||
}
|
||||
@@ -48,6 +48,14 @@ test.describe('ClipboardAddon', () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||
});
|
||||
test('primary selection', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;p;${testDataEncoded}\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||
});
|
||||
test('empty selection (default)', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;;${testDataEncoded}\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), testDataDecoded);
|
||||
});
|
||||
test('invalid base64 string', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;${testDataEncoded}invalid\x07`);
|
||||
deepEqual(await ctx.page.evaluate(() => window.navigator.clipboard.readText()), '');
|
||||
@@ -60,15 +68,24 @@ test.describe('ClipboardAddon', () => {
|
||||
});
|
||||
|
||||
test.describe('read data', async function (): Promise<any> {
|
||||
test('simple string', async () => {
|
||||
test.beforeAll(async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.data = [];
|
||||
window.term.onData(e => data.push(e));
|
||||
`);
|
||||
});
|
||||
test('simple string', async () => {
|
||||
await ctx.page.evaluate('window.data.length = 0;');
|
||||
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
|
||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;c;${testDataEncoded}\x07`]);
|
||||
});
|
||||
test('primary selection', async () => {
|
||||
await ctx.page.evaluate('window.data.length = 0;');
|
||||
await ctx.page.evaluate(() => window.navigator.clipboard.writeText('hello world'));
|
||||
await ctx.proxy.write(`\x1b]52;p;?\x07`);
|
||||
deepEqual(await ctx.page.evaluate('window.data'), [`\x1b]52;p;${testDataEncoded}\x07`]);
|
||||
});
|
||||
test('clear clipboard', async () => {
|
||||
await ctx.proxy.write(`\x1b]52;c;!\x07`);
|
||||
await ctx.proxy.write(`\x1b]52;c;?\x07`);
|
||||
|
||||
+12
-21
@@ -30,18 +30,6 @@ declare module '@xterm/addon-clipboard' {
|
||||
public dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Clipboard selection type. This is used to specify which selection buffer to
|
||||
* read or write to.
|
||||
* - SYSTEM `c`: The system clipboard.
|
||||
* - PRIMARY `p`: The primary clipboard. This is provided for compatibility
|
||||
* with Linux X11.
|
||||
*/
|
||||
export const enum ClipboardSelectionType {
|
||||
SYSTEM = 'c',
|
||||
PRIMARY = 'p',
|
||||
}
|
||||
|
||||
export interface IBase64 {
|
||||
/**
|
||||
* Converts a utf-8 string to a base64 string.
|
||||
@@ -78,36 +66,39 @@ declare module '@xterm/addon-clipboard' {
|
||||
export interface IClipboardProvider {
|
||||
/**
|
||||
* Gets the clipboard content.
|
||||
* @param selection The clipboard selection to read.
|
||||
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
|
||||
* @returns A promise that resolves with clipboard selection data.
|
||||
*/
|
||||
readText(selection: ClipboardSelectionType): string | Promise<string>;
|
||||
readText(selection: string): string | Promise<string>;
|
||||
|
||||
/**
|
||||
* Sets the clipboard content.
|
||||
* @param selection The clipboard selection to set.
|
||||
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
|
||||
* @param data The clipboard text to write.
|
||||
*/
|
||||
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
|
||||
writeText(selection: string, text: string): void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The clipboard provider interface that enables xterm.js to access the system clipboard.
|
||||
* The clipboard provider interface that enables xterm.js to access the browser clipboard.
|
||||
*
|
||||
* Note this this clipboard provider does not implement own cut buffers as xterm does,
|
||||
* but redirect the selection parameter always to navigator.clipboard.
|
||||
*/
|
||||
export class BrowserClipboardProvider implements IClipboardProvider{
|
||||
/**
|
||||
* Reads text from the clipboard.
|
||||
* @param selection The selection type to read from.
|
||||
* @param selection The selection type to read from (always refers to navigator.clipboard).
|
||||
* @returns A promise that resolves with the text from the clipboard.
|
||||
*/
|
||||
public readText(selection: ClipboardSelectionType): Promise<string>;
|
||||
public readText(selection: string): Promise<string>;
|
||||
|
||||
/**
|
||||
* Writes text to the clipboard.
|
||||
* @param selection The selection type to write to.
|
||||
* @param selection The selection type to write to (always refers to navigator.clipboard).
|
||||
* @param data The text to write to the clipboard.
|
||||
* @returns A promise that resolves when the text has been written to the clipboard.
|
||||
*/
|
||||
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
|
||||
public writeText(selection: string, data: string): Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -25,6 +25,6 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"sixel": "^0.16.0",
|
||||
"xterm-wasm-parts": "^0.3.0"
|
||||
"xterm-wasm-parts": "^0.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ImageRenderer } from './ImageRenderer';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import QoiDecoder from 'xterm-wasm-parts/lib/qoi/QoiDecoder.wasm';
|
||||
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
|
||||
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
|
||||
|
||||
@@ -36,6 +37,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
private _hp = new HeaderParser();
|
||||
private _header: IHeaderFields = DEFAULT_HEADER;
|
||||
private _dec: Base64Decoder;
|
||||
private _qoiDec: QoiDecoder;
|
||||
private _metrics = UNSUPPORTED_TYPE;
|
||||
|
||||
constructor(
|
||||
@@ -47,6 +49,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
|
||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||
this._qoiDec = new QoiDecoder(DecoderConst.KEEP_DATA);
|
||||
}
|
||||
|
||||
public reset(): void {}
|
||||
@@ -115,27 +118,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return true;
|
||||
}
|
||||
|
||||
// HACK: The types on Blob are too restrictive, this is a Uint8Array so the browser accepts it
|
||||
const blob = new Blob([this._dec.data8 as Uint8Array<ArrayBuffer>], { type: this._metrics.mime });
|
||||
this._dec.release();
|
||||
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<boolean>(r => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, w, h);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0, w, h);
|
||||
this._storage.addImage(canvas);
|
||||
r(true);
|
||||
});
|
||||
img.src = url;
|
||||
// sanity measure to avoid terminal blocking from dangling promise
|
||||
// happens from corrupt data (onload never gets fired)
|
||||
setTimeout(() => r(true), 1000);
|
||||
});
|
||||
let blob: Blob | ImageData;
|
||||
if (this._metrics.mime === 'image/qoi') {
|
||||
const data = this._qoiDec.decode(this._dec.data8);
|
||||
blob = new ImageData(
|
||||
new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength),
|
||||
this._qoiDec.width,
|
||||
this._qoiDec.height
|
||||
);
|
||||
this._qoiDec.release();
|
||||
if (w === this._qoiDec.width && h === this._qoiDec.height) {
|
||||
// use fast-path if we don't need to rescale
|
||||
this._dec.release();
|
||||
const canvas = ImageRenderer.createCanvas(undefined, this._qoiDec.width, this._qoiDec.height);
|
||||
canvas.getContext('2d')?.putImageData(blob, 0, 0);
|
||||
this._storage.addImage(canvas);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
blob = new Blob([this._dec.data8], { type: this._metrics.mime });
|
||||
}
|
||||
this._dec.release();
|
||||
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
|
||||
.then(bm => {
|
||||
this._storage.addImage(bm);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,20 +16,61 @@ import { SixelImageStorage } from './SixelImageStorage';
|
||||
import { IIPImageStorage } from './IIPImageStorage';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
|
||||
|
||||
/**
|
||||
* Document VT features provided by this addon.
|
||||
*
|
||||
* @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
|
||||
*
|
||||
* Sixel support is provided by the addon @xterm/addon-image with these limitations:
|
||||
* - immediate coloring (no shared palette, allows high color settings of `img2sixel`)
|
||||
* - max. palette size of 4096 colors
|
||||
* - max. pixel width of 16K
|
||||
* - max. 25 MB per sixel sequence
|
||||
* - VT340 cursor positioning (begin of last sixel data row)
|
||||
*
|
||||
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image) for more details.
|
||||
*
|
||||
*
|
||||
* @vt: #E[Supported via @xterm/addon-image.] OSC 1337 "iTerm2 Commands" "OSC 1337 ; Pt BEL" "Custom iTerm2 commands."
|
||||
*
|
||||
* Only the inline image protocol (IIP) is supported by the addon @xterm/addon-image with
|
||||
* the following limitations:
|
||||
* - sequence:
|
||||
* - format: `OSC 1337 ; File=inline=1 ; size=<unencoded size> ; ... : <base64 payload> BEL`
|
||||
* - size param must be set and payload may not exceed CEIL(size * 4 / 3)
|
||||
* - strict base64 handling as of RFC4648 §4 (standard alphabet, optional padding,
|
||||
* no separator bytes allowed)
|
||||
* - supported params: size, name, width, height, preserveAspectRatio
|
||||
* - image formats: PNG, JPEG and GIF
|
||||
* - no animation support (renders first image of a GIF)
|
||||
* - no multipart support
|
||||
* - VT340 cursor positioning (begin of last sixel data row)
|
||||
*
|
||||
* See [addon readme](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-image)
|
||||
* and [iTerm2 IIP docs](https://iterm2.com/documentation-images.html) for more details.
|
||||
*
|
||||
*
|
||||
* @vt: #E[Supported via @xterm/addon-image.] APC KITTY_GRAPHICS "Kitty Graphics" "APC G Pt ST" "Kitty Graphics Protocol."
|
||||
*
|
||||
* Kitty graphics support is provided by the addon @xterm/addon-image.
|
||||
* Note that while basic image output already works, this is still work in progress.
|
||||
*/
|
||||
|
||||
// default values of addon ctor options
|
||||
const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
enableSizeReports: true,
|
||||
pixelLimit: 16777216, // limit to 4096 * 4096 pixels
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 256,
|
||||
sixelSizeLimit: 25000000,
|
||||
sixelPaletteLimit: 4096,
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -166,7 +207,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
this._disposeLater(
|
||||
kittyStorage,
|
||||
kittyHandler,
|
||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
||||
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import { IDisposable } from '@xterm/xterm';
|
||||
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const PLACEHOLDER_HEIGHT = 24;
|
||||
const enum Constants {
|
||||
PLACEHOLDER_LENGTH = 4096,
|
||||
PLACEHOLDER_HEIGHT = 24
|
||||
}
|
||||
|
||||
/**
|
||||
* ImageRenderer - terminal frontend extension:
|
||||
@@ -110,7 +112,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
public showPlaceholder(value: boolean): void {
|
||||
if (value) {
|
||||
if (!this._placeholder && this.cellSize.height !== -1) {
|
||||
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
|
||||
this._createPlaceHolder(Math.max(this.cellSize.height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||
}
|
||||
} else {
|
||||
this._placeholderBitmap?.close();
|
||||
@@ -249,7 +251,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
}
|
||||
|
||||
if (!this._placeholder) {
|
||||
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
|
||||
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
|
||||
} else if (height >= this._placeholder!.height) {
|
||||
this._createPlaceHolder(height + 1);
|
||||
}
|
||||
@@ -358,7 +360,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
canvas.style.zIndex = '0';
|
||||
screenElement.appendChild(canvas);
|
||||
}
|
||||
const ctx = canvas.getContext('2d', { alpha: true, desynchronized: true });
|
||||
const ctx = canvas.getContext('2d', { alpha: true });
|
||||
if (!ctx) {
|
||||
canvas.remove();
|
||||
return;
|
||||
@@ -379,7 +381,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
return this._layers.has(layer);
|
||||
}
|
||||
|
||||
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
|
||||
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
|
||||
this._placeholderBitmap?.close();
|
||||
this._placeholderBitmap = undefined;
|
||||
|
||||
@@ -403,7 +405,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
|
||||
// create placeholder line, width aligned to blueprint width
|
||||
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
|
||||
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || Constants.PLACEHOLDER_LENGTH;
|
||||
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
|
||||
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
|
||||
if (!ctx2) {
|
||||
|
||||
@@ -16,24 +16,23 @@ import {
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
BYTES_PER_PIXEL_RGB,
|
||||
BYTES_PER_PIXEL_RGBA,
|
||||
ALPHA_OPAQUE,
|
||||
KittyPixelConstants,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
const DECODER_KEEP_DATA = 4194304;
|
||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
||||
const enum Constants {
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
DECODER_KEEP_DATA = 4194304,
|
||||
DECODER_INITIAL_DATA = 4194304, // 4MB
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
DECODER_OK = 0,
|
||||
// Maximum control data size
|
||||
MAX_CONTROL_DATA_SIZE = 512,
|
||||
// Semicolon codepoint
|
||||
SEMICOLON = 0x3B
|
||||
}
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
|
||||
// Maximum control data size
|
||||
const MAX_CONTROL_DATA_SIZE = 512;
|
||||
|
||||
// Semicolon codepoint
|
||||
const SEMICOLON = 0x3B;
|
||||
const DECODER_OK = Constants.DECODER_OK as unknown as DecodeStatus.OK;
|
||||
|
||||
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||
@@ -50,7 +49,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
private _inControlData = true;
|
||||
|
||||
// Buffer for control data.
|
||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
||||
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
|
||||
private _controlLength = 0;
|
||||
|
||||
// Pre-calculated encoded size limit
|
||||
@@ -77,7 +76,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
// Convert decoded size limit -> max encoded bytes.
|
||||
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
this._initialEncodedBytes = Math.min(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
@@ -129,7 +128,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
// Scan for semicolon
|
||||
let controlEnd = end;
|
||||
for (let i = start; i < end; i++) {
|
||||
if (data[i] === SEMICOLON) {
|
||||
if (data[i] === Constants.SEMICOLON) {
|
||||
this._inControlData = false;
|
||||
controlEnd = i;
|
||||
break;
|
||||
@@ -138,7 +137,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
// Copy control data
|
||||
const copyLength = controlEnd - start;
|
||||
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
||||
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
@@ -201,7 +200,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
this._activeDecoder = pending.decoder;
|
||||
}
|
||||
if (!this._activeDecoder) {
|
||||
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder.init();
|
||||
}
|
||||
|
||||
@@ -484,7 +483,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
return true;
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
@@ -534,8 +533,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
private _sendResponse(id: number, message: string, quiet: number, placementId?: number): void {
|
||||
const isOk = message === 'OK';
|
||||
if (isOk && quiet === 1) return;
|
||||
if (!isOk && quiet === 2) return;
|
||||
if (isOk && quiet >= 1) return;
|
||||
if (!isOk && quiet >= 2) return;
|
||||
|
||||
const pPart = placementId ? `,p=${placementId}` : '';
|
||||
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
|
||||
@@ -723,7 +722,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
throw new Error('Width and height required for raw pixel data');
|
||||
}
|
||||
|
||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
@@ -734,13 +733,13 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
|
||||
if (image.format === KittyFormat.RGBA) {
|
||||
// RGBA: use bytes directly — no copy needed
|
||||
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * BYTES_PER_PIXEL_RGBA), width, height));
|
||||
return createImageBitmap(new ImageData(new Uint8ClampedArray(bytes.buffer as ArrayBuffer, bytes.byteOffset, pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA), width, height));
|
||||
}
|
||||
|
||||
// RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
|
||||
// 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
|
||||
// Assumes little-endian (all modern browsers/Node.js).
|
||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
||||
const data = new Uint8ClampedArray(pixelCount * KittyPixelConstants.BYTES_PER_PIXEL_RGBA);
|
||||
const src32 = new Uint32Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 4));
|
||||
const dst32 = new Uint32Array(data.buffer);
|
||||
const alignedPixels = pixelCount & ~3; // round down to multiple of 4
|
||||
@@ -759,15 +758,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDispos
|
||||
}
|
||||
|
||||
// Handle remaining 1–3 pixels
|
||||
let srcByte = alignedPixels * BYTES_PER_PIXEL_RGB;
|
||||
let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
|
||||
let srcByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
let dstByte = alignedPixels * KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||
for (let i = alignedPixels; i < pixelCount; i++) {
|
||||
data[dstByte] = bytes[srcByte];
|
||||
data[dstByte + 1] = bytes[srcByte + 1];
|
||||
data[dstByte + 2] = bytes[srcByte + 2];
|
||||
data[dstByte + 3] = ALPHA_OPAQUE;
|
||||
srcByte += BYTES_PER_PIXEL_RGB;
|
||||
dstByte += BYTES_PER_PIXEL_RGBA;
|
||||
data[dstByte + 3] = KittyPixelConstants.ALPHA_OPAQUE;
|
||||
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
|
||||
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
|
||||
}
|
||||
|
||||
return createImageBitmap(new ImageData(data, width, height));
|
||||
|
||||
@@ -82,9 +82,11 @@ export const enum KittyKey {
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const ALPHA_OPAQUE = 255;
|
||||
export const enum KittyPixelConstants {
|
||||
BYTES_PER_PIXEL_RGB = 3,
|
||||
BYTES_PER_PIXEL_RGBA = 4,
|
||||
ALPHA_OPAQUE = 255
|
||||
}
|
||||
|
||||
// Parsed Kitty graphics command.
|
||||
export interface IKittyCommand {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { readFileSync } from 'fs';
|
||||
import { FINALIZER, introducer, sixelEncode } from 'sixel';
|
||||
import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
|
||||
import { deepStrictEqual, ok, strictEqual } from 'assert';
|
||||
import QoiEncoder from 'xterm-wasm-parts/lib/qoi/QoiEncoder.wasm';
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
@@ -74,6 +75,10 @@ const TESTDATA_IIP: [string, [number, number]][] = [
|
||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_jpg.iip', { encoding: 'utf-8' }), [72, 48]],
|
||||
[readFileSync('./addons/addon-image/fixture/iip/w3c_png.iip', { encoding: 'utf-8' }), [72, 48]]
|
||||
];
|
||||
const PALETTE_PNG_BASE64 = readFileSync('./addons/addon-image/fixture/palette.png').toString('base64');
|
||||
const qoiEnc = new QoiEncoder(1024*1024);
|
||||
const qoiData = qoiEnc.encode(TESTDATA.bytes, 640, 80);
|
||||
const PALETTE_QOI_BASE64 = Buffer.from(qoiData).toString('base64');
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
@@ -85,11 +90,6 @@ test.afterAll(async () => await ctx.page.close());
|
||||
test.describe('ImageAddon', () => {
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// DEBT: This test never worked on webkit
|
||||
if (ctx.browser.browserType().name() === 'webkit') {
|
||||
testInfo.skip();
|
||||
return;
|
||||
}
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.imageAddon?.dispose();
|
||||
@@ -132,13 +132,13 @@ test.describe('ImageAddon', () => {
|
||||
sixelSupport: true,
|
||||
sixelScrolling: true,
|
||||
sixelPaletteLimit: 512, // set to 512 to get example image working
|
||||
sixelSizeLimit: 25000000,
|
||||
sixelSizeLimit: 33554432,
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
iipSizeLimit: 33554432,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
kittySizeLimit: 33554432
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -312,6 +312,68 @@ test.describe('ImageAddon', () => {
|
||||
deepStrictEqual(await getOrigSize(1), TESTDATA_IIP[4][1]);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP - QOI support', () => {
|
||||
test('palette should yield same bytes from PNG and QOI', async () => {
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=525:${PALETTE_PNG_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [640, 80]);
|
||||
await ctx.proxy.write(`\x1b[10H\x1b]1337;File=inline=1;size=${qoiData.length}:${PALETTE_QOI_BASE64}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [640, 80]);
|
||||
const pngScrape = await getImageAtBufferCell(0, 0);
|
||||
const qoiScrape = await getImageAtBufferCell(0, 11);
|
||||
deepStrictEqual(qoiScrape, pngScrape);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('IIP - resizing', () => {
|
||||
/**
|
||||
* The correct resize behavior is wonky and needs to be tested against iTerm2,
|
||||
* especially for missing params to derive correct default behavior.
|
||||
* We document the current behavior here w'o claiming to be fully in line with iTerm2.
|
||||
* ref: https://iterm2.com/documentation-images.html
|
||||
* imgcat: https://iterm2.com/utilities/imgcat
|
||||
*
|
||||
* NOTE: QOI has a slightly different parse path, thus we test resizing explicitly
|
||||
*/
|
||||
const images = [
|
||||
['palette.png', 525, PALETTE_PNG_BASE64],
|
||||
['palette.qoi', qoiData.length, PALETTE_QOI_BASE64]
|
||||
];
|
||||
for (const [name, size, payload] of images) {
|
||||
test(name + ': N --> width=20 height=5 preserveAspectRatio=0', async () => {
|
||||
// cell based resize
|
||||
const header = 'width=20;height=5;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [dim.cellWidth * 20, dim.cellHeight * 5]);
|
||||
});
|
||||
test(name + ': Npx --> width=320px height=160px preserveAspectRatio=0', async () => {
|
||||
// pixel based resize
|
||||
const header = 'width=320px;height=160px;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(1), [320, 160]);
|
||||
});
|
||||
test(name + ': N% --> width=50% height=30% preserveAspectRatio=0', async () => {
|
||||
// % of viewport resize
|
||||
const header = 'width=50%;height=30%;preserveAspectRatio=0';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
deepStrictEqual(await getOrigSize(1), [Math.floor(dim.width * 0.5), Math.floor(dim.height * 0.3)]);
|
||||
});
|
||||
test(name + ': ommitted dimension assumes preserveAspectRatio=1', async () => {
|
||||
// width provided in percent
|
||||
const header = 'width=50%';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header}:${payload}\x07`);
|
||||
const dim = await getDimensions();
|
||||
const width = Math.floor(dim.width * 0.5);
|
||||
deepStrictEqual(await getOrigSize(1), [width, Math.floor(width * 80 / 640)]);
|
||||
// height provided in pixel
|
||||
const header2 = 'height=200px';
|
||||
await ctx.proxy.write(`\x1b]1337;File=inline=1;size=${size};${header2}:${payload}\x07`);
|
||||
deepStrictEqual(await getOrigSize(2), [Math.floor(200 * 640 / 80), 200]);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -345,3 +407,7 @@ async function getOrigSize(id: number): Promise<[number, number]> {
|
||||
window.imageAddon._storage._images.get(${id}).orig.height
|
||||
]`);
|
||||
}
|
||||
|
||||
async function getImageAtBufferCell(x: number, y: number): Promise<string | undefined> {
|
||||
return ctx.page.evaluate<any>(`window.imageAddon.getImageAtBufferCell(${x}, ${y})?.toDataURL('image/png')`);
|
||||
}
|
||||
|
||||
@@ -109,11 +109,6 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
// TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
|
||||
|
||||
test.beforeEach(async ({}, testInfo) => {
|
||||
// DEBT: This test never worked on webkit
|
||||
if (ctx.browser.browserType().name() === 'webkit') {
|
||||
testInfo.skip();
|
||||
return;
|
||||
}
|
||||
await ctx.page.evaluate(`
|
||||
window.term.reset()
|
||||
window.imageAddon?.dispose();
|
||||
@@ -622,6 +617,18 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('suppresses OK response when q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=81,a=q,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('suppresses error response when q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
@@ -802,6 +809,30 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('a=t OK suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=181,a=t,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
|
||||
test('a=T OK suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Gi=191,a=T,q=2,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Transmission medium rejection', () => {
|
||||
@@ -1151,6 +1182,22 @@ test.describe('Kitty Graphics Protocol', () => {
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
});
|
||||
|
||||
test('OK response suppressed by q=2', async () => {
|
||||
await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
(window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
|
||||
});
|
||||
|
||||
await ctx.proxy.write(`\x1b_Ga=p,i=221,q=2\x1b\\`);
|
||||
await timeout(100);
|
||||
|
||||
strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
|
||||
strictEqual(await getImageStorageLength(), 1);
|
||||
});
|
||||
|
||||
test('ENOENT error suppressed by q=2', async () => {
|
||||
await ctx.page.evaluate(() => {
|
||||
(window as any).kittyGotResponse = false;
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"devDependencies": {
|
||||
"@types/lru-cache": "^5.1.0",
|
||||
"@types/opentype.js": "^0.7.0",
|
||||
"axios": "^1.6.0",
|
||||
"axios": "^1.15.2",
|
||||
"font-finder": "^1.1.0",
|
||||
"mkdirp": "0.5.5",
|
||||
"yauzl": "^3.2.1"
|
||||
|
||||
@@ -49,7 +49,14 @@ test.describe('SerializeAddon', () => {
|
||||
for (let i = 0; i < buffer.length; i++) {
|
||||
// Do this intentionally to get content of underlining source
|
||||
const bufferLine = buffer.getLine(i)._line;
|
||||
lines.push(JSON.stringify(bufferLine));
|
||||
lines.push(JSON.stringify(bufferLine, (key, value) => {
|
||||
// BufferLine caches are internal/transient and can legitimately differ
|
||||
// across equivalent terminal states.
|
||||
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}));
|
||||
}
|
||||
return {
|
||||
x: buffer.cursorX,
|
||||
|
||||
@@ -33,16 +33,16 @@ test.describe('WebFontsAddon', () => {
|
||||
test.describe('font loading at runtime', () => {
|
||||
test('loadFonts (JS)', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
||||
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||
loadFonts([ff1, ff2]);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||
});
|
||||
test('loadFonts (CSS, unquoted)', async () => {
|
||||
await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: BPdots; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||
loadFonts(['Kongtext', 'BPdots']);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }, { family: 'BPdots', status: 'loaded' }]);
|
||||
@@ -50,8 +50,8 @@ test.describe('WebFontsAddon', () => {
|
||||
test('loadFonts (CSS, quoted)', async ({ browser }) => {
|
||||
// NOTE: firefox preserves family quotes from CSS rules in fontface, all other browsers unquote them
|
||||
await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/bpdots.regular.otf) format('opentype')}", 1);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'Kongtext'; src: url(/fonts/kongtext.regular.ttf) format('truetype')}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: 'BPdots'; src: url(/fonts/bpdots.regular.otf) format('opentype')}", 1);
|
||||
loadFonts(['Kongtext', 'BPdots']);
|
||||
`);
|
||||
if (browser.browserType().name() === 'firefox') {
|
||||
@@ -63,8 +63,8 @@ test.describe('WebFontsAddon', () => {
|
||||
test('FontFace hashing', async () => {
|
||||
// multiple calls of `loadFonts` with the same objects shall not bloat document.fonts
|
||||
await ctx.page.evaluate(`
|
||||
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/bpdots.regular.otf) format('opentype')");
|
||||
const ff1 = new FontFace('Kongtext', "url(/fonts/kongtext.regular.ttf) format('truetype')");
|
||||
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
|
||||
loadFonts([ff1, ff2]);
|
||||
loadFonts([ff1, ff2]);
|
||||
loadFonts([ff1, ff2]).then(() => loadFonts([ff1, ff2]));
|
||||
@@ -76,7 +76,7 @@ test.describe('WebFontsAddon', () => {
|
||||
// to make this test work, we exclude the default measurement char W (x57) by restricting unicode-range
|
||||
// now the browser will postpone font loading until codepoint is hit --> wrong glyph metrics on first usage
|
||||
const data = await ctx.page.evaluate(`
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
|
||||
`);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
|
||||
@@ -88,7 +88,11 @@ test.describe('WebFontsAddon', () => {
|
||||
|
||||
// safari loads the font, firefox & chrome dont
|
||||
if (browser.browserType().name() === 'webkit') {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
try {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
|
||||
} catch (e) {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
}
|
||||
} else {
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
|
||||
}
|
||||
@@ -103,7 +107,10 @@ test.describe('WebFontsAddon', () => {
|
||||
window.helperTerm.open(term.element);
|
||||
`);
|
||||
await timeout(100);
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
if (browser.browserType().name() !== 'webkit') {
|
||||
// safari sometimes reports 'unloadeed'?
|
||||
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
|
||||
}
|
||||
|
||||
// cleanup this messy test case
|
||||
await ctx.page.evaluate(`
|
||||
|
||||
@@ -78,9 +78,11 @@ void main() {
|
||||
}`);
|
||||
}
|
||||
|
||||
const INDICES_PER_CELL = 11;
|
||||
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
|
||||
const CELL_POSITION_INDICES = 2;
|
||||
const enum Constants {
|
||||
INDICES_PER_CELL = 11,
|
||||
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
|
||||
CELL_POSITION_INDICES = 2
|
||||
}
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $i = 0;
|
||||
@@ -160,22 +162,22 @@ export class GlyphRenderer extends Disposable {
|
||||
this._register(toDisposable(() => gl.deleteBuffer(this._attributesBuffer)));
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.OFFSET);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 0);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.SIZE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXPAGE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXPAGE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXCOORD);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXCOORD, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.TEXSIZE);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.TEXSIZE, 1);
|
||||
gl.enableVertexAttribArray(VertexAttribLocations.CELL_POSITION);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
|
||||
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
|
||||
|
||||
// Setup static uniforms
|
||||
@@ -222,12 +224,12 @@ export class GlyphRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _updateCell(array: Float32Array, x: number, y: number, code: number | undefined, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void {
|
||||
$i = (y * this._terminal.cols + x) * INDICES_PER_CELL;
|
||||
$i = (y * this._terminal.cols + x) * Constants.INDICES_PER_CELL;
|
||||
|
||||
// Exit early if this is a null character, allow space character to continue as it may have
|
||||
// underline/strikethrough styles
|
||||
if (code === NULL_CELL_CODE || code === undefined/* This is used for the right side of wide chars */) {
|
||||
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
|
||||
array.fill(0, $i, $i + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,7 +290,7 @@ export class GlyphRenderer extends Disposable {
|
||||
|
||||
public clear(): void {
|
||||
const terminal = this._terminal;
|
||||
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
|
||||
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
|
||||
|
||||
// Clear vertices
|
||||
if (this._vertices.count !== newCount) {
|
||||
@@ -310,7 +312,7 @@ export class GlyphRenderer extends Disposable {
|
||||
for (let x = 0; x < terminal.cols; x++) {
|
||||
this._vertices.attributes[i + 9] = x / terminal.cols;
|
||||
this._vertices.attributes[i + 10] = y / terminal.rows;
|
||||
i += INDICES_PER_CELL;
|
||||
i += Constants.INDICES_PER_CELL;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -346,8 +348,8 @@ export class GlyphRenderer extends Disposable {
|
||||
// - So we don't send vertices for all the line-ending whitespace to the GPU
|
||||
let bufferLength = 0;
|
||||
for (let y = 0; y < renderModel.lineLengths.length; y++) {
|
||||
const si = y * this._terminal.cols * INDICES_PER_CELL;
|
||||
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
|
||||
const si = y * this._terminal.cols * Constants.INDICES_PER_CELL;
|
||||
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
|
||||
activeBuffer.set(sub, bufferLength);
|
||||
bufferLength += sub.length;
|
||||
}
|
||||
@@ -356,7 +358,9 @@ export class GlyphRenderer extends Disposable {
|
||||
gl.bindBuffer(gl.ARRAY_BUFFER, this._attributesBuffer);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, activeBuffer.subarray(0, bufferLength), gl.STREAM_DRAW);
|
||||
|
||||
// Bind the atlas page texture if they have changed
|
||||
// Bind the atlas page texture if they have changed. AtlasPage.version is globally
|
||||
// monotonic, so a page object swap at the same index (which happens after a page merge)
|
||||
// is detected by the same comparison.
|
||||
for (let i = 0; i < this._atlas.pages.length; i++) {
|
||||
if (this._atlas.pages[i].version !== this._atlasTextures[i].version) {
|
||||
this._bindAtlasPageTexture(gl, this._atlas, i);
|
||||
@@ -364,11 +368,15 @@ export class GlyphRenderer extends Disposable {
|
||||
}
|
||||
|
||||
// Draw the viewport
|
||||
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
|
||||
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
|
||||
}
|
||||
|
||||
public setAtlas(atlas: ITextureAtlas): void {
|
||||
this._atlas = atlas;
|
||||
this.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
public invalidateAtlasTextures(): void {
|
||||
for (const glTexture of this._atlasTextures) {
|
||||
glTexture.version = -1;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IColor } from 'common/Types';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
|
||||
import { RenderModelConstants } from './RenderModel';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
|
||||
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
@@ -217,9 +217,9 @@ export class RectangleRenderer extends Disposable {
|
||||
currentFg = 0;
|
||||
currentInverse = false;
|
||||
for (x = 0; x < terminal.cols; x++) {
|
||||
modelIndex = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
bg = model.cells[modelIndex + RENDER_MODEL_BG_OFFSET];
|
||||
fg = model.cells[modelIndex + RENDER_MODEL_FG_OFFSET];
|
||||
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
|
||||
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
|
||||
inverse = !!(fg & FgFlags.INVERSE);
|
||||
if (bg !== currentBg || (fg !== currentFg && (currentInverse || inverse))) {
|
||||
// A rectangle needs to be drawn if going from non-default to another color
|
||||
@@ -336,8 +336,9 @@ export class RectangleRenderer extends Disposable {
|
||||
}
|
||||
}
|
||||
|
||||
if (vertices.attributes.length < offset + 4) {
|
||||
vertices.attributes = expandFloat32Array(vertices.attributes, this._terminal.rows * this._terminal.cols * INDICES_PER_RECTANGLE);
|
||||
if (vertices.attributes.length < offset + INDICES_PER_RECTANGLE) {
|
||||
// +1 for the viewport-clear rectangle at offset 0.
|
||||
vertices.attributes = expandFloat32Array(vertices.attributes, (this._terminal.rows * this._terminal.cols + 1) * INDICES_PER_RECTANGLE);
|
||||
}
|
||||
$x1 = startX * this._dimensions.device.cell.width;
|
||||
$y1 = y * this._dimensions.device.cell.height;
|
||||
|
||||
@@ -7,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);
|
||||
|
||||
@@ -133,7 +133,9 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
|
||||
private _requestClearModel = false;
|
||||
public beginFrame(): boolean {
|
||||
return this._requestClearModel;
|
||||
const result = this._requestClearModel;
|
||||
this._requestClearModel = false;
|
||||
return result;
|
||||
}
|
||||
|
||||
public clearTexture(): void {
|
||||
@@ -193,7 +195,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
|
||||
// Merge into the new page
|
||||
const mergedPage = this._mergePages(mergingPages, mergedPageIndex);
|
||||
mergedPage.version++;
|
||||
mergedPage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Delete the pages, shifting glyph texture pages as needed
|
||||
for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) {
|
||||
@@ -251,7 +253,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
for (const g of adjustingPage.glyphs) {
|
||||
g.texturePage--;
|
||||
}
|
||||
adjustingPage.version++;
|
||||
adjustingPage.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -937,7 +939,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
rasterizedGlyph.size.y
|
||||
);
|
||||
activePage.addGlyph(rasterizedGlyph);
|
||||
activePage.version++;
|
||||
activePage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
return rasterizedGlyph;
|
||||
}
|
||||
@@ -1047,9 +1049,13 @@ class AtlasPage {
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to check whether the canvas of the atlas page has changed.
|
||||
* Monotonically increasing across all atlas pages globally. Used to detect when the texture
|
||||
* unit at a given index needs to be re-uploaded — both for content changes within the same
|
||||
* page and for a page object swap at the same index (which happens after a page merge,
|
||||
* where a per-page counter could coincide with the previously-bound page's value).
|
||||
*/
|
||||
public version = 0;
|
||||
public static nextVersion: number = 0;
|
||||
public version = ++AtlasPage.nextVersion;
|
||||
|
||||
// Texture atlas current positioning data. The texture packing strategy used is to fill from
|
||||
// left-to-right and top-to-bottom. When the glyph being written is less than half of the current
|
||||
@@ -1092,7 +1098,7 @@ class AtlasPage {
|
||||
this.currentRow.y = 0;
|
||||
this.currentRow.height = 0;
|
||||
this.fixedRows.length = 0;
|
||||
this.version++;
|
||||
this.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ import { ICoreService, IDecorationService, IOptionsService } from 'common/servic
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
import { RectangleRenderer } from './RectangleRenderer';
|
||||
import { COMBINED_CHAR_BIT_MASK, RENDER_MODEL_BG_OFFSET, RENDER_MODEL_EXT_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL, RenderModel } from './RenderModel';
|
||||
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
|
||||
import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
|
||||
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
|
||||
import { IRenderLayer } from './renderLayer/Types';
|
||||
@@ -28,6 +28,10 @@ import { addDisposableListener } from 'browser/Dom';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
const enum Constants {
|
||||
MERGE_RETRY_LIMIT = 32
|
||||
}
|
||||
|
||||
export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private _renderLayers: IRenderLayer[];
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
|
||||
@@ -375,6 +379,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateModel(start, end);
|
||||
}
|
||||
|
||||
// A mid-update atlas page merge invalidates vertex data and may not bump the host
|
||||
// page's version, so re-run the update and force a full texture rebind.
|
||||
let merged = false;
|
||||
let mergeRetries = 0;
|
||||
while (this._charAtlas && this._glyphRenderer.value.beginFrame() && mergeRetries++ < Constants.MERGE_RETRY_LIMIT) {
|
||||
merged = true;
|
||||
this._clearModel(true);
|
||||
this._updateModel(0, this._terminal.rows - 1);
|
||||
}
|
||||
if (merged) {
|
||||
this._glyphRenderer.value.invalidateAtlasTextures();
|
||||
}
|
||||
|
||||
// Render
|
||||
this._rectangleRenderer.value.renderBackgrounds();
|
||||
this._glyphRenderer.value.render(this._model);
|
||||
@@ -493,7 +510,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
chars = cell.getChars();
|
||||
code = cell.getCode();
|
||||
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
|
||||
if (!rowHasBlinkingCells && cell.isBlink()) {
|
||||
rowHasBlinkingCells = true;
|
||||
@@ -538,9 +555,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Nothing has changed, no updates needed
|
||||
if (this._model.cells[i] === code &&
|
||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] === this._cellColorResolver.result.bg &&
|
||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] === this._cellColorResolver.result.fg &&
|
||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
||||
this._model.cells[i + RenderModelConstants.BG_OFFSET] === this._cellColorResolver.result.bg &&
|
||||
this._model.cells[i + RenderModelConstants.FG_OFFSET] === this._cellColorResolver.result.fg &&
|
||||
this._model.cells[i + RenderModelConstants.EXT_OFFSET] === this._cellColorResolver.result.ext) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -553,9 +570,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Cache the results in the model
|
||||
this._model.cells[i] = code;
|
||||
this._model.cells[i + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[i + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[i + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
this._model.cells[i + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[i + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[i + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
|
||||
width = cell.getWidth();
|
||||
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
|
||||
@@ -566,14 +583,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Null out non-first cells
|
||||
for (x++; x <= lastCharX; x++) {
|
||||
j = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
|
||||
this._glyphRenderer.value!.updateCell(x, y, NULL_CELL_CODE, 0, 0, 0, NULL_CELL_CHAR, 0, 0);
|
||||
this._model.cells[j] = NULL_CELL_CODE;
|
||||
// Don't re-resolve the cell color since multi-colored ligature backgrounds are not
|
||||
// supported
|
||||
this._model.cells[j + RENDER_MODEL_BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RENDER_MODEL_FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RENDER_MODEL_EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
this._model.cells[j + RenderModelConstants.BG_OFFSET] = this._cellColorResolver.result.bg;
|
||||
this._model.cells[j + RenderModelConstants.FG_OFFSET] = this._cellColorResolver.result.fg;
|
||||
this._model.cells[j + RenderModelConstants.EXT_OFFSET] = this._cellColorResolver.result.ext;
|
||||
}
|
||||
x--; // Go back to the previous update cell for next iteration
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ test.beforeAll(async ({ browser }) => {
|
||||
await openTerminal(ctx);
|
||||
ctxWrapper.value = ctx;
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
try {
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
} catch (e) {}
|
||||
`);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
@@ -24,15 +26,18 @@ test.afterAll(async () => await ctx.page.close());
|
||||
test.describe('WebGL Renderer Integration Tests', async () => {
|
||||
// HACK: webgl2 is often not supported in headless firefox on Linux
|
||||
// https://github.com/microsoft/playwright/issues/11566
|
||||
// also disable safari due to #5852
|
||||
if (platform() === 'linux') {
|
||||
test.skip(({ browserName }) => browserName === 'firefox');
|
||||
test.skip(({ browserName }) => browserName === 'firefox' || browserName === 'webkit');
|
||||
}
|
||||
|
||||
injectSharedRendererTests(ctxWrapper);
|
||||
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
|
||||
await ctx.page.evaluate(`
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
try {
|
||||
window.addon = new window.WebglAddon(true);
|
||||
window.term.loadAddon(window.addon);
|
||||
} catch (e) {}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,10 @@ const config: PlaywrightTestConfig = {
|
||||
{
|
||||
name: 'Chromium',
|
||||
use: {
|
||||
browserName: 'chromium'
|
||||
browserName: 'chromium',
|
||||
launchOptions: {
|
||||
args: ['--enable-unsafe-swiftshader']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -57,6 +57,9 @@ xterm.js version: {{version}}
|
||||
{{#CSI.length}}
|
||||
- [CSI](#csi)
|
||||
{{/CSI.length}}
|
||||
{{#APC.length}}
|
||||
- [APC](#apc)
|
||||
{{/APC.length}}
|
||||
{{#DCS.length}}
|
||||
- [DCS](#dcs)
|
||||
{{/DCS.length}}
|
||||
@@ -80,8 +83,9 @@ This document lists xterm.js' support of terminal sequences. The sequences are g
|
||||
- CSI - Control Sequence Introducer: sequence starting with \`ESC [\` (7bit) or CSI (\`\\x9B\`, 8bit)
|
||||
- DCS - Device Control String: sequence starting with \`ESC P\` (7bit) or DCS (\`\\x90\`, 8bit)
|
||||
- OSC - Operating System Command: sequence starting with \`ESC ]\` (7bit) or OSC (\`\\x9D\`, 8bit)
|
||||
- APC - Application Program Command: sequence starting with \`ESC _\` (7bit) or OSC (\`\\x9F\`, 8bit)
|
||||
|
||||
Application Program Command (APC), Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
||||
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
|
||||
any sequence of these types will be silently ignored. They are also not hookable by the API.
|
||||
|
||||
Note that the list only marks sequences implemented in xterm.js' core codebase as supported. Missing sequences are either
|
||||
@@ -178,6 +182,33 @@ To denote the sequences the tables use the same abbreviations as xterm does:
|
||||
{{/CSI.length}}
|
||||
|
||||
|
||||
{{#APC.length}}
|
||||
## APC
|
||||
|
||||
| Mnemonic | Name | Sequence | Short Description | Support |
|
||||
| -------- | ---- | -------- | ----------------- | ------- |
|
||||
{{#APC}}
|
||||
| {{mnemonic}} | {{name}} | \`{{sequence}}\` | {{{shortDescription}}} {{#longDescription.length}}_[more](#{{longTarget}}){: .link-details}_{{/longDescription.length}} | {{{status}}} |
|
||||
{{/APC}}
|
||||
|
||||
{{#APC.hasLongDescriptions}}
|
||||
{{#APC}}
|
||||
{{#longDescription.length}}
|
||||
<section class="sequence-details">
|
||||
|
||||
### {{name}}
|
||||
{{#longDescription}}
|
||||
{{{.}}}
|
||||
{{/longDescription}}
|
||||
|
||||
</section>
|
||||
{{/longDescription.length}}
|
||||
{{/APC}}
|
||||
{{/APC.hasLongDescriptions}}
|
||||
|
||||
{{/APC.length}}
|
||||
|
||||
|
||||
{{#DCS.length}}
|
||||
## DCS
|
||||
|
||||
|
||||
@@ -252,6 +252,11 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
input.value = 'auto';
|
||||
this._handlers.updateTerminalSize();
|
||||
}
|
||||
const screenElement = document.querySelector('.xterm-screen');
|
||||
if (screenElement instanceof HTMLElement) {
|
||||
screenElement.style.outline = this._autoResize ? ''
|
||||
: 'solid thin red';
|
||||
}
|
||||
} else if (o === 'theme') {
|
||||
value = this._getTheme();
|
||||
}
|
||||
|
||||
Generated
+30
-718
File diff suppressed because it is too large
Load Diff
+3
-11
@@ -29,6 +29,7 @@
|
||||
"scripts": {
|
||||
"presetup": "npm run build",
|
||||
"setup": "npm run esbuild",
|
||||
"setup-full": "node bin/setup-full.mjs",
|
||||
"postsetup": "npm run esbuild-demo-server",
|
||||
"start": "node demo/start",
|
||||
"dev": "concurrently -k -p [{name}] -n tsc,esbuild,esbuild-demo-client,esbuild-demo-server,server -c blue,yellow,cyan,green,magenta \"npm:tsc-watch\" \"npm:esbuild-watch\" \"npm:esbuild-demo-client-watch\" \"npm:esbuild-demo-server-watch\" \"npm:start\"",
|
||||
@@ -55,7 +56,6 @@
|
||||
"test-unit": "node ./bin/test_unit.js",
|
||||
"test-unit-slow-tests": "npm run test-unit | grep \"ms)\"",
|
||||
"test-unit-coverage": "node ./bin/test_unit.js --coverage",
|
||||
"test-unit-dev": "cross-env NODE_PATH='./out' mocha",
|
||||
"test-integration": "node ./bin/test_integration.js --workers=75%",
|
||||
"test-integration-chromium": "node ./bin/test_integration.js --workers=75% \"--project=Chromium\"",
|
||||
"test-integration-firefox": "node ./bin/test_integration.js --workers=75% \"--project=FirefoxStable\"",
|
||||
@@ -65,30 +65,25 @@
|
||||
"benchmark-baseline": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --baseline out-test/benchmark/*benchmark.js",
|
||||
"benchmark-eval": "NODE_PATH=./out xterm-benchmark -r 5 -c test/benchmark/benchmark.json --eval out-test/benchmark/*benchmark.js",
|
||||
"clean": "rm -rf lib out addons/*/lib addons/*/out",
|
||||
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
|
||||
"vtfeatures": "node bin/extract_vtfeatures.js src/*/*.ts src/*/*/*.ts src/*.ts addons/**/src/*.ts",
|
||||
"prepackage": "npm run build",
|
||||
"package": "webpack",
|
||||
"postpackage": "npm run esbuild-package",
|
||||
"prepackage-headless": "npm run esbuild-package-headless-only",
|
||||
"package-headless": "webpack --config ./webpack.config.headless.js",
|
||||
"postpackage-headless": "node ./bin/package_headless.js",
|
||||
"prepublishOnly": "npm run package",
|
||||
"agent:setup-repo": "node bin/agent/setup-repo.mjs"
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lunapaint/png-codec": "^0.2.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@stylistic/eslint-plugin": "^4.4.1",
|
||||
"@types/chai": "^4.2.22",
|
||||
"@types/debug": "^4.1.7",
|
||||
"@types/deep-equal": "^1.0.1",
|
||||
"@types/express": "4",
|
||||
"@types/express-ws": "^3.0.1",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/mocha": "^9.0.0",
|
||||
"@types/node": "^22.19.3",
|
||||
"@types/trusted-types": "^1.0.6",
|
||||
"@types/utf8": "^3.0.0",
|
||||
"@types/webpack": "^5.28.0",
|
||||
"@types/ws": "^8.2.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.50.1",
|
||||
@@ -96,8 +91,6 @@
|
||||
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
|
||||
"chai": "^4.3.4",
|
||||
"concurrently": "^9.1.2",
|
||||
"cross-env": "^7.0.3",
|
||||
"deep-equal": "^2.0.5",
|
||||
"esbuild": "~0.25.2",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-plugin-jsdoc": "^50.8.0",
|
||||
@@ -113,7 +106,6 @@
|
||||
"ts-loader": "^9.3.1",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.50.1",
|
||||
"utf8": "^3.0.0",
|
||||
"webpack": "^5.61.0",
|
||||
"webpack-cli": "^4.9.1",
|
||||
"ws": "^8.2.3",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ export class MockTerminal implements ITerminal {
|
||||
public registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
|
||||
|
||||
@@ -19,13 +19,15 @@ import { Emitter } from 'common/Event';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
|
||||
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
|
||||
const ROW_CONTAINER_CLASS = 'xterm-rows';
|
||||
const FG_CLASS_PREFIX = 'xterm-fg-';
|
||||
const BG_CLASS_PREFIX = 'xterm-bg-';
|
||||
const FOCUS_CLASS = 'xterm-focus';
|
||||
const SELECTION_CLASS = 'xterm-selection';
|
||||
const CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle';
|
||||
const enum Constants {
|
||||
TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-',
|
||||
ROW_CONTAINER_CLASS = 'xterm-rows',
|
||||
FG_CLASS_PREFIX = 'xterm-fg-',
|
||||
BG_CLASS_PREFIX = 'xterm-bg-',
|
||||
FOCUS_CLASS = 'xterm-focus',
|
||||
SELECTION_CLASS = 'xterm-selection',
|
||||
CURSOR_BLINK_IDLE_CLASS = 'xterm-cursor-blink-idle'
|
||||
}
|
||||
|
||||
let nextTerminalId = 1;
|
||||
|
||||
@@ -76,12 +78,12 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
) {
|
||||
super();
|
||||
this._rowContainer = this._document.createElement('div');
|
||||
this._rowContainer.classList.add(ROW_CONTAINER_CLASS);
|
||||
this._rowContainer.classList.add(Constants.ROW_CONTAINER_CLASS);
|
||||
this._rowContainer.style.lineHeight = 'normal';
|
||||
this._rowContainer.setAttribute('aria-hidden', 'true');
|
||||
this._refreshRowElements(this._bufferService.cols, this._bufferService.rows);
|
||||
this._selectionContainer = this._document.createElement('div');
|
||||
this._selectionContainer.classList.add(SELECTION_CLASS);
|
||||
this._selectionContainer.classList.add(Constants.SELECTION_CLASS);
|
||||
this._selectionContainer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this.dimensions = createRenderDimensions();
|
||||
@@ -93,7 +95,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
|
||||
this._rowFactory = instantiationService.createInstance(DomRendererRowFactory, document);
|
||||
|
||||
this._element.classList.add(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._element.classList.add(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._screenElement.appendChild(this._rowContainer);
|
||||
this._screenElement.appendChild(this._selectionContainer);
|
||||
|
||||
@@ -110,7 +112,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
));
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
this._element.classList.remove(Constants.TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
|
||||
// Outside influences such as React unmounts may manipulate the DOM before our disposal.
|
||||
// https://github.com/xtermjs/xterm.js/issues/2960
|
||||
@@ -160,7 +162,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
const styles =
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
|
||||
` display: inline-block;` + // TODO: find workaround for inline-block (creates ~20% render penalty)
|
||||
` height: 100%;` +
|
||||
` vertical-align: top;` +
|
||||
@@ -181,7 +183,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
|
||||
// Base CSS
|
||||
let styles =
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} {` +
|
||||
// Disabling pointer events circumvents a browser behavior that prevents `click` events from
|
||||
// being delivered if the target element is replaced during the click. This happened due to
|
||||
// refresh() being called during the mousedown handler to start a selection.
|
||||
@@ -189,14 +191,14 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
` color: ${colors.foreground.css};` +
|
||||
`}`;
|
||||
styles +=
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${ROW_CONTAINER_CLASS} span {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}, ${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} span {` +
|
||||
` font-family: ${this._optionsService.rawOptions.fontFamily};` +
|
||||
` font-size: ${this._optionsService.rawOptions.fontSize}px;` +
|
||||
` font-kerning: none;` +
|
||||
` white-space: pre` +
|
||||
`}`;
|
||||
styles +=
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .xterm-dim {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .xterm-dim {` +
|
||||
` color: ${color.multiplyOpacity(colors.foreground, 0.5).css};` +
|
||||
`}`;
|
||||
// Text styles
|
||||
@@ -242,70 +244,70 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
`}`;
|
||||
// Cursor
|
||||
styles +=
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
` animation: ${blinkAnimationUnderlineId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
` animation: ${blinkAnimationBarId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.FOCUS_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
` animation: ${blinkAnimationBlockId} 1s step-end infinite;` +
|
||||
`}` +
|
||||
// Disable cursor blinking when idle
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS}.${CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS}.${Constants.CURSOR_BLINK_IDLE_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_BLINK_CLASS} {` +
|
||||
` animation: none !important;` +
|
||||
`}` +
|
||||
// !important helps fix an issue where the cursor will not render on top of the selection,
|
||||
// however it's very hard to fix this issue and retain the blink animation without the use of
|
||||
// !important. So this edge case fails when cursor blink is on.
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS} {` +
|
||||
` background-color: ${colors.cursor.css};` +
|
||||
` color: ${colors.cursorAccent.css};` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BLOCK_CLASS}:not(.${RowCss.CURSOR_BLINK_CLASS}) {` +
|
||||
` background-color: ${colors.cursor.css} !important;` +
|
||||
` color: ${colors.cursorAccent.css} !important;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_OUTLINE_CLASS} {` +
|
||||
` outline: 1px solid ${colors.cursor.css};` +
|
||||
` outline-offset: -1px;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_BAR_CLASS} {` +
|
||||
` box-shadow: ${this._optionsService.rawOptions.cursorWidth}px 0 0 ${colors.cursor.css} inset;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.ROW_CONTAINER_CLASS} .${RowCss.CURSOR_CLASS}.${RowCss.CURSOR_STYLE_UNDERLINE_CLASS} {` +
|
||||
` border-bottom: 1px ${colors.cursor.css};` +
|
||||
` border-bottom-style: solid;` +
|
||||
` height: calc(100% - 1px);` +
|
||||
`}`;
|
||||
// Selection
|
||||
styles +=
|
||||
`${this._terminalSelector} .${SELECTION_CLASS} {` +
|
||||
`${this._terminalSelector} .${Constants.SELECTION_CLASS} {` +
|
||||
` position: absolute;` +
|
||||
` top: 0;` +
|
||||
` left: 0;` +
|
||||
` z-index: 1;` +
|
||||
` pointer-events: none;` +
|
||||
`}` +
|
||||
`${this._terminalSelector}.focus .${SELECTION_CLASS} div {` +
|
||||
`${this._terminalSelector}.focus .${Constants.SELECTION_CLASS} div {` +
|
||||
` position: absolute;` +
|
||||
` background-color: ${colors.selectionBackgroundOpaque.css};` +
|
||||
`}` +
|
||||
`${this._terminalSelector} .${SELECTION_CLASS} div {` +
|
||||
`${this._terminalSelector} .${Constants.SELECTION_CLASS} div {` +
|
||||
` position: absolute;` +
|
||||
` background-color: ${colors.selectionInactiveBackgroundOpaque.css};` +
|
||||
`}`;
|
||||
// Colors
|
||||
for (const [i, c] of colors.ansi.entries()) {
|
||||
styles +=
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i} { color: ${c.css}; }` +
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${i}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(c, 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${i} { background-color: ${c.css}; }`;
|
||||
}
|
||||
styles +=
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
|
||||
`${this._terminalSelector} .${FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { color: ${color.opaque(colors.background).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.FG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR}.${RowCss.DIM_CLASS} { color: ${color.multiplyOpacity(color.opaque(colors.background), 0.5).css}; }` +
|
||||
`${this._terminalSelector} .${Constants.BG_CLASS_PREFIX}${INVERTED_DEFAULT_COLOR} { background-color: ${colors.foreground.css}; }`;
|
||||
|
||||
this._themeStyleElement.textContent = styles;
|
||||
}
|
||||
@@ -361,13 +363,13 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
public handleBlur(): void {
|
||||
this._rowContainer.classList.remove(FOCUS_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.FOCUS_CLASS);
|
||||
this._cursorBlinkStateManager.pause();
|
||||
this.renderRows(0, this._bufferService.rows - 1);
|
||||
}
|
||||
|
||||
public handleFocus(): void {
|
||||
this._rowContainer.classList.add(FOCUS_CLASS);
|
||||
this._rowContainer.classList.add(Constants.FOCUS_CLASS);
|
||||
this._cursorBlinkStateManager.resume();
|
||||
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
|
||||
}
|
||||
@@ -561,7 +563,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
|
||||
private get _terminalSelector(): string {
|
||||
return `.${TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
|
||||
return `.${Constants.TERMINAL_CLASS_PREFIX}${this._terminalClass}`;
|
||||
}
|
||||
|
||||
private _handleLinkHover(e: ILinkifierEvent): void {
|
||||
@@ -667,7 +669,7 @@ class CursorBlinkStateManager {
|
||||
|
||||
public restartBlinkAnimation(): void {
|
||||
if (this._isIdlePaused) {
|
||||
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
}
|
||||
this._resetIdleTimer();
|
||||
}
|
||||
@@ -679,7 +681,7 @@ class CursorBlinkStateManager {
|
||||
|
||||
public resume(): void {
|
||||
this._isIdlePaused = false;
|
||||
this._rowContainer.classList.remove(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.remove(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
this._resetIdleTimer();
|
||||
}
|
||||
|
||||
@@ -699,7 +701,7 @@ class CursorBlinkStateManager {
|
||||
}
|
||||
|
||||
private _stopBlinkingDueToIdle(): void {
|
||||
this._rowContainer.classList.add(CURSOR_BLINK_IDLE_CLASS);
|
||||
this._rowContainer.classList.add(Constants.CURSOR_BLINK_IDLE_CLASS);
|
||||
this._isIdlePaused = true;
|
||||
this._idleTimeout = undefined;
|
||||
}
|
||||
|
||||
@@ -8,14 +8,14 @@ import { assert } from 'chai';
|
||||
import { DomRendererRowFactory } from 'browser/renderer/dom/DomRendererRowFactory';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags, BgFlags, Attributes, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
|
||||
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
|
||||
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
|
||||
|
||||
const dom = new jsdom.JSDOM('');
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('DomRendererRowFactory', () => {
|
||||
let dom: jsdom.JSDOM;
|
||||
@@ -517,7 +517,7 @@ describe('DomRendererRowFactory', () => {
|
||||
}
|
||||
|
||||
function createEmptyLineData(cols: number): IBufferLine {
|
||||
const lineData = new BufferLine(cols);
|
||||
const lineData = new BufferLine(TEST_STRING_CACHE, cols);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
lineData.setCell(i, NULL_CELL_DATA);
|
||||
}
|
||||
|
||||
@@ -18,8 +18,10 @@ import * as platform from 'common/Platform';
|
||||
import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable';
|
||||
// import 'vs/css!./media/scrollbars';
|
||||
|
||||
const HIDE_TIMEOUT = 500;
|
||||
const SCROLL_WHEEL_SENSITIVITY = 50;
|
||||
const enum Constants {
|
||||
HIDE_TIMEOUT = 500,
|
||||
SCROLL_WHEEL_SENSITIVITY = 50
|
||||
}
|
||||
|
||||
class MouseWheelClassifierItem {
|
||||
public timestamp: number;
|
||||
@@ -404,12 +406,12 @@ export class SmoothScrollableElement extends Widget {
|
||||
|
||||
let desiredScrollPosition: INewScrollPosition = {};
|
||||
if (deltaY) {
|
||||
const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY;
|
||||
const deltaScrollTop = Constants.SCROLL_WHEEL_SENSITIVITY * deltaY;
|
||||
const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop));
|
||||
this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop);
|
||||
}
|
||||
if (deltaX) {
|
||||
const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX;
|
||||
const deltaScrollLeft = Constants.SCROLL_WHEEL_SENSITIVITY * deltaX;
|
||||
const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft));
|
||||
this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft);
|
||||
}
|
||||
@@ -533,7 +535,7 @@ export class SmoothScrollableElement extends Widget {
|
||||
|
||||
private _scheduleHide(): void {
|
||||
if (!this._mouseIsOver && !this._isDragging) {
|
||||
this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT);
|
||||
this._hideTimeout.cancelAndSet(() => this._hide(), Constants.HIDE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,13 @@ import { assert } from 'chai';
|
||||
import { ICharacterJoinerService } from 'browser/services/Services';
|
||||
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { MockBufferService, createCellData } from 'common/TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
describe('CharacterJoinerService', () => {
|
||||
let service: ICharacterJoinerService;
|
||||
|
||||
@@ -22,7 +25,7 @@ describe('CharacterJoinerService', () => {
|
||||
lines.set(2, lineData([['a -> b -', 0xFFFFFFFF], ['> c -> d', 0]]));
|
||||
|
||||
lines.set(3, lineData([['no joined ranges']]));
|
||||
lines.set(4, new BufferLine(0));
|
||||
lines.set(4, new BufferLine(TEST_STRING_CACHE, 0));
|
||||
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
|
||||
const line6 = lineData([['wi']]);
|
||||
line6.resize(line6.length + 1, createCellData(0, '¥', 2));
|
||||
@@ -267,7 +270,7 @@ describe('CharacterJoinerService', () => {
|
||||
type IPartialLineData = ([string] | [string, number]);
|
||||
|
||||
function lineData(data: IPartialLineData[]): IBufferLine {
|
||||
const tline = new BufferLine(0);
|
||||
const tline = new BufferLine(TEST_STRING_CACHE, 0);
|
||||
for (let i = 0; i < data.length; ++i) {
|
||||
const line = data[i][0];
|
||||
const attr = (data[i][1] || 0) as number;
|
||||
|
||||
@@ -18,7 +18,7 @@ export class MouseCoordsService implements IMouseCoordsService {
|
||||
|
||||
public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
|
||||
return getCoords(
|
||||
window,
|
||||
getWindow(element),
|
||||
event,
|
||||
element,
|
||||
colCount,
|
||||
|
||||
@@ -29,6 +29,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
private _renderDebouncer: IRenderDebouncerWithCallback;
|
||||
private _pausedResizeTask: DebouncedIdleTask;
|
||||
private _observerDisposable = this._register(new MutableDisposable());
|
||||
private _intersectionObserver: IntersectionObserver | undefined;
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
private _needsFullRefresh: boolean = false;
|
||||
@@ -127,8 +128,12 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
// and resume based on terminal visibility if so
|
||||
if ('IntersectionObserver' in w) {
|
||||
const observer = new w.IntersectionObserver(e => this._handleIntersectionChange(e[e.length - 1]), { threshold: 0 });
|
||||
this._observerDisposable.value = toDisposable(() => {
|
||||
this._intersectionObserver?.disconnect();
|
||||
this._intersectionObserver = undefined;
|
||||
});
|
||||
this._intersectionObserver = observer;
|
||||
observer.observe(screenElement);
|
||||
this._observerDisposable.value = toDisposable(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
|
||||
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
|
||||
* run yet.
|
||||
*/
|
||||
export class MicrotaskTimer implements IDisposable {
|
||||
private _isScheduled = false;
|
||||
private _isDisposed = false;
|
||||
|
||||
public dispose(): void {
|
||||
this.cancel();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this._isScheduled = false;
|
||||
}
|
||||
|
||||
public set(runner: () => void): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling set on a disposed MicrotaskTimer');
|
||||
}
|
||||
if (this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
if (!this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = false;
|
||||
runner();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class IntervalTimer implements IDisposable {
|
||||
private _disposable: IDisposable | undefined;
|
||||
private _isDisposed = false;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
|
||||
import { InstantiationService } from 'common/services/InstantiationService';
|
||||
import { LogService } from 'common/services/LogService';
|
||||
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
|
||||
import { BufferService, BufferServiceConstants } from 'common/services/BufferService';
|
||||
import { OptionsService } from 'common/services/OptionsService';
|
||||
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types';
|
||||
import { CoreService } from 'common/services/CoreService';
|
||||
@@ -173,8 +173,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
return;
|
||||
}
|
||||
|
||||
x = Math.max(x, MINIMUM_COLS);
|
||||
y = Math.max(y, MINIMUM_ROWS);
|
||||
x = Math.max(x, BufferServiceConstants.MINIMUM_COLS);
|
||||
y = Math.max(y, BufferServiceConstants.MINIMUM_ROWS);
|
||||
|
||||
// Flush pending writes before resize to avoid race conditions where async
|
||||
// writes are processed with incorrect dimensions
|
||||
@@ -243,8 +243,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
}
|
||||
|
||||
/** Add handler for APC escape sequence. See xterm.d.ts for details. */
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._inputHandler.registerApcHandler(ident, callback);
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._inputHandler.registerApcHandler(id, callback);
|
||||
}
|
||||
|
||||
protected _setup(): void {
|
||||
|
||||
+30
-48
@@ -30,28 +30,9 @@ import { XTERM_VERSION } from 'common/Version';
|
||||
*/
|
||||
const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-': 1, '.': 2 };
|
||||
|
||||
/**
|
||||
* VT commands done by the parser - FIXME: move this to the parser?
|
||||
*/
|
||||
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
|
||||
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
|
||||
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
|
||||
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
|
||||
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
|
||||
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
|
||||
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
|
||||
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
|
||||
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
|
||||
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
|
||||
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
|
||||
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
|
||||
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
|
||||
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
|
||||
|
||||
/**
|
||||
* Document xterm VT features here that are currently unsupported
|
||||
*/
|
||||
// @vt: #E[Supported via @xterm/addon-image.] DCS SIXEL "SIXEL Graphics" "DCS Ps ; Ps ; Ps ; q Pt ST" "Draw SIXEL image."
|
||||
// @vt: #N DCS DECUDK "User Defined Keys" "DCS Ps ; Ps \| Pt ST" "Definitions for user-defined keys."
|
||||
// @vt: #N DCS XTGETTCAP "Request Terminfo String" "DCS + q Pt ST" "Request Terminfo String."
|
||||
// @vt: #N DCS XTSETTCAP "Set Terminfo Data" "DCS + p Pt ST" "Set Terminfo Data."
|
||||
@@ -60,12 +41,13 @@ const GLEVEL: { [key: string]: number } = { '(': 0, ')': 1, '*': 2, '+': 3, '-':
|
||||
/**
|
||||
* Max length of the UTF32 input buffer. Real memory consumption is 4 times higher.
|
||||
*/
|
||||
const MAX_PARSEBUFFER_LENGTH = 131072;
|
||||
|
||||
/**
|
||||
* Limit length of title and icon name stacks.
|
||||
*/
|
||||
const STACK_LIMIT = 10;
|
||||
const enum Constants {
|
||||
MAX_PARSEBUFFER_LENGTH = 131072,
|
||||
/** Limit length of title and icon name stacks. */
|
||||
STACK_LIMIT = 10,
|
||||
// create a warning log if an async handler takes longer than the limit (in ms)
|
||||
SLOW_ASYNC_LIMIT = 5000
|
||||
}
|
||||
|
||||
// map params to window option
|
||||
function paramToWindowOption(n: number, opts: IWindowOptions): boolean {
|
||||
@@ -104,9 +86,6 @@ export enum WindowsOptionsReportType {
|
||||
GET_CELL_SIZE_PIXELS = 1
|
||||
}
|
||||
|
||||
// create a warning log if an async handler takes longer than the limit (in ms)
|
||||
const SLOW_ASYNC_LIMIT = 5000;
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $temp = 0;
|
||||
|
||||
@@ -211,6 +190,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
}
|
||||
this._logService.debug('Unknown DCS code: ', { identifier: this._parser.identToString(ident), action, payload });
|
||||
});
|
||||
this._parser.setApcHandlerFallback((ident, action, payload) => {
|
||||
this._logService.debug('Unknown APC code: ', { identifier: this._parser.identToString(ident), action, payload });
|
||||
});
|
||||
|
||||
/**
|
||||
* print handler
|
||||
@@ -409,7 +391,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (this._logService.logLevel <= LogLevelEnum.WARN) {
|
||||
let slowTimeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const slowPromise = new Promise<never>((_res, rej) => {
|
||||
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), SLOW_ASYNC_LIMIT);
|
||||
slowTimeout = setTimeout(() => rej('#SLOW_TIMEOUT'), Constants.SLOW_ASYNC_LIMIT);
|
||||
});
|
||||
Promise.race([p, slowPromise])
|
||||
.then(() => {
|
||||
@@ -423,7 +405,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (err !== '#SLOW_TIMEOUT') {
|
||||
throw err;
|
||||
}
|
||||
console.warn(`async parser handler taking longer than ${SLOW_ASYNC_LIMIT} ms`);
|
||||
console.warn(`async parser handler taking longer than ${Constants.SLOW_ASYNC_LIMIT} ms`);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -461,8 +443,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
cursorStartX = this._parseStack.cursorStartX;
|
||||
cursorStartY = this._parseStack.cursorStartY;
|
||||
this._parseStack.paused = false;
|
||||
if (data.length > MAX_PARSEBUFFER_LENGTH) {
|
||||
start = this._parseStack.position + MAX_PARSEBUFFER_LENGTH;
|
||||
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
start = this._parseStack.position + Constants.MAX_PARSEBUFFER_LENGTH;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,8 +461,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
|
||||
// resize input buffer if needed
|
||||
if (this._parseBuffer.length < data.length) {
|
||||
if (this._parseBuffer.length < MAX_PARSEBUFFER_LENGTH) {
|
||||
this._parseBuffer = new Uint32Array(Math.min(data.length, MAX_PARSEBUFFER_LENGTH));
|
||||
if (this._parseBuffer.length < Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
this._parseBuffer = new Uint32Array(Math.min(data.length, Constants.MAX_PARSEBUFFER_LENGTH));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -491,9 +473,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
}
|
||||
|
||||
// process big data in smaller chunks
|
||||
if (data.length > MAX_PARSEBUFFER_LENGTH) {
|
||||
for (let i = start; i < data.length; i += MAX_PARSEBUFFER_LENGTH) {
|
||||
const end = i + MAX_PARSEBUFFER_LENGTH < data.length ? i + MAX_PARSEBUFFER_LENGTH : data.length;
|
||||
if (data.length > Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
for (let i = start; i < data.length; i += Constants.MAX_PARSEBUFFER_LENGTH) {
|
||||
const end = i + Constants.MAX_PARSEBUFFER_LENGTH < data.length ? i + Constants.MAX_PARSEBUFFER_LENGTH : data.length;
|
||||
const len = (typeof data === 'string')
|
||||
? this._stringDecoder.decode(data.substring(i, end), this._parseBuffer)
|
||||
: this._utf8Decoder.decode(data.subarray(i, end), this._parseBuffer);
|
||||
@@ -729,8 +711,8 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
/**
|
||||
* Forward registerApcHandler from parser.
|
||||
*/
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._parser.registerApcHandler(ident, new ApcHandler(callback));
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._parser.registerApcHandler(id, new ApcHandler(callback));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2415,7 +2397,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
color &= ~Attributes.RGB_MASK;
|
||||
color |= AttributeData.fromColorRGB([c1, c2, c3]);
|
||||
} else if (mode === 5) {
|
||||
color &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
color &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
color |= Attributes.CM_P256 | (c1 & 0xff);
|
||||
}
|
||||
return color;
|
||||
@@ -2628,19 +2610,19 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
p = params.params[i];
|
||||
if (p >= 30 && p <= 37) {
|
||||
// fg color 8
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= Attributes.CM_P16 | (p - 30);
|
||||
} else if (p >= 40 && p <= 47) {
|
||||
// bg color 8
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= Attributes.CM_P16 | (p - 40);
|
||||
} else if (p >= 90 && p <= 97) {
|
||||
// fg color 16
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= Attributes.CM_P16 | (p - 90) | 8;
|
||||
} else if (p >= 100 && p <= 107) {
|
||||
// bg color 16
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.PCOLOR_MASK);
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= Attributes.CM_P16 | (p - 100) | 8;
|
||||
} else if (p === 0) {
|
||||
// default
|
||||
@@ -2700,11 +2682,11 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
} else if (p === 39) {
|
||||
// reset fg
|
||||
attr.fg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= DEFAULT_ATTR_DATA.fg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
|
||||
attr.fg |= DEFAULT_ATTR_DATA.fg & Attributes.RGB_MASK;
|
||||
} else if (p === 49) {
|
||||
// reset bg
|
||||
attr.bg &= ~(Attributes.CM_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= DEFAULT_ATTR_DATA.bg & (Attributes.PCOLOR_MASK | Attributes.RGB_MASK);
|
||||
attr.bg |= DEFAULT_ATTR_DATA.bg & Attributes.RGB_MASK;
|
||||
} else if (p === 38 || p === 48 || p === 58) {
|
||||
// fg color 256 and RGB
|
||||
i += this._extractColor(params, i, attr);
|
||||
@@ -2971,13 +2953,13 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
case 22: // PushTitle
|
||||
if (second === 0 || second === 2) {
|
||||
this._windowTitleStack.push(this._windowTitle);
|
||||
if (this._windowTitleStack.length > STACK_LIMIT) {
|
||||
if (this._windowTitleStack.length > Constants.STACK_LIMIT) {
|
||||
this._windowTitleStack.shift();
|
||||
}
|
||||
}
|
||||
if (second === 0 || second === 1) {
|
||||
this._iconNameStack.push(this._iconName);
|
||||
if (this._iconNameStack.length > STACK_LIMIT) {
|
||||
if (this._iconNameStack.length > Constants.STACK_LIMIT) {
|
||||
this._iconNameStack.shift();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -22,7 +22,7 @@ export interface ICoreTerminal {
|
||||
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
|
||||
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
|
||||
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
}
|
||||
|
||||
export interface IDisposable {
|
||||
@@ -476,7 +476,7 @@ export interface IInputHandler {
|
||||
registerDcsHandler(id: IFunctionIdentifier, callback: (data: string, param: IParams) => boolean | Promise<boolean>): IDisposable;
|
||||
registerEscHandler(id: IFunctionIdentifier, callback: () => boolean | Promise<boolean>): IDisposable;
|
||||
registerOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
|
||||
/** C0 BEL */ bell(): boolean;
|
||||
/** C0 LF */ lineFeed(): boolean;
|
||||
|
||||
@@ -8,22 +8,34 @@ import { Buffer } from 'common/buffer/Buffer';
|
||||
import { CircularList } from 'common/CircularList';
|
||||
import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
|
||||
const INIT_COLS = 80;
|
||||
const INIT_ROWS = 24;
|
||||
const INIT_SCROLLBACK = 1000;
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
class TestBuffer extends Buffer {
|
||||
public getStringCache(): BufferLineStringCache {
|
||||
return (this as unknown as { _stringCache: BufferLineStringCache })._stringCache;
|
||||
}
|
||||
|
||||
public getStringCacheClearTimeout(): unknown {
|
||||
return (this.getStringCache() as unknown as { _clearTimeout: { value: unknown } })._clearTimeout.value;
|
||||
}
|
||||
}
|
||||
|
||||
describe('Buffer', () => {
|
||||
let optionsService: MockOptionsService;
|
||||
let bufferService: MockBufferService;
|
||||
let buffer: Buffer;
|
||||
let buffer: TestBuffer;
|
||||
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
|
||||
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
|
||||
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, optionsService, bufferService, new MockLogService());
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -151,7 +163,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('no scrollback', () => {
|
||||
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
buffer.y = INIT_ROWS - 1;
|
||||
buffer.fillViewportRows();
|
||||
@@ -1054,7 +1066,7 @@ describe('Buffer', () => {
|
||||
describe('buffer marked to have no scrollback', () => {
|
||||
it('should always have a scrollback of 0', () => {
|
||||
// Test size on initialization
|
||||
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
// Test size on buffer increase
|
||||
@@ -1068,7 +1080,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('addMarker', () => {
|
||||
it('should adjust a marker line when the buffer is trimmed', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
const marker = buffer.addMarker(buffer.lines.length - 1);
|
||||
assert.equal(marker.line, buffer.lines.length - 1);
|
||||
@@ -1076,7 +1088,7 @@ describe('Buffer', () => {
|
||||
assert.equal(marker.line, buffer.lines.length - 2);
|
||||
});
|
||||
it('should dispose of a marker if it is trimmed off the buffer', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
@@ -1088,7 +1100,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
it('should call onDispose', () => {
|
||||
const eventStack: string[] = [];
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer = new TestBuffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
@@ -1104,7 +1116,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe ('translateBufferLineToString', () => {
|
||||
it('should handle selecting a section of ascii text', () => {
|
||||
const line = new BufferLine(4);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 4);
|
||||
line.setCell(0, createCellData(0, 'a', 1));
|
||||
line.setCell(1, createCellData(0, 'b', 1));
|
||||
line.setCell(2, createCellData(0, 'c', 1));
|
||||
@@ -1116,7 +1128,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a cut-off double width character by including it', () => {
|
||||
const line = new BufferLine(3);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1127,7 +1139,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle a zero width character in the middle of the string by not including it', () => {
|
||||
const line = new BufferLine(3);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line.setCell(0, createCellData(0, '語', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
line.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1144,7 +1156,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle single width emojis', () => {
|
||||
const line = new BufferLine(2);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
line.setCell(0, createCellData(0, '😁', 1));
|
||||
line.setCell(1, createCellData(0, 'a', 1));
|
||||
buffer.lines.set(0, line);
|
||||
@@ -1157,7 +1169,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
it('should handle double width emojis', () => {
|
||||
const line = new BufferLine(2);
|
||||
const line = new BufferLine(TEST_STRING_CACHE, 2);
|
||||
line.setCell(0, createCellData(0, '😁', 2));
|
||||
line.setCell(1, createCellData(0, '', 0));
|
||||
buffer.lines.set(0, line);
|
||||
@@ -1168,7 +1180,7 @@ describe('Buffer', () => {
|
||||
const str2 = buffer.translateBufferLineToString(0, true, 0, 2);
|
||||
assert.equal(str2, '😁');
|
||||
|
||||
const line2 = new BufferLine(3);
|
||||
const line2 = new BufferLine(TEST_STRING_CACHE, 3);
|
||||
line2.setCell(0, createCellData(0, '😁', 2));
|
||||
line2.setCell(1, createCellData(0, '', 0));
|
||||
line2.setCell(2, createCellData(0, 'a', 1));
|
||||
@@ -1179,6 +1191,99 @@ describe('Buffer', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('line string cache cleanup', () => {
|
||||
it('should clear shared cache entries with a single timer', () => {
|
||||
const originalSetTimeout = globalThis.setTimeout;
|
||||
const originalClearTimeout = globalThis.clearTimeout;
|
||||
const originalDateNow = Date.now;
|
||||
let timeoutId = 0;
|
||||
let now = 0;
|
||||
const clearedTimeouts: number[] = [];
|
||||
const scheduledTimeouts = new Map<number, { delay: number, fire: () => void }>();
|
||||
(globalThis as any).setTimeout = ((handler: (...args: any[]) => void, timeout?: number) => {
|
||||
const id = ++timeoutId;
|
||||
scheduledTimeouts.set(id, {
|
||||
delay: timeout ?? 0,
|
||||
fire: () => {
|
||||
scheduledTimeouts.delete(id);
|
||||
handler();
|
||||
}
|
||||
});
|
||||
return id as ReturnType<typeof setTimeout>;
|
||||
}) as typeof setTimeout;
|
||||
(globalThis as any).clearTimeout = ((id: ReturnType<typeof setTimeout>) => {
|
||||
const numericId = id as unknown as number;
|
||||
clearedTimeouts.push(numericId);
|
||||
scheduledTimeouts.delete(numericId);
|
||||
}) as typeof clearTimeout;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 1));
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(buffer.translateBufferLineToString(1, false), `b${' '.repeat(INIT_COLS - 1)}`);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 2);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 15000);
|
||||
const initialTimerCreationCount = timeoutId;
|
||||
|
||||
now = 5000;
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(timeoutId, initialTimerCreationCount);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.deepEqual(clearedTimeouts, []);
|
||||
|
||||
now = 15000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
assert.equal(timeoutId, initialTimerCreationCount + 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
assert.equal(scheduledTimeouts.size, 1);
|
||||
assert.equal([...scheduledTimeouts.values()][0].delay, 5000);
|
||||
|
||||
now = 20000;
|
||||
[...scheduledTimeouts.values()][0].fire();
|
||||
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
assert.equal(buffer.translateBufferLineToString(0, false), `a${' '.repeat(INIT_COLS - 1)}`);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
globalThis.setTimeout = originalSetTimeout;
|
||||
globalThis.clearTimeout = originalClearTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('should reset line string cache state on clear and resize', () => {
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
|
||||
const cache = buffer.getStringCache();
|
||||
assert.equal(cache.entries.size, 1);
|
||||
assert.ok(buffer.getStringCacheClearTimeout() !== undefined);
|
||||
|
||||
buffer.clear();
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
|
||||
buffer.fillViewportRows();
|
||||
buffer.lines.get(0)!.setCell(0, createCellData(0, 'b', 1));
|
||||
buffer.translateBufferLineToString(0, false);
|
||||
assert.equal(cache.entries.size, 1);
|
||||
|
||||
buffer.resize(INIT_COLS - 1, INIT_ROWS);
|
||||
assert.equal(cache.entries.size, 0);
|
||||
assert.equal(buffer.getStringCacheClearTimeout(), undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('memory cleanup after shrinking', () => {
|
||||
it('should realign memory from idle task execution', async () => {
|
||||
buffer.fillViewportRows();
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
*/
|
||||
|
||||
import { CircularList, IInsertEvent } from 'common/CircularList';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IdleTaskQueue } from 'common/TaskQueue';
|
||||
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { getWrappedLineTrimmedLength, reflowLargerApplyNewLayout, reflowLargerCreateNewLayout, reflowLargerGetLinesToRemove, reflowSmallerGetNewLineLengths } from 'common/buffer/BufferReflow';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
@@ -25,7 +27,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
|
||||
* - cursor position
|
||||
* - scroll position
|
||||
*/
|
||||
export class Buffer implements IBuffer {
|
||||
export class Buffer extends Disposable implements IBuffer {
|
||||
public lines: CircularList<IBufferLine>;
|
||||
public ydisp: number = 0;
|
||||
public ybase: number = 0;
|
||||
@@ -50,6 +52,7 @@ export class Buffer implements IBuffer {
|
||||
private _isClearing: boolean = false;
|
||||
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
|
||||
private _memoryCleanupPosition = 0;
|
||||
private readonly _stringCache: BufferLineStringCache;
|
||||
|
||||
constructor(
|
||||
private _hasScrollback: boolean,
|
||||
@@ -57,6 +60,7 @@ export class Buffer implements IBuffer {
|
||||
private _bufferService: IBufferService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
this._cols = this._bufferService.cols;
|
||||
this._rows = this._bufferService.rows;
|
||||
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
|
||||
@@ -64,6 +68,9 @@ export class Buffer implements IBuffer {
|
||||
this.scrollBottom = this._rows - 1;
|
||||
this.setupTabStops();
|
||||
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
|
||||
this._register(toDisposable(() => this._memoryCleanupQueue.clear()));
|
||||
this._register(toDisposable(() => this.clearAllMarkers()));
|
||||
this._stringCache = this._register(new BufferLineStringCache());
|
||||
}
|
||||
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
@@ -93,7 +100,7 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
|
||||
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
|
||||
return new BufferLine(this._bufferService.cols, this.getNullCell(attr), isWrapped);
|
||||
return new BufferLine(this._stringCache, this._bufferService.cols, this.getNullCell(attr), isWrapped);
|
||||
}
|
||||
|
||||
public get hasScrollback(): boolean {
|
||||
@@ -138,6 +145,7 @@ export class Buffer implements IBuffer {
|
||||
* Clears the buffer to it's initial state, discarding all previous data.
|
||||
*/
|
||||
public clear(): void {
|
||||
this._stringCache.clear();
|
||||
this.ydisp = 0;
|
||||
this.ybase = 0;
|
||||
this.y = 0;
|
||||
@@ -156,6 +164,7 @@ export class Buffer implements IBuffer {
|
||||
public resize(newCols: number, newRows: number): void {
|
||||
// store reference to null cell with default attrs
|
||||
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
|
||||
this._stringCache.clear();
|
||||
|
||||
// count bufferlines with overly big memory to be cleaned afterwards
|
||||
let dirtyMemoryLines = 0;
|
||||
@@ -190,7 +199,7 @@ export class Buffer implements IBuffer {
|
||||
if (this._optionsService.rawOptions.windowsPty.backend !== undefined || this._optionsService.rawOptions.windowsPty.buildNumber !== undefined) {
|
||||
// Just add the new missing rows on Windows as conpty reprints the screen with it's
|
||||
// view of the world. Once a line enters scrollback for conpty it remains there
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
} else {
|
||||
if (this.ybase > 0 && this.lines.length <= this.ybase + this.y + addToY + 1) {
|
||||
// There is room above the buffer and there are no empty elements below the line,
|
||||
@@ -204,7 +213,7 @@ export class Buffer implements IBuffer {
|
||||
} else {
|
||||
// Add a blank line if there is no buffer left at the top to scroll to, or if there
|
||||
// are blank lines after the cursor
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,7 +354,7 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
if (this.lines.length < newRows) {
|
||||
// Add an extra row at the bottom of the viewport
|
||||
this.lines.push(new BufferLine(newCols, nullCell));
|
||||
this.lines.push(new BufferLine(this._stringCache, newCols, nullCell, false));
|
||||
}
|
||||
} else {
|
||||
if (this.ydisp === this.ybase) {
|
||||
|
||||
@@ -4,18 +4,41 @@
|
||||
*/
|
||||
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
|
||||
import { BufferLine } from 'common/buffer//BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { CharData, IBufferLine } from '../Types';
|
||||
import { CharData, IBufferLine, ICellData } from '../Types';
|
||||
import { assert } from 'chai';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { createCellData, NULL_CELL_DATA, extendedAttributes } from 'common/TestUtils.test';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
|
||||
|
||||
class TestBufferLine extends BufferLine {
|
||||
constructor(cols: number, fillCellData?: ICellData, isWrapped: boolean = false) {
|
||||
super(TEST_STRING_CACHE, cols, fillCellData, isWrapped);
|
||||
}
|
||||
|
||||
public get combined(): {[index: number]: string} {
|
||||
return this._combined;
|
||||
}
|
||||
|
||||
public get cachedString(): string | undefined {
|
||||
return this._getStringCacheEntry(false)?.value;
|
||||
}
|
||||
|
||||
public set cachedString(value: string | undefined) {
|
||||
this._getStringCacheEntry(true)!.value = value;
|
||||
}
|
||||
|
||||
public get isCachedStringTrimmed(): boolean {
|
||||
return this._getStringCacheEntry(false)?.isTrimmed ?? false;
|
||||
}
|
||||
|
||||
public set isCachedStringTrimmed(value: boolean) {
|
||||
this._getStringCacheEntry(true)!.isTrimmed = value;
|
||||
}
|
||||
|
||||
public toArray(): CharData[] {
|
||||
const result = [];
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
@@ -807,5 +830,81 @@ describe('BufferLine', function(): void {
|
||||
assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
|
||||
assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
|
||||
});
|
||||
|
||||
it('should cache canonical string translations', () => {
|
||||
const line = new TestBufferLine(5);
|
||||
line.setCell(0, createCellData(1, 'a', 1));
|
||||
line.setCell(1, createCellData(1, 'b', 1));
|
||||
line.setCell(2, createCellData(1, 'c', 1));
|
||||
|
||||
// Trimmed-only canonical request should cache the trimmed value.
|
||||
const trimmed = line.translateToString(true, undefined, undefined, undefined);
|
||||
assert.equal(trimmed, 'abc');
|
||||
assert.equal(line.cachedString, 'abc');
|
||||
assert.equal(line.isCachedStringTrimmed, true);
|
||||
|
||||
// Non-trimmed canonical request should refresh cache with the full value.
|
||||
const translated = line.translateToString(false, undefined, undefined, undefined);
|
||||
assert.equal(translated, 'abc ');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
// Once non-trimmed is cached, trimmed should be derived via trimEnd().
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'abc');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
line.cachedString = 'cached-non-trimmed ';
|
||||
line.isCachedStringTrimmed = false;
|
||||
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'cached-non-trimmed ');
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-non-trimmed');
|
||||
|
||||
line.cachedString = 'cached-trimmed';
|
||||
line.isCachedStringTrimmed = true;
|
||||
assert.equal(line.translateToString(true, undefined, undefined, undefined), 'cached-trimmed');
|
||||
assert.equal(line.translateToString(false, undefined, undefined, undefined), 'abc ');
|
||||
assert.equal(line.cachedString, 'abc ');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
|
||||
// Any optional translation argument should bypass cache.
|
||||
assert.equal(line.translateToString(false, 0, 2, undefined), 'ab');
|
||||
assert.equal(line.translateToString(true, 0, 2, undefined), 'ab');
|
||||
});
|
||||
|
||||
it('should invalidate cached canonical strings on line mutations', () => {
|
||||
const assertCacheInvalidated = (mutate: (line: TestBufferLine) => void): void => {
|
||||
const line = new TestBufferLine(5);
|
||||
line.fill(createCellData(1, 'a', 1));
|
||||
line.translateToString(true, undefined, undefined, undefined);
|
||||
assert.equal(line.cachedString, 'aaaaa');
|
||||
assert.equal(line.isCachedStringTrimmed, true);
|
||||
line.translateToString(false, undefined, undefined, undefined);
|
||||
assert.equal(line.cachedString, 'aaaaa');
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
mutate(line);
|
||||
assert.equal(line.cachedString, undefined);
|
||||
assert.equal(line.isCachedStringTrimmed, false);
|
||||
};
|
||||
|
||||
assertCacheInvalidated(line => line.set(0, [0, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
assertCacheInvalidated(line => line.setCell(0, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.setCellFromCodepoint(0, 'b'.charCodeAt(0), 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.addCodepointToCell(0, 0x301, 0));
|
||||
assertCacheInvalidated(line => line.insertCells(1, 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.deleteCells(1, 1, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.replaceCells(1, 3, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.resize(6, createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => line.fill(createCellData(1, 'b', 1)));
|
||||
assertCacheInvalidated(line => {
|
||||
const src = new TestBufferLine(5);
|
||||
src.fill(createCellData(1, 'x', 1));
|
||||
line.copyFrom(src);
|
||||
});
|
||||
assertCacheInvalidated(line => {
|
||||
const src = new TestBufferLine(5);
|
||||
src.fill(createCellData(1, 'x', 1));
|
||||
line.copyCellsFrom(src, 0, 0, 2, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+128
-59
@@ -9,25 +9,26 @@ import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
|
||||
import { stringFromCodePoint } from 'common/input/TextDecoder';
|
||||
|
||||
/**
|
||||
* buffer memory layout:
|
||||
*
|
||||
* | uint32_t | uint32_t | uint32_t |
|
||||
* | `content` | `FG` | `BG` |
|
||||
* | wcwidth(2) comb(1) codepoint(21) | flags(8) R(8) G(8) B(8) | flags(8) R(8) G(8) B(8) |
|
||||
*/
|
||||
// Buffer memory layout:
|
||||
//
|
||||
// [0]: content `uint32_t` - wcwidth(2) comb(1) codepoint(21)
|
||||
// [1]: fg `uint32_t` - flags(8) r(8) g(8) b(8)
|
||||
// [2]: bg `uint32_t` - flags(8) r(8) g(8) b(8)
|
||||
|
||||
|
||||
/** typed array slots taken by one cell */
|
||||
const CELL_SIZE = 3;
|
||||
const enum Constants {
|
||||
/** The number of 32 bit array indices taken by one cell. */
|
||||
CELL_INDICIES = 3,
|
||||
/** Factor when to cleanup underlying array buffer after shrinking. */
|
||||
CLEANUP_THRESHOLD = 2
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell member indices.
|
||||
*
|
||||
* Direct access:
|
||||
* `content = data[column * CELL_SIZE + Cell.CONTENT];`
|
||||
* `fg = data[column * CELL_SIZE + Cell.FG];`
|
||||
* `bg = data[column * CELL_SIZE + Cell.BG];`
|
||||
* `content = data[column * Constants.CELL_INDICIES + Cell.CONTENT];`
|
||||
* `fg = data[column * Constants.CELL_INDICIES + Cell.FG];`
|
||||
* `bg = data[column * Constants.CELL_INDICIES + Cell.BG];`
|
||||
*/
|
||||
const enum Cell {
|
||||
CONTENT = 0,
|
||||
@@ -41,8 +42,17 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
|
||||
let $startIndex = 0;
|
||||
const $workCell = new CellData();
|
||||
|
||||
/** Factor when to cleanup underlying array buffer after shrinking. */
|
||||
const CLEANUP_THRESHOLD = 2;
|
||||
export interface IBufferLineStringCacheEntry {
|
||||
value: string | undefined;
|
||||
isTrimmed: boolean;
|
||||
generation: number;
|
||||
}
|
||||
|
||||
export interface IBufferLineStringCache {
|
||||
generation: number;
|
||||
allocateEntry(): IBufferLineStringCacheEntry;
|
||||
touch?(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Typed array based bufferline implementation.
|
||||
@@ -63,10 +73,16 @@ export class BufferLine implements IBufferLine {
|
||||
protected _data: Uint32Array;
|
||||
protected _combined: {[index: number]: string} = {};
|
||||
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
|
||||
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
|
||||
public length: number;
|
||||
|
||||
constructor(cols: number, fillCellData?: ICellData, public isWrapped: boolean = false) {
|
||||
this._data = new Uint32Array(cols * CELL_SIZE);
|
||||
constructor(
|
||||
protected readonly _stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData?: ICellData,
|
||||
public isWrapped: boolean = false
|
||||
) {
|
||||
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
|
||||
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
for (let i = 0; i < cols; ++i) {
|
||||
this.setCell(i, cell);
|
||||
@@ -79,10 +95,10 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public get(index: number): CharData {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
return [
|
||||
this._data[index * CELL_SIZE + Cell.FG],
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG],
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? this._combined[index]
|
||||
: (cp) ? stringFromCodePoint(cp) : '',
|
||||
@@ -98,12 +114,13 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public set(index: number, value: CharData): void {
|
||||
this._data[index * CELL_SIZE + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
|
||||
this._invalidateStringCache();
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG] = value[CHAR_DATA_ATTR_INDEX];
|
||||
if (value[CHAR_DATA_CHAR_INDEX].length > 1) {
|
||||
this._combined[index] = value[1];
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
} else {
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,22 +129,22 @@ export class BufferLine implements IBufferLine {
|
||||
* use these when only one value is needed, otherwise use `loadCell`
|
||||
*/
|
||||
public getWidth(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT;
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT;
|
||||
}
|
||||
|
||||
/** Test whether content has width. */
|
||||
public hasWidth(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.WIDTH_MASK;
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.WIDTH_MASK;
|
||||
}
|
||||
|
||||
/** Get FG cell component. */
|
||||
public getFg(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.FG];
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.FG];
|
||||
}
|
||||
|
||||
/** Get BG cell component. */
|
||||
public getBg(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.BG];
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.BG];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +153,7 @@ export class BufferLine implements IBufferLine {
|
||||
* from real empty cells.
|
||||
*/
|
||||
public hasContent(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,7 +162,7 @@ export class BufferLine implements IBufferLine {
|
||||
* a single UTF32 codepoint or the last codepoint of a combined string.
|
||||
*/
|
||||
public getCodePoint(index: number): number {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index].charCodeAt(this._combined[index].length - 1);
|
||||
}
|
||||
@@ -154,12 +171,12 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** Test whether the cell contains a combined string. */
|
||||
public isCombined(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.CONTENT] & Content.IS_COMBINED_MASK;
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] & Content.IS_COMBINED_MASK;
|
||||
}
|
||||
|
||||
/** Returns the string content of the cell. */
|
||||
public getString(index: number): string {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index];
|
||||
}
|
||||
@@ -172,7 +189,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** Get state of protected flag. */
|
||||
public isProtected(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.BG] & BgFlags.PROTECTED;
|
||||
return this._data[index * Constants.CELL_INDICIES + Cell.BG] & BgFlags.PROTECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,7 +197,7 @@ export class BufferLine implements IBufferLine {
|
||||
* to GC as it significantly reduced the amount of new objects/references needed.
|
||||
*/
|
||||
public loadCell(index: number, cell: ICellData): ICellData {
|
||||
$startIndex = index * CELL_SIZE;
|
||||
$startIndex = index * Constants.CELL_INDICIES;
|
||||
cell.content = this._data[$startIndex + Cell.CONTENT];
|
||||
cell.fg = this._data[$startIndex + Cell.FG];
|
||||
cell.bg = this._data[$startIndex + Cell.BG];
|
||||
@@ -197,15 +214,16 @@ export class BufferLine implements IBufferLine {
|
||||
* Set data at `index` to `cell`.
|
||||
*/
|
||||
public setCell(index: number, cell: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._combined[index] = cell.combinedData;
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = cell.extended;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = cell.content;
|
||||
this._data[index * CELL_SIZE + Cell.FG] = cell.fg;
|
||||
this._data[index * CELL_SIZE + Cell.BG] = cell.bg;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = cell.content;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG] = cell.fg;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.BG] = cell.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,12 +232,13 @@ export class BufferLine implements IBufferLine {
|
||||
* it gets an optimized access method.
|
||||
*/
|
||||
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
|
||||
this._invalidateStringCache();
|
||||
if (attrs.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = attrs.extended;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[index * CELL_SIZE + Cell.FG] = attrs.fg;
|
||||
this._data[index * CELL_SIZE + Cell.BG] = attrs.bg;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.FG] = attrs.fg;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.BG] = attrs.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,7 +248,8 @@ export class BufferLine implements IBufferLine {
|
||||
* by the previous `setDataFromCodePoint` call, we can omit it here.
|
||||
*/
|
||||
public addCodepointToCell(index: number, codePoint: number, width: number): void {
|
||||
let content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
this._invalidateStringCache();
|
||||
let content = this._data[index * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
// we already have a combined string, simply add
|
||||
this._combined[index] += stringFromCodePoint(codePoint);
|
||||
@@ -251,10 +271,11 @@ export class BufferLine implements IBufferLine {
|
||||
content &= ~Content.WIDTH_MASK;
|
||||
content |= width << Content.WIDTH_SHIFT;
|
||||
}
|
||||
this._data[index * CELL_SIZE + Cell.CONTENT] = content;
|
||||
this._data[index * Constants.CELL_INDICIES + Cell.CONTENT] = content;
|
||||
}
|
||||
|
||||
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
pos %= this.length;
|
||||
|
||||
// handle fullwidth at pos: reset cell one to the left if pos is second cell of a wide char
|
||||
@@ -282,6 +303,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
pos %= this.length;
|
||||
if (n < this.length - pos) {
|
||||
for (let i = 0; i < this.length - pos - n; ++i) {
|
||||
@@ -308,6 +330,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
|
||||
@@ -344,13 +367,14 @@ export class BufferLine implements IBufferLine {
|
||||
* The underlying array buffer will not change if there is still enough space
|
||||
* to hold the new buffer line data.
|
||||
* Returns a boolean indicating, whether a `cleanupMemory` call would free
|
||||
* excess memory (true after shrinking > CLEANUP_THRESHOLD).
|
||||
* excess memory (true after shrinking > Constants.CLEANUP_THRESHOLD).
|
||||
*/
|
||||
public resize(cols: number, fillCellData: ICellData): boolean {
|
||||
this._invalidateStringCache();
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
const uint32Cells = cols * CELL_SIZE;
|
||||
const uint32Cells = cols * Constants.CELL_INDICIES;
|
||||
if (cols > this.length) {
|
||||
if (this._data.buffer.byteLength >= uint32Cells * 4) {
|
||||
// optimization: avoid alloc and data copy if buffer has enough room
|
||||
@@ -385,17 +409,17 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
}
|
||||
this.length = cols;
|
||||
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
return uint32Cells * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup underlying array buffer.
|
||||
* A cleanup will be triggered if the array buffer exceeds the actual used
|
||||
* memory by a factor of CLEANUP_THRESHOLD.
|
||||
* memory by a factor of Constants.CLEANUP_THRESHOLD.
|
||||
* Returns 0 or 1 indicating whether a cleanup happened.
|
||||
*/
|
||||
public cleanupMemory(): number {
|
||||
if (this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
|
||||
if (this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength) {
|
||||
const data = new Uint32Array(this._data.length);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
@@ -406,6 +430,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** fill a line with fillCharData */
|
||||
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
this._invalidateStringCache();
|
||||
// full branching on respectProtect==true, hopefully getting fast JIT for standard case
|
||||
if (respectProtect) {
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
@@ -424,6 +449,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** alter to a full copy of line */
|
||||
public copyFrom(line: BufferLine): void {
|
||||
this._invalidateStringCache();
|
||||
if (this.length !== line.length) {
|
||||
this._data = new Uint32Array(line._data);
|
||||
} else {
|
||||
@@ -444,7 +470,7 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** create a new clone */
|
||||
public clone(): IBufferLine {
|
||||
const newLine = new BufferLine(0);
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
|
||||
newLine._data = new Uint32Array(this._data);
|
||||
newLine.length = this.length;
|
||||
for (const el in this._combined) {
|
||||
@@ -459,8 +485,8 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
public getTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
@@ -468,30 +494,31 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
public getNoBgTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
if ((this._data[i * CELL_SIZE + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * CELL_SIZE + Cell.BG] & Attributes.CM_MASK)) {
|
||||
return i + (this._data[i * CELL_SIZE + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
if ((this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[i * Constants.CELL_INDICIES + Cell.BG] & Attributes.CM_MASK)) {
|
||||
return i + (this._data[i * Constants.CELL_INDICIES + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public copyCellsFrom(src: BufferLine, srcCol: number, destCol: number, length: number, applyInReverse: boolean): void {
|
||||
this._invalidateStringCache();
|
||||
const srcData = src._data;
|
||||
if (applyInReverse) {
|
||||
for (let cell = length - 1; cell >= 0; cell--) {
|
||||
for (let i = 0; i < CELL_SIZE; i++) {
|
||||
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let cell = 0; cell < length; cell++) {
|
||||
for (let i = 0; i < CELL_SIZE; i++) {
|
||||
this._data[(destCol + cell) * CELL_SIZE + i] = srcData[(srcCol + cell) * CELL_SIZE + i];
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[(destCol + cell) * Constants.CELL_INDICIES + i] = srcData[(srcCol + cell) * Constants.CELL_INDICIES + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
if (srcData[(srcCol + cell) * Constants.CELL_INDICIES + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
}
|
||||
}
|
||||
@@ -508,7 +535,8 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates the buffer line to a string.
|
||||
* Translates the buffer line to a string. Caching only applies to canonical full-line translation
|
||||
* requests (regardless of `trimRight` value).
|
||||
*
|
||||
* @param trimRight Whether to trim any empty cells on the right.
|
||||
* @param startCol The column to start the string (0-based inclusive).
|
||||
@@ -521,6 +549,19 @@ export class BufferLine implements IBufferLine {
|
||||
* returned string, the corresponding entries in `outColumns` will have the same column number.
|
||||
*/
|
||||
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
|
||||
const isCanonicalRequest = (startCol === undefined || startCol === 0) && endCol === undefined && outColumns === undefined;
|
||||
if (isCanonicalRequest) {
|
||||
this._stringCache.touch?.();
|
||||
}
|
||||
const stringCacheEntry = isCanonicalRequest ? this._getStringCacheEntry(false) : undefined;
|
||||
if (isCanonicalRequest && stringCacheEntry?.value !== undefined) {
|
||||
if (trimRight) {
|
||||
return stringCacheEntry.isTrimmed ? stringCacheEntry.value : stringCacheEntry.value.trimEnd();
|
||||
}
|
||||
if (!stringCacheEntry.isTrimmed) {
|
||||
return stringCacheEntry.value;
|
||||
}
|
||||
}
|
||||
startCol = startCol ?? 0;
|
||||
endCol = endCol ?? this.length;
|
||||
if (trimRight) {
|
||||
@@ -531,7 +572,7 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
let result = '';
|
||||
while (startCol < endCol) {
|
||||
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
|
||||
const content = this._data[startCol * Constants.CELL_INDICIES + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
const chars = (content & Content.IS_COMBINED_MASK) ? this._combined[startCol] : (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
result += chars;
|
||||
@@ -545,6 +586,34 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
if (isCanonicalRequest) {
|
||||
const cacheEntry = this._getStringCacheEntry(true)!;
|
||||
cacheEntry.value = result;
|
||||
cacheEntry.isTrimmed = !!trimRight;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected _getStringCacheEntry(createIfNeeded: boolean): IBufferLineStringCacheEntry | undefined {
|
||||
const cachedEntry = this._stringCacheEntryRef?.deref();
|
||||
if (cachedEntry) {
|
||||
if (cachedEntry.generation === this._stringCache.generation) {
|
||||
return cachedEntry;
|
||||
}
|
||||
}
|
||||
if (!createIfNeeded) {
|
||||
return undefined;
|
||||
}
|
||||
const cacheEntry = this._stringCache.allocateEntry();
|
||||
this._stringCacheEntryRef = new WeakRef(cacheEntry);
|
||||
return cacheEntry;
|
||||
}
|
||||
|
||||
private _invalidateStringCache(): void {
|
||||
const cacheEntry = this._getStringCacheEntry(false);
|
||||
if (cacheEntry) {
|
||||
cacheEntry.value = undefined;
|
||||
cacheEntry.isTrimmed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IBufferLineStringCache, IBufferLineStringCacheEntry } from 'common/buffer/BufferLine';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
import { Disposable, MutableDisposable, toDisposable, type IDisposable } from 'common/Lifecycle';
|
||||
|
||||
const enum Constants {
|
||||
CACHE_TTL_MS = 15000
|
||||
}
|
||||
|
||||
export class BufferLineStringCache extends Disposable implements IBufferLineStringCache {
|
||||
public generation: number = 0;
|
||||
public readonly entries: Set<IBufferLineStringCacheEntry> = new Set();
|
||||
private readonly _clearTimeout = this._register(new MutableDisposable<IDisposable>());
|
||||
private _lastAccessTimestamp: number = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._register(toDisposable(() => this.entries.clear()));
|
||||
}
|
||||
|
||||
public touch(): void {
|
||||
this._scheduleClear();
|
||||
}
|
||||
|
||||
public allocateEntry(): IBufferLineStringCacheEntry {
|
||||
const entry: IBufferLineStringCacheEntry = {
|
||||
value: undefined,
|
||||
isTrimmed: false,
|
||||
generation: this.generation
|
||||
};
|
||||
this.entries.add(entry);
|
||||
this._scheduleClear();
|
||||
return entry;
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._clearTimeout.clear();
|
||||
this._lastAccessTimestamp = 0;
|
||||
this.generation++;
|
||||
for (const entry of this.entries) {
|
||||
entry.value = undefined;
|
||||
entry.isTrimmed = false;
|
||||
}
|
||||
this.entries.clear();
|
||||
}
|
||||
|
||||
private _scheduleClear(): void {
|
||||
this._lastAccessTimestamp = Date.now();
|
||||
if (this._clearTimeout.value) {
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS);
|
||||
}
|
||||
|
||||
private _scheduleClearTimeout(timeoutMs: number): void {
|
||||
this._clearTimeout.value = disposableTimeout(() => {
|
||||
const elapsed = Date.now() - this._lastAccessTimestamp;
|
||||
if (elapsed >= Constants.CACHE_TTL_MS) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
this._scheduleClearTimeout(Constants.CACHE_TTL_MS - elapsed);
|
||||
}, timeoutMs);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { Disposable, MutableDisposable } from 'common/Lifecycle';
|
||||
import { IAttributeData } from 'common/Types';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { IBuffer, IBufferSet } from 'common/buffer/Types';
|
||||
@@ -18,6 +18,8 @@ export class BufferSet extends Disposable implements IBufferSet {
|
||||
private _normal!: Buffer;
|
||||
private _alt!: Buffer;
|
||||
private _activeBuffer!: Buffer;
|
||||
private readonly _normalBuffer = this._register(new MutableDisposable<Buffer>());
|
||||
private readonly _altBuffer = this._register(new MutableDisposable<Buffer>());
|
||||
|
||||
private readonly _onBufferActivate = this._register(new Emitter<{ activeBuffer: IBuffer, inactiveBuffer: IBuffer }>());
|
||||
public readonly onBufferActivate = this._onBufferActivate.event;
|
||||
@@ -38,11 +40,13 @@ export class BufferSet extends Disposable implements IBufferSet {
|
||||
|
||||
public reset(): void {
|
||||
this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService);
|
||||
this._normalBuffer.value = this._normal;
|
||||
this._normal.fillViewportRows();
|
||||
|
||||
// The alt buffer should never have scrollback.
|
||||
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
|
||||
this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService);
|
||||
this._altBuffer.value = this._alt;
|
||||
this._activeBuffer = this._normal;
|
||||
this._onBufferActivate.fire({
|
||||
activeBuffer: this._normal,
|
||||
|
||||
@@ -453,19 +453,59 @@ describe('KittyKeyboard', () => {
|
||||
describe('event types (press/repeat/release)', () => {
|
||||
const flags = KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
|
||||
|
||||
it('press event (default, no suffix)', () => {
|
||||
it('UTF-8 text press event', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[97u');
|
||||
assert.strictEqual(result.key, 'a');
|
||||
});
|
||||
|
||||
it('press event explicit :1 when modifiers present', () => {
|
||||
it('Escape key press event (default, no suffix)', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[27u');
|
||||
});
|
||||
|
||||
it('Enter key press event → legacy \\r', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\r');
|
||||
});
|
||||
|
||||
it('Tab key press event → legacy \\t', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\t');
|
||||
});
|
||||
|
||||
it('Backspace key press event → legacy \\x7f', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x7f');
|
||||
});
|
||||
|
||||
it('press event when modifiers present', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[97;5u');
|
||||
});
|
||||
|
||||
it('repeat event → :2 suffix', () => {
|
||||
it('UTF-8 text repeat event', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[97;1:2u');
|
||||
assert.strictEqual(result.key, 'a');
|
||||
});
|
||||
|
||||
it('Escape key repeat event → :2 suffix', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[27;1:2u');
|
||||
});
|
||||
|
||||
it('Enter key repeat event → legacy \\r', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\r');
|
||||
});
|
||||
|
||||
it('Tab key repeat event → legacy \\t', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\t');
|
||||
});
|
||||
|
||||
it('Backspace key repeat event → legacy \\x7f', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x7f');
|
||||
});
|
||||
|
||||
it('release event → :3 suffix', () => {
|
||||
@@ -473,6 +513,26 @@ describe('KittyKeyboard', () => {
|
||||
assert.strictEqual(result.key, '\x1b[97;1:3u');
|
||||
});
|
||||
|
||||
it('Escape key release event → :3 suffix', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Escape' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[27;1:3u');
|
||||
});
|
||||
|
||||
it('Enter key release event is not reported', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, undefined);
|
||||
});
|
||||
|
||||
it('Tab key release event is not reported', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, undefined);
|
||||
});
|
||||
|
||||
it('Backspace key release event is not reported', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, undefined);
|
||||
});
|
||||
|
||||
it('release with modifier → mod:3', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[97;5:3u');
|
||||
@@ -494,6 +554,27 @@ describe('KittyKeyboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense and
|
||||
// isn't specified in the spec, but press and repeat events shouldn't get swallowed.
|
||||
describe('REPORT_EVENT_TYPES flag without DISAMBIGUATE_ESCAPE_CODES', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
|
||||
|
||||
it('press event is not swallowed when modifiers present', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[97;5u');
|
||||
});
|
||||
|
||||
it('repeat event is not swallowed when modifiers present', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[97;5:2u');
|
||||
});
|
||||
|
||||
it('release event is reported as CSI u sequence', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'a', ctrlKey: true }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[97;5:3u');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifier-only reporting', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
|
||||
|
||||
@@ -563,6 +644,55 @@ describe('KittyKeyboard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag with REPORT_EVENT_TYPES', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_EVENT_TYPES;
|
||||
|
||||
it('Enter key press event → CSI 13 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[13u');
|
||||
});
|
||||
|
||||
it('Tab key press event → CSI 9 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[9u');
|
||||
});
|
||||
|
||||
it('Backspace key press event → CSI 127 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.PRESS);
|
||||
assert.strictEqual(result.key, '\x1b[127u');
|
||||
});
|
||||
|
||||
it('Enter key repeat event → CSI 13;1:2 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[13;1:2u');
|
||||
});
|
||||
|
||||
it('Tab key repeat event → CSI 9;1:2 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[9;1:2u');
|
||||
});
|
||||
|
||||
it('Backspace key repeat event → CSI 127;1:2 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.REPEAT);
|
||||
assert.strictEqual(result.key, '\x1b[127;1:2u');
|
||||
});
|
||||
|
||||
it('Enter key release event → CSI 13;1:3 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[13;1:3u');
|
||||
});
|
||||
|
||||
it('Tab key release event → CSI 9;1:3 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[9;1:3u');
|
||||
});
|
||||
|
||||
it('Backspace key release event → CSI 127;1:3 u', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[127;1:3u');
|
||||
});
|
||||
});
|
||||
|
||||
describe('REPORT_ASSOCIATED_TEXT flag', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES | KittyKeyboardFlags.REPORT_ASSOCIATED_TEXT;
|
||||
|
||||
|
||||
@@ -471,29 +471,36 @@ export class KittyKeyboard {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Special handling for Enter/Tab/Backspace.
|
||||
const specialKey = keyCode === 13 || keyCode === 9 || keyCode === 127;
|
||||
|
||||
// Per spec, Enter/Tab/Backspace will not have release events unless "Report all keys as escape
|
||||
// codes" is also set.
|
||||
if (specialKey && eventType === KittyKeyboardEventType.RELEASE && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const isFunc = this._functionalKeyCodes[ev.key] !== undefined || this._getNumpadKeyCode(ev) !== undefined;
|
||||
|
||||
let useCsiU = false;
|
||||
|
||||
if (flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) {
|
||||
useCsiU = true;
|
||||
} else if (reportEventTypes) {
|
||||
useCsiU = true;
|
||||
} else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) {
|
||||
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy
|
||||
// mode" and consider space to be a text-generating key, so these skip the isFunc fast-path
|
||||
// and only get CSI u when modifiers are present (handled below).
|
||||
const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127;
|
||||
if (isFunc && !isDisambiguateLegacy) {
|
||||
useCsiU = true;
|
||||
} else if (modifiers > 0) {
|
||||
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) {
|
||||
useCsiU = false;
|
||||
} else {
|
||||
useCsiU = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const useCsiU = !!(
|
||||
flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES ||
|
||||
(reportEventTypes && eventType === KittyKeyboardEventType.RELEASE) ||
|
||||
// Enabling REPORT_EVENT_TYPES without DISAMBIGUATE_ESCAPE_CODES doesn't really make sense, so
|
||||
// just make REPORT_EVENT_TYPES imply DISAMBIGUATE_ESCAPE_CODES here for simplicity.
|
||||
// See: https://github.com/kovidgoyal/kitty/issues/9999
|
||||
((flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES || reportEventTypes) &&
|
||||
(
|
||||
// Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy mode" and
|
||||
// consider space to be a text-generating key, so these skip the isFunc fast-path and only
|
||||
// get CSI u when modifiers are present (handled below).
|
||||
(isFunc && !specialKey) ||
|
||||
(
|
||||
(modifiers > 0 && ev.key.length !== 1) ||
|
||||
modifiers - 1 > KittyKeyboardModifiers.SHIFT
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
if (useCsiU) {
|
||||
result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32, utf32ToString } from 'common/input/TextDecoder';
|
||||
import { encode } from 'utf8';
|
||||
|
||||
|
||||
// convert UTF32 codepoints to string
|
||||
@@ -29,6 +28,35 @@ function fromByteString(s: string): Uint8Array {
|
||||
return result;
|
||||
}
|
||||
|
||||
function stringToUtf8Bytes(s: string): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
let cp = s.charCodeAt(i);
|
||||
if (cp >= 0xD800 && cp <= 0xDBFF && i + 1 < s.length) {
|
||||
const next = s.charCodeAt(i + 1);
|
||||
if (next >= 0xDC00 && next <= 0xDFFF) {
|
||||
cp = 0x10000 + ((cp - 0xD800) << 10) + (next - 0xDC00);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
if (cp < 0x80) {
|
||||
bytes.push(cp);
|
||||
} else if (cp < 0x800) {
|
||||
bytes.push(0xC0 | (cp >> 6), 0x80 | (cp & 0x3F));
|
||||
} else if (cp < 0x10000) {
|
||||
bytes.push(0xE0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3F), 0x80 | (cp & 0x3F));
|
||||
} else {
|
||||
bytes.push(
|
||||
0xF0 | (cp >> 18),
|
||||
0x80 | ((cp >> 12) & 0x3F),
|
||||
0x80 | ((cp >> 6) & 0x3F),
|
||||
0x80 | (cp & 0x3F)
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function assertDecodedRange(
|
||||
min: number,
|
||||
max: number,
|
||||
@@ -172,7 +200,7 @@ describe('text encodings', () => {
|
||||
max,
|
||||
(i) => (i >= 0xD800 && i <= 0xDFFF) || i === 0xFEFF,
|
||||
(i) => String.fromCharCode(i),
|
||||
(input, target) => decoder.decode(fromByteString(encode(input)), target),
|
||||
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
|
||||
(data, length) => toString(data, length)
|
||||
);
|
||||
});
|
||||
@@ -187,7 +215,7 @@ describe('text encodings', () => {
|
||||
max,
|
||||
() => false,
|
||||
(i) => stringFromCodePoint(i),
|
||||
(input, target) => decoder.decode(fromByteString(encode(input)), target),
|
||||
(input, target) => decoder.decode(stringToUtf8Bytes(input), target),
|
||||
(data, length) => toString(data, length)
|
||||
);
|
||||
});
|
||||
@@ -196,7 +224,7 @@ describe('text encodings', () => {
|
||||
it('0xFEFF(BOM)', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(5);
|
||||
const utf8Data = fromByteString(encode(String.fromCharCode(0xFEFF)));
|
||||
const utf8Data = stringToUtf8Bytes(String.fromCharCode(0xFEFF));
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.equal(length, 0);
|
||||
decoder.clear();
|
||||
@@ -207,7 +235,7 @@ describe('text encodings', () => {
|
||||
const decoder = new Utf8ToUtf32();
|
||||
const target = new Uint32Array(500);
|
||||
for (let i = 0; i < TEST_STRINGS.length; ++i) {
|
||||
const utf8Data = fromByteString(encode(TEST_STRINGS[i]));
|
||||
const utf8Data = stringToUtf8Bytes(TEST_STRINGS[i]);
|
||||
const length = decoder.decode(utf8Data, target);
|
||||
assert.equal(toString(target, length), TEST_STRINGS[i]);
|
||||
decoder.clear();
|
||||
|
||||
@@ -9,30 +9,30 @@ import { Emitter } from 'common/Event';
|
||||
|
||||
declare const setTimeout: (handler: () => void, timeout?: number) => void;
|
||||
|
||||
/**
|
||||
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
|
||||
* Enable flow control to avoid this limit and make sure that your backend correctly
|
||||
* propagates this to the underlying pty. (see docs for further instructions)
|
||||
* Since this limit is meant as a safety parachute to prevent browser crashs,
|
||||
* it is set to a very high number. Typically xterm.js gets unresponsive with
|
||||
* a 100 times lower number (>500 kB).
|
||||
*/
|
||||
const DISCARD_WATERMARK = 50000000; // ~50 MB
|
||||
|
||||
/**
|
||||
* The max number of ms to spend on writes before allowing the renderer to
|
||||
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
|
||||
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
|
||||
* depends on the time it takes for the renderer to draw the frame.
|
||||
*/
|
||||
const WRITE_TIMEOUT_MS = 12;
|
||||
|
||||
/**
|
||||
* Threshold of max held chunks in the write buffer, that were already processed.
|
||||
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
|
||||
* memory consumption by data thats not used anymore.
|
||||
*/
|
||||
const WRITE_BUFFER_LENGTH_THRESHOLD = 50;
|
||||
const enum Constants {
|
||||
/**
|
||||
* Safety watermark to avoid memory exhaustion and browser engine crash on fast data input.
|
||||
* Enable flow control to avoid this limit and make sure that your backend correctly
|
||||
* propagates this to the underlying pty. (see docs for further instructions)
|
||||
* Since this limit is meant as a safety parachute to prevent browser crashs,
|
||||
* it is set to a very high number. Typically xterm.js gets unresponsive with
|
||||
* a 100 times lower number (>500 kB).
|
||||
*/
|
||||
DISCARD_WATERMARK = 50000000, // ~50 MB
|
||||
/**
|
||||
* The max number of ms to spend on writes before allowing the renderer to
|
||||
* catch up with a 0ms setTimeout. A value of < 33 to keep us close to
|
||||
* 30fps, and a value of < 16 to try to run at 60fps. Of course, the real FPS
|
||||
* depends on the time it takes for the renderer to draw the frame.
|
||||
*/
|
||||
WRITE_TIMEOUT_MS = 12,
|
||||
/**
|
||||
* Threshold of max held chunks in the write buffer, that were already processed.
|
||||
* This is a tradeoff between extensive write buffer shifts (bad runtime) and high
|
||||
* memory consumption by data thats not used anymore.
|
||||
*/
|
||||
WRITE_BUFFER_LENGTH_THRESHOLD = 50
|
||||
}
|
||||
|
||||
export class WriteBuffer extends Disposable {
|
||||
private _writeBuffer: (string | Uint8Array)[] = [];
|
||||
@@ -133,7 +133,7 @@ export class WriteBuffer extends Disposable {
|
||||
}
|
||||
|
||||
public write(data: string | Uint8Array, callback?: () => void): void {
|
||||
if (this._pendingData > DISCARD_WATERMARK) {
|
||||
if (this._pendingData > Constants.DISCARD_WATERMARK) {
|
||||
throw new Error('write data discarded, use flow control to avoid losing data');
|
||||
}
|
||||
|
||||
@@ -169,7 +169,7 @@ export class WriteBuffer extends Disposable {
|
||||
* effectively lowering the redrawing needs, schematically:
|
||||
*
|
||||
* macroTask _innerWrite:
|
||||
* if (performance.now() - (lastTime | 0) < WRITE_TIMEOUT_MS):
|
||||
* if (performance.now() - (lastTime | 0) < Constants.WRITE_TIMEOUT_MS):
|
||||
* schedule microTask _innerWrite(lastTime)
|
||||
* else:
|
||||
* schedule macroTask _innerWrite(0)
|
||||
@@ -218,7 +218,7 @@ export class WriteBuffer extends Disposable {
|
||||
* responsibility to slice hard work), but we can at least schedule a screen update as we
|
||||
* gain control.
|
||||
*/
|
||||
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= WRITE_TIMEOUT_MS
|
||||
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS
|
||||
? setTimeout(() => this._innerWrite(0, r))
|
||||
: this._innerWrite(startTime, r);
|
||||
|
||||
@@ -235,7 +235,7 @@ export class WriteBuffer extends Disposable {
|
||||
* current microtask queue (executed before setTimeout).
|
||||
*/
|
||||
// const continuation: (r: boolean) => void = performance.now() - startTime >=
|
||||
// WRITE_TIMEOUT_MS
|
||||
// Constants.WRITE_TIMEOUT_MS
|
||||
// ? r => setTimeout(() => this._innerWrite(0, r))
|
||||
// : r => this._innerWrite(startTime, r);
|
||||
|
||||
@@ -255,14 +255,14 @@ export class WriteBuffer extends Disposable {
|
||||
this._bufferOffset++;
|
||||
this._pendingData -= data.length;
|
||||
|
||||
if (performance.now() - startTime >= WRITE_TIMEOUT_MS) {
|
||||
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (this._writeBuffer.length > this._bufferOffset) {
|
||||
// Allow renderer to catch up before processing the next batch
|
||||
// trim already processed chunks if we are above threshold
|
||||
if (this._bufferOffset > WRITE_BUFFER_LENGTH_THRESHOLD) {
|
||||
if (this._bufferOffset > Constants.WRITE_BUFFER_LENGTH_THRESHOLD) {
|
||||
this._writeBuffer = this._writeBuffer.slice(this._bufferOffset);
|
||||
this._callbacks = this._callbacks.slice(this._bufferOffset);
|
||||
this._bufferOffset = 0;
|
||||
|
||||
+354
-255
@@ -5,7 +5,7 @@
|
||||
import { assert } from 'chai';
|
||||
import { ApcParser, ApcHandler } from 'common/parser/ApcParser';
|
||||
import { StringToUtf32, utf32ToString } from 'common/input/TextDecoder';
|
||||
import { IApcHandler } from 'common/parser/Types';
|
||||
import { IApcHandler, IFunctionIdentifier } from 'common/parser/Types';
|
||||
|
||||
function toUtf32(s: string): Uint32Array {
|
||||
const utf32 = new Uint32Array(s.length);
|
||||
@@ -14,31 +14,53 @@ function toUtf32(s: string): Uint32Array {
|
||||
return utf32.subarray(0, length);
|
||||
}
|
||||
|
||||
class TestHandler implements IApcHandler {
|
||||
public id: number;
|
||||
public output: [string, number, string, (boolean | string)?][];
|
||||
public msg: string;
|
||||
public returnFalse: boolean;
|
||||
|
||||
constructor(
|
||||
id: number,
|
||||
output: [string, number, string, (boolean | string)?][],
|
||||
msg: string,
|
||||
returnFalse: boolean = false
|
||||
) {
|
||||
this.id = id;
|
||||
this.output = output;
|
||||
this.msg = msg;
|
||||
this.returnFalse = returnFalse;
|
||||
function identifier(id: IFunctionIdentifier): number {
|
||||
let res = 0;
|
||||
if (id.prefix) {
|
||||
if (id.prefix.length > 1) {
|
||||
throw new Error('only one byte as prefix supported');
|
||||
}
|
||||
res = id.prefix.charCodeAt(0);
|
||||
if (res && 0x3c > res || res > 0x3f) {
|
||||
throw new Error('prefix must be in range 0x3c .. 0x3f');
|
||||
}
|
||||
}
|
||||
if (id.intermediates) {
|
||||
if (id.intermediates.length > 2) {
|
||||
throw new Error('only two bytes as intermediates are supported');
|
||||
}
|
||||
for (let i = 0; i < id.intermediates.length; ++i) {
|
||||
const intermediate = id.intermediates.charCodeAt(i);
|
||||
if (0x20 > intermediate || intermediate > 0x2f) {
|
||||
throw new Error('intermediate must be in range 0x20 .. 0x2f');
|
||||
}
|
||||
res <<= 8;
|
||||
res |= intermediate;
|
||||
}
|
||||
}
|
||||
if (id.final.length !== 1) {
|
||||
throw new Error('final must be a single byte');
|
||||
}
|
||||
const finalCode = id.final.charCodeAt(0);
|
||||
if (0x40 > finalCode || finalCode > 0x7e) {
|
||||
throw new Error('final must be in range 0x40 .. 0x7e');
|
||||
}
|
||||
res <<= 8;
|
||||
res |= finalCode;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
class TestHandler implements IApcHandler {
|
||||
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
|
||||
public start(): void {
|
||||
this.output.push([this.msg, this.id, 'START']);
|
||||
this.output.push([this.msg, 'START']);
|
||||
}
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
this.output.push([this.msg, this.id, 'PUT', utf32ToString(data, start, end)]);
|
||||
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
|
||||
}
|
||||
public end(success: boolean): boolean {
|
||||
this.output.push([this.msg, this.id, 'END', success]);
|
||||
this.output.push([this.msg, 'END', success]);
|
||||
if (this.returnFalse) {
|
||||
return false;
|
||||
}
|
||||
@@ -48,173 +70,105 @@ class TestHandler implements IApcHandler {
|
||||
|
||||
describe('ApcParser', () => {
|
||||
let parser: ApcParser;
|
||||
let reports: [number, string, (boolean | string | undefined)?][] = [];
|
||||
|
||||
let reports: any[] = [];
|
||||
beforeEach(() => {
|
||||
reports = [];
|
||||
parser = new ApcParser();
|
||||
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
|
||||
reports.push([id, action, data]);
|
||||
});
|
||||
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
|
||||
});
|
||||
|
||||
describe('identifier parsing', () => {
|
||||
it('single character identifier', () => {
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100,a=T;payload');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [
|
||||
[0x47, 'START', undefined], // 0x47 = 'G'
|
||||
[0x47, 'PUT', 'f=100,a=T;payload'],
|
||||
[0x47, 'END', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifier with no payload', () => {
|
||||
parser.start();
|
||||
const data = toUtf32('G');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [
|
||||
[0x47, 'START', undefined],
|
||||
[0x47, 'END', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it('identifier with chunked payload', () => {
|
||||
parser.start();
|
||||
let data = toUtf32('Gf=100');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(',a=T');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(';payload');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [
|
||||
[0x47, 'START', undefined],
|
||||
[0x47, 'PUT', 'f=100'],
|
||||
[0x47, 'PUT', ',a=T'],
|
||||
[0x47, 'PUT', ';payload'],
|
||||
[0x47, 'END', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it('empty APC sequence', () => {
|
||||
parser.start();
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('handler registration', () => {
|
||||
let handlerReports: [string, number, string, (boolean | string)?][];
|
||||
|
||||
beforeEach(() => {
|
||||
handlerReports = [];
|
||||
});
|
||||
|
||||
it('registerHandler for specific identifier', () => {
|
||||
const G_CODE = 0x47; // 'G'
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100,a=T;imagedata');
|
||||
it('setApcHandler', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, [
|
||||
['kitty', G_CODE, 'START'],
|
||||
['kitty', G_CODE, 'PUT', 'f=100,a=T;imagedata'],
|
||||
['kitty', G_CODE, 'END', true]
|
||||
]);
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
|
||||
it('unregistered identifier falls back', () => {
|
||||
const G_CODE = 0x47; // 'G'
|
||||
const X_CODE = 0x58; // 'X'
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
|
||||
parser.start();
|
||||
const data = toUtf32('Xsome data');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, []);
|
||||
assert.deepEqual(reports, [
|
||||
[X_CODE, 'START', undefined],
|
||||
[X_CODE, 'PUT', 'some data'],
|
||||
[X_CODE, 'END', true]
|
||||
// messages from TestHandler
|
||||
['th', 'START'],
|
||||
['th', 'PUT', 'Here comes'],
|
||||
['th', 'PUT', 'the mouse!'],
|
||||
['th', 'END', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it('clearHandler removes handler', () => {
|
||||
const G_CODE = 0x47;
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
|
||||
parser.clearHandler(G_CODE);
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100');
|
||||
it('clearApcHandler', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th'));
|
||||
parser.clearHandler(identifier({intermediates: '+', final: 'p'}));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, []);
|
||||
assert.deepEqual(reports, [
|
||||
[G_CODE, 'START', undefined],
|
||||
[G_CODE, 'PUT', 'f=100'],
|
||||
[G_CODE, 'END', true]
|
||||
// messages from fallback handler
|
||||
[identifier({intermediates: '+', final: 'p'}), 'START', undefined],
|
||||
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'Here comes'],
|
||||
[identifier({intermediates: '+', final: 'p'}), 'PUT', 'the mouse!'],
|
||||
[identifier({intermediates: '+', final: 'p'}), 'END', true]
|
||||
]);
|
||||
});
|
||||
|
||||
it('multiple handlers for same identifier', () => {
|
||||
const G_CODE = 0x47;
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
|
||||
parser.start();
|
||||
const data = toUtf32('Gdata');
|
||||
it('addApcHandler', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2'));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, [
|
||||
['handler2', G_CODE, 'START'],
|
||||
['handler1', G_CODE, 'START'],
|
||||
['handler2', G_CODE, 'PUT', 'data'],
|
||||
['handler1', G_CODE, 'PUT', 'data'],
|
||||
['handler2', G_CODE, 'END', true],
|
||||
['handler1', G_CODE, 'END', false]
|
||||
assert.deepEqual(reports, [
|
||||
['th2', 'START'],
|
||||
['th1', 'START'],
|
||||
['th2', 'PUT', 'Here comes'],
|
||||
['th1', 'PUT', 'Here comes'],
|
||||
['th2', 'PUT', 'the mouse!'],
|
||||
['th1', 'PUT', 'the mouse!'],
|
||||
['th2', 'END', true],
|
||||
['th1', 'END', false] // false due being already handled by th2!
|
||||
]);
|
||||
});
|
||||
|
||||
it('handler returning false allows fallthrough', () => {
|
||||
const G_CODE = 0x47;
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2', true));
|
||||
parser.start();
|
||||
const data = toUtf32('Gdata');
|
||||
it('addApcHandler with return false', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, [
|
||||
['handler2', G_CODE, 'START'],
|
||||
['handler1', G_CODE, 'START'],
|
||||
['handler2', G_CODE, 'PUT', 'data'],
|
||||
['handler1', G_CODE, 'PUT', 'data'],
|
||||
['handler2', G_CODE, 'END', true],
|
||||
['handler1', G_CODE, 'END', true]
|
||||
assert.deepEqual(reports, [
|
||||
['th2', 'START'],
|
||||
['th1', 'START'],
|
||||
['th2', 'PUT', 'Here comes'],
|
||||
['th1', 'PUT', 'Here comes'],
|
||||
['th2', 'PUT', 'the mouse!'],
|
||||
['th1', 'PUT', 'the mouse!'],
|
||||
['th2', 'END', true],
|
||||
['th1', 'END', true] // true since th2 indicated to keep bubbling
|
||||
]);
|
||||
});
|
||||
|
||||
it('dispose removes handler', () => {
|
||||
const G_CODE = 0x47;
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler1'));
|
||||
const disposable = parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'handler2'));
|
||||
disposable.dispose();
|
||||
parser.start();
|
||||
const data = toUtf32('Gdata');
|
||||
it('dispose handlers', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th1'));
|
||||
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 'th2', true));
|
||||
dispo.dispose();
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(handlerReports, [
|
||||
['handler1', G_CODE, 'START'],
|
||||
['handler1', G_CODE, 'PUT', 'data'],
|
||||
['handler1', G_CODE, 'END', true]
|
||||
assert.deepEqual(reports, [
|
||||
['th1', 'START'],
|
||||
['th1', 'PUT', 'Here comes'],
|
||||
['th1', 'PUT', 'the mouse!'],
|
||||
['th1', 'END', true]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApcHandler convenience class', () => {
|
||||
describe('ApcHandlerFactory', () => {
|
||||
const TEST_PAYLOAD_LIMIT = 100;
|
||||
const CHUNK_SIZE = 10;
|
||||
let originalPayloadLimit: number;
|
||||
@@ -231,133 +185,278 @@ describe('ApcParser', () => {
|
||||
});
|
||||
|
||||
it('should be called once on end(true)', () => {
|
||||
const G_CODE = 0x47;
|
||||
const results: [number, string][] = [];
|
||||
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
|
||||
results.push([G_CODE, data]);
|
||||
return true;
|
||||
}));
|
||||
parser.start();
|
||||
let data = toUtf32('Gf=100');
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(',a=T;payload');
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(results, [[G_CODE, 'f=100,a=T;payload']]);
|
||||
assert.deepEqual(reports, ['Here comes the mouse!']);
|
||||
});
|
||||
|
||||
it('should not be called on end(false)', () => {
|
||||
const G_CODE = 0x47;
|
||||
const results: [number, string][] = [];
|
||||
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
|
||||
results.push([G_CODE, data]);
|
||||
return true;
|
||||
}));
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100,a=T;payload');
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(false);
|
||||
assert.deepEqual(results, []);
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
|
||||
it('should handle payload up to limit', function(): void {
|
||||
this.timeout(30000);
|
||||
const G_CODE = 0x47;
|
||||
const results: [number, string][] = [];
|
||||
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
|
||||
results.push([G_CODE, data]);
|
||||
return true;
|
||||
}));
|
||||
parser.start();
|
||||
let data = toUtf32('G');
|
||||
it('should be disposable', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
|
||||
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('A'.repeat(CHUNK_SIZE));
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
|
||||
dispo.dispose();
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
data = toUtf32('some other');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' data');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
|
||||
});
|
||||
it('should respect return false', () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['one', data]); return true; }));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(['two', data]); return false; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
|
||||
});
|
||||
it('should work up to payload limit', function(): void {
|
||||
this.timeout(30000);
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
const data = toUtf32('A'.repeat(CHUNK_SIZE));
|
||||
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
|
||||
parser.put(data, 0, data.length);
|
||||
}
|
||||
parser.end(true);
|
||||
assert.deepEqual(results, [[G_CODE, 'A'.repeat(TEST_PAYLOAD_LIMIT)]]);
|
||||
assert.deepEqual(reports, ['A'.repeat(TEST_PAYLOAD_LIMIT)]);
|
||||
});
|
||||
|
||||
it('should abort for payload over limit', function(): void {
|
||||
it('should abort for payload limit +1', function(): void {
|
||||
this.timeout(30000);
|
||||
const G_CODE = 0x47;
|
||||
const results: [number, string][] = [];
|
||||
parser.registerHandler(G_CODE, new ApcHandler((data: string) => {
|
||||
results.push([G_CODE, data]);
|
||||
return true;
|
||||
}));
|
||||
parser.start();
|
||||
let data = toUtf32('G');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('A'.repeat(CHUNK_SIZE));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('A'.repeat(CHUNK_SIZE));
|
||||
for (let i = 0; i < TEST_PAYLOAD_LIMIT; i += CHUNK_SIZE) {
|
||||
parser.put(data, 0, data.length);
|
||||
}
|
||||
data = toUtf32('A');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.end(true);
|
||||
assert.deepEqual(results, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset behavior', () => {
|
||||
let handlerReports: [string, number, string, (boolean | string)?][];
|
||||
|
||||
beforeEach(() => {
|
||||
handlerReports = [];
|
||||
});
|
||||
|
||||
it('reset during payload cleans up handlers', () => {
|
||||
const G_CODE = 0x47;
|
||||
parser.registerHandler(G_CODE, new TestHandler(G_CODE, handlerReports, 'kitty'));
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100');
|
||||
parser.put(data, 0, data.length);
|
||||
parser.reset();
|
||||
assert.deepEqual(handlerReports, [
|
||||
['kitty', G_CODE, 'START'],
|
||||
['kitty', G_CODE, 'PUT', 'f=100'],
|
||||
['kitty', G_CODE, 'END', false]
|
||||
]);
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
class TestHandlerAsync implements IApcHandler {
|
||||
constructor(public output: any[], public msg: string, public returnFalse: boolean = false) {}
|
||||
public start(): void {
|
||||
this.output.push([this.msg, 'START']);
|
||||
}
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
this.output.push([this.msg, 'PUT', utf32ToString(data, start, end)]);
|
||||
}
|
||||
public async end(success: boolean): Promise<boolean> {
|
||||
// simple sleep to check in tests whether ordering gets messed up
|
||||
await Promise.resolve();
|
||||
this.output.push([this.msg, 'END', success]);
|
||||
if (this.returnFalse) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
async function unhookP(parser: ApcParser, success: boolean): Promise<void> {
|
||||
let result: void | Promise<boolean>;
|
||||
let prev: boolean | undefined;
|
||||
while (result = parser.end(success, prev)) {
|
||||
prev = await result;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
describe('ApcParser - async tests', () => {
|
||||
let parser: ApcParser;
|
||||
let reports: [number, string, (boolean | string | undefined)?][] = [];
|
||||
|
||||
let reports: any[] = [];
|
||||
beforeEach(() => {
|
||||
reports = [];
|
||||
parser = new ApcParser();
|
||||
parser.setHandlerFallback((id: number, action: 'START' | 'PUT' | 'END', data?: string | boolean) => {
|
||||
reports.push([id, action, data]);
|
||||
});
|
||||
parser.setHandlerFallback((id, action, data) => reports.push([id, action, data]));
|
||||
});
|
||||
|
||||
async function endP(parser: ApcParser, success: boolean): Promise<void> {
|
||||
let result: void | Promise<boolean>;
|
||||
let prev: boolean | undefined;
|
||||
while (result = parser.end(success, prev)) {
|
||||
prev = await result;
|
||||
}
|
||||
}
|
||||
|
||||
describe('async ApcHandler', () => {
|
||||
it('should handle async handler', async () => {
|
||||
const G_CODE = 0x47;
|
||||
const results: [number, string][] = [];
|
||||
parser.registerHandler(G_CODE, new ApcHandler(async (data: string) => {
|
||||
await new Promise(res => setTimeout(res, 10));
|
||||
results.push([G_CODE, data]);
|
||||
return true;
|
||||
}));
|
||||
parser.start();
|
||||
const data = toUtf32('Gf=100,a=T');
|
||||
parser.put(data, 0, data.length);
|
||||
await endP(parser, true);
|
||||
assert.deepEqual(results, [[G_CODE, 'f=100,a=T']]);
|
||||
describe('sync and async mixed', () => {
|
||||
describe('sync | async | sync', () => {
|
||||
it('first should run, cleanup action for others', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', false));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [
|
||||
// messages from TestHandler
|
||||
['s2', 'START'],
|
||||
['a1', 'START'],
|
||||
['s1', 'START'],
|
||||
['s2', 'PUT', 'Here comes'],
|
||||
['a1', 'PUT', 'Here comes'],
|
||||
['s1', 'PUT', 'Here comes'],
|
||||
['s2', 'PUT', 'the mouse!'],
|
||||
['a1', 'PUT', 'the mouse!'],
|
||||
['s1', 'PUT', 'the mouse!'],
|
||||
['s2', 'END', true],
|
||||
['a1', 'END', false], // important: a1 before s1
|
||||
['s1', 'END', false]
|
||||
]);
|
||||
});
|
||||
it('all should run', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's2', true));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [
|
||||
// messages from TestHandler
|
||||
['s2', 'START'],
|
||||
['a1', 'START'],
|
||||
['s1', 'START'],
|
||||
['s2', 'PUT', 'Here comes'],
|
||||
['a1', 'PUT', 'Here comes'],
|
||||
['s1', 'PUT', 'Here comes'],
|
||||
['s2', 'PUT', 'the mouse!'],
|
||||
['a1', 'PUT', 'the mouse!'],
|
||||
['s1', 'PUT', 'the mouse!'],
|
||||
['s2', 'END', true],
|
||||
['a1', 'END', true], // important: a1 before s1
|
||||
['s1', 'END', true]
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('async | sync | async', () => {
|
||||
it('first should run, cleanup action for others', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', false));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', false));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', false));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [
|
||||
// messages from TestHandler
|
||||
['a2', 'START'],
|
||||
['s1', 'START'],
|
||||
['a1', 'START'],
|
||||
['a2', 'PUT', 'Here comes'],
|
||||
['s1', 'PUT', 'Here comes'],
|
||||
['a1', 'PUT', 'Here comes'],
|
||||
['a2', 'PUT', 'the mouse!'],
|
||||
['s1', 'PUT', 'the mouse!'],
|
||||
['a1', 'PUT', 'the mouse!'],
|
||||
['a2', 'END', true],
|
||||
['s1', 'END', false], // important: s1 between a2 .. a1
|
||||
['a1', 'END', false]
|
||||
]);
|
||||
});
|
||||
it('all should run', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a1', true));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandler(reports, 's1', true));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new TestHandlerAsync(reports, 'a2', true));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32('the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [
|
||||
// messages from TestHandler
|
||||
['a2', 'START'],
|
||||
['s1', 'START'],
|
||||
['a1', 'START'],
|
||||
['a2', 'PUT', 'Here comes'],
|
||||
['s1', 'PUT', 'Here comes'],
|
||||
['a1', 'PUT', 'Here comes'],
|
||||
['a2', 'PUT', 'the mouse!'],
|
||||
['s1', 'PUT', 'the mouse!'],
|
||||
['a1', 'PUT', 'the mouse!'],
|
||||
['a2', 'END', true],
|
||||
['s1', 'END', true], // important: s1 between a2 .. a1
|
||||
['a1', 'END', true]
|
||||
]);
|
||||
});
|
||||
});
|
||||
describe('ApcHandlerFactory', () => {
|
||||
it('should be called once on end(true)', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, ['Here comes the mouse!']);
|
||||
});
|
||||
it('should not be called on end(false)', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(data); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, false);
|
||||
assert.deepEqual(reports, []);
|
||||
});
|
||||
it('should be disposable', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
|
||||
const dispo = parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return true; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!']]);
|
||||
dispo.dispose();
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
data = toUtf32('some other');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' data');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'some other data']]);
|
||||
});
|
||||
it('should respect return false', async () => {
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['one', data]); return true; }));
|
||||
parser.registerHandler(identifier({intermediates: '+', final: 'p'}), new ApcHandler(async data => { reports.push(['two', data]); return false; }));
|
||||
parser.start(identifier({intermediates: '+', final: 'p'}));
|
||||
let data = toUtf32('Here comes');
|
||||
parser.put(data, 0, data.length);
|
||||
data = toUtf32(' the mouse!');
|
||||
parser.put(data, 0, data.length);
|
||||
await unhookP(parser, true);
|
||||
assert.deepEqual(reports, [['two', 'Here comes the mouse!'], ['one', 'Here comes the mouse!']]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import { IApcHandler, IHandlerCollection, ApcFallbackHandlerType, IApcParser, ISubParserStackState } from 'common/parser/Types';
|
||||
import { ApcState, ParserConstants } from 'common/parser/Constants';
|
||||
import { ParserConstants } from 'common/parser/Constants';
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { IDisposable } from 'common/Types';
|
||||
|
||||
@@ -19,10 +19,9 @@ const EMPTY_HANDLERS: IApcHandler[] = [];
|
||||
* The identifier is the character code of the first byte after ESC _.
|
||||
*/
|
||||
export class ApcParser implements IApcParser {
|
||||
private _state = ApcState.START;
|
||||
private _active = EMPTY_HANDLERS;
|
||||
private _id = -1;
|
||||
private _handlers: IHandlerCollection<IApcHandler> = Object.create(null);
|
||||
private _active = EMPTY_HANDLERS;
|
||||
private _ident: number = 0;
|
||||
private _handlerFb: ApcFallbackHandlerType = () => { };
|
||||
private _stack: ISubParserStackState = {
|
||||
paused: false,
|
||||
@@ -64,22 +63,24 @@ export class ApcParser implements IApcParser {
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
// force cleanup handlers if payload was already sent
|
||||
if (this._state === ApcState.PAYLOAD) {
|
||||
// force cleanup handlers
|
||||
if (this._active.length) {
|
||||
for (let j = this._stack.paused ? this._stack.loopPosition - 1 : this._active.length - 1; j >= 0; --j) {
|
||||
this._active[j].end(false);
|
||||
}
|
||||
}
|
||||
this._stack.paused = false;
|
||||
this._active = EMPTY_HANDLERS;
|
||||
this._id = -1;
|
||||
this._state = ApcState.START;
|
||||
this._ident = 0;
|
||||
}
|
||||
|
||||
private _start(): void {
|
||||
this._active = this._handlers[this._id] || EMPTY_HANDLERS;
|
||||
public start(ident: number): void {
|
||||
// always reset leftover handlers
|
||||
this.reset();
|
||||
this._ident = ident;
|
||||
this._active = this._handlers[ident] || EMPTY_HANDLERS;
|
||||
if (!this._active.length) {
|
||||
this._handlerFb(this._id, 'START');
|
||||
this._handlerFb(this._ident, 'START');
|
||||
} else {
|
||||
for (let j = this._active.length - 1; j >= 0; j--) {
|
||||
this._active[j].start();
|
||||
@@ -87,9 +88,9 @@ export class ApcParser implements IApcParser {
|
||||
}
|
||||
}
|
||||
|
||||
private _put(data: Uint32Array, start: number, end: number): void {
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (!this._active.length) {
|
||||
this._handlerFb(this._id, 'PUT', utf32ToString(data, start, end));
|
||||
this._handlerFb(this._ident, 'PUT', utf32ToString(data, start, end));
|
||||
} else {
|
||||
for (let j = this._active.length - 1; j >= 0; j--) {
|
||||
this._active[j].put(data, start, end);
|
||||
@@ -97,100 +98,51 @@ export class ApcParser implements IApcParser {
|
||||
}
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
// always reset leftover handlers
|
||||
this.reset();
|
||||
this._state = ApcState.ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Put data to current APC command.
|
||||
* For APC, the first character is used as the identifier.
|
||||
* Format: ESC _ <identifier><payload> ESC \
|
||||
* Example: ESC _ G f=100,a=T;... ESC \ (Kitty graphics, identifier='G')
|
||||
*/
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._state === ApcState.ABORT) {
|
||||
return;
|
||||
}
|
||||
if (this._state === ApcState.ID) {
|
||||
// The first character is the identifier
|
||||
if (start < end) {
|
||||
this._id = data[start++];
|
||||
this._state = ApcState.PAYLOAD;
|
||||
this._start();
|
||||
}
|
||||
}
|
||||
if (this._state === ApcState.PAYLOAD && end - start > 0) {
|
||||
this._put(data, start, end);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates end of an APC command.
|
||||
* Whether the APC got aborted or finished normally
|
||||
* is indicated by `success`.
|
||||
*/
|
||||
public end(success: boolean, promiseResult: boolean = true): void | Promise<boolean> {
|
||||
if (this._state === ApcState.START) {
|
||||
return;
|
||||
}
|
||||
// do nothing if command was faulty
|
||||
if (this._state !== ApcState.ABORT) {
|
||||
// if we are still in ID state and get an early end
|
||||
// means we got an empty APC sequence with no identifier,
|
||||
// which is invalid - just reset and return
|
||||
if (this._state === ApcState.ID) {
|
||||
this._active = EMPTY_HANDLERS;
|
||||
this._id = -1;
|
||||
this._state = ApcState.START;
|
||||
return;
|
||||
if (!this._active.length) {
|
||||
this._handlerFb(this._ident, 'END', success);
|
||||
} else {
|
||||
let handlerResult: boolean | Promise<boolean> = false;
|
||||
let j = this._active.length - 1;
|
||||
let fallThrough = false;
|
||||
if (this._stack.paused) {
|
||||
j = this._stack.loopPosition - 1;
|
||||
handlerResult = promiseResult;
|
||||
fallThrough = this._stack.fallThrough;
|
||||
this._stack.paused = false;
|
||||
}
|
||||
|
||||
if (!this._active.length) {
|
||||
this._handlerFb(this._id, 'END', success);
|
||||
} else {
|
||||
let handlerResult: boolean | Promise<boolean> = false;
|
||||
let j = this._active.length - 1;
|
||||
let fallThrough = false;
|
||||
if (this._stack.paused) {
|
||||
j = this._stack.loopPosition - 1;
|
||||
handlerResult = promiseResult;
|
||||
fallThrough = this._stack.fallThrough;
|
||||
this._stack.paused = false;
|
||||
}
|
||||
if (!fallThrough && handlerResult === false) {
|
||||
for (; j >= 0; j--) {
|
||||
handlerResult = this._active[j].end(success);
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._stack.paused = true;
|
||||
this._stack.loopPosition = j;
|
||||
this._stack.fallThrough = false;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
j--;
|
||||
}
|
||||
// cleanup left over handlers
|
||||
// we always have to call .end for proper cleanup,
|
||||
// here we use `success` to indicate whether a handler should execute
|
||||
if (!fallThrough && handlerResult === false) {
|
||||
for (; j >= 0; j--) {
|
||||
handlerResult = this._active[j].end(false);
|
||||
if (handlerResult instanceof Promise) {
|
||||
handlerResult = this._active[j].end(success);
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._stack.paused = true;
|
||||
this._stack.loopPosition = j;
|
||||
this._stack.fallThrough = true;
|
||||
this._stack.fallThrough = false;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
j--;
|
||||
}
|
||||
// cleanup left over handlers (fallThrough for async)
|
||||
for (; j >= 0; j--) {
|
||||
handlerResult = this._active[j].end(false);
|
||||
if (handlerResult instanceof Promise) {
|
||||
this._stack.paused = true;
|
||||
this._stack.loopPosition = j;
|
||||
this._stack.fallThrough = true;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
this._active = EMPTY_HANDLERS;
|
||||
this._id = -1;
|
||||
this._state = ApcState.START;
|
||||
this._ident = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType, ResumableHandlersType } from 'common/parser/Types';
|
||||
import { IParsingState, IParams, ParamsArray, IOscParser, IOscHandler, OscFallbackHandlerType, IFunctionIdentifier, IParserStackState, ParserStackType } from 'common/parser/Types';
|
||||
import { EscapeSequenceParser, TransitionTable, VT500_TRANSITION_TABLE } from 'common/parser/EscapeSequenceParser';
|
||||
import { assert } from 'chai';
|
||||
import { StringToUtf32, stringFromCodePoint, utf32ToString } from 'common/input/TextDecoder';
|
||||
@@ -12,6 +12,7 @@ import { Params } from 'common/parser/Params';
|
||||
import { OscHandler } from 'common/parser/OscParser';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { DcsHandler } from 'common/parser/DcsParser';
|
||||
import { ApcHandler } from 'common/parser/ApcParser';
|
||||
|
||||
|
||||
function r(a: number, b: number): string[] {
|
||||
@@ -145,6 +146,15 @@ const testTerminal: any = {
|
||||
},
|
||||
actionDCSUnhook(success: boolean): void {
|
||||
this.calls.push(['dcs unhook', success]);
|
||||
},
|
||||
actionAPCStart(): void {
|
||||
this.calls.push(['apc start']);
|
||||
},
|
||||
actionAPCPut(s: string): void {
|
||||
this.calls.push(['apc put', s]);
|
||||
},
|
||||
actionAPCEnd(success: boolean): void {
|
||||
this.calls.push(['apc end', success]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -163,7 +173,9 @@ const states: number[] = [
|
||||
ParserState.DCS_IGNORE,
|
||||
ParserState.DCS_INTERMEDIATE,
|
||||
ParserState.DCS_PASSTHROUGH,
|
||||
ParserState.APC_STRING
|
||||
ParserState.APC_ENTRY,
|
||||
ParserState.APC_INTERMEDIATE,
|
||||
ParserState.APC_PASSTHROUGH
|
||||
];
|
||||
let state: any;
|
||||
|
||||
@@ -198,6 +210,18 @@ testParser.setDcsHandlerFallback((collectAndFlag, action, payload) => {
|
||||
testTerminal.actionDCSUnhook(payload);
|
||||
}
|
||||
});
|
||||
testParser.setApcHandlerFallback((collectAndFlag, action, payload) => {
|
||||
switch (action) {
|
||||
case 'START':
|
||||
testTerminal.actionAPCStart(payload);
|
||||
break;
|
||||
case 'PUT':
|
||||
testTerminal.actionAPCPut(payload);
|
||||
break;
|
||||
case 'END':
|
||||
testTerminal.actionAPCEnd(payload);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// translate string based parse calls into typed array based
|
||||
@@ -278,7 +302,7 @@ describe('EscapeSequenceParser', () => {
|
||||
const exceptions: { [key: number]: { [key: string]: any[] } } = {
|
||||
8: { '\x18': [], '\x1a': [] }, // abort OSC_STRING
|
||||
13: { '\x18': [['dcs unhook', false]], '\x1a': [['dcs unhook', false]] }, // abort DCS_PASSTHROUGH
|
||||
14: { '\x18': [], '\x1a': [] } // abort APC_STRING
|
||||
16: { '\x18': [['apc end', false]], '\x1a': [['apc end', false]] } // abort APC_PASSTHROUGH
|
||||
};
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
@@ -725,20 +749,6 @@ describe('EscapeSequenceParser', () => {
|
||||
}
|
||||
}
|
||||
});
|
||||
it('trans ANYWHERE/ESCAPE --> APC_STRING', () => {
|
||||
parser.reset();
|
||||
// C0 (ESC _)
|
||||
parse(parser, '\x1b_');
|
||||
assert.equal(parser.currentState, ParserState.APC_STRING);
|
||||
parser.reset();
|
||||
// C1
|
||||
for (state in states) {
|
||||
parser.currentState = state;
|
||||
parse(parser, '\x9f');
|
||||
assert.equal(parser.currentState, ParserState.APC_STRING);
|
||||
parser.reset();
|
||||
}
|
||||
});
|
||||
it('state SOS_PM_STRING ignore rules', () => {
|
||||
parser.reset();
|
||||
let ignored = r(0x00, 0x18);
|
||||
@@ -1035,6 +1045,134 @@ describe('EscapeSequenceParser', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
});
|
||||
it('state APC_ENTRY', () => {
|
||||
parser.reset();
|
||||
// C0
|
||||
parse(parser, '\x1b_');
|
||||
assert.equal(parser.currentState, ParserState.APC_ENTRY);
|
||||
parser.reset();
|
||||
// C1
|
||||
for (state in states) {
|
||||
parser.currentState = state;
|
||||
parse(parser, '\x9f');
|
||||
assert.equal(parser.currentState, ParserState.APC_ENTRY);
|
||||
parser.reset();
|
||||
}
|
||||
});
|
||||
it('state APC_ENTRY ignore rules', () => {
|
||||
parser.reset();
|
||||
const ignored = [
|
||||
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
|
||||
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
|
||||
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
|
||||
for (let i = 0; i < ignored.length; ++i) {
|
||||
parser.currentState = ParserState.APC_ENTRY;
|
||||
parse(parser, ignored[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_ENTRY);
|
||||
parser.reset();
|
||||
}
|
||||
});
|
||||
it('trans APC_ENTRY --> APC_INTERMEDIATE with collect action', () => {
|
||||
parser.reset();
|
||||
const collect = r(0x20, 0x30);
|
||||
for (let i = 0; i < collect.length; ++i) {
|
||||
parser.currentState = ParserState.APC_ENTRY;
|
||||
parse(parser, collect[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
|
||||
assert.equal(parser.collect, collect[i]);
|
||||
parser.reset();
|
||||
}
|
||||
});
|
||||
it('trans APC_ENTRY --> APC_PASSTHROUGH with start', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
const collect = r(0x30, 0x7f);
|
||||
for (let i = 0; i < collect.length; ++i) {
|
||||
parser.currentState = ParserState.APC_ENTRY;
|
||||
parse(parser, collect[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
|
||||
testTerminal.compare([['apc start']]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
}
|
||||
});
|
||||
it('trans APC_INTERMEDIATE --> APC_PASSTHROUGH with start', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
const collect = r(0x30, 0x7f);
|
||||
for (let i = 0; i < collect.length; ++i) {
|
||||
parser.currentState = ParserState.APC_INTERMEDIATE;
|
||||
parse(parser, collect[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
|
||||
testTerminal.compare([['apc start']]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
}
|
||||
});
|
||||
it('state APC_INTERMEDIATE ignore rules', () => {
|
||||
parser.reset();
|
||||
const ignored = [
|
||||
'\x00', '\x01', '\x02', '\x03', '\x04', '\x05', '\x06', '\x07', '\x08',
|
||||
'\x09', '\x0a', '\x0b', '\x0c', '\x0d', '\x0e', '\x0f', '\x10', '\x11',
|
||||
'\x12', '\x13', '\x14', '\x15', '\x16', '\x17', '\x19', '\x1c', '\x1d', '\x1e', '\x1f', '\x7f'];
|
||||
for (let i = 0; i < ignored.length; ++i) {
|
||||
parser.currentState = ParserState.APC_INTERMEDIATE;
|
||||
parse(parser, ignored[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_INTERMEDIATE);
|
||||
parser.reset();
|
||||
}
|
||||
});
|
||||
it('state APC_PASSTHROUGH put action', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
let puts = r(0x08, 0x0e);
|
||||
puts = puts.concat(r(0x20, 0x7f));
|
||||
for (let i = 0; i < puts.length; ++i) {
|
||||
parser.currentState = ParserState.APC_PASSTHROUGH;
|
||||
parse(parser, puts[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
|
||||
testTerminal.compare([['apc put', puts[i]]]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
}
|
||||
});
|
||||
it('state APC_PASSTHROUGH ignore rules', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
let puts = r(0x00, 0x08);
|
||||
puts = puts.concat(r(0x0e, 0x18));
|
||||
puts.push('\x19');
|
||||
puts = puts.concat(r(0x1c, 0x20));
|
||||
puts.push('\x7f');
|
||||
for (let i = 0; i < puts.length; ++i) {
|
||||
parser.currentState = ParserState.APC_PASSTHROUGH;
|
||||
parse(parser, puts[i]);
|
||||
assert.equal(parser.currentState, ParserState.APC_PASSTHROUGH);
|
||||
testTerminal.compare([]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
}
|
||||
});
|
||||
it('trans APC_PASSTHROUGH --> GROUND|ESCAPE with end action', () => {
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
const collect = ['\x9c', '\x18', '\x1a']; // ST - true, CAN & SUB - false
|
||||
for (let i = 0; i < collect.length; ++i) {
|
||||
parser.currentState = ParserState.APC_PASSTHROUGH;
|
||||
parse(parser, collect[i]);
|
||||
assert.equal(parser.currentState, ParserState.GROUND);
|
||||
testTerminal.compare([['apc end', collect[i] === '\x9c']]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
}
|
||||
// ESC end
|
||||
parser.currentState = ParserState.APC_PASSTHROUGH;
|
||||
parse(parser, '\x1b');
|
||||
assert.equal(parser.currentState, ParserState.ESCAPE);
|
||||
testTerminal.compare([['apc end', true]]);
|
||||
parser.reset();
|
||||
testTerminal.clear();
|
||||
});
|
||||
});
|
||||
|
||||
function test(s: string, value: any, noReset: any): void {
|
||||
@@ -1100,6 +1238,42 @@ describe('EscapeSequenceParser', () => {
|
||||
['print', 'defg']
|
||||
], null);
|
||||
});
|
||||
it('single APC', () => {
|
||||
test('\x1b_X3+$aäbc;däe\x9c', [
|
||||
['apc start'],
|
||||
['apc put', '3+$aäbc;däe'],
|
||||
['apc end', true]
|
||||
], null);
|
||||
});
|
||||
it('multi APC', () => {
|
||||
test('\x1b_Xabc;de', [
|
||||
['apc start'],
|
||||
['apc put', 'abc;de']
|
||||
], null);
|
||||
testTerminal.clear();
|
||||
test('abc\x9c', [
|
||||
['apc put', 'abc'],
|
||||
['apc end', true]
|
||||
], true);
|
||||
});
|
||||
it('print + DCS(C1) + print', () => {
|
||||
test('abc\x9fAbc;de\x9cxyz', [
|
||||
['print', 'abc'],
|
||||
['apc start'],
|
||||
['apc put', 'bc;de'],
|
||||
['apc end', true],
|
||||
['print', 'xyz']
|
||||
], null);
|
||||
});
|
||||
it('print + DCS(C0) + print', () => {
|
||||
test('abc\x1b_Abc;de\x1b\\xyz', [
|
||||
['print', 'abc'],
|
||||
['apc start'],
|
||||
['apc put', 'bc;de'],
|
||||
['apc end', true],
|
||||
['print', 'xyz']
|
||||
], null);
|
||||
});
|
||||
it('error recovery', () => {
|
||||
test('\x1b[1€abcdefg\x9b<;c', [
|
||||
['print', 'abcdefg'],
|
||||
@@ -1146,6 +1320,22 @@ describe('EscapeSequenceParser', () => {
|
||||
['dcs unhook', false] // false for abort
|
||||
], null);
|
||||
});
|
||||
it('CAN should abort APC', () => {
|
||||
test('abc\x9fXbc;de\x18', [
|
||||
['print', 'abc'],
|
||||
['apc start'],
|
||||
['apc put', 'bc;de'],
|
||||
['apc end', false] // false for abort
|
||||
], null);
|
||||
});
|
||||
it('SUB should abort APC', () => {
|
||||
test('abc\x9fXbc;de\x1a', [
|
||||
['print', 'abc'],
|
||||
['apc start'],
|
||||
['apc put', 'bc;de'],
|
||||
['apc end', false] // false for abort
|
||||
], null);
|
||||
});
|
||||
it('CAN should abort OSC', () => {
|
||||
test('\x1b]0;abc123€öäü\x18', [
|
||||
['osc', '0;abc123€öäü, success: false']
|
||||
@@ -1210,6 +1400,7 @@ describe('EscapeSequenceParser', () => {
|
||||
const exe: string[] = [];
|
||||
const osc: [number, string][] = [];
|
||||
const dcs: ([string] | [string, string] | [string, string, ParamsArray, number])[] = [];
|
||||
const apc: [string, string][] = [];
|
||||
function clearAccu(): void {
|
||||
print = '';
|
||||
esc.length = 0;
|
||||
@@ -1217,6 +1408,7 @@ describe('EscapeSequenceParser', () => {
|
||||
exe.length = 0;
|
||||
osc.length = 0;
|
||||
dcs.length = 0;
|
||||
apc.length = 0;
|
||||
}
|
||||
beforeEach(() => {
|
||||
parser2 = new TestEscapeSequenceParser();
|
||||
@@ -1574,6 +1766,95 @@ describe('EscapeSequenceParser', () => {
|
||||
assert.deepEqual(dcsCustom, [['A', [1, 2, 3], 'abc']]);
|
||||
});
|
||||
});
|
||||
it('APC handler', () => {
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, {
|
||||
start: function (): void {
|
||||
apc.push(['start', '']);
|
||||
},
|
||||
put: function (data: Uint32Array, start: number, end: number): void {
|
||||
let s = '';
|
||||
for (let i = start; i < end; ++i) {
|
||||
s += stringFromCodePoint(data[i]);
|
||||
}
|
||||
apc.push(['put', s]);
|
||||
},
|
||||
end: function (success): boolean {
|
||||
apc.push(['end', success ? '1' : '0']);
|
||||
return true;
|
||||
}
|
||||
});
|
||||
parse(parser2, '\x1b_+pabc');
|
||||
parse(parser2, ';de\x9c');
|
||||
assert.deepEqual(apc, [
|
||||
['start', ''],
|
||||
['put', 'abc'], ['put', ';de'],
|
||||
['end', '1']
|
||||
]);
|
||||
parser2.clearApcHandler({ intermediates: '+', final: 'p' });
|
||||
parser2.clearApcHandler({ intermediates: '+', final: 'p' }); // should not throw
|
||||
clearAccu();
|
||||
parse(parser2, '\x1b_+pabc');
|
||||
parse(parser2, ';de\x9c');
|
||||
assert.deepEqual(apc, []);
|
||||
});
|
||||
describe('APC custom handlers', () => {
|
||||
const APC_INPUT = '\x1b_+pabc\x1b\\';
|
||||
it('Prevent fallback', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['B', 'abc']]);
|
||||
});
|
||||
it('Allow fallback', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return false; }));
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['B', 'abc'], ['A', 'abc']]);
|
||||
});
|
||||
it('Multiple custom handlers fallback once', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return false; }));
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['C', 'abc'], ['B', 'abc']]);
|
||||
});
|
||||
it('Multiple custom handlers no fallback', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['C', data]); return true; }));
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['C', 'abc']]);
|
||||
});
|
||||
it('Execution order should go from latest handler down to the original', () => {
|
||||
const order: number[] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(1); return true; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(2); return false; }));
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(() => { order.push(3); return false; }));
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(order, [3, 2, 1]);
|
||||
});
|
||||
it('Dispose should work', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
|
||||
dispo.dispose();
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['A', 'abc']]);
|
||||
});
|
||||
it('Should not corrupt the parser when dispose is called twice', () => {
|
||||
const apcCustom: [string, string][] = [];
|
||||
parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['A', data]); return true; }));
|
||||
const dispo = parser2.registerApcHandler({ intermediates: '+', final: 'p' }, new ApcHandler(data => { apcCustom.push(['B', data]); return true; }));
|
||||
dispo.dispose();
|
||||
dispo.dispose();
|
||||
parse(parser2, APC_INPUT);
|
||||
assert.deepEqual(apcCustom, [['A', 'abc']]);
|
||||
});
|
||||
});
|
||||
it('ERROR handler', () => {
|
||||
let errorState: IParsingState | null = null;
|
||||
parser2.setErrorHandler(function (state: IParsingState): IParsingState {
|
||||
@@ -1625,15 +1906,22 @@ describe('EscapeSequenceParser', () => {
|
||||
assert.throws(() => { parser.identifier({ final: '\x7f' }); }, 'final must be in range 64 .. 126');
|
||||
assert.throws(() => { parser.identifier({ final: 'zz' }); }, 'final must be a single byte');
|
||||
});
|
||||
it('final ESC range 0x30 .. 0x7e, one byte', () => {
|
||||
it('final ESC + APC range 0x30 .. 0x7e, one byte', () => {
|
||||
for (let i = 0x30; i <= 0x7e; ++i) {
|
||||
const final = String.fromCharCode(i);
|
||||
let handler: IDisposable | undefined;
|
||||
assert.doesNotThrow(() => { handler = parser.registerEscHandler({ final }, () => true); }, 'final must be in range 48 .. 126');
|
||||
if (handler) handler.dispose();
|
||||
assert.doesNotThrow(
|
||||
() => { handler = parser.registerApcHandler({ final }, {start: () => {}, put: ()=>{}, end: ()=>true}); },
|
||||
'final must be in range 48 .. 126'
|
||||
);
|
||||
if (handler) handler.dispose();
|
||||
}
|
||||
assert.throws(() => { parser.registerEscHandler({ final: '\x2f' }, () => true); }, 'final must be in range 48 .. 126');
|
||||
assert.throws(() => { parser.registerEscHandler({ final: '\x7f' }, () => true); }, 'final must be in range 48 .. 126');
|
||||
assert.throws(() => { parser.registerApcHandler({ final: '\x2f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
|
||||
assert.throws(() => { parser.registerApcHandler({ final: '\x7f' }, {start: () => {}, put: ()=>{}, end: ()=>true}); }, 'final must be in range 48 .. 126');
|
||||
});
|
||||
it('id calculation - should stacking prefix -> intermediate -> final', () => {
|
||||
assert.equal(parser.identToString(parser.identifier({ final: 'z' })), 'z');
|
||||
@@ -1705,6 +1993,25 @@ describe('EscapeSequenceParser', () => {
|
||||
]
|
||||
);
|
||||
});
|
||||
it('APC', () => {
|
||||
const callstack: any[] = [];
|
||||
const h1 = parser.registerApcHandler({ final: 'z' }, new ApcHandler(data => { callstack.push(['z', data]); return true; }));
|
||||
const h2 = parser.registerApcHandler({ intermediates: '!', final: 'z' }, new ApcHandler(data => { callstack.push(['!z', data]); return true; }));
|
||||
const h3 = parser.registerApcHandler({ intermediates: '!!', final: 'z' }, new ApcHandler(data => { callstack.push(['!!z', data]); return true; }));
|
||||
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
|
||||
h1.dispose();
|
||||
h2.dispose();
|
||||
h3.dispose();
|
||||
parse(parser, '\x1b_zAB\x1b\\\x1b_!zAB\x1b\\\x1b_!!zAB\x1b\\');
|
||||
assert.deepEqual(
|
||||
callstack,
|
||||
[
|
||||
['z', 'AB'],
|
||||
['!z', 'AB'],
|
||||
['!!z', 'AB'],
|
||||
]
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
// TODO: error conditions and error recovery (not implemented yet in parser)
|
||||
@@ -1758,9 +2065,9 @@ async function throwsAsync(fn: () => Promise<any>, message?: string | undefined)
|
||||
}
|
||||
|
||||
describe('EscapeSequenceParser - async', () => {
|
||||
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | FIN
|
||||
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1
|
||||
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\FIN';
|
||||
// sequences: SGR 1;31 | hello SP | ESC %G | wor | ESC E | ld! | SGR 0 | EXE \r\n | $> | DCS 1;2 a [xyz] ST | OSC 1;foo=bar ST | APC X abc ST | FIN
|
||||
// needed handlers: CSI m, PRINT, ESC %G, ESC E, EXE \r, EXE \n, OSC 1, APC X
|
||||
const INPUT = '\x1b[1;31mhello \x1b%Gwor\x1bEld!\x1b[0m\r\n$>\x1bP1;2axyz\x1b\\\x1b]1;foo=bar\x1b\\\x1b_Xabc\x1b\\FIN';
|
||||
let RESULT: any[];
|
||||
let parser: TestEscapeSequenceParser;
|
||||
const callstack: any[] = [];
|
||||
@@ -1782,6 +2089,7 @@ describe('EscapeSequenceParser - async', () => {
|
||||
['PRINT', '$>'],
|
||||
['DCS a', ['xyz', [1, 2]]],
|
||||
['OSC 1', 'foo=bar'],
|
||||
['APC X', 'abc'],
|
||||
['PRINT', 'FIN']
|
||||
];
|
||||
parser = new TestEscapeSequenceParser();
|
||||
@@ -1805,6 +2113,7 @@ describe('EscapeSequenceParser - async', () => {
|
||||
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
|
||||
parser.registerOscHandler(1, new OscHandler(data => { callstack.push(['OSC 1', data]); return true; }));
|
||||
parser.registerDcsHandler({ final: 'a' }, new DcsHandler((data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
|
||||
parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => { callstack.push(['APC X', data]); return true; }));
|
||||
});
|
||||
|
||||
it('sync handlers keep being parsed in sync mode', () => {
|
||||
@@ -1840,6 +2149,7 @@ describe('EscapeSequenceParser - async', () => {
|
||||
parser.setExecuteHandler('\n', () => { callstack.push(['EXE \n']); return true; });
|
||||
parser.registerOscHandler(1, new OscHandler(async data => { callstack.push(['OSC 1', data]); return true; }));
|
||||
parser.registerDcsHandler({ final: 'a' }, new DcsHandler(async (data, params) => { callstack.push(['DCS a', [data, params.toArray()]]); return true; }));
|
||||
parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['APC X', data]); return true; }));
|
||||
});
|
||||
|
||||
it('sync parse call does not work anymore', () => {
|
||||
@@ -1876,7 +2186,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
});
|
||||
it('correct result on chunked awaited parse calls', async () => {
|
||||
@@ -1903,6 +2214,7 @@ describe('EscapeSequenceParser - async', () => {
|
||||
['PRINT', '>'],
|
||||
['DCS a', ['xyz', [1, 2]]],
|
||||
['OSC 1', 'foo=bar'],
|
||||
['APC X', 'abc'],
|
||||
['PRINT', 'F'],
|
||||
['PRINT', 'I'],
|
||||
['PRINT', 'N']
|
||||
@@ -1921,7 +2233,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[0, ParserStackType.ESC, 0],
|
||||
[0, ParserStackType.CSI, 0],
|
||||
[0, ParserStackType.DCS, 0],
|
||||
[0, ParserStackType.OSC, 0]
|
||||
[0, ParserStackType.OSC, 0],
|
||||
[0, ParserStackType.APC, 0],
|
||||
]);
|
||||
});
|
||||
it('multiple async SGR handlers', async () => {
|
||||
@@ -1941,7 +2254,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[27, ParserStackType.CSI, 1],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -1954,7 +2268,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
|
||||
@@ -1972,7 +2287,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 1],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -1985,7 +2301,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
});
|
||||
it('multiple async ESC handlers', async () => {
|
||||
@@ -2003,7 +2320,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2016,7 +2334,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
|
||||
@@ -2033,7 +2352,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 1],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2046,7 +2366,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
});
|
||||
it('sync/async SGR mixed', async () => {
|
||||
@@ -2071,7 +2392,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[27, ParserStackType.CSI, 2],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// dispose SGR2 (sync one)
|
||||
@@ -2092,7 +2414,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[27, ParserStackType.CSI, 1],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// dispose SGR3 (async one)
|
||||
@@ -2105,7 +2428,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
});
|
||||
it('multiple async OSC handlers', async () => {
|
||||
@@ -2123,7 +2447,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2136,7 +2461,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
|
||||
@@ -2153,7 +2479,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2166,7 +2493,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
});
|
||||
@@ -2185,7 +2513,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2198,7 +2527,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
|
||||
@@ -2215,7 +2545,8 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
@@ -2228,7 +2559,74 @@ describe('EscapeSequenceParser - async', () => {
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0]
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
});
|
||||
it('multiple async APC handlers', async () => {
|
||||
// register with fallback
|
||||
const APC2 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return false; }));
|
||||
await parseP(parser, INPUT);
|
||||
for (let i = 0; i < callstack.length; ++i) {
|
||||
const entry = callstack[i];
|
||||
if (entry[0] === '2# APC X') assert.equal(callstack[i + 1][0], 'APC a', 'Should fallback to original handler');
|
||||
}
|
||||
evalStackSaves(parser.trackedStack, [
|
||||
[6, ParserStackType.CSI, 0],
|
||||
[15, ParserStackType.ESC, 0],
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
APC2.dispose();
|
||||
await parseP(parser, INPUT);
|
||||
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
|
||||
evalStackSaves(parser.trackedStack, [
|
||||
[6, ParserStackType.CSI, 0],
|
||||
[15, ParserStackType.ESC, 0],
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
|
||||
// register without fallback
|
||||
const APC22 = parser.registerApcHandler({ final: 'X' }, new ApcHandler(async data => { callstack.push(['#2 APC X', data]); return true; }));
|
||||
await parseP(parser, INPUT);
|
||||
for (let i = 0; i < callstack.length; ++i) {
|
||||
const entry = callstack[i];
|
||||
if (entry[0] === '2# APC X') assert.notEqual(callstack[i + 1][0], 'APC X', 'Should fallback to original handler');
|
||||
}
|
||||
evalStackSaves(parser.trackedStack, [
|
||||
[6, ParserStackType.CSI, 0],
|
||||
[15, ParserStackType.ESC, 0],
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
// after dispose we should be back to RESULT
|
||||
APC22.dispose();
|
||||
await parseP(parser, INPUT);
|
||||
assert.deepEqual(callstack, RESULT, 'Should not call custom handler');
|
||||
evalStackSaves(parser.trackedStack, [
|
||||
[6, ParserStackType.CSI, 0],
|
||||
[15, ParserStackType.ESC, 0],
|
||||
[20, ParserStackType.ESC, 0],
|
||||
[27, ParserStackType.CSI, 0],
|
||||
[41, ParserStackType.DCS, 0],
|
||||
[54, ParserStackType.OSC, 0],
|
||||
[62, ParserStackType.APC, 0],
|
||||
]);
|
||||
clearAccu();
|
||||
});
|
||||
|
||||
@@ -12,6 +12,24 @@ import { OscParser } from 'common/parser/OscParser';
|
||||
import { DcsParser } from 'common/parser/DcsParser';
|
||||
import { ApcParser } from 'common/parser/ApcParser';
|
||||
|
||||
/**
|
||||
* VT commands done by the parser
|
||||
*/
|
||||
// @vt: #Y ESC CSI "Control Sequence Introducer" "ESC [" "Start of a CSI sequence."
|
||||
// @vt: #Y ESC OSC "Operating System Command" "ESC ]" "Start of an OSC sequence."
|
||||
// @vt: #Y ESC DCS "Device Control String" "ESC P" "Start of a DCS sequence."
|
||||
// @vt: #Y ESC ST "String Terminator" "ESC \" "Terminator used for string type sequences."
|
||||
// @vt: #Y ESC PM "Privacy Message" "ESC ^" "Start of a privacy message."
|
||||
// @vt: #Y ESC APC "Application Program Command" "ESC _" "Start of an APC sequence."
|
||||
// @vt: #Y C1 CSI "Control Sequence Introducer" "\x9B" "Start of a CSI sequence."
|
||||
// @vt: #Y C1 OSC "Operating System Command" "\x9D" "Start of an OSC sequence."
|
||||
// @vt: #Y C1 DCS "Device Control String" "\x90" "Start of a DCS sequence."
|
||||
// @vt: #Y C1 ST "String Terminator" "\x9C" "Terminator used for string type sequences."
|
||||
// @vt: #Y C1 PM "Privacy Message" "\x9E" "Start of a privacy message."
|
||||
// @vt: #Y C1 APC "Application Program Command" "\x9F" "Start of an APC sequence."
|
||||
// @vt: #Y C0 NUL "Null" "\0, \x00" "NUL is ignored."
|
||||
// @vt: #Y C0 ESC "Escape" "\e, \x1B" "Start of a sequence. Cancels any other sequence."
|
||||
|
||||
/**
|
||||
* Table values are generated like this:
|
||||
* index: currentState << TableValue.INDEX_STATE_SHIFT | charCode
|
||||
@@ -77,7 +95,9 @@ const NON_ASCII_PRINTABLE = 0xA0;
|
||||
* Taken from https://vt100.net/emu/dec_ansi_parser.
|
||||
*/
|
||||
export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
|
||||
const table: TransitionTable = new TransitionTable(4095);
|
||||
// table size:
|
||||
// (ParserState.STATE_LENGTH - 1) << TableAccess.INDEX_STATE_SHIFT | NON_ASCII_PRINTABLE + 1
|
||||
const table: TransitionTable = new TransitionTable(4257);
|
||||
|
||||
// range macro for byte
|
||||
const BYTE_VALUES = 256;
|
||||
@@ -105,7 +125,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
|
||||
table.add(0x1b, state, ParserAction.CLEAR, ParserState.ESCAPE); // ESC
|
||||
table.add(0x9d, state, ParserAction.OSC_START, ParserState.OSC_STRING); // OSC
|
||||
table.addMany([0x98, 0x9e], state, ParserAction.IGNORE, ParserState.SOS_PM_STRING); // SOS, PM
|
||||
table.add(0x9f, state, ParserAction.APC_START, ParserState.APC_STRING); // APC
|
||||
table.add(0x9f, state, ParserAction.CLEAR, ParserState.APC_ENTRY); // APC
|
||||
table.add(0x9b, state, ParserAction.CLEAR, ParserState.CSI_ENTRY); // CSI
|
||||
table.add(0x90, state, ParserAction.CLEAR, ParserState.DCS_ENTRY); // DCS
|
||||
}
|
||||
@@ -136,11 +156,20 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
|
||||
table.add(0x9c, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.GROUND);
|
||||
table.add(0x7f, ParserState.SOS_PM_STRING, ParserAction.IGNORE, ParserState.SOS_PM_STRING);
|
||||
// apc
|
||||
table.add(0x5f, ParserState.ESCAPE, ParserAction.APC_START, ParserState.APC_STRING);
|
||||
table.addMany(PRINTABLES, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
|
||||
table.addMany(EXECUTABLES, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
|
||||
table.add(0x7f, ParserState.APC_STRING, ParserAction.IGNORE, ParserState.APC_STRING);
|
||||
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_STRING, ParserAction.APC_END, ParserState.GROUND);
|
||||
table.add(0x5f, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.APC_ENTRY);
|
||||
table.addMany(EXECUTABLES, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
|
||||
table.add(0x7f, ParserState.APC_ENTRY, ParserAction.IGNORE, ParserState.APC_ENTRY);
|
||||
table.addMany(r(0x20, 0x30), ParserState.APC_ENTRY, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
|
||||
table.addMany(r(0x30, 0x7f), ParserState.APC_ENTRY, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
|
||||
table.addMany(r(0x30, 0x7f), ParserState.APC_INTERMEDIATE, ParserAction.APC_START, ParserState.APC_PASSTHROUGH);
|
||||
table.addMany(EXECUTABLES, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
|
||||
table.addMany(r(0x20, 0x30), ParserState.APC_INTERMEDIATE, ParserAction.COLLECT, ParserState.APC_INTERMEDIATE);
|
||||
table.add(0x7f, ParserState.APC_INTERMEDIATE, ParserAction.IGNORE, ParserState.APC_INTERMEDIATE);
|
||||
table.addMany(PRINTABLES, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
|
||||
table.addMany(EXECUTABLES, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
|
||||
table.addMany(r(0x08, 0x0e), ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
|
||||
table.add(0x7f, ParserState.APC_PASSTHROUGH, ParserAction.IGNORE, ParserState.APC_PASSTHROUGH);
|
||||
table.addMany([0x1b, 0x9c, 0x18, 0x1a], ParserState.APC_PASSTHROUGH, ParserAction.APC_END, ParserState.GROUND);
|
||||
// csi entries
|
||||
table.add(0x5b, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.CSI_ENTRY);
|
||||
table.addMany(r(0x40, 0x7f), ParserState.CSI_ENTRY, ParserAction.CSI_DISPATCH, ParserState.GROUND);
|
||||
@@ -169,22 +198,18 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
|
||||
table.add(0x50, ParserState.ESCAPE, ParserAction.CLEAR, ParserState.DCS_ENTRY);
|
||||
table.addMany(EXECUTABLES, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
|
||||
table.add(0x7f, ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
|
||||
table.addMany(r(0x1c, 0x20), ParserState.DCS_ENTRY, ParserAction.IGNORE, ParserState.DCS_ENTRY);
|
||||
table.addMany(r(0x20, 0x30), ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
|
||||
table.addMany(r(0x30, 0x3c), ParserState.DCS_ENTRY, ParserAction.PARAM, ParserState.DCS_PARAM);
|
||||
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_ENTRY, ParserAction.COLLECT, ParserState.DCS_PARAM);
|
||||
table.addMany(EXECUTABLES, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.addMany(r(0x20, 0x80), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.addMany(r(0x1c, 0x20), ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.addMany(EXECUTABLES, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
|
||||
table.add(0x7f, ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
|
||||
table.addMany(r(0x1c, 0x20), ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_PARAM);
|
||||
table.addMany(r(0x30, 0x3c), ParserState.DCS_PARAM, ParserAction.PARAM, ParserState.DCS_PARAM);
|
||||
table.addMany([0x3c, 0x3d, 0x3e, 0x3f], ParserState.DCS_PARAM, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.addMany(r(0x20, 0x30), ParserState.DCS_PARAM, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
|
||||
table.addMany(EXECUTABLES, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
|
||||
table.add(0x7f, ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
|
||||
table.addMany(r(0x1c, 0x20), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_INTERMEDIATE);
|
||||
table.addMany(r(0x20, 0x30), ParserState.DCS_INTERMEDIATE, ParserAction.COLLECT, ParserState.DCS_INTERMEDIATE);
|
||||
table.addMany(r(0x30, 0x40), ParserState.DCS_INTERMEDIATE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.addMany(r(0x40, 0x7f), ParserState.DCS_INTERMEDIATE, ParserAction.DCS_HOOK, ParserState.DCS_PASSTHROUGH);
|
||||
@@ -200,7 +225,7 @@ export const VT500_TRANSITION_TABLE = (function (): TransitionTable {
|
||||
table.add(NON_ASCII_PRINTABLE, ParserState.CSI_IGNORE, ParserAction.IGNORE, ParserState.CSI_IGNORE);
|
||||
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_IGNORE, ParserAction.IGNORE, ParserState.DCS_IGNORE);
|
||||
table.add(NON_ASCII_PRINTABLE, ParserState.DCS_PASSTHROUGH, ParserAction.DCS_PUT, ParserState.DCS_PASSTHROUGH);
|
||||
table.add(NON_ASCII_PRINTABLE, ParserState.APC_STRING, ParserAction.APC_PUT, ParserState.APC_STRING);
|
||||
table.add(NON_ASCII_PRINTABLE, ParserState.APC_PASSTHROUGH, ParserAction.APC_PUT, ParserState.APC_PASSTHROUGH);
|
||||
return table;
|
||||
})();
|
||||
|
||||
@@ -247,6 +272,8 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
// handler lookup containers
|
||||
protected _printHandler: PrintHandlerType;
|
||||
protected _executeHandlers: { [flag: number]: ExecuteHandlerType };
|
||||
// fast path for EXE bytes < 0x18
|
||||
protected _executeHandlersArr: (ExecuteHandlerType | undefined)[];
|
||||
protected _csiHandlers: IHandlerCollection<CsiHandlerType>;
|
||||
protected _escHandlers: IHandlerCollection<EscHandlerType>;
|
||||
protected readonly _oscParser: IOscParser;
|
||||
@@ -290,11 +317,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
|
||||
this._printHandler = this._printHandlerFb;
|
||||
this._executeHandlers = Object.create(null);
|
||||
this._executeHandlersArr = new Array(0x18).fill(undefined);
|
||||
this._csiHandlers = Object.create(null);
|
||||
this._escHandlers = Object.create(null);
|
||||
this._register(toDisposable(() => {
|
||||
this._csiHandlers = Object.create(null);
|
||||
this._executeHandlers = Object.create(null);
|
||||
this._executeHandlersArr = new Array(0x18).fill(undefined);
|
||||
this._escHandlers = Object.create(null);
|
||||
}));
|
||||
this._oscParser = this._register(new OscParser());
|
||||
@@ -381,10 +410,14 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
}
|
||||
|
||||
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
|
||||
this._executeHandlers[flag.charCodeAt(0)] = handler;
|
||||
const code = flag.charCodeAt(0);
|
||||
this._executeHandlers[code] = handler;
|
||||
if (code < 0x18) this._executeHandlersArr[code] = handler;
|
||||
}
|
||||
public clearExecuteHandler(flag: string): void {
|
||||
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
|
||||
const code = flag.charCodeAt(0);
|
||||
if (this._executeHandlers[code]) delete this._executeHandlers[code];
|
||||
if (code < 0x18) this._executeHandlersArr[code] = undefined;
|
||||
}
|
||||
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
|
||||
this._executeHandlerFb = handler;
|
||||
@@ -431,11 +464,13 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
this._oscParser.setHandlerFallback(handler);
|
||||
}
|
||||
|
||||
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
|
||||
return this._apcParser.registerHandler(ident, handler);
|
||||
public registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable {
|
||||
id.prefix = undefined; // APC does not support prefix byte
|
||||
return this._apcParser.registerHandler(this._identifier(id, [0x30, 0x7e]), handler);
|
||||
}
|
||||
public clearApcHandler(ident: number): void {
|
||||
this._apcParser.clearHandler(ident);
|
||||
public clearApcHandler(id: IFunctionIdentifier): void {
|
||||
id.prefix = undefined; // APC does not support prefix byte
|
||||
this._apcParser.clearHandler(this._identifier(id, [0x30, 0x7e]));
|
||||
}
|
||||
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
|
||||
this._apcParser.setHandlerFallback(handler);
|
||||
@@ -462,8 +497,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
this._oscParser.reset();
|
||||
this._dcsParser.reset();
|
||||
this._apcParser.reset();
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
this.precedingJoinState = 0;
|
||||
// abort pending continuation from async handler
|
||||
@@ -506,6 +540,10 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
* - OSC_STRING:OSC_PUT
|
||||
* - DCS_PASSTHROUGH:DCS_PUT
|
||||
*
|
||||
* Additionally the following fast paths exist before the table lookup:
|
||||
* - EXE bytes < 0x18 in non-payload states (avoids table lookup entirely)
|
||||
* - 7-bit CSI sequences without intermediates (ESC [ params final)
|
||||
*
|
||||
* Note on asynchronous handler support:
|
||||
* Any handler returning a promise will be treated as asynchronous.
|
||||
* To keep the in-band blocking working for async handlers, `parse` pauses execution,
|
||||
@@ -608,8 +646,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
break;
|
||||
case ParserStackType.OSC:
|
||||
@@ -619,8 +656,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
break;
|
||||
case ParserStackType.APC:
|
||||
@@ -630,8 +666,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) this._parseStack.transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
break;
|
||||
}
|
||||
@@ -649,34 +684,90 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
for (let i = start; i < length; ++i) {
|
||||
code = data[i];
|
||||
|
||||
// EXE fast-path: common control bytes (0x00-0x17) in non-payload states
|
||||
if (code < 0x18 && this.currentState <= ParserState.CSI_IGNORE) {
|
||||
(this._executeHandlersArr[code] ?? this._executeHandlerFb)(code);
|
||||
this.precedingJoinState = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
// CSI fast-path: collapse ESC [ into a single entry, parse params+final in a tight loop
|
||||
if (code === 0x1b
|
||||
&& this.currentState < ParserState.OSC_STRING
|
||||
&& i + 2 < length && data[i + 1] === 0x5b
|
||||
) {
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
let k = i + 2;
|
||||
let ch = data[k];
|
||||
if (ch >= 0x3c && ch <= 0x3f) {
|
||||
this._collect = ch;
|
||||
k++;
|
||||
}
|
||||
let csiDone = false;
|
||||
for (; k < length; k++) {
|
||||
ch = data[k];
|
||||
if (ch >= 0x30 && ch <= 0x39) {
|
||||
this._params.addDigit(ch - 48);
|
||||
} else if (ch === 0x3b) {
|
||||
this._params.addParam(0);
|
||||
} else if (ch === 0x3a) {
|
||||
this._params.addSubParam(-1);
|
||||
} else if (ch >= 0x40 && ch <= 0x7e) {
|
||||
const handlers = this._csiHandlers[this._collect << 8 | ch];
|
||||
let j = handlers ? handlers.length - 1 : -1;
|
||||
for (; j >= 0; j--) {
|
||||
handlerResult = handlers[j](this._params);
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
transition = ParserAction.CSI_DISPATCH << TableAccess.TRANSITION_ACTION_SHIFT | ParserState.GROUND;
|
||||
this._preserveStack(ParserStackType.CSI, handlers, j, transition, k);
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
if (j < 0) {
|
||||
this._csiHandlerFb(this._collect << 8 | ch, this._params);
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
i = k;
|
||||
this.currentState = ParserState.GROUND;
|
||||
csiDone = true;
|
||||
break;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!csiDone) {
|
||||
i = k - 1;
|
||||
this.currentState = ParserState.CSI_PARAM;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// normal transition & action lookup
|
||||
transition = this._transitions.table[this.currentState << TableAccess.INDEX_STATE_SHIFT | (code < 0xa0 ? code : NON_ASCII_PRINTABLE)];
|
||||
transition = this._transitions.table[
|
||||
this.currentState << TableAccess.INDEX_STATE_SHIFT |
|
||||
(code < NON_ASCII_PRINTABLE ? code : NON_ASCII_PRINTABLE)
|
||||
];
|
||||
switch (transition >> TableAccess.TRANSITION_ACTION_SHIFT) {
|
||||
case ParserAction.PRINT:
|
||||
// read ahead with loop unrolling
|
||||
// Note: 0x20 (SP) is included, 0x7F (DEL) is excluded
|
||||
for (let j = i + 1; ; ++j) {
|
||||
if (j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
|
||||
this._printHandler(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
}
|
||||
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
|
||||
this._printHandler(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
}
|
||||
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
|
||||
this._printHandler(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
}
|
||||
if (++j >= length || (code = data[j]) < 0x20 || (code > 0x7e && code < NON_ASCII_PRINTABLE)) {
|
||||
this._printHandler(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
let c = i;
|
||||
const l4 = length - 4;
|
||||
while (c < l4
|
||||
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
|
||||
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
|
||||
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
|
||||
&& data[++c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)
|
||||
) {}
|
||||
if (c >= l4) {
|
||||
while (c < length && data[c] >= 0x20 && (data[c] <= 0x7e || data[c] >= NON_ASCII_PRINTABLE)) {
|
||||
c++;
|
||||
}
|
||||
}
|
||||
this._printHandler(data, i, c);
|
||||
i = c - 1;
|
||||
break;
|
||||
case ParserAction.EXECUTE:
|
||||
if (this._executeHandlers[code]) this._executeHandlers[code]();
|
||||
@@ -758,8 +849,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
case ParserAction.CLEAR:
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
break;
|
||||
case ParserAction.DCS_HOOK:
|
||||
@@ -783,8 +873,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
@@ -808,22 +897,23 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
case ParserAction.APC_START:
|
||||
this._apcParser.start();
|
||||
this._apcParser.start(this._collect << 8 | code);
|
||||
break;
|
||||
case ParserAction.APC_PUT:
|
||||
// inner loop - exit APC_PUT: 0x18, 0x1a, 0x1b, 0x9c
|
||||
// allowed: 00/08 .. 00/13, 02/00 .. 07/14 + NON_ASCII_PRINTABLE
|
||||
for (let j = i + 1; ; ++j) {
|
||||
if (j >= length || (code = data[j]) === 0x18 || code === 0x1a || code === 0x1b || code === 0x9c || (code > 0x7f && code < NON_ASCII_PRINTABLE)) {
|
||||
this._apcParser.put(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
}
|
||||
if (j < length && (
|
||||
(data[j] >= 0x20 && data[j] < 0x7f) || (data[j] >= 0x08 && data[j] < 0x0e) || data[j] >= NON_ASCII_PRINTABLE
|
||||
)) continue;
|
||||
this._apcParser.put(data, i, j);
|
||||
i = j - 1;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case ParserAction.APC_END:
|
||||
@@ -833,8 +923,7 @@ export class EscapeSequenceParser extends Disposable implements IEscapeSequenceP
|
||||
return handlerResult;
|
||||
}
|
||||
if (code === 0x1b) transition |= ParserState.ESCAPE;
|
||||
this._params.reset();
|
||||
this._params.addParam(0); // ZDM
|
||||
this._params.resetZdm();
|
||||
this._collect = 0;
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
*/
|
||||
import { IParams, ParamsArray } from 'common/parser/Types';
|
||||
|
||||
// max value supported for a single param/subparam (clamped to positive int32 range)
|
||||
const MAX_VALUE = 0x7FFFFFFF;
|
||||
// max allowed subparams for a single sequence (hardcoded limitation)
|
||||
const MAX_SUBPARAMS = 256;
|
||||
const enum Constants {
|
||||
/**
|
||||
* Max value supported for a single param/subparam (clamped to positive int32 range)
|
||||
*/
|
||||
MAX_VALUE = 0x7FFFFFFF,
|
||||
/**
|
||||
* Max allowed subparams for a single sequence (hardcoded limitation)
|
||||
*/
|
||||
MAX_SUBPARAMS = 256
|
||||
}
|
||||
|
||||
/**
|
||||
* Params storage class.
|
||||
@@ -70,7 +76,7 @@ export class Params implements IParams {
|
||||
* @param maxSubParamsLength max length of storable sub parameters
|
||||
*/
|
||||
constructor(public maxLength: number = 32, public maxSubParamsLength: number = 32) {
|
||||
if (maxSubParamsLength > MAX_SUBPARAMS) {
|
||||
if (maxSubParamsLength > Constants.MAX_SUBPARAMS) {
|
||||
throw new Error('maxSubParamsLength must not be greater than 256');
|
||||
}
|
||||
this.params = new Int32Array(maxLength);
|
||||
@@ -129,6 +135,19 @@ export class Params implements IParams {
|
||||
this._digitIsSub = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset and add 0 as first param (ZDM).
|
||||
*/
|
||||
public resetZdm(): void {
|
||||
this.length = 1;
|
||||
this._subParamsLength = 0;
|
||||
this._rejectDigits = false;
|
||||
this._rejectSubDigits = false;
|
||||
this._digitIsSub = false;
|
||||
this._subParamsIdx[0] = 0;
|
||||
this.params[0] = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a parameter value.
|
||||
* `Params` only stores up to `maxLength` parameters, any later
|
||||
@@ -146,7 +165,7 @@ export class Params implements IParams {
|
||||
throw new Error('values lesser than -1 are not allowed');
|
||||
}
|
||||
this._subParamsIdx[this.length] = this._subParamsLength << 8 | this._subParamsLength;
|
||||
this.params[this.length++] = value > MAX_VALUE ? MAX_VALUE : value;
|
||||
this.params[this.length++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -168,7 +187,7 @@ export class Params implements IParams {
|
||||
if (value < -1) {
|
||||
throw new Error('values lesser than -1 are not allowed');
|
||||
}
|
||||
this._subParams[this._subParamsLength++] = value > MAX_VALUE ? MAX_VALUE : value;
|
||||
this._subParams[this._subParamsLength++] = value > Constants.MAX_VALUE ? Constants.MAX_VALUE : value;
|
||||
this._subParamsIdx[this.length - 1]++;
|
||||
}
|
||||
|
||||
@@ -224,6 +243,6 @@ export class Params implements IParams {
|
||||
|
||||
const store = this._digitIsSub ? this._subParams : this.params;
|
||||
const cur = store[length - 1];
|
||||
store[length - 1] = ~cur ? Math.min(cur * 10 + value, MAX_VALUE) : value;
|
||||
store[length - 1] = ~cur ? Math.min(cur * 10 + value, Constants.MAX_VALUE) : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface IParams {
|
||||
clone(): IParams;
|
||||
toArray(): ParamsArray;
|
||||
reset(): void;
|
||||
resetZdm(): void;
|
||||
addParam(value: number): void;
|
||||
addSubParam(value: number): void;
|
||||
hasSubParams(idx: number): boolean;
|
||||
@@ -107,7 +108,7 @@ export type EscFallbackHandlerType = (identifier: number) => void;
|
||||
/**
|
||||
* EXECUTE handler types.
|
||||
*/
|
||||
export type ExecuteHandlerType = () => boolean;
|
||||
export type ExecuteHandlerType = (ident?: number) => boolean;
|
||||
export type ExecuteFallbackHandlerType = (ident: number) => void;
|
||||
|
||||
/**
|
||||
@@ -219,8 +220,8 @@ export interface IEscapeSequenceParser extends IDisposable {
|
||||
clearOscHandler(ident: number): void;
|
||||
setOscHandlerFallback(handler: OscFallbackHandlerType): void;
|
||||
|
||||
registerApcHandler(ident: number, handler: IApcHandler): IDisposable;
|
||||
clearApcHandler(ident: number): void;
|
||||
registerApcHandler(id: IFunctionIdentifier, handler: IApcHandler): IDisposable;
|
||||
clearApcHandler(id: IFunctionIdentifier): void;
|
||||
setApcHandlerFallback(handler: ApcFallbackHandlerType): void;
|
||||
|
||||
setErrorHandler(handler: (state: IParsingState) => IParsingState): void;
|
||||
@@ -251,7 +252,7 @@ export interface IDcsParser extends ISubParser<IDcsHandler, DcsFallbackHandlerTy
|
||||
}
|
||||
|
||||
export interface IApcParser extends ISubParser<IApcHandler, ApcFallbackHandlerType> {
|
||||
start(): void;
|
||||
start(ident: number): void;
|
||||
end(success: boolean, promiseResult?: boolean): void | Promise<boolean>;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export class ParserApi implements IParser {
|
||||
public addOscHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this.registerOscHandler(ident, callback);
|
||||
}
|
||||
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._core.registerApcHandler(ident, callback);
|
||||
public registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
|
||||
return this._core.registerApcHandler(id, callback);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { DecorationService } from './DecorationService';
|
||||
import { DecorationLineCache, DecorationService } from './DecorationService';
|
||||
import { IMarker } from 'common/Types';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { MockLogService } from 'common/TestUtils.test';
|
||||
import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
|
||||
function createFakeMarker(line: number): IMarker {
|
||||
return Object.freeze(new class extends Disposable {
|
||||
@@ -19,11 +21,16 @@ function createFakeMarker(line: number): IMarker {
|
||||
}());
|
||||
}
|
||||
|
||||
function createDecorationService(): DecorationService {
|
||||
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
|
||||
return new DecorationService(new MockLogService(), bufferService);
|
||||
}
|
||||
|
||||
const fakeMarker: IMarker = createFakeMarker(1);
|
||||
|
||||
describe('DecorationService', () => {
|
||||
it('should set isDisposed to true after dispose', () => {
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const service = createDecorationService();
|
||||
const decoration = service.registerDecoration({
|
||||
marker: fakeMarker
|
||||
});
|
||||
@@ -35,7 +42,7 @@ describe('DecorationService', () => {
|
||||
|
||||
describe('forEachDecorationAtCell', () => {
|
||||
it('should find decoration at its marker line', () => {
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const service = createDecorationService();
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10
|
||||
@@ -48,7 +55,7 @@ describe('DecorationService', () => {
|
||||
});
|
||||
|
||||
it('should find decoration with height > 1 on subsequent lines', () => {
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const service = createDecorationService();
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10,
|
||||
@@ -74,7 +81,7 @@ describe('DecorationService', () => {
|
||||
});
|
||||
|
||||
it('should not find decoration outside its x range', () => {
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const service = createDecorationService();
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
x: 5,
|
||||
@@ -99,11 +106,35 @@ describe('DecorationService', () => {
|
||||
service.forEachDecorationAtCell(8, 5, undefined, d => foundAtX8.push(d));
|
||||
assert.strictEqual(foundAtX8.length, 0);
|
||||
});
|
||||
|
||||
it('should find multi-line decoration when single-line decorations exist on other lines', () => {
|
||||
const bufferService = new MockBufferService(80, 24, new MockOptionsService());
|
||||
const serviceWithBuffer = new DecorationService(new MockLogService(), bufferService);
|
||||
const buffer = bufferService.buffer;
|
||||
(buffer as Buffer).fillViewportRows();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
serviceWithBuffer.registerDecoration({
|
||||
marker: buffer.addMarker(i),
|
||||
width: 5
|
||||
});
|
||||
}
|
||||
const multiLine = serviceWithBuffer.registerDecoration({
|
||||
marker: buffer.addMarker(10),
|
||||
width: 10,
|
||||
height: 3
|
||||
});
|
||||
assert.ok(multiLine);
|
||||
|
||||
const found: typeof multiLine[] = [];
|
||||
serviceWithBuffer.forEachDecorationAtCell(0, 11, undefined, d => found.push(d));
|
||||
assert.include(found, multiLine);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDecorationsAtCell', () => {
|
||||
it('should find decoration with height > 1 on subsequent lines', () => {
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const service = createDecorationService();
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10,
|
||||
@@ -117,4 +148,73 @@ describe('DecorationService', () => {
|
||||
assert.strictEqual([...service.getDecorationsAtCell(0, 8)].length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DecorationLineCache', () => {
|
||||
it('should return undefined for lines with no indexed decorations', () => {
|
||||
const cache = new DecorationLineCache();
|
||||
assert.isUndefined(cache.getDecorationsOnLine(0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('line index maintenance', () => {
|
||||
it('should keep lookups correct after buffer trim', () => {
|
||||
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
|
||||
const service = new DecorationService(new MockLogService(), bufferService);
|
||||
const buffer = bufferService.buffer;
|
||||
(buffer as Buffer).fillViewportRows();
|
||||
|
||||
const marker = buffer.addMarker(buffer.lines.length - 1);
|
||||
const decoration = service.registerDecoration({ marker, width: 10 });
|
||||
assert.ok(decoration);
|
||||
|
||||
buffer.lines.onTrimEmitter.fire(1);
|
||||
|
||||
const found: typeof decoration[] = [];
|
||||
service.forEachDecorationAtCell(0, marker.line, undefined, d => found.push(d));
|
||||
assert.strictEqual(found.length, 1);
|
||||
});
|
||||
|
||||
it('should remove decoration from line index when marker is trimmed off buffer', () => {
|
||||
const bufferService = new MockBufferService(80, 5, new MockOptionsService({ scrollback: 0 }));
|
||||
const service = new DecorationService(new MockLogService(), bufferService);
|
||||
const buffer = bufferService.buffer;
|
||||
(buffer as Buffer).fillViewportRows();
|
||||
|
||||
const marker = buffer.addMarker(0);
|
||||
const decoration = service.registerDecoration({ marker, width: 10 });
|
||||
assert.ok(decoration);
|
||||
|
||||
buffer.lines.onTrimEmitter.fire(1);
|
||||
assert.isTrue(marker.isDisposed);
|
||||
assert.isTrue(decoration!.isDisposed);
|
||||
|
||||
const found: typeof decoration[] = [];
|
||||
service.forEachDecorationAtCell(0, 0, undefined, d => found.push(d));
|
||||
assert.strictEqual(found.length, 0);
|
||||
});
|
||||
|
||||
it('should keep multi-line decoration indexed after line insert', async () => {
|
||||
const bufferService = new MockBufferService(80, 10, new MockOptionsService({ scrollback: 100 }));
|
||||
const service = new DecorationService(new MockLogService(), bufferService);
|
||||
const buffer = bufferService.buffer;
|
||||
(buffer as Buffer).fillViewportRows();
|
||||
|
||||
const marker = buffer.addMarker(3);
|
||||
const decoration = service.registerDecoration({ marker, width: 10, height: 3 });
|
||||
assert.ok(decoration);
|
||||
|
||||
buffer.lines.splice(5, 0, buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
await new Promise<void>(resolve => queueMicrotask(resolve));
|
||||
|
||||
const foundOnSpan: typeof decoration[] = [];
|
||||
for (let line = marker.line; line < marker.line + 3; line++) {
|
||||
service.forEachDecorationAtCell(0, line, undefined, d => foundOnSpan.push(d));
|
||||
}
|
||||
assert.include(foundOnSpan, decoration);
|
||||
|
||||
const foundOutsideSpan: typeof decoration[] = [];
|
||||
service.forEachDecorationAtCell(0, marker.line + 3, undefined, d => foundOutsideSpan.push(d));
|
||||
assert.strictEqual(foundOutsideSpan.length, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,19 +3,19 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import type { IDeleteEvent, IInsertEvent } from 'common/CircularList';
|
||||
import { MicrotaskTimer } from 'common/Async';
|
||||
import { css } from 'common/Color';
|
||||
import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle';
|
||||
import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
|
||||
import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IBufferService, IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
|
||||
import { SortedList } from 'common/SortedList';
|
||||
import { IColor } from 'common/Types';
|
||||
import { IColor, ICircularList } from 'common/Types';
|
||||
import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $xmin = 0;
|
||||
let $xmax = 0;
|
||||
let $ymin = 0;
|
||||
let $ymax = 0;
|
||||
|
||||
export class DecorationService extends Disposable implements IDecorationService {
|
||||
public serviceBrand: any;
|
||||
@@ -27,6 +27,8 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
*/
|
||||
private readonly _decorations: SortedList<IInternalDecoration>;
|
||||
|
||||
private readonly _lineCache = this._register(new DecorationLineCache());
|
||||
|
||||
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
|
||||
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
|
||||
private readonly _onDecorationRemoved = this._register(new Emitter<IInternalDecoration>());
|
||||
@@ -34,12 +36,19 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
|
||||
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
|
||||
|
||||
constructor(@ILogService private readonly _logService: ILogService) {
|
||||
constructor(
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@IBufferService private readonly _bufferService: IBufferService
|
||||
) {
|
||||
super();
|
||||
|
||||
this._decorations = new SortedList(e => e?.marker.line, this._logService);
|
||||
|
||||
this._register(toDisposable(() => this.reset()));
|
||||
this._register(this._bufferService.buffers.onBufferActivate(() => {
|
||||
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
|
||||
}));
|
||||
this._lineCache.attachToBufferLines(this._bufferService.buffer.lines);
|
||||
}
|
||||
|
||||
public registerDecoration(options: IDecorationOptions): IDecoration | undefined {
|
||||
@@ -53,12 +62,14 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
listener.dispose();
|
||||
if (decoration) {
|
||||
if (this._decorations.delete(decoration)) {
|
||||
this._lineCache.remove(decoration);
|
||||
this._onDecorationRemoved.fire(decoration);
|
||||
}
|
||||
markerDispose.dispose();
|
||||
}
|
||||
});
|
||||
this._decorations.insert(decoration);
|
||||
this._lineCache.add(decoration);
|
||||
this._onDecorationRegistered.fire(decoration);
|
||||
}
|
||||
return decoration;
|
||||
@@ -69,19 +80,17 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
d.dispose();
|
||||
}
|
||||
this._decorations.clear();
|
||||
this._lineCache.clear();
|
||||
}
|
||||
|
||||
public *getDecorationsAtCell(x: number, line: number, layer?: 'bottom' | 'top'): IterableIterator<IInternalDecoration> {
|
||||
const bucket = this._lineCache.getDecorationsOnLine(line);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
let xmin = 0;
|
||||
let xmax = 0;
|
||||
let ymin = 0;
|
||||
let ymax = 0;
|
||||
for (const d of this._decorations.values()) {
|
||||
ymin = d.marker.line;
|
||||
ymax = ymin + (d.options.height ?? 1);
|
||||
if (line < ymin || line >= ymax) {
|
||||
continue;
|
||||
}
|
||||
for (const d of bucket) {
|
||||
xmin = d.options.x ?? 0;
|
||||
xmax = xmin + (d.options.width ?? 1);
|
||||
if (x >= xmin && x < xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
|
||||
@@ -91,12 +100,11 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
}
|
||||
|
||||
public forEachDecorationAtCell(x: number, line: number, layer: 'bottom' | 'top' | undefined, callback: (decoration: IInternalDecoration) => void): void {
|
||||
for (const d of this._decorations.values()) {
|
||||
$ymin = d.marker.line;
|
||||
$ymax = $ymin + (d.options.height ?? 1);
|
||||
if (line < $ymin || line >= $ymax) {
|
||||
continue;
|
||||
}
|
||||
const bucket = this._lineCache.getDecorationsOnLine(line);
|
||||
if (!bucket) {
|
||||
return;
|
||||
}
|
||||
for (const d of bucket) {
|
||||
$xmin = d.options.x ?? 0;
|
||||
$xmax = $xmin + (d.options.width ?? 1);
|
||||
if (x >= $xmin && x < $xmax && (!layer || (d.options.layer ?? 'bottom') === layer)) {
|
||||
@@ -106,10 +114,235 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-logical-line index of decorations for fast cell lookup.
|
||||
*
|
||||
* Keys are marker.line coordinates (logical buffer lines), not CircularList ring slots.
|
||||
* Multi-line decorations appear in every line bucket they span. The index is kept aligned
|
||||
* with marker.line updates via buffer line trim/insert/delete events.
|
||||
*/
|
||||
export class DecorationLineCache extends Disposable {
|
||||
private readonly _decorationsByLine: Map<number, IInternalDecoration[]> = new Map();
|
||||
private readonly _decorations = new Set<IInternalDecoration>();
|
||||
private readonly _bufferLineListeners = this._register(new MutableDisposable<DisposableStore>());
|
||||
private readonly _lineIndexSyncTimer = this._register(new MicrotaskTimer());
|
||||
private _lineIndexSyncCallbacks: (() => void)[] = [];
|
||||
|
||||
public clear(): void {
|
||||
this._lineIndexSyncCallbacks.length = 0;
|
||||
this._lineIndexSyncTimer.cancel();
|
||||
this._decorationsByLine.clear();
|
||||
this._decorations.clear();
|
||||
}
|
||||
|
||||
public add(decoration: IInternalDecoration): void {
|
||||
this._decorations.add(decoration);
|
||||
this._addToLineBuckets(decoration);
|
||||
}
|
||||
|
||||
public remove(decoration: IInternalDecoration): void {
|
||||
this._decorations.delete(decoration);
|
||||
this._removeFromLineBuckets(decoration);
|
||||
}
|
||||
|
||||
public getDecorationsOnLine(line: number): ReadonlyArray<IInternalDecoration> | undefined {
|
||||
return this._decorationsByLine.get(line);
|
||||
}
|
||||
|
||||
public attachToBufferLines(lines: ICircularList<unknown>): void {
|
||||
const store = new DisposableStore();
|
||||
this._bufferLineListeners.value = store;
|
||||
store.add(lines.onTrim(amount => this._handleBufferLinesTrim(amount)));
|
||||
store.add(lines.onInsert(event => this._handleBufferLinesInsert(event)));
|
||||
store.add(lines.onDelete(event => this._handleBufferLinesDelete(event)));
|
||||
}
|
||||
|
||||
private _getDecorationHeight(decoration: IInternalDecoration): number {
|
||||
return decoration.options.height ?? 1;
|
||||
}
|
||||
|
||||
private _addToLineBuckets(decoration: IInternalDecoration): void {
|
||||
const start = decoration.marker.line;
|
||||
if (start < 0) {
|
||||
return;
|
||||
}
|
||||
decoration._indexedStartLine = start;
|
||||
const height = this._getDecorationHeight(decoration);
|
||||
for (let line = start; line < start + height; line++) {
|
||||
let bucket = this._decorationsByLine.get(line);
|
||||
if (!bucket) {
|
||||
bucket = [];
|
||||
this._decorationsByLine.set(line, bucket);
|
||||
}
|
||||
bucket.push(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
private _removeFromLineBuckets(decoration: IInternalDecoration): void {
|
||||
const start = decoration._indexedStartLine;
|
||||
const height = this._getDecorationHeight(decoration);
|
||||
for (let line = start; line < start + height; line++) {
|
||||
const bucket = this._decorationsByLine.get(line);
|
||||
if (!bucket) {
|
||||
continue;
|
||||
}
|
||||
const index = bucket.indexOf(decoration);
|
||||
if (index !== -1) {
|
||||
bucket.splice(index, 1);
|
||||
}
|
||||
if (bucket.length === 0) {
|
||||
this._decorationsByLine.delete(line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _reindexDecoration(decoration: IInternalDecoration): void {
|
||||
this._removeFromLineBuckets(decoration);
|
||||
if (!decoration.marker.isDisposed && decoration.marker.line >= 0) {
|
||||
this._addToLineBuckets(decoration);
|
||||
}
|
||||
}
|
||||
|
||||
/** Re-index after marker line updates (buffer listeners may run before markers). */
|
||||
private _scheduleLineIndexSync(callback: () => void): void {
|
||||
this._lineIndexSyncCallbacks.push(callback);
|
||||
this._lineIndexSyncTimer.set(() => {
|
||||
const callbacks = this._lineIndexSyncCallbacks;
|
||||
this._lineIndexSyncCallbacks = [];
|
||||
for (const cb of callbacks) {
|
||||
cb();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleBufferLinesTrim(amount: number): void {
|
||||
if (amount <= 0) {
|
||||
return;
|
||||
}
|
||||
const newMap = new Map<number, IInternalDecoration[]>();
|
||||
for (const [line, bucket] of this._decorationsByLine) {
|
||||
const newLine = line - amount;
|
||||
if (newLine < 0) {
|
||||
continue;
|
||||
}
|
||||
const existing = newMap.get(newLine);
|
||||
if (existing) {
|
||||
existing.push(...bucket);
|
||||
} else {
|
||||
newMap.set(newLine, bucket.slice());
|
||||
}
|
||||
}
|
||||
this._decorationsByLine.clear();
|
||||
for (const [line, bucket] of newMap) {
|
||||
this._decorationsByLine.set(line, bucket);
|
||||
}
|
||||
for (const d of this._decorations) {
|
||||
if (!d.marker.isDisposed) {
|
||||
d._indexedStartLine -= amount;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _handleBufferLinesInsert(event: IInsertEvent): void {
|
||||
this._scheduleLineIndexSync(() => this._applyBufferLinesInsert(event));
|
||||
}
|
||||
|
||||
private _handleBufferLinesDelete(event: IDeleteEvent): void {
|
||||
this._scheduleLineIndexSync(() => this._applyBufferLinesDelete(event));
|
||||
}
|
||||
|
||||
private _mergeLineBucket(newMap: Map<number, IInternalDecoration[]>, line: number, bucket: IInternalDecoration[]): void {
|
||||
const existing = newMap.get(line);
|
||||
if (existing) {
|
||||
existing.push(...bucket);
|
||||
} else {
|
||||
newMap.set(line, bucket.slice());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift indexed line keys and sync start lines. O(unique indexed lines), not O(decoration count).
|
||||
* Decorations that span the insert point are re-indexed individually (rare vs single-line hits).
|
||||
*/
|
||||
private _applyBufferLinesInsert(event: IInsertEvent): void {
|
||||
const { index, amount } = event;
|
||||
const spanCrossers: IInternalDecoration[] = [];
|
||||
for (const d of this._decorations) {
|
||||
if (d.marker.isDisposed) {
|
||||
continue;
|
||||
}
|
||||
const start = d._indexedStartLine;
|
||||
if (start < index && start + this._getDecorationHeight(d) > index) {
|
||||
spanCrossers.push(d);
|
||||
this._removeFromLineBuckets(d);
|
||||
}
|
||||
}
|
||||
const newMap = new Map<number, IInternalDecoration[]>();
|
||||
for (const [line, bucket] of this._decorationsByLine) {
|
||||
const newLine = line >= index ? line + amount : line;
|
||||
this._mergeLineBucket(newMap, newLine, bucket);
|
||||
}
|
||||
this._decorationsByLine.clear();
|
||||
for (const [line, bucket] of newMap) {
|
||||
this._decorationsByLine.set(line, bucket);
|
||||
}
|
||||
for (const d of this._decorations) {
|
||||
if (d.marker.isDisposed) {
|
||||
continue;
|
||||
}
|
||||
if (d._indexedStartLine >= index) {
|
||||
d._indexedStartLine = d.marker.line;
|
||||
}
|
||||
}
|
||||
for (const d of spanCrossers) {
|
||||
this._addToLineBuckets(d);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop deleted line keys, shift keys below, sync start lines. Full re-index only when a
|
||||
* multi-line decoration spans across the deleted range but survives.
|
||||
*/
|
||||
private _applyBufferLinesDelete(event: IDeleteEvent): void {
|
||||
const deleteEnd = event.index + event.amount;
|
||||
const newMap = new Map<number, IInternalDecoration[]>();
|
||||
for (const [line, bucket] of this._decorationsByLine) {
|
||||
if (line >= event.index && line < deleteEnd) {
|
||||
continue;
|
||||
}
|
||||
const newLine = line >= deleteEnd ? line - event.amount : line;
|
||||
this._mergeLineBucket(newMap, newLine, bucket);
|
||||
}
|
||||
this._decorationsByLine.clear();
|
||||
for (const [line, bucket] of newMap) {
|
||||
this._decorationsByLine.set(line, bucket);
|
||||
}
|
||||
const toReindex: IInternalDecoration[] = [];
|
||||
for (const d of this._decorations) {
|
||||
if (d.marker.isDisposed) {
|
||||
continue;
|
||||
}
|
||||
const start = d._indexedStartLine;
|
||||
const height = this._getDecorationHeight(d);
|
||||
if (start >= deleteEnd) {
|
||||
d._indexedStartLine = d.marker.line;
|
||||
} else if (start < event.index && start + height > deleteEnd) {
|
||||
toReindex.push(d);
|
||||
}
|
||||
}
|
||||
for (const d of toReindex) {
|
||||
this._reindexDecoration(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Decoration extends DisposableStore implements IInternalDecoration {
|
||||
public readonly marker: IMarker;
|
||||
public element: HTMLElement | undefined;
|
||||
|
||||
/** Start line used for line-index removal when marker.line is cleared on dispose. */
|
||||
public _indexedStartLine: number;
|
||||
|
||||
public readonly onRenderEmitter = this.add(new Emitter<HTMLElement>());
|
||||
public readonly onRender = this.onRenderEmitter.event;
|
||||
private readonly _onDispose = this.add(new Emitter<void>());
|
||||
@@ -144,6 +377,7 @@ class Decoration extends DisposableStore implements IInternalDecoration {
|
||||
) {
|
||||
super();
|
||||
this.marker = options.marker;
|
||||
this._indexedStartLine = options.marker.line;
|
||||
if (this.options.overviewRulerOptions && !this.options.overviewRulerOptions.position) {
|
||||
this.options.overviewRulerOptions.position = 'full';
|
||||
}
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
|
||||
import { IServiceIdentifier } from 'common/services/Services';
|
||||
|
||||
const DI_TARGET = 'di$target';
|
||||
const DI_DEPENDENCIES = 'di$dependencies';
|
||||
const enum Constants {
|
||||
DI_TARGET = 'di$target',
|
||||
DI_DEPENDENCIES = 'di$dependencies'
|
||||
}
|
||||
|
||||
export const serviceRegistry: Map<string, IServiceIdentifier<any>> = new Map();
|
||||
|
||||
export function getServiceDependencies(ctor: any): { id: IServiceIdentifier<any>, index: number, optional: boolean }[] {
|
||||
return ctor[DI_DEPENDENCIES] || [];
|
||||
return ctor[Constants.DI_DEPENDENCIES] || [];
|
||||
}
|
||||
|
||||
export function createDecorator<T>(id: string): IServiceIdentifier<T> {
|
||||
@@ -40,10 +42,10 @@ export function createDecorator<T>(id: string): IServiceIdentifier<T> {
|
||||
}
|
||||
|
||||
function storeServiceDependency(id: Function, target: Function, index: number): void {
|
||||
if ((target as any)[DI_TARGET] === target) {
|
||||
(target as any)[DI_DEPENDENCIES].push({ id, index });
|
||||
if ((target as any)[Constants.DI_TARGET] === target) {
|
||||
(target as any)[Constants.DI_DEPENDENCIES].push({ id, index });
|
||||
} else {
|
||||
(target as any)[DI_DEPENDENCIES] = [{ id, index }];
|
||||
(target as any)[DI_TARGET] = target;
|
||||
(target as any)[Constants.DI_DEPENDENCIES] = [{ id, index }];
|
||||
(target as any)[Constants.DI_TARGET] = target;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -396,4 +396,6 @@ export interface IInternalDecoration extends IDecoration {
|
||||
readonly backgroundColorRGB: IColor | undefined;
|
||||
readonly foregroundColorRGB: IColor | undefined;
|
||||
readonly onRenderEmitter: Emitter<HTMLElement>;
|
||||
/** @internal Start line for line-index removal; kept in sync on buffer line shifts. */
|
||||
_indexedStartLine: number;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2015",
|
||||
"es2016.Array.Include"
|
||||
"es2016.Array.Include",
|
||||
"es2019.string",
|
||||
"es2021.weakref"
|
||||
],
|
||||
"outDir": "../../out",
|
||||
"types": [
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"lib": [
|
||||
"es2015",
|
||||
"es2016.Array.Include"
|
||||
"es2016.Array.Include",
|
||||
"es2021.weakref"
|
||||
],
|
||||
"outDir": "../../out",
|
||||
"types": [
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { perfContext, before, RuntimeCase } from 'xterm-benchmark';
|
||||
import { DecorationService } from 'common/services/DecorationService';
|
||||
import { MockLogService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
|
||||
const enum Constants {
|
||||
COLS = 80,
|
||||
ROWS = 30,
|
||||
SINGLE_LINE_DECORATION_COUNT = 20_000,
|
||||
MULTI_LINE_DECORATION_COUNT = 19_999,
|
||||
MULTI_LINE_HEIGHT = 2,
|
||||
VIEWPORT_SCAN_ITERATIONS = 20
|
||||
}
|
||||
|
||||
function registerSingleLineDecorations(service: DecorationService, bufferService: MockBufferService, count: number): void {
|
||||
const buffer = bufferService.buffer;
|
||||
for (let i = 0; i < count; i++) {
|
||||
const line = i % 5000;
|
||||
const marker = buffer.addMarker(line);
|
||||
service.registerDecoration({ marker, width: Constants.COLS });
|
||||
}
|
||||
}
|
||||
|
||||
function registerMixedDecorations(service: DecorationService, bufferService: MockBufferService): void {
|
||||
const buffer = bufferService.buffer;
|
||||
for (let i = 0; i < Constants.MULTI_LINE_DECORATION_COUNT; i++) {
|
||||
const line = i % 5000;
|
||||
const marker = buffer.addMarker(line);
|
||||
service.registerDecoration({ marker, width: Constants.COLS });
|
||||
}
|
||||
const multiLineMarker = buffer.addMarker(10);
|
||||
service.registerDecoration({
|
||||
marker: multiLineMarker,
|
||||
width: Constants.COLS,
|
||||
height: Constants.MULTI_LINE_HEIGHT
|
||||
});
|
||||
}
|
||||
|
||||
function scanVisibleGrid(service: DecorationService, rows: number, cols: number): number {
|
||||
let hitCount = 0;
|
||||
for (let row = 0; row < rows; row++) {
|
||||
for (let col = 0; col < cols; col++) {
|
||||
service.forEachDecorationAtCell(col, row, undefined, () => {
|
||||
hitCount++;
|
||||
});
|
||||
}
|
||||
}
|
||||
return hitCount;
|
||||
}
|
||||
|
||||
perfContext('DecorationService.forEachDecorationAtCell', () => {
|
||||
perfContext('single-line dense / sparse line hit', () => {
|
||||
let service: DecorationService;
|
||||
let bufferService: MockBufferService;
|
||||
before(() => {
|
||||
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
|
||||
service = new DecorationService(new MockLogService(), bufferService);
|
||||
registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let hitCount = 0;
|
||||
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
|
||||
service.forEachDecorationAtCell(0, 0, undefined, () => {
|
||||
hitCount++;
|
||||
});
|
||||
}
|
||||
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('mixed single-line + multi-line / sparse line hit', () => {
|
||||
let service: DecorationService;
|
||||
let bufferService: MockBufferService;
|
||||
before(() => {
|
||||
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
|
||||
service = new DecorationService(new MockLogService(), bufferService);
|
||||
registerMixedDecorations(service, bufferService);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let hitCount = 0;
|
||||
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
|
||||
service.forEachDecorationAtCell(0, 10, undefined, () => {
|
||||
hitCount++;
|
||||
});
|
||||
}
|
||||
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS, hitCount };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
|
||||
perfContext('viewport grid scan', () => {
|
||||
let service: DecorationService;
|
||||
let bufferService: MockBufferService;
|
||||
before(() => {
|
||||
bufferService = new MockBufferService(Constants.COLS, Constants.ROWS, new MockOptionsService());
|
||||
service = new DecorationService(new MockLogService(), bufferService);
|
||||
registerSingleLineDecorations(service, bufferService, Constants.SINGLE_LINE_DECORATION_COUNT);
|
||||
});
|
||||
new RuntimeCase('', () => {
|
||||
let totalHits = 0;
|
||||
for (let i = 0; i < Constants.VIEWPORT_SCAN_ITERATIONS; i++) {
|
||||
totalHits += scanVisibleGrid(service, Constants.ROWS, Constants.COLS);
|
||||
}
|
||||
return { payloadSize: Constants.VIEWPORT_SCAN_ITERATIONS * Constants.ROWS * Constants.COLS, totalHits };
|
||||
}, { fork: false }).showAverageRuntime();
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,10 @@ import { perfContext, before, beforeEach, ThroughputRuntimeCase } from 'xterm-be
|
||||
|
||||
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
|
||||
import { C0, C1 } from 'common/data/EscapeSequences';
|
||||
import { IDcsHandler, IOscHandler, IParams } from 'common/parser/Types';
|
||||
import { IDcsHandler, IOscHandler, IApcHandler, IParams } from 'common/parser/Types';
|
||||
import { OscHandler } from 'common/parser/OscParser';
|
||||
import { DcsHandler } from '../../out/common/parser/DcsParser';
|
||||
import { DcsHandler } from 'common/parser/DcsParser';
|
||||
import { ApcHandler } from 'common/parser/ApcParser';
|
||||
|
||||
const SIZE = 5000000;
|
||||
|
||||
@@ -32,6 +33,12 @@ class FastOscHandler implements IOscHandler {
|
||||
public end(success: boolean): boolean { return true; }
|
||||
}
|
||||
|
||||
class FastApcHandler implements IApcHandler {
|
||||
public start(): void {}
|
||||
public put(data: Uint32Array, start: number, end: number): void {}
|
||||
public end(success: boolean): boolean { return true; }
|
||||
}
|
||||
|
||||
|
||||
perfContext('Parser throughput - 50MB data', () => {
|
||||
let parsed: Uint32Array;
|
||||
@@ -108,6 +115,8 @@ perfContext('Parser throughput - 50MB data', () => {
|
||||
parser.registerEscHandler({ intermediates: '%', final: 'G' }, () => true);
|
||||
parser.registerDcsHandler({ final: 'p' }, new DcsHandler(data => true));
|
||||
parser.registerDcsHandler({ final: 'q' }, new FastDcsHandler());
|
||||
parser.registerApcHandler({ final: 'X' }, new ApcHandler(data => true));
|
||||
parser.registerApcHandler({ final: 'Y' }, new FastApcHandler());
|
||||
});
|
||||
|
||||
perfContext('PRINT - a', () => {
|
||||
@@ -349,4 +358,64 @@ perfContext('Parser throughput - 50MB data', () => {
|
||||
return { payloadSize: parsed.length };
|
||||
}, { fork: true }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('APC string interface (short seq)', () => {
|
||||
before(() => {
|
||||
const data = '\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\\x1b_Xhi\x1b\\';
|
||||
let content = '';
|
||||
while (content.length < SIZE) {
|
||||
content += data;
|
||||
}
|
||||
parsed = toUtf32(content);
|
||||
});
|
||||
new ThroughputRuntimeCase('', async () => {
|
||||
parser.parse(parsed, parsed.length);
|
||||
return { payloadSize: parsed.length };
|
||||
}, { fork: true }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('APC string interface (long seq)', () => {
|
||||
before(() => {
|
||||
const data = '\x1b_XLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\';
|
||||
let content = '';
|
||||
while (content.length < SIZE) {
|
||||
content += data;
|
||||
}
|
||||
parsed = toUtf32(content);
|
||||
});
|
||||
new ThroughputRuntimeCase('', async () => {
|
||||
parser.parse(parsed, parsed.length);
|
||||
return { payloadSize: parsed.length };
|
||||
}, { fork: true }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('APC class interface (short seq)', () => {
|
||||
before(() => {
|
||||
const data = '\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\\x1b_Yhi\x1b\\';
|
||||
let content = '';
|
||||
while (content.length < SIZE) {
|
||||
content += data;
|
||||
}
|
||||
parsed = toUtf32(content);
|
||||
});
|
||||
new ThroughputRuntimeCase('', async () => {
|
||||
parser.parse(parsed, parsed.length);
|
||||
return { payloadSize: parsed.length };
|
||||
}, { fork: true }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('APC class interface (long seq)', () => {
|
||||
before(() => {
|
||||
const data = '\x1b_YLorem ipsum dolor sit amet, consetetur sadipscing elitr.\x1b\\';
|
||||
let content = '';
|
||||
while (content.length < SIZE) {
|
||||
content += data;
|
||||
}
|
||||
parsed = toUtf32(content);
|
||||
});
|
||||
new ThroughputRuntimeCase('', async () => {
|
||||
parser.parse(parsed, parsed.length);
|
||||
return { payloadSize: parsed.length };
|
||||
}, { fork: true }).showAverageThroughput();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ perfContext('Terminal: ls -lR /usr/lib', () => {
|
||||
}
|
||||
});
|
||||
|
||||
perfContext('write/string/async', () => {
|
||||
perfContext('write/string', () => {
|
||||
let terminal: CoreBrowserTerminal;
|
||||
before(() => {
|
||||
terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 });
|
||||
@@ -55,13 +55,13 @@ perfContext('Terminal: ls -lR /usr/lib', () => {
|
||||
}, { fork: false }).showAverageThroughput();
|
||||
});
|
||||
|
||||
perfContext('write/Utf8/async', () => {
|
||||
perfContext('write/utf8', () => {
|
||||
let terminal: CoreBrowserTerminal;
|
||||
before(() => {
|
||||
terminal = new CoreBrowserTerminal({ cols: 80, rows: 25, scrollback: 1000 });
|
||||
});
|
||||
new ThroughputRuntimeCase('', async () => {
|
||||
await new Promise<void>(res => terminal.write(content, res));
|
||||
await new Promise<void>(res => terminal.write(contentUtf8, res));
|
||||
return { payloadSize: contentUtf8.length };
|
||||
}, { fork: false }).showAverageThroughput();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
})
|
||||
|
||||
@@ -169,7 +169,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 1), [0, 0, 0, 255]);
|
||||
});
|
||||
|
||||
test('background 0-15 invisible', async () => {
|
||||
// due to #5852
|
||||
test.skip('background 0-15 invisible', async () => {
|
||||
const theme: ITheme = {
|
||||
black: '#010203',
|
||||
red: '#040506',
|
||||
@@ -335,7 +336,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
||||
}
|
||||
});
|
||||
|
||||
test('background 16-255 invisible', async () => {
|
||||
// due to #5852
|
||||
test.skip('background 16-255 invisible', async () => {
|
||||
let data = '';
|
||||
for (let y = 0; y < 240 / 16; y++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
@@ -715,7 +717,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
||||
}
|
||||
});
|
||||
|
||||
test('background true color grey invisible', async () => {
|
||||
// due to #5852
|
||||
test.skip('background true color grey invisible', async () => {
|
||||
let data = '';
|
||||
for (let y = 0; y < 16; y++) {
|
||||
for (let x = 0; x < 16; x++) {
|
||||
@@ -872,7 +875,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 8, 2), [64, 64, 64, 255]);
|
||||
});
|
||||
|
||||
test('should enforce half the contrast for dim cells', async () => {
|
||||
// due to #5852
|
||||
test.skip('should enforce half the contrast for dim cells', async () => {
|
||||
const theme: ITheme = {
|
||||
background: '#ffffff',
|
||||
black: '#2e3436',
|
||||
@@ -1177,7 +1181,8 @@ export function injectSharedRendererTests(ctx: ISharedRendererTestContext): void
|
||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 2, 3), [0, 0, 255, 255]);
|
||||
await pollFor(ctx.value.page, () => getCellColor(ctx.value, 3, 3), [0, 0, 255, 255]);
|
||||
});
|
||||
test('#4758: multiple invisible text characters without SGR change should not be rendered', async () => {
|
||||
// due to #5852
|
||||
test.skip('#4758: multiple invisible text characters without SGR change should not be rendered', async () => {
|
||||
// Regression test: #4758 when multiple invisible characters are used
|
||||
await ctx.value.proxy.writeln(`■\x1b[8m■■`);
|
||||
// Full refresh as the before result is the same as after
|
||||
|
||||
Vendored
+5
-5
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+5
-5
@@ -1909,12 +1909,12 @@ declare module '@xterm/xterm' {
|
||||
prefix?: string;
|
||||
/**
|
||||
* Optional intermediate bytes, must be in range \x20 .. \x2f.
|
||||
* Usable in CSI, DCS and ESC.
|
||||
* Usable in CSI, DCS, ESC and APC.
|
||||
*/
|
||||
intermediates?: string;
|
||||
/**
|
||||
* Final byte, must be in range \x40 .. \x7e for CSI and DCS,
|
||||
* \x30 .. \x7e for ESC.
|
||||
* \x30 .. \x7e for ESC and APC.
|
||||
*/
|
||||
final: string;
|
||||
}
|
||||
@@ -2002,8 +2002,8 @@ declare module '@xterm/xterm' {
|
||||
|
||||
/**
|
||||
* Adds a handler for APC escape sequences.
|
||||
* @param ident The identifier (first character) of the sequence as a
|
||||
* character code, e.g. 71 for 'G' (Kitty graphics protocol).
|
||||
* @param id Specifies the function identifier under which the callback
|
||||
* gets registered, e.g. {final: 'G'} for Kitty graphics protocol.
|
||||
* @param callback The function to handle the sequence. Note that the
|
||||
* function will only be called once if the sequence finished successfully.
|
||||
* There is currently no way to intercept smaller data chunks, data chunks
|
||||
@@ -2016,7 +2016,7 @@ declare module '@xterm/xterm' {
|
||||
* handler. The most recently added handler is tried first.
|
||||
* @returns An IDisposable you can call to remove this handler.
|
||||
*/
|
||||
registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
registerApcHandler(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user