diff --git a/.github/hooks/setupRepo.json b/.github/hooks/setupRepo.json
new file mode 100644
index 000000000..5dd0c6560
--- /dev/null
+++ b/.github/hooks/setupRepo.json
@@ -0,0 +1,10 @@
+{
+ "hooks": {
+ "SessionStart": [
+ {
+ "type": "command",
+ "command": "npm run agent:setup-repo"
+ }
+ ]
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index d001f1606..a6fecc191 100644
--- a/README.md
+++ b/README.md
@@ -24,7 +24,36 @@ First, you need to install the module. We ship exclusively through [npm](https:/
npm install --save @xterm/xterm
```
-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 `
` onto which xterm can attach itself. Finally, instantiate the `Terminal` object and then call the `open` function with the DOM object of the `div`.
+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
+
+```
+
+### Standalone example
+
+Here is a complete standalone example in a HTML file:
```html
@@ -36,7 +65,7 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
@@ -44,14 +73,6 @@ To start using xterm.js on your browser, add the `xterm.js` and `xterm.css` to t
```
-### 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:
@@ -94,7 +115,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
@@ -229,6 +250,8 @@ 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.
- [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D)
Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list.
diff --git a/addons/addon-attach/package.json b/addons/addon-attach/package.json
index ca4ee237d..7d874cd0b 100644
--- a/addons/addon-attach/package.json
+++ b/addons/addon-attach/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-attach/test/tsconfig.json b/addons/addon-attach/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-attach/test/tsconfig.json
+++ b/addons/addon-attach/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-clipboard/package.json b/addons/addon-clipboard/package.json
index 3ab8e7f3a..176fa861a 100644
--- a/addons/addon-clipboard/package.json
+++ b/addons/addon-clipboard/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-clipboard/test/tsconfig.json b/addons/addon-clipboard/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-clipboard/test/tsconfig.json
+++ b/addons/addon-clipboard/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-fit/package.json b/addons/addon-fit/package.json
index 5b00a3b23..22158e79f 100644
--- a/addons/addon-fit/package.json
+++ b/addons/addon-fit/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-fit/test/tsconfig.json b/addons/addon-fit/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-fit/test/tsconfig.json
+++ b/addons/addon-fit/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-image/README.md b/addons/addon-image/README.md
index 9fd327e38..dc225d13b 100644
--- a/addons/addon-image/README.md
+++ b/addons/addon-image/README.md
@@ -30,7 +30,9 @@ const customSettings: IImageAddonOptions = {
storageLimit: 128, // FIFO storage limit in MB
showPlaceholder: true, // whether to show a placeholder for evicted images
iipSupport: true, // enable iTerm IIP support
- iipSizeLimit: 20000000 // size limit of a single IIP sequence
+ iipSizeLimit: 20000000, // size limit of a single IIP sequence
+ kittySupport: true, // enable Kitty graphics support
+ kittySizeLimit: 20000000 // size limit of a single Kitty sequence
}
// initialization
diff --git a/addons/addon-image/fixture/kitty/black-1x1.png b/addons/addon-image/fixture/kitty/black-1x1.png
new file mode 100644
index 000000000..945631742
Binary files /dev/null and b/addons/addon-image/fixture/kitty/black-1x1.png differ
diff --git a/addons/addon-image/fixture/kitty/multicolor-200x100.png b/addons/addon-image/fixture/kitty/multicolor-200x100.png
new file mode 100644
index 000000000..59080f319
Binary files /dev/null and b/addons/addon-image/fixture/kitty/multicolor-200x100.png differ
diff --git a/addons/addon-image/fixture/kitty/rgb-3x1.png b/addons/addon-image/fixture/kitty/rgb-3x1.png
new file mode 100644
index 000000000..3f071895d
Binary files /dev/null and b/addons/addon-image/fixture/kitty/rgb-3x1.png differ
diff --git a/addons/addon-image/package.json b/addons/addon-image/package.json
index 5078a57f4..3d5fa37f6 100644
--- a/addons/addon-image/package.json
+++ b/addons/addon-image/package.json
@@ -18,7 +18,7 @@
"xterm.js"
],
"scripts": {
- "prepackage": "../../node_modules/.bin/tsc -p .",
+ "prepackage": "../../node_modules/.bin/tsgo -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
diff --git a/addons/addon-image/src/IIPHandler.ts b/addons/addon-image/src/IIPHandler.ts
index 9662303f9..76492c405 100644
--- a/addons/addon-image/src/IIPHandler.ts
+++ b/addons/addon-image/src/IIPHandler.ts
@@ -4,7 +4,8 @@
*/
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
import { ImageRenderer } from './ImageRenderer';
-import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
+import { IIPImageStorage } from './IIPImageStorage';
+import { CELL_SIZE_DEFAULT } from './ImageStorage';
import Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
import { HeaderParser, IHeaderFields, HeaderState } from './IIPHeaderParser';
import { imageType, UNSUPPORTED_TYPE } from './IIPMetrics';
@@ -40,7 +41,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
constructor(
private readonly _opts: IImageAddonOptions,
private readonly _renderer: ImageRenderer,
- private readonly _storage: ImageStorage,
+ private readonly _storage: IIPImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
const maxEncodedBytes = Math.ceil(this._opts.iipSizeLimit * 4 / 3);
diff --git a/addons/addon-image/src/IIPImageStorage.ts b/addons/addon-image/src/IIPImageStorage.ts
new file mode 100644
index 000000000..0da91607f
--- /dev/null
+++ b/addons/addon-image/src/IIPImageStorage.ts
@@ -0,0 +1,26 @@
+/**
+ * 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);
+ }
+}
diff --git a/addons/addon-image/src/ImageAddon.ts b/addons/addon-image/src/ImageAddon.ts
index d18b59a06..7af0e519c 100644
--- a/addons/addon-image/src/ImageAddon.ts
+++ b/addons/addon-image/src/ImageAddon.ts
@@ -5,10 +5,15 @@
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';
// default values of addon ctor options
@@ -22,7 +27,9 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
- iipSizeLimit: 20000000
+ iipSizeLimit: 20000000,
+ kittySupport: true,
+ kittySizeLimit: 20000000
};
// max palette size supported by the sixel lib (compile time setting)
@@ -56,6 +63,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
private _disposables: IDisposable[] = [];
private _terminal: ITerminalExt | undefined;
private _handlers: Map = new Map();
+ private readonly _onImageAdded = new Emitter();
+ public readonly onImageAdded: IEvent = this._onImageAdded.event;
constructor(opts?: Partial) {
this._opts = Object.assign({}, DEFAULT_OPTIONS, opts);
@@ -68,6 +77,7 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
}
this._disposables.length = 0;
this._handlers.clear();
+ this._onImageAdded.dispose();
}
private _disposeLater(...args: IDisposable[]): void {
@@ -82,6 +92,7 @@ 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) {
@@ -129,7 +140,8 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// SIXEL handler
if (this._opts.sixelSupport) {
- const sixelHandler = new SixelHandler(this._opts, this._storage!, terminal);
+ const sixelStorage = new SixelImageStorage(this._storage!, this._opts, this._renderer!, terminal);
+ const sixelHandler = new SixelHandler(this._opts, sixelStorage, terminal);
this._handlers.set('sixel', sixelHandler);
this._disposeLater(
terminal._core._inputHandler._parser.registerDcsHandler({ final: 'q' }, sixelHandler)
@@ -138,12 +150,25 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
// iTerm IIP handler
if (this._opts.iipSupport) {
- const iipHandler = new IIPHandler(this._opts, this._renderer!, this._storage!, terminal);
+ const iipStorage = new IIPImageStorage(this._storage!);
+ const iipHandler = new IIPHandler(this._opts, this._renderer!, iipStorage, 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(0x47, kittyHandler)
+ );
+ }
}
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
diff --git a/addons/addon-image/src/ImageRenderer.ts b/addons/addon-image/src/ImageRenderer.ts
index 1068c4673..53a0a9c9b 100644
--- a/addons/addon-image/src/ImageRenderer.ts
+++ b/addons/addon-image/src/ImageRenderer.ts
@@ -5,7 +5,7 @@
import { toRGBA8888 } from 'sixel/lib/Colors';
import { IDisposable } from '@xterm/xterm';
-import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
+import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
const PLACEHOLDER_LENGTH = 4096;
@@ -18,8 +18,9 @@ const PLACEHOLDER_HEIGHT = 24;
* - draw image tiles onRender
*/
export class ImageRenderer extends Disposable implements IDisposable {
- public canvas: HTMLCanvasElement | undefined;
- private _ctx: CanvasRenderingContext2D | null | undefined;
+ /** @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();
private _placeholder: HTMLCanvasElement | undefined;
private _placeholderBitmap: ImageBitmap | undefined;
private _optionsRefresh = this._register(new MutableDisposable());
@@ -86,6 +87,7 @@ 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;
@@ -95,8 +97,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._oldSetRenderer = undefined;
}
this._renderService = undefined;
- this.canvas = undefined;
- this._ctx = undefined;
+ this._layers.clear();
this._placeholderBitmap?.close();
this._placeholderBitmap = undefined;
this._placeholder = undefined;
@@ -140,27 +141,38 @@ export class ImageRenderer extends Disposable implements IDisposable {
/**
* Clear a region of the image layer canvas.
*/
- 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)
- );
+ 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);
+ }
}
/**
* Clear whole image canvas.
*/
- public clearAll(): void {
- this._ctx?.clearRect(0, 0, this.canvas?.width || 0, this.canvas?.height || 0);
+ 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);
+ }
}
/**
* Draw neighboring tiles on the image layer canvas.
*/
public draw(imgSpec: IImageSpec, tileId: number, col: number, row: number, count: number = 1): void {
- if (!this._ctx) {
+ const ctx = this._layers.get(imgSpec.layer);
+ if (!ctx) {
return;
}
const { width, height } = this.cellSize;
@@ -187,7 +199,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
// 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
- this._ctx.drawImage(
+ 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)
@@ -227,7 +239,8 @@ 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 {
- if (this._ctx) {
+ const ctx = this._layers.get('top');
+ if (ctx) {
const { width, height } = this.cellSize;
// Don't try to draw anything, if we cannot get valid renderer metrics.
@@ -241,7 +254,7 @@ export class ImageRenderer extends Disposable implements IDisposable {
this._createPlaceHolder(height + 1);
}
if (!this._placeholder) return;
- this._ctx.drawImage(
+ ctx.drawImage(
this._placeholderBitmap ?? this._placeholder!,
col * width,
(row * height) % 2 ? 0 : 1, // needs %2 offset correction
@@ -260,12 +273,13 @@ export class ImageRenderer extends Disposable implements IDisposable {
* Checked once from `ImageStorage.render`.
*/
public rescaleCanvas(): void {
- 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;
+ 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;
+ }
}
}
@@ -304,35 +318,65 @@ 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) => {
- this.removeLayerFromDom();
+ for (const key of [...this._layers.keys()]) {
+ this.removeLayerFromDom(key);
+ }
this._oldSetRenderer?.call(this._renderService, renderer);
};
}
- public insertLayerToDom(): void {
+ public insertLayerToDom(layer: ImageLayer = 'top'): void {
// make sure that the terminal is attached to a document and to DOM
- 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 {
+ 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);
+ } 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, desynchronized: 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);
}
}
- public removeLayerFromDom(): void {
- if (this.canvas) {
- this._ctx = undefined;
- this.canvas.remove();
- this.canvas = undefined;
- }
+ public hasLayer(layer: ImageLayer): boolean {
+ return this._layers.has(layer);
}
private _createPlaceHolder(height: number = PLACEHOLDER_HEIGHT): void {
diff --git a/addons/addon-image/src/ImageStorage.ts b/addons/addon-image/src/ImageStorage.ts
index aea9b5e5f..8c89a16b0 100644
--- a/addons/addon-image/src/ImageStorage.ts
+++ b/addons/addon-image/src/ImageStorage.ts
@@ -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 } from './Types';
+import { ITerminalExt, IExtendedAttrsImage, IImageAddonOptions, IImageSpec, IBufferLineExt, BgFlags, Cell, Content, ICellSize, ExtFlags, Attributes, UnderlineStyle, ImageLayer } from './Types';
// fallback default cell size
@@ -124,6 +124,8 @@ 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,
@@ -189,11 +191,13 @@ 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 (spec && window.ImageBitmap && spec.orig instanceof ImageBitmap) {
+ if (window.ImageBitmap && spec.orig instanceof ImageBitmap) {
spec.orig.close();
}
+ this.onImageDeleted?.(id);
}
/**
@@ -217,27 +221,27 @@ export class ImageStorage implements IDisposable {
}
/**
- * 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.
+ * Delete an image by its internal storage ID.
+ * Used by protocols that support explicit deletion (e.g. Kitty a=d).
*/
- 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();
- }
+ public deleteImage(id: number): void {
+ const spec = this._images.get(id);
+ if (spec) {
+ spec.marker?.dispose();
+ this._delImg(id);
}
}
/**
* 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): void {
+ public addImage(img: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer = 'top', zIndex: number = 0): number {
// never allow storage to exceed memory limit
this._evictOldest(img.width * img.height);
@@ -259,7 +263,7 @@ export class ImageStorage implements IDisposable {
let offset = originX;
let tileCount = 0;
- if (!this._opts.sixelScrolling) {
+ if (!scrolling) {
buffer.x = 0;
buffer.y = 0;
offset = 0;
@@ -273,7 +277,7 @@ export class ImageStorage implements IDisposable {
this._writeToCell(line as IBufferLineExt, offset + col, imageId, row * cols + col);
tileCount++;
}
- if (this._opts.sixelScrolling) {
+ if (scrolling) {
if (row < rows - 1) this._terminal._core._inputHandler.lineFeed();
} else {
if (++buffer.y >= termRows) break;
@@ -283,7 +287,7 @@ export class ImageStorage implements IDisposable {
this._terminal._core._inputHandler._dirtyRowTracker.markDirty(buffer.y);
// cursor positioning modes
- if (this._opts.sixelScrolling) {
+ if (scrolling) {
buffer.x = offset;
} else {
buffer.x = originX;
@@ -326,11 +330,15 @@ export class ImageStorage implements IDisposable {
actualCellSize: { ...cellSize }, // clone needed, since later modified
marker: endMarker || undefined,
tileCount,
- bufferType: this._terminal.buffer.active.type
+ bufferType: this._terminal.buffer.active.type,
+ layer,
+ zIndex
};
// finally add the image
this._images.set(imageId, imgSpec);
+ this.onImageAdded?.();
+ return imageId;
}
@@ -340,16 +348,30 @@ export class ImageStorage implements IDisposable {
*/
// TODO: Should we move this to the ImageRenderer?
public render(range: { start: number, end: number }): void {
- // 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;
+ // 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;
}
+ 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) {
@@ -357,12 +379,25 @@ export class ImageStorage implements IDisposable {
this._fullyCleared = true;
this._needsFullClear = false;
}
- if (this._renderer.canvas) {
- this._renderer.removeLayerFromDom();
+ if (this._renderer.hasLayer('top')) {
+ this._renderer.removeLayerFromDom('top');
+ }
+ if (this._renderer.hasLayer('bottom')) {
+ this._renderer.removeLayerFromDom('bottom');
}
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();
@@ -377,50 +412,77 @@ export class ImageStorage implements IDisposable {
// clear drawing area
this._renderer.clearLines(start, end);
- // walk all cells in viewport and draw tiles found
+ // 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).
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) {
- let e: IExtendedAttrsImage = line._extendedAttrs[col] ?? EMPTY_ATTRS;
- const imageId = e.imageId;
- if (imageId === undefined || imageId === -1) {
+ e = line._extendedAttrs[col] ?? EMPTY_ATTRS;
+ } else {
+ const maybeImg = line._extendedAttrs[col] as IExtendedAttrsImage | undefined;
+ if (!maybeImg || maybeImg.imageId === undefined || maybeImg.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
- */
- while (
- ++col < cols
- && (line.getBg(col) & BgFlags.HAS_EXTENDED)
- && (e = line._extendedAttrs[col] ?? EMPTY_ATTRS)
- && (e.imageId === imageId)
- && (e.tileId === startTile + count)
- ) {
- count++;
+ 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;
}
- 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);
- }
- this._fullyCleared = false;
+ e = nextE;
+ count++;
}
+ col--;
+ if (imgSpec) {
+ if (imgSpec.actual) {
+ drawCalls.push({ imgSpec, tileId: startTile, col: startCol, row, count });
+ }
+ } else if (this._opts.showPlaceholder) {
+ placeholderCalls.push({ col: startCol, row, count });
+ }
+ 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 {
diff --git a/addons/addon-image/src/SixelHandler.ts b/addons/addon-image/src/SixelHandler.ts
index 07d90341c..2f12fbe19 100644
--- a/addons/addon-image/src/SixelHandler.ts
+++ b/addons/addon-image/src/SixelHandler.ts
@@ -3,7 +3,7 @@
* @license MIT
*/
-import { ImageStorage } from './ImageStorage';
+import { SixelImageStorage } from './SixelImageStorage';
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: ImageStorage,
+ private readonly _storage: SixelImageStorage,
private readonly _coreTerminal: ITerminalExt
) {
DecoderAsync({
diff --git a/addons/addon-image/src/SixelImageStorage.ts b/addons/addon-image/src/SixelImageStorage.ts
new file mode 100644
index 000000000..4c5f555bb
--- /dev/null
+++ b/addons/addon-image/src/SixelImageStorage.ts
@@ -0,0 +1,50 @@
+/**
+ * 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();
+ }
+ }
+ }
+}
diff --git a/addons/addon-image/src/Types.ts b/addons/addon-image/src/Types.ts
index 60de0f3d2..80cec9e24 100644
--- a/addons/addon-image/src/Types.ts
+++ b/addons/addon-image/src/Types.ts
@@ -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, IEscapeSequenceParser } from 'common/parser/Types';
+import type { IParams, IDcsHandler, IOscHandler, IApcHandler, 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, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
+export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
/**
* Plugin ctor options.
@@ -38,6 +38,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
+ kittySupport: boolean;
+ kittySizeLimit: number;
}
export interface IResetHandler {
@@ -97,6 +99,8 @@ export interface ICellSize {
height: number;
}
+export type ImageLayer = 'top' | 'bottom';
+
export interface IImageSpec {
orig: HTMLCanvasElement | ImageBitmap | undefined;
origCellSize: ICellSize;
@@ -105,4 +109,6 @@ export interface IImageSpec {
marker: IMarker | undefined;
tileCount: number;
bufferType: 'alternate' | 'normal';
+ layer: ImageLayer;
+ zIndex: number;
}
diff --git a/addons/addon-image/src/kitty/KittyGraphicsHandler.ts b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts
new file mode 100644
index 000000000..17cd5ed9f
--- /dev/null
+++ b/addons/addon-image/src/kitty/KittyGraphicsHandler.ts
@@ -0,0 +1,820 @@
+/**
+ * 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,
+ BYTES_PER_PIXEL_RGB,
+ BYTES_PER_PIXEL_RGBA,
+ ALPHA_OPAQUE,
+ parseKittyCommand
+} from './KittyGraphicsTypes';
+
+// Memory limit for base64 decoder (4MB, same as IIPHandler)
+const DECODER_KEEP_DATA = 4194304;
+const DECODER_INITIAL_DATA = 4194304; // 4MB
+
+// Local mirror of const enum (esbuild can't inline const enums from external packages)
+const DECODER_OK: DecodeStatus.OK = 0;
+
+// Maximum control data size
+const MAX_CONTROL_DATA_SIZE = 512;
+
+// Semicolon codepoint
+const SEMICOLON = 0x3B;
+
+// 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(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 = 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(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] === SEMICOLON) {
+ this._inControlData = false;
+ controlEnd = i;
+ break;
+ }
+ }
+
+ // Copy control data
+ const copyLength = controlEnd - start;
+ if (this._controlLength + copyLength > 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(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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 ? BYTES_PER_PIXEL_RGBA : 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= 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 {
+ return this._decodeAndDisplay(image, cmd)
+ .then(() => true)
+ .catch(() => false);
+ }
+
+ private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise {
+ 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 {
+ 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((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 ? BYTES_PER_PIXEL_RGBA : 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 * BYTES_PER_PIXEL_RGBA), width, height));
+ }
+
+ // RGB→RGBA: interleave alpha using uint32 block processing (4 pixels per iteration).
+ // 3 uint32 reads + 4 uint32 writes per 4 pixels vs 28 byte reads/writes — ~6x faster.
+ // Assumes little-endian (all modern browsers/Node.js).
+ const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
+ const 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 * BYTES_PER_PIXEL_RGB;
+ let dstByte = alignedPixels * BYTES_PER_PIXEL_RGBA;
+ for (let i = alignedPixels; i < pixelCount; i++) {
+ data[dstByte] = bytes[srcByte];
+ data[dstByte + 1] = bytes[srcByte + 1];
+ data[dstByte + 2] = bytes[srcByte + 2];
+ data[dstByte + 3] = ALPHA_OPAQUE;
+ srcByte += BYTES_PER_PIXEL_RGB;
+ dstByte += BYTES_PER_PIXEL_RGBA;
+ }
+
+ return createImageBitmap(new ImageData(data, width, height));
+ }
+
+ private async _decompressZlib(compressed: Uint8Array): Promise {
+ 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 {
+ 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 {
+ return this._kittyStorage.images;
+ }
+
+ public get _kittyIdToStorageId(): ReadonlyMap {
+ return this._kittyStorage.kittyIdToStorageId;
+ }
+
+ public get pendingTransmissions(): ReadonlyMap {
+ return this._pendingTransmissions;
+ }
+}
diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts
new file mode 100644
index 000000000..4c61eccf7
--- /dev/null
+++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.test.ts
@@ -0,0 +1,149 @@
+/**
+ * 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);
+ });
+ });
+});
diff --git a/addons/addon-image/src/kitty/KittyGraphicsTypes.ts b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts
new file mode 100644
index 000000000..82b2b0a9a
--- /dev/null
+++ b/addons/addon-image/src/kitty/KittyGraphicsTypes.ts
@@ -0,0 +1,193 @@
+/**
+ * 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 BYTES_PER_PIXEL_RGB = 3;
+export const BYTES_PER_PIXEL_RGBA = 4;
+export const 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;
+}
diff --git a/addons/addon-image/src/kitty/KittyImageStorage.ts b/addons/addon-image/src/kitty/KittyImageStorage.ts
new file mode 100644
index 000000000..95ea594c7
--- /dev/null
+++ b/addons/addon-image/src/kitty/KittyImageStorage.ts
@@ -0,0 +1,151 @@
+/**
+ * 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 = 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>
+ // (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 = new Map();
+ private readonly _storageIdToKittyId: Map = 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): 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 {
+ return this._images;
+ }
+
+ public get kittyIdToStorageId(): ReadonlyMap {
+ 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);
+ }
+ }
+ }
+}
diff --git a/addons/addon-image/src/tsconfig.json b/addons/addon-image/src/tsconfig.json
index a18b47c9c..9a5d24c1f 100644
--- a/addons/addon-image/src/tsconfig.json
+++ b/addons/addon-image/src/tsconfig.json
@@ -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": [
diff --git a/addons/addon-image/test/ImageAddon.test.ts b/addons/addon-image/test/ImageAddon.test.ts
index a26ba0f48..67769204f 100644
--- a/addons/addon-image/test/ImageAddon.test.ts
+++ b/addons/addon-image/test/ImageAddon.test.ts
@@ -23,6 +23,8 @@ export interface IImageAddonOptions {
sixelSizeLimit: number;
iipSupport: boolean;
iipSizeLimit: number;
+ kittySupport: boolean;
+ kittySizeLimit: number;
}
// eslint-disable-next-line
@@ -134,7 +136,9 @@ test.describe('ImageAddon', () => {
storageLimit: 128,
showPlaceholder: true,
iipSupport: true,
- iipSizeLimit: 20000000
+ iipSizeLimit: 20000000,
+ kittySupport: true,
+ kittySizeLimit: 20000000
};
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
});
@@ -149,7 +153,9 @@ test.describe('ImageAddon', () => {
storageLimit: 10,
showPlaceholder: false,
iipSupport: false,
- iipSizeLimit: 1000
+ iipSizeLimit: 1000,
+ kittySupport: false,
+ kittySizeLimit: 1000
};
await ctx.page.evaluate(opts => {
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
@@ -190,6 +196,18 @@ 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);
diff --git a/addons/addon-image/test/KittyGraphics.test.ts b/addons/addon-image/test/KittyGraphics.test.ts
new file mode 100644
index 000000000..315538aa1
--- /dev/null
+++ b/addons/addon-image/test/KittyGraphics.test.ts
@@ -0,0 +1,2223 @@
+/**
+ * Copyright (c) 2026 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import test from '@playwright/test';
+import { readFileSync } from 'fs';
+import { ITestContext, createTestContext, openTerminal, pollFor, timeout } from '../../../test/playwright/TestUtils';
+import { deepStrictEqual, ok, strictEqual } from 'assert';
+
+/**
+ * Plugin ctor options.
+ */
+export interface IImageAddonOptions {
+ enableSizeReports: boolean;
+ pixelLimit: number;
+ storageLimit: number;
+ showPlaceholder: boolean;
+ sixelSupport: boolean;
+ sixelScrolling: boolean;
+ sixelPaletteLimit: number;
+ sixelSizeLimit: number;
+ iipSupport: boolean;
+ iipSizeLimit: number;
+ kittySupport: boolean;
+ kittySizeLimit: number;
+}
+
+// eslint-disable-next-line
+declare const ImageAddon: {
+ new(options?: Partial): any;
+};
+
+interface IDimensions {
+ cellWidth: number;
+ cellHeight: number;
+ width: number;
+ height: number;
+}
+
+// Kitty graphics test images
+const KITTY_BLACK_1X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png').toString('base64');
+const KITTY_BLACK_1X1_BYTES = Array.from(readFileSync('./addons/addon-image/fixture/kitty/black-1x1.png'));
+const KITTY_RGB_3X1_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/rgb-3x1.png').toString('base64');
+const KITTY_MULTICOLOR_200X100_BASE64 = readFileSync('./addons/addon-image/fixture/kitty/multicolor-200x100.png').toString('base64');
+const KITTY_MULTICOLOR_200X100_BYTES = Array.from(readFileSync('./addons/addon-image/fixture/kitty/multicolor-200x100.png'));
+
+// Raw RGB pixel data (f=24): 3 bytes per pixel, no header — requires s= and v=
+const RAW_RGB_1X1_BLACK = Buffer.from([0, 0, 0]).toString('base64');
+const RAW_RGB_1X1_RED = Buffer.from([255, 0, 0]).toString('base64');
+const RAW_RGB_3X1 = Buffer.from([
+ 255, 0, 0,
+ 0, 255, 0,
+ 0, 0, 255
+]).toString('base64');
+const RAW_RGB_2X2 = Buffer.from([
+ 255, 0, 0, 0, 255, 0,
+ 0, 0, 255, 255, 255, 0
+]).toString('base64');
+// 5 pixels (1 uint32 block + 1 remainder) — tests block+tail boundary
+const RAW_RGB_5X1 = Buffer.from([
+ 255, 0, 0,
+ 0, 255, 0,
+ 0, 0, 255,
+ 255, 255, 0,
+ 255, 0, 255
+]).toString('base64');
+// 8 pixels (2 full uint32 blocks, 0 remainder) — tests multi-block path
+const RAW_RGB_4X2 = Buffer.from([
+ 255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 0,
+ 255, 0, 255, 0, 255, 255, 128, 128, 128, 255, 255, 255
+]).toString('base64');
+
+// Raw RGBA pixel data (f=32): 4 bytes per pixel, no header — requires s= and v=
+const RAW_RGBA_1X1_WHITE = Buffer.from([255, 255, 255, 255]).toString('base64');
+const RAW_RGBA_1X1_RED = Buffer.from([255, 0, 0, 255]).toString('base64');
+const RAW_RGBA_1X1_TRANSPARENT = Buffer.from([0, 0, 0, 0]).toString('base64');
+const RAW_RGBA_3X1 = Buffer.from([
+ 255, 0, 0, 255,
+ 0, 255, 0, 255,
+ 0, 0, 255, 255
+]).toString('base64');
+const RAW_RGBA_2X2 = Buffer.from([
+ 255, 0, 0, 255, 0, 255, 0, 255,
+ 0, 0, 255, 255, 255, 255, 0, 255
+]).toString('base64');
+// 5 pixels — tests RGBA zero-copy with non-power-of-2 count
+const RAW_RGBA_5X1 = Buffer.from([
+ 255, 0, 0, 255,
+ 0, 255, 0, 255,
+ 0, 0, 255, 255,
+ 255, 255, 0, 255,
+ 255, 0, 255, 255
+]).toString('base64');
+
+let ctx: ITestContext;
+test.beforeAll(async ({ browser }) => {
+ ctx = await createTestContext(browser);
+ await openTerminal(ctx, { cols: 80, rows: 24 });
+});
+test.afterAll(async () => await ctx.page.close());
+
+test.describe('Kitty Graphics Protocol', () => {
+ // TODO: Add tests for larger images with various dimensions
+ // TODO: Add tests for virtual placement (U=1)
+ // TODO: Add tests for animation frames
+ // TODO: Add performance tests for streaming large images
+ // TODO: Implement cursor movement per Kitty spec - cursor should move by cols/rows after placement (unless C=1)
+ // TODO: Distinguish lowercase delete selectors (placement only) from uppercase (placement + free data)
+
+ test.beforeEach(async ({}, testInfo) => {
+ // DEBT: This test never worked on webkit
+ if (ctx.browser.browserType().name() === 'webkit') {
+ testInfo.skip();
+ return;
+ }
+ await ctx.page.evaluate(`
+ window.term.reset()
+ window.imageAddon?.dispose();
+ window.imageAddon = new ImageAddon({ sixelPaletteLimit: 512 });
+ window.term.loadAddon(window.imageAddon);
+ `);
+ });
+
+ test.describe('Basic transmission and storage', () => {
+ test('stores 1x1 black PNG with a=T (transmit and display)', async () => {
+ const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [1, 1]);
+ });
+
+ test('stores 3x1 RGB PNG with a=T', async () => {
+ const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [3, 1]);
+ });
+
+ test('transmit only (a=t) does not display but stores in handler', async () => {
+ const seq = `\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ });
+
+ test('uses specified image ID', async () => {
+ const seq = `\x1b_Ga=t,f=100,i=42;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(42)`), true);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), false);
+ });
+
+ test('assigns auto-incrementing IDs when not specified', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(1)`), true);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(2)`), true);
+ });
+
+ test('defaults to transmit action when action is omitted', async () => {
+ const seq = `\x1b_Gf=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ });
+
+ test('ignores command when action is empty string', async () => {
+ const seq = `\x1b_Ga=,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
+ });
+ });
+
+ test.describe('Chunked transmission', () => {
+ test('handles chunked transmission (m=1)', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ const seq1 = `\x1b_Ga=T,f=100,i=99,m=1;${part1}\x1b\\`;
+ const seq2 = `\x1b_Ga=T,f=100,i=99;${part2}\x1b\\`;
+
+ await ctx.proxy.write(seq1);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+
+ await ctx.proxy.write(seq2);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ });
+
+ test('verifies chunked data is assembled correctly', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=99;${part2}\x1b\\`);
+ await timeout(100);
+
+ const storedData = await ctx.page.evaluate(async () => {
+ const blob = (window as any).imageAddon._handlers.get('kitty').images.get(99).data;
+ const buffer = await blob.arrayBuffer();
+ return Array.from(new Uint8Array(buffer));
+ });
+ deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
+ });
+
+ test('enforces size limit across chunked transmissions', async () => {
+ // Create a custom addon with very small size limit (100 bytes)
+ // The 1x1 PNG is ~164 bytes base64, so 2 chunks should exceed 100
+ await ctx.page.evaluate(() => {
+ (window as any).smallLimitAddon = new ImageAddon({
+ kittySupport: true,
+ kittySizeLimit: 100 // Very small limit
+ });
+ (window as any).term.loadAddon((window as any).smallLimitAddon);
+ });
+
+ // Split the base64 data into two chunks
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ // Send chunked data - first chunk (~82 bytes) is under limit
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=777,m=1;${part1}\x1b\\`);
+ await timeout(50);
+
+ // Second chunk brings total to ~164 bytes, exceeding 100 byte limit
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=777;${part2}\x1b\\`);
+ await timeout(100);
+
+ // Image should NOT be stored due to size limit
+ strictEqual(await ctx.page.evaluate(`window.smallLimitAddon._handlers.get('kitty').images.has(777)`), false);
+
+ // Cleanup
+ await ctx.page.evaluate(() => {
+ (window as any).smallLimitAddon.dispose();
+ });
+ });
+
+ test('chunked a=T works when subsequent chunks omit i= (spec pattern)', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=400,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+
+ await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ });
+
+ test('chunked a=t works when subsequent chunks omit i= (spec pattern)', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=401,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(401)`), true);
+ });
+
+ test('chunked data without i= on subsequent chunks is assembled correctly', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=402,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
+ await timeout(100);
+
+ const storedData = await ctx.page.evaluate(async () => {
+ const blob = (window as any).imageAddon._handlers.get('kitty').images.get(402).data;
+ const buffer = await blob.arrayBuffer();
+ return Array.from(new Uint8Array(buffer));
+ });
+ deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
+ });
+
+ test('three-chunk transfer with only m= on middle and last chunks', async () => {
+ const third = Math.floor(KITTY_BLACK_1X1_BASE64.length / 3);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, third);
+ const part2End = third + Math.floor((KITTY_BLACK_1X1_BASE64.length - third) / 2);
+ const alignedPart2End = part2End - (part2End - third) % 4 + third;
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(third, alignedPart2End);
+ const part3 = KITTY_BLACK_1X1_BASE64.substring(alignedPart2End);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=403,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Gm=1;${part2}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Gm=0;${part3}\x1b\\`);
+ await timeout(100);
+
+ const storedData = await ctx.page.evaluate(async () => {
+ const blob = (window as any).imageAddon._handlers.get('kitty').images.get(403).data;
+ const buffer = await blob.arrayBuffer();
+ return Array.from(new Uint8Array(buffer));
+ });
+ deepStrictEqual(storedData, KITTY_BLACK_1X1_BYTES);
+ });
+
+ test('chunked a=T without i= on any chunk works (no response)', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+
+ await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ });
+
+ test('chunked transfer responds OK on final chunk when i= on first only', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=405,m=1;${part1}\x1b\\`);
+ await timeout(50);
+
+ let response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '');
+
+ await ctx.proxy.write(`\x1b_Gm=0;${part2}\x1b\\`);
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=405;OK\x1b\\');
+ });
+ });
+
+ test.describe('Delete commands', () => {
+ test('delete command (a=d,d=i) removes specific image by id', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=10;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=10\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
+ });
+
+ test('delete command (a=d) removes all images when no id specified', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=2;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
+ });
+
+ test('delete by id aborts in-flight chunked upload', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=50,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=50\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
+ });
+
+ test('delete by id only aborts targeted upload, not others', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=55,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=56,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=55\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.has(56)`), true);
+ });
+
+ test('delete all aborts in-flight chunked upload', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=60,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=61,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 0);
+ });
+
+ test('d=i selector deletes specific image by id', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=81;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=80\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(81)`), true);
+ });
+
+ test('d=I selector deletes specific image by id (uppercase)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=82;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=83;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=I,i=82\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(83)`), true);
+ });
+
+ test('d=a selector deletes all images', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=84;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=85;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
+ });
+
+ test('d=A selector deletes all images (uppercase)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=86;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=87;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 2);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=A\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 0);
+ });
+
+ test('d=a selector also removes displayed images from storage', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=88;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('d=i selector also removes displayed image from storage', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=89;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=89\x1b\\`);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('d=i without id does nothing', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=90;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ });
+
+ test('d=i selector clears pixels from canvas', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=92,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=92\x1b\\`);
+ await timeout(100);
+ strictEqual(await getPixel(0, 0, 0, 0), null);
+ });
+
+ test('d=a selector clears all pixels from canvas', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=93,q=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=a\x1b\\`);
+ await timeout(100);
+ strictEqual(await getPixel(0, 0, 0, 0), null);
+ });
+
+ test('unsupported delete selector is ignored', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=91;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=c\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ });
+
+ test('chunks sent after delete are not assembled with previous data', async () => {
+ const half = Math.floor(KITTY_BLACK_1X1_BASE64.length / 2);
+ const part1 = KITTY_BLACK_1X1_BASE64.substring(0, half);
+ const part2 = KITTY_BLACK_1X1_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').pendingTransmissions.size`), 1);
+
+ await ctx.proxy.write(`\x1b_Ga=d\x1b\\`);
+ await timeout(50);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=70;${part2}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(70)`), true);
+ const storedSize: number = await ctx.page.evaluate(async () => {
+ const blob = (window as any).imageAddon._handlers.get('kitty').images.get(70).data;
+ return blob.size;
+ });
+ ok(storedSize < KITTY_BLACK_1X1_BYTES.length, 'stored data should be smaller than full image (only second half)');
+ });
+ });
+
+ test.describe('Query support (a=q)', () => {
+ test('responds with OK for capability query without payload', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=31,a=q;\x1b\\');
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=31;OK\x1b\\');
+ });
+
+ test('responds with OK for valid PNG query', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=42,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=42;OK\x1b\\');
+ });
+
+ test('query does NOT store the image (unlike transmit)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).term.onData(() => { /* consume response */ });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=50,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), false);
+ });
+
+ test('responds with error for invalid base64', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=60,a=q,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=60;EINVAL:'), true);
+ });
+
+ test('responds with error for RGB data without dimensions', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=70,a=q,f=24;AAAA\x1b\\');
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=70;EINVAL:width and height required for raw pixel data\x1b\\');
+ });
+
+ test('suppresses OK response when q=1', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=80,a=q,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('suppresses error response when q=2', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=90,a=q,q=2,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('responds with EINVAL when both i and I keys are specified', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ // Per spec: "Specifying both i and I keys in any command is an error"
+ await ctx.proxy.write(`\x1b_Gi=100,I=200,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=100;EINVAL:cannot specify both i and I keys\x1b\\');
+ });
+
+ test('responds with EINVAL for i+I conflict even without payload', async () => {
+ let response = '';
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ // Delete command with both i and I (no payload case)
+ await ctx.proxy.write('\x1b_Gi=101,I=201,a=d\x1b\\');
+ await timeout(100);
+
+ response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=101;EINVAL:cannot specify both i and I keys\x1b\\');
+ });
+ });
+
+ test.describe('Error responses for transmit and display', () => {
+ test('a=t sends EINVAL on decode error when id is specified', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=110,a=t,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=110;EINVAL:invalid base64 data\x1b\\');
+ });
+
+ test('a=t sends no response on decode error without id', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write('\x1b_Ga=t,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('a=T sends EINVAL on decode error when id is specified', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=120,a=T,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=120;EINVAL:invalid base64 data\x1b\\');
+ });
+
+ test('a=T sends no response on decode error without id', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write('\x1b_Ga=T,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('a=T sends EINVAL when raw pixel render fails (missing dimensions)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=130,a=T,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=130;EINVAL:'), true);
+ });
+
+ test('a=T sends OK on successful render with id', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=140,a=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=140;OK\x1b\\');
+ });
+
+ test('a=t sends OK on successful transmit with id', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=150,a=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=150;OK\x1b\\');
+ });
+
+ test('a=t EINVAL suppressed by q=2', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=160,a=t,q=2,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('a=T EINVAL suppressed by q=2', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write('\x1b_Gi=170,a=T,q=2,f=100;!!!invalid!!!\x1b\\');
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('a=t OK suppressed by q=1', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=180,a=t,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('a=T OK suppressed by q=1', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=190,a=T,q=1,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+ });
+
+ test.describe('Transmission medium rejection', () => {
+ test('query rejects t=f (file transmission)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=200,a=q,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=200;EINVAL:'), true);
+ });
+
+ test('query rejects t=s (shared memory)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=201,a=q,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=201;EINVAL:'), true);
+ });
+
+ test('query rejects t=t (temp file)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=202,a=q,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=202;EINVAL:'), true);
+ });
+
+ test('query accepts t=d (direct transmission)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=203,a=q,t=d,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=203;OK\x1b\\');
+ });
+
+ test('query without t key defaults to direct (OK)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=204,a=q,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=204;OK\x1b\\');
+ });
+
+ test('transmit rejects t=f with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=300,a=t,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=300;EINVAL:'), true);
+ });
+
+ test('transmit rejects t=s with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=301,a=t,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=301;EINVAL:'), true);
+ });
+
+ test('transmit rejects t=t with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=302,a=t,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=302;EINVAL:'), true);
+ });
+
+ test('transmit rejects t=f without id (no response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=t,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '');
+ });
+
+ test('transmit+display rejects t=f with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=310,a=T,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=310;EINVAL:'), true);
+ });
+
+ test('transmit+display rejects t=s with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=311,a=T,t=s,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=311;EINVAL:'), true);
+ });
+
+ test('transmit+display rejects t=t with id (EINVAL response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=312,a=T,t=t,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response.startsWith('\x1b_Gi=312;EINVAL:'), true);
+ });
+
+ test('transmit+display rejects t=f without id (no response)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=T,t=f,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '');
+ });
+ });
+
+ test.describe('Placement action (a=p)', () => {
+ test('displays a previously transmitted image at cursor', async () => {
+ // Transmit image without display
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=210;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+
+ // Place the previously transmitted image
+ await ctx.proxy.write(`\x1b_Ga=p,i=210\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
+ });
+
+ test('responds OK on successful placement', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=211;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=211\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=211;OK\x1b\\');
+ });
+
+ test('responds ENOENT for non-existent image id', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=9999\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=9999;ENOENT:image not found\x1b\\');
+ });
+
+ test('without id sends no response', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('places at specified column/row size (c/r)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=212;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=212,c=5,r=3\x1b\\`);
+ await timeout(200);
+
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getCursor(), [5, 2]);
+ });
+
+ test('cursor advances past placed image (default C=0)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=213;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getCursor(), [0, 0]);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=213\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getCursor(), [1, 0]);
+ });
+
+ test('cursor does not move when C=1', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=214;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getCursor(), [0, 0]);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=214,c=5,r=3,C=1\x1b\\`);
+ await timeout(200);
+ deepStrictEqual(await getCursor(), [0, 0]);
+ });
+
+ test('supports z-index (negative = bottom layer)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=215;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=215,z=-1\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1);
+ });
+
+ test('supports source crop via x/y', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=216;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(100);
+
+ // Crop to the Orange rectangle (x=20, y=0, 20x50)
+ await ctx.proxy.write(`\x1b_Ga=p,i=216,x=20,y=0,w=20,h=50\x1b\\`);
+ await timeout(200);
+
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [20, 50]);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
+ });
+
+ test('supports sub-cell offset via X/Y', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=217;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=217,X=5,Y=3\x1b\\`);
+ await timeout(100);
+
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 5, 3), [0, 0, 0, 255]);
+ });
+
+ test('multiple placements of same image create separate displays', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=218;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+
+ // First placement
+ await ctx.proxy.write(`\x1b_Ga=p,i=218,p=1\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+
+ // Move cursor down and place again with different placement ID
+ await ctx.proxy.write('\x1b[3;1H');
+ await ctx.proxy.write(`\x1b_Ga=p,i=218,p=2\x1b\\`);
+ await timeout(100);
+
+ // Both placements should be in shared storage
+ strictEqual(await getImageStorageLength(), 2);
+ });
+
+ test('image data remains available after placement for future placements', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=219;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ // Place three times
+ await ctx.proxy.write(`\x1b_Ga=p,i=219\x1b\\`);
+ await timeout(100);
+ await ctx.proxy.write('\x1b[2;1H');
+ await ctx.proxy.write(`\x1b_Ga=p,i=219\x1b\\`);
+ await timeout(100);
+ await ctx.proxy.write('\x1b[3;1H');
+ await ctx.proxy.write(`\x1b_Ga=p,i=219\x1b\\`);
+ await timeout(100);
+
+ // Image data should still be available
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(219)`), true);
+ });
+
+ test('OK response suppressed by q=1', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=220;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=220,q=1\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ strictEqual(await getImageStorageLength(), 1);
+ });
+
+ test('ENOENT error suppressed by q=2', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyGotResponse = false;
+ (window as any).term.onData(() => { (window as any).kittyGotResponse = true; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=9998,q=2\x1b\\`);
+ await timeout(100);
+
+ strictEqual(await ctx.page.evaluate('window.kittyGotResponse'), false);
+ });
+
+ test('ENOENT still reported when q=1 (only suppresses OK)', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=9997,q=1\x1b\\`);
+ await timeout(100);
+
+ const response: string = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=9997;ENOENT:image not found\x1b\\');
+ });
+
+ test('renders pixels correctly when placing a PNG image', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=221;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=221\x1b\\`);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+
+ test('renders pixels correctly when placing raw RGB image', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=24,s=3,v=1,i=222;${RAW_RGB_3X1}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=222\x1b\\`);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+
+ test('renders pixels correctly when placing raw RGBA image', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=32,s=3,v=1,i=223;${RAW_RGBA_3X1}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=223\x1b\\`);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+
+ test('response includes placement id when p is specified', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=224;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=224,p=42\x1b\\`);
+ await timeout(100);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=224,p=42;OK\x1b\\');
+ });
+
+ test('only c specified computes r from aspect ratio', async () => {
+ // 200x100 image (2:1 aspect) with c=10.
+ // Per spec: r = ceil((h/w) * c * cw / ch) = ceil(0.5 * 10 * cw / ch)
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=225;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=225,c=10\x1b\\`);
+ await timeout(200);
+
+ const cursor = await getCursor();
+ const cellDims: number[] = await ctx.page.evaluate(() => {
+ const d = (window as any).term._core._renderService.dimensions.css.cell;
+ return [d.width, d.height];
+ });
+ const expectedR = Math.ceil((100 / 200) * 10 * cellDims[0] / cellDims[1]);
+ deepStrictEqual(cursor, [10, expectedR - 1]);
+ });
+
+ test('only r specified computes c from aspect ratio', async () => {
+ // 200x100 image (2:1 aspect) with r=5.
+ // Per spec: c = ceil((w/h) * r * ch / cw) = ceil(2 * 5 * ch / cw)
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=226;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(100);
+
+ await ctx.proxy.write(`\x1b_Ga=p,i=226,r=5\x1b\\`);
+ await timeout(200);
+
+ const cursor = await getCursor();
+ const cellDims: number[] = await ctx.page.evaluate(() => {
+ const d = (window as any).term._core._renderService.dimensions.css.cell;
+ return [d.width, d.height];
+ });
+ const expectedC = Math.ceil((200 / 100) * 5 * cellDims[1] / cellDims[0]);
+ deepStrictEqual(cursor, [expectedC, 4]);
+ });
+ });
+
+ test.describe('Cursor positioning', () => {
+ // NOTE: Current tests document ACTUAL behavior (MVP - cursor doesn't move)
+ // Per Kitty spec: cursor placed at first column after last image column,
+ // on the last row of the image. C=1 means don't move cursor.
+
+ test('cursor advances past 1x1 image', async () => {
+ const cursorBefore = await getCursor();
+ const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+ const cursorAfter = await getCursor();
+ deepStrictEqual(cursorBefore, [0, 0]);
+ // 1x1 pixel image occupies 1 column, cursor advances past it
+ deepStrictEqual(cursorAfter, [1, 0]);
+ });
+
+ test('cursor advances with text before image', async () => {
+ await ctx.proxy.write('Hello');
+ deepStrictEqual(await getCursor(), [5, 0]);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ // Cursor advances 1 column past the image
+ deepStrictEqual(await getCursor(), [6, 0]);
+ });
+
+ test('cursor advances with text after image', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ // Cursor at column 1 (past 1-col image)
+ deepStrictEqual(await getCursor(), [1, 0]);
+
+ await ctx.proxy.write('World');
+ deepStrictEqual(await getCursor(), [6, 0]);
+ });
+
+ test('cursor position with multiple images on same line', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(50);
+ deepStrictEqual(await getCursor(), [1, 0]);
+
+ await ctx.proxy.write('###');
+ deepStrictEqual(await getCursor(), [4, 0]);
+
+ // 3x1 pixel image: ceil(3/cellWidth)=1 column
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(50);
+ deepStrictEqual(await getCursor(), [5, 0]);
+ });
+
+ test('cursor advances on newline after image', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getCursor(), [1, 0]);
+
+ await ctx.proxy.write('\n');
+ deepStrictEqual(await getCursor(), [1, 1]);
+ });
+
+ test('cursor should move right by cols when c specified', async () => {
+ // c=5, r computed from 1x1 aspect ratio: r = ceil((1/1) * (5*cw) / ch)
+ // With 1:1 aspect, image height in pixels = 5*cw, so r = ceil(5*cw/ch)
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const cursor = await getCursor();
+ strictEqual(cursor[0], 5);
+ // r is computed from aspect ratio, so y > 0 for a square image at c=5
+ });
+
+ test('cursor should move down by rows when r specified', async () => {
+ // r=3, c computed from 1x1 aspect ratio: c = ceil((1/1) * (3*ch) / cw)
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,r=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const cursor = await getCursor();
+ strictEqual(cursor[1], 2);
+ // c is computed from aspect ratio, so x > 1 for a square image at r=3
+ });
+
+ test('cursor should move by cols AND rows when both specified', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,c=4,r=2;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ // cursor at (4, 1): past 4 columns, on last row (row 1)
+ deepStrictEqual(await getCursor(), [4, 1]);
+ });
+
+ test('cursor should NOT move when C=1 is specified', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,c=5,r=3,C=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ // C=1: cursor stays at origin
+ deepStrictEqual(await getCursor(), [0, 0]);
+ });
+
+ test('cursor should calculate cols/rows from image size when not specified', async () => {
+ const dim = await getDimensions();
+
+ // 3x1 pixel image: cols = ceil(3/cellWidth), rows = ceil(1/cellHeight)
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ const expectedCols = Math.ceil(3 / dim.cellWidth);
+ const cursor = await getCursor();
+
+ // Cursor advances past image columns, stays on row 0 (single row image)
+ strictEqual(cursor[0], expectedCols, 'cursor should advance by image columns');
+ strictEqual(cursor[1], 0, 'cursor should stay on row 0 for single-row image');
+ });
+ });
+
+ test.describe('Z-index layer placement', () => {
+ test('default placement (no z key) stores image on top layer', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 0);
+ });
+
+ test('z=0 stores image on top layer', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=0;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 0);
+ });
+
+ test('z=1 (positive) stores image on top layer', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'top');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), 1);
+ });
+
+ test('z=-1 uses bottom layer even when allowTransparency is disabled', async () => {
+ await ctx.page.evaluate(`window.term.options.allowTransparency = false`);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1);
+ });
+
+ test('z=-1 (negative) stores image on bottom layer when allowTransparency is enabled', async () => {
+ await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -1);
+ });
+
+ test('z=-100 (large negative) stores image on bottom layer when allowTransparency is enabled', async () => {
+ await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).layer`), 'bottom');
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.get(1).zIndex`), -100);
+ });
+
+ test('top layer canvas has correct CSS class', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ const hasClass = await ctx.page.evaluate(() => {
+ const el = document.querySelector('.xterm-image-layer-top');
+ return el !== null;
+ });
+ strictEqual(hasClass, true);
+ });
+
+ test('bottom layer canvas has correct CSS class', async () => {
+ await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ const hasClass = await ctx.page.evaluate(() => {
+ const el = document.querySelector('.xterm-image-layer-bottom');
+ return el !== null;
+ });
+ strictEqual(hasClass, true);
+ });
+
+ test('bottom layer canvas is before text canvas in DOM order', async () => {
+ await ctx.page.evaluate(`window.term.options.allowTransparency = true`);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,z=-1;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ const isFirst = await ctx.page.evaluate(() => {
+ const screen = document.querySelector('.xterm-screen');
+ return screen?.firstElementChild?.classList.contains('xterm-image-layer-bottom') ?? false;
+ });
+ strictEqual(isFirst, true);
+ });
+ });
+
+ test.describe('Pixel verification', () => {
+ test('renders 1x1 black PNG at cursor position', async () => {
+ const seq = `\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
+ });
+
+ test('renders 3x1 RGB PNG (red, green, blue pixels)', async () => {
+ const seq = `\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`;
+ await ctx.proxy.write(seq);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+ });
+
+ test.describe('Larger image (200x100 multicolor PNG)', () => {
+ test.describe('Basic transmission and storage', () => {
+ test('stores 200x100 PNG with a=T', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ });
+
+ test('transmit only (a=t) stores 200x100 image without display', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.size`), 1);
+ });
+
+ test('stores with specified image ID', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=400;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(400)`), true);
+ });
+ });
+
+ test.describe('Chunked transmission', () => {
+ test('handles 2-chunk transmission', async () => {
+ const half = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 2);
+ const part1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, half);
+ const part2 = KITTY_MULTICOLOR_200X100_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500,m=1;${part1}\x1b\\`);
+ await timeout(50);
+ strictEqual(await getImageStorageLength(), 0);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=500;${part2}\x1b\\`);
+ await timeout(200);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ });
+
+ test('handles 3-chunk transmission', async () => {
+ const third = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 3);
+ const p1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, third);
+ const p2 = KITTY_MULTICOLOR_200X100_BASE64.substring(third, third * 2);
+ const p3 = KITTY_MULTICOLOR_200X100_BASE64.substring(third * 2);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501,m=1;${p1}\x1b\\`);
+ await timeout(50);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501,m=1;${p2}\x1b\\`);
+ await timeout(50);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=501;${p3}\x1b\\`);
+ await timeout(200);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ });
+
+ test('verifies chunked data assembles correctly', async () => {
+ const half = Math.floor(KITTY_MULTICOLOR_200X100_BASE64.length / 2);
+ const part1 = KITTY_MULTICOLOR_200X100_BASE64.substring(0, half);
+ const part2 = KITTY_MULTICOLOR_200X100_BASE64.substring(half);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=502,m=1;${part1}\x1b\\`);
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=502;${part2}\x1b\\`);
+ await timeout(200);
+
+ const storedData = await ctx.page.evaluate(async () => {
+ const blob = (window as any).imageAddon._handlers.get('kitty').images.get(502).data;
+ const buffer = await blob.arrayBuffer();
+ return Array.from(new Uint8Array(buffer));
+ });
+ deepStrictEqual(storedData, KITTY_MULTICOLOR_200X100_BYTES);
+ });
+ });
+
+ test.describe('Cursor positioning', () => {
+ test('cursor advances past multi-cell image', async () => {
+ const dim = await getDimensions();
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ const expectedCols = Math.ceil(200 / dim.cellWidth);
+ const expectedRows = Math.ceil(100 / dim.cellHeight) - 1;
+ const cursor = await getCursor();
+ strictEqual(cursor[0], expectedCols, 'cursor should advance by image columns');
+ strictEqual(cursor[1], expectedRows, 'cursor should be on last row of image');
+ });
+
+ test('cursor does not move with C=1', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ deepStrictEqual(await getCursor(), [0, 0]);
+ });
+
+ test('cursor uses explicit c and r over image dimensions', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,c=10,r=5;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ deepStrictEqual(await getCursor(), [10, 4]);
+ });
+ });
+
+ test.describe('Pixel verification', () => {
+ // The 200x100 image has 20 colored rectangles in a 10x2 grid.
+ // Each rectangle is 20px wide x 50px tall.
+ // Top row (y=0..49): Red, Orange, Yellow, Lime, Green, Cyan, SkyBlue, Blue, Purple, Magenta
+ // Bottom row (y=50..99): Pink, Brown, Maroon, Olive, Teal, Navy, Gray, DarkGray, LightGray, White
+
+ test('renders red rectangle at top-left origin (0,0)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ // Pixel (0,0) is in the first rectangle: Red
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ });
+
+ test('renders top row colors at rectangle centers', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ // Sample center of each top-row rectangle (y=25, x=10,30,50,...,190)
+ // All within the first cell row, so we read from the canvas at cell (0,0)
+ // Red at x=10
+ deepStrictEqual(await getPixel(0, 0, 10, 25), [255, 0, 0, 255]);
+ // Orange at x=30
+ deepStrictEqual(await getPixel(0, 0, 30, 25), [255, 128, 0, 255]);
+ // Yellow at x=50
+ deepStrictEqual(await getPixel(0, 0, 50, 25), [255, 255, 0, 255]);
+ // Lime at x=70
+ deepStrictEqual(await getPixel(0, 0, 70, 25), [0, 255, 0, 255]);
+ // Green at x=90
+ deepStrictEqual(await getPixel(0, 0, 90, 25), [0, 128, 0, 255]);
+ });
+
+ test('renders bottom row colors at rectangle centers', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ // Bottom row starts at y=50. Center at y=75.
+ // Pink at x=10
+ deepStrictEqual(await getPixel(0, 0, 10, 75), [255, 192, 203, 255]);
+ // Brown at x=30
+ deepStrictEqual(await getPixel(0, 0, 30, 75), [165, 42, 42, 255]);
+ // Maroon at x=50
+ deepStrictEqual(await getPixel(0, 0, 50, 75), [128, 0, 0, 255]);
+ // Olive at x=70
+ deepStrictEqual(await getPixel(0, 0, 70, 75), [128, 128, 0, 255]);
+ // Teal at x=90
+ deepStrictEqual(await getPixel(0, 0, 90, 75), [0, 128, 128, 255]);
+ });
+
+ test('renders correct colors at rectangle boundaries', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ // Last pixel of first rectangle (x=19, y=0): still Red
+ deepStrictEqual(await getPixel(0, 0, 19, 0), [255, 0, 0, 255]);
+ // First pixel of second rectangle (x=20, y=0): Orange
+ deepStrictEqual(await getPixel(0, 0, 20, 0), [255, 128, 0, 255]);
+ // Last pixel of top row (x=199, y=49): Magenta
+ deepStrictEqual(await getPixel(0, 0, 199, 49), [255, 0, 255, 255]);
+ // First pixel of bottom row (x=0, y=50): Pink
+ deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
+ });
+
+ test('renders correct color at bottom-right corner', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ // Bottom-right corner (x=199, y=99): White
+ deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
+ });
+
+ test('renders a strip of top-row pixels via getPixels', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ // Read 3 pixels starting at x=18 y=0, spanning the Red/Orange boundary
+ const pixels = await getPixels(0, 0, 18, 0, 3, 1);
+ // x=18,19 -> Red; x=20 -> Orange
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]); // x=18: Red
+ deepStrictEqual(pixels?.slice(4, 8), [255, 0, 0, 255]); // x=19: Red
+ deepStrictEqual(pixels?.slice(8, 12), [255, 128, 0, 255]); // x=20: Orange
+ });
+
+ test('applies source crop via x/y/w/h before display', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getOrigSize(1), [20, 50]);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 128, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 19, 49), [255, 128, 0, 255]);
+ });
+
+ test('scales cropped source region to c/r placement rectangle', async () => {
+ // Firefox's createImageBitmap uses different resize sampling, producing
+ // slightly off pixel values compared to Chromium, so skip on Firefox.
+ if (ctx.browser.browserType().name() === 'firefox') {
+ test.skip();
+ }
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,x=1,y=0,w=1,h=1,c=4,r=2;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getCursor(), [4, 1]);
+ const left = await getPixel(0, 0, 2, 10);
+ const right = await getPixel(0, 0, 25, 10);
+ deepStrictEqual(left, [0, 255, 0, 255]);
+ deepStrictEqual(right, [0, 255, 0, 255]);
+ });
+
+ test('applies sub-cell offset via X/Y within first cell', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,X=5,Y=3;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 5, 3), [0, 0, 0, 255]);
+ });
+
+ test('w=0 is treated as unset (displays full width)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,w=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 199, 99), [255, 255, 255, 255]);
+ });
+
+ test('h=0 is treated as unset (displays full height)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,h=0;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 0, 50), [255, 192, 203, 255]);
+ });
+
+ test('x exceeding image width produces no display', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,x=999;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('negative x/y values are clamped to 0', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,x=-10,y=-10;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getOrigSize(1), [200, 100]);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ });
+
+ test('combined crop and sub-cell offset', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,x=20,y=0,w=20,h=50,X=5,Y=3;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 4, 2), [0, 0, 0, 0]);
+ deepStrictEqual(await getPixel(0, 0, 5, 3), [255, 128, 0, 255]);
+ });
+
+ test('sub-cell offset with explicit c/r advances cursor correctly', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,X=5,Y=3,c=4,r=2;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+
+ deepStrictEqual(await getCursor(), [4, 1]);
+ });
+ });
+
+ test.describe('Query support', () => {
+ test('responds with OK for valid 200x100 PNG query', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=600,a=q,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=600;OK\x1b\\');
+ });
+
+ test('query does not store the 200x100 image', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).term.onData(() => { /* consume response */ });
+ });
+
+ await ctx.proxy.write(`\x1b_Gi=601,a=q,f=100;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(601)`), false);
+ });
+ });
+
+ test.describe('Delete commands', () => {
+ test('delete removes 200x100 image by id', async () => {
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=700;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await timeout(200);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), true);
+
+ await ctx.proxy.write(`\x1b_Ga=d,d=i,i=700\x1b\\`);
+ await timeout(50);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(700)`), false);
+ });
+ });
+ });
+
+ test.describe('Raw RGB pixel format (f=24)', () => {
+ test.describe('Pixel verification', () => {
+ test('renders 1x1 black pixel with alpha set to 255', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1,v=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [0, 0, 0, 255]);
+ });
+
+ test('renders 1x1 red pixel with alpha set to 255', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1,v=1;${RAW_RGB_1X1_RED}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ });
+
+ test('renders 3x1 strip (red, green, blue)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+
+ test('renders 2x2 grid with correct pixel layout', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 0, 1), [0, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 1), [255, 255, 0, 255]);
+ });
+
+ test('renders 5x1 row with block+remainder pixel layout', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 2, 0), [0, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 4, 0), [255, 0, 255, 255]);
+ });
+
+ test('renders 4x2 grid with multi-block pixel layout', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 2, 0), [0, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 0, 1), [255, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 1), [0, 255, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 2, 1), [128, 128, 128, 255]);
+ deepStrictEqual(await getPixel(0, 0, 3, 1), [255, 255, 255, 255]);
+ });
+ });
+
+ test.describe('Storage and dimensions', () => {
+ test('stores image with correct original dimensions (3x1)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=3,v=1;${RAW_RGB_3X1}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [3, 1]);
+ });
+
+ test('stores image with correct original dimensions (2x2)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_2X2}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [2, 2]);
+ });
+
+ test('stores image with correct original dimensions (5x1)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=5,v=1;${RAW_RGB_5X1}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [5, 1]);
+ });
+
+ test('stores image with correct original dimensions (4x2)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=4,v=2;${RAW_RGB_4X2}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [4, 2]);
+ });
+ });
+
+ test.describe('Validation', () => {
+ test('does not render without width (s=)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,v=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render without height (v=)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=1;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render without either dimension', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render with insufficient byte count', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=24,s=2,v=2;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('query returns EINVAL without dimensions', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=200,a=q,f=24;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=200;EINVAL:width and height required for raw pixel data\x1b\\');
+ });
+
+ test('query returns EINVAL for insufficient pixel data', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=201,a=q,f=24,s=2,v=2;${RAW_RGB_1X1_BLACK}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=201;EINVAL:insufficient pixel data\x1b\\');
+ });
+
+ test('query returns OK for valid RGB data with correct dimensions', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=202,a=q,f=24,s=1,v=1;${RAW_RGB_1X1_RED}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=202;OK\x1b\\');
+ });
+ });
+ });
+
+ test.describe('Raw RGBA pixel format (f=32)', () => {
+ test.describe('Pixel verification', () => {
+ test('renders 1x1 opaque white pixel', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_WHITE}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 255, 255, 255]);
+ });
+
+ test('renders 1x1 opaque red pixel', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ });
+
+ test('preserves full transparency (alpha=0)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1,v=1;${RAW_RGBA_1X1_TRANSPARENT}\x1b\\`);
+ await timeout(100);
+ const pixel = await getPixel(0, 0, 0, 0);
+ strictEqual(pixel?.[3], 0);
+ });
+
+ test('renders 3x1 strip (red, green, blue opaque)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
+ await timeout(100);
+
+ const pixels = await getPixels(0, 0, 0, 0, 3, 1);
+ deepStrictEqual(pixels?.slice(0, 4), [255, 0, 0, 255]);
+ deepStrictEqual(pixels?.slice(4, 8), [0, 255, 0, 255]);
+ deepStrictEqual(pixels?.slice(8, 12), [0, 0, 255, 255]);
+ });
+
+ test('renders 2x2 grid with correct pixel layout', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 0, 1), [0, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 1), [255, 255, 0, 255]);
+ });
+
+ test('renders 5x1 row with zero-copy pixel layout', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
+ await timeout(100);
+ deepStrictEqual(await getPixel(0, 0, 0, 0), [255, 0, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 1, 0), [0, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 2, 0), [0, 0, 255, 255]);
+ deepStrictEqual(await getPixel(0, 0, 3, 0), [255, 255, 0, 255]);
+ deepStrictEqual(await getPixel(0, 0, 4, 0), [255, 0, 255, 255]);
+ });
+ });
+
+ test.describe('Storage and dimensions', () => {
+ test('stores image with correct original dimensions (3x1)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=3,v=1;${RAW_RGBA_3X1}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [3, 1]);
+ });
+
+ test('stores image with correct original dimensions (2x2)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_2X2}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [2, 2]);
+ });
+
+ test('stores image with correct original dimensions (5x1)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=5,v=1;${RAW_RGBA_5X1}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ deepStrictEqual(await getOrigSize(1), [5, 1]);
+ });
+ });
+
+ test.describe('Validation', () => {
+ test('does not render without width (s=)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render without height (v=)', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=1;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render without either dimension', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('does not render with insufficient byte count', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=32,s=2,v=2;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 0);
+ });
+
+ test('query returns EINVAL without dimensions', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=300,a=q,f=32;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=300;EINVAL:width and height required for raw pixel data\x1b\\');
+ });
+
+ test('query returns EINVAL for insufficient pixel data', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=301,a=q,f=32,s=2,v=2;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=301;EINVAL:insufficient pixel data\x1b\\');
+ });
+
+ test('query returns OK for valid RGBA data with correct dimensions', async () => {
+ await ctx.page.evaluate(() => {
+ (window as any).kittyResponse = '';
+ (window as any).term.onData((data: string) => { (window as any).kittyResponse = data; });
+ });
+ await ctx.proxy.write(`\x1b_Gi=302,a=q,f=32,s=1,v=1;${RAW_RGBA_1X1_RED}\x1b\\`);
+ await timeout(100);
+ const response = await ctx.page.evaluate('window.kittyResponse');
+ strictEqual(response, '\x1b_Gi=302;OK\x1b\\');
+ });
+ });
+ });
+
+ test.describe('Eviction and memory leak prevention', () => {
+ test('re-transmit with same i= cleans up old storage entry', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=50;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), true);
+ const oldStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(50)`);
+ ok(oldStorageId !== undefined);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=50;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(50)`), true);
+ const newStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(50)`);
+ ok(newStorageId !== undefined);
+ ok(newStorageId !== oldStorageId);
+ });
+
+ test('memory limit eviction cleans Kitty handler maps', async () => {
+ // Resize terminal to fit 7 non-overlapping 200x100 images without scrolling.
+ // Each image ≈ 29 cols × 8 rows at default cell size.
+ await ctx.page.evaluate(`
+ window.term.reset();
+ window.imageAddon?.dispose();
+ window.term.resize(80, 48);
+ window.imageAddon = new ImageAddon({ storageLimit: 0.5 });
+ window.term.loadAddon(window.imageAddon);
+ `);
+
+ // storageLimit 0.5 MB = 125,000 pixels. Each 200x100 image = 20,000 pixels.
+ // 6 images = 120K pixels (under limit). 7th triggers eviction (140K > 125K).
+ // Place non-overlapping so tile-count eviction doesn't interfere.
+ const positions = [[1, 1], [30, 1], [1, 9], [30, 9], [1, 17], [30, 17]];
+ for (let n = 0; n < 6; n++) {
+ const [c, r] = positions[n];
+ const id = 60 + n;
+ await ctx.proxy.write(`\x1b[${r};${c}H\x1b_Ga=T,f=100,i=${id},C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ }
+ await pollFor(ctx.page, 'window.imageAddon._storage._images.size', 6);
+
+ // 7th image pushes total past 125K pixels — oldest evicted
+ await ctx.proxy.write(`\x1b[25;1H\x1b_Ga=T,f=100,i=66,C=1;${KITTY_MULTICOLOR_200X100_BASE64}\x1b\\`);
+ await pollFor(ctx.page, `window.imageAddon._handlers.get('kitty').images.has(60)`, false);
+
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(60)`), false);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(66)`), true);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(66)`), true);
+
+ // Restore terminal size
+ await ctx.page.evaluate('window.term.resize(80, 24)');
+ });
+
+ test('scrollback eviction cleans Kitty handler maps', async () => {
+ await ctx.page.evaluate(`
+ window.term.reset();
+ window.imageAddon?.dispose();
+ window.imageAddon = new ImageAddon();
+ window.term.loadAddon(window.imageAddon);
+ `);
+
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=70;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty').images.has(70)`), true);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(70)`), true);
+
+ // Scroll past scrollback + viewport to push image's marker off the buffer
+ await ctx.page.evaluate(() => new Promise(res => {
+ const term = (window as any).term;
+ const amount: number = (term.options.scrollback as number) + (term.rows as number) + 10;
+ term.write('\n'.repeat(amount), res);
+ }));
+
+ await pollFor(ctx.page, `window.imageAddon._handlers.get('kitty').images.has(70)`, false);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.has(70)`), false);
+ });
+
+ test('re-transmit with a=t then a=T cleans old storage before display', async () => {
+ await ctx.proxy.write(`\x1b_Ga=T,f=100,i=80;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await getImageStorageLength(), 1);
+ const oldStorageId = await ctx.page.evaluate(`window.imageAddon._handlers.get('kitty')._kittyIdToStorageId.get(80)`);
+ ok(oldStorageId !== undefined);
+
+ await ctx.proxy.write(`\x1b_Ga=t,f=100,i=80;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await timeout(100);
+ strictEqual(await ctx.page.evaluate(`window.imageAddon._storage._images.has(${oldStorageId})`), false);
+ });
+ });
+
+ test.describe('onImageAdded callback', () => {
+ test('onImageAdded fires for each kitty image', async () => {
+ await ctx.page.evaluate(`
+ window._imageAddedCount = 0;
+ window.imageAddon.onImageAdded(() => { window._imageAddedCount++; });
+ `);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_BLACK_1X1_BASE64}\x1b\\`);
+ await pollFor(ctx.page, 'window._imageAddedCount', 1);
+ await ctx.proxy.write(`\x1b_Ga=T,f=100;${KITTY_RGB_3X1_BASE64}\x1b\\`);
+ await pollFor(ctx.page, 'window._imageAddedCount', 2);
+ });
+ });
+});
+
+/**
+ * Helper functions
+ */
+async function getDimensions(): Promise {
+ const dimensions: any = await ctx.page.evaluate(`term.dimensions`);
+ return {
+ cellWidth: Math.round(dimensions.css.cell.width),
+ cellHeight: Math.round(dimensions.css.cell.height),
+ width: Math.round(dimensions.css.canvas.width),
+ height: Math.round(dimensions.css.canvas.height)
+ };
+}
+
+async function getCursor(): Promise<[number, number]> {
+ return ctx.page.evaluate('[window.term.buffer.active.cursorX, window.term.buffer.active.cursorY]');
+}
+
+async function getImageStorageLength(): Promise {
+ return ctx.page.evaluate('window.imageAddon._storage._images.size');
+}
+
+async function getOrigSize(id: number): Promise<[number, number]> {
+ return ctx.page.evaluate(`[
+ window.imageAddon._storage._images.get(${id}).orig.width,
+ window.imageAddon._storage._images.get(${id}).orig.height
+ ]`);
+}
+
+async function getPixel(col: number, row: number, x: number, y: number): Promise {
+ return ctx.page.evaluate(([col, row, x, y]: number[]) => {
+ const canvas = (window as any).imageAddon.getImageAtBufferCell(col, row);
+ if (!canvas) return null;
+ const ctx2d = canvas.getContext('2d');
+ if (!ctx2d) return null;
+ return Array.from(ctx2d.getImageData(x, y, 1, 1).data);
+ }, [col, row, x, y]);
+}
+
+async function getPixels(col: number, row: number, x: number, y: number, w: number, h: number): Promise {
+ return ctx.page.evaluate(([col, row, x, y, w, h]: number[]) => {
+ const canvas = (window as any).imageAddon.getImageAtBufferCell(col, row);
+ if (!canvas) return null;
+ const ctx2d = canvas.getContext('2d');
+ if (!ctx2d) return null;
+ return Array.from(ctx2d.getImageData(x, y, w, h).data);
+ }, [col, row, x, y, w, h]);
+}
diff --git a/addons/addon-image/test/tsconfig.json b/addons/addon-image/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-image/test/tsconfig.json
+++ b/addons/addon-image/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-image/typings/addon-image.d.ts b/addons/addon-image/typings/addon-image.d.ts
index 48ba488cf..a3fc9e1c8 100644
--- a/addons/addon-image/typings/addon-image.d.ts
+++ b/addons/addon-image/typings/addon-image.d.ts
@@ -3,7 +3,7 @@
* @license MIT
*/
-import { Terminal, ITerminalAddon } from '@xterm/xterm';
+import { Terminal, ITerminalAddon, IEvent } from '@xterm/xterm';
declare module '@xterm/addon-image' {
export interface IImageAddonOptions {
@@ -76,6 +76,15 @@ 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 {
@@ -107,6 +116,11 @@ declare module '@xterm/addon-image' {
*/
public showPlaceholder: boolean;
+ /**
+ * Event fired whenever a new image is added to storage.
+ */
+ public readonly onImageAdded: IEvent;
+
/**
* Get original image canvas at buffer position.
*/
diff --git a/addons/addon-ligatures/package.json b/addons/addon-ligatures/package.json
index 3117b7e80..1b972c957 100644
--- a/addons/addon-ligatures/package.json
+++ b/addons/addon-ligatures/package.json
@@ -14,8 +14,8 @@
"node": ">8.0.0"
},
"scripts": {
- "build": "tsc -p src",
- "watch": "tsc -w -p src",
+ "build": "tsgo -p src",
+ "watch": "tsgo -w -p src",
"prepackage": "npm run build",
"package": "webpack",
"pretest": "npm run build",
diff --git a/addons/addon-ligatures/test/tsconfig.json b/addons/addon-ligatures/test/tsconfig.json
index ca68b4685..6986e2b46 100644
--- a/addons/addon-ligatures/test/tsconfig.json
+++ b/addons/addon-ligatures/test/tsconfig.json
@@ -9,8 +9,12 @@
"outDir": "../out-esbuild-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"strict": true,
+ "paths": {
+ "*": [
+ "./*"
+ ]
+ },
"types": [
"../../../node_modules/@types/mocha",
"../../../node_modules/@types/node"
diff --git a/addons/addon-progress/package.json b/addons/addon-progress/package.json
index 11e13abbe..8b130d6ce 100644
--- a/addons/addon-progress/package.json
+++ b/addons/addon-progress/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-progress/test/tsconfig.json b/addons/addon-progress/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-progress/test/tsconfig.json
+++ b/addons/addon-progress/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-search/package.json b/addons/addon-search/package.json
index 2a8ca6f72..ebec5a8b8 100644
--- a/addons/addon-search/package.json
+++ b/addons/addon-search/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "prepackage": "../../node_modules/.bin/tsc -p .",
+ "prepackage": "../../node_modules/.bin/tsgo -p .",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
"start": "node ../../demo/start"
diff --git a/addons/addon-search/test/tsconfig.json b/addons/addon-search/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-search/test/tsconfig.json
+++ b/addons/addon-search/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-serialize/benchmark/tsconfig.json b/addons/addon-serialize/benchmark/tsconfig.json
index 1f6429f6e..afc3944a2 100644
--- a/addons/addon-serialize/benchmark/tsconfig.json
+++ b/addons/addon-serialize/benchmark/tsconfig.json
@@ -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/*"],
diff --git a/addons/addon-serialize/package.json b/addons/addon-serialize/package.json
index f6acb018b..6fa91ffee 100644
--- a/addons/addon-serialize/package.json
+++ b/addons/addon-serialize/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-serialize/src/tsconfig.json b/addons/addon-serialize/src/tsconfig.json
index 6fb641ef4..725647366 100644
--- a/addons/addon-serialize/src/tsconfig.json
+++ b/addons/addon-serialize/src/tsconfig.json
@@ -10,7 +10,6 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -23,6 +22,7 @@
]
},
"strict": true,
+ "skipLibCheck": true,
"types": [
"../../../node_modules/@types/mocha"
]
diff --git a/addons/addon-serialize/test/tsconfig.json b/addons/addon-serialize/test/tsconfig.json
index cff277055..769909b19 100644
--- a/addons/addon-serialize/test/tsconfig.json
+++ b/addons/addon-serialize/test/tsconfig.json
@@ -9,7 +9,6 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
diff --git a/addons/addon-unicode-graphemes/benchmark/tsconfig.json b/addons/addon-unicode-graphemes/benchmark/tsconfig.json
index c6f37a6ce..47e6b53a4 100644
--- a/addons/addon-unicode-graphemes/benchmark/tsconfig.json
+++ b/addons/addon-unicode-graphemes/benchmark/tsconfig.json
@@ -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/*"],
diff --git a/addons/addon-unicode-graphemes/package.json b/addons/addon-unicode-graphemes/package.json
index 168407702..640a61a7d 100644
--- a/addons/addon-unicode-graphemes/package.json
+++ b/addons/addon-unicode-graphemes/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-unicode-graphemes/src/tsconfig.json b/addons/addon-unicode-graphemes/src/tsconfig.json
index e9967bbe2..824909dbd 100644
--- a/addons/addon-unicode-graphemes/src/tsconfig.json
+++ b/addons/addon-unicode-graphemes/src/tsconfig.json
@@ -11,7 +11,6 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
diff --git a/addons/addon-unicode-graphemes/test/tsconfig.json b/addons/addon-unicode-graphemes/test/tsconfig.json
index cff277055..769909b19 100644
--- a/addons/addon-unicode-graphemes/test/tsconfig.json
+++ b/addons/addon-unicode-graphemes/test/tsconfig.json
@@ -9,7 +9,6 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
diff --git a/addons/addon-unicode11/package.json b/addons/addon-unicode11/package.json
index d027996b5..65c9b076e 100644
--- a/addons/addon-unicode11/package.json
+++ b/addons/addon-unicode11/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-unicode11/src/tsconfig.json b/addons/addon-unicode11/src/tsconfig.json
index 8eb7752bd..31286fb0f 100644
--- a/addons/addon-unicode11/src/tsconfig.json
+++ b/addons/addon-unicode11/src/tsconfig.json
@@ -11,13 +11,15 @@
"sourceMap": true,
"removeComments": true,
"strict": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"@xterm/addon-unicode11": [
"../typings/addon-unicode11.d.ts"
+ ],
+ "*": [
+ "./*"
]
},
"types": [
diff --git a/addons/addon-unicode11/test/tsconfig.json b/addons/addon-unicode11/test/tsconfig.json
index cff277055..900a727ed 100644
--- a/addons/addon-unicode11/test/tsconfig.json
+++ b/addons/addon-unicode11/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-web-fonts/package.json b/addons/addon-web-fonts/package.json
index 856c7e258..ee52931da 100644
--- a/addons/addon-web-fonts/package.json
+++ b/addons/addon-web-fonts/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-web-fonts/test/tsconfig.json b/addons/addon-web-fonts/test/tsconfig.json
index 120fccdc3..4151488a8 100644
--- a/addons/addon-web-fonts/test/tsconfig.json
+++ b/addons/addon-web-fonts/test/tsconfig.json
@@ -10,13 +10,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-web-links/package.json b/addons/addon-web-links/package.json
index 214a26d4c..d058376c8 100644
--- a/addons/addon-web-links/package.json
+++ b/addons/addon-web-links/package.json
@@ -16,7 +16,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-web-links/test/tsconfig.json b/addons/addon-web-links/test/tsconfig.json
index 120fccdc3..4151488a8 100644
--- a/addons/addon-web-links/test/tsconfig.json
+++ b/addons/addon-web-links/test/tsconfig.json
@@ -10,13 +10,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-webgl/package.json b/addons/addon-webgl/package.json
index d8aea18c0..4ad686b04 100644
--- a/addons/addon-webgl/package.json
+++ b/addons/addon-webgl/package.json
@@ -17,7 +17,7 @@
"xterm.js"
],
"scripts": {
- "build": "../../node_modules/.bin/tsc -p .",
+ "build": "../../node_modules/.bin/tsgo -p .",
"prepackage": "npm run build",
"package": "../../node_modules/.bin/webpack",
"prepublishOnly": "npm run package",
diff --git a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts
index 22ffdfa91..c35299809 100644
--- a/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts
+++ b/addons/addon-webgl/src/customGlyphs/CustomGlyphDefinitions.ts
@@ -7,6 +7,35 @@ 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 });
@@ -988,31 +1017,3 @@ 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',
-}
diff --git a/addons/addon-webgl/src/tsconfig.json b/addons/addon-webgl/src/tsconfig.json
index 924d3762d..aeb42a1bf 100644
--- a/addons/addon-webgl/src/tsconfig.json
+++ b/addons/addon-webgl/src/tsconfig.json
@@ -10,7 +10,6 @@
"outDir": "../out",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
@@ -20,6 +19,9 @@
],
"@xterm/addon-webgl": [
"../typings/addon-webgl.d.ts"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/addons/addon-webgl/test/tsconfig.json b/addons/addon-webgl/test/tsconfig.json
index c602c462a..73439ddc1 100644
--- a/addons/addon-webgl/test/tsconfig.json
+++ b/addons/addon-webgl/test/tsconfig.json
@@ -9,13 +9,15 @@
"outDir": "../out-test",
"sourceMap": true,
"removeComments": true,
- "baseUrl": ".",
"paths": {
"common/*": [
"../../../src/common/*"
],
"browser/*": [
"../../../src/browser/*"
+ ],
+ "*": [
+ "./*"
]
},
"strict": true,
diff --git a/bin/agent/setup-repo.mjs b/bin/agent/setup-repo.mjs
new file mode 100644
index 000000000..2db85d87a
--- /dev/null
+++ b/bin/agent/setup-repo.mjs
@@ -0,0 +1,138 @@
+// @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']);
diff --git a/demo/client/components/window/addonImageWindow.ts b/demo/client/components/window/addonImageWindow.ts
index ce5ab9de2..392b7177b 100644
--- a/demo/client/components/window/addonImageWindow.ts
+++ b/demo/client/components/window/addonImageWindow.ts
@@ -51,14 +51,30 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
container.appendChild(document.createElement('br'));
container.appendChild(document.createElement('br'));
- 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);
+ // 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);
this._initImageAddonExposed();
}
@@ -125,12 +141,25 @@ 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) => {
diff --git a/demo/client/components/window/cellInspectorWindow.ts b/demo/client/components/window/cellInspectorWindow.ts
index b7a50bf6d..8bc28a974 100644
--- a/demo/client/components/window/cellInspectorWindow.ts
+++ b/demo/client/components/window/cellInspectorWindow.ts
@@ -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._mouseService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
+ const coords = core._mouseCoordsService?.getCoords(e, core.screenElement, terminal.cols, terminal.rows);
if (!coords) {
this._clearDisplay();
return;
diff --git a/demo/client/components/window/optionsWindow.ts b/demo/client/components/window/optionsWindow.ts
index c24fb2688..18ab9254d 100644
--- a/demo/client/components/window/optionsWindow.ts
+++ b/demo/client/components/window/optionsWindow.ts
@@ -116,6 +116,7 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
'linkHandler',
'logger',
'quirks',
+ 'scrollbar',
'theme',
'vtExtensions',
'windowOptions',
diff --git a/demo/client/components/window/testWindow.ts b/demo/client/components/window/testWindow.ts
index a67fef16c..1ea6b3e67 100644
--- a/demo/client/components/window/testWindow.ts
+++ b/demo/client/components/window/testWindow.ts
@@ -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}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
+ term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
}
-function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
+async function loadTestLongLines(term: Terminal, addons: AddonCollection): Promise {
const rendererName = addons.webgl.instance ? 'webgl' : 'dom';
const testData = [];
let byteCount = 0;
@@ -729,16 +729,23 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
testData.push(data);
}
const start = performance.now();
- for (let i = 0; i < 1024 * 50; i++) {
+ for (let i = 0; i < 1024; i++) {
for (const d of testData) {
- term.write(d);
+ 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(r => term.write('', () => r()));
+ 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}kB in ${time}ms (${mbs}MB/s) using the (${rendererName} renderer)`);
+ term.write(`\n\r\nWrote ${byteCount}KiB in ${time}ms (${mbs}MiB/s) using the (${rendererName} renderer)`);
// Send ^C to get a new prompt
(term as any)._core._onData.fire('\x03');
});
diff --git a/demo/client/tsconfig.json b/demo/client/tsconfig.json
index 6c6015318..244f19c6e 100644
--- a/demo/client/tsconfig.json
+++ b/demo/client/tsconfig.json
@@ -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,7 +20,8 @@
"@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": [
diff --git a/demo/server/tsconfig.json b/demo/server/tsconfig.json
index 2cddc3c94..e31d6904a 100644
--- a/demo/server/tsconfig.json
+++ b/demo/server/tsconfig.json
@@ -5,7 +5,8 @@
"outDir": "../out-demo/server",
"rootDir": ".",
"sourceMap": true,
- "esModuleInterop": true
+ "esModuleInterop": true,
+ "skipLibCheck": true
},
"include": [
"./**/*",
diff --git a/headless/README.md b/headless/README.md
index 205fd79c5..cf9169ec9 100644
--- a/headless/README.md
+++ b/headless/README.md
@@ -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) 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/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.
diff --git a/package-lock.json b/package-lock.json
index 791c05217..24a3b3b07 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"@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",
@@ -1107,10 +1108,11 @@
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1185,10 +1187,11 @@
}
},
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -1970,6 +1973,123 @@
"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",
@@ -3751,10 +3871,11 @@
}
},
"node_modules/eslint/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -4440,10 +4561,11 @@
"dev": true
},
"node_modules/glob/node_modules/minimatch": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
- "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -5686,12 +5808,13 @@
}
},
"node_modules/minimatch": {
- "version": "9.0.5",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
- "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "version": "9.0.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
+ "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
+ "license": "ISC",
"dependencies": {
- "brace-expansion": "^2.0.1"
+ "brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -5757,10 +5880,11 @@
}
},
"node_modules/mocha/node_modules/minimatch": {
- "version": "5.1.6",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz",
- "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==",
+ "version": "5.1.9",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
+ "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
},
@@ -5972,10 +6096,11 @@
}
},
"node_modules/nyc/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -6549,9 +6674,9 @@
}
},
"node_modules/qs": {
- "version": "6.14.1",
- "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz",
- "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==",
+ "version": "6.14.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+ "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -6787,10 +6912,11 @@
}
},
"node_modules/rimraf/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
@@ -7500,10 +7626,11 @@
}
},
"node_modules/test-exclude/node_modules/minimatch": {
- "version": "3.1.2",
- "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
- "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
+ "version": "3.1.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+ "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
+ "license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
diff --git a/package.json b/package.json
index 5ae5a4e7b..151566544 100644
--- a/package.json
+++ b/package.json
@@ -34,8 +34,8 @@
"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": "tsc -b ./tsconfig.all.json",
- "tsc-watch": "tsc -b -w ./tsconfig.all.json --preserveWatchOutput",
+ "tsc": "tsgo -b ./tsconfig.all.json",
+ "tsc-watch": "tsgo -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",
@@ -72,7 +72,8 @@
"prepackage-headless": "npm run esbuild-package-headless-only",
"package-headless": "webpack --config ./webpack.config.headless.js",
"postpackage-headless": "node ./bin/package_headless.js",
- "prepublishOnly": "npm run package"
+ "prepublishOnly": "npm run package",
+ "agent:setup-repo": "node bin/agent/setup-repo.mjs"
},
"devDependencies": {
"@lunapaint/png-codec": "^0.2.0",
@@ -92,6 +93,7 @@
"@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",
diff --git a/src/browser/Clipboard.test.ts b/src/browser/Clipboard.test.ts
index aa7bdabdd..2028504ec 100644
--- a/src/browser/Clipboard.test.ts
+++ b/src/browser/Clipboard.test.ts
@@ -29,4 +29,14 @@ 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~`);
+ });
});
diff --git a/src/browser/Clipboard.ts b/src/browser/Clipboard.ts
index ec85b5836..aaf8a48e4 100644
--- a/src/browser/Clipboard.ts
+++ b/src/browser/Clipboard.ts
@@ -19,10 +19,13 @@ export function prepareTextForTerminal(text: string): string {
* @param text The pasted text to bracket
*/
export function bracketTextForPaste(text: string, bracketedPasteMode: boolean): string {
- if (bracketedPasteMode) {
- return '\x1b[200~' + text + '\x1b[201~';
+ if (!bracketedPasteMode) {
+ return text;
}
- return text;
+ // 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~`;
}
/**
diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts
index 8ad8409f4..52eb00025 100644
--- a/src/browser/CoreBrowserTerminal.ts
+++ b/src/browser/CoreBrowserTerminal.ts
@@ -36,16 +36,17 @@ 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, IMouseService, IRenderService, ISelectionService, IThemeService } from 'browser/services/Services';
+import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IKeyboardService, ILinkProviderService, IMouseCoordsService, 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, CoreMouseAction, CoreMouseButton, CoreMouseEventType, IColorEvent, ITerminalOptions, KeyboardResultType, SpecialColorIndex } from 'common/Types';
+import { ColorRequestType, 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';
@@ -77,7 +78,6 @@ 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,6 +87,7 @@ 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;
@@ -413,7 +414,16 @@ 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', () => this._compositionHelper!.compositionstart()));
+ 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!, '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));
@@ -543,8 +553,8 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._compositionHelper = this._instantiationService.createInstance(CompositionHelper, this.textarea, this._compositionView);
this._helperContainer.appendChild(this._compositionView);
- this._mouseService = this._instantiationService.createInstance(MouseService);
- this._instantiationService.setService(IMouseService, this._mouseService);
+ this._mouseCoordsService = this._instantiationService.createInstance(MouseCoordsService);
+ this._instantiationService.setService(IMouseCoordsService, this._mouseCoordsService);
const linkifier = this._linkifier.value = this._register(this._instantiationService.createInstance(Linkifier, this.screenElement));
@@ -563,7 +573,10 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._renderService!.handleCursorMove();
this._syncTextArea();
}));
- this._register(this.onResize(() => this._renderService!.handleResize(this.cols, this.rows)));
+ this._register(this.onResize(() => {
+ this._renderService!.handleResize(this.cols, this.rows);
+ this._syncTextArea();
+ }));
this._register(this.onBlur(() => this._renderService!.handleBlur()));
this._register(this.onFocus(() => this._renderService!.handleFocus()));
@@ -579,6 +592,8 @@ 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)));
@@ -602,7 +617,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.coreMouseService.areMouseEventsActive) {
+ if (this.mouseStateService.areMouseEventsActive) {
this._selectionService.disable();
this.element.classList.add('enable-mouse-events');
} else {
@@ -638,285 +653,18 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
// Listen for mouse events and translate
// them into terminal mouse protocols.
- this.bindMouse();
+ this._mouseService.bindMouse({
+ element: this.element!,
+ screenElement: this.screenElement!,
+ document: this._document!,
+ handleTouchScroll: amount => this._viewport?.handleTouchScroll(amount)
+ }, disposable => this._register(disposable), () => this.focus());
}
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.
@@ -990,7 +738,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
}
public attachCustomWheelEventHandler(customWheelEventHandler: CustomWheelEventHandler): void {
- this._customWheelEventHandler = customWheelEventHandler;
+ this.mouseStateService.setCustomWheelEventHandler(customWheelEventHandler);
}
public registerLinkProvider(linkProvider: ILinkProvider): IDisposable {
@@ -1360,6 +1108,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
this._setup();
super.reset();
+ this._mouseService?.reset();
this._selectionService?.reset();
this._decorationService.reset();
diff --git a/src/browser/Linkifier.ts b/src/browser/Linkifier.ts
index 5e43782d4..31c91daee 100644
--- a/src/browser/Linkifier.ts
+++ b/src/browser/Linkifier.ts
@@ -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, IMouseService, IRenderService } from './services/Services';
+import { ILinkProviderService, IMouseCoordsService, 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,
- @IMouseService private readonly _mouseService: IMouseService,
+ @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@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, this._mouseService);
+ const position = this._positionFromMouseEvent(event, this._element);
if (!position) {
return;
}
@@ -222,7 +222,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
- const position = this._positionFromMouseEvent(event, this._element, this._mouseService);
+ const position = this._positionFromMouseEvent(event, this._element);
if (!position) {
return;
}
@@ -251,7 +251,7 @@ export class Linkifier extends Disposable implements ILinkifier2 {
return;
}
- const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element, this._mouseService);
+ const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
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, this._mouseService!);
+ const position = this._positionFromMouseEvent(this._lastMouseEvent, this._element);
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, mouseService: IMouseService): IBufferCellPosition | undefined {
- const coords = mouseService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
+ private _positionFromMouseEvent(event: MouseEvent, element: HTMLElement): IBufferCellPosition | undefined {
+ const coords = this._mouseCoordsService.getCoords(event, element, this._bufferService.cols, this._bufferService.rows);
if (!coords) {
return;
}
diff --git a/src/browser/Terminal.test.ts b/src/browser/Terminal.test.ts
index ba2ce6dd5..c651c248a 100644
--- a/src/browser/Terminal.test.ts
+++ b/src/browser/Terminal.test.ts
@@ -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 } from 'common/TestUtils.test';
+import { MockUnicodeService, createCellData } 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, 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.lines.get(0)!.setCell(0, createCellData(0, 'a', 0));
+ term.buffer.lines.get(INIT_ROWS - 1)!.setCell(0, createCellData(0, 'b', 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, 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(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.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, 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.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.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, 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.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.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, 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.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.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, 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(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.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, 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.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.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, 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.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.y = INIT_ROWS - 1; // Move cursor to last line
term.buffer.scrollTop = 1;
term.buffer.scrollBottom = 3;
diff --git a/src/browser/TestUtils.test.ts b/src/browser/TestUtils.test.ts
index c46ee0fb7..485900e41 100644
--- a/src/browser/TestUtils.test.ts
+++ b/src/browser/TestUtils.test.ts
@@ -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, ICoreMouseService } from 'common/services/Services';
+import { IUnicodeService, IOptionsService, ICoreService, IMouseStateService } 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 coreMouseService!: ICoreMouseService;
+ public mouseStateService!: IMouseStateService;
public coreService!: ICoreService;
public optionsService!: IOptionsService;
public unicodeService!: IUnicodeService;
@@ -392,6 +392,9 @@ 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 {
diff --git a/src/browser/Viewport.ts b/src/browser/Viewport.ts
index 09864f7ac..b120105bf 100644
--- a/src/browser/Viewport.ts
+++ b/src/browser/Viewport.ts
@@ -6,14 +6,13 @@
import { ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
import { ViewportConstants } from 'browser/shared/Constants';
import { Disposable, toDisposable } from 'common/Lifecycle';
-import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services';
+import { IBufferService, IMouseStateService, IOptionsService } from 'common/services/Services';
import { CoreMouseEventType } from 'common/Types';
-import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom';
+import { 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 {
@@ -34,7 +33,7 @@ export class Viewport extends Disposable {
screenElement: HTMLElement,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreBrowserService coreBrowserService: ICoreBrowserService,
- @ICoreMouseService coreMouseService: ICoreMouseService,
+ @IMouseStateService mouseStateService: IMouseStateService,
@IThemeService themeService: IThemeService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService
@@ -65,7 +64,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(coreMouseService.onProtocolChange(type => {
+ this._register(mouseStateService.onProtocolChange(type => {
this._scrollableElement.updateOptions({
handleMouseWheel: !(type & CoreMouseEventType.WHEEL)
});
@@ -107,9 +106,6 @@ export class Viewport extends Disposable {
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 {
@@ -204,12 +200,10 @@ export class Viewport extends Disposable {
this._isHandlingScroll = false;
}
- private _handleGestureChange(e: IGestureEvent): void {
- e.preventDefault();
- e.stopPropagation();
+ public handleTouchScroll(translationY: number): void {
const pos = this._scrollableElement.getScrollPosition();
this._scrollableElement.setScrollPosition({
- scrollTop: pos.scrollTop - e.translationY
+ scrollTop: pos.scrollTop - translationY
});
}
}
diff --git a/src/browser/decorations/OverviewRulerRenderer.ts b/src/browser/decorations/OverviewRulerRenderer.ts
index c5492f55d..4302b3420 100644
--- a/src/browser/decorations/OverviewRulerRenderer.ts
+++ b/src/browser/decorations/OverviewRulerRenderer.ts
@@ -51,8 +51,6 @@ 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,
@@ -91,13 +89,7 @@ export class OverviewRulerRenderer extends Disposable {
}
}));
- // 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._renderService.onDimensionsChange(() => this._queueRefresh(true)));
this._register(this._coreBrowserService.onDprChange(() => this._queueRefresh(true)));
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
@@ -144,10 +136,12 @@ export class OverviewRulerRenderer extends Disposable {
}
private _refreshCanvasDimensions(): void {
+ 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 = `${this._screenElement.clientHeight}px`;
- this._canvas.height = Math.round(this._screenElement.clientHeight * this._coreBrowserService.dpr);
+ this._canvas.style.height = `${cssCanvasHeight}px`;
+ this._canvas.height = deviceCanvasHeight;
this._refreshDrawConstants();
this._refreshColorZonePadding();
}
diff --git a/src/browser/input/CompositionHelper.ts b/src/browser/input/CompositionHelper.ts
index 62c32cb96..2830199bc 100644
--- a/src/browser/input/CompositionHelper.ts
+++ b/src/browser/input/CompositionHelper.ts
@@ -87,7 +87,9 @@ export class CompositionHelper {
* @param ev The event.
*/
public compositionupdate(ev: Pick): void {
- this._compositionView.textContent = ev.data;
+ // 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.updateCompositionElements();
setTimeout(() => {
const end = this._textarea.selectionEnd ?? this._textarea.value.length;
@@ -256,6 +258,12 @@ 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();
diff --git a/src/browser/public/Terminal.ts b/src/browser/public/Terminal.ts
index f25da387e..75aada048 100644
--- a/src/browser/public/Terminal.ts
+++ b/src/browser/public/Terminal.ts
@@ -103,7 +103,7 @@ export class Terminal extends Disposable implements ITerminalApi {
public get modes(): IModes {
const m = this._core.coreService.decPrivateModes;
let mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' = 'none';
- switch (this._core.coreMouseService.activeProtocol) {
+ switch (this._core.mouseStateService.activeProtocol) {
case 'X10': mouseTrackingMode = 'x10'; break;
case 'VT200': mouseTrackingMode = 'vt200'; break;
case 'DRAG': mouseTrackingMode = 'drag'; break;
diff --git a/src/browser/renderer/dom/DomRendererRowFactory.test.ts b/src/browser/renderer/dom/DomRendererRowFactory.test.ts
index b6f6eedca..05fc8000c 100644
--- a/src/browser/renderer/dom/DomRendererRowFactory.test.ts
+++ b/src/browser/renderer/dom/DomRendererRowFactory.test.ts
@@ -10,7 +10,7 @@ import { NULL_CELL_CODE, NULL_CELL_WIDTH, NULL_CELL_CHAR, DEFAULT_ATTR, FgFlags,
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
-import { MockCoreService, MockDecorationService, MockOptionsService } from 'common/TestUtils.test';
+import { MockCoreService, MockDecorationService, MockOptionsService, createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
import { MockCharacterJoinerService, MockCoreBrowserService, MockThemeService } from 'browser/TestUtils.test';
import { TestWidthCache } from 'browser/renderer/dom/WidthCache.test';
@@ -48,9 +48,9 @@ describe('DomRendererRowFactory', () => {
it('should set correct attributes for double width characters', () => {
widthCache.setWidths({ '語': 10 });
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, '語', 2));
// There should be no element for the following "empty" cell
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0]));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, '', 0));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'語'
@@ -117,7 +117,7 @@ describe('DomRendererRowFactory', () => {
describe('attributes', () => {
it('should add class for bold', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -127,7 +127,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for italic', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -137,7 +137,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for dim', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -148,7 +148,7 @@ describe('DomRendererRowFactory', () => {
describe('underline', () => {
it('should add class for straight underline style', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
@@ -159,7 +159,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for double underline style', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOUBLE;
@@ -170,7 +170,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for curly underline style', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.CURLY;
@@ -181,7 +181,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for double dotted style', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
@@ -192,7 +192,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should add class for dashed underline style', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.DASHED;
@@ -205,7 +205,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for overline', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -215,7 +215,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add class for strikethrough', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -225,7 +225,7 @@ describe('DomRendererRowFactory', () => {
});
it('should hide blinking text when blink is off', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BLINK | FgFlags.UNDERLINE;
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
@@ -241,7 +241,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add classes for 256 foreground colors', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P256;
for (let i = 0; i < 256; i++) {
cell.fg &= ~Attributes.PCOLOR_MASK;
@@ -255,7 +255,7 @@ describe('DomRendererRowFactory', () => {
});
it('should add classes for 256 background colors', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.bg |= Attributes.CM_P256;
for (let i = 0; i < 256; i++) {
cell.bg &= ~Attributes.PCOLOR_MASK;
@@ -269,7 +269,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert colors', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
@@ -280,7 +280,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert default fg color', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= FgFlags.INVERSE;
cell.bg |= Attributes.CM_P16 | 1;
lineData.setCell(0, cell);
@@ -291,7 +291,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert default bg color', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE;
lineData.setCell(0, cell);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -301,7 +301,7 @@ describe('DomRendererRowFactory', () => {
});
it('should turn bold fg text bright', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= FgFlags.BOLD | Attributes.CM_P16;
for (let i = 0; i < 8; i++) {
cell.fg &= ~Attributes.PCOLOR_MASK;
@@ -315,7 +315,7 @@ describe('DomRendererRowFactory', () => {
});
it('should set style attribute for RBG', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
@@ -326,7 +326,7 @@ describe('DomRendererRowFactory', () => {
});
it('should correctly invert RGB colors', () => {
- const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
+ const cell = createCellData(0, 'a', 1);
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3 | FgFlags.INVERSE;
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
lineData.setCell(0, cell);
@@ -339,8 +339,8 @@ describe('DomRendererRowFactory', () => {
describe('selectionForeground', () => {
it('should force selected cells with content to be rendered above the background', () => {
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
rowFactory.handleSelectionChanged([1, 0], [2, 0], false);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
@@ -348,7 +348,7 @@ describe('DomRendererRowFactory', () => {
);
});
it('should force whitespace cells to be rendered above the background', () => {
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
rowFactory.handleSelectionChanged([0, 0], [2, 0], false);
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
@@ -374,9 +374,9 @@ describe('DomRendererRowFactory', () => {
});
it('can merge codepoints for equal spacing', () => {
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'b', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'abc'
@@ -385,9 +385,9 @@ describe('DomRendererRowFactory', () => {
it('should not merge codepoints with different spacing', () => {
widthCache.setWidths({ '€': 2 });
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'a€c'
@@ -395,9 +395,9 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge on FG change', () => {
- const aColor1 = CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]);
+ const aColor1 = createCellData(DEFAULT_ATTR, 'a', 1);
aColor1.fg |= Attributes.CM_P16 | 1;
- const bColor2 = CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]);
+ const bColor2 = createCellData(DEFAULT_ATTR, 'b', 1);
bColor2.fg |= Attributes.CM_P16 | 2;
lineData.setCell(0, aColor1);
lineData.setCell(1, aColor1);
@@ -410,11 +410,11 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge cursor cell', () => {
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'X', 1, 'X'.charCodeAt(0)]));
- lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
- lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'X', 1));
+ lineData.setCell(3, createCellData(DEFAULT_ATTR, 'b', 1));
+ lineData.setCell(4, createCellData(DEFAULT_ATTR, 'b', 1));
const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'aaXbb'
@@ -453,7 +453,7 @@ describe('DomRendererRowFactory', () => {
assert.equal(extractHtml(spans),
''
);
- lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(4, createCellData(DEFAULT_ATTR, 'a', 1));
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
assert.equal(extractHtml(spans),
'a'
@@ -462,9 +462,9 @@ describe('DomRendererRowFactory', () => {
it('should apply correct positive or negative spacing', () => {
widthCache.setWidths({ '€': 2, '語': 10, '𝄞': 7 }); // €: too small (+3px), 語: exact, 𝄞: too wide (-2px)
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '€', 1, '€'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, '€', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'c', 1));
lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, '語', 2, 'c'.charCodeAt(0)]));
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, '𝄞', 1, 'c'.charCodeAt(0)]));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
@@ -474,13 +474,13 @@ describe('DomRendererRowFactory', () => {
});
it('should not merge across link borders', () => {
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
- lineData.setCell(3, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
- lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
- lineData.setCell(5, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
- lineData.setCell(6, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
+ lineData.setCell(3, createCellData(DEFAULT_ATTR, 'x', 1));
+ lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
+ lineData.setCell(5, createCellData(DEFAULT_ATTR, 'b', 1));
+ lineData.setCell(6, createCellData(DEFAULT_ATTR, 'b', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
assert.equal(extractHtml(spans),
'aaxxxbb'
@@ -488,10 +488,10 @@ describe('DomRendererRowFactory', () => {
});
it('empty cells included in link underline', () => {
- lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
- lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
- lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'x', 1, 'x'.charCodeAt(0)]));
+ lineData.setCell(0, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(1, createCellData(DEFAULT_ATTR, 'a', 1));
+ lineData.setCell(2, createCellData(DEFAULT_ATTR, 'x', 1));
+ lineData.setCell(4, createCellData(DEFAULT_ATTR, 'x', 1));
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
assert.equal(extractHtml(spans),
'aax x'
@@ -500,7 +500,7 @@ describe('DomRendererRowFactory', () => {
it('link range gets capped to actual line borders', () => {
for (let i = 0; i < 10; ++i) {
- lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
+ lineData.setCell(i, createCellData(DEFAULT_ATTR, 'a', 1));
}
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -100, 100);
assert.equal(extractHtml(spans),
@@ -519,7 +519,7 @@ describe('DomRendererRowFactory', () => {
function createEmptyLineData(cols: number): IBufferLine {
const lineData = new BufferLine(cols);
for (let i = 0; i < cols; i++) {
- lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]));
+ lineData.setCell(i, NULL_CELL_DATA);
}
return lineData;
}
diff --git a/src/browser/scrollable/touch.ts b/src/browser/scrollable/touch.ts
index cba4e5b57..8ffdfe699 100644
--- a/src/browser/scrollable/touch.ts
+++ b/src/browser/scrollable/touch.ts
@@ -153,6 +153,8 @@ export interface IGestureEvent extends MouseEvent {
translationY: number;
pageX: number;
pageY: number;
+ clientX: number;
+ clientY: number;
tapCount: number;
}
@@ -459,6 +461,8 @@ export class Gesture extends Disposable {
evt.translationY = touch.pageY - tail(data.rollingPageY)!;
evt.pageX = touch.pageX;
evt.pageY = touch.pageY;
+ evt.clientX = touch.clientX;
+ evt.clientY = touch.clientY;
this._dispatchEvent(evt);
if (data.rollingPageX.length > 3) {
diff --git a/src/browser/services/CharacterJoinerService.test.ts b/src/browser/services/CharacterJoinerService.test.ts
index 6b5326d93..a0ccc67d6 100644
--- a/src/browser/services/CharacterJoinerService.test.ts
+++ b/src/browser/services/CharacterJoinerService.test.ts
@@ -9,7 +9,7 @@ import { CharacterJoinerService } from 'browser/services/CharacterJoinerService'
import { BufferLine } from 'common/buffer/BufferLine';
import { IBufferLine } from 'common/Types';
import { CellData } from 'common/buffer/CellData';
-import { MockBufferService } from 'common/TestUtils.test';
+import { MockBufferService, createCellData } from 'common/TestUtils.test';
describe('CharacterJoinerService', () => {
let service: ICharacterJoinerService;
@@ -25,17 +25,17 @@ describe('CharacterJoinerService', () => {
lines.set(4, new BufferLine(0));
lines.set(5, lineData([['a', 0x11111111], [' -> b -> c -> '], ['d', 0x22222222]]));
const line6 = lineData([['wi']]);
- line6.resize(line6.length + 1, CellData.fromCharData([0, '¥', 2, '¥'.charCodeAt(0)]));
- line6.resize(line6.length + 1, CellData.fromCharData([0, '', 0, 0]));
+ line6.resize(line6.length + 1, createCellData(0, '¥', 2));
+ line6.resize(line6.length + 1, createCellData(0, '', 0));
let sub = lineData([['deemo']]);
let oldSize = line6.length;
- line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
+ line6.resize(oldSize + sub.length, createCellData(0, '', 0));
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
line6.resize(line6.length + 1, CellData.fromCharData([0, '\xf0\x9f\x98\x81', 1, 128513]));
- line6.resize(line6.length + 1, CellData.fromCharData([0, ' ', 1, ' '.charCodeAt(0)]));
+ line6.resize(line6.length + 1, createCellData(0, ' ', 1));
sub = lineData([['jiabc']]);
oldSize = line6.length;
- line6.resize(oldSize + sub.length, CellData.fromCharData([0, '', 0, 0]));
+ line6.resize(oldSize + sub.length, createCellData(0, '', 0));
for (let i = 0; i < sub.length; ++i) line6.setCell(i + oldSize, sub.loadCell(i, new CellData()));
lines.set(6, line6);
@@ -272,8 +272,8 @@ function lineData(data: IPartialLineData[]): IBufferLine {
const line = data[i][0];
const attr = (data[i][1] || 0) as number;
const offset = tline.length;
- tline.resize(tline.length + line.split('').length, CellData.fromCharData([0, '', 0, 0]));
- line.split('').map((char, idx) => tline.setCell(idx + offset, CellData.fromCharData([attr, char, 1, char.charCodeAt(0)])));
+ tline.resize(tline.length + line.split('').length, createCellData(0, '', 0));
+ line.split('').map((char, idx) => tline.setCell(idx + offset, createCellData(attr, char, 1)));
}
return tline;
}
diff --git a/src/browser/services/CoreBrowserService.ts b/src/browser/services/CoreBrowserService.ts
index 5052de6d2..658caaeb1 100644
--- a/src/browser/services/CoreBrowserService.ts
+++ b/src/browser/services/CoreBrowserService.ts
@@ -13,7 +13,7 @@ export class CoreBrowserService extends Disposable implements ICoreBrowserServic
private _isFocused = false;
private _cachedIsFocused: boolean | undefined = undefined;
- private _screenDprMonitor = this._register(new ScreenDprMonitor(this._window));
+ private _screenDprMonitor: ScreenDprMonitor;
private readonly _onDprChange = this._register(new Emitter());
public readonly onDprChange = this._onDprChange.event;
@@ -27,6 +27,8 @@ export class CoreBrowserService extends Disposable implements ICoreBrowserServic
) {
super();
+ this._screenDprMonitor = this._register(new ScreenDprMonitor(this._window));
+
// Monitor device pixel ratio
this._register(this.onWindowChange(w => this._screenDprMonitor.setWindow(w)));
this._register(EventUtils.forward(this._screenDprMonitor.onDprChange, this._onDprChange));
diff --git a/src/browser/services/MouseCoordsService.ts b/src/browser/services/MouseCoordsService.ts
new file mode 100644
index 000000000..e2455fd2b
--- /dev/null
+++ b/src/browser/services/MouseCoordsService.ts
@@ -0,0 +1,47 @@
+/**
+ * Copyright (c) 2026 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+
+import { getWindow } from 'browser/Dom';
+import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
+import { ICharSizeService, IMouseCoordsService, IRenderService } from 'browser/services/Services';
+
+export class MouseCoordsService implements IMouseCoordsService {
+ public serviceBrand: undefined;
+
+ constructor(
+ @ICharSizeService private readonly _charSizeService: ICharSizeService,
+ @IRenderService private readonly _renderService: IRenderService
+ ) {
+ }
+
+ public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
+ return getCoords(
+ window,
+ event,
+ element,
+ colCount,
+ rowCount,
+ this._charSizeService.hasValidSize,
+ this._renderService.dimensions.css.cell.width,
+ this._renderService.dimensions.css.cell.height,
+ isSelection
+ );
+ }
+
+ public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
+ const coords = getCoordsRelativeToElement(getWindow(element), event, element);
+ if (!this._charSizeService.hasValidSize) {
+ return undefined;
+ }
+ coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
+ coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
+ return {
+ col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
+ row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
+ x: Math.floor(coords[0]),
+ y: Math.floor(coords[1])
+ };
+ }
+}
diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts
new file mode 100644
index 000000000..f395083e3
--- /dev/null
+++ b/src/browser/services/MouseService.test.ts
@@ -0,0 +1,213 @@
+/**
+ * Copyright (c) 2019 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+import { assert } from 'chai';
+import { MouseService } from 'browser/services/MouseService';
+import { MouseStateService } from 'common/services/MouseStateService';
+import { CoreMouseAction, CoreMouseButton } from 'common/Types';
+import { IBufferService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
+import { MockCoreBrowserService, MockRenderService, MockSelectionService } from 'browser/TestUtils.test';
+
+function toBytes(s: string | undefined): number[] {
+ if (!s) {
+ return [];
+ }
+ const res: number[] = [];
+ for (let i = 0; i < s.length; ++i) {
+ res.push(s.charCodeAt(i));
+ }
+ return res;
+}
+
+// Minimal mocks for deps that MouseService touches in these tests
+const bufferService: IBufferService = {
+ buffer: { hasScrollback: true } as any,
+ cols: 500,
+ rows: 500
+} as any;
+
+const optionsService: IOptionsService = {
+ rawOptions: {
+ logLevel: 'info',
+ fastScrollSensitivity: 1,
+ scrollSensitivity: 1
+ }
+} as any;
+
+const logService: ILogService = {
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {}
+} as any;
+
+describe('MouseService _triggerMouseEvent', () => {
+ let mouseService: MouseService;
+ let mouseStateService: MouseStateService;
+ let coreService: ICoreService;
+ let reports: string[];
+
+ beforeEach(() => {
+ reports = [];
+ mouseStateService = new MouseStateService();
+ coreService = {
+ triggerDataEvent: (data: string) => reports.push(data),
+ triggerBinaryEvent: (data: string) => reports.push(data),
+ decPrivateModes: { applicationCursorKeys: false }
+ } as any;
+
+ mouseService = new MouseService(
+ new MockRenderService(),
+ {
+ getMouseReportCoords: (_ev: MouseEvent, _el: HTMLElement) => ({ col: 0, row: 0, x: 0, y: 0 })
+ } as any,
+ mouseStateService,
+ coreService,
+ bufferService,
+ optionsService,
+ new MockSelectionService(),
+ logService,
+ new MockCoreBrowserService()
+ );
+ });
+
+ function trigger(e: Parameters[0]): boolean {
+ return (mouseService as any)._triggerMouseEvent(e);
+ }
+
+ it('NONE', () => {
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
+ });
+
+ it('X10', () => {
+ mouseStateService.activeProtocol = 'X10';
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
+ });
+
+ it('VT200', () => {
+ mouseStateService.activeProtocol = 'VT200';
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
+ });
+
+ it('DRAG', () => {
+ mouseStateService.activeProtocol = 'DRAG';
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
+ });
+
+ it('ANY', () => {
+ mouseStateService.activeProtocol = 'ANY';
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), true);
+ // should not report in any case
+ // invalid button + action combinations
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.MOVE }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.UP }), false);
+ // invalid coords
+ assert.equal(trigger({ col: -1, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 500, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: -1, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
+ assert.equal(trigger({ col: 0, row: 500, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
+ });
+
+ describe('coords', () => {
+ it('DEFAULT encoding', () => {
+ mouseStateService.activeProtocol = 'ANY';
+ for (let i = 0; i < bufferService.cols; ++i) {
+ assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ if (i > 222) {
+ // supress mouse reports if we are out of addressible range (max. 222)
+ assert.deepEqual(toBytes(reports.pop()), []);
+ } else {
+ assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]);
+ }
+ }
+ });
+
+ it('SGR encoding', () => {
+ mouseStateService.activeProtocol = 'ANY';
+ mouseStateService.activeEncoding = 'SGR';
+ for (let i = 0; i < bufferService.cols; ++i) {
+ assert.equal(trigger({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`);
+ }
+ });
+
+ it('SGR_PIXELS encoding', () => {
+ mouseStateService.activeProtocol = 'ANY';
+ mouseStateService.activeEncoding = 'SGR_PIXELS';
+ for (let i = 0; i < 500; ++i) {
+ assert.equal(trigger({ col: 0, row: 0, x: i, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
+ assert.deepEqual(reports.pop(), `\x1b[<0;${i};0M`);
+ }
+ });
+ });
+
+ it('eventCodes with modifiers (DEFAULT encoding)', () => {
+ // TODO: implement AUX button tests
+ mouseStateService.activeProtocol = 'ANY';
+ mouseStateService.activeEncoding = 'DEFAULT';
+ // all buttons + down + no modifer
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
+ assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']);
+ reports = [];
+
+ // all buttons + up + no modifier
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
+ assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']);
+ reports = [];
+
+ // all buttons + move + no modifier
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
+ assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']);
+ reports = [];
+
+ // button none + move + modifiers
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: false, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: true }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: false }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: true }), true);
+ assert.equal(trigger({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: true }), true);
+ assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']);
+ reports = [];
+ });
+});
diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts
index 3e382575e..d701796c2 100644
--- a/src/browser/services/MouseService.ts
+++ b/src/browser/services/MouseService.ts
@@ -3,45 +3,537 @@
* @license MIT
*/
-import { getWindow } from 'browser/Dom';
-import { ICharSizeService, IRenderService, IMouseService } from './Services';
-import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
+import { addDisposableListener } from 'browser/Dom';
+import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from 'common/services/Services';
+import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from 'common/Types';
+import { C0 } from 'common/data/EscapeSequences';
+import { toDisposable } from 'common/Lifecycle';
+import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services';
+import { Gesture, EventType as GestureEventType, IGestureEvent } from 'browser/scrollable/touch';
+
+type RequestedMouseEvents = Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener | null>;
+
+interface IMouseBindContext {
+ readonly target: IMouseServiceTarget;
+ readonly focus: () => void;
+ readonly requestedEvents: RequestedMouseEvents;
+}
export class MouseService implements IMouseService {
public serviceBrand: undefined;
+ private _lastEvent: ICoreMouseEvent | null = null;
+ private _wheelPartialScroll: number = 0;
+ private _touchScrollAccumulator: number = 0;
+
constructor(
@IRenderService private readonly _renderService: IRenderService,
- @ICharSizeService private readonly _charSizeService: ICharSizeService
+ @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
+ @IMouseStateService private readonly _mouseStateService: IMouseStateService,
+ @ICoreService private readonly _coreService: ICoreService,
+ @IBufferService private readonly _bufferService: IBufferService,
+ @IOptionsService private readonly _optionsService: IOptionsService,
+ @ISelectionService private readonly _selectionService: ISelectionService,
+ @ILogService private readonly _logService: ILogService,
+ @ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
) {
}
- public getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined {
- return getCoords(
- window,
- event,
- element,
- colCount,
- rowCount,
- this._charSizeService.hasValidSize,
- this._renderService.dimensions.css.cell.width,
- this._renderService.dimensions.css.cell.height,
- isSelection
- );
+ public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void {
+ const { element, document } = target;
+
+ /**
+ * Event listener state handling.
+ * We listen to the onProtocolChange event of MouseStateService 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: RequestedMouseEvents = {
+ mouseup: null,
+ wheel: null,
+ mousedrag: null,
+ mousemove: null
+ };
+ const ctx: IMouseBindContext = { target, focus, requestedEvents };
+ const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = {
+ mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent),
+ wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent),
+ mousedrag: (ev: Event) => this._handleMouseDrag(ctx, ev as MouseEvent),
+ mousemove: (ev: Event) => this._handleMouseMove(ctx, ev as MouseEvent)
+ };
+ register(this._mouseStateService.onProtocolChange(events => {
+ this._handleProtocolChange(ctx, eventListeners, events);
+ }));
+ // force initial onProtocolChange so we dont miss early mouse requests
+ this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol;
+
+ // Ensure document-level listeners are removed on dispose
+ register(toDisposable(() => {
+ if (requestedEvents.mouseup) {
+ document.removeEventListener('mouseup', requestedEvents.mouseup);
+ }
+ if (requestedEvents.mousedrag) {
+ document.removeEventListener('mousemove', requestedEvents.mousedrag);
+ }
+ }));
+
+ /**
+ * "Always on" event listeners.
+ */
+ register(addDisposableListener(element, 'mousedown', (ev: MouseEvent) => this._handleMouseDown(ctx, ev)));
+ register(addDisposableListener(element, 'wheel', (ev: WheelEvent) => this._handlePassiveWheel(ctx, ev), { passive: false }));
+ register(Gesture.addTarget(target.screenElement));
+ register(addDisposableListener(target.screenElement, GestureEventType.START, () => this._handleTouchStart()));
+ register(addDisposableListener(target.screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleTouchChange(ctx, e)));
}
- public getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined {
- const coords = getCoordsRelativeToElement(getWindow(element), event, element);
- if (!this._charSizeService.hasValidSize) {
- return undefined;
+ private _sendEvent(ctx: IMouseBindContext, ev: MouseEvent | WheelEvent): boolean {
+ // Get mouse coordinates
+ const pos = this._mouseCoordsService.getMouseReportCoords(ev as MouseEvent, ctx.target.screenElement);
+ if (!pos) {
+ return false;
}
- coords[0] = Math.min(Math.max(coords[0], 0), this._renderService.dimensions.css.canvas.width - 1);
- coords[1] = Math.min(Math.max(coords[1], 0), this._renderService.dimensions.css.canvas.height - 1);
+
+ let but: CoreMouseButton;
+ let action: CoreMouseAction | undefined;
+ switch ((ev as MouseEvent & { overrideType?: string }).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 (!this._mouseStateService.allowCustomWheelEvent(ev as WheelEvent)) {
+ return false;
+ }
+ const deltaY = (ev as WheelEvent).deltaY;
+ if (deltaY === 0) {
+ return false;
+ }
+ const lines = this._consumeWheelEvent(
+ ev as WheelEvent,
+ this._renderService?.dimensions?.device?.cell?.height,
+ this._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 this._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
+ });
+ }
+
+ private _handleMouseUp(ctx: IMouseBindContext, ev: MouseEvent): void {
+ this._sendEvent(ctx, ev);
+ if (!ev.buttons) {
+ // if no other button is held remove global handlers
+ if (ctx.requestedEvents.mouseup) {
+ ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup);
+ }
+ if (ctx.requestedEvents.mousedrag) {
+ ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag);
+ }
+ }
+ }
+
+ private _handleWheel(ctx: IMouseBindContext, ev: WheelEvent): false {
+ this._sendEvent(ctx, ev);
+ ev.preventDefault();
+ ev.stopPropagation();
+ return false;
+ }
+
+ private _handleMouseDrag(ctx: IMouseBindContext, ev: MouseEvent): void {
+ // deal only with move while a button is held
+ if (ev.buttons) {
+ this._sendEvent(ctx, ev);
+ }
+ }
+
+ private _handleMouseMove(ctx: IMouseBindContext, ev: MouseEvent): void {
+ // deal only with move without any button
+ if (!ev.buttons) {
+ this._sendEvent(ctx, ev);
+ }
+ }
+
+ private _handleMouseDown(ctx: IMouseBindContext, ev: MouseEvent): void {
+ ev.preventDefault();
+ ctx.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._mouseStateService.areMouseEventsActive || this._selectionService.shouldForceSelection(ev)) {
+ return;
+ }
+
+ this._sendEvent(ctx, 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 (ctx.requestedEvents.mouseup) {
+ ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup);
+ }
+ if (ctx.requestedEvents.mousedrag) {
+ ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag);
+ }
+ }
+
+ private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void {
+ // do nothing, if app side handles wheel itself
+ if (ctx.requestedEvents.wheel) {
+ return;
+ }
+
+ if (!this._mouseStateService.allowCustomWheelEvent(ev)) {
+ return false;
+ }
+
+ if (!this._bufferService.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.deltaY;
+ if (deltaY === 0) {
+ return false;
+ }
+
+ const lines = this._consumeWheelEvent(
+ ev,
+ this._renderService?.dimensions?.device?.cell?.height,
+ this._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;
+ }
+ }
+
+ private _handleTouchStart(): void {
+ this._touchScrollAccumulator = 0;
+ }
+
+ private _handleTouchChange(ctx: IMouseBindContext, e: IGestureEvent): void {
+ e.preventDefault();
+ e.stopPropagation();
+
+ // When mouse protocol has wheel events active, send as mouse wheel events.
+ if (ctx.requestedEvents.wheel) {
+ this._handleTouchScrollAsWheel(ctx, e);
+ return;
+ }
+
+ // When in alt buffer (no scrollback), send up/down key sequences.
+ if (!this._bufferService.buffer.hasScrollback) {
+ this._handleTouchScrollAsKeys(e);
+ return;
+ }
+
+ // Normal scrollback: delegate to viewport scrolling when available.
+ ctx.target.handleTouchScroll?.(e.translationY);
+ }
+
+ private _handleTouchScrollAsKeys(e: IGestureEvent): void {
+ const cellHeight = this._renderService?.dimensions.css.cell.height;
+ if (!cellHeight) {
+ return;
+ }
+
+ this._touchScrollAccumulator -= e.translationY;
+ const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
+ if (lines === 0) {
+ return;
+ }
+
+ this._touchScrollAccumulator -= lines * cellHeight;
+ const sequence = C0.ESC
+ + (this._coreService.decPrivateModes.applicationCursorKeys ? 'O' : '[')
+ + (lines < 0 ? 'A' : 'B');
+ for (let i = 0; i < Math.abs(lines); i++) {
+ this._coreService.triggerDataEvent(sequence, true);
+ }
+ }
+
+ private _handleTouchScrollAsWheel(ctx: IMouseBindContext, e: IGestureEvent): void {
+ const cellHeight = this._renderService?.dimensions.css.cell.height;
+ if (!cellHeight) {
+ return;
+ }
+
+ this._touchScrollAccumulator -= e.translationY;
+ const lines = Math.trunc(this._touchScrollAccumulator / cellHeight);
+ if (lines === 0) {
+ return;
+ }
+
+ this._touchScrollAccumulator -= lines * cellHeight;
+ const pos = this._mouseCoordsService.getMouseReportCoords(e, ctx.target.screenElement);
+ if (!pos) {
+ return;
+ }
+
+ for (let i = 0; i < Math.abs(lines); i++) {
+ this._triggerMouseEvent({
+ col: pos.col,
+ row: pos.row,
+ x: pos.x,
+ y: pos.y,
+ button: CoreMouseButton.WHEEL,
+ action: lines < 0 ? CoreMouseAction.UP : CoreMouseAction.DOWN,
+ ctrl: false,
+ alt: false,
+ shift: false
+ });
+ }
+ }
+
+ public reset(): void {
+ this._lastEvent = null;
+ this._wheelPartialScroll = 0;
+ this._touchScrollAccumulator = 0;
+ }
+
+ private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void {
+ const { element, document } = ctx.target;
+ const { requestedEvents } = ctx;
+ // apply global changes on events
+ if (events) {
+ if (this._optionsService.rawOptions.logLevel === 'debug') {
+ this._logService.debug('Binding to mouse events:', this._explainEvents(events));
+ }
+ element.classList.add('enable-mouse-events');
+ this._selectionService.disable();
+ } else {
+ this._logService.debug('Unbinding from mouse events.');
+ element.classList.remove('enable-mouse-events');
+ this._selectionService.enable();
+ }
+
+ // add/remove handlers from requestedEvents
+ if (!(events & CoreMouseEventType.MOVE)) {
+ if (requestedEvents.mousemove) {
+ element.removeEventListener('mousemove', requestedEvents.mousemove);
+ }
+ requestedEvents.mousemove = null;
+ } else if (!requestedEvents.mousemove) {
+ element.addEventListener('mousemove', eventListeners.mousemove);
+ requestedEvents.mousemove = eventListeners.mousemove;
+ }
+
+ if (!(events & CoreMouseEventType.WHEEL)) {
+ if (requestedEvents.wheel) {
+ element.removeEventListener('wheel', requestedEvents.wheel);
+ }
+ requestedEvents.wheel = null;
+ } else if (!requestedEvents.wheel) {
+ element.addEventListener('wheel', eventListeners.wheel, { passive: false });
+ requestedEvents.wheel = eventListeners.wheel;
+ }
+
+ if (!(events & CoreMouseEventType.UP)) {
+ if (requestedEvents.mouseup) {
+ document.removeEventListener('mouseup', requestedEvents.mouseup);
+ }
+ requestedEvents.mouseup = null;
+ } else {
+ requestedEvents.mouseup ??= eventListeners.mouseup;
+ }
+
+ if (!(events & CoreMouseEventType.DRAG)) {
+ if (requestedEvents.mousedrag) {
+ document.removeEventListener('mousemove', requestedEvents.mousedrag);
+ }
+ requestedEvents.mousedrag = null;
+ } else {
+ requestedEvents.mousedrag ??= eventListeners.mousedrag;
+ }
+ }
+
+ private _applyScrollModifier(amount: number, ev: WheelEvent): number {
+ // Multiply the scroll speed when the modifier key is pressed
+ if (ev.altKey || ev.ctrlKey || ev.shiftKey) {
+ return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity;
+ }
+ return amount * this._optionsService.rawOptions.scrollSensitivity;
+ }
+
+ /**
+ * Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls.
+ * This prevents hyper-sensitive scrolling in alt buffer.
+ */
+ private _consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number {
+ // Do nothing if it's not a vertical scroll event
+ if (ev.deltaY === 0 || ev.shiftKey) {
+ return 0;
+ }
+
+ if (cellHeight === undefined || dpr === undefined) {
+ return 0;
+ }
+
+ const targetWheelEventPixels = cellHeight / dpr;
+ let amount = this._applyScrollModifier(ev.deltaY, ev);
+
+ if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
+ amount /= (targetWheelEventPixels + 0.0); // Prevent integer division
+
+ const isLikelyTrackpad = Math.abs(ev.deltaY) < 50;
+ if (isLikelyTrackpad) {
+ amount *= 0.3;
+ }
+
+ this._wheelPartialScroll += amount;
+ amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1);
+ this._wheelPartialScroll %= 1;
+ } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
+ amount *= this._bufferService.rows;
+ }
+ return amount;
+ }
+
+ /**
+ * Triggers a mouse event to be sent.
+ *
+ * Returns true if the event passed all protocol restrictions and a report
+ * was sent, otherwise false. The return value may be used to decide whether
+ * the default event action in the browser component should be omitted.
+ *
+ * Note: The method will change values of the given event object
+ * to fulfill protocol and encoding restrictions.
+ */
+ private _triggerMouseEvent(e: ICoreMouseEvent): boolean {
+ // range check for col/row
+ if (e.col < 0 || e.col >= this._bufferService.cols
+ || e.row < 0 || e.row >= this._bufferService.rows) {
+ return false;
+ }
+
+ // filter nonsense combinations of button + action
+ if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) {
+ return false;
+ }
+ if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) {
+ return false;
+ }
+ if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) {
+ return false;
+ }
+
+ // report 1-based coords
+ e.col++;
+ e.row++;
+
+ // debounce move events at grid or pixel level
+ if (e.action === CoreMouseAction.MOVE
+ && this._lastEvent
+ && this._equalEvents(this._lastEvent, e, this._mouseStateService.isPixelEncoding)
+ ) {
+ return false;
+ }
+
+ // apply protocol restrictions
+ if (!this._mouseStateService.restrictMouseEvent(e)) {
+ return false;
+ }
+
+ // encode report and send
+ const report = this._mouseStateService.encodeMouseEvent(e);
+ if (report) {
+ if (this._mouseStateService.isDefaultEncoding) {
+ this._coreService.triggerBinaryEvent(report);
+ } else {
+ this._coreService.triggerDataEvent(report, true);
+ }
+ }
+
+ this._lastEvent = e;
+ return true;
+ }
+
+ private _explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
return {
- col: Math.floor(coords[0] / this._renderService.dimensions.css.cell.width),
- row: Math.floor(coords[1] / this._renderService.dimensions.css.cell.height),
- x: Math.floor(coords[0]),
- y: Math.floor(coords[1])
+ down: !!(events & CoreMouseEventType.DOWN),
+ up: !!(events & CoreMouseEventType.UP),
+ drag: !!(events & CoreMouseEventType.DRAG),
+ move: !!(events & CoreMouseEventType.MOVE),
+ wheel: !!(events & CoreMouseEventType.WHEEL)
};
}
+
+ private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean {
+ if (pixels) {
+ if (e1.x !== e2.x) return false;
+ if (e1.y !== e2.y) return false;
+ } else {
+ if (e1.col !== e2.col) return false;
+ if (e1.row !== e2.row) return false;
+ }
+ if (e1.button !== e2.button) return false;
+ if (e1.action !== e2.action) return false;
+ if (e1.ctrl !== e2.ctrl) return false;
+ if (e1.alt !== e2.alt) return false;
+ if (e1.shift !== e2.shift) return false;
+ return true;
+ }
+
}
diff --git a/src/browser/services/SelectionService.test.ts b/src/browser/services/SelectionService.test.ts
index 67158def0..8deedb751 100644
--- a/src/browser/services/SelectionService.test.ts
+++ b/src/browser/services/SelectionService.test.ts
@@ -7,7 +7,7 @@ import { assert } from 'chai';
import { SelectionService, SelectionMode } from './SelectionService';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { IBufferLine } from 'common/Types';
-import { MockBufferService, MockOptionsService, MockCoreService } from 'common/TestUtils.test';
+import { MockBufferService, MockOptionsService, MockCoreService, createCellData } from 'common/TestUtils.test';
import { BufferLine } from 'common/buffer/BufferLine';
import { IBufferService, IOptionsService } from 'common/services/Services';
import { MockCoreBrowserService, MockMouseService, MockRenderService } from 'browser/TestUtils.test';
@@ -57,14 +57,14 @@ describe('SelectionService', () => {
function stringToRow(text: string): IBufferLine {
const result = new BufferLine(text.length);
for (let i = 0; i < text.length; i++) {
- result.setCell(i, CellData.fromCharData([0, text.charAt(i), 1, text.charCodeAt(i)]));
+ result.setCell(i, createCellData(0, text.charAt(i), 1));
}
return result;
}
function stringArrayToRow(chars: string[]): IBufferLine {
const line = new BufferLine(chars.length);
- chars.map((c, idx) => line.setCell(idx, CellData.fromCharData([0, c, 1, c.charCodeAt(0)])));
+ chars.map((c, idx) => line.setCell(idx, createCellData(0, c, 1)));
return line;
}
diff --git a/src/browser/services/SelectionService.ts b/src/browser/services/SelectionService.ts
index a5d1d759b..e1b8510e9 100644
--- a/src/browser/services/SelectionService.ts
+++ b/src/browser/services/SelectionService.ts
@@ -8,7 +8,7 @@ import { getCoordsRelativeToElement } from 'browser/input/Mouse';
import { moveToCellSequence } from 'browser/input/MoveToCell';
import { SelectionModel } from 'browser/selection/SelectionModel';
import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from 'browser/selection/Types';
-import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
+import { ICoreBrowserService, IMouseCoordsService, IRenderService, ISelectionService } from 'browser/services/Services';
import { Disposable, toDisposable } from 'common/Lifecycle';
import * as Browser from 'common/Platform';
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
@@ -126,7 +126,7 @@ export class SelectionService extends Disposable implements ISelectionService {
private readonly _linkifier: ILinkifier2,
@IBufferService private readonly _bufferService: IBufferService,
@ICoreService private readonly _coreService: ICoreService,
- @IMouseService private readonly _mouseService: IMouseService,
+ @IMouseCoordsService private readonly _mouseCoordsService: IMouseCoordsService,
@IOptionsService private readonly _optionsService: IOptionsService,
@IRenderService private readonly _renderService: IRenderService,
@ICoreBrowserService private readonly _coreBrowserService: ICoreBrowserService
@@ -395,7 +395,7 @@ export class SelectionService extends Disposable implements ISelectionService {
* @param event The mouse event.
*/
private _getMouseBufferCoords(event: MouseEvent): [number, number] | undefined {
- const coords = this._mouseService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
+ const coords = this._mouseCoordsService.getCoords(event, this._screenElement, this._bufferService.cols, this._bufferService.rows, true);
if (!coords) {
return undefined;
}
@@ -715,7 +715,7 @@ export class SelectionService extends Disposable implements ISelectionService {
if (this.selectionText.length <= 1 && timeElapsed < ALT_CLICK_MOVE_CURSOR_TIME && event.altKey && this._optionsService.rawOptions.altClickMovesCursor) {
if (this._bufferService.buffer.ybase === this._bufferService.buffer.ydisp) {
- const coordinates = this._mouseService.getCoords(
+ const coordinates = this._mouseCoordsService.getCoords(
event,
this._element,
this._bufferService.cols,
diff --git a/src/browser/services/Services.ts b/src/browser/services/Services.ts
index 1c82b28bd..72659cafe 100644
--- a/src/browser/services/Services.ts
+++ b/src/browser/services/Services.ts
@@ -49,14 +49,28 @@ export interface ICoreBrowserService {
readonly dpr: number;
}
-export const IMouseService = createDecorator('MouseService');
-export interface IMouseService {
+export const IMouseCoordsService = createDecorator('MouseCoordsService');
+export interface IMouseCoordsService {
serviceBrand: undefined;
getCoords(event: {clientX: number, clientY: number}, element: HTMLElement, colCount: number, rowCount: number, isSelection?: boolean): [number, number] | undefined;
getMouseReportCoords(event: MouseEvent, element: HTMLElement): { col: number, row: number, x: number, y: number } | undefined;
}
+export const IMouseService = createDecorator('MouseService');
+export interface IMouseService {
+ serviceBrand: undefined;
+
+ bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void;
+ reset(): void;
+}
+export interface IMouseServiceTarget {
+ element: HTMLElement;
+ screenElement: HTMLElement;
+ document: Document;
+ handleTouchScroll?(amount: number): void;
+}
+
export const IRenderService = createDecorator('RenderService');
export interface IRenderService extends IDisposable {
serviceBrand: undefined;
diff --git a/src/browser/tsconfig.json b/src/browser/tsconfig.json
index 673a55a6c..ff5bf6ad7 100644
--- a/src/browser/tsconfig.json
+++ b/src/browser/tsconfig.json
@@ -9,9 +9,10 @@
"types": [
"../../node_modules/@types/mocha"
],
- "baseUrl": "..",
+ "skipLibCheck": true,
"paths": {
- "common/*": [ "./common/*" ]
+ "common/*": [ "./../common/*" ],
+ "*": [ "./../*" ]
}
},
"include": [
diff --git a/src/common/CoreTerminal.ts b/src/common/CoreTerminal.ts
index 0ce19a4d1..e2a3adf72 100644
--- a/src/common/CoreTerminal.ts
+++ b/src/common/CoreTerminal.ts
@@ -21,14 +21,14 @@
* http://linux.die.net/man/7/urxvt
*/
-import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, ICoreMouseService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
+import { IInstantiationService, IOptionsService, IBufferService, ILogService, ICharsetService, ICoreService, IMouseStateService, IUnicodeService, LogLevelEnum, ITerminalOptions, IOscLinkService } from 'common/services/Services';
import { InstantiationService } from 'common/services/InstantiationService';
import { LogService } from 'common/services/LogService';
import { BufferService, MINIMUM_COLS, MINIMUM_ROWS } from 'common/services/BufferService';
import { OptionsService } from 'common/services/OptionsService';
import { IDisposable, IAttributeData, ICoreTerminal, IScrollEvent } from 'common/Types';
import { CoreService } from 'common/services/CoreService';
-import { CoreMouseService } from 'common/services/CoreMouseService';
+import { MouseStateService } from 'common/services/MouseStateService';
import { UnicodeService } from 'common/services/UnicodeService';
import { CharsetService } from 'common/services/CharsetService';
import { updateWindowsModeWrappedState } from 'common/WindowsMode';
@@ -50,7 +50,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
protected readonly _charsetService: ICharsetService;
protected readonly _oscLinkService: IOscLinkService;
- public readonly coreMouseService: ICoreMouseService;
+ public readonly mouseStateService: IMouseStateService;
public readonly coreService: ICoreService;
public readonly unicodeService: IUnicodeService;
public readonly optionsService: IOptionsService;
@@ -113,8 +113,8 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this._instantiationService.setService(IBufferService, this._bufferService);
this.coreService = this._register(this._instantiationService.createInstance(CoreService));
this._instantiationService.setService(ICoreService, this.coreService);
- this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService));
- this._instantiationService.setService(ICoreMouseService, this.coreMouseService);
+ this.mouseStateService = this._register(this._instantiationService.createInstance(MouseStateService));
+ this._instantiationService.setService(IMouseStateService, this.mouseStateService);
this.unicodeService = this._register(this._instantiationService.createInstance(UnicodeService));
this._instantiationService.setService(IUnicodeService, this.unicodeService);
this._charsetService = this._instantiationService.createInstance(CharsetService);
@@ -124,7 +124,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
// Register input handler and handle/forward events
- this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.coreMouseService, this.unicodeService));
+ this._inputHandler = this._register(new InputHandler(this._bufferService, this._charsetService, this.coreService, this._logService, this.optionsService, this._oscLinkService, this.mouseStateService, this.unicodeService));
this._register(EventUtils.forward(this._inputHandler.onLineFeed, this._onLineFeed));
// Setup listeners
@@ -256,7 +256,7 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
this._bufferService.reset();
this._charsetService.reset();
this.coreService.reset();
- this.coreMouseService.reset();
+ this.mouseStateService.reset();
}
diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts
index f36c28b45..4d19ef4f1 100644
--- a/src/common/InputHandler.test.ts
+++ b/src/common/InputHandler.test.ts
@@ -11,7 +11,7 @@ import { CellData } from 'common/buffer/CellData';
import { Attributes, BgFlags, UnderlineStyle } from 'common/buffer/Constants';
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
import { Params } from 'common/parser/Params';
-import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockCoreMouseService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
+import { MockCoreService, MockBufferService, MockOptionsService, MockLogService, MockMouseStateService, MockCharsetService, MockUnicodeService, MockOscLinkService } from 'common/TestUtils.test';
import { IBufferService, ICoreService, type IOscLinkService } from 'common/services/Services';
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
import { clone } from 'common/Clone';
@@ -70,7 +70,7 @@ describe('InputHandler', () => {
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
oscLinkService = new OscLinkService(bufferService);
- inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockCoreMouseService(), new MockUnicodeService());
+ inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, oscLinkService, new MockMouseStateService(), new MockUnicodeService());
});
describe('SL/SR/DECIC/DECDC', () => {
@@ -261,7 +261,7 @@ describe('InputHandler', () => {
describe('setMode', () => {
it('should toggle bracketedPasteMode', () => {
const coreService = new MockCoreService();
- const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Set bracketed paste mode
inputHandler.setModePrivate(Params.fromArray([2004]));
assert.equal(coreService.decPrivateModes.bracketedPasteMode, true);
@@ -272,7 +272,7 @@ describe('InputHandler', () => {
it('should toggle colorSchemeUpdates (DECSET 2031)', () => {
const coreService = new MockCoreService();
const optionsService = new MockOptionsService();
- const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Set color scheme updates mode (default colorSchemeQuery=true)
inputHandler.setModePrivate(Params.fromArray([2031]));
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, true);
@@ -284,7 +284,7 @@ describe('InputHandler', () => {
const coreService = new MockCoreService();
const optionsService = new MockOptionsService();
optionsService.rawOptions.vtExtensions = { colorSchemeQuery: false };
- const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ const inputHandler = new TestInputHandler(new MockBufferService(80, 30), new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
// Attempt to set color scheme updates mode
inputHandler.setModePrivate(Params.fromArray([2031]));
assert.equal(coreService.decPrivateModes.colorSchemeUpdates, false);
@@ -306,7 +306,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
@@ -352,7 +352,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
@@ -402,7 +402,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
@@ -439,7 +439,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
@@ -488,7 +488,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService({ scrollOnEraseInDisplay: true }),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
const aLine = Array(bufferService.cols + 1).join('a');
@@ -520,7 +520,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
@@ -646,7 +646,7 @@ describe('InputHandler', () => {
new MockLogService(),
new MockOptionsService(),
new MockOscLinkService(),
- new MockCoreMouseService(),
+ new MockMouseStateService(),
new MockUnicodeService()
);
const container = new Uint32Array(10);
@@ -674,7 +674,7 @@ describe('InputHandler', () => {
beforeEach(() => {
bufferService = new MockBufferService(80, 30);
- handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ handler = new TestInputHandler(bufferService, new MockCharsetService(), new MockCoreService(), new MockLogService(), new MockOptionsService(), new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
it('should handle DECSET/DECRST 47 (alt screen buffer)', async () => {
await handler.parseP('\x1b[?47h\r\n\x1b[31mJUNK\x1b[?47lTEST');
@@ -887,7 +887,7 @@ describe('InputHandler', () => {
describe('colon notation', () => {
let inputHandler2: TestInputHandler;
beforeEach(() => {
- inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ inputHandler2 = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
describe('should equal to semicolon', () => {
it('CSI 38:2::50:100:150 m', async () => {
@@ -2461,7 +2461,7 @@ describe('InputHandler', () => {
bufferService = new BufferService(optionsService, new MockLogService());
bufferService.resize(80, 30);
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
- inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
describe('stack limit', () => {
@@ -2513,7 +2513,7 @@ describe('InputHandler', () => {
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
coreService.onData(data => { console.log(data); });
- inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
+ inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockMouseStateService(), new MockUnicodeService());
});
it('async CUP with CPR check', async () => {
diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts
index 1997d9f11..68265c5df 100644
--- a/src/common/InputHandler.ts
+++ b/src/common/InputHandler.ts
@@ -15,7 +15,7 @@ import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } fr
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
import { CellData } from 'common/buffer/CellData';
import { AttributeData } from 'common/buffer/AttributeData';
-import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
+import { ICoreService, IBufferService, IOptionsService, ILogService, IMouseStateService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
import { OscHandler } from 'common/parser/OscParser';
import { DcsHandler } from 'common/parser/DcsParser';
@@ -178,7 +178,7 @@ export class InputHandler extends Disposable implements IInputHandler {
private readonly _logService: ILogService,
private readonly _optionsService: IOptionsService,
private readonly _oscLinkService: IOscLinkService,
- private readonly _coreMouseService: ICoreMouseService,
+ private readonly _mouseStateService: IMouseStateService,
private readonly _unicodeService: IUnicodeService,
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
) {
@@ -1987,19 +1987,19 @@ export class InputHandler extends Disposable implements IInputHandler {
break;
case 9: // X10 Mouse
// no release, no motion, no wheel, no modifiers.
- this._coreMouseService.activeProtocol = 'X10';
+ this._mouseStateService.activeProtocol = 'X10';
break;
case 1000: // vt200 mouse
// no motion.
- this._coreMouseService.activeProtocol = 'VT200';
+ this._mouseStateService.activeProtocol = 'VT200';
break;
case 1002: // button event mouse
- this._coreMouseService.activeProtocol = 'DRAG';
+ this._mouseStateService.activeProtocol = 'DRAG';
break;
case 1003: // any event mouse
// any event - sends motion events,
// even if there is no button held down.
- this._coreMouseService.activeProtocol = 'ANY';
+ this._mouseStateService.activeProtocol = 'ANY';
break;
case 1004: // send focusin/focusout events
// focusin: ^[[I
@@ -2011,13 +2011,13 @@ export class InputHandler extends Disposable implements IInputHandler {
this._logService.debug('DECSET 1005 not supported (see #2507)');
break;
case 1006: // sgr ext mode mouse
- this._coreMouseService.activeEncoding = 'SGR';
+ this._mouseStateService.activeEncoding = 'SGR';
break;
case 1015: // urxvt ext mode mouse - removed in #2507
this._logService.debug('DECSET 1015 not supported (see #2507)');
break;
case 1016: // sgr pixels mode mouse
- this._coreMouseService.activeEncoding = 'SGR_PIXELS';
+ this._mouseStateService.activeEncoding = 'SGR_PIXELS';
break;
case 25: // show cursor
this._coreService.isCursorHidden = false;
@@ -2248,7 +2248,7 @@ export class InputHandler extends Disposable implements IInputHandler {
case 1000: // vt200 mouse
case 1002: // button event mouse
case 1003: // any event mouse
- this._coreMouseService.activeProtocol = 'NONE';
+ this._mouseStateService.activeProtocol = 'NONE';
break;
case 1004: // send focusin/focusout events
this._coreService.decPrivateModes.sendFocus = false;
@@ -2257,13 +2257,13 @@ export class InputHandler extends Disposable implements IInputHandler {
this._logService.debug('DECRST 1005 not supported (see #2507)');
break;
case 1006: // sgr ext mode mouse
- this._coreMouseService.activeEncoding = 'DEFAULT';
+ this._mouseStateService.activeEncoding = 'DEFAULT';
break;
case 1015: // urxvt ext mode mouse - removed in #2507
this._logService.debug('DECRST 1015 not supported (see #2507)');
break;
case 1016: // sgr pixels mode mouse
- this._coreMouseService.activeEncoding = 'DEFAULT';
+ this._mouseStateService.activeEncoding = 'DEFAULT';
break;
case 25: // hide cursor
this._coreService.isCursorHidden = true;
@@ -2357,7 +2357,7 @@ export class InputHandler extends Disposable implements IInputHandler {
// access helpers
const dm = this._coreService.decPrivateModes;
- const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._coreMouseService;
+ const { activeProtocol: mouseProtocol, activeEncoding: mouseEncoding } = this._mouseStateService;
const cs = this._coreService;
const { buffers, cols } = this._bufferService;
const { active, alt } = buffers;
@@ -3570,7 +3570,7 @@ export class InputHandler extends Disposable implements IInputHandler {
return true;
}
const flags = params.params[0] || 0;
- const mode = params.params[1] || 1;
+ const mode = params.length > 1 ? (params.params[1] || 1) : 1;
const state = this._coreService.kittyKeyboard;
switch (mode) {
diff --git a/src/common/TestUtils.test.ts b/src/common/TestUtils.test.ts
index 6e6f0b642..0884a4ab4 100644
--- a/src/common/TestUtils.test.ts
+++ b/src/common/TestUtils.test.ts
@@ -3,7 +3,7 @@
* @license MIT
*/
-import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, ICoreMouseService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
+import { IBufferService, ICoreService, ILogService, IOptionsService, ITerminalOptions, IMouseStateService, ICharsetService, UnicodeCharProperties, UnicodeCharWidth, IUnicodeService, IUnicodeVersionProvider, LogLevelEnum, IDecorationService, IInternalDecoration, IOscLinkService, type IBufferResizeEvent } from 'common/services/Services';
import { UnicodeService } from 'common/services/UnicodeService';
import { clone } from 'common/Clone';
import { DEFAULT_OPTIONS } from 'common/services/OptionsService';
@@ -13,6 +13,14 @@ import { IDecPrivateModes, ICoreMouseEvent, CoreMouseEventType, ICharset, IModes
import { UnicodeV6 } from 'common/input/UnicodeV6';
import { IDecorationOptions, IDecoration } from '@xterm/xterm';
import { Emitter, type IEvent } from 'common/Event';
+import { CellData } from 'common/buffer/CellData';
+import { DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH } from 'common/buffer/Constants';
+
+export function createCellData(attr: number, char: string, width: number): CellData {
+ return CellData.fromCharData([attr, char, width, char.length === 0 ? 0 : char.charCodeAt(0)]);
+}
+
+export const NULL_CELL_DATA = Object.freeze(createCellData(DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH));
export class MockBufferService implements IBufferService {
public serviceBrand: any;
@@ -58,22 +66,21 @@ export class MockBufferService implements IBufferService {
public reset(): void { }
}
-export class MockCoreMouseService implements ICoreMouseService {
+export class MockMouseStateService implements IMouseStateService {
public serviceBrand: any;
public areMouseEventsActive: boolean = false;
public activeEncoding: string = '';
public activeProtocol: string = '';
+ public isDefaultEncoding: boolean = true;
+ public isPixelEncoding: boolean = false;
public addEncoding(name: string): void { }
public addProtocol(name: string): void { }
public reset(): void { }
- public triggerMouseEvent(event: ICoreMouseEvent): boolean { return false; }
public onProtocolChange: IEvent = new Emitter().event;
- public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
- throw new Error('Method not implemented.');
- }
- public consumeWheelEvent(ev: WheelEvent, cellHeight: number, dpr: number): number {
- return 1;
- }
+ public restrictMouseEvent(event: ICoreMouseEvent): boolean { return true; }
+ public encodeMouseEvent(event: ICoreMouseEvent): string { return ''; }
+ public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void { }
+ public allowCustomWheelEvent(ev: WheelEvent): boolean { return true; }
}
export class MockCharsetService implements ICharsetService {
diff --git a/src/common/Types.ts b/src/common/Types.ts
index a46f47b2a..0a9c89a1a 100644
--- a/src/common/Types.ts
+++ b/src/common/Types.ts
@@ -7,12 +7,12 @@ import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
import { UnderlineStyle } from 'common/buffer/Constants';
import { IBufferSet } from 'common/buffer/Types';
import { IParams } from 'common/parser/Types';
-import { ICoreMouseService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
+import { IMouseStateService, ICoreService, IOptionsService, IUnicodeService } from 'common/services/Services';
import { IFunctionIdentifier, ITerminalOptions as IPublicTerminalOptions } from '@xterm/xterm';
import type { Emitter, IEvent } from 'common/Event';
export interface ICoreTerminal {
- coreMouseService: ICoreMouseService;
+ mouseStateService: IMouseStateService;
coreService: ICoreService;
optionsService: IOptionsService;
unicodeService: IUnicodeService;
@@ -101,13 +101,13 @@ export interface ICharset {
[key: string]: string | undefined;
}
-export type CharData = [number, string, number, number];
+export type CharData = [attr: number, char: string, width: number, code: number];
export interface IColor {
readonly css: string;
readonly rgba: number; // 32-bit int with rgba in each byte
}
-export type IColorRGB = [number, number, number];
+export type IColorRGB = [red: number, green: number, blue: number];
export interface IExtendedAttrs {
ext: number;
@@ -343,7 +343,7 @@ export interface ICoreMouseEvent {
* it is not possible to report multiple buttons at once.
* Wheel is treated as a button.
* There are invalid combinations of buttons and actions possible
- * (like move + wheel), those are silently ignored by the CoreMouseService.
+ * (like move + wheel), those are silently ignored by the MouseStateService.
*/
button: CoreMouseButton;
action: CoreMouseAction;
@@ -360,7 +360,7 @@ export interface ICoreMouseEvent {
* CoreMouseEventType
* To be reported to the browser component which events a mouse
* protocol wants to be catched and forwarded as an ICoreMouseEvent
- * to CoreMouseService.
+ * to MouseStateService.
*/
export const enum CoreMouseEventType {
NONE = 0,
@@ -378,7 +378,7 @@ export const enum CoreMouseEventType {
/**
* Mouse protocol interface.
- * A mouse protocol can be registered and activated at the CoreMouseService.
+ * A mouse protocol can be registered and activated at the MouseStateService.
* `events` should contain a list of needed events as a hint for the browser component
* to install/remove the appropriate event handlers.
* `restrict` applies further protocol specific restrictions like not allowed
@@ -391,7 +391,7 @@ export interface ICoreMouseProtocol {
/**
* CoreMouseEncoding
- * The tracking encoding can be registered and activated at the CoreMouseService.
+ * The tracking encoding can be registered and activated at the MouseStateService.
* If a ICoreMouseEvent passes all procotol restrictions it will be encoded
* with the active encoding and sent out.
* Note: Returning an empty string will supress sending a mouse report,
diff --git a/src/common/buffer/Buffer.test.ts b/src/common/buffer/Buffer.test.ts
index f9e3e1b14..9c013c5de 100644
--- a/src/common/buffer/Buffer.test.ts
+++ b/src/common/buffer/Buffer.test.ts
@@ -6,7 +6,7 @@
import { assert } from 'chai';
import { Buffer } from 'common/buffer/Buffer';
import { CircularList } from 'common/CircularList';
-import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
+import { MockOptionsService, MockBufferService, MockLogService, createCellData } from 'common/TestUtils.test';
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
import { CellData } from 'common/buffer/CellData';
import { ExtendedAttrs } from 'common/buffer/AttributeData';
@@ -1105,10 +1105,10 @@ describe('Buffer', () => {
describe ('translateBufferLineToString', () => {
it('should handle selecting a section of ascii text', () => {
const line = new BufferLine(4);
- line.setCell(0, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([ 0, 'b', 1, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([ 0, 'c', 1, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([ 0, 'd', 1, 'd'.charCodeAt(0)]));
+ line.setCell(0, createCellData(0, 'a', 1));
+ line.setCell(1, createCellData(0, 'b', 1));
+ line.setCell(2, createCellData(0, 'c', 1));
+ line.setCell(3, createCellData(0, 'd', 1));
buffer.lines.set(0, line);
const str = buffer.translateBufferLineToString(0, true, 0, 2);
@@ -1117,9 +1117,9 @@ describe('Buffer', () => {
it('should handle a cut-off double width character by including it', () => {
const line = new BufferLine(3);
- line.setCell(0, CellData.fromCharData([ 0, '語', 2, 35486 ]));
- line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
- line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
+ line.setCell(0, createCellData(0, '語', 2));
+ line.setCell(1, createCellData(0, '', 0));
+ line.setCell(2, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1128,9 +1128,9 @@ describe('Buffer', () => {
it('should handle a zero width character in the middle of the string by not including it', () => {
const line = new BufferLine(3);
- line.setCell(0, CellData.fromCharData([ 0, '語', 2, '語'.charCodeAt(0) ]));
- line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
- line.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
+ line.setCell(0, createCellData(0, '語', 2));
+ line.setCell(1, createCellData(0, '', 0));
+ line.setCell(2, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
const str0 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1145,8 +1145,8 @@ describe('Buffer', () => {
it('should handle single width emojis', () => {
const line = new BufferLine(2);
- line.setCell(0, CellData.fromCharData([ 0, '😁', 1, '😁'.charCodeAt(0) ]));
- line.setCell(1, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
+ line.setCell(0, createCellData(0, '😁', 1));
+ line.setCell(1, createCellData(0, 'a', 1));
buffer.lines.set(0, line);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1158,8 +1158,8 @@ describe('Buffer', () => {
it('should handle double width emojis', () => {
const line = new BufferLine(2);
- line.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
- line.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
+ line.setCell(0, createCellData(0, '😁', 2));
+ line.setCell(1, createCellData(0, '', 0));
buffer.lines.set(0, line);
const str1 = buffer.translateBufferLineToString(0, true, 0, 1);
@@ -1169,9 +1169,9 @@ describe('Buffer', () => {
assert.equal(str2, '😁');
const line2 = new BufferLine(3);
- line2.setCell(0, CellData.fromCharData([ 0, '😁', 2, '😁'.charCodeAt(0) ]));
- line2.setCell(1, CellData.fromCharData([ 0, '', 0, 0]));
- line2.setCell(2, CellData.fromCharData([ 0, 'a', 1, 'a'.charCodeAt(0)]));
+ line2.setCell(0, createCellData(0, '😁', 2));
+ line2.setCell(1, createCellData(0, '', 0));
+ line2.setCell(2, createCellData(0, 'a', 1));
buffer.lines.set(0, line2);
const str3 = buffer.translateBufferLineToString(0, true, 0, 3);
diff --git a/src/common/buffer/BufferLine.test.ts b/src/common/buffer/BufferLine.test.ts
index 9d08c2bef..b1c554781 100644
--- a/src/common/buffer/BufferLine.test.ts
+++ b/src/common/buffer/BufferLine.test.ts
@@ -5,9 +5,10 @@
import { NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE, DEFAULT_ATTR, Content, UnderlineStyle, BgFlags, Attributes, FgFlags } from 'common/buffer/Constants';
import { BufferLine } from 'common/buffer//BufferLine';
import { CellData } from 'common/buffer/CellData';
-import { CharData, IBufferLine } from '../Types';
+import { CharData, IBufferLine, IExtendedAttrs } from '../Types';
import { assert } from 'chai';
import { AttributeData } from 'common/buffer/AttributeData';
+import { createCellData, NULL_CELL_DATA } from 'common/TestUtils.test';
class TestBufferLine extends BufferLine {
@@ -24,6 +25,12 @@ class TestBufferLine extends BufferLine {
}
}
+function extendedAttributes(line: IBufferLine, index: number): IExtendedAttrs | undefined {
+ const cell = new CellData();
+ line.loadCell(index, cell);
+ return cell.hasExtendedAttrs() !== 0 ? cell.extended : undefined;
+}
+
describe('AttributeData', () => {
describe('extended attributes', () => {
it('hasExtendedAttrs', () => {
@@ -173,78 +180,78 @@ describe('BufferLine', function(): void {
assert.equal(line.length, 10);
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [0, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]);
assert.equal(line.isWrapped, true);
- line = new TestBufferLine(10, CellData.fromCharData([123, 'a', 456, 'a'.charCodeAt(0)]), true);
+ line = new TestBufferLine(10, createCellData(123, 'a', 456), true);
assert.equal(line.length, 10);
assert.deepEqual(line.loadCell(0, new CellData()).getAsCharData(), [123, 'a', 456, 'a'.charCodeAt(0)]);
assert.equal(line.isWrapped, true);
});
it('insertCells', function(): void {
const line = new TestBufferLine(3);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.insertCells(1, 3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.insertCells(1, 3, createCellData(4, 'd', 1));
assert.deepEqual(line.toArray(), [
- [1, 'a', 0, 'a'.charCodeAt(0)],
- [4, 'd', 0, 'd'.charCodeAt(0)],
- [4, 'd', 0, 'd'.charCodeAt(0)]
+ [1, 'a', 1, 'a'.charCodeAt(0)],
+ [4, 'd', 1, 'd'.charCodeAt(0)],
+ [4, 'd', 1, 'd'.charCodeAt(0)]
]);
});
it('deleteCells', function(): void {
const line = new TestBufferLine(5);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
- line.deleteCells(1, 2, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.setCell(3, createCellData(4, 'd', 1));
+ line.setCell(4, createCellData(5, 'e', 1));
+ line.deleteCells(1, 2, createCellData(6, 'f', 1));
assert.deepEqual(line.toArray(), [
- [1, 'a', 0, 'a'.charCodeAt(0)],
- [4, 'd', 0, 'd'.charCodeAt(0)],
- [5, 'e', 0, 'e'.charCodeAt(0)],
- [6, 'f', 0, 'f'.charCodeAt(0)],
- [6, 'f', 0, 'f'.charCodeAt(0)]
+ [1, 'a', 1, 'a'.charCodeAt(0)],
+ [4, 'd', 1, 'd'.charCodeAt(0)],
+ [5, 'e', 1, 'e'.charCodeAt(0)],
+ [6, 'f', 1, 'f'.charCodeAt(0)],
+ [6, 'f', 1, 'f'.charCodeAt(0)]
]);
});
it('replaceCells', function(): void {
const line = new TestBufferLine(5);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
- line.replaceCells(2, 4, CellData.fromCharData([6, 'f', 0, 'f'.charCodeAt(0)]));
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.setCell(3, createCellData(4, 'd', 1));
+ line.setCell(4, createCellData(5, 'e', 1));
+ line.replaceCells(2, 4, createCellData(6, 'f', 1));
assert.deepEqual(line.toArray(), [
- [1, 'a', 0, 'a'.charCodeAt(0)],
- [2, 'b', 0, 'b'.charCodeAt(0)],
- [6, 'f', 0, 'f'.charCodeAt(0)],
- [6, 'f', 0, 'f'.charCodeAt(0)],
- [5, 'e', 0, 'e'.charCodeAt(0)]
+ [1, 'a', 1, 'a'.charCodeAt(0)],
+ [2, 'b', 1, 'b'.charCodeAt(0)],
+ [6, 'f', 1, 'f'.charCodeAt(0)],
+ [6, 'f', 1, 'f'.charCodeAt(0)],
+ [5, 'e', 1, 'e'.charCodeAt(0)]
]);
});
it('fill', function(): void {
const line = new TestBufferLine(5);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
- line.fill(CellData.fromCharData([123, 'z', 0, 'z'.charCodeAt(0)]));
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.setCell(3, createCellData(4, 'd', 1));
+ line.setCell(4, createCellData(5, 'e', 1));
+ line.fill(createCellData(123, 'z', 1));
assert.deepEqual(line.toArray(), [
- [123, 'z', 0, 'z'.charCodeAt(0)],
- [123, 'z', 0, 'z'.charCodeAt(0)],
- [123, 'z', 0, 'z'.charCodeAt(0)],
- [123, 'z', 0, 'z'.charCodeAt(0)],
- [123, 'z', 0, 'z'.charCodeAt(0)]
+ [123, 'z', 1, 'z'.charCodeAt(0)],
+ [123, 'z', 1, 'z'.charCodeAt(0)],
+ [123, 'z', 1, 'z'.charCodeAt(0)],
+ [123, 'z', 1, 'z'.charCodeAt(0)],
+ [123, 'z', 1, 'z'.charCodeAt(0)]
]);
});
it('clone', function(): void {
const line = new TestBufferLine(5, undefined, true);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.setCell(3, createCellData(4, 'd', 1));
+ line.setCell(4, createCellData(5, 'e', 1));
const line2 = line.clone();
assert.deepEqual(TestBufferLine.prototype.toArray.apply(line2), line.toArray());
assert.equal(line2.length, line.length);
@@ -252,12 +259,12 @@ describe('BufferLine', function(): void {
});
it('copyFrom', function(): void {
const line = new TestBufferLine(5);
- line.setCell(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- line.setCell(1, CellData.fromCharData([2, 'b', 0, 'b'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([3, 'c', 0, 'c'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([4, 'd', 0, 'd'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([5, 'e', 0, 'e'.charCodeAt(0)]));
- const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), true);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(1, createCellData(2, 'b', 1));
+ line.setCell(2, createCellData(3, 'c', 1));
+ line.setCell(3, createCellData(4, 'd', 1));
+ line.setCell(4, createCellData(5, 'e', 1));
+ const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
line2.copyFrom(line);
assert.deepEqual(line2.toArray(), line.toArray());
assert.equal(line2.length, line.length);
@@ -267,9 +274,9 @@ describe('BufferLine', function(): void {
// CHAR_DATA_CODE_INDEX resembles current behavior in InputHandler.print
// --> set code to the last charCodeAt value of the string
// Note: needs to be fixed once the string pointer is in place
- const line = new TestBufferLine(2, CellData.fromCharData([1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]));
- assert.deepEqual(line.toArray(), [[1, 'e\u0301', 0, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 0, '\u0301'.charCodeAt(0)]]);
- const line2 = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, '\u0301'.charCodeAt(0)]), true);
+ const line = new TestBufferLine(2, createCellData(1, 'e\u0301', 1));
+ assert.deepEqual(line.toArray(), [[1, 'e\u0301', 1, '\u0301'.charCodeAt(0)], [1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]]);
+ const line2 = new TestBufferLine(5, createCellData(1, 'a', 1), true);
line2.copyFrom(line);
assert.deepEqual(line2.toArray(), line.toArray());
const line3 = line.clone();
@@ -277,72 +284,72 @@ describe('BufferLine', function(): void {
});
describe('resize', function(): void {
it('enlarge(false)', function(): void {
- const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
- line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
+ line.resize(10, createCellData(1, 'a', 1));
+ assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('enlarge(true)', function(): void {
- const line = new TestBufferLine(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
- line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(5, createCellData(1, 'a', 1), false);
+ line.resize(10, createCellData(1, 'a', 1));
+ assert.deepEqual(line.toArray(), (Array(10) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('shrink(true) - should apply new size', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
- line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
+ line.resize(5, createCellData(1, 'a', 1));
+ assert.deepEqual(line.toArray(), (Array(5) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('shrink to 0 length', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
- line.resize(0, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
- assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 0, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
+ line.resize(0, createCellData(1, 'a', 1));
+ assert.deepEqual(line.toArray(), (Array(0) as any).fill([1, 'a', 1, 'a'.charCodeAt(0)]));
});
it('should remove combining data on replaced cells after shrinking then enlarging', () => {
- const line = new TestBufferLine(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]), false);
+ const line = new TestBufferLine(10, createCellData(1, 'a', 1), false);
line.set(2, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
line.set(9, [ 0, '😁', 1, '😁'.charCodeAt(0) ]);
assert.equal(line.translateToString(), 'aa😁aaaaaa😁');
assert.equal(Object.keys(line.combined).length, 2);
- line.resize(5, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
+ line.resize(5, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa😁aa');
- line.resize(10, CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]));
+ line.resize(10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa😁aaaaaaa');
assert.equal(Object.keys(line.combined).length, 1);
});
});
describe('getTrimLength', function(): void {
it('empty line', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
assert.equal(line.getTrimmedLength(), 0);
});
it('ASCII', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, 'a', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('surrogate', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, '𝄞', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('combining', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, 'e\u0301', 1));
assert.equal(line.getTrimmedLength(), 3);
});
it('fullwidth', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, '1', 2));
+ line.setCell(3, createCellData(0, '', 0));
assert.equal(line.getTrimmedLength(), 4); // also counts null cell after fullwidth
});
});
describe('translateToString with and w\'o trimming', function(): void {
it('empty line', function(): void {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
const columns: number[] = [];
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
@@ -351,11 +358,11 @@ describe('BufferLine', function(): void {
});
it('ASCII', function(): void {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, 'a', 1));
+ line.setCell(4, createCellData(1, 'a', 1));
+ line.setCell(5, createCellData(1, 'a', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa');
@@ -372,11 +379,11 @@ describe('BufferLine', function(): void {
});
it('surrogate', function(): void {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
- line.setCell(5, CellData.fromCharData([1, '𝄞', 1, '𝄞'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, '𝄞', 1));
+ line.setCell(4, createCellData(1, '𝄞', 1));
+ line.setCell(5, createCellData(1, '𝄞', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 𝄞 𝄞𝄞 ');
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 𝄞 𝄞𝄞');
@@ -392,11 +399,11 @@ describe('BufferLine', function(): void {
});
it('combining', function(): void {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
- line.setCell(5, CellData.fromCharData([1, 'e\u0301', 1, '\u0301'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, 'e\u0301', 1));
+ line.setCell(4, createCellData(1, 'e\u0301', 1));
+ line.setCell(5, createCellData(1, 'e\u0301', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301 ');
assert.deepEqual(columns, [0, 1, 2, 2, 3, 4, 4, 5, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a e\u0301 e\u0301e\u0301');
@@ -412,14 +419,14 @@ describe('BufferLine', function(): void {
});
it('fullwidth', function(): void {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
- line.setCell(3, CellData.fromCharData([0, '', 0, 0]));
- line.setCell(5, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
- line.setCell(6, CellData.fromCharData([0, '', 0, 0]));
- line.setCell(7, CellData.fromCharData([1, '1', 2, '1'.charCodeAt(0)]));
- line.setCell(8, CellData.fromCharData([0, '', 0, 0]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, '1', 2));
+ line.setCell(3, createCellData(0, '', 0));
+ line.setCell(5, createCellData(1, '1', 2));
+ line.setCell(6, createCellData(0, '', 0));
+ line.setCell(7, createCellData(1, '1', 2));
+ line.setCell(8, createCellData(0, '', 0));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a 1 11 ');
assert.deepEqual(columns, [0, 1, 2, 4, 5, 7, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a 1 11');
@@ -441,12 +448,12 @@ describe('BufferLine', function(): void {
});
it('space at end', function(): void {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(5, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
- line.setCell(6, CellData.fromCharData([1, ' ', 1, ' '.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
+ line.setCell(2, createCellData(1, 'a', 1));
+ line.setCell(4, createCellData(1, 'a', 1));
+ line.setCell(5, createCellData(1, 'a', 1));
+ line.setCell(6, createCellData(1, ' ', 1));
assert.equal(line.translateToString(false, undefined, undefined, columns), 'a a aa ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), 'a a aa ');
@@ -457,7 +464,7 @@ describe('BufferLine', function(): void {
// sanity check - broken line with invalid out of bound null width cells
// this can atm happen with deleting/inserting chars in inputhandler by "breaking"
// fullwidth pairs --> needs to be fixed after settling BufferLine impl
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
assert.equal(line.translateToString(false, undefined, undefined, columns), ' ');
assert.deepEqual(columns, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]);
assert.equal(line.translateToString(true, undefined, undefined, columns), '');
@@ -465,15 +472,15 @@ describe('BufferLine', function(): void {
});
it('should work with endCol=0', () => {
const columns: number[] = [];
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
- line.setCell(0, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
+ line.setCell(0, createCellData(1, 'a', 1));
assert.equal(line.translateToString(true, 0, 0, columns), '');
assert.deepEqual(columns, [0]);
});
});
describe('addCharToCell', () => {
it('should set width to 1 for empty cell', () => {
- const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(3, NULL_CELL_DATA, false);
line.addCodepointToCell(0, '\u0301'.charCodeAt(0), 0);
const cell = line.loadCell(0, new CellData());
// chars contains single combining char
@@ -483,7 +490,7 @@ describe('BufferLine', function(): void {
assert.equal(cell.isCombined(), 0);
});
it('should add char to combining string in cell', () => {
- const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(3, NULL_CELL_DATA, false);
const cell = line .loadCell(0, new CellData());
cell.setFromCharData([123, 'e\u0301', 1, 'e\u0301'.charCodeAt(1)]);
line.setCell(0, cell);
@@ -496,7 +503,7 @@ describe('BufferLine', function(): void {
assert.equal(cell.isCombined(), Content.IS_COMBINED_MASK);
});
it('should create combining string on taken cell', () => {
- const line = new TestBufferLine(3, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, NULL_CELL_WIDTH, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(3, NULL_CELL_DATA, false);
const cell = line .loadCell(0, new CellData());
cell.setFromCharData([123, 'e', 1, 'e'.charCodeAt(1)]);
line.setCell(0, cell);
@@ -511,98 +518,98 @@ describe('BufferLine', function(): void {
});
describe('correct fullwidth handling', () => {
function populate(line: BufferLine): void {
- const cell = CellData.fromCharData([1, '¥', 2, '¥'.charCodeAt(0)]);
+ const cell = createCellData(1, '¥', 2);
for (let i = 0; i < line.length; i += 2) {
line.setCell(i, cell);
}
}
it('insert - wide char at pos', () => {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.insertCells(9, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(9, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), '¥¥¥¥ a');
- line.insertCells(8, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(8, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), '¥¥¥¥a ');
- line.insertCells(1, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(1, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' a ¥¥¥a');
});
it('insert - wide char at end', () => {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.insertCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(0, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa¥¥¥ ');
- line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(4, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa a ¥¥');
- line.insertCells(4, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.insertCells(4, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa aa ¥ ');
});
it('delete', () => {
- const line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ const line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.deleteCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.deleteCells(0, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥¥¥a');
- line.deleteCells(5, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.deleteCells(5, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥¥aaa');
- line.deleteCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.deleteCells(0, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' ¥¥aaaaa');
});
it('replace - start at 0', () => {
- let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ let line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 1, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 1, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'a ¥¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aa¥¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaa ¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 8, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaa¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 9, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaaa ');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(0, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(0, 10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), 'aaaaaaaaaa');
});
it('replace - start at 1', () => {
- let line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ let line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 2, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 2, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' a¥¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 3, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 3, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aa ¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 4, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 4, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaa¥¥¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 8, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 8, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaa¥');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 9, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 9, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaaa ');
- line = new TestBufferLine(10, CellData.fromCharData([DEFAULT_ATTR, NULL_CELL_CHAR, 0, NULL_CELL_CODE]), false);
+ line = new TestBufferLine(10, NULL_CELL_DATA, false);
populate(line);
- line.replaceCells(1, 10, CellData.fromCharData([1, 'a', 1, 'a'.charCodeAt(0)]));
+ line.replaceCells(1, 10, createCellData(1, 'a', 1));
assert.equal(line.translateToString(), ' aaaaaaaaa');
});
});
describe('extended attributes', () => {
it('setCells', function(): void {
const line = new TestBufferLine(5);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -612,7 +619,7 @@ describe('BufferLine', function(): void {
line.setCell(1, cell);
// same eAttr, different codepoint
- cell.content = 65; // 'A'
+ cell.content = createCellData(1, 'A', 1).content;
line.setCell(2, cell);
// different eAttr
@@ -625,25 +632,25 @@ describe('BufferLine', function(): void {
line.setCell(4, cell);
assert.deepEqual(line.toArray(), [
- [1, 'a', 0, 'a'.charCodeAt(0)],
- [1, 'a', 0, 'a'.charCodeAt(0)],
- [1, 'A', 0, 'A'.charCodeAt(0)],
- [1, 'A', 0, 'A'.charCodeAt(0)],
- [1, 'A', 0, 'A'.charCodeAt(0)]
+ [1, 'a', 1, 'a'.charCodeAt(0)],
+ [1, 'a', 1, 'a'.charCodeAt(0)],
+ [1, 'A', 1, 'A'.charCodeAt(0)],
+ [1, 'A', 1, 'A'.charCodeAt(0)],
+ [1, 'A', 1, 'A'.charCodeAt(0)]
]);
- assert.equal((line as any)._extendedAttrs[0], undefined);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
- assert.equal((line as any)._extendedAttrs[4], undefined);
+ assert.equal(extendedAttributes(line, 0), undefined);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
+ assert.equal(extendedAttributes(line, 4)?.underlineStyle, undefined);
// should be ref to the same object
- assert.equal((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[2]);
+ assert.equal(extendedAttributes(line, 1), extendedAttributes(line, 2));
// should be a different obj
- assert.notEqual((line as any)._extendedAttrs[1], (line as any)._extendedAttrs[3]);
+ assert.notEqual(extendedAttributes(line, 1), extendedAttributes(line, 3));
});
it('loadCell', () => {
const line = new TestBufferLine(5);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -686,65 +693,65 @@ describe('BufferLine', function(): void {
});
it('fill', () => {
const line = new TestBufferLine(3);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
cell.extended.underlineStyle = UnderlineStyle.CURLY;
cell.bg |= BgFlags.HAS_EXTENDED;
line.fill(cell);
- assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
});
it('insertCells', () => {
const line = new TestBufferLine(5);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
cell.extended.underlineStyle = UnderlineStyle.CURLY;
cell.bg |= BgFlags.HAS_EXTENDED;
line.insertCells(1, 3, cell);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[4], undefined);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 4), undefined);
cell.extended = cell.extended.clone();
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
line.insertCells(2, 2, cell);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOTTED);
- assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOTTED);
- assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOTTED);
+ assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOTTED);
+ assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
});
it('deleteCells', () => {
const line = new TestBufferLine(5);
- const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const fillCell = createCellData(1, 'a', 1);
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
fillCell.bg |= BgFlags.HAS_EXTENDED;
line.fill(fillCell);
fillCell.extended = fillCell.extended.clone();
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
line.deleteCells(1, 3, fillCell);
- assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
- assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.DOUBLE);
- assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.DOUBLE);
+ assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
+ assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.DOUBLE);
+ assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.DOUBLE);
});
it('replaceCells', () => {
const line = new TestBufferLine(5);
- const fillCell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const fillCell = createCellData(1, 'a', 1);
fillCell.extended.underlineStyle = UnderlineStyle.CURLY;
fillCell.bg |= BgFlags.HAS_EXTENDED;
line.fill(fillCell);
fillCell.extended = fillCell.extended.clone();
fillCell.extended.underlineStyle = UnderlineStyle.DOUBLE;
line.replaceCells(1, 3, fillCell);
- assert.equal((line as any)._extendedAttrs[0].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[1].underlineStyle, UnderlineStyle.DOUBLE);
- assert.equal((line as any)._extendedAttrs[2].underlineStyle, UnderlineStyle.DOUBLE);
- assert.equal((line as any)._extendedAttrs[3].underlineStyle, UnderlineStyle.CURLY);
- assert.equal((line as any)._extendedAttrs[4].underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 0)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 1)?.underlineStyle, UnderlineStyle.DOUBLE);
+ assert.equal(extendedAttributes(line, 2)?.underlineStyle, UnderlineStyle.DOUBLE);
+ assert.equal(extendedAttributes(line, 3)?.underlineStyle, UnderlineStyle.CURLY);
+ assert.equal(extendedAttributes(line, 4)?.underlineStyle, UnderlineStyle.CURLY);
});
it('clone', () => {
const line = new TestBufferLine(5);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
// no eAttrs
line.setCell(0, cell);
@@ -767,15 +774,15 @@ describe('BufferLine', function(): void {
line.setCell(4, cell);
const nLine = line.clone();
- assert.equal((nLine as any)._extendedAttrs[0], (line as any)._extendedAttrs[0]);
- assert.equal((nLine as any)._extendedAttrs[1], (line as any)._extendedAttrs[1]);
- assert.equal((nLine as any)._extendedAttrs[2], (line as any)._extendedAttrs[2]);
- assert.equal((nLine as any)._extendedAttrs[3], (line as any)._extendedAttrs[3]);
- assert.equal((nLine as any)._extendedAttrs[4], (line as any)._extendedAttrs[4]);
+ assert.equal(extendedAttributes(nLine, 0), extendedAttributes(line, 0));
+ assert.equal(extendedAttributes(nLine, 1), extendedAttributes(line, 1));
+ assert.equal(extendedAttributes(nLine, 2), extendedAttributes(line, 2));
+ assert.equal(extendedAttributes(nLine, 3), extendedAttributes(line, 3));
+ assert.equal(extendedAttributes(nLine, 4), extendedAttributes(line, 4));
});
it('copyFrom', () => {
const initial = new TestBufferLine(5);
- const cell = CellData.fromCharData([1, 'a', 0, 'a'.charCodeAt(0)]);
+ const cell = createCellData(1, 'a', 1);
// no eAttrs
initial.setCell(0, cell);
@@ -798,13 +805,13 @@ describe('BufferLine', function(): void {
initial.setCell(4, cell);
const line = new TestBufferLine(5);
- line.fill(CellData.fromCharData([1, 'b', 0, 'b'.charCodeAt(0)]));
+ line.fill(createCellData(1, 'b', 1));
line.copyFrom(initial);
- assert.equal((line as any)._extendedAttrs[0], (initial as any)._extendedAttrs[0]);
- assert.equal((line as any)._extendedAttrs[1], (initial as any)._extendedAttrs[1]);
- assert.equal((line as any)._extendedAttrs[2], (initial as any)._extendedAttrs[2]);
- assert.equal((line as any)._extendedAttrs[3], (initial as any)._extendedAttrs[3]);
- assert.equal((line as any)._extendedAttrs[4], (initial as any)._extendedAttrs[4]);
+ assert.equal(extendedAttributes(line, 0), extendedAttributes(initial, 0));
+ assert.equal(extendedAttributes(line, 1), extendedAttributes(initial, 1));
+ assert.equal(extendedAttributes(line, 2), extendedAttributes(initial, 2));
+ assert.equal(extendedAttributes(line, 3), extendedAttributes(initial, 3));
+ assert.equal(extendedAttributes(line, 4), extendedAttributes(initial, 4));
});
});
});
diff --git a/src/common/input/Keyboard.ts b/src/common/input/Keyboard.ts
index 320854677..545b436c8 100644
--- a/src/common/input/Keyboard.ts
+++ b/src/common/input/Keyboard.ts
@@ -99,7 +99,13 @@ export function evaluateKeyboardEvent(
break;
case 13:
// return/enter
- result.key = ev.altKey ? C0.ESC + C0.CR : C0.CR;
+ if (ev.key === 'c' && ev.ctrlKey) {
+ // HACK: Safari on iPad, iOS, AppleVisionPro sends key 13 when typing ctrl-c on hardware
+ // keyboard
+ result.key = C0.ETX;
+ } else {
+ result.key = ev.altKey ? C0.ESC + C0.CR : C0.CR;
+ }
result.cancel = true;
break;
case 27:
diff --git a/src/common/input/KittyKeyboard.test.ts b/src/common/input/KittyKeyboard.test.ts
index e495ac591..a0422ecb1 100644
--- a/src/common/input/KittyKeyboard.test.ts
+++ b/src/common/input/KittyKeyboard.test.ts
@@ -100,24 +100,24 @@ describe('KittyKeyboard', () => {
assert.strictEqual(result.key, '\x1b[27u');
});
- it('Enter → CSI 13 u', () => {
+ it('Enter → legacy \\r', () => {
const result = kitty.evaluate(createEvent({ key: 'Enter' }), flags);
- assert.strictEqual(result.key, '\x1b[13u');
+ assert.strictEqual(result.key, '\r');
});
- it('Tab → CSI 9 u', () => {
+ it('Tab → legacy \\t', () => {
const result = kitty.evaluate(createEvent({ key: 'Tab' }), flags);
- assert.strictEqual(result.key, '\x1b[9u');
+ assert.strictEqual(result.key, '\t');
});
- it('Backspace → CSI 127 u', () => {
+ it('Backspace → legacy \\x7f', () => {
const result = kitty.evaluate(createEvent({ key: 'Backspace' }), flags);
- assert.strictEqual(result.key, '\x1b[127u');
+ assert.strictEqual(result.key, '\x7f');
});
- it('Space → CSI 32 u', () => {
+ it('Space → plain space (text-generating key)', () => {
const result = kitty.evaluate(createEvent({ key: ' ' }), flags);
- assert.strictEqual(result.key, '\x1b[32u');
+ assert.strictEqual(result.key, ' ');
});
it('Shift+Tab → CSI 9;2 u', () => {
@@ -134,6 +134,21 @@ describe('KittyKeyboard', () => {
const result = kitty.evaluate(createEvent({ key: 'Escape', altKey: true }), flags);
assert.strictEqual(result.key, '\x1b[27;3u');
});
+
+ it('Ctrl+Backspace → CSI 127;5 u', () => {
+ const result = kitty.evaluate(createEvent({ key: 'Backspace', ctrlKey: true }), flags);
+ assert.strictEqual(result.key, '\x1b[127;5u');
+ });
+
+ it('Ctrl+Space → CSI 32;5 u', () => {
+ const result = kitty.evaluate(createEvent({ key: ' ', ctrlKey: true }), flags);
+ assert.strictEqual(result.key, '\x1b[32;5u');
+ });
+
+ it('Alt+Space → CSI 32;3 u', () => {
+ const result = kitty.evaluate(createEvent({ key: ' ', altKey: true }), flags);
+ assert.strictEqual(result.key, '\x1b[32;3u');
+ });
});
describe('navigation keys', () => {
diff --git a/src/common/input/KittyKeyboard.ts b/src/common/input/KittyKeyboard.ts
index b296695c5..76c468cc4 100644
--- a/src/common/input/KittyKeyboard.ts
+++ b/src/common/input/KittyKeyboard.ts
@@ -457,9 +457,11 @@ export class KittyKeyboard {
} else if (reportEventTypes) {
useCsiU = true;
} else if (flags & KittyKeyboardFlags.DISAMBIGUATE_ESCAPE_CODES) {
- if (keyCode === 27 || keyCode === 127 || keyCode === 13 || keyCode === 9 || keyCode === 32) {
- useCsiU = true;
- } else if (isFunc) {
+ // Per spec, Enter/Tab/Backspace "still generate the same bytes as in legacy
+ // mode" and consider space to be a text-generating key, so these skip the isFunc fast-path
+ // and only get CSI u when modifiers are present (handled below).
+ const isDisambiguateLegacy = keyCode === 13 || keyCode === 9 || keyCode === 127;
+ if (isFunc && !isDisambiguateLegacy) {
useCsiU = true;
} else if (modifiers > 0) {
if (ev.shiftKey && !ev.ctrlKey && !ev.altKey && !ev.metaKey && ev.key.length === 1) {
@@ -474,7 +476,10 @@ export class KittyKeyboard {
result.key = this._buildCsiUSequence(ev, keyCode, modifiers, eventType, flags, isFunc, isMod);
result.cancel = true;
} else {
- if (ev.key.length === 1 && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
+ const legacyByte = keyCode === 13 ? '\r' : keyCode === 9 ? '\t' : keyCode === 127 ? '\x7f' : undefined;
+ if (legacyByte) {
+ result.key = legacyByte;
+ } else if (ev.key.length === 1 && !ev.ctrlKey && !ev.altKey && !ev.metaKey) {
result.key = ev.key;
}
}
diff --git a/src/common/services/CoreMouseService.test.ts b/src/common/services/CoreMouseService.test.ts
deleted file mode 100644
index 3b79596d2..000000000
--- a/src/common/services/CoreMouseService.test.ts
+++ /dev/null
@@ -1,212 +0,0 @@
-/**
- * Copyright (c) 2019 The xterm.js authors. All rights reserved.
- * @license MIT
- */
-import { CoreMouseService } from 'common/services/CoreMouseService';
-import { MockCoreService, MockBufferService, MockOptionsService } from 'common/TestUtils.test';
-import { assert } from 'chai';
-import { ICoreMouseEvent, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types';
-
-// needed mock services
-const bufferService = new MockBufferService(300, 100);
-const coreService = new MockCoreService();
-const optionsService = new MockOptionsService();
-
-function toBytes(s: string | undefined): number[] {
- if (!s) {
- return [];
- }
- const res: number[] = [];
- for (let i = 0; i < s.length; ++i) {
- res.push(s.charCodeAt(i));
- }
- return res;
-}
-
-describe('CoreMouseService', () => {
- it('init', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- assert.equal(cms.activeEncoding, 'DEFAULT');
- assert.equal(cms.activeProtocol, 'NONE');
- });
- it('default protocols - NONE, X10, VT200, DRAG, ANY', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- assert.deepEqual(Object.keys((cms as any)._protocols), ['NONE', 'X10', 'VT200', 'DRAG', 'ANY']);
- });
- it('default encodings - DEFAULT, SGR', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- assert.deepEqual(Object.keys((cms as any)._encodings), ['DEFAULT', 'SGR', 'SGR_PIXELS']);
- });
- it('protocol/encoding setter, reset', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- cms.activeEncoding = 'SGR';
- cms.activeProtocol = 'ANY';
- assert.equal(cms.activeEncoding, 'SGR');
- assert.equal(cms.activeProtocol, 'ANY');
- cms.reset();
- assert.equal(cms.activeEncoding, 'DEFAULT');
- assert.equal(cms.activeProtocol, 'NONE');
- assert.throws(() => { cms.activeEncoding = 'xyz'; }, 'unknown encoding "xyz"');
- assert.throws(() => { cms.activeProtocol = 'xyz'; }, 'unknown protocol "xyz"');
- });
- it('addEncoding', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- cms.addEncoding('XYZ', (e: ICoreMouseEvent) => '');
- cms.activeEncoding = 'XYZ';
- assert.equal(cms.activeEncoding, 'XYZ');
- });
- it('addProtocol', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- cms.addProtocol('XYZ', { events: CoreMouseEventType.NONE, restrict: (e: ICoreMouseEvent) => false });
- cms.activeProtocol = 'XYZ';
- assert.equal(cms.activeProtocol, 'XYZ');
- });
- it('onProtocolChange', () => {
- const cms = new CoreMouseService(bufferService, coreService, optionsService);
- const wantedEvents: CoreMouseEventType[] = [];
- cms.onProtocolChange(events => wantedEvents.push(events));
- cms.activeProtocol = 'NONE';
- assert.deepEqual(wantedEvents, [CoreMouseEventType.NONE]);
- cms.activeProtocol = 'ANY';
- assert.deepEqual(wantedEvents, [
- CoreMouseEventType.NONE,
- CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL | CoreMouseEventType.DRAG | CoreMouseEventType.MOVE
- ]);
- });
- describe('triggerMouseEvent', () => {
- let cms: CoreMouseService;
- let reports: string[];
- beforeEach(() => {
- cms = new CoreMouseService(bufferService, coreService, optionsService);
- reports = [];
- coreService.triggerDataEvent = (data: string, userInput?: boolean) => reports.push(data);
- coreService.triggerBinaryEvent = (data: string) => reports.push(data);
- });
- it('NONE', () => {
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
- });
- it('X10', () => {
- cms.activeProtocol = 'X10';
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
- });
- it('VT200', () => {
- cms.activeProtocol = 'VT200';
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), false);
- });
- it('DRAG', () => {
- cms.activeProtocol = 'DRAG';
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
- });
- it('ANY', () => {
- cms.activeProtocol = 'ANY';
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE }), true);
- // should not report in any case
- // invalid button + action combinations
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.MOVE }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.UP }), false);
- // invalid coords
- assert.equal(cms.triggerMouseEvent({ col: -1, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 500, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: -1, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 500, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), false);
- });
- describe('coords', () => {
- it('DEFAULT encoding', () => {
- cms.activeProtocol = 'ANY';
- for (let i = 0; i < bufferService.cols; ++i) {
- assert.equal(cms.triggerMouseEvent({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- if (i > 222) {
- // supress mouse reports if we are out of addressible range (max. 222)
- assert.deepEqual(toBytes(reports.pop()), []);
- } else {
- assert.deepEqual(toBytes(reports.pop()), [0x1b, 0x5b, 0x4d, 0x20, i + 33, 0x21]);
- }
- }
- });
- it('SGR encoding', () => {
- cms.activeProtocol = 'ANY';
- cms.activeEncoding = 'SGR';
- for (let i = 0; i < bufferService.cols; ++i) {
- assert.equal(cms.triggerMouseEvent({ col: i, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.deepEqual(reports.pop(), `\x1b[<0;${i + 1};1M`);
- }
- });
- it('SGR_PIXELS encoding', () => {
- cms.activeProtocol = 'ANY';
- cms.activeEncoding = 'SGR_PIXELS';
- for (let i = 0; i < 500; ++i) {
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: i, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN }), true);
- assert.deepEqual(reports.pop(), `\x1b[<0;${i};0M`);
- }
- });
- });
- it('eventCodes with modifiers (DEFAULT encoding)', () => {
- // TODO: implement AUX button tests
- cms.activeProtocol = 'ANY';
- cms.activeEncoding = 'DEFAULT';
- // all buttons + down + no modifer
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.DOWN, ctrl: false, alt: false, shift: false }), true);
- assert.deepEqual(reports, ['\x1b[M !!', '\x1b[M!!!', '\x1b[M"!!', '\x1b[Ma!!']);
- reports = [];
-
- // all buttons + up + no modifier
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.WHEEL, action: CoreMouseAction.UP, ctrl: false, alt: false, shift: false }), true);
- assert.deepEqual(reports, ['\x1b[M#!!', '\x1b[M#!!', '\x1b[M#!!', '\x1b[M`!!']);
- reports = [];
-
- // all buttons + move + no modifier
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.LEFT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.MIDDLE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.RIGHT, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: false }), true);
- assert.deepEqual(reports, ['\x1b[M@!!', '\x1b[MA!!', '\x1b[MB!!', '\x1b[MC!!']);
- reports = [];
-
- // button none + move + modifiers
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: false, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: false, shift: true }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: false }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: false, alt: true, shift: true }), true);
- assert.equal(cms.triggerMouseEvent({ col: 0, row: 0, x: 0, y: 0, button: CoreMouseButton.NONE, action: CoreMouseAction.MOVE, ctrl: true, alt: true, shift: true }), true);
- assert.deepEqual(reports, ['\x1b[MS!!', '\x1b[MK!!', '\x1b[MG!!', '\x1b[M[!!', '\x1b[MO!!', '\x1b[M_!!']);
- reports = [];
- });
- });
-});
diff --git a/src/common/services/MouseStateService.test.ts b/src/common/services/MouseStateService.test.ts
new file mode 100644
index 000000000..0f2cd489a
--- /dev/null
+++ b/src/common/services/MouseStateService.test.ts
@@ -0,0 +1,88 @@
+/**
+ * Copyright (c) 2019 The xterm.js authors. All rights reserved.
+ * @license MIT
+ */
+import { MouseStateService } from 'common/services/MouseStateService';
+import { assert } from 'chai';
+import { ICoreMouseEvent, CoreMouseEventType } from 'common/Types';
+
+function toBytes(s: string | undefined): number[] {
+ if (!s) {
+ return [];
+ }
+ const res: number[] = [];
+ for (let i = 0; i < s.length; ++i) {
+ res.push(s.charCodeAt(i));
+ }
+ return res;
+}
+
+describe('MouseStateService', () => {
+ it('init', () => {
+ const cms = new MouseStateService();
+ assert.equal(cms.activeEncoding, 'DEFAULT');
+ assert.equal(cms.activeProtocol, 'NONE');
+ });
+ it('default protocols - NONE, X10, VT200, DRAG, ANY', () => {
+ const cms = new MouseStateService();
+ assert.deepEqual(Object.keys((cms as any)._protocols), ['NONE', 'X10', 'VT200', 'DRAG', 'ANY']);
+ });
+ it('default encodings - DEFAULT, SGR', () => {
+ const cms = new MouseStateService();
+ assert.deepEqual(Object.keys((cms as any)._encodings), ['DEFAULT', 'SGR', 'SGR_PIXELS']);
+ });
+ it('protocol/encoding setter, reset', () => {
+ const cms = new MouseStateService();
+ cms.activeEncoding = 'SGR';
+ cms.activeProtocol = 'ANY';
+ assert.equal(cms.activeEncoding, 'SGR');
+ assert.equal(cms.activeProtocol, 'ANY');
+ cms.reset();
+ assert.equal(cms.activeEncoding, 'DEFAULT');
+ assert.equal(cms.activeProtocol, 'NONE');
+ assert.throws(() => { cms.activeEncoding = 'xyz'; }, 'unknown encoding "xyz"');
+ assert.throws(() => { cms.activeProtocol = 'xyz'; }, 'unknown protocol "xyz"');
+ });
+ it('addEncoding', () => {
+ const cms = new MouseStateService();
+ cms.addEncoding('XYZ', (e: ICoreMouseEvent) => '');
+ cms.activeEncoding = 'XYZ';
+ assert.equal(cms.activeEncoding, 'XYZ');
+ });
+ it('addProtocol', () => {
+ const cms = new MouseStateService();
+ cms.addProtocol('XYZ', { events: CoreMouseEventType.NONE, restrict: (e: ICoreMouseEvent) => false });
+ cms.activeProtocol = 'XYZ';
+ assert.equal(cms.activeProtocol, 'XYZ');
+ });
+ it('onProtocolChange', () => {
+ const cms = new MouseStateService();
+ const wantedEvents: CoreMouseEventType[] = [];
+ cms.onProtocolChange(events => wantedEvents.push(events));
+ cms.activeProtocol = 'NONE';
+ assert.deepEqual(wantedEvents, [CoreMouseEventType.NONE]);
+ cms.activeProtocol = 'ANY';
+ assert.deepEqual(wantedEvents, [
+ CoreMouseEventType.NONE,
+ CoreMouseEventType.DOWN | CoreMouseEventType.UP | CoreMouseEventType.WHEEL | CoreMouseEventType.DRAG | CoreMouseEventType.MOVE
+ ]);
+ });
+ it('restrictMouseEvent/encodeMouseEvent', () => {
+ const cms = new MouseStateService();
+ const event: ICoreMouseEvent = {
+ col: 1,
+ row: 1,
+ x: 0,
+ y: 0,
+ button: 0,
+ action: 1,
+ ctrl: false,
+ alt: false,
+ shift: false
+ };
+ cms.activeProtocol = 'ANY';
+ cms.activeEncoding = 'DEFAULT';
+ assert.equal(cms.restrictMouseEvent(event), true);
+ assert.deepEqual(toBytes(cms.encodeMouseEvent(event)), [0x1b, 0x5b, 0x4d, 0x20, 0x21, 0x21]);
+ });
+});
diff --git a/src/common/services/CoreMouseService.ts b/src/common/services/MouseStateService.ts
similarity index 57%
rename from src/common/services/CoreMouseService.ts
rename to src/common/services/MouseStateService.ts
index d569f0460..21420460a 100644
--- a/src/common/services/CoreMouseService.ts
+++ b/src/common/services/MouseStateService.ts
@@ -2,7 +2,7 @@
* Copyright (c) 2019 The xterm.js authors. All rights reserved.
* @license MIT
*/
-import { IBufferService, ICoreService, ICoreMouseService, IOptionsService } from 'common/services/Services';
+import { IMouseStateService } from 'common/services/Services';
import { ICoreMouseProtocol, ICoreMouseEvent, CoreMouseEncoding, CoreMouseEventType, CoreMouseButton, CoreMouseAction } from 'common/Types';
import { Disposable } from 'common/Lifecycle';
import { Emitter } from 'common/Event';
@@ -151,7 +151,7 @@ const DEFAULT_ENCODINGS: { [key: string]: CoreMouseEncoding } = {
};
/**
- * CoreMouseService
+ * MouseStateService
*
* Provides mouse tracking reports with different protocols and encodings.
* - protocols: NONE (default), X10, VT200, DRAG, ANY
@@ -166,25 +166,21 @@ const DEFAULT_ENCODINGS: { [key: string]: CoreMouseEncoding } = {
* a tracking report to the backend based on protocol and encoding limitations.
* To send a mouse event call `triggerMouseEvent`.
*/
-export class CoreMouseService extends Disposable implements ICoreMouseService {
+export class MouseStateService extends Disposable implements IMouseStateService {
public serviceBrand: any;
private _protocols: { [name: string]: ICoreMouseProtocol } = {};
private _encodings: { [name: string]: CoreMouseEncoding } = {};
private _activeProtocol: string = '';
private _activeEncoding: string = '';
- private _lastEvent: ICoreMouseEvent | null = null;
- private _wheelPartialScroll: number = 0;
+ private _customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined;
private readonly _onProtocolChange = this._register(new Emitter());
public readonly onProtocolChange = this._onProtocolChange.event;
- constructor(
- @IBufferService private readonly _bufferService: IBufferService,
- @ICoreService private readonly _coreService: ICoreService,
- @IOptionsService private readonly _optionsService: IOptionsService
- ) {
+ constructor() {
super();
+
// register default protocols and encodings
for (const name of Object.keys(DEFAULT_PROTOCOLS)) this.addProtocol(name, DEFAULT_PROTOCOLS[name]);
for (const name of Object.keys(DEFAULT_ENCODINGS)) this.addEncoding(name, DEFAULT_ENCODINGS[name]);
@@ -230,136 +226,29 @@ export class CoreMouseService extends Disposable implements ICoreMouseService {
public reset(): void {
this.activeProtocol = 'NONE';
this.activeEncoding = 'DEFAULT';
- this._lastEvent = null;
- this._wheelPartialScroll = 0;
}
- /**
- * Processes a wheel event, accounting for partial scrolls for trackpad, mouse scrolls.
- * This prevents hyper-sensitive scrolling in alt buffer.
- */
- public consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number {
- // Do nothing if it's not a vertical scroll event
- if (ev.deltaY === 0 || ev.shiftKey) {
- return 0;
- }
-
- if (cellHeight === undefined || dpr === undefined) {
- return 0;
- }
-
- const targetWheelEventPixels = cellHeight / dpr;
- let amount = this._applyScrollModifier(ev.deltaY, ev);
-
- if (ev.deltaMode === WheelEvent.DOM_DELTA_PIXEL) {
- amount /= (targetWheelEventPixels + 0.0); // Prevent integer division
-
- const isLikelyTrackpad = Math.abs(ev.deltaY) < 50;
- if (isLikelyTrackpad) {
- amount *= 0.3;
- }
-
- this._wheelPartialScroll += amount;
- amount = Math.floor(Math.abs(this._wheelPartialScroll)) * (this._wheelPartialScroll > 0 ? 1 : -1);
- this._wheelPartialScroll %= 1;
- } else if (ev.deltaMode === WheelEvent.DOM_DELTA_PAGE) {
- amount *= this._bufferService.rows;
- }
- return amount;
+ public setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void {
+ this._customWheelEventHandler = customWheelEventHandler;
}
- private _applyScrollModifier(amount: number, ev: WheelEvent): number {
- // Multiply the scroll speed when the modifier key is pressed
- if (ev.altKey || ev.ctrlKey || ev.shiftKey) {
- return amount * this._optionsService.rawOptions.fastScrollSensitivity * this._optionsService.rawOptions.scrollSensitivity;
- }
- return amount * this._optionsService.rawOptions.scrollSensitivity;
+ public allowCustomWheelEvent(ev: WheelEvent): boolean {
+ return this._customWheelEventHandler ? this._customWheelEventHandler(ev) !== false : true;
}
- /**
- * Triggers a mouse event to be sent.
- *
- * Returns true if the event passed all protocol restrictions and a report
- * was sent, otherwise false. The return value may be used to decide whether
- * the default event action in the bowser component should be omitted.
- *
- * Note: The method will change values of the given event object
- * to fullfill protocol and encoding restrictions.
- */
- public triggerMouseEvent(e: ICoreMouseEvent): boolean {
- // range check for col/row
- if (e.col < 0 || e.col >= this._bufferService.cols
- || e.row < 0 || e.row >= this._bufferService.rows) {
- return false;
- }
-
- // filter nonsense combinations of button + action
- if (e.button === CoreMouseButton.WHEEL && e.action === CoreMouseAction.MOVE) {
- return false;
- }
- if (e.button === CoreMouseButton.NONE && e.action !== CoreMouseAction.MOVE) {
- return false;
- }
- if (e.button !== CoreMouseButton.WHEEL && (e.action === CoreMouseAction.LEFT || e.action === CoreMouseAction.RIGHT)) {
- return false;
- }
-
- // report 1-based coords
- e.col++;
- e.row++;
-
- // debounce move events at grid or pixel level
- if (e.action === CoreMouseAction.MOVE
- && this._lastEvent
- && this._equalEvents(this._lastEvent, e, this._activeEncoding === 'SGR_PIXELS')
- ) {
- return false;
- }
-
- // apply protocol restrictions
- if (!this._protocols[this._activeProtocol].restrict(e)) {
- return false;
- }
-
- // encode report and send
- const report = this._encodings[this._activeEncoding](e);
- if (report) {
- // always send DEFAULT as binary data
- if (this._activeEncoding === 'DEFAULT') {
- this._coreService.triggerBinaryEvent(report);
- } else {
- this._coreService.triggerDataEvent(report, true);
- }
- }
-
- this._lastEvent = e;
-
- return true;
+ public restrictMouseEvent(e: ICoreMouseEvent): boolean {
+ return this._protocols[this._activeProtocol].restrict(e);
}
- public explainEvents(events: CoreMouseEventType): { [event: string]: boolean } {
- return {
- down: !!(events & CoreMouseEventType.DOWN),
- up: !!(events & CoreMouseEventType.UP),
- drag: !!(events & CoreMouseEventType.DRAG),
- move: !!(events & CoreMouseEventType.MOVE),
- wheel: !!(events & CoreMouseEventType.WHEEL)
- };
+ public encodeMouseEvent(e: ICoreMouseEvent): string {
+ return this._encodings[this._activeEncoding](e);
}
- private _equalEvents(e1: ICoreMouseEvent, e2: ICoreMouseEvent, pixels: boolean): boolean {
- if (pixels) {
- if (e1.x !== e2.x) return false;
- if (e1.y !== e2.y) return false;
- } else {
- if (e1.col !== e2.col) return false;
- if (e1.row !== e2.row) return false;
- }
- if (e1.button !== e2.button) return false;
- if (e1.action !== e2.action) return false;
- if (e1.ctrl !== e2.ctrl) return false;
- if (e1.alt !== e2.alt) return false;
- if (e1.shift !== e2.shift) return false;
- return true;
+ public get isDefaultEncoding(): boolean {
+ return this._activeEncoding === 'DEFAULT';
+ }
+
+ public get isPixelEncoding(): boolean {
+ return this._activeEncoding === 'SGR_PIXELS';
}
}
diff --git a/src/common/services/Services.ts b/src/common/services/Services.ts
index b40f16fd2..a15dc23dc 100644
--- a/src/common/services/Services.ts
+++ b/src/common/services/Services.ts
@@ -3,7 +3,7 @@
* @license MIT
*/
-import { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, type IOverviewRulerOptions } from '@xterm/xterm';
+import type { IDecoration, IDecorationOptions, ILinkHandler, ILogger, IWindowsPty, IOverviewRulerOptions } from '@xterm/xterm';
import { CoreMouseEncoding, CoreMouseEventType, CursorInactiveStyle, CursorStyle, IAttributeData, ICharset, IColor, ICoreMouseEvent, ICoreMouseProtocol, IDecPrivateModes, IDisposable, IKittyKeyboardState, IModes, IOscLinkData, IWindowOptions } from 'common/Types';
import { IBuffer, IBufferSet } from 'common/buffer/Types';
import { createDecorator } from 'common/services/ServiceRegistry';
@@ -33,8 +33,8 @@ export interface IBufferResizeEvent {
rowsChanged: boolean;
}
-export const ICoreMouseService = createDecorator('CoreMouseService');
-export interface ICoreMouseService {
+export const IMouseStateService = createDecorator('MouseStateService');
+export interface IMouseStateService {
serviceBrand: undefined;
activeProtocol: string;
@@ -43,33 +43,17 @@ export interface ICoreMouseService {
addProtocol(name: string, protocol: ICoreMouseProtocol): void;
addEncoding(name: string, encoding: CoreMouseEncoding): void;
reset(): void;
-
- /**
- * Triggers a mouse event to be sent.
- *
- * Returns true if the event passed all protocol restrictions and a report
- * was sent, otherwise false. The return value may be used to decide whether
- * the default event action in the bowser component should be omitted.
- *
- * Note: The method will change values of the given event object
- * to fullfill protocol and encoding restrictions.
- */
- triggerMouseEvent(event: ICoreMouseEvent): boolean;
+ setCustomWheelEventHandler(customWheelEventHandler: ((event: WheelEvent) => boolean) | undefined): void;
+ allowCustomWheelEvent(ev: WheelEvent): boolean;
/**
* Event to announce changes in mouse tracking.
*/
onProtocolChange: IEvent;
-
- /**
- * Human readable version of mouse events.
- */
- explainEvents(events: CoreMouseEventType): { [event: string]: boolean };
-
- /**
- * Process wheel event taking partial scroll into account.
- */
- consumeWheelEvent(ev: WheelEvent, cellHeight?: number, dpr?: number): number;
+ restrictMouseEvent(event: ICoreMouseEvent): boolean;
+ encodeMouseEvent(event: ICoreMouseEvent): string;
+ readonly isDefaultEncoding: boolean;
+ readonly isPixelEncoding: boolean;
}
export const ICoreService = createDecorator('CoreService');
diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json
index 28f5205ca..22501b9cf 100644
--- a/src/common/tsconfig.json
+++ b/src/common/tsconfig.json
@@ -9,9 +9,9 @@
"types": [
"../../node_modules/@types/mocha"
],
- "baseUrl": "..",
"paths": {
- "common/*": [ "./common/*" ]
+ "common/*": [ "./../common/*" ],
+ "*": [ "./../*" ]
}
},
"include": [
diff --git a/src/headless/public/Terminal.ts b/src/headless/public/Terminal.ts
index 726149338..0d4e6e93b 100644
--- a/src/headless/public/Terminal.ts
+++ b/src/headless/public/Terminal.ts
@@ -103,7 +103,7 @@ export class Terminal extends Disposable implements ITerminalApi {
public get modes(): IModes {
const m = this._core.coreService.decPrivateModes;
let mouseTrackingMode: 'none' | 'x10' | 'vt200' | 'drag' | 'any' = 'none';
- switch (this._core.coreMouseService.activeProtocol) {
+ switch (this._core.mouseStateService.activeProtocol) {
case 'X10': mouseTrackingMode = 'x10'; break;
case 'VT200': mouseTrackingMode = 'vt200'; break;
case 'DRAG': mouseTrackingMode = 'drag'; break;
diff --git a/src/headless/tsconfig.json b/src/headless/tsconfig.json
index e48c4fd5b..6ae7b1dc9 100644
--- a/src/headless/tsconfig.json
+++ b/src/headless/tsconfig.json
@@ -10,9 +10,9 @@
"../../node_modules/@types/mocha",
"../../node_modules/@types/node"
],
- "baseUrl": "../",
"paths": {
- "common/*": [ "./common/*" ]
+ "common/*": [ "./../common/*" ],
+ "*": [ "./../*" ]
}
},
"include": [
diff --git a/test/benchmark/tsconfig.json b/test/benchmark/tsconfig.json
index 8d1736df7..2f24f0762 100644
--- a/test/benchmark/tsconfig.json
+++ b/test/benchmark/tsconfig.json
@@ -12,7 +12,6 @@
"strict": false,
"target": "es2021",
"module": "commonjs",
- "baseUrl": ".",
"paths": {
"common/*": [ "../../src/common/*" ],
"browser/*": [ "../../src/browser/*" ],
diff --git a/test/playwright/InputHandler.test.ts b/test/playwright/InputHandler.test.ts
index a62d1fbcc..eec2938a8 100644
--- a/test/playwright/InputHandler.test.ts
+++ b/test/playwright/InputHandler.test.ts
@@ -584,13 +584,13 @@ test.describe('InputHandler Integration Tests', () => {
});
test('Ps = 1 0 0 5 - Enable UTF-8 Mouse Mode, xterm', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
await ctx.proxy.write('\x1b[?1005h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
});
test('Ps = 1 0 0 6 - Enable SGR Mouse Mode, xterm', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
});
test('Ps = 1 0 0 7 - Enable Alternate Scroll Mode, xterm', async () => {
await assertNoModeChange('\x1b[?1007h');
@@ -603,13 +603,13 @@ test.describe('InputHandler Integration Tests', () => {
});
test('Ps = 1 0 1 5 - Enable urxvt Mouse Mode', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
await ctx.proxy.write('\x1b[?1015h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
});
test('Ps = 1 0 1 6 - Enable SGR Mouse PixelMode, xterm', async () => {
await ctx.proxy.write('\x1b[?1016h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR_PIXELS');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR_PIXELS');
});
test('Ps = 1 0 3 4 - Interpret "meta" key, xterm', async () => {
await assertNoModeChange('\x1b[?1034h');
@@ -916,15 +916,15 @@ test.describe('InputHandler Integration Tests', () => {
});
test('Ps = 1 0 0 5 - Disable UTF-8 Mouse Mode, xterm.', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
await ctx.proxy.write('\x1b[?1005l');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
});
test('Ps = 1 0 0 6 - Disable SGR Mouse Mode, xterm.', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
await ctx.proxy.write('\x1b[?1006l');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'DEFAULT');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'DEFAULT');
});
test('Ps = 1 0 0 7 - Disable Alternate Scroll Mode, xterm. This corresponds to the alternateScroll resource.', async () => {
await assertNoModeChange('\x1b[?1007l');
@@ -937,15 +937,15 @@ test.describe('InputHandler Integration Tests', () => {
});
test('Ps = 1 0 1 5 - Disable urxvt Mouse Mode.', async () => {
await ctx.proxy.write('\x1b[?1006h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
await ctx.proxy.write('\x1b[?1015l');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR');
});
test('Ps = 1 0 1 6 - Disable SGR Mouse Pixel-Mode, xterm.', async () => {
await ctx.proxy.write('\x1b[?1016h');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'SGR_PIXELS');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'SGR_PIXELS');
await ctx.proxy.write('\x1b[?1016l');
- await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding), 'DEFAULT');
+ await pollFor(ctx.page, async () => await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding), 'DEFAULT');
});
test('Ps = 1 0 3 4 - Don\'t interpret "meta" key, xterm. This disables the eightBitInput resource.', async () => {
await assertNoModeChange('\x1b[?1034l');
@@ -1849,8 +1849,8 @@ async function getModeSnapshot(): Promise {
cols: await ctx.proxy.cols,
rows: await ctx.proxy.rows,
bufferType: await ctx.proxy.buffer.active.type,
- mouseProtocol: await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeProtocol),
- mouseEncoding: await ctx.proxy.core.evaluate(([core]) => core.coreMouseService.activeEncoding)
+ mouseProtocol: await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeProtocol),
+ mouseEncoding: await ctx.proxy.core.evaluate(([core]) => core.mouseStateService.activeEncoding)
};
}
diff --git a/test/playwright/TestUtils.ts b/test/playwright/TestUtils.ts
index f1569d41b..8a76c8820 100644
--- a/test/playwright/TestUtils.ts
+++ b/test/playwright/TestUtils.ts
@@ -9,9 +9,10 @@ import type { IRenderDimensions as IRenderDimensionsInternal } from 'browser/ren
import type { IRenderService } from 'browser/services/Services';
import type { ICoreTerminal, IDisposable, IMarker } from 'common/Types';
import * as playwright from '@playwright/test';
-import { PageFunction } from 'playwright-core/types/structs';
import { IBuffer, IBufferCell, IBufferLine, IBufferNamespace, IBufferRange, IDecoration, IDecorationOptions, IModes, IRenderDimensions, ITerminalInitOnlyOptions, ITerminalOptions, Terminal } from '@xterm/xterm';
+type PageFunction = (arg: Arg) => R | Promise;
+
export interface ITestContext {
browser: Browser;
page: Page;
@@ -106,7 +107,7 @@ type PlaywrightApiProxy(pageFunction: PageFunction[], T>): Promise;
+ evaluate(pageFunction: PageFunction): Promise;
write(data: string | Uint8Array): Promise;
}
@@ -288,11 +289,11 @@ export class TerminalProxy implements ITerminalProxyCustomMethods, PlaywrightApi
public async clearTextureAtlas(): Promise { return this.evaluate(([term]) => term.clearTextureAtlas()); }
// #endregion
- public async evaluate(pageFunction: PageFunction[], T>): Promise {
+ public async evaluate(pageFunction: PageFunction): Promise {
return this._page.evaluate(pageFunction, [await this.getHandle()]);
}
- public async evaluateHandle(pageFunction: PageFunction[], T>): Promise> {
+ public async evaluateHandle(pageFunction: PageFunction): Promise> {
return this._page.evaluateHandle(pageFunction, [await this.getHandle()]);
}
@@ -342,7 +343,7 @@ class TerminalBufferProxy /* implements EnsureAsyncProperties*/ {
return undefined;
}
- public async evaluate(pageFunction: PageFunction[], T>): Promise {
+ public async evaluate(pageFunction: PageFunction): Promise {
return this._page.evaluate(pageFunction, [await this._handle]);
}
}
@@ -373,7 +374,7 @@ class TerminalBufferLine {
return undefined;
}
- public async evaluate(pageFunction: PageFunction[], T>): Promise {
+ public async evaluate(pageFunction: PageFunction): Promise {
return this._page.evaluate(pageFunction, [this._handle]);
}
}
@@ -414,7 +415,7 @@ class TerminalBufferCell {
public isAttributeDefault(): Promise { return this.evaluate(([cell]) => cell.isAttributeDefault()); }
- public async evaluate(pageFunction: PageFunction[], T>): Promise {
+ public async evaluate(pageFunction: PageFunction): Promise {
return this._page.evaluate(pageFunction, [this._handle]);
}
}
@@ -438,7 +439,7 @@ class TerminalCoreProxy {
return this._proxy.evaluateHandle(([term]) => (term as any)._core as ICoreTerminal);
}
- public async evaluate(pageFunction: PageFunction[], T>): Promise {
+ public async evaluate(pageFunction: PageFunction): Promise {
return this._page.evaluate(pageFunction, [await this._getCoreHandle()]);
}
}
diff --git a/test/playwright/tsconfig.json b/test/playwright/tsconfig.json
index 255e6eb0e..f1372e89f 100644
--- a/test/playwright/tsconfig.json
+++ b/test/playwright/tsconfig.json
@@ -14,8 +14,9 @@
"removeComments": true,
"pretty": true,
"strict": true,
+ "noImplicitAny": false,
"declaration": true,
- "baseUrl": ".",
+ "moduleResolution": "node",
"paths": {
"browser/*": [
"../../src/browser/*"