Compare commits
48
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee2da690d9 | ||
|
|
b4529f317b | ||
|
|
e14746e6e9 | ||
|
|
82333b9414 | ||
|
|
9eeb2799c1 | ||
|
|
5630471480 | ||
|
|
e0c59e68b2 | ||
|
|
cc8e680790 | ||
|
|
452ee50c81 | ||
|
|
f41e53a0f0 | ||
|
|
b287397c4f | ||
|
|
aebbe81252 | ||
|
|
d155bb6767 | ||
|
|
3a147e8e10 | ||
|
|
c2db7d4c8a | ||
|
|
456bc73bae | ||
|
|
2e776c0820 | ||
|
|
6f163beb97 | ||
|
|
b0d0bf8187 | ||
|
|
32e77c3b81 | ||
|
|
cba6e3d191 | ||
|
|
5741e450e6 | ||
|
|
6ef653e589 | ||
|
|
a4c0e4690e | ||
|
|
a31f78bc8b | ||
|
|
ca8e8546b5 | ||
|
|
3d473b52b6 | ||
|
|
a2bec2aeab | ||
|
|
205cf6089d | ||
|
|
8b0a6f9785 | ||
|
|
db0226be5c | ||
|
|
ab5c06cf17 | ||
|
|
9a778f0c98 | ||
|
|
c102b4af50 | ||
|
|
f3a7cac49f | ||
|
|
0d2a58c9d0 | ||
|
|
8a00898533 | ||
|
|
edb0e1ceb3 | ||
|
|
93c1650446 | ||
|
|
77695cb405 | ||
|
|
dc4ebfd6e3 | ||
|
|
fae6f03f90 | ||
|
|
d36af624cf | ||
|
|
a105d3e457 | ||
|
|
d5900c7eae | ||
|
|
beebe8b5dc | ||
|
|
f666135881 | ||
|
|
eea03cf0a9 |
@@ -0,0 +1,3 @@
|
||||
[submodule "vendor/ghostty"]
|
||||
path = vendor/ghostty
|
||||
url = https://github.com/ghostty-org/ghostty
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -64,6 +64,9 @@ export class SearchEngine {
|
||||
throw new Error(`Invalid col: ${startCol} to search in terminal of ${this._terminal.cols} cols`);
|
||||
}
|
||||
|
||||
startRow = Math.max(0, startRow);
|
||||
startCol = Math.max(0, startCol);
|
||||
|
||||
this._lineCache.initLinesCache();
|
||||
|
||||
const searchPosition: ISearchPosition = {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
import { copyFileSync, mkdirSync, renameSync } from 'fs';
|
||||
import path from 'path';
|
||||
import process from 'process';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const ghosttyDir = path.join(repoRoot, 'vendor', 'ghostty');
|
||||
const wasmOutDir = path.join(repoRoot, 'vendor', 'ghostty-wasm');
|
||||
const wasmBinDir = path.join(wasmOutDir, 'bin');
|
||||
const wasmFile = path.join(wasmOutDir, 'ghostty-vt.wasm');
|
||||
const wasmBinFile = path.join(wasmBinDir, 'ghostty-vt.wasm');
|
||||
|
||||
mkdirSync(wasmOutDir, { recursive: true });
|
||||
|
||||
const result = spawnSync('zig', [
|
||||
'build',
|
||||
'lib-vt',
|
||||
'-Dtarget=wasm32-freestanding',
|
||||
'-Doptimize=ReleaseSmall',
|
||||
'-p',
|
||||
path.relative(ghosttyDir, wasmOutDir)
|
||||
], {
|
||||
cwd: ghosttyDir,
|
||||
stdio: 'inherit',
|
||||
shell: true
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
|
||||
try {
|
||||
renameSync(wasmBinFile, wasmFile);
|
||||
} catch {
|
||||
copyFileSync(wasmBinFile, wasmFile);
|
||||
}
|
||||
+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/'
|
||||
};
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
const cp = require('child_process');
|
||||
const path = require('path');
|
||||
|
||||
globalThis.require = require;
|
||||
globalThis.XTERM_GHOSTTY_WASM_URL = path.resolve(__dirname, '../vendor/ghostty-wasm/ghostty-vt.wasm');
|
||||
|
||||
const COVERAGE_LINES_THRESHOLD = 40;
|
||||
|
||||
// Add `out` to the NODE_PATH so absolute paths can be resolved.
|
||||
|
||||
+24
-11
@@ -218,17 +218,30 @@
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
background-color: var(--vscode-scrollbarSliderBackground, rgba(100, 100, 100, 0.4));
|
||||
mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>");
|
||||
-webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 11 11'><path d='M2.5 8.5 L5.5 2.5 L8.5 8.5 Z' fill='black'/></svg>");
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-position: center center;
|
||||
-webkit-mask-position: center center;
|
||||
mask-size: 100% 100%;
|
||||
-webkit-mask-size: 100% 100%;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
.xterm .xterm-scrollable-element > .xterm-scrollbar > .xterm-scra.xterm-arrow-down {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .xterm-visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
@@ -238,20 +251,20 @@
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
.xterm .xterm-scrollable-element > .xterm-invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
.xterm .xterm-scrollable-element > .xterm-invisible.xterm-fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
@@ -259,7 +272,7 @@
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
@@ -267,13 +280,13 @@
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
.xterm .xterm-scrollable-element > .xterm-shadow.xterm-shadow-top.xterm-shadow-left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
|
||||
@@ -284,6 +284,7 @@ function createTerminal(): Terminal {
|
||||
|
||||
const isWindows = ['Windows', 'Win16', 'Win32', 'WinCE'].indexOf(navigator.platform) >= 0;
|
||||
term = new Terminal({
|
||||
scrollbar: { showScrollbar: true },
|
||||
allowProposedApi: true,
|
||||
windowsPty: isWindows ? {
|
||||
// In a real scenario, these values should be verified on the backend
|
||||
|
||||
@@ -115,17 +115,20 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
'documentOverride',
|
||||
'linkHandler',
|
||||
'logger',
|
||||
'overviewRuler',
|
||||
'quirks',
|
||||
'theme',
|
||||
'vtExtensions',
|
||||
'windowOptions',
|
||||
'windowsPty',
|
||||
];
|
||||
const nestedBooleanOptions: { label: string, parent: string, prop: string }[] = [
|
||||
{ label: 'vtExtensions.kittyKeyboard', parent: 'vtExtensions', prop: 'kittyKeyboard' },
|
||||
{ label: 'vtExtensions.kittySgrBoldFaintControl', parent: 'vtExtensions', prop: 'kittySgrBoldFaintControl' },
|
||||
{ label: 'vtExtensions.win32InputMode', parent: 'vtExtensions', prop: 'win32InputMode' }
|
||||
const nestedBooleanOptions: { label: string, path: string[], prop: string }[] = [
|
||||
{ label: 'scrollbar.showScrollbar', path: ['scrollbar'], prop: 'showScrollbar' },
|
||||
{ label: 'scrollbar.showArrows', path: ['scrollbar'], prop: 'showArrows' },
|
||||
{ label: 'scrollbar.overviewRuler.showTopBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showTopBorder' },
|
||||
{ label: 'scrollbar.overviewRuler.showBottomBorder', path: ['scrollbar', 'overviewRuler'], prop: 'showBottomBorder' },
|
||||
{ label: 'vtExtensions.kittyKeyboard', path: ['vtExtensions'], prop: 'kittyKeyboard' },
|
||||
{ label: 'vtExtensions.kittySgrBoldFaintControl', path: ['vtExtensions'], prop: 'kittySgrBoldFaintControl' },
|
||||
{ label: 'vtExtensions.win32InputMode', path: ['vtExtensions'], prop: 'win32InputMode' }
|
||||
];
|
||||
const stringOptions: { [key: string]: string[] | null } = {
|
||||
cursorStyle: ['block', 'underline', 'bar'],
|
||||
@@ -161,8 +164,10 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
booleanOptions.forEach(o => {
|
||||
html += `<div class="option"><label><input id="opt-${o}" type="checkbox" ${(this._terminal.options as Record<string, unknown>)[o] ? 'checked' : ''}/> ${o}</label></div>`;
|
||||
});
|
||||
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
|
||||
const checked = (this._terminal.options as Record<string, Record<string, unknown> | undefined>)[parent]?.[prop] ?? false;
|
||||
nestedBooleanOptions.forEach(({ label, path, prop }) => {
|
||||
const options = this._terminal.options as Record<string, unknown>;
|
||||
const parent = path.reduce<Record<string, unknown> | undefined>((acc, key) => (acc as Record<string, unknown> | undefined)?.[key] as Record<string, unknown> | undefined, options);
|
||||
const checked = (parent as Record<string, unknown> | undefined)?.[prop] ?? false;
|
||||
html += `<div class="option"><label><input id="opt-${label.replace('.', '-')}" type="checkbox" ${checked ? 'checked' : ''}/> ${label}</label></div>`;
|
||||
});
|
||||
html += '</div><div class="option-group">';
|
||||
@@ -196,11 +201,22 @@ export class OptionsWindow extends BaseWindow implements IControlWindow {
|
||||
}
|
||||
});
|
||||
});
|
||||
nestedBooleanOptions.forEach(({ label, parent, prop }) => {
|
||||
nestedBooleanOptions.forEach(({ label, path, prop }) => {
|
||||
const input = document.getElementById(`opt-${label.replace('.', '-')}`) as HTMLInputElement;
|
||||
addDomListener(input, 'change', () => {
|
||||
console.log('change', label, input.checked);
|
||||
(this._terminal.options as Record<string, unknown>)[parent] = { ...(this._terminal.options as Record<string, Record<string, unknown> | undefined>)[parent], [prop]: input.checked };
|
||||
const options = this._terminal.options as Record<string, unknown>;
|
||||
if (path.length === 1) {
|
||||
const parentKey = path[0];
|
||||
options[parentKey] = { ...(options[parentKey] as Record<string, unknown> | undefined), [prop]: input.checked };
|
||||
return;
|
||||
}
|
||||
if (path.length === 2) {
|
||||
const [parentKey, childKey] = path;
|
||||
const parent = (options[parentKey] as Record<string, unknown> | undefined) ?? {};
|
||||
const child = (parent[childKey] as Record<string, unknown> | undefined) ?? {};
|
||||
options[parentKey] = { ...parent, [childKey]: { ...child, [prop]: input.checked } };
|
||||
}
|
||||
});
|
||||
});
|
||||
numberOptions.forEach(o => {
|
||||
|
||||
@@ -397,6 +397,13 @@ function customGlyphAlignmentHandler(term: Terminal): void {
|
||||
}
|
||||
}
|
||||
|
||||
term.write('\x1b[0mTriangular fill tests:\x1b[36m\n\r');
|
||||
term.write('1FB9C 1FB9D 1FB9E 1FB9F all\n\r');
|
||||
term.write('\u{02592}\u{02592}\u{02592}\u{1FB9C} \u{1FB9D}\u{02592}\u{02592}\u{02592} \u{00020}\u{00020}\u{00020}\u{1FB9E} \u{1FB9F}\u{00020}\u{00020}\u{00020} \u{00020}\u{1FB9E}\u{1FB9F}\u{00020}\n\r');
|
||||
term.write('\u{02592}\u{02592}\u{1FB9C}\u{00020} \u{00020}\u{1FB9D}\u{02592}\u{02592} \u{00020}\u{00020}\u{1FB9E}\u{02592} \u{02592}\u{1FB9F}\u{00020}\u{00020} \u{1FB9E}\u{02592}\u{02592}\u{1FB9F}\n\r');
|
||||
term.write('\u{02592}\u{1FB9C}\u{00020}\u{00020} \u{00020}\u{00020}\u{1FB9D}\u{02592} \u{00020}\u{1FB9E}\u{02592}\u{02592} \u{02592}\u{02592}\u{1FB9F}\u{00020} \u{1FB9D}\u{02592}\u{02592}\u{1FB9C}\n\r');
|
||||
term.write('\u{1FB9C}\u{00020}\u{00020}\u{00020} \u{00020}\u{00020}\u{00020}\u{1FB9D} \u{1FB9E}\u{02592}\u{02592}\u{02592} \u{02592}\u{02592}\u{02592}\u{1FB9F} \u{00020}\u{1FB9D}\u{1FB9C}\u{00020}\n\r');
|
||||
|
||||
term.write('\x1b[0mPowerline alignment tests:\n\r');
|
||||
const powerlineLeftChars = ['\u{E0B2}', '\u{E0B3}', '\u{E0B6}', '\u{E0B7}', '\u{E0BA}', '\u{E0BB}', '\u{E0BE}', '\u{E0BF}', '\u{E0C2}', '\u{E0C3}', '\u{E0C5}', '\u{E0C7}', '\u{E0CA}', '\u{E0D4}'];
|
||||
const powerlineRightChars = ['\u{E0B0}', '\u{E0B1}', '\u{E0B4}', '\u{E0B5}', '\u{E0B8}', '\u{E0B9}', '\u{E0BC}', '\u{E0BD}', '\u{E0C0}', '\u{E0C1}', '\u{E0C4}', '\u{E0C6}', '\u{E0C8}', '\u{E0D2}', '\u{E0CC}', '\u{E0CD}', '\u{E0CE}', '\u{E0CF}', '\u{E0D0}', '\u{E0D1}'];
|
||||
@@ -738,7 +745,7 @@ function loadTestLongLines(term: Terminal, addons: AddonCollection): void {
|
||||
}
|
||||
|
||||
function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
term.options['overviewRuler'] = { width: 14 };
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
const marker = term.registerMarker(1);
|
||||
const decoration = term.registerDecoration({
|
||||
marker,
|
||||
@@ -755,7 +762,7 @@ function addDecoration(term: Terminal, dim: number = 1): void {
|
||||
}
|
||||
|
||||
function addOverviewRuler(term: Terminal): void {
|
||||
term.options['overviewRuler'] = { width: 14 };
|
||||
term.options.scrollbar = { ...(term.options.scrollbar ?? {}), width: 14, overviewRuler: term.options.scrollbar?.overviewRuler ?? {} };
|
||||
term.registerDecoration({ marker: term.registerMarker(1), overviewRulerOptions: { color: '#ef2929' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(3), overviewRulerOptions: { color: '#8ae234' } });
|
||||
term.registerDecoration({ marker: term.registerMarker(5), overviewRulerOptions: { color: '#729fcf' } });
|
||||
|
||||
@@ -46,6 +46,7 @@ function startServer(): void {
|
||||
});
|
||||
|
||||
app.use('/fonts', express.static(demoRoot + '/fonts'));
|
||||
app.use('/vendor/ghostty-wasm', express.static(demoRoot + '/../vendor/ghostty-wasm'));
|
||||
|
||||
app.use('/dist', express.static(demoRoot + '/dist'));
|
||||
app.use('/src', express.static(demoRoot + '/src'));
|
||||
|
||||
@@ -10,7 +10,6 @@ export default tseslint.config(
|
||||
{
|
||||
ignores: [
|
||||
'addons/*/src/third-party/*.ts',
|
||||
'src/vs/*',
|
||||
'**/out/*',
|
||||
'**/out-test/*',
|
||||
'**/out-esbuild/*',
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
"esbuild-demo-client-watch": "node bin/esbuild.mjs --demo-client --watch",
|
||||
"esbuild-demo-server": "node bin/esbuild.mjs --demo-server",
|
||||
"esbuild-demo-server-watch": "node bin/esbuild.mjs --demo-server --watch",
|
||||
"ghostty-wasm": "node bin/build_ghostty_wasm.mjs",
|
||||
"test": "npm run test-unit",
|
||||
"posttest": "npm run lint",
|
||||
"lint": "eslint --max-warnings 0 src/ addons/ demo/ test/",
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -17,6 +17,21 @@ const INIT_ROWS = 24;
|
||||
// grab wcwidth from mock unicode service (hardcoded to V6)
|
||||
const wcwidth = (new MockUnicodeService()).wcwidth;
|
||||
|
||||
function assertLineEqual(actual: any, expected: any): void {
|
||||
assert.isOk(actual);
|
||||
assert.isOk(expected);
|
||||
assert.equal(actual.length, expected.length);
|
||||
assert.equal(actual.isWrapped, expected.isWrapped);
|
||||
const actualCell = new CellData();
|
||||
const expectedCell = new CellData();
|
||||
for (let i = 0; i < actual.length; i++) {
|
||||
assert.deepEqual(
|
||||
actual.loadCell(i, actualCell).getAsCharData(),
|
||||
expected.loadCell(i, expectedCell).getAsCharData()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Terminal', () => {
|
||||
let term: TestTerminal;
|
||||
const termOptions = {
|
||||
@@ -183,9 +198,9 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.ybase, 0);
|
||||
assert.equal(term.buffer.ydisp, 0);
|
||||
assert.equal(term.buffer.lines.length, term.rows);
|
||||
assert.deepEqual(term.buffer.lines.get(0), promptLine);
|
||||
assertLineEqual(term.buffer.lines.get(0), promptLine);
|
||||
for (let i = 1; i < term.rows; i++) {
|
||||
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
}
|
||||
});
|
||||
it('should clear a buffer larger than rows', async () => {
|
||||
@@ -198,9 +213,9 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.ybase, 0);
|
||||
assert.equal(term.buffer.ydisp, 0);
|
||||
assert.equal(term.buffer.lines.length, term.rows);
|
||||
assert.deepEqual(term.buffer.lines.get(0), promptLine);
|
||||
assertLineEqual(term.buffer.lines.get(0), promptLine);
|
||||
for (let i = 1; i < term.rows; i++) {
|
||||
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
}
|
||||
});
|
||||
it('should not break the prompt when cleared twice', () => {
|
||||
@@ -211,9 +226,9 @@ describe('Terminal', () => {
|
||||
assert.equal(term.buffer.ybase, 0);
|
||||
assert.equal(term.buffer.ydisp, 0);
|
||||
assert.equal(term.buffer.lines.length, term.rows);
|
||||
assert.deepEqual(term.buffer.lines.get(0), promptLine);
|
||||
assertLineEqual(term.buffer.lines.get(0), promptLine);
|
||||
for (let i = 1; i < term.rows; i++) {
|
||||
assert.deepEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
assertLineEqual(term.buffer.lines.get(i), term.buffer.getBlankLine(DEFAULT_ATTR_DATA));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+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); });
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
import { IInputHandler, IAttributeData, IDisposable, IWindowOptions, IColorEvent, IParseStack, ColorIndex, ColorRequestType, SpecialColorIndex } from 'common/Types';
|
||||
import { C0, C1 } from 'common/data/EscapeSequences';
|
||||
import { CHARSETS, DEFAULT_CHARSET } from 'common/data/Charsets';
|
||||
import { EscapeSequenceParser } from 'common/parser/EscapeSequenceParser';
|
||||
import { GhosttyWasmParser } from 'common/parser/GhosttyWasmParser';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { StringToUtf32, stringFromCodePoint, Utf8ToUtf32 } from 'common/input/TextDecoder';
|
||||
import { BufferLine, DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { IParsingState, IEscapeSequenceParser, IParams, IFunctionIdentifier } from 'common/parser/Types';
|
||||
import { NULL_CELL_CODE, NULL_CELL_WIDTH, Attributes, FgFlags, BgFlags, Content, UnderlineStyle } from 'common/buffer/Constants';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { AttributeData } from 'common/buffer/AttributeData';
|
||||
import { ICoreService, IBufferService, IOptionsService, ILogService, ICoreMouseService, ICharsetService, IUnicodeService, LogLevelEnum, IOscLinkService } from 'common/services/Services';
|
||||
import { UnicodeService } from 'common/services/UnicodeService';
|
||||
@@ -109,6 +109,7 @@ const SLOW_ASYNC_LIMIT = 5000;
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
let $temp = 0;
|
||||
const $workCell = new CellData();
|
||||
|
||||
/**
|
||||
* The terminal's standard implementation of IInputHandler, this handles all
|
||||
@@ -180,7 +181,7 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
private readonly _oscLinkService: IOscLinkService,
|
||||
private readonly _coreMouseService: ICoreMouseService,
|
||||
private readonly _unicodeService: IUnicodeService,
|
||||
private readonly _parser: IEscapeSequenceParser = new EscapeSequenceParser()
|
||||
private readonly _parser: IEscapeSequenceParser = new GhosttyWasmParser()
|
||||
) {
|
||||
super();
|
||||
this._register(this._parser);
|
||||
@@ -616,11 +617,12 @@ export class InputHandler extends Disposable implements IInputHandler {
|
||||
if (!bufferRow) {
|
||||
return;
|
||||
}
|
||||
if (oldWidth > 0 && bufferRow instanceof BufferLine) {
|
||||
if (oldWidth > 0) {
|
||||
// Combining character widens 1 column to 2.
|
||||
// Move old character to next line.
|
||||
bufferRow.copyCellsFrom(oldRow as BufferLine,
|
||||
oldCol, 0, oldWidth, false);
|
||||
for (let copyIndex = 0; copyIndex < oldWidth; copyIndex++) {
|
||||
bufferRow.setCell(copyIndex, oldRow.loadCell(oldCol + copyIndex, $workCell));
|
||||
}
|
||||
}
|
||||
// clear left over cells to the right
|
||||
while (oldCol < cols) {
|
||||
|
||||
+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);
|
||||
|
||||
@@ -5,8 +5,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,12 +22,11 @@ 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', () => {
|
||||
it('should create a CircularList with max length equal to rows + scrollback, for its lines', () => {
|
||||
assert.instanceOf(buffer.lines, CircularList);
|
||||
assert.equal(buffer.lines.maxLength, bufferService.rows + INIT_SCROLLBACK);
|
||||
});
|
||||
it('should set the Buffer\'s scrollBottom value equal to the terminal\'s rows -1', () => {
|
||||
@@ -151,7 +149,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 +1052,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 +1066,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 +1074,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 +1086,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);
|
||||
@@ -1180,31 +1178,19 @@ describe('Buffer', () => {
|
||||
});
|
||||
|
||||
describe('memory cleanup after shrinking', () => {
|
||||
it('should realign memory from idle task execution', async () => {
|
||||
it('should keep line sizes consistent after shrinking', async () => {
|
||||
buffer.fillViewportRows();
|
||||
|
||||
// shrink more than 2 times to trigger lazy memory cleanup
|
||||
buffer.resize(INIT_COLS / 2 - 1, INIT_ROWS);
|
||||
|
||||
// sync
|
||||
for (let i = 0; i < INIT_ROWS; i++) {
|
||||
const line = buffer.lines.get(i)!;
|
||||
// line memory is still at old size from initialization
|
||||
assert.equal((line as any)._data.buffer.byteLength, INIT_COLS * 3 * 4);
|
||||
// array.length and .length get immediately adjusted
|
||||
assert.equal((line as any)._data.length, (INIT_COLS / 2 - 1) * 3);
|
||||
assert.equal(line.length, INIT_COLS / 2 - 1);
|
||||
assert.equal(line.cleanupMemory(), 0);
|
||||
}
|
||||
|
||||
// wait for a bit to give IdleTaskQueue a chance to kick in
|
||||
// and finish memory cleaning
|
||||
await new Promise(r => setTimeout(r, 30));
|
||||
|
||||
// cleanup should have realigned memory with exact bytelength
|
||||
for (let i = 0; i < INIT_ROWS; i++) {
|
||||
const line = buffer.lines.get(i)!;
|
||||
assert.equal((line as any)._data.buffer.byteLength, (INIT_COLS / 2 - 1) * 3 * 4);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+56
-16
@@ -3,7 +3,7 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { CircularList, IInsertEvent } from 'common/CircularList';
|
||||
import { IInsertEvent } from 'common/CircularList';
|
||||
import { IdleTaskQueue } from 'common/TaskQueue';
|
||||
import { IAttributeData, IBufferLine, ICellData, ICharset } from 'common/Types';
|
||||
import { ExtendedAttrs } from 'common/buffer/AttributeData';
|
||||
@@ -13,11 +13,16 @@ import { CellData } from 'common/buffer/CellData';
|
||||
import { NULL_CELL_CHAR, NULL_CELL_CODE, NULL_CELL_WIDTH, WHITESPACE_CELL_CHAR, WHITESPACE_CELL_CODE, WHITESPACE_CELL_WIDTH } from 'common/buffer/Constants';
|
||||
import { Marker } from 'common/buffer/Marker';
|
||||
import { IBuffer } from 'common/buffer/Types';
|
||||
import { GhosttyCircularList } from 'common/buffer/GhosttyCircularList';
|
||||
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
|
||||
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
|
||||
|
||||
// Work variable to avoid garbage collection during reflow
|
||||
const $workCell = new CellData();
|
||||
|
||||
/**
|
||||
* This class represents a terminal buffer (an internal state of the terminal), where the
|
||||
* following information is stored (in high-level):
|
||||
@@ -26,7 +31,7 @@ export const MAX_BUFFER_SIZE = 4294967295; // 2^32 - 1
|
||||
* - scroll position
|
||||
*/
|
||||
export class Buffer implements IBuffer {
|
||||
public lines: CircularList<IBufferLine>;
|
||||
public lines: GhosttyCircularList;
|
||||
public ydisp: number = 0;
|
||||
public ybase: number = 0;
|
||||
public y: number = 0;
|
||||
@@ -48,18 +53,25 @@ export class Buffer implements IBuffer {
|
||||
private _cols: number;
|
||||
private _rows: number;
|
||||
private _isClearing: boolean = false;
|
||||
private _memoryCleanupQueue: InstanceType<typeof IdleTaskQueue>;
|
||||
private _memoryCleanupPosition = 0;
|
||||
private _ghosttyBuffer: GhosttyWasmBuffer;
|
||||
|
||||
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;
|
||||
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
|
||||
const maxLength = this._getCorrectBufferLength(this._rows);
|
||||
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
|
||||
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
|
||||
this.scrollTop = 0;
|
||||
this.scrollBottom = this._rows - 1;
|
||||
this.setupTabStops();
|
||||
this._memoryCleanupQueue = new IdleTaskQueue(this._logService);
|
||||
}
|
||||
|
||||
public getNullCell(attr?: IAttributeData): ICellData {
|
||||
@@ -138,7 +150,10 @@ export class Buffer implements IBuffer {
|
||||
this.ybase = 0;
|
||||
this.y = 0;
|
||||
this.x = 0;
|
||||
this.lines = new CircularList<IBufferLine>(this._getCorrectBufferLength(this._rows));
|
||||
const maxLength = this._getCorrectBufferLength(this._rows);
|
||||
this._ghosttyBuffer.dispose();
|
||||
this._ghosttyBuffer = new GhosttyWasmBuffer(this._cols, this._rows, maxLength);
|
||||
this.lines = new GhosttyCircularList(this._ghosttyBuffer, maxLength);
|
||||
this.scrollTop = 0;
|
||||
this.scrollBottom = this._rows - 1;
|
||||
this.setupTabStops();
|
||||
@@ -152,6 +167,8 @@ export class Buffer implements IBuffer {
|
||||
public resize(newCols: number, newRows: number): void {
|
||||
// store reference to null cell with default attrs
|
||||
const nullCell = this.getNullCell(DEFAULT_ATTR_DATA);
|
||||
const oldCols = this._cols;
|
||||
const oldMaxLength = this.lines.maxLength;
|
||||
|
||||
// count bufferlines with overly big memory to be cleaned afterwards
|
||||
let dirtyMemoryLines = 0;
|
||||
@@ -159,6 +176,14 @@ export class Buffer implements IBuffer {
|
||||
// Increase max length if needed before adjustments to allow space to fill
|
||||
// as required.
|
||||
const newMaxLength = this._getCorrectBufferLength(newRows);
|
||||
const shouldDelayColResize = this._isReflowEnabled && newCols < oldCols;
|
||||
const shouldDelayRowResize = newRows < this._rows;
|
||||
const canDelayResize = newMaxLength <= oldMaxLength;
|
||||
const shouldDelayResize = canDelayResize && (shouldDelayColResize || shouldDelayRowResize);
|
||||
const initialCols = shouldDelayColResize ? oldCols : newCols;
|
||||
const initialRows = shouldDelayRowResize ? this._rows : newRows;
|
||||
const initialMaxLength = shouldDelayResize ? oldMaxLength : newMaxLength;
|
||||
this._ghosttyBuffer.resize(initialCols, initialRows, initialMaxLength);
|
||||
if (newMaxLength > this.lines.maxLength) {
|
||||
this.lines.maxLength = newMaxLength;
|
||||
}
|
||||
@@ -259,6 +284,13 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldDelayResize) {
|
||||
if (newRows < this._rows || newMaxLength < oldMaxLength) {
|
||||
this.lines.normalize();
|
||||
}
|
||||
this._ghosttyBuffer.resize(newCols, newRows, newMaxLength);
|
||||
}
|
||||
|
||||
this._cols = newCols;
|
||||
this._rows = newRows;
|
||||
|
||||
@@ -277,9 +309,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) {
|
||||
@@ -303,11 +332,7 @@ export class Buffer implements IBuffer {
|
||||
}
|
||||
|
||||
private get _isReflowEnabled(): boolean {
|
||||
const windowsPty = this._optionsService.rawOptions.windowsPty;
|
||||
if (windowsPty && windowsPty.buildNumber) {
|
||||
return this._hasScrollback && windowsPty.backend === 'conpty' && windowsPty.buildNumber >= 21376;
|
||||
}
|
||||
return this._hasScrollback;
|
||||
return true;
|
||||
}
|
||||
|
||||
private _reflow(newCols: number, newRows: number): void {
|
||||
@@ -431,7 +456,19 @@ export class Buffer implements IBuffer {
|
||||
// which would stop the reflow from happening if an exception would throw.
|
||||
break;
|
||||
}
|
||||
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol - cellsToCopy, destCol - cellsToCopy, cellsToCopy, true);
|
||||
const destStart = destCol - cellsToCopy;
|
||||
const srcStart = srcCol - cellsToCopy;
|
||||
const sameLine = wrappedLines[destLineIndex] === wrappedLines[srcLineIndex];
|
||||
const overlaps = sameLine && destStart < srcStart + cellsToCopy && srcStart < destStart + cellsToCopy;
|
||||
if (overlaps && destStart > srcStart) {
|
||||
for (let cell = cellsToCopy - 1; cell >= 0; cell--) {
|
||||
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
|
||||
}
|
||||
} else {
|
||||
for (let cell = 0; cell < cellsToCopy; cell++) {
|
||||
wrappedLines[destLineIndex].setCell(destStart + cell, wrappedLines[srcLineIndex].loadCell(srcStart + cell, $workCell));
|
||||
}
|
||||
}
|
||||
destCol -= cellsToCopy;
|
||||
if (destCol === 0) {
|
||||
destLineIndex--;
|
||||
@@ -487,7 +524,10 @@ export class Buffer implements IBuffer {
|
||||
// Record original lines so they don't get overridden when we rearrange the list
|
||||
const originalLines: BufferLine[] = [];
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
originalLines.push(this.lines.get(i) as BufferLine);
|
||||
const line = this.lines.get(i);
|
||||
if (line) {
|
||||
originalLines.push(line.clone() as BufferLine);
|
||||
}
|
||||
}
|
||||
const originalLinesLength = this.lines.length;
|
||||
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { CircularList } from 'common/CircularList';
|
||||
import { IBufferLine, ICellData } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { ICircularList, IBufferLine, ICellData } from 'common/Types';
|
||||
|
||||
export interface INewLayoutResult {
|
||||
layout: number[];
|
||||
@@ -22,7 +21,7 @@ export interface INewLayoutResult {
|
||||
* @param nullCell The cell data to use when filling in empty cells.
|
||||
* @param reflowCursorLine Whether to reflow the line containing the cursor.
|
||||
*/
|
||||
export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
|
||||
export function reflowLargerGetLinesToRemove(lines: ICircularList<IBufferLine>, oldCols: number, newCols: number, bufferAbsoluteY: number, nullCell: ICellData, reflowCursorLine: boolean): number[] {
|
||||
// Gather all BufferLines that need to be removed from the Buffer here so that they can be
|
||||
// batched up and only committed once
|
||||
const toRemove: number[] = [];
|
||||
@@ -30,16 +29,16 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
|
||||
for (let y = 0; y < lines.length - 1; y++) {
|
||||
// Check if this row is wrapped
|
||||
let i = y;
|
||||
let nextLine = lines.get(++i) as BufferLine;
|
||||
let nextLine = lines.get(++i) as IBufferLine;
|
||||
if (!nextLine.isWrapped) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check how many lines it's wrapped for
|
||||
const wrappedLines: BufferLine[] = [lines.get(y) as BufferLine];
|
||||
const wrappedLines: IBufferLine[] = [lines.get(y) as IBufferLine];
|
||||
while (i < lines.length && nextLine.isWrapped) {
|
||||
wrappedLines.push(nextLine);
|
||||
nextLine = lines.get(++i) as BufferLine;
|
||||
nextLine = lines.get(++i) as IBufferLine;
|
||||
}
|
||||
|
||||
if (!reflowCursorLine) {
|
||||
@@ -62,7 +61,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
|
||||
const destRemainingCells = newCols - destCol;
|
||||
const cellsToCopy = Math.min(srcRemainingCells, destRemainingCells);
|
||||
|
||||
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[srcLineIndex], srcCol, destCol, cellsToCopy, false);
|
||||
copyCells(wrappedLines[srcLineIndex], wrappedLines[destLineIndex], srcCol, destCol, cellsToCopy);
|
||||
|
||||
destCol += cellsToCopy;
|
||||
if (destCol === newCols) {
|
||||
@@ -78,7 +77,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
|
||||
// Make sure the last cell isn't wide, if it is copy it to the current dest
|
||||
if (destCol === 0 && destLineIndex !== 0) {
|
||||
if (wrappedLines[destLineIndex - 1].getWidth(newCols - 1) === 2) {
|
||||
wrappedLines[destLineIndex].copyCellsFrom(wrappedLines[destLineIndex - 1], newCols - 1, destCol++, 1, false);
|
||||
copyCells(wrappedLines[destLineIndex - 1], wrappedLines[destLineIndex], newCols - 1, destCol++, 1);
|
||||
// Null out the end of the last row
|
||||
wrappedLines[destLineIndex - 1].setCell(newCols - 1, nullCell);
|
||||
}
|
||||
@@ -113,7 +112,7 @@ export function reflowLargerGetLinesToRemove(lines: CircularList<IBufferLine>, o
|
||||
* @param lines The buffer lines.
|
||||
* @param toRemove The indexes to remove.
|
||||
*/
|
||||
export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
|
||||
export function reflowLargerCreateNewLayout(lines: ICircularList<IBufferLine>, toRemove: number[]): INewLayoutResult {
|
||||
const layout: number[] = [];
|
||||
// First iterate through the list and get the actual indexes to use for rows
|
||||
let nextToRemoveIndex = 0;
|
||||
@@ -148,11 +147,11 @@ export function reflowLargerCreateNewLayout(lines: CircularList<IBufferLine>, to
|
||||
* @param lines The buffer lines.
|
||||
* @param newLayout The new layout to apply.
|
||||
*/
|
||||
export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, newLayout: number[]): void {
|
||||
export function reflowLargerApplyNewLayout(lines: ICircularList<IBufferLine>, newLayout: number[]): void {
|
||||
// Record original lines so they don't get overridden when we rearrange the list
|
||||
const newLayoutLines: BufferLine[] = [];
|
||||
const newLayoutLines: IBufferLine[] = [];
|
||||
for (let i = 0; i < newLayout.length; i++) {
|
||||
newLayoutLines.push(lines.get(newLayout[i]) as BufferLine);
|
||||
newLayoutLines.push((lines.get(newLayout[i]) as IBufferLine).clone());
|
||||
}
|
||||
|
||||
// Rearrange the list
|
||||
@@ -176,7 +175,7 @@ export function reflowLargerApplyNewLayout(lines: CircularList<IBufferLine>, new
|
||||
* @param oldCols The columns before resize.
|
||||
* @param newCols The columns after resize.
|
||||
*/
|
||||
export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCols: number, newCols: number): number[] {
|
||||
export function reflowSmallerGetNewLineLengths(wrappedLines: IBufferLine[], oldCols: number, newCols: number): number[] {
|
||||
const newLineLengths: number[] = [];
|
||||
const cellsNeeded = wrappedLines.map((l, i) => getWrappedLineTrimmedLength(wrappedLines, i, oldCols)).reduce((p, c) => p + c);
|
||||
|
||||
@@ -209,7 +208,7 @@ export function reflowSmallerGetNewLineLengths(wrappedLines: BufferLine[], oldCo
|
||||
return newLineLengths;
|
||||
}
|
||||
|
||||
export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols: number): number {
|
||||
export function getWrappedLineTrimmedLength(lines: IBufferLine[], i: number, cols: number): number {
|
||||
// If this is the last row in the wrapped line, get the actual trimmed length
|
||||
if (i === lines.length - 1) {
|
||||
return lines[i].getTrimmedLength();
|
||||
@@ -224,3 +223,17 @@ export function getWrappedLineTrimmedLength(lines: BufferLine[], i: number, cols
|
||||
}
|
||||
return cols;
|
||||
}
|
||||
|
||||
const workCell = new CellData();
|
||||
|
||||
function copyCells(src: IBufferLine, dest: IBufferLine, srcCol: number, destCol: number, length: number): void {
|
||||
if (src === dest && destCol > srcCol) {
|
||||
for (let i = length - 1; i >= 0; i--) {
|
||||
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < length; i++) {
|
||||
dest.setCell(destCol + i, src.loadCell(srcCol + i, workCell));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { assert } from 'chai';
|
||||
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
|
||||
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
|
||||
import { DEFAULT_ATTR_DATA } from 'common/buffer/BufferLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
|
||||
const WASM_PATH = 'vendor/ghostty-wasm/ghostty-vt.wasm';
|
||||
|
||||
describe('GhosttyBufferLine', () => {
|
||||
before(() => {
|
||||
(globalThis as any).XTERM_GHOSTTY_WASM_URL = WASM_PATH;
|
||||
});
|
||||
|
||||
it('writes and reads simple cells', () => {
|
||||
const buffer = new GhosttyWasmBuffer(5, 2, 5);
|
||||
const line = new GhosttyBufferLine(buffer, 0);
|
||||
|
||||
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
|
||||
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
|
||||
|
||||
assert.equal(line.translateToString(true), 'AB');
|
||||
assert.equal(line.getCodePoint(0), 'A'.charCodeAt(0));
|
||||
assert.equal(line.getCodePoint(1), 'B'.charCodeAt(0));
|
||||
|
||||
buffer.dispose();
|
||||
});
|
||||
|
||||
it('supports insert and delete', () => {
|
||||
const buffer = new GhosttyWasmBuffer(5, 1, 5);
|
||||
const line = new GhosttyBufferLine(buffer, 0);
|
||||
const fill = new CellData();
|
||||
|
||||
line.setCellFromCodepoint(0, 'A'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
|
||||
line.setCellFromCodepoint(1, 'B'.charCodeAt(0), 1, DEFAULT_ATTR_DATA);
|
||||
|
||||
line.insertCells(1, 1, fill);
|
||||
assert.equal(line.translateToString(true), 'A B');
|
||||
|
||||
line.deleteCells(1, 1, fill);
|
||||
assert.equal(line.translateToString(true), 'AB');
|
||||
|
||||
buffer.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { CharData, IAttributeData, IBufferLine, ICellData, IExtendedAttrs } from 'common/Types';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { BufferLine } from 'common/buffer/BufferLine';
|
||||
import { Attributes, BgFlags, CHAR_DATA_ATTR_INDEX, CHAR_DATA_CHAR_INDEX, CHAR_DATA_WIDTH_INDEX, Content, WHITESPACE_CELL_CHAR } from 'common/buffer/Constants';
|
||||
import { stringFromCodePoint } from 'common/input/TextDecoder';
|
||||
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
|
||||
|
||||
// Work variables to avoid garbage collection
|
||||
const workCell = new CellData();
|
||||
|
||||
export class GhosttyBufferLine implements IBufferLine {
|
||||
public get length(): number {
|
||||
return this._buffer.cols;
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly _buffer: GhosttyWasmBuffer,
|
||||
private readonly _row: number
|
||||
) {
|
||||
}
|
||||
|
||||
public get row(): number {
|
||||
return this._row;
|
||||
}
|
||||
|
||||
public get _extendedAttrs(): { [key: number]: IExtendedAttrs | undefined } {
|
||||
const rowMap = this._buffer.getExtendedRow(this._row);
|
||||
if (!rowMap) {
|
||||
return {};
|
||||
}
|
||||
const result: { [key: number]: IExtendedAttrs | undefined } = {};
|
||||
for (const [col, ext] of rowMap) {
|
||||
result[col] = ext;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public get isWrapped(): boolean {
|
||||
return this._buffer.getRowWrap(this._row);
|
||||
}
|
||||
|
||||
public set isWrapped(value: boolean) {
|
||||
this._buffer.setRowWrap(this._row, value);
|
||||
}
|
||||
|
||||
public get(index: number): CharData {
|
||||
const [content, fg] = this._buffer.getCell(this._row, index);
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
const combined = (content & Content.IS_COMBINED_MASK)
|
||||
? this._buffer.getCombined(this._row, index) ?? ''
|
||||
: '';
|
||||
return [
|
||||
fg,
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? combined
|
||||
: (cp) ? stringFromCodePoint(cp) : '',
|
||||
content >> Content.WIDTH_SHIFT,
|
||||
(content & Content.IS_COMBINED_MASK)
|
||||
? (combined.length ? combined.charCodeAt(combined.length - 1) : 0)
|
||||
: cp
|
||||
];
|
||||
}
|
||||
|
||||
public set(index: number, value: CharData): void {
|
||||
const fg = value[CHAR_DATA_ATTR_INDEX];
|
||||
const chars = value[CHAR_DATA_CHAR_INDEX];
|
||||
if (chars.length > 1) {
|
||||
this._buffer.setCombined(this._row, index, chars);
|
||||
this._buffer.setCell(this._row, index, index | Content.IS_COMBINED_MASK | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
|
||||
} else {
|
||||
this._buffer.clearCombined(this._row, index);
|
||||
this._buffer.setCell(this._row, index, chars.charCodeAt(0) | (value[CHAR_DATA_WIDTH_INDEX] << Content.WIDTH_SHIFT), fg, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public loadCell(index: number, cell: ICellData): ICellData {
|
||||
const [content, fg, bg] = this._buffer.getCell(this._row, index);
|
||||
cell.content = content;
|
||||
cell.fg = fg;
|
||||
cell.bg = bg;
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
cell.combinedData = this._buffer.getCombined(this._row, index) ?? '';
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
cell.extended = this._buffer.getExtended(this._row, index)!;
|
||||
}
|
||||
return cell;
|
||||
}
|
||||
|
||||
public setCell(index: number, cell: ICellData): void {
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._buffer.setCombined(this._row, index, cell.combinedData);
|
||||
} else {
|
||||
this._buffer.clearCombined(this._row, index);
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._buffer.setExtended(this._row, index, cell.extended);
|
||||
} else {
|
||||
this._buffer.clearExtended(this._row, index);
|
||||
}
|
||||
this._buffer.setCell(this._row, index, cell.content, cell.fg, cell.bg);
|
||||
}
|
||||
|
||||
public setCellFromCodepoint(index: number, codePoint: number, width: number, attrs: IAttributeData): void {
|
||||
if (attrs.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._buffer.setExtended(this._row, index, attrs.extended);
|
||||
} else {
|
||||
this._buffer.clearExtended(this._row, index);
|
||||
}
|
||||
this._buffer.clearCombined(this._row, index);
|
||||
this._buffer.setCell(this._row, index, codePoint | (width << Content.WIDTH_SHIFT), attrs.fg, attrs.bg);
|
||||
}
|
||||
|
||||
public addCodepointToCell(index: number, codePoint: number, width: number): void {
|
||||
const [content, fg, bg] = this._buffer.getCell(this._row, index);
|
||||
let newContent = content;
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
const existing = this._buffer.getCombined(this._row, index) ?? '';
|
||||
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
|
||||
} else {
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
const existing = stringFromCodePoint(content & Content.CODEPOINT_MASK);
|
||||
this._buffer.setCombined(this._row, index, existing + stringFromCodePoint(codePoint));
|
||||
newContent &= ~Content.CODEPOINT_MASK;
|
||||
newContent |= Content.IS_COMBINED_MASK;
|
||||
} else {
|
||||
newContent = codePoint | (1 << Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
if (width) {
|
||||
newContent &= ~Content.WIDTH_MASK;
|
||||
newContent |= width << Content.WIDTH_SHIFT;
|
||||
}
|
||||
this._buffer.setCell(this._row, index, newContent, fg, bg);
|
||||
}
|
||||
|
||||
public insertCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
pos %= this.length;
|
||||
if (pos && this.getWidth(pos - 1) === 2) {
|
||||
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
|
||||
}
|
||||
|
||||
if (n < this.length - pos) {
|
||||
for (let i = this.length - pos - n - 1; i >= 0; --i) {
|
||||
this.setCell(pos + n + i, this.loadCell(pos + i, workCell));
|
||||
}
|
||||
for (let i = 0; i < n; ++i) {
|
||||
this.setCell(pos + i, fillCellData);
|
||||
}
|
||||
} else {
|
||||
for (let i = pos; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.getWidth(this.length - 1) === 2) {
|
||||
this.setCellFromCodepoint(this.length - 1, 0, 1, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
public deleteCells(pos: number, n: number, fillCellData: ICellData): void {
|
||||
pos %= this.length;
|
||||
if (n < this.length - pos) {
|
||||
for (let i = 0; i < this.length - pos - n; ++i) {
|
||||
this.setCell(pos + i, this.loadCell(pos + n + i, workCell));
|
||||
}
|
||||
for (let i = this.length - n; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
} else {
|
||||
for (let i = pos; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
if (pos && this.getWidth(pos - 1) === 2) {
|
||||
this.setCellFromCodepoint(pos - 1, 0, 1, fillCellData);
|
||||
}
|
||||
if (this.getWidth(pos) === 0 && !this.hasContent(pos)) {
|
||||
this.setCellFromCodepoint(pos, 0, 1, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
public replaceCells(start: number, end: number, fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
if (respectProtect) {
|
||||
if (start && this.getWidth(start - 1) === 2 && !this.isProtected(start - 1)) {
|
||||
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
|
||||
}
|
||||
if (end < this.length && this.getWidth(end - 1) === 2 && !this.isProtected(end)) {
|
||||
this.setCellFromCodepoint(end, 0, 1, fillCellData);
|
||||
}
|
||||
while (start < end && start < this.length) {
|
||||
if (!this.isProtected(start)) {
|
||||
this.setCell(start, fillCellData);
|
||||
}
|
||||
start++;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (start && this.getWidth(start - 1) === 2) {
|
||||
this.setCellFromCodepoint(start - 1, 0, 1, fillCellData);
|
||||
}
|
||||
if (end < this.length && this.getWidth(end - 1) === 2) {
|
||||
this.setCellFromCodepoint(end, 0, 1, fillCellData);
|
||||
}
|
||||
|
||||
while (start < end && start < this.length) {
|
||||
this.setCell(start++, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
public resize(_cols: number, _fillCellData: ICellData): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
public cleanupMemory(): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
public fill(fillCellData: ICellData, respectProtect: boolean = false): void {
|
||||
if (respectProtect) {
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
if (!this.isProtected(i)) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
this.setCell(i, fillCellData);
|
||||
}
|
||||
}
|
||||
|
||||
public copyFrom(line: IBufferLine): void {
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
this.setCell(i, line.loadCell(i, workCell));
|
||||
}
|
||||
this.isWrapped = line.isWrapped;
|
||||
}
|
||||
|
||||
public clone(): IBufferLine {
|
||||
const clone = new BufferLine(this.length);
|
||||
for (let i = 0; i < this.length; ++i) {
|
||||
clone.setCell(i, this.loadCell(i, workCell));
|
||||
}
|
||||
clone.isWrapped = this.isWrapped;
|
||||
return clone;
|
||||
}
|
||||
|
||||
public getTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
if ((this._buffer.getCell(this._row, i)[0] & Content.HAS_CONTENT_MASK)) {
|
||||
return i + (this._buffer.getCell(this._row, i)[0] >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public getNoBgTrimmedLength(): number {
|
||||
for (let i = this.length - 1; i >= 0; --i) {
|
||||
const [content, , bg] = this._buffer.getCell(this._row, i);
|
||||
if ((content & Content.HAS_CONTENT_MASK) || (bg & Attributes.CM_MASK)) {
|
||||
return i + (content >> Content.WIDTH_SHIFT);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
public translateToString(trimRight?: boolean, startCol?: number, endCol?: number, outColumns?: number[]): string {
|
||||
startCol = startCol ?? 0;
|
||||
endCol = endCol ?? this.length;
|
||||
if (trimRight) {
|
||||
endCol = Math.min(endCol, this.getTrimmedLength());
|
||||
}
|
||||
if (outColumns) {
|
||||
outColumns.length = 0;
|
||||
}
|
||||
let result = '';
|
||||
while (startCol < endCol) {
|
||||
const content = this._buffer.getCell(this._row, startCol)[0];
|
||||
const cp = content & Content.CODEPOINT_MASK;
|
||||
const chars = (content & Content.IS_COMBINED_MASK)
|
||||
? (this._buffer.getCombined(this._row, startCol) ?? '')
|
||||
: (cp) ? stringFromCodePoint(cp) : WHITESPACE_CELL_CHAR;
|
||||
result += chars;
|
||||
if (outColumns) {
|
||||
for (let i = 0; i < chars.length; ++i) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
}
|
||||
startCol += (content >> Content.WIDTH_SHIFT) || 1;
|
||||
}
|
||||
if (outColumns) {
|
||||
outColumns.push(startCol);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
public getWidth(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[0] >> Content.WIDTH_SHIFT;
|
||||
}
|
||||
|
||||
public hasWidth(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[0] & Content.WIDTH_MASK;
|
||||
}
|
||||
|
||||
public getFg(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[1];
|
||||
}
|
||||
|
||||
public getBg(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[2];
|
||||
}
|
||||
|
||||
public hasContent(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[0] & Content.HAS_CONTENT_MASK;
|
||||
}
|
||||
|
||||
public getCodePoint(index: number): number {
|
||||
const content = this._buffer.getCell(this._row, index)[0];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
const combined = this._buffer.getCombined(this._row, index) ?? '';
|
||||
return combined.charCodeAt(combined.length - 1);
|
||||
}
|
||||
return content & Content.CODEPOINT_MASK;
|
||||
}
|
||||
|
||||
public isCombined(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[0] & Content.IS_COMBINED_MASK;
|
||||
}
|
||||
|
||||
public getString(index: number): string {
|
||||
const content = this._buffer.getCell(this._row, index)[0];
|
||||
if (content & Content.IS_COMBINED_MASK) {
|
||||
return this._buffer.getCombined(this._row, index) ?? '';
|
||||
}
|
||||
if (content & Content.CODEPOINT_MASK) {
|
||||
return stringFromCodePoint(content & Content.CODEPOINT_MASK);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
public isProtected(index: number): number {
|
||||
return this._buffer.getCell(this._row, index)[2] & BgFlags.PROTECTED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { ICircularList, IBufferLine } from 'common/Types';
|
||||
import { Disposable } from 'common/Lifecycle';
|
||||
import { Emitter } from 'common/Event';
|
||||
import { IDeleteEvent, IInsertEvent } from 'common/CircularList';
|
||||
import { GhosttyWasmBuffer } from 'common/buffer/GhosttyWasmBuffer';
|
||||
import { GhosttyBufferLine } from 'common/buffer/GhosttyBufferLine';
|
||||
import { CellData } from 'common/buffer/CellData';
|
||||
import { BgFlags, Content } from 'common/buffer/Constants';
|
||||
|
||||
export class GhosttyCircularList extends Disposable implements ICircularList<IBufferLine> {
|
||||
private _startIndex: number = 0;
|
||||
private _length: number = 0;
|
||||
private _lines: GhosttyBufferLine[];
|
||||
|
||||
public readonly onDeleteEmitter = this._register(new Emitter<IDeleteEvent>());
|
||||
public readonly onDelete = this.onDeleteEmitter.event;
|
||||
public readonly onInsertEmitter = this._register(new Emitter<IInsertEvent>());
|
||||
public readonly onInsert = this.onInsertEmitter.event;
|
||||
public readonly onTrimEmitter = this._register(new Emitter<number>());
|
||||
public readonly onTrim = this.onTrimEmitter.event;
|
||||
|
||||
private _workCell = new CellData();
|
||||
|
||||
constructor(
|
||||
private _buffer: GhosttyWasmBuffer,
|
||||
private _maxLength: number
|
||||
) {
|
||||
super();
|
||||
this._lines = new Array<GhosttyBufferLine>(_maxLength);
|
||||
for (let i = 0; i < _maxLength; i++) {
|
||||
this._lines[i] = new GhosttyBufferLine(this._buffer, i);
|
||||
}
|
||||
}
|
||||
|
||||
public get maxLength(): number {
|
||||
return this._maxLength;
|
||||
}
|
||||
|
||||
public set maxLength(newMaxLength: number) {
|
||||
if (this._maxLength === newMaxLength) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLength = Math.min(newMaxLength, this.length);
|
||||
const oldLines = this._getLogicalLines(nextLength);
|
||||
const newLines = new Array<GhosttyBufferLine>(newMaxLength);
|
||||
for (let i = 0; i < newMaxLength; i++) {
|
||||
newLines[i] = new GhosttyBufferLine(this._buffer, i);
|
||||
}
|
||||
for (let i = 0; i < nextLength; i++) {
|
||||
this._buffer.copyRow(oldLines[i].row, newLines[i].row);
|
||||
}
|
||||
|
||||
this._lines = newLines;
|
||||
this._maxLength = newMaxLength;
|
||||
this._startIndex = 0;
|
||||
this._length = nextLength;
|
||||
}
|
||||
|
||||
public get length(): number {
|
||||
return this._length;
|
||||
}
|
||||
|
||||
public set length(newLength: number) {
|
||||
this._length = newLength;
|
||||
}
|
||||
|
||||
public get isFull(): boolean {
|
||||
return this._length === this._maxLength;
|
||||
}
|
||||
|
||||
public get(index: number): IBufferLine | undefined {
|
||||
if (index < 0 || index >= this._length) {
|
||||
return undefined;
|
||||
}
|
||||
return this._lines[this._getCyclicIndex(index)];
|
||||
}
|
||||
|
||||
public set(index: number, value: IBufferLine): void {
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
if (index >= this._length) {
|
||||
this._length = Math.min(index + 1, this._maxLength);
|
||||
}
|
||||
this._setIndex(index, value);
|
||||
}
|
||||
|
||||
public push(value: IBufferLine): void {
|
||||
if (this._length === this._maxLength) {
|
||||
this._startIndex = ++this._startIndex % this._maxLength;
|
||||
this.onTrimEmitter.fire(1);
|
||||
} else {
|
||||
this._length++;
|
||||
}
|
||||
this.set(this._length - 1, value);
|
||||
}
|
||||
|
||||
public recycle(): GhosttyBufferLine {
|
||||
if (!this.isFull) {
|
||||
throw new Error('recycle is only supported when buffer is full');
|
||||
}
|
||||
this._startIndex = ++this._startIndex % this._maxLength;
|
||||
this.onTrimEmitter.fire(1);
|
||||
return this._lines[this._getCyclicIndex(this._length - 1)];
|
||||
}
|
||||
|
||||
public pop(): IBufferLine | undefined {
|
||||
if (this._length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const value = this._lines[this._getCyclicIndex(this._length - 1)];
|
||||
this._length--;
|
||||
return value;
|
||||
}
|
||||
|
||||
public splice(start: number, deleteCount: number, ...items: IBufferLine[]): void {
|
||||
if (deleteCount === 0 && items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const before = Math.max(0, Math.min(start, this._length));
|
||||
const actualDeleteCount = Math.min(deleteCount, this._length - before);
|
||||
|
||||
if (actualDeleteCount) {
|
||||
for (let i = before; i < this._length - actualDeleteCount; i++) {
|
||||
const src = this.get(i + actualDeleteCount);
|
||||
if (src) {
|
||||
this._setIndex(i, src);
|
||||
}
|
||||
}
|
||||
this._length -= actualDeleteCount;
|
||||
this.onDeleteEmitter.fire({ index: before, amount: actualDeleteCount });
|
||||
}
|
||||
|
||||
if (items.length) {
|
||||
for (let i = this._length - 1; i >= before; i--) {
|
||||
const src = this.get(i);
|
||||
if (src) {
|
||||
this._setIndex(i + items.length, src);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
this._setIndex(before + i, items[i]);
|
||||
}
|
||||
this.onInsertEmitter.fire({ index: before, amount: items.length });
|
||||
|
||||
if (this._length + items.length > this._maxLength) {
|
||||
const countToTrim = this._length + items.length - this._maxLength;
|
||||
this._startIndex = (this._startIndex + countToTrim) % this._maxLength;
|
||||
this._length = this._maxLength;
|
||||
this.onTrimEmitter.fire(countToTrim);
|
||||
} else {
|
||||
this._length += items.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public trimStart(count: number): void {
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
count = Math.min(count, this._length);
|
||||
if (count > 0) {
|
||||
this._startIndex = (this._startIndex + count) % this._maxLength;
|
||||
this._length -= count;
|
||||
this.onTrimEmitter.fire(count);
|
||||
}
|
||||
}
|
||||
|
||||
public shiftElements(start: number, count: number, offset: number): void {
|
||||
if (count <= 0) {
|
||||
return;
|
||||
}
|
||||
if (start < 0 || start >= this._length) {
|
||||
throw new Error('start argument out of range');
|
||||
}
|
||||
if (start + offset < 0) {
|
||||
throw new Error('Cannot shift elements in list beyond index 0');
|
||||
}
|
||||
|
||||
if (offset > 0) {
|
||||
for (let i = count - 1; i >= 0; i--) {
|
||||
const srcLine = this.get(start + i);
|
||||
if (srcLine) {
|
||||
this._setIndex(start + i + offset, srcLine);
|
||||
}
|
||||
}
|
||||
const expandListBy = (start + count + offset) - this._length;
|
||||
if (expandListBy > 0) {
|
||||
this._length += expandListBy;
|
||||
while (this._length > this._maxLength) {
|
||||
this._length--;
|
||||
this._startIndex = (this._startIndex + 1) % this._maxLength;
|
||||
this.onTrimEmitter.fire(1);
|
||||
}
|
||||
}
|
||||
} else if (offset < 0) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const srcLine = this.get(start + i);
|
||||
if (srcLine) {
|
||||
this._setIndex(start + i + offset, srcLine);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public normalize(): void {
|
||||
if (this._startIndex === 0) {
|
||||
return;
|
||||
}
|
||||
const snapshot = this._getLogicalLines(this._length).map(line => line.clone());
|
||||
for (let i = 0; i < snapshot.length; i++) {
|
||||
this._copyInto(this._lines[i].row, snapshot[i]);
|
||||
}
|
||||
this._startIndex = 0;
|
||||
}
|
||||
|
||||
private _setIndex(index: number, value: IBufferLine): void {
|
||||
const line = this._lines[this._getCyclicIndex(index)];
|
||||
this._copyInto(line.row, value);
|
||||
}
|
||||
|
||||
private _getCyclicIndex(index: number): number {
|
||||
return (this._startIndex + index) % this._maxLength;
|
||||
}
|
||||
|
||||
private _copyInto(rowIndex: number, value: IBufferLine): void {
|
||||
const nullContent = 1 << Content.WIDTH_SHIFT;
|
||||
if (value instanceof GhosttyBufferLine) {
|
||||
this._buffer.copyRow(value.row, rowIndex);
|
||||
return;
|
||||
}
|
||||
|
||||
const colsToCopy = Math.min(this._buffer.cols, value.length);
|
||||
for (let col = 0; col < colsToCopy; col++) {
|
||||
const cell = value.loadCell(col, this._workCell);
|
||||
this._buffer.setCell(rowIndex, col, cell.content, cell.fg, cell.bg);
|
||||
if (cell.content & Content.IS_COMBINED_MASK) {
|
||||
this._buffer.setCombined(rowIndex, col, cell.combinedData);
|
||||
} else {
|
||||
this._buffer.clearCombined(rowIndex, col);
|
||||
}
|
||||
if (cell.bg & BgFlags.HAS_EXTENDED) {
|
||||
this._buffer.setExtended(rowIndex, col, cell.extended);
|
||||
} else {
|
||||
this._buffer.clearExtended(rowIndex, col);
|
||||
}
|
||||
}
|
||||
for (let col = colsToCopy; col < this._buffer.cols; col++) {
|
||||
this._buffer.setCell(rowIndex, col, nullContent, 0, 0);
|
||||
this._buffer.clearCombined(rowIndex, col);
|
||||
this._buffer.clearExtended(rowIndex, col);
|
||||
}
|
||||
this._buffer.setRowWrap(rowIndex, value.isWrapped);
|
||||
}
|
||||
|
||||
private _getLogicalLines(count: number): GhosttyBufferLine[] {
|
||||
const result: GhosttyBufferLine[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
result.push(this._lines[this._getCyclicIndex(i)]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
|
||||
import { IExtendedAttrs } from 'common/Types';
|
||||
|
||||
const CELL_STRIDE = 3;
|
||||
|
||||
export class GhosttyWasmBuffer {
|
||||
private readonly _wasm: IGhosttyWasmExports;
|
||||
private _handle: number;
|
||||
private _cols: number;
|
||||
private _rows: number;
|
||||
private _maxRows: number;
|
||||
|
||||
private _combined = new Map<number, Map<number, string>>();
|
||||
private _extended = new Map<number, Map<number, IExtendedAttrs>>();
|
||||
|
||||
private _cellScratchPtr: number;
|
||||
|
||||
constructor(cols: number, rows: number, maxRows: number) {
|
||||
this._wasm = GhosttyWasmRuntime.getInstanceSync();
|
||||
this._cols = cols;
|
||||
this._rows = rows;
|
||||
this._maxRows = maxRows;
|
||||
|
||||
this._handle = this._wasm.ghostty_xterm_buffer_new(cols, rows, maxRows);
|
||||
if (!this._handle) {
|
||||
throw new Error('Failed to allocate Ghostty xterm buffer');
|
||||
}
|
||||
|
||||
this._cellScratchPtr = this._wasm.ghostty_wasm_alloc_u8_array(CELL_STRIDE * 4);
|
||||
if (!this._cellScratchPtr) {
|
||||
this._wasm.ghostty_xterm_buffer_free(this._handle);
|
||||
this._handle = 0;
|
||||
throw new Error('Failed to allocate Ghostty cell scratch buffer');
|
||||
}
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
if (this._handle) {
|
||||
this._wasm.ghostty_xterm_buffer_free(this._handle);
|
||||
this._handle = 0;
|
||||
}
|
||||
if (this._cellScratchPtr) {
|
||||
this._wasm.ghostty_wasm_free_u8_array(this._cellScratchPtr, CELL_STRIDE * 4);
|
||||
this._cellScratchPtr = 0;
|
||||
}
|
||||
}
|
||||
|
||||
public get cols(): number {
|
||||
return this._cols;
|
||||
}
|
||||
|
||||
public get rows(): number {
|
||||
return this._rows;
|
||||
}
|
||||
|
||||
public get maxRows(): number {
|
||||
return this._maxRows;
|
||||
}
|
||||
|
||||
public resize(cols: number, rows: number, maxRows: number): void {
|
||||
const oldCols = this._cols;
|
||||
if (!this._wasm.ghostty_xterm_buffer_resize(this._handle, cols, rows, maxRows)) {
|
||||
throw new Error('Failed to resize Ghostty xterm buffer');
|
||||
}
|
||||
this._cols = cols;
|
||||
this._rows = rows;
|
||||
this._maxRows = maxRows;
|
||||
|
||||
if (cols < oldCols) {
|
||||
for (const [row, rowMap] of this._combined) {
|
||||
for (const col of rowMap.keys()) {
|
||||
if (col >= cols) {
|
||||
rowMap.delete(col);
|
||||
}
|
||||
}
|
||||
if (rowMap.size === 0) {
|
||||
this._combined.delete(row);
|
||||
}
|
||||
}
|
||||
for (const [row, rowMap] of this._extended) {
|
||||
for (const col of rowMap.keys()) {
|
||||
if (col >= cols) {
|
||||
rowMap.delete(col);
|
||||
}
|
||||
}
|
||||
if (rowMap.size === 0) {
|
||||
this._extended.delete(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this._wasm.ghostty_xterm_buffer_clear(this._handle);
|
||||
this._combined.clear();
|
||||
this._extended.clear();
|
||||
}
|
||||
|
||||
public getCell(row: number, col: number): [number, number, number] {
|
||||
this._wasm.ghostty_xterm_buffer_get_cell(this._handle, row, col, this._cellScratchPtr);
|
||||
const view = new DataView(this._wasm.memory.buffer, this._cellScratchPtr, CELL_STRIDE * 4);
|
||||
const content = view.getUint32(0, true);
|
||||
const fg = view.getUint32(4, true);
|
||||
const bg = view.getUint32(8, true);
|
||||
return [content, fg, bg];
|
||||
}
|
||||
|
||||
public setCell(row: number, col: number, content: number, fg: number, bg: number): void {
|
||||
this._wasm.ghostty_xterm_buffer_set_cell(this._handle, row, col, content, fg, bg);
|
||||
}
|
||||
|
||||
public clearRow(row: number, content: number, fg: number, bg: number): void {
|
||||
this._wasm.ghostty_xterm_buffer_clear_row(this._handle, row, content, fg, bg);
|
||||
this._combined.delete(row);
|
||||
this._extended.delete(row);
|
||||
}
|
||||
|
||||
public copyRow(srcRow: number, dstRow: number): void {
|
||||
this._wasm.ghostty_xterm_buffer_copy_row(this._handle, srcRow, dstRow);
|
||||
const combined = this._combined.get(srcRow);
|
||||
if (combined) {
|
||||
this._combined.set(dstRow, new Map(combined));
|
||||
} else {
|
||||
this._combined.delete(dstRow);
|
||||
}
|
||||
const extended = this._extended.get(srcRow);
|
||||
if (extended) {
|
||||
this._extended.set(dstRow, new Map(extended));
|
||||
} else {
|
||||
this._extended.delete(dstRow);
|
||||
}
|
||||
}
|
||||
|
||||
public getRowWrap(row: number): boolean {
|
||||
return this._wasm.ghostty_xterm_buffer_get_row_wrap(this._handle, row) !== 0;
|
||||
}
|
||||
|
||||
public setRowWrap(row: number, wrap: boolean): void {
|
||||
this._wasm.ghostty_xterm_buffer_set_row_wrap(this._handle, row, wrap ? 1 : 0);
|
||||
}
|
||||
|
||||
public getCombined(row: number, col: number): string | undefined {
|
||||
return this._combined.get(row)?.get(col);
|
||||
}
|
||||
|
||||
public setCombined(row: number, col: number, data: string): void {
|
||||
let rowMap = this._combined.get(row);
|
||||
if (!rowMap) {
|
||||
rowMap = new Map();
|
||||
this._combined.set(row, rowMap);
|
||||
}
|
||||
rowMap.set(col, data);
|
||||
}
|
||||
|
||||
public clearCombined(row: number, col: number): void {
|
||||
const rowMap = this._combined.get(row);
|
||||
if (!rowMap) {
|
||||
return;
|
||||
}
|
||||
rowMap.delete(col);
|
||||
if (rowMap.size === 0) {
|
||||
this._combined.delete(row);
|
||||
}
|
||||
}
|
||||
|
||||
public getExtended(row: number, col: number): IExtendedAttrs | undefined {
|
||||
return this._extended.get(row)?.get(col);
|
||||
}
|
||||
|
||||
public getExtendedRow(row: number): Map<number, IExtendedAttrs> | undefined {
|
||||
return this._extended.get(row);
|
||||
}
|
||||
|
||||
public setExtended(row: number, col: number, data: IExtendedAttrs): void {
|
||||
let rowMap = this._extended.get(row);
|
||||
if (!rowMap) {
|
||||
rowMap = new Map();
|
||||
this._extended.set(row, rowMap);
|
||||
}
|
||||
rowMap.set(col, data);
|
||||
}
|
||||
|
||||
public clearExtended(row: number, col: number): void {
|
||||
const rowMap = this._extended.get(row);
|
||||
if (!rowMap) {
|
||||
return;
|
||||
}
|
||||
rowMap.delete(col);
|
||||
if (rowMap.size === 0) {
|
||||
this._extended.delete(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,831 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
import { Disposable, toDisposable } from 'common/Lifecycle';
|
||||
import { IDisposable } from 'common/Types';
|
||||
import { IParsingState, IDcsHandler, IEscapeSequenceParser, IParams, IOscHandler, IHandlerCollection, CsiHandlerType, OscFallbackHandlerType, EscHandlerType, IDcsParser, DcsFallbackHandlerType, IFunctionIdentifier, ExecuteFallbackHandlerType, CsiFallbackHandlerType, EscFallbackHandlerType, PrintHandlerType, PrintFallbackHandlerType, ExecuteHandlerType, IParserStackState, ParserStackType, IApcHandler, IApcParser, ApcFallbackHandlerType } from 'common/parser/Types';
|
||||
import { OscParser } from 'common/parser/OscParser';
|
||||
import { DcsParser } from 'common/parser/DcsParser';
|
||||
import { ApcParser } from 'common/parser/ApcParser';
|
||||
import { Params } from 'common/parser/Params';
|
||||
import { StringToUtf32 } from 'common/input/TextDecoder';
|
||||
import { GhosttyWasmRuntime, IGhosttyWasmExports } from 'common/parser/GhosttyWasmRuntime';
|
||||
|
||||
const enum RawEventTag {
|
||||
PRINT = 0,
|
||||
EXECUTE = 1,
|
||||
ESC = 2,
|
||||
CSI = 3,
|
||||
OSC = 4,
|
||||
DCS_HOOK = 5,
|
||||
DCS_PUT = 6,
|
||||
DCS_UNHOOK = 7,
|
||||
APC_START = 8,
|
||||
APC_PUT = 9,
|
||||
APC_END = 10
|
||||
}
|
||||
|
||||
export class GhosttyWasmParser extends Disposable implements IEscapeSequenceParser {
|
||||
public precedingJoinState = 0;
|
||||
|
||||
private _printHandler: PrintHandlerType;
|
||||
private _executeHandlers: { [flag: number]: ExecuteHandlerType };
|
||||
private _csiHandlers: IHandlerCollection<CsiHandlerType>;
|
||||
private _escHandlers: IHandlerCollection<EscHandlerType>;
|
||||
private readonly _oscParser: OscParser;
|
||||
private readonly _dcsParser: IDcsParser;
|
||||
private readonly _apcParser: IApcParser;
|
||||
private _errorHandler: (state: IParsingState) => IParsingState;
|
||||
|
||||
private _printHandlerFb: PrintFallbackHandlerType;
|
||||
private _executeHandlerFb: ExecuteFallbackHandlerType;
|
||||
private _csiHandlerFb: CsiFallbackHandlerType;
|
||||
private _escHandlerFb: EscFallbackHandlerType;
|
||||
private _errorHandlerFb: (state: IParsingState) => IParsingState;
|
||||
|
||||
private _parseStack: IParserStackState = {
|
||||
state: ParserStackType.NONE,
|
||||
handlers: [],
|
||||
handlerPos: 0,
|
||||
transition: 0,
|
||||
chunkPos: 0
|
||||
};
|
||||
|
||||
private _wasm?: IGhosttyWasmExports;
|
||||
private _parserHandle = 0;
|
||||
private _wasmReady?: Promise<boolean>;
|
||||
|
||||
private _eventSize = 0;
|
||||
private _eventMaxParams = 0;
|
||||
private _eventMaxIntermediates = 0;
|
||||
private readonly _ptrSize = 4;
|
||||
|
||||
private _eventIndex = 0;
|
||||
private _pendingEvents: { ptr: number, len: number } | undefined;
|
||||
private _resumeParams: IParams | undefined;
|
||||
|
||||
private _textEncoder = new TextEncoder();
|
||||
private _textDecoder = new TextDecoder('utf-8');
|
||||
private _utf32Scratch = new Uint32Array(4096);
|
||||
private _oscBuffer = new StringToUtf32();
|
||||
private _dcsPutBuffer = new Uint32Array(1);
|
||||
private _apcPutBuffer = new Uint32Array(1);
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this._printHandlerFb = () => { };
|
||||
this._executeHandlerFb = () => { };
|
||||
this._csiHandlerFb = () => { };
|
||||
this._escHandlerFb = () => { };
|
||||
this._errorHandlerFb = (state: IParsingState): IParsingState => state;
|
||||
|
||||
this._printHandler = this._printHandlerFb;
|
||||
this._executeHandlers = Object.create(null);
|
||||
this._csiHandlers = Object.create(null);
|
||||
this._escHandlers = Object.create(null);
|
||||
|
||||
this._oscParser = this._register(new OscParser());
|
||||
this._dcsParser = this._register(new DcsParser());
|
||||
this._apcParser = this._register(new ApcParser());
|
||||
this._errorHandler = this._errorHandlerFb;
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
this._csiHandlers = Object.create(null);
|
||||
this._executeHandlers = Object.create(null);
|
||||
this._escHandlers = Object.create(null);
|
||||
}));
|
||||
|
||||
this._register(toDisposable(() => {
|
||||
if (this._wasm && this._parserHandle) {
|
||||
this._wasm.ghostty_raw_parser_free(this._parserHandle);
|
||||
}
|
||||
this._parserHandle = 0;
|
||||
}));
|
||||
|
||||
this.registerEscHandler({ final: '\\' }, () => true);
|
||||
}
|
||||
|
||||
public identToString(ident: number): string {
|
||||
const res: string[] = [];
|
||||
while (ident) {
|
||||
res.push(String.fromCharCode(ident & 0xFF));
|
||||
ident >>= 8;
|
||||
}
|
||||
return res.reverse().join('');
|
||||
}
|
||||
|
||||
public setPrintHandler(handler: PrintHandlerType): void {
|
||||
this._printHandler = handler;
|
||||
}
|
||||
|
||||
public clearPrintHandler(): void {
|
||||
this._printHandler = this._printHandlerFb;
|
||||
}
|
||||
|
||||
public registerEscHandler(id: IFunctionIdentifier, handler: EscHandlerType): IDisposable {
|
||||
const ident = this._identifier(id, [0x30, 0x7e]);
|
||||
this._escHandlers[ident] ??= [];
|
||||
const handlerList = this._escHandlers[ident];
|
||||
handlerList.push(handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
const handlerIndex = handlerList.indexOf(handler);
|
||||
if (handlerIndex !== -1) {
|
||||
handlerList.splice(handlerIndex, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public clearEscHandler(id: IFunctionIdentifier): void {
|
||||
if (this._escHandlers[this._identifier(id, [0x30, 0x7e])]) delete this._escHandlers[this._identifier(id, [0x30, 0x7e])];
|
||||
}
|
||||
|
||||
public setEscHandlerFallback(handler: EscFallbackHandlerType): void {
|
||||
this._escHandlerFb = handler;
|
||||
}
|
||||
|
||||
public setExecuteHandler(flag: string, handler: ExecuteHandlerType): void {
|
||||
this._executeHandlers[flag.charCodeAt(0)] = handler;
|
||||
}
|
||||
|
||||
public clearExecuteHandler(flag: string): void {
|
||||
if (this._executeHandlers[flag.charCodeAt(0)]) delete this._executeHandlers[flag.charCodeAt(0)];
|
||||
}
|
||||
|
||||
public setExecuteHandlerFallback(handler: ExecuteFallbackHandlerType): void {
|
||||
this._executeHandlerFb = handler;
|
||||
}
|
||||
|
||||
public registerCsiHandler(id: IFunctionIdentifier, handler: CsiHandlerType): IDisposable {
|
||||
const ident = this._identifier(id);
|
||||
this._csiHandlers[ident] ??= [];
|
||||
const handlerList = this._csiHandlers[ident];
|
||||
handlerList.push(handler);
|
||||
return {
|
||||
dispose: () => {
|
||||
const handlerIndex = handlerList.indexOf(handler);
|
||||
if (handlerIndex !== -1) {
|
||||
handlerList.splice(handlerIndex, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public clearCsiHandler(id: IFunctionIdentifier): void {
|
||||
if (this._csiHandlers[this._identifier(id)]) delete this._csiHandlers[this._identifier(id)];
|
||||
}
|
||||
|
||||
public setCsiHandlerFallback(callback: CsiFallbackHandlerType): void {
|
||||
this._csiHandlerFb = callback;
|
||||
}
|
||||
|
||||
public registerDcsHandler(id: IFunctionIdentifier, handler: IDcsHandler): IDisposable {
|
||||
return this._dcsParser.registerHandler(this._identifier(id), handler);
|
||||
}
|
||||
|
||||
public clearDcsHandler(id: IFunctionIdentifier): void {
|
||||
this._dcsParser.clearHandler(this._identifier(id));
|
||||
}
|
||||
|
||||
public setDcsHandlerFallback(handler: DcsFallbackHandlerType): void {
|
||||
this._dcsParser.setHandlerFallback(handler);
|
||||
}
|
||||
|
||||
public registerOscHandler(ident: number, handler: IOscHandler): IDisposable {
|
||||
return this._oscParser.registerHandler(ident, handler);
|
||||
}
|
||||
|
||||
public clearOscHandler(ident: number): void {
|
||||
this._oscParser.clearHandler(ident);
|
||||
}
|
||||
|
||||
public setOscHandlerFallback(handler: OscFallbackHandlerType): void {
|
||||
this._oscParser.setHandlerFallback(handler);
|
||||
}
|
||||
|
||||
public registerApcHandler(ident: number, handler: IApcHandler): IDisposable {
|
||||
return this._apcParser.registerHandler(ident, handler);
|
||||
}
|
||||
|
||||
public clearApcHandler(ident: number): void {
|
||||
this._apcParser.clearHandler(ident);
|
||||
}
|
||||
|
||||
public setApcHandlerFallback(handler: ApcFallbackHandlerType): void {
|
||||
this._apcParser.setHandlerFallback(handler);
|
||||
}
|
||||
|
||||
public setErrorHandler(handler: (state: IParsingState) => IParsingState): void {
|
||||
this._errorHandler = handler;
|
||||
}
|
||||
|
||||
public clearErrorHandler(): void {
|
||||
this._errorHandler = this._errorHandlerFb;
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
this._oscParser.reset();
|
||||
this._dcsParser.reset();
|
||||
this._apcParser.reset();
|
||||
this.precedingJoinState = 0;
|
||||
if (this._wasm && this._parserHandle) {
|
||||
this._wasm.ghostty_raw_parser_reset(this._parserHandle);
|
||||
}
|
||||
if (this._parseStack.state !== ParserStackType.NONE) {
|
||||
this._parseStack.state = ParserStackType.RESET;
|
||||
this._parseStack.handlers = [];
|
||||
}
|
||||
}
|
||||
|
||||
public parse(data: Uint32Array, length: number, promiseResult?: boolean): void | Promise<boolean> {
|
||||
if (!this._wasm) {
|
||||
this._wasmReady ??= this._initWasm();
|
||||
return this._wasmReady;
|
||||
}
|
||||
|
||||
if (this._parseStack.state !== ParserStackType.NONE) {
|
||||
return this._resume(promiseResult);
|
||||
}
|
||||
|
||||
const bytes = this._encodeUtf32(data, length);
|
||||
const result = this._writeToWasm(bytes);
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
return this._drainEvents();
|
||||
}
|
||||
|
||||
private _resume(promiseResult?: boolean): void | Promise<boolean> {
|
||||
if (promiseResult === undefined || this._parseStack.state === ParserStackType.FAIL) {
|
||||
this._parseStack.state = ParserStackType.FAIL;
|
||||
throw new Error('improper continuation due to previous async handler, giving up parsing');
|
||||
}
|
||||
|
||||
switch (this._parseStack.state) {
|
||||
case ParserStackType.CSI:
|
||||
case ParserStackType.ESC:
|
||||
return this._resumeHandlerLoop(promiseResult);
|
||||
case ParserStackType.OSC:
|
||||
return this._resumeOsc(promiseResult);
|
||||
case ParserStackType.DCS:
|
||||
return this._resumeDcs(promiseResult);
|
||||
case ParserStackType.APC:
|
||||
return this._resumeApc(promiseResult);
|
||||
case ParserStackType.RESET:
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return;
|
||||
default:
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private _resumeHandlerLoop(promiseResult: boolean): void | Promise<boolean> {
|
||||
const handlers = this._parseStack.handlers as CsiHandlerType[] | EscHandlerType[];
|
||||
let handlerPos = this._parseStack.handlerPos - 1;
|
||||
if (promiseResult === false && handlerPos > -1) {
|
||||
if (this._parseStack.state === ParserStackType.CSI) {
|
||||
const params = this._resumeParams ?? new Params();
|
||||
for (; handlerPos >= 0; handlerPos--) {
|
||||
const handlerResult = (handlers as CsiHandlerType[])[handlerPos](params);
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._parseStack.handlerPos = handlerPos;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (; handlerPos >= 0; handlerPos--) {
|
||||
const handlerResult = (handlers as EscHandlerType[])[handlerPos]();
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._parseStack.handlerPos = handlerPos;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._resumeParams = undefined;
|
||||
this._parseStack.handlers = [];
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return this._continueAfterAsync();
|
||||
}
|
||||
|
||||
private _resumeOsc(promiseResult: boolean): void | Promise<boolean> {
|
||||
const handlerResult = this._oscParser.end(true, promiseResult);
|
||||
if (handlerResult) {
|
||||
return handlerResult as Promise<boolean>;
|
||||
}
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return this._continueAfterAsync();
|
||||
}
|
||||
|
||||
private _resumeDcs(promiseResult: boolean): void | Promise<boolean> {
|
||||
const handlerResult = this._dcsParser.unhook(true, promiseResult);
|
||||
if (handlerResult) {
|
||||
return handlerResult as Promise<boolean>;
|
||||
}
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return this._continueAfterAsync();
|
||||
}
|
||||
|
||||
private _resumeApc(promiseResult: boolean): void | Promise<boolean> {
|
||||
const handlerResult = this._apcParser.end(true, promiseResult);
|
||||
if (handlerResult) {
|
||||
return handlerResult as Promise<boolean>;
|
||||
}
|
||||
this._parseStack.state = ParserStackType.NONE;
|
||||
return this._continueAfterAsync();
|
||||
}
|
||||
|
||||
private _identifier(id: IFunctionIdentifier, finalRange: number[] = [0x40, 0x7e]): number {
|
||||
let res = 0;
|
||||
if (id.prefix) {
|
||||
if (id.prefix.length > 1) {
|
||||
throw new Error('only one byte as prefix supported');
|
||||
}
|
||||
res = id.prefix.charCodeAt(0);
|
||||
if (res && 0x3c > res || res > 0x3f) {
|
||||
throw new Error('prefix must be in range 0x3c .. 0x3f');
|
||||
}
|
||||
}
|
||||
if (id.intermediates) {
|
||||
if (id.intermediates.length > 2) {
|
||||
throw new Error('only two bytes as intermediates are supported');
|
||||
}
|
||||
for (let i = 0; i < id.intermediates.length; ++i) {
|
||||
const intermediate = id.intermediates.charCodeAt(i);
|
||||
if (0x20 > intermediate || intermediate > 0x2f) {
|
||||
throw new Error('intermediate must be in range 0x20 .. 0x2f');
|
||||
}
|
||||
res <<= 8;
|
||||
res |= intermediate;
|
||||
}
|
||||
}
|
||||
if (id.final.length !== 1) {
|
||||
throw new Error('final must be a single byte');
|
||||
}
|
||||
const finalCode = id.final.charCodeAt(0);
|
||||
if (finalRange[0] > finalCode || finalCode > finalRange[1]) {
|
||||
throw new Error(`final must be in range ${finalRange[0]} .. ${finalRange[1]}`);
|
||||
}
|
||||
res <<= 8;
|
||||
res |= finalCode;
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
private async _initWasm(): Promise<boolean> {
|
||||
this._wasm = await GhosttyWasmRuntime.getInstance();
|
||||
this._parserHandle = this._wasm.ghostty_raw_parser_new(0);
|
||||
this._eventSize = this._wasm.ghostty_raw_parser_event_size();
|
||||
this._eventMaxParams = this._wasm.ghostty_raw_parser_max_params();
|
||||
this._eventMaxIntermediates = this._wasm.ghostty_raw_parser_max_intermediates();
|
||||
return true;
|
||||
}
|
||||
|
||||
private _encodeUtf32(data: Uint32Array, length: number): Uint8Array {
|
||||
if (length === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const codePoint = data[i];
|
||||
if (codePoint === 0x9c) {
|
||||
bytes.push(0x1b, 0x5c);
|
||||
continue;
|
||||
}
|
||||
if (codePoint <= 0x7f || (codePoint >= 0x80 && codePoint <= 0x9f)) {
|
||||
bytes.push(codePoint);
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
bytes.push(
|
||||
0xc0 | (codePoint >> 6),
|
||||
0x80 | (codePoint & 0x3f)
|
||||
);
|
||||
} else if (codePoint <= 0xffff) {
|
||||
bytes.push(
|
||||
0xe0 | (codePoint >> 12),
|
||||
0x80 | ((codePoint >> 6) & 0x3f),
|
||||
0x80 | (codePoint & 0x3f)
|
||||
);
|
||||
} else {
|
||||
bytes.push(
|
||||
0xf0 | (codePoint >> 18),
|
||||
0x80 | ((codePoint >> 12) & 0x3f),
|
||||
0x80 | ((codePoint >> 6) & 0x3f),
|
||||
0x80 | (codePoint & 0x3f)
|
||||
);
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
private _writeToWasm(bytes: Uint8Array): void | Promise<boolean> {
|
||||
if (!this._wasm || !this._parserHandle) {
|
||||
return;
|
||||
}
|
||||
if (!bytes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const dataPtr = this._wasm.ghostty_wasm_alloc_u8_array(bytes.length);
|
||||
if (!dataPtr) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mem = new Uint8Array(this._wasm.memory.buffer, dataPtr, bytes.length);
|
||||
mem.set(bytes);
|
||||
this._wasm.ghostty_raw_parser_write(this._parserHandle, dataPtr, bytes.length);
|
||||
this._wasm.ghostty_wasm_free_u8_array(dataPtr, bytes.length);
|
||||
}
|
||||
|
||||
private _drainEvents(): void | Promise<boolean> {
|
||||
if (!this._wasm || !this._parserHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventsPtr = this._wasm.ghostty_raw_parser_events_ptr(this._parserHandle);
|
||||
const eventsLen = this._wasm.ghostty_raw_parser_events_len(this._parserHandle);
|
||||
if (!eventsPtr || !eventsLen) {
|
||||
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
this._pendingEvents = { ptr: eventsPtr, len: eventsLen };
|
||||
this._eventIndex = 0;
|
||||
|
||||
const result = this._processEvents();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
|
||||
this._pendingEvents = undefined;
|
||||
}
|
||||
|
||||
private _continueAfterAsync(): void | Promise<boolean> {
|
||||
if (!this._wasm || !this._parserHandle || !this._pendingEvents) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._eventIndex++;
|
||||
const result = this._processEvents();
|
||||
if (result) {
|
||||
return result;
|
||||
}
|
||||
|
||||
this._wasm.ghostty_raw_parser_events_clear(this._parserHandle);
|
||||
this._pendingEvents = undefined;
|
||||
}
|
||||
|
||||
private _processEvents(): void | Promise<boolean> {
|
||||
if (!this._pendingEvents || !this._wasm) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { ptr, len } = this._pendingEvents;
|
||||
const mem = this._wasm.memory.buffer;
|
||||
const view = new DataView(mem);
|
||||
|
||||
let textStart = 0;
|
||||
let textLen = 0;
|
||||
|
||||
for (; this._eventIndex < len; this._eventIndex++) {
|
||||
const base = ptr + this._eventIndex * this._eventSize;
|
||||
const tag = view.getInt32(base, true);
|
||||
|
||||
if (tag !== RawEventTag.PRINT && textLen > 0) {
|
||||
this._flushText(view, ptr, textStart, textLen);
|
||||
textLen = 0;
|
||||
}
|
||||
|
||||
switch (tag) {
|
||||
case RawEventTag.PRINT: {
|
||||
if (textLen === 0) {
|
||||
textStart = this._eventIndex;
|
||||
}
|
||||
textLen++;
|
||||
break;
|
||||
}
|
||||
case RawEventTag.EXECUTE: {
|
||||
const code = view.getUint32(base + 4, true);
|
||||
this._handleExecute(code);
|
||||
break;
|
||||
}
|
||||
case RawEventTag.ESC: {
|
||||
const event = this._readEvent(view, base);
|
||||
const ident = this._buildIdent(event.intermediates, event.final, false, [0x30, 0x7e]);
|
||||
const handlers = this._escHandlers[ident];
|
||||
const handlerResult = this._callEscHandlers(handlers, ident);
|
||||
if (handlerResult) {
|
||||
return handlerResult;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RawEventTag.CSI: {
|
||||
const event = this._readEvent(view, base);
|
||||
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
|
||||
const ident = this._buildIdent(event.intermediates, event.final, true);
|
||||
const handlers = this._csiHandlers[ident];
|
||||
const handlerResult = this._callCsiHandlers(handlers, ident, params);
|
||||
if (handlerResult) {
|
||||
return handlerResult;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RawEventTag.OSC: {
|
||||
const event = this._readEvent(view, base);
|
||||
const oscResult = this._handleOsc(event.code, event.dataPtr, event.dataLen);
|
||||
if (oscResult) {
|
||||
return oscResult;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case RawEventTag.DCS_HOOK: {
|
||||
const event = this._readEvent(view, base);
|
||||
const params = this._buildParams(event.params, event.paramsLen, event.paramsSep);
|
||||
const ident = this._buildIdent(event.intermediates, event.final, true);
|
||||
this._dcsParser.hook(ident, params);
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
}
|
||||
case RawEventTag.DCS_PUT: {
|
||||
const code = view.getUint32(base + 4, true);
|
||||
this._dcsPutBuffer[0] = code;
|
||||
this._dcsParser.put(this._dcsPutBuffer, 0, 1);
|
||||
break;
|
||||
}
|
||||
case RawEventTag.DCS_UNHOOK: {
|
||||
const dcsResult = this._dcsParser.unhook(true);
|
||||
if (dcsResult) {
|
||||
this._parseStack.state = ParserStackType.DCS;
|
||||
this.precedingJoinState = 0;
|
||||
return dcsResult as Promise<boolean>;
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
}
|
||||
case RawEventTag.APC_START: {
|
||||
this._apcParser.start();
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
}
|
||||
case RawEventTag.APC_PUT: {
|
||||
const code = view.getUint32(base + 4, true);
|
||||
this._apcPutBuffer[0] = code;
|
||||
this._apcParser.put(this._apcPutBuffer, 0, 1);
|
||||
break;
|
||||
}
|
||||
case RawEventTag.APC_END: {
|
||||
const apcResult = this._apcParser.end(true);
|
||||
if (apcResult) {
|
||||
this._parseStack.state = ParserStackType.APC;
|
||||
this.precedingJoinState = 0;
|
||||
return apcResult as Promise<boolean>;
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (textLen > 0) {
|
||||
this._flushText(view, ptr, textStart, textLen);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
private _flushText(view: DataView, ptr: number, start: number, count: number): void {
|
||||
let remaining = count;
|
||||
let offset = 0;
|
||||
while (remaining > 0) {
|
||||
const chunk = Math.min(remaining, this._utf32Scratch.length);
|
||||
for (let i = 0; i < chunk; i++) {
|
||||
const base = ptr + (start + offset + i) * this._eventSize;
|
||||
const code = view.getUint32(base + 4, true);
|
||||
this._utf32Scratch[i] = code;
|
||||
}
|
||||
this._printHandler(this._utf32Scratch, 0, chunk);
|
||||
remaining -= chunk;
|
||||
offset += chunk;
|
||||
}
|
||||
}
|
||||
|
||||
private _handleExecute(code: number): void {
|
||||
if (this._executeHandlers[code]) {
|
||||
this._executeHandlers[code]();
|
||||
} else {
|
||||
this._executeHandlerFb(code);
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
}
|
||||
|
||||
private _callEscHandlers(handlers: EscHandlerType[] | undefined, ident: number): void | Promise<boolean> {
|
||||
if (!handlers) {
|
||||
this._escHandlerFb(ident);
|
||||
this.precedingJoinState = 0;
|
||||
return;
|
||||
}
|
||||
let j = handlers.length - 1;
|
||||
for (; j >= 0; j--) {
|
||||
const handlerResult = handlers[j]();
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._parseStack.state = ParserStackType.ESC;
|
||||
this._parseStack.handlers = handlers;
|
||||
this._parseStack.handlerPos = j;
|
||||
this.precedingJoinState = 0;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
if (j < 0) {
|
||||
this._escHandlerFb(ident);
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
}
|
||||
|
||||
private _callCsiHandlers(handlers: CsiHandlerType[] | undefined, ident: number, params: IParams): void | Promise<boolean> {
|
||||
if (!handlers) {
|
||||
this._csiHandlerFb(ident, params);
|
||||
this.precedingJoinState = 0;
|
||||
return;
|
||||
}
|
||||
let j = handlers.length - 1;
|
||||
for (; j >= 0; j--) {
|
||||
const handlerResult = handlers[j](params);
|
||||
if (handlerResult === true) {
|
||||
break;
|
||||
} else if (handlerResult instanceof Promise) {
|
||||
this._parseStack.state = ParserStackType.CSI;
|
||||
this._resumeParams = params;
|
||||
this._parseStack.handlers = handlers;
|
||||
this._parseStack.handlerPos = j;
|
||||
this.precedingJoinState = 0;
|
||||
return handlerResult;
|
||||
}
|
||||
}
|
||||
if (j < 0) {
|
||||
this._csiHandlerFb(ident, params);
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
}
|
||||
|
||||
private _handleOsc(oscId: number, dataPtr: number, dataLen: number): void | Promise<boolean> {
|
||||
console.log('handle osc');
|
||||
if (!this._wasm) {
|
||||
return;
|
||||
}
|
||||
|
||||
let payload = '';
|
||||
if (dataLen > 0) {
|
||||
if (!dataPtr || dataPtr + dataLen > this._wasm.memory.buffer.byteLength) {
|
||||
return;
|
||||
}
|
||||
const bytes = new Uint8Array(this._wasm.memory.buffer, dataPtr, dataLen);
|
||||
payload = this._textDecoder.decode(bytes);
|
||||
}
|
||||
if (payload.indexOf('\u0000') !== -1) {
|
||||
if (oscId === 8) {
|
||||
const parts = payload.split('\u0000').filter((part, index, all) => {
|
||||
if (part.length > 0) {
|
||||
return true;
|
||||
}
|
||||
return index < all.length - 1;
|
||||
});
|
||||
if (parts.length > 0) {
|
||||
const uri = parts[parts.length - 1] ?? '';
|
||||
const params = parts.slice(0, -1).join(':');
|
||||
payload = `${params};${uri}`;
|
||||
} else {
|
||||
payload = '';
|
||||
}
|
||||
} else {
|
||||
payload = payload.replace(/\u0000+/g, '');
|
||||
}
|
||||
}
|
||||
const oscPrefix = `${oscId};`;
|
||||
const rawPayload = payload.startsWith(oscPrefix)
|
||||
? payload.slice(oscPrefix.length)
|
||||
: payload;
|
||||
let oscPayload = rawPayload;
|
||||
if (oscId === 8 && oscPayload && !oscPayload.startsWith(';')) {
|
||||
const sepIndex = oscPayload.indexOf(';');
|
||||
const paramsPart = sepIndex === -1 ? oscPayload : oscPayload.slice(0, sepIndex);
|
||||
if (!paramsPart.includes('=')) {
|
||||
oscPayload = `;${oscPayload}`;
|
||||
}
|
||||
}
|
||||
const oscString = `${oscPrefix}${oscPayload}`;
|
||||
|
||||
this._oscParser.start();
|
||||
const length = this._decodeToScratch(oscString);
|
||||
this._oscParser.put(this._utf32Scratch, 0, length);
|
||||
const handlerResult = this._oscParser.end(true);
|
||||
if (handlerResult) {
|
||||
this._parseStack.state = ParserStackType.OSC;
|
||||
this.precedingJoinState = 0;
|
||||
return handlerResult as Promise<boolean>;
|
||||
}
|
||||
this.precedingJoinState = 0;
|
||||
}
|
||||
|
||||
private _buildParams(params: Uint16Array, length: number, sepBits: number): Params {
|
||||
const result = new Params(this._eventMaxParams, 256);
|
||||
if (length === 0) {
|
||||
result.addParam(0);
|
||||
return result;
|
||||
}
|
||||
|
||||
let i = 0;
|
||||
while (i < length) {
|
||||
result.addParam(params[i]);
|
||||
if (sepBits & (1 << i)) {
|
||||
i++;
|
||||
while (i < length) {
|
||||
result.addSubParam(params[i]);
|
||||
if ((sepBits & (1 << i)) === 0) {
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private _buildIdent(intermediates: string, final: string, allowPrefix: boolean, finalRange: number[] = [0x40, 0x7e]): number {
|
||||
let prefix: string | undefined;
|
||||
let inters = intermediates;
|
||||
if (allowPrefix && intermediates.length) {
|
||||
const first = intermediates.charCodeAt(0);
|
||||
if (first >= 0x3c && first <= 0x3f) {
|
||||
prefix = intermediates.charAt(0);
|
||||
inters = intermediates.substring(1);
|
||||
}
|
||||
}
|
||||
return this._identifier({ prefix, intermediates: inters || undefined, final }, finalRange);
|
||||
}
|
||||
|
||||
private _readEvent(view: DataView, base: number): {
|
||||
final: string;
|
||||
intermediates: string;
|
||||
params: Uint16Array;
|
||||
paramsLen: number;
|
||||
paramsSep: number;
|
||||
code: number;
|
||||
dataPtr: number;
|
||||
dataLen: number;
|
||||
} {
|
||||
const code = view.getUint32(base + 4, true);
|
||||
const final = String.fromCharCode(view.getUint8(base + 8));
|
||||
const intermediatesLen = Math.min(view.getUint8(base + 9), this._eventMaxIntermediates);
|
||||
const paramsLen = Math.min(view.getUint8(base + 10), this._eventMaxParams);
|
||||
const paramsSep = view.getUint32(base + 12, true);
|
||||
|
||||
let intermediates = '';
|
||||
const interOffset = base + 16;
|
||||
for (let i = 0; i < intermediatesLen; i++) {
|
||||
intermediates += String.fromCharCode(view.getUint8(interOffset + i));
|
||||
}
|
||||
|
||||
const paramsOffset = base + 16 + this._eventMaxIntermediates;
|
||||
const params = new Uint16Array(paramsLen);
|
||||
for (let i = 0; i < paramsLen; i++) {
|
||||
params[i] = view.getUint16(paramsOffset + i * 2, true);
|
||||
}
|
||||
|
||||
const rawDataPtrOffset = paramsOffset + this._eventMaxParams * 2;
|
||||
const dataPtrOffset = (rawDataPtrOffset + (this._ptrSize - 1)) & ~(this._ptrSize - 1);
|
||||
const dataPtr = view.getUint32(dataPtrOffset, true);
|
||||
const dataLen = view.getUint32(dataPtrOffset + this._ptrSize, true);
|
||||
|
||||
return {
|
||||
final,
|
||||
intermediates,
|
||||
params,
|
||||
paramsLen,
|
||||
paramsSep,
|
||||
code,
|
||||
dataPtr,
|
||||
dataLen
|
||||
};
|
||||
}
|
||||
|
||||
private _decodeToScratch(text: string): number {
|
||||
if (this._utf32Scratch.length < text.length * 2) {
|
||||
this._utf32Scratch = new Uint32Array(text.length * 2);
|
||||
}
|
||||
return this._oscBuffer.decode(text, this._utf32Scratch);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright (c) 2026 The xterm.js authors. All rights reserved.
|
||||
* @license MIT
|
||||
*/
|
||||
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
export interface IGhosttyWasmExports {
|
||||
memory: WebAssembly.Memory;
|
||||
ghostty_raw_parser_new(allocPtr: number): number;
|
||||
ghostty_raw_parser_free(parser: number): void;
|
||||
ghostty_raw_parser_reset(parser: number): void;
|
||||
ghostty_raw_parser_write(parser: number, dataPtr: number, len: number): void;
|
||||
ghostty_raw_parser_events_ptr(parser: number): number;
|
||||
ghostty_raw_parser_events_len(parser: number): number;
|
||||
ghostty_raw_parser_events_clear(parser: number): void;
|
||||
ghostty_raw_parser_event_size(): number;
|
||||
ghostty_raw_parser_max_params(): number;
|
||||
ghostty_raw_parser_max_intermediates(): number;
|
||||
ghostty_wasm_alloc_u8_array(len: number): number;
|
||||
ghostty_wasm_free_u8_array(ptr: number, len: number): void;
|
||||
ghostty_xterm_buffer_new(cols: number, rows: number, maxRows: number): number;
|
||||
ghostty_xterm_buffer_free(buffer: number): void;
|
||||
ghostty_xterm_buffer_resize(buffer: number, cols: number, rows: number, maxRows: number): number;
|
||||
ghostty_xterm_buffer_clear(buffer: number): void;
|
||||
ghostty_xterm_buffer_get_cols(buffer: number): number;
|
||||
ghostty_xterm_buffer_get_rows(buffer: number): number;
|
||||
ghostty_xterm_buffer_get_max_rows(buffer: number): number;
|
||||
ghostty_xterm_buffer_get_cell(buffer: number, row: number, col: number, outPtr: number): void;
|
||||
ghostty_xterm_buffer_set_cell(buffer: number, row: number, col: number, content: number, fg: number, bg: number): void;
|
||||
ghostty_xterm_buffer_clear_row(buffer: number, row: number, content: number, fg: number, bg: number): void;
|
||||
ghostty_xterm_buffer_copy_row(buffer: number, srcRow: number, dstRow: number): void;
|
||||
ghostty_xterm_buffer_get_row_wrap(buffer: number, row: number): number;
|
||||
ghostty_xterm_buffer_set_row_wrap(buffer: number, row: number, wrap: number): void;
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/naming-convention */
|
||||
|
||||
export class GhosttyWasmRuntime {
|
||||
private static _instancePromise: Promise<IGhosttyWasmExports> | undefined;
|
||||
private static _instanceSync: IGhosttyWasmExports | undefined;
|
||||
|
||||
public static getInstance(): Promise<IGhosttyWasmExports> {
|
||||
this._instancePromise ??= this._loadAsync();
|
||||
return this._instancePromise;
|
||||
}
|
||||
|
||||
public static getInstanceSync(): IGhosttyWasmExports {
|
||||
this._instanceSync ??= this._loadSync();
|
||||
return this._instanceSync;
|
||||
}
|
||||
|
||||
private static async _loadAsync(): Promise<IGhosttyWasmExports> {
|
||||
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
|
||||
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
|
||||
if (isNode) {
|
||||
const requireFn = (globalThis as any).require
|
||||
?? (globalThis as any).process?.mainModule?.require
|
||||
?? (globalThis as any).__xtermRequire;
|
||||
const fs = requireFn?.('fs') as { readFile(path: string, cb: (err: Error | null, data?: Uint8Array) => void): void } | undefined;
|
||||
if (!fs) {
|
||||
throw new Error('Node.js async WASM loading requires require("fs")');
|
||||
}
|
||||
const wasmBytes = await new Promise<Uint8Array>((resolve, reject) => {
|
||||
fs.readFile(wasmUrl, (err, data) => {
|
||||
if (err || !data) {
|
||||
reject(err ?? new Error(`Failed to load Ghostty WASM from ${wasmUrl}`));
|
||||
return;
|
||||
}
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
const env = { log: () => {} };
|
||||
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
|
||||
return instance.exports as unknown as IGhosttyWasmExports;
|
||||
}
|
||||
|
||||
const response = await fetch(wasmUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
|
||||
}
|
||||
const wasmBytes = await response.arrayBuffer();
|
||||
const env = { log: () => {} };
|
||||
const { instance } = await WebAssembly.instantiate(wasmBytes, { env });
|
||||
return instance.exports as unknown as IGhosttyWasmExports;
|
||||
}
|
||||
|
||||
private static _loadSync(): IGhosttyWasmExports {
|
||||
const wasmUrl = GhosttyWasmRuntime._getWasmUrl();
|
||||
const isNode = typeof globalThis !== 'undefined' && !!(globalThis as any).process?.versions?.node;
|
||||
if (!isNode && typeof document !== 'undefined') {
|
||||
const request = new XMLHttpRequest();
|
||||
request.open('GET', wasmUrl, false);
|
||||
request.overrideMimeType('text/plain; charset=x-user-defined');
|
||||
request.send(null);
|
||||
if (request.status !== 200 || !request.responseText) {
|
||||
throw new Error(`Failed to load Ghostty WASM from ${wasmUrl}`);
|
||||
}
|
||||
const binary = request.responseText;
|
||||
const wasmBytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
wasmBytes[i] = binary.charCodeAt(i) & 0xFF;
|
||||
}
|
||||
const module = new WebAssembly.Module(wasmBytes);
|
||||
const env = { log: () => {} };
|
||||
const instance = new WebAssembly.Instance(module, { env });
|
||||
return instance.exports as unknown as IGhosttyWasmExports;
|
||||
}
|
||||
|
||||
// Node.js sync load for tests and headless usage.
|
||||
const requireFn = (globalThis as any).require
|
||||
?? (globalThis as any).process?.mainModule?.require
|
||||
?? (globalThis as any).__xtermRequire;
|
||||
const fs = requireFn?.('fs') as { readFileSync(path: string): Uint8Array } | undefined;
|
||||
if (!fs) {
|
||||
throw new Error('Node.js sync WASM loading requires require("fs")');
|
||||
}
|
||||
const wasmBytes = fs.readFileSync(wasmUrl);
|
||||
const module = new WebAssembly.Module(wasmBytes);
|
||||
const env = { log: () => {} };
|
||||
const instance = new WebAssembly.Instance(module, { env });
|
||||
return instance.exports as unknown as IGhosttyWasmExports;
|
||||
}
|
||||
|
||||
private static _getWasmUrl(): string {
|
||||
const override = (globalThis as any).XTERM_GHOSTTY_WASM_URL as string | undefined;
|
||||
if (override) {
|
||||
return override;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
|
||||
}
|
||||
return 'vendor/ghostty-wasm/ghostty-vt.wasm';
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { css } from 'common/Color';
|
||||
import { Disposable, DisposableStore, toDisposable } from 'common/Lifecycle';
|
||||
import { IDecorationService, IInternalDecoration } from 'common/services/Services';
|
||||
import { IDecorationService, IInternalDecoration, ILogService } from 'common/services/Services';
|
||||
import { SortedList } from 'common/SortedList';
|
||||
import { IColor } from 'common/Types';
|
||||
import { IDecoration, IDecorationOptions, IMarker } from '@xterm/xterm';
|
||||
@@ -25,7 +25,7 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
* while marker line values do change, they should all change by the same amount so this should
|
||||
* never become out of order.
|
||||
*/
|
||||
private readonly _decorations: SortedList<IInternalDecoration> = new SortedList(e => e?.marker.line);
|
||||
private readonly _decorations: SortedList<IInternalDecoration>;
|
||||
|
||||
private readonly _onDecorationRegistered = this._register(new Emitter<IInternalDecoration>());
|
||||
public readonly onDecorationRegistered = this._onDecorationRegistered.event;
|
||||
@@ -34,9 +34,11 @@ export class DecorationService extends Disposable implements IDecorationService
|
||||
|
||||
public get decorations(): IterableIterator<IInternalDecoration> { return this._decorations.values(); }
|
||||
|
||||
constructor() {
|
||||
constructor(@ILogService private readonly _logService: ILogService) {
|
||||
super();
|
||||
|
||||
this._decorations = new SortedList(e => e?.marker.line, this._logService);
|
||||
|
||||
this._register(toDisposable(() => this.reset()));
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
|
||||
rows: 24,
|
||||
showCursorImmediately: false,
|
||||
cursorBlink: false,
|
||||
blinkIntervalDuration: 0,
|
||||
cursorStyle: 'block',
|
||||
cursorWidth: 1,
|
||||
cursorInactiveStyle: 'outline',
|
||||
@@ -31,6 +32,7 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
|
||||
logLevel: 'info',
|
||||
logger: null,
|
||||
scrollback: 1000,
|
||||
scrollbar: { showScrollbar: true },
|
||||
scrollOnEraseInDisplay: false,
|
||||
scrollOnUserInput: true,
|
||||
scrollSensitivity: 1,
|
||||
@@ -53,7 +55,6 @@ export const DEFAULT_OPTIONS: Readonly<Required<ITerminalOptions>> = {
|
||||
altClickMovesCursor: true,
|
||||
convertEol: false,
|
||||
termName: 'xterm',
|
||||
overviewRuler: {},
|
||||
quirks: {},
|
||||
vtExtensions: {}
|
||||
};
|
||||
@@ -168,6 +169,12 @@ export class OptionsService extends Disposable implements IOptionsService {
|
||||
}
|
||||
value = FONT_WEIGHT_OPTIONS.includes(value) ? value : DEFAULT_OPTIONS[key];
|
||||
break;
|
||||
case 'blinkIntervalDuration':
|
||||
value = Math.floor(value);
|
||||
if (value < 0) {
|
||||
throw new Error(`${key} cannot be less than 0, value: ${value}`);
|
||||
}
|
||||
break;
|
||||
case 'cursorWidth':
|
||||
value = Math.floor(value);
|
||||
// Fall through for bounds check
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BufferService } from 'common/services/BufferService';
|
||||
import { OptionsService } from 'common/services/OptionsService';
|
||||
import { OscLinkService } from 'common/services/OscLinkService';
|
||||
import { IBufferService, IOptionsService, IOscLinkService } from 'common/services/Services';
|
||||
import { MockLogService } from 'common/TestUtils.test';
|
||||
|
||||
describe('OscLinkService', () => {
|
||||
describe('constructor', () => {
|
||||
@@ -17,7 +18,7 @@ describe('OscLinkService', () => {
|
||||
let oscLinkService: IOscLinkService;
|
||||
beforeEach(() => {
|
||||
optionsService = new OptionsService({ rows: 3, cols: 10 });
|
||||
bufferService = new BufferService(optionsService);
|
||||
bufferService = new BufferService(optionsService, new MockLogService());
|
||||
oscLinkService = new OscLinkService(bufferService);
|
||||
});
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface ITerminalOptions {
|
||||
cols?: number;
|
||||
convertEol?: boolean;
|
||||
cursorBlink?: boolean;
|
||||
blinkIntervalDuration?: number;
|
||||
cursorStyle?: CursorStyle;
|
||||
cursorWidth?: number;
|
||||
cursorInactiveStyle?: CursorInactiveStyle;
|
||||
@@ -264,8 +265,8 @@ export interface ITerminalOptions {
|
||||
windowsPty?: IWindowsPty;
|
||||
windowOptions?: IWindowOptions;
|
||||
wordSeparator?: string;
|
||||
overviewRuler?: IOverviewRulerOptions;
|
||||
quirks?: ITerminalQuirks;
|
||||
scrollbar?: IScrollbarOptions;
|
||||
scrollOnEraseInDisplay?: boolean;
|
||||
vtExtensions?: IVtExtensions;
|
||||
|
||||
@@ -308,6 +309,13 @@ export interface ITerminalQuirks {
|
||||
allowSetCursorBlink?: boolean;
|
||||
}
|
||||
|
||||
export interface IScrollbarOptions {
|
||||
showScrollbar?: boolean;
|
||||
showArrows?: boolean;
|
||||
width?: number;
|
||||
overviewRuler?: IOverviewRulerOptions;
|
||||
}
|
||||
|
||||
export interface IVtExtensions {
|
||||
kittyKeyboard?: boolean;
|
||||
kittySgrBoldFaintControl?: boolean;
|
||||
|
||||
@@ -12,8 +12,7 @@
|
||||
],
|
||||
"baseUrl": "../",
|
||||
"paths": {
|
||||
"common/*": [ "./common/*" ],
|
||||
"vs/*": [ "./vs/*" ]
|
||||
"common/*": [ "./common/*" ]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
@@ -22,7 +21,6 @@
|
||||
"../../typings/xterm-headless.d.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../common" },
|
||||
{ "path": "../vs" }
|
||||
{ "path": "../common" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
temp/
|
||||
|
||||
# Tests
|
||||
base/test/
|
||||
base/**/test/
|
||||
|
||||
# Unwanted modules
|
||||
base/node/
|
||||
base/parts/
|
||||
base/worker/
|
||||
|
||||
# Binary files
|
||||
base/browser/ui/codicons/codicon/codicon.ttf
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user