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/*"