Compare commits

..
Author SHA1 Message Date
Anthony Kim c9beccb3d1 Prevent image jitter when resizing in demo 2026-02-11 22:03:02 -08:00
161 changed files with 2555 additions and 10267 deletions
@@ -57,13 +57,6 @@ 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
@@ -1,11 +1,7 @@
---
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.
applyTo: '**/*.benchmark.ts'
---
# Benchmark Run Instructions
## Running Benchmarks
# Benchmark run instructions
- Full suite: `npm run benchmark`
- Single benchmark file:
@@ -15,14 +11,8 @@ description: Run and author benchmark tests in xterm.js. Use when editing benchm
- Use `-t` to get the path, then:
- `npm run benchmark -- -s "<path>" out-test/benchmark/Event.benchmark.js`
## Benchmark Case Selection
When writing benchmark instructions:
- Use `RuntimeCase` to measure pure runtime in ms.
- Use `ThroughputRuntimeCase` when measuring throughput in MB/s.
## Notes
When writing instructions, use `RuntimeCase` to measure pure runtime in ms, use `ThroughputRuntimeCase` when measuring throughput in MB/s.
Notes:
- Benchmarks run from built JS in `out-test/benchmark/*.benchmark.js`.
- Keep `NODE_PATH=./out` (handled by the npm script).
@@ -1,11 +1,7 @@
---
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.
applyTo: '**/*.test.ts'
---
# Unit Test Instructions
When writing unit tests, follow these rules:
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.
+13 -13
View File
@@ -207,31 +207,31 @@ jobs:
- name: Build demo server
run: npm run esbuild-demo-server
- name: Integration tests (core) # Tests use 50% workers to reduce flakiness
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=core
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=core
- name: Integration tests (addon-attach)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-attach
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-attach
- name: Integration tests (addon-clipboard)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-clipboard
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-clipboard
- name: Integration tests (addon-fit)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-fit
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-fit
- name: Integration tests (addon-image)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-image
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-image
- name: Integration tests (addon-progress)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-progress
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-progress
- name: Integration tests (addon-search)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-search
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-search
- name: Integration tests (addon-serialize)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-serialize
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-serialize
- name: Integration tests (addon-unicode-graphemes)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode-graphemes
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode-graphemes
- name: Integration tests (addon-unicode11)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-unicode11
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-unicode11
- name: Integration tests (addon-web-fonts)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-fonts
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-fonts
- name: Integration tests (addon-web-links)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-web-links
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-web-links
- name: Integration tests (addon-webgl)
run: npm run test-integration-${{ matrix.browser }} -- --workers=50% --forbid-only --suite=addon-webgl
run: npm run test-integration-${{ matrix.browser }} --workers=50% --forbid-only --suite=addon-webgl
release-dry-run:
needs: build
+11 -35
View File
@@ -24,36 +24,7 @@ First, you need to install the module. We ship exclusively through [npm](https:/
npm install --save @xterm/xterm
```
The recommended way to load xterm.js with the ES module syntax, either using TypeScript or a modern JS tooling. Note that both CommonJS and ES module files are shipped with the npm package.
```javascript
import { Terminal } from '@xterm/xterm';
```
Then instantiate a `Terminal` object, open it on an element and write to it:
```javascript
const term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
```
Most use cases will hook up input and output to a pseudoterminal API such as [node-pty](https://www.npmjs.com/package/node-pty) which will drive the terminal.
```javascript
pty.onData(data => term.write(data));
term.onData(data => pty.write(data));
```
Then make sure to also include the `css/xterm.css` file, for example in HTML:
```html
<link rel="stylesheet" href="node_modules/@xterm/xterm/css/xterm.css" />
```
### Standalone example
Here is a complete standalone example in a HTML file:
To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to the head of your HTML page. Then create a `<div id="terminal"></div>` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
```html
<!doctype html>
@@ -65,7 +36,7 @@ Here is a complete standalone example in a HTML file:
<body>
<div id="terminal"></div>
<script>
const term = new Terminal();
var term = new Terminal();
term.open(document.getElementById('terminal'));
term.write('Hello from \x1B[1;3;31mxterm.js\x1B[0m $ ')
</script>
@@ -73,6 +44,14 @@ Here is a complete standalone example in a HTML file:
</html>
```
### Importing
The recommended way to load xterm.js is via the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/xterm';
```
### Addons
Addons are separate modules that extend the `Terminal` by building on the [xterm.js API](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm.d.ts). To use an addon, you first need to install it in your project:
@@ -115,7 +94,7 @@ Since xterm.js is typically implemented as a developer tool, generally only mode
### Node.js Support
We also publish [`@xterm/headless`](https://www.npmjs.com/package/@xterm/headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
We also publish [`xterm-headless`](https://www.npmjs.com/package/xterm-headless) which is a stripped down version of xterm.js that runs headless in Node.js. An example use case for this is to keep track of a terminal's state where the process is running and using the [serialize addon](https://www.npmjs.com/package/@xterm/addon-serialize) so it can get all state restored upon reconnection.
## API
@@ -250,9 +229,6 @@ Xterm.js is used in many world-class applications to provide great terminal expe
- [**ecmaOS**](https://ecmaos.sh): A kernel and suite of applications tying modern web technologies into a browser-based operating system.
- [**LabEx**](https://labex.io): Interactive learning platform with hands-on labs and xterm.js-based online terminals, focused on learn-by-doing approach.
- [**EmuDevz**](https://afska.github.io/emudevz): A free coding game where players learn how to build an emulator from scratch.
- [**SSH Connection Manager**](https://github.com/Amtrend/ssh-manager): Modern web-based SSH terminal and SFTP manager with AES-256 encryption, PWA support, and session management.
- [**WooTTY**](https://github.com/icoretech/wootty): Flawless browser terminal for real operators.
- [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+4 -1
View File
@@ -16,10 +16,13 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"dependencies": {
"js-base64": "^3.7.5"
}
}
+18 -10
View File
@@ -4,7 +4,8 @@
*/
import type { IDisposable, ITerminalAddon, Terminal } from '@xterm/xterm';
import { type IClipboardProvider, type IBase64 } from '@xterm/addon-clipboard';
import { type IClipboardProvider, ClipboardSelectionType, type IBase64 } from '@xterm/addon-clipboard';
import { Base64 as JSBase64 } from 'js-base64';
export class ClipboardAddon implements ITerminalAddon {
private _terminal?: Terminal;
@@ -24,7 +25,7 @@ export class ClipboardAddon implements ITerminalAddon {
return this._disposable?.dispose();
}
private _readText(sel: string, data: string): void {
private _readText(sel: ClipboardSelectionType, data: string): void {
const b64 = this._base64.encodeText(data);
this._terminal?.input(`\x1b]52;${sel};${b64}\x07`, false);
}
@@ -35,7 +36,7 @@ export class ClipboardAddon implements ITerminalAddon {
return true;
}
const pc = args[0];
const pc = args[0] as ClipboardSelectionType;
const pd = args[1];
if (pd === '?') {
const text = this._provider.readText(pc);
@@ -69,23 +70,30 @@ export class ClipboardAddon implements ITerminalAddon {
}
export class BrowserClipboardProvider implements IClipboardProvider {
public readText(selection: string): Promise<string> {
public async readText(selection: ClipboardSelectionType): Promise<string> {
if (selection !== 'c') {
return Promise.resolve('');
}
return navigator.clipboard.readText();
}
public writeText(selection: string, text: string): Promise<void> {
public async writeText(selection: ClipboardSelectionType, text: string): Promise<void> {
if (selection !== 'c') {
return Promise.resolve();
}
return navigator.clipboard.writeText(text);
}
}
export class Base64 implements IBase64 {
public encodeText(data: string): string {
return btoa(data);
return JSBase64.encode(data);
}
public decodeText(data: string): string {
try {
return atob(data);
} catch {}
return '';
const text = JSBase64.decode(data);
if (!JSBase64.isValid(data) || JSBase64.encode(text) !== data) {
return '';
}
return text;
}
}
@@ -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() === 'webkit') {
if (ctx.browser.browserType().name() !== 'chromium') {
testInfo.skip();
return;
}
@@ -48,14 +48,6 @@ 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()), '');
@@ -68,24 +60,15 @@ test.describe('ClipboardAddon', () => {
});
test.describe('read data', async function (): Promise<any> {
test.beforeAll(async () => {
test('simple string', 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`);
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+21 -12
View File
@@ -30,6 +30,18 @@ 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.
@@ -66,39 +78,36 @@ declare module '@xterm/addon-clipboard' {
export interface IClipboardProvider {
/**
* Gets the clipboard content.
* @param selection The clipboard selection to read (see OSC 52 spec of xterm).
* @param selection The clipboard selection to read.
* @returns A promise that resolves with clipboard selection data.
*/
readText(selection: string): string | Promise<string>;
readText(selection: ClipboardSelectionType): string | Promise<string>;
/**
* Sets the clipboard content.
* @param selection The clipboard selection to set (see OSC 52 spec of xterm).
* @param selection The clipboard selection to set.
* @param data The clipboard text to write.
*/
writeText(selection: string, text: string): void | Promise<void>;
writeText(selection: ClipboardSelectionType, text: string): void | Promise<void>;
}
/**
* 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.
* The clipboard provider interface that enables xterm.js to access the system clipboard.
*/
export class BrowserClipboardProvider implements IClipboardProvider{
/**
* Reads text from the clipboard.
* @param selection The selection type to read from (always refers to navigator.clipboard).
* @param selection The selection type to read from.
* @returns A promise that resolves with the text from the clipboard.
*/
public readText(selection: string): Promise<string>;
public readText(selection: ClipboardSelectionType): Promise<string>;
/**
* Writes text to the clipboard.
* @param selection The selection type to write to (always refers to navigator.clipboard).
* @param selection The selection type to write to.
* @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: string, data: string): Promise<void>;
public writeText(selection: ClipboardSelectionType, data: string): Promise<void>;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+8 -7
View File
@@ -19,10 +19,8 @@ interface ITerminalDimensions {
cols: number;
}
const enum Constants {
MINIMUM_COLS = 2,
MINIMUM_ROWS = 1
}
const MINIMUM_COLS = 2;
const MINIMUM_ROWS = 1;
function getWindow(e: Node): Window {
if (e?.ownerDocument?.defaultView) {
@@ -50,7 +48,10 @@ export class FitAddon implements ITerminalAddon, IFitApi {
return;
}
this._terminal.resize(dims.cols, dims.rows);
// Force a full render
if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) {
this._terminal.resize(dims.cols, dims.rows);
}
}
public proposeDimensions(): ITerminalDimensions | undefined {
@@ -88,8 +89,8 @@ export class FitAddon implements ITerminalAddon, IFitApi {
const availableHeight = parentElementHeight - elementPaddingVer;
const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth;
const geometry = {
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))
cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)),
rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height))
};
return geometry;
}
-11
View File
@@ -119,17 +119,6 @@ test.describe('FitAddon', () => {
strictEqual(await ctx.proxy.cols, 2);
strictEqual(await ctx.proxy.rows, 1);
});
test('same dimensions', async function(): Promise<any> {
await setDimensions();
await ctx.page.evaluate(`window.fit.fit()`);
const cols: number = await ctx.proxy.cols;
const rows: number = await ctx.proxy.rows;
// Calling fit() again at the same container size should not throw
await ctx.page.evaluate(`window.fit.fit()`);
strictEqual(await ctx.proxy.cols, cols);
strictEqual(await ctx.proxy.rows, rows);
});
});
});
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+3 -5
View File
@@ -25,14 +25,12 @@ 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: 4096, // initial sixel palette size
sixelSizeLimit: 33554432, // size limit of a single sixel sequence
sixelPaletteLimit: 256, // initial sixel palette size
sixelSizeLimit: 25000000, // 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: 33554432, // size limit of a single IIP sequence
kittySupport: true, // enable Kitty graphics support
kittySizeLimit: 33554432 // size limit of a single Kitty sequence
iipSizeLimit: 20000000 // size limit of a single IIP sequence
}
// initialization
Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 B

+2 -2
View File
@@ -18,13 +18,13 @@
"xterm.js"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
},
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.4.1"
"xterm-wasm-parts": "^0.3.0"
}
}
+22 -26
View File
@@ -4,10 +4,8 @@
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
import { IIPImageStorage } from './IIPImageStorage';
import { CELL_SIZE_DEFAULT } from './ImageStorage';
import { ImageStorage, 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';
@@ -37,19 +35,17 @@ 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(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _storage: IIPImageStorage,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
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 {}
@@ -118,27 +114,27 @@ export class IIPHandler implements IOscHandler, IResetHandler {
return true;
}
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 });
}
// 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);
});
}
return createImageBitmap(blob, { resizeWidth: w, resizeHeight: h })
.then(bm => {
this._storage.addImage(bm);
-26
View File
@@ -1,26 +0,0 @@
/**
* Copyright (c) 2023 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ImageStorage } from './ImageStorage';
/**
* IIP (iTerm Image Protocol) specific image storage controller.
*
* Wraps the shared ImageStorage with IIP protocol semantics:
* - Always uses scrolling mode (cursor advances with image)
*/
export class IIPImageStorage {
constructor(
private readonly _storage: ImageStorage
) {}
/**
* Add an IIP image to storage.
* Always uses scrolling mode — cursor advances past the image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._storage.addImage(img, true);
}
}
+1 -9
View File
@@ -4,7 +4,7 @@
*/
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'image/qoi' | 'unsupported' | '';
export type ImageType = 'image/png' | 'image/jpeg' | 'image/gif' | 'unsupported' | '';
export interface IMetrics {
mime: ImageType;
@@ -45,14 +45,6 @@ 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;
}
+5 -71
View File
@@ -5,72 +5,24 @@
import type { ITerminalAddon, IDisposable } from '@xterm/xterm';
import type { ImageAddon as IImageApi } from '@xterm/addon-image';
import { Emitter, type IEvent } from 'common/Event';
import { IIPHandler } from './IIPHandler';
import { ImageRenderer } from './ImageRenderer';
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
import { KittyImageStorage } from './kitty/KittyImageStorage';
import { SixelHandler } from './SixelHandler';
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: 4096,
sixelSizeLimit: 33554432,
sixelPaletteLimit: 256,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -104,8 +56,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map<String, IResetHandler> = new Map();
private readonly _onImageAdded = new Emitter<void>();
public readonly onImageAdded: IEvent<void> = this._onImageAdded.event;
constructor(opts?: Partial<IImageAddonOptions>) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -118,7 +68,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
this._disposables.length = 0;
this._handlers.clear();
this._onImageAdded.dispose();
}
private _disposeLater(...args: IDisposable[]): void {
@@ -133,7 +82,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// internal data structures
this._renderer = new ImageRenderer(terminal);
this._storage = new ImageStorage(terminal, this._renderer, this._opts);
this._storage.onImageAdded = () => this._onImageAdded.fire();
// enable size reports
if (this._opts.enableSizeReports) {
@@ -181,8 +129,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// SIXEL handler
if (this._opts.sixelSupport) {
const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
this._handlers.set('sixel', sixelHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
@@ -191,25 +138,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
const iipStorage = new IIPImageStorage(this._storage!);
const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, terminal);
const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
this._handlers.set('iip', iipHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
);
}
// Kitty graphics handler
if (this._opts.kittySupport) {
const kittyStorage = new KittyImageStorage(this._storage!);
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, kittyStorage, terminal);
this._handlers.set('kitty', kittyHandler);
this._disposeLater(
kittyStorage,
kittyHandler,
terminal._core._inputHandler._parser.registerApcHandler({ final: 'G' }, kittyHandler)
);
}
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
+56 -104
View File
@@ -5,13 +5,11 @@
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const enum Constants {
PLACEHOLDER_LENGTH = 4096,
PLACEHOLDER_HEIGHT = 24
}
const PLACEHOLDER_LENGTH = 4096;
const PLACEHOLDER_HEIGHT = 24;
/**
* ImageRenderer - terminal frontend extension:
@@ -20,9 +18,8 @@ const enum Constants {
* - draw image tiles onRender
*/
export class ImageRenderer extends Disposable implements IDisposable {
/** @deprecated Kept for backward compat — points to top layer canvas. */
public get canvas(): HTMLCanvasElement | undefined { return this._layers.get('top')?.canvas; }
private _layers = new Map<ImageLayer, CanvasRenderingContext2D>();
public canvas: HTMLCanvasElement | undefined;
private _ctx: CanvasRenderingContext2D | null | undefined;
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
@@ -89,7 +86,6 @@ export class ImageRenderer extends Disposable implements IDisposable {
});
this._register(toDisposable(() => {
this.removeLayerFromDom();
this.removeLayerFromDom('bottom');
if (this._terminal._core && this._oldOpen) {
this._terminal._core.open = this._oldOpen;
this._oldOpen = undefined;
@@ -99,7 +95,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined;
}
this._renderService = undefined;
this._layers.clear();
this.canvas = undefined;
this._ctx = undefined;
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
@@ -112,7 +109,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, Constants.PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(this.cellSize.height + 1, PLACEHOLDER_HEIGHT));
}
} else {
this._placeholderBitmap?.close();
@@ -143,38 +140,27 @@ export class ImageRenderer extends Disposable implements IDisposable {
/**
* Clear a region of the image layer canvas.
*/
public clearLines(start: number, end: number, layer?: ImageLayer): void {
const y = start * (this.dimensions?.css.cell.height || 0);
const w = this.dimensions?.css.canvas.width || 0;
const h = (++end - start) * (this.dimensions?.css.cell.height || 0);
if (!layer || layer === 'top') {
this._layers.get('top')?.clearRect(0, y, w, h);
}
if (!layer || layer === 'bottom') {
this._layers.get('bottom')?.clearRect(0, y, w, h);
}
public clearLines(start: number, end: number): void {
this._ctx?.clearRect(
0,
start * (this.dimensions?.css.cell.height || 0),
this.dimensions?.css.canvas.width || 0,
(++end - start) * (this.dimensions?.css.cell.height || 0)
);
}
/**
* Clear whole image canvas.
*/
public clearAll(layer?: ImageLayer): void {
if (!layer || layer === 'top') {
const ctx = this._layers.get('top');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
if (!layer || layer === 'bottom') {
const ctx = this._layers.get('bottom');
ctx?.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
public clearAll(): void {
this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
}
/**
* Draw neighboring tiles on the image layer canvas.
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
const ctx = this._layers.get(imgSpec.layer);
if (!ctx) {
if (!this._ctx) {
return;
}
const { width, height } = this.cellSize;
@@ -197,14 +183,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
const finalHeight = sy + height > img.height ? img.height - sy : height;
// Floor all pixel offsets to get stable tile mapping without any overflows.
// Note: For not pixel perfect aligned cells like in the DOM renderer
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
ctx.drawImage(
// Let the canvas handle sub-pixel coordinates natively to avoid
// inconsistent rounding that causes tile gaps/overlaps during resize.
this._ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
sx, sy, finalWidth, finalHeight,
dx, dy, finalWidth, finalHeight
);
}
@@ -230,8 +214,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
if (ctx) {
ctx.drawImage(
img,
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
sx, sy, finalWidth, finalHeight,
0, 0, canvas.width, canvas.height
);
return canvas;
}
@@ -241,8 +225,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Draw a line with placeholder on the image layer canvas.
*/
public drawPlaceholder(col: number, row: number, count: number = 1): void {
const ctx = this._layers.get('top');
if (ctx) {
if (this._ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -251,12 +234,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
}
if (!this._placeholder) {
this._createPlaceHolder(Math.max(height + 1, Constants.PLACEHOLDER_HEIGHT));
this._createPlaceHolder(Math.max(height + 1, PLACEHOLDER_HEIGHT));
} else if (height >= this._placeholder!.height) {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
ctx.drawImage(
this._ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -275,13 +258,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Checked once from `ImageStorage.render`.
*/
public rescaleCanvas(): void {
const w = this.dimensions?.css.canvas.width || 0;
const h = this.dimensions?.css.canvas.height || 0;
for (const ctx of this._layers.values()) {
if (ctx.canvas.width !== w || ctx.canvas.height !== h) {
ctx.canvas.width = w;
ctx.canvas.height = h;
}
if (!this.canvas) {
return;
}
if (this.canvas.width !== this.dimensions!.css.canvas.width || this.canvas.height !== this.dimensions!.css.canvas.height) {
this.canvas.width = this.dimensions!.css.canvas.width || 0;
this.canvas.height = this.dimensions!.css.canvas.height || 0;
}
}
@@ -320,68 +302,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._renderService = this._terminal._core._renderService;
this._oldSetRenderer = this._renderService.setRenderer.bind(this._renderService);
this._renderService.setRenderer = (renderer: any) => {
for (const key of [...this._layers.keys()]) {
this.removeLayerFromDom(key);
}
this.removeLayerFromDom();
this._oldSetRenderer?.call(this._renderService, renderer);
};
}
public insertLayerToDom(layer: ImageLayer = 'top'): void {
public insertLayerToDom(): void {
// make sure that the terminal is attached to a document and to DOM
if (!this.document || !this._terminal._core.screenElement) {
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
return;
}
if (this._layers.has(layer)) {
return;
}
const canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
canvas.classList.add(`xterm-image-layer-${layer}`);
const screenElement = this._terminal._core.screenElement;
// Use isolation to create a stacking context without overriding z-index,
// which would conflict with integrators (e.g. VS Code) that set their
// own z-index on the screen element.
screenElement.style.isolation = 'isolate';
if (layer === 'bottom') {
// Use z-index:-1 so it paints behind non-positioned text elements.
// The screen element needs to be a stacking context (via isolation)
// to contain the negative z-index, otherwise it would go behind the
// entire terminal.
canvas.style.zIndex = '-1';
screenElement.insertBefore(canvas, screenElement.firstChild);
if (this.document && this._terminal._core.screenElement) {
if (!this.canvas) {
this.canvas = ImageRenderer.createCanvas(
this.document, this.dimensions?.css.canvas.width || 0,
this.dimensions?.css.canvas.height || 0
);
this.canvas.classList.add('xterm-image-layer');
this._terminal._core.screenElement.appendChild(this.canvas);
this._ctx = this.canvas.getContext('2d', { alpha: true, desynchronized: true });
this.clearAll();
}
} else {
// Explicit z-index ensures the image canvas reliably stacks above
// the text layer (DOM renderer rows). z-index: 0 is below the
// selection overlay (z-index: 1).
canvas.style.zIndex = '0';
screenElement.appendChild(canvas);
}
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) {
canvas.remove();
return;
}
this._layers.set(layer, ctx);
this.clearAll(layer);
}
public removeLayerFromDom(layer: ImageLayer = 'top'): void {
const ctx = this._layers.get(layer);
if (ctx) {
ctx.canvas.remove();
this._layers.delete(layer);
console.warn('image addon: cannot insert output canvas to DOM, missing document or screenElement');
}
}
public hasLayer(layer: ImageLayer): boolean {
return this._layers.has(layer);
public removeLayerFromDom(): void {
if (this.canvas) {
this._ctx = undefined;
this.canvas.remove();
this.canvas = undefined;
}
}
private _createPlaceHolder(height: number = Constants.PLACEHOLDER_HEIGHT): void {
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
@@ -405,7 +357,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) || Constants.PLACEHOLDER_LENGTH;
const width = (screen.width + bWidth - 1) & ~(bWidth - 1) || PLACEHOLDER_LENGTH;
this._placeholder = ImageRenderer.createCanvas(this.document, width, height);
const ctx2 = this._placeholder.getContext('2d', { alpha: false });
if (!ctx2) {
+59 -121
View File
@@ -5,7 +5,7 @@
import { IDisposable } from '@xterm/xterm';
import { ImageRenderer } from './ImageRenderer';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle } from './Types';
// fallback default cell size
@@ -124,8 +124,6 @@ export class ImageStorage implements IDisposable {
private _pixelLimit: number = 2500000;
private _viewportMetrics: { cols: number, rows: number };
public onImageAdded: (() => void) | undefined;
public onImageDeleted: ((storageId: number) => void) | undefined;
constructor(
private _terminal: ITerminalExt,
@@ -191,13 +189,11 @@ export class ImageStorage implements IDisposable {
private _delImg(id: number): void {
const spec = this._images.get(id);
if (!spec) return;
this._images.delete(id);
// FIXME: really ugly workaround to get bitmaps deallocated :(
if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
if (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
this.onImageDeleted?.(id);
}
/**
@@ -221,27 +217,27 @@ export class ImageStorage implements IDisposable {
}
/**
* Delete an image by its internal storage ID.
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
* Only advance text cursor.
* This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/
public deleteImage(id: number): void {
const spec = this._images.get(id);
if (spec) {
spec.marker?.dispose();
this._delImg(id);
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
/**
* Method to add an image to the storage.
* @param img - The image to add (canvas or bitmap).
* @param scrolling - When true, cursor advances with the image (lineFeed per row).
* When false, image is placed at (0,0) and cursor is restored (DECSET 80 / sixel origin mode).
* @param layer - Which canvas layer to render on ('top' or 'bottom').
* @param zIndex - Z-index for image layering within the same layer.
* @returns The internal image ID assigned to the stored image.
*/
public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): number {
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
@@ -263,7 +259,7 @@ export class ImageStorage implements IDisposable {
let offset = originX;
let tileCount = 0;
if (!scrolling) {
if (!this._opts.sixelScrolling) {
buffer.x = 0;
buffer.y = 0;
offset = 0;
@@ -277,7 +273,7 @@ export class ImageStorage implements IDisposable {
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++;
}
if (scrolling) {
if (this._opts.sixelScrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else {
if (++buffer.y >= termRows) break;
@@ -287,7 +283,7 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes
if (scrolling) {
if (this._opts.sixelScrolling) {
buffer.x = offset;
} else {
buffer.x = originX;
@@ -330,15 +326,11 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
bufferType: this._terminal.buffer.active.type,
layer,
zIndex
bufferType: this._terminal.buffer.active.type
};
// finally add the image
this._images.set(imageId, imgSpec);
this.onImageAdded?.();
return imageId;
}
@@ -348,30 +340,16 @@ export class ImageStorage implements IDisposable {
*/
// TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void {
// Determine which layers have images
let hasTopImages = false;
let hasBottomImages = false;
for (const spec of this._images.values()) {
if (spec.layer === 'bottom') {
hasBottomImages = true;
} else {
hasTopImages = true;
// setup image canvas in case we have none yet, but have images in store
if (!this._renderer.canvas && this._images.size) {
this._renderer.insertLayerToDom();
// safety measure - in case we cannot spawn a canvas at all, just exit
if (!this._renderer.canvas) {
return;
}
if (hasTopImages && hasBottomImages) break;
}
// Lazily insert layers that are needed
if (hasTopImages && !this._renderer.hasLayer('top')) {
this._renderer.insertLayerToDom('top');
if (!this._renderer.hasLayer('top')) return;
}
if (hasBottomImages && !this._renderer.hasLayer('bottom')) {
this._renderer.insertLayerToDom('bottom');
}
// rescale if needed
this._renderer.rescaleCanvas();
// exit early if we dont have any images to test for
if (!this._images.size) {
if (!this._fullyCleared) {
@@ -379,25 +357,12 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true;
this._needsFullClear = false;
}
if (this._renderer.hasLayer('top')) {
this._renderer.removeLayerFromDom('top');
}
if (this._renderer.hasLayer('bottom')) {
this._renderer.removeLayerFromDom('bottom');
if (this._renderer.canvas) {
this._renderer.removeLayerFromDom();
}
return;
}
// Remove layers no longer needed
if (!hasTopImages && this._renderer.hasLayer('top')) {
this._renderer.clearAll('top');
this._renderer.removeLayerFromDom('top');
}
if (!hasBottomImages && this._renderer.hasLayer('bottom')) {
this._renderer.clearAll('bottom');
this._renderer.removeLayerFromDom('bottom');
}
// buffer switches force a full clear
if (this._needsFullClear) {
this._renderer.clearAll();
@@ -412,77 +377,50 @@ export class ImageStorage implements IDisposable {
// clear drawing area
this._renderer.clearLines(start, end);
// Collect draw calls so we can sort by z-index (lower z drawn first).
const drawCalls: { imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number }[] = [];
const placeholderCalls: { col: number, row: number, count: number }[] = [];
// walk all cells in viewport and collect tiles found
// Note: We check _extendedAttrs directly (not just HAS_EXTENDED flag)
// because text writes clear the BG flag but leave image tile data intact.
// This lets top-layer images survive text overwrites (kitty C=1 behavior).
// walk all cells in viewport and draw tiles found
for (let row = start; row <= end; ++row) {
const line = buffer.lines.get(row + buffer.ydisp) as IBufferLineExt;
if (!line) return;
for (let col = 0; col < cols; ++col) {
let e: IExtendedAttrsImage;
if (line.getBg(col) & BgFlags.HAS_EXTENDED) {
e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
} else {
const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!maybeImg || maybeImg.imageId === undefined || maybeImg.imageId === -1) {
let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
e = maybeImg;
}
const imageId = e.imageId;
if (imageId === undefined || imageId === -1) {
continue;
}
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
* Also check _extendedAttrs directly for cells where text cleared HAS_EXTENDED.
*/
while (++col < cols) {
const nextE = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
if (!nextE || nextE.imageId !== imageId || nextE.tileId !== startTile + count) {
break;
const imgSpec = this._images.get(imageId);
if (e.tileId !== -1) {
const startTile = e.tileId;
const startCol = col;
let count = 1;
/**
* merge tiles to the right into a single draw call, if:
* - not at end of line
* - cell has same image id
* - cell has consecutive tile id
*/
while (
++col < cols
&& (line.getBg(col) & BgFlags.HAS_EXTENDED)
&& (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
&& (e.imageId === imageId)
&& (e.tileId === startTile + count)
) {
count++;
}
e = nextE;
count++;
}
col--;
if (imgSpec) {
if (imgSpec.actual) {
drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
col--;
if (imgSpec) {
if (imgSpec.actual) {
this._renderer.draw(imgSpec, startTile, startCol, row, count);
}
} else if (this._opts.showPlaceholder) {
this._renderer.drawPlaceholder(startCol, row, count);
}
} else if (this._opts.showPlaceholder) {
placeholderCalls.push({ col: startCol, row, count });
this._fullyCleared = false;
}
this._fullyCleared = false;
}
}
}
// Sort by z-index so lower z draws first (higher z renders on top)
drawCalls.sort((a, b) => a.imgSpec.zIndex - b.imgSpec.zIndex);
// Draw placeholders first (lowest priority)
for (const call of placeholderCalls) {
this._renderer.drawPlaceholder(call.col, call.row, call.count);
}
// Draw images in z-index order
for (const call of drawCalls) {
this._renderer.draw(call.imgSpec, call.tileId, call.col, call.row, call.count);
}
}
public viewportResize(metrics: { cols: number, rows: number }): void {
+2 -2
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { SixelImageStorage } from './SixelImageStorage';
import { ImageStorage } from './ImageStorage';
import { IDcsHandler, IParams, IImageAddonOptions, ITerminalExt, AttributeData, IResetHandler, ReadonlyColorSet } from './Types';
import { toRGBA8888, BIG_ENDIAN, PALETTE_ANSI_256, PALETTE_VT340_COLOR } from 'sixel/lib/Colors';
import { RGBA8888 } from 'sixel/lib/Types';
@@ -26,7 +26,7 @@ export class SixelHandler implements IDcsHandler, IResetHandler {
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _storage: SixelImageStorage,
private readonly _storage: ImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
DecoderAsync({
@@ -1,50 +0,0 @@
/**
* Copyright (c) 2020 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
import { IImageAddonOptions, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
/**
* Sixel-specific image storage controller.
*
* Wraps the shared ImageStorage with sixel protocol semantics:
* - Cursor behavior governed by DECSET 80 (sixelScrolling option)
* - advanceCursor for empty sixels carrying only height
*/
export class SixelImageStorage {
constructor(
private readonly _storage: ImageStorage,
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _terminal: ITerminalExt
) {}
/**
* Add a sixel image to storage.
* Cursor behavior depends on the sixelScrolling option (DECSET 80).
*/
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
this._storage.addImage(img, this._opts.sixelScrolling);
}
/**
* Only advance text cursor.
* This is an edge case from empty sixels carrying only a height but no pixels.
* Partially fixes https://github.com/jerch/xterm-addon-image/issues/37.
*/
public advanceCursor(height: number): void {
if (this._opts.sixelScrolling) {
let cellSize = this._renderer.cellSize;
if (cellSize.width === -1 || cellSize.height === -1) {
cellSize = CELL_SIZE_DEFAULT;
}
const rows = Math.ceil(height / cellSize.height);
for (let i = 1; i < rows; ++i) {
this._terminal._core._inputHandler.lineFeed();
}
}
}
}
+2 -8
View File
@@ -8,7 +8,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
// private imports from base repo we build against
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
import type { AttributeData } from 'common/buffer/AttributeData';
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
@@ -22,7 +22,7 @@ export const enum Cell {
}
// export some privates for local usage
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/**
* Plugin ctor options.
@@ -38,8 +38,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
export interface IResetHandler {
@@ -99,8 +97,6 @@ export interface ICellSize {
height: number;
}
export type ImageLayer = 'top' | 'bottom';
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
@@ -109,6 +105,4 @@ export interface IImageSpec {
marker: IMarker | undefined;
tileCount: number;
bufferType: 'alternate' | 'normal';
layer: ImageLayer;
zIndex: number;
}
@@ -1,819 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
import { ImageRenderer } from '../ImageRenderer';
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
import { KittyImageStorage } from './KittyImageStorage';
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import {
KittyAction,
KittyFormat,
KittyCompression,
IKittyCommand,
IPendingTransmission,
IKittyImageData,
KittyPixelConstants,
parseKittyCommand
} from './KittyGraphicsTypes';
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
}
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 {
private _aborted = false;
private _decodeError = false;
private _activeDecoder: Base64Decoder | null = null;
private readonly _maxEncodedBytes: number;
private readonly _initialEncodedBytes: number;
// Streaming related states
// True while receiving control data (before semicolon).
private _inControlData = true;
// Buffer for control data.
private _controlData = new Uint32Array(Constants.MAX_CONTROL_DATA_SIZE);
private _controlLength = 0;
// Pre-calculated encoded size limit
private _encodedSizeLimit = 0;
private _totalEncodedSize = 0;
// Parsed command. These are the control data before semicolon.
private _parsedCommand: IKittyCommand | null = null;
// Storage related states
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
// Tracks the pending key of the most recently started chunked upload.
// Per spec, subsequent chunks only need m= (and optionally q=), without i=.
// When a chunk arrives with no i=, this key is used to find the pending upload.
private _lastPendingKey: number | undefined;
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
private readonly _kittyStorage: KittyImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
// 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(Constants.DECODER_INITIAL_DATA, this._maxEncodedBytes);
}
public reset(): void {
this._cleanupAllPending();
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
this._kittyStorage.reset();
}
public dispose(): void {
this.reset();
}
private _removePendingEntry(key: number): void {
this._pendingTransmissions.delete(key);
if (this._lastPendingKey === key) {
this._lastPendingKey = undefined;
}
}
private _cleanupAllPending(): void {
for (const pending of this._pendingTransmissions.values()) {
pending.decoder.release();
}
this._pendingTransmissions.clear();
this._lastPendingKey = undefined;
}
public start(): void {
this._aborted = false;
this._decodeError = false;
this._inControlData = true;
this._controlLength = 0;
this._parsedCommand = null;
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
this._encodedSizeLimit = this._maxEncodedBytes;
this._totalEncodedSize = 0;
this._activeDecoder = null;
}
public put(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
if (!this._inControlData) {
this._streamPayload(data, start, end);
} else {
// Scan for semicolon
let controlEnd = end;
for (let i = start; i < end; i++) {
if (data[i] === Constants.SEMICOLON) {
this._inControlData = false;
controlEnd = i;
break;
}
}
// Copy control data
const copyLength = controlEnd - start;
if (this._controlLength + copyLength > Constants.MAX_CONTROL_DATA_SIZE) {
this._aborted = true;
return;
}
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
this._controlLength += copyLength;
if (!this._inControlData) {
// Found semicolon - parse control data early for validation
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
// Early validation: i+I conflict
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
this._aborted = true;
return;
}
// Delete action doesn't need payload - skip streaming
if (this._parsedCommand.action === KittyAction.DELETE) {
return;
}
// Stream remaining as payload
const payloadStart = controlEnd + 1;
if (payloadStart < end) {
this._streamPayload(data, payloadStart, end);
}
}
}
}
// Stream payload bytes into the base64 decoder.
private _streamPayload(data: Uint32Array, start: number, end: number): void {
if (this._aborted) return;
// Check size limit (compare encoded bytes against pre-calculated limit)
// Include cumulative size from pending transmission for multi-chunk images.
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = this._parsedCommand?.id ?? this._lastPendingKey ?? 0;
const pending = this._pendingTransmissions.get(pendingKey);
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
this._totalEncodedSize += end - start;
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
if (cumulativeEncodedSize > this._encodedSizeLimit) {
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
if (decoderToRelease) {
decoderToRelease.release();
}
this._activeDecoder = null;
if (pending) {
this._removePendingEntry(pendingKey);
}
this._aborted = true;
return;
}
if (this._decodeError) return;
if (pending?.decoder && !this._activeDecoder) {
this._activeDecoder = pending.decoder;
}
if (!this._activeDecoder) {
this._activeDecoder = new Base64Decoder(Constants.DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
this._activeDecoder.init();
}
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
this._activeDecoder.release();
this._activeDecoder = null;
this._decodeError = true;
if (pending) {
this._removePendingEntry(pendingKey);
}
}
}
public end(success: boolean): boolean | Promise<boolean> {
if (this._aborted || !success) {
if (this._activeDecoder) {
this._activeDecoder.release();
this._activeDecoder = null;
}
return true;
}
// No semicolon = no payload (delete, capability query)
if (this._inControlData) {
return this._handleNoPayloadCommand();
}
// Use command parsed early in put() - i+I already validated there
const cmd = this._parsedCommand!;
// Delete action was handled by skipping payload - just execute
if (cmd.action === KittyAction.DELETE) {
return this._handleDelete(cmd);
}
// Per spec, subsequent chunks may omit i=, so fall back to _lastPendingKey.
const pendingKey = cmd.id ?? this._lastPendingKey ?? 0;
const isMoreComing = cmd.more === 1;
const pending = this._pendingTransmissions.get(pendingKey);
if (isMoreComing) {
if (this._activeDecoder) {
if (pending) {
pending.totalEncodedSize += this._totalEncodedSize;
pending.decodeError = pending.decodeError || this._decodeError;
} else {
this._pendingTransmissions.set(pendingKey, {
cmd: { ...cmd },
decoder: this._activeDecoder,
totalEncodedSize: this._totalEncodedSize,
decodeError: this._decodeError
});
}
this._lastPendingKey = pendingKey;
this._activeDecoder = null;
}
return true;
}
// Final chunk received — clear the last pending key
if (pending) {
this._lastPendingKey = undefined;
}
let decodeError = this._decodeError;
let finalCmd = cmd;
let decoder = this._activeDecoder;
if (pending) {
finalCmd = pending.cmd;
decoder = pending.decoder;
decodeError = decodeError || pending.decodeError;
this._pendingTransmissions.delete(pendingKey);
}
let imageBytes = new Uint8Array(0);
if (decoder) {
if (decoder.end() !== DECODER_OK) {
decodeError = true;
}
imageBytes = decoder.data8;
}
this._activeDecoder = null;
// Handle command first — handlers create Blob/ImageData from imageBytes,
// which copies the data. Only then is it safe to release the decoder's
// wasm memory that imageBytes points into.
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
if (decoder) {
decoder.release();
}
return result;
}
// Command handling
private _parseControlDataString(): string {
let str = '';
for (let i = 0; i < this._controlLength; i++) {
str += String.fromCodePoint(this._controlData[i]);
}
return str;
}
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
const cmd = parseKittyCommand(this._parseControlDataString());
// Per spec: specifying both i and I is an error
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
return true;
}
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.DELETE:
return this._handleDelete(cmd);
case KittyAction.QUERY:
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
return true;
case KittyAction.PLACEMENT:
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
const action = cmd.action ?? 't';
switch (action) {
case KittyAction.TRANSMIT: {
const result = this._handleTransmit(cmd, bytes, decodeError);
// Only send response when _handleTransmit didn't already respond
// (it handles unsupported transmission medium responses internally)
if ((cmd.transmission ?? 'd') === 'd' && cmd.id !== undefined) {
if (decodeError) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
} else if (bytes.length > 0) {
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
}
}
return result;
}
case KittyAction.TRANSMIT_DISPLAY:
return this._handleTransmitDisplay(cmd, bytes, decodeError);
case KittyAction.QUERY:
return this._handleQuery(cmd, bytes, decodeError);
case KittyAction.PLACEMENT:
// a=p ignores any payload — image data was already transmitted
return this._handlePlacement(cmd);
default:
// TODO: Implement remaining actions when needed:
// - a=f (frame): animation frame operations
// - a=a (animation): animation control
// - a=c (compose): compose images
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
}
return true;
}
}
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
if (cmd.id === undefined) {
return true;
}
const id = cmd.id;
const image = this._kittyStorage.getImage(id);
if (!image) {
this._sendResponse(id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
return true;
}
const result = this._displayImage(image, cmd);
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0, cmd.placementId);
return true;
});
}
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
// Currently only supports direct transmission (t=d, the default).
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
// - t=s: Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
// 1. Check cmd.transmission (t key) before treating bytes as image data
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
// 3. For t=d: treat bytes as image data (current behavior)
// When implementing, also update _handleQuery to accept these transmission mediums.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:unsupported transmission medium', cmd.quiet ?? 0);
}
return true;
}
if (decodeError || bytes.length === 0) return true;
this._kittyStorage.storeImage(cmd.id, {
data: new Blob([bytes as BlobPart]),
width: cmd.width ?? 0,
height: cmd.height ?? 0,
format: (cmd.format ?? KittyFormat.RGBA) as 24 | 32 | 100,
compression: cmd.compression ?? ''
});
return true;
}
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
if (decodeError) {
if (cmd.id !== undefined) {
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
}
return true;
}
this._handleTransmit(cmd, bytes, decodeError);
const id = cmd.id ?? this._kittyStorage.lastImageId;
const image = this._kittyStorage.getImage(id);
if (image) {
const result = this._displayImage(image, cmd);
if (cmd.id !== undefined) {
return result.then(success => {
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
return true;
});
}
return result.then(() => true);
}
return true;
}
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
const id = cmd.id ?? 0;
const quiet = cmd.quiet ?? 0;
// Per spec: reject unsupported transmission mediums (only t=d is supported atm)
// TODO: When filesystem support is added (Node.js/Electron), update this to accept
// t=f (file), t=t (temp file), and t=s (shared memory) and respond OK for queries.
const transmission = cmd.transmission ?? 'd';
if (transmission !== 'd') {
this._sendResponse(id, 'EINVAL:unsupported transmission medium', quiet);
return true;
}
// Check decode error first (invalid base64)
if (decodeError) {
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
return true;
}
// Capability query (no payload) - just respond OK
if (bytes.length === 0) {
this._sendResponse(id, 'OK', quiet);
return true;
}
const format = cmd.format ?? KittyFormat.RGBA;
if (format === KittyFormat.PNG) {
this._sendResponse(id, 'OK', quiet);
} else {
const width = cmd.width ?? 0;
const height = cmd.height ?? 0;
if (!width || !height) {
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
return true;
}
const bytesPerPixel = format === KittyFormat.RGBA ? KittyPixelConstants.BYTES_PER_PIXEL_RGBA : KittyPixelConstants.BYTES_PER_PIXEL_RGB;
const expectedBytes = width * height * bytesPerPixel;
if (bytes.length < expectedBytes) {
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
return true;
}
this._sendResponse(id, 'OK', quiet);
}
return true;
}
private _handleDelete(cmd: IKittyCommand): boolean {
// Per spec: default delete selector is 'a' (delete all visible placements)
const selector = cmd.deleteSelector ?? 'a';
// TODO: Distinguish lowercase (delete placements only) from uppercase
// (delete placements + free stored image data). Currently both variants
// free everything since we don't separate stored data from placements.
switch (selector) {
case 'a':
case 'A':
this._cleanupAllPending();
this._kittyStorage.deleteAll();
break;
case 'i':
case 'I':
// TODO: When placement id tracking is implemented (see TODO in
// KittyImageStorage), d=i with p=<pid> should delete only that
// specific placement, while d=i without p should delete all
// placements for the image.
if (cmd.id !== undefined) {
const pending = this._pendingTransmissions.get(cmd.id);
if (pending) {
pending.decoder.release();
}
this._removePendingEntry(cmd.id);
this._kittyStorage.deleteById(cmd.id);
}
break;
default:
// Unsupported selectors (c, n, p, q, r, x, y, z, f) — ignore for now
break;
}
return true;
}
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;
const pPart = placementId ? `,p=${placementId}` : '';
const response = `\x1b_Gi=${id}${pPart};${message}\x1b\\`;
this._coreTerminal._core.coreService.triggerDataEvent(response);
}
// Image display
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
return this._decodeAndDisplay(image, cmd)
.then(() => true)
.catch(() => false);
}
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
let bitmap: ImageBitmap | undefined = await this._createBitmap(image);
try {
const cropX = Math.max(0, cmd.x ?? 0);
const cropY = Math.max(0, cmd.y ?? 0);
const cropW = cmd.sourceWidth || (bitmap.width - cropX);
const cropH = cmd.sourceHeight || (bitmap.height - cropY);
const maxCropW = Math.max(0, bitmap.width - cropX);
const maxCropH = Math.max(0, bitmap.height - cropY);
const finalCropW = Math.max(0, Math.min(cropW, maxCropW));
const finalCropH = Math.max(0, Math.min(cropH, maxCropH));
if (finalCropW === 0 || finalCropH === 0) {
throw new Error('invalid source rectangle');
}
if (cropX !== 0 || cropY !== 0 || finalCropW !== bitmap.width || finalCropH !== bitmap.height) {
const cropped = await createImageBitmap(bitmap, cropX, cropY, finalCropW, finalCropH);
bitmap.close();
bitmap = cropped;
}
const cw = this._renderer.dimensions?.css.cell.width || CELL_SIZE_DEFAULT.width;
const ch = this._renderer.dimensions?.css.cell.height || CELL_SIZE_DEFAULT.height;
// Per spec: c/r default to image's natural cell dimensions.
// If only one of c/r is specified, compute the other from image aspect ratio.
let imgCols: number;
let imgRows: number;
if (cmd.columns !== undefined && cmd.rows !== undefined) {
imgCols = cmd.columns;
imgRows = cmd.rows;
} else if (cmd.columns !== undefined) {
imgCols = cmd.columns;
imgRows = Math.max(1, Math.ceil((bitmap.height / bitmap.width) * (imgCols * cw) / ch));
} else if (cmd.rows !== undefined) {
imgRows = cmd.rows;
imgCols = Math.max(1, Math.ceil((bitmap.width / bitmap.height) * (imgRows * ch) / cw));
} else {
imgCols = Math.ceil(bitmap.width / cw);
imgRows = Math.ceil(bitmap.height / ch);
}
let w = bitmap.width;
let h = bitmap.height;
// Scale bitmap to fit placement rectangle when c/r are specified
if (cmd.columns !== undefined || cmd.rows !== undefined) {
w = Math.round(imgCols * cw);
h = Math.round(imgRows * ch);
}
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
// Save cursor position before addImage modifies it
const buffer = this._coreTerminal._core.buffer;
const savedX = buffer.x;
const savedY = buffer.y;
const savedYbase = buffer.ybase;
// Determine layer based on z-index: negative = behind text, 0+ = on top.
// When z<0 we always use the bottom layer even without allowTransparency —
// the image will simply be hidden behind the opaque text background, which
// is the correct behavior (client asked for "behind text").
const wantsBottom = cmd.zIndex !== undefined && cmd.zIndex < 0;
const layer: ImageLayer = wantsBottom ? 'bottom' : 'top';
if (w !== bitmap.width || h !== bitmap.height) {
const scaled = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
bitmap.close();
bitmap = scaled;
}
// Per spec: X/Y are pixel offsets within the first cell, so clamp to cell dimensions
const xOffset = Math.min(Math.max(0, cmd.xOffset ?? 0), cw - 1);
const yOffset = Math.min(Math.max(0, cmd.yOffset ?? 0), ch - 1);
if (xOffset !== 0 || yOffset !== 0) {
// Per spec: X/Y is not added to c/r area. When c/r are explicit, the
// total placement area remains c*cw × r*ch pixels and the offset image
// is clipped to fit. When c/r are unset, the padded canvas determines
// the natural cell dimensions.
const canvasW = (cmd.columns !== undefined) ? Math.round(imgCols * cw) : bitmap.width + xOffset;
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : bitmap.height + yOffset;
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
const offsetCtx = offsetCanvas.getContext('2d');
if (!offsetCtx) {
throw new Error('Failed to create offset canvas context');
}
offsetCtx.drawImage(bitmap, xOffset, yOffset);
const offsetBitmap = await createImageBitmap(offsetCanvas);
offsetCanvas.width = offsetCanvas.height = 0;
bitmap.close();
bitmap = offsetBitmap;
w = bitmap.width;
h = bitmap.height;
if (w * h > this._opts.pixelLimit) {
throw new Error('image exceeds pixel limit');
}
if (cmd.columns === undefined) {
imgCols = Math.ceil(bitmap.width / cw);
}
if (cmd.rows === undefined) {
imgRows = Math.ceil(bitmap.height / ch);
}
}
const zIndex = cmd.zIndex ?? 0;
this._kittyStorage.addImage(image.id, bitmap, true, layer, zIndex);
bitmap = undefined; // ownership transferred to storage
// Kitty cursor movement
// Per spec: cursor placed at first column after last image column,
// on the last row of the image. C=1 means don't move cursor.
if (cmd.cursorMovement === 1) {
// C=1: restore cursor to position before image was placed
const scrolled = buffer.ybase - savedYbase;
buffer.x = savedX;
// Can't restore cursor to scrollback?
buffer.y = Math.max(savedY - scrolled, 0);
} else {
// Default (C=0): advance cursor horizontally past the image
// addImage already positioned cursor on the last row via lineFeeds
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
}
} catch (e) {
bitmap?.close();
throw e;
}
}
// Create ImageBitmap from already-decoded image data.
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
if (image.compression === KittyCompression.ZLIB) {
bytes = await this._decompressZlib(bytes);
}
if (image.format === KittyFormat.PNG) {
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
if (!window.createImageBitmap) {
const url = URL.createObjectURL(blob);
const img = new Image();
return new Promise<ImageBitmap>((resolve, reject) => {
img.addEventListener('load', () => {
URL.revokeObjectURL(url);
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
canvas.getContext('2d')?.drawImage(img, 0, 0);
createImageBitmap(canvas).then(resolve).catch(reject);
});
img.addEventListener('error', () => {
URL.revokeObjectURL(url);
reject(new Error('Failed to load image'));
});
img.src = url;
});
}
return createImageBitmap(blob);
}
// Raw pixel data
const width = image.width;
const height = image.height;
if (!width || !height) {
throw new Error('Width and height required for raw pixel data');
}
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) {
throw new Error('Insufficient pixel data');
}
const pixelCount = width * height;
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 * 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 * 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
let srcOffset = 0;
let dstOffset = 0;
for (let i = 0; i < alignedPixels; i += 4) {
const b0 = src32[srcOffset++];
const b1 = src32[srcOffset++];
const b2 = src32[srcOffset++];
// Little-endian: pixel bytes are [R,G,B] → uint32 ABGR layout
dst32[dstOffset++] = 0xFF000000 | b0;
dst32[dstOffset++] = 0xFF000000 | (b0 >>> 24) | (b1 << 8);
dst32[dstOffset++] = 0xFF000000 | (b1 >>> 16) | (b2 << 16);
dst32[dstOffset++] = 0xFF000000 | (b2 >>> 8);
}
// Handle remaining 1–3 pixels
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] = KittyPixelConstants.ALPHA_OPAQUE;
srcByte += KittyPixelConstants.BYTES_PER_PIXEL_RGB;
dstByte += KittyPixelConstants.BYTES_PER_PIXEL_RGBA;
}
return createImageBitmap(new ImageData(data, width, height));
}
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
try {
return await this._decompress(compressed, 'deflate');
} catch {
return await this._decompress(compressed, 'deflate-raw');
}
}
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
const ds = new DecompressionStream(format);
const writer = ds.writable.getWriter();
writer.write(compressed as BufferSource);
writer.close();
const chunks: Uint8Array[] = [];
const reader = ds.readable.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.length;
}
return result;
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._kittyStorage.images;
}
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyStorage.kittyIdToStorageId;
}
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
return this._pendingTransmissions;
}
}
@@ -1,149 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { assert } from 'chai';
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
describe('KittyGraphicsTypes', () => {
describe('parseKittyCommand', () => {
it('should parse control data with action and format', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.action, 'T');
assert.strictEqual(cmd.format, 100);
});
it('should parse control data with all options', () => {
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.id, 5);
assert.strictEqual(cmd.width, 10);
assert.strictEqual(cmd.height, 20);
assert.strictEqual(cmd.columns, 3);
assert.strictEqual(cmd.rows, 2);
assert.strictEqual(cmd.more, 1);
assert.strictEqual(cmd.quiet, 2);
});
it('should handle empty control data', () => {
const cmd = parseKittyCommand('');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, undefined);
});
it('should parse transmit action', () => {
const cmd = parseKittyCommand('a=t,f=100');
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
assert.strictEqual(cmd.format, KittyFormat.PNG);
});
it('should parse delete action', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.action, KittyAction.DELETE);
assert.strictEqual(cmd.id, 5);
});
it('should parse empty action as empty string', () => {
const cmd = parseKittyCommand('a=,f=100');
assert.strictEqual(cmd.action, '');
assert.strictEqual(cmd.format, 100);
});
it('should leave action undefined when key is not present', () => {
const cmd = parseKittyCommand('f=100,i=5');
assert.strictEqual(cmd.action, undefined);
assert.strictEqual(cmd.format, 100);
assert.strictEqual(cmd.id, 5);
});
it('should parse compression key', () => {
const cmd = parseKittyCommand('a=t,f=32,o=z');
assert.strictEqual(cmd.action, 't');
assert.strictEqual(cmd.format, 32);
assert.strictEqual(cmd.compression, 'z');
});
it('should parse cursor movement key', () => {
const cmd = parseKittyCommand('a=T,f=100,C=1');
assert.strictEqual(cmd.cursorMovement, 1);
});
it('should parse cursor movement key C=0', () => {
const cmd = parseKittyCommand('a=T,f=100,C=0');
assert.strictEqual(cmd.cursorMovement, 0);
});
it('should parse x and y offset', () => {
const cmd = parseKittyCommand('a=T,x=10,y=20');
assert.strictEqual(cmd.x, 10);
assert.strictEqual(cmd.y, 20);
});
it('should handle keys without values', () => {
const cmd = parseKittyCommand('a=t,f=,i=5');
assert.strictEqual(cmd.action, 't');
assert.ok(isNaN(cmd.format!));
assert.strictEqual(cmd.id, 5);
});
it('should parse z-index key with positive value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=10');
assert.strictEqual(cmd.zIndex, 10);
});
it('should parse z-index key with zero', () => {
const cmd = parseKittyCommand('a=T,f=100,z=0');
assert.strictEqual(cmd.zIndex, 0);
});
it('should parse z-index key with negative value', () => {
const cmd = parseKittyCommand('a=T,f=100,z=-1');
assert.strictEqual(cmd.zIndex, -1);
});
it('should leave zIndex undefined when not specified', () => {
const cmd = parseKittyCommand('a=T,f=100');
assert.strictEqual(cmd.zIndex, undefined);
});
it('should parse delete selector key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.action, 'd');
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should parse uppercase delete selector', () => {
const cmd = parseKittyCommand('a=d,d=A');
assert.strictEqual(cmd.deleteSelector, 'A');
});
it('should parse delete selector d=a (all)', () => {
const cmd = parseKittyCommand('a=d,d=a');
assert.strictEqual(cmd.deleteSelector, 'a');
});
it('should leave deleteSelector undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,i=5');
assert.strictEqual(cmd.deleteSelector, undefined);
});
it('should parse placement id key', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
assert.strictEqual(cmd.placementId, 3);
assert.strictEqual(cmd.deleteSelector, 'i');
assert.strictEqual(cmd.id, 5);
});
it('should leave placementId undefined when not specified', () => {
const cmd = parseKittyCommand('a=d,d=i,i=5');
assert.strictEqual(cmd.placementId, undefined);
});
it('should parse image number key', () => {
const cmd = parseKittyCommand('a=t,f=100,I=42');
assert.strictEqual(cmd.imageNumber, 42);
});
});
});
@@ -1,195 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*
* Kitty graphics protocol types, constants, and parsing utilities.
*/
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
// Kitty graphics protocol action types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
export const enum KittyAction {
TRANSMIT = 't',
TRANSMIT_DISPLAY = 'T',
QUERY = 'q',
PLACEMENT = 'p',
DELETE = 'd'
}
// Kitty graphics protocol format types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyFormat {
RGB = 24,
RGBA = 32,
PNG = 100
}
// Kitty graphics protocol compression types.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
export const enum KittyCompression {
NONE = '',
ZLIB = 'z'
}
// Kitty graphics protocol control data keys.
// See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
export const enum KittyKey {
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
ACTION = 'a',
// Image format (24=RGB, 32=RGBA, 100=PNG)
FORMAT = 'f',
// Image ID for referencing stored images
ID = 'i',
// Image number (alternative to ID, terminal assigns ID)
IMAGE_NUMBER = 'I',
// Source image width in pixels
WIDTH = 's',
// Source image height in pixels
HEIGHT = 'v',
// The left edge (in pixels) of the image area to display
X_OFFSET = 'x',
// The top edge (in pixels) of the image area to display
Y_OFFSET = 'y',
// Width (in pixels) of the source rectangle to display
SOURCE_WIDTH = 'w',
// Height (in pixels) of the source rectangle to display
SOURCE_HEIGHT = 'h',
// Horizontal offset (in pixels) within the first cell
X_PLACEMENT_OFFSET = 'X',
// Vertical offset (in pixels) within the first cell
Y_PLACEMENT_OFFSET = 'Y',
// Number of terminal columns to display the image over
COLUMNS = 'c',
// Number of terminal rows to display the image over
ROWS = 'r',
// More data flag (1=more chunks coming, 0=final chunk)
MORE = 'm',
// Compression type (z=zlib). This is essential for chunking larger images.
COMPRESSION = 'o',
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
QUIET = 'q',
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
CURSOR_MOVEMENT = 'C',
// Z-index for image layering (negative = behind text, 0+ = on top)
Z_INDEX = 'z',
// Transmission medium (d=direct, f=file, t=temp file, s=shared memory)
TRANSMISSION = 't',
// Delete selector (a/A=all, i/I=by id, c/C=at cursor, etc.) — only used when a=d
DELETE_SELECTOR = 'd',
// Placement ID for targeting specific placements
PLACEMENT_ID = 'p'
}
// Pixel format constants
export const enum KittyPixelConstants {
BYTES_PER_PIXEL_RGB = 3,
BYTES_PER_PIXEL_RGBA = 4,
ALPHA_OPAQUE = 255
}
// Parsed Kitty graphics command.
export interface IKittyCommand {
action?: string;
format?: number;
id?: number;
imageNumber?: number;
width?: number;
height?: number;
x?: number;
y?: number;
sourceWidth?: number;
sourceHeight?: number;
xOffset?: number;
yOffset?: number;
columns?: number;
rows?: number;
more?: number;
quiet?: number;
cursorMovement?: number;
zIndex?: number;
transmission?: string;
deleteSelector?: string;
placementId?: number;
compression?: string;
payload?: string;
}
// Pending chunked transmission state.
// Stores metadata from the first chunk while accumulating decoded payload data.
export interface IPendingTransmission {
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
cmd: IKittyCommand;
// Decoder used across chunked payloads
decoder: Base64Decoder;
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
totalEncodedSize: number;
// Whether any chunk has failed to decode
decodeError: boolean;
}
// Stored Kitty image data.
export interface IKittyImageData {
id: number;
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
data: Blob;
width: number;
height: number;
format: 24 | 32 | 100;
compression?: string;
}
// Parses Kitty graphics control data into a command object.
export function parseKittyCommand(data: string): IKittyCommand {
const cmd: IKittyCommand = {};
const parts = data.split(',');
for (const part of parts) {
const eqIdx = part.indexOf('=');
if (eqIdx === -1) continue;
const key = part.substring(0, eqIdx);
const value = part.substring(eqIdx + 1);
// Handle string keys first
if (key === KittyKey.ACTION) {
cmd.action = value;
continue;
}
if (key === KittyKey.COMPRESSION) {
cmd.compression = value;
continue;
}
if (key === KittyKey.TRANSMISSION) {
cmd.transmission = value;
continue;
}
if (key === KittyKey.DELETE_SELECTOR) {
cmd.deleteSelector = value;
continue;
}
const numValue = parseInt(value);
switch (key) {
case KittyKey.FORMAT: cmd.format = numValue; break;
case KittyKey.ID: cmd.id = numValue; break;
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
case KittyKey.WIDTH: cmd.width = numValue; break;
case KittyKey.HEIGHT: cmd.height = numValue; break;
case KittyKey.X_OFFSET: cmd.x = numValue; break;
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
case KittyKey.SOURCE_WIDTH: cmd.sourceWidth = numValue; break;
case KittyKey.SOURCE_HEIGHT: cmd.sourceHeight = numValue; break;
case KittyKey.X_PLACEMENT_OFFSET: cmd.xOffset = numValue; break;
case KittyKey.Y_PLACEMENT_OFFSET: cmd.yOffset = numValue; break;
case KittyKey.COLUMNS: cmd.columns = numValue; break;
case KittyKey.ROWS: cmd.rows = numValue; break;
case KittyKey.MORE: cmd.more = numValue; break;
case KittyKey.QUIET: cmd.quiet = numValue; break;
case KittyKey.CURSOR_MOVEMENT: cmd.cursorMovement = numValue; break;
case KittyKey.Z_INDEX: cmd.zIndex = numValue; break;
case KittyKey.PLACEMENT_ID: cmd.placementId = numValue; break;
}
}
return cmd;
}
@@ -1,151 +0,0 @@
/**
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
* @license MIT
*/
import { IDisposable } from '@xterm/xterm';
import { ImageStorage } from '../ImageStorage';
import { ImageLayer } from '../Types';
import { IKittyImageData } from './KittyGraphicsTypes';
// Kitty-specific image storage controller.
//
// Wraps shared ImageStorage with kitty protocol semantics:
// - tracks transmitted image payloads by kitty image id
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
// - mirrors shared-storage evictions into kitty maps
// - applies protocol-level undisplayed-image eviction policy
export class KittyImageStorage implements IDisposable {
private static readonly _maxStoredImages = 256;
private _nextImageId = 1;
private readonly _images: Map<number, IKittyImageData> = new Map();
// TODO: Support multiple placements per image. The kitty spec identifies
// placements by an (image id, placement id) pair — same i + different p
// values should coexist, and same i + same p should replace the prior
// placement. Currently we track only one storage entry per kitty image id,
// so multiple placements of the same image overwrite each other. Fixing
// this requires changing these maps to Map<number, Map<number, number>>
// (kittyId → placementId → storageId) and updating addImage/deleteById
// accordingly. The underlying shared ImageStorage would also need to
// support multiple entries per logical image.
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
private readonly _storageIdToKittyId: Map<number, number> = new Map();
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
private readonly _handleStorageImageDeleted = (storageId: number): void => {
const kittyId = this._storageIdToKittyId.get(storageId);
if (kittyId !== undefined) {
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
this._images.delete(kittyId);
}
};
constructor(
private readonly _storage: ImageStorage
) {
this._previousOnImageDeleted = this._storage.onImageDeleted;
this._wrappedOnImageDeleted = (storageId: number) => {
this._previousOnImageDeleted?.(storageId);
this._handleStorageImageDeleted(storageId);
};
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
}
public reset(): void {
this._nextImageId = 1;
this._images.clear();
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public dispose(): void {
this.reset();
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
this._storage.onImageDeleted = this._previousOnImageDeleted;
}
}
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
const imageId = id ?? this._nextImageId++;
const oldStorageId = this._kittyIdToStorageId.get(imageId);
if (oldStorageId !== undefined) {
this._storage.deleteImage(oldStorageId);
this._kittyIdToStorageId.delete(imageId);
this._storageIdToKittyId.delete(oldStorageId);
}
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
this._evictUndisplayedImages();
}
this._images.set(imageId, {
...imageData,
id: imageId
});
return imageId;
}
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
// Clean up stale reverse-mapping from a previous placement of the same
// kitty image. The old shared-storage entry is kept (it may still be
// visible on screen) but its reverse mapping is removed so that eviction
// of the old entry won't incorrectly delete the kitty image data.
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
if (oldStorageId !== undefined) {
this._storageIdToKittyId.delete(oldStorageId);
}
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
this._kittyIdToStorageId.set(kittyId, storageId);
this._storageIdToKittyId.set(storageId, kittyId);
}
public getImage(kittyId: number): IKittyImageData | undefined {
return this._images.get(kittyId);
}
public deleteById(kittyId: number): void {
this._images.delete(kittyId);
const storageId = this._kittyIdToStorageId.get(kittyId);
if (storageId !== undefined) {
this._storage.deleteImage(storageId);
this._kittyIdToStorageId.delete(kittyId);
this._storageIdToKittyId.delete(storageId);
}
}
public deleteAll(): void {
this._images.clear();
for (const storageId of this._kittyIdToStorageId.values()) {
this._storage.deleteImage(storageId);
}
this._kittyIdToStorageId.clear();
this._storageIdToKittyId.clear();
}
public get images(): ReadonlyMap<number, IKittyImageData> {
return this._images;
}
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
return this._kittyIdToStorageId;
}
public get lastImageId(): number {
return this._nextImageId - 1;
}
private _evictUndisplayedImages(): void {
for (const [kittyId] of this._images) {
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
break;
}
if (!this._kittyIdToStorageId.has(kittyId)) {
this._images.delete(kittyId);
}
}
}
}
+2 -2
View File
@@ -11,11 +11,11 @@
"types": [
"../../../node_modules/@types/mocha"
],
"baseUrl": ".",
"paths": {
"browser/*": [ "../../../src/browser/*" ],
"common/*": [ "../../../src/common/*" ],
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
"*": [ "./*" ]
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
}
},
"include": [
+8 -92
View File
@@ -8,7 +8,6 @@ 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.
@@ -24,8 +23,6 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
kittySupport: boolean;
kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -75,10 +72,6 @@ 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 }) => {
@@ -90,6 +83,11 @@ 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 +130,11 @@ test.describe('ImageAddon', () => {
sixelSupport: true,
sixelScrolling: true,
sixelPaletteLimit: 512, // set to 512 to get example image working
sixelSizeLimit: 33554432,
sixelSizeLimit: 25000000,
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
iipSizeLimit: 33554432,
kittySupport: true,
kittySizeLimit: 33554432
iipSizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -153,9 +149,7 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
iipSizeLimit: 1000,
kittySupport: false,
kittySizeLimit: 1000
iipSizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -196,18 +190,6 @@ test.describe('ImageAddon', () => {
});
test.describe('image lifecycle & eviction', () => {
test('onImageAdded fires for each image', async () => {
await ctx.page.evaluate(`
window._imageAddedCount = 0;
window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
`);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 1);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 2);
await ctx.proxy.write(SIXEL_SEQ_0);
await pollFor(ctx.page, 'window._imageAddedCount', 3);
});
test('delete image once scrolled off', async () => {
await ctx.proxy.write(SIXEL_SEQ_0);
pollFor(ctx.page, 'window.imageAddon._storage._images.size', 1);
@@ -312,68 +294,6 @@ 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]);
});
}
});
});
/**
@@ -407,7 +327,3 @@ 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')`);
}
File diff suppressed because it is too large Load Diff
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -15
View File
@@ -3,7 +3,7 @@
* @license MIT
*/
import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
import { Terminal, ITerminalAddon } from '@xterm/xterm';
declare module '@xterm/addon-image' {
export interface IImageAddonOptions {
@@ -76,15 +76,6 @@ declare module '@xterm/addon-image' {
iipSupport?: boolean;
/** IIP sequence size limit (default 20000000 bytes). */
iipSizeLimit?: number;
/**
* Kitty graphics protocol settings
*/
/** Whether Kitty graphics protocol is enabled (default is true). */
kittySupport?: boolean;
/** Kitty image size limit in bytes (default 20000000 bytes). */
kittySizeLimit?: number;
}
export class ImageAddon implements ITerminalAddon {
@@ -116,11 +107,6 @@ declare module '@xterm/addon-image' {
*/
public showPlaceholder: boolean;
/**
* Event fired whenever a new image is added to storage.
*/
public readonly onImageAdded: IEvent<void>;
/**
* Get original image canvas at buffer position.
*/
+4 -4
View File
@@ -14,8 +14,8 @@
"node": ">8.0.0"
},
"scripts": {
"build": "tsgo -p src",
"watch": "tsgo -w -p src",
"build": "tsc -p src",
"watch": "tsc -w -p src",
"prepackage": "npm run build",
"package": "webpack",
"pretest": "npm run build",
@@ -37,9 +37,9 @@
"devDependencies": {
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.15.0",
"axios": "^1.6.0",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^3.2.1"
"yauzl": "^2.10.0"
}
}
+1 -5
View File
@@ -9,12 +9,8 @@
"outDir": "../out-esbuild-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"strict": true,
"paths": {
"*": [
"./*"
]
},
"types": [
"../../../node_modules/@types/mocha",
"../../../node_modules/@types/node"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"prepackage": "../../node_modules/.bin/tsgo -p .",
"prepackage": "../../node_modules/.bin/tsc -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
@@ -4,13 +4,13 @@
"dom",
"es2021"
],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "node",
"strict": false,
"target": "es2021",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -1
View File
@@ -10,6 +10,7 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -22,7 +23,6 @@
]
},
"strict": true,
"skipLibCheck": true,
"types": [
"../../../node_modules/@types/mocha"
]
@@ -49,14 +49,7 @@ 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, (key, value) => {
// BufferLine caches are internal/transient and can legitimately differ
// across equivalent terminal states.
if (key === '_stringCache' || key === '_stringCacheEntryRef') {
return undefined;
}
return value;
}));
lines.push(JSON.stringify(bufferLine));
}
return {
x: buffer.cursorX,
@@ -9,6 +9,7 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -1,13 +1,13 @@
{
"compilerOptions": {
"lib": ["dom", "es6"],
"rootDir": "..",
"outDir": "../out-benchmark",
"types": ["../../../node_modules/@types/node"],
"moduleResolution": "node",
"strict": false,
"target": "es2015",
"module": "commonjs",
"baseUrl": ".",
"paths": {
"common/*": ["../../../src/common/*"],
"browser/*": ["../../../src/browser/*"],
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
@@ -11,6 +11,7 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -9,6 +9,7 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -3
View File
@@ -11,15 +11,13 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"@xterm/addon-unicode11": [
"../typings/addon-unicode11.d.ts"
],
"*": [
"./*"
]
},
"types": [
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
@@ -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(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/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(/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);
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);
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(/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);
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);
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(/fonts/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/fonts/bpdots.regular.otf) format('opentype')");
const ff1 = new FontFace('Kongtext', "url(/kongtext.regular.ttf) format('truetype')");
const ff2 = new FontFace('BPdots', "url(/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(/fonts/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
document.styleSheets[0].insertRule("@font-face {font-family: Kongtext; src: url(/kongtext.regular.ttf) format('truetype'); unicode-range: U+00A0-00FF}", 0);
`);
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
@@ -88,11 +88,7 @@ test.describe('WebFontsAddon', () => {
// safari loads the font, firefox & chrome dont
if (browser.browserType().name() === 'webkit') {
try {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loading' }]);
} catch (e) {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
} else {
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'unloaded' }]);
}
@@ -107,10 +103,7 @@ test.describe('WebFontsAddon', () => {
window.helperTerm.open(term.element);
`);
await timeout(100);
if (browser.browserType().name() !== 'webkit') {
// safari sometimes reports 'unloadeed'?
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
}
deepStrictEqual(await getDocumentFonts(), [{ family: 'Kongtext', status: 'loaded' }]);
// cleanup this messy test case
await ctx.page.evaluate(`
+1 -3
View File
@@ -10,15 +10,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+1 -3
View File
@@ -10,15 +10,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -1
View File
@@ -17,7 +17,7 @@
"xterm.js"
],
"scripts": {
"build": "../../node_modules/.bin/tsgo -p .",
"build": "../../node_modules/.bin/tsc -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
+16 -18
View File
@@ -78,11 +78,9 @@ void main() {
}`);
}
const enum Constants {
INDICES_PER_CELL = 11,
BYTES_PER_CELL = INDICES_PER_CELL * 4/* Float32Array.BYTES_PER_ELEMENT */,
CELL_POSITION_INDICES = 2
}
const INDICES_PER_CELL = 11;
const BYTES_PER_CELL = INDICES_PER_CELL * Float32Array.BYTES_PER_ELEMENT;
const CELL_POSITION_INDICES = 2;
// Work variables to avoid garbage collection
let $i = 0;
@@ -162,22 +160,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, Constants.BYTES_PER_CELL, 0);
gl.vertexAttribPointer(VertexAttribLocations.OFFSET, 2, gl.FLOAT, false, BYTES_PER_CELL, 0);
gl.vertexAttribDivisor(VertexAttribLocations.OFFSET, 1);
gl.enableVertexAttribArray(VertexAttribLocations.SIZE);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, Constants.BYTES_PER_CELL, 2 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.SIZE, 2, gl.FLOAT, false, 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, Constants.BYTES_PER_CELL, 4 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXPAGE, 1, gl.FLOAT, false, 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, Constants.BYTES_PER_CELL, 5 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXCOORD, 2, gl.FLOAT, false, 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, Constants.BYTES_PER_CELL, 7 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.TEXSIZE, 2, gl.FLOAT, false, 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, Constants.BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribPointer(VertexAttribLocations.CELL_POSITION, 2, gl.FLOAT, false, BYTES_PER_CELL, 9 * Float32Array.BYTES_PER_ELEMENT);
gl.vertexAttribDivisor(VertexAttribLocations.CELL_POSITION, 1);
// Setup static uniforms
@@ -224,12 +222,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) * Constants.INDICES_PER_CELL;
$i = (y * this._terminal.cols + x) * 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 + Constants.INDICES_PER_CELL - 1 - Constants.CELL_POSITION_INDICES);
array.fill(0, $i, $i + INDICES_PER_CELL - 1 - CELL_POSITION_INDICES);
return;
}
@@ -290,7 +288,7 @@ export class GlyphRenderer extends Disposable {
public clear(): void {
const terminal = this._terminal;
const newCount = terminal.cols * terminal.rows * Constants.INDICES_PER_CELL;
const newCount = terminal.cols * terminal.rows * INDICES_PER_CELL;
// Clear vertices
if (this._vertices.count !== newCount) {
@@ -312,7 +310,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 += Constants.INDICES_PER_CELL;
i += INDICES_PER_CELL;
}
}
}
@@ -348,8 +346,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 * Constants.INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * Constants.INDICES_PER_CELL);
const si = y * this._terminal.cols * INDICES_PER_CELL;
const sub = this._vertices.attributes.subarray(si, si + renderModel.lineLengths[y] * INDICES_PER_CELL);
activeBuffer.set(sub, bufferLength);
bufferLength += sub.length;
}
@@ -366,7 +364,7 @@ export class GlyphRenderer extends Disposable {
}
// Draw the viewport
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / Constants.INDICES_PER_CELL);
gl.drawElementsInstanced(gl.TRIANGLE_STRIP, 4, gl.UNSIGNED_BYTE, 0, bufferLength / INDICES_PER_CELL);
}
public setAtlas(atlas: ITextureAtlas): void {
+4 -4
View File
@@ -10,7 +10,7 @@ import { Attributes, FgFlags } from 'common/buffer/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IColor } from 'common/Types';
import { Terminal } from '@xterm/xterm';
import { RenderModelConstants } from './RenderModel';
import { RENDER_MODEL_BG_OFFSET, RENDER_MODEL_FG_OFFSET, RENDER_MODEL_INDICIES_PER_CELL } from './RenderModel';
import { IRenderModel, IWebGL2RenderingContext, IWebGLVertexArrayObject } from './Types';
import { createProgram, expandFloat32Array, PROJECTION_MATRIX } from './WebglUtils';
import { throwIfFalsy } from 'browser/renderer/shared/RendererUtils';
@@ -217,9 +217,9 @@ export class RectangleRenderer extends Disposable {
currentFg = 0;
currentInverse = false;
for (x = 0; x < terminal.cols; x++) {
modelIndex = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
bg = model.cells[modelIndex + RenderModelConstants.BG_OFFSET];
fg = model.cells[modelIndex + RenderModelConstants.FG_OFFSET];
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];
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
+5 -7
View File
@@ -7,12 +7,10 @@ import { ICursorRenderModel, IRenderModel } from './Types';
import { ISelectionRenderModel } from 'browser/renderer/shared/Types';
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
export const enum RenderModelConstants {
INDICIES_PER_CELL = 4,
BG_OFFSET = 1,
FG_OFFSET = 2,
EXT_OFFSET = 3
}
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 COMBINED_CHAR_BIT_MASK = 0x80000000;
@@ -29,7 +27,7 @@ export class RenderModel implements IRenderModel {
}
public resize(cols: number, rows: number): void {
const indexCount = cols * rows * RenderModelConstants.INDICIES_PER_CELL;
const indexCount = cols * rows * RENDER_MODEL_INDICIES_PER_CELL;
if (indexCount !== this.cells.length) {
this.cells = new Uint32Array(indexCount);
this.lineLengths = new Uint32Array(rows);
+13 -13
View File
@@ -19,7 +19,7 @@ import { ICoreService, IDecorationService, IOptionsService } from 'common/servic
import { Terminal } from '@xterm/xterm';
import { GlyphRenderer } from './GlyphRenderer';
import { RectangleRenderer } from './RectangleRenderer';
import { COMBINED_CHAR_BIT_MASK, RenderModel, RenderModelConstants } from './RenderModel';
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 { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
import { IRenderLayer } from './renderLayer/Types';
@@ -30,7 +30,7 @@ import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
export class WebglRenderer extends Disposable implements IRenderer {
private _renderLayers: IRenderLayer[];
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = this._register(new MutableDisposable());
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
private _textBlinkStateManager: TextBlinkStateManager;
private _charAtlasDisposable = this._register(new MutableDisposable());
private _charAtlas: ITextureAtlas | undefined;
@@ -493,7 +493,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
chars = cell.getChars();
code = cell.getCode();
i = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
if (!rowHasBlinkingCells && cell.isBlink()) {
rowHasBlinkingCells = true;
@@ -538,9 +538,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Nothing has changed, no updates needed
if (this._model.cells[i] === code &&
this._model.cells[i + 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) {
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) {
continue;
}
@@ -553,9 +553,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Cache the results in the model
this._model.cells[i] = code;
this._model.cells[i + 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;
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;
width = cell.getWidth();
this._glyphRenderer.value!.updateCell(x, y, code, this._cellColorResolver.result.bg, this._cellColorResolver.result.fg, this._cellColorResolver.result.ext, chars, width, lastBg);
@@ -566,14 +566,14 @@ export class WebglRenderer extends Disposable implements IRenderer {
// Null out non-first cells
for (x++; x <= lastCharX; x++) {
j = ((y * terminal.cols) + x) * RenderModelConstants.INDICIES_PER_CELL;
j = ((y * terminal.cols) + x) * RENDER_MODEL_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 + 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;
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;
}
x--; // Go back to the previous update cell for next iteration
}
@@ -7,35 +7,6 @@ import { CustomGlyphDefinitionType, CustomGlyphScaleType, CustomGlyphVectorType,
/* eslint-disable max-len */
const enum Shapes {
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
/** ─ */ LEFT_TO_RIGHT = 'M0,.5 L1,.5',
/** └ */ TOP_TO_RIGHT = 'M.5,0 L.5,.5 L1,.5',
/** ┘ */ TOP_TO_LEFT = 'M.5,0 L.5,.5 L0,.5',
/** ┐ */ LEFT_TO_BOTTOM = 'M0,.5 L.5,.5 L.5,1',
/** ┌ */ RIGHT_TO_BOTTOM = 'M0.5,1 L.5,.5 L1,.5',
/** ╵ */ MIDDLE_TO_TOP = 'M.5,.5 L.5,0',
/** ╴ */ MIDDLE_TO_LEFT = 'M.5,.5 L0,.5',
/** ╶ */ MIDDLE_TO_RIGHT = 'M.5,.5 L1,.5',
/** ╷ */ MIDDLE_TO_BOTTOM = 'M.5,.5 L.5,1',
/** ┴ */ T_TOP = 'M0,.5 L1,.5 M.5,.5 L.5,0',
/** ┤ */ T_LEFT = 'M.5,0 L.5,1 M.5,.5 L0,.5',
/** ├ */ T_RIGHT = 'M.5,0 L.5,1 M.5,.5 L1,.5',
/** ┬ */ T_BOTTOM = 'M0,.5 L1,.5 M.5,.5 L.5,1',
/** ┼ */ CROSS = 'M0,.5 L1,.5 M.5,0 L.5,1',
/** ╌ */ TWO_DASHES_HORIZONTAL = 'M.1,.5 L.4,.5 M.6,.5 L.9,.5', // .2 empty, .3 filled
/** ┄ */ THREE_DASHES_HORIZONTAL = 'M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5', // .1333 empty, .2 filled
/** ┉ */ FOUR_DASHES_HORIZONTAL = 'M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5', // .1 empty, .15 filled
/** ╎ */ TWO_DASHES_VERTICAL = 'M.5,.1 L.5,.4 M.5,.6 L.5,.9',
/** ┆ */ THREE_DASHES_VERTICAL = 'M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333',
/** ┊ */ FOUR_DASHES_VERTICAL = 'M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95',
}
namespace GitBranchSymbolsParts {
// Lines
export const LINE_H: CustomGlyphDefinitionPart = Object.freeze({ type: CustomGlyphDefinitionType.PATH_FUNCTION, data: Shapes.LEFT_TO_RIGHT, strokeWidth: 1 });
@@ -1017,3 +988,31 @@ function segmentedDigit(pattern: number): string {
return paths.join(' ');
}
const enum Shapes {
/** │ */ TOP_TO_BOTTOM = 'M.5,0 L.5,1',
/** ─ */ LEFT_TO_RIGHT = 'M0,.5 L1,.5',
/** └ */ TOP_TO_RIGHT = 'M.5,0 L.5,.5 L1,.5',
/** ┘ */ TOP_TO_LEFT = 'M.5,0 L.5,.5 L0,.5',
/** ┐ */ LEFT_TO_BOTTOM = 'M0,.5 L.5,.5 L.5,1',
/** ┌ */ RIGHT_TO_BOTTOM = 'M0.5,1 L.5,.5 L1,.5',
/** ╵ */ MIDDLE_TO_TOP = 'M.5,.5 L.5,0',
/** ╴ */ MIDDLE_TO_LEFT = 'M.5,.5 L0,.5',
/** ╶ */ MIDDLE_TO_RIGHT = 'M.5,.5 L1,.5',
/** ╷ */ MIDDLE_TO_BOTTOM = 'M.5,.5 L.5,1',
/** ┴ */ T_TOP = 'M0,.5 L1,.5 M.5,.5 L.5,0',
/** ┤ */ T_LEFT = 'M.5,0 L.5,1 M.5,.5 L0,.5',
/** ├ */ T_RIGHT = 'M.5,0 L.5,1 M.5,.5 L1,.5',
/** ┬ */ T_BOTTOM = 'M0,.5 L1,.5 M.5,.5 L.5,1',
/** ┼ */ CROSS = 'M0,.5 L1,.5 M.5,0 L.5,1',
/** ╌ */ TWO_DASHES_HORIZONTAL = 'M.1,.5 L.4,.5 M.6,.5 L.9,.5', // .2 empty, .3 filled
/** ┄ */ THREE_DASHES_HORIZONTAL = 'M.0667,.5 L.2667,.5 M.4,.5 L.6,.5 M.7333,.5 L.9333,.5', // .1333 empty, .2 filled
/** ┉ */ FOUR_DASHES_HORIZONTAL = 'M.05,.5 L.2,.5 M.3,.5 L.45,.5 M.55,.5 L.7,.5 M.8,.5 L.95,.5', // .1 empty, .15 filled
/** ╎ */ TWO_DASHES_VERTICAL = 'M.5,.1 L.5,.4 M.5,.6 L.5,.9',
/** ┆ */ THREE_DASHES_VERTICAL = 'M.5,.0667 L.5,.2667 M.5,.4 L.5,.6 M.5,.7333 L.5,.9333',
/** ┊ */ FOUR_DASHES_VERTICAL = 'M.5,.05 L.5,.2 M.5,.3 L.5,.45 L.5,.55 M.5,.7 L.5,.95',
}
+1 -3
View File
@@ -10,6 +10,7 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -19,9 +20,6 @@
],
"@xterm/addon-webgl": [
"../typings/addon-webgl.d.ts"
],
"*": [
"./*"
]
},
"strict": true,
+5 -10
View File
@@ -15,10 +15,8 @@ test.beforeAll(async ({ browser }) => {
await openTerminal(ctx);
ctxWrapper.value = ctx;
await ctx.page.evaluate(`
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
`);
});
test.afterAll(async () => await ctx.page.close());
@@ -26,18 +24,15 @@ 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' || browserName === 'webkit');
test.skip(({ browserName }) => browserName === 'firefox');
}
injectSharedRendererTests(ctxWrapper);
injectSharedRendererTestsStandalone(ctxWrapper, async () => {
await ctx.page.evaluate(`
try {
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
} catch (e) {}
window.addon = new window.WebglAddon(true);
window.term.loadAddon(window.addon);
`);
});
});
+1 -4
View File
@@ -7,10 +7,7 @@ const config: PlaywrightTestConfig = {
{
name: 'Chromium',
use: {
browserName: 'chromium',
launchOptions: {
args: ['--enable-unsafe-swiftshader']
}
browserName: 'chromium'
}
},
{
+1 -3
View File
@@ -9,15 +9,13 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
"baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
],
"*": [
"./*"
]
},
"strict": true,
+1 -32
View File
@@ -57,9 +57,6 @@ xterm.js version: {{version}}
{{#CSI.length}}
- [CSI](#csi)
{{/CSI.length}}
{{#APC.length}}
- [APC](#apc)
{{/APC.length}}
{{#DCS.length}}
- [DCS](#dcs)
{{/DCS.length}}
@@ -83,9 +80,8 @@ 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)
Privacy Message (PM) and Start of String (SOS) are recognized but not supported,
Application Program Command (APC), 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
@@ -182,33 +178,6 @@ 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
-196
View File
@@ -1,196 +0,0 @@
from termios import tcgetattr, tcsetattr, TCSADRAIN, TIOCGWINSZ, TCSAFLUSH
from tty import setcbreak, setraw
import os
import sys
from contextlib import contextmanager
from select import select
from time import sleep
from json import dumps, loads
from typing import Optional, Tuple, Union
class TerminalContext:
def __init__(self, fd: int, close_fd=False) -> None:
if not os.isatty(fd):
raise TypeError('fd is not a terminal')
self.close_fd = close_fd
self.fd = fd
self.is_cbreak = False
self.is_raw = False
self._initial_attr = self.termios_attributes
@classmethod
def from_cterm(cls):
fd = os.open(os.ctermid(), os.O_RDWR)
return TerminalContext(fd, True)
def close(self) -> None:
tcsetattr(self.fd, TCSADRAIN, self._initial_attr)
if self.close_fd:
os.close(self.fd)
@property
def termios_attributes(self) -> list:
return tcgetattr(self.fd)
@property
def ttyname(self) -> str:
return os.ttyname(self.fd)
@contextmanager
def cbreak_mode(self):
"""
Enter cbreak mode context.
"""
if self.is_cbreak:
yield
return
tattr = self.termios_attributes
try:
setcbreak(self.fd, TCSADRAIN)
self.is_cbreak = True
yield
finally:
tcsetattr(self.fd, TCSADRAIN, tattr)
self.is_cbreak = False
@contextmanager
def raw_mode(self):
"""
Enter cbreak mode context.
"""
if self.is_raw:
yield
return
tattr = self.termios_attributes
try:
setraw(self.fd, TCSADRAIN)
self.is_raw = True
yield
finally:
tcsetattr(self.fd, TCSADRAIN, tattr)
self.is_raw = False
@contextmanager
def custom_state(self, undo=None):
"""
Enter custom terminal state, that needs to to be undone by ``undo``.
Useful, if you want to apply a custom terminal state and
have to make sure, that it gets properly reset to previous state.
"""
try:
yield
finally:
if undo:
undo()
def write(self, data: Union[str, bytes]) -> None:
"""
Write string or bytes directly to the terminal.
"""
if isinstance(data, str):
data = data.encode('utf-8')
sent = os.write(self.fd, data)
while sent:
data = data[sent:]
sent = os.write(self.fd, data)
def read(self, amount: int = 1024, timeout: Optional[float] = None) -> bytes:
"""
Blocking read from the terminal.
If nothing was sent from the terminal within ``timeout``,
empty bytes are returned.
"""
can_read, _, _ = select([self.fd], [], [], timeout)
if can_read:
return os.read(self.fd, amount)
return b''
@contextmanager
def cterminal_context():
t = TerminalContext.from_cterm()
try:
yield t
finally:
t.close()
def extract_events(data: list[str]):
if len(data) < 5:
raise Exception('not enough reports')
types = set(data)
if len(types) == 1:
return {'PRESS': data[0], 'REPEAT': data[0], 'RELEASE': None}
if len(types) == 2:
last = data.pop()
if last != data[0] and len(set(data)) == 1:
return {'PRESS': data[0], 'REPEAT': data[0], 'RELEASE': last}
raise Exception('weird reports, 2 types')
if len(types) == 3:
first = data.pop(0)
last = data.pop()
if first != last and first != data[0] and last != data[0]:
return {'PRESS': first, 'REPEAT': data[0], 'RELEASE': last}
raise Exception('weird reports, 3 types')
raise Exception('more than 3 types')
def query(term: TerminalContext, mode: int):
with term.custom_state(undo=lambda:term.write('\x1b[<u')):
term.write(f'\x1b[>{mode}u')
term.read(timeout=.1)
print('PRESS (within 5s) and HOLD (for 5s)\r')
data: list[bytes] = []
cur = term.read(timeout=5)
while cur:
data.append(cur)
cur = term.read(timeout=.5)
if len(data) > 5:
print('RELEASE\r', end='')
try:
return extract_events([b.decode('utf-8') for b in data])
except Exception as e:
print('Error:', e, '\r')
print(data, '\r')
term.read(timeout=.1)
def save(filedata: dict[str, dict[str, str]], filename, entry, mode, events):
try:
filedata[entry][mode] = events
except KeyError:
filedata[entry] = {mode: events}
with open(filename, 'w') as f:
f.write(dumps(filedata, indent=2))
def main():
mode = 1
filedata = {}
print(sys.argv)
if len(sys.argv) != 3:
print('ERROR: not enough arguments')
print('Usage: python kitty_keytester.py <mode> <json-file>')
return
mode = int(sys.argv[1])
filename = sys.argv[2]
if os.path.exists(filename):
with open(filename) as f:
filedata = loads(f.read())
with cterminal_context() as term:
while True:
with term.raw_mode():
events = query(term, mode)
if events:
print(events)
entry = input('Entry: ')
if entry:
save(filedata, filename, entry, str(mode), events)
cont = input('Continue? (y)')
if cont not in ['y', '']:
break
if __name__ == '__main__':
main()
-138
View File
@@ -1,138 +0,0 @@
// @ts-check
import { cpSync, existsSync, lstatSync, readFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { basename, dirname, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../..');
const nodeModulesPath = resolve(repoRoot, 'node_modules');
const npmExecutable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
/** @typedef {{ folder: string; reason: string }} Candidate */
/** @param {string} message */
function log(message) {
console.info(`[setup-fast] ${message}`);
}
/** @param {string[]} args */
function runNpm(args) {
log(`Running: npm ${args.join(' ')}`);
const result = spawnSync(npmExecutable, args, {
cwd: repoRoot,
stdio: 'inherit'
});
if (result.error) {
throw result.error;
}
if ((result.status ?? 1) !== 0) {
process.exit(result.status ?? 1);
}
}
/**
* @param {Candidate[]} candidates
* @param {string | undefined} folder
* @param {string} reason
*/
function addCandidate(candidates, folder, reason) {
if (!folder) {
return;
}
if (candidates.some(candidate => candidate.folder === folder)) {
return;
}
candidates.push({ folder, reason });
log(`Candidate found (${reason}): ${folder}`);
}
function detectMainSiblingFolder() {
const currentFolderName = basename(repoRoot);
if (!currentFolderName.startsWith('xterm.js') || currentFolderName === 'xterm.js') {
log(`Current folder is "${currentFolderName}", skipping xterm.js sibling lookup.`);
return undefined;
}
const siblingFolder = resolve(dirname(repoRoot), 'xterm.js');
log(`Current folder is "${currentFolderName}", sibling main folder candidate: ${siblingFolder}`);
return siblingFolder;
}
function detectWorktreeMainFolder() {
const gitPath = resolve(repoRoot, '.git');
if (!existsSync(gitPath)) {
log('No .git entry found at repo root.');
return undefined;
}
const gitStat = lstatSync(gitPath);
if (!gitStat.isFile()) {
log('.git is not a file, this repo does not appear to be a worktree checkout.');
return undefined;
}
const gitFileContent = readFileSync(gitPath, 'utf8').trim();
const gitDirMatch = /^gitdir:\s*(.+)$/m.exec(gitFileContent);
if (!gitDirMatch) {
log('Could not parse gitdir from .git file.');
return undefined;
}
const gitDirPath = resolve(repoRoot, gitDirMatch[1].trim());
log(`Parsed gitdir from .git file: ${gitDirPath}`);
const normalizedGitDirPath = gitDirPath.replace(/\\/g, '/');
const worktreeMarker = '/.git/worktrees/';
const markerIndex = normalizedGitDirPath.indexOf(worktreeMarker);
if (markerIndex === -1) {
log('gitdir path does not contain /.git/worktrees/, skipping worktree main folder lookup.');
return undefined;
}
const normalizedMainFolder = normalizedGitDirPath.slice(0, markerIndex);
const mainFolder = sep === '/' ? normalizedMainFolder : normalizedMainFolder.split('/').join(sep);
log(`Worktree main folder candidate: ${mainFolder}`);
return mainFolder;
}
function resolveSourceFolder() {
/** @type {Candidate[]} */
const candidates = [];
addCandidate(candidates, detectMainSiblingFolder(), 'xterm.js sibling');
addCandidate(candidates, detectWorktreeMainFolder(), 'worktree main repo');
for (const candidate of candidates) {
const candidateNodeModulesPath = resolve(candidate.folder, 'node_modules');
if (existsSync(candidateNodeModulesPath)) {
log(`Using candidate (${candidate.reason}) with node_modules: ${candidate.folder}`);
return candidate.folder;
}
log(`Candidate skipped (${candidate.reason}), node_modules missing: ${candidateNodeModulesPath}`);
}
log('No candidate folder with node_modules was found.');
return undefined;
}
if (!existsSync(nodeModulesPath)) {
log(`node_modules missing: ${nodeModulesPath}`);
const sourceFolder = resolveSourceFolder();
if (sourceFolder) {
const sourceNodeModulesPath = resolve(sourceFolder, 'node_modules');
log(`Copying node_modules from ${sourceNodeModulesPath} to ${nodeModulesPath}`);
try {
cpSync(sourceNodeModulesPath, nodeModulesPath, { recursive: true });
log('node_modules copy completed.');
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
log(`node_modules copy failed: ${message}`);
log('Falling back to npm ci.');
runNpm(['ci']);
}
} else {
log('No source folder available, running npm ci.');
runNpm(['ci']);
}
} else {
log(`node_modules already exists: ${nodeModulesPath}`);
}
runNpm(['run', 'setup']);
+4 -12
View File
@@ -328,9 +328,7 @@ function createTerminal(): Terminal {
}
const cols = size.cols;
const rows = size.rows;
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight;
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows;
fetch(url, { method: 'POST' });
});
@@ -381,9 +379,7 @@ function createTerminal(): Terminal {
if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) {
runFakeTerminal();
} else {
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight, { method: 'POST' });
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows, { method: 'POST' });
const processId = await res.text();
pid = processId;
socketURL += processId;
@@ -617,12 +613,8 @@ function addDomListener(element: HTMLElement, type: string, handler: (...args: a
}
function updateTerminalSize(): void {
const showScrollbar = term!.options.scrollbar?.showScrollbar ?? true;
const scrollBarWidth = (term!.options.scrollback === 0 || !showScrollbar)
? 0
: (term!.options.scrollbar?.width ?? 14);
const width = optionsWindow.autoResize ? '100%'
: ((term as any).dimensions.css.canvas.width + scrollBarWidth).toString() + 'px';
: ((term as any).dimensions.css.canvas.width + (term as any)._core.viewport.scrollBarWidth).toString() + 'px';
const height = optionsWindow.autoResize ? '100%'
: ((term as any).dimensions.css.canvas.height).toString() + 'px';
terminalContainer!.style.width = width;
@@ -634,7 +626,7 @@ function updateTerminalSize(): void {
function getBox(width: number, height: number): any {
return {
string: '+',
style: 'font-size: 1px; padding: ' + Math.floor(height / 2) + 'px ' + Math.floor(width / 2) + 'px; line-height: ' + height + 'px;'
style: 'font-size: 1px; padding: ' + Math.floor(height/2) + 'px ' + Math.floor(width/2) + 'px; line-height: ' + height + 'px;'
};
}
if (source instanceof HTMLCanvasElement) {
@@ -51,30 +51,14 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
container.appendChild(document.createElement('br'));
container.appendChild(document.createElement('br'));
// Sixel demos
const dlSixel = document.createElement('dl');
const dtSixel = document.createElement('dt');
dtSixel.textContent = 'Sixel';
dlSixel.appendChild(dtSixel);
this._addDdWithButton(dlSixel, 'image-demo1', 'snake');
this._addDdWithButton(dlSixel, 'image-demo2', 'oranges');
container.appendChild(dlSixel);
// IIP demos
const dlIip = document.createElement('dl');
const dtIip = document.createElement('dt');
dtIip.textContent = 'IIP (iTerm)';
dlIip.appendChild(dtIip);
this._addDdWithButton(dlIip, 'image-demo3', 'palette');
container.appendChild(dlIip);
// Kitty demos
const dlKitty = document.createElement('dl');
const dtKitty = document.createElement('dt');
dtKitty.textContent = 'Kitty';
dlKitty.appendChild(dtKitty);
this._addDdWithButton(dlKitty, 'image-demo-kitty1', 'palette');
container.appendChild(dlKitty);
const dl = document.createElement('dl');
const dt = document.createElement('dt');
dt.textContent = 'Image Test';
dl.appendChild(dt);
this._addDdWithButton(dl, 'image-demo1', 'snake (sixel)');
this._addDdWithButton(dl, 'image-demo2', 'oranges (sixel)');
this._addDdWithButton(dl, 'image-demo3', 'palette (iip)');
container.appendChild(dl);
this._initImageAddonExposed();
}
@@ -141,25 +125,12 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
});
const kittyDemo = (url: string) => () => fetch(url)
.then(resp => resp.arrayBuffer())
.then(buffer => {
const data = new Uint8Array(buffer);
let sdata = '';
for (let i = 0; i < data.length; ++i) sdata += String.fromCharCode(data[i]);
const payload = btoa(sdata);
this._terminal.write('\r\n');
this._terminal.write(`\x1b_Ga=T,f=100;${payload}\x1b\\`);
});
document.getElementById('image-demo1')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/saitoha/libsixel/master/images/snake.six'));
document.getElementById('image-demo2')!.addEventListener('click',
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
document.getElementById('image-demo3')!.addEventListener('click',
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
document.getElementById('image-demo-kitty1')!.addEventListener('click',
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
// demo for image retrieval API
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
@@ -85,7 +85,7 @@ export class CellInspectorWindow extends BaseWindow implements IControlWindow {
terminal.element.addEventListener('mousemove', (e: MouseEvent) => {
const core = (terminal as any)._core;
const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
const coords = core._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
if (!coords) {
this._clearDisplay();
return;
@@ -116,7 +116,6 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
'linkHandler',
'logger',
'quirks',
'scrollbar',
'theme',
'vtExtensions',
'windowOptions',
+5 -12
View File
@@ -705,13 +705,13 @@ function loadTest(term: Terminal, addons: AddonCollection): void {
term.write('', () => {
const time = Math.round(performance.now() - start);
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
}
async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise<void> {
function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
const testData = [];
let byteCount = 0;
@@ -729,23 +729,16 @@ async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promi
testData.push(data);
}
const start = performance.now();
for (let i = 0; i < 1024; i++) {
for (let i = 0; i < 1024 * 50; i++) {
for (const d of testData) {
try {
term.write(d);
} catch {
// Flush events when cap is hit and try again(workaround for not having flow control in
// demo)
await new Promise<void>(r => term.write('', () => r()));
term.write(d);
}
term.write(d);
}
}
// Wait for all data to be parsed before evaluating time
term.write('', () => {
const time = Math.round(performance.now() - start);
const mbs = ((byteCount / 1024) * (1 / (time / 1000))).toFixed(2);
term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
term.write(`\n\r\nWrote ${byteCount}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
+2 -3
View File
@@ -5,8 +5,8 @@
"outDir": "../out-demo/client",
"rootDir": ".",
"sourceMap": true,
"baseUrl": ".",
"strict": true,
"skipLibCheck": true,
"paths": {
"@xterm/addon-attach": ["../../addons/addon-attach"],
"@xterm/addon-clipboard": ["../../addons/addon-clipboard"],
@@ -20,8 +20,7 @@
"@xterm/addon-webgl": ["../../addons/addon-webgl"],
"@xterm/addon-unicode11": ["../../addons/addon-unicode11"],
"@xterm/addon-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"],
"*": ["./*"]
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"]
}
},
"include": [
+3 -18
View File
@@ -65,8 +65,6 @@ function startServer(): void {
}
const cols = parseInt(req.query.cols);
const rows = parseInt(req.query.rows);
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
const isWindows = process.platform === 'win32';
const term = pty.spawn(isWindows ? 'powershell.exe' : 'bash', [], {
name: 'xterm-256color',
@@ -79,13 +77,7 @@ function startServer(): void {
useConptyDll: isWindows,
});
// Set pixel dimensions immediately after spawn (pty.spawn doesn't support them)
if (pixelWidth > 0 && pixelHeight > 0) {
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
console.log('Created terminal with PID: ' + term.pid + ' (' + cols + 'x' + rows + ', ' + pixelWidth + 'px x ' + pixelHeight + 'px)');
} else {
console.log('Created terminal with PID: ' + term.pid);
}
console.log('Created terminal with PID: ' + term.pid);
terminals[term.pid] = term;
unsentOutput[term.pid] = '';
temporaryDisposable[term.pid] = term.onData(function(data) {
@@ -103,17 +95,10 @@ function startServer(): void {
const pid = parseInt(req.params.pid);
const cols = parseInt(req.query.cols);
const rows = parseInt(req.query.rows);
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
const term = terminals[pid];
if (pixelWidth > 0 && pixelHeight > 0) {
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols, ' + rows + ' rows, ' + pixelWidth + 'px x ' + pixelHeight + 'px');
} else {
term.resize(cols, rows);
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
}
term.resize(cols, rows);
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
res.end();
});
+1 -2
View File
@@ -5,8 +5,7 @@
"outDir": "../out-demo/server",
"rootDir": ".",
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true
"esModuleInterop": true
},
"include": [
"./**/*",
@@ -1,373 +0,0 @@
{
"Escape +CapsLock": {
"1": {
"PRESS": "\u001b[27;65u",
"REPEAT": "\u001b[27;65u",
"RELEASE": null
}
},
"F1 +CapsLock": {
"1": {
"PRESS": "\u001b[1;65P",
"REPEAT": "\u001b[1;65P",
"RELEASE": null
}
},
"Insert +CapsLock": {
"1": {
"PRESS": "\u001b[2;65~",
"REPEAT": "\u001b[2;65~",
"RELEASE": null
}
},
"Delete +CapsLock": {
"1": {
"PRESS": "\u001b[3;65~",
"REPEAT": "\u001b[3;65~",
"RELEASE": null
}
},
"PageUp +CapsLock": {
"1": {
"PRESS": "\u001b[5;65~",
"REPEAT": "\u001b[5;65~",
"RELEASE": null
}
},
"Dead (Backquote) +CapsLock": {
"1": {
"PRESS": "^",
"REPEAT": "^",
"RELEASE": null
}
},
"1 (Digit1) +CapsLock": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"\u00df (Minus) +CapsLock": {
"1": {
"PRESS": "\u1e9e",
"REPEAT": "\u1e9e",
"RELEASE": null
}
},
"Dead (Equal) +CapsLock": {
"1": {
"PRESS": "\u00b4",
"REPEAT": "\u00b4",
"RELEASE": null
}
},
"Backspace +CapsLock": {
"1": {
"PRESS": "\u007f",
"REPEAT": "\u007f",
"RELEASE": null
}
},
"Tab +CapsLock": {
"1": {
"PRESS": "\t",
"REPEAT": "\t",
"RELEASE": null
}
},
"Q (KeyQ) +CapsLock": {
"1": {
"PRESS": "Q",
"REPEAT": "Q",
"RELEASE": null
}
},
"\u00dc (BracketLeft) +CapsLock": {
"1": {
"PRESS": "\u00dc",
"REPEAT": "\u00dc",
"RELEASE": null
}
},
"+ (BracketRight) +CapsLock": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter +CapsLock": {
"1": {
"PRESS": "\r",
"REPEAT": "\r",
"RELEASE": null
}
},
"\u00d6 (Semicolon) +CapsLock": {
"1": {
"PRESS": "\u00d6",
"REPEAT": "\u00d6",
"RELEASE": null
}
},
"\u00c4 (Quote) +CapsLock": {
"1": {
"PRESS": "\u00c4",
"REPEAT": "\u00c4",
"RELEASE": null
}
},
"# (Bashslash) +CapsLock": {
"1": {
"PRESS": "#",
"REPEAT": "#",
"RELEASE": null
}
},
"< (IntlBackslash) +CapsLock": {
"1": {
"PRESS": "<",
"REPEAT": "<",
"RELEASE": null
}
},
", (Comma) +CapsLock": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
},
". (Period) +CapsLock": {
"1": {
"PRESS": ".",
"REPEAT": ".",
"RELEASE": null
}
},
"- (Slash) +CapsLock": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"ArrowUp +CapsLock": {
"1": {
"PRESS": "\u001b[1;65A",
"REPEAT": "\u001b[1;65A",
"RELEASE": null
}
},
"PageDown +CapsLock": {
"1": {
"PRESS": "\u001b[6;65~",
"REPEAT": "\u001b[6;65~",
"RELEASE": null
}
},
"Home +CapsLock": {
"1": {
"PRESS": "\u001b[1;65H",
"REPEAT": "\u001b[1;65H",
"RELEASE": null
}
},
"End +CapsLock": {
"1": {
"PRESS": "\u001b[1;65F",
"REPEAT": "\u001b[1;65F",
"RELEASE": null
}
},
"+ (NumpadAdd) +CapsLock": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter (NumpadEnter) +CapsLock": {
"1": {
"PRESS": "\u001b[57414;65u",
"REPEAT": "\u001b[57414;65u",
"RELEASE": null
}
},
"Insert (Numpad0) +CapsLock": {
"1": {
"PRESS": "\u001b[57425;65u",
"REPEAT": "\u001b[57425;65u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal) +CapsLock": {
"1": {
"PRESS": "\u001b[57426;65u",
"REPEAT": "\u001b[57426;65u",
"RELEASE": null
}
},
"Numlock +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"AltGraph (AltRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight) +CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"/ (NumpadDivide) +NumLock": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"* (NumpadMultiply) +NumLock": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"- (NumpadSubtract) +NumLock": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"0 (Numpad0) +NumLock": {
"1": {
"PRESS": "0",
"REPEAT": "0",
"RELEASE": null
}
},
"1 (Numpad1) +NumLock": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"2 (Numpad2) +NumLock": {
"1": {
"PRESS": "2",
"REPEAT": "2",
"RELEASE": null
}
},
"3 (Numpad3) +NumLock": {
"1": {
"PRESS": "3",
"REPEAT": "3",
"RELEASE": null
}
},
"4 (Numpad4) +NumLock": {
"1": {
"PRESS": "4",
"REPEAT": "4",
"RELEASE": null
}
},
"5 (Numpad5) +NumLock": {
"1": {
"PRESS": "5",
"REPEAT": "5",
"RELEASE": null
}
},
"6 (Numpad6) +NumLock": {
"1": {
"PRESS": "6",
"REPEAT": "6",
"RELEASE": null
}
},
"7 (Numpad7) +NumLock": {
"1": {
"PRESS": "7",
"REPEAT": "7",
"RELEASE": null
}
},
"8 (Numpad8) +NumLock": {
"1": {
"PRESS": "8",
"REPEAT": "8",
"RELEASE": null
}
},
"9 (Numpad9) +NumLock": {
"1": {
"PRESS": "9",
"REPEAT": "9",
"RELEASE": null
}
},
", (NumpadDecimal) +NumLock": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
}
}
@@ -1,702 +0,0 @@
{
"Escape": {
"1": {
"PRESS": "\u001b[27u",
"REPEAT": "\u001b[27u",
"RELEASE": null
}
},
"F1": {
"1": {
"PRESS": "\u001b[P",
"REPEAT": "\u001b[P",
"RELEASE": null
}
},
"F2": {
"1": {
"PRESS": "\u001b[Q",
"REPEAT": "\u001b[Q",
"RELEASE": null
}
},
"F3": {
"1": {
"PRESS": "\u001b[13~",
"REPEAT": "\u001b[13~",
"RELEASE": null
}
},
"F4": {
"1": {
"PRESS": "\u001b[S",
"REPEAT": "\u001b[S",
"RELEASE": null
}
},
"F5": {
"1": {
"PRESS": "\u001b[15~",
"REPEAT": "\u001b[15~",
"RELEASE": null
}
},
"F6": {
"1": {
"PRESS": "\u001b[17~",
"REPEAT": "\u001b[17~",
"RELEASE": null
}
},
"F7": {
"1": {
"PRESS": "\u001b[18~",
"REPEAT": "\u001b[18~",
"RELEASE": null
}
},
"F8": {
"1": {
"PRESS": "\u001b[19~",
"REPEAT": "\u001b[19~",
"RELEASE": null
}
},
"F9": {
"1": {
"PRESS": "\u001b[20~",
"REPEAT": "\u001b[20~",
"RELEASE": null
}
},
"F10": {
"1": {
"PRESS": "\u001b[21~",
"REPEAT": "\u001b[21~",
"RELEASE": null
}
},
"F11": {
"1": {
"PRESS": "\u001b[23~",
"REPEAT": "\u001b[23~",
"RELEASE": null
}
},
"F12": {
"1": {
"PRESS": "\u001b[24~",
"REPEAT": "\u001b[24~",
"RELEASE": null
}
},
"Insert": {
"1": {
"PRESS": "\u001b[2~",
"REPEAT": "\u001b[2~",
"RELEASE": null
}
},
"Delete": {
"1": {
"PRESS": "\u001b[3~",
"REPEAT": "\u001b[3~",
"RELEASE": null
}
},
"PageUp": {
"1": {
"PRESS": "\u001b[5~",
"REPEAT": "\u001b[5~",
"RELEASE": null
}
},
"PageDown": {
"1": {
"PRESS": "\u001b[6~",
"REPEAT": "\u001b[6~",
"RELEASE": null
}
},
"Home": {
"1": {
"PRESS": "\u001b[H",
"REPEAT": "\u001b[H",
"RELEASE": null
}
},
"End": {
"1": {
"PRESS": "\u001b[F",
"REPEAT": "\u001b[F",
"RELEASE": null
}
},
"Dead (Backquote)": {
"1": {
"PRESS": "^",
"REPEAT": "^",
"RELEASE": null
}
},
"1 (Digit1)": {
"1": {
"PRESS": "1",
"REPEAT": "1",
"RELEASE": null
}
},
"2 (Digit2)": {
"1": {
"PRESS": "2",
"REPEAT": "2",
"RELEASE": null
}
},
"3 (Digit3)": {
"1": {
"PRESS": "3",
"REPEAT": "3",
"RELEASE": null
}
},
"4 (Digit4)": {
"1": {
"PRESS": "4",
"REPEAT": "4",
"RELEASE": null
}
},
"5 (Digit5)": {
"1": {
"PRESS": "5",
"REPEAT": "5",
"RELEASE": null
}
},
"6 (Digit6)": {
"1": {
"PRESS": "6",
"REPEAT": "6",
"RELEASE": null
}
},
"7 (Digit7)": {
"1": {
"PRESS": "7",
"REPEAT": "7",
"RELEASE": null
}
},
"8 (Digit8)": {
"1": {
"PRESS": "8",
"REPEAT": "8",
"RELEASE": null
}
},
"9 (Digit9)": {
"1": {
"PRESS": "9",
"REPEAT": "9",
"RELEASE": null
}
},
"0 (Digit0)": {
"1": {
"PRESS": "0",
"REPEAT": "0",
"RELEASE": null
}
},
"\u00df (Minus)": {
"1": {
"PRESS": "\u00df",
"REPEAT": "\u00df",
"RELEASE": null
}
},
"Dead (Equal)": {
"1": {
"PRESS": "\u00b4",
"REPEAT": "\u00b4",
"RELEASE": null
}
},
"Backspace": {
"1": {
"PRESS": "\u007f",
"REPEAT": "\u007f",
"RELEASE": null
}
},
"Tab": {
"1": {
"PRESS": "\t",
"REPEAT": "\t",
"RELEASE": null
}
},
"q (KeyQ)": {
"1": {
"PRESS": "q",
"REPEAT": "q",
"RELEASE": null
}
},
"w (KeyW)": {
"1": {
"PRESS": "w",
"REPEAT": "w",
"RELEASE": null
}
},
"e (KeyE)": {
"1": {
"PRESS": "e",
"REPEAT": "e",
"RELEASE": null
}
},
"r (KeyR)": {
"1": {
"PRESS": "r",
"REPEAT": "r",
"RELEASE": null
}
},
"t (KeyT)": {
"1": {
"PRESS": "t",
"REPEAT": "t",
"RELEASE": null
}
},
"z (KeyY)": {
"1": {
"PRESS": "z",
"REPEAT": "z",
"RELEASE": null
}
},
"u (KeyU)": {
"1": {
"PRESS": "u",
"REPEAT": "u",
"RELEASE": null
}
},
"i (KeyI)": {
"1": {
"PRESS": "i",
"REPEAT": "i",
"RELEASE": null
}
},
"o (KeyO)": {
"1": {
"PRESS": "o",
"REPEAT": "o",
"RELEASE": null
}
},
"p (KeyP)": {
"1": {
"PRESS": "p",
"REPEAT": "p",
"RELEASE": null
}
},
"\u00fc (BracketLeft)": {
"1": {
"PRESS": "\u00fc",
"REPEAT": "\u00fc",
"RELEASE": null
}
},
"+ (BracketRight)": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"Enter": {
"1": {
"PRESS": "\r",
"REPEAT": "\r",
"RELEASE": null
}
},
"a (KeyA)": {
"1": {
"PRESS": "a",
"REPEAT": "a",
"RELEASE": null
}
},
"s (KeyS)": {
"1": {
"PRESS": "s",
"REPEAT": "s",
"RELEASE": null
}
},
"d (KeyD)": {
"1": {
"PRESS": "d",
"REPEAT": "d",
"RELEASE": null
}
},
"f (KeyF)": {
"1": {
"PRESS": "f",
"REPEAT": "f",
"RELEASE": null
}
},
"g (KeyG)": {
"1": {
"PRESS": "g",
"REPEAT": "g",
"RELEASE": null
}
},
"h (KeyH)": {
"1": {
"PRESS": "h",
"REPEAT": "h",
"RELEASE": null
}
},
"j (KeyJ)": {
"1": {
"PRESS": "j",
"REPEAT": "j",
"RELEASE": null
}
},
"k (KeyK)": {
"1": {
"PRESS": "k",
"REPEAT": "k",
"RELEASE": null
}
},
"l (KeyL)": {
"1": {
"PRESS": "l",
"REPEAT": "l",
"RELEASE": null
}
},
"\u00f6 (Semicolon)": {
"1": {
"PRESS": "\u00f6",
"REPEAT": "\u00f6",
"RELEASE": null
}
},
"\u00e4 (Quote)": {
"1": {
"PRESS": "\u00e4",
"REPEAT": "\u00e4",
"RELEASE": null
}
},
"# (Backslash)": {
"1": {
"PRESS": "#",
"REPEAT": "#",
"RELEASE": null
}
},
"< (IntlBackslash)": {
"1": {
"PRESS": "<",
"REPEAT": "<",
"RELEASE": null
}
},
"y (KeyZ)": {
"1": {
"PRESS": "y",
"REPEAT": "y",
"RELEASE": null
}
},
"x (KeyX)": {
"1": {
"PRESS": "x",
"REPEAT": "x",
"RELEASE": null
}
},
"c (KeyC)": {
"1": {
"PRESS": "c",
"REPEAT": "c",
"RELEASE": null
}
},
"v (KeyV)": {
"1": {
"PRESS": "v",
"REPEAT": "v",
"RELEASE": null
}
},
"b (KeyB)": {
"1": {
"PRESS": "b",
"REPEAT": "b",
"RELEASE": null
}
},
"n (KeyN)": {
"1": {
"PRESS": "n",
"REPEAT": "n",
"RELEASE": null
}
},
"m (KeyM)": {
"1": {
"PRESS": "m",
"REPEAT": "m",
"RELEASE": null
}
},
", (Comma)": {
"1": {
"PRESS": ",",
"REPEAT": ",",
"RELEASE": null
}
},
". (Period)": {
"1": {
"PRESS": ".",
"REPEAT": ".",
"RELEASE": null
}
},
"- (Slash)": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"ArrowLeft": {
"1": {
"PRESS": "\u001b[D",
"REPEAT": "\u001b[D",
"RELEASE": null
}
},
"ArrowUp": {
"1": {
"PRESS": "\u001b[A",
"REPEAT": "\u001b[A",
"RELEASE": null
}
},
"ArrowRight": {
"1": {
"PRESS": "\u001b[C",
"REPEAT": "\u001b[C",
"RELEASE": null
}
},
"ArrowDown": {
"1": {
"PRESS": "\u001b[B",
"REPEAT": "\u001b[B",
"RELEASE": null
}
},
"/ (NumpadDivide)": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"* (NumpadMultiply)": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"- (NumpadSubtract)": {
"1": {
"PRESS": "-",
"REPEAT": "-",
"RELEASE": null
}
},
"Home (Numpad7)": {
"1": {
"PRESS": "\u001b[57423u",
"REPEAT": "\u001b[57423u",
"RELEASE": null
}
},
"ArrowUp (Numpad8)": {
"1": {
"PRESS": "\u001b[57419u",
"REPEAT": "\u001b[57419u",
"RELEASE": null
}
},
"PageUp (Numpad9)": {
"1": {
"PRESS": "\u001b[57421u",
"REPEAT": "\u001b[57421u",
"RELEASE": null
}
},
"+ (NumpadAdd)": {
"1": {
"PRESS": "+",
"REPEAT": "+",
"RELEASE": null
}
},
"ArrowLeft (Numpad4)": {
"1": {
"PRESS": "\u001b[57417u",
"REPEAT": "\u001b[57417u",
"RELEASE": null
}
},
"Clear (Numpad5)": {
"1": {
"PRESS": "\u001b[E",
"REPEAT": "\u001b[E",
"RELEASE": null
}
},
"ArrowRight (Numpad6)": {
"1": {
"PRESS": "\u001b[57418u",
"REPEAT": "\u001b[57418u",
"RELEASE": null
}
},
"End (Numpad1)": {
"1": {
"PRESS": "\u001b[57424u",
"REPEAT": "\u001b[57424u",
"RELEASE": null
}
},
"ArrowDown (Numpad2)": {
"1": {
"PRESS": "\u001b[57420u",
"REPEAT": "\u001b[57420u",
"RELEASE": null
}
},
"PageDown (Numpad3)": {
"1": {
"PRESS": "\u001b[57422u",
"REPEAT": "\u001b[57422u",
"RELEASE": null
}
},
"Enter (NumpadEnter)": {
"1": {
"PRESS": "\u001b[57414u",
"REPEAT": "\u001b[57414u",
"RELEASE": null
}
},
"Insert (Numpad0)": {
"1": {
"PRESS": "\u001b[57425u",
"REPEAT": "\u001b[57425u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal)": {
"1": {
"PRESS": "\u001b[57426u",
"REPEAT": "\u001b[57426u",
"RELEASE": null
}
},
"Numlock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
" (Space)": {
"1": {
"PRESS": " ",
"REPEAT": " ",
"RELEASE": null
}
},
"AltGraph (AltRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight)": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
}
}
@@ -1,331 +0,0 @@
{
"Escape +Shift": {
"1": {
"PRESS": "\u001b[27;2u",
"REPEAT": "\u001b[27;2u",
"RELEASE": null
}
},
"F1 +Shift": {
"1": {
"PRESS": "\u001b[1;2P",
"REPEAT": "\u001b[1;2P",
"RELEASE": null
}
},
"Delete +Shift": {
"1": {
"PRESS": "\u001b[3;2~",
"REPEAT": "\u001b[3;2~",
"RELEASE": null
}
},
"PageUp +Shift": {
"1": {
"PRESS": "\u001b[5;2~",
"REPEAT": "\u001b[5;2~",
"RELEASE": null
}
},
"Dead (Backquote) +Shift": {
"1": {
"PRESS": "\u00b0",
"REPEAT": "\u00b0",
"RELEASE": null
}
},
"! (Digit1) +Shift": {
"1": {
"PRESS": "!",
"REPEAT": "!",
"RELEASE": null
}
},
"\" (Digit2) +Shift": {
"1": {
"PRESS": "\"",
"REPEAT": "\"",
"RELEASE": null
}
},
"\u00a7 (Digit3) +Shift": {
"1": {
"PRESS": "\u00a7",
"REPEAT": "\u00a7",
"RELEASE": null
}
},
"$ (Digit4) +Shifft": {
"1": {
"PRESS": "$",
"REPEAT": "$",
"RELEASE": null
}
},
"% (Digit5) +Shift": {
"1": {
"PRESS": "%",
"REPEAT": "%",
"RELEASE": null
}
},
"& (Digit6) +Shift": {
"1": {
"PRESS": "&",
"REPEAT": "&",
"RELEASE": null
}
},
"/ (Digit7) +Shift": {
"1": {
"PRESS": "/",
"REPEAT": "/",
"RELEASE": null
}
},
"( (Digit8) +Shift": {
"1": {
"PRESS": "(",
"REPEAT": "(",
"RELEASE": null
}
},
") (Digit9) +Shift": {
"1": {
"PRESS": ")",
"REPEAT": ")",
"RELEASE": null
}
},
"= (Digit0) +Shift": {
"1": {
"PRESS": "=",
"REPEAT": "=",
"RELEASE": null
}
},
"? (Minus) +Shift": {
"1": {
"PRESS": "?",
"REPEAT": "?",
"RELEASE": null
}
},
"Backspace +Shift": {
"1": {
"PRESS": "\u001b[127;2u",
"REPEAT": "\u001b[127;2u",
"RELEASE": null
}
},
"Tab +Shift": {
"1": {
"PRESS": "\u001b[9;2u",
"REPEAT": "\u001b[9;2u",
"RELEASE": null
}
},
"Q (KeyQ) +Shift": {
"1": {
"PRESS": "Q",
"REPEAT": "Q",
"RELEASE": null
}
},
"\u00dc (BracketLeft) +Shift": {
"1": {
"PRESS": "\u00dc",
"REPEAT": "\u00dc",
"RELEASE": null
}
},
"* (BracketRight) +Shift": {
"1": {
"PRESS": "*",
"REPEAT": "*",
"RELEASE": null
}
},
"Enter +Shift": {
"1": {
"PRESS": "\u001b[13;2u",
"REPEAT": "\u001b[13;2u",
"RELEASE": null
}
},
"\u00d6 (Semicolon) +Shift": {
"1": {
"PRESS": "\u00d6",
"REPEAT": "\u00d6",
"RELEASE": null
}
},
"\u00c4 (Quote) +Shift": {
"1": {
"PRESS": "\u00c4",
"REPEAT": "\u00c4",
"RELEASE": null
}
},
"' (Backslash) +Shift": {
"1": {
"PRESS": "'",
"REPEAT": "'",
"RELEASE": null
}
},
"> (IntlBackslash) +Shift": {
"1": {
"PRESS": ">",
"REPEAT": ">",
"RELEASE": null
}
},
"; (Comma) +Shift": {
"1": {
"PRESS": ";",
"REPEAT": ";",
"RELEASE": null
}
},
": (Period) +Shift": {
"1": {
"PRESS": ":",
"REPEAT": ":",
"RELEASE": null
}
},
"_ (Slash) +Shift": {
"1": {
"PRESS": "_",
"REPEAT": "_",
"RELEASE": null
}
},
"ArrowUp +Shift": {
"1": {
"PRESS": "\u001b[1;2A",
"REPEAT": "\u001b[1;2A",
"RELEASE": null
}
},
"PageDown +Shift": {
"1": {
"PRESS": "\u001b[6;2~",
"REPEAT": "\u001b[6;2~",
"RELEASE": null
}
},
"Home +Shift": {
"1": {
"PRESS": "\u001b[1;2H",
"REPEAT": "\u001b[1;2H",
"RELEASE": null
}
},
"Home (Numpad7) +Shift": {
"1": {
"PRESS": "\u001b[57423;2u",
"REPEAT": "\u001b[57423;2u",
"RELEASE": null
}
},
"ArrowUp (Numapd8) +Shift": {
"1": {
"PRESS": "\u001b[57419;2u",
"REPEAT": "\u001b[57419;2u",
"RELEASE": null
}
},
"Clear (Numpad5) +Shift": {
"1": {
"PRESS": "\u001b[1;2E",
"REPEAT": "\u001b[1;2E",
"RELEASE": null
}
},
"Enter (NumpadEnter) +Shift": {
"1": {
"PRESS": "\u001b[57414;2u",
"REPEAT": "\u001b[57414;2u",
"RELEASE": null
}
},
"Insert (Numpad0) +Shift": {
"1": {
"PRESS": "\u001b[57425;2u",
"REPEAT": "\u001b[57425;2u",
"RELEASE": null
}
},
"\u0000 (NumpadDecimal) +Shift": {
"1": {
"PRESS": "\u001b[57426;2u",
"REPEAT": "\u001b[57426;2u",
"RELEASE": null
}
},
"Numlock +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"CapsLock +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Shift (ShiftRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Meta (MetaLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Alt (AltLeft) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
" (Space) +Shift": {
"1": {
"PRESS": " ",
"REPEAT": " ",
"RELEASE": null
}
},
"AltGraph (AltRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
},
"Control (ControlRight) +Shift": {
"1": {
"PRESS": null,
"REPEAT": null,
"RELEASE": null
}
}
}
+5 -5
View File
@@ -2,17 +2,17 @@
⚠ This package is experimental
`@xterm/headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/@xterm/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
`xterm-headless` is a headless terminal that can be run in node.js. This is useful in combination with the frontend [`xterm`](https://www.npmjs.com/package/xterm) for example to keep track of a terminal's state on a remote server where the process is hosted.
## Getting Started
First, you need to install the module, we ship exclusively through npm, so you need that installed and then add xterm.js as a dependency by running:
```sh
npm install @xterm/headless
npm install xterm-headless
```
Then import as you would a regular node package. The recommended way to load `@xterm/headless` is with TypeScript and the ES6 module syntax:
Then import as you would a regular node package. The recommended way to load `xterm-headless` is with TypeScript and the ES6 module syntax:
```javascript
import { Terminal } from '@xterm/headless';
@@ -20,12 +20,12 @@ import { Terminal } from '@xterm/headless';
## API
The full API for `@xterm/headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
The full API for `xterm-headless` is contained within the [TypeScript declaration file](https://github.com/xtermjs/xterm.js/blob/master/typings/xterm-headless.d.ts), use the branch/tag picker in GitHub (`w`) to navigate to the correct version of the API.
Note that some APIs are marked *experimental*, these are added to enable experimentation with new ideas without committing to support it like a normal [semver](https://semver.org/) API. Note that these APIs can change radically between versions, so be sure to read release notes if you plan on using experimental APIs.
### Addons
Addons in `@xterm/headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
Addons in `xterm-headless` work the [same as in `xterm`](https://github.com/xtermjs/xterm.js/blob/master/README.md#addons) with the one caveat being that the addon needs to be packaged for node.js and not use any DOM APIs.
Currently no official addons are packaged on npm.
+89 -226
View File
@@ -29,7 +29,6 @@
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
@@ -64,7 +63,10 @@
"addons/addon-clipboard": {
"name": "@xterm/addon-clipboard",
"version": "0.2.0",
"license": "MIT"
"license": "MIT",
"dependencies": {
"js-base64": "^3.7.5"
}
},
"addons/addon-fit": {
"name": "@xterm/addon-fit",
@@ -77,7 +79,7 @@
"license": "MIT",
"devDependencies": {
"sixel": "^0.16.0",
"xterm-wasm-parts": "^0.4.1"
"xterm-wasm-parts": "^0.3.0"
}
},
"addons/addon-ligatures": {
@@ -91,10 +93,10 @@
"devDependencies": {
"@types/lru-cache": "^5.1.0",
"@types/opentype.js": "^0.7.0",
"axios": "^1.15.0",
"axios": "^1.6.0",
"font-finder": "^1.1.0",
"mkdirp": "0.5.5",
"yauzl": "^3.2.1"
"yauzl": "^2.10.0"
},
"engines": {
"node": ">8.0.0"
@@ -242,7 +244,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -556,7 +557,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
},
@@ -598,7 +598,6 @@
"url": "https://opencollective.com/csstools"
}
],
"peer": true,
"engines": {
"node": ">=18"
}
@@ -1108,11 +1107,10 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1187,11 +1185,10 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1796,7 +1793,6 @@
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.50.1.tgz",
"integrity": "sha512-hM5faZwg7aVNa819m/5r7D0h0c9yC4DUlWAOvHAtISdFTc8xB86VmX5Xqabrama3wIPJ/q9RbGS1worb6JfnMg==",
"dev": true,
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.1",
"@typescript-eslint/types": "8.50.1",
@@ -1974,123 +1970,6 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript/native-preview": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview/-/native-preview-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-TZ/0Tv954jRpn5IHQJZQF0gaNmw//ZTvkgr3nC2H8u9t4GxUhIk5vfAHVFgdl8JnNGbl7gZks37FVsEwXjdHqg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsgo": "bin/tsgo.js"
},
"optionalDependencies": {
"@typescript/native-preview-darwin-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-darwin-x64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-arm": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-linux-x64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-win32-arm64": "7.0.0-dev.20260213.1",
"@typescript/native-preview-win32-x64": "7.0.0-dev.20260213.1"
}
},
"node_modules/@typescript/native-preview-darwin-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-arm64/-/native-preview-darwin-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-nqfuHgFZ8MvaHeb1XOkAGIGc6cR91dwQdlHmlbH918n63JguKzkYjzzulpr48IVn1SVewGwjtfs1moCpaDpWcg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-darwin-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-darwin-x64/-/native-preview-darwin-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-JDVDVLPIYhYZR12omTKwzKbdw+wnPEtLnkmM+nioqbISlV29fg7IBgFx0/mf1D+Gz1ztUQVHDTqw4GEgOZ9VTg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@typescript/native-preview-linux-arm": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm/-/native-preview-linux-arm-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-O6iVB8tTnLZJVYrChH6VJuCwqAa2ObWVdMtrKQw5UtmLXBhsOhZ3isttSuGzQrPfTiGN091IdW1KuRi8Ft1lnQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-arm64/-/native-preview-linux-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-d7Gwl8W4aics0KI4H0zz3TP1ngnP8L5COND0TPBZ7ifWVrxKDNSLhF2V9OuStW43RPfA4Gzik9iIv+l0Lg+eGw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-linux-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-linux-x64/-/native-preview-linux-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-Aq55fID1fE5/8K4XvfVddgw8LKUgY3kp5IOb+QEklfyle4MuAV5gtat3MnEv9XMbiidk8iKVxYrYASlUQEQzDw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@typescript/native-preview-win32-arm64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-arm64/-/native-preview-win32-arm64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-VlyULgXApmGMSqAANjskGx0AcjHchf3I8rhZ47AxF/XKAkZPe/5ewfleJ97iln7dg1UL3lq2cJY2CXxU+JPcFQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@typescript/native-preview-win32-x64": {
"version": "7.0.0-dev.20260213.1",
"resolved": "https://registry.npmjs.org/@typescript/native-preview-win32-x64/-/native-preview-win32-x64-7.0.0-dev.20260213.1.tgz",
"integrity": "sha512-lEtWvZDa2oCgqJ2gbP6BRLUeTyHLyrt5BfJmVlvmVSTZzdoBpFROJdp9yprsa2Zry2o5GK8HFAxd3RlvxkOU5Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@webassemblyjs/ast": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz",
@@ -2362,7 +2241,6 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2515,11 +2393,10 @@
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -2622,15 +2499,14 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz",
"integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
"follow-redirects": "^1.15.6",
"form-data": "^4.0.4",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/balanced-match": {
@@ -2754,7 +2630,6 @@
"url": "https://github.com/sponsors/ai"
}
],
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -3750,7 +3625,6 @@
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz",
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3877,11 +3751,10 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4119,6 +3992,15 @@
"node": ">= 4.9.1"
}
},
"node_modules/fd-slicer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
"integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==",
"dev": true,
"dependencies": {
"pend": "~1.2.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -4249,16 +4131,15 @@
}
},
"node_modules/flatted": {
"version": "3.4.2",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==",
"dev": true,
"license": "ISC"
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"dev": true,
"funding": [
{
@@ -4266,7 +4147,6 @@
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
@@ -4560,11 +4440,10 @@
"dev": true
},
"node_modules/glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -5417,6 +5296,11 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/js-base64": {
"version": "3.7.8",
"resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz",
"integrity": "sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow=="
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5757,11 +5641,10 @@
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -5803,13 +5686,12 @@
}
},
"node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
"brace-expansion": "^2.0.1"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5875,11 +5757,10 @@
}
},
"node_modules/mocha/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
"integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -6091,11 +5972,10 @@
}
},
"node_modules/nyc/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -6472,8 +6352,7 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"dev": true,
"license": "MIT"
"dev": true
},
"node_modules/picocolors": {
"version": "1.1.1",
@@ -6482,11 +6361,10 @@
"dev": true
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
@@ -6656,14 +6534,10 @@
}
},
"node_modules/proxy-from-env": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -6675,9 +6549,9 @@
}
},
"node_modules/qs": {
"version": "6.14.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"version": "6.14.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
"integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6736,11 +6610,10 @@
}
},
"node_modules/readdirp/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
@@ -6914,11 +6787,10 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7015,7 +6887,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"dev": true,
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -7629,11 +7500,10 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7856,7 +7726,6 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8023,7 +7892,6 @@
"resolved": "https://registry.npmjs.org/webpack/-/webpack-5.104.1.tgz",
"integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==",
"dev": true,
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -8072,7 +7940,6 @@
"resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz",
"integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==",
"dev": true,
"peer": true,
"dependencies": {
"@discoveryjs/json-ext": "^0.5.0",
"@webpack-cli/configtest": "^1.2.0",
@@ -8444,9 +8311,9 @@
}
},
"node_modules/xterm-wasm-parts": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.4.1.tgz",
"integrity": "sha512-/qwoasrSyrzs90WHTN4wpISAEz1u9sOpLsDhkd9a2h27l1pz/KZd9h4p+X2pUR7OqyXvDsOdj0dV3906s3wh8g==",
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xterm-wasm-parts/-/xterm-wasm-parts-0.3.0.tgz",
"integrity": "sha512-V/lhvDv2Scov2ukhTNmmur6jMq2DSL8QOdQdXWgfqcAYbUf7bgixwl2sB1gYPGE21NTw2OvbAw+vANRncWzR4w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8535,17 +8402,13 @@
}
},
"node_modules/yauzl": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.1.tgz",
"integrity": "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A==",
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz",
"integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==",
"dev": true,
"license": "MIT",
"dependencies": {
"buffer-crc32": "~0.2.3",
"pend": "~1.2.0"
},
"engines": {
"node": ">=12"
"fd-slicer": "~1.1.0"
}
},
"node_modules/yocto-queue": {
+3 -5
View File
@@ -29,14 +29,13 @@
"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\"",
"build": "npm run tsc",
"watch": "npm run tsc-watch",
"tsc": "tsgo -b ./tsconfig.all.json",
"tsc-watch": "tsgo -b -w ./tsconfig.all.json --preserveWatchOutput",
"tsc": "tsc -b ./tsconfig.all.json",
"tsc-watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput",
"esbuild": "node bin/esbuild_all.mjs",
"esbuild-watch": "node bin/esbuild_all.mjs --watch",
"esbuild-package": "node bin/esbuild_all.mjs --prod",
@@ -66,7 +65,7 @@
"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 src/*.ts addons/**/src/*.ts",
"vtfeatures": "node bin/extract_vtfeatures.js src/**/*.ts src/*.ts",
"prepackage": "npm run build",
"package": "webpack",
"postpackage": "npm run esbuild-package",
@@ -93,7 +92,6 @@
"@types/ws": "^8.2.0",
"@typescript-eslint/eslint-plugin": "^8.50.1",
"@typescript-eslint/parser": "^8.50.1",
"@typescript/native-preview": "^7.0.0-dev.20260213.1",
"chai": "^4.3.4",
"concurrently": "^9.1.2",
"cross-env": "^7.0.3",
+3 -5
View File
@@ -12,9 +12,7 @@ import { IBuffer } from 'common/buffer/Types';
import { IInstantiationService } from 'common/services/Services';
import { addDisposableListener } from 'browser/Dom';
const enum Constants {
MAX_ROWS_TO_READ = 20
}
const MAX_ROWS_TO_READ = 20;
const enum BoundaryPosition {
TOP,
@@ -139,7 +137,7 @@ export class AccessibilityManager extends Disposable {
}
private _handleChar(char: string): void {
if (this._liveRegionLineCount < Constants.MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount < 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();
@@ -152,7 +150,7 @@ export class AccessibilityManager extends Disposable {
if (char === '\n') {
this._liveRegionLineCount++;
if (this._liveRegionLineCount === Constants.MAX_ROWS_TO_READ + 1) {
if (this._liveRegionLineCount === MAX_ROWS_TO_READ + 1) {
this._liveRegion.textContent = Strings.tooMuchOutput.get();
}
}
-10
View File
@@ -29,14 +29,4 @@ describe('evaluatePastedTextProcessing', () => {
assert.equal(unbracketedText, 'foo bar');
assert.equal(bracketedText, '\x1b[200~foo bar\x1b[201~');
});
it('should escape embedded escape sequences in pasted text only when bracketed', () => {
const ESC_SYMBOL = '\u241b';
const pastedText = '\x1b[201~foo\x1b[200~bar';
const unbracketedText = Clipboard.bracketTextForPaste(pastedText, false);
const bracketedText = Clipboard.bracketTextForPaste(pastedText, true);
assert.equal(unbracketedText, pastedText, 'non bracketed paste should remain unchanged');
assert.equal(bracketedText, `\x1b[200~${ESC_SYMBOL}[201~foo${ESC_SYMBOL}[200~bar\x1b[201~`);
});
});
+3 -6
View File
@@ -19,13 +19,10 @@ export function prepareTextForTerminal(text: string): string {
* @param text The pasted text to bracket
*/
export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): string {
if (!bracketedPasteMode) {
return text;
if (bracketedPasteMode) {
return '\x1b[200~' + text + '\x1b[201~';
}
// Sanitize pasted text to prevent injected escape sequences (e.g. exiting bracketed paste)
// by replacing ESC (\x1b) with its visible representation U+241B (␛).
const sanitizedText = text.replace(/\x1b/g, '\u241b');
return `\x1b[200~${sanitizedText}\x1b[201~`;
return text;
}
/**
+282 -31
View File
@@ -36,17 +36,16 @@ import { CharSizeService } from 'browser/services/CharSizeService';
import { CharacterJoinerService } from 'browser/services/CharacterJoinerService';
import { CoreBrowserService } from 'browser/services/CoreBrowserService';
import { LinkProviderService } from 'browser/services/LinkProviderService';
import { MouseCoordsService } from 'browser/services/MouseCoordsService';
import { MouseService } from 'browser/services/MouseService';
import { RenderService } from 'browser/services/RenderService';
import { SelectionService } from 'browser/services/SelectionService';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
import { ThemeService } from 'browser/services/ThemeService';
import { KeyboardService } from 'browser/services/KeyboardService';
import { channels, color, rgb } from 'common/Color';
import { CoreTerminal } from 'common/CoreTerminal';
import * as Browser from 'common/Platform';
import { ColorRequestType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
import { ColorRequestType, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { IBuffer } from 'common/buffer/Types';
import { C0, C1ESCAPED } from 'common/data/EscapeSequences';
@@ -78,6 +77,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
public browser: IBrowser = Browser as any;
private _customKeyEventHandler: CustomKeyEventHandler | undefined;
private _customWheelEventHandler: CustomWheelEventHandler | undefined;
// Browser services
private readonly _decorationService: DecorationService;
@@ -87,7 +87,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Optional browser services
private _charSizeService: ICharSizeService | undefined;
private _coreBrowserService: ICoreBrowserService | undefined;
private _mouseCoordsService: IMouseCoordsService | undefined;
private _mouseService: IMouseService | undefined;
private _renderService: IRenderService | undefined;
private _themeService: IThemeService | undefined;
@@ -414,16 +413,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._register(addDisposableListener(this.textarea!, 'keyup', (ev: KeyboardEvent) => this._keyUp(ev), true));
this._register(addDisposableListener(this.textarea!, 'keydown', (ev: KeyboardEvent) => this._keyDown(ev), true));
this._register(addDisposableListener(this.textarea!, 'keypress', (ev: KeyboardEvent) => this._keyPress(ev), true));
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => {
// Ensure the textarea is synced to the latest cursor location before composition begins. This
// is to workaround a problem where highly dynamic TUIs like agentic CLIs reprint agressively
// would cause the IME to appear in the wrong position. The theory is that when the IME is
// triggered during a partial render the textarea position becomes locked and will not move
// until it is hidden and a custom move occurs.
this._syncTextArea();
this._compositionHelper!.compositionstart();
this._compositionHelper!.updateCompositionElements();
}));
this._register(addDisposableListener(this.textarea!, 'compositionstart', () => this._compositionHelper!.compositionstart()));
this._register(addDisposableListener(this.textarea!, 'compositionupdate', (e: CompositionEvent) => this._compositionHelper!.compositionupdate(e)));
this._register(addDisposableListener(this.textarea!, 'compositionend', () => this._compositionHelper!.compositionend()));
this._register(addDisposableListener(this.textarea!, 'input', (ev: InputEvent) => this._inputEvent(ev), true));
@@ -553,8 +543,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
this._helperContainer.appendChild(this._compositionView);
this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
@@ -573,10 +563,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._renderService!.handleCursorMove();
this._syncTextArea();
}));
this._register(this.onResize(() => {
this._renderService!.handleResize(this.cols, this.rows);
this._syncTextArea();
}));
this._register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows)));
this._register(this.onBlur(() => this._renderService!.handleBlur()));
this._register(this.onFocus(() => this._renderService!.handleFocus()));
@@ -592,8 +579,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
linkifier
));
this._instantiationService.setService(ISelectionService, this._selectionService);
this._mouseService = this._instantiationService.createInstance(MouseService);
this._instantiationService.setService(IMouseService, this._mouseService);
this._register(this._selectionService.onRequestScrollLines(e => this.scrollLines(e.amount, e.suppressScrollEvent)));
this._register(this._selectionService.onSelectionChange(() => this._onSelectionChange.fire()));
this._register(this._selectionService.onRequestRedraw(e => this._renderService!.handleSelectionChanged(e.start, e.end, e.columnSelectMode)));
@@ -617,7 +602,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._register(addDisposableListener(this.element, 'mousedown', (e: MouseEvent) => this._selectionService!.handleMouseDown(e)));
// apply mouse event classes set by escape codes before terminal was attached
if (this.mouseStateService.areMouseEventsActive) {
if (this.coreMouseService.areMouseEventsActive) {
this._selectionService.disable();
this.element.classList.add('enable-mouse-events');
} else {
@@ -653,18 +638,285 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Listen for mouse events and translate
// them into terminal mouse protocols.
this._mouseService.bindMouse({
element: this.element!,
screenElement: this.screenElement!,
document: this._document!,
handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
}, disposable => this._register(disposable), () => this.focus());
this.bindMouse();
}
private _createRenderer(): IRenderer {
return this._instantiationService.createInstance(DomRenderer, this, this._document!, this.element!, this.screenElement!, this._viewportElement!, this._helperContainer!, this.linkifier!);
}
/**
* Bind certain mouse events to the terminal.
* By default only 3 button + wheel up/down is ativated. For higher buttons
* no mouse report will be created. Typically the standard actions will be active.
*
* There are several reasons not to enable support for higher buttons/wheel:
* - Button 4 and 5 are typically used for history back and forward navigation,
* there is no straight forward way to supress/intercept those standard actions.
* - Support for higher buttons does not work in some platform/browser combinations.
* - Left/right wheel was not tested.
* - Emulators vary in mouse button support, typically only 3 buttons and
* wheel up/down work reliable.
*
* TODO: Move mouse event code into its own file.
*/
public bindMouse(): void {
const self = this;
const el = this.element!;
// send event to CoreMouseService
function sendEvent(ev: MouseEvent | WheelEvent): boolean {
// Get mouse coordinates
const pos = self._mouseService?.getMouseReportCoords(ev, self.screenElement!);
if (!pos) {
return false;
}
let but: CoreMouseButton;
let action: CoreMouseAction | undefined;
switch ((ev as any).overrideType || ev.type) {
case 'mousemove':
action = CoreMouseAction.MOVE;
if (ev.buttons === undefined) {
// buttons is not supported on macOS, try to get a value from button instead
but = CoreMouseButton.NONE;
if (ev.button !== undefined) {
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
}
} else {
// according to MDN buttons only reports up to button 5 (AUX2)
but = ev.buttons & 1 ? CoreMouseButton.LEFT :
ev.buttons & 4 ? CoreMouseButton.MIDDLE :
ev.buttons & 2 ? CoreMouseButton.RIGHT :
CoreMouseButton.NONE; // fallback to NONE
}
break;
case 'mouseup':
action = CoreMouseAction.UP;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'mousedown':
action = CoreMouseAction.DOWN;
but = ev.button < 3 ? ev.button : CoreMouseButton.NONE;
break;
case 'wheel':
if (self._customWheelEventHandler && self._customWheelEventHandler(ev as WheelEvent) === false) {
return false;
}
const deltaY = (ev as WheelEvent).deltaY;
if (deltaY === 0) {
return false;
}
const lines = self.coreMouseService.consumeWheelEvent(
ev as WheelEvent,
self._renderService?.dimensions?.device?.cell?.height,
self._coreBrowserService?.dpr
);
if (lines === 0) {
return false;
}
action = deltaY < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN;
but = CoreMouseButton.WHEEL;
break;
default:
// dont handle other event types by accident
return false;
}
// exit if we cannot determine valid button/action values
// do nothing for higher buttons than wheel
if (action === undefined || but === undefined || but > CoreMouseButton.WHEEL) {
return false;
}
return self.coreMouseService.triggerMouseEvent({
col: pos.col,
row: pos.row,
x: pos.x,
y: pos.y,
button: but,
action,
ctrl: ev.ctrlKey,
alt: ev.altKey,
shift: ev.shiftKey
});
}
/**
* Event listener state handling.
* We listen to the onProtocolChange event of CoreMouseService and put
* requested listeners in `requestedEvents`. With this the listeners
* have all bits to do the event listener juggling.
* Note: 'mousedown' currently is "always on" and not managed
* by onProtocolChange.
*/
const requestedEvents: { [key: string]: ((ev: MouseEvent | WheelEvent) => void) | null } = {
mouseup: null,
wheel: null,
mousedrag: null,
mousemove: null
};
const eventListeners: { [key: string]: (ev: any) => void | boolean } = {
mouseup: (ev: MouseEvent) => {
sendEvent(ev);
if (!ev.buttons) {
// if no other button is held remove global handlers
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
if (requestedEvents.mousedrag) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
}
}
},
wheel: (ev: WheelEvent) => {
sendEvent(ev);
ev.preventDefault();
ev.stopPropagation();
return false;
},
mousedrag: (ev: MouseEvent) => {
// deal only with move while a button is held
if (ev.buttons) {
sendEvent(ev);
}
},
mousemove: (ev: MouseEvent) => {
// deal only with move without any button
if (!ev.buttons) {
sendEvent(ev);
}
}
};
this._register(this.coreMouseService.onProtocolChange(events => {
// apply global changes on events
if (events) {
if (this.optionsService.rawOptions.logLevel === 'debug') {
this._logService.debug('Binding to mouse events:', this.coreMouseService.explainEvents(events));
}
this.element!.classList.add('enable-mouse-events');
this._selectionService!.disable();
} else {
this._logService.debug('Unbinding from mouse events.');
this.element!.classList.remove('enable-mouse-events');
this._selectionService!.enable();
}
// add/remove handlers from requestedEvents
if (!(events & CoreMouseEventType.MOVE)) {
el.removeEventListener('mousemove', requestedEvents.mousemove!);
requestedEvents.mousemove = null;
} else if (!requestedEvents.mousemove) {
el.addEventListener('mousemove', eventListeners.mousemove);
requestedEvents.mousemove = eventListeners.mousemove;
}
if (!(events & CoreMouseEventType.WHEEL)) {
el.removeEventListener('wheel', requestedEvents.wheel!);
requestedEvents.wheel = null;
} else if (!requestedEvents.wheel) {
el.addEventListener('wheel', eventListeners.wheel, { passive: false });
requestedEvents.wheel = eventListeners.wheel;
}
if (!(events & CoreMouseEventType.UP)) {
this._document!.removeEventListener('mouseup', requestedEvents.mouseup!);
requestedEvents.mouseup = null;
} else {
requestedEvents.mouseup ??= eventListeners.mouseup;
}
if (!(events & CoreMouseEventType.DRAG)) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag!);
requestedEvents.mousedrag = null;
} else {
requestedEvents.mousedrag ??= eventListeners.mousedrag;
}
}));
// force initial onProtocolChange so we dont miss early mouse requests
this.coreMouseService.activeProtocol = this.coreMouseService.activeProtocol;
// Ensure document-level listeners are removed on dispose
this._register(toDisposable(() => {
if (requestedEvents.mouseup) {
this._document!.removeEventListener('mouseup', requestedEvents.mouseup);
}
if (requestedEvents.mousedrag) {
this._document!.removeEventListener('mousemove', requestedEvents.mousedrag);
}
}));
/**
* "Always on" event listeners.
*/
this._register(addDisposableListener(el, 'mousedown', (ev: MouseEvent) => {
ev.preventDefault();
this.focus();
// Don't send the mouse button to the pty if mouse events are disabled or
// if the selection manager is having selection forced (ie. a modifier is
// held).
if (!this.coreMouseService.areMouseEventsActive || this._selectionService!.shouldForceSelection(ev)) {
return;
}
sendEvent(ev);
// Register additional global handlers which should keep reporting outside
// of the terminal element.
// Note: Other emulators also do this for 'mousedown' while a button
// is held, we currently limit 'mousedown' to the terminal only.
if (requestedEvents.mouseup) {
this._document!.addEventListener('mouseup', requestedEvents.mouseup);
}
if (requestedEvents.mousedrag) {
this._document!.addEventListener('mousemove', requestedEvents.mousedrag);
}
}));
this._register(addDisposableListener(el, 'wheel', (ev: WheelEvent) => {
// do nothing, if app side handles wheel itself
if (requestedEvents.wheel) return;
if (this._customWheelEventHandler && this._customWheelEventHandler(ev) === false) {
return false;
}
if (!this.buffer.hasScrollback) {
// Convert wheel events into up/down events when the buffer does not have scrollback, this
// enables scrolling in apps hosted in the alt buffer such as vim or tmux even when mouse
// events are not enabled.
// This used implementation used get the actual lines/partial lines scrolled from the
// viewport but since moving to the new viewport implementation has been simplified to
// simply send a single up or down sequence.
// Do nothing if there's no vertical scroll
const deltaY = (ev as WheelEvent).deltaY;
if (deltaY === 0) {
return false;
}
const lines = self.coreMouseService.consumeWheelEvent(
ev as WheelEvent,
self._renderService?.dimensions?.device?.cell?.height,
self._coreBrowserService?.dpr
);
if (lines === 0) {
ev.preventDefault();
ev.stopPropagation();
return false;
}
// Construct and send sequences
const sequence = C0.ESC + (this.coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[') + (ev.deltaY < 0 ? 'A' : 'B');
this.coreService.triggerDataEvent(sequence, true);
ev.preventDefault();
ev.stopPropagation();
return false;
}
}, { passive: false }));
}
/**
* Tells the renderer to refresh terminal content between two rows (inclusive) at the next
* opportunity.
@@ -738,7 +990,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
}
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
this._customWheelEventHandler = customWheelEventHandler;
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
@@ -1108,7 +1360,6 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._setup();
super.reset();
this._mouseService?.reset();
this._selectionService?.reset();
this._decorationService.reset();
+8 -8
View File
@@ -7,7 +7,7 @@ import { IBufferCellPosition, ILink, ILinkDecorations, ILinkWithState, ILinkifie
import { Disposable, dispose, toDisposable } from 'common/Lifecycle';
import { IDisposable } from 'common/Types';
import { IBufferService } from 'common/services/Services';
import { ILinkProviderService, IMouseCoordsService, IRenderService } from './services/Services';
import { ILinkProviderService, IMouseService, IRenderService } from './services/Services';
import { Emitter } from 'common/Event';
import { addDisposableListener } from 'browser/Dom';
@@ -30,7 +30,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
constructor(
private readonly _element: HTMLElement,
@IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IMouseService private readonly _mouseService: IMouseService,
@IRenderService private readonly _renderService: IRenderService,
@IBufferService private readonly _bufferService: IBufferService,
@ILinkProviderService private readonly _linkProviderService: ILinkProviderService
@@ -60,7 +60,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
private _handleMouseMove(event: MouseEvent): void {
this._lastMouseEvent = event;
const position = this._positionFromMouseEvent(event, this._element);
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
if (!position) {
return;
}
@@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
const position = this._positionFromMouseEvent(event, this._element);
const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
if (!position) {
return;
}
@@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
if (!position) {
return;
@@ -312,7 +312,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
this._clearCurrentLink(start, end);
if (this._lastMouseEvent) {
// re-eval previously active link after changes
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService!);
if (position) {
this._askForLink(position, false);
}
@@ -378,8 +378,8 @@ export class Linkifier extends Disposable implements ILinkifier2 {
* Get the buffer position from a mouse event
* @param event
*/
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined {
const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement, mouseService: IMouseService): IBufferCellPosition | undefined {
const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
if (!coords) {
return;
}
+43 -43
View File
@@ -8,7 +8,7 @@ import type { IBrowser } from 'browser/Types';
import { assert } from 'chai';
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { MockUnicodeService, createCellData } from 'common/TestUtils.test';
import { MockUnicodeService } from 'common/TestUtils.test';
import { IMarker } from 'common/Types';
const INIT_COLS = 80;
@@ -440,8 +440,8 @@ describe('Terminal', () => {
describe('scroll() function', () => {
describe('when scrollback > 0', () => {
it('should create a new line and scroll', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.scroll(DEFAULT_ATTR_DATA.clone());
assert.equal(term.buffer.lines.length, INIT_ROWS + 1);
@@ -451,9 +451,9 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -463,11 +463,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.y = 3;
term.buffer.scrollBottom = 3;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -481,11 +481,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.buffer.scrollBottom = 3;
@@ -506,9 +506,9 @@ describe('Terminal', () => {
});
it('should create a new line and shift everything up', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
assert.equal(term.buffer.lines.length, INIT_ROWS);
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -521,9 +521,9 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -533,11 +533,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.y = 3;
term.buffer.scrollBottom = 3;
term.scroll(DEFAULT_ATTR_DATA.clone());
@@ -550,11 +550,11 @@ describe('Terminal', () => {
});
it('should properly scroll inside a scroll region (scrollTop and scrollBottom set)', () => {
term.buffer.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
term.buffer.lines.get(1)!.setCell(0, createCellData(0, 'b', 0));
term.buffer.lines.get(2)!.setCell(0, createCellData(0, 'c', 0));
term.buffer.lines.get(3)!.setCell(0, createCellData(0, 'd', 0));
term.buffer.lines.get(4)!.setCell(0, createCellData(0, 'e', 0));
term.buffer.lines.get(0)!.setCell(0, CellData.fromCharData([0, 'a', 0, 'a'.charCodeAt(0)]));
term.buffer.lines.get(1)!.setCell(0, CellData.fromCharData([0, 'b', 0, 'b'.charCodeAt(0)]));
term.buffer.lines.get(2)!.setCell(0, CellData.fromCharData([0, 'c', 0, 'c'.charCodeAt(0)]));
term.buffer.lines.get(3)!.setCell(0, CellData.fromCharData([0, 'd', 0, 'd'.charCodeAt(0)]));
term.buffer.lines.get(4)!.setCell(0, CellData.fromCharData([0, 'e', 0, 'e'.charCodeAt(0)]));
term.buffer.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.buffer.scrollBottom = 3;
@@ -871,7 +871,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 1);
});
it('multiple combined é', async () => {
await term.writeP('e\u0301'.repeat(99));
await term.writeP(Array(100).join('e\u0301'));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
assert.equal(cell.getChars(), 'e\u0301');
@@ -884,7 +884,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 1);
});
it('multiple surrogate with combined', async () => {
await term.writeP('\uD800\uDC00\u0301'.repeat(99));
await term.writeP(Array(100).join('\uD800\uDC00\u0301'));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
assert.equal(cell.getChars(), '\uD800\uDC00\u0301');
@@ -912,7 +912,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.x, 3);
});
it('line of ¥ even', async () => {
await term.writeP('¥'.repeat(49));
await term.writeP(Array(50).join('¥'));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -932,7 +932,7 @@ describe('Terminal', () => {
});
it('line of ¥ odd', async () => {
term.buffer.x = 1;
await term.writeP('¥'.repeat(49));
await term.writeP(Array(50).join('¥'));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -956,7 +956,7 @@ describe('Terminal', () => {
});
it('line of ¥ with combining odd', async () => {
term.buffer.x = 1;
await term.writeP('¥\u0301'.repeat(49));
await term.writeP(Array(50).join('¥\u0301'));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -979,7 +979,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 2);
});
it('line of ¥ with combining even', async () => {
await term.writeP('¥\u0301'.repeat(49));
await term.writeP(Array(50).join('¥\u0301'));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -999,7 +999,7 @@ describe('Terminal', () => {
});
it('line of surrogate fullwidth with combining odd', async () => {
term.buffer.x = 1;
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
for (let i = 1; i < term.cols - 1; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (!(i % 2)) {
@@ -1022,7 +1022,7 @@ describe('Terminal', () => {
assert.equal(cell.getWidth(), 2);
});
it('line of surrogate fullwidth with combining even', async () => {
await term.writeP('\ud843\ude6d\u0301'.repeat(49));
await term.writeP(Array(50).join('\ud843\ude6d\u0301'));
for (let i = 0; i < term.cols; ++i) {
term.buffer.lines.get(0)!.loadCell(i, cell);
if (i % 2) {
@@ -1045,7 +1045,7 @@ describe('Terminal', () => {
describe('insert mode', () => {
const cell = new CellData();
it('halfwidth - all', async () => {
await term.writeP('0123456789'.repeat(8).slice(-80));
await term.writeP(Array(9).join('0123456789').slice(-80));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
@@ -1057,7 +1057,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '4');
});
it('fullwidth - insert', async () => {
await term.writeP('0123456789'.repeat(8).slice(-80));
await term.writeP(Array(9).join('0123456789').slice(-80));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
@@ -1070,7 +1070,7 @@ describe('Terminal', () => {
assert.equal(term.buffer.lines.get(0)!.loadCell(79, cell).getChars(), '3');
});
it('fullwidth - right border', async () => {
await term.writeP('¥'.repeat(40));
await term.writeP(Array(41).join('¥'));
term.buffer.x = 10;
term.buffer.y = 0;
term.write('\x1b[4h');
+3 -6
View File
@@ -12,7 +12,7 @@ import { IBufferLine, ICellData, IAttributeData, ICircularList, XtermListener, I
import { Buffer } from 'common/buffer/Buffer';
import * as Browser from 'common/Platform';
import { CoreBrowserTerminal } from 'browser/CoreBrowserTerminal';
import { IUnicodeService, IOptionsService, ICoreService, IMouseStateService } from 'common/services/Services';
import { IUnicodeService, IOptionsService, ICoreService, ICoreMouseService } from 'common/services/Services';
import { IFunctionIdentifier, IParams } from 'common/parser/Types';
import { AttributeData } from 'common/buffer/AttributeData';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
@@ -51,7 +51,7 @@ export class MockTerminal implements ITerminal {
public dimensions: IRenderDimensionsApi | undefined;
public markers!: IMarker[];
public linkifier: ILinkifier2 | undefined;
public mouseStateService!: IMouseStateService;
public coreMouseService!: ICoreMouseService;
public coreService!: ICoreService;
public optionsService!: IOptionsService;
public unicodeService!: IUnicodeService;
@@ -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(id: IFunctionIdentifier, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
public registerApcHandler(ident: number, callback: (data: string) => boolean | Promise<boolean>): IDisposable {
throw new Error('Method not implemented.');
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
@@ -392,9 +392,6 @@ export class MockMouseService implements IMouseService {
public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
throw new Error('Not implemented');
}
public bindMouse(): void { }
public reset(): void { }
}
export class MockRenderService implements IRenderService {
+12 -24
View File
@@ -6,13 +6,14 @@
import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { ViewportConstants } from 'browser/shared/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
import { IBufferService, ICoreService, IMouseStateService, IOptionsService } from 'common/services/Services';
import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services';
import { CoreMouseEventType } from 'common/Types';
import { scheduleAtNextAnimationFrame } from 'browser/Dom';
import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom';
import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement';
import type { IScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions';
import { Emitter, EventUtils } from 'common/Event';
import { Scrollable, ScrollbarVisibility, type IScrollEvent } from 'browser/scrollable/scrollable';
import { Gesture, EventType as GestureEventType, type IGestureEvent } from 'browser/scrollable/touch';
export class Viewport extends Disposable {
@@ -27,15 +28,13 @@ export class Viewport extends Disposable {
private _isSyncing: boolean = false;
private _isHandlingScroll: boolean = false;
private _suppressOnScrollHandler: boolean = false;
private _needsSyncOnRender: boolean = false;
constructor(
element: HTMLElement,
screenElement: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
@ICoreService private readonly _coreService: ICoreService,
@IMouseStateService mouseStateService: IMouseStateService,
@ICoreMouseService coreMouseService: ICoreMouseService,
@IThemeService themeService: IThemeService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService
@@ -66,7 +65,7 @@ export class Viewport extends Disposable {
'scrollbar'
], () => this._scrollableElement.updateOptions(this._getChangeOptions())));
// Don't handle mouse wheel if wheel events are supported by the current mouse prototcol
this._register(mouseStateService.onProtocolChange(type => {
this._register(coreMouseService.onProtocolChange(type => {
this._scrollableElement.updateOptions({
handleMouseWheel: !(type & CoreMouseEventType.WHEEL)
});
@@ -106,18 +105,11 @@ export class Viewport extends Disposable {
}));
this._register(this._bufferService.onScroll(() => this._sync()));
// Flush deferred viewport sync after a render completes (e.g. after ESU ends
// synchronized output mode). This ensures DOM scroll position updates atomically
// with the canvas render.
this._register(this._renderService.onRender(() => {
if (this._needsSyncOnRender) {
this._needsSyncOnRender = false;
this._sync();
}
}));
this._register(this._scrollableElement.onScroll(e => this._handleScroll(e)));
// Touch/gesture scrolling support
this._register(Gesture.addTarget(screenElement));
this._register(addDisposableListener(screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleGestureChange(e)));
}
public scrollLines(disp: number): void {
@@ -173,12 +165,6 @@ export class Viewport extends Disposable {
if (!this._renderService || this._isSyncing) {
return;
}
// Defer DOM scroll updates during synchronized output to prevent visible
// scroll position flickering while the canvas content is frozen.
if (this._coreService.decPrivateModes.synchronizedOutput) {
this._needsSyncOnRender = true;
return;
}
this._isSyncing = true;
// Ignore any onScroll event that happens as a result of dimensions changing as this should
@@ -218,10 +204,12 @@ export class Viewport extends Disposable {
this._isHandlingScroll = false;
}
public handleTouchScroll(translationY: number): void {
private _handleGestureChange(e: IGestureEvent): void {
e.preventDefault();
e.stopPropagation();
const pos = this._scrollableElement.getScrollPosition();
this._scrollableElement.setScrollPosition({
scrollTop: pos.scrollTop - translationY
scrollTop: pos.scrollTop - e.translationY
});
}
}
@@ -51,6 +51,8 @@ export class OverviewRulerRenderer extends Disposable {
private _shouldUpdateAnchor: boolean | undefined = true;
private _lastKnownBufferLength: number = 0;
private _containerHeight: number | undefined;
constructor(
private readonly _viewportElement: HTMLElement,
private readonly _screenElement: HTMLElement,
@@ -89,17 +91,17 @@ export class OverviewRulerRenderer extends Disposable {
}
}));
this._register(this._renderService.onDimensionsChange(() => this._queueRefresh(true)));
// Container height changed
this._register(this._renderService.onRender((): void => {
if (!this._containerHeight || this._containerHeight !== this._screenElement.clientHeight) {
this._queueRefresh(true);
this._containerHeight = this._screenElement.clientHeight;
}
}));
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);
}
@@ -142,23 +144,15 @@ 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`;
this._canvas.width = Math.round(this._width * this._coreBrowserService.dpr);
this._canvas.style.height = `${cssCanvasHeight}px`;
this._canvas.height = deviceCanvasHeight;
this._canvas.style.height = `${this._screenElement.clientHeight}px`;
this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowserService.dpr);
this._refreshDrawConstants();
this._refreshColorZonePadding();
}
private _refreshDecorations(): void {
if (this._store.isDisposed || !this._renderService.hasRenderer()) {
return;
}
if (this._shouldUpdateDimensions) {
this._refreshCanvasDimensions();
}
@@ -212,18 +206,13 @@ 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(() => {
if (!this._store.isDisposed) {
this._refreshDecorations();
}
this._refreshDecorations();
this._animationFrame = undefined;
});
}
@@ -233,31 +233,5 @@ describe('CompositionHelper', () => {
}, 0);
}, 0);
});
it('Should insert middle composition and subsequent input without appending existing trailing text', (done) => {
textarea.value = '一二';
// screenReaderMode keeps textarea content/selection for assistive technologies (eg. screen
// readers), so the caret can be moved within the textarea (eg. via arrow keys) before
// starting composition.
textarea.selectionStart = 1;
textarea.selectionEnd = 1;
compositionHelper.compositionstart();
compositionHelper.compositionupdate({ data: '一' });
textarea.value = '一一二';
// After the composed text is inserted, the caret typically moves to after it.
textarea.selectionStart = 2;
textarea.selectionEnd = 2;
setTimeout(() => { // wait for any textarea updates
compositionHelper.compositionend();
// Second character '1' (a non-composition character)
textarea.value = '一一1二';
setTimeout(() => { // wait for any textarea updates
assert.equal(handledText, '一1');
done();
}, 0);
}, 0);
});
});
});
+7 -34
View File
@@ -30,12 +30,6 @@ export class CompositionHelper {
*/
private _compositionPosition: IPosition;
/**
* Text that existed after the composing range when composition started.
* This is used to avoid treating existing trailing text as new input.
*/
private _compositionSuffix: string;
/**
* Whether a composition is in the process of being sent, setting this to false will cancel any
* in-progress composition.
@@ -63,7 +57,6 @@ export class CompositionHelper {
this._isComposing = false;
this._isSendingComposition = false;
this._compositionPosition = { start: 0, end: 0 };
this._compositionSuffix = '';
this._dataAlreadySent = '';
}
@@ -72,13 +65,7 @@ export class CompositionHelper {
*/
public compositionstart(): void {
this._isComposing = true;
// It's important to use the selection here instead of textarea length to avoid conflicts with
// screen reader mode
const start = this._textarea.selectionStart ?? this._textarea.value.length;
const end = this._textarea.selectionEnd ?? start;
this._compositionPosition.start = Math.min(start, end);
this._compositionPosition.end = Math.max(start, end);
this._compositionSuffix = this._textarea.value.substring(this._compositionPosition.end);
this._compositionPosition.start = this._textarea.value.length;
this._compositionView.textContent = '';
this._dataAlreadySent = '';
this._compositionView.classList.add('active');
@@ -89,13 +76,10 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick<CompositionEvent, 'data'>): void {
// Mark text as LTR, direction=rtl is used in CSS so the end of the text is followed for long
// compositions
this._compositionView.textContent = `\u200E${ev.data}\u200E`;
this._compositionView.textContent = ev.data;
this.updateCompositionElements();
setTimeout(() => {
const end = this._textarea.selectionEnd ?? this._textarea.value.length;
this._compositionPosition.end = Math.max( this._compositionPosition.start, end);
this._compositionPosition.end = this._textarea.value.length;
}, 0);
}
@@ -162,7 +146,6 @@ export class CompositionHelper {
start: this._compositionPosition.start,
end: this._compositionPosition.end
};
const currentCompositionSuffix = this._compositionSuffix;
// Since composition* events happen before the changes take place in the textarea on most
// browsers, use a setTimeout with 0ms time to allow the native compositionend event to
@@ -186,14 +169,10 @@ export class CompositionHelper {
// if a new composition has started.
input = this._textarea.value.substring(currentCompositionPosition.start, this._compositionPosition.start);
} else {
// Keep support for non-composition characters typed immediately after composition end
// while avoiding re-sending the trailing text that was already present
// before composition started.
const value = this._textarea.value;
const valueEnd = currentCompositionSuffix.length > 0 && value.endsWith(currentCompositionSuffix)
? value.length - currentCompositionSuffix.length
: value.length;
input = value.substring(currentCompositionPosition.start, Math.max(currentCompositionPosition.start, valueEnd));
// Don't use the end position here in order to pick up any characters after the
// composition has finished, for example when typing a non-composition character
// (eg. 2) after a composition character.
input = this._textarea.value.substring(currentCompositionPosition.start);
}
if (input.length > 0) {
this._coreService.triggerDataEvent(input, true);
@@ -260,12 +239,6 @@ export class CompositionHelper {
this._compositionView.style.lineHeight = cellHeight + 'px';
this._compositionView.style.fontFamily = this._optionsService.rawOptions.fontFamily;
this._compositionView.style.fontSize = this._optionsService.rawOptions.fontSize + 'px';
// Limit the composition view width to the space between the cursor and
// the terminal's right edge, preventing it from overflowing the terminal.
const maxWidth = this._bufferService.cols * this._renderService.dimensions.css.cell.width - cursorLeft;
this._compositionView.style.maxWidth = maxWidth + 'px';
this._compositionView.style.overflow = 'hidden';
this._compositionView.style.direction = 'rtl';
// Sync the textarea to the exact position of the composition view so the IME knows where the
// text is.
const compositionViewBounds = this._compositionView.getBoundingClientRect();

Some files were not shown because too many files have changed in this diff Show More