Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3556f16138 | ||
|
|
c2ecfe7446 | ||
|
|
5656443163 | ||
|
|
38174907f3 | ||
|
|
91fcb46a1a | ||
|
|
1d569f873d | ||
|
|
e89fbf2979 | ||
|
|
db244ea5a8 | ||
|
|
fdcd1c2720 | ||
|
|
57b48f6048 | ||
|
|
ec0c93fd5a | ||
|
|
be339bf796 | ||
|
|
7294124736 | ||
|
|
2c3fad39af | ||
|
|
edb4f89dd8 | ||
|
|
224c113b64 | ||
|
|
3e0c7d18e5 | ||
|
|
95d9224b8c | ||
|
|
7499a2181d | ||
|
|
7dc820f5fa | ||
|
|
e59a6c1f7b | ||
|
|
f3e2c08a5d | ||
|
|
b138bf38d0 | ||
|
|
c6e4627b45 | ||
|
|
d91045dc5f | ||
|
|
41dfdba2cf | ||
|
|
ba33decb68 | ||
|
|
17e3b84bcc | ||
|
|
182ff2f635 | ||
|
|
bcb4e5298b | ||
|
|
42facac2d9 | ||
|
|
2ab0f10a26 | ||
|
|
280dc0a36a | ||
|
|
ebe8cb749a | ||
|
|
c28f3c8d73 | ||
|
|
3bd7ac083a | ||
|
|
30b42838fd | ||
|
|
80e832e417 | ||
|
|
470947abd8 | ||
|
|
bb49a80ace | ||
|
|
65b87e0f40 | ||
|
|
39fc0f259e | ||
|
|
b787ac8b1b | ||
|
|
457efbf55d | ||
|
|
42270a0b68 | ||
|
|
090b77f354 | ||
|
|
534ce1365b | ||
|
|
f757c965ee | ||
|
|
4aa02f3fd9 | ||
|
|
f43273b3f0 | ||
|
|
c06c3025f2 | ||
|
|
1064e4f783 | ||
|
|
fad4821fd9 | ||
|
|
306c3d73ec | ||
|
|
8f2bb32c9c | ||
|
|
7b6c814566 | ||
|
|
adc9dea083 | ||
|
|
d565a2750e | ||
|
|
c5ab83d697 | ||
|
|
c62e6dd765 | ||
|
|
29a07f1d8f | ||
|
|
88cef97049 | ||
|
|
0e823004e6 | ||
|
|
df3422fb0b | ||
|
|
4ec85e67ba | ||
|
|
1a67b6f4b3 | ||
|
|
afaed108e4 | ||
|
|
c27d6bb462 | ||
|
|
9c4f0f96ca | ||
|
|
18c370a154 | ||
|
|
850f4ad738 | ||
|
|
b665f6259a | ||
|
|
d195dc9a3b | ||
|
|
4e5994d8a2 | ||
|
|
4a0cb5ae6b | ||
|
|
9e72a4b6d3 | ||
|
|
f484584b70 | ||
|
|
e749cb6125 | ||
|
|
8df996f9e7 | ||
|
|
51f6186352 | ||
|
|
ae5c41c518 | ||
|
|
17e7dafa17 | ||
|
|
df9fb7e473 | ||
|
|
41d9c4080f | ||
|
|
f100e93ba5 | ||
|
|
1d3825d341 | ||
|
|
57315e6cc9 | ||
|
|
2be4fc68e5 | ||
|
|
66fabfc232 | ||
|
|
3e2a42eb29 | ||
|
|
6a47d09b4c | ||
|
|
b36001ee7b | ||
|
|
e64f25a0dd | ||
|
|
3bcb57545e | ||
|
|
dc726a2ae6 | ||
|
|
06e5ac7ab4 | ||
|
|
c6e3492a4a | ||
|
|
86291b6e96 | ||
|
|
c8c8b3a7fe | ||
|
|
50e90ddf8a | ||
|
|
7059a07c09 | ||
|
|
c5c6fc2a64 | ||
|
|
e0add7fe4b | ||
|
|
b0f5bc2d28 | ||
|
|
c5a11220c1 | ||
|
|
6f55150485 | ||
|
|
c8dddddbbb | ||
|
|
6ba731d73b | ||
|
|
2d3909be2c | ||
|
|
1e189625fa | ||
|
|
80debafa11 | ||
|
|
1023e40428 | ||
|
|
5c47e6dd9a | ||
|
|
4ebb3fffe9 | ||
|
|
093e81b9a7 | ||
|
|
e04dd2501f | ||
|
|
d911b40a47 | ||
|
|
3dbfa7ce3e | ||
|
|
6287c6ddfe | ||
|
|
52d4b2719c | ||
|
|
7615cd210a | ||
|
|
4d4b994c35 | ||
|
|
7fa8b7a069 | ||
|
|
a2e51e8d36 | ||
|
|
f89253d24b | ||
|
|
7aeee95346 | ||
|
|
66fcdb2583 | ||
|
|
2a18600d18 | ||
|
|
5ef4f86543 | ||
|
|
874e767b40 | ||
|
|
605a7c6e30 | ||
|
|
802decb046 | ||
|
|
79a95dbfd8 | ||
|
|
75ba88c51b | ||
|
|
f9cea732f2 | ||
|
|
4dc934ce7c | ||
|
|
b2eff5ac67 | ||
|
|
31c53bc9c2 | ||
|
|
0d2975884f | ||
|
|
e8b5fd3c23 | ||
|
|
7abcca7e73 | ||
|
|
92339ac9dd | ||
|
|
ad4a6e8cb9 | ||
|
|
a094340afe | ||
|
|
cc7e3bbbf7 | ||
|
|
73d87b2c20 | ||
|
|
c5bf395b10 | ||
|
|
9d24099aa1 | ||
|
|
066c227436 | ||
|
|
32ad3a18bf | ||
|
|
d43b19c260 | ||
|
|
7dd0dac859 | ||
|
|
7e70d6ad78 | ||
|
|
893cdd494a | ||
|
|
2794e32b3f | ||
|
|
7d69e730ca | ||
|
|
da51d9608c | ||
|
|
3a84bcb7ae | ||
|
|
d9082215e7 | ||
|
|
41938b307e | ||
|
|
785c3cb336 | ||
|
|
ea7e958959 | ||
|
|
915b0b81a1 | ||
|
|
4e9abe7d96 | ||
|
|
84aca0b912 | ||
|
|
d82d42ef9a | ||
|
|
4d2f2e0d8e | ||
|
|
56986cb7eb | ||
|
|
4946436997 | ||
|
|
bf83ea06e4 | ||
|
|
b7e535e2ec | ||
|
|
8e12e69e4e | ||
|
|
849a9a53ca | ||
|
|
ee7d84bdd3 | ||
|
|
9c8de633e9 | ||
|
|
9c5420371b | ||
|
|
22778b5b49 | ||
|
|
cbe59a17ae | ||
|
|
76671cecb6 | ||
|
|
a11fcd2e0b | ||
|
|
b28b5d0c2b | ||
|
|
685434ca84 | ||
|
|
7fe89a1c1a | ||
|
|
eaebe2ab15 | ||
|
|
32553b4135 | ||
|
|
6c2465d745 | ||
|
|
5922123b9e | ||
|
|
eb65910c1b | ||
|
|
f0aeb70c92 | ||
|
|
9deec7aabf | ||
|
|
18cd9858ed | ||
|
|
6fd7ecc812 | ||
|
|
ff72c00219 | ||
|
|
0911688295 | ||
|
|
62ce318a07 | ||
|
|
08ae141ef7 | ||
|
|
ba7e775a17 | ||
|
|
487b27f512 | ||
|
|
5d120de3d5 | ||
|
|
3ab299af7f | ||
|
|
6e375cb588 | ||
|
|
7a6e724bfe | ||
|
|
69ee4ad23b | ||
|
|
4b77e601cd | ||
|
|
5e9b2cbc14 | ||
|
|
3a59c8ec2b |
@@ -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,10 @@ build/
|
||||
yarn.lock
|
||||
test-results/
|
||||
|
||||
# Tracing/debug files
|
||||
.cursor/nal-trace/
|
||||
.cursor/*.log
|
||||
|
||||
# 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) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
|
||||
const height = this._dec.height;
|
||||
|
||||
// partial fix for https://github.com/jerch/xterm-addon-image/issues/37
|
||||
if (!width || ! height) {
|
||||
if (!width || !height) {
|
||||
if (height) {
|
||||
this._storage.advanceCursor(height);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"package": "webpack",
|
||||
"pretest": "npm run build",
|
||||
"test": "nyc mocha out/**/*.test.js",
|
||||
"prepublish": "npm run package"
|
||||
"prepublishOnly": "npm run package"
|
||||
},
|
||||
"keywords": [
|
||||
"font",
|
||||
@@ -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"
|
||||
|
||||
@@ -9,15 +9,18 @@ import { ITestContext, createTestContext, openTerminal } from '../../../test/pla
|
||||
|
||||
|
||||
let ctx: ITestContext;
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
ctx.page.setViewportSize({ width: 1024, height: 768 });
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => await ctx.page.close());
|
||||
|
||||
|
||||
test.describe('ProgressAddon', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
ctx = await createTestContext(browser);
|
||||
await openTerminal(ctx);
|
||||
});
|
||||
test.afterAll(async () => {
|
||||
await ctx?.page?.close();
|
||||
});
|
||||
|
||||
test.beforeEach(async function(): Promise<any> {
|
||||
await ctx.page.evaluate(`
|
||||
window.progressStack = [];
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { configEquals } from './CharAtlasUtils';
|
||||
import { ICharAtlasConfig } from './Types';
|
||||
import { NULL_COLOR } from 'common/Color';
|
||||
import { IColor } from 'common/Types';
|
||||
|
||||
function createTestConfig(overrides: Partial<ICharAtlasConfig> = {}): ICharAtlasConfig {
|
||||
const color: IColor = { css: '#ffffff', rgba: 0xffffffff };
|
||||
const contrastCache = {
|
||||
clear: () => {},
|
||||
setCss: () => {},
|
||||
getCss: () => undefined,
|
||||
setColor: () => {},
|
||||
getColor: () => undefined
|
||||
};
|
||||
const colors = {
|
||||
foreground: color,
|
||||
background: color,
|
||||
cursor: NULL_COLOR,
|
||||
cursorAccent: NULL_COLOR,
|
||||
selectionForeground: undefined,
|
||||
selectionBackgroundTransparent: NULL_COLOR,
|
||||
selectionBackgroundOpaque: NULL_COLOR,
|
||||
selectionInactiveBackgroundTransparent: NULL_COLOR,
|
||||
selectionInactiveBackgroundOpaque: NULL_COLOR,
|
||||
overviewRulerBorder: NULL_COLOR,
|
||||
scrollbarSliderBackground: NULL_COLOR,
|
||||
scrollbarSliderHoverBackground: NULL_COLOR,
|
||||
scrollbarSliderActiveBackground: NULL_COLOR,
|
||||
ansi: new Array(256).fill(color),
|
||||
contrastCache,
|
||||
halfContrastCache: contrastCache
|
||||
};
|
||||
return {
|
||||
customGlyphs: true,
|
||||
devicePixelRatio: 1,
|
||||
deviceMaxTextureSize: 4096,
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
fontSize: 15,
|
||||
fontFamily: 'monospace',
|
||||
fontWeight: 'normal',
|
||||
fontWeightBold: 'bold',
|
||||
deviceCellWidth: 10,
|
||||
deviceCellHeight: 20,
|
||||
deviceCharWidth: 8,
|
||||
deviceCharHeight: 16,
|
||||
allowTransparency: false,
|
||||
drawBoldTextInBrightColors: true,
|
||||
minimumContrastRatio: 1,
|
||||
colors,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('CharAtlasUtils', () => {
|
||||
describe('configEquals', () => {
|
||||
it('should return true for identical configs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig();
|
||||
assert.ok(configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceMaxTextureSize differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceMaxTextureSize: 8192 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellWidth differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellWidth: 11 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
|
||||
it('should return false when deviceCellHeight differs', () => {
|
||||
const a = createTestConfig();
|
||||
const b = createTestConfig({ deviceCellHeight: 21 });
|
||||
assert.ok(!configEquals(a, b));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
}
|
||||
}
|
||||
return a.devicePixelRatio === b.devicePixelRatio &&
|
||||
a.deviceMaxTextureSize === b.deviceMaxTextureSize &&
|
||||
a.customGlyphs === b.customGlyphs &&
|
||||
a.lineHeight === b.lineHeight &&
|
||||
a.letterSpacing === b.letterSpacing &&
|
||||
@@ -67,6 +68,8 @@ export function configEquals(a: ICharAtlasConfig, b: ICharAtlasConfig): boolean
|
||||
a.fontWeight === b.fontWeight &&
|
||||
a.fontWeightBold === b.fontWeightBold &&
|
||||
a.allowTransparency === b.allowTransparency &&
|
||||
a.deviceCellWidth === b.deviceCellWidth &&
|
||||
a.deviceCellHeight === b.deviceCellHeight &&
|
||||
a.deviceCharWidth === b.deviceCharWidth &&
|
||||
a.deviceCharHeight === b.deviceCharHeight &&
|
||||
a.drawBoldTextInBrightColors === b.drawBoldTextInBrightColors &&
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject, type IRasterizedGlyph, type ITextureAtlas } from './Types';
|
||||
import { createProgram, GLTexture, PROJECTION_MATRIX } from './WebglUtils';
|
||||
import type { IOptionsService } from 'common/services/Services';
|
||||
import type { ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { allowRescaling, throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
interface IVertices {
|
||||
@@ -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;
|
||||
@@ -112,7 +114,8 @@ export class GlyphRenderer extends Disposable {
|
||||
private readonly _terminal: Terminal,
|
||||
private readonly _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _optionsService: IOptionsService
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
@@ -125,7 +128,7 @@ export class GlyphRenderer extends Disposable {
|
||||
TextureAtlas.maxTextureSize = throwIfFalsy(gl.getParameter(gl.MAX_TEXTURE_SIZE) as number | null);
|
||||
}
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages)));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, createFragmentShaderSource(TextureAtlas.maxAtlasPages), this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
@@ -134,8 +137,9 @@ export class GlyphRenderer extends Disposable {
|
||||
this._textureLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_texture'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
@@ -160,22 +164,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 +226,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 +292,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 +314,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 +350,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 +360,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 +370,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,10 +10,11 @@ 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';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
const enum VertexAttribLocations {
|
||||
POSITION = 0,
|
||||
@@ -89,21 +90,23 @@ export class RectangleRenderer extends Disposable {
|
||||
private _terminal: Terminal,
|
||||
private _gl: IWebGL2RenderingContext,
|
||||
private _dimensions: IRenderDimensions,
|
||||
private readonly _themeService: IThemeService
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
|
||||
const gl = this._gl;
|
||||
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource));
|
||||
this._program = throwIfFalsy(createProgram(gl, vertexShaderSource, fragmentShaderSource, this._logService));
|
||||
this._register(toDisposable(() => gl.deleteProgram(this._program)));
|
||||
|
||||
// Uniform locations
|
||||
this._projectionLocation = throwIfFalsy(gl.getUniformLocation(this._program, 'u_projection'));
|
||||
|
||||
// Create and set the vertex array object
|
||||
this._vertexArrayObject = gl.createVertexArray();
|
||||
gl.bindVertexArray(this._vertexArrayObject);
|
||||
const vertexArrayObject = this._vertexArrayObject = gl.createVertexArray();
|
||||
this._register(toDisposable(() => gl.deleteVertexArray(vertexArrayObject)));
|
||||
gl.bindVertexArray(vertexArrayObject);
|
||||
|
||||
// Setup a_unitquad, this defines the 4 vertices of a rectangle
|
||||
const unitQuadVertices = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]);
|
||||
@@ -217,9 +220,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 +339,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);
|
||||
|
||||
@@ -109,6 +109,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
page.canvas.remove();
|
||||
}
|
||||
this._onAddTextureAtlasCanvas.dispose();
|
||||
this._onRemoveTextureAtlasCanvas.dispose();
|
||||
}
|
||||
|
||||
public warmUp(): void {
|
||||
@@ -133,7 +134,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 +196,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 +254,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
for (const g of adjustingPage.glyphs) {
|
||||
g.texturePage--;
|
||||
}
|
||||
adjustingPage.version++;
|
||||
adjustingPage.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +530,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
let customGlyph = false;
|
||||
if (this._config.customGlyphs !== false) {
|
||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, this._logService, backgroundColor.css, variantOffset);
|
||||
}
|
||||
|
||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||
@@ -937,7 +940,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
rasterizedGlyph.size.y
|
||||
);
|
||||
activePage.addGlyph(rasterizedGlyph);
|
||||
activePage.version++;
|
||||
activePage.version = ++AtlasPage.nextVersion;
|
||||
|
||||
return rasterizedGlyph;
|
||||
}
|
||||
@@ -1047,9 +1050,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 +1099,7 @@ class AtlasPage {
|
||||
this.currentRow.y = 0;
|
||||
this.currentRow.height = 0;
|
||||
this.fixedRows.length = 0;
|
||||
this.version++;
|
||||
this.version = ++AtlasPage.nextVersion;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface ICursorRenderModel {
|
||||
export interface IWebGL2RenderingContext extends WebGLRenderingContext {
|
||||
vertexAttribDivisor(index: number, divisor: number): void;
|
||||
createVertexArray(): IWebGLVertexArrayObject;
|
||||
deleteVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
bindVertexArray(vao: IWebGLVertexArrayObject): void;
|
||||
drawElementsInstanced(mode: number, count: number, type: number, offset: number, instanceCount: number): void;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ICharacterJoinerService, ICharSizeService, ICoreBrowserService, IRender
|
||||
import { ITerminal } from 'browser/Types';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { getSafariVersion, isSafari } from 'common/Platform';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { IWebGL2RenderingContext } from './Types';
|
||||
import { WebglRenderer } from './WebglRenderer';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
@@ -65,6 +65,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
const charSizeService: ICharSizeService = unsafeCore._charSizeService;
|
||||
const coreBrowserService: ICoreBrowserService = unsafeCore._coreBrowserService;
|
||||
const decorationService: IDecorationService = unsafeCore._decorationService;
|
||||
const logService: ILogService = unsafeCore._logService;
|
||||
const themeService: IThemeService = unsafeCore._themeService;
|
||||
|
||||
this._renderer = this._register(new WebglRenderer(
|
||||
@@ -74,6 +75,7 @@ export class WebglAddon extends Disposable implements ITerminalAddon, IWebglApi
|
||||
coreBrowserService,
|
||||
coreService,
|
||||
decorationService,
|
||||
logService,
|
||||
optionsService,
|
||||
themeService,
|
||||
this._customGlyphs,
|
||||
|
||||
@@ -15,11 +15,11 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
import { RectangleRenderer } from './RectangleRenderer';
|
||||
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,9 +28,13 @@ 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> = new MutableDisposable();
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
|
||||
private _textBlinkStateManager: TextBlinkStateManager;
|
||||
private _charAtlasDisposable = this._register(new MutableDisposable());
|
||||
private _charAtlas: ITextureAtlas | undefined;
|
||||
@@ -73,6 +77,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
private readonly _coreService: ICoreService,
|
||||
private readonly _decorationService: IDecorationService,
|
||||
private readonly _logService: ILogService,
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _themeService: IThemeService,
|
||||
private readonly _customGlyphs: boolean = true,
|
||||
@@ -118,19 +123,19 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextlost', (e) => {
|
||||
console.log('webglcontextlost event received');
|
||||
this._logService.debug('webglcontextlost event received');
|
||||
// Prevent the default behavior in order to enable WebGL context restoration.
|
||||
e.preventDefault();
|
||||
// Wait a few seconds to see if the 'webglcontextrestored' event is fired.
|
||||
// If not, dispatch the onContextLoss notification to observers.
|
||||
this._contextRestorationTimeout = setTimeout(() => {
|
||||
this._contextRestorationTimeout = undefined;
|
||||
console.warn('webgl context not restored; firing onContextLoss');
|
||||
this._logService.warn('webgl context not restored; firing onContextLoss');
|
||||
this._onContextLoss.fire(e);
|
||||
}, 3000 /* ms */);
|
||||
}));
|
||||
this._register(addDisposableListener(this._canvas, 'webglcontextrestored', (e) => {
|
||||
console.warn('webglcontextrestored event received');
|
||||
this._logService.warn('webglcontextrestored event received');
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
// The texture atlas and glyph renderer must be fully reinitialized
|
||||
@@ -154,6 +159,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._isAttached = this._core.screenElement!.isConnected;
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
clearTimeout(this._contextRestorationTimeout);
|
||||
this._contextRestorationTimeout = undefined;
|
||||
for (const l of this._renderLayers) {
|
||||
l.dispose();
|
||||
}
|
||||
@@ -270,8 +277,8 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
* Initializes members dependent on WebGL context state.
|
||||
*/
|
||||
private _initializeWebGLState(): [RectangleRenderer, GlyphRenderer] {
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService);
|
||||
this._rectangleRenderer.value = new RectangleRenderer(this._terminal, this._gl, this.dimensions, this._themeService, this._logService);
|
||||
this._glyphRenderer.value = new GlyphRenderer(this._terminal, this._gl, this.dimensions, this._optionsService, this._logService);
|
||||
|
||||
// Update dimensions and acquire char atlas
|
||||
this.handleCharSizeChanged();
|
||||
@@ -375,6 +382,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 +513,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 +558,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 +573,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 +586,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
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
/**
|
||||
* A matrix that when multiplies will translate 0-1 coordinates (left to right,
|
||||
@@ -16,21 +17,21 @@ export const PROJECTION_MATRIX = new Float32Array([
|
||||
-1, 1, 0, 1
|
||||
]);
|
||||
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string): WebGLProgram | undefined {
|
||||
export function createProgram(gl: WebGLRenderingContext, vertexSource: string, fragmentSource: string, logService: ILogService): WebGLProgram | undefined {
|
||||
const program = throwIfFalsy(gl.createProgram());
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.VERTEX_SHADER, vertexSource, logService)));
|
||||
gl.attachShader(program, throwIfFalsy(createShader(gl, gl.FRAGMENT_SHADER, fragmentSource, logService)));
|
||||
gl.linkProgram(program);
|
||||
const success = gl.getProgramParameter(program, gl.LINK_STATUS);
|
||||
if (success) {
|
||||
return program;
|
||||
}
|
||||
|
||||
console.error(gl.getProgramInfoLog(program));
|
||||
logService.error(gl.getProgramInfoLog(program));
|
||||
gl.deleteProgram(program);
|
||||
}
|
||||
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string): WebGLShader | undefined {
|
||||
export function createShader(gl: WebGLRenderingContext, type: number, source: string, logService: ILogService): WebGLShader | undefined {
|
||||
const shader = throwIfFalsy(gl.createShader(type));
|
||||
gl.shaderSource(shader, source);
|
||||
gl.compileShader(shader);
|
||||
@@ -39,7 +40,7 @@ export function createShader(gl: WebGLRenderingContext, type: number, source: st
|
||||
return shader;
|
||||
}
|
||||
|
||||
console.error(gl.getShaderInfoLog(shader));
|
||||
logService.error(gl.getShaderInfoLog(shader));
|
||||
gl.deleteShader(shader);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
import { customGlyphDefinitions } from './CustomGlyphDefinitions';
|
||||
import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType, type CustomGlyphDefinitionPart, type CustomGlyphPathDrawFunctionDefinition, type CustomGlyphPatternDefinition, type ICustomGlyphSolidOctantBlockVector, type ICustomGlyphVectorShape } from './Types';
|
||||
|
||||
@@ -22,6 +23,7 @@ export function tryDrawCustomGlyph(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): boolean {
|
||||
@@ -30,7 +32,7 @@ export function tryDrawCustomGlyph(
|
||||
// Normalize to array for uniform handling
|
||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||
for (const part of parts) {
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, logService, backgroundColor, variantOffset);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -49,6 +51,7 @@ function drawDefinitionPart(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
@@ -79,7 +82,7 @@ function drawDefinitionPart(
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, logService, part.strokeWidth);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH:
|
||||
drawPathDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
@@ -88,7 +91,7 @@ function drawDefinitionPart(
|
||||
drawPathNegativeDefinitionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, backgroundColor);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.VECTOR_SHAPE:
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio);
|
||||
drawVectorShape(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, fontSize, devicePixelRatio, logService);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.BRAILLE:
|
||||
drawBrailleCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
@@ -510,6 +513,7 @@ function drawPathFunctionCharacter(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService,
|
||||
strokeWidth?: number
|
||||
): void {
|
||||
ctx.save();
|
||||
@@ -536,7 +540,7 @@ function drawPathFunctionCharacter(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
@@ -598,7 +602,8 @@ function drawVectorShape(
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number
|
||||
devicePixelRatio: number,
|
||||
logService: ILogService
|
||||
): void {
|
||||
// Clip the cell to make sure drawing doesn't occur beyond bounds
|
||||
const clipRegion = new Path2D();
|
||||
@@ -619,7 +624,7 @@ function drawVectorShape(
|
||||
}
|
||||
const f = svgToCanvasInstructionMap[type];
|
||||
if (!f) {
|
||||
console.error(`Could not find drawing instructions for "${type}"`);
|
||||
logService.error(`Could not find drawing instructions for "${type}"`);
|
||||
continue;
|
||||
}
|
||||
const args: string[] = instruction.substring(1).split(',');
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export default tseslint.config(
|
||||
singleline: { delimiter: 'comma', requireLast: false }
|
||||
}],
|
||||
'@stylistic/type-annotation-spacing': 'warn',
|
||||
'@stylistic/space-unary-ops': 'warn',
|
||||
|
||||
'@typescript-eslint/array-type': ['warn', { default: 'array', readonly: 'generic' }],
|
||||
'@typescript-eslint/consistent-type-assertions': 'warn',
|
||||
@@ -85,6 +86,7 @@ export default tseslint.config(
|
||||
'eqeqeq': ['warn', 'always'],
|
||||
'jsdoc/check-alignment': 'warn',
|
||||
'jsdoc/check-param-names': 'warn',
|
||||
'jsdoc/multiline-blocks': 'warn',
|
||||
'jsdoc/no-multi-asterisks': 'warn',
|
||||
'keyword-spacing': 'warn',
|
||||
'max-len': ['warn', {
|
||||
|
||||
Generated
+63
-735
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,8 +563,9 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
|
||||
try {
|
||||
this._onWillOpen.fire(this.element);
|
||||
} catch (e) {
|
||||
this._logService.error('onWillOpen handler threw an exception', e);
|
||||
}
|
||||
catch { /* fails to load addon for some reason */ }
|
||||
if (!this._renderService.hasRenderer()) {
|
||||
this._renderService.setRenderer(this._createRenderer());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -258,6 +258,15 @@ export class MockBuffer implements IBuffer {
|
||||
public getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine {
|
||||
return Buffer.prototype.getBlankLine.apply(this, arguments as any);
|
||||
}
|
||||
public attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
|
||||
return Buffer.prototype.attachWrappedRow.apply(this, arguments as any);
|
||||
}
|
||||
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void {
|
||||
Buffer.prototype.clearWrappedRow.apply(this, arguments as any);
|
||||
}
|
||||
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
|
||||
return Buffer.prototype.createScrollWrappedLine.apply(this, arguments as any);
|
||||
}
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
throw new Error('Method not implemented.');
|
||||
}
|
||||
|
||||
@@ -94,6 +94,12 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true)));
|
||||
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
|
||||
this._register(this._themeService.onChangeColors(() => this._queueRefresh()));
|
||||
this._register(toDisposable(() => {
|
||||
if (this._animationFrame !== undefined) {
|
||||
this._coreBrowserService.window.cancelAnimationFrame(this._animationFrame);
|
||||
this._animationFrame = undefined;
|
||||
}
|
||||
}));
|
||||
this._queueRefresh(true);
|
||||
}
|
||||
|
||||
@@ -136,6 +142,9 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _refreshCanvasDimensions(): void {
|
||||
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
|
||||
return;
|
||||
}
|
||||
const cssCanvasHeight = this._renderService.dimensions.css.canvas.height;
|
||||
const deviceCanvasHeight = this._renderService.dimensions.device.canvas.height;
|
||||
this._canvas.style.width = `${this._width}px`;
|
||||
@@ -147,6 +156,9 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _refreshDecorations(): void {
|
||||
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
|
||||
return;
|
||||
}
|
||||
if (this._shouldUpdateDimensions) {
|
||||
this._refreshCanvasDimensions();
|
||||
}
|
||||
@@ -200,13 +212,18 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}
|
||||
|
||||
private _queueRefresh(updateCanvasDimensions?: boolean, updateAnchor?: boolean): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._shouldUpdateDimensions = updateCanvasDimensions || this._shouldUpdateDimensions;
|
||||
this._shouldUpdateAnchor = updateAnchor || this._shouldUpdateAnchor;
|
||||
if (this._animationFrame !== undefined) {
|
||||
return;
|
||||
}
|
||||
this._animationFrame = this._coreBrowserService.window.requestAnimationFrame(() => {
|
||||
this._refreshDecorations();
|
||||
if (!this._store.isDisposed) {
|
||||
this._refreshDecorations();
|
||||
}
|
||||
this._animationFrame = undefined;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -68,7 +69,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
) {
|
||||
super();
|
||||
|
||||
this._pausedResizeTask = new DebouncedIdleTask(this._logService);
|
||||
this._pausedResizeTask = this._register(new DebouncedIdleTask(this._logService));
|
||||
|
||||
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
|
||||
this._register(this._renderDebouncer);
|
||||
@@ -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
|
||||
|
||||
@@ -9,7 +9,7 @@ import { moveToCellSequence } from 'browser/input/MoveToCell';
|
||||
import { SelectionModel } from 'browser/selection/SelectionModel';
|
||||
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
|
||||
import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
|
||||
import { getRangeLength } from 'common/buffer/BufferRange';
|
||||
@@ -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');
|
||||
@@ -103,7 +102,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
|
||||
private _mouseMoveListener: EventListener;
|
||||
private _mouseUpListener: EventListener;
|
||||
private _trimListener: IDisposable;
|
||||
private readonly _trimListener = this._register(new MutableDisposable<IDisposable>());
|
||||
private _workCell: CellData = new CellData();
|
||||
|
||||
private _mouseDownTimeStamp: number = 0;
|
||||
@@ -141,7 +140,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
this.clearSelection();
|
||||
}
|
||||
});
|
||||
this._trimListener = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = this._bufferService.buffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._register(this._bufferService.buffers.onBufferActivate(e => this._handleBufferActivate(e)));
|
||||
|
||||
this.enable();
|
||||
@@ -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,
|
||||
@@ -770,8 +769,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
// reverseIndex) and delete in a splice is only ever used when the same
|
||||
// number of elements was just added. Given this is could actually be
|
||||
// beneficial to leave the selection as is for these cases.
|
||||
this._trimListener.dispose();
|
||||
this._trimListener = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
this._trimListener.value = e.activeBuffer.lines.onTrim(amount => this._handleTrim(amount));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,6 +73,42 @@ export class TimeoutTimer implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedules a single runner on the microtask queue. Unlike {@link TimeoutTimer}, a scheduled
|
||||
* microtask cannot be unqueued; {@link cancel} prevents the runner from executing if it has not
|
||||
* run yet.
|
||||
*/
|
||||
export class MicrotaskTimer implements IDisposable {
|
||||
private _isScheduled = false;
|
||||
private _isDisposed = false;
|
||||
|
||||
public dispose(): void {
|
||||
this.cancel();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
this._isScheduled = false;
|
||||
}
|
||||
|
||||
public set(runner: () => void): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling set on a disposed MicrotaskTimer');
|
||||
}
|
||||
if (this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
if (!this._isScheduled) {
|
||||
return;
|
||||
}
|
||||
this._isScheduled = false;
|
||||
runner();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class IntervalTimer implements IDisposable {
|
||||
private _disposable: IDisposable | undefined;
|
||||
private _isDisposed = false;
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { clone } from 'common/Clone';
|
||||
|
||||
describe('clone', () => {
|
||||
it('should clone simple objects', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), { a: 1, b: 2 });
|
||||
});
|
||||
|
||||
it('should clone nested objects', () => {
|
||||
const test = {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
bar: {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone array values', () => {
|
||||
const test = {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
};
|
||||
|
||||
assert.deepEqual(clone(test), {
|
||||
a: [1, 2, 3],
|
||||
b: [1, null, 'test', { foo: 'bar' }]
|
||||
});
|
||||
});
|
||||
|
||||
it('should stop mutation from occuring on the original object', () => {
|
||||
const test = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a = 5;
|
||||
test.c.foo = 'barbaz';
|
||||
|
||||
assert.deepEqual(cloned, {
|
||||
a: 1,
|
||||
b: 2,
|
||||
c: {
|
||||
foo: 'bar'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should clone to a maximum depth of 5 by default', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {
|
||||
d: {
|
||||
e: {
|
||||
f: 'foo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test);
|
||||
|
||||
test.a.b.c.d.e.f = 'bar';
|
||||
|
||||
// The values at a greater depth then 5 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c.d.e.f, 'bar');
|
||||
});
|
||||
|
||||
it('should allow an optional maximum depth to be set', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: 'foo'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cloned = clone(test, 2);
|
||||
|
||||
test.a.b.c = 'bar';
|
||||
|
||||
// The values at a greater depth then 2 should not be cloned
|
||||
assert.equal((cloned as any).a.b.c, 'bar');
|
||||
});
|
||||
|
||||
it('should not throw when cloning a recursive reference', () => {
|
||||
const test = {
|
||||
a: {
|
||||
b: {
|
||||
c: {}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test.a.b.c = test;
|
||||
|
||||
assert.doesNotThrow(() => clone(test));
|
||||
});
|
||||
});
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2016 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/*
|
||||
* A simple utility for cloning values
|
||||
*/
|
||||
export function clone<T>(val: T, depth: number = 5): T {
|
||||
if (typeof val !== 'object') {
|
||||
return val;
|
||||
}
|
||||
|
||||
// If we're cloning an array, use an array as the base, otherwise use an object
|
||||
const clonedObject: any = Array.isArray(val) ? [] : {};
|
||||
|
||||
for (const key in val) {
|
||||
// Recursively clone eack item unless we're at the maximum depth
|
||||
clonedObject[key] = depth <= 1 ? val[key] : (val[key] && clone(val[key], depth - 1));
|
||||
}
|
||||
|
||||
return clonedObject as T;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -263,7 +263,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
private _handleWindowsPtyOptionChange(): void {
|
||||
let value = false;
|
||||
const windowsPty = this.optionsService.rawOptions.windowsPty;
|
||||
if (windowsPty && windowsPty.buildNumber !== undefined && windowsPty.buildNumber !== undefined) {
|
||||
if (windowsPty && windowsPty.backend !== undefined && windowsPty.buildNumber !== undefined) {
|
||||
value = !!(windowsPty.backend === 'conpty' && windowsPty.buildNumber < 21376);
|
||||
}
|
||||
if (value) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Params } from 'common/parser/Params';
|
||||
import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService, extendedAttributes } from 'common/TestUtils.test';
|
||||
import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { BufferService } from 'common/services/BufferService';
|
||||
import { CoreService } from 'common/services/CoreService';
|
||||
import { OscLinkService } from 'common/services/OscLinkService';
|
||||
@@ -227,32 +226,32 @@ describe('InputHandler', () => {
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, undefined);
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, undefined);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([1]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([2]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'block');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([3]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([4]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'underline');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([5]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, true);
|
||||
|
||||
optionsService.options = clone(DEFAULT_OPTIONS);
|
||||
optionsService.options = structuredClone(DEFAULT_OPTIONS);
|
||||
inputHandler.setCursorStyle(Params.fromArray([6]));
|
||||
assert.equal(coreService.decPrivateModes.cursorStyle, 'bar');
|
||||
assert.equal(coreService.decPrivateModes.cursorBlink, false);
|
||||
|
||||
+71
-72
@@ -11,6 +11,8 @@ import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { getWrappedRangeEnd } from 'common/buffer/BufferLineUtils';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
@@ -30,28 +32,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 +43,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 +88,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 +192,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 +393,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 +407,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 +445,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 +463,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 +475,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);
|
||||
@@ -585,8 +569,9 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (screenReaderMode) {
|
||||
this._onA11yChar.fire(stringFromCodePoint(code));
|
||||
}
|
||||
if (this._getCurrentLinkId()) {
|
||||
this._oscLinkService.addLineToLink(this._getCurrentLinkId(), this._activeBuffer.ybase + this._activeBuffer.y);
|
||||
const linkId = this._getCurrentLinkId();
|
||||
if (linkId) {
|
||||
this._oscLinkService.addLineToLink(linkId, this._activeBuffer.ybase + this._activeBuffer.y);
|
||||
}
|
||||
|
||||
// goto next line if ch would overflow
|
||||
@@ -607,8 +592,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (this._activeBuffer.y >= this._bufferService.rows) {
|
||||
this._activeBuffer.y = this._bufferService.rows - 1;
|
||||
}
|
||||
// The line already exists (eg. the initial viewport), mark it as a
|
||||
// wrapped line
|
||||
// The line already exists (eg. the initial viewport), mark it as a wrapped line
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = true;
|
||||
}
|
||||
// row changed, get it again
|
||||
@@ -729,8 +713,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));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -773,7 +757,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
// reprint is common, especially on resize. Note that the windowsMode wrapped line heuristics
|
||||
// can mess with this so windowsMode should be disabled, which is recommended on Windows build
|
||||
// 21376 and above.
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
|
||||
}
|
||||
// If the end of the line is hit, prevent this action from wrapping around to the next line.
|
||||
if (this._activeBuffer.x >= this._bufferService.cols) {
|
||||
@@ -837,7 +821,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
&& this._activeBuffer.y > this._activeBuffer.scrollTop
|
||||
&& this._activeBuffer.y <= this._activeBuffer.scrollBottom
|
||||
&& this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)?.isWrapped) {
|
||||
this._activeBuffer.lines.get(this._activeBuffer.ybase + this._activeBuffer.y)!.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + this._activeBuffer.y, false);
|
||||
this._activeBuffer.y--;
|
||||
this._activeBuffer.x = this._bufferService.cols - 1;
|
||||
// find last taken cell - last cell can have 3 different states:
|
||||
@@ -1201,21 +1185,43 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
respectProtect
|
||||
);
|
||||
if (clearWrap) {
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
}
|
||||
|
||||
private _unwrapBufferRow(y: number, preserveContent: boolean): void {
|
||||
const line = this._activeBuffer.lines.get(y);
|
||||
if (!line?.isWrapped) {
|
||||
return;
|
||||
}
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
if (preserveContent) {
|
||||
this._activeBuffer.lines.set(y, line.materializeToStandalone(this._activeBuffer.getNullCell(this._eraseAttrData())));
|
||||
} else {
|
||||
this._activeBuffer.clearWrappedRow(y, this._activeBuffer.getNullCell(this._eraseAttrData()), false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
line.isWrapped = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper method to reset cells in a terminal row. The cell gets replaced with the eraseChar of
|
||||
* the terminal and the isWrapped property is set to false.
|
||||
* @param y row index
|
||||
*/
|
||||
private _resetBufferLine(y: number, respectProtect: boolean = false): void {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y);
|
||||
const absY = this._activeBuffer.ybase + y;
|
||||
const nullCell = this._activeBuffer.getNullCell(this._eraseAttrData());
|
||||
const last = getWrappedRangeEnd(this._activeBuffer.lines, absY);
|
||||
for (let i = last; i > absY; i--) {
|
||||
this._activeBuffer.clearWrappedRow(i, nullCell);
|
||||
}
|
||||
const line = this._activeBuffer.lines.get(absY);
|
||||
if (line) {
|
||||
line.fill(this._activeBuffer.getNullCell(this._eraseAttrData()), respectProtect);
|
||||
this._bufferService.buffer.clearMarkers(this._activeBuffer.ybase + y);
|
||||
line.isWrapped = false;
|
||||
line.fill(nullCell, respectProtect);
|
||||
this._bufferService.buffer.clearMarkers(absY);
|
||||
this._activeBuffer.clearWrappedRow(absY, nullCell);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1263,10 +1269,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
this._eraseInBufferLine(j, 0, this._activeBuffer.x + 1, true, respectProtect);
|
||||
if (this._activeBuffer.x + 1 >= this._bufferService.cols) {
|
||||
// Deleted entire previous line. This next line can no longer be wrapped.
|
||||
const nextLine = this._activeBuffer.lines.get(j + 1);
|
||||
if (nextLine) {
|
||||
nextLine.isWrapped = false;
|
||||
}
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + j + 1, true);
|
||||
}
|
||||
while (j--) {
|
||||
this._resetBufferLine(j, respectProtect);
|
||||
@@ -1530,7 +1533,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.deleteCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1563,7 +1566,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.insertCells(0, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1586,7 +1589,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.insertCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -1609,7 +1612,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
for (let y = this._activeBuffer.scrollTop; y <= this._activeBuffer.scrollBottom; ++y) {
|
||||
const line = this._activeBuffer.lines.get(this._activeBuffer.ybase + y)!;
|
||||
line.deleteCells(this._activeBuffer.x, param, this._activeBuffer.getNullCell(this._eraseAttrData()));
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(this._activeBuffer.ybase + y, true);
|
||||
}
|
||||
this._dirtyRowTracker.markRangeDirty(this._activeBuffer.scrollTop, this._activeBuffer.scrollBottom);
|
||||
return true;
|
||||
@@ -2415,7 +2418,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;
|
||||
@@ -2608,10 +2611,6 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
* | 3 | CMY color. | #N |
|
||||
* | 4 | CMYK color. | #N |
|
||||
* | 5 | Indexed (256 colors) as `Ps ; 5 ; INDEX` or `Ps : 5 : INDEX`. | #Y |
|
||||
*
|
||||
*
|
||||
* FIXME: blinking is implemented in attrs, but not working in renderers?
|
||||
* FIXME: remove dead branch for p=100
|
||||
*/
|
||||
public charAttributes(params: IParams): boolean {
|
||||
// Optimize a single SGR0.
|
||||
@@ -2628,19 +2627,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 +2699,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 +2970,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();
|
||||
}
|
||||
}
|
||||
@@ -3497,7 +3496,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
const line = this._activeBuffer.lines.get(row);
|
||||
if (line) {
|
||||
line.fill(cell);
|
||||
line.isWrapped = false;
|
||||
this._unwrapBufferRow(row, true);
|
||||
}
|
||||
}
|
||||
this._dirtyRowTracker.markAllDirty();
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { LimitedStringBuilder, StringBuilder } from 'common/StringBuilder';
|
||||
|
||||
describe('StringBuilder', () => {
|
||||
it('should start empty', () => {
|
||||
const builder = new StringBuilder();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should append a single chunk', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'hello');
|
||||
});
|
||||
|
||||
it('should join multiple chunks in order', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('foo');
|
||||
builder.append('bar');
|
||||
builder.append('baz');
|
||||
assert.strictEqual(builder.length, 9);
|
||||
assert.strictEqual(builder.toString(), 'foobarbaz');
|
||||
});
|
||||
|
||||
it('should handle empty chunks', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('');
|
||||
builder.append('a');
|
||||
builder.append('');
|
||||
assert.strictEqual(builder.length, 1);
|
||||
assert.strictEqual(builder.toString(), 'a');
|
||||
});
|
||||
|
||||
it('should reset accumulated data', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('hello');
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending after reset', () => {
|
||||
const builder = new StringBuilder();
|
||||
builder.append('old');
|
||||
builder.reset();
|
||||
builder.append('new');
|
||||
assert.strictEqual(builder.toString(), 'new');
|
||||
});
|
||||
|
||||
it('should accumulate many small chunks without quadratic concatenation', () => {
|
||||
const builder = new StringBuilder();
|
||||
const chunk = 'x';
|
||||
const count = 10000;
|
||||
for (let i = 0; i < count; i++) {
|
||||
builder.append(chunk);
|
||||
}
|
||||
assert.strictEqual(builder.length, count);
|
||||
assert.strictEqual(builder.toString(), 'x'.repeat(count));
|
||||
});
|
||||
});
|
||||
|
||||
describe('LimitedStringBuilder', () => {
|
||||
it('should expose the configured limit', () => {
|
||||
const builder = new LimitedStringBuilder(42);
|
||||
assert.strictEqual(builder.limit, 42);
|
||||
});
|
||||
|
||||
it('should start empty', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should accept data up to the limit', () => {
|
||||
const builder = new LimitedStringBuilder(10);
|
||||
assert.strictEqual(builder.append('12345'), false);
|
||||
assert.strictEqual(builder.append('67890'), false);
|
||||
assert.strictEqual(builder.length, 10);
|
||||
assert.strictEqual(builder.toString(), '1234567890');
|
||||
});
|
||||
|
||||
it('should accept a single chunk exactly at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
assert.strictEqual(builder.append('abcde'), false);
|
||||
assert.strictEqual(builder.length, 5);
|
||||
assert.strictEqual(builder.toString(), 'abcde');
|
||||
});
|
||||
|
||||
it('should reject data exceeding the limit and clear the buffer', () => {
|
||||
const builder = new LimitedStringBuilder(5);
|
||||
builder.append('abc');
|
||||
assert.strictEqual(builder.append('def'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject a single chunk larger than the limit', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('toolong'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should allow appending again after reset following a limit breach', () => {
|
||||
const builder = new LimitedStringBuilder(3);
|
||||
assert.strictEqual(builder.append('abcd'), true);
|
||||
builder.reset();
|
||||
assert.strictEqual(builder.append('ab'), false);
|
||||
assert.strictEqual(builder.toString(), 'ab');
|
||||
});
|
||||
|
||||
it('should accumulate many chunks before hitting the limit', () => {
|
||||
const limit = 100;
|
||||
const builder = new LimitedStringBuilder(limit);
|
||||
const chunk = 'A';
|
||||
for (let i = 0; i < limit; i++) {
|
||||
assert.strictEqual(builder.append(chunk), false);
|
||||
}
|
||||
assert.strictEqual(builder.toString(), 'A'.repeat(limit));
|
||||
assert.strictEqual(builder.append('B'), true);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
|
||||
it('should reject when limit is zero and any data is appended', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append('a'), true);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
});
|
||||
|
||||
it('should allow zero-length appends at the limit', () => {
|
||||
const builder = new LimitedStringBuilder(0);
|
||||
assert.strictEqual(builder.append(''), false);
|
||||
assert.strictEqual(builder.length, 0);
|
||||
assert.strictEqual(builder.toString(), '');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/**
|
||||
* Accumulates string data from multiple chunks without O(n²) string concatenation.
|
||||
*/
|
||||
export class StringBuilder {
|
||||
private _chunks: string[] = [];
|
||||
private _length = 0;
|
||||
|
||||
public get length(): number {
|
||||
return this._length;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._chunks.length = 0;
|
||||
this._length = 0;
|
||||
}
|
||||
|
||||
public append(chunk: string): void {
|
||||
this._chunks.push(chunk);
|
||||
this._length += chunk.length;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._chunks.join('');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* String builder that rejects payloads larger than a fixed limit.
|
||||
*/
|
||||
export class LimitedStringBuilder {
|
||||
private readonly _builder = new StringBuilder();
|
||||
|
||||
constructor(private readonly _limit: number) { }
|
||||
|
||||
public get length(): number {
|
||||
return this._builder.length;
|
||||
}
|
||||
|
||||
public get limit(): number {
|
||||
return this._limit;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._builder.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns true if the limit was exceeded (buffer is cleared in that case)
|
||||
*/
|
||||
public append(chunk: string): boolean {
|
||||
this._builder.append(chunk);
|
||||
if (this._builder.length > this._limit) {
|
||||
this._builder.reset();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public toString(): string {
|
||||
return this._builder.toString();
|
||||
}
|
||||
}
|
||||
@@ -168,4 +168,8 @@ export class DebouncedIdleTask {
|
||||
public flush(): void {
|
||||
this._queue.flush();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._queue.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
import { clone } from 'common/Clone';
|
||||
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
|
||||
import { IBufferSet, IBuffer } from 'common/buffer/Types';
|
||||
import { BufferSet } from 'common/buffer/BufferSet';
|
||||
@@ -157,7 +156,7 @@ export class MockLogService implements ILogService {
|
||||
|
||||
export class MockOptionsService implements IOptionsService {
|
||||
public serviceBrand: any;
|
||||
public readonly rawOptions: Required<ITerminalOptions> = clone(DEFAULT_OPTIONS);
|
||||
public readonly rawOptions: Required<ITerminalOptions> = structuredClone(DEFAULT_OPTIONS);
|
||||
public options: Required<ITerminalOptions> = this.rawOptions;
|
||||
public onOptionChange: IEvent<keyof ITerminalOptions> = new Emitter<keyof ITerminalOptions>().event;
|
||||
constructor(testOptions?: Partial<ITerminalOptions>) {
|
||||
|
||||
+18
-18
@@ -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;
|
||||
@@ -528,8 +528,8 @@ export interface IInputHandler {
|
||||
/** CSI ' } */ insertColumns(params: IParams): boolean;
|
||||
/** CSI ' ~ */ deleteColumns(params: IParams): boolean;
|
||||
|
||||
/** OSC 0
|
||||
OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 0 */
|
||||
/** OSC 2 */ setTitle(data: string): boolean;
|
||||
/** OSC 4 */ setOrReportIndexedColor(data: string): boolean;
|
||||
/** OSC 10 */ setOrReportFgColor(data: string): boolean;
|
||||
/** OSC 11 */ setOrReportBgColor(data: string): boolean;
|
||||
@@ -542,24 +542,24 @@ export interface IInputHandler {
|
||||
/** ESC E */ nextLine(): boolean;
|
||||
/** ESC = */ keypadApplicationMode(): boolean;
|
||||
/** ESC > */ keypadNumericMode(): boolean;
|
||||
/** ESC % G
|
||||
ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C
|
||||
ESC ) C
|
||||
ESC * C
|
||||
ESC + C
|
||||
ESC - C
|
||||
ESC . C
|
||||
ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC % G */
|
||||
/** ESC % @ */ selectDefaultCharset(): boolean;
|
||||
/** ESC ( C */
|
||||
/** ESC ) C */
|
||||
/** ESC * C */
|
||||
/** ESC + C */
|
||||
/** ESC - C */
|
||||
/** ESC . C */
|
||||
/** ESC / C */ selectCharset(collectAndFlag: string): boolean;
|
||||
/** ESC D */ index(): boolean;
|
||||
/** ESC H */ tabSet(): boolean;
|
||||
/** ESC M */ reverseIndex(): boolean;
|
||||
/** ESC c */ fullReset(): boolean;
|
||||
/** ESC n
|
||||
ESC o
|
||||
ESC |
|
||||
ESC }
|
||||
ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC n */
|
||||
/** ESC o */
|
||||
/** ESC | */
|
||||
/** ESC } */
|
||||
/** ESC ~ */ setgLevel(level: number): boolean;
|
||||
/** ESC # 8 */ screenAlignmentPattern(): boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,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,13 @@
|
||||
*/
|
||||
|
||||
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 { attachWrappedRow, clearWrappedRow, createScrollWrappedLine } from 'common/buffer/BufferLineUtils';
|
||||
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 +28,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 +53,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 +61,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 +69,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 +101,19 @@ 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 attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine {
|
||||
return attachWrappedRow(this.lines, y, this._stringCache, this._cols, fillCellData);
|
||||
}
|
||||
|
||||
public clearWrappedRow(y: number, fillCellData: ICellData, preserveContent: boolean = true): void {
|
||||
clearWrappedRow(this.lines, y, fillCellData, this._stringCache, this._cols, preserveContent);
|
||||
}
|
||||
|
||||
public createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine {
|
||||
return createScrollWrappedLine(lineAbove, this._stringCache, this._cols, this.getNullCell(attr), attr);
|
||||
}
|
||||
|
||||
public get hasScrollback(): boolean {
|
||||
@@ -138,6 +158,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 +177,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 +212,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 +226,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 +367,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+369
-99
@@ -8,26 +8,28 @@ import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
|
||||
import { stringFromCodePoint } from 'common/input/TextDecoder';
|
||||
import { StringBuilder } from 'common/StringBuilder';
|
||||
|
||||
/**
|
||||
* buffer memory layout:
|
||||
*
|
||||
* | 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,
|
||||
@@ -40,9 +42,19 @@ export const DEFAULT_ATTR_DATA = Object.freeze(new AttributeData());
|
||||
// Work variables to avoid garbage collection
|
||||
let $startIndex = 0;
|
||||
const $workCell = new CellData();
|
||||
const $translateToStringBuilder = new StringBuilder();
|
||||
|
||||
/** 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,15 +75,107 @@ export class BufferLine implements IBufferLine {
|
||||
protected _data: Uint32Array;
|
||||
protected _combined: {[index: number]: string} = {};
|
||||
protected _extendedAttrs: {[index: number]: IExtendedAttrs | undefined} = {};
|
||||
protected _stringCacheEntryRef: WeakRef<IBufferLineStringCacheEntry> | undefined;
|
||||
/** When set, this row is a viewport slice of the logical line owned by this buffer line. */
|
||||
protected _logicalHead: BufferLine | undefined;
|
||||
protected _segmentStart: number = 0;
|
||||
protected _logicalCellCount: number = 0;
|
||||
protected _overflowRowCount: number = 0;
|
||||
protected _fillCellData: ICellData;
|
||||
public length: number;
|
||||
public isWrapped: boolean = false;
|
||||
|
||||
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,
|
||||
isWrapped: boolean = false,
|
||||
logicalHead?: BufferLine,
|
||||
segmentStart?: number
|
||||
) {
|
||||
const cell = fillCellData ?? CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
this._fillCellData = cell;
|
||||
if (logicalHead) {
|
||||
this._logicalHead = logicalHead;
|
||||
this._segmentStart = segmentStart ?? 0;
|
||||
this._data = logicalHead._data;
|
||||
this._combined = logicalHead._combined;
|
||||
this._extendedAttrs = logicalHead._extendedAttrs;
|
||||
this.length = cols;
|
||||
this.isWrapped = true;
|
||||
return;
|
||||
}
|
||||
this._data = new Uint32Array(cols * Constants.CELL_INDICIES);
|
||||
this._logicalCellCount = cols;
|
||||
for (let i = 0; i < cols; ++i) {
|
||||
this.setCell(i, cell);
|
||||
}
|
||||
this.length = cols;
|
||||
this.isWrapped = isWrapped;
|
||||
}
|
||||
|
||||
public get logicalCellCount(): number {
|
||||
return this._logicalHead ? this._logicalHead._logicalCellCount : this._logicalCellCount;
|
||||
}
|
||||
|
||||
public registerOverflowRow(): void {
|
||||
if (this._logicalHead) {
|
||||
this._logicalHead.registerOverflowRow();
|
||||
} else {
|
||||
this._overflowRowCount++;
|
||||
}
|
||||
}
|
||||
|
||||
public unregisterOverflowRow(): void {
|
||||
if (this._logicalHead) {
|
||||
this._logicalHead.unregisterOverflowRow();
|
||||
} else if (this._overflowRowCount > 0) {
|
||||
this._overflowRowCount--;
|
||||
}
|
||||
}
|
||||
|
||||
public clearOverflowRows(): void {
|
||||
if (!this._logicalHead) {
|
||||
this._overflowRowCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** After reflow merges wrapped rows into the head, reset overflow tracking. */
|
||||
public clearReflowMergedState(): void {
|
||||
if (!this._logicalHead) {
|
||||
this._overflowRowCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public ensureLogicalCapacity(minCells: number, fillCellData: ICellData): void {
|
||||
const head = this._logicalHead ?? this;
|
||||
if (minCells <= head._logicalCellCount) {
|
||||
return;
|
||||
}
|
||||
head._invalidateStringCache();
|
||||
const uint32Cells = minCells * Constants.CELL_INDICIES;
|
||||
if (head._data.buffer.byteLength >= uint32Cells * 4) {
|
||||
head._data = new Uint32Array(head._data.buffer, 0, uint32Cells);
|
||||
} else {
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(head._data);
|
||||
head._data = data;
|
||||
}
|
||||
for (let i = head._logicalCellCount; i < minCells; ++i) {
|
||||
const start = i * Constants.CELL_INDICIES;
|
||||
head._data[start + Cell.CONTENT] = fillCellData.content;
|
||||
head._data[start + Cell.FG] = fillCellData.fg;
|
||||
head._data[start + Cell.BG] = fillCellData.bg;
|
||||
}
|
||||
head._logicalCellCount = minCells;
|
||||
}
|
||||
|
||||
protected _absoluteColumn(column: number): number {
|
||||
return this._segmentStart + column;
|
||||
}
|
||||
|
||||
protected _cellStartIndex(column: number): number {
|
||||
return this._absoluteColumn(column) * Constants.CELL_INDICIES;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,16 +183,18 @@ export class BufferLine implements IBufferLine {
|
||||
* @deprecated
|
||||
*/
|
||||
public get(index: number): CharData {
|
||||
const content = this._data[index * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
const content = this._data[start + Cell.CONTENT];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
return [
|
||||
this._data[index * CELL_SIZE + Cell.FG],
|
||||
this._data[start + Cell.FG],
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? this._combined[index]
|
||||
? this._combined[abs]
|
||||
: (cp) ? stringFromCodePoint(cp) : '',
|
||||
content >> Content.WIDTH_SHIFT,
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? this._combined[index].charCodeAt(this._combined[index].length - 1)
|
||||
? this._combined[abs].charCodeAt(this._combined[abs].length - 1)
|
||||
: cp
|
||||
];
|
||||
}
|
||||
@@ -98,12 +204,15 @@ 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();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
this._data[start + 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._combined[abs] = value[1];
|
||||
this._data[start + Cell.CONTENT] = abs | 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[start + Cell.CONTENT] = value[CHAR_DATA_CHAR_INDEX].charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,22 +221,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[this._cellStartIndex(index) + 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[this._cellStartIndex(index) + 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[this._cellStartIndex(index) + Cell.FG];
|
||||
}
|
||||
|
||||
/** Get BG cell component. */
|
||||
public getBg(index: number): number {
|
||||
return this._data[index * CELL_SIZE + Cell.BG];
|
||||
return this._data[this._cellStartIndex(index) + Cell.BG];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -136,7 +245,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[this._cellStartIndex(index) + Cell.CONTENT] & Content.HAS_CONTENT_MASK;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,23 +254,25 @@ 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 abs = this._absoluteColumn(index);
|
||||
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index].charCodeAt(this._combined[index].length - 1);
|
||||
return this._combined[abs].charCodeAt(this._combined[abs].length - 1);
|
||||
}
|
||||
return content & Content.CODEPOINT_MASK;
|
||||
}
|
||||
|
||||
/** 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[this._cellStartIndex(index) + 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 abs = this._absoluteColumn(index);
|
||||
const content = this._data[this._cellStartIndex(index) + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._combined[index];
|
||||
return this._combined[abs];
|
||||
}
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
|
||||
@@ -172,7 +283,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[this._cellStartIndex(index) + Cell.BG] & BgFlags.PROTECTED;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -180,15 +291,16 @@ 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;
|
||||
const abs = this._absoluteColumn(index);
|
||||
$startIndex = this._cellStartIndex(index);
|
||||
cell.content = this._data[$startIndex + Cell.CONTENT];
|
||||
cell.fg = this._data[$startIndex + Cell.FG];
|
||||
cell.bg = this._data[$startIndex + Cell.BG];
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
cell.combinedData = this._combined[index];
|
||||
cell.combinedData = this._combined[abs];
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
cell.extended = this._extendedAttrs[index]!;
|
||||
cell.extended = this._extendedAttrs[abs]!;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
@@ -197,15 +309,18 @@ export class BufferLine implements IBufferLine {
|
||||
* Set data at `index` to `cell`.
|
||||
*/
|
||||
public setCell(index: number, cell: ICellData): void {
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._combined[index] = cell.combinedData;
|
||||
this._combined[abs] = cell.combinedData;
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = cell.extended;
|
||||
this._extendedAttrs[abs] = 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[start + Cell.CONTENT] = cell.content;
|
||||
this._data[start + Cell.FG] = cell.fg;
|
||||
this._data[start + Cell.BG] = cell.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,12 +329,15 @@ export class BufferLine implements IBufferLine {
|
||||
* it gets an optimized access method.
|
||||
*/
|
||||
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
|
||||
this._invalidateStringCache();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
if (attrs.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[index] = attrs.extended;
|
||||
this._extendedAttrs[abs] = 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[start + Cell.CONTENT] = codePoint | (width << Content.WIDTH_SHIFT);
|
||||
this._data[start + Cell.FG] = attrs.fg;
|
||||
this._data[start + Cell.BG] = attrs.bg;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -229,16 +347,19 @@ 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();
|
||||
const abs = this._absoluteColumn(index);
|
||||
const start = this._cellStartIndex(index);
|
||||
let content = this._data[start + Cell.CONTENT];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
// we already have a combined string, simply add
|
||||
this._combined[index] += stringFromCodePoint(codePoint);
|
||||
this._combined[abs] += stringFromCodePoint(codePoint);
|
||||
} else {
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
// normal case for combining chars:
|
||||
// - move current leading char + new one into combined string
|
||||
// - set combined flag
|
||||
this._combined[index] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
|
||||
this._combined[abs] = stringFromCodePoint(content & Content.CODEPOINT_MASK) + stringFromCodePoint(codePoint);
|
||||
content &= ~Content.CODEPOINT_MASK; // set codepoint in buffer to 0
|
||||
content |= Content.IS_COMBINED_MASK;
|
||||
} else {
|
||||
@@ -251,10 +372,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[start + 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 +404,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 +431,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,23 +468,32 @@ 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 {
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
const uint32Cells = cols * CELL_SIZE;
|
||||
if (cols > this.length) {
|
||||
if (this._data.buffer.byteLength >= uint32Cells * 4) {
|
||||
// optimization: avoid alloc and data copy if buffer has enough room
|
||||
this._data = new Uint32Array(this._data.buffer, 0, uint32Cells);
|
||||
} else {
|
||||
// slow path: new alloc and full data copy
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
this._invalidateStringCache();
|
||||
if (this._logicalHead) {
|
||||
if (cols > this.length) {
|
||||
this._logicalHead.ensureLogicalCapacity(this._segmentStart + cols, fillCellData);
|
||||
}
|
||||
this.length = cols;
|
||||
return false;
|
||||
}
|
||||
if (this._overflowRowCount > 0) {
|
||||
if (cols > this.length) {
|
||||
this.ensureLogicalCapacity(Math.max(this._logicalCellCount, this._segmentStart + cols), fillCellData);
|
||||
}
|
||||
this.length = cols;
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
if (cols === this.length) {
|
||||
return this._data.length * 4 * Constants.CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
}
|
||||
const uint32Cells = cols * Constants.CELL_INDICIES;
|
||||
if (cols > this.length) {
|
||||
const data = new Uint32Array(uint32Cells);
|
||||
data.set(this._data.subarray(0, this.length * Constants.CELL_INDICIES));
|
||||
this._data = data;
|
||||
for (let i = this.length; i < cols; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
@@ -385,20 +518,23 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
}
|
||||
this.length = cols;
|
||||
return uint32Cells * 4 * CLEANUP_THRESHOLD < this._data.buffer.byteLength;
|
||||
this._logicalCellCount = cols;
|
||||
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) {
|
||||
const data = new Uint32Array(this._data.length);
|
||||
data.set(this._data);
|
||||
this._data = data;
|
||||
const head = this._logicalHead ?? this;
|
||||
const usedCells = head._logicalCellCount * Constants.CELL_INDICIES;
|
||||
if (usedCells * 4 * Constants.CLEANUP_THRESHOLD < head._data.buffer.byteLength) {
|
||||
const data = new Uint32Array(usedCells);
|
||||
data.set(head._data.subarray(0, usedCells));
|
||||
head._data = data;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
@@ -406,6 +542,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) {
|
||||
@@ -415,8 +552,10 @@ export class BufferLine implements IBufferLine {
|
||||
}
|
||||
return;
|
||||
}
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
if (!this._logicalHead) {
|
||||
this._combined = {};
|
||||
this._extendedAttrs = {};
|
||||
}
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
@@ -424,29 +563,72 @@ export class BufferLine implements IBufferLine {
|
||||
|
||||
/** alter to a full copy of line */
|
||||
public copyFrom(line: BufferLine): void {
|
||||
if (this._logicalHead) {
|
||||
this.materializeFromLogicalHead(this._fillCellData);
|
||||
}
|
||||
this._invalidateStringCache();
|
||||
this._logicalHead = undefined;
|
||||
this._segmentStart = 0;
|
||||
this._overflowRowCount = 0;
|
||||
if (line._logicalHead) {
|
||||
const standalone = new BufferLine(this._stringCache, line.length, this._fillCellData, false);
|
||||
standalone.copyCellsFrom(line, 0, 0, line.length, false);
|
||||
line = standalone;
|
||||
}
|
||||
if (this.length !== line.length) {
|
||||
this._data = new Uint32Array(line._data);
|
||||
this._data = new Uint32Array(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
|
||||
} else {
|
||||
// use high speed copy if lengths are equal
|
||||
this._data.set(line._data);
|
||||
this._data.set(line._data.subarray(0, line.length * Constants.CELL_INDICIES));
|
||||
}
|
||||
this.length = line.length;
|
||||
this._logicalCellCount = line._logicalCellCount || line.length;
|
||||
this._combined = {};
|
||||
for (const el in line._combined) {
|
||||
this._combined[el] = line._combined[el];
|
||||
const key = parseInt(el, 10);
|
||||
if (key < this._logicalCellCount) {
|
||||
this._combined[key] = line._combined[el];
|
||||
}
|
||||
}
|
||||
this._extendedAttrs = {};
|
||||
for (const el in line._extendedAttrs) {
|
||||
this._extendedAttrs[el] = line._extendedAttrs[el];
|
||||
const key = parseInt(el, 10);
|
||||
if (key < this._logicalCellCount) {
|
||||
this._extendedAttrs[key] = line._extendedAttrs[el];
|
||||
}
|
||||
}
|
||||
this.isWrapped = line.isWrapped;
|
||||
}
|
||||
|
||||
public materializeFromLogicalHead(fillCellData: ICellData): BufferLine {
|
||||
if (!this._logicalHead) {
|
||||
return this;
|
||||
}
|
||||
const standalone = new BufferLine(this._stringCache, this.length, fillCellData, false);
|
||||
standalone.copyCellsFrom(this._logicalHead, this._segmentStart, 0, this.length, false);
|
||||
this._logicalHead.unregisterOverflowRow();
|
||||
this._data = standalone._data;
|
||||
this._combined = standalone._combined;
|
||||
this._extendedAttrs = standalone._extendedAttrs;
|
||||
this._logicalHead = undefined;
|
||||
this._segmentStart = 0;
|
||||
this._logicalCellCount = this.length;
|
||||
this.isWrapped = false;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** create a new clone */
|
||||
public clone(): IBufferLine {
|
||||
const newLine = new BufferLine(0);
|
||||
newLine._data = new Uint32Array(this._data);
|
||||
if (this._logicalHead) {
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, true, this._logicalHead, this._segmentStart);
|
||||
newLine.length = this.length;
|
||||
return newLine;
|
||||
}
|
||||
const newLine = new BufferLine(this._stringCache, 0, undefined, false);
|
||||
newLine._data = new Uint32Array(this._data.subarray(0, this._logicalCellCount * Constants.CELL_INDICIES));
|
||||
newLine.length = this.length;
|
||||
newLine._logicalCellCount = this._logicalCellCount;
|
||||
newLine._overflowRowCount = this._overflowRowCount;
|
||||
for (const el in this._combined) {
|
||||
newLine._combined[el] = this._combined[el];
|
||||
}
|
||||
@@ -459,8 +641,34 @@ 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);
|
||||
const start = this._cellStartIndex(i);
|
||||
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Creates a standalone line containing the full logical line content. */
|
||||
public toStandaloneLogicalLine(cols: number, fillCellData: ICellData, stringCache: IBufferLineStringCache): BufferLine {
|
||||
const head = this._logicalHead ?? this;
|
||||
const trimmed = head.getLogicalTrimmedLength();
|
||||
const flat = new BufferLine(stringCache, cols, fillCellData, false);
|
||||
if (trimmed > 0) {
|
||||
flat.copyCellsFrom(head, 0, 0, trimmed, false);
|
||||
flat._logicalCellCount = trimmed;
|
||||
}
|
||||
head.clearReflowMergedState();
|
||||
return flat;
|
||||
}
|
||||
|
||||
/** Trimmed length across the entire logical line (all segments). */
|
||||
public getLogicalTrimmedLength(): number {
|
||||
const head = this._logicalHead ?? this;
|
||||
for (let i = head._logicalCellCount - 1; i >= 0; --i) {
|
||||
const start = i * Constants.CELL_INDICIES;
|
||||
if ((head._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (head._data[start + Cell.CONTENT] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
@@ -468,31 +676,43 @@ 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);
|
||||
const start = this._cellStartIndex(i);
|
||||
if ((this._data[start + Cell.CONTENT] & Content.HAS_CONTENT_MASK) || (this._data[start + Cell.BG] & Attributes.CM_MASK)) {
|
||||
return i + (this._data[start + 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;
|
||||
const srcOffset = src._segmentStart;
|
||||
const destOffset = this._segmentStart;
|
||||
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];
|
||||
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
|
||||
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[destStart + i] = srcData[srcStart + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const absSrc = srcOffset + srcCol + cell;
|
||||
const absDest = destOffset + destCol + cell;
|
||||
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
|
||||
}
|
||||
}
|
||||
} 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];
|
||||
const srcStart = (srcOffset + srcCol + cell) * Constants.CELL_INDICIES;
|
||||
const destStart = (destOffset + destCol + cell) * Constants.CELL_INDICIES;
|
||||
for (let i = 0; i < Constants.CELL_INDICIES; i++) {
|
||||
this._data[destStart + i] = srcData[srcStart + i];
|
||||
}
|
||||
if (srcData[(srcCol + cell) * CELL_SIZE + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
this._extendedAttrs[destCol + cell] = src._extendedAttrs[srcCol + cell];
|
||||
if (srcData[srcStart + Cell.BG] & BgFlags.HAS_EXTENDED) {
|
||||
const absSrc = srcOffset + srcCol + cell;
|
||||
const absDest = destOffset + destCol + cell;
|
||||
this._extendedAttrs[absDest] = src._extendedAttrs[absSrc];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -501,14 +721,16 @@ export class BufferLine implements IBufferLine {
|
||||
const srcCombinedKeys = Object.keys(src._combined);
|
||||
for (let i = 0; i < srcCombinedKeys.length; i++) {
|
||||
const key = parseInt(srcCombinedKeys[i], 10);
|
||||
if (key >= srcCol) {
|
||||
this._combined[key - srcCol + destCol] = src._combined[key];
|
||||
const srcAbsCol = srcOffset + srcCol;
|
||||
if (key >= srcAbsCol && key < srcAbsCol + length) {
|
||||
this._combined[key - srcCol + destOffset + destCol] = src._combined[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 +743,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) {
|
||||
@@ -529,12 +764,16 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.length = 0;
|
||||
}
|
||||
let result = '';
|
||||
$translateToStringBuilder.reset();
|
||||
while (startCol < endCol) {
|
||||
const content = this._data[startCol * CELL_SIZE + Cell.CONTENT];
|
||||
const abs = this._absoluteColumn(startCol);
|
||||
const start = this._cellStartIndex(startCol);
|
||||
const content = this._data[start + 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;
|
||||
const chars = (content & Content.IS_COMBINED_MASK)
|
||||
? (this._combined[abs] ?? '')
|
||||
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
$translateToStringBuilder.append(chars);
|
||||
if (outColumns) {
|
||||
for (let i = 0; i < chars.length; ++i) {
|
||||
outColumns.push(startCol);
|
||||
@@ -545,6 +784,37 @@ export class BufferLine implements IBufferLine {
|
||||
if (outColumns) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
const result = $translateToStringBuilder.toString();
|
||||
$translateToStringBuilder.reset();
|
||||
if (isCanonicalRequest) {
|
||||
const cacheEntry = this._getStringCacheEntry(true)!;
|
||||
cacheEntry.value = result;
|
||||
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;
|
||||
}
|
||||
|
||||
protected _invalidateStringCache(): void {
|
||||
const head = this._logicalHead ?? this;
|
||||
const cacheEntry = head._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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { IAttributeData, IBufferLine, ICellData } from 'common/Types';
|
||||
import { IBufferLineStringCache } from 'common/buffer/BufferLine';
|
||||
|
||||
export function getLogicalHead(line: IBufferLine): BufferLine {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
return line.head;
|
||||
}
|
||||
return line as BufferLine;
|
||||
}
|
||||
|
||||
export function isBufferOverflowLine(line: IBufferLine): line is BufferOverflowLine {
|
||||
return line instanceof BufferOverflowLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the logical cell offset for the start of a buffer row.
|
||||
*/
|
||||
export function getSegmentStart(line: IBufferLine): number {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
return line.segmentStart;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks the row at `y` as a wrapped continuation of the logical line above.
|
||||
* Extends the logical line storage and replaces the row with a {@link BufferOverflowLine}.
|
||||
*/
|
||||
export function attachWrappedRow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
|
||||
y: number,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData
|
||||
): IBufferLine {
|
||||
const headY = y > 0 ? getWrappedRangeStart(lines, y - 1) : y;
|
||||
const head = getLogicalHead(lines.get(headY)!);
|
||||
const segmentStart = head.logicalCellCount;
|
||||
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
|
||||
|
||||
const existingLine = lines.get(y);
|
||||
if (existingLine && !(existingLine instanceof BufferOverflowLine) && existingLine.getTrimmedLength() > 0) {
|
||||
head.copyCellsFrom(existingLine as BufferLine, 0, segmentStart, existingLine.length, false);
|
||||
}
|
||||
|
||||
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
|
||||
lines.set(y, overflowLine);
|
||||
head.registerOverflowRow();
|
||||
return overflowLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the wrapped state for a row, detaching overflow rows from their logical line.
|
||||
*/
|
||||
export function clearWrappedRow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void },
|
||||
y: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache?: IBufferLineStringCache,
|
||||
cols?: number,
|
||||
preserveContent: boolean = true
|
||||
): void {
|
||||
const line = lines.get(y);
|
||||
if (!line) {
|
||||
return;
|
||||
}
|
||||
if (line.isWrapped) {
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
if (preserveContent) {
|
||||
lines.set(y, line.materializeToStandalone(fillCellData));
|
||||
} else if (stringCache !== undefined && cols !== undefined) {
|
||||
line.head.unregisterOverflowRow();
|
||||
lines.set(y, new BufferLine(stringCache, cols, fillCellData, false));
|
||||
} else {
|
||||
lines.set(y, line.materializeToStandalone(fillCellData));
|
||||
}
|
||||
} else if (line instanceof BufferLine) {
|
||||
(line as BufferLine).materializeFromLogicalHead(fillCellData);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const head = line as BufferLine;
|
||||
head.clearOverflowRows();
|
||||
}
|
||||
|
||||
export function getWrappedRangeStart(lines: { get(index: number): IBufferLine | undefined }, y: number): number {
|
||||
let first = y;
|
||||
while (first > 0 && lines.get(first)?.isWrapped) {
|
||||
first--;
|
||||
}
|
||||
return first;
|
||||
}
|
||||
|
||||
export function getWrappedRangeEnd(lines: { get(index: number): IBufferLine | undefined; length: number }, y: number): number {
|
||||
let last = y;
|
||||
while (last + 1 < lines.length && lines.get(last + 1)?.isWrapped) {
|
||||
last++;
|
||||
}
|
||||
return last;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies a logical line group into a single standalone buffer row and removes overflow rows.
|
||||
* Used before reflow so existing reflow algorithms can operate on independent lines.
|
||||
*/
|
||||
export function flattenLogicalGroup(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
|
||||
headY: number,
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache: IBufferLineStringCache
|
||||
): void {
|
||||
const last = getWrappedRangeEnd(lines, headY);
|
||||
if (last <= headY) {
|
||||
return;
|
||||
}
|
||||
const head = getLogicalHead(lines.get(headY)!);
|
||||
lines.set(headY, head.toStandaloneLogicalLine(cols, fillCellData, stringCache));
|
||||
for (let i = headY + 1; i <= last; i++) {
|
||||
const line = lines.get(i);
|
||||
if (line instanceof BufferOverflowLine) {
|
||||
const standalone = line.materializeToStandalone(fillCellData);
|
||||
standalone.isWrapped = true;
|
||||
lines.set(i, standalone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function materializeLogicalLinesForReflow(
|
||||
lines: { get(index: number): IBufferLine | undefined; set(index: number, line: IBufferLine): void; length: number },
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
stringCache: IBufferLineStringCache
|
||||
): void {
|
||||
let y = 0;
|
||||
while (y < lines.length) {
|
||||
const line = lines.get(y);
|
||||
const next = lines.get(y + 1);
|
||||
if (line && next instanceof BufferOverflowLine && next.head === getLogicalHead(line)) {
|
||||
flattenLogicalGroup(lines, y, cols, fillCellData, stringCache);
|
||||
y++;
|
||||
continue;
|
||||
}
|
||||
y++;
|
||||
}
|
||||
}
|
||||
|
||||
export function createScrollWrappedLine(
|
||||
lineAbove: IBufferLine | undefined,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData,
|
||||
attr: IAttributeData
|
||||
): IBufferLine {
|
||||
if (!lineAbove) {
|
||||
return new BufferLine(stringCache, cols, fillCellData, true);
|
||||
}
|
||||
const head = getLogicalHead(lineAbove);
|
||||
const segmentStart = head.logicalCellCount;
|
||||
head.ensureLogicalCapacity(segmentStart + cols, fillCellData);
|
||||
const overflowLine = new BufferOverflowLine(head, segmentStart, stringCache, cols, fillCellData);
|
||||
head.registerOverflowRow();
|
||||
return overflowLine;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { BufferLineStringCache } from 'common/buffer/BufferLineStringCache';
|
||||
import { attachWrappedRow } from 'common/buffer/BufferLineUtils';
|
||||
import { BufferOverflowLine } from 'common/buffer/BufferOverflowLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
import { IBufferLine } from 'common/Types';
|
||||
|
||||
const TEST_STRING_CACHE = new BufferLineStringCache();
|
||||
const NULL_CELL = CellData.fromCharData([0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
|
||||
|
||||
class LineList {
|
||||
private _lines: IBufferLine[] = [];
|
||||
public get(index: number): IBufferLine | undefined { return this._lines[index]; }
|
||||
public set(index: number, line: IBufferLine): void { this._lines[index] = line; }
|
||||
public get length(): number { return this._lines.length; }
|
||||
}
|
||||
|
||||
describe('BufferLogicalLine', () => {
|
||||
it('should share storage across wrapped rows', () => {
|
||||
const cols = 5;
|
||||
const lines = new LineList();
|
||||
const head = new BufferLine(TEST_STRING_CACHE, cols, NULL_CELL, false);
|
||||
lines.set(0, head);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
head.setCellFromCodepoint(i, 'a'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
attachWrappedRow(lines, 1, TEST_STRING_CACHE, cols, NULL_CELL);
|
||||
const overflow = lines.get(1)!;
|
||||
assert.instanceOf(overflow, BufferOverflowLine);
|
||||
for (let i = 0; i < cols; i++) {
|
||||
(overflow as BufferLine).setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
assert.equal(head.translateToString(true), 'abcde');
|
||||
assert.equal(overflow.translateToString(true), 'fghij');
|
||||
assert.equal(head.getLogicalTrimmedLength(), 10);
|
||||
});
|
||||
|
||||
it('should reflow unwrap without copying when logical content fits', () => {
|
||||
const lines = new LineList();
|
||||
const head = new BufferLine(TEST_STRING_CACHE, 5, NULL_CELL, false);
|
||||
lines.set(0, head);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
head.setCellFromCodepoint(i, 'f'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
attachWrappedRow(lines, 1, TEST_STRING_CACHE, 2, NULL_CELL);
|
||||
const overflow = lines.get(1)! as BufferOverflowLine;
|
||||
for (let i = 0; i < 2; i++) {
|
||||
overflow.setCellFromCodepoint(i, 'k'.charCodeAt(0) + i, 1, NULL_CELL);
|
||||
}
|
||||
assert.equal(head.getLogicalTrimmedLength(), 7);
|
||||
assert.equal(overflow.translateToString(true), 'kl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ICellData } from 'common/Types';
|
||||
import { BufferLine, IBufferLineStringCache } from 'common/buffer/BufferLine';
|
||||
|
||||
/**
|
||||
* A viewport row that is a view into a slice of a parent {@link BufferLine}'s logical storage.
|
||||
*/
|
||||
export class BufferOverflowLine extends BufferLine {
|
||||
public readonly head: BufferLine;
|
||||
public readonly segmentStart: number;
|
||||
|
||||
constructor(
|
||||
head: BufferLine,
|
||||
segmentStart: number,
|
||||
stringCache: IBufferLineStringCache,
|
||||
cols: number,
|
||||
fillCellData: ICellData
|
||||
) {
|
||||
super(stringCache, cols, fillCellData, true, head, segmentStart);
|
||||
this.head = head;
|
||||
this.segmentStart = segmentStart;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copies this row's segment into a standalone {@link BufferLine}.
|
||||
*/
|
||||
public materializeToStandalone(fillCellData: ICellData): BufferLine {
|
||||
return this.materializeFromLogicalHead(fillCellData);
|
||||
}
|
||||
}
|
||||
@@ -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)]);
|
||||
|
||||
@@ -178,7 +178,10 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
|
||||
*/
|
||||
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
|
||||
const newLineLengths: number[] = [];
|
||||
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
|
||||
let cellsNeeded = 0;
|
||||
for (let i = 0; i < wrappedLines.length; i++) {
|
||||
cellsNeeded += getWrappedLineTrimmedLength(wrappedLines, i, oldCols);
|
||||
}
|
||||
|
||||
// Use srcCol and srcLine to find the new wrapping point, use that to get the cellsAvailable and
|
||||
// linesNeeded
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -34,6 +34,9 @@ export interface IBuffer {
|
||||
nextStop(x?: number): number;
|
||||
prevStop(x?: number): number;
|
||||
getBlankLine(attr: IAttributeData, isWrapped?: boolean): IBufferLine;
|
||||
attachWrappedRow(y: number, fillCellData: ICellData): IBufferLine;
|
||||
clearWrappedRow(y: number, fillCellData: ICellData, preserveContent?: boolean): void;
|
||||
createScrollWrappedLine(lineAbove: IBufferLine | undefined, attr: IAttributeData): IBufferLine;
|
||||
getNullCell(attr?: IAttributeData): ICellData;
|
||||
getWhitespaceCell(attr?: IAttributeData): ICellData;
|
||||
addMarker(y: number): IMarker;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -125,5 +125,86 @@ describe('WriteBuffer', () => {
|
||||
assert.deepEqual(stack, []);
|
||||
assert.deepEqual(cbStack, []);
|
||||
});
|
||||
it('flushSync fires onWriteParsed', () => {
|
||||
let parsed = 0;
|
||||
wb.onWriteParsed(() => parsed++);
|
||||
wb.write('a');
|
||||
wb.write('b');
|
||||
assert.equal(parsed, 0);
|
||||
wb.flushSync();
|
||||
assert.equal(parsed, 1);
|
||||
});
|
||||
it('flushSync with no pending writes does not fire onWriteParsed', () => {
|
||||
let parsed = 0;
|
||||
wb.onWriteParsed(() => parsed++);
|
||||
wb.flushSync();
|
||||
assert.equal(parsed, 0);
|
||||
});
|
||||
it('dispose cancels scheduled innerWrite', done => {
|
||||
wb.write('a');
|
||||
wb.dispose();
|
||||
setTimeout(() => {
|
||||
assert.deepEqual(stack, []);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('dispose does not fire onWriteParsed for pending writes', done => {
|
||||
let parsed = 0;
|
||||
wb.onWriteParsed(() => parsed++);
|
||||
wb.write('a');
|
||||
wb.dispose();
|
||||
setTimeout(() => {
|
||||
assert.equal(parsed, 0);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('write after dispose is a no-op', done => {
|
||||
wb.dispose();
|
||||
wb.write('a');
|
||||
setTimeout(() => {
|
||||
assert.deepEqual(stack, []);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('dispose is idempotent', done => {
|
||||
wb.write('a');
|
||||
wb.dispose();
|
||||
wb.dispose();
|
||||
setTimeout(() => {
|
||||
assert.deepEqual(stack, []);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('async handler continuation is skipped after dispose', done => {
|
||||
let resolve!: (value: boolean) => void;
|
||||
const pending = new Promise<boolean>(r => { resolve = r; });
|
||||
wb = new WriteBuffer(() => pending);
|
||||
wb.write('a');
|
||||
wb.dispose();
|
||||
resolve(true);
|
||||
setTimeout(() => {
|
||||
assert.deepEqual(stack, []);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('handleUserInput still processes first chunk synchronously', () => {
|
||||
wb.handleUserInput();
|
||||
wb.write('a');
|
||||
assert.deepEqual(stack, ['a']);
|
||||
});
|
||||
it('flushSync after dispose is a no-op', done => {
|
||||
wb.write('a');
|
||||
wb.dispose();
|
||||
wb.flushSync();
|
||||
setTimeout(() => {
|
||||
assert.deepEqual(stack, []);
|
||||
done();
|
||||
}, 20);
|
||||
});
|
||||
it('writeSync after dispose is a no-op', () => {
|
||||
wb.dispose();
|
||||
wb.writeSync('a');
|
||||
assert.deepEqual(stack, []);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,35 +4,34 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { TimeoutTimer } from 'common/Async';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
declare const setTimeout: (handler: () => void, timeout?: number) => void;
|
||||
|
||||
/**
|
||||
* 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)[] = [];
|
||||
@@ -43,11 +42,18 @@ export class WriteBuffer extends Disposable {
|
||||
private _syncCalls = 0;
|
||||
private _didUserInput = false;
|
||||
|
||||
private readonly _innerWriteTimer = this._register(new TimeoutTimer());
|
||||
private readonly _onWriteParsed = this._register(new Emitter<void>());
|
||||
public readonly onWriteParsed = this._onWriteParsed.event;
|
||||
|
||||
constructor(private _action: (data: string | Uint8Array, promiseResult?: boolean) => void | Promise<boolean>) {
|
||||
super();
|
||||
this._register(toDisposable(() => {
|
||||
this._writeBuffer.length = 0;
|
||||
this._callbacks.length = 0;
|
||||
this._pendingData = 0;
|
||||
this._bufferOffset = 0;
|
||||
}));
|
||||
}
|
||||
|
||||
public handleUserInput(): void {
|
||||
@@ -63,6 +69,9 @@ export class WriteBuffer extends Disposable {
|
||||
* promises to resolve.
|
||||
*/
|
||||
public flushSync(): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
// exit early if another sync write loop is active
|
||||
if (this._isSyncWriting) {
|
||||
return;
|
||||
@@ -71,7 +80,9 @@ export class WriteBuffer extends Disposable {
|
||||
|
||||
// Process all pending chunks synchronously
|
||||
let chunk: string | Uint8Array | undefined;
|
||||
let didProcess = false;
|
||||
while (chunk = this._writeBuffer.shift()) {
|
||||
didProcess = true;
|
||||
this._action(chunk);
|
||||
const cb = this._callbacks.shift();
|
||||
if (cb) cb();
|
||||
@@ -84,12 +95,18 @@ export class WriteBuffer extends Disposable {
|
||||
this._callbacks.length = 0;
|
||||
|
||||
this._isSyncWriting = false;
|
||||
if (didProcess) {
|
||||
this._onWriteParsed.fire();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Unreliable, to be removed soon.
|
||||
*/
|
||||
public writeSync(data: string | Uint8Array, maxSubsequentCalls?: number): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
// stop writeSync recursions with maxSubsequentCalls argument
|
||||
// This is dangerous to use as it will lose the current data chunk
|
||||
// and return immediately.
|
||||
@@ -133,7 +150,10 @@ export class WriteBuffer extends Disposable {
|
||||
}
|
||||
|
||||
public write(data: string | Uint8Array, callback?: () => void): void {
|
||||
if (this._pendingData > DISCARD_WATERMARK) {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
if (this._pendingData > Constants.DISCARD_WATERMARK) {
|
||||
throw new Error('write data discarded, use flow control to avoid losing data');
|
||||
}
|
||||
|
||||
@@ -153,7 +173,7 @@ export class WriteBuffer extends Disposable {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => this._innerWrite());
|
||||
this._scheduleInnerWrite();
|
||||
}
|
||||
|
||||
this._pendingData += data.length;
|
||||
@@ -169,7 +189,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)
|
||||
@@ -189,7 +209,17 @@ export class WriteBuffer extends Disposable {
|
||||
*
|
||||
* Note, for pure sync code `lastTime` and `promiseResult` have no meaning.
|
||||
*/
|
||||
private _scheduleInnerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
this._innerWriteTimer.cancelAndSet(() => this._innerWrite(lastTime, promiseResult), 0);
|
||||
}
|
||||
|
||||
protected _innerWrite(lastTime: number = 0, promiseResult: boolean = true): void {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
const startTime = lastTime || performance.now();
|
||||
while (this._writeBuffer.length > this._bufferOffset) {
|
||||
const data = this._writeBuffer[this._bufferOffset];
|
||||
@@ -218,9 +248,16 @@ export class WriteBuffer extends Disposable {
|
||||
* responsibility to slice hard work), but we can at least schedule a screen update as we
|
||||
* gain control.
|
||||
*/
|
||||
const continuation: (r: boolean) => void = (r: boolean) => performance.now() - startTime >= WRITE_TIMEOUT_MS
|
||||
? setTimeout(() => this._innerWrite(0, r))
|
||||
: this._innerWrite(startTime, r);
|
||||
const continuation: (r: boolean) => void = (r: boolean) => {
|
||||
if (this._store.isDisposed) {
|
||||
return;
|
||||
}
|
||||
if (performance.now() - startTime >= Constants.WRITE_TIMEOUT_MS) {
|
||||
this._scheduleInnerWrite(0, r);
|
||||
} else {
|
||||
this._innerWrite(startTime, r);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Optimization considerations:
|
||||
@@ -235,7 +272,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,19 +292,19 @@ 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;
|
||||
}
|
||||
setTimeout(() => this._innerWrite());
|
||||
this._scheduleInnerWrite();
|
||||
} else {
|
||||
this._writeBuffer.length = 0;
|
||||
this._callbacks.length = 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,9 +4,10 @@
|
||||
*/
|
||||
|
||||
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';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IApcHandler[] = [];
|
||||
|
||||
@@ -19,10 +20,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 +64,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 +89,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 +99,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -201,13 +154,13 @@ export class ApcParser implements IApcParser {
|
||||
export class ApcHandler implements IApcHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(ApcHandler._payloadLimit);
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
|
||||
|
||||
public start(): void {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -215,9 +168,7 @@ export class ApcHandler implements IApcHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > ApcHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -227,18 +178,18 @@ export class ApcHandler implements IApcHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data);
|
||||
ret = this._handler(this._data.toString());
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,6 +8,7 @@ import { IDcsHandler, IParams, IHandlerCollection, IDcsParser, DcsFallbackHandle
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { Params } from 'common/parser/Params';
|
||||
import { ParserConstants } from 'common/parser/Constants';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IDcsHandler[] = [];
|
||||
|
||||
@@ -140,7 +141,7 @@ EMPTY_PARAMS.addParam(0);
|
||||
export class DcsHandler implements IDcsHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(DcsHandler._payloadLimit);
|
||||
private _params: IParams = EMPTY_PARAMS;
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
@@ -152,7 +153,7 @@ export class DcsHandler implements IDcsHandler {
|
||||
// perf optimization:
|
||||
// clone only, if we have non empty params, otherwise stick with default
|
||||
this._params = (params.length > 1 || params.params[0]) ? params.clone() : EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -160,9 +161,7 @@ export class DcsHandler implements IDcsHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > DcsHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -172,20 +171,20 @@ export class DcsHandler implements IDcsHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data, this._params);
|
||||
ret = this._handler(this._data.toString(), this._params);
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data and params until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._params = EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._params = EMPTY_PARAMS;
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IOscHandler, IHandlerCollection, OscFallbackHandlerType, IOscParser, IS
|
||||
import { OscState, ParserConstants } from 'common/parser/Constants';
|
||||
import { utf32ToString } from 'common/input/TextDecoder';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { LimitedStringBuilder } from 'common/StringBuilder';
|
||||
|
||||
const EMPTY_HANDLERS: IOscHandler[] = [];
|
||||
|
||||
@@ -194,13 +195,13 @@ export class OscParser implements IOscParser {
|
||||
export class OscHandler implements IOscHandler {
|
||||
private static _payloadLimit = ParserConstants.PAYLOAD_LIMIT;
|
||||
|
||||
private _data = '';
|
||||
private _data = new LimitedStringBuilder(OscHandler._payloadLimit);
|
||||
private _hitLimit: boolean = false;
|
||||
|
||||
constructor(private _handler: (data: string) => boolean | Promise<boolean>) { }
|
||||
|
||||
public start(): void {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
}
|
||||
|
||||
@@ -208,9 +209,7 @@ export class OscHandler implements IOscHandler {
|
||||
if (this._hitLimit) {
|
||||
return;
|
||||
}
|
||||
this._data += utf32ToString(data, start, end);
|
||||
if (this._data.length > OscHandler._payloadLimit) {
|
||||
this._data = '';
|
||||
if (this._data.append(utf32ToString(data, start, end))) {
|
||||
this._hitLimit = true;
|
||||
}
|
||||
}
|
||||
@@ -220,18 +219,18 @@ export class OscHandler implements IOscHandler {
|
||||
if (this._hitLimit) {
|
||||
ret = false;
|
||||
} else if (success) {
|
||||
ret = this._handler(this._data);
|
||||
ret = this._handler(this._data.toString());
|
||||
if (ret instanceof Promise) {
|
||||
// need to hold data until `ret` got resolved
|
||||
// dont care for errors, data will be freed anyway on next start
|
||||
return ret.then(res => {
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return res;
|
||||
});
|
||||
}
|
||||
}
|
||||
this._data = '';
|
||||
this._data.reset();
|
||||
this._hitLimit = false;
|
||||
return ret;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ export class BufferNamespaceApi extends Disposable implements IBufferNamespaceAp
|
||||
super();
|
||||
this._normal = new BufferApiView(this._core.buffers.normal, 'normal');
|
||||
this._alternate = new BufferApiView(this._core.buffers.alt, 'alternate');
|
||||
this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active));
|
||||
this._register(this._core.buffers.onBufferActivate(() => this._onBufferChange.fire(this.active)));
|
||||
}
|
||||
public get active(): IBufferApi {
|
||||
if (this._core.buffers.active === this._core.buffers.normal) { return this.normal; }
|
||||
|
||||
@@ -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);
|
||||
@@ -67,17 +69,17 @@ export class BufferService extends Disposable implements IBufferService {
|
||||
public scroll(eraseAttr: IAttributeData, isWrapped: boolean = false): void {
|
||||
const buffer = this.buffer;
|
||||
|
||||
let newLine: IBufferLine | undefined;
|
||||
newLine = this._cachedBlankLine;
|
||||
const topRow = buffer.ybase + buffer.scrollTop;
|
||||
const bottomRow = buffer.ybase + buffer.scrollBottom;
|
||||
|
||||
let newLine: IBufferLine;
|
||||
newLine = this._cachedBlankLine!;
|
||||
if (!newLine || newLine.length !== this.cols || newLine.getFg(0) !== eraseAttr.fg || newLine.getBg(0) !== eraseAttr.bg) {
|
||||
newLine = buffer.getBlankLine(eraseAttr, isWrapped);
|
||||
this._cachedBlankLine = newLine;
|
||||
}
|
||||
newLine.isWrapped = isWrapped;
|
||||
|
||||
const topRow = buffer.ybase + buffer.scrollTop;
|
||||
const bottomRow = buffer.ybase + buffer.scrollBottom;
|
||||
|
||||
if (buffer.scrollTop === 0) {
|
||||
// Determine whether the buffer is going to be trimmed after insertion.
|
||||
const willBufferBeTrimmed = buffer.lines.isFull;
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { clone } from 'common/Clone';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { IDecPrivateModes, IKittyKeyboardState, IModes } from 'common/Types';
|
||||
import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
@@ -61,14 +60,14 @@ export class CoreService extends Disposable implements ICoreService {
|
||||
) {
|
||||
super();
|
||||
this.isCursorInitialized = _optionsService.rawOptions.showCursorImmediately ?? false;
|
||||
this.modes = clone(DEFAULT_MODES);
|
||||
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.modes = structuredClone(DEFAULT_MODES);
|
||||
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this.modes = clone(DEFAULT_MODES);
|
||||
this.decPrivateModes = clone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.modes = structuredClone(DEFAULT_MODES);
|
||||
this.decPrivateModes = structuredClone(DEFAULT_DEC_PRIVATE_MODES);
|
||||
this.kittyKeyboard = DEFAULT_KITTY_KEYBOARD_STATE();
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user