Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9beccb3d1 | ||
|
|
f0d291f645 | ||
|
|
f6b98291cf | ||
|
|
f7118b19c6 | ||
|
|
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 |
@@ -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'));
|
||||
|
||||
@@ -30,9 +30,7 @@ const customSettings: IImageAddonOptions = {
|
||||
storageLimit: 128, // FIFO storage limit in MB
|
||||
showPlaceholder: true, // whether to show a placeholder for evicted images
|
||||
iipSupport: true, // enable iTerm IIP support
|
||||
iipSizeLimit: 20000000, // size limit of a single IIP sequence
|
||||
kittySupport: true, // enable Kitty graphics support
|
||||
kittySizeLimit: 20000000 // size limit of a single Kitty sequence
|
||||
iipSizeLimit: 20000000 // size limit of a single IIP sequence
|
||||
}
|
||||
|
||||
// initialization
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 123 B |
Binary file not shown.
|
Before Width: | Height: | Size: 449 B |
Binary file not shown.
|
Before Width: | Height: | Size: 131 B |
@@ -5,16 +5,19 @@
|
||||
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 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 = {
|
||||
@@ -41,8 +44,8 @@ export class IIPHandler implements IOscHandler, IResetHandler {
|
||||
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 +61,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 +78,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;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ImageAddon as IImageApi } from '@xterm/addon-image';
|
||||
import { IIPHandler } from './IIPHandler';
|
||||
import { ImageRenderer } from './ImageRenderer';
|
||||
import { ImageStorage, CELL_SIZE_DEFAULT } from './ImageStorage';
|
||||
import { KittyGraphicsHandler } from './kitty/KittyGraphicsHandler';
|
||||
import { SixelHandler } from './SixelHandler';
|
||||
import { ITerminalExt, IImageAddonOptions, IResetHandler } from './Types';
|
||||
|
||||
@@ -23,9 +22,7 @@ const DEFAULT_OPTIONS: IImageAddonOptions = {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
iipSizeLimit: 20000000
|
||||
};
|
||||
|
||||
// max palette size supported by the sixel lib (compile time setting)
|
||||
@@ -147,15 +144,6 @@ export class ImageAddon implements ITerminalAddon, IImageApi {
|
||||
terminal._core._inputHandler._parser.registerOscHandler(1337, iipHandler)
|
||||
);
|
||||
}
|
||||
|
||||
// Kitty graphics handler
|
||||
if (this._opts.kittySupport) {
|
||||
const kittyHandler = new KittyGraphicsHandler(this._opts, this._renderer!, this._storage!, terminal);
|
||||
this._handlers.set('kitty', kittyHandler);
|
||||
this._disposeLater(
|
||||
terminal._core._inputHandler._parser.registerApcHandler(0x47, kittyHandler)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: storageLimit is skipped here to not intoduce a surprising side effect.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
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 { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
const PLACEHOLDER_LENGTH = 4096;
|
||||
const PLACEHOLDER_HEIGHT = 24;
|
||||
@@ -183,14 +183,12 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
const finalWidth = count * width + sx > img.width ? img.width - sx : count * width;
|
||||
const finalHeight = sy + height > img.height ? img.height - sy : height;
|
||||
|
||||
// Floor all pixel offsets to get stable tile mapping without any overflows.
|
||||
// Note: For not pixel perfect aligned cells like in the DOM renderer
|
||||
// this will move a tile slightly to the top/left (subpixel range, thus ignore it).
|
||||
// FIX #34: avoid striping on displays with pixelDeviceRatio != 1 by ceiling height and width
|
||||
// Let the canvas handle sub-pixel coordinates natively to avoid
|
||||
// inconsistent rounding that causes tile gaps/overlaps during resize.
|
||||
this._ctx.drawImage(
|
||||
img,
|
||||
Math.floor(sx), Math.floor(sy), Math.ceil(finalWidth), Math.ceil(finalHeight),
|
||||
Math.floor(dx), Math.floor(dy), Math.ceil(finalWidth), Math.ceil(finalHeight)
|
||||
sx, sy, finalWidth, finalHeight,
|
||||
dx, dy, finalWidth, finalHeight
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,8 +214,8 @@ export class ImageRenderer extends Disposable implements IDisposable {
|
||||
if (ctx) {
|
||||
ctx.drawImage(
|
||||
img,
|
||||
Math.floor(sx), Math.floor(sy), Math.floor(finalWidth), Math.floor(finalHeight),
|
||||
0, 0, Math.floor(finalWidth), Math.floor(finalHeight)
|
||||
sx, sy, finalWidth, finalHeight,
|
||||
0, 0, canvas.width, canvas.height
|
||||
);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
@@ -234,23 +234,10 @@ export class ImageStorage implements IDisposable {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an image by its internal storage ID.
|
||||
* Used by protocols that support explicit deletion (e.g. Kitty a=d).
|
||||
*/
|
||||
public deleteImage(id: number): void {
|
||||
const spec = this._images.get(id);
|
||||
if (spec) {
|
||||
spec.marker?.dispose();
|
||||
this._delImg(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Method to add an image to the storage.
|
||||
* Returns the internal image ID assigned to the stored image.
|
||||
*/
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): number {
|
||||
public addImage(img: HTMLCanvasElement | ImageBitmap): void {
|
||||
// never allow storage to exceed memory limit
|
||||
this._evictOldest(img.width * img.height);
|
||||
|
||||
@@ -344,7 +331,6 @@ export class ImageStorage implements IDisposable {
|
||||
|
||||
// finally add the image
|
||||
this._images.set(imageId, imgSpec);
|
||||
return imageId;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { IDisposable, IMarker, Terminal } from '@xterm/xterm';
|
||||
// private imports from base repo we build against
|
||||
import { Attributes, BgFlags, Content, ExtFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import type { AttributeData } from 'common/buffer/AttributeData';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IApcHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IParams, IDcsHandler, IOscHandler, IEscapeSequenceParser } from 'common/parser/Types';
|
||||
import type { IBufferLine, IExtendedAttrs, IInputHandler } from 'common/Types';
|
||||
import type { ITerminal, ReadonlyColorSet } from 'browser/Types';
|
||||
import type { IRenderDimensions } from 'browser/renderer/shared/Types';
|
||||
@@ -22,7 +22,7 @@ export const enum Cell {
|
||||
}
|
||||
|
||||
// export some privates for local usage
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, IApcHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
export { AttributeData, IParams, IDcsHandler, IOscHandler, BgFlags, IRenderDimensions, IRenderService, Content, ExtFlags, Attributes, UnderlineStyle, ReadonlyColorSet };
|
||||
|
||||
/**
|
||||
* Plugin ctor options.
|
||||
@@ -38,8 +38,6 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
export interface IResetHandler {
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { IApcHandler, IImageAddonOptions, 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 {
|
||||
KittyAction,
|
||||
KittyFormat,
|
||||
KittyCompression,
|
||||
IKittyCommand,
|
||||
IPendingTransmission,
|
||||
IKittyImageData,
|
||||
BYTES_PER_PIXEL_RGB,
|
||||
BYTES_PER_PIXEL_RGBA,
|
||||
ALPHA_OPAQUE,
|
||||
parseKittyCommand
|
||||
} from './KittyGraphicsTypes';
|
||||
|
||||
// Memory limit for base64 decoder (4MB, same as IIPHandler)
|
||||
const DECODER_KEEP_DATA = 4194304;
|
||||
const DECODER_INITIAL_DATA = 4194304; // 4MB
|
||||
|
||||
// Local mirror of const enum (esbuild can't inline const enums from external packages)
|
||||
const DECODER_OK: DecodeStatus.OK = 0;
|
||||
|
||||
// Maximum control data size
|
||||
const MAX_CONTROL_DATA_SIZE = 512;
|
||||
|
||||
// Semicolon codepoint
|
||||
const SEMICOLON = 0x3B;
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol handler with streaming base64 decoding.
|
||||
*/
|
||||
export class KittyGraphicsHandler implements IApcHandler, IResetHandler {
|
||||
private _aborted = false;
|
||||
private _decodeError = false;
|
||||
|
||||
private _activeDecoder: Base64Decoder | null = null;
|
||||
private readonly _maxEncodedBytes: number;
|
||||
private readonly _initialEncodedBytes: number;
|
||||
|
||||
// Streaming related states
|
||||
|
||||
/** True while receiving control data (before semicolon). */
|
||||
private _inControlData = true;
|
||||
|
||||
/** Buffer for control data. */
|
||||
private _controlData = new Uint32Array(MAX_CONTROL_DATA_SIZE);
|
||||
private _controlLength = 0;
|
||||
|
||||
/** Pre-calculated encoded size limit */
|
||||
private _encodedSizeLimit = 0;
|
||||
private _totalEncodedSize = 0;
|
||||
|
||||
/** Parsed command. These are the control data before semicolon. */
|
||||
private _parsedCommand: IKittyCommand | null = null;
|
||||
|
||||
// Storage related states
|
||||
|
||||
private _pendingTransmissions: Map<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();
|
||||
|
||||
constructor(
|
||||
private readonly _opts: IImageAddonOptions,
|
||||
private readonly _renderer: ImageRenderer,
|
||||
private readonly _storage: ImageStorage,
|
||||
private readonly _coreTerminal: ITerminalExt
|
||||
) {
|
||||
// Convert decoded size limit -> max encoded bytes.
|
||||
this._maxEncodedBytes = Math.ceil(this._opts.kittySizeLimit * 4 / 3);
|
||||
// ensure we preallocate more than configured limit while using 4mb initial size.
|
||||
this._initialEncodedBytes = Math.min(DECODER_INITIAL_DATA, this._maxEncodedBytes);
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
for (const pending of this._pendingTransmissions.values()) {
|
||||
pending.decoder.release();
|
||||
}
|
||||
this._pendingTransmissions.clear();
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
this._images.clear();
|
||||
this._kittyIdToStorageId.clear();
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
this._aborted = false;
|
||||
this._decodeError = false;
|
||||
this._inControlData = true;
|
||||
this._controlLength = 0;
|
||||
this._parsedCommand = null;
|
||||
// Pre-calculate encoded limit once: base64 is 4 bytes encoded → 3 bytes decoded
|
||||
this._encodedSizeLimit = this._maxEncodedBytes;
|
||||
this._totalEncodedSize = 0;
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
|
||||
public put(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
if (!this._inControlData) {
|
||||
this._streamPayload(data, start, end);
|
||||
} else {
|
||||
// Scan for semicolon
|
||||
let controlEnd = end;
|
||||
for (let i = start; i < end; i++) {
|
||||
if (data[i] === SEMICOLON) {
|
||||
this._inControlData = false;
|
||||
controlEnd = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Copy control data
|
||||
const copyLength = controlEnd - start;
|
||||
if (this._controlLength + copyLength > MAX_CONTROL_DATA_SIZE) {
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
this._controlData.set(data.subarray(start, controlEnd), this._controlLength);
|
||||
this._controlLength += copyLength;
|
||||
|
||||
if (!this._inControlData) {
|
||||
// Found semicolon - parse control data early for validation
|
||||
this._parsedCommand = parseKittyCommand(this._parseControlDataString());
|
||||
|
||||
// Early validation: i+I conflict
|
||||
if (this._parsedCommand.id !== undefined && this._parsedCommand.imageNumber !== undefined) {
|
||||
this._sendResponse(this._parsedCommand.id, 'EINVAL:cannot specify both i and I keys', this._parsedCommand.quiet ?? 0);
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete action doesn't need payload - skip streaming
|
||||
if (this._parsedCommand.action === KittyAction.DELETE) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream remaining as payload
|
||||
const payloadStart = controlEnd + 1;
|
||||
if (payloadStart < end) {
|
||||
this._streamPayload(data, payloadStart, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream payload bytes into the base64 decoder.
|
||||
*/
|
||||
private _streamPayload(data: Uint32Array, start: number, end: number): void {
|
||||
if (this._aborted) return;
|
||||
|
||||
// Check size limit (compare encoded bytes against pre-calculated limit)
|
||||
// Include cumulative size from pending transmission for multi-chunk images
|
||||
const pendingKey = this._parsedCommand?.id ?? 0;
|
||||
const pending = this._pendingTransmissions.get(pendingKey);
|
||||
const previousEncodedSize = pending?.totalEncodedSize ?? 0;
|
||||
this._totalEncodedSize += end - start;
|
||||
const cumulativeEncodedSize = previousEncodedSize + this._totalEncodedSize;
|
||||
if (cumulativeEncodedSize > this._encodedSizeLimit) {
|
||||
const decoderToRelease = this._activeDecoder ?? pending?.decoder;
|
||||
if (decoderToRelease) {
|
||||
decoderToRelease.release();
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
if (pending) {
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
}
|
||||
this._aborted = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._decodeError) return;
|
||||
|
||||
if (pending?.decoder && !this._activeDecoder) {
|
||||
this._activeDecoder = pending.decoder;
|
||||
}
|
||||
if (!this._activeDecoder) {
|
||||
this._activeDecoder = new Base64Decoder(DECODER_KEEP_DATA, this._maxEncodedBytes, this._initialEncodedBytes);
|
||||
this._activeDecoder.init();
|
||||
}
|
||||
|
||||
if (this._activeDecoder.put(data.subarray(start, end)) !== DECODER_OK) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
this._decodeError = true;
|
||||
if (pending) {
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public end(success: boolean): boolean | Promise<boolean> {
|
||||
if (this._aborted || !success) {
|
||||
if (this._activeDecoder) {
|
||||
this._activeDecoder.release();
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No semicolon = no payload (delete, capability query)
|
||||
if (this._inControlData) {
|
||||
return this._handleNoPayloadCommand();
|
||||
}
|
||||
|
||||
// Use command parsed early in put() - i+I already validated there
|
||||
const cmd = this._parsedCommand!;
|
||||
|
||||
// Delete action was handled by skipping payload - just execute
|
||||
if (cmd.action === KittyAction.DELETE) {
|
||||
return this._handleDelete(cmd);
|
||||
}
|
||||
|
||||
const pendingKey = cmd.id ?? 0;
|
||||
const isMoreComing = cmd.more === 1;
|
||||
const pending = this._pendingTransmissions.get(pendingKey);
|
||||
|
||||
if (isMoreComing) {
|
||||
if (this._activeDecoder) {
|
||||
if (pending) {
|
||||
pending.totalEncodedSize += this._totalEncodedSize;
|
||||
pending.decodeError = pending.decodeError || this._decodeError;
|
||||
} else {
|
||||
this._pendingTransmissions.set(pendingKey, {
|
||||
cmd: { ...cmd },
|
||||
decoder: this._activeDecoder,
|
||||
totalEncodedSize: this._totalEncodedSize,
|
||||
decodeError: this._decodeError
|
||||
});
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let decodeError = this._decodeError;
|
||||
let finalCmd = cmd;
|
||||
let decoder = this._activeDecoder;
|
||||
|
||||
if (pending) {
|
||||
finalCmd = pending.cmd;
|
||||
decoder = pending.decoder;
|
||||
decodeError = decodeError || pending.decodeError;
|
||||
this._pendingTransmissions.delete(pendingKey);
|
||||
}
|
||||
|
||||
let imageBytes = new Uint8Array(0);
|
||||
if (decoder) {
|
||||
if (decoder.end() !== DECODER_OK) {
|
||||
decodeError = true;
|
||||
}
|
||||
imageBytes = decoder.data8;
|
||||
}
|
||||
this._activeDecoder = null;
|
||||
|
||||
// Handle command first — handlers create Blob/ImageData from imageBytes,
|
||||
// which copies the data. Only then is it safe to release the decoder's
|
||||
// wasm memory that imageBytes points into.
|
||||
const result = this._handleCommandWithBytesAndCmd(finalCmd, imageBytes, decodeError);
|
||||
if (decoder) {
|
||||
decoder.release();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Command handling
|
||||
|
||||
private _parseControlDataString(): string {
|
||||
let str = '';
|
||||
for (let i = 0; i < this._controlLength; i++) {
|
||||
str += String.fromCodePoint(this._controlData[i]);
|
||||
}
|
||||
return str;
|
||||
}
|
||||
|
||||
private _handleNoPayloadCommand(): boolean | Promise<boolean> {
|
||||
const cmd = parseKittyCommand(this._parseControlDataString());
|
||||
|
||||
// Per spec: specifying both i and I is an error
|
||||
if (cmd.id !== undefined && cmd.imageNumber !== undefined) {
|
||||
this._sendResponse(cmd.id, 'EINVAL:cannot specify both i and I keys', cmd.quiet ?? 0);
|
||||
return true;
|
||||
}
|
||||
|
||||
const action = cmd.action ?? 't';
|
||||
|
||||
switch (action) {
|
||||
case KittyAction.DELETE:
|
||||
return this._handleDelete(cmd);
|
||||
case KittyAction.QUERY:
|
||||
this._sendResponse(cmd.id ?? 0, 'OK', cmd.quiet ?? 0);
|
||||
return true;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleCommandWithBytesAndCmd(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean | Promise<boolean> {
|
||||
const action = cmd.action ?? 't';
|
||||
|
||||
switch (action) {
|
||||
case KittyAction.TRANSMIT: {
|
||||
const result = this._handleTransmit(cmd, bytes, decodeError);
|
||||
if (cmd.id !== undefined && !decodeError && bytes.length > 0) {
|
||||
this._sendResponse(cmd.id, 'OK', cmd.quiet ?? 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
case KittyAction.TRANSMIT_DISPLAY:
|
||||
return this._handleTransmitDisplay(cmd, bytes, decodeError);
|
||||
case KittyAction.QUERY:
|
||||
return this._handleQuery(cmd, bytes, decodeError);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTransmit(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||
// TODO: Support file-based transmission modes (t=f, t=t, t=s)
|
||||
// Currently only supports direct transmission (t=d, the default).
|
||||
// - t=f (file): Payload is base64-encoded file path. Terminal reads image from that path.
|
||||
// - t=t (temp file): Payload is base64-encoded path in temp directory. Terminal reads, deletes.
|
||||
// - t=s Payload is base64-encoded POSIX shm name. Terminal reads from shared memory.
|
||||
// These modes require filesystem/IPC access not available in browsers. For Node.js/Electron:
|
||||
// 1. Check cmd.transmission (t key) before treating bytes as image data
|
||||
// 2. For t=f/t/s: decode bytes as UTF-8 string (the path/name), then read file contents
|
||||
// 3. For t=d: treat bytes as image data (current behavior)
|
||||
|
||||
if (decodeError || bytes.length === 0) return true;
|
||||
|
||||
const id = cmd.id ?? this._nextImageId++;
|
||||
const image: IKittyImageData = {
|
||||
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;
|
||||
|
||||
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);
|
||||
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;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleQuery(cmd: IKittyCommand, bytes: Uint8Array, decodeError: boolean): boolean {
|
||||
const id = cmd.id ?? 0;
|
||||
const quiet = cmd.quiet ?? 0;
|
||||
|
||||
// Check decode error first (invalid base64)
|
||||
if (decodeError) {
|
||||
this._sendResponse(id, 'EINVAL:invalid base64 data', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Capability query (no payload) - just respond OK
|
||||
if (bytes.length === 0) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
const format = cmd.format ?? KittyFormat.RGBA;
|
||||
|
||||
if (format === KittyFormat.PNG) {
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
} else {
|
||||
const width = cmd.width ?? 0;
|
||||
const height = cmd.height ?? 0;
|
||||
|
||||
if (!width || !height) {
|
||||
this._sendResponse(id, 'EINVAL:width and height required for raw pixel data', quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
const bytesPerPixel = format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
this._sendResponse(id, `EINVAL:insufficient pixel data`, quiet);
|
||||
return true;
|
||||
}
|
||||
|
||||
this._sendResponse(id, 'OK', quiet);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _handleDelete(cmd: IKittyCommand): boolean {
|
||||
const id = cmd.id;
|
||||
|
||||
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();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _sendResponse(id: number, message: string, quiet: number): void {
|
||||
const isOk = message === 'OK';
|
||||
if (isOk && quiet === 1) return;
|
||||
if (!isOk && quiet === 2) return;
|
||||
|
||||
const response = `\x1b_Gi=${id};${message}\x1b\\`;
|
||||
this._coreTerminal._core.coreService.triggerDataEvent(response);
|
||||
}
|
||||
|
||||
// Image display
|
||||
|
||||
private _displayImage(image: IKittyImageData, cmd: IKittyCommand): boolean | Promise<boolean> {
|
||||
return this._decodeAndDisplay(image, cmd)
|
||||
.then(() => true)
|
||||
.catch(() => true);
|
||||
}
|
||||
|
||||
private async _decodeAndDisplay(image: IKittyImageData, cmd: IKittyCommand): Promise<void> {
|
||||
const bitmap = await this._createBitmap(image);
|
||||
|
||||
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);
|
||||
|
||||
let w = bitmap.width;
|
||||
let h = bitmap.height;
|
||||
|
||||
// Scale bitmap to fit placement rectangle when c/r are specified
|
||||
if (cmd.columns !== undefined || cmd.rows !== undefined) {
|
||||
w = Math.round(imgCols * cw);
|
||||
h = Math.round(imgRows * ch);
|
||||
}
|
||||
|
||||
if (w * h > this._opts.pixelLimit) return;
|
||||
|
||||
// Save cursor position before addImage modifies it
|
||||
const buffer = this._coreTerminal._core.buffer;
|
||||
const savedX = buffer.x;
|
||||
const savedY = buffer.y;
|
||||
const savedYbase = buffer.ybase;
|
||||
|
||||
let storageId: number;
|
||||
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);
|
||||
}
|
||||
this._kittyIdToStorageId.set(image.id, storageId);
|
||||
|
||||
// Kitty cursor movement
|
||||
// Per spec: cursor placed at first column after last image column,
|
||||
// on the last row of the image. C=1 means don't move cursor.
|
||||
if (cmd.cursorMovement === 1) {
|
||||
// C=1: restore cursor to position before image was placed
|
||||
const scrolled = buffer.ybase - savedYbase;
|
||||
buffer.x = savedX;
|
||||
// Can't restore cursor to scrollback?
|
||||
buffer.y = Math.max(savedY - scrolled, 0);
|
||||
} else {
|
||||
// Default (C=0): advance cursor horizontally past the image
|
||||
// addImage already positioned cursor on the last row via lineFeeds
|
||||
buffer.x = Math.min(savedX + imgCols, this._coreTerminal.cols);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create ImageBitmap from already-decoded image data.
|
||||
*/
|
||||
private async _createBitmap(image: IKittyImageData): Promise<ImageBitmap> {
|
||||
let bytes: Uint8Array = new Uint8Array(await image.data.arrayBuffer());
|
||||
|
||||
if (image.compression === KittyCompression.ZLIB) {
|
||||
bytes = await this._decompressZlib(bytes);
|
||||
}
|
||||
|
||||
if (image.format === KittyFormat.PNG) {
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'image/png' });
|
||||
if (!window.createImageBitmap) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
return new Promise<ImageBitmap>((resolve, reject) => {
|
||||
img.addEventListener('load', () => {
|
||||
URL.revokeObjectURL(url);
|
||||
const canvas = ImageRenderer.createCanvas(window.document, img.width, img.height);
|
||||
canvas.getContext('2d')?.drawImage(img, 0, 0);
|
||||
createImageBitmap(canvas).then(resolve).catch(reject);
|
||||
});
|
||||
img.addEventListener('error', reject);
|
||||
img.src = url;
|
||||
});
|
||||
}
|
||||
return createImageBitmap(blob);
|
||||
}
|
||||
|
||||
// Raw pixel data
|
||||
const width = image.width;
|
||||
const height = image.height;
|
||||
|
||||
if (!width || !height) {
|
||||
throw new Error('Width and height required for raw pixel data');
|
||||
}
|
||||
|
||||
const bytesPerPixel = image.format === KittyFormat.RGBA ? BYTES_PER_PIXEL_RGBA : BYTES_PER_PIXEL_RGB;
|
||||
const expectedBytes = width * height * bytesPerPixel;
|
||||
|
||||
if (bytes.length < expectedBytes) {
|
||||
throw new Error('Insufficient pixel data');
|
||||
}
|
||||
|
||||
const pixelCount = width * height;
|
||||
const data = new Uint8ClampedArray(pixelCount * BYTES_PER_PIXEL_RGBA);
|
||||
const isRgba = image.format === KittyFormat.RGBA;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
return createImageBitmap(new ImageData(data, width, height));
|
||||
}
|
||||
|
||||
private async _decompressZlib(compressed: Uint8Array): Promise<Uint8Array> {
|
||||
try {
|
||||
return await this._decompress(compressed, 'deflate');
|
||||
} catch {
|
||||
return await this._decompress(compressed, 'deflate-raw');
|
||||
}
|
||||
}
|
||||
|
||||
private async _decompress(compressed: Uint8Array, format: 'deflate' | 'deflate-raw'): Promise<Uint8Array> {
|
||||
const ds = new DecompressionStream(format);
|
||||
const writer = ds.writable.getWriter();
|
||||
writer.write(compressed as BufferSource);
|
||||
writer.close();
|
||||
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = ds.readable.getReader();
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
chunks.push(value);
|
||||
}
|
||||
|
||||
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
||||
const result = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
result.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public get images(): ReadonlyMap<number, IKittyImageData> {
|
||||
return this._images;
|
||||
}
|
||||
|
||||
public get pendingTransmissions(): ReadonlyMap<number, IPendingTransmission> {
|
||||
return this._pendingTransmissions;
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { assert } from 'chai';
|
||||
import { parseKittyCommand, KittyAction, KittyFormat } from './KittyGraphicsTypes';
|
||||
|
||||
describe('KittyGraphicsTypes', () => {
|
||||
describe('parseKittyCommand', () => {
|
||||
it('should parse control data with action and format', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100');
|
||||
assert.strictEqual(cmd.action, 'T');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should parse control data with all options', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,i=5,s=10,v=20,c=3,r=2,m=1,q=2');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
assert.strictEqual(cmd.width, 10);
|
||||
assert.strictEqual(cmd.height, 20);
|
||||
assert.strictEqual(cmd.columns, 3);
|
||||
assert.strictEqual(cmd.rows, 2);
|
||||
assert.strictEqual(cmd.more, 1);
|
||||
assert.strictEqual(cmd.quiet, 2);
|
||||
});
|
||||
|
||||
it('should handle empty control data', () => {
|
||||
const cmd = parseKittyCommand('');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, undefined);
|
||||
});
|
||||
|
||||
it('should parse transmit action', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=100');
|
||||
assert.strictEqual(cmd.action, KittyAction.TRANSMIT);
|
||||
assert.strictEqual(cmd.format, KittyFormat.PNG);
|
||||
});
|
||||
|
||||
it('should parse delete action', () => {
|
||||
const cmd = parseKittyCommand('a=d,i=5');
|
||||
assert.strictEqual(cmd.action, KittyAction.DELETE);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse empty action as empty string', () => {
|
||||
const cmd = parseKittyCommand('a=,f=100');
|
||||
assert.strictEqual(cmd.action, '');
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
});
|
||||
|
||||
it('should leave action undefined when key is not present', () => {
|
||||
const cmd = parseKittyCommand('f=100,i=5');
|
||||
assert.strictEqual(cmd.action, undefined);
|
||||
assert.strictEqual(cmd.format, 100);
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
|
||||
it('should parse compression key', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=32,o=z');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.strictEqual(cmd.format, 32);
|
||||
assert.strictEqual(cmd.compression, 'z');
|
||||
});
|
||||
|
||||
it('should parse cursor movement key', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,C=1');
|
||||
assert.strictEqual(cmd.cursorMovement, 1);
|
||||
});
|
||||
|
||||
it('should parse cursor movement key C=0', () => {
|
||||
const cmd = parseKittyCommand('a=T,f=100,C=0');
|
||||
assert.strictEqual(cmd.cursorMovement, 0);
|
||||
});
|
||||
|
||||
it('should parse x and y offset', () => {
|
||||
const cmd = parseKittyCommand('a=T,x=10,y=20');
|
||||
assert.strictEqual(cmd.x, 10);
|
||||
assert.strictEqual(cmd.y, 20);
|
||||
});
|
||||
|
||||
it('should handle keys without values', () => {
|
||||
const cmd = parseKittyCommand('a=t,f=,i=5');
|
||||
assert.strictEqual(cmd.action, 't');
|
||||
assert.ok(isNaN(cmd.format!));
|
||||
assert.strictEqual(cmd.id, 5);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,171 +0,0 @@
|
||||
/**
|
||||
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Kitty graphics protocol types, constants, and parsing utilities.
|
||||
*/
|
||||
|
||||
import type Base64Decoder from 'xterm-wasm-parts/lib/base64/Base64Decoder.wasm';
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol action types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'a'.
|
||||
*/
|
||||
export const enum KittyAction {
|
||||
TRANSMIT = 't',
|
||||
TRANSMIT_DISPLAY = 'T',
|
||||
QUERY = 'q',
|
||||
PLACEMENT = 'p',
|
||||
DELETE = 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol format types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
*/
|
||||
export const enum KittyFormat {
|
||||
RGB = 24,
|
||||
RGBA = 32,
|
||||
PNG = 100
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol compression types.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference under key 'o'.
|
||||
*/
|
||||
export const enum KittyCompression {
|
||||
NONE = '',
|
||||
ZLIB = 'z'
|
||||
}
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol control data keys.
|
||||
* See: https://sw.kovidgoyal.net/kitty/graphics-protocol/#control-data-reference
|
||||
*/
|
||||
export const enum KittyKey {
|
||||
// Action to perform (t=transmit, T=transmit+display, q=query, p=placement, d=delete)
|
||||
ACTION = 'a',
|
||||
// Image format (24=RGB, 32=RGBA, 100=PNG)
|
||||
FORMAT = 'f',
|
||||
// Image ID for referencing stored images
|
||||
ID = 'i',
|
||||
// Image number (alternative to ID, terminal assigns ID)
|
||||
IMAGE_NUMBER = 'I',
|
||||
// Source image width in pixels
|
||||
WIDTH = 's',
|
||||
// Source image height in pixels
|
||||
HEIGHT = 'v',
|
||||
// The left edge (in pixels) of the image area to display
|
||||
X_OFFSET = 'x',
|
||||
// The top edge (in pixels) of the image area to display
|
||||
Y_OFFSET = 'y',
|
||||
// Number of terminal columns to display the image over
|
||||
COLUMNS = 'c',
|
||||
// Number of terminal rows to display the image over
|
||||
ROWS = 'r',
|
||||
// More data flag (1=more chunks coming, 0=final chunk)
|
||||
MORE = 'm',
|
||||
// Compression type (z=zlib). This is essential for chunking larger images.
|
||||
COMPRESSION = 'o',
|
||||
// Quiet mode (1=suppress OK responses, 2=suppress error responses)
|
||||
QUIET = 'q',
|
||||
// Cursor movement policy (0=move cursor after image, 1=don't move cursor)
|
||||
CURSOR_MOVEMENT = 'C'
|
||||
}
|
||||
|
||||
// Pixel format constants
|
||||
export const BYTES_PER_PIXEL_RGB = 3;
|
||||
export const BYTES_PER_PIXEL_RGBA = 4;
|
||||
export const ALPHA_OPAQUE = 255;
|
||||
|
||||
/**
|
||||
* Parsed Kitty graphics command.
|
||||
*/
|
||||
export interface IKittyCommand {
|
||||
action?: string;
|
||||
format?: number;
|
||||
id?: number;
|
||||
imageNumber?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
x?: number;
|
||||
y?: number;
|
||||
columns?: number;
|
||||
rows?: number;
|
||||
more?: number;
|
||||
quiet?: number;
|
||||
cursorMovement?: number;
|
||||
compression?: string;
|
||||
payload?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pending chunked transmission state.
|
||||
* Stores metadata from the first chunk while accumulating decoded payload data.
|
||||
*/
|
||||
export interface IPendingTransmission {
|
||||
/** The parsed command from the first chunk (contains action, format, dimensions, etc.) */
|
||||
cmd: IKittyCommand;
|
||||
/** Decoder used across chunked payloads */
|
||||
decoder: Base64Decoder;
|
||||
/** Total encoded (base64) bytes received across all chunks - for size limit enforcement */
|
||||
totalEncodedSize: number;
|
||||
/** Whether any chunk has failed to decode */
|
||||
decodeError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored Kitty image data.
|
||||
*/
|
||||
export interface IKittyImageData {
|
||||
id: number;
|
||||
/** Decoded image data stored as Blob (off JS heap) to avoid 2GB heap limit */
|
||||
data: Blob;
|
||||
width: number;
|
||||
height: number;
|
||||
format: 24 | 32 | 100;
|
||||
compression?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses Kitty graphics control data into a command object.
|
||||
*/
|
||||
export function parseKittyCommand(data: string): IKittyCommand {
|
||||
const cmd: IKittyCommand = {};
|
||||
const parts = data.split(',');
|
||||
|
||||
for (const part of parts) {
|
||||
const eqIdx = part.indexOf('=');
|
||||
if (eqIdx === -1) continue;
|
||||
|
||||
const key = part.substring(0, eqIdx);
|
||||
const value = part.substring(eqIdx + 1);
|
||||
|
||||
// Handle string keys first
|
||||
if (key === KittyKey.ACTION) {
|
||||
cmd.action = value;
|
||||
continue;
|
||||
}
|
||||
if (key === KittyKey.COMPRESSION) {
|
||||
cmd.compression = value;
|
||||
continue;
|
||||
}
|
||||
const numValue = parseInt(value);
|
||||
switch (key) {
|
||||
case KittyKey.FORMAT: cmd.format = numValue; break;
|
||||
case KittyKey.ID: cmd.id = numValue; break;
|
||||
case KittyKey.IMAGE_NUMBER: cmd.imageNumber = numValue; break;
|
||||
case KittyKey.WIDTH: cmd.width = numValue; break;
|
||||
case KittyKey.HEIGHT: cmd.height = numValue; break;
|
||||
case KittyKey.X_OFFSET: cmd.x = numValue; break;
|
||||
case KittyKey.Y_OFFSET: cmd.y = numValue; break;
|
||||
case KittyKey.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;
|
||||
}
|
||||
}
|
||||
|
||||
return cmd;
|
||||
}
|
||||
@@ -15,7 +15,6 @@
|
||||
"paths": {
|
||||
"browser/*": [ "../../../src/browser/*" ],
|
||||
"common/*": [ "../../../src/common/*" ],
|
||||
"vs/*": [ "../../../src/vs/*" ],
|
||||
"@xterm/addon-image": [ "../typings/addon-image.d.ts" ]
|
||||
}
|
||||
},
|
||||
@@ -25,7 +24,6 @@
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../src/browser" },
|
||||
{ "path": "../../../src/common" },
|
||||
{ "path": "../../../src/vs" }
|
||||
{ "path": "../../../src/common" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -23,8 +23,6 @@ export interface IImageAddonOptions {
|
||||
sixelSizeLimit: number;
|
||||
iipSupport: boolean;
|
||||
iipSizeLimit: number;
|
||||
kittySupport: boolean;
|
||||
kittySizeLimit: number;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
@@ -136,9 +134,7 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 128,
|
||||
showPlaceholder: true,
|
||||
iipSupport: true,
|
||||
iipSizeLimit: 20000000,
|
||||
kittySupport: true,
|
||||
kittySizeLimit: 20000000
|
||||
iipSizeLimit: 20000000
|
||||
};
|
||||
deepStrictEqual(await ctx.page.evaluate(`window.imageAddon._opts`), DEFAULT_OPTIONS);
|
||||
});
|
||||
@@ -153,9 +149,7 @@ test.describe('ImageAddon', () => {
|
||||
storageLimit: 10,
|
||||
showPlaceholder: false,
|
||||
iipSupport: false,
|
||||
iipSizeLimit: 1000,
|
||||
kittySupport: false,
|
||||
kittySizeLimit: 1000
|
||||
iipSizeLimit: 1000
|
||||
};
|
||||
await ctx.page.evaluate(opts => {
|
||||
(window as any).imageAddonCustom = new ImageAddon(opts.opts);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
-9
@@ -76,15 +76,6 @@ declare module '@xterm/addon-image' {
|
||||
iipSupport?: boolean;
|
||||
/** IIP sequence size limit (default 20000000 bytes). */
|
||||
iipSizeLimit?: number;
|
||||
|
||||
/**
|
||||
* Kitty graphics protocol settings
|
||||
*/
|
||||
|
||||
/** Whether Kitty graphics protocol is enabled (default is true). */
|
||||
kittySupport?: boolean;
|
||||
/** Kitty image size limit in bytes (default 20000000 bytes). */
|
||||
kittySizeLimit?: number;
|
||||
}
|
||||
|
||||
export class ImageAddon implements ITerminalAddon {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -644,8 +644,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 +820,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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
"browser/*": [
|
||||
"../../../src/browser/*"
|
||||
],
|
||||
"vs/*": [
|
||||
"../../../src/vs/*"
|
||||
],
|
||||
"@xterm/addon-webgl": [
|
||||
"../typings/addon-webgl.d.ts"
|
||||
]
|
||||
@@ -42,9 +39,6 @@
|
||||
},
|
||||
{
|
||||
"path": "../../../src/browser"
|
||||
},
|
||||
{
|
||||
"path": "../../../src/vs"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
+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
|
||||
@@ -327,9 +328,7 @@ function createTerminal(): Terminal {
|
||||
}
|
||||
const cols = size.cols;
|
||||
const rows = size.rows;
|
||||
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
|
||||
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
|
||||
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight;
|
||||
const url = '/terminals/' + pid + '/size?cols=' + cols + '&rows=' + rows;
|
||||
|
||||
fetch(url, { method: 'POST' });
|
||||
});
|
||||
@@ -380,9 +379,7 @@ function createTerminal(): Terminal {
|
||||
if (useRealTerminal instanceof HTMLInputElement && !useRealTerminal.checked) {
|
||||
runFakeTerminal();
|
||||
} else {
|
||||
const pixelWidth = Math.round(term!.dimensions?.css?.canvas?.width ?? 0);
|
||||
const pixelHeight = Math.round(term!.dimensions?.css?.canvas?.height ?? 0);
|
||||
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows + '&pixelWidth=' + pixelWidth + '&pixelHeight=' + pixelHeight, { method: 'POST' });
|
||||
const res = await fetch('/terminals?cols=' + term!.cols + '&rows=' + term!.rows, { method: 'POST' });
|
||||
const processId = await res.text();
|
||||
pid = processId;
|
||||
socketURL += processId;
|
||||
@@ -629,7 +626,7 @@ function updateTerminalSize(): void {
|
||||
function getBox(width: number, height: number): any {
|
||||
return {
|
||||
string: '+',
|
||||
style: 'font-size: 1px; padding: ' + Math.floor(height / 2) + 'px ' + Math.floor(width / 2) + 'px; line-height: ' + height + 'px;'
|
||||
style: 'font-size: 1px; padding: ' + Math.floor(height/2) + 'px ' + Math.floor(width/2) + 'px; line-height: ' + height + 'px;'
|
||||
};
|
||||
}
|
||||
if (source instanceof HTMLCanvasElement) {
|
||||
|
||||
@@ -51,30 +51,14 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
container.appendChild(document.createElement('br'));
|
||||
container.appendChild(document.createElement('br'));
|
||||
|
||||
// Sixel demos
|
||||
const dlSixel = document.createElement('dl');
|
||||
const dtSixel = document.createElement('dt');
|
||||
dtSixel.textContent = 'Sixel';
|
||||
dlSixel.appendChild(dtSixel);
|
||||
this._addDdWithButton(dlSixel, 'image-demo1', 'snake');
|
||||
this._addDdWithButton(dlSixel, 'image-demo2', 'oranges');
|
||||
container.appendChild(dlSixel);
|
||||
|
||||
// IIP demos
|
||||
const dlIip = document.createElement('dl');
|
||||
const dtIip = document.createElement('dt');
|
||||
dtIip.textContent = 'IIP (iTerm)';
|
||||
dlIip.appendChild(dtIip);
|
||||
this._addDdWithButton(dlIip, 'image-demo3', 'palette');
|
||||
container.appendChild(dlIip);
|
||||
|
||||
// Kitty demos
|
||||
const dlKitty = document.createElement('dl');
|
||||
const dtKitty = document.createElement('dt');
|
||||
dtKitty.textContent = 'Kitty';
|
||||
dlKitty.appendChild(dtKitty);
|
||||
this._addDdWithButton(dlKitty, 'image-demo-kitty1', 'palette');
|
||||
container.appendChild(dlKitty);
|
||||
const dl = document.createElement('dl');
|
||||
const dt = document.createElement('dt');
|
||||
dt.textContent = 'Image Test';
|
||||
dl.appendChild(dt);
|
||||
this._addDdWithButton(dl, 'image-demo1', 'snake (sixel)');
|
||||
this._addDdWithButton(dl, 'image-demo2', 'oranges (sixel)');
|
||||
this._addDdWithButton(dl, 'image-demo3', 'palette (iip)');
|
||||
container.appendChild(dl);
|
||||
|
||||
this._initImageAddonExposed();
|
||||
}
|
||||
@@ -141,25 +125,12 @@ export class AddonImageWindow extends BaseWindow implements IControlWindow {
|
||||
this._terminal.write(`\x1b]1337;File=inline=1;size=${data.length}:${btoa(sdata)}\x1b\\`);
|
||||
});
|
||||
|
||||
const kittyDemo = (url: string) => () => fetch(url)
|
||||
.then(resp => resp.arrayBuffer())
|
||||
.then(buffer => {
|
||||
const data = new Uint8Array(buffer);
|
||||
let sdata = '';
|
||||
for (let i = 0; i < data.length; ++i) sdata += String.fromCharCode(data[i]);
|
||||
const payload = btoa(sdata);
|
||||
this._terminal.write('\r\n');
|
||||
this._terminal.write(`\x1b_Ga=T,f=100;${payload}\x1b\\`);
|
||||
});
|
||||
|
||||
document.getElementById('image-demo1')!.addEventListener('click',
|
||||
sixelDemo('https://raw.githubusercontent.com/saitoha/libsixel/master/images/snake.six'));
|
||||
document.getElementById('image-demo2')!.addEventListener('click',
|
||||
sixelDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/testfiles/test2.sixel'));
|
||||
document.getElementById('image-demo3')!.addEventListener('click',
|
||||
iipDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
document.getElementById('image-demo-kitty1')!.addEventListener('click',
|
||||
kittyDemo('https://raw.githubusercontent.com/jerch/node-sixel/master/palette.png'));
|
||||
|
||||
// demo for image retrieval API
|
||||
this._terminal.element!.addEventListener('click', (ev: MouseEvent) => {
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -19,11 +19,12 @@
|
||||
"@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-unicode-graphemes": ["../../addons/addon-unicode-graphemes"],
|
||||
"@xterm/addon-ligatures": ["../../addons/addon-ligatures"]
|
||||
}
|
||||
},
|
||||
"include": ["./**/*", "../../typings/xterm.d.ts"]
|
||||
"include": [
|
||||
"./**/*",
|
||||
"../../typings/xterm.d.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+3
-18
@@ -65,8 +65,6 @@ function startServer(): void {
|
||||
}
|
||||
const cols = parseInt(req.query.cols);
|
||||
const rows = parseInt(req.query.rows);
|
||||
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
|
||||
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
|
||||
const isWindows = process.platform === 'win32';
|
||||
const term = pty.spawn(isWindows ? 'powershell.exe' : 'bash', [], {
|
||||
name: 'xterm-256color',
|
||||
@@ -79,13 +77,7 @@ function startServer(): void {
|
||||
useConptyDll: isWindows,
|
||||
});
|
||||
|
||||
// Set pixel dimensions immediately after spawn (pty.spawn doesn't support them)
|
||||
if (pixelWidth > 0 && pixelHeight > 0) {
|
||||
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
|
||||
console.log('Created terminal with PID: ' + term.pid + ' (' + cols + 'x' + rows + ', ' + pixelWidth + 'px x ' + pixelHeight + 'px)');
|
||||
} else {
|
||||
console.log('Created terminal with PID: ' + term.pid);
|
||||
}
|
||||
console.log('Created terminal with PID: ' + term.pid);
|
||||
terminals[term.pid] = term;
|
||||
unsentOutput[term.pid] = '';
|
||||
temporaryDisposable[term.pid] = term.onData(function(data) {
|
||||
@@ -103,17 +95,10 @@ function startServer(): void {
|
||||
const pid = parseInt(req.params.pid);
|
||||
const cols = parseInt(req.query.cols);
|
||||
const rows = parseInt(req.query.rows);
|
||||
const pixelWidth = typeof req.query.pixelWidth === 'string' ? parseInt(req.query.pixelWidth) : 0;
|
||||
const pixelHeight = typeof req.query.pixelHeight === 'string' ? parseInt(req.query.pixelHeight) : 0;
|
||||
const term = terminals[pid];
|
||||
|
||||
if (pixelWidth > 0 && pixelHeight > 0) {
|
||||
term.resize(cols, rows, { width: pixelWidth, height: pixelHeight });
|
||||
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols, ' + rows + ' rows, ' + pixelWidth + 'px x ' + pixelHeight + 'px');
|
||||
} else {
|
||||
term.resize(cols, rows);
|
||||
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
|
||||
}
|
||||
term.resize(cols, rows);
|
||||
console.log('Resized terminal ' + pid + ' to ' + cols + ' cols and ' + rows + ' rows.');
|
||||
res.end();
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'addons/*/src/third-party/*.ts',
|
||||
'src/vs/*',
|
||||
'**/out/*',
|
||||
'**/out-test/*',
|
||||
'**/out-esbuild/*',
|
||||
|
||||
Generated
-6
@@ -82,12 +82,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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -95,7 +100,7 @@ export class OverviewRulerRenderer extends Disposable {
|
||||
}));
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -176,10 +181,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;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ScrollbarVisibility } from './scrollable';
|
||||
|
||||
export interface IScrollableElementCreationOptions {
|
||||
/**
|
||||
* The scrollable element should not do any DOM mutations until renderNow() is called.
|
||||
* Defaults to false.
|
||||
*/
|
||||
lazyRender?: boolean;
|
||||
/**
|
||||
* CSS Class name for the scrollable element.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Drop subtle horizontal and vertical shadows.
|
||||
* Defaults to false.
|
||||
*/
|
||||
useShadows?: boolean;
|
||||
/**
|
||||
* Handle mouse wheel (listen to mouse wheel scrolling).
|
||||
* Defaults to true
|
||||
*/
|
||||
handleMouseWheel?: boolean;
|
||||
/**
|
||||
* If mouse wheel is handled, make mouse wheel scrolling smooth.
|
||||
* Defaults to true.
|
||||
*/
|
||||
mouseWheelSmoothScroll?: boolean;
|
||||
/**
|
||||
* Flip axes. Treat vertical scrolling like horizontal and vice-versa.
|
||||
* Defaults to false.
|
||||
*/
|
||||
flipAxes?: boolean;
|
||||
/**
|
||||
* If enabled, will scroll horizontally when scrolling vertical.
|
||||
* Defaults to false.
|
||||
*/
|
||||
scrollYToX?: boolean;
|
||||
/**
|
||||
* Consume all mouse wheel events if a scrollbar is needed (i.e. scrollSize > size).
|
||||
* Defaults to false.
|
||||
*/
|
||||
consumeMouseWheelIfScrollbarIsNeeded?: boolean;
|
||||
/**
|
||||
* Always consume mouse wheel events, even when scrolling is no longer possible.
|
||||
* Defaults to false.
|
||||
*/
|
||||
alwaysConsumeMouseWheel?: boolean;
|
||||
/**
|
||||
* A multiplier to be used on the `deltaX` and `deltaY` of mouse wheel scroll events.
|
||||
* Defaults to 1.
|
||||
*/
|
||||
mouseWheelScrollSensitivity?: number;
|
||||
/**
|
||||
* FastScrolling mulitplier speed when pressing `Alt`
|
||||
* Defaults to 5.
|
||||
*/
|
||||
fastScrollSensitivity?: number;
|
||||
/**
|
||||
* Whether the scrollable will only scroll along the predominant axis when scrolling both
|
||||
* vertically and horizontally at the same time.
|
||||
* Prevents horizontal drift when scrolling vertically on a trackpad.
|
||||
* Defaults to true.
|
||||
*/
|
||||
scrollPredominantAxis?: boolean;
|
||||
/**
|
||||
* The dom node events should be bound to.
|
||||
* If no listenOnDomNode is provided, the constructor dom node is used.
|
||||
*/
|
||||
listenOnDomNode?: HTMLElement;
|
||||
/**
|
||||
* Control the visibility of the horizontal scrollbar.
|
||||
* Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible)
|
||||
* Defaults to 'auto'.
|
||||
*/
|
||||
horizontal?: ScrollbarVisibility;
|
||||
/**
|
||||
* Height (in px) of the horizontal scrollbar.
|
||||
* Defaults to 10.
|
||||
*/
|
||||
horizontalScrollbarSize?: number;
|
||||
/**
|
||||
* Height (in px) of the horizontal scrollbar slider.
|
||||
* Defaults to `horizontalScrollbarSize`
|
||||
*/
|
||||
horizontalSliderSize?: number;
|
||||
/**
|
||||
* Render arrows (left/right) for the horizontal scrollbar.
|
||||
* Defaults to false.
|
||||
*/
|
||||
horizontalHasArrows?: boolean;
|
||||
/**
|
||||
* Control the visibility of the vertical scrollbar.
|
||||
* Accepted values: 'auto' (on mouse over), 'visible' (always visible), 'hidden' (never visible)
|
||||
* Defaults to 'auto'.
|
||||
*/
|
||||
vertical?: ScrollbarVisibility;
|
||||
/**
|
||||
* Width (in px) of the vertical scrollbar.
|
||||
* Defaults to 10.
|
||||
*/
|
||||
verticalScrollbarSize?: number;
|
||||
/**
|
||||
* Width (in px) of the vertical scrollbar slider.
|
||||
* Defaults to `verticalScrollbarSize`
|
||||
*/
|
||||
verticalSliderSize?: number;
|
||||
/**
|
||||
* Render arrows (top/bottom) for the vertical scrollbar.
|
||||
* Defaults to false.
|
||||
*/
|
||||
verticalHasArrows?: boolean;
|
||||
/**
|
||||
* Scroll gutter clicks move by page vs. jump to position.
|
||||
* Defaults to false.
|
||||
*/
|
||||
scrollByPage?: boolean;
|
||||
}
|
||||
|
||||
export interface IScrollableElementChangeOptions {
|
||||
handleMouseWheel?: boolean;
|
||||
mouseWheelScrollSensitivity?: number;
|
||||
fastScrollSensitivity?: number;
|
||||
scrollPredominantAxis?: boolean;
|
||||
horizontal?: ScrollbarVisibility;
|
||||
horizontalScrollbarSize?: number;
|
||||
horizontalHasArrows?: boolean;
|
||||
vertical?: ScrollbarVisibility;
|
||||
verticalScrollbarSize?: number;
|
||||
verticalHasArrows?: boolean;
|
||||
scrollByPage?: boolean;
|
||||
}
|
||||
|
||||
export interface IScrollableElementResolvedOptions {
|
||||
lazyRender: boolean;
|
||||
className: string;
|
||||
useShadows: boolean;
|
||||
handleMouseWheel: boolean;
|
||||
flipAxes: boolean;
|
||||
scrollYToX: boolean;
|
||||
consumeMouseWheelIfScrollbarIsNeeded: boolean;
|
||||
alwaysConsumeMouseWheel: boolean;
|
||||
mouseWheelScrollSensitivity: number;
|
||||
fastScrollSensitivity: number;
|
||||
scrollPredominantAxis: boolean;
|
||||
mouseWheelSmoothScroll: boolean;
|
||||
listenOnDomNode: HTMLElement | null;
|
||||
horizontal: ScrollbarVisibility;
|
||||
horizontalScrollbarSize: number;
|
||||
horizontalSliderSize: number;
|
||||
horizontalHasArrows: boolean;
|
||||
vertical: ScrollbarVisibility;
|
||||
verticalScrollbarSize: number;
|
||||
verticalSliderSize: number;
|
||||
verticalHasArrows: boolean;
|
||||
scrollByPage: boolean;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { GlobalPointerMoveMonitor } from './globalPointerMoveMonitor';
|
||||
import { Widget } from './widget';
|
||||
import { TimeoutTimer } from 'common/Async';
|
||||
import * as dom from '../Dom';
|
||||
|
||||
export interface IScrollbarArrowOptions {
|
||||
handleActivate: () => void;
|
||||
className: string;
|
||||
// icon: ThemeIcon;
|
||||
|
||||
bgWidth: number;
|
||||
bgHeight: number;
|
||||
|
||||
top?: number;
|
||||
left?: number;
|
||||
bottom?: number;
|
||||
right?: number;
|
||||
}
|
||||
|
||||
export class ScrollbarArrow extends Widget {
|
||||
|
||||
private _handleActivate: () => void;
|
||||
public bgDomNode: HTMLElement;
|
||||
public domNode: HTMLElement;
|
||||
private _pointerdownRepeatTimer: dom.WindowIntervalTimer;
|
||||
private _pointerdownScheduleRepeatTimer: TimeoutTimer;
|
||||
private _pointerMoveMonitor: GlobalPointerMoveMonitor;
|
||||
|
||||
constructor(opts: IScrollbarArrowOptions) {
|
||||
super();
|
||||
this._handleActivate = opts.handleActivate;
|
||||
|
||||
this.bgDomNode = document.createElement('div');
|
||||
this.bgDomNode.className = 'xterm-arrow-background';
|
||||
this.bgDomNode.style.position = 'absolute';
|
||||
this.bgDomNode.style.width = opts.bgWidth + 'px';
|
||||
this.bgDomNode.style.height = opts.bgHeight + 'px';
|
||||
if (typeof opts.top !== 'undefined') {
|
||||
this.bgDomNode.style.top = '0px';
|
||||
}
|
||||
if (typeof opts.left !== 'undefined') {
|
||||
this.bgDomNode.style.left = '0px';
|
||||
}
|
||||
if (typeof opts.bottom !== 'undefined') {
|
||||
this.bgDomNode.style.bottom = '0px';
|
||||
}
|
||||
if (typeof opts.right !== 'undefined') {
|
||||
this.bgDomNode.style.right = '0px';
|
||||
}
|
||||
|
||||
this.domNode = document.createElement('div');
|
||||
this.domNode.className = opts.className;
|
||||
// this.domNode.classList.add(...ThemeIcon.asClassNameArray(opts.icon));
|
||||
|
||||
this.domNode.style.position = 'absolute';
|
||||
const arrowSize = Math.min(opts.bgWidth, opts.bgHeight);
|
||||
this.domNode.style.width = arrowSize + 'px';
|
||||
this.domNode.style.height = arrowSize + 'px';
|
||||
if (typeof opts.top !== 'undefined') {
|
||||
this.domNode.style.top = opts.top + 'px';
|
||||
}
|
||||
if (typeof opts.left !== 'undefined') {
|
||||
this.domNode.style.left = opts.left + 'px';
|
||||
}
|
||||
if (typeof opts.bottom !== 'undefined') {
|
||||
this.domNode.style.bottom = opts.bottom + 'px';
|
||||
}
|
||||
if (typeof opts.right !== 'undefined') {
|
||||
this.domNode.style.right = opts.right + 'px';
|
||||
}
|
||||
|
||||
this._pointerMoveMonitor = this._register(new GlobalPointerMoveMonitor());
|
||||
this._register(dom.addStandardDisposableListener(this.bgDomNode, dom.eventType.POINTER_DOWN, (e) => this._arrowPointerDown(e)));
|
||||
this._register(dom.addStandardDisposableListener(this.domNode, dom.eventType.POINTER_DOWN, (e) => this._arrowPointerDown(e)));
|
||||
|
||||
this._pointerdownRepeatTimer = this._register(new dom.WindowIntervalTimer());
|
||||
this._pointerdownScheduleRepeatTimer = this._register(new TimeoutTimer());
|
||||
}
|
||||
|
||||
private _arrowPointerDown(e: PointerEvent): void {
|
||||
if (!e.target || !(e.target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
const scheduleRepeater = (): void => {
|
||||
this._pointerdownRepeatTimer.cancelAndSet(() => this._handleActivate(), 1000 / 24, dom.getWindow(e));
|
||||
};
|
||||
|
||||
this._handleActivate();
|
||||
this._pointerdownRepeatTimer.cancel();
|
||||
this._pointerdownScheduleRepeatTimer.cancelAndSet(scheduleRepeater, 200);
|
||||
|
||||
this._pointerMoveMonitor.startMonitoring(
|
||||
e.target,
|
||||
e.pointerId,
|
||||
e.buttons,
|
||||
(pointerMoveData) => { /* Intentional empty */ },
|
||||
() => {
|
||||
this._pointerdownRepeatTimer.cancel();
|
||||
this._pointerdownScheduleRepeatTimer.cancel();
|
||||
}
|
||||
);
|
||||
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
/**
|
||||
* The minimal size of the slider (such that it can still be clickable).
|
||||
* The slider is artificially enlarged to keep it usable.
|
||||
*/
|
||||
const MINIMUM_SLIDER_SIZE = 20;
|
||||
|
||||
interface IScrollbarStateComputedValues {
|
||||
computedAvailableSize: number;
|
||||
computedIsNeeded: boolean;
|
||||
computedSliderSize: number;
|
||||
computedSliderRatio: number;
|
||||
computedSliderPosition: number;
|
||||
}
|
||||
|
||||
export class ScrollbarState {
|
||||
|
||||
/**
|
||||
* For the vertical scrollbar: the width.
|
||||
* For the horizontal scrollbar: the height.
|
||||
*/
|
||||
private _scrollbarSize: number;
|
||||
|
||||
/**
|
||||
* For the vertical scrollbar: the height of the pair horizontal scrollbar.
|
||||
* For the horizontal scrollbar: the width of the pair vertical scrollbar.
|
||||
*/
|
||||
private _oppositeScrollbarSize: number;
|
||||
|
||||
/**
|
||||
* For the vertical scrollbar: the height of the scrollbar's arrows.
|
||||
* For the horizontal scrollbar: the width of the scrollbar's arrows.
|
||||
*/
|
||||
private _arrowSize: number;
|
||||
|
||||
// --- variables
|
||||
/**
|
||||
* For the vertical scrollbar: the viewport height.
|
||||
* For the horizontal scrollbar: the viewport width.
|
||||
*/
|
||||
private _visibleSize: number;
|
||||
|
||||
/**
|
||||
* For the vertical scrollbar: the scroll height.
|
||||
* For the horizontal scrollbar: the scroll width.
|
||||
*/
|
||||
private _scrollSize: number;
|
||||
|
||||
/**
|
||||
* For the vertical scrollbar: the scroll top.
|
||||
* For the horizontal scrollbar: the scroll left.
|
||||
*/
|
||||
private _scrollPosition: number;
|
||||
|
||||
// --- computed variables
|
||||
|
||||
/**
|
||||
* `visibleSize` - `oppositeScrollbarSize`
|
||||
*/
|
||||
private _computedAvailableSize: number;
|
||||
/**
|
||||
* (`scrollSize` > 0 && `scrollSize` > `visibleSize`)
|
||||
*/
|
||||
private _computedIsNeeded: boolean;
|
||||
|
||||
private _computedSliderSize: number;
|
||||
private _computedSliderRatio: number;
|
||||
private _computedSliderPosition: number;
|
||||
|
||||
constructor(arrowSize: number, scrollbarSize: number, oppositeScrollbarSize: number, visibleSize: number, scrollSize: number, scrollPosition: number) {
|
||||
this._scrollbarSize = Math.round(scrollbarSize);
|
||||
this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize);
|
||||
this._arrowSize = Math.round(arrowSize);
|
||||
|
||||
this._visibleSize = visibleSize;
|
||||
this._scrollSize = scrollSize;
|
||||
this._scrollPosition = scrollPosition;
|
||||
|
||||
this._computedAvailableSize = 0;
|
||||
this._computedIsNeeded = false;
|
||||
this._computedSliderSize = 0;
|
||||
this._computedSliderRatio = 0;
|
||||
this._computedSliderPosition = 0;
|
||||
|
||||
this._refreshComputedValues();
|
||||
}
|
||||
|
||||
public clone(): ScrollbarState {
|
||||
return new ScrollbarState(this._arrowSize, this._scrollbarSize, this._oppositeScrollbarSize, this._visibleSize, this._scrollSize, this._scrollPosition);
|
||||
}
|
||||
|
||||
public setVisibleSize(visibleSize: number): boolean {
|
||||
const iVisibleSize = Math.round(visibleSize);
|
||||
if (this._visibleSize !== iVisibleSize) {
|
||||
this._visibleSize = iVisibleSize;
|
||||
this._refreshComputedValues();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setScrollSize(scrollSize: number): boolean {
|
||||
const iScrollSize = Math.round(scrollSize);
|
||||
if (this._scrollSize !== iScrollSize) {
|
||||
this._scrollSize = iScrollSize;
|
||||
this._refreshComputedValues();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setScrollPosition(scrollPosition: number): boolean {
|
||||
const iScrollPosition = Math.round(scrollPosition);
|
||||
if (this._scrollPosition !== iScrollPosition) {
|
||||
this._scrollPosition = iScrollPosition;
|
||||
this._refreshComputedValues();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public setScrollbarSize(scrollbarSize: number): void {
|
||||
this._scrollbarSize = Math.round(scrollbarSize);
|
||||
}
|
||||
|
||||
public setArrowSize(arrowSize: number): void {
|
||||
const iArrowSize = Math.round(arrowSize);
|
||||
if (this._arrowSize !== iArrowSize) {
|
||||
this._arrowSize = iArrowSize;
|
||||
this._refreshComputedValues();
|
||||
}
|
||||
}
|
||||
|
||||
public setOppositeScrollbarSize(oppositeScrollbarSize: number): void {
|
||||
this._oppositeScrollbarSize = Math.round(oppositeScrollbarSize);
|
||||
}
|
||||
|
||||
private static _computeValues(
|
||||
oppositeScrollbarSize: number,
|
||||
arrowSize: number,
|
||||
visibleSize: number,
|
||||
scrollSize: number,
|
||||
scrollPosition: number
|
||||
): IScrollbarStateComputedValues {
|
||||
const computedAvailableSize = Math.max(0, visibleSize - oppositeScrollbarSize);
|
||||
const computedRepresentableSize = Math.max(0, computedAvailableSize - 2 * arrowSize);
|
||||
const computedIsNeeded = (scrollSize > 0 && scrollSize > visibleSize);
|
||||
|
||||
if (!computedIsNeeded) {
|
||||
return {
|
||||
computedAvailableSize: Math.round(computedAvailableSize),
|
||||
computedIsNeeded: computedIsNeeded,
|
||||
computedSliderSize: Math.round(computedRepresentableSize),
|
||||
computedSliderRatio: 0,
|
||||
computedSliderPosition: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const computedSliderSize = Math.round(Math.max(MINIMUM_SLIDER_SIZE, Math.floor(visibleSize * computedRepresentableSize / scrollSize)));
|
||||
|
||||
const computedSliderRatio = (computedRepresentableSize - computedSliderSize) / (scrollSize - visibleSize);
|
||||
const computedSliderPosition = (scrollPosition * computedSliderRatio);
|
||||
|
||||
return {
|
||||
computedAvailableSize: Math.round(computedAvailableSize),
|
||||
computedIsNeeded: computedIsNeeded,
|
||||
computedSliderSize: Math.round(computedSliderSize),
|
||||
computedSliderRatio: computedSliderRatio,
|
||||
computedSliderPosition: Math.round(computedSliderPosition),
|
||||
};
|
||||
}
|
||||
|
||||
private _refreshComputedValues(): void {
|
||||
const r = ScrollbarState._computeValues(this._oppositeScrollbarSize, this._arrowSize, this._visibleSize, this._scrollSize, this._scrollPosition);
|
||||
this._computedAvailableSize = r.computedAvailableSize;
|
||||
this._computedIsNeeded = r.computedIsNeeded;
|
||||
this._computedSliderSize = r.computedSliderSize;
|
||||
this._computedSliderRatio = r.computedSliderRatio;
|
||||
this._computedSliderPosition = r.computedSliderPosition;
|
||||
}
|
||||
|
||||
public getArrowSize(): number {
|
||||
return this._arrowSize;
|
||||
}
|
||||
|
||||
public getScrollPosition(): number {
|
||||
return this._scrollPosition;
|
||||
}
|
||||
|
||||
public getRectangleLargeSize(): number {
|
||||
return this._computedAvailableSize;
|
||||
}
|
||||
|
||||
public getRectangleSmallSize(): number {
|
||||
return this._scrollbarSize;
|
||||
}
|
||||
|
||||
public isNeeded(): boolean {
|
||||
return this._computedIsNeeded;
|
||||
}
|
||||
|
||||
public getSliderSize(): number {
|
||||
return this._computedSliderSize;
|
||||
}
|
||||
|
||||
public getSliderPosition(): number {
|
||||
return this._computedSliderPosition;
|
||||
}
|
||||
|
||||
public getDesiredScrollPositionFromOffset(offset: number): number {
|
||||
if (!this._computedIsNeeded) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const desiredSliderPosition = offset - this._arrowSize - this._computedSliderSize / 2;
|
||||
return Math.round(desiredSliderPosition / this._computedSliderRatio);
|
||||
}
|
||||
|
||||
public getDesiredScrollPositionFromOffsetPaged(offset: number): number {
|
||||
if (!this._computedIsNeeded) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const correctedOffset = offset - this._arrowSize;
|
||||
let desiredScrollPosition = this._scrollPosition;
|
||||
if (correctedOffset < this._computedSliderPosition) {
|
||||
desiredScrollPosition -= this._visibleSize;
|
||||
} else {
|
||||
desiredScrollPosition += this._visibleSize;
|
||||
}
|
||||
return desiredScrollPosition;
|
||||
}
|
||||
|
||||
public getDesiredScrollPositionFromDelta(delta: number): number {
|
||||
if (!this._computedIsNeeded) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const desiredSliderPosition = this._computedSliderPosition + delta;
|
||||
return Math.round(desiredSliderPosition / this._computedSliderRatio);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { FastDomNode } from './fastDomNode';
|
||||
import { TimeoutTimer } from 'common/Async';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { ScrollbarVisibility } from './scrollable';
|
||||
|
||||
export class ScrollbarVisibilityController extends Disposable {
|
||||
private _visibility: ScrollbarVisibility;
|
||||
private _visibleClassName: string;
|
||||
private _invisibleClassName: string;
|
||||
private _domNode: FastDomNode<HTMLElement> | null;
|
||||
private _rawShouldBeVisible: boolean;
|
||||
private _shouldBeVisible: boolean;
|
||||
private _isNeeded: boolean;
|
||||
private _isVisible: boolean;
|
||||
private _revealTimer: TimeoutTimer;
|
||||
|
||||
constructor(visibility: ScrollbarVisibility, visibleClassName: string, invisibleClassName: string) {
|
||||
super();
|
||||
this._visibility = visibility;
|
||||
this._visibleClassName = visibleClassName;
|
||||
this._invisibleClassName = invisibleClassName;
|
||||
this._domNode = null;
|
||||
this._isVisible = false;
|
||||
this._isNeeded = false;
|
||||
this._rawShouldBeVisible = false;
|
||||
this._shouldBeVisible = false;
|
||||
this._revealTimer = this._register(new TimeoutTimer());
|
||||
}
|
||||
|
||||
public setVisibility(visibility: ScrollbarVisibility): void {
|
||||
if (this._visibility !== visibility) {
|
||||
this._visibility = visibility;
|
||||
this._updateShouldBeVisible();
|
||||
}
|
||||
}
|
||||
|
||||
public setShouldBeVisible(rawShouldBeVisible: boolean): void {
|
||||
this._rawShouldBeVisible = rawShouldBeVisible;
|
||||
this._updateShouldBeVisible();
|
||||
}
|
||||
|
||||
private _applyVisibilitySetting(): boolean {
|
||||
if (this._visibility === ScrollbarVisibility.HIDDEN) {
|
||||
return false;
|
||||
}
|
||||
if (this._visibility === ScrollbarVisibility.VISIBLE) {
|
||||
return true;
|
||||
}
|
||||
return this._rawShouldBeVisible;
|
||||
}
|
||||
|
||||
private _updateShouldBeVisible(): void {
|
||||
const shouldBeVisible = this._applyVisibilitySetting();
|
||||
|
||||
if (this._shouldBeVisible !== shouldBeVisible) {
|
||||
this._shouldBeVisible = shouldBeVisible;
|
||||
this.ensureVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
public setIsNeeded(isNeeded: boolean): void {
|
||||
if (this._isNeeded !== isNeeded) {
|
||||
this._isNeeded = isNeeded;
|
||||
this.ensureVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
public setDomNode(domNode: FastDomNode<HTMLElement>): void {
|
||||
this._domNode = domNode;
|
||||
this._domNode.setClassName(this._invisibleClassName);
|
||||
|
||||
this.setShouldBeVisible(false);
|
||||
}
|
||||
|
||||
public ensureVisibility(): void {
|
||||
|
||||
if (!this._isNeeded) {
|
||||
this._hide(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._shouldBeVisible) {
|
||||
this._reveal();
|
||||
} else {
|
||||
this._hide(true);
|
||||
}
|
||||
}
|
||||
|
||||
private _reveal(): void {
|
||||
if (this._isVisible) {
|
||||
return;
|
||||
}
|
||||
this._isVisible = true;
|
||||
|
||||
this._revealTimer.setIfNotSet(() => {
|
||||
this._domNode?.setClassName(this._visibleClassName);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
private _hide(withFadeAway: boolean): void {
|
||||
this._revealTimer.cancel();
|
||||
if (!this._isVisible) {
|
||||
return;
|
||||
}
|
||||
this._isVisible = false;
|
||||
this._domNode?.setClassName(this._invisibleClassName + (withFadeAway ? ' xterm-fade' : ''));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,481 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import * as DomUtils from '../Dom';
|
||||
import { Disposable, IDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
const mainWindow = (typeof window === 'object' ? window : globalThis) as Window & typeof globalThis;
|
||||
|
||||
function tail<T>(array: ArrayLike<T>, n: number = 0): T | undefined {
|
||||
return array[array.length - (1 + n)];
|
||||
}
|
||||
|
||||
function memoize(_target: any, key: string, descriptor: PropertyDescriptor): void {
|
||||
let fnKey: string | null = null;
|
||||
let fn: Function | null = null;
|
||||
|
||||
if (typeof descriptor.value === 'function') {
|
||||
fnKey = 'value';
|
||||
fn = descriptor.value;
|
||||
|
||||
if (fn!.length !== 0) {
|
||||
console.warn('Memoize should only be used in functions with zero parameters');
|
||||
}
|
||||
} else if (typeof descriptor.get === 'function') {
|
||||
fnKey = 'get';
|
||||
fn = descriptor.get;
|
||||
}
|
||||
|
||||
if (!fn || !fnKey) {
|
||||
throw new Error('not supported');
|
||||
}
|
||||
|
||||
const memoizeKey = `$memoize$${key}`;
|
||||
const descriptorAny = descriptor as { [key: string]: any };
|
||||
descriptorAny[fnKey] = function (...args: any[]) {
|
||||
if (!this.hasOwnProperty(memoizeKey)) {
|
||||
Object.defineProperty(this, memoizeKey, {
|
||||
configurable: false,
|
||||
enumerable: false,
|
||||
writable: false,
|
||||
value: fn.apply(this, args)
|
||||
});
|
||||
}
|
||||
|
||||
return (this as { [key: string]: any })[memoizeKey];
|
||||
};
|
||||
}
|
||||
|
||||
class LinkedListNode<E> {
|
||||
|
||||
public static readonly Undefined = new LinkedListNode<any>(undefined);
|
||||
|
||||
public element: E;
|
||||
public next: LinkedListNode<E>;
|
||||
public prev: LinkedListNode<E>;
|
||||
|
||||
public constructor(element: E) {
|
||||
this.element = element;
|
||||
this.next = LinkedListNode.Undefined;
|
||||
this.prev = LinkedListNode.Undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class LinkedList<E> {
|
||||
|
||||
private _first: LinkedListNode<E> = LinkedListNode.Undefined;
|
||||
private _last: LinkedListNode<E> = LinkedListNode.Undefined;
|
||||
|
||||
public push(element: E): () => void {
|
||||
return this._insert(element, true);
|
||||
}
|
||||
|
||||
private _insert(element: E, atTheEnd: boolean): () => void {
|
||||
const newNode = new LinkedListNode(element);
|
||||
if (this._first === LinkedListNode.Undefined) {
|
||||
this._first = newNode;
|
||||
this._last = newNode;
|
||||
|
||||
} else if (atTheEnd) {
|
||||
const oldLast = this._last;
|
||||
this._last = newNode;
|
||||
newNode.prev = oldLast;
|
||||
oldLast.next = newNode;
|
||||
|
||||
} else {
|
||||
const oldFirst = this._first;
|
||||
this._first = newNode;
|
||||
newNode.next = oldFirst;
|
||||
oldFirst.prev = newNode;
|
||||
}
|
||||
let didRemove = false;
|
||||
return () => {
|
||||
if (!didRemove) {
|
||||
didRemove = true;
|
||||
this._remove(newNode);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private _remove(node: LinkedListNode<E>): void {
|
||||
if (node.prev !== LinkedListNode.Undefined && node.next !== LinkedListNode.Undefined) {
|
||||
const anchor = node.prev;
|
||||
anchor.next = node.next;
|
||||
node.next.prev = anchor;
|
||||
|
||||
} else if (node.prev === LinkedListNode.Undefined && node.next === LinkedListNode.Undefined) {
|
||||
this._first = LinkedListNode.Undefined;
|
||||
this._last = LinkedListNode.Undefined;
|
||||
|
||||
} else if (node.next === LinkedListNode.Undefined) {
|
||||
this._last = this._last.prev!;
|
||||
this._last.next = LinkedListNode.Undefined;
|
||||
|
||||
} else if (node.prev === LinkedListNode.Undefined) {
|
||||
this._first = this._first.next!;
|
||||
this._first.prev = LinkedListNode.Undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public *[Symbol.iterator](): Iterator<E> {
|
||||
let node = this._first;
|
||||
while (node !== LinkedListNode.Undefined) {
|
||||
yield node.element;
|
||||
node = node.next;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export namespace EventType {
|
||||
export const TAP = '-xterm-gesturetap';
|
||||
export const CHANGE = '-xterm-gesturechange';
|
||||
export const START = '-xterm-gesturestart';
|
||||
export const END = '-xterm-gesturesend';
|
||||
export const CONTEXT_MENU = '-xterm-gesturecontextmenu';
|
||||
}
|
||||
|
||||
interface ITouchData {
|
||||
id: number;
|
||||
initialTarget: EventTarget;
|
||||
initialTimeStamp: number;
|
||||
initialPageX: number;
|
||||
initialPageY: number;
|
||||
rollingTimestamps: number[];
|
||||
rollingPageX: number[];
|
||||
rollingPageY: number[];
|
||||
}
|
||||
|
||||
export interface IGestureEvent extends MouseEvent {
|
||||
initialTarget: EventTarget | undefined;
|
||||
translationX: number;
|
||||
translationY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
tapCount: number;
|
||||
}
|
||||
|
||||
interface ITouch {
|
||||
identifier: number;
|
||||
screenX: number;
|
||||
screenY: number;
|
||||
clientX: number;
|
||||
clientY: number;
|
||||
pageX: number;
|
||||
pageY: number;
|
||||
radiusX: number;
|
||||
radiusY: number;
|
||||
rotationAngle: number;
|
||||
force: number;
|
||||
target: Element;
|
||||
}
|
||||
|
||||
interface ITouchList {
|
||||
[i: number]: ITouch;
|
||||
length: number;
|
||||
item(index: number): ITouch;
|
||||
identifiedTouch(id: number): ITouch;
|
||||
}
|
||||
|
||||
interface ITouchEvent extends Event {
|
||||
touches: ITouchList;
|
||||
targetTouches: ITouchList;
|
||||
changedTouches: ITouchList;
|
||||
}
|
||||
|
||||
export class Gesture extends Disposable {
|
||||
|
||||
private static readonly _scrollFriction = -0.005;
|
||||
private static _instance: Gesture;
|
||||
private static readonly _holdDelay = 700;
|
||||
|
||||
private _dispatched = false;
|
||||
private readonly _targets = new LinkedList<HTMLElement>();
|
||||
private readonly _ignoreTargets = new LinkedList<HTMLElement>();
|
||||
private _handle: IDisposable | null;
|
||||
|
||||
private readonly _activeTouches: { [id: number]: ITouchData };
|
||||
|
||||
private _lastSetTapCountTime: number;
|
||||
|
||||
private static readonly _clearTapCountTime = 400; // ms
|
||||
|
||||
|
||||
private constructor() {
|
||||
super();
|
||||
|
||||
this._activeTouches = {};
|
||||
this._handle = null;
|
||||
this._lastSetTapCountTime = 0;
|
||||
|
||||
const targetWindow = mainWindow;
|
||||
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchstart', (e: ITouchEvent) => this._handleTouchStart(e), { passive: false }));
|
||||
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchend', (e: ITouchEvent) => this._handleTouchEnd(targetWindow, e)));
|
||||
this._register(DomUtils.addDisposableListener(targetWindow.document, 'touchmove', (e: ITouchEvent) => this._handleTouchMove(e), { passive: false }));
|
||||
}
|
||||
|
||||
public static addTarget(element: HTMLElement): IDisposable {
|
||||
if (!Gesture.isTouchDevice()) {
|
||||
return Disposable.None;
|
||||
}
|
||||
if (!Gesture._instance) {
|
||||
Gesture._instance = new Gesture();
|
||||
}
|
||||
|
||||
const remove = Gesture._instance._targets.push(element);
|
||||
return toDisposable(remove);
|
||||
}
|
||||
|
||||
public static ignoreTarget(element: HTMLElement): IDisposable {
|
||||
if (!Gesture.isTouchDevice()) {
|
||||
return Disposable.None;
|
||||
}
|
||||
if (!Gesture._instance) {
|
||||
Gesture._instance = new Gesture();
|
||||
}
|
||||
|
||||
const remove = Gesture._instance._ignoreTargets.push(element);
|
||||
return toDisposable(remove);
|
||||
}
|
||||
|
||||
@memoize
|
||||
public static isTouchDevice(): boolean {
|
||||
return 'ontouchstart' in mainWindow || navigator.maxTouchPoints > 0;
|
||||
}
|
||||
|
||||
public override dispose(): void {
|
||||
if (this._handle) {
|
||||
this._handle.dispose();
|
||||
this._handle = null;
|
||||
}
|
||||
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
private _handleTouchStart(e: ITouchEvent): void {
|
||||
const timestamp = Date.now();
|
||||
|
||||
if (this._handle) {
|
||||
this._handle.dispose();
|
||||
this._handle = null;
|
||||
}
|
||||
|
||||
for (let i = 0, len = e.targetTouches.length; i < len; i++) {
|
||||
const touch = e.targetTouches.item(i);
|
||||
|
||||
this._activeTouches[touch.identifier] = {
|
||||
id: touch.identifier,
|
||||
initialTarget: touch.target,
|
||||
initialTimeStamp: timestamp,
|
||||
initialPageX: touch.pageX,
|
||||
initialPageY: touch.pageY,
|
||||
rollingTimestamps: [timestamp],
|
||||
rollingPageX: [touch.pageX],
|
||||
rollingPageY: [touch.pageY]
|
||||
};
|
||||
|
||||
const evt = this._newGestureEvent(EventType.START, touch.target);
|
||||
evt.pageX = touch.pageX;
|
||||
evt.pageY = touch.pageY;
|
||||
this._dispatchEvent(evt);
|
||||
}
|
||||
|
||||
if (this._dispatched) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._dispatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleTouchEnd(targetWindow: Window, e: ITouchEvent): void {
|
||||
const timestamp = Date.now();
|
||||
|
||||
const activeTouchCount = Object.keys(this._activeTouches).length;
|
||||
|
||||
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
|
||||
|
||||
const touch = e.changedTouches.item(i);
|
||||
|
||||
if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) {
|
||||
console.warn('move of an UNKNOWN touch', touch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = this._activeTouches[touch.identifier];
|
||||
const holdTime = Date.now() - data.initialTimeStamp;
|
||||
|
||||
if (holdTime < Gesture._holdDelay
|
||||
&& Math.abs(data.initialPageX - tail(data.rollingPageX)!) < 30
|
||||
&& Math.abs(data.initialPageY - tail(data.rollingPageY)!) < 30) {
|
||||
|
||||
const evt = this._newGestureEvent(EventType.TAP, data.initialTarget);
|
||||
evt.pageX = tail(data.rollingPageX)!;
|
||||
evt.pageY = tail(data.rollingPageY)!;
|
||||
this._dispatchEvent(evt);
|
||||
|
||||
} else if (holdTime >= Gesture._holdDelay
|
||||
&& Math.abs(data.initialPageX - tail(data.rollingPageX)!) < 30
|
||||
&& Math.abs(data.initialPageY - tail(data.rollingPageY)!) < 30) {
|
||||
|
||||
const evt = this._newGestureEvent(EventType.CONTEXT_MENU, data.initialTarget);
|
||||
evt.pageX = tail(data.rollingPageX)!;
|
||||
evt.pageY = tail(data.rollingPageY)!;
|
||||
this._dispatchEvent(evt);
|
||||
|
||||
} else if (activeTouchCount === 1) {
|
||||
const finalX = tail(data.rollingPageX)!;
|
||||
const finalY = tail(data.rollingPageY)!;
|
||||
|
||||
const deltaT = tail(data.rollingTimestamps)! - data.rollingTimestamps[0];
|
||||
const deltaX = finalX - data.rollingPageX[0];
|
||||
const deltaY = finalY - data.rollingPageY[0];
|
||||
|
||||
const dispatchTo = [...this._targets].filter(t => data.initialTarget instanceof Node && t.contains(data.initialTarget));
|
||||
this._inertia(targetWindow, dispatchTo, timestamp,
|
||||
Math.abs(deltaX) / deltaT,
|
||||
deltaX > 0 ? 1 : -1,
|
||||
finalX,
|
||||
Math.abs(deltaY) / deltaT,
|
||||
deltaY > 0 ? 1 : -1,
|
||||
finalY
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
this._dispatchEvent(this._newGestureEvent(EventType.END, data.initialTarget));
|
||||
delete this._activeTouches[touch.identifier];
|
||||
}
|
||||
|
||||
if (this._dispatched) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._dispatched = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _newGestureEvent(type: string, initialTarget?: EventTarget): IGestureEvent {
|
||||
const event = document.createEvent('CustomEvent') as unknown as IGestureEvent;
|
||||
event.initEvent(type, false, true);
|
||||
event.initialTarget = initialTarget;
|
||||
event.tapCount = 0;
|
||||
return event;
|
||||
}
|
||||
|
||||
private _dispatchEvent(event: IGestureEvent): void {
|
||||
if (event.type === EventType.TAP) {
|
||||
const currentTime = (new Date()).getTime();
|
||||
let setTapCount = 0;
|
||||
if (currentTime - this._lastSetTapCountTime > Gesture._clearTapCountTime) {
|
||||
setTapCount = 1;
|
||||
} else {
|
||||
setTapCount = 2;
|
||||
}
|
||||
|
||||
this._lastSetTapCountTime = currentTime;
|
||||
event.tapCount = setTapCount;
|
||||
} else if (event.type === EventType.CHANGE || event.type === EventType.CONTEXT_MENU) {
|
||||
this._lastSetTapCountTime = 0;
|
||||
}
|
||||
|
||||
if (event.initialTarget instanceof Node) {
|
||||
for (const ignoreTarget of this._ignoreTargets) {
|
||||
if (ignoreTarget.contains(event.initialTarget)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const targets: [number, HTMLElement][] = [];
|
||||
for (const target of this._targets) {
|
||||
if (target.contains(event.initialTarget)) {
|
||||
let depth = 0;
|
||||
let now: Node | null = event.initialTarget;
|
||||
while (now && now !== target) {
|
||||
depth++;
|
||||
now = now.parentElement;
|
||||
}
|
||||
targets.push([depth, target]);
|
||||
}
|
||||
}
|
||||
|
||||
targets.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
for (const [, target] of targets) {
|
||||
target.dispatchEvent(event);
|
||||
this._dispatched = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _inertia(targetWindow: Window, dispatchTo: ReadonlyArray<EventTarget>, t1: number, vX: number, dirX: number, x: number, vY: number, dirY: number, y: number): void {
|
||||
this._handle = DomUtils.scheduleAtNextAnimationFrame(targetWindow, () => {
|
||||
const now = Date.now();
|
||||
|
||||
const deltaT = now - t1;
|
||||
let deltaPosX = 0;
|
||||
let deltaPosY = 0;
|
||||
let stopped = true;
|
||||
|
||||
vX += Gesture._scrollFriction * deltaT;
|
||||
vY += Gesture._scrollFriction * deltaT;
|
||||
|
||||
if (vX > 0) {
|
||||
stopped = false;
|
||||
deltaPosX = dirX * vX * deltaT;
|
||||
}
|
||||
|
||||
if (vY > 0) {
|
||||
stopped = false;
|
||||
deltaPosY = dirY * vY * deltaT;
|
||||
}
|
||||
|
||||
const evt = this._newGestureEvent(EventType.CHANGE);
|
||||
evt.translationX = deltaPosX;
|
||||
evt.translationY = deltaPosY;
|
||||
dispatchTo.forEach(d => d.dispatchEvent(evt));
|
||||
|
||||
if (!stopped) {
|
||||
this._inertia(targetWindow, dispatchTo, now, vX, dirX, x + deltaPosX, vY, dirY, y + deltaPosY);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _handleTouchMove(e: ITouchEvent): void {
|
||||
const timestamp = Date.now();
|
||||
|
||||
for (let i = 0, len = e.changedTouches.length; i < len; i++) {
|
||||
|
||||
const touch = e.changedTouches.item(i);
|
||||
|
||||
if (!this._activeTouches.hasOwnProperty(String(touch.identifier))) {
|
||||
console.warn('end of an UNKNOWN touch', touch);
|
||||
continue;
|
||||
}
|
||||
|
||||
const data = this._activeTouches[touch.identifier];
|
||||
|
||||
const evt = this._newGestureEvent(EventType.CHANGE, data.initialTarget);
|
||||
evt.translationX = touch.pageX - tail(data.rollingPageX)!;
|
||||
evt.translationY = touch.pageY - tail(data.rollingPageY)!;
|
||||
evt.pageX = touch.pageX;
|
||||
evt.pageY = touch.pageY;
|
||||
this._dispatchEvent(evt);
|
||||
|
||||
if (data.rollingPageX.length > 3) {
|
||||
data.rollingPageX.shift();
|
||||
data.rollingPageY.shift();
|
||||
data.rollingTimestamps.shift();
|
||||
}
|
||||
|
||||
data.rollingPageX.push(touch.pageX);
|
||||
data.rollingPageY.push(touch.pageY);
|
||||
data.rollingTimestamps.push(timestamp);
|
||||
}
|
||||
|
||||
if (this._dispatched) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this._dispatched = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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';
|
||||
import type { ScrollbarArrow } from './scrollbarArrow';
|
||||
|
||||
export class VerticalScrollbar extends AbstractScrollbar {
|
||||
private _arrowUp: ScrollbarArrow | undefined;
|
||||
private _arrowDown: ScrollbarArrow | undefined;
|
||||
private _arrowScrollDelta: number = 0;
|
||||
|
||||
constructor(scrollable: Scrollable, options: IScrollableElementResolvedOptions, host: IScrollbarHost) {
|
||||
const scrollDimensions = scrollable.getScrollDimensions();
|
||||
const scrollPosition = scrollable.getCurrentScrollPosition();
|
||||
const hasArrows = options.verticalHasArrows;
|
||||
super({
|
||||
lazyRender: options.lazyRender,
|
||||
host: host,
|
||||
scrollbarState: new ScrollbarState(
|
||||
(hasArrows ? options.verticalScrollbarSize : 0),
|
||||
(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize),
|
||||
0,
|
||||
scrollDimensions.height,
|
||||
scrollDimensions.scrollHeight,
|
||||
scrollPosition.scrollTop
|
||||
),
|
||||
visibility: options.vertical,
|
||||
extraScrollbarClassName: 'xterm-vertical',
|
||||
scrollable: scrollable,
|
||||
scrollByPage: options.scrollByPage
|
||||
});
|
||||
|
||||
this._setArrows(hasArrows, options.verticalScrollbarSize);
|
||||
|
||||
this._createSlider(0, Math.floor((options.verticalScrollbarSize - options.verticalSliderSize) / 2), options.verticalSliderSize, undefined);
|
||||
}
|
||||
|
||||
protected _updateSlider(sliderSize: number, sliderPosition: number): void {
|
||||
this.slider.setHeight(sliderSize);
|
||||
this.slider.setTop(sliderPosition);
|
||||
}
|
||||
|
||||
protected _renderDomNode(largeSize: number, smallSize: number): void {
|
||||
this.domNode.setWidth(smallSize);
|
||||
this.domNode.setHeight(largeSize);
|
||||
this.domNode.setRight(0);
|
||||
this.domNode.setTop(0);
|
||||
}
|
||||
|
||||
public handleScroll(e: IScrollEvent): boolean {
|
||||
this._shouldRender = this._handleElementScrollSize(e.scrollHeight) || this._shouldRender;
|
||||
this._shouldRender = this._handleElementScrollPosition(e.scrollTop) || this._shouldRender;
|
||||
this._shouldRender = this._handleElementSize(e.height) || this._shouldRender;
|
||||
return this._shouldRender;
|
||||
}
|
||||
|
||||
protected _pointerDownRelativePosition(offsetX: number, offsetY: number): number {
|
||||
return offsetY;
|
||||
}
|
||||
|
||||
protected _sliderPointerPosition(e: ISimplifiedPointerEvent): number {
|
||||
return e.pageY;
|
||||
}
|
||||
|
||||
protected _sliderOrthogonalPointerPosition(e: ISimplifiedPointerEvent): number {
|
||||
return e.pageX;
|
||||
}
|
||||
|
||||
protected _updateScrollbarSize(size: number): void {
|
||||
this.slider.setWidth(size);
|
||||
}
|
||||
|
||||
public writeScrollPosition(target: INewScrollPosition, scrollPosition: number): void {
|
||||
target.scrollTop = scrollPosition;
|
||||
}
|
||||
|
||||
private _arrowScroll(delta: number): void {
|
||||
const currentPosition = this._scrollable.getCurrentScrollPosition();
|
||||
this._scrollable.setScrollPositionNow({ scrollTop: currentPosition.scrollTop + delta });
|
||||
}
|
||||
|
||||
private _setArrows(showArrows: boolean, size: number): void {
|
||||
this._arrowScrollDelta = size;
|
||||
if (!this._arrowUp || !this._arrowDown) {
|
||||
const arrowDelta = 0;
|
||||
this._arrowUp = this._createArrow({
|
||||
className: 'xterm-scra xterm-arrow-up',
|
||||
top: arrowDelta,
|
||||
left: arrowDelta,
|
||||
bgWidth: size,
|
||||
bgHeight: size,
|
||||
handleActivate: () => this._arrowScroll(-this._arrowScrollDelta)
|
||||
});
|
||||
this._arrowDown = this._createArrow({
|
||||
className: 'xterm-scra xterm-arrow-down',
|
||||
bottom: arrowDelta,
|
||||
left: arrowDelta,
|
||||
bgWidth: size,
|
||||
bgHeight: size,
|
||||
handleActivate: () => this._arrowScroll(this._arrowScrollDelta)
|
||||
});
|
||||
}
|
||||
|
||||
this._updateArrowSize(this._arrowUp, size);
|
||||
this._updateArrowSize(this._arrowDown, size);
|
||||
|
||||
if (!this._arrowUp || !this._arrowDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
const display = showArrows ? '' : 'none';
|
||||
this._arrowUp.bgDomNode.style.display = display;
|
||||
this._arrowUp.domNode.style.display = display;
|
||||
this._arrowDown.bgDomNode.style.display = display;
|
||||
this._arrowDown.domNode.style.display = display;
|
||||
}
|
||||
|
||||
private _updateArrowSize(arrow: ScrollbarArrow | undefined, size: number): void {
|
||||
if (!arrow) {
|
||||
return;
|
||||
}
|
||||
arrow.bgDomNode.style.width = `${size}px`;
|
||||
arrow.bgDomNode.style.height = `${size}px`;
|
||||
arrow.domNode.style.width = `${size}px`;
|
||||
arrow.domNode.style.height = `${size}px`;
|
||||
}
|
||||
|
||||
public updateOptions(options: IScrollableElementResolvedOptions): void {
|
||||
const arrowSize = options.verticalHasArrows ? options.verticalScrollbarSize : 0;
|
||||
this._scrollbarState.setArrowSize(arrowSize);
|
||||
this._setArrows(options.verticalHasArrows, options.verticalScrollbarSize);
|
||||
this.updateScrollbarSize(options.vertical === ScrollbarVisibility.HIDDEN ? 0 : options.verticalScrollbarSize);
|
||||
this._scrollbarState.setOppositeScrollbarSize(0);
|
||||
this._visibilityController.setVisibility(options.vertical);
|
||||
this._scrollByPage = options.scrollByPage;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* 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 { IMouseEvent, StandardMouseEvent } from './mouseEvent';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
|
||||
export abstract class Widget extends Disposable {
|
||||
|
||||
protected _onclick(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void {
|
||||
this._register(dom.addDisposableListener(domNode, dom.eventType.CLICK, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e))));
|
||||
}
|
||||
|
||||
protected _onmouseover(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void {
|
||||
this._register(dom.addDisposableListener(domNode, dom.eventType.MOUSE_OVER, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e))));
|
||||
}
|
||||
|
||||
protected _onmouseleave(domNode: HTMLElement, listener: (e: IMouseEvent) => void): void {
|
||||
this._register(dom.addDisposableListener(domNode, dom.eventType.MOUSE_LEAVE, (e: MouseEvent) => listener(new StandardMouseEvent(dom.getWindow(domNode), e))));
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { ICoreBrowserService } from './Services';
|
||||
import { Emitter, EventUtils } from 'common/Event';
|
||||
import { addDisposableListener } from 'vs/base/browser/dom';
|
||||
import { addDisposableListener } from 'browser/Dom';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
export class CoreBrowserService extends Disposable implements ICoreBrowserService {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { getWindow } from 'vs/base/browser/dom';
|
||||
import { getWindow } from 'browser/Dom';
|
||||
import { ICharSizeService, IRenderService, IMouseService } from './Services';
|
||||
import { getCoords, getCoordsRelativeToElement } from 'browser/input/Mouse';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { IRenderDimensions, IRenderer } from 'browser/renderer/shared/Types';
|
||||
import { ICharSizeService, ICoreBrowserService, IRenderService, IThemeService } from 'browser/services/Services';
|
||||
import { Disposable, MutableDisposable, toDisposable } from 'common/Lifecycle';
|
||||
import { DebouncedIdleTask } from 'common/TaskQueue';
|
||||
import { IBufferService, ICoreService, IDecorationService, IOptionsService } from 'common/services/Services';
|
||||
import { IBufferService, ICoreService, IDecorationService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
interface ISelectionState {
|
||||
@@ -27,7 +27,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
|
||||
private _renderer: MutableDisposable<IRenderer> = this._register(new MutableDisposable());
|
||||
private _renderDebouncer: IRenderDebouncerWithCallback;
|
||||
private _pausedResizeTask = new DebouncedIdleTask();
|
||||
private _pausedResizeTask: DebouncedIdleTask;
|
||||
private _observerDisposable = this._register(new MutableDisposable());
|
||||
|
||||
private _isPaused: boolean = false;
|
||||
@@ -58,6 +58,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
private _rowCount: number,
|
||||
screenElement: HTMLElement,
|
||||
@IOptionsService private readonly _optionsService: IOptionsService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@ICharSizeService private readonly _charSizeService: ICharSizeService,
|
||||
@ICoreService private readonly _coreService: ICoreService,
|
||||
@IDecorationService decorationService: IDecorationService,
|
||||
@@ -67,6 +68,8 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
) {
|
||||
super();
|
||||
|
||||
this._pausedResizeTask = new DebouncedIdleTask(this._logService);
|
||||
|
||||
this._renderDebouncer = new RenderDebouncer((start, end) => this._renderRows(start, end), this._coreBrowserService);
|
||||
this._register(this._renderDebouncer);
|
||||
|
||||
@@ -131,6 +134,7 @@ export class RenderService extends Disposable implements IRenderService {
|
||||
|
||||
private _handleIntersectionChange(entry: IntersectionObserverEntry): void {
|
||||
this._isPaused = entry.isIntersecting === undefined ? (entry.intersectionRatio === 0) : !entry.isIntersecting;
|
||||
this._renderer.value?.handleViewportVisibilityChange?.(!this._isPaused);
|
||||
|
||||
// Terminal was hidden on open
|
||||
if (!this._isPaused && !this._charSizeService.hasValidSize) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ISelectionRedrawRequestEvent, ISelectionRequestScrollLinesEvent } from
|
||||
import { ICoreBrowserService, IMouseService, IRenderService, ISelectionService } from 'browser/services/Services';
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import * as Browser from 'common/Platform';
|
||||
import { IBufferLine, IDisposable } from 'common/Types';
|
||||
import { IBufferLine, ICellData, IDisposable } from 'common/Types';
|
||||
import { getRangeLength } from 'common/buffer/BufferRange';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
@@ -1021,7 +1021,7 @@ export class SelectionService extends Disposable implements ISelectionService {
|
||||
* word logic.
|
||||
* @param cell The cell to check.
|
||||
*/
|
||||
private _isCharWordSeparator(cell: CellData): boolean {
|
||||
private _isCharWordSeparator(cell: ICellData): boolean {
|
||||
// Zero width characters are never separators as they are always to the
|
||||
// right of wide characters
|
||||
if (cell.getWidth() === 0) {
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
],
|
||||
"baseUrl": "..",
|
||||
"paths": {
|
||||
"common/*": [ "./common/*" ],
|
||||
"vs/*": [ "./vs/*" ]
|
||||
"common/*": [ "./common/*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -20,7 +19,6 @@
|
||||
"../../typings/xterm.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../common" },
|
||||
{ "path": "../vs" }
|
||||
{ "path": "../common" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*
|
||||
* Minimal async helpers for xterm.js core.
|
||||
*/
|
||||
|
||||
import { DisposableStore, IDisposable, toDisposable } from 'common/Lifecycle';
|
||||
|
||||
export function timeout(millis: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, millis));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a timeout that can be disposed using its returned value.
|
||||
* @param handler The timeout handler.
|
||||
* @param timeout An optional timeout in milliseconds.
|
||||
* @param store An optional {@link DisposableStore} that will have the timeout disposable managed
|
||||
* automatically.
|
||||
*/
|
||||
export function disposableTimeout(handler: () => void, timeout = 0, store?: DisposableStore): IDisposable {
|
||||
const timer = setTimeout(() => {
|
||||
handler();
|
||||
if (store) {
|
||||
disposable.dispose();
|
||||
}
|
||||
}, timeout);
|
||||
const disposable = toDisposable(() => {
|
||||
clearTimeout(timer);
|
||||
});
|
||||
store?.add(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
export class TimeoutTimer implements IDisposable {
|
||||
private _token: any = -1;
|
||||
private _isDisposed = false;
|
||||
|
||||
public dispose(): void {
|
||||
this.cancel();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
|
||||
public cancel(): void {
|
||||
if (this._token !== -1) {
|
||||
clearTimeout(this._token);
|
||||
this._token = -1;
|
||||
}
|
||||
}
|
||||
|
||||
public cancelAndSet(runner: () => void, timeout: number): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling cancelAndSet on a disposed TimeoutTimer');
|
||||
}
|
||||
this.cancel();
|
||||
this._token = setTimeout(() => {
|
||||
this._token = -1;
|
||||
runner();
|
||||
}, timeout);
|
||||
}
|
||||
|
||||
public setIfNotSet(runner: () => void, timeout: number): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling setIfNotSet on a disposed TimeoutTimer');
|
||||
}
|
||||
if (this._token !== -1) {
|
||||
return;
|
||||
}
|
||||
this._token = setTimeout(() => {
|
||||
this._token = -1;
|
||||
runner();
|
||||
}, timeout);
|
||||
}
|
||||
}
|
||||
|
||||
export class IntervalTimer implements IDisposable {
|
||||
private _disposable: IDisposable | undefined;
|
||||
private _isDisposed = false;
|
||||
|
||||
public cancel(): void {
|
||||
this._disposable?.dispose();
|
||||
this._disposable = undefined;
|
||||
}
|
||||
|
||||
public cancelAndSet(runner: () => void, interval: number, context: Window | typeof globalThis = globalThis): void {
|
||||
if (this._isDisposed) {
|
||||
throw new Error('Calling cancelAndSet on a disposed IntervalTimer');
|
||||
}
|
||||
this.cancel();
|
||||
const handle = context.setInterval(() => {
|
||||
runner();
|
||||
}, interval);
|
||||
this._disposable = {
|
||||
dispose: () => {
|
||||
context.clearInterval(handle as any);
|
||||
this._disposable = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.cancel();
|
||||
this._isDisposed = true;
|
||||
}
|
||||
}
|
||||
@@ -107,10 +107,10 @@ export abstract class CoreTerminal extends Disposable implements ICoreTerminal {
|
||||
this._instantiationService = new InstantiationService();
|
||||
this.optionsService = this._register(new OptionsService(options));
|
||||
this._instantiationService.setService(IOptionsService, this.optionsService);
|
||||
this._bufferService = this._register(this._instantiationService.createInstance(BufferService));
|
||||
this._instantiationService.setService(IBufferService, this._bufferService);
|
||||
this._logService = this._register(this._instantiationService.createInstance(LogService));
|
||||
this._instantiationService.setService(ILogService, this._logService);
|
||||
this._bufferService = this._register(this._instantiationService.createInstance(BufferService));
|
||||
this._instantiationService.setService(IBufferService, this._bufferService);
|
||||
this.coreService = this._register(this._instantiationService.createInstance(CoreService));
|
||||
this._instantiationService.setService(ICoreService, this.coreService);
|
||||
this.coreMouseService = this._register(this._instantiationService.createInstance(CoreMouseService));
|
||||
|
||||
+4
-2
@@ -109,8 +109,10 @@ export namespace EventUtils {
|
||||
};
|
||||
}
|
||||
|
||||
export function runAndSubscribe<T>(event: IEvent<T>, handler: (e: T | undefined) => void): IDisposable {
|
||||
handler(undefined);
|
||||
export function runAndSubscribe<T>(event: IEvent<T>, handler: (e: T) => void, initial: T): IDisposable;
|
||||
export function runAndSubscribe<T>(event: IEvent<T>, handler: (e: T | undefined) => void): IDisposable;
|
||||
export function runAndSubscribe<T>(event: IEvent<T>, handler: (e: T | undefined) => void, initial?: T): IDisposable {
|
||||
handler(initial);
|
||||
return event(e => handler(e));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,7 +65,7 @@ describe('InputHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService();
|
||||
bufferService = new BufferService(optionsService);
|
||||
bufferService = new BufferService(optionsService, new MockLogService());
|
||||
bufferService.resize(80, 30);
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
oscLinkService = new OscLinkService(bufferService);
|
||||
@@ -2458,7 +2458,7 @@ describe('InputHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService({ vtExtensions: { kittyKeyboard: true } });
|
||||
bufferService = new BufferService(optionsService);
|
||||
bufferService = new BufferService(optionsService, new MockLogService());
|
||||
bufferService.resize(80, 30);
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
inputHandler = new TestInputHandler(bufferService, new MockCharsetService(), coreService, new MockLogService(), optionsService, new MockOscLinkService(), new MockCoreMouseService(), new MockUnicodeService());
|
||||
@@ -2508,7 +2508,7 @@ describe('InputHandler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService();
|
||||
bufferService = new BufferService(optionsService);
|
||||
bufferService = new BufferService(optionsService, new MockLogService());
|
||||
bufferService.resize(80, 30);
|
||||
coreService = new CoreService(bufferService, new MockLogService(), optionsService);
|
||||
coreService.onData(data => { console.log(data); });
|
||||
|
||||
+20
-7
@@ -14,17 +14,21 @@ export function toDisposable(fn: () => void): IDisposable {
|
||||
return { dispose: fn };
|
||||
}
|
||||
|
||||
export function dispose<T extends IDisposable>(disposables: T | T[] | undefined): void {
|
||||
if (!disposables) {
|
||||
return;
|
||||
export function dispose<T extends IDisposable>(disposable: T): T;
|
||||
export function dispose<T extends IDisposable>(disposable: T | undefined): T | undefined;
|
||||
export function dispose<T extends IDisposable>(disposables: T[]): T[];
|
||||
export function dispose<T extends IDisposable>(arg: T | T[] | undefined): T | T[] | undefined {
|
||||
if (!arg) {
|
||||
return arg;
|
||||
}
|
||||
if (Array.isArray(disposables)) {
|
||||
for (const d of disposables) {
|
||||
if (Array.isArray(arg)) {
|
||||
for (const d of arg) {
|
||||
d.dispose();
|
||||
}
|
||||
} else {
|
||||
disposables.dispose();
|
||||
return [];
|
||||
}
|
||||
arg.dispose();
|
||||
return arg;
|
||||
}
|
||||
|
||||
export function combinedDisposable(...disposables: IDisposable[]): IDisposable {
|
||||
@@ -58,9 +62,18 @@ export class DisposableStore implements IDisposable {
|
||||
}
|
||||
this._disposables.clear();
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
for (const d of this._disposables) {
|
||||
d.dispose();
|
||||
}
|
||||
this._disposables.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Disposable implements IDisposable {
|
||||
public static readonly None: IDisposable = Object.freeze({ dispose() { } });
|
||||
|
||||
protected readonly _store = new DisposableStore();
|
||||
|
||||
public dispose(): void {
|
||||
|
||||
@@ -22,8 +22,17 @@ const userAgent = (isNode) ? 'node' : navigator.userAgent;
|
||||
const platform = (isNode) ? 'node' : navigator.platform;
|
||||
|
||||
export const isFirefox = userAgent.includes('Firefox');
|
||||
export const isChrome = userAgent.includes('Chrome');
|
||||
export const isLegacyEdge = userAgent.includes('Edge');
|
||||
export const isSafari = /^((?!chrome|android).)*safari/i.test(userAgent);
|
||||
|
||||
interface IZoomWindow {
|
||||
devicePixelRatio?: number;
|
||||
}
|
||||
|
||||
export function getZoomFactor(_targetWindow: IZoomWindow): number {
|
||||
return 1;
|
||||
}
|
||||
export function getSafariVersion(): number {
|
||||
if (!isSafari) {
|
||||
return 0;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { assert } from 'chai';
|
||||
import { SortedList } from 'common/SortedList';
|
||||
import { MockLogService } from 'common/TestUtils.test';
|
||||
|
||||
const deepStrictEqual = assert.deepStrictEqual;
|
||||
|
||||
@@ -15,7 +16,7 @@ describe('SortedList', () => {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
list = new SortedList<number>(e => e);
|
||||
list = new SortedList<number>(e => e, new MockLogService());
|
||||
});
|
||||
|
||||
describe('insert', () => {
|
||||
@@ -90,7 +91,7 @@ describe('SortedList', () => {
|
||||
assertList([]);
|
||||
});
|
||||
it('custom key', () => {
|
||||
const customList = new SortedList<{ key: number }>(e => e.key);
|
||||
const customList = new SortedList<{ key: number }>(e => e.key, new MockLogService());
|
||||
customList.insert({ key: 5 });
|
||||
customList.insert({ key: 2 });
|
||||
customList.insert({ key: 10 });
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { IdleTaskQueue } from 'common/TaskQueue';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
// Work variables to avoid garbage collection.
|
||||
let i = 0;
|
||||
@@ -18,16 +19,19 @@ export class SortedList<T> {
|
||||
private _array: T[] = [];
|
||||
|
||||
private readonly _insertedValues: T[] = [];
|
||||
private readonly _flushInsertedTask = new IdleTaskQueue();
|
||||
private readonly _flushInsertedTask: InstanceType<typeof IdleTaskQueue>;
|
||||
private _isFlushingInserted = false;
|
||||
|
||||
private readonly _deletedIndices: number[] = [];
|
||||
private readonly _flushDeletedTask = new IdleTaskQueue();
|
||||
private readonly _flushDeletedTask: InstanceType<typeof IdleTaskQueue>;
|
||||
private _isFlushingDeleted = false;
|
||||
|
||||
constructor(
|
||||
private readonly _getKey: (value: T) => number
|
||||
private readonly _getKey: (value: T) => number,
|
||||
logService: ILogService
|
||||
) {
|
||||
this._flushInsertedTask = new IdleTaskQueue(logService);
|
||||
this._flushDeletedTask = new IdleTaskQueue(logService);
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
|
||||
import { isNode } from 'common/Platform';
|
||||
import type { ILogService } from 'common/services/Services';
|
||||
|
||||
interface ITaskQueue {
|
||||
/**
|
||||
@@ -34,6 +35,11 @@ abstract class TaskQueue implements ITaskQueue {
|
||||
private _tasks: (() => boolean | void)[] = [];
|
||||
private _idleCallback?: number;
|
||||
private _i = 0;
|
||||
protected readonly _logService: ILogService;
|
||||
|
||||
constructor(logService: ILogService) {
|
||||
this._logService = logService;
|
||||
}
|
||||
|
||||
protected abstract _requestCallback(callback: CallbackWithDeadline): number;
|
||||
protected abstract _cancelCallback(identifier: number): void;
|
||||
@@ -90,7 +96,7 @@ abstract class TaskQueue implements ITaskQueue {
|
||||
// Warn when the time exceeding the deadline is over 20ms, if this happens in practice the
|
||||
// task should be split into sub-tasks to ensure the UI remains responsive.
|
||||
if (lastDeadlineRemaining - taskDuration < -20) {
|
||||
console.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`);
|
||||
this._logService.warn(`task queue exceeded allotted deadline by ${Math.abs(Math.round(lastDeadlineRemaining - taskDuration))}ms`);
|
||||
}
|
||||
this._start();
|
||||
return;
|
||||
@@ -151,8 +157,8 @@ export const IdleTaskQueue = (!isNode && 'requestIdleCallback' in window) ? Idle
|
||||
export class DebouncedIdleTask {
|
||||
private _queue: ITaskQueue;
|
||||
|
||||
constructor() {
|
||||
this._queue = new IdleTaskQueue();
|
||||
constructor(logService: ILogService) {
|
||||
this._queue = new IdleTaskQueue(logService);
|
||||
}
|
||||
|
||||
public set(task: () => boolean | void): void {
|
||||
|
||||
@@ -27,7 +27,7 @@ export class MockBufferService implements IBufferService {
|
||||
public rows: number,
|
||||
optionsService: IOptionsService = new MockOptionsService()
|
||||
) {
|
||||
this.buffers = new BufferSet(optionsService, this);
|
||||
this.buffers = new BufferSet(optionsService, this, new MockLogService());
|
||||
// Listen to buffer activation events and automatically fire scroll events
|
||||
this.buffers.onBufferActivate(e => {
|
||||
this._onScroll.fire(e.activeBuffer.ydisp);
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { assert } from 'chai';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { CircularList } from 'common/CircularList';
|
||||
import { MockOptionsService, MockBufferService } from 'common/TestUtils.test';
|
||||
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
@@ -23,7 +23,7 @@ describe('Buffer', () => {
|
||||
beforeEach(() => {
|
||||
optionsService = new MockOptionsService({ scrollback: INIT_SCROLLBACK });
|
||||
bufferService = new MockBufferService(INIT_COLS, INIT_ROWS);
|
||||
buffer = new Buffer(true, optionsService, bufferService);
|
||||
buffer = new Buffer(true, optionsService, bufferService, new MockLogService());
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -151,7 +151,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('no scrollback', () => {
|
||||
it('should trim from the top of the buffer when the cursor reaches the bottom', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService);
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
buffer.y = INIT_ROWS - 1;
|
||||
buffer.fillViewportRows();
|
||||
@@ -1054,7 +1054,7 @@ describe('Buffer', () => {
|
||||
describe('buffer marked to have no scrollback', () => {
|
||||
it('should always have a scrollback of 0', () => {
|
||||
// Test size on initialization
|
||||
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService);
|
||||
buffer = new Buffer(false, new MockOptionsService({ scrollback: 1000 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.lines.maxLength, INIT_ROWS);
|
||||
// Test size on buffer increase
|
||||
@@ -1068,7 +1068,7 @@ describe('Buffer', () => {
|
||||
|
||||
describe('addMarker', () => {
|
||||
it('should adjust a marker line when the buffer is trimmed', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService);
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
const marker = buffer.addMarker(buffer.lines.length - 1);
|
||||
assert.equal(marker.line, buffer.lines.length - 1);
|
||||
@@ -1076,7 +1076,7 @@ describe('Buffer', () => {
|
||||
assert.equal(marker.line, buffer.lines.length - 2);
|
||||
});
|
||||
it('should dispose of a marker if it is trimmed off the buffer', () => {
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService);
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
@@ -1088,7 +1088,7 @@ describe('Buffer', () => {
|
||||
});
|
||||
it('should call onDispose', () => {
|
||||
const eventStack: string[] = [];
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService);
|
||||
buffer = new Buffer(true, new MockOptionsService({ scrollback: 0 }), bufferService, new MockLogService());
|
||||
buffer.fillViewportRows();
|
||||
assert.equal(buffer.markers.length, 0);
|
||||
const marker = buffer.addMarker(0);
|
||||
|
||||
@@ -14,7 +14,7 @@ import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR,
|
||||
import { Marker } from 'common/buffer/Marker';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
import { DEFAULT_CHARSET } from 'common/data/Charsets';
|
||||
import { IBufferService, IOptionsService } from 'common/services/Services';
|
||||
import { IBufferService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
|
||||
export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
|
||||
|
||||
@@ -48,11 +48,14 @@ export class Buffer implements IBuffer {
|
||||
private _cols: number;
|
||||
private _rows: number;
|
||||
private _isClearing: boolean = false;
|
||||
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
|
||||
private _memoryCleanupPosition = 0;
|
||||
|
||||
constructor(
|
||||
private _hasScrollback: boolean,
|
||||
private _optionsService: IOptionsService,
|
||||
private _bufferService: IBufferService
|
||||
private _bufferService: IBufferService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
this._cols = this._bufferService.cols;
|
||||
this._rows = this._bufferService.rows;
|
||||
@@ -60,6 +63,7 @@ export class Buffer implements IBuffer {
|
||||
this.scrollTop = 0;
|
||||
this.scrollBottom = this._rows - 1;
|
||||
this.setupTabStops();
|
||||
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
|
||||
}
|
||||
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
@@ -277,9 +281,6 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
private _memoryCleanupQueue = new IdleTaskQueue();
|
||||
private _memoryCleanupPosition = 0;
|
||||
|
||||
private _batchedMemoryCleanup(): boolean {
|
||||
let normalRun = true;
|
||||
if (this._memoryCleanupPosition >= this.lines.length) {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { assert } from 'chai';
|
||||
import { BufferSet } from 'common/buffer/BufferSet';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { MockOptionsService, MockBufferService } from 'common/TestUtils.test';
|
||||
import { MockOptionsService, MockBufferService, MockLogService } from 'common/TestUtils.test';
|
||||
|
||||
describe('BufferSet', () => {
|
||||
let bufferSet: BufferSet;
|
||||
@@ -14,7 +14,8 @@ describe('BufferSet', () => {
|
||||
beforeEach(() => {
|
||||
bufferSet = new BufferSet(
|
||||
new MockOptionsService({ scrollback: 1000 }),
|
||||
new MockBufferService(80, 24)
|
||||
new MockBufferService(80, 24),
|
||||
new MockLogService()
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle';
|
||||
import { IAttributeData } from 'common/Types';
|
||||
import { Buffer } from 'common/buffer/Buffer';
|
||||
import { IBuffer, IBufferSet } from 'common/buffer/Types';
|
||||
import { IBufferService, IOptionsService } from 'common/services/Services';
|
||||
import { IBufferService, ILogService, IOptionsService } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
/**
|
||||
@@ -27,7 +27,8 @@ export class BufferSet extends Disposable implements IBufferSet {
|
||||
*/
|
||||
constructor(
|
||||
private readonly _optionsService: IOptionsService,
|
||||
private readonly _bufferService: IBufferService
|
||||
private readonly _bufferService: IBufferService,
|
||||
private readonly _logService: ILogService
|
||||
) {
|
||||
super();
|
||||
this.reset();
|
||||
@@ -36,12 +37,12 @@ export class BufferSet extends Disposable implements IBufferSet {
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._normal = new Buffer(true, this._optionsService, this._bufferService);
|
||||
this._normal = new Buffer(true, this._optionsService, this._bufferService, this._logService);
|
||||
this._normal.fillViewportRows();
|
||||
|
||||
// The alt buffer should never have scrollback.
|
||||
// See http://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-The-Alternate-Screen-Buffer
|
||||
this._alt = new Buffer(false, this._optionsService, this._bufferService);
|
||||
this._alt = new Buffer(false, this._optionsService, this._bufferService, this._logService);
|
||||
this._activeBuffer = this._normal;
|
||||
this._onBufferActivate.fire({
|
||||
activeBuffer: this._normal,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
import { Attributes, BgFlags, FgFlags, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { assert } from 'chai';
|
||||
|
||||
function createStyledCell(char: string, underlineStyle: UnderlineStyle, underlineColor: number): CellData {
|
||||
const cell = new CellData();
|
||||
const fg = Attributes.CM_P256 | 12 | FgFlags.BOLD | FgFlags.UNDERLINE;
|
||||
cell.setFromCharData([fg, char, 1, char.charCodeAt(0)]);
|
||||
cell.bg = Attributes.CM_P16 | 2 | BgFlags.ITALIC;
|
||||
cell.extended.underlineStyle = underlineStyle;
|
||||
cell.extended.underlineColor = Attributes.CM_P256 | underlineColor;
|
||||
cell.updateExtended();
|
||||
return cell;
|
||||
}
|
||||
|
||||
describe('CellData', () => {
|
||||
describe('attributesEquals', () => {
|
||||
it('returns true for same attributes with different chars', () => {
|
||||
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
|
||||
const cellB = createStyledCell('B', UnderlineStyle.DOUBLE, 45);
|
||||
|
||||
assert.equal(cellA.attributesEquals(cellB), true);
|
||||
});
|
||||
|
||||
it('detects underline style changes', () => {
|
||||
const cellA = createStyledCell('A', UnderlineStyle.DOUBLE, 45);
|
||||
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
|
||||
|
||||
assert.equal(cellA.attributesEquals(cellB), false);
|
||||
});
|
||||
|
||||
it('detects underline color changes', () => {
|
||||
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
|
||||
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 46);
|
||||
|
||||
assert.equal(cellA.attributesEquals(cellB), false);
|
||||
});
|
||||
|
||||
it('ignores underline variant offsets', () => {
|
||||
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
|
||||
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
|
||||
cellA.extended.underlineVariantOffset = 1;
|
||||
cellB.extended.underlineVariantOffset = 3;
|
||||
cellA.updateExtended();
|
||||
cellB.updateExtended();
|
||||
|
||||
assert.equal(cellA.attributesEquals(cellB), true);
|
||||
});
|
||||
|
||||
it('ignores url ids', () => {
|
||||
const cellA = createStyledCell('A', UnderlineStyle.SINGLE, 45);
|
||||
const cellB = createStyledCell('B', UnderlineStyle.SINGLE, 45);
|
||||
cellA.extended.urlId = 1;
|
||||
cellB.extended.urlId = 2;
|
||||
cellA.updateExtended();
|
||||
cellB.updateExtended();
|
||||
|
||||
assert.equal(cellA.attributesEquals(cellB), true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import { CharData, ICellData, IExtendedAttrs } from 'common/Types';
|
||||
import { stringFromCodePoint } from 'common/input/TextDecoder';
|
||||
import { CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, CHAR_DATA_ATTR_INDEX, Content } from 'common/buffer/Constants';
|
||||
import { AttributeData, ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
import type { IBufferCell as IBufferCellApi } from '@xterm/xterm';
|
||||
|
||||
/**
|
||||
* CellData - represents a single Cell in the terminal buffer.
|
||||
@@ -91,4 +92,60 @@ export class CellData extends AttributeData implements ICellData {
|
||||
public getAsCharData(): CharData {
|
||||
return [this.fg, this.getChars(), this.getWidth(), this.getCode()];
|
||||
}
|
||||
|
||||
public attributesEquals(other: IBufferCellApi): boolean {
|
||||
if (this.getFgColorMode() !== other.getFgColorMode() || this.getFgColor() !== other.getFgColor()) {
|
||||
return false;
|
||||
}
|
||||
if (this.getBgColorMode() !== other.getBgColorMode() || this.getBgColor() !== other.getBgColor()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isInverse() !== other.isInverse()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isBold() !== other.isBold()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isUnderline() !== other.isUnderline()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isUnderline()) {
|
||||
if (this.getUnderlineStyle() !== other.getUnderlineStyle()) {
|
||||
return false;
|
||||
}
|
||||
const thisDefault = this.isUnderlineColorDefault();
|
||||
const otherDefault = other.isUnderlineColorDefault();
|
||||
if (!(thisDefault && otherDefault)) {
|
||||
if (thisDefault !== otherDefault) {
|
||||
return false;
|
||||
}
|
||||
if (this.getUnderlineColor() !== other.getUnderlineColor()) {
|
||||
return false;
|
||||
}
|
||||
if (this.getUnderlineColorMode() !== other.getUnderlineColorMode()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.isOverline() !== other.isOverline()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isBlink() !== other.isBlink()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isInvisible() !== other.isInvisible()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isItalic() !== other.isItalic()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isDim() !== other.isDim()) {
|
||||
return false;
|
||||
}
|
||||
if (this.isStrikethrough() !== other.isStrikethrough()) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -474,11 +474,25 @@ describe('KittyKeyboard', () => {
|
||||
});
|
||||
|
||||
it('modifier key release includes its own bit cleared', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE);
|
||||
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags | KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, '\x1b[57441;1:3u');
|
||||
});
|
||||
});
|
||||
|
||||
describe('modifier-only reporting', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_EVENT_TYPES;
|
||||
|
||||
it('does not report modifier press without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: true }), flags);
|
||||
assert.strictEqual(result.key, undefined);
|
||||
});
|
||||
|
||||
it('does not report modifier release without REPORT_ALL_KEYS_AS_ESCAPE_CODES', () => {
|
||||
const result = kitty.evaluate(createEvent({ key: 'Shift', code: 'ShiftLeft', shiftKey: false }), flags, KittyKeyboardEventType.RELEASE);
|
||||
assert.strictEqual(result.key, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('REPORT_ALL_KEYS_AS_ESCAPE_CODES flag', () => {
|
||||
const flags = KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES;
|
||||
|
||||
|
||||
@@ -418,7 +418,7 @@ export class KittyKeyboard {
|
||||
return result;
|
||||
}
|
||||
|
||||
if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES) && !reportEventTypes) {
|
||||
if (isMod && !(flags & KittyKeyboardFlags.REPORT_ALL_KEYS_AS_ESCAPE_CODES)) {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,10 +18,10 @@ export class BufferLineApiView implements IBufferLineApi {
|
||||
}
|
||||
|
||||
if (cell) {
|
||||
this._line.loadCell(x, cell as ICellData);
|
||||
this._line.loadCell(x, cell as unknown as ICellData);
|
||||
return cell;
|
||||
}
|
||||
return this._line.loadCell(x, new CellData());
|
||||
return this._line.loadCell(x, new CellData()) as unknown as IBufferCellApi;
|
||||
}
|
||||
public translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string {
|
||||
return this._line.translateToString(trimRight, startColumn, endColumn);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Disposable } from 'common/Lifecycle';
|
||||
import { IAttributeData, IBufferLine } from 'common/Types';
|
||||
import { BufferSet } from 'common/buffer/BufferSet';
|
||||
import { IBuffer, IBufferSet } from 'common/buffer/Types';
|
||||
import { IBufferService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { IBufferService, ILogService, IOptionsService, type IBufferResizeEvent } from 'common/services/Services';
|
||||
import { Emitter } from 'common/Event';
|
||||
|
||||
export const MINIMUM_COLS = 2; // Less than 2 can mess with wide chars
|
||||
@@ -32,11 +32,14 @@ export class BufferService extends Disposable implements IBufferService {
|
||||
/** An IBufferline to clone/copy from for new blank lines */
|
||||
private _cachedBlankLine: IBufferLine | undefined;
|
||||
|
||||
constructor(@IOptionsService optionsService: IOptionsService) {
|
||||
constructor(
|
||||
@IOptionsService optionsService: IOptionsService,
|
||||
@ILogService logService: ILogService
|
||||
) {
|
||||
super();
|
||||
this.cols = Math.max(optionsService.rawOptions.cols || 0, MINIMUM_COLS);
|
||||
this.rows = Math.max(optionsService.rawOptions.rows || 0, MINIMUM_ROWS);
|
||||
this.buffers = this._register(new BufferSet(optionsService, this));
|
||||
this.buffers = this._register(new BufferSet(optionsService, this, logService));
|
||||
this._register(this.buffers.onBufferActivate(e => {
|
||||
this._onScroll.fire(e.activeBuffer.ydisp);
|
||||
}));
|
||||
|
||||
@@ -8,6 +8,7 @@ import { DecorationService } from './DecorationService';
|
||||
import { IMarker } from 'common/Types';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { MockLogService } from 'common/TestUtils.test';
|
||||
|
||||
function createFakeMarker(line: number): IMarker {
|
||||
return Object.freeze(new class extends Disposable {
|
||||
@@ -22,7 +23,7 @@ const fakeMarker: IMarker = createFakeMarker(1);
|
||||
|
||||
describe('DecorationService', () => {
|
||||
it('should set isDisposed to true after dispose', () => {
|
||||
const service = new DecorationService();
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const decoration = service.registerDecoration({
|
||||
marker: fakeMarker
|
||||
});
|
||||
@@ -34,7 +35,7 @@ describe('DecorationService', () => {
|
||||
|
||||
describe('forEachDecorationAtCell', () => {
|
||||
it('should find decoration at its marker line', () => {
|
||||
const service = new DecorationService();
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10
|
||||
@@ -47,7 +48,7 @@ describe('DecorationService', () => {
|
||||
});
|
||||
|
||||
it('should find decoration with height > 1 on subsequent lines', () => {
|
||||
const service = new DecorationService();
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10,
|
||||
@@ -73,7 +74,7 @@ describe('DecorationService', () => {
|
||||
});
|
||||
|
||||
it('should not find decoration outside its x range', () => {
|
||||
const service = new DecorationService();
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
x: 5,
|
||||
@@ -102,7 +103,7 @@ describe('DecorationService', () => {
|
||||
|
||||
describe('getDecorationsAtCell', () => {
|
||||
it('should find decoration with height > 1 on subsequent lines', () => {
|
||||
const service = new DecorationService();
|
||||
const service = new DecorationService(new MockLogService());
|
||||
const decoration = service.registerDecoration({
|
||||
marker: createFakeMarker(5),
|
||||
width: 10,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user