Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c28e27062 | ||
|
|
30c97a225c | ||
|
|
1186d09e05 | ||
|
|
d99e6bb2bc | ||
|
|
579a0a530f | ||
|
|
0b6220e5ea | ||
|
|
0d119b30d6 | ||
|
|
79c94de968 | ||
|
|
e3980a27db | ||
|
|
898c0d91c3 | ||
|
|
83401e3769 | ||
|
|
6b756318f0 | ||
|
|
3a9bfa94bc | ||
|
|
209e5aa6b9 | ||
|
|
24e2eb9d7b | ||
|
|
d5ae437dda | ||
|
|
5efff43e93 | ||
|
|
ee85a1da9e | ||
|
|
c30fedff6b | ||
|
|
21f51564d1 | ||
|
|
27b01b22ef | ||
|
|
387ec93a90 | ||
|
|
4388a4ac13 | ||
|
|
08c97d12b0 | ||
|
|
fbf1f7bc5d | ||
|
|
c8704930e3 | ||
|
|
e60c96823d | ||
|
|
74642ce5d5 | ||
|
|
eccbc60aa0 | ||
|
|
6b64660934 | ||
|
|
10182cc656 | ||
|
|
3c7f313c34 | ||
|
|
457542b7b3 | ||
|
|
36118d9d4c | ||
|
|
cf8a4e466d | ||
|
|
4de03aaa38 | ||
|
|
677e5275f9 | ||
|
|
d4c3b693bb | ||
|
|
2f66b5fdb6 | ||
|
|
008aa1bfb5 | ||
|
|
1d0cf32b28 | ||
|
|
c01e4b7be7 | ||
|
|
fafb195f0f | ||
|
|
f0d291f645 | ||
|
|
f6b98291cf | ||
|
|
f7118b19c6 | ||
|
|
a0574f4e9c | ||
|
|
d9b5d150ae | ||
|
|
a25db1c76d | ||
|
|
c6a572fe21 | ||
|
|
b9fa53169f | ||
|
|
e14746e6e9 | ||
|
|
82333b9414 | ||
|
|
9eeb2799c1 | ||
|
|
5630471480 | ||
|
|
e0c59e68b2 | ||
|
|
cc8e680790 | ||
|
|
452ee50c81 | ||
|
|
f41e53a0f0 | ||
|
|
b287397c4f | ||
|
|
aebbe81252 | ||
|
|
d155bb6767 | ||
|
|
3a147e8e10 | ||
|
|
c2db7d4c8a | ||
|
|
456bc73bae | ||
|
|
2e776c0820 | ||
|
|
6f163beb97 | ||
|
|
b0d0bf8187 | ||
|
|
32e77c3b81 | ||
|
|
cba6e3d191 | ||
|
|
5741e450e6 | ||
|
|
6ef653e589 | ||
|
|
a4c0e4690e | ||
|
|
a31f78bc8b | ||
|
|
ca8e8546b5 | ||
|
|
3d473b52b6 | ||
|
|
a2bec2aeab | ||
|
|
205cf6089d | ||
|
|
8b0a6f9785 | ||
|
|
db0226be5c | ||
|
|
ab5c06cf17 | ||
|
|
9a778f0c98 | ||
|
|
c102b4af50 | ||
|
|
f3a7cac49f | ||
|
|
0d2a58c9d0 | ||
|
|
8a00898533 | ||
|
|
edb0e1ceb3 | ||
|
|
93c1650446 | ||
|
|
77695cb405 | ||
|
|
dc4ebfd6e3 | ||
|
|
fae6f03f90 | ||
|
|
d36af624cf | ||
|
|
a105d3e457 | ||
|
|
d5900c7eae | ||
|
|
beebe8b5dc | ||
|
|
f666135881 | ||
|
|
eea03cf0a9 |
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "npm run agent:setup-repo"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -69,9 +69,10 @@ export class FitAddon implements ITerminalAddon, IFitApi {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scrollbarWidth = (this._terminal.options.scrollback === 0
|
||||
const showScrollbar = this._terminal.options.scrollbar?.showScrollbar ?? true;
|
||||
const scrollbarWidth = (this._terminal.options.scrollback === 0 || !showScrollbar
|
||||
? 0
|
||||
: (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
|
||||
: (this._terminal.options.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH));
|
||||
|
||||
const parentElementStyle = _getComputedStyle(this._terminal.element.parentElement);
|
||||
const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height'));
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
*/
|
||||
import { IImageAddonOptions, IOscHandler, IResetHandler, ITerminalExt } from './Types';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
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';
|
||||
|
||||
// limit hold memory in base64 decoder (encoded bytes)
|
||||
const KEEP_DATA = 4194304;
|
||||
const INITIAL_DATA = 1048576;
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
// Local const enum mirror - esbuild can't inline const enums from external packages
|
||||
const enum DecoderConst {
|
||||
// Limit held memory in base64 decoder (encoded bytes).
|
||||
KEEP_DATA = 4194304,
|
||||
// Initial buffer allocation for the decoder.
|
||||
INITIAL_DATA = 1048576,
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
OK = 0
|
||||
}
|
||||
|
||||
// default IIP header values
|
||||
const DEFAULT_HEADER: IHeaderFields = {
|
||||
@@ -37,12 +41,12 @@ 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);
|
||||
const initialBytes = Math.min(INITIAL_DATA, maxEncodedBytes);
|
||||
this._dec = new Base64Decoder(KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||
const initialBytes = Math.min(DecoderConst.INITIAL_DATA, maxEncodedBytes);
|
||||
this._dec = new Base64Decoder(DecoderConst.KEEP_DATA, maxEncodedBytes, initialBytes);
|
||||
}
|
||||
|
||||
public reset(): void {}
|
||||
@@ -58,7 +62,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
if (this._aborted) return;
|
||||
|
||||
if (this._hp.state === HeaderState.END) {
|
||||
if (this._dec.put(data.subarray(start, end)) !== DECODER_OK) {
|
||||
if ((this._dec.put(data.subarray(start, end)) as number) !== DecoderConst.OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
@@ -75,7 +79,7 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
return;
|
||||
}
|
||||
this._dec.init();
|
||||
if (this._dec.put(data.subarray(dataPos, end)) !== DECODER_OK) {
|
||||
if ((this._dec.put(data.subarray(dataPos, end)) as number) !== DecoderConst.OK) {
|
||||
this._dec.release();
|
||||
this._aborted = true;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,10 @@ 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
|
||||
@@ -132,7 +135,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)
|
||||
@@ -141,7 +145,8 @@ 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)
|
||||
@@ -150,9 +155,12 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
|
||||
// Kitty graphics handler
|
||||
if (this._opts.kittySupport) {
|
||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import { toRGBA8888 } from 'sixel/lib/Colors';
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ICellSize, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { ICellSize, ImageLayer, ITerminalExt, IImageSpec, IRenderDimensions, IRenderService } from './Types';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const PLACEHOLDER_HEIGHT = 24;
|
||||
@@ -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<ImageLayer, CanvasRenderingContext2D>();
|
||||
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 {
|
||||
|
||||
@@ -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,7 @@ export class ImageStorage implements IDisposable {
|
||||
private _pixelLimit: number = 2500000;
|
||||
|
||||
private _viewportMetrics: { cols: number, rows: number };
|
||||
public onImageDeleted: ((storageId: number) => void) | undefined;
|
||||
|
||||
constructor(
|
||||
private _terminal: ITerminalExt,
|
||||
@@ -189,11 +190,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -216,24 +219,6 @@ export class ImageStorage implements IDisposable {
|
||||
this._fullyCleared = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image by its internal storage ID.
|
||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||
@@ -248,9 +233,14 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* Returns the internal image ID assigned to the stored image.
|
||||
* @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): number {
|
||||
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);
|
||||
|
||||
@@ -272,7 +262,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;
|
||||
@@ -286,7 +276,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;
|
||||
@@ -296,7 +286,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;
|
||||
@@ -339,7 +329,9 @@ 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
|
||||
@@ -354,16 +346,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) {
|
||||
@@ -371,12 +377,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();
|
||||
@@ -391,50 +410,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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,8 @@ export interface ICellSize {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type ImageLayer = 'top' | 'bottom';
|
||||
|
||||
export interface IImageSpec {
|
||||
orig: HTMLCanvasElement | ImageBitmap | undefined;
|
||||
origCellSize: ICellSize;
|
||||
@@ -107,4 +109,6 @@ export interface IImageSpec {
|
||||
marker: IMarker | undefined;
|
||||
tileCount: number;
|
||||
bufferType: 'alternate' | 'normal';
|
||||
layer: ImageLayer;
|
||||
zIndex: number;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt } from '../Types';
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { IApcHandler, IImageAddonOptions, IResetHandler, ITerminalExt, ImageLayer } from '../Types';
|
||||
import { ImageRenderer } from '../ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||
import { CELL_SIZE_DEFAULT } from '../ImageStorage';
|
||||
import { KittyImageStorage } from './KittyImageStorage';
|
||||
import Base64Decoder, { type DecodeStatus } from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
import {
|
||||
KittyAction,
|
||||
@@ -33,10 +35,8 @@ 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 {
|
||||
// Kitty graphics protocol handler with streaming base64 decoding.
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler, IDisposable {
|
||||
private _aborted = false;
|
||||
private _decodeError = false;
|
||||
|
||||
@@ -46,38 +46,32 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
|
||||
// Streaming related states
|
||||
|
||||
/** True while receiving control data (before semicolon). */
|
||||
// True while receiving control data (before semicolon).
|
||||
private _inControlData = true;
|
||||
|
||||
/** Buffer for control data. */
|
||||
// Buffer for control data.
|
||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
||||
private _controlLength = 0;
|
||||
|
||||
/** Pre-calculated encoded size limit */
|
||||
// Pre-calculated encoded size limit
|
||||
private _encodedSizeLimit = 0;
|
||||
private _totalEncodedSize = 0;
|
||||
|
||||
/** Parsed command. These are the control data before semicolon. */
|
||||
// Parsed command. These are the control data before semicolon.
|
||||
private _parsedCommand: IKittyCommand | null = null;
|
||||
|
||||
// Storage related states
|
||||
|
||||
private _pendingTransmissions: Map<number, IPendingTransmission> = new Map();
|
||||
private _nextImageId = 1;
|
||||
/** Maps Kitty protocol image ID → ImageStorage internal ID for deletion/lookup. */
|
||||
private _kittyIdToStorageId: Map<number, number> = new Map();
|
||||
// TODO: Eliminate double storage — raw image data lives here (as Blob) AND rendered
|
||||
// ImageBitmaps live in ImageStorage. Currently we only use ImageStorage.addImage(bitmap)
|
||||
// for tiling + cursor movement + marker-based eviction.
|
||||
//
|
||||
|
||||
// See: https://github.com/xtermjs/xterm.js/pull/5619#issuecomment-3853678815
|
||||
private _images: Map<number, IKittyImageData> = 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 _storage: ImageStorage,
|
||||
private readonly _kittyStorage: KittyImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
// Convert decoded size limit -> max encoded bytes.
|
||||
@@ -87,16 +81,31 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
this._cleanupAllPending();
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
this._images.clear();
|
||||
this._kittyIdToStorageId.clear();
|
||||
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 {
|
||||
@@ -161,15 +170,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream payload bytes into the base64 decoder.
|
||||
*/
|
||||
// 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
|
||||
const pendingKey = this._parsedCommand?.id ?? 0;
|
||||
// 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;
|
||||
@@ -181,7 +189,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
if (pending) {
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
this._removePendingEntry(pendingKey);
|
||||
}
|
||||
this._aborted = true;
|
||||
return;
|
||||
@@ -202,7 +210,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
this._activeDecoder = null;
|
||||
this._decodeError = true;
|
||||
if (pending) {
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
this._removePendingEntry(pendingKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -229,7 +237,8 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
return this._handleDelete(cmd);
|
||||
}
|
||||
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
// 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);
|
||||
|
||||
@@ -246,11 +255,17 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
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;
|
||||
@@ -308,7 +323,16 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -319,8 +343,14 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
switch (action) {
|
||||
case KittyAction.TRANSMIT: {
|
||||
const result = this._handleTransmit(cmd, bytes, decodeError);
|
||||
if (cmd.id !== undefined && !decodeError && bytes.length > 0) {
|
||||
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
||||
// 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;
|
||||
}
|
||||
@@ -328,59 +358,89 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
||||
case KittyAction.QUERY:
|
||||
return this._handleQuery(cmd, bytes, decodeError);
|
||||
case KittyAction.PLACEMENT:
|
||||
// a=p ignores any payload — image data was already transmitted
|
||||
return this._handlePlacement(cmd);
|
||||
default:
|
||||
// TODO: Implement remaining actions when needed:
|
||||
// - a=f (frame): animation frame operations
|
||||
// - a=a (animation): animation control
|
||||
// - a=c (compose): compose images
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:unsupported action', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePlacement(cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||
if (cmd.id === undefined) {
|
||||
return true;
|
||||
}
|
||||
const image = this._kittyStorage.getImage(cmd.id);
|
||||
if (!image) {
|
||||
this._sendResponse(cmd.id, 'ENOENT:image not found', cmd.quiet ?? 0, cmd.placementId);
|
||||
return true;
|
||||
}
|
||||
const result = this._displayImage(image, cmd);
|
||||
return result.then(success => {
|
||||
this._sendResponse(cmd.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.
|
||||
// - 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;
|
||||
|
||||
const id = cmd.id ?? this._nextImageId++;
|
||||
const image: IKittyImageData = {
|
||||
id,
|
||||
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 ?? ''
|
||||
};
|
||||
this._images.set(id, image);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleTransmitDisplay(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||
if (decodeError) return true;
|
||||
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
if (decodeError) {
|
||||
if (cmd.id !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:invalid base64 data', cmd.quiet ?? 0);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
this._handleTransmit(cmd, bytes, decodeError);
|
||||
|
||||
// If still accumulating chunks, don't display yet
|
||||
if (this._pendingTransmissions.has(pendingKey)) return true;
|
||||
|
||||
// Display the completed image
|
||||
const id = cmd.id ?? this._nextImageId - 1;
|
||||
const image = this._images.get(id);
|
||||
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 as Promise<boolean>).then(r => {
|
||||
this._sendResponse(id, 'OK', cmd.quiet ?? 0);
|
||||
return r;
|
||||
return result.then(success => {
|
||||
this._sendResponse(id, success ? 'OK' : 'EINVAL:image rendering failed', cmd.quiet ?? 0);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return result.then(() => true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -389,6 +449,15 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
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);
|
||||
@@ -428,64 +497,98 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
|
||||
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||
const id = cmd.id;
|
||||
// Per spec: default delete selector is 'a' (delete all visible placements)
|
||||
const selector = cmd.deleteSelector ?? 'a';
|
||||
|
||||
if (id !== undefined) {
|
||||
// Abort in-flight chunked upload for this specific image
|
||||
const pending = this._pendingTransmissions.get(id);
|
||||
if (pending) {
|
||||
pending.decoder.release();
|
||||
this._pendingTransmissions.delete(id);
|
||||
}
|
||||
|
||||
this._images.delete(id);
|
||||
const storageId = this._kittyIdToStorageId.get(id);
|
||||
if (storageId !== undefined) {
|
||||
this._storage.deleteImage(storageId);
|
||||
this._kittyIdToStorageId.delete(id);
|
||||
}
|
||||
} else {
|
||||
// Abort all in-flight chunked uploads
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
|
||||
this._images.clear();
|
||||
for (const storageId of this._kittyIdToStorageId.values()) {
|
||||
this._storage.deleteImage(storageId);
|
||||
}
|
||||
this._kittyIdToStorageId.clear();
|
||||
// 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':
|
||||
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): void {
|
||||
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 response = `\x1b_Gi=${id};${message}\x1b\\`;
|
||||
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): boolean | Promise<boolean> {
|
||||
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): Promise<boolean> {
|
||||
return this._decodeAndDisplay(image, cmd)
|
||||
.then(() => true)
|
||||
.catch(() => true);
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
const bitmap = await this._createBitmap(image);
|
||||
let bitmap = await this._createBitmap(image);
|
||||
|
||||
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) {
|
||||
bitmap.close();
|
||||
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
|
||||
const imgCols = cmd.columns ?? Math.ceil(bitmap.width / cw);
|
||||
const imgRows = cmd.rows ?? Math.ceil(bitmap.height / ch);
|
||||
// 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;
|
||||
@@ -496,7 +599,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
h = Math.round(imgRows * ch);
|
||||
}
|
||||
|
||||
if (w * h > this._opts.pixelLimit) return;
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
bitmap.close();
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
|
||||
// Save cursor position before addImage modifies it
|
||||
const buffer = this._coreTerminal._core.buffer;
|
||||
@@ -504,14 +610,57 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
const savedY = buffer.y;
|
||||
const savedYbase = buffer.ybase;
|
||||
|
||||
let storageId: number;
|
||||
// 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';
|
||||
|
||||
let finalBitmap = bitmap;
|
||||
if (w !== bitmap.width || h !== bitmap.height) {
|
||||
const resized = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
storageId = this._storage.addImage(resized);
|
||||
} else {
|
||||
storageId = this._storage.addImage(bitmap);
|
||||
finalBitmap = await createImageBitmap(bitmap, { resizeWidth: w, resizeHeight: h });
|
||||
bitmap.close();
|
||||
}
|
||||
this._kittyIdToStorageId.set(image.id, storageId);
|
||||
|
||||
// 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) : finalBitmap.width + xOffset;
|
||||
const canvasH = (cmd.rows !== undefined) ? Math.round(imgRows * ch) : finalBitmap.height + yOffset;
|
||||
const offsetCanvas = ImageRenderer.createCanvas(window.document, canvasW, canvasH);
|
||||
const offsetCtx = offsetCanvas.getContext('2d');
|
||||
if (!offsetCtx) {
|
||||
finalBitmap.close();
|
||||
throw new Error('Failed to create offset canvas context');
|
||||
}
|
||||
offsetCtx.drawImage(finalBitmap, xOffset, yOffset);
|
||||
|
||||
const offsetBitmap = await createImageBitmap(offsetCanvas);
|
||||
offsetCanvas.width = offsetCanvas.height = 0;
|
||||
finalBitmap.close();
|
||||
finalBitmap = offsetBitmap;
|
||||
w = finalBitmap.width;
|
||||
h = finalBitmap.height;
|
||||
if (w * h > this._opts.pixelLimit) {
|
||||
finalBitmap.close();
|
||||
throw new Error('image exceeds pixel limit');
|
||||
}
|
||||
if (cmd.columns === undefined) {
|
||||
imgCols = Math.ceil(finalBitmap.width / cw);
|
||||
}
|
||||
if (cmd.rows === undefined) {
|
||||
imgRows = Math.ceil(finalBitmap.height / ch);
|
||||
}
|
||||
}
|
||||
|
||||
const zIndex = cmd.zIndex ?? 0;
|
||||
this._kittyStorage.addImage(image.id, finalBitmap, true, layer, zIndex);
|
||||
|
||||
// Kitty cursor movement
|
||||
// Per spec: cursor placed at first column after last image column,
|
||||
@@ -529,9 +678,7 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ImageBitmap from already-decoded image data.
|
||||
*/
|
||||
// Create ImageBitmap from already-decoded image data.
|
||||
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
||||
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
||||
|
||||
@@ -551,7 +698,10 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||
});
|
||||
img.addEventListener('error', reject);
|
||||
img.addEventListener('error', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
});
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
@@ -574,18 +724,43 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
|
||||
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 isRgba = image.format === KittyFormat.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 < pixelCount; i++) {
|
||||
data[dstOffset] = bytes[srcOffset];
|
||||
data[dstOffset + 1] = bytes[srcOffset + 1];
|
||||
data[dstOffset + 2] = bytes[srcOffset + 2];
|
||||
data[dstOffset + 3] = isRgba ? bytes[srcOffset + 3] : ALPHA_OPAQUE;
|
||||
srcOffset += bytesPerPixel;
|
||||
dstOffset += BYTES_PER_PIXEL_RGBA;
|
||||
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++] = (b0 & 0x00FFFFFF) | 0xFF000000;
|
||||
dst32[dstOffset++] = ((b0 >>> 24) | (b1 << 8)) & 0x00FFFFFF | 0xFF000000;
|
||||
dst32[dstOffset++] = ((b1 >>> 16) | (b2 << 16)) & 0x00FFFFFF | 0xFF000000;
|
||||
dst32[dstOffset++] = (b2 >>> 8) | 0xFF000000;
|
||||
}
|
||||
|
||||
// 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));
|
||||
@@ -625,7 +800,11 @@ export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
}
|
||||
|
||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||
return this._images;
|
||||
return this._kittyStorage.images;
|
||||
}
|
||||
|
||||
public get _kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||
return this._kittyStorage.kittyIdToStorageId;
|
||||
}
|
||||
|
||||
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
@@ -86,5 +86,64 @@ describe('KittyGraphicsTypes', () => {
|
||||
assert.ok(isNaN(cmd.format!));
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse z-index key with positive value', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=10');
|
||||
assert.strictEqual(cmd.zIndex, 10);
|
||||
});
|
||||
|
||||
it('should parse z-index key with zero', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=0');
|
||||
assert.strictEqual(cmd.zIndex, 0);
|
||||
});
|
||||
|
||||
it('should parse z-index key with negative value', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,z=-1');
|
||||
assert.strictEqual(cmd.zIndex, -1);
|
||||
});
|
||||
|
||||
it('should leave zIndex undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.strictEqual(cmd.zIndex, undefined);
|
||||
});
|
||||
|
||||
it('should parse delete selector key', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||
assert.strictEqual(cmd.action, 'd');
|
||||
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse uppercase delete selector', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=A');
|
||||
assert.strictEqual(cmd.deleteSelector, 'A');
|
||||
});
|
||||
|
||||
it('should parse delete selector d=a (all)', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=a');
|
||||
assert.strictEqual(cmd.deleteSelector, 'a');
|
||||
});
|
||||
|
||||
it('should leave deleteSelector undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.strictEqual(cmd.deleteSelector, undefined);
|
||||
});
|
||||
|
||||
it('should parse placement id key', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5,p=3');
|
||||
assert.strictEqual(cmd.placementId, 3);
|
||||
assert.strictEqual(cmd.deleteSelector, 'i');
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should leave placementId undefined when not specified', () => {
|
||||
const cmd = parseKittyCommand('a=d,d=i,i=5');
|
||||
assert.strictEqual(cmd.placementId, undefined);
|
||||
});
|
||||
|
||||
it('should parse image number key', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100,I=42');
|
||||
assert.strictEqual(cmd.imageNumber, 42);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||
@@ -7,10 +7,8 @@
|
||||
|
||||
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'.
|
||||
*/
|
||||
// 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',
|
||||
@@ -19,29 +17,23 @@ export const enum KittyAction {
|
||||
DELETE = 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol format types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
*/
|
||||
// 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'.
|
||||
*/
|
||||
// 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
|
||||
*/
|
||||
// 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',
|
||||
@@ -59,6 +51,14 @@ export const enum KittyKey {
|
||||
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
|
||||
@@ -70,7 +70,15 @@ export const enum KittyKey {
|
||||
// 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'
|
||||
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
|
||||
@@ -78,9 +86,7 @@ export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const ALPHA_OPAQUE = 255;
|
||||
|
||||
/**
|
||||
* Parsed Kitty graphics command.
|
||||
*/
|
||||
// Parsed Kitty graphics command.
|
||||
export interface IKittyCommand {
|
||||
action?: string;
|
||||
format?: number;
|
||||
@@ -90,36 +96,40 @@ export interface IKittyCommand {
|
||||
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.
|
||||
*/
|
||||
// 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.) */
|
||||
// The parsed command from the first chunk (contains action, format, dimensions, etc.)
|
||||
cmd: IKittyCommand;
|
||||
/** Decoder used across chunked payloads */
|
||||
// Decoder used across chunked payloads
|
||||
decoder: Base64Decoder;
|
||||
/** Total encoded (base64) bytes received across all chunks - for size limit enforcement */
|
||||
// Total encoded (base64) bytes received across all chunks - for size limit enforcement
|
||||
totalEncodedSize: number;
|
||||
/** Whether any chunk has failed to decode */
|
||||
// Whether any chunk has failed to decode
|
||||
decodeError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored Kitty image data.
|
||||
*/
|
||||
// Stored Kitty image data.
|
||||
export interface IKittyImageData {
|
||||
id: number;
|
||||
/** Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit */
|
||||
// Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit
|
||||
data: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
@@ -127,9 +137,7 @@ export interface IKittyImageData {
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Kitty graphics control data into a command object.
|
||||
*/
|
||||
// Parses Kitty graphics control data into a command object.
|
||||
export function parseKittyCommand(data: string): IKittyCommand {
|
||||
const cmd: IKittyCommand = {};
|
||||
const parts = data.split(',');
|
||||
@@ -150,6 +158,14 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
||||
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;
|
||||
@@ -159,11 +175,17 @@ export function parseKittyCommand(data: string): IKittyCommand {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IDisposable } from '@xterm/xterm';
|
||||
import { ImageStorage } from '../ImageStorage';
|
||||
import { ImageLayer } from '../Types';
|
||||
import { IKittyImageData } from './KittyGraphicsTypes';
|
||||
|
||||
// Kitty-specific image storage controller.
|
||||
//
|
||||
// Wraps shared ImageStorage with kitty protocol semantics:
|
||||
// - tracks transmitted image payloads by kitty image id
|
||||
// - tracks kitty image id -> shared ImageStorage id mapping for displayed images
|
||||
// - mirrors shared-storage evictions into kitty maps
|
||||
// - applies protocol-level undisplayed-image eviction policy
|
||||
export class KittyImageStorage implements IDisposable {
|
||||
private static readonly _maxStoredImages = 256;
|
||||
|
||||
private _nextImageId = 1;
|
||||
private readonly _images: Map<number, IKittyImageData> = new Map();
|
||||
private readonly _kittyIdToStorageId: Map<number, number> = new Map();
|
||||
private readonly _storageIdToKittyId: Map<number, number> = new Map();
|
||||
|
||||
private readonly _previousOnImageDeleted: ((storageId: number) => void) | undefined;
|
||||
private readonly _wrappedOnImageDeleted: (storageId: number) => void;
|
||||
private readonly _handleStorageImageDeleted = (storageId: number): void => {
|
||||
const kittyId = this._storageIdToKittyId.get(storageId);
|
||||
if (kittyId !== undefined) {
|
||||
this._kittyIdToStorageId.delete(kittyId);
|
||||
this._storageIdToKittyId.delete(storageId);
|
||||
this._images.delete(kittyId);
|
||||
}
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly _storage: ImageStorage
|
||||
) {
|
||||
this._previousOnImageDeleted = this._storage.onImageDeleted;
|
||||
this._wrappedOnImageDeleted = (storageId: number) => {
|
||||
this._previousOnImageDeleted?.(storageId);
|
||||
this._handleStorageImageDeleted(storageId);
|
||||
};
|
||||
this._storage.onImageDeleted = this._wrappedOnImageDeleted;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._nextImageId = 1;
|
||||
this._images.clear();
|
||||
this._kittyIdToStorageId.clear();
|
||||
this._storageIdToKittyId.clear();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.reset();
|
||||
if (this._storage.onImageDeleted === this._wrappedOnImageDeleted) {
|
||||
this._storage.onImageDeleted = this._previousOnImageDeleted;
|
||||
}
|
||||
}
|
||||
|
||||
public storeImage(id: number | undefined, imageData: Omit<IKittyImageData, 'id'>): number {
|
||||
const imageId = id ?? this._nextImageId++;
|
||||
|
||||
const oldStorageId = this._kittyIdToStorageId.get(imageId);
|
||||
if (oldStorageId !== undefined) {
|
||||
this._storage.deleteImage(oldStorageId);
|
||||
this._kittyIdToStorageId.delete(imageId);
|
||||
this._storageIdToKittyId.delete(oldStorageId);
|
||||
}
|
||||
|
||||
if (!this._images.has(imageId) && this._images.size >= KittyImageStorage._maxStoredImages) {
|
||||
this._evictUndisplayedImages();
|
||||
}
|
||||
|
||||
this._images.set(imageId, {
|
||||
...imageData,
|
||||
id: imageId
|
||||
});
|
||||
return imageId;
|
||||
}
|
||||
|
||||
public addImage(kittyId: number, image: HTMLCanvasElement | ImageBitmap, scrolling: boolean, layer: ImageLayer, zIndex: number): void {
|
||||
// Clean up stale reverse-mapping from a previous placement of the same
|
||||
// kitty image. The old shared-storage entry is kept (it may still be
|
||||
// visible on screen) but its reverse mapping is removed so that eviction
|
||||
// of the old entry won't incorrectly delete the kitty image data.
|
||||
const oldStorageId = this._kittyIdToStorageId.get(kittyId);
|
||||
if (oldStorageId !== undefined) {
|
||||
this._storageIdToKittyId.delete(oldStorageId);
|
||||
}
|
||||
const storageId = this._storage.addImage(image, scrolling, layer, zIndex);
|
||||
this._kittyIdToStorageId.set(kittyId, storageId);
|
||||
this._storageIdToKittyId.set(storageId, kittyId);
|
||||
}
|
||||
|
||||
public getImage(kittyId: number): IKittyImageData | undefined {
|
||||
return this._images.get(kittyId);
|
||||
}
|
||||
|
||||
public deleteById(kittyId: number): void {
|
||||
this._images.delete(kittyId);
|
||||
const storageId = this._kittyIdToStorageId.get(kittyId);
|
||||
if (storageId !== undefined) {
|
||||
this._storage.deleteImage(storageId);
|
||||
this._kittyIdToStorageId.delete(kittyId);
|
||||
this._storageIdToKittyId.delete(storageId);
|
||||
}
|
||||
}
|
||||
|
||||
public deleteAll(): void {
|
||||
this._images.clear();
|
||||
for (const storageId of this._kittyIdToStorageId.values()) {
|
||||
this._storage.deleteImage(storageId);
|
||||
}
|
||||
this._kittyIdToStorageId.clear();
|
||||
this._storageIdToKittyId.clear();
|
||||
}
|
||||
|
||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||
return this._images;
|
||||
}
|
||||
|
||||
public get kittyIdToStorageId(): ReadonlyMap<number, number> {
|
||||
return this._kittyIdToStorageId;
|
||||
}
|
||||
|
||||
public get lastImageId(): number {
|
||||
return this._nextImageId - 1;
|
||||
}
|
||||
|
||||
private _evictUndisplayedImages(): void {
|
||||
for (const [kittyId] of this._images) {
|
||||
if (this._images.size <= KittyImageStorage._maxStoredImages / 2) {
|
||||
break;
|
||||
}
|
||||
if (!this._kittyIdToStorageId.has(kittyId)) {
|
||||
this._images.delete(kittyId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,11 @@
|
||||
"types": [
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"browser/*": [ "../../../src/browser/*" ],
|
||||
"common/*": [ "../../../src/common/*" ],
|
||||
"vs/*": [ "../../../src/vs/*" ],
|
||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
|
||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ],
|
||||
"*": [ "./*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -25,7 +24,6 @@
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../src/browser" },
|
||||
{ "path": "../../../src/common" },
|
||||
{ "path": "../../../src/vs" }
|
||||
{ "path": "../../../src/common" }
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { Terminal, ITerminalAddon, IDisposable } from '@xterm/xterm';
|
||||
import type { ProgressAddon as IProgressApi, IProgressState } from '@xterm/addon-progress';
|
||||
import type { Emitter, Event } from 'vs/base/common/event';
|
||||
import type { Emitter, IEvent } from 'common/Event';
|
||||
|
||||
|
||||
const enum ProgressType {
|
||||
@@ -39,7 +39,7 @@ export class ProgressAddon implements ITerminalAddon, IProgressApi {
|
||||
private _pr = 0;
|
||||
// HACK: This uses ! to align with the API, this should be fixed when 5283 is resolved
|
||||
private _onChange!: Emitter<IProgressState>;
|
||||
public onChange!: Event<IProgressState>;
|
||||
public onChange!: IEvent<IProgressState>;
|
||||
|
||||
public dispose(): void {
|
||||
this._seqHandler?.dispose();
|
||||
|
||||
@@ -15,12 +15,12 @@
|
||||
"../../../node_modules/@types/mocha"
|
||||
],
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"vs/*": [
|
||||
"../../../src/vs/*"
|
||||
],
|
||||
"@xterm/addon-progress": [
|
||||
"../typings/addon-progress.d.ts"
|
||||
]
|
||||
@@ -35,7 +35,7 @@
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
"path": "../../../src/common"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import type { Terminal, IDisposable, IDecoration } from '@xterm/xterm';
|
||||
import type { ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { dispose, Disposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { dispose, Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,9 +5,9 @@
|
||||
|
||||
import type { Terminal, IDisposable, ITerminalAddon } from '@xterm/xterm';
|
||||
import type { SearchAddon as ISearchApi, ISearchOptions, ISearchAddonOptions, ISearchResultChangeEvent, ISearchDecorationOptions } from '@xterm/addon-search';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { disposableTimeout } from 'vs/base/common/async';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { SearchState } from './SearchState';
|
||||
import { SearchEngine, type ISearchResult } from './SearchEngine';
|
||||
@@ -47,7 +47,7 @@ export class SearchAddon extends Disposable implements ITerminalAddon, ISearchAp
|
||||
private readonly _onBeforeSearch = this._register(new Emitter<void>());
|
||||
public readonly onBeforeSearch = this._onBeforeSearch.event;
|
||||
|
||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> {
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> {
|
||||
return this._resultTracker.onDidChangeResults;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SearchEngine } from './SearchEngine';
|
||||
import { SearchLineCache } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import type { ISearchOptions } from '@xterm/addon-search';
|
||||
import { DisposableStore } from 'vs/base/common/lifecycle';
|
||||
import { DisposableStore } from 'common/Lifecycle';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
import { assert } from 'chai';
|
||||
import { SearchLineCache, LineCacheEntry } from './SearchLineCache';
|
||||
import { Terminal } from 'browser/public/Terminal';
|
||||
import { timeout } from 'vs/base/common/async';
|
||||
import { timeout } from 'common/Async';
|
||||
|
||||
function writeP(terminal: Terminal, data: string): Promise<void> {
|
||||
return new Promise(r => terminal.write(data, r));
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
*/
|
||||
|
||||
import type { Terminal } from '@xterm/xterm';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle';
|
||||
import { disposableTimeout } from 'vs/base/common/async';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { disposableTimeout } from 'common/Async';
|
||||
|
||||
export type LineCacheEntry = [
|
||||
/**
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
import type { ISearchResultChangeEvent } from '@xterm/addon-search';
|
||||
import type { IDisposable } from '@xterm/xterm';
|
||||
import { Emitter, Event } from 'vs/base/common/event';
|
||||
import { Disposable } from 'vs/base/common/lifecycle';
|
||||
import { Emitter, type IEvent } from 'common/Event';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import type { ISearchResult } from './SearchEngine';
|
||||
|
||||
/**
|
||||
@@ -25,7 +25,7 @@ export class SearchResultTracker extends Disposable {
|
||||
private _selectedDecoration: ISelectedDecoration | undefined;
|
||||
|
||||
private readonly _onDidChangeResults = this._register(new Emitter<ISearchResultChangeEvent>());
|
||||
public get onDidChangeResults(): Event<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
public get onDidChangeResults(): IEvent<ISearchResultChangeEvent> { return this._onDidChangeResults.event; }
|
||||
|
||||
/**
|
||||
* Gets the current search results.
|
||||
|
||||
@@ -21,9 +21,6 @@
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"vs/*": [
|
||||
"../../../src/vs/*"
|
||||
],
|
||||
"@xterm/addon-search" : [
|
||||
"../typings/addon-search.d.ts"
|
||||
]
|
||||
@@ -39,9 +36,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"dom",
|
||||
"es2021"
|
||||
],
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-benchmark",
|
||||
"types": ["../../../node_modules/@types/node"],
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"target": "es2021",
|
||||
"module": "commonjs",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": ["../../../src/common/*"],
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -116,7 +116,7 @@ describe('SerializeAddon', () => {
|
||||
describe('underline styles', () => {
|
||||
it('should serialize single underline with style', async () => {
|
||||
await writeP(terminal, sgr('4:1') + 'test' + sgr('24'));
|
||||
assert.equal(serializeAddon.serialize(), '\u001b[4:1mtest\u001b[0m');
|
||||
assert.equal(serializeAddon.serialize(), '\u001b[4mtest\u001b[0m');
|
||||
});
|
||||
|
||||
it('should serialize double underline', async () => {
|
||||
|
||||
@@ -89,11 +89,19 @@ function equalUnderline(cell1: IBufferCell | IAttributeData, cell2: IBufferCell)
|
||||
if (!cell1.isUnderline() && !cell2.isUnderline()) {
|
||||
return true;
|
||||
}
|
||||
const cell1Data = cell1 as unknown as IAttributeData;
|
||||
const cell2Data = cell2 as unknown as IAttributeData;
|
||||
return cell1Data.getUnderlineStyle() === cell2Data.getUnderlineStyle()
|
||||
&& cell1Data.getUnderlineColor() === cell2Data.getUnderlineColor()
|
||||
&& cell1Data.getUnderlineColorMode() === cell2Data.getUnderlineColorMode();
|
||||
if (cell1.getUnderlineStyle() !== cell2.getUnderlineStyle()) {
|
||||
return false;
|
||||
}
|
||||
const cell1Default = cell1.isUnderlineColorDefault();
|
||||
const cell2Default = cell2.isUnderlineColorDefault();
|
||||
if (cell1Default && cell2Default) {
|
||||
return true;
|
||||
}
|
||||
if (cell1Default !== cell2Default) {
|
||||
return false;
|
||||
}
|
||||
return cell1.getUnderlineColor() === cell2.getUnderlineColor()
|
||||
&& cell1.getUnderlineColorMode() === cell2.getUnderlineColorMode();
|
||||
}
|
||||
|
||||
function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
||||
@@ -109,6 +117,16 @@ function equalFlags(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): bo
|
||||
&& cell1.isStrikethrough() === cell2.isStrikethrough();
|
||||
}
|
||||
|
||||
function attributesEquals(cell1: IBufferCell | IAttributeData, cell2: IBufferCell): boolean {
|
||||
const cell1AsBufferCell = cell1 as IBufferCell;
|
||||
if (typeof cell1AsBufferCell.attributesEquals === 'function') {
|
||||
return cell1AsBufferCell.attributesEquals(cell2);
|
||||
}
|
||||
return equalFg(cell1, cell2)
|
||||
&& equalBg(cell1, cell2)
|
||||
&& equalFlags(cell1, cell2);
|
||||
}
|
||||
|
||||
class StringSerializeHandler extends BaseSerializeHandler {
|
||||
private _rowIndex: number = 0;
|
||||
private _allRows: string[] = new Array<string>();
|
||||
@@ -258,6 +276,9 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
private _diffStyle(cell: IBufferCell | IAttributeData, oldCell: IBufferCell): number[] {
|
||||
const sgrSeq: number[] = [];
|
||||
if (attributesEquals(cell, oldCell)) {
|
||||
return sgrSeq;
|
||||
}
|
||||
const fgChanged = !equalFg(cell, oldCell);
|
||||
const bgChanged = !equalBg(cell, oldCell);
|
||||
const flagsChanged = !equalFlags(cell, oldCell);
|
||||
@@ -290,17 +311,18 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); }
|
||||
if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); }
|
||||
if (!equalUnderline(cell, oldCell)) {
|
||||
const cellData = cell as unknown as IAttributeData;
|
||||
const style = cellData.getUnderlineStyle();
|
||||
const style = cell.getUnderlineStyle();
|
||||
if (style === UnderlineStyle.NONE) {
|
||||
sgrSeq.push(24);
|
||||
} else if (style === UnderlineStyle.SINGLE && cell.isUnderlineColorDefault()) {
|
||||
sgrSeq.push(4);
|
||||
} else {
|
||||
// Use SGR 4:X format for underline styles
|
||||
sgrSeq.push('4:' + style as unknown as number);
|
||||
// Handle underline color
|
||||
if (!cellData.isUnderlineColorDefault()) {
|
||||
const color = cellData.getUnderlineColor();
|
||||
if (cellData.isUnderlineColorRGB()) {
|
||||
if (!cell.isUnderlineColorDefault()) {
|
||||
const color = cell.getUnderlineColor();
|
||||
if (cell.isUnderlineColorRGB()) {
|
||||
sgrSeq.push('58:2::' + ((color >>> 16) & 0xFF) + ':' + ((color >>> 8) & 0xFF) + ':' + (color & 0xFF) as unknown as number);
|
||||
} else {
|
||||
sgrSeq.push('58:5:' + color as unknown as number);
|
||||
@@ -675,12 +697,11 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
|
||||
private _getUnderlineColor(cell: IBufferCell): string | undefined {
|
||||
const cellData = cell as unknown as IAttributeData;
|
||||
if (cellData.isUnderlineColorDefault()) {
|
||||
if (cell.isUnderlineColorDefault()) {
|
||||
return undefined;
|
||||
}
|
||||
const color = cellData.getUnderlineColor();
|
||||
if (cellData.isUnderlineColorRGB()) {
|
||||
const color = cell.getUnderlineColor();
|
||||
if (cell.isUnderlineColorRGB()) {
|
||||
const rgb = [
|
||||
(color >> 16) & 255,
|
||||
(color >> 8) & 255,
|
||||
@@ -693,8 +714,7 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
|
||||
private _getUnderlineStyle(cell: IBufferCell): string {
|
||||
const cellData = cell as unknown as IAttributeData;
|
||||
switch (cellData.getUnderlineStyle()) {
|
||||
switch (cell.getUnderlineStyle()) {
|
||||
case UnderlineStyle.SINGLE:
|
||||
return 'underline';
|
||||
case UnderlineStyle.DOUBLE:
|
||||
@@ -713,6 +733,10 @@ export class HTMLSerializeHandler extends BaseSerializeHandler {
|
||||
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): string[] | undefined {
|
||||
const content: string[] = [];
|
||||
|
||||
if (attributesEquals(cell, oldCell)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const fgChanged = !equalFg(cell, oldCell);
|
||||
const bgChanged = !equalBg(cell, oldCell);
|
||||
const flagsChanged = !equalFlags(cell, oldCell);
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
|
||||
@@ -203,6 +203,46 @@ test.describe('SerializeAddon', () => {
|
||||
strictEqual(await ctx.page.evaluate(`window.serialize.serialize();`), lines.join('\r\n'));
|
||||
});
|
||||
|
||||
test('buffer cell attributesEquals compares underline style and color', async () => {
|
||||
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
|
||||
const sameAttributes = await ctx.page.evaluate(`(() => {
|
||||
const line = window.term.buffer.active.getLine(0);
|
||||
const cellA = line?.getCell(0);
|
||||
const cellB = line?.getCell(1);
|
||||
if (!cellA || !cellB) {
|
||||
return undefined;
|
||||
}
|
||||
return cellA.attributesEquals(cellB);
|
||||
})()`);
|
||||
strictEqual(sameAttributes, true);
|
||||
|
||||
await ctx.page.evaluate(`window.term.reset()`);
|
||||
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_GREEN)}B${sgr(NORMAL)}`);
|
||||
const differentColor = await ctx.page.evaluate(`(() => {
|
||||
const line = window.term.buffer.active.getLine(0);
|
||||
const cellA = line?.getCell(0);
|
||||
const cellB = line?.getCell(1);
|
||||
if (!cellA || !cellB) {
|
||||
return undefined;
|
||||
}
|
||||
return cellA.attributesEquals(cellB);
|
||||
})()`);
|
||||
strictEqual(differentColor, false);
|
||||
|
||||
await ctx.page.evaluate(`window.term.reset()`);
|
||||
await ctx.proxy.write(`${sgr(UNDERLINE_DOUBLE, UNDERLINE_COLOR_RED)}A${sgr(UNDERLINED, UNDERLINE_COLOR_RED)}B${sgr(NORMAL)}`);
|
||||
const differentStyle = await ctx.page.evaluate(`(() => {
|
||||
const line = window.term.buffer.active.getLine(0);
|
||||
const cellA = line?.getCell(0);
|
||||
const cellB = line?.getCell(1);
|
||||
if (!cellA || !cellB) {
|
||||
return undefined;
|
||||
}
|
||||
return cellA.attributesEquals(cellB);
|
||||
})()`);
|
||||
strictEqual(differentStyle, false);
|
||||
});
|
||||
|
||||
test('serialize all rows of content with color256', async function(): Promise<any> {
|
||||
const rows = 32;
|
||||
const cols = 10;
|
||||
@@ -602,6 +642,9 @@ const BOLD = '1';
|
||||
const DIM = '2';
|
||||
const ITALIC = '3';
|
||||
const UNDERLINED = '4';
|
||||
const UNDERLINE_DOUBLE = '4:2';
|
||||
const UNDERLINE_COLOR_RED = '58;5;196';
|
||||
const UNDERLINE_COLOR_GREEN = '58;5;46';
|
||||
const BLINK = '5';
|
||||
const INVERSE = '7';
|
||||
const INVISIBLE = '8';
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "es6"],
|
||||
"rootDir": "..",
|
||||
"outDir": "../out-benchmark",
|
||||
"types": ["../../../node_modules/@types/node"],
|
||||
"moduleResolution": "node",
|
||||
"strict": false,
|
||||
"target": "es2015",
|
||||
"module": "commonjs",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": ["../../../src/common/*"],
|
||||
"browser/*": ["../../../src/browser/*"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,7 +11,6 @@
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -11,13 +11,15 @@
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"@xterm/addon-unicode11": [
|
||||
"../typings/addon-unicode11.d.ts"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"types": [
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ICellData } from 'common/Types';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { rgba } from 'common/Color';
|
||||
import { treatGlyphAsBackgroundColor } from 'browser/renderer/shared/RendererUtils';
|
||||
import { blockPatternCodepoints } from './customGlyphs/CustomGlyphDefinitions';
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $fg = 0;
|
||||
@@ -42,7 +43,7 @@ export class CellColorResolver {
|
||||
* Resolves colors for the cell, putting the result into the shared {@link result}. This resolves
|
||||
* overrides, inverse and selection for the cell which can then be used to feed into the renderer.
|
||||
*/
|
||||
public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number): void {
|
||||
public resolve(cell: ICellData, x: number, y: number, deviceCellWidth: number, deviceCellHeight: number): void {
|
||||
this.result.bg = cell.bg;
|
||||
this.result.fg = cell.fg;
|
||||
this.result.ext = cell.bg & BgFlags.HAS_EXTENDED ? cell.extended.ext : 0;
|
||||
@@ -63,7 +64,9 @@ export class CellColorResolver {
|
||||
const lineWidth = Math.max(1, Math.floor(this._optionService.rawOptions.fontSize * this._coreBrowserService.dpr / 15));
|
||||
$variantOffset = x * deviceCellWidth % (Math.round(lineWidth) * 2);
|
||||
}
|
||||
|
||||
if ($variantOffset === 0 && blockPatternCodepoints.has(code)) {
|
||||
$variantOffset = ((x * deviceCellWidth) % 2) * 2 + ((y * deviceCellHeight) % 2);
|
||||
}
|
||||
// Apply decorations on the bottom layer
|
||||
this._decorationService.forEachDecorationAtCell(x, y, 'bottom', d => {
|
||||
if (d.backgroundColorRGB) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ITerminalOptions, Terminal } from '@xterm/xterm';
|
||||
import { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import { ICharAtlasConfig, ITextureAtlas } from './Types';
|
||||
import { generateConfig, configEquals } from './CharAtlasUtils';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
interface ITextureAtlasCacheEntry {
|
||||
atlas: ITextureAtlas;
|
||||
@@ -67,8 +68,9 @@ export function acquireTextureAtlas(
|
||||
}
|
||||
|
||||
const core: ITerminal = (terminal as any)._core;
|
||||
const logService = (core as any)._logService as ILogService;
|
||||
const newEntry: ITextureAtlasCacheEntry = {
|
||||
atlas: new TextureAtlas(document, newConfig, core.unicodeService),
|
||||
atlas: new TextureAtlas(document, newConfig, core.unicodeService, logService),
|
||||
config: newConfig,
|
||||
ownedBy: [terminal]
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@ import { IdleTaskQueue } from 'common/TaskQueue';
|
||||
import { IColor } from 'common/Types';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { Attributes, DEFAULT_COLOR, DEFAULT_EXT, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { IUnicodeService } from 'common/services/Services';
|
||||
import { ILogService, IUnicodeService } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
/**
|
||||
@@ -88,7 +88,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
constructor(
|
||||
private readonly _document: Document,
|
||||
private readonly _config: ICharAtlasConfig,
|
||||
private readonly _unicodeService: IUnicodeService
|
||||
private readonly _unicodeService: IUnicodeService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
this._createNewPage();
|
||||
this._tmpCanvas = createCanvas(
|
||||
@@ -119,7 +120,7 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
|
||||
private _doWarmUp(): void {
|
||||
// Pre-fill with ASCII 33-126, this is not urgent and done in idle callbacks
|
||||
const queue = new IdleTaskQueue();
|
||||
const queue = new IdleTaskQueue(this._logService);
|
||||
for (let i = 33; i < 126; i++) {
|
||||
queue.enqueue(() => {
|
||||
if (!this._cacheMap.get(i, DEFAULT_COLOR, DEFAULT_COLOR, DEFAULT_EXT)) {
|
||||
@@ -525,7 +526,8 @@ export class TextureAtlas implements ITextureAtlas {
|
||||
// Draw custom characters if applicable
|
||||
let customGlyph = false;
|
||||
if (this._config.customGlyphs !== false) {
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css);
|
||||
const variantOffset = this._workAttributeData.getUnderlineVariantOffset();
|
||||
customGlyph = tryDrawCustomGlyph(this._tmpCtx, chars, padding, padding, this._config.deviceCellWidth, this._config.deviceCellHeight, this._config.deviceCharWidth, this._config.deviceCharHeight, this._config.fontSize, this._config.devicePixelRatio, backgroundColor.css, variantOffset);
|
||||
}
|
||||
|
||||
// Whether to clear pixels based on a threshold difference between the glyph color and the
|
||||
|
||||
@@ -13,7 +13,8 @@ import { ICharSizeService, ICharacterJoinerService, ICoreBrowserService, IThemeS
|
||||
import { CharData, IBufferLine, ICellData } from 'common/Types';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { Attributes, Content, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { Attributes, Content, FgFlags, NULL_CELL_CHAR, NULL_CELL_CODE } from 'common/buffer/Constants';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { GlyphRenderer } from './GlyphRenderer';
|
||||
@@ -23,13 +24,14 @@ import { IWebGL2RenderingContext, type ITextureAtlas } from './Types';
|
||||
import { LinkRenderLayer } from './renderLayer/LinkRenderLayer';
|
||||
import { IRenderLayer } from './renderLayer/Types';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
import { combinedDisposable, Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||
|
||||
export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private _renderLayers: IRenderLayer[];
|
||||
private _cursorBlinkStateManager: MutableDisposable<CursorBlinkStateManager> = new MutableDisposable();
|
||||
private _textBlinkStateManager: TextBlinkStateManager;
|
||||
private _charAtlasDisposable = this._register(new MutableDisposable());
|
||||
private _charAtlas: ITextureAtlas | undefined;
|
||||
private _devicePixelRatio: number;
|
||||
@@ -37,8 +39,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
private _observerDisposable = this._register(new MutableDisposable());
|
||||
|
||||
private _model: RenderModel = new RenderModel();
|
||||
private _rowHasBlinkingCells: boolean[] = [];
|
||||
private _rowHasBlinkingCellsCount: number = 0;
|
||||
private _workCell: ICellData = new CellData();
|
||||
private _workCell2: ICellData = new CellData();
|
||||
private _cellColorResolver: CellColorResolver;
|
||||
|
||||
private _canvas: HTMLCanvasElement;
|
||||
@@ -105,6 +108,12 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateDimensions();
|
||||
this._updateCursorBlink();
|
||||
this._register(_optionsService.onOptionChange(() => this._handleOptionsChanged()));
|
||||
this._textBlinkStateManager = this._register(new TextBlinkStateManager(
|
||||
() => this._requestRedrawViewport(),
|
||||
this._coreBrowserService,
|
||||
this._optionsService
|
||||
));
|
||||
this._resetBlinkingRowState();
|
||||
|
||||
this._deviceMaxTextureSize = this._gl.getParameter(this._gl.MAX_TEXTURE_SIZE);
|
||||
|
||||
@@ -178,6 +187,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._updateDimensions();
|
||||
|
||||
this._model.resize(this._terminal.cols, this._terminal.rows);
|
||||
this._resetBlinkingRowState();
|
||||
|
||||
// Resize all render layers
|
||||
for (const l of this._renderLayers) {
|
||||
@@ -231,6 +241,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
this._requestRedrawViewport();
|
||||
}
|
||||
|
||||
public handleViewportVisibilityChange(isVisible: boolean): void {
|
||||
this._textBlinkStateManager.setViewportVisible(isVisible);
|
||||
}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
|
||||
for (const l of this._renderLayers) {
|
||||
l.handleSelectionChanged(this._terminal, start, end, columnSelectMode);
|
||||
@@ -323,6 +337,9 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
l.reset(this._terminal);
|
||||
}
|
||||
|
||||
this._resetBlinkingRowState();
|
||||
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
|
||||
|
||||
this._cursorBlinkStateManager.value?.restartBlinkAnimation();
|
||||
this._updateCursorBlink();
|
||||
}
|
||||
@@ -420,6 +437,7 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
for (y = start; y <= end; y++) {
|
||||
row = y + terminal.buffer.ydisp;
|
||||
line = terminal.buffer.lines.get(row)!;
|
||||
let rowHasBlinkingCells = false;
|
||||
this._model.lineLengths[y] = 0;
|
||||
isCursorRow = cursorY === row;
|
||||
skipJoinedCheckUntilX = 0;
|
||||
@@ -477,8 +495,12 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
code = cell.getCode();
|
||||
i = ((y * terminal.cols) + x) * RENDER_MODEL_INDICIES_PER_CELL;
|
||||
|
||||
if (!rowHasBlinkingCells && cell.isBlink()) {
|
||||
rowHasBlinkingCells = true;
|
||||
}
|
||||
|
||||
// Load colors/resolve overrides into work colors
|
||||
this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width);
|
||||
this._cellColorResolver.resolve(cell, x, row, this.dimensions.device.cell.width, this.dimensions.device.cell.height);
|
||||
|
||||
// Override colors for cursor cell
|
||||
if (isCursorVisible && row === cursorY) {
|
||||
@@ -506,6 +528,10 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._textBlinkStateManager.isEnabled && !this._textBlinkStateManager.isBlinkOn && cell.isBlink()) {
|
||||
this._cellColorResolver.result.fg |= FgFlags.INVISIBLE;
|
||||
}
|
||||
|
||||
if (code !== NULL_CELL_CODE) {
|
||||
this._model.lineLengths[y] = x + 1;
|
||||
}
|
||||
@@ -552,11 +578,31 @@ export class WebglRenderer extends Disposable implements IRenderer {
|
||||
x--; // Go back to the previous update cell for next iteration
|
||||
}
|
||||
}
|
||||
this._setRowBlinkState(y, rowHasBlinkingCells);
|
||||
}
|
||||
if (modelUpdated) {
|
||||
this._rectangleRenderer.value!.updateBackgrounds(this._model);
|
||||
}
|
||||
this._rectangleRenderer.value!.updateCursor(this._model);
|
||||
this._updateTextBlinkState();
|
||||
}
|
||||
|
||||
private _resetBlinkingRowState(): void {
|
||||
this._rowHasBlinkingCells = new Array(this._terminal.rows).fill(false);
|
||||
this._rowHasBlinkingCellsCount = 0;
|
||||
}
|
||||
|
||||
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
|
||||
const previous = this._rowHasBlinkingCells[row];
|
||||
if (previous === hasBlinkingCells) {
|
||||
return;
|
||||
}
|
||||
this._rowHasBlinkingCells[row] = hasBlinkingCells;
|
||||
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
|
||||
}
|
||||
|
||||
private _updateTextBlinkState(): void {
|
||||
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 });
|
||||
@@ -644,8 +673,8 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi
|
||||
] },
|
||||
|
||||
// Diagonal fill characters (1FB98-1FB99)
|
||||
'\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,0 L1,1 M0,.25 L.75,1 M0,.5 L.5,1 M0,.75 L.25,1 M.25,0 L1,.75 M.5,0 L1,.5 M.75,0 L1,.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL
|
||||
'\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M0,.25 L.25,0 M0,.5 L.5,0 M0,.75 L.75,0 M0,1 L1,0 M.25,1 L1,.25 M.5,1 L1,.5 M.75,1 L1,.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL
|
||||
'\u{1FB98}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,-0.25 L1.25,1.25 M-0.25,0 L1,1.25 M-0.25,0.25 L0.75,1.25 M-0.25,0.5 L0.5,1.25 M0,-0.25 L1.25,1 M0.25,-0.25 L1.25,0.75 M0.5,-0.25 L1.25,0.5 M-0.25,0.75 L0.25,1.25 M0.75,-0.25 L1.25,0.25', strokeWidth: 1 }, // UPPER LEFT TO LOWER RIGHT FILL
|
||||
'\u{1FB99}': { type: CustomGlyphDefinitionType.PATH_FUNCTION, data: 'M-0.25,0.5 L0.5,-0.25 M-0.25,0.75 L0.75,-0.25 M-0.25,1 L1,-0.25 M-0.25,1.25 L1.25,-0.25 M0,1.25 L1.25,0 M0.25,1.25 L1.25,0.25 M0.5,1.25 L1.25,0.5 M-0.25,0.25 L0.25,-0.25 M0.75,1.25 L1.25,0.75', strokeWidth: 1 }, // UPPER RIGHT TO LOWER LEFT FILL
|
||||
|
||||
// Smooth mosaic terminal graphic characters (1FB9A-1FB9B)
|
||||
'\u{1FB9A}': { type: CustomGlyphDefinitionType.VECTOR_SHAPE, data: { d: 'M0,0 L.5,.5 L0,1 L1,1 L.5,.5 L1,0', type: CustomGlyphVectorType.FILL } }, // UPPER AND LOWER TRIANGULAR HALF BLOCK
|
||||
@@ -820,6 +849,27 @@ export const customGlyphDefinitions: { [index: string]: CustomGlyphCharacterDefi
|
||||
// #endregion
|
||||
};
|
||||
|
||||
export const blockPatternCodepoints = new Set<number>([
|
||||
// Shade characters (2591-2593)
|
||||
0x2591,
|
||||
0x2592,
|
||||
0x2593,
|
||||
// Rectangular shade characters (1FB8C-1FB94)
|
||||
0x1FB8C,
|
||||
0x1FB8D,
|
||||
0x1FB8E,
|
||||
0x1FB8F,
|
||||
0x1FB90,
|
||||
0x1FB91,
|
||||
0x1FB92,
|
||||
0x1FB94,
|
||||
// Triangular shade characters (1FB9C-1FB9F)
|
||||
0x1FB9C,
|
||||
0x1FB9D,
|
||||
0x1FB9E,
|
||||
0x1FB9F
|
||||
]);
|
||||
|
||||
/**
|
||||
* Generates a drawing function for sextant characters. Sextants are a 2x3 grid where each cell
|
||||
* can be on or off.
|
||||
@@ -967,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',
|
||||
}
|
||||
|
||||
@@ -22,14 +22,15 @@ export function tryDrawCustomGlyph(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
backgroundColor?: string
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): boolean {
|
||||
const unifiedCharDefinition = customGlyphDefinitions[c];
|
||||
if (unifiedCharDefinition) {
|
||||
// Normalize to array for uniform handling
|
||||
const parts = Array.isArray(unifiedCharDefinition) ? unifiedCharDefinition : [unifiedCharDefinition];
|
||||
for (const part of parts) {
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor);
|
||||
drawDefinitionPart(ctx, part, xOffset, yOffset, deviceCellWidth, deviceCellHeight, deviceCharWidth, deviceCharHeight, fontSize, devicePixelRatio, backgroundColor, variantOffset);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
@@ -48,7 +49,8 @@ function drawDefinitionPart(
|
||||
deviceCharHeight: number,
|
||||
fontSize: number,
|
||||
devicePixelRatio: number,
|
||||
backgroundColor?: string
|
||||
backgroundColor?: string,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
// Handle scaleType - adjust dimensions and offset when scaling to character area
|
||||
let drawWidth = deviceCellWidth;
|
||||
@@ -74,7 +76,7 @@ function drawDefinitionPart(
|
||||
drawBlockVectorChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.BLOCK_PATTERN:
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight);
|
||||
drawPatternChar(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, variantOffset);
|
||||
break;
|
||||
case CustomGlyphDefinitionType.PATH_FUNCTION:
|
||||
drawPathFunctionCharacter(ctx, part.data, drawXOffset, drawYOffset, drawWidth, drawHeight, devicePixelRatio, part.strokeWidth);
|
||||
@@ -437,7 +439,8 @@ function drawPatternChar(
|
||||
xOffset: number,
|
||||
yOffset: number,
|
||||
deviceCellWidth: number,
|
||||
deviceCellHeight: number
|
||||
deviceCellHeight: number,
|
||||
variantOffset: number = 0
|
||||
): void {
|
||||
let patternSet = cachedPatterns.get(charDefinition);
|
||||
if (!patternSet) {
|
||||
@@ -486,6 +489,15 @@ function drawPatternChar(
|
||||
pattern = throwIfFalsy(ctx.createPattern(tmpCanvas, null));
|
||||
patternSet.set(fillStyle, pattern);
|
||||
}
|
||||
// Apply pattern offset to ensure seamless tiling across cells when cell dimensions are odd.
|
||||
// variantOffset encodes: bit 1 = x pixel shift, bit 0 = y pixel shift.
|
||||
const dx = (variantOffset >> 1) & 1;
|
||||
const dy = variantOffset & 1;
|
||||
if (dx !== 0 || dy !== 0) {
|
||||
pattern.setTransform(new DOMMatrix().translateSelf(-dx, -dy));
|
||||
} else {
|
||||
pattern.setTransform(new DOMMatrix());
|
||||
}
|
||||
ctx.fillStyle = pattern;
|
||||
ctx.fillRect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
|
||||
}
|
||||
@@ -500,6 +512,11 @@ function drawPathFunctionCharacter(
|
||||
devicePixelRatio: number,
|
||||
strokeWidth?: number
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.rect(xOffset, yOffset, deviceCellWidth, deviceCellHeight);
|
||||
ctx.clip();
|
||||
|
||||
ctx.beginPath();
|
||||
let actualInstructions: string;
|
||||
if (typeof charDefinition === 'function') {
|
||||
@@ -526,7 +543,7 @@ function drawPathFunctionCharacter(
|
||||
if (!args[0] || !args[1]) {
|
||||
continue;
|
||||
}
|
||||
f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio), state);
|
||||
f(ctx, translateArgs(args, deviceCellWidth, deviceCellHeight, xOffset, yOffset, true, devicePixelRatio, 0, 0, false), state);
|
||||
state.lastCommand = type;
|
||||
}
|
||||
if (strokeWidth !== undefined) {
|
||||
@@ -537,6 +554,7 @@ function drawPathFunctionCharacter(
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.closePath();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -685,7 +703,7 @@ const svgToCanvasInstructionMap: { [index: string]: (ctx: CanvasRenderingContext
|
||||
}
|
||||
};
|
||||
|
||||
function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0): number[] {
|
||||
function translateArgs(args: string[], cellWidth: number, cellHeight: number, xOffset: number, yOffset: number, doClamp: boolean, devicePixelRatio: number, leftPadding: number = 0, rightPadding: number = 0, clampToCell: boolean = true): number[] {
|
||||
const result = args.map(e => parseFloat(e) || parseInt(e));
|
||||
|
||||
if (result.length < 2) {
|
||||
@@ -695,10 +713,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO
|
||||
for (let x = 0; x < result.length; x += 2) {
|
||||
// Translate from 0-1 to 0-cellWidth
|
||||
result[x] *= cellWidth - (leftPadding * devicePixelRatio) - (rightPadding * devicePixelRatio);
|
||||
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
|
||||
// line at 100% devicePixelRatio
|
||||
// Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally
|
||||
// clamp to the cell bounds.
|
||||
if (doClamp && result[x] !== 0) {
|
||||
result[x] = clamp(Math.round(result[x] + 0.5) - 0.5, cellWidth, 0);
|
||||
const rounded = Math.round(result[x] + 0.5) - 0.5;
|
||||
result[x] = clampToCell ? clamp(rounded, cellWidth, 0) : rounded;
|
||||
}
|
||||
// Apply the cell's offset (ie. x*cellWidth)
|
||||
result[x] += xOffset + (leftPadding * devicePixelRatio);
|
||||
@@ -707,10 +726,11 @@ function translateArgs(args: string[], cellWidth: number, cellHeight: number, xO
|
||||
for (let y = 1; y < result.length; y += 2) {
|
||||
// Translate from 0-1 to 0-cellHeight
|
||||
result[y] *= cellHeight;
|
||||
// Ensure coordinate doesn't escape cell bounds and round to the nearest 0.5 to ensure a crisp
|
||||
// line at 100% devicePixelRatio
|
||||
// Round to the nearest 0.5 to ensure a crisp line at 100% devicePixelRatio, and optionally
|
||||
// clamp to the cell bounds.
|
||||
if (doClamp && result[y] !== 0) {
|
||||
result[y] = clamp(Math.round(result[y] + 0.5) - 0.5, cellHeight, 0);
|
||||
const rounded = Math.round(result[y] + 0.5) - 0.5;
|
||||
result[y] = clampToCell ? clamp(rounded, cellHeight, 0) : rounded;
|
||||
}
|
||||
// Apply the cell's offset (ie. x*cellHeight)
|
||||
result[y] += yOffset;
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"outDir": "../out",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
@@ -18,11 +17,11 @@
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"vs/*": [
|
||||
"../../../src/vs/*"
|
||||
],
|
||||
"@xterm/addon-webgl": [
|
||||
"../typings/addon-webgl.d.ts"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
@@ -42,9 +41,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,13 +9,15 @@
|
||||
"outDir": "../out-test",
|
||||
"sourceMap": true,
|
||||
"removeComments": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"common/*": [
|
||||
"../../../src/common/*"
|
||||
],
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"strict": true,
|
||||
|
||||
@@ -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']);
|
||||
+2
-3
@@ -127,6 +127,7 @@ if (config.addon) {
|
||||
} else if (config.isDemoClient) {
|
||||
bundleConfig = {
|
||||
...bundleConfig,
|
||||
sourcemap: false,
|
||||
entryPoints: [`demo/client/client.ts`],
|
||||
outfile: 'demo/dist/client-bundle.js',
|
||||
external: ['util', 'os', 'fs', 'path', 'stream', 'Terminal'],
|
||||
@@ -190,9 +191,7 @@ if (config.addon) {
|
||||
entryPoints: [
|
||||
'src/browser/**/*.ts',
|
||||
'src/common/**/*.ts',
|
||||
'src/headless/**/*.ts',
|
||||
'src/vs/base/**/*.ts',
|
||||
'src/vs/patches/**/*.ts'
|
||||
'src/headless/**/*.ts'
|
||||
],
|
||||
outdir: 'out-esbuild/'
|
||||
};
|
||||
|
||||
@@ -25,7 +25,6 @@ const addons = [
|
||||
'clipboard',
|
||||
'fit',
|
||||
'image',
|
||||
'kitty-graphics',
|
||||
'progress',
|
||||
'search',
|
||||
'serialize',
|
||||
|
||||
+24
-11
@@ -218,17 +218,30 @@
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
background-color: var(--vscode-scrollbarSliderBackground, rgba(100, 100, 100, 0.4));
|
||||
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>");
|
||||
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>");
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center center;
|
||||
-webkit-mask-position: center center;
|
||||
mask-size: 100% 100%;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra.xterm-arrow-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .xterm-visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
@@ -238,20 +251,20 @@
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
.xterm .xterm-scrollable-element > .xterm-invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
.xterm .xterm-scrollable-element > .xterm-invisible.xterm-fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
@@ -259,7 +272,7 @@
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
@@ -267,13 +280,13 @@
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top.xterm-shadow-left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ function createTerminal(): Terminal {
|
||||
|
||||
const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0;
|
||||
term = new Terminal({
|
||||
scrollbar: { showScrollbar: true },
|
||||
allowProposedApi: true,
|
||||
windowsPty: isWindows ? {
|
||||
// In a real scenario, these values should be verified on the backend
|
||||
|
||||
@@ -115,17 +115,20 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
'documentOverride',
|
||||
'linkHandler',
|
||||
'logger',
|
||||
'overviewRuler',
|
||||
'quirks',
|
||||
'theme',
|
||||
'vtExtensions',
|
||||
'windowOptions',
|
||||
'windowsPty',
|
||||
];
|
||||
const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [
|
||||
{ label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' },
|
||||
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' },
|
||||
{ label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' }
|
||||
const nestedBooleanOptions: { label: string, path: string[], prop: string }[] = [
|
||||
{ label: 'scrollbar.showScrollbar', path: ['scrollbar'], prop: 'showScrollbar' },
|
||||
{ label: 'scrollbar.showArrows', path: ['scrollbar'], prop: 'showArrows' },
|
||||
{ label: 'scrollbar.overviewRuler.showTopBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showTopBorder' },
|
||||
{ label: 'scrollbar.overviewRuler.showBottomBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showBottomBorder' },
|
||||
{ label: 'vtExtensions.kittyKeyboard', path: ['vtExtensions'], prop: 'kittyKeyboard' },
|
||||
{ label: 'vtExtensions.kittySgrBoldFaintControl', path: ['vtExtensions'], prop: 'kittySgrBoldFaintControl' },
|
||||
{ label: 'vtExtensions.win32InputMode', path: ['vtExtensions'], prop: 'win32InputMode' }
|
||||
];
|
||||
const stringOptions: { [key: string]: string[] | null } = {
|
||||
cursorStyle: ['block', 'underline', 'bar'],
|
||||
@@ -161,8 +164,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
booleanOptions.forEach(o => {
|
||||
html += `<div class="option"><label><input id="opt-${o}" type="checkbox" ${(this._terminal.options as Record<string, unknown>)[o] ? 'checked' : ''}/> ${o}</label></div>`;
|
||||
});
|
||||
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
|
||||
const checked = (this._terminal.options as Record<string, Record<string, unknown> | undefined>)[parent]?.[prop] ?? false;
|
||||
nestedBooleanOptions.forEach(({ label, path, prop }) => {
|
||||
const options = this._terminal.options as Record<string, unknown>;
|
||||
const parent = path.reduce<Record<string, unknown> | undefined>((acc, key) => (acc as Record<string, unknown> | undefined)?.[key] as Record<string, unknown> | undefined, options);
|
||||
const checked = (parent as Record<string, unknown> | undefined)?.[prop] ?? false;
|
||||
html += `<div class="option"><label><input id="opt-${label.replace('.', '-')}" type="checkbox" ${checked ? 'checked' : ''}/> ${label}</label></div>`;
|
||||
});
|
||||
html += '</div><div class="option-group">';
|
||||
@@ -196,11 +201,22 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
}
|
||||
});
|
||||
});
|
||||
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
|
||||
nestedBooleanOptions.forEach(({ label, path, prop }) => {
|
||||
const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement;
|
||||
addDomListener(input, 'change', () => {
|
||||
console.log('change', label, input.checked);
|
||||
(this._terminal.options as Record<string, unknown>)[parent] = { ...(this._terminal.options as Record<string, Record<string, unknown> | undefined>)[parent], [prop]: input.checked };
|
||||
const options = this._terminal.options as Record<string, unknown>;
|
||||
if (path.length === 1) {
|
||||
const parentKey = path[0];
|
||||
options[parentKey] = { ...(options[parentKey] as Record<string, unknown> | undefined), [prop]: input.checked };
|
||||
return;
|
||||
}
|
||||
if (path.length === 2) {
|
||||
const [parentKey, childKey] = path;
|
||||
const parent = (options[parentKey] as Record<string, unknown> | undefined) ?? {};
|
||||
const child = (parent[childKey] as Record<string, unknown> | undefined) ?? {};
|
||||
options[parentKey] = { ...parent, [childKey]: { ...child, [prop]: input.checked } };
|
||||
}
|
||||
});
|
||||
});
|
||||
numberOptions.forEach(o => {
|
||||
|
||||
@@ -397,6 +397,13 @@ function customGlyphAlignmentHandler(term: Terminal): void {
|
||||
}
|
||||
}
|
||||
|
||||
term.write('\x1b[0mTriangular fill tests:\x1b[36m\n\r');
|
||||
term.write('1FB9C 1FB9D 1FB9E 1FB9F all\n\r');
|
||||
term.write('\u{02592}\u{02592}\u{02592}\u{1FB9C} \u{1FB9D}\u{02592}\u{02592}\u{02592} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r');
|
||||
term.write('\u{02592}\u{02592}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{02592}\u{02592} \u{00020}\u{00020}\u{1FB9E}\u{02592} \u{02592}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{02592}\u{02592}\u{1FB9F}\n\r');
|
||||
term.write('\u{02592}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{02592} \u{00020}\u{1FB9E}\u{02592}\u{02592} \u{02592}\u{02592}\u{1FB9F}\u{00020} \u{1FB9D}\u{02592}\u{02592}\u{1FB9C}\n\r');
|
||||
term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{02592}\u{02592}\u{02592} \u{02592}\u{02592}\u{02592}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r');
|
||||
|
||||
term.write('\x1b[0mPowerline alignment tests:\n\r');
|
||||
const powerlineLeftChars = ['\u{E0B2}', '\u{E0B3}', '\u{E0B6}', '\u{E0B7}', '\u{E0BA}', '\u{E0BB}', '\u{E0BE}', '\u{E0BF}', '\u{E0C2}', '\u{E0C3}', '\u{E0C5}', '\u{E0C7}', '\u{E0CA}', '\u{E0D4}'];
|
||||
const powerlineRightChars = ['\u{E0B0}', '\u{E0B1}', '\u{E0B4}', '\u{E0B5}', '\u{E0B8}', '\u{E0B9}', '\u{E0BC}', '\u{E0BD}', '\u{E0C0}', '\u{E0C1}', '\u{E0C4}', '\u{E0C6}', '\u{E0C8}', '\u{E0D2}', '\u{E0CC}', '\u{E0CD}', '\u{E0CE}', '\u{E0CF}', '\u{E0D0}', '\u{E0D1}'];
|
||||
@@ -738,7 +745,7 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
||||
}
|
||||
|
||||
function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
term.options['overviewRuler'] = { width: 14 };
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
const marker = term.registerMarker(1);
|
||||
const decoration = term.registerDecoration({
|
||||
marker,
|
||||
@@ -755,7 +762,7 @@ function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
}
|
||||
|
||||
function addOverviewRuler(term: Terminal): void {
|
||||
term.options['overviewRuler'] = { width: 14 };
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
term.registerDecoration({ marker: term.registerMarker(1), overviewRulerOptions: { color: '#ef2929' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(3), overviewRulerOptions: { color: '#8ae234' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(5), overviewRulerOptions: { color: '#729fcf' } });
|
||||
|
||||
@@ -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"],
|
||||
@@ -19,11 +19,13 @@
|
||||
"@xterm/addon-web-links": ["../../addons/addon-web-links"],
|
||||
"@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-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
|
||||
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"],
|
||||
"*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*", "../../typings/xterm.d.ts"]
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../typings/xterm.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
"outDir": "../out-demo/server",
|
||||
"rootDir": ".",
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"./**/*",
|
||||
|
||||
@@ -10,7 +10,6 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'addons/*/src/third-party/*.ts',
|
||||
'src/vs/*',
|
||||
'**/out/*',
|
||||
'**/out-test/*',
|
||||
'**/out-esbuild/*',
|
||||
|
||||
Generated
+118
-6
@@ -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",
|
||||
@@ -82,12 +83,6 @@
|
||||
"xterm-wasm-parts": "^0.3.0"
|
||||
}
|
||||
},
|
||||
"addons/addon-kitty-graphics": {
|
||||
"name": "@xterm/addon-kitty-graphics",
|
||||
"version": "0.1.0",
|
||||
"extraneous": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"addons/addon-ligatures": {
|
||||
"name": "@xterm/addon-ligatures",
|
||||
"version": "0.10.0",
|
||||
@@ -1976,6 +1971,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",
|
||||
|
||||
+5
-3
@@ -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",
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { ICoreBrowserService, IRenderService } from 'browser/services/Services';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
import { IInstantiationService } from 'common/services/Services';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
const MAX_ROWS_TO_READ = 20;
|
||||
|
||||
|
||||
@@ -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~`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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~`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -56,7 +56,7 @@ import { WindowsOptionsReportType } from '../common/InputHandler';
|
||||
import { AccessibilityManager } from './AccessibilityManager';
|
||||
import { Linkifier } from './Linkifier';
|
||||
import { Emitter, EventUtils, type IEvent } from 'common/Event';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
import { MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
@@ -616,11 +616,14 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal {
|
||||
}
|
||||
this._register(this.optionsService.onSpecificOptionChange('screenReaderMode', e => this._handleScreenReaderModeOptionChange(e)));
|
||||
|
||||
if (this.options.overviewRuler.width) {
|
||||
const showScrollbar = this.options.scrollbar?.showScrollbar ?? true;
|
||||
const overviewRulerWidth = this.options.scrollbar?.width;
|
||||
if (showScrollbar && overviewRulerWidth) {
|
||||
this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement));
|
||||
}
|
||||
this.optionsService.onSpecificOptionChange('overviewRuler', value => {
|
||||
if (!this._overviewRulerRenderer && value && this._viewportElement && this.screenElement) {
|
||||
this.optionsService.onSpecificOptionChange('scrollbar', value => {
|
||||
const shouldShow = (value?.showScrollbar ?? true) && !!value?.width;
|
||||
if (!this._overviewRulerRenderer && shouldShow && this._viewportElement && this.screenElement) {
|
||||
this._overviewRulerRenderer = this._register(this._instantiationService.createInstance(OverviewRulerRenderer, this._viewportElement, this.screenElement));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Minimal DOM helpers for xterm.js browser code.
|
||||
*/
|
||||
|
||||
import { IntervalTimer } from 'common/Async';
|
||||
import { IDisposable } from 'common/Lifecycle';
|
||||
|
||||
export function getWindow(e: Node | UIEvent | undefined | null): Window {
|
||||
const candidateNode = e as Node | undefined | null;
|
||||
if (candidateNode?.ownerDocument?.defaultView) {
|
||||
return candidateNode.ownerDocument.defaultView;
|
||||
}
|
||||
|
||||
const candidateEvent = e as UIEvent | undefined | null;
|
||||
if (candidateEvent?.view) {
|
||||
return candidateEvent.view;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
class DomListener implements IDisposable {
|
||||
private _handler: ((e: any) => void) | null;
|
||||
private _node: EventTarget | null;
|
||||
private readonly _type: string;
|
||||
private readonly _options: boolean | AddEventListenerOptions | undefined;
|
||||
|
||||
constructor(node: EventTarget, type: string, handler: (e: any) => void, options?: boolean | AddEventListenerOptions) {
|
||||
this._node = node;
|
||||
this._type = type;
|
||||
this._handler = handler;
|
||||
this._options = options;
|
||||
node.addEventListener(type, handler, options);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (!this._node || !this._handler) {
|
||||
return;
|
||||
}
|
||||
this._node.removeEventListener(this._type, this._handler, this._options);
|
||||
this._node = null;
|
||||
this._handler = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function addDisposableListener<K extends keyof GlobalEventHandlersEventMap>(node: EventTarget, type: K, handler: (event: GlobalEventHandlersEventMap[K]) => void, useCapture?: boolean): IDisposable;
|
||||
export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable;
|
||||
export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, options: AddEventListenerOptions): IDisposable;
|
||||
export function addDisposableListener(node: EventTarget, type: string, handler: (event: any) => void, useCaptureOrOptions?: boolean | AddEventListenerOptions): IDisposable {
|
||||
return new DomListener(node, type, handler, useCaptureOrOptions);
|
||||
}
|
||||
|
||||
export function addStandardDisposableListener(node: HTMLElement, type: string, handler: (event: any) => void, useCapture?: boolean): IDisposable {
|
||||
return addDisposableListener(node, type, handler, useCapture);
|
||||
}
|
||||
|
||||
export const eventType = {
|
||||
CLICK: 'click',
|
||||
MOUSE_DOWN: 'mousedown',
|
||||
MOUSE_OVER: 'mouseover',
|
||||
MOUSE_LEAVE: 'mouseleave',
|
||||
KEY_DOWN: 'keydown',
|
||||
KEY_UP: 'keyup',
|
||||
INPUT: 'input',
|
||||
BLUR: 'blur',
|
||||
FOCUS: 'focus',
|
||||
CHANGE: 'change',
|
||||
POINTER_DOWN: 'pointerdown',
|
||||
POINTER_MOVE: 'pointermove',
|
||||
POINTER_UP: 'pointerup',
|
||||
MOUSE_WHEEL: 'wheel',
|
||||
WHEEL: 'wheel'
|
||||
} as const;
|
||||
|
||||
export function getDomNodePagePosition(domNode: HTMLElement): { left: number, top: number, width: number, height: number } {
|
||||
const bb = domNode.getBoundingClientRect();
|
||||
const win = getWindow(domNode);
|
||||
return {
|
||||
left: bb.left + win.scrollX,
|
||||
top: bb.top + win.scrollY,
|
||||
width: bb.width,
|
||||
height: bb.height
|
||||
};
|
||||
}
|
||||
|
||||
class AnimationFrameQueueItem implements IDisposable {
|
||||
private _canceled = false;
|
||||
|
||||
constructor(private readonly _runner: () => void, public priority: number) {
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this._canceled = true;
|
||||
}
|
||||
|
||||
public execute(): void {
|
||||
if (this._canceled) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
this._runner();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
public static sort(a: AnimationFrameQueueItem, b: AnimationFrameQueueItem): number {
|
||||
return b.priority - a.priority;
|
||||
}
|
||||
}
|
||||
|
||||
interface IWindowAnimationFrameState {
|
||||
next: AnimationFrameQueueItem[];
|
||||
current: AnimationFrameQueueItem[];
|
||||
animFrameRequested: boolean;
|
||||
inAnimationFrameRunner: boolean;
|
||||
}
|
||||
|
||||
const animationFrameState = new Map<Window, IWindowAnimationFrameState>();
|
||||
|
||||
function getAnimationFrameState(targetWindow: Window): IWindowAnimationFrameState {
|
||||
let state = animationFrameState.get(targetWindow);
|
||||
if (!state) {
|
||||
state = {
|
||||
next: [],
|
||||
current: [],
|
||||
animFrameRequested: false,
|
||||
inAnimationFrameRunner: false
|
||||
};
|
||||
animationFrameState.set(targetWindow, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
function animationFrameRunner(targetWindow: Window): void {
|
||||
const state = getAnimationFrameState(targetWindow);
|
||||
state.animFrameRequested = false;
|
||||
|
||||
state.current = state.next;
|
||||
state.next = [];
|
||||
|
||||
state.inAnimationFrameRunner = true;
|
||||
while (state.current.length > 0) {
|
||||
state.current.sort(AnimationFrameQueueItem.sort);
|
||||
const top = state.current.shift()!;
|
||||
top.execute();
|
||||
}
|
||||
state.inAnimationFrameRunner = false;
|
||||
}
|
||||
|
||||
export function scheduleAtNextAnimationFrame(targetWindow: Window, runner: () => void, priority: number = 0): IDisposable {
|
||||
const state = getAnimationFrameState(targetWindow);
|
||||
const item = new AnimationFrameQueueItem(runner, priority);
|
||||
state.next.push(item);
|
||||
|
||||
if (!state.animFrameRequested) {
|
||||
state.animFrameRequested = true;
|
||||
targetWindow.requestAnimationFrame(() => animationFrameRunner(targetWindow));
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
export class WindowIntervalTimer extends IntervalTimer {
|
||||
private readonly _defaultTarget?: Window;
|
||||
|
||||
constructor(node?: Node) {
|
||||
super();
|
||||
this._defaultTarget = node ? getWindow(node) : undefined;
|
||||
}
|
||||
|
||||
public cancelAndSet(runner: () => void, interval: number, targetWindow?: Window): void {
|
||||
super.cancelAndSet(runner, interval, targetWindow ?? this._defaultTarget ?? window);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import { IDisposable } from 'common/Types';
|
||||
import { IBufferService } from 'common/services/Services';
|
||||
import { ILinkProviderService, IMouseService, IRenderService } from './services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
export class Linkifier extends Disposable implements ILinkifier2 {
|
||||
public get currentLink(): ILinkWithState | undefined { return this._currentLink; }
|
||||
|
||||
+24
-16
@@ -8,12 +8,12 @@ import { ViewportConstants } from 'browser/shared/Constants';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IBufferService, ICoreMouseService, IOptionsService } from 'common/services/Services';
|
||||
import { CoreMouseEventType } from 'common/Types';
|
||||
import { addDisposableListener, scheduleAtNextAnimationFrame } from 'vs/base/browser/dom';
|
||||
import { SmoothScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement';
|
||||
import type { ScrollableElementChangeOptions } from 'vs/base/browser/ui/scrollbar/scrollableElementOptions';
|
||||
import { addDisposableListener, scheduleAtNextAnimationFrame } from 'browser/Dom';
|
||||
import { SmoothScrollableElement } from 'browser/scrollable/scrollableElement';
|
||||
import type { IScrollableElementChangeOptions } from 'browser/scrollable/scrollableElementOptions';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
import { Scrollable, ScrollbarVisibility, type ScrollEvent } from 'vs/base/common/scrollable';
|
||||
import { Gesture, EventType as GestureEventType, type GestureEvent } from 'vs/base/browser/touch';
|
||||
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 {
|
||||
|
||||
@@ -52,16 +52,17 @@ export class Viewport extends Disposable {
|
||||
}));
|
||||
|
||||
this._scrollableElement = this._register(new SmoothScrollableElement(screenElement, {
|
||||
vertical: ScrollbarVisibility.Auto,
|
||||
horizontal: ScrollbarVisibility.Hidden,
|
||||
vertical: ScrollbarVisibility.AUTO,
|
||||
horizontal: ScrollbarVisibility.HIDDEN,
|
||||
useShadows: false,
|
||||
mouseWheelSmoothScroll: true,
|
||||
verticalHasArrows: this._optionsService.rawOptions.scrollbar?.showArrows ?? false,
|
||||
...this._getChangeOptions()
|
||||
}, scrollable));
|
||||
this._register(this._optionsService.onMultipleOptionChange([
|
||||
'scrollSensitivity',
|
||||
'fastScrollSensitivity',
|
||||
'overviewRuler'
|
||||
'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 => {
|
||||
@@ -83,13 +84,13 @@ export class Viewport extends Disposable {
|
||||
this._register(toDisposable(() => this._styleElement.remove()));
|
||||
this._register(EventUtils.runAndSubscribe(themeService.onChangeColors, () => {
|
||||
this._styleElement.textContent = [
|
||||
`.xterm .xterm-scrollable-element > .scrollbar > .slider {`,
|
||||
`.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider {`,
|
||||
` background: ${themeService.colors.scrollbarSliderBackground.css};`,
|
||||
`}`,
|
||||
`.xterm .xterm-scrollable-element > .scrollbar > .slider:hover {`,
|
||||
`.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider:hover {`,
|
||||
` background: ${themeService.colors.scrollbarSliderHoverBackground.css};`,
|
||||
`}`,
|
||||
`.xterm .xterm-scrollable-element > .scrollbar > .slider.active {`,
|
||||
`.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-slider.xterm-active {`,
|
||||
` background: ${themeService.colors.scrollbarSliderActiveBackground.css};`,
|
||||
`}`
|
||||
].join('\n');
|
||||
@@ -108,7 +109,7 @@ export class Viewport extends Disposable {
|
||||
|
||||
// Touch/gesture scrolling support
|
||||
this._register(Gesture.addTarget(screenElement));
|
||||
this._register(addDisposableListener(screenElement, GestureEventType.Change, (e: GestureEvent) => this._handleGestureChange(e)));
|
||||
this._register(addDisposableListener(screenElement, GestureEventType.CHANGE, (e: IGestureEvent) => this._handleGestureChange(e)));
|
||||
}
|
||||
|
||||
public scrollLines(disp: number): void {
|
||||
@@ -129,11 +130,18 @@ export class Viewport extends Disposable {
|
||||
});
|
||||
}
|
||||
|
||||
private _getChangeOptions(): ScrollableElementChangeOptions {
|
||||
private _getChangeOptions(): IScrollableElementChangeOptions {
|
||||
const showScrollbar = this._optionsService.rawOptions.scrollbar?.showScrollbar ?? true;
|
||||
const showArrows = this._optionsService.rawOptions.scrollbar?.showArrows ?? false;
|
||||
const verticalScrollbarSize = showScrollbar
|
||||
? (this._optionsService.rawOptions.scrollbar?.width ?? ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)
|
||||
: 0;
|
||||
return {
|
||||
mouseWheelScrollSensitivity: this._optionsService.rawOptions.scrollSensitivity,
|
||||
fastScrollSensitivity: this._optionsService.rawOptions.fastScrollSensitivity,
|
||||
verticalScrollbarSize: this._optionsService.rawOptions.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH
|
||||
vertical: showScrollbar ? ScrollbarVisibility.AUTO : ScrollbarVisibility.HIDDEN,
|
||||
verticalScrollbarSize,
|
||||
verticalHasArrows: showArrows
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,7 +187,7 @@ export class Viewport extends Disposable {
|
||||
this._isSyncing = false;
|
||||
}
|
||||
|
||||
private _handleScroll(e: ScrollEvent): void {
|
||||
private _handleScroll(e: IScrollEvent): void {
|
||||
if (!this._renderService) {
|
||||
return;
|
||||
}
|
||||
@@ -196,7 +204,7 @@ export class Viewport extends Disposable {
|
||||
this._isHandlingScroll = false;
|
||||
}
|
||||
|
||||
private _handleGestureChange(e: GestureEvent): void {
|
||||
private _handleGestureChange(e: IGestureEvent): void {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const pos = this._scrollableElement.getScrollPosition();
|
||||
|
||||
@@ -38,7 +38,12 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
private readonly _ctx: CanvasRenderingContext2D;
|
||||
private readonly _colorZoneStore: IColorZoneStore = new ColorZoneStore();
|
||||
private get _width(): number {
|
||||
return this._optionsService.options.overviewRuler?.width || 0;
|
||||
const scrollbar = this._optionsService.rawOptions.scrollbar;
|
||||
const showScrollbar = scrollbar?.showScrollbar ?? true;
|
||||
if (!showScrollbar) {
|
||||
return 0;
|
||||
}
|
||||
return scrollbar?.width ?? 0;
|
||||
}
|
||||
private _animationFrame: number | undefined;
|
||||
|
||||
@@ -46,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,
|
||||
@@ -86,16 +89,10 @@ 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('overviewRuler', () => this._queueRefresh(true)));
|
||||
this._register(this._optionsService.onSpecificOptionChange('scrollbar', () => this._queueRefresh(true)));
|
||||
this._register(this._themeService.onChangeColors(() => this._queueRefresh()));
|
||||
this._queueRefresh(true);
|
||||
}
|
||||
@@ -139,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();
|
||||
}
|
||||
@@ -176,10 +175,10 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
private _renderRulerOutline(): void {
|
||||
this._ctx.fillStyle = this._themeService.colors.overviewRulerBorder.css;
|
||||
this._ctx.fillRect(0, 0, Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height);
|
||||
if (this._optionsService.rawOptions.overviewRuler.showTopBorder) {
|
||||
if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showTopBorder) {
|
||||
this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, 0, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, Constants.OVERVIEW_RULER_BORDER_WIDTH);
|
||||
}
|
||||
if (this._optionsService.rawOptions.overviewRuler.showBottomBorder) {
|
||||
if (this._optionsService.rawOptions.scrollbar?.overviewRuler?.showBottomBorder) {
|
||||
this._ctx.fillRect(Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.width - Constants.OVERVIEW_RULER_BORDER_WIDTH, this._canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,7 @@ export class Terminal extends Disposable implements ITerminalApi {
|
||||
public get onDimensionsChange(): IEvent<IRenderDimensions> { return this._core.onDimensionsChange; }
|
||||
|
||||
public get element(): HTMLElement | undefined { return this._core.element; }
|
||||
public get screenElement(): HTMLElement | undefined { return this._core.screenElement; }
|
||||
public get parser(): IParser {
|
||||
return this._parser ??= new ParserApi(this._core);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { WidthCache } from 'browser/renderer/dom/WidthCache';
|
||||
import { INVERTED_DEFAULT_COLOR, RendererConstants } from 'browser/renderer/shared/Constants';
|
||||
import { createRenderDimensions } from 'browser/renderer/shared/RendererUtils';
|
||||
import { createSelectionRenderModel } from 'browser/renderer/shared/SelectionRenderModel';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { IRenderDimensions, IRenderer, IRequestRedrawEvent, ISelectionRenderModel } from 'browser/renderer/shared/Types';
|
||||
import { ICharSizeService, ICoreBrowserService, IThemeService } from 'browser/services/Services';
|
||||
import { ILinkifier2, ILinkifierEvent, ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
@@ -15,7 +16,7 @@ import { color } from 'common/Color';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IBufferService, ICoreService, IInstantiationService, IOptionsService } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
|
||||
|
||||
const TERMINAL_CLASS_PREFIX = 'xterm-dom-renderer-owner-';
|
||||
@@ -45,10 +46,14 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
private _widthCache: WidthCache;
|
||||
private _selectionRenderModel: ISelectionRenderModel = createSelectionRenderModel();
|
||||
private _cursorBlinkStateManager: CursorBlinkStateManager;
|
||||
private _textBlinkStateManager: TextBlinkStateManager;
|
||||
private _rowHasBlinkingCells: boolean[] = [];
|
||||
private _rowHasBlinkingCellsCount: number = 0;
|
||||
|
||||
public dimensions: IRenderDimensions;
|
||||
|
||||
public readonly onRequestRedraw = this._register(new Emitter<IRequestRedrawEvent>()).event;
|
||||
private readonly _onRequestRedraw = this._register(new Emitter<IRequestRedrawEvent>());
|
||||
public readonly onRequestRedraw = this._onRequestRedraw.event;
|
||||
|
||||
constructor(
|
||||
private readonly _terminal: ITerminal,
|
||||
@@ -95,6 +100,11 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
this._cursorBlinkStateManager = new CursorBlinkStateManager(this._rowContainer, this._coreBrowserService);
|
||||
this._register(addDisposableListener(this._document, 'mousedown', () => this._cursorBlinkStateManager.restartBlinkAnimation()));
|
||||
this._register(toDisposable(() => this._cursorBlinkStateManager.dispose()));
|
||||
this._textBlinkStateManager = this._register(new TextBlinkStateManager(
|
||||
() => this._onRequestRedraw.fire({ start: 0, end: this._bufferService.rows - 1 }),
|
||||
this._coreBrowserService,
|
||||
this._optionsService
|
||||
));
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
this._element.classList.remove(TERMINAL_CLASS_PREFIX + this._terminalClass);
|
||||
@@ -193,6 +203,9 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
`}` +
|
||||
`${this._terminalSelector} span.${RowCss.ITALIC_CLASS} {` +
|
||||
` font-style: italic;` +
|
||||
`}` +
|
||||
`${this._terminalSelector} span.${RowCss.BLINK_HIDDEN_CLASS} {` +
|
||||
` visibility: hidden;` +
|
||||
`}`;
|
||||
// Blink animation
|
||||
const blinkAnimationUnderlineId = `blink_underline_${this._terminalClass}`;
|
||||
@@ -318,10 +331,14 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
const row = this._document.createElement('div');
|
||||
this._rowContainer.appendChild(row);
|
||||
this._rowElements.push(row);
|
||||
this._rowHasBlinkingCells.push(false);
|
||||
}
|
||||
// Remove excess elements
|
||||
while (this._rowElements.length > rows) {
|
||||
this._rowContainer.removeChild(this._rowElements.pop()!);
|
||||
if (this._rowHasBlinkingCells.pop()) {
|
||||
this._rowHasBlinkingCellsCount--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,6 +366,10 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
this.renderRows(this._bufferService.buffer.y, this._bufferService.buffer.y);
|
||||
}
|
||||
|
||||
public handleViewportVisibilityChange(isVisible: boolean): void {
|
||||
this._textBlinkStateManager.setViewportVisible(isVisible);
|
||||
}
|
||||
|
||||
public handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void {
|
||||
// Remove all selections
|
||||
this._selectionContainer.replaceChildren();
|
||||
@@ -450,6 +471,11 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
*/
|
||||
e.replaceChildren();
|
||||
}
|
||||
if (this._rowHasBlinkingCellsCount > 0) {
|
||||
this._rowHasBlinkingCells.fill(false);
|
||||
this._rowHasBlinkingCellsCount = 0;
|
||||
this._textBlinkStateManager.setNeedsBlinkInViewport(false);
|
||||
}
|
||||
}
|
||||
|
||||
public renderRows(start: number, end: number): void {
|
||||
@@ -459,6 +485,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
const cursorBlink = this._coreService.decPrivateModes.cursorBlink ?? this._optionsService.rawOptions.cursorBlink;
|
||||
const cursorStyle = this._coreService.decPrivateModes.cursorStyle ?? this._optionsService.rawOptions.cursorStyle;
|
||||
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
|
||||
const rowInfo = { hasBlinkingCells: false };
|
||||
|
||||
for (let y = start; y <= end; y++) {
|
||||
const row = y + buffer.ydisp;
|
||||
@@ -476,13 +503,17 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
cursorInactiveStyle,
|
||||
cursorX,
|
||||
cursorBlink,
|
||||
this._textBlinkStateManager.isBlinkOn,
|
||||
this.dimensions.css.cell.width,
|
||||
this._widthCache,
|
||||
-1,
|
||||
-1
|
||||
-1,
|
||||
rowInfo
|
||||
)
|
||||
);
|
||||
this._setRowBlinkState(y, rowInfo.hasBlinkingCells);
|
||||
}
|
||||
this._updateTextBlinkState();
|
||||
}
|
||||
|
||||
private get _terminalSelector(): string {
|
||||
@@ -527,6 +558,7 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
const cursorBlink = this._optionsService.rawOptions.cursorBlink;
|
||||
const cursorStyle = this._optionsService.rawOptions.cursorStyle;
|
||||
const cursorInactiveStyle = this._optionsService.rawOptions.cursorInactiveStyle;
|
||||
const rowInfo = { hasBlinkingCells: false };
|
||||
|
||||
// refresh rows within link range
|
||||
for (let i = y; i <= y2; ++i) {
|
||||
@@ -545,13 +577,30 @@ export class DomRenderer extends Disposable implements IRenderer {
|
||||
cursorInactiveStyle,
|
||||
cursorX,
|
||||
cursorBlink,
|
||||
this._textBlinkStateManager.isBlinkOn,
|
||||
this.dimensions.css.cell.width,
|
||||
this._widthCache,
|
||||
enabled ? (i === y ? x : 0) : -1,
|
||||
enabled ? ((i === y2 ? x2 : cols) - 1) : -1
|
||||
enabled ? ((i === y2 ? x2 : cols) - 1) : -1,
|
||||
rowInfo
|
||||
)
|
||||
);
|
||||
this._setRowBlinkState(i, rowInfo.hasBlinkingCells);
|
||||
}
|
||||
this._updateTextBlinkState();
|
||||
}
|
||||
|
||||
private _setRowBlinkState(row: number, hasBlinkingCells: boolean): void {
|
||||
const previous = this._rowHasBlinkingCells[row];
|
||||
if (previous === hasBlinkingCells) {
|
||||
return;
|
||||
}
|
||||
this._rowHasBlinkingCells[row] = hasBlinkingCells;
|
||||
this._rowHasBlinkingCellsCount += hasBlinkingCells ? 1 : -1;
|
||||
}
|
||||
|
||||
private _updateTextBlinkState(): void {
|
||||
this._textBlinkStateManager.setNeedsBlinkInViewport(this._rowHasBlinkingCellsCount > 0);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
describe('createRow', () => {
|
||||
it('should not create anything for an empty row', () => {
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
''
|
||||
);
|
||||
@@ -51,7 +51,7 @@ describe('DomRendererRowFactory', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, '語', 2, '語'.charCodeAt(0)]));
|
||||
// There should be no element for the following "empty" cell
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, '', 0, 0]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>語</span>'
|
||||
);
|
||||
@@ -59,7 +59,7 @@ describe('DomRendererRowFactory', () => {
|
||||
|
||||
it('should add class for cursor and cursor style', () => {
|
||||
for (const style of ['block', 'bar', 'underline']) {
|
||||
const spans = rowFactory.createRow(lineData, 0, true, style, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, style, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-cursor xterm-cursor-${style}"> </span>`
|
||||
);
|
||||
@@ -67,7 +67,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should add class for cursor blink', () => {
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, true, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, true, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-cursor xterm-cursor-blink xterm-cursor-block"> </span>`
|
||||
);
|
||||
@@ -86,7 +86,7 @@ describe('DomRendererRowFactory', () => {
|
||||
new MockThemeService()
|
||||
);
|
||||
for (const inactiveStyle of ['outline', 'block', 'bar', 'underline', 'none']){
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', inactiveStyle, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', inactiveStyle, 0, false, true, 5, widthCache, -1, -1);
|
||||
if (inactiveStyle === 'none') {
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-cursor"> </span>`);
|
||||
@@ -109,7 +109,7 @@ describe('DomRendererRowFactory', () => {
|
||||
new MockDecorationService(),
|
||||
new MockThemeService()
|
||||
);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, 'block', undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span> </span>`
|
||||
);
|
||||
@@ -120,7 +120,7 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BOLD;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bold">a</span>'
|
||||
);
|
||||
@@ -130,7 +130,7 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.ITALIC;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-italic">a</span>'
|
||||
);
|
||||
@@ -140,7 +140,7 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.DIM;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-dim">a</span>'
|
||||
);
|
||||
@@ -153,7 +153,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-underline-1">a</span>'
|
||||
);
|
||||
@@ -164,7 +164,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DOUBLE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-underline-2">a</span>'
|
||||
);
|
||||
@@ -175,7 +175,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.CURLY;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-underline-3">a</span>'
|
||||
);
|
||||
@@ -186,7 +186,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DOTTED;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-underline-4">a</span>'
|
||||
);
|
||||
@@ -197,7 +197,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.DASHED;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-underline-5">a</span>'
|
||||
);
|
||||
@@ -208,7 +208,7 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.OVERLINE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-overline">a</span>'
|
||||
);
|
||||
@@ -218,12 +218,28 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.STRIKETHROUGH;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-strikethrough">a</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should hide blinking text when blink is off', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg = DEFAULT_ATTR_DATA.fg | FgFlags.BLINK | FgFlags.UNDERLINE;
|
||||
cell.bg = DEFAULT_ATTR_DATA.bg | BgFlags.HAS_EXTENDED;
|
||||
cell.extended.underlineStyle = UnderlineStyle.SINGLE;
|
||||
lineData.setCell(0, cell);
|
||||
const onSpans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(onSpans),
|
||||
'<span class="xterm-underline-1">a</span>'
|
||||
);
|
||||
const offSpans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, false, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(offSpans),
|
||||
'<span class="xterm-blink-hidden xterm-underline-1">a</span>'
|
||||
);
|
||||
});
|
||||
|
||||
it('should add classes for 256 foreground colors', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg |= Attributes.CM_P256;
|
||||
@@ -231,7 +247,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.fg &= ~Attributes.PCOLOR_MASK;
|
||||
cell.fg |= i;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-fg-${i}">a</span>`
|
||||
);
|
||||
@@ -245,7 +261,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.bg &= ~Attributes.PCOLOR_MASK;
|
||||
cell.bg |= i;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-bg-${i}">a</span>`
|
||||
);
|
||||
@@ -257,7 +273,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.fg |= Attributes.CM_P16 | 2 | FgFlags.INVERSE;
|
||||
cell.bg |= Attributes.CM_P16 | 1;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-2 xterm-fg-1">a</span>'
|
||||
);
|
||||
@@ -268,7 +284,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.fg |= FgFlags.INVERSE;
|
||||
cell.bg |= Attributes.CM_P16 | 1;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-257 xterm-fg-1">a</span>'
|
||||
);
|
||||
@@ -278,7 +294,7 @@ describe('DomRendererRowFactory', () => {
|
||||
const cell = CellData.fromCharData([0, 'a', 1, 'a'.charCodeAt(0)]);
|
||||
cell.fg |= Attributes.CM_P16 | 1 | FgFlags.INVERSE;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1 xterm-fg-257">a</span>'
|
||||
);
|
||||
@@ -291,7 +307,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.fg &= ~Attributes.PCOLOR_MASK;
|
||||
cell.fg |= i;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
`<span class="xterm-bold xterm-fg-${i + 8}">a</span>`
|
||||
);
|
||||
@@ -303,7 +319,7 @@ describe('DomRendererRowFactory', () => {
|
||||
cell.fg |= Attributes.CM_RGB | 1 << 16 | 2 << 8 | 3;
|
||||
cell.bg |= Attributes.CM_RGB | 4 << 16 | 5 << 8 | 6;
|
||||
lineData.setCell(0, cell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span style="background-color:#040506;color:#010203;">a</span>'
|
||||
);
|
||||
@@ -314,7 +330,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span style="background-color:#010203;color:#040506;">a</span>'
|
||||
);
|
||||
@@ -326,7 +342,7 @@ describe('DomRendererRowFactory', () => {
|
||||
lineData.setCell(0, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
lineData.setCell(1, CellData.fromCharData([DEFAULT_ATTR, 'b', 1, 'b'.charCodeAt(0)]));
|
||||
rowFactory.handleSelectionChanged([1, 0], [2, 0], false);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>a</span><span style="background-color:#ff0000;" class="xterm-decoration-top">b</span>'
|
||||
);
|
||||
@@ -334,7 +350,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)]));
|
||||
rowFactory.handleSelectionChanged([0, 0], [2, 0], false);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span style="background-color:#ff0000;" class="xterm-decoration-top"> a</span>'
|
||||
);
|
||||
@@ -351,7 +367,7 @@ describe('DomRendererRowFactory', () => {
|
||||
});
|
||||
|
||||
it('should not create anything for an empty row', () => {
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
''
|
||||
);
|
||||
@@ -361,7 +377,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>abc</span>'
|
||||
);
|
||||
@@ -372,7 +388,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c</span>'
|
||||
);
|
||||
@@ -387,7 +403,7 @@ describe('DomRendererRowFactory', () => {
|
||||
lineData.setCell(1, aColor1);
|
||||
lineData.setCell(2, bColor2);
|
||||
lineData.setCell(3, bColor2);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-fg-1">aa</span><span class="xterm-fg-2">bb</span>'
|
||||
);
|
||||
@@ -399,7 +415,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, true, undefined, undefined, 2, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span class="xterm-cursor xterm-cursor-block">X</span><span>bb</span>'
|
||||
);
|
||||
@@ -412,7 +428,7 @@ describe('DomRendererRowFactory', () => {
|
||||
nullCell.bg = Attributes.CM_P16 | 2;
|
||||
lineData.setCell(3, nullCell);
|
||||
lineData.setCell(4, nullCell);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span> </span><span class="xterm-bg-1"> </span><span class="xterm-bg-2"> </span>'
|
||||
);
|
||||
@@ -422,23 +438,23 @@ describe('DomRendererRowFactory', () => {
|
||||
const nullCell = lineData.loadCell(0, new CellData());
|
||||
nullCell.bg = Attributes.CM_P16 | 1;
|
||||
lineData.setCell(0, nullCell);
|
||||
let spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
let spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span>'
|
||||
);
|
||||
lineData.setCell(1, nullCell);
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span>'
|
||||
);
|
||||
lineData.setCell(2, nullCell);
|
||||
lineData.setCell(3, nullCell);
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span>'
|
||||
);
|
||||
lineData.setCell(4, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -1, -1);
|
||||
spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span class="xterm-bg-1"> </span><span>a</span>'
|
||||
);
|
||||
@@ -451,7 +467,7 @@ describe('DomRendererRowFactory', () => {
|
||||
lineData.setCell(2, CellData.fromCharData([DEFAULT_ATTR, 'c', 1, 'c'.charCodeAt(0)]));
|
||||
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, 5, widthCache, -1, -1);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -1, -1);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>a</span><span style="letter-spacing: 3px;">€</span><span>c語</span><span style="letter-spacing: -2px;">𝄞</span>'
|
||||
);
|
||||
@@ -465,7 +481,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, 2, 4);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span style="text-decoration: underline;">xxx</span><span>bb</span>'
|
||||
);
|
||||
@@ -476,7 +492,7 @@ describe('DomRendererRowFactory', () => {
|
||||
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)]));
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, 2, 4);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, 2, 4);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span>aa</span><span style="text-decoration: underline;">x x</span>'
|
||||
);
|
||||
@@ -486,7 +502,7 @@ describe('DomRendererRowFactory', () => {
|
||||
for (let i = 0; i < 10; ++i) {
|
||||
lineData.setCell(i, CellData.fromCharData([DEFAULT_ATTR, 'a', 1, 'a'.charCodeAt(0)]));
|
||||
}
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, 5, widthCache, -100, 100);
|
||||
const spans = rowFactory.createRow(lineData, 0, false, undefined, undefined, 0, false, true, 5, widthCache, -100, 100);
|
||||
assert.equal(extractHtml(spans),
|
||||
'<span style="text-decoration: underline;">aaaaaaaaaa</span>'
|
||||
);
|
||||
|
||||
@@ -24,6 +24,7 @@ export const enum RowCss {
|
||||
UNDERLINE_CLASS = 'xterm-underline',
|
||||
OVERLINE_CLASS = 'xterm-overline',
|
||||
STRIKETHROUGH_CLASS = 'xterm-strikethrough',
|
||||
BLINK_HIDDEN_CLASS = 'xterm-blink-hidden',
|
||||
CURSOR_CLASS = 'xterm-cursor',
|
||||
CURSOR_BLINK_CLASS = 'xterm-cursor-blink',
|
||||
CURSOR_STYLE_BLOCK_CLASS = 'xterm-cursor-block',
|
||||
@@ -66,13 +67,18 @@ export class DomRendererRowFactory {
|
||||
cursorInactiveStyle: string | undefined,
|
||||
cursorX: number,
|
||||
cursorBlink: boolean,
|
||||
blinkOn: boolean,
|
||||
cellWidth: number,
|
||||
widthCache: WidthCache,
|
||||
linkStart: number,
|
||||
linkEnd: number
|
||||
linkEnd: number,
|
||||
rowInfo?: { hasBlinkingCells: boolean }
|
||||
): HTMLSpanElement[] {
|
||||
|
||||
const elements: HTMLSpanElement[] = [];
|
||||
if (rowInfo) {
|
||||
rowInfo.hasBlinkingCells = false;
|
||||
}
|
||||
const joinedRanges = this._characterJoinerService.getJoinedCharacters(row);
|
||||
const colors = this._themeService.colors;
|
||||
|
||||
@@ -118,7 +124,7 @@ export class DomRendererRowFactory {
|
||||
// Process any joined character ranges as needed. Because of how the
|
||||
// ranges are produced, we know that they are valid for the characters
|
||||
// and attributes of our input.
|
||||
let cell = this._workCell;
|
||||
let cell: ICellData = this._workCell;
|
||||
if (joinedRanges.length > 0 && x === joinedRanges[0][0] && isValidJoinRange) {
|
||||
const range = joinedRanges.shift()!;
|
||||
// If the ligature's selection state is not consistent, don't join it. This helps the
|
||||
@@ -153,6 +159,13 @@ export class DomRendererRowFactory {
|
||||
const isInSelection = this._isCellInSelection(x, row);
|
||||
const isCursorCell = isCursorRow && x === cursorX;
|
||||
const isLinkHover = hasHover && x >= linkStart && x <= linkEnd;
|
||||
if (rowInfo && cell.isBlink()) {
|
||||
rowInfo.hasBlinkingCells = true;
|
||||
}
|
||||
const isBlinkHidden = !blinkOn && cell.isBlink();
|
||||
if (isBlinkHidden) {
|
||||
classes.push(RowCss.BLINK_HIDDEN_CLASS);
|
||||
}
|
||||
|
||||
let isDecorated = false;
|
||||
this._decorationService.forEachDecorationAtCell(x, row, undefined, d => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { TextBlinkStateManager } from 'browser/renderer/shared/TextBlinkStateManager';
|
||||
import { MockOptionsService } from 'common/TestUtils.test';
|
||||
import type { ICoreBrowserService } from 'browser/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
class FakeWindow {
|
||||
public nextId = 1;
|
||||
public intervals = new Map<number, () => void>();
|
||||
|
||||
public setInterval(callback: () => void, _duration: number): number {
|
||||
const id = this.nextId++;
|
||||
this.intervals.set(id, callback);
|
||||
return id;
|
||||
}
|
||||
|
||||
public clearInterval(id: number): void {
|
||||
this.intervals.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
function createManager(duration: number): {
|
||||
manager: TextBlinkStateManager;
|
||||
window: FakeWindow;
|
||||
getRenderCount: () => number;
|
||||
} {
|
||||
const fakeWindow = new FakeWindow();
|
||||
let renderCount = 0;
|
||||
const coreBrowserService: ICoreBrowserService = {
|
||||
serviceBrand: undefined,
|
||||
isFocused: true,
|
||||
dpr: 1,
|
||||
onDprChange: new Emitter<number>().event,
|
||||
onWindowChange: new Emitter<Window & typeof globalThis>().event,
|
||||
window: fakeWindow as any,
|
||||
mainDocument: {} as any
|
||||
};
|
||||
const optionsService = new MockOptionsService({ blinkIntervalDuration: duration });
|
||||
const manager = new TextBlinkStateManager(() => {
|
||||
renderCount++;
|
||||
}, coreBrowserService, optionsService);
|
||||
return {
|
||||
manager,
|
||||
window: fakeWindow,
|
||||
getRenderCount: () => renderCount
|
||||
};
|
||||
}
|
||||
|
||||
function getOnlyIntervalCallback(window: FakeWindow): () => void {
|
||||
const iterator = window.intervals.values();
|
||||
const first = iterator.next();
|
||||
assert.ok(!first.done);
|
||||
assert.ok(iterator.next().done);
|
||||
return first.value;
|
||||
}
|
||||
|
||||
describe('TextBlinkStateManager', () => {
|
||||
it('starts interval only when needed', () => {
|
||||
const { manager, window } = createManager(100);
|
||||
assert.equal(window.intervals.size, 0);
|
||||
manager.setNeedsBlinkInViewport(true);
|
||||
assert.equal(window.intervals.size, 1);
|
||||
});
|
||||
|
||||
it('stops interval and restores blink visibility when no longer needed', () => {
|
||||
const { manager, window, getRenderCount } = createManager(100);
|
||||
manager.setNeedsBlinkInViewport(true);
|
||||
const tick = getOnlyIntervalCallback(window);
|
||||
tick();
|
||||
const rendersAfterTick = getRenderCount();
|
||||
assert.equal(manager.isBlinkOn, false);
|
||||
manager.setNeedsBlinkInViewport(false);
|
||||
assert.equal(window.intervals.size, 0);
|
||||
assert.equal(manager.isBlinkOn, true);
|
||||
assert.equal(getRenderCount(), rendersAfterTick + 1);
|
||||
});
|
||||
|
||||
it('pauses while viewport is hidden and resumes when visible', () => {
|
||||
const { manager, window } = createManager(100);
|
||||
manager.setNeedsBlinkInViewport(true);
|
||||
assert.equal(window.intervals.size, 1);
|
||||
manager.setViewportVisible(false);
|
||||
assert.equal(window.intervals.size, 0);
|
||||
manager.setViewportVisible(true);
|
||||
assert.equal(window.intervals.size, 1);
|
||||
});
|
||||
|
||||
it('does not start interval when duration is zero', () => {
|
||||
const { manager, window } = createManager(0);
|
||||
manager.setNeedsBlinkInViewport(true);
|
||||
assert.equal(window.intervals.size, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ICoreBrowserService } from 'browser/services/Services';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IOptionsService } from 'common/services/Services';
|
||||
|
||||
export class TextBlinkStateManager extends Disposable {
|
||||
private _intervalDuration: number = 0;
|
||||
private _interval: number | undefined;
|
||||
private _blinkOn: boolean = true;
|
||||
private _needsBlinkInViewport: boolean = false;
|
||||
private _isViewportVisible: boolean = true;
|
||||
|
||||
constructor(
|
||||
private readonly _renderCallback: () => void,
|
||||
private readonly _coreBrowserService: ICoreBrowserService,
|
||||
private readonly _optionsService: IOptionsService
|
||||
) {
|
||||
super();
|
||||
this._register(this._optionsService.onSpecificOptionChange('blinkIntervalDuration', duration => {
|
||||
this.setIntervalDuration(duration);
|
||||
}));
|
||||
this.setIntervalDuration(this._optionsService.rawOptions.blinkIntervalDuration);
|
||||
this._register(toDisposable(() => this._clearInterval()));
|
||||
}
|
||||
|
||||
public get isBlinkOn(): boolean {
|
||||
return this._blinkOn;
|
||||
}
|
||||
|
||||
public get isEnabled(): boolean {
|
||||
return this._intervalDuration > 0;
|
||||
}
|
||||
|
||||
public setNeedsBlinkInViewport(needsBlinkInViewport: boolean): void {
|
||||
if (this._needsBlinkInViewport === needsBlinkInViewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._needsBlinkInViewport = needsBlinkInViewport;
|
||||
this._updateIntervalState();
|
||||
}
|
||||
|
||||
public setViewportVisible(isVisible: boolean): void {
|
||||
if (this._isViewportVisible === isVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._isViewportVisible = isVisible;
|
||||
this._updateIntervalState();
|
||||
}
|
||||
|
||||
public setIntervalDuration(duration: number): void {
|
||||
if (duration === this._intervalDuration) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._intervalDuration = duration;
|
||||
this._clearInterval();
|
||||
this._updateIntervalState();
|
||||
}
|
||||
|
||||
private _updateIntervalState(): void {
|
||||
const shouldBlink = this._intervalDuration > 0 && this._needsBlinkInViewport && this._isViewportVisible;
|
||||
if (shouldBlink) {
|
||||
if (this._interval !== undefined) {
|
||||
return;
|
||||
}
|
||||
const wasBlinkOn = this._blinkOn;
|
||||
this._blinkOn = true;
|
||||
this._interval = this._coreBrowserService.window.setInterval(() => {
|
||||
this._blinkOn = !this._blinkOn;
|
||||
this._renderCallback();
|
||||
}, this._intervalDuration);
|
||||
if (!wasBlinkOn) {
|
||||
this._renderCallback();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this._clearInterval();
|
||||
if (!this._blinkOn) {
|
||||
this._blinkOn = true;
|
||||
this._renderCallback();
|
||||
}
|
||||
}
|
||||
|
||||
private _clearInterval(): void {
|
||||
if (this._interval !== undefined) {
|
||||
this._coreBrowserService.window.clearInterval(this._interval);
|
||||
this._interval = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ export interface IRenderer extends IDisposable {
|
||||
handleCharSizeChanged(): void;
|
||||
handleBlur(): void;
|
||||
handleFocus(): void;
|
||||
handleViewportVisibilityChange?(isVisible: boolean): void;
|
||||
handleSelectionChanged(start: [number, number] | undefined, end: [number, number] | undefined, columnSelectMode: boolean): void;
|
||||
handleCursorMove(): void;
|
||||
clear(): void;
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from '../Dom';
|
||||
import { FastDomNode } from './fastDomNode';
|
||||
import { GlobalPointerMoveMonitor } from './globalPointerMoveMonitor';
|
||||
import { StandardWheelEvent } from './mouseEvent';
|
||||
import { ScrollbarArrow, IScrollbarArrowOptions } from './scrollbarArrow';
|
||||
import { ScrollbarState } from './scrollbarState';
|
||||
import { ScrollbarVisibilityController } from './scrollbarVisibilityController';
|
||||
import { Widget } from './widget';
|
||||
import * as platform from 'common/Platform';
|
||||
import { INewScrollPosition, Scrollable, ScrollbarVisibility } from './scrollable';
|
||||
|
||||
/**
|
||||
* The orthogonal distance to the slider at which dragging "resets". This implements "snapping"
|
||||
*/
|
||||
const POINTER_DRAG_RESET_DISTANCE = 140;
|
||||
|
||||
export interface ISimplifiedPointerEvent {
|
||||
buttons: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
}
|
||||
|
||||
export interface IScrollbarHost {
|
||||
handleMouseWheel(mouseWheelEvent: StandardWheelEvent): void;
|
||||
handleDragStart(): void;
|
||||
handleDragEnd(): void;
|
||||
}
|
||||
|
||||
interface IAbstractScrollbarOptions {
|
||||
lazyRender: boolean;
|
||||
host: IScrollbarHost;
|
||||
scrollbarState: ScrollbarState;
|
||||
visibility: ScrollbarVisibility;
|
||||
extraScrollbarClassName: string;
|
||||
scrollable: Scrollable;
|
||||
scrollByPage: boolean;
|
||||
}
|
||||
|
||||
export abstract class AbstractScrollbar extends Widget {
|
||||
|
||||
protected _host: IScrollbarHost;
|
||||
protected _scrollable: Scrollable;
|
||||
protected _scrollByPage: boolean;
|
||||
private _lazyRender: boolean;
|
||||
protected _scrollbarState: ScrollbarState;
|
||||
protected _visibilityController: ScrollbarVisibilityController;
|
||||
private _pointerMoveMonitor: GlobalPointerMoveMonitor;
|
||||
|
||||
public domNode: FastDomNode<HTMLElement>;
|
||||
public slider!: FastDomNode<HTMLElement>;
|
||||
|
||||
protected _shouldRender: boolean;
|
||||
|
||||
constructor(opts: IAbstractScrollbarOptions) {
|
||||
super();
|
||||
this._lazyRender = opts.lazyRender;
|
||||
this._host = opts.host;
|
||||
this._scrollable = opts.scrollable;
|
||||
this._scrollByPage = opts.scrollByPage;
|
||||
this._scrollbarState = opts.scrollbarState;
|
||||
this._visibilityController = this._register(new ScrollbarVisibilityController(opts.visibility, 'xterm-visible xterm-scrollbar ' + opts.extraScrollbarClassName, 'xterm-invisible xterm-scrollbar ' + opts.extraScrollbarClassName));
|
||||
this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded());
|
||||
this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor());
|
||||
this._shouldRender = true;
|
||||
this.domNode = new FastDomNode(document.createElement('div'));
|
||||
this.domNode.setAttribute('role', 'presentation');
|
||||
this.domNode.setAttribute('aria-hidden', 'true');
|
||||
|
||||
this._visibilityController.setDomNode(this.domNode);
|
||||
this.domNode.setPosition('absolute');
|
||||
|
||||
this._register(dom.addDisposableListener(this.domNode.domNode, dom.eventType.POINTER_DOWN, (e: PointerEvent) => this._domNodePointerDown(e)));
|
||||
}
|
||||
|
||||
// ----------------- creation
|
||||
|
||||
/**
|
||||
* Creates the dom node for an arrow & adds it to the container
|
||||
*/
|
||||
protected _createArrow(opts: IScrollbarArrowOptions): ScrollbarArrow {
|
||||
const arrow = this._register(new ScrollbarArrow(opts));
|
||||
this.domNode.domNode.appendChild(arrow.bgDomNode);
|
||||
this.domNode.domNode.appendChild(arrow.domNode);
|
||||
return arrow;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the slider dom node, adds it to the container & hooks up the events
|
||||
*/
|
||||
protected _createSlider(top: number, left: number, width: number | undefined, height: number | undefined): void {
|
||||
this.slider = new FastDomNode(document.createElement('div'));
|
||||
this.slider.setClassName('xterm-slider');
|
||||
this.slider.setPosition('absolute');
|
||||
this.slider.setTop(top);
|
||||
this.slider.setLeft(left);
|
||||
if (typeof width === 'number') {
|
||||
this.slider.setWidth(width);
|
||||
}
|
||||
if (typeof height === 'number') {
|
||||
this.slider.setHeight(height);
|
||||
}
|
||||
this.slider.setLayerHinting(true);
|
||||
this.slider.setContain('strict');
|
||||
|
||||
this.domNode.domNode.appendChild(this.slider.domNode);
|
||||
|
||||
this._register(dom.addDisposableListener(
|
||||
this.slider.domNode,
|
||||
dom.eventType.POINTER_DOWN,
|
||||
(e: PointerEvent) => {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this._sliderPointerDown(e);
|
||||
}
|
||||
}
|
||||
));
|
||||
|
||||
this._onclick(this.slider.domNode, e => {
|
||||
if (e.leftButton) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ----------------- Update state
|
||||
|
||||
protected _handleElementSize(visibleSize: number): boolean {
|
||||
if (this._scrollbarState.setVisibleSize(visibleSize)) {
|
||||
this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded());
|
||||
this._shouldRender = true;
|
||||
if (!this._lazyRender) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
return this._shouldRender;
|
||||
}
|
||||
|
||||
protected _handleElementScrollSize(elementScrollSize: number): boolean {
|
||||
if (this._scrollbarState.setScrollSize(elementScrollSize)) {
|
||||
this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded());
|
||||
this._shouldRender = true;
|
||||
if (!this._lazyRender) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
return this._shouldRender;
|
||||
}
|
||||
|
||||
protected _handleElementScrollPosition(elementScrollPosition: number): boolean {
|
||||
if (this._scrollbarState.setScrollPosition(elementScrollPosition)) {
|
||||
this._visibilityController.setIsNeeded(this._scrollbarState.isNeeded());
|
||||
this._shouldRender = true;
|
||||
if (!this._lazyRender) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
return this._shouldRender;
|
||||
}
|
||||
|
||||
// ----------------- rendering
|
||||
|
||||
public beginReveal(): void {
|
||||
this._visibilityController.setShouldBeVisible(true);
|
||||
}
|
||||
|
||||
public beginHide(): void {
|
||||
this._visibilityController.setShouldBeVisible(false);
|
||||
}
|
||||
|
||||
public render(): void {
|
||||
if (!this._shouldRender) {
|
||||
return;
|
||||
}
|
||||
this._shouldRender = false;
|
||||
|
||||
this._renderDomNode(this._scrollbarState.getRectangleLargeSize(), this._scrollbarState.getRectangleSmallSize());
|
||||
this._updateSlider(this._scrollbarState.getSliderSize(), this._scrollbarState.getArrowSize() + this._scrollbarState.getSliderPosition());
|
||||
}
|
||||
// ----------------- DOM events
|
||||
|
||||
private _domNodePointerDown(e: PointerEvent): void {
|
||||
if (e.target !== this.domNode.domNode) {
|
||||
return;
|
||||
}
|
||||
this._handlePointerDown(e);
|
||||
}
|
||||
|
||||
public delegatePointerDown(e: PointerEvent): void {
|
||||
const domTop = this.domNode.domNode.getClientRects()[0].top;
|
||||
const sliderStart = domTop + this._scrollbarState.getSliderPosition();
|
||||
const sliderStop = domTop + this._scrollbarState.getSliderPosition() + this._scrollbarState.getSliderSize();
|
||||
const pointerPos = this._sliderPointerPosition(e);
|
||||
if (sliderStart <= pointerPos && pointerPos <= sliderStop) {
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this._sliderPointerDown(e);
|
||||
}
|
||||
} else {
|
||||
this._handlePointerDown(e);
|
||||
}
|
||||
}
|
||||
|
||||
private _handlePointerDown(e: PointerEvent): void {
|
||||
let offsetX: number;
|
||||
let offsetY: number;
|
||||
if (e.target === this.domNode.domNode && typeof e.offsetX === 'number' && typeof e.offsetY === 'number') {
|
||||
offsetX = e.offsetX;
|
||||
offsetY = e.offsetY;
|
||||
} else {
|
||||
const domNodePosition = dom.getDomNodePagePosition(this.domNode.domNode);
|
||||
offsetX = e.pageX - domNodePosition.left;
|
||||
offsetY = e.pageY - domNodePosition.top;
|
||||
}
|
||||
|
||||
const offset = this._pointerDownRelativePosition(offsetX, offsetY);
|
||||
this._setDesiredScrollPositionNow(
|
||||
this._scrollByPage
|
||||
? this._scrollbarState.getDesiredScrollPositionFromOffsetPaged(offset)
|
||||
: this._scrollbarState.getDesiredScrollPositionFromOffset(offset)
|
||||
);
|
||||
|
||||
if (e.button === 0) {
|
||||
e.preventDefault();
|
||||
this._sliderPointerDown(e);
|
||||
}
|
||||
}
|
||||
|
||||
private _sliderPointerDown(e: PointerEvent): void {
|
||||
if (!e.target || !(e.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const initialPointerPosition = this._sliderPointerPosition(e);
|
||||
const initialPointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(e);
|
||||
const initialScrollbarState = this._scrollbarState.clone();
|
||||
this.slider.toggleClassName('xterm-active', true);
|
||||
|
||||
this._pointerMoveMonitor.startMonitoring(
|
||||
e.target,
|
||||
e.pointerId,
|
||||
e.buttons,
|
||||
(pointerMoveData: PointerEvent) => {
|
||||
const pointerOrthogonalPosition = this._sliderOrthogonalPointerPosition(pointerMoveData);
|
||||
const pointerOrthogonalDelta = Math.abs(pointerOrthogonalPosition - initialPointerOrthogonalPosition);
|
||||
|
||||
if (platform.isWindows && pointerOrthogonalDelta > POINTER_DRAG_RESET_DISTANCE) {
|
||||
this._setDesiredScrollPositionNow(initialScrollbarState.getScrollPosition());
|
||||
return;
|
||||
}
|
||||
|
||||
const pointerPosition = this._sliderPointerPosition(pointerMoveData);
|
||||
const pointerDelta = pointerPosition - initialPointerPosition;
|
||||
this._setDesiredScrollPositionNow(initialScrollbarState.getDesiredScrollPositionFromDelta(pointerDelta));
|
||||
},
|
||||
() => {
|
||||
this.slider.toggleClassName('xterm-active', false);
|
||||
this._host.handleDragEnd();
|
||||
}
|
||||
);
|
||||
|
||||
this._host.handleDragStart();
|
||||
}
|
||||
|
||||
private _setDesiredScrollPositionNow(_desiredScrollPosition: number): void {
|
||||
|
||||
const desiredScrollPosition: INewScrollPosition = {};
|
||||
this.writeScrollPosition(desiredScrollPosition, _desiredScrollPosition);
|
||||
|
||||
this._scrollable.setScrollPositionNow(desiredScrollPosition);
|
||||
}
|
||||
|
||||
public updateScrollbarSize(scrollbarSize: number): void {
|
||||
this._updateScrollbarSize(scrollbarSize);
|
||||
this._scrollbarState.setScrollbarSize(scrollbarSize);
|
||||
this._shouldRender = true;
|
||||
if (!this._lazyRender) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
public isNeeded(): boolean {
|
||||
return this._scrollbarState.isNeeded();
|
||||
}
|
||||
|
||||
// ----------------- Overwrite these
|
||||
|
||||
protected abstract _renderDomNode(largeSize: number, smallSize: number): void;
|
||||
protected abstract _updateSlider(sliderSize: number, sliderPosition: number): void;
|
||||
|
||||
protected abstract _pointerDownRelativePosition(offsetX: number, offsetY: number): number;
|
||||
protected abstract _sliderPointerPosition(e: ISimplifiedPointerEvent): number;
|
||||
protected abstract _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number;
|
||||
protected abstract _updateScrollbarSize(size: number): void;
|
||||
|
||||
public abstract writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
export class FastDomNode<T extends HTMLElement> {
|
||||
|
||||
private _width: string = '';
|
||||
private _height: string = '';
|
||||
private _top: string = '';
|
||||
private _left: string = '';
|
||||
private _bottom: string = '';
|
||||
private _right: string = '';
|
||||
private _className: string = '';
|
||||
private _position: string = '';
|
||||
private _layerHint: boolean = false;
|
||||
private _contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint' = 'none';
|
||||
|
||||
constructor(
|
||||
public readonly domNode: T
|
||||
) { }
|
||||
|
||||
public setWidth(_width: number | string): void {
|
||||
const width = numberAsPixels(_width);
|
||||
if (this._width === width) {
|
||||
return;
|
||||
}
|
||||
this._width = width;
|
||||
this.domNode.style.width = this._width;
|
||||
}
|
||||
|
||||
public setHeight(_height: number | string): void {
|
||||
const height = numberAsPixels(_height);
|
||||
if (this._height === height) {
|
||||
return;
|
||||
}
|
||||
this._height = height;
|
||||
this.domNode.style.height = this._height;
|
||||
}
|
||||
|
||||
public setTop(_top: number | string): void {
|
||||
const top = numberAsPixels(_top);
|
||||
if (this._top === top) {
|
||||
return;
|
||||
}
|
||||
this._top = top;
|
||||
this.domNode.style.top = this._top;
|
||||
}
|
||||
|
||||
public setLeft(_left: number | string): void {
|
||||
const left = numberAsPixels(_left);
|
||||
if (this._left === left) {
|
||||
return;
|
||||
}
|
||||
this._left = left;
|
||||
this.domNode.style.left = this._left;
|
||||
}
|
||||
|
||||
public setBottom(_bottom: number | string): void {
|
||||
const bottom = numberAsPixels(_bottom);
|
||||
if (this._bottom === bottom) {
|
||||
return;
|
||||
}
|
||||
this._bottom = bottom;
|
||||
this.domNode.style.bottom = this._bottom;
|
||||
}
|
||||
|
||||
public setRight(_right: number | string): void {
|
||||
const right = numberAsPixels(_right);
|
||||
if (this._right === right) {
|
||||
return;
|
||||
}
|
||||
this._right = right;
|
||||
this.domNode.style.right = this._right;
|
||||
}
|
||||
|
||||
public setClassName(className: string): void {
|
||||
if (this._className === className) {
|
||||
return;
|
||||
}
|
||||
this._className = className;
|
||||
this.domNode.className = this._className;
|
||||
}
|
||||
|
||||
public toggleClassName(className: string, shouldHaveIt?: boolean): void {
|
||||
this.domNode.classList.toggle(className, shouldHaveIt);
|
||||
this._className = this.domNode.className;
|
||||
}
|
||||
|
||||
public setPosition(position: string): void {
|
||||
if (this._position === position) {
|
||||
return;
|
||||
}
|
||||
this._position = position;
|
||||
this.domNode.style.position = this._position;
|
||||
}
|
||||
|
||||
public setLayerHinting(layerHint: boolean): void {
|
||||
if (this._layerHint === layerHint) {
|
||||
return;
|
||||
}
|
||||
this._layerHint = layerHint;
|
||||
if (layerHint) {
|
||||
this.domNode.style.transform = 'translate3d(0px, 0px, 0px)';
|
||||
} else {
|
||||
this.domNode.style.transform = '';
|
||||
}
|
||||
}
|
||||
|
||||
public setContain(contain: 'none' | 'strict' | 'content' | 'size' | 'layout' | 'style' | 'paint'): void {
|
||||
if (this._contain === contain) {
|
||||
return;
|
||||
}
|
||||
this._contain = contain;
|
||||
this.domNode.style.contain = this._contain;
|
||||
}
|
||||
|
||||
public setAttribute(name: string, value: string): void {
|
||||
this.domNode.setAttribute(name, value);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
function numberAsPixels(value: number | string): string {
|
||||
return (typeof value === 'number' ? `${value}px` : value);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from '../Dom';
|
||||
import { DisposableStore, IDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
type PointerMoveCallback = (event: PointerEvent) => void;
|
||||
type OnStopCallback = () => void;
|
||||
|
||||
export class GlobalPointerMoveMonitor implements IDisposable {
|
||||
|
||||
private readonly _hooks = new DisposableStore();
|
||||
private _pointerMoveCallback: PointerMoveCallback | null = null;
|
||||
private _onStopCallback: OnStopCallback | null = null;
|
||||
|
||||
public dispose(): void {
|
||||
this.stopMonitoring(false);
|
||||
this._hooks.dispose();
|
||||
}
|
||||
|
||||
public stopMonitoring(invokeStopCallback: boolean): void {
|
||||
if (!this.isMonitoring()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._hooks.clear();
|
||||
this._pointerMoveCallback = null;
|
||||
const onStopCallback = this._onStopCallback;
|
||||
this._onStopCallback = null;
|
||||
|
||||
if (invokeStopCallback && onStopCallback) {
|
||||
onStopCallback();
|
||||
}
|
||||
}
|
||||
|
||||
public isMonitoring(): boolean {
|
||||
return !!this._pointerMoveCallback;
|
||||
}
|
||||
|
||||
public startMonitoring(
|
||||
initialElement: Element,
|
||||
pointerId: number,
|
||||
initialButtons: number,
|
||||
pointerMoveCallback: PointerMoveCallback,
|
||||
onStopCallback: OnStopCallback
|
||||
): void {
|
||||
if (this.isMonitoring()) {
|
||||
this.stopMonitoring(false);
|
||||
}
|
||||
this._pointerMoveCallback = pointerMoveCallback;
|
||||
this._onStopCallback = onStopCallback;
|
||||
|
||||
let eventSource: Element | Window = initialElement;
|
||||
|
||||
try {
|
||||
initialElement.setPointerCapture(pointerId);
|
||||
this._hooks.add(toDisposable(() => {
|
||||
try {
|
||||
initialElement.releasePointerCapture(pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}));
|
||||
} catch {
|
||||
eventSource = dom.getWindow(initialElement);
|
||||
}
|
||||
|
||||
this._hooks.add(dom.addDisposableListener(
|
||||
eventSource,
|
||||
dom.eventType.POINTER_MOVE,
|
||||
(e) => {
|
||||
if (e.buttons !== initialButtons) {
|
||||
this.stopMonitoring(true);
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
this._pointerMoveCallback!(e);
|
||||
}
|
||||
));
|
||||
|
||||
this._hooks.add(dom.addDisposableListener(
|
||||
eventSource,
|
||||
dom.eventType.POINTER_UP,
|
||||
(e: PointerEvent) => this.stopMonitoring(true)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { AbstractScrollbar, ISimplifiedPointerEvent, IScrollbarHost } from './abstractScrollbar';
|
||||
import { IScrollableElementResolvedOptions } from './scrollableElementOptions';
|
||||
import { ScrollbarState } from './scrollbarState';
|
||||
import { INewScrollPosition, Scrollable, ScrollbarVisibility, IScrollEvent } from './scrollable';
|
||||
|
||||
export class HorizontalScrollbar extends AbstractScrollbar {
|
||||
|
||||
constructor(scrollable: Scrollable, options: IScrollableElementResolvedOptions, host: IScrollbarHost) {
|
||||
const scrollDimensions = scrollable.getScrollDimensions();
|
||||
const scrollPosition = scrollable.getCurrentScrollPosition();
|
||||
super({
|
||||
lazyRender: options.lazyRender,
|
||||
host: host,
|
||||
scrollbarState: new ScrollbarState(
|
||||
(options.horizontalHasArrows ? options.horizontalScrollbarSize : 0),
|
||||
(options.horizontal === ScrollbarVisibility.HIDDEN ? 0 : options.horizontalScrollbarSize),
|
||||
(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize),
|
||||
scrollDimensions.width,
|
||||
scrollDimensions.scrollWidth,
|
||||
scrollPosition.scrollLeft
|
||||
),
|
||||
visibility: options.horizontal,
|
||||
extraScrollbarClassName: 'xterm-horizontal',
|
||||
scrollable: scrollable,
|
||||
scrollByPage: options.scrollByPage
|
||||
});
|
||||
|
||||
if (options.horizontalHasArrows) {
|
||||
throw new Error('horizontalHasArrows is not supported in xterm.js');
|
||||
}
|
||||
|
||||
this._createSlider(Math.floor((options.horizontalScrollbarSize - options.horizontalSliderSize) / 2), 0, undefined, options.horizontalSliderSize);
|
||||
}
|
||||
|
||||
protected _updateSlider(sliderSize: number, sliderPosition: number): void {
|
||||
this.slider.setWidth(sliderSize);
|
||||
this.slider.setLeft(sliderPosition);
|
||||
}
|
||||
|
||||
protected _renderDomNode(largeSize: number, smallSize: number): void {
|
||||
this.domNode.setWidth(largeSize);
|
||||
this.domNode.setHeight(smallSize);
|
||||
this.domNode.setLeft(0);
|
||||
this.domNode.setBottom(0);
|
||||
}
|
||||
|
||||
public handleScroll(e: IScrollEvent): boolean {
|
||||
this._shouldRender = this._handleElementScrollSize(e.scrollWidth) || this._shouldRender;
|
||||
this._shouldRender = this._handleElementScrollPosition(e.scrollLeft) || this._shouldRender;
|
||||
this._shouldRender = this._handleElementSize(e.width) || this._shouldRender;
|
||||
return this._shouldRender;
|
||||
}
|
||||
|
||||
protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number {
|
||||
return offsetX;
|
||||
}
|
||||
|
||||
protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number {
|
||||
return e.pageX;
|
||||
}
|
||||
|
||||
protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number {
|
||||
return e.pageY;
|
||||
}
|
||||
|
||||
protected _updateScrollbarSize(size: number): void {
|
||||
this.slider.setHeight(size);
|
||||
}
|
||||
|
||||
public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void {
|
||||
target.scrollLeft = scrollPosition;
|
||||
}
|
||||
|
||||
public updateOptions(options: IScrollableElementResolvedOptions): void {
|
||||
this.updateScrollbarSize(options.horizontal === ScrollbarVisibility.HIDDEN ? 0 : options.horizontalScrollbarSize);
|
||||
this._scrollbarState.setOppositeScrollbarSize(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize);
|
||||
this._visibilityController.setVisibility(options.horizontal);
|
||||
this._scrollByPage = options.scrollByPage;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as platform from 'common/Platform';
|
||||
|
||||
interface IWindowChainElement {
|
||||
readonly window: WeakRef<Window>;
|
||||
readonly iframeElement: Element | null;
|
||||
}
|
||||
|
||||
const sameOriginWindowChainCache = new WeakMap<Window, IWindowChainElement[] | null>();
|
||||
|
||||
function getParentWindowIfSameOrigin(w: Window): Window | null {
|
||||
if (!w.parent || w.parent === w) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const location = w.location;
|
||||
const parentLocation = w.parent.location;
|
||||
if (location.origin !== 'null' && parentLocation.origin !== 'null' && location.origin !== parentLocation.origin) {
|
||||
return null;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return w.parent;
|
||||
}
|
||||
|
||||
class IframeUtils {
|
||||
|
||||
private static _getSameOriginWindowChain(targetWindow: Window): IWindowChainElement[] {
|
||||
let windowChainCache = sameOriginWindowChainCache.get(targetWindow);
|
||||
if (!windowChainCache) {
|
||||
windowChainCache = [];
|
||||
sameOriginWindowChainCache.set(targetWindow, windowChainCache);
|
||||
let w: Window | null = targetWindow;
|
||||
let parent: Window | null;
|
||||
do {
|
||||
parent = getParentWindowIfSameOrigin(w);
|
||||
if (parent) {
|
||||
windowChainCache.push({
|
||||
window: new WeakRef(w),
|
||||
iframeElement: w.frameElement ?? null
|
||||
});
|
||||
} else {
|
||||
windowChainCache.push({
|
||||
window: new WeakRef(w),
|
||||
iframeElement: null
|
||||
});
|
||||
}
|
||||
w = parent;
|
||||
} while (w);
|
||||
}
|
||||
return windowChainCache.slice(0);
|
||||
}
|
||||
|
||||
public static getPositionOfChildWindowRelativeToAncestorWindow(childWindow: Window, ancestorWindow: Window | null): { top: number, left: number } {
|
||||
|
||||
if (!ancestorWindow || childWindow === ancestorWindow) {
|
||||
return {
|
||||
top: 0,
|
||||
left: 0
|
||||
};
|
||||
}
|
||||
|
||||
let top = 0;
|
||||
let left = 0;
|
||||
|
||||
const windowChain = this._getSameOriginWindowChain(childWindow);
|
||||
|
||||
for (const windowChainEl of windowChain) {
|
||||
const windowInChain = windowChainEl.window.deref();
|
||||
top += windowInChain?.scrollY ?? 0;
|
||||
left += windowInChain?.scrollX ?? 0;
|
||||
|
||||
if (windowInChain === ancestorWindow) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!windowChainEl.iframeElement) {
|
||||
break;
|
||||
}
|
||||
|
||||
const boundingRect = windowChainEl.iframeElement.getBoundingClientRect();
|
||||
top += boundingRect.top;
|
||||
left += boundingRect.left;
|
||||
}
|
||||
|
||||
return {
|
||||
top: top,
|
||||
left: left
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface IMouseEvent {
|
||||
readonly browserEvent: MouseEvent;
|
||||
readonly leftButton: boolean;
|
||||
readonly middleButton: boolean;
|
||||
readonly rightButton: boolean;
|
||||
readonly buttons: number;
|
||||
readonly target: HTMLElement;
|
||||
readonly detail: number;
|
||||
readonly posx: number;
|
||||
readonly posy: number;
|
||||
readonly ctrlKey: boolean;
|
||||
readonly shiftKey: boolean;
|
||||
readonly altKey: boolean;
|
||||
readonly metaKey: boolean;
|
||||
readonly timestamp: number;
|
||||
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
export class StandardMouseEvent implements IMouseEvent {
|
||||
|
||||
public readonly browserEvent: MouseEvent;
|
||||
|
||||
public readonly leftButton: boolean;
|
||||
public readonly middleButton: boolean;
|
||||
public readonly rightButton: boolean;
|
||||
public readonly buttons: number;
|
||||
public readonly target: HTMLElement;
|
||||
public detail: number;
|
||||
public readonly posx: number;
|
||||
public readonly posy: number;
|
||||
public readonly ctrlKey: boolean;
|
||||
public readonly shiftKey: boolean;
|
||||
public readonly altKey: boolean;
|
||||
public readonly metaKey: boolean;
|
||||
public readonly timestamp: number;
|
||||
|
||||
constructor(targetWindow: Window, e: MouseEvent) {
|
||||
this.timestamp = Date.now();
|
||||
this.browserEvent = e;
|
||||
this.leftButton = e.button === 0;
|
||||
this.middleButton = e.button === 1;
|
||||
this.rightButton = e.button === 2;
|
||||
this.buttons = e.buttons;
|
||||
|
||||
this.target = e.target as HTMLElement;
|
||||
|
||||
this.detail = e.detail ?? 1;
|
||||
if (e.type === 'dblclick') {
|
||||
this.detail = 2;
|
||||
}
|
||||
this.ctrlKey = e.ctrlKey;
|
||||
this.shiftKey = e.shiftKey;
|
||||
this.altKey = e.altKey;
|
||||
this.metaKey = e.metaKey;
|
||||
|
||||
if (typeof e.pageX === 'number') {
|
||||
this.posx = e.pageX;
|
||||
this.posy = e.pageY;
|
||||
} else {
|
||||
this.posx = e.clientX + this.target.ownerDocument.body.scrollLeft + this.target.ownerDocument.documentElement.scrollLeft;
|
||||
this.posy = e.clientY + this.target.ownerDocument.body.scrollTop + this.target.ownerDocument.documentElement.scrollTop;
|
||||
}
|
||||
|
||||
const iframeOffsets = IframeUtils.getPositionOfChildWindowRelativeToAncestorWindow(targetWindow, e.view);
|
||||
this.posx -= iframeOffsets.left;
|
||||
this.posy -= iframeOffsets.top;
|
||||
}
|
||||
|
||||
public preventDefault(): void {
|
||||
this.browserEvent.preventDefault();
|
||||
}
|
||||
|
||||
public stopPropagation(): void {
|
||||
this.browserEvent.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
export interface IMouseWheelEvent extends MouseEvent {
|
||||
readonly wheelDelta: number;
|
||||
readonly wheelDeltaX: number;
|
||||
readonly wheelDeltaY: number;
|
||||
|
||||
readonly deltaX: number;
|
||||
readonly deltaY: number;
|
||||
readonly deltaZ: number;
|
||||
readonly deltaMode: number;
|
||||
}
|
||||
|
||||
interface IWebKitMouseWheelEvent {
|
||||
wheelDeltaY: number;
|
||||
wheelDeltaX: number;
|
||||
}
|
||||
|
||||
interface IGeckoMouseWheelEvent {
|
||||
HORIZONTAL_AXIS: number;
|
||||
VERTICAL_AXIS: number;
|
||||
axis: number;
|
||||
detail: number;
|
||||
}
|
||||
|
||||
export class StandardWheelEvent {
|
||||
|
||||
public readonly browserEvent: IMouseWheelEvent | null;
|
||||
public readonly deltaY: number;
|
||||
public readonly deltaX: number;
|
||||
public readonly target: Node | null;
|
||||
|
||||
constructor(e: IMouseWheelEvent | null, deltaX: number = 0, deltaY: number = 0) {
|
||||
|
||||
this.browserEvent = e ?? null;
|
||||
this.target = e ? (e.target ?? (e as any).targetNode ?? e.srcElement ?? null) : null;
|
||||
|
||||
this.deltaY = deltaY;
|
||||
this.deltaX = deltaX;
|
||||
|
||||
let shouldFactorDPR: boolean = false;
|
||||
if (platform.isChrome) {
|
||||
const chromeVersionMatch = navigator.userAgent.match(/Chrome\/(\d+)/);
|
||||
const chromeMajorVersion = chromeVersionMatch ? parseInt(chromeVersionMatch[1]) : 123;
|
||||
shouldFactorDPR = chromeMajorVersion <= 122;
|
||||
}
|
||||
|
||||
if (e) {
|
||||
const e1 = e as IWebKitMouseWheelEvent as any;
|
||||
const e2 = e as unknown as IGeckoMouseWheelEvent;
|
||||
const devicePixelRatio = e.view?.devicePixelRatio ?? 1;
|
||||
|
||||
if (typeof e1.wheelDeltaY !== 'undefined') {
|
||||
if (shouldFactorDPR) {
|
||||
this.deltaY = e1.wheelDeltaY / (120 * devicePixelRatio);
|
||||
} else {
|
||||
this.deltaY = e1.wheelDeltaY / 120;
|
||||
}
|
||||
} else if (typeof e2.VERTICAL_AXIS !== 'undefined' && e2.axis === e2.VERTICAL_AXIS) {
|
||||
this.deltaY = -e2.detail / 3;
|
||||
} else if (e.type === 'wheel') {
|
||||
const ev = e as unknown as WheelEvent;
|
||||
|
||||
if (ev.deltaMode === ev.DOM_DELTA_LINE) {
|
||||
if (platform.isFirefox && !platform.isMac) {
|
||||
this.deltaY = -e.deltaY / 3;
|
||||
} else {
|
||||
this.deltaY = -e.deltaY;
|
||||
}
|
||||
} else {
|
||||
this.deltaY = -e.deltaY / 40;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof e1.wheelDeltaX !== 'undefined') {
|
||||
if (platform.isSafari && platform.isWindows) {
|
||||
this.deltaX = -(e1.wheelDeltaX / 120);
|
||||
} else if (shouldFactorDPR) {
|
||||
this.deltaX = e1.wheelDeltaX / (120 * devicePixelRatio);
|
||||
} else {
|
||||
this.deltaX = e1.wheelDeltaX / 120;
|
||||
}
|
||||
} else if (typeof e2.HORIZONTAL_AXIS !== 'undefined' && e2.axis === e2.HORIZONTAL_AXIS) {
|
||||
this.deltaX = -e.detail / 3;
|
||||
} else if (e.type === 'wheel') {
|
||||
const ev = e as unknown as WheelEvent;
|
||||
|
||||
if (ev.deltaMode === ev.DOM_DELTA_LINE) {
|
||||
if (platform.isFirefox && !platform.isMac) {
|
||||
this.deltaX = -e.deltaX / 3;
|
||||
} else {
|
||||
this.deltaX = -e.deltaX;
|
||||
}
|
||||
} else {
|
||||
this.deltaX = -e.deltaX / 40;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.deltaY === 0 && this.deltaX === 0 && e.wheelDelta) {
|
||||
if (shouldFactorDPR) {
|
||||
this.deltaY = e.wheelDelta / (120 * devicePixelRatio);
|
||||
} else {
|
||||
this.deltaY = e.wheelDelta / 120;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public preventDefault(): void {
|
||||
this.browserEvent?.preventDefault();
|
||||
}
|
||||
|
||||
public stopPropagation(): void {
|
||||
this.browserEvent?.stopPropagation();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,486 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { Emitter, IEvent } from 'common/Event';
|
||||
import { Disposable, IDisposable } from 'common/Lifecycle';
|
||||
|
||||
export const enum ScrollbarVisibility {
|
||||
AUTO = 1,
|
||||
HIDDEN = 2,
|
||||
VISIBLE = 3
|
||||
}
|
||||
|
||||
export interface IScrollEvent {
|
||||
inSmoothScrolling: boolean;
|
||||
|
||||
oldWidth: number;
|
||||
oldScrollWidth: number;
|
||||
oldScrollLeft: number;
|
||||
|
||||
width: number;
|
||||
scrollWidth: number;
|
||||
scrollLeft: number;
|
||||
|
||||
oldHeight: number;
|
||||
oldScrollHeight: number;
|
||||
oldScrollTop: number;
|
||||
|
||||
height: number;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
|
||||
widthChanged: boolean;
|
||||
scrollWidthChanged: boolean;
|
||||
scrollLeftChanged: boolean;
|
||||
|
||||
heightChanged: boolean;
|
||||
scrollHeightChanged: boolean;
|
||||
scrollTopChanged: boolean;
|
||||
}
|
||||
|
||||
export class ScrollState implements IScrollDimensions, IScrollPosition {
|
||||
private _scrollStateBrand: void = undefined;
|
||||
|
||||
public readonly rawScrollLeft: number;
|
||||
public readonly rawScrollTop: number;
|
||||
|
||||
public readonly width: number;
|
||||
public readonly scrollWidth: number;
|
||||
public readonly scrollLeft: number;
|
||||
public readonly height: number;
|
||||
public readonly scrollHeight: number;
|
||||
public readonly scrollTop: number;
|
||||
|
||||
constructor(
|
||||
private readonly _forceIntegerValues: boolean,
|
||||
width: number,
|
||||
scrollWidth: number,
|
||||
scrollLeft: number,
|
||||
height: number,
|
||||
scrollHeight: number,
|
||||
scrollTop: number
|
||||
) {
|
||||
if (this._forceIntegerValues) {
|
||||
width = width | 0;
|
||||
scrollWidth = scrollWidth | 0;
|
||||
scrollLeft = scrollLeft | 0;
|
||||
height = height | 0;
|
||||
scrollHeight = scrollHeight | 0;
|
||||
scrollTop = scrollTop | 0;
|
||||
}
|
||||
|
||||
this.rawScrollLeft = scrollLeft;
|
||||
this.rawScrollTop = scrollTop;
|
||||
|
||||
if (width < 0) {
|
||||
width = 0;
|
||||
}
|
||||
if (scrollLeft + width > scrollWidth) {
|
||||
scrollLeft = scrollWidth - width;
|
||||
}
|
||||
if (scrollLeft < 0) {
|
||||
scrollLeft = 0;
|
||||
}
|
||||
|
||||
if (height < 0) {
|
||||
height = 0;
|
||||
}
|
||||
if (scrollTop + height > scrollHeight) {
|
||||
scrollTop = scrollHeight - height;
|
||||
}
|
||||
if (scrollTop < 0) {
|
||||
scrollTop = 0;
|
||||
}
|
||||
|
||||
this.width = width;
|
||||
this.scrollWidth = scrollWidth;
|
||||
this.scrollLeft = scrollLeft;
|
||||
this.height = height;
|
||||
this.scrollHeight = scrollHeight;
|
||||
this.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
public equals(other: ScrollState): boolean {
|
||||
return (
|
||||
this.rawScrollLeft === other.rawScrollLeft
|
||||
&& this.rawScrollTop === other.rawScrollTop
|
||||
&& this.width === other.width
|
||||
&& this.scrollWidth === other.scrollWidth
|
||||
&& this.scrollLeft === other.scrollLeft
|
||||
&& this.height === other.height
|
||||
&& this.scrollHeight === other.scrollHeight
|
||||
&& this.scrollTop === other.scrollTop
|
||||
);
|
||||
}
|
||||
|
||||
public withScrollDimensions(update: INewScrollDimensions, useRawScrollPositions: boolean): ScrollState {
|
||||
return new ScrollState(
|
||||
this._forceIntegerValues,
|
||||
(typeof update.width !== 'undefined' ? update.width : this.width),
|
||||
(typeof update.scrollWidth !== 'undefined' ? update.scrollWidth : this.scrollWidth),
|
||||
useRawScrollPositions ? this.rawScrollLeft : this.scrollLeft,
|
||||
(typeof update.height !== 'undefined' ? update.height : this.height),
|
||||
(typeof update.scrollHeight !== 'undefined' ? update.scrollHeight : this.scrollHeight),
|
||||
useRawScrollPositions ? this.rawScrollTop : this.scrollTop
|
||||
);
|
||||
}
|
||||
|
||||
public withScrollPosition(update: INewScrollPosition): ScrollState {
|
||||
return new ScrollState(
|
||||
this._forceIntegerValues,
|
||||
this.width,
|
||||
this.scrollWidth,
|
||||
(typeof update.scrollLeft !== 'undefined' ? update.scrollLeft : this.rawScrollLeft),
|
||||
this.height,
|
||||
this.scrollHeight,
|
||||
(typeof update.scrollTop !== 'undefined' ? update.scrollTop : this.rawScrollTop)
|
||||
);
|
||||
}
|
||||
|
||||
public createScrollEvent(previous: ScrollState, inSmoothScrolling: boolean): IScrollEvent {
|
||||
const widthChanged = (this.width !== previous.width);
|
||||
const scrollWidthChanged = (this.scrollWidth !== previous.scrollWidth);
|
||||
const scrollLeftChanged = (this.scrollLeft !== previous.scrollLeft);
|
||||
|
||||
const heightChanged = (this.height !== previous.height);
|
||||
const scrollHeightChanged = (this.scrollHeight !== previous.scrollHeight);
|
||||
const scrollTopChanged = (this.scrollTop !== previous.scrollTop);
|
||||
|
||||
return {
|
||||
inSmoothScrolling: inSmoothScrolling,
|
||||
oldWidth: previous.width,
|
||||
oldScrollWidth: previous.scrollWidth,
|
||||
oldScrollLeft: previous.scrollLeft,
|
||||
|
||||
width: this.width,
|
||||
scrollWidth: this.scrollWidth,
|
||||
scrollLeft: this.scrollLeft,
|
||||
|
||||
oldHeight: previous.height,
|
||||
oldScrollHeight: previous.scrollHeight,
|
||||
oldScrollTop: previous.scrollTop,
|
||||
|
||||
height: this.height,
|
||||
scrollHeight: this.scrollHeight,
|
||||
scrollTop: this.scrollTop,
|
||||
|
||||
widthChanged: widthChanged,
|
||||
scrollWidthChanged: scrollWidthChanged,
|
||||
scrollLeftChanged: scrollLeftChanged,
|
||||
|
||||
heightChanged: heightChanged,
|
||||
scrollHeightChanged: scrollHeightChanged,
|
||||
scrollTopChanged: scrollTopChanged,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export interface IScrollDimensions {
|
||||
readonly width: number;
|
||||
readonly scrollWidth: number;
|
||||
readonly height: number;
|
||||
readonly scrollHeight: number;
|
||||
}
|
||||
export interface INewScrollDimensions {
|
||||
width?: number;
|
||||
scrollWidth?: number;
|
||||
height?: number;
|
||||
scrollHeight?: number;
|
||||
}
|
||||
|
||||
export interface IScrollPosition {
|
||||
readonly scrollLeft: number;
|
||||
readonly scrollTop: number;
|
||||
}
|
||||
export interface ISmoothScrollPosition {
|
||||
readonly scrollLeft: number;
|
||||
readonly scrollTop: number;
|
||||
|
||||
readonly width: number;
|
||||
readonly height: number;
|
||||
}
|
||||
export interface INewScrollPosition {
|
||||
scrollLeft?: number;
|
||||
scrollTop?: number;
|
||||
}
|
||||
|
||||
export interface IScrollableOptions {
|
||||
forceIntegerValues: boolean;
|
||||
smoothScrollDuration: number;
|
||||
scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable;
|
||||
}
|
||||
|
||||
export class Scrollable extends Disposable {
|
||||
|
||||
private _scrollableBrand: void = undefined;
|
||||
|
||||
private _smoothScrollDuration: number;
|
||||
private readonly _scheduleAtNextAnimationFrame: (callback: () => void) => IDisposable;
|
||||
private _state: ScrollState;
|
||||
private _smoothScrolling: SmoothScrollingOperation | null;
|
||||
|
||||
private _onScroll = this._register(new Emitter<IScrollEvent>());
|
||||
public readonly onScroll: IEvent<IScrollEvent> = this._onScroll.event;
|
||||
|
||||
constructor(options: IScrollableOptions) {
|
||||
super();
|
||||
|
||||
this._smoothScrollDuration = options.smoothScrollDuration;
|
||||
this._scheduleAtNextAnimationFrame = options.scheduleAtNextAnimationFrame;
|
||||
this._state = new ScrollState(options.forceIntegerValues, 0, 0, 0, 0, 0, 0);
|
||||
this._smoothScrolling = null;
|
||||
}
|
||||
|
||||
public override dispose(): void {
|
||||
if (this._smoothScrolling) {
|
||||
this._smoothScrolling.dispose();
|
||||
this._smoothScrolling = null;
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public setSmoothScrollDuration(smoothScrollDuration: number): void {
|
||||
this._smoothScrollDuration = smoothScrollDuration;
|
||||
}
|
||||
|
||||
public validateScrollPosition(scrollPosition: INewScrollPosition): IScrollPosition {
|
||||
return this._state.withScrollPosition(scrollPosition);
|
||||
}
|
||||
|
||||
public getScrollDimensions(): IScrollDimensions {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public setScrollDimensions(dimensions: INewScrollDimensions, useRawScrollPositions: boolean): void {
|
||||
const newState = this._state.withScrollDimensions(dimensions, useRawScrollPositions);
|
||||
this._setState(newState, Boolean(this._smoothScrolling));
|
||||
|
||||
this._smoothScrolling?.acceptScrollDimensions(this._state);
|
||||
}
|
||||
|
||||
public getFutureScrollPosition(): IScrollPosition {
|
||||
if (this._smoothScrolling) {
|
||||
return this._smoothScrolling.to;
|
||||
}
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public getCurrentScrollPosition(): IScrollPosition {
|
||||
return this._state;
|
||||
}
|
||||
|
||||
public setScrollPositionNow(update: INewScrollPosition): void {
|
||||
const newState = this._state.withScrollPosition(update);
|
||||
|
||||
if (this._smoothScrolling) {
|
||||
this._smoothScrolling.dispose();
|
||||
this._smoothScrolling = null;
|
||||
}
|
||||
|
||||
this._setState(newState, false);
|
||||
}
|
||||
|
||||
public setScrollPositionSmooth(update: INewScrollPosition, reuseAnimation?: boolean): void {
|
||||
if (this._smoothScrollDuration === 0) {
|
||||
this.setScrollPositionNow(update); return;
|
||||
}
|
||||
|
||||
if (this._smoothScrolling) {
|
||||
update = {
|
||||
scrollLeft: (typeof update.scrollLeft === 'undefined' ? this._smoothScrolling.to.scrollLeft : update.scrollLeft),
|
||||
scrollTop: (typeof update.scrollTop === 'undefined' ? this._smoothScrolling.to.scrollTop : update.scrollTop)
|
||||
};
|
||||
|
||||
const validTarget = this._state.withScrollPosition(update);
|
||||
|
||||
if (this._smoothScrolling.to.scrollLeft === validTarget.scrollLeft && this._smoothScrolling.to.scrollTop === validTarget.scrollTop) {
|
||||
return;
|
||||
}
|
||||
let newSmoothScrolling: SmoothScrollingOperation;
|
||||
if (reuseAnimation) {
|
||||
newSmoothScrolling = new SmoothScrollingOperation(this._smoothScrolling.from, validTarget, this._smoothScrolling.startTime, this._smoothScrolling.duration);
|
||||
} else {
|
||||
newSmoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration);
|
||||
}
|
||||
this._smoothScrolling.dispose();
|
||||
this._smoothScrolling = newSmoothScrolling;
|
||||
} else {
|
||||
const validTarget = this._state.withScrollPosition(update);
|
||||
|
||||
this._smoothScrolling = SmoothScrollingOperation.start(this._state, validTarget, this._smoothScrollDuration);
|
||||
}
|
||||
|
||||
this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => {
|
||||
if (!this._smoothScrolling) {
|
||||
return;
|
||||
}
|
||||
this._smoothScrolling.animationFrameDisposable = null;
|
||||
this._performSmoothScrolling();
|
||||
});
|
||||
}
|
||||
|
||||
public hasPendingScrollAnimation(): boolean {
|
||||
return Boolean(this._smoothScrolling);
|
||||
}
|
||||
|
||||
private _performSmoothScrolling(): void {
|
||||
if (!this._smoothScrolling) {
|
||||
return;
|
||||
}
|
||||
const update = this._smoothScrolling.tick();
|
||||
const newState = this._state.withScrollPosition(update);
|
||||
|
||||
this._setState(newState, true);
|
||||
|
||||
if (!this._smoothScrolling) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (update.isDone) {
|
||||
this._smoothScrolling.dispose();
|
||||
this._smoothScrolling = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this._smoothScrolling.animationFrameDisposable = this._scheduleAtNextAnimationFrame(() => {
|
||||
if (!this._smoothScrolling) {
|
||||
return;
|
||||
}
|
||||
this._smoothScrolling.animationFrameDisposable = null;
|
||||
this._performSmoothScrolling();
|
||||
});
|
||||
}
|
||||
|
||||
private _setState(newState: ScrollState, inSmoothScrolling: boolean): void {
|
||||
const oldState = this._state;
|
||||
if (oldState.equals(newState)) {
|
||||
return;
|
||||
}
|
||||
this._state = newState;
|
||||
this._onScroll.fire(this._state.createScrollEvent(oldState, inSmoothScrolling));
|
||||
}
|
||||
}
|
||||
|
||||
class SmoothScrollingUpdate {
|
||||
|
||||
public readonly scrollLeft: number;
|
||||
public readonly scrollTop: number;
|
||||
public readonly isDone: boolean;
|
||||
|
||||
constructor(scrollLeft: number, scrollTop: number, isDone: boolean) {
|
||||
this.scrollLeft = scrollLeft;
|
||||
this.scrollTop = scrollTop;
|
||||
this.isDone = isDone;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface IAnimation {
|
||||
(completion: number): number;
|
||||
}
|
||||
|
||||
function createEaseOutCubic(from: number, to: number): IAnimation {
|
||||
const delta = to - from;
|
||||
return function (completion: number): number {
|
||||
return from + delta * easeOutCubic(completion);
|
||||
};
|
||||
}
|
||||
|
||||
function createComposed(a: IAnimation, b: IAnimation, cut: number): IAnimation {
|
||||
return function (completion: number): number {
|
||||
if (completion < cut) {
|
||||
return a(completion / cut);
|
||||
}
|
||||
return b((completion - cut) / (1 - cut));
|
||||
};
|
||||
}
|
||||
|
||||
class SmoothScrollingOperation {
|
||||
|
||||
public readonly from: ISmoothScrollPosition;
|
||||
public to: ISmoothScrollPosition;
|
||||
public readonly duration: number;
|
||||
public readonly startTime: number;
|
||||
public animationFrameDisposable: IDisposable | null;
|
||||
|
||||
private _scrollLeft!: IAnimation;
|
||||
private _scrollTop!: IAnimation;
|
||||
|
||||
constructor(from: ISmoothScrollPosition, to: ISmoothScrollPosition, startTime: number, duration: number) {
|
||||
this.from = from;
|
||||
this.to = to;
|
||||
this.duration = duration;
|
||||
this.startTime = startTime;
|
||||
|
||||
this.animationFrameDisposable = null;
|
||||
|
||||
this._initAnimations();
|
||||
}
|
||||
|
||||
private _initAnimations(): void {
|
||||
this._scrollLeft = this._initAnimation(this.from.scrollLeft, this.to.scrollLeft, this.to.width);
|
||||
this._scrollTop = this._initAnimation(this.from.scrollTop, this.to.scrollTop, this.to.height);
|
||||
}
|
||||
|
||||
private _initAnimation(from: number, to: number, viewportSize: number): IAnimation {
|
||||
const delta = Math.abs(from - to);
|
||||
if (delta > 2.5 * viewportSize) {
|
||||
let stop1: number; let stop2: number;
|
||||
if (from < to) {
|
||||
stop1 = from + 0.75 * viewportSize;
|
||||
stop2 = to - 0.75 * viewportSize;
|
||||
} else {
|
||||
stop1 = from - 0.75 * viewportSize;
|
||||
stop2 = to + 0.75 * viewportSize;
|
||||
}
|
||||
return createComposed(createEaseOutCubic(from, stop1), createEaseOutCubic(stop2, to), 0.33);
|
||||
}
|
||||
return createEaseOutCubic(from, to);
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this.animationFrameDisposable !== null) {
|
||||
this.animationFrameDisposable.dispose();
|
||||
this.animationFrameDisposable = null;
|
||||
}
|
||||
}
|
||||
|
||||
public acceptScrollDimensions(state: ScrollState): void {
|
||||
this.to = state.withScrollPosition(this.to);
|
||||
this._initAnimations();
|
||||
}
|
||||
|
||||
public tick(): SmoothScrollingUpdate {
|
||||
return this._tick(Date.now());
|
||||
}
|
||||
|
||||
protected _tick(now: number): SmoothScrollingUpdate {
|
||||
const completion = (now - this.startTime) / this.duration;
|
||||
|
||||
if (completion < 1) {
|
||||
const newScrollLeft = this._scrollLeft(completion);
|
||||
const newScrollTop = this._scrollTop(completion);
|
||||
return new SmoothScrollingUpdate(newScrollLeft, newScrollTop, false);
|
||||
}
|
||||
|
||||
return new SmoothScrollingUpdate(this.to.scrollLeft, this.to.scrollTop, true);
|
||||
}
|
||||
|
||||
public static start(from: ISmoothScrollPosition, to: ISmoothScrollPosition, duration: number): SmoothScrollingOperation {
|
||||
duration = duration + 10;
|
||||
const startTime = Date.now() - 10;
|
||||
|
||||
return new SmoothScrollingOperation(from, to, startTime, duration);
|
||||
}
|
||||
}
|
||||
|
||||
function easeInCubic(t: number): number {
|
||||
return Math.pow(t, 3);
|
||||
}
|
||||
|
||||
function easeOutCubic(t: number): number {
|
||||
return 1 - easeInCubic(1 - t);
|
||||
}
|
||||
@@ -0,0 +1,579 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as dom from '../Dom';
|
||||
import { FastDomNode } from './fastDomNode';
|
||||
import { IMouseEvent, IMouseWheelEvent, StandardWheelEvent } from './mouseEvent';
|
||||
import { IScrollbarHost } from './abstractScrollbar';
|
||||
import { HorizontalScrollbar } from './horizontalScrollbar';
|
||||
import { IScrollableElementChangeOptions, IScrollableElementCreationOptions, IScrollableElementResolvedOptions } from './scrollableElementOptions';
|
||||
import { VerticalScrollbar } from './verticalScrollbar';
|
||||
import { Widget } from './widget';
|
||||
import { TimeoutTimer } from 'common/Async';
|
||||
import { Emitter, IEvent } from 'common/Event';
|
||||
import { IDisposable, dispose } from 'common/Lifecycle';
|
||||
import * as platform from 'common/Platform';
|
||||
import { INewScrollDimensions, INewScrollPosition, IScrollDimensions, IScrollPosition, IScrollEvent, Scrollable, ScrollbarVisibility } from './scrollable';
|
||||
// import 'vs/css!./media/scrollbars';
|
||||
|
||||
const HIDE_TIMEOUT = 500;
|
||||
const SCROLL_WHEEL_SENSITIVITY = 50;
|
||||
|
||||
class MouseWheelClassifierItem {
|
||||
public timestamp: number;
|
||||
public deltaX: number;
|
||||
public deltaY: number;
|
||||
public score: number;
|
||||
|
||||
constructor(timestamp: number, deltaX: number, deltaY: number) {
|
||||
this.timestamp = timestamp;
|
||||
this.deltaX = deltaX;
|
||||
this.deltaY = deltaY;
|
||||
this.score = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class MouseWheelClassifier {
|
||||
|
||||
public static readonly INSTANCE = new MouseWheelClassifier();
|
||||
|
||||
private readonly _capacity: number;
|
||||
private _memory: MouseWheelClassifierItem[];
|
||||
private _front: number;
|
||||
private _rear: number;
|
||||
|
||||
constructor() {
|
||||
this._capacity = 5;
|
||||
this._memory = [];
|
||||
this._front = -1;
|
||||
this._rear = -1;
|
||||
}
|
||||
|
||||
public isPhysicalMouseWheel(): boolean {
|
||||
if (this._front === -1 && this._rear === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let remainingInfluence = 1;
|
||||
let score = 0;
|
||||
let iteration = 1;
|
||||
|
||||
let index = this._rear;
|
||||
while (index !== -1) {
|
||||
const influence = (index === this._front ? remainingInfluence : Math.pow(2, -iteration));
|
||||
remainingInfluence -= influence;
|
||||
score += this._memory[index].score * influence;
|
||||
|
||||
if (index === this._front) {
|
||||
break;
|
||||
}
|
||||
|
||||
index = (this._capacity + index - 1) % this._capacity;
|
||||
iteration++;
|
||||
}
|
||||
|
||||
return (score <= 0.5);
|
||||
}
|
||||
|
||||
public acceptStandardWheelEvent(e: StandardWheelEvent): void {
|
||||
if (platform.isChrome) {
|
||||
const targetWindow = dom.getWindow(e.browserEvent);
|
||||
const pageZoomFactor = platform.getZoomFactor(targetWindow);
|
||||
this.accept(Date.now(), e.deltaX * pageZoomFactor, e.deltaY * pageZoomFactor);
|
||||
} else {
|
||||
this.accept(Date.now(), e.deltaX, e.deltaY);
|
||||
}
|
||||
}
|
||||
|
||||
public accept(timestamp: number, deltaX: number, deltaY: number): void {
|
||||
let previousItem = null;
|
||||
const item = new MouseWheelClassifierItem(timestamp, deltaX, deltaY);
|
||||
|
||||
if (this._front === -1 && this._rear === -1) {
|
||||
this._memory[0] = item;
|
||||
this._front = 0;
|
||||
this._rear = 0;
|
||||
} else {
|
||||
previousItem = this._memory[this._rear];
|
||||
|
||||
this._rear = (this._rear + 1) % this._capacity;
|
||||
if (this._rear === this._front) {
|
||||
this._front = (this._front + 1) % this._capacity;
|
||||
}
|
||||
this._memory[this._rear] = item;
|
||||
}
|
||||
|
||||
item.score = this._computeScore(item, previousItem);
|
||||
}
|
||||
|
||||
private _computeScore(item: MouseWheelClassifierItem, previousItem: MouseWheelClassifierItem | null): number {
|
||||
|
||||
if (Math.abs(item.deltaX) > 0 && Math.abs(item.deltaY) > 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
let score: number = 0.5;
|
||||
|
||||
if (!this._isAlmostInt(item.deltaX) || !this._isAlmostInt(item.deltaY)) {
|
||||
score += 0.25;
|
||||
}
|
||||
|
||||
if (previousItem) {
|
||||
const absDeltaX = Math.abs(item.deltaX);
|
||||
const absDeltaY = Math.abs(item.deltaY);
|
||||
|
||||
const absPreviousDeltaX = Math.abs(previousItem.deltaX);
|
||||
const absPreviousDeltaY = Math.abs(previousItem.deltaY);
|
||||
|
||||
const minDeltaX = Math.max(Math.min(absDeltaX, absPreviousDeltaX), 1);
|
||||
const minDeltaY = Math.max(Math.min(absDeltaY, absPreviousDeltaY), 1);
|
||||
|
||||
const maxDeltaX = Math.max(absDeltaX, absPreviousDeltaX);
|
||||
const maxDeltaY = Math.max(absDeltaY, absPreviousDeltaY);
|
||||
|
||||
const isSameModulo = (maxDeltaX % minDeltaX === 0 && maxDeltaY % minDeltaY === 0);
|
||||
if (isSameModulo) {
|
||||
score -= 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.min(Math.max(score, 0), 1);
|
||||
}
|
||||
|
||||
private _isAlmostInt(value: number): boolean {
|
||||
const delta = Math.abs(Math.round(value) - value);
|
||||
return (delta < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
export class SmoothScrollableElement extends Widget {
|
||||
|
||||
private readonly _options: IScrollableElementResolvedOptions;
|
||||
protected readonly _scrollable: Scrollable;
|
||||
private readonly _verticalScrollbar: VerticalScrollbar;
|
||||
private readonly _horizontalScrollbar: HorizontalScrollbar;
|
||||
private readonly _domNode: HTMLElement;
|
||||
|
||||
private readonly _leftShadowDomNode: FastDomNode<HTMLElement> | null;
|
||||
private readonly _topShadowDomNode: FastDomNode<HTMLElement> | null;
|
||||
private readonly _topLeftShadowDomNode: FastDomNode<HTMLElement> | null;
|
||||
|
||||
private readonly _listenOnDomNode: HTMLElement;
|
||||
|
||||
private _mouseWheelToDispose: IDisposable[];
|
||||
|
||||
private _isDragging: boolean;
|
||||
private _mouseIsOver: boolean;
|
||||
|
||||
private readonly _hideTimeout: TimeoutTimer;
|
||||
private _shouldRender: boolean;
|
||||
|
||||
private _revealOnScroll: boolean;
|
||||
|
||||
private readonly _onScroll = this._register(new Emitter<IScrollEvent>());
|
||||
public readonly onScroll: IEvent<IScrollEvent> = this._onScroll.event;
|
||||
|
||||
public get options(): Readonly<IScrollableElementResolvedOptions> {
|
||||
return this._options;
|
||||
}
|
||||
|
||||
public constructor(element: HTMLElement, options: IScrollableElementCreationOptions, scrollable?: Scrollable) {
|
||||
super();
|
||||
options = options ?? {};
|
||||
let resolvedScrollable: Scrollable;
|
||||
const ownsScrollable = !scrollable;
|
||||
if (scrollable) {
|
||||
resolvedScrollable = scrollable;
|
||||
} else {
|
||||
options.mouseWheelSmoothScroll = false;
|
||||
resolvedScrollable = new Scrollable({
|
||||
forceIntegerValues: true,
|
||||
smoothScrollDuration: 0,
|
||||
scheduleAtNextAnimationFrame: (callback) => dom.scheduleAtNextAnimationFrame(dom.getWindow(element), callback)
|
||||
});
|
||||
}
|
||||
|
||||
this._options = resolveOptions(options);
|
||||
this._scrollable = resolvedScrollable;
|
||||
|
||||
this._register(this._scrollable.onScroll((e) => {
|
||||
this._handleScroll(e);
|
||||
this._onScroll.fire(e);
|
||||
}));
|
||||
if (ownsScrollable) {
|
||||
this._register(this._scrollable);
|
||||
}
|
||||
|
||||
const scrollbarHost: IScrollbarHost = {
|
||||
handleMouseWheel: (mouseWheelEvent: StandardWheelEvent) => this._handleMouseWheel(mouseWheelEvent),
|
||||
handleDragStart: () => this._handleDragStart(),
|
||||
handleDragEnd: () => this._handleDragEnd(),
|
||||
};
|
||||
this._verticalScrollbar = this._register(new VerticalScrollbar(this._scrollable, this._options, scrollbarHost));
|
||||
this._horizontalScrollbar = this._register(new HorizontalScrollbar(this._scrollable, this._options, scrollbarHost));
|
||||
|
||||
this._domNode = document.createElement('div');
|
||||
this._domNode.className = 'xterm-scrollable-element ' + this._options.className;
|
||||
this._domNode.setAttribute('role', 'presentation');
|
||||
this._domNode.style.position = 'relative';
|
||||
this._domNode.appendChild(element);
|
||||
this._domNode.appendChild(this._horizontalScrollbar.domNode.domNode);
|
||||
this._domNode.appendChild(this._verticalScrollbar.domNode.domNode);
|
||||
|
||||
if (this._options.useShadows) {
|
||||
this._leftShadowDomNode = new FastDomNode(document.createElement('div'));
|
||||
this._leftShadowDomNode.setClassName('xterm-shadow');
|
||||
this._domNode.appendChild(this._leftShadowDomNode.domNode);
|
||||
|
||||
this._topShadowDomNode = new FastDomNode(document.createElement('div'));
|
||||
this._topShadowDomNode.setClassName('xterm-shadow');
|
||||
this._domNode.appendChild(this._topShadowDomNode.domNode);
|
||||
|
||||
this._topLeftShadowDomNode = new FastDomNode(document.createElement('div'));
|
||||
this._topLeftShadowDomNode.setClassName('xterm-shadow');
|
||||
this._domNode.appendChild(this._topLeftShadowDomNode.domNode);
|
||||
} else {
|
||||
this._leftShadowDomNode = null;
|
||||
this._topShadowDomNode = null;
|
||||
this._topLeftShadowDomNode = null;
|
||||
}
|
||||
|
||||
this._listenOnDomNode = this._options.listenOnDomNode ?? this._domNode;
|
||||
|
||||
this._mouseWheelToDispose = [];
|
||||
this._setListeningToMouseWheel(this._options.handleMouseWheel);
|
||||
|
||||
this._onmouseover(this._listenOnDomNode, (e) => this._handleMouseOver(e));
|
||||
this._onmouseleave(this._listenOnDomNode, (e) => this._handleMouseLeave(e));
|
||||
|
||||
this._hideTimeout = this._register(new TimeoutTimer());
|
||||
this._isDragging = false;
|
||||
this._mouseIsOver = false;
|
||||
|
||||
this._shouldRender = true;
|
||||
|
||||
this._revealOnScroll = true;
|
||||
}
|
||||
|
||||
public override dispose(): void {
|
||||
this._mouseWheelToDispose = dispose(this._mouseWheelToDispose);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
public getDomNode(): HTMLElement {
|
||||
return this._domNode;
|
||||
}
|
||||
|
||||
public getScrollDimensions(): IScrollDimensions {
|
||||
return this._scrollable.getScrollDimensions();
|
||||
}
|
||||
|
||||
public setScrollDimensions(dimensions: INewScrollDimensions): void {
|
||||
this._scrollable.setScrollDimensions(dimensions, false);
|
||||
}
|
||||
|
||||
public setScrollPosition(update: INewScrollPosition & { reuseAnimation?: boolean }): void {
|
||||
if (update.reuseAnimation) {
|
||||
this._scrollable.setScrollPositionSmooth(update, update.reuseAnimation);
|
||||
} else {
|
||||
this._scrollable.setScrollPositionNow(update);
|
||||
}
|
||||
}
|
||||
|
||||
public getScrollPosition(): IScrollPosition {
|
||||
return this._scrollable.getCurrentScrollPosition();
|
||||
}
|
||||
|
||||
public updateClassName(newClassName: string): void {
|
||||
this._options.className = newClassName;
|
||||
if (platform.isMac) {
|
||||
this._options.className += ' xterm-mac';
|
||||
}
|
||||
this._domNode.className = 'xterm-scrollable-element ' + this._options.className;
|
||||
}
|
||||
|
||||
public updateOptions(newOptions: IScrollableElementChangeOptions): void {
|
||||
if (typeof newOptions.handleMouseWheel !== 'undefined') {
|
||||
this._options.handleMouseWheel = newOptions.handleMouseWheel;
|
||||
this._setListeningToMouseWheel(this._options.handleMouseWheel);
|
||||
}
|
||||
if (typeof newOptions.mouseWheelScrollSensitivity !== 'undefined') {
|
||||
this._options.mouseWheelScrollSensitivity = newOptions.mouseWheelScrollSensitivity;
|
||||
}
|
||||
if (typeof newOptions.fastScrollSensitivity !== 'undefined') {
|
||||
this._options.fastScrollSensitivity = newOptions.fastScrollSensitivity;
|
||||
}
|
||||
if (typeof newOptions.scrollPredominantAxis !== 'undefined') {
|
||||
this._options.scrollPredominantAxis = newOptions.scrollPredominantAxis;
|
||||
}
|
||||
if (typeof newOptions.horizontal !== 'undefined') {
|
||||
this._options.horizontal = newOptions.horizontal;
|
||||
}
|
||||
if (typeof newOptions.vertical !== 'undefined') {
|
||||
this._options.vertical = newOptions.vertical;
|
||||
}
|
||||
if (typeof newOptions.horizontalHasArrows !== 'undefined') {
|
||||
this._options.horizontalHasArrows = newOptions.horizontalHasArrows;
|
||||
}
|
||||
if (typeof newOptions.verticalHasArrows !== 'undefined') {
|
||||
this._options.verticalHasArrows = newOptions.verticalHasArrows;
|
||||
}
|
||||
if (typeof newOptions.horizontalScrollbarSize !== 'undefined') {
|
||||
this._options.horizontalScrollbarSize = newOptions.horizontalScrollbarSize;
|
||||
}
|
||||
if (typeof newOptions.verticalScrollbarSize !== 'undefined') {
|
||||
this._options.verticalScrollbarSize = newOptions.verticalScrollbarSize;
|
||||
}
|
||||
if (typeof newOptions.scrollByPage !== 'undefined') {
|
||||
this._options.scrollByPage = newOptions.scrollByPage;
|
||||
}
|
||||
this._horizontalScrollbar.updateOptions(this._options);
|
||||
this._verticalScrollbar.updateOptions(this._options);
|
||||
|
||||
if (!this._options.lazyRender) {
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
public delegateScrollFromMouseWheelEvent(browserEvent: IMouseWheelEvent): void {
|
||||
this._handleMouseWheel(new StandardWheelEvent(browserEvent));
|
||||
}
|
||||
|
||||
// -------------------- mouse wheel scrolling --------------------
|
||||
|
||||
private _setListeningToMouseWheel(shouldListen: boolean): void {
|
||||
const isListening = (this._mouseWheelToDispose.length > 0);
|
||||
|
||||
if (isListening === shouldListen) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._mouseWheelToDispose = dispose(this._mouseWheelToDispose);
|
||||
|
||||
if (shouldListen) {
|
||||
const onMouseWheel = (browserEvent: IMouseWheelEvent): void => {
|
||||
this._handleMouseWheel(new StandardWheelEvent(browserEvent));
|
||||
};
|
||||
|
||||
this._mouseWheelToDispose.push(dom.addDisposableListener(this._listenOnDomNode, dom.eventType.MOUSE_WHEEL, onMouseWheel, { passive: false }));
|
||||
}
|
||||
}
|
||||
|
||||
private _handleMouseWheel(e: StandardWheelEvent): void {
|
||||
if (e.browserEvent?.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
|
||||
const classifier = MouseWheelClassifier.INSTANCE;
|
||||
classifier.acceptStandardWheelEvent(e);
|
||||
|
||||
let didScroll = false;
|
||||
|
||||
if (e.deltaY || e.deltaX) {
|
||||
let deltaY = e.deltaY * this._options.mouseWheelScrollSensitivity;
|
||||
let deltaX = e.deltaX * this._options.mouseWheelScrollSensitivity;
|
||||
|
||||
if (this._options.scrollPredominantAxis) {
|
||||
if (this._options.scrollYToX && deltaX + deltaY === 0) {
|
||||
deltaX = deltaY = 0;
|
||||
} else if (Math.abs(deltaY) >= Math.abs(deltaX)) {
|
||||
deltaX = 0;
|
||||
} else {
|
||||
deltaY = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (this._options.flipAxes) {
|
||||
[deltaY, deltaX] = [deltaX, deltaY];
|
||||
}
|
||||
|
||||
const shiftConvert = !platform.isMac && e.browserEvent && e.browserEvent.shiftKey;
|
||||
if ((this._options.scrollYToX || shiftConvert) && !deltaX) {
|
||||
deltaX = deltaY;
|
||||
deltaY = 0;
|
||||
}
|
||||
|
||||
if (e.browserEvent && e.browserEvent.altKey) {
|
||||
deltaX = deltaX * this._options.fastScrollSensitivity;
|
||||
deltaY = deltaY * this._options.fastScrollSensitivity;
|
||||
}
|
||||
|
||||
const futureScrollPosition = this._scrollable.getFutureScrollPosition();
|
||||
|
||||
let desiredScrollPosition: INewScrollPosition = {};
|
||||
if (deltaY) {
|
||||
const deltaScrollTop = SCROLL_WHEEL_SENSITIVITY * deltaY;
|
||||
const desiredScrollTop = futureScrollPosition.scrollTop - (deltaScrollTop < 0 ? Math.floor(deltaScrollTop) : Math.ceil(deltaScrollTop));
|
||||
this._verticalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollTop);
|
||||
}
|
||||
if (deltaX) {
|
||||
const deltaScrollLeft = SCROLL_WHEEL_SENSITIVITY * deltaX;
|
||||
const desiredScrollLeft = futureScrollPosition.scrollLeft - (deltaScrollLeft < 0 ? Math.floor(deltaScrollLeft) : Math.ceil(deltaScrollLeft));
|
||||
this._horizontalScrollbar.writeScrollPosition(desiredScrollPosition, desiredScrollLeft);
|
||||
}
|
||||
|
||||
desiredScrollPosition = this._scrollable.validateScrollPosition(desiredScrollPosition);
|
||||
|
||||
if (futureScrollPosition.scrollLeft !== desiredScrollPosition.scrollLeft || futureScrollPosition.scrollTop !== desiredScrollPosition.scrollTop) {
|
||||
|
||||
const canPerformSmoothScroll = (
|
||||
this._options.mouseWheelSmoothScroll
|
||||
&& classifier.isPhysicalMouseWheel()
|
||||
);
|
||||
|
||||
if (canPerformSmoothScroll) {
|
||||
this._scrollable.setScrollPositionSmooth(desiredScrollPosition);
|
||||
} else {
|
||||
this._scrollable.setScrollPositionNow(desiredScrollPosition);
|
||||
}
|
||||
|
||||
didScroll = true;
|
||||
}
|
||||
}
|
||||
|
||||
let consumeMouseWheel = didScroll;
|
||||
if (!consumeMouseWheel && this._options.alwaysConsumeMouseWheel) {
|
||||
consumeMouseWheel = true;
|
||||
}
|
||||
if (!consumeMouseWheel && this._options.consumeMouseWheelIfScrollbarIsNeeded && (this._verticalScrollbar.isNeeded() || this._horizontalScrollbar.isNeeded())) {
|
||||
consumeMouseWheel = true;
|
||||
}
|
||||
|
||||
if (consumeMouseWheel) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
private _handleScroll(e: IScrollEvent): void {
|
||||
this._shouldRender = this._horizontalScrollbar.handleScroll(e) || this._shouldRender;
|
||||
this._shouldRender = this._verticalScrollbar.handleScroll(e) || this._shouldRender;
|
||||
|
||||
if (this._options.useShadows) {
|
||||
this._shouldRender = true;
|
||||
}
|
||||
|
||||
if (this._revealOnScroll) {
|
||||
this._reveal();
|
||||
}
|
||||
|
||||
if (!this._options.lazyRender) {
|
||||
this._render();
|
||||
}
|
||||
}
|
||||
|
||||
public renderNow(): void {
|
||||
if (!this._options.lazyRender) {
|
||||
throw new Error('Please use `lazyRender` together with `renderNow`!');
|
||||
}
|
||||
|
||||
this._render();
|
||||
}
|
||||
|
||||
private _render(): void {
|
||||
if (!this._shouldRender) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._shouldRender = false;
|
||||
|
||||
this._horizontalScrollbar.render();
|
||||
this._verticalScrollbar.render();
|
||||
|
||||
if (this._options.useShadows) {
|
||||
const scrollState = this._scrollable.getCurrentScrollPosition();
|
||||
const enableTop = scrollState.scrollTop > 0;
|
||||
const enableLeft = scrollState.scrollLeft > 0;
|
||||
|
||||
const leftClassName = (enableLeft ? ' xterm-shadow-left' : '');
|
||||
const topClassName = (enableTop ? ' xterm-shadow-top' : '');
|
||||
const topLeftClassName = (enableLeft || enableTop ? ' xterm-shadow-top-left-corner' : '');
|
||||
this._leftShadowDomNode!.setClassName(`xterm-shadow${leftClassName}`);
|
||||
this._topShadowDomNode!.setClassName(`xterm-shadow${topClassName}`);
|
||||
this._topLeftShadowDomNode!.setClassName(`xterm-shadow${topLeftClassName}${topClassName}${leftClassName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------- fade in / fade out --------------------
|
||||
|
||||
private _handleDragStart(): void {
|
||||
this._isDragging = true;
|
||||
this._reveal();
|
||||
}
|
||||
|
||||
private _handleDragEnd(): void {
|
||||
this._isDragging = false;
|
||||
this._hide();
|
||||
}
|
||||
|
||||
private _handleMouseLeave(e: IMouseEvent): void {
|
||||
this._mouseIsOver = false;
|
||||
this._hide();
|
||||
}
|
||||
|
||||
private _handleMouseOver(e: IMouseEvent): void {
|
||||
this._mouseIsOver = true;
|
||||
this._reveal();
|
||||
}
|
||||
|
||||
private _reveal(): void {
|
||||
this._verticalScrollbar.beginReveal();
|
||||
this._horizontalScrollbar.beginReveal();
|
||||
this._scheduleHide();
|
||||
}
|
||||
|
||||
private _hide(): void {
|
||||
if (!this._mouseIsOver && !this._isDragging) {
|
||||
this._verticalScrollbar.beginHide();
|
||||
this._horizontalScrollbar.beginHide();
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleHide(): void {
|
||||
if (!this._mouseIsOver && !this._isDragging) {
|
||||
this._hideTimeout.cancelAndSet(() => this._hide(), HIDE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOptions(opts: IScrollableElementCreationOptions): IScrollableElementResolvedOptions {
|
||||
const result: IScrollableElementResolvedOptions = {
|
||||
lazyRender: (typeof opts.lazyRender !== 'undefined' ? opts.lazyRender : false),
|
||||
className: (typeof opts.className !== 'undefined' ? opts.className : ''),
|
||||
useShadows: (typeof opts.useShadows !== 'undefined' ? opts.useShadows : true),
|
||||
handleMouseWheel: (typeof opts.handleMouseWheel !== 'undefined' ? opts.handleMouseWheel : true),
|
||||
flipAxes: (typeof opts.flipAxes !== 'undefined' ? opts.flipAxes : false),
|
||||
consumeMouseWheelIfScrollbarIsNeeded: (typeof opts.consumeMouseWheelIfScrollbarIsNeeded !== 'undefined' ? opts.consumeMouseWheelIfScrollbarIsNeeded : false),
|
||||
alwaysConsumeMouseWheel: (typeof opts.alwaysConsumeMouseWheel !== 'undefined' ? opts.alwaysConsumeMouseWheel : false),
|
||||
scrollYToX: (typeof opts.scrollYToX !== 'undefined' ? opts.scrollYToX : false),
|
||||
mouseWheelScrollSensitivity: (typeof opts.mouseWheelScrollSensitivity !== 'undefined' ? opts.mouseWheelScrollSensitivity : 1),
|
||||
fastScrollSensitivity: (typeof opts.fastScrollSensitivity !== 'undefined' ? opts.fastScrollSensitivity : 5),
|
||||
scrollPredominantAxis: (typeof opts.scrollPredominantAxis !== 'undefined' ? opts.scrollPredominantAxis : true),
|
||||
mouseWheelSmoothScroll: (typeof opts.mouseWheelSmoothScroll !== 'undefined' ? opts.mouseWheelSmoothScroll : true),
|
||||
|
||||
listenOnDomNode: (typeof opts.listenOnDomNode !== 'undefined' ? opts.listenOnDomNode : null),
|
||||
|
||||
horizontal: (typeof opts.horizontal !== 'undefined' ? opts.horizontal : ScrollbarVisibility.AUTO),
|
||||
horizontalScrollbarSize: (typeof opts.horizontalScrollbarSize !== 'undefined' ? opts.horizontalScrollbarSize : 10),
|
||||
horizontalSliderSize: (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : 0),
|
||||
horizontalHasArrows: (typeof opts.horizontalHasArrows !== 'undefined' ? opts.horizontalHasArrows : false),
|
||||
|
||||
vertical: (typeof opts.vertical !== 'undefined' ? opts.vertical : ScrollbarVisibility.AUTO),
|
||||
verticalScrollbarSize: (typeof opts.verticalScrollbarSize !== 'undefined' ? opts.verticalScrollbarSize : 10),
|
||||
verticalHasArrows: (typeof opts.verticalHasArrows !== 'undefined' ? opts.verticalHasArrows : false),
|
||||
verticalSliderSize: (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : 0),
|
||||
|
||||
scrollByPage: (typeof opts.scrollByPage !== 'undefined' ? opts.scrollByPage : false)
|
||||
};
|
||||
|
||||
result.horizontalSliderSize = (typeof opts.horizontalSliderSize !== 'undefined' ? opts.horizontalSliderSize : result.horizontalScrollbarSize);
|
||||
result.verticalSliderSize = (typeof opts.verticalSliderSize !== 'undefined' ? opts.verticalSliderSize : result.verticalScrollbarSize);
|
||||
|
||||
if (platform.isMac) {
|
||||
result.className += ' xterm-mac';
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user